Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/i18n.py: 20%

256 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-26 11:31 +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 datetime 

77import enum 

78import fnmatch 

79import logging 

80import sys 

81import traceback 

82import typing as t 

83from pathlib import Path 

84 

85import jinja2.ext as jinja2 

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

87from viur.core.config import conf 

88 

89systemTranslations = {} 

90"""Memory storage for translation methods""" 

91 

92KINDNAME = "viur-translations" 

93"""Kindname for the translations""" 

94 

95 

96class AddMissing(enum.IntEnum): 

97 """ 

98 An indicator flag for the `add_missing` parameter of the `translate` 

99 to make this decision final and ignore the `conf.i18n.add_missing_translations` configuration. 

100 """ 

101 

102 NEVER = enum.auto() 

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

104 It's like a final `False`. 

105 """ 

106 

107 ALWAYS = enum.auto() 

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

109 It's like a final `True`. 

110 """ 

111 

112 

113class LanguageWrapper(dict): 

114 """ 

115 Wrapper-class for a multi-language value. 

116 

117 It's a dictionary, allowing accessing each stored language, 

118 but can also be used as a string, in which case it tries to 

119 guess the correct language. 

120 Used by the HTML renderer to provide multi-lang bones values for templates. 

121 """ 

122 

123 def __init__(self, languages: list[str] | tuple[str]): 

124 """ 

125 :param languages: Languages which are set in the bone. 

126 """ 

127 super(LanguageWrapper, self).__init__() 

128 self.languages = languages 

129 

130 def __str__(self) -> str: 

131 return str(self.resolve()) 

132 

133 def __bool__(self) -> bool: 

134 # Overridden to support if skel["bone"] tests in html render 

135 # (otherwise that test is always true as this dict contains keys) 

136 return bool(str(self)) 

137 

138 def resolve(self) -> str: 

139 """ 

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

141 

142 :returns: An item stored inside this instance or the empty string. 

143 """ 

144 lang = current.language.get() 

145 if lang: 

146 lang = conf.i18n.language_alias_map.get(lang, lang) 

147 else: 

148 logging.warning(f"No lang set to current! {lang = }") 

149 lang = self.languages[0] 

150 if (value := self.get(lang)) and str(value).strip(): 

151 # The site language is available and not empty 

152 return value 

153 else: # Choose the first not-empty value as alternative 

154 for lang in self.languages: 

155 if (value := self.get(lang)) and str(value).strip(): 

156 return value 

157 return "" # TODO: maybe we should better use sth like None or N/A 

158 

159 

160class translate: 

161 """ 

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

163 

164 This class is the replacement for the old translate() function provided by ViUR2. This classes __init__ 

165 takes the unique translation key (a string usually something like "user.auth_user_password.loginfailed" which 

166 uniquely defines this text fragment), a default text that will be used if no translation for this key has been 

167 added yet (in the projects default language) and a hint (an optional text that can convey context information 

168 for the persons translating these texts - they are not shown to the end-user). This class will resolve its 

169 translations upfront, so the actual resolving (by casting this class to string) is fast. This resolves most 

170 translation issues with bones, which can now take an instance of this class as it's description/hints. 

171 """ 

172 

173 __slots__ = ( 

174 "add_missing", 

175 "default_variables", 

176 "defaultText", 

177 "filename", 

178 "force_lang", 

179 "hint", 

180 "key", 

181 "lineno", 

182 "public", 

183 "translationCache", 

184 ) 

185 

186 def __init__( 

187 self, 

188 key: str, 

189 defaultText: str = None, 

190 hint: str = None, 

191 force_lang: str = None, 

192 public: bool = False, 

193 add_missing: bool | AddMissing = False, 

194 default_variables: dict[str, t.Any] | None = None, 

195 caller_is_jinja: bool = False, 

196 ): 

197 """ 

198 :param key: The unique key defining this text fragment. 

199 Usually it's a path/filename and a unique descriptor in that file 

200 :param defaultText: The text to use if no translation has been added yet. 

201 While optional, it's recommended to set this, as the key is used 

202 instead if neither are available. 

203 :param hint: A text only shown to the person translating this text, 

204 as the key/defaultText may have different meanings in the 

205 target language. 

206 :param force_lang: Use this language instead the one of the request. 

207 :param public: Flag for public translations, which can be obtained via /json/_translate/get_public. 

208 :param default_variables: Default values for variable substitution. 

209 :param caller_is_jinja: Is the call caused by our jinja method? 

210 """ 

211 super().__init__() 

212 

213 if not isinstance(key, str): 213 ↛ 215line 213 didn't jump to line 215 because the condition on line 213 was never true

214 # TODO: ViUR4: raise a ValueError instead of the warning 

215 logging.warning(f"Got non-string (type {type(key)}) as {key=}!", exc_info=True) 

216 if isinstance(key, translate): 

217 # Because of the string cast below, we would otherwise have a translated string as key 

218 key = key.key 

219 

220 if force_lang is not None and force_lang not in conf.i18n.available_dialects: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 raise ValueError(f"The language {force_lang=} is not available") 

222 

223 key = str(key) # ensure key is a str 

224 self.key = key.lower() 

225 self.defaultText = defaultText or key 

226 self.hint = hint 

227 self.translationCache = None 

228 self.force_lang = force_lang 

229 self.public = public 

230 self.add_missing = add_missing 

231 self.default_variables = default_variables or {} 

232 self.filename, self.lineno = None, None 

233 

234 if ( 234 ↛ 240line 234 didn't jump to line 240 because the condition on line 234 was never true

235 add_missing is not AddMissing.NEVER 

236 and (add_missing or conf.i18n.add_missing_translations) 

237 and self.key not in systemTranslations 

238 ): 

239 # This translation seems to be new and should be added 

240 for frame, line in traceback.walk_stack(sys._getframe(0).f_back): 

241 if self.filename is None: 

242 # Use the first frame as fallback. 

243 # In case of calling this class directly, 

244 # this is anyway the caller we're looking for. 

245 self.filename = frame.f_code.co_filename 

246 self.lineno = frame.f_lineno 

247 if not caller_is_jinja: 

248 break 

249 if caller_is_jinja and not frame.f_code.co_filename.endswith(".py"): 

250 # Look for the latest html, macro (not py) where the 

251 # translate method has been used, that's our caller 

252 self.filename = frame.f_code.co_filename 

253 self.lineno = line 

254 break 

255 

256 def __repr__(self) -> str: 

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

258 

259 def __str__(self) -> str: 

260 if self.translationCache is None: 

261 global systemTranslations 

262 

263 if self.key not in systemTranslations: 

264 # either the translate()-object has add_missing set 

265 if not (add_missing := self.add_missing) and not isinstance(add_missing, AddMissing): 

266 # otherwise, use configuration flag 

267 add_missing = conf.i18n.add_missing_translations 

268 

269 # match against fnmatch pattern, when given 

270 if isinstance(add_missing, str): 

271 add_missing = fnmatch.fnmatch(self.key, add_missing) 

272 elif isinstance(add_missing, t.Iterable): 

273 add_missing = bool(any(fnmatch.fnmatch(self.key, pat) for pat in add_missing)) 

274 elif callable(add_missing): 

275 add_missing = add_missing(self) 

276 else: 

277 add_missing = bool(add_missing) 

278 

279 if add_missing is True or add_missing is AddMissing.ALWAYS: 

280 # This translation seems to be new and should be added 

281 add_missing_translation( 

282 key=self.key, 

283 hint=self.hint, 

284 default_text=self.defaultText, 

285 filename=self.filename, 

286 lineno=self.lineno, 

287 variables=list(self.default_variables.keys()), 

288 public=self.public, 

289 ) 

290 

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

292 

293 if (lang := self.force_lang) is None: 

294 # The default case: use the request language 

295 lang = current.language.get() 

296 

297 if value := self.translationCache.get(lang): 

298 return self.substitute_vars(value, **self.default_variables) 

299 

300 # Use the default text from datastore or from the caller arguments 

301 return self.substitute_vars( 

302 self.translationCache.get("_default_text_") or self.defaultText, 

303 **self.default_variables 

304 ) 

305 

306 def translate(self, **kwargs) -> str: 

307 """Substitute the given kwargs in the translated or default text.""" 

308 return self.substitute_vars(str(self), **(self.default_variables | kwargs)) 

309 

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

311 """Just an alias for translate""" 

312 return self.translate(**kwargs) 

313 

314 @staticmethod 

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

316 """Substitute vars in a translation 

317 

318 Variables has to start with two braces (`{{`), followed by the variable 

319 name and end with two braces (`}}`). 

320 Values can be anything, they are cast to string anyway. 

321 "Hello {{name}}!" becomes with name="Bob": "Hello Bob!" 

322 """ 

323 res = str(value) 

324 for k, v in kwargs.items(): 

325 # 2 braces * (escape + real brace) + 1 for variable = 5 

326 res = res.replace(f"{{{{{k}}}}}", str(v)) 

327 return res 

328 

329 @staticmethod 

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

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

332 

333 If an aliased language does not have a value in the translation dict, 

334 the value of the main language is copied. 

335 """ 

336 for alias, main in conf.i18n.language_alias_map.items(): 

337 if not (value := translations.get(alias)) or not value.strip(): 

338 if main_value := translations.get(main): 

339 # Use only not empty value 

340 translations[alias] = main_value 

341 return translations 

342 

343 

344class TranslationExtension(jinja2.Extension): 

345 """ 

346 Default translation extension for jinja2 render. 

347 Use like {% translate "translationKey", "defaultText", "translationHint", replaceValue1="replacedText1" %} 

348 All except translationKey is optional. translationKey is the same Key supplied to _() before. 

349 defaultText will be printed if no translation is available. 

350 translationHint is an optional hint for anyone adding a now translation how/where that translation is used. 

351 `force_lang` can be used as a keyword argument (the only allowed way) to 

352 force the use of a specific language, not the language of the request. 

353 """ 

354 

355 tags = { 

356 "translate", 

357 } 

358 

359 def parse(self, parser): 

360 # Parse the translate tag 

361 global systemTranslations 

362 

363 args = [] # positional args for the `_translate()` method 

364 kwargs = {} # keyword args (force_lang + substitute vars) for the `_translate()` method 

365 lineno = parser.stream.current.lineno 

366 filename = parser.stream.filename 

367 

368 # Parse arguments (args and kwargs) until the current block ends 

369 lastToken = None 

370 while parser.stream.current.type != 'block_end': 

371 lastToken = parser.parse_expression() 

372 if parser.stream.current.type == "comma": # It's a positional arg 

373 args.append(lastToken.value) 

374 next(parser.stream) # Advance pointer 

375 lastToken = None 

376 elif parser.stream.current.type == "assign": 

377 next(parser.stream) # Advance beyond = 

378 expr = parser.parse_expression() 

379 kwargs[lastToken.name] = expr.value 

380 if parser.stream.current.type == "comma": 

381 next(parser.stream) 

382 elif parser.stream.current.type == "block_end": 

383 lastToken = None 

384 break 

385 else: 

386 raise SyntaxError() 

387 lastToken = None 

388 

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

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

391 args.append(lastToken.value) 

392 

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

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

395 

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

397 args += [kwargs] 

398 name = args[0].lower() 

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

400 

401 if conf.i18n.add_missing_translations and name not in systemTranslations: 

402 add_missing_translation( 

403 key=name, 

404 hint=args[1], 

405 default_text=args[2], 

406 filename=filename, 

407 lineno=lineno, 

408 variables=list(kwargs.keys()), 

409 public=public, 

410 ) 

411 

412 translations = translate.merge_alias(systemTranslations.get(name, {})) 

413 args[1] = translations.get("_default_text_") or args[1] 

414 args = [jinja2.nodes.Const(x) for x in args] 

415 args.append(jinja2.nodes.Const(translations)) 

416 return jinja2.nodes.CallBlock(self.call_method("_translate", args), [], [], []).set_lineno(lineno) 

417 

418 def _translate( 

419 self, key: str, default_text: str, hint: str, kwargs: dict[str, t.Any], 

420 translations: dict[str, str], caller 

421 ) -> str: 

422 """Perform the actual translation during render""" 

423 lang = kwargs.pop("force_lang", current.language.get()) 

424 res = str(translations.get(lang, default_text)) 

425 return translate.substitute_vars(res, **kwargs) 

426 

427 

428def initializeTranslations() -> None: 

429 """ 

430 Fetches all translations from the datastore and populates the *systemTranslations* dictionary of this module. 

431 Currently, the translate-class will resolve using that dictionary; but as we expect projects to grow and 

432 accumulate translations that are no longer/not yet used, we plan to made the translation-class fetch it's 

433 translations directly from the datastore, so we don't have to allocate memory for unused translations. 

434 """ 

435 # Load translations from static languages module into systemTranslations 

436 # If they're in the datastore, they will be overwritten below. 

437 for lang in dir(languages): 

438 if lang.startswith("__"): 

439 continue 

440 for name, tr_value in getattr(languages, lang).items(): 

441 systemTranslations.setdefault(name, {})[lang] = tr_value 

442 

443 # Load translations from datastore into systemTranslations 

444 # TODO: iter() would be more memory efficient, but unfortunately takes much longer than run() 

445 # for entity in db.Query(KINDNAME).iter(): 

446 for entity in db.Query(KINDNAME).run(10_000): 

447 if "name" not in entity: 

448 logging.warning(f"translations entity {entity.key} has no name set --> Call migration") 

449 migrate_translation(entity.key) 

450 # Before the migration has run do a quick modification to get it loaded as is 

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

452 if not entity.get("name"): 

453 logging.error(f'translations entity {entity.key} has an empty {entity["name"]=} set. Skipping.') 

454 continue 

455 if entity and not isinstance(entity["translations"], dict): 

456 logging.error(f'translations entity {entity.key} has invalid ' 

457 f'translations set: {entity["translations"]}. Skipping.') 

458 continue 

459 

460 translations = { 

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

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

463 } 

464 

465 for lang, translation in entity["translations"].items(): 

466 if lang not in conf.i18n.available_dialects: 

467 # Don't store unknown languages in the memory 

468 continue 

469 if not translation or not str(translation).strip(): 

470 # Skip empty values 

471 continue 

472 translations[lang] = translation 

473 

474 systemTranslations[entity["name"]] = translations 

475 

476 

477@tasks.CallDeferred 

478@tasks.retry_n_times(20) 

479def add_missing_translation( 

480 key: str, 

481 hint: str | None = None, 

482 default_text: str | None = None, 

483 filename: str | None = None, 

484 lineno: int | None = None, 

485 variables: list[str] = None, 

486 public: bool = False, 

487) -> None: 

488 """Add missing translations to datastore""" 

489 

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

491 

492 try: 

493 from viur.core.modules.translation import TranslationSkel, Creator 

494 except ImportError as exc: 

495 # We use translate inside the TranslationSkel, this causes circular dependencies which can be ignored 

496 logging.warning(f"ImportError (probably during warmup), " 

497 f"cannot add translation {key}: {exc}", exc_info=True) 

498 return 

499 

500 # Ensure lowercase key 

501 key = key.lower() 

502 

503 # Check if key already exists 

504 # if db.Get(db.Key(KINDNAME, key)): # FIXME ViUR4 should only use named keys 

505 entity = db.Query(KINDNAME).filter("name =", key).getEntry() 

506 if entity is not None: 

507 # Ensure it doesn't exist to avoid datastore conflicts 

508 logging.warning(f"Found an entity with {key=}. Probably an other instance was faster.") 

509 return 

510 

511 if isinstance(filename, str): 

512 try: 

513 filename = str(Path(filename) 

514 .relative_to(conf.instance.project_base_path, 

515 conf.instance.core_base_path)) 

516 except ValueError: 

517 pass # not a subpath 

518 

519 logging.info(f"Add missing translation {key}") 

520 skel = TranslationSkel() 

521 skel["name"] = key 

522 skel["default_text"] = default_text or None 

523 skel["hint"] = hint or None 

524 skel["usage_filename"] = filename 

525 skel["usage_lineno"] = lineno 

526 skel["usage_variables"] = variables or [] 

527 skel["creator"] = Creator.VIUR 

528 skel["public"] = public 

529 skel.write() 

530 

531 # Add to system translation to avoid triggering this method again 

532 systemTranslations[key] = { 

533 "_default_text_": default_text or None, 

534 "_public_": public, 

535 } 

536 

537 

538@tasks.CallDeferred 

539@tasks.retry_n_times(20) 

540def migrate_translation( 

541 key: db.Key, 

542) -> None: 

543 """Migrate entities, if required. 

544 

545 With viur-core 3.6 translations are now managed as Skeletons and require 

546 some changes, which are performed in this method. 

547 """ 

548 from viur.core.modules.translation import TranslationSkel 

549 logging.info(f"Migrate translation {key}") 

550 entity: db.Entity = db.Get(key) 

551 if "name" not in entity: 

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

553 if "translation" in entity: 

554 if not isinstance(dict, entity["translation"]): 

555 logging.error("translation is not a dict?") 

556 entity["translation"]["_viurLanguageWrapper_"] = True 

557 skel = TranslationSkel() 

558 skel.setEntity(entity) 

559 skel["key"] = key 

560 try: 

561 skel.write() 

562 except ValueError as exc: 

563 logging.exception(exc) 

564 if "unique value" in exc.args[0] and "recently claimed" in exc.args[0]: 

565 logging.info(f"Delete duplicate entry {key}: {entity}") 

566 db.Delete(key) 

567 else: 

568 raise exc 

569 

570 

571localizedDateTime = translate("const_datetimeformat", "%a %b %d %H:%M:%S %Y", "Localized Time and Date format string") 

572localizedDate = translate("const_dateformat", "%m/%d/%Y", "Localized Date only format string") 

573localizedTime = translate("const_timeformat", "%H:%M:%S", "Localized Time only format string") 

574localizedAbbrevDayNames = { 

575 0: translate("const_day_0_short", "Sun", "Abbreviation for Sunday"), 

576 1: translate("const_day_1_short", "Mon", "Abbreviation for Monday"), 

577 2: translate("const_day_2_short", "Tue", "Abbreviation for Tuesday"), 

578 3: translate("const_day_3_short", "Wed", "Abbreviation for Wednesday"), 

579 4: translate("const_day_4_short", "Thu", "Abbreviation for Thursday"), 

580 5: translate("const_day_5_short", "Fri", "Abbreviation for Friday"), 

581 6: translate("const_day_6_short", "Sat", "Abbreviation for Saturday"), 

582} 

583localizedDayNames = { 

584 0: translate("const_day_0_long", "Sunday", "Sunday"), 

585 1: translate("const_day_1_long", "Monday", "Monday"), 

586 2: translate("const_day_2_long", "Tuesday", "Tuesday"), 

587 3: translate("const_day_3_long", "Wednesday", "Wednesday"), 

588 4: translate("const_day_4_long", "Thursday", "Thursday"), 

589 5: translate("const_day_5_long", "Friday", "Friday"), 

590 6: translate("const_day_6_long", "Saturday", "Saturday"), 

591} 

592localizedAbbrevMonthNames = { 

593 1: translate("const_month_1_short", "Jan", "Abbreviation for January"), 

594 2: translate("const_month_2_short", "Feb", "Abbreviation for February"), 

595 3: translate("const_month_3_short", "Mar", "Abbreviation for March"), 

596 4: translate("const_month_4_short", "Apr", "Abbreviation for April"), 

597 5: translate("const_month_5_short", "May", "Abbreviation for May"), 

598 6: translate("const_month_6_short", "Jun", "Abbreviation for June"), 

599 7: translate("const_month_7_short", "Jul", "Abbreviation for July"), 

600 8: translate("const_month_8_short", "Aug", "Abbreviation for August"), 

601 9: translate("const_month_9_short", "Sep", "Abbreviation for September"), 

602 10: translate("const_month_10_short", "Oct", "Abbreviation for October"), 

603 11: translate("const_month_11_short", "Nov", "Abbreviation for November"), 

604 12: translate("const_month_12_short", "Dec", "Abbreviation for December"), 

605} 

606localizedMonthNames = { 

607 1: translate("const_month_1_long", "January", "January"), 

608 2: translate("const_month_2_long", "February", "February"), 

609 3: translate("const_month_3_long", "March", "March"), 

610 4: translate("const_month_4_long", "April", "April"), 

611 5: translate("const_month_5_long", "May", "May"), 

612 6: translate("const_month_6_long", "June", "June"), 

613 7: translate("const_month_7_long", "July", "July"), 

614 8: translate("const_month_8_long", "August", "August"), 

615 9: translate("const_month_9_long", "September", "September"), 

616 10: translate("const_month_10_long", "October", "October"), 

617 11: translate("const_month_11_long", "November", "November"), 

618 12: translate("const_month_12_long", "December", "December"), 

619} 

620 

621 

622def localizedStrfTime(datetimeObj: datetime.datetime, format: str) -> str: 

623 """ 

624 Provides correct localized names for directives like %a which don't get translated on GAE properly as we can't 

625 set the locale (for each request). 

626 This currently replaces %a, %A, %b, %B, %c, %x and %X. 

627 

628 :param datetimeObj: Datetime-instance to call strftime on 

629 :param format: String containing the Format to apply. 

630 :returns: Date and time formatted according to format with correct localization 

631 """ 

632 if "%c" in format: 

633 format = format.replace("%c", str(localizedDateTime)) 

634 if "%x" in format: 

635 format = format.replace("%x", str(localizedDate)) 

636 if "%X" in format: 

637 format = format.replace("%X", str(localizedTime)) 

638 if "%a" in format: 

639 format = format.replace("%a", str(localizedAbbrevDayNames[int(datetimeObj.strftime("%w"))])) 

640 if "%A" in format: 

641 format = format.replace("%A", str(localizedDayNames[int(datetimeObj.strftime("%w"))])) 

642 if "%b" in format: 

643 format = format.replace("%b", str(localizedAbbrevMonthNames[int(datetimeObj.strftime("%m"))])) 

644 if "%B" in format: 

645 format = format.replace("%B", str(localizedMonthNames[int(datetimeObj.strftime("%m"))])) 

646 return datetimeObj.strftime(format)