1
1
import logging
2
+ from collections import UserString
2
3
from collections .abc import Callable
3
4
from contextvars import ContextVar
4
5
from dataclasses import dataclass
5
6
from gettext import GNUTranslations , NullTranslations
6
7
from importlib .resources .abc import Traversable
7
- from typing import NewType , TypeAlias
8
+ from typing import Literal , NewType , Protocol , TypeAlias , cast , overload
8
9
9
10
from questionpy_common .environment import (
10
11
Environment ,
@@ -32,6 +33,7 @@ class _RequestState:
32
33
class _DomainState :
33
34
untranslated_lang : Bcp47LanguageTag
34
35
available_mos : dict [Bcp47LanguageTag , Traversable ]
36
+ logger : logging .LoggerAdapter
35
37
request_state : _RequestState | None = None
36
38
37
39
@@ -44,15 +46,6 @@ def domain_of(package: SourceManifest | PackageNamespaceAndShortName) -> Gettext
44
46
return GettextDomain (f"{ package .namespace } .{ package .short_name } " )
45
47
46
48
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
-
56
49
def _build_translations (mos : list [Traversable ]) -> NullTranslations :
57
50
if not mos :
58
51
return _NULL_TRANSLATIONS
@@ -109,9 +102,6 @@ def get_translations_of_package(package: Package) -> NullTranslations:
109
102
return request_state .translations
110
103
111
104
112
- _GettextFun : TypeAlias = Callable [[str ], str ]
113
-
114
-
115
105
def _get_package_owning_module (module_name : str ) -> Package :
116
106
# TODO: Dedupe when #152 is in dev.
117
107
try :
@@ -138,31 +128,32 @@ def _ensure_initialized(domain: GettextDomain, package: Package, env: Environmen
138
128
# Already initialized.
139
129
return domain_state
140
130
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 ]
142
134
available_mos = _get_available_mos (package )
143
135
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" ,
147
138
", " .join (available_mos .keys ()),
148
139
)
149
140
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 "
152
143
"untranslated strings to be in '%s'." ,
153
144
untranslated_lang ,
154
145
)
155
146
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 )
157
148
158
149
def initialize_for_request (request_user : RequestUser ) -> None :
159
150
langs_to_use = [lang for lang in request_user .preferred_languages if lang in domain_state .available_mos ]
160
151
161
152
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 )
163
154
primary_lang = langs_to_use [0 ]
164
155
else :
165
- _log .debug (
156
+ domain_logger .debug (
166
157
"There are no MO files for any of the user's preferred languages. Messages will not be translated "
167
158
"and we'll assume the untranslated strings to be in '%s'." ,
168
159
domain_state .untranslated_lang ,
@@ -181,20 +172,78 @@ def initialize_for_request(request_user: RequestUser) -> None:
181
172
return domain_state
182
173
183
174
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
+ """
185
227
# TODO: Maybe cache this?
186
228
package = _get_package_owning_module (module_name )
187
229
domain = domain_of (package .manifest )
188
230
domain_state = _ensure_initialized (domain , package , get_qpy_environment ())
189
231
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
+
191
240
request_state = _require_request_state (domain , domain_state )
192
241
return request_state .translations .gettext (message )
193
242
194
243
def ngettext (message : str ) -> str :
195
244
return message
196
245
197
- return gettext , ngettext
246
+ return cast ( _Gettext , gettext ) , ngettext
198
247
199
248
200
249
__all__ = ["DEFAULT_CATEGORY" , "GettextDomain" , "domain_of" , "get_for" , "get_translations_of_package" ]
0 commit comments