Skip to content

Commit 4226c66

Browse files
committed
feat(i18n): implement deferred translation
1 parent bcd482a commit 4226c66

File tree

6 files changed

+112
-61
lines changed

6 files changed

+112
-61
lines changed

examples/i18n/python/local/i18n/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@
99

1010

1111
class I18NModel(FormModel):
12-
# TODO: Implement deferred translation.
13-
_ = lambda x: x # noqa: E731
14-
1512
txt: StaticTextElement = static_text(
1613
# TRANSLATORS: This comment will be shown in the .pot file.
1714
_("Important Notice"),
@@ -35,5 +32,7 @@ def general_feedback(self) -> str | None:
3532
class I18NQuestion(Question):
3633
attempt_class = I18NAttempt
3734

35+
options: I18NModel
36+
3837

3938
init = make_question_type_init(I18NQuestion)

poetry.lock

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ python = "^3.11"
2828
aiohttp = "^3.9.3"
2929
pydantic = "^2.6.4"
3030
PyYAML = "^6.0.1"
31-
questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "f634dfd1baa7b6176f89c8b1954edaef88ae8c70" }
31+
questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "2875836da3c4e789d5877c865cc4902212ee316f" }
3232
jinja2 = "^3.1.3"
3333
aiohttp-jinja2 = "^1.6"
3434
lxml = "~5.1.0"

questionpy/i18n.py

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import logging
2+
from collections import UserString
23
from collections.abc import Callable
34
from contextvars import ContextVar
45
from dataclasses import dataclass
56
from gettext import GNUTranslations, NullTranslations
67
from importlib.resources.abc import Traversable
7-
from typing import NewType, TypeAlias
8+
from typing import Literal, NewType, Protocol, TypeAlias, cast, overload
89

910
from questionpy_common.environment import (
1011
Environment,
@@ -32,6 +33,7 @@ class _RequestState:
3233
class _DomainState:
3334
untranslated_lang: Bcp47LanguageTag
3435
available_mos: dict[Bcp47LanguageTag, Traversable]
36+
logger: logging.LoggerAdapter
3537
request_state: _RequestState | None = None
3638

3739

@@ -44,15 +46,6 @@ def domain_of(package: SourceManifest | PackageNamespaceAndShortName) -> Gettext
4446
return GettextDomain(f"{package.namespace}.{package.short_name}")
4547

4648

47-
def _guess_untranslated_language(package: Package) -> Bcp47LanguageTag:
48-
# We'll assume that the untranslated messages are in the first supported language according to the manifest.
49-
if package.manifest.languages:
50-
return package.manifest.languages[0]
51-
# If the package lists no supported languages in its manifest, we'll assume it's english.
52-
# TODO: An alternative might be "C" or "unknown"?
53-
return Bcp47LanguageTag("en")
54-
55-
5649
def _build_translations(mos: list[Traversable]) -> NullTranslations:
5750
if not mos:
5851
return _NULL_TRANSLATIONS
@@ -109,9 +102,6 @@ def get_translations_of_package(package: Package) -> NullTranslations:
109102
return request_state.translations
110103

111104

112-
_GettextFun: TypeAlias = Callable[[str], str]
113-
114-
115105
def _get_package_owning_module(module_name: str) -> Package:
116106
# TODO: Dedupe when #152 is in dev.
117107
try:
@@ -138,31 +128,32 @@ def _ensure_initialized(domain: GettextDomain, package: Package, env: Environmen
138128
# Already initialized.
139129
return domain_state
140130

141-
untranslated_lang = _guess_untranslated_language(package)
131+
domain_logger = logging.LoggerAdapter(_log.getChild(domain), extra={"domain": domain})
132+
133+
untranslated_lang = package.manifest.languages[0]
142134
available_mos = _get_available_mos(package)
143135
if available_mos:
144-
_log.debug(
145-
"For domain '%s', MO files for the following languages were found: %s",
146-
domain,
136+
domain_logger.debug(
137+
"MO files for the following languages were found: %s",
147138
", ".join(available_mos.keys()),
148139
)
149140
else:
150-
_log.debug(
151-
"For domain '%s', no MO files were found. Messages will not be translated. We'll assume the "
141+
domain_logger.debug(
142+
"No MO files were found. Messages will not be translated. We'll assume the "
152143
"untranslated strings to be in '%s'.",
153144
untranslated_lang,
154145
)
155146

156-
domain_state = states_by_domain[domain] = _DomainState(untranslated_lang, available_mos)
147+
domain_state = states_by_domain[domain] = _DomainState(untranslated_lang, available_mos, domain_logger)
157148

158149
def initialize_for_request(request_user: RequestUser) -> None:
159150
langs_to_use = [lang for lang in request_user.preferred_languages if lang in domain_state.available_mos]
160151

161152
if langs_to_use:
162-
_log.debug("Using the following languages for this request: %s", langs_to_use)
153+
domain_logger.debug("Using the following languages for this request: %s", langs_to_use)
163154
primary_lang = langs_to_use[0]
164155
else:
165-
_log.debug(
156+
domain_logger.debug(
166157
"There are no MO files for any of the user's preferred languages. Messages will not be translated "
167158
"and we'll assume the untranslated strings to be in '%s'.",
168159
domain_state.untranslated_lang,
@@ -181,20 +172,78 @@ def initialize_for_request(request_user: RequestUser) -> None:
181172
return domain_state
182173

183174

184-
def get_for(module_name: str) -> tuple[_GettextFun, _GettextFun]:
175+
class _DeferredTranslatedMessage(UserString):
176+
def __init__(self, domain_state: _DomainState, msg_id: str) -> None:
177+
super().__init__(msg_id)
178+
self._domain_state = domain_state
179+
180+
@property
181+
def data(self) -> str:
182+
if self._domain_state.request_state:
183+
return self._domain_state.request_state.translations.gettext(self._msg_id)
184+
185+
self._domain_state.logger.debug(
186+
"Deferred message '%s' not translated because domain is not initialized for request.",
187+
self._msg_id,
188+
)
189+
return self._msg_id
190+
191+
@data.setter
192+
def data(self, value: str) -> None:
193+
self._msg_id = value
194+
195+
196+
class _Gettext(Protocol):
197+
@overload
198+
def __call__(self, message: str, *, defer: None = None) -> str | UserString: ...
199+
200+
@overload
201+
def __call__(self, message: str, *, defer: Literal[True]) -> UserString: ...
202+
203+
@overload
204+
def __call__(self, message: str, *, defer: Literal[False]) -> str: ...
205+
206+
def __call__(self, message: str, *, defer: bool | None = None) -> str | UserString:
207+
"""Translate the given message.
208+
209+
Args:
210+
message: Gettext `msgid`.
211+
defer: By default, translation is deferred only when no request is being processed at the time of the
212+
`gettext` call. This parameter can be explicitly set to `True` to force deferral or to `False` to never
213+
defer translations. In the latter case, calling `gettext` before a request is processed
214+
(e.g. during init) will raise an error.
215+
"""
216+
217+
218+
_NGettext: TypeAlias = Callable[[str], str]
219+
220+
221+
def get_for(module_name: str) -> tuple[_Gettext, _NGettext]:
222+
"""Initializes i18n for the package owning the given Python module and returns the gettext-family functions.
223+
224+
Args:
225+
module_name: The Python `__package__` or `__module__` whose domain should be used.
226+
"""
185227
# TODO: Maybe cache this?
186228
package = _get_package_owning_module(module_name)
187229
domain = domain_of(package.manifest)
188230
domain_state = _ensure_initialized(domain, package, get_qpy_environment())
189231

190-
def gettext(message: str) -> str:
232+
def gettext(message: str, *, defer: bool | None = None) -> str | UserString:
233+
if defer is None:
234+
defer = domain_state.request_state is None
235+
236+
if defer:
237+
domain_state.logger.debug("Deferring translation of message '%s'.", message)
238+
return _DeferredTranslatedMessage(domain_state, message)
239+
191240
request_state = _require_request_state(domain, domain_state)
192241
return request_state.translations.gettext(message)
193242

194243
def ngettext(message: str) -> str:
195244
return message
196245

197-
return gettext, ngettext
246+
return cast(_Gettext, gettext), ngettext
198247

199248

200249
__all__ = ["DEFAULT_CATEGORY", "GettextDomain", "domain_of", "get_for", "get_translations_of_package"]

questionpy_sdk/webserver/elements.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,11 @@ class CxdTextInputElement(TextInputElement, _CxdFormElement):
6262
value: str | None = None
6363

6464
def contextualize(self, pattern: Pattern[str], replacement: str) -> None:
65-
self.label = sub(pattern, replacement, self.label)
65+
self.label = sub(pattern, replacement, str(self.label))
6666
if self.default:
67-
self.default = sub(pattern, replacement, self.default)
67+
self.default = sub(pattern, replacement, str(self.default))
6868
if self.placeholder:
69-
self.placeholder = sub(pattern, replacement, self.placeholder)
69+
self.placeholder = sub(pattern, replacement, str(self.placeholder))
7070

7171
def add_form_data_value(self, element_form_data: Any) -> None:
7272
if element_form_data:
@@ -77,11 +77,11 @@ class CxdTextAreaElement(TextAreaElement, _CxdFormElement):
7777
value: str | None = None
7878

7979
def contextualize(self, pattern: Pattern[str], replacement: str) -> None:
80-
self.label = sub(pattern, replacement, self.label)
80+
self.label = sub(pattern, replacement, str(self.label))
8181
if self.default:
82-
self.default = sub(pattern, replacement, self.default)
82+
self.default = sub(pattern, replacement, str(self.default))
8383
if self.placeholder:
84-
self.placeholder = sub(pattern, replacement, self.placeholder)
84+
self.placeholder = sub(pattern, replacement, str(self.placeholder))
8585

8686
def add_form_data_value(self, element_form_data: Any) -> None:
8787
if element_form_data:
@@ -90,16 +90,16 @@ def add_form_data_value(self, element_form_data: Any) -> None:
9090

9191
class CxdStaticTextElement(StaticTextElement, _CxdFormElement):
9292
def contextualize(self, pattern: Pattern[str], replacement: str) -> None:
93-
self.label = sub(pattern, replacement, self.label)
94-
self.text = sub(pattern, replacement, self.text)
93+
self.label = sub(pattern, replacement, str(self.label))
94+
self.text = sub(pattern, replacement, str(self.text))
9595

9696

9797
class CxdCheckboxElement(CheckboxElement, _CxdFormElement):
9898
def contextualize(self, pattern: Pattern[str], replacement: str) -> None:
9999
if self.left_label:
100-
self.left_label = sub(pattern, replacement, self.left_label)
100+
self.left_label = sub(pattern, replacement, str(self.left_label))
101101
if self.right_label:
102-
self.right_label = sub(pattern, replacement, self.right_label)
102+
self.right_label = sub(pattern, replacement, str(self.right_label))
103103

104104
def add_form_data_value(self, element_form_data: Any) -> None:
105105
if element_form_data:
@@ -135,7 +135,7 @@ def add_form_data_value(self, element_form_data: Any) -> None:
135135

136136
class CxdOption(Option, _CxdFormElement):
137137
def contextualize(self, pattern: Pattern[str], replacement: str) -> None:
138-
self.label = sub(pattern, replacement, self.label)
138+
self.label = sub(pattern, replacement, str(self.label))
139139

140140

141141
class CxdRadioGroupElement(RadioGroupElement, _CxdFormElement):
@@ -154,7 +154,7 @@ def __init__(self, **data: Any):
154154
self.options = []
155155

156156
def contextualize(self, pattern: Pattern[str], replacement: str) -> None:
157-
self.label = sub(pattern, replacement, self.label)
157+
self.label = sub(pattern, replacement, str(self.label))
158158
for cxd_option in self.cxd_options:
159159
cxd_option.contextualize(pattern, replacement)
160160

@@ -182,7 +182,7 @@ def __init__(self, **data: Any):
182182
self.options = []
183183

184184
def contextualize(self, pattern: Pattern[str], replacement: str) -> None:
185-
self.label = sub(pattern, replacement, self.label)
185+
self.label = sub(pattern, replacement, str(self.label))
186186
for cxd_option in self.cxd_options:
187187
cxd_option.contextualize(pattern, replacement)
188188

0 commit comments

Comments
 (0)