Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/i18n.py: 43%
294 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
1"""
2This module provides translation, also known as internationalization -- short: i18n.
4Project translations must be stored in the datastore. There are only some
5static translation tables in the viur-core to have some basic ones.
7The viur-core's own "translation" module (routed as _translation) provides
8an API to manage these translations, for example in the vi-admin.
10How to use translations?
11First, make sure that the languages are configured:
12.. code-block:: python
13 from viur.core.config import conf
14 # These are the main languages (for which translated values exist)
15 # that should be available for the project.
16 conf.i18n.available_languages = = ["en", "de", "fr"]
18 # These are some aliases for languages that should use the translated
19 # values of a particular main language, but don't have their own values.
20 conf.i18n.language_alias_map = {
21 "at": "de", # Austria uses German
22 "ch": "de", # Switzerland uses German
23 "be": "fr", # Belgian uses France
24 "us": "en", # US uses English
25 }
27Now translations can be used
291. In python
30.. code-block:: python
31 from viur.core.i18n import translate
32 # Just the translation key, the minimal case
33 print(translate("translation-key"))
34 # also provide a default value to use if there's no value in the datastore
35 # set and a hint to provide some context.
36 print(translate("translation-key", "the default value", "a hint"))
37 # Use string interpolation with variables
38 print(translate("hello", "Hello {{name}}!", "greeting a user")(name=current.user.get()["firstname"]))
402. In jinja
41.. code-block:: jinja
42 {# Use the ViUR translation extension, it can be compiled with the template,
43 caches the translation values and is therefore efficient #}
44 {% do translate "hello", "Hello {{name}}!", "greet a user", name="ViUR" %}
46 {# But in some cases the key or interpolation variables are dynamic and
47 not available during template compilation.
48 For this you can use the translate function: #}
49 {{ translate("hello", "Hello {{name}}!", "greet a user", name=skel["firstname"]) }}
52How to add translations
53There are two ways to add translations:
541. Manually
55With the vi-admin. Entries can be added manually by creating a new skeleton
56and filling in of the key and values.
582. Automatically
59The add_missing_translations option must be enabled for this.
60.. code-block:: python
62 from viur.core.config import conf
63 conf.i18n.add_missing_translations = True
66If a translation is now printed and the key is unknown (because someone has
67just added the related print code), an entry is added in the datastore kind.
68In addition, the default text and the hint are filled in and the filename
69and the line from the call from the code are set in the skeleton.
70This is the recommended way, as ViUR collects all the information you need
71and you only have to enter the translated values.
72(3. own way
73Of course you can create skeletons / entries in the datastore in your project
74on your own. Just use the TranslateSkel).
75""" # FIXME: grammar, rst syntax
76import abc
77import datetime
78import enum
79import fnmatch
80import logging
81import sys
82import traceback
83import typing as t
84from pathlib import Path
86import jinja2.ext as jinja2
87from viur.core import current, db, languages, tasks
88from viur.core.config import conf
90systemTranslations = {}
91"""Memory storage for translation methods"""
93KINDNAME = "viur-translations"
94"""Kindname for the translations"""
97class AddMissing(enum.IntEnum):
98 """
99 An indicator flag for the `add_missing` parameter of the `translate`
100 to make this decision final and ignore the `conf.i18n.add_missing_translations` configuration.
101 """
103 NEVER = enum.auto()
104 """This translation will never be added, regardless of any config.
105 It's like a final `False`.
106 """
108 ALWAYS = enum.auto()
109 """This translation will be always be added, regardless of any config.
110 It's like a final `True`.
111 """
114class LanguageWrapper(dict):
115 """
116 Wrapper-class for a multi-language value.
118 It's a dictionary, allowing accessing each stored language,
119 but can also be used as a string, in which case it tries to
120 guess the correct language.
121 Used by the HTML renderer to provide multi-lang bones values for templates.
122 """
124 def __init__(self, languages: list[str] | tuple[str]):
125 """
126 :param languages: Languages which are set in the bone.
127 """
128 super(LanguageWrapper, self).__init__()
129 self.languages = languages
131 def __str__(self) -> str:
132 return str(self.resolve())
134 def __bool__(self) -> bool:
135 # Overridden to support if skel["bone"] tests in html render
136 # (otherwise that test is always true as this dict contains keys)
137 return bool(str(self))
139 def resolve(self) -> str:
140 """
141 Causes this wrapper to evaluate to the best language available for the current request.
143 :returns: An item stored inside this instance or the empty string.
144 """
145 lang = current.language.get()
146 if lang:
147 lang = conf.i18n.language_alias_map.get(lang, lang)
148 else:
149 logging.warning(f"No lang set to current! {lang = }")
150 lang = self.languages[0]
151 if (value := self.get(lang)) and str(value).strip(): 151 ↛ 155line 151 didn't jump to line 155 because the condition on line 151 was always true
152 # The site language is available and not empty
153 return value
154 else: # Choose the first not-empty value as alternative
155 for lang in self.languages:
156 if (value := self.get(lang)) and str(value).strip():
157 return value
158 return "" # TODO: maybe we should better use sth like None or N/A
161class translate:
162 """
163 Translate class which chooses the correct translation according to the request language
165 This class is the replacement for the old translate() function provided by ViUR2. This classes __init__
166 takes the unique translation key (a string usually something like "user.auth_user_password.loginfailed" which
167 uniquely defines this text fragment), a default text that will be used if no translation for this key has been
168 added yet (in the projects default language) and a hint (an optional text that can convey context information
169 for the persons translating these texts - they are not shown to the end-user). This class will resolve its
170 translations upfront, so the actual resolving (by casting this class to string) is fast. This resolves most
171 translation issues with bones, which can now take an instance of this class as it's description/hints.
172 """
174 __slots__ = (
175 "add_missing",
176 "default_variables",
177 "defaultText",
178 "filename",
179 "force_lang",
180 "hint",
181 "key",
182 "lineno",
183 "public",
184 "translationCache",
185 )
187 def __init__(
188 self,
189 key: str,
190 defaultText: str = None,
191 hint: str = None,
192 force_lang: str = None,
193 public: bool = False,
194 add_missing: bool | AddMissing = False,
195 default_variables: dict[str, t.Any] | None = None,
196 caller_is_jinja: bool = False,
197 ):
198 """
199 :param key: The unique key defining this text fragment.
200 Usually it's a path/filename and a unique descriptor in that file
201 :param defaultText: The text to use if no translation has been added yet.
202 While optional, it's recommended to set this, as the key is used
203 instead if neither are available.
204 :param hint: A text only shown to the person translating this text,
205 as the key/defaultText may have different meanings in the
206 target language.
207 :param force_lang: Use this language instead the one of the request.
208 :param public: Flag for public translations, which can be obtained via /json/_translate/get_public.
209 :param default_variables: Default values for variable substitution.
210 :param caller_is_jinja: Is the call caused by our jinja method?
211 """
212 super().__init__()
214 if not isinstance(key, str): 214 ↛ 216line 214 didn't jump to line 216 because the condition on line 214 was never true
215 # TODO: ViUR4: raise a ValueError instead of the warning
216 logging.warning(f"Got non-string (type {type(key)}) as {key=}!", exc_info=True)
217 if isinstance(key, translate):
218 # Because of the string cast below, we would otherwise have a translated string as key
219 key = key.key
221 if force_lang is not None and force_lang not in conf.i18n.available_dialects: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true
222 raise ValueError(f"The language {force_lang=} is not available")
224 key = str(key) # ensure key is a str
225 self.key = key.lower()
226 self.defaultText = defaultText or key
227 self.hint = hint
228 self.translationCache = None
229 self.force_lang = force_lang
230 self.public = public
231 self.add_missing = add_missing
232 self.default_variables = default_variables or {}
233 self.filename, self.lineno = None, None
235 if ( 235 ↛ 241line 235 didn't jump to line 241 because the condition on line 235 was never true
236 add_missing is not AddMissing.NEVER
237 and (add_missing or conf.i18n.add_missing_translations)
238 and self.key not in systemTranslations
239 ):
240 # This translation seems to be new and should be added
241 for frame, line in traceback.walk_stack(sys._getframe(0).f_back):
242 if self.filename is None:
243 # Use the first frame as fallback.
244 # In case of calling this class directly,
245 # this is anyway the caller we're looking for.
246 self.filename = frame.f_code.co_filename
247 self.lineno = frame.f_lineno
248 if not caller_is_jinja:
249 break
250 if caller_is_jinja and not frame.f_code.co_filename.endswith(".py"):
251 # Look for the latest html, macro (not py) where the
252 # translate method has been used, that's our caller
253 self.filename = frame.f_code.co_filename
254 self.lineno = line
255 break
257 def __repr__(self) -> str:
258 return f"<translate object for {self.key} with force_lang={self.force_lang}>"
260 def __str__(self) -> str:
261 if self.translationCache is None:
262 global systemTranslations
264 if self.key not in systemTranslations: 264 ↛ 292line 264 didn't jump to line 292 because the condition on line 264 was always true
265 # either the translate()-object has add_missing set
266 if not (add_missing := self.add_missing) and not isinstance(add_missing, AddMissing):
267 # otherwise, use configuration flag
268 add_missing = conf.i18n.add_missing_translations
270 # match against fnmatch pattern, when given
271 if isinstance(add_missing, str): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true
272 add_missing = fnmatch.fnmatch(self.key, add_missing)
273 elif isinstance(add_missing, t.Iterable): 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 add_missing = bool(any(fnmatch.fnmatch(self.key, pat) for pat in add_missing))
275 elif callable(add_missing): 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 add_missing = add_missing(self)
277 else:
278 add_missing = bool(add_missing)
280 if add_missing is True or add_missing is AddMissing.ALWAYS: 280 ↛ 282line 280 didn't jump to line 282 because the condition on line 280 was never true
281 # This translation seems to be new and should be added
282 add_missing_translation(
283 key=self.key,
284 hint=self.hint,
285 default_text=self.defaultText,
286 filename=self.filename,
287 lineno=self.lineno,
288 variables=list(self.default_variables.keys()),
289 public=self.public,
290 )
292 self.translationCache = self.merge_alias(systemTranslations.get(self.key, {}))
294 if (lang := self.force_lang) is None: 294 ↛ 299line 294 didn't jump to line 299 because the condition on line 294 was always true
295 # The default case: use the request language
296 lang = current.language.get()
298 # Default text comes from the datastore or from the caller arguments
299 return self.substitute_vars(
300 self.resolve_language(
301 self.translationCache,
302 lang,
303 self.translationCache.get("_default_text_") or self.defaultText,
304 ),
305 **self.default_variables
306 )
308 def translate(self, **kwargs) -> str:
309 """Substitute the given kwargs in the translated or default text."""
310 return self.substitute_vars(str(self), **(self.default_variables | kwargs))
312 def __call__(self, **kwargs) -> str:
313 """Just an alias for translate"""
314 return self.translate(**kwargs)
316 @staticmethod
317 def substitute_vars(value: str, **kwargs) -> str:
318 """Substitute vars in a translation
320 Variables has to start with two braces (`{{`), followed by the variable
321 name and end with two braces (`}}`).
322 Values can be anything, they are cast to string anyway.
323 "Hello {{name}}!" becomes with name="Bob": "Hello Bob!"
324 """
325 res = str(value)
326 for k, v in kwargs.items():
327 # 2 braces * (escape + real brace) + 1 for variable = 5
328 res = res.replace(f"{{{{{k}}}}}", str(v))
329 return res
331 @staticmethod
332 def resolve_language(translations: dict[str, str], lang: str | None, default_text: t.Any) -> str:
333 """Choose the best value for the requested language
335 The resolution order is:
337 1. the requested language ``lang``; an aliased language
338 (``conf.i18n.language_alias_map``) resolves as well, because
339 :meth:`merge_alias` has copied the value of its main language into
340 the translation dict before,
341 2. each language of ``conf.i18n.fallback_languages``, in the
342 configured order,
343 3. the ``default_text``.
345 A value counts as missing if it's unset or contains only whitespace --
346 the same rule as in :meth:`merge_alias`. ``lang`` may be None (no
347 language set in the request), in that case only the fallback languages
348 are tried.
350 :param translations: The translation values per language.
351 :param lang: The requested language or None.
352 :param default_text: Used when neither the requested language nor any
353 fallback language provides a value.
354 :return: The value of the first language that has one, otherwise the
355 default text.
356 """
357 for candidate in (lang, *conf.i18n.fallback_languages):
358 if candidate and (value := translations.get(candidate)) and str(value).strip(): 358 ↛ 359line 358 didn't jump to line 359 because the condition on line 358 was never true
359 return str(value)
361 return str(default_text)
363 @staticmethod
364 def merge_alias(translations: dict[str, str]):
365 """Make sure each aliased language has a value
367 If an aliased language does not have a value in the translation dict,
368 the value of the main language is copied.
369 """
370 for alias, main in conf.i18n.language_alias_map.items(): 370 ↛ 371line 370 didn't jump to line 371 because the loop on line 370 never started
371 if not (value := translations.get(alias)) or not value.strip():
372 if main_value := translations.get(main):
373 # Use only not empty value
374 translations[alias] = main_value
375 return translations
378class TranslationExtension(jinja2.Extension):
379 """
380 Default translation extension for jinja2 render.
381 Use like {% translate "translationKey", "defaultText", "translationHint", replaceValue1="replacedText1" %}
382 All except translationKey is optional. translationKey is the same Key supplied to _() before.
383 defaultText will be printed if no translation is available.
384 translationHint is an optional hint for anyone adding a now translation how/where that translation is used.
385 `force_lang` can be used as a keyword argument (the only allowed way) to
386 force the use of a specific language, not the language of the request.
387 """
389 tags = {
390 "translate",
391 }
393 def parse(self, parser):
394 # Parse the translate tag
395 global systemTranslations
397 args = [] # positional args for the `_translate()` method
398 kwargs = {} # keyword args (force_lang + substitute vars) for the `_translate()` method
399 lineno = parser.stream.current.lineno
400 filename = parser.stream.filename
402 # Parse arguments (args and kwargs) until the current block ends
403 lastToken = None
404 while parser.stream.current.type != 'block_end':
405 lastToken = parser.parse_expression()
406 if parser.stream.current.type == "comma": # It's a positional arg
407 args.append(lastToken.value)
408 next(parser.stream) # Advance pointer
409 lastToken = None
410 elif parser.stream.current.type == "assign":
411 next(parser.stream) # Advance beyond =
412 expr = parser.parse_expression()
413 kwargs[lastToken.name] = expr.value
414 if parser.stream.current.type == "comma":
415 next(parser.stream)
416 elif parser.stream.current.type == "block_end":
417 lastToken = None
418 break
419 else:
420 raise SyntaxError()
421 lastToken = None
423 if lastToken: # TODO: what's this? what it is doing?
424 # logging.debug(f"final append {lastToken = }")
425 args.append(lastToken.value)
427 if not 0 < len(args) <= 3:
428 raise SyntaxError("Translation-Key missing or excess parameters!")
430 args += [""] * (3 - len(args))
431 args += [kwargs]
432 name = args[0].lower()
433 public = kwargs.pop("_public_", False) or False
435 if conf.i18n.add_missing_translations and name not in systemTranslations:
436 add_missing_translation(
437 key=name,
438 hint=args[1],
439 default_text=args[2],
440 filename=filename,
441 lineno=lineno,
442 variables=list(kwargs.keys()),
443 public=public,
444 )
446 translations = translate.merge_alias(systemTranslations.get(name, {}))
447 args[1] = translations.get("_default_text_") or args[1]
448 args = [jinja2.nodes.Const(x) for x in args]
449 args.append(jinja2.nodes.Const(translations))
450 return jinja2.nodes.CallBlock(self.call_method("_translate", args), [], [], []).set_lineno(lineno)
452 def _translate(
453 self, key: str, default_text: str, hint: str, kwargs: dict[str, t.Any],
454 translations: dict[str, str], caller
455 ) -> str:
456 """Perform the actual translation during render"""
457 lang = kwargs.pop("force_lang", current.language.get())
458 res = translate.resolve_language(translations, lang, default_text)
459 return translate.substitute_vars(res, **kwargs)
462class TranslationSource(abc.ABC):
463 """A source of translations, loaded by :meth:`initializeTranslations`
465 Set instances to :attr:`core.config.I18N.sources`. Sources are loaded in
466 order, a later source replaces the entries of an earlier one per key.
467 """
469 @abc.abstractmethod
470 def load(self) -> dict[str, dict[str, t.Any]]:
471 """Return the translations of this source as {key: {lang: value}}
473 Keys starting with an underscore are metadata (`_default_text_`,
474 `_public_`) and not treated as a language.
475 """
476 ...
479class StaticModuleSource(TranslationSource):
480 """Translations from the dicts of a python module, e.g. viur.core.languages"""
482 def __init__(self, module: t.Any = languages):
483 super().__init__()
484 self.module = module
486 def __repr__(self) -> str:
487 return f"{self.__class__.__name__}({self.module.__name__})"
489 def load(self) -> dict[str, dict[str, t.Any]]:
490 res = {}
491 for lang, mapping in vars(self.module).items():
492 if lang.startswith("_") or not isinstance(mapping, dict):
493 continue
494 for name, tr_value in mapping.items():
495 res.setdefault(name, {})[lang] = tr_value
497 return res
500class DatastoreSource(TranslationSource):
501 """Translations from the datastore, as managed by the translation module"""
503 def __repr__(self) -> str:
504 return f"{self.__class__.__name__}()"
506 def load(self) -> dict[str, dict[str, t.Any]]:
507 res = {}
508 # TODO: iter() would be more memory efficient, but unfortunately takes much longer than run()
509 # for entity in db.Query(KINDNAME).iter():
510 for entity in db.Query(KINDNAME).run(10_000):
511 if "name" not in entity: 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true
512 logging.warning(f"translations entity {entity.key} has no name set --> Call migration")
513 migrate_translation(entity.key)
514 # Before the migration has run do a quick modification to get it loaded as is
515 entity["name"] = entity["key"] or entity.key.name
516 if not entity.get("name"): 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true
517 logging.error(f'translations entity {entity.key} has an empty {entity["name"]=} set. Skipping.')
518 continue
519 if not isinstance(entity.get("translations"), dict):
520 logging.error(f"translations entity {entity.key} has invalid "
521 f"translations set: {entity.get('translations')!r}. Skipping.")
522 continue
524 res[entity["name"]] = entity["translations"] | {
525 "_default_text_": entity.get("default_text") or None,
526 "_public_": entity.get("public") or False,
527 }
529 return res
532DEFAULT_TRANSLATION_SOURCES: tuple[TranslationSource, ...] = (StaticModuleSource(), DatastoreSource())
533"""Used when :attr:`core.config.I18N.sources` is None"""
536def normalize_translations(translations: dict[str, t.Any]) -> dict[str, t.Any]:
537 """Drop unknown languages and empty values, but keep the metadata entries"""
538 res = {}
539 for lang, tr_value in translations.items():
540 if lang.startswith("_"):
541 # Metadata, not a language
542 res[lang] = tr_value
543 elif lang in conf.i18n.available_dialects and tr_value and str(tr_value).strip():
544 # Don't store unknown languages or empty values in the memory
545 res[lang] = tr_value
547 return res
550def initializeTranslations() -> None:
551 """
552 Loads all translations of :attr:`core.config.I18N.sources` into the *systemTranslations* of this module.
553 Currently, the translate-class will resolve using that dictionary; but as we expect projects to grow and
554 accumulate translations that are no longer/not yet used, we plan to made the translation-class fetch it's
555 translations directly from the datastore, so we don't have to allocate memory for unused translations.
556 """
557 for source in conf.i18n.sources if conf.i18n.sources is not None else DEFAULT_TRANSLATION_SOURCES:
558 try:
559 loaded = source.load()
560 except Exception:
561 logging.error(f"Translation source {source!r} failed to load")
562 raise
564 if not isinstance(loaded, dict):
565 raise TypeError(f"Translation source {source!r} returned {type(loaded).__name__}, expected dict")
567 for name, translations in loaded.items():
568 systemTranslations[name] = normalize_translations(translations)
571@tasks.CallDeferred
572@tasks.retry_n_times(20)
573def add_missing_translation(
574 key: str,
575 hint: str | None = None,
576 default_text: str | None = None,
577 filename: str | None = None,
578 lineno: int | None = None,
579 variables: list[str] = None,
580 public: bool = False,
581) -> None:
582 """Add missing translations to datastore"""
584 logging.info(f"add_missing_translation {key=} {hint=} {default_text=} {filename=} {lineno=} {variables=} {public=}")
586 try:
587 from viur.core.modules.translation import TranslationSkel, Creator
588 except ImportError as exc:
589 # We use translate inside the TranslationSkel, this causes circular dependencies which can be ignored
590 logging.warning(f"ImportError (probably during warmup), "
591 f"cannot add translation {key}: {exc}", exc_info=True)
592 return
594 # Ensure lowercase key
595 key = key.lower()
597 # Check if key already exists
598 # if db.get(db.Key(KINDNAME, key)): # FIXME ViUR4 should only use named keys
599 entity = db.Query(KINDNAME).filter("name =", key).getEntry()
600 if entity is not None:
601 # Ensure it doesn't exist to avoid datastore conflicts
602 logging.warning(f"Found an entity with {key=}. Probably an other instance was faster.")
603 return
605 if isinstance(filename, str):
606 filename = Path(filename)
607 if not filename.is_absolute():
608 # Already a relative path (e.g. a Jinja template name) — keep as-is
609 filename = str(filename)
610 elif filename.is_relative_to(conf.instance.project_base_path):
611 filename = str(filename.relative_to(conf.instance.project_base_path, walk_up=True))
612 else:
613 filename = str(filename.relative_to(conf.instance.core_base_path, walk_up=True))
615 logging.info(f"Add missing translation {key}")
616 skel = TranslationSkel()
617 skel["name"] = key
618 skel["default_text"] = default_text or None
619 skel["hint"] = hint or None
620 skel["usage_filename"] = filename
621 skel["usage_lineno"] = lineno
622 skel["usage_variables"] = variables or []
623 skel["creator"] = Creator.VIUR
624 skel["public"] = public
625 skel.write()
627 # Add to system translation to avoid triggering this method again
628 systemTranslations[key] = {
629 "_default_text_": default_text or None,
630 "_public_": public,
631 }
634@tasks.CallDeferred
635@tasks.retry_n_times(20)
636def migrate_translation(
637 key: db.Key,
638) -> None:
639 """Migrate entities, if required.
641 With viur-core 3.6 translations are now managed as Skeletons and require
642 some changes, which are performed in this method.
643 """
644 from viur.core.modules.translation import TranslationSkel
645 logging.info(f"Migrate translation {key}")
647 entity: db.Entity = db.get(key)
648 if "name" not in entity:
649 entity["name"] = entity["key"] or key.name
651 # Pre-3.6 stored the texts as a plain {lang: text} dict. Without the LanguageWrapper
652 # marker BaseBone.unserialize cannot tell the languages apart and puts the whole dict
653 # into the main language -- and translations_missing (compute=OnWrite) reads the bone
654 # during write(), so that is what would be persisted.
655 if not isinstance(translations := entity.get("translations"), dict):
656 logging.error(f"Skipping translation {key}: {translations!r} is not a dict")
657 return
658 translations["_viurLanguageWrapper_"] = True
660 skel = TranslationSkel()
661 skel.setEntity(entity)
662 skel["key"] = key
663 try:
664 skel.write()
665 except ValueError as exc:
666 logging.exception(exc)
667 if "unique value" in exc.args[0] and "recently claimed" in exc.args[0]:
668 logging.info(f"Delete duplicate entry {key}: {entity}")
669 db.delete(key)
670 else:
671 raise exc
674localizedDateTime = translate("const_datetimeformat", "%a %b %d %H:%M:%S %Y", "Localized Time and Date format string")
675localizedDate = translate("const_dateformat", "%m/%d/%Y", "Localized Date only format string")
676localizedTime = translate("const_timeformat", "%H:%M:%S", "Localized Time only format string")
677localizedAbbrevDayNames = {
678 0: translate("const_day_0_short", "Sun", "Abbreviation for Sunday"),
679 1: translate("const_day_1_short", "Mon", "Abbreviation for Monday"),
680 2: translate("const_day_2_short", "Tue", "Abbreviation for Tuesday"),
681 3: translate("const_day_3_short", "Wed", "Abbreviation for Wednesday"),
682 4: translate("const_day_4_short", "Thu", "Abbreviation for Thursday"),
683 5: translate("const_day_5_short", "Fri", "Abbreviation for Friday"),
684 6: translate("const_day_6_short", "Sat", "Abbreviation for Saturday"),
685}
686localizedDayNames = {
687 0: translate("const_day_0_long", "Sunday", "Sunday"),
688 1: translate("const_day_1_long", "Monday", "Monday"),
689 2: translate("const_day_2_long", "Tuesday", "Tuesday"),
690 3: translate("const_day_3_long", "Wednesday", "Wednesday"),
691 4: translate("const_day_4_long", "Thursday", "Thursday"),
692 5: translate("const_day_5_long", "Friday", "Friday"),
693 6: translate("const_day_6_long", "Saturday", "Saturday"),
694}
695localizedAbbrevMonthNames = {
696 1: translate("const_month_1_short", "Jan", "Abbreviation for January"),
697 2: translate("const_month_2_short", "Feb", "Abbreviation for February"),
698 3: translate("const_month_3_short", "Mar", "Abbreviation for March"),
699 4: translate("const_month_4_short", "Apr", "Abbreviation for April"),
700 5: translate("const_month_5_short", "May", "Abbreviation for May"),
701 6: translate("const_month_6_short", "Jun", "Abbreviation for June"),
702 7: translate("const_month_7_short", "Jul", "Abbreviation for July"),
703 8: translate("const_month_8_short", "Aug", "Abbreviation for August"),
704 9: translate("const_month_9_short", "Sep", "Abbreviation for September"),
705 10: translate("const_month_10_short", "Oct", "Abbreviation for October"),
706 11: translate("const_month_11_short", "Nov", "Abbreviation for November"),
707 12: translate("const_month_12_short", "Dec", "Abbreviation for December"),
708}
709localizedMonthNames = {
710 1: translate("const_month_1_long", "January", "January"),
711 2: translate("const_month_2_long", "February", "February"),
712 3: translate("const_month_3_long", "March", "March"),
713 4: translate("const_month_4_long", "April", "April"),
714 5: translate("const_month_5_long", "May", "May"),
715 6: translate("const_month_6_long", "June", "June"),
716 7: translate("const_month_7_long", "July", "July"),
717 8: translate("const_month_8_long", "August", "August"),
718 9: translate("const_month_9_long", "September", "September"),
719 10: translate("const_month_10_long", "October", "October"),
720 11: translate("const_month_11_long", "November", "November"),
721 12: translate("const_month_12_long", "December", "December"),
722}
725def localizedStrfTime(datetimeObj: datetime.datetime, format: str) -> str:
726 """
727 Provides correct localized names for directives like %a which don't get translated on GAE properly as we can't
728 set the locale (for each request).
729 This currently replaces %a, %A, %b, %B, %c, %x and %X.
731 :param datetimeObj: Datetime-instance to call strftime on
732 :param format: String containing the Format to apply.
733 :returns: Date and time formatted according to format with correct localization
734 """
735 if "%c" in format:
736 format = format.replace("%c", str(localizedDateTime))
737 if "%x" in format:
738 format = format.replace("%x", str(localizedDate))
739 if "%X" in format:
740 format = format.replace("%X", str(localizedTime))
741 if "%a" in format:
742 format = format.replace("%a", str(localizedAbbrevDayNames[int(datetimeObj.strftime("%w"))]))
743 if "%A" in format:
744 format = format.replace("%A", str(localizedDayNames[int(datetimeObj.strftime("%w"))]))
745 if "%b" in format:
746 format = format.replace("%b", str(localizedAbbrevMonthNames[int(datetimeObj.strftime("%m"))]))
747 if "%B" in format:
748 format = format.replace("%B", str(localizedMonthNames[int(datetimeObj.strftime("%m"))]))
749 return datetimeObj.strftime(format)