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

1""" 

2This module provides translation, also known as internationalization -- short: i18n. 

3 

4Project translations must be stored in the datastore. There are only some 

5static translation tables in the viur-core to have some basic ones. 

6 

7The viur-core's own "translation" module (routed as _translation) provides 

8an API to manage these translations, for example in the vi-admin. 

9 

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"] 

17 

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 } 

26 

27Now translations can be used 

28 

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"])) 

39 

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" %} 

45 

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"]) }} 

50 

51 

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. 

57 

582. Automatically 

59The add_missing_translations option must be enabled for this. 

60.. code-block:: python 

61 

62 from viur.core.config import conf 

63 conf.i18n.add_missing_translations = True 

64 

65 

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 

85 

86import jinja2.ext as jinja2 

87from viur.core import current, db, languages, tasks 

88from viur.core.config import conf 

89 

90systemTranslations = {} 

91"""Memory storage for translation methods""" 

92 

93KINDNAME = "viur-translations" 

94"""Kindname for the translations""" 

95 

96 

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 """ 

102 

103 NEVER = enum.auto() 

104 """This translation will never be added, regardless of any config. 

105 It's like a final `False`. 

106 """ 

107 

108 ALWAYS = enum.auto() 

109 """This translation will be always be added, regardless of any config. 

110 It's like a final `True`. 

111 """ 

112 

113 

114class LanguageWrapper(dict): 

115 """ 

116 Wrapper-class for a multi-language value. 

117 

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 """ 

123 

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 

130 

131 def __str__(self) -> str: 

132 return str(self.resolve()) 

133 

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)) 

138 

139 def resolve(self) -> str: 

140 """ 

141 Causes this wrapper to evaluate to the best language available for the current request. 

142 

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 

159 

160 

161class translate: 

162 """ 

163 Translate class which chooses the correct translation according to the request language 

164 

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 """ 

173 

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 ) 

186 

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__() 

213 

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 

220 

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") 

223 

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 

234 

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 

256 

257 def __repr__(self) -> str: 

258 return f"<translate object for {self.key} with force_lang={self.force_lang}>" 

259 

260 def __str__(self) -> str: 

261 if self.translationCache is None: 

262 global systemTranslations 

263 

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 

269 

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) 

279 

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 ) 

291 

292 self.translationCache = self.merge_alias(systemTranslations.get(self.key, {})) 

293 

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() 

297 

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 ) 

307 

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)) 

311 

312 def __call__(self, **kwargs) -> str: 

313 """Just an alias for translate""" 

314 return self.translate(**kwargs) 

315 

316 @staticmethod 

317 def substitute_vars(value: str, **kwargs) -> str: 

318 """Substitute vars in a translation 

319 

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 

330 

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 

334 

335 The resolution order is: 

336 

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``. 

344 

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. 

349 

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) 

360 

361 return str(default_text) 

362 

363 @staticmethod 

364 def merge_alias(translations: dict[str, str]): 

365 """Make sure each aliased language has a value 

366 

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 

376 

377 

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 """ 

388 

389 tags = { 

390 "translate", 

391 } 

392 

393 def parse(self, parser): 

394 # Parse the translate tag 

395 global systemTranslations 

396 

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 

401 

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 

422 

423 if lastToken: # TODO: what's this? what it is doing? 

424 # logging.debug(f"final append {lastToken = }") 

425 args.append(lastToken.value) 

426 

427 if not 0 < len(args) <= 3: 

428 raise SyntaxError("Translation-Key missing or excess parameters!") 

429 

430 args += [""] * (3 - len(args)) 

431 args += [kwargs] 

432 name = args[0].lower() 

433 public = kwargs.pop("_public_", False) or False 

434 

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 ) 

445 

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) 

451 

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) 

460 

461 

462class TranslationSource(abc.ABC): 

463 """A source of translations, loaded by :meth:`initializeTranslations` 

464 

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 """ 

468 

469 @abc.abstractmethod 

470 def load(self) -> dict[str, dict[str, t.Any]]: 

471 """Return the translations of this source as {key: {lang: value}} 

472 

473 Keys starting with an underscore are metadata (`_default_text_`, 

474 `_public_`) and not treated as a language. 

475 """ 

476 ... 

477 

478 

479class StaticModuleSource(TranslationSource): 

480 """Translations from the dicts of a python module, e.g. viur.core.languages""" 

481 

482 def __init__(self, module: t.Any = languages): 

483 super().__init__() 

484 self.module = module 

485 

486 def __repr__(self) -> str: 

487 return f"{self.__class__.__name__}({self.module.__name__})" 

488 

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 

496 

497 return res 

498 

499 

500class DatastoreSource(TranslationSource): 

501 """Translations from the datastore, as managed by the translation module""" 

502 

503 def __repr__(self) -> str: 

504 return f"{self.__class__.__name__}()" 

505 

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 

523 

524 res[entity["name"]] = entity["translations"] | { 

525 "_default_text_": entity.get("default_text") or None, 

526 "_public_": entity.get("public") or False, 

527 } 

528 

529 return res 

530 

531 

532DEFAULT_TRANSLATION_SOURCES: tuple[TranslationSource, ...] = (StaticModuleSource(), DatastoreSource()) 

533"""Used when :attr:`core.config.I18N.sources` is None""" 

534 

535 

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 

546 

547 return res 

548 

549 

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 

563 

564 if not isinstance(loaded, dict): 

565 raise TypeError(f"Translation source {source!r} returned {type(loaded).__name__}, expected dict") 

566 

567 for name, translations in loaded.items(): 

568 systemTranslations[name] = normalize_translations(translations) 

569 

570 

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""" 

583 

584 logging.info(f"add_missing_translation {key=} {hint=} {default_text=} {filename=} {lineno=} {variables=} {public=}") 

585 

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 

593 

594 # Ensure lowercase key 

595 key = key.lower() 

596 

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 

604 

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)) 

614 

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() 

626 

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 } 

632 

633 

634@tasks.CallDeferred 

635@tasks.retry_n_times(20) 

636def migrate_translation( 

637 key: db.Key, 

638) -> None: 

639 """Migrate entities, if required. 

640 

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}") 

646 

647 entity: db.Entity = db.get(key) 

648 if "name" not in entity: 

649 entity["name"] = entity["key"] or key.name 

650 

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 

659 

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 

672 

673 

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} 

723 

724 

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. 

730 

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)