cherrypy-foundation 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. cherrypy_foundation/__init__.py +0 -0
  2. cherrypy_foundation/components/ColorModes.jinja +70 -0
  3. cherrypy_foundation/components/Datatable.css +47 -0
  4. cherrypy_foundation/components/Datatable.jinja +63 -0
  5. cherrypy_foundation/components/Datatable.js +358 -0
  6. cherrypy_foundation/components/Field.css +10 -0
  7. cherrypy_foundation/components/Field.jinja +66 -0
  8. cherrypy_foundation/components/Field.js +56 -0
  9. cherrypy_foundation/components/Fields.jinja +4 -0
  10. cherrypy_foundation/components/Flash.jinja +13 -0
  11. cherrypy_foundation/components/Icon.jinja +3 -0
  12. cherrypy_foundation/components/LocaleSelection.jinja +13 -0
  13. cherrypy_foundation/components/LocaleSelection.js +26 -0
  14. cherrypy_foundation/components/SideBySideMultiSelect.css +25 -0
  15. cherrypy_foundation/components/SideBySideMultiSelect.jinja +9 -0
  16. cherrypy_foundation/components/SideBySideMultiSelect.js +9 -0
  17. cherrypy_foundation/components/Typeahead.css +55 -0
  18. cherrypy_foundation/components/Typeahead.jinja +106 -0
  19. cherrypy_foundation/components/Typeahead.js +8 -0
  20. cherrypy_foundation/components/__init__.py +51 -0
  21. cherrypy_foundation/components/tests/__init__.py +0 -0
  22. cherrypy_foundation/components/tests/test_static.py +90 -0
  23. cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
  24. cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
  25. cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  26. cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  27. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css +9262 -0
  28. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css.map +95 -0
  29. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css +6 -0
  30. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css.map +7 -0
  31. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js +4846 -0
  32. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js.map +1 -0
  33. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js +7 -0
  34. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js.map +7 -0
  35. cherrypy_foundation/components/vendor/bootstrap5/js/color-modes.js +80 -0
  36. cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.css +849 -0
  37. cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.min.css +1 -0
  38. cherrypy_foundation/components/vendor/datatables/images/sort_asc.png +0 -0
  39. cherrypy_foundation/components/vendor/datatables/images/sort_asc_disabled.png +0 -0
  40. cherrypy_foundation/components/vendor/datatables/images/sort_both.png +0 -0
  41. cherrypy_foundation/components/vendor/datatables/images/sort_desc.png +0 -0
  42. cherrypy_foundation/components/vendor/datatables/images/sort_desc_disabled.png +0 -0
  43. cherrypy_foundation/components/vendor/datatables/js/dataTables.js +14073 -0
  44. cherrypy_foundation/components/vendor/datatables/js/dataTables.min.js +4 -0
  45. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.css +556 -0
  46. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.min.css +1 -0
  47. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.js +1700 -0
  48. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.min.js +8 -0
  49. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.js +2944 -0
  50. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.min.js +4 -0
  51. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.css +13 -0
  52. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.min.css +1 -0
  53. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.js +1202 -0
  54. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.min.js +4 -0
  55. cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.js +11577 -0
  56. cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.min.js +13 -0
  57. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.css +194 -0
  58. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.min.css +1 -0
  59. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.js +1861 -0
  60. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.min.js +4 -0
  61. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.js +75023 -0
  62. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.min.js +3 -0
  63. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/vfs_fonts.js +6 -0
  64. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.css +53 -0
  65. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.min.css +1 -0
  66. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.js +485 -0
  67. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.min.js +4 -0
  68. cherrypy_foundation/components/vendor/jquery/jquery.min.js +2 -0
  69. cherrypy_foundation/components/vendor/multi/LICENSE +7 -0
  70. cherrypy_foundation/components/vendor/multi/README.md +109 -0
  71. cherrypy_foundation/components/vendor/multi/multi.css +95 -0
  72. cherrypy_foundation/components/vendor/multi/multi.js +328 -0
  73. cherrypy_foundation/components/vendor/popper/popper.js +1825 -0
  74. cherrypy_foundation/components/vendor/popper/popper.min.js +6 -0
  75. cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.css +1 -0
  76. cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.js +10 -0
  77. cherrypy_foundation/error_page.py +94 -0
  78. cherrypy_foundation/flash.py +50 -0
  79. cherrypy_foundation/form.py +119 -0
  80. cherrypy_foundation/logging.py +103 -0
  81. cherrypy_foundation/passwd.py +65 -0
  82. cherrypy_foundation/plugins/__init__.py +0 -0
  83. cherrypy_foundation/plugins/db.py +286 -0
  84. cherrypy_foundation/plugins/ldap.py +257 -0
  85. cherrypy_foundation/plugins/restapi.py +74 -0
  86. cherrypy_foundation/plugins/scheduler.py +287 -0
  87. cherrypy_foundation/plugins/smtp.py +223 -0
  88. cherrypy_foundation/plugins/tests/__init__.py +0 -0
  89. cherrypy_foundation/plugins/tests/test_db.py +118 -0
  90. cherrypy_foundation/plugins/tests/test_ldap.py +451 -0
  91. cherrypy_foundation/plugins/tests/test_scheduler.py +100 -0
  92. cherrypy_foundation/plugins/tests/test_scheduler_db.py +107 -0
  93. cherrypy_foundation/plugins/tests/test_smtp.py +140 -0
  94. cherrypy_foundation/sessions.py +93 -0
  95. cherrypy_foundation/tests/__init__.py +72 -0
  96. cherrypy_foundation/tests/templates/test_flash.html +9 -0
  97. cherrypy_foundation/tests/templates/test_form.html +16 -0
  98. cherrypy_foundation/tests/templates/test_url.html +15 -0
  99. cherrypy_foundation/tests/test_error_page.py +78 -0
  100. cherrypy_foundation/tests/test_flash.py +61 -0
  101. cherrypy_foundation/tests/test_form.py +148 -0
  102. cherrypy_foundation/tests/test_logging.py +78 -0
  103. cherrypy_foundation/tests/test_passwd.py +51 -0
  104. cherrypy_foundation/tests/test_sessions.py +89 -0
  105. cherrypy_foundation/tests/test_url.py +161 -0
  106. cherrypy_foundation/tools/__init__.py +0 -0
  107. cherrypy_foundation/tools/auth.py +263 -0
  108. cherrypy_foundation/tools/auth_mfa.py +249 -0
  109. cherrypy_foundation/tools/i18n.py +529 -0
  110. cherrypy_foundation/tools/jinja2.py +158 -0
  111. cherrypy_foundation/tools/ratelimit.py +265 -0
  112. cherrypy_foundation/tools/secure_headers.py +119 -0
  113. cherrypy_foundation/tools/sessions_timeout.py +167 -0
  114. cherrypy_foundation/tools/tests/__init__.py +0 -0
  115. cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
  116. cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
  117. cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po +15 -0
  118. cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo +0 -0
  119. cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po +15 -0
  120. cherrypy_foundation/tools/tests/locales/messages.pot +2 -0
  121. cherrypy_foundation/tools/tests/templates/test_jinja2.html +11 -0
  122. cherrypy_foundation/tools/tests/templates/test_jinjax.html +9 -0
  123. cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
  124. cherrypy_foundation/tools/tests/test_auth.py +110 -0
  125. cherrypy_foundation/tools/tests/test_auth_mfa.py +369 -0
  126. cherrypy_foundation/tools/tests/test_i18n.py +247 -0
  127. cherrypy_foundation/tools/tests/test_jinja2.py +153 -0
  128. cherrypy_foundation/tools/tests/test_ratelimit.py +109 -0
  129. cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
  130. cherrypy_foundation/url.py +66 -0
  131. cherrypy_foundation/widgets.py +48 -0
  132. cherrypy_foundation-1.0.0.dist-info/METADATA +71 -0
  133. cherrypy_foundation-1.0.0.dist-info/RECORD +136 -0
  134. cherrypy_foundation-1.0.0.dist-info/WHEEL +5 -0
  135. cherrypy_foundation-1.0.0.dist-info/licenses/LICENSE.md +674 -0
  136. cherrypy_foundation-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,529 @@
1
+ # Internationalisation tool for cherrypy
2
+ # Copyright (C) 2012-2026 IKUS Software
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """
18
+ Internationalization (i18n) and Localization (l10n) support for CherryPy.
19
+
20
+ This module provides a CherryPy tool that integrates GNU gettext and Babel
21
+ to handle language selection, translations, locale-aware formatting, and
22
+ timezone handling on a per-request basis.
23
+
24
+ The active language is resolved in the following order (highest priority first):
25
+
26
+ 1. Language explicitly set with ``with i18n.preferred_lang():``
27
+ 2. User-defined callback (``tools.i18n.func``)
28
+ 3. HTTP ``Accept-Language`` request header
29
+ 4. Default language configured via ``tools.i18n.default``
30
+
31
+ Translations are loaded using Babel and gettext, and the resolved locale is
32
+ available through ``i18n.get_translation()`` during request handling.
33
+
34
+ ---------------------------------------------------------------------------
35
+ Basic usage
36
+ ---------------------------------------------------------------------------
37
+
38
+ Within Python code, mark translatable strings using ``ugettext`` or
39
+ ``ungettext``:
40
+
41
+ from i18n import gettext as _, ngettext
42
+
43
+ class MyController:
44
+ @cherrypy.expose
45
+ def index(self):
46
+ locale = cherrypy.response.i18n.locale
47
+ s1 = _(u"Translatable string")
48
+ s2 = ngettext(
49
+ u"There is one item.",
50
+ u"There are multiple items.",
51
+ 2
52
+ )
53
+ return "<br />".join([s1, s2, locale.display_name])
54
+
55
+ ---------------------------------------------------------------------------
56
+ Lazy translations
57
+ ---------------------------------------------------------------------------
58
+
59
+ If code is executed before a CherryPy response object is available
60
+ (e.g. model definitions or module-level constants), use the ``*_lazy``
61
+ helpers. These defer translation until the value is actually rendered:
62
+
63
+ from i18n_tool import gettext_lazy
64
+
65
+ class Model:
66
+ name = gettext_lazy(u"Model name")
67
+
68
+ ---------------------------------------------------------------------------
69
+ Templates
70
+ ---------------------------------------------------------------------------
71
+
72
+ For template rendering, i18n integrate with jinja2.
73
+
74
+ {% trans %}Text to translate{% endtrans %}
75
+ {{ _('Text to translate') }}
76
+ {{ get_translation().gettext('Text to translate') }}
77
+ {{ get_translation().locale }}
78
+ {{ var | format_datetime(format='full') }}
79
+ {{ var | format_date(format='full') }}
80
+
81
+ ---------------------------------------------------------------------------
82
+ Configuration
83
+ ---------------------------------------------------------------------------
84
+
85
+ Example CherryPy configuration:
86
+
87
+ [/]
88
+ tools.i18n.on = True
89
+ tools.i18n.default = "en_US"
90
+ tools.i18n.mo_dir = "/path/to/i18n"
91
+ tools.i18n.domain = "myapp"
92
+
93
+ The ``mo_dir`` directory must contain subdirectories named after language
94
+ codes (e.g. ``en``, ``fr_CA``), each containing an ``LC_MESSAGES`` directory
95
+ with the compiled ``.mo`` file:
96
+
97
+ <mo_dir>/<language>/LC_MESSAGES/<domain>.mo
98
+
99
+ """
100
+
101
+ import logging
102
+ import os
103
+ from contextlib import contextmanager
104
+ from contextvars import ContextVar
105
+ from functools import lru_cache
106
+ from gettext import NullTranslations, translation
107
+
108
+ import cherrypy
109
+ import pytz
110
+ from babel import dates
111
+ from babel.core import Locale, get_global
112
+ from babel.support import LazyProxy, Translations
113
+
114
+ _preferred_lang = ContextVar('preferred_lang', default=())
115
+ _preferred_timezone = ContextVar('preferred_timezone', default=())
116
+ _translation = ContextVar('translation', default=None)
117
+ _tzinfo = ContextVar('tzinfo', default=None)
118
+
119
+
120
+ def _get_config(key, default=None):
121
+ """
122
+ Lookup configuration from request, if available. Fallback to global config.
123
+ """
124
+ if getattr(cherrypy, 'request') and getattr(cherrypy.request, 'config') and key in cherrypy.request.config:
125
+ return cherrypy.request.config[key]
126
+ return cherrypy.config.get(key, default)
127
+
128
+
129
+ @contextmanager
130
+ def preferred_lang(lang):
131
+ """
132
+ Re-define the preferred language to be used for translation within a given context.
133
+
134
+ with i18n.preferred_lang('fr'):
135
+ i18n.gettext('some string')
136
+ """
137
+ if not (lang is None or isinstance(lang, str)):
138
+ raise ValueError(lang)
139
+ try:
140
+ # Update preferred lang and clear translation.
141
+ if lang:
142
+ token_l = _preferred_lang.set((lang,))
143
+ else:
144
+ token_l = _preferred_lang.set(tuple())
145
+ token_t = _translation.set(None)
146
+ yield
147
+ finally:
148
+ # Restore previous value
149
+ _preferred_lang.reset(token_l)
150
+ _translation.reset(token_t)
151
+
152
+
153
+ @contextmanager
154
+ def preferred_timezone(timezone):
155
+ """
156
+ Re-define the preferred timezone to be used for date format within a given context.
157
+
158
+ with i18n.preferred_lang('America/Montreal'):
159
+ i18n.format_datetime(...)
160
+ """
161
+ if not (timezone is None or isinstance(timezone, str)):
162
+ raise ValueError(timezone)
163
+ try:
164
+ # Update preferred timezone and clear tzinfo.
165
+ if timezone:
166
+ token_t = _preferred_timezone.set((timezone,))
167
+ else:
168
+ token_t = _preferred_timezone.set(tuple())
169
+ token_z = _tzinfo.set(None)
170
+ yield
171
+ finally:
172
+ # Restore previous value
173
+ _preferred_timezone.reset(token_t)
174
+ _tzinfo.reset(token_z)
175
+
176
+
177
+ @lru_cache(maxsize=32)
178
+ def _search_translation(dirname, domain, *locales, sourcecode_lang='en'):
179
+ """
180
+ Loads the first existing translations for known locale.
181
+
182
+ :parameters:
183
+ langs : List
184
+ List of languages as returned by `parse_accept_language_header`.
185
+ dirname : String
186
+ A single directory of the translations (`tools.i18n.mo_dir`).
187
+ domain : String
188
+ Gettext domain of the catalog (`tools.i18n.domain`).
189
+
190
+ :returns: Translations, the corresponding Locale object.
191
+ """
192
+ if not isinstance(locales, (list, tuple)):
193
+ locales = tuple(locales)
194
+
195
+ # Loop on each locales to find the best matching translation.
196
+ for locale in locales:
197
+ try:
198
+ # Use `gettext.translation()` instead of `gettext.find()` to chain translation fr_CA -> fr -> src.
199
+ t = translation(domain=domain, localedir=dirname, languages=[locale], fallback=True, class_=Translations)
200
+ except Exception:
201
+ # If exception occur while loading the translation file. The file is probably corrupted.
202
+ cherrypy.log(
203
+ f'failed to load gettext catalog domain={domain} localedir={dirname} locale={locale}',
204
+ context='I18N',
205
+ severity=logging.WARNING,
206
+ traceback=True,
207
+ )
208
+ continue
209
+ if t.__class__ is NullTranslations and not locale.startswith(sourcecode_lang):
210
+ # Continue searching if translation is not found.
211
+ continue
212
+ t.locale = Locale.parse(locale)
213
+ return t
214
+ # If translation file not found, return default
215
+ t = NullTranslations()
216
+ t.locale = Locale(sourcecode_lang)
217
+ return t
218
+
219
+
220
+ def get_language_name(lang_code):
221
+ """
222
+ Translate the language code into it's language display name.
223
+ """
224
+ try:
225
+ locale = Locale.parse(lang_code)
226
+ except Exception:
227
+ return lang_code
228
+ translation = get_translation()
229
+ return locale.get_language_name(translation.locale)
230
+
231
+
232
+ def get_timezone():
233
+ """
234
+ Get the best timezone information for the current context.
235
+
236
+ The timezone returned is determined with the following priorities:
237
+
238
+ * value of preferred_timezone()
239
+ * tools.i18n.default_timezone
240
+ * default server time.
241
+
242
+ """
243
+ # When tzinfo is defined, use it
244
+ tzinfo = _tzinfo.get()
245
+ if tzinfo is not None:
246
+ return tzinfo
247
+ # Otherwise search for a valid timezone.
248
+ default = _get_config('tools.i18n.default_timezone')
249
+ preferred_timezone = _preferred_timezone.get()
250
+ if default and default not in preferred_timezone:
251
+ preferred_timezone = (
252
+ *preferred_timezone,
253
+ default,
254
+ )
255
+ for timezone in preferred_timezone:
256
+ try:
257
+ tzinfo = dates.get_timezone(timezone)
258
+ break
259
+ except Exception:
260
+ pass
261
+ # If we can't find a valid timezone using the default and preferred value, fall back to server timezone.
262
+ if tzinfo is None:
263
+ tzinfo = dates.get_timezone(None)
264
+ _tzinfo.set(tzinfo)
265
+ return tzinfo
266
+
267
+
268
+ def get_translation():
269
+ """
270
+ Get the best translation for the current context.
271
+ """
272
+ # When translation is defined, use it
273
+ translation = _translation.get()
274
+ if translation is not None:
275
+ return translation
276
+
277
+ # Otherwise, we need to search the translation.
278
+ # `preferred_lang` should always has a sane value within a cherrypy request because of hooks
279
+ # But we also need to support calls outside cherrypy.
280
+ sourcecode_lang = _get_config('tools.i18n.sourcecode_lang', 'en')
281
+ default = _get_config('tools.i18n.default')
282
+ preferred_lang = _preferred_lang.get()
283
+ if default and default not in preferred_lang:
284
+ preferred_lang = (
285
+ *preferred_lang,
286
+ default,
287
+ )
288
+ mo_dir = _get_config('tools.i18n.mo_dir')
289
+ domain = _get_config('tools.i18n.domain', 'messages')
290
+ translation = _search_translation(mo_dir, domain, *preferred_lang, sourcecode_lang=sourcecode_lang)
291
+ _translation.set(translation)
292
+ return translation
293
+
294
+
295
+ def list_available_locales():
296
+ """
297
+ Return a list of available translations.
298
+ """
299
+ return_value = []
300
+ # Always return the source code locale.
301
+ sourcecode_lang = _get_config('tools.i18n.sourcecode_lang', 'en')
302
+ return_value.append(Locale.parse(sourcecode_lang))
303
+ # Then scan language directory for more translation.
304
+ mo_dir = _get_config('tools.i18n.mo_dir')
305
+ domain = _get_config('tools.i18n.domain', 'messages')
306
+ if not mo_dir:
307
+ return
308
+ for lang in os.listdir(mo_dir):
309
+ if os.path.exists(os.path.join(mo_dir, lang, 'LC_MESSAGES', f'{domain}.mo')):
310
+ try:
311
+ return_value.append(Locale.parse(lang))
312
+ except Exception:
313
+ continue
314
+ return return_value
315
+
316
+
317
+ def list_available_timezones():
318
+ """
319
+ Return list of available timezone.
320
+ """
321
+ # Babel only support a narrow list of timezone.
322
+ babel_timezone = get_global('zone_territories').keys()
323
+ return [t for t in pytz.all_timezones if t in babel_timezone]
324
+
325
+
326
+ # Public translation functions
327
+ def gettext(message):
328
+ """Standard translation function. You can use it in all your exposed
329
+ methods and everywhere where the response object is available.
330
+
331
+ :parameters:
332
+ message : Unicode
333
+ The message to translate.
334
+
335
+ :returns: The translated message.
336
+ :rtype: Unicode
337
+ """
338
+ return get_translation().gettext(message)
339
+
340
+
341
+ ugettext = gettext
342
+
343
+
344
+ def ngettext(singular, plural, num):
345
+ """Like ugettext, but considers plural forms.
346
+
347
+ :parameters:
348
+ singular : Unicode
349
+ The message to translate in singular form.
350
+ plural : Unicode
351
+ The message to translate in plural form.
352
+ num : Integer
353
+ Number to apply the plural formula on. If num is 1 or no
354
+ translation is found, singular is returned.
355
+
356
+ :returns: The translated message as singular or plural.
357
+ :rtype: Unicode
358
+ """
359
+ return get_translation().ngettext(singular, plural, num)
360
+
361
+
362
+ ungettext = ngettext
363
+
364
+
365
+ def gettext_lazy(message):
366
+ """Like gettext, but lazy.
367
+
368
+ :returns: A proxy for the translation object.
369
+ :rtype: LazyProxy
370
+ """
371
+
372
+ def func():
373
+ return get_translation().gettext(message)
374
+
375
+ return LazyProxy(func, enable_cache=False)
376
+
377
+
378
+ def format_datetime(datetime=None, format='medium', tzinfo=None):
379
+ """
380
+ Wrapper around babel format_datetime to use current locale and current timezone.
381
+ """
382
+ return dates.format_datetime(
383
+ datetime=datetime,
384
+ format=format,
385
+ locale=get_translation().locale,
386
+ tzinfo=tzinfo or get_timezone(),
387
+ )
388
+
389
+
390
+ def format_date(datetime=None, format='medium', tzinfo=None):
391
+ """
392
+ Wrapper around babel format_date to provide a default locale.
393
+ """
394
+ # To enforce the timezone and locale, make use of format_datetime for dates.
395
+ return dates.format_datetime(
396
+ datetime=datetime,
397
+ format=dates.get_date_format(format),
398
+ locale=get_translation().locale,
399
+ tzinfo=tzinfo or get_timezone(),
400
+ )
401
+
402
+
403
+ def get_timezone_name(tzinfo, width='long'):
404
+ return dates.get_timezone_name(tzinfo, width=width, locale=get_translation().locale)
405
+
406
+
407
+ def _load_default(mo_dir, domain, default, **kwargs):
408
+ """
409
+ Initialize the language using the default value from the configuration.
410
+ """
411
+ # Clear current translation
412
+ _preferred_lang.set(tuple())
413
+ _preferred_timezone.set(tuple())
414
+ # Clear current translation
415
+ _translation.set(None)
416
+ # Clear current timezone
417
+ _tzinfo.set(None)
418
+
419
+
420
+ def _load_accept_language(**kwargs):
421
+ """
422
+ When running within a request, load the preferred language from Accept-Language header.
423
+ """
424
+ if cherrypy.request.headers.elements('Accept-Language'):
425
+ # Sort language by quality
426
+ languages = sorted(cherrypy.request.headers.elements('Accept-Language'), key=lambda x: x.qvalue, reverse=True)
427
+ _preferred_lang.set(tuple(lang.value.replace('-', '_') for lang in languages))
428
+ # Clear current translation
429
+ _translation.set(None)
430
+
431
+
432
+ def _load_cookie_language(**kwargs):
433
+ """
434
+ Load preferred language from a request cookie.
435
+
436
+ Expected cookie value formats:
437
+ - en
438
+ - en_US
439
+ - en-US
440
+ """
441
+ # Skip this step if cookie name is not defined.
442
+ cookie_name = _get_config('tools.i18n.cookie_name')
443
+ if not cookie_name:
444
+ return
445
+
446
+ # Check if value defined in cookie.
447
+ cookie = cherrypy.request.cookie
448
+ if cookie_name not in cookie:
449
+ return
450
+
451
+ try:
452
+ value = cookie[cookie_name].value.replace('-', '_')
453
+ except Exception:
454
+ return
455
+
456
+ # Set preferred language and clear cached translation
457
+ _preferred_lang.set((value,))
458
+ _translation.set(None)
459
+
460
+
461
+ def _load_func_language(**kwargs):
462
+ """
463
+ When running a request where a current user is found, load preferred language from user preferences.
464
+ """
465
+ func = _get_config('tools.i18n.lang', default=_get_config('tools.i18n.func'))
466
+ if not func:
467
+ return
468
+ try:
469
+ lang = func()
470
+ except Exception:
471
+ return
472
+ if not lang:
473
+ return
474
+ # Add custom lang to preferred_lang
475
+ _preferred_lang.set((lang,))
476
+ # Clear current translation
477
+ _translation.set(None)
478
+
479
+
480
+ def _load_func_tzinfo(**kwargs):
481
+ """
482
+ When running a request, load the preferred timezone information from user preferences.
483
+ """
484
+ func = _get_config('tools.i18n.tzinfo')
485
+ if not func:
486
+ return
487
+ try:
488
+ tzinfo = func()
489
+ except Exception:
490
+ return
491
+ if not tzinfo:
492
+ return
493
+ # Add custom lang to preferred_lang
494
+ _preferred_timezone.set((tzinfo,))
495
+ # Clear current translation
496
+ _tzinfo.set(None)
497
+
498
+
499
+ def _set_content_language(**kwargs):
500
+ """
501
+ Sets the Content-Language response header (if not already set) to the
502
+ language of `cherrypy.response.i18n.locale`.
503
+ """
504
+ if 'Content-Language' not in cherrypy.response.headers:
505
+ # Only define the content language if the handler uses i18n module.
506
+ translation = _translation.get()
507
+ if translation:
508
+ locale = translation.locale
509
+ language_tag = f"{locale.language}-{locale.territory}" if locale.territory else locale.language
510
+ cherrypy.response.headers['Content-Language'] = language_tag
511
+
512
+
513
+ class I18nTool(cherrypy.Tool):
514
+ """Tool to integrate babel translations in CherryPy."""
515
+
516
+ def __init__(self):
517
+ super().__init__('before_handler', _load_default, 'i18n')
518
+
519
+ def _setup(self):
520
+ cherrypy.Tool._setup(self)
521
+ # Attach additional hooks as different priority to update preferred lang with more accurate preferences.
522
+ cherrypy.request.hooks.attach('before_handler', _load_accept_language, priority=60)
523
+ cherrypy.request.hooks.attach('before_handler', _load_cookie_language, priority=70)
524
+ cherrypy.request.hooks.attach('before_handler', _load_func_language, priority=75)
525
+ cherrypy.request.hooks.attach('before_handler', _load_func_tzinfo, priority=75)
526
+ cherrypy.request.hooks.attach('before_finalize', _set_content_language)
527
+
528
+
529
+ cherrypy.tools.i18n = I18nTool()
@@ -0,0 +1,158 @@
1
+ # Jinja2 tools for cherrypy
2
+ # Copyright (C) 2021-2026 IKUS Software
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import importlib
18
+ import logging
19
+ import time
20
+
21
+ import cherrypy
22
+ import jinja2
23
+
24
+ # Sentinel value
25
+ _UNDEFINED = object()
26
+
27
+ # Capture epoch time to invalidate cache of static file.
28
+ _cache_invalidate = int(time.time())
29
+
30
+
31
+ class Jinja2Tool(cherrypy.Tool):
32
+ """
33
+ Jinja2 Tool for CherryPy.
34
+ """
35
+
36
+ def __init__(self):
37
+ super().__init__('before_handler', self._wrap_handler, 'jinja2', priority=30)
38
+
39
+ def _finalize_assets(self, catalog, html):
40
+ """
41
+ Replace the placeholder token in the rendered HTML with the fully
42
+ formatted asset tags, then reset asset state for the next render.
43
+ """
44
+ assets_html = catalog._format_collected_assets()
45
+ assets_html = str(assets_html).replace('<script type="module" ', '<script ')
46
+ catalog._emit_assets_later = False
47
+ catalog.collected_css = []
48
+ catalog.collected_js = []
49
+ return str(html).replace(catalog._assets_placeholder, assets_html)
50
+
51
+ def _wrap_handler(self, env, template, extra_processor=None, debug=False):
52
+
53
+ def wrap(*args, **kwargs):
54
+ # Call original handler
55
+ context = self.oldhandler(*args, **kwargs)
56
+ # Render template.
57
+ return self.render_request(env=env, template=template, context=context, extra_processor=extra_processor)
58
+
59
+ request = cherrypy.serving.request
60
+ if request.handler is not None:
61
+ # Replace request.handler with self
62
+ if debug:
63
+ cherrypy.log('replacing request handler', context='TOOLS.JINJA2', severity=logging.DEBUG)
64
+ self.oldhandler = request.handler
65
+ request.handler = wrap
66
+
67
+ def create_env(self, package_name, filters={}, globals={}, tests={}):
68
+ """
69
+ Utility function used to create a default Jinja2 environment with good default.
70
+ """
71
+ env = jinja2.Environment(
72
+ loader=jinja2.PackageLoader(package_name),
73
+ auto_reload=True,
74
+ autoescape=True,
75
+ trim_blocks=True,
76
+ lstrip_blocks=True,
77
+ )
78
+ # Param to bust caches
79
+ env.globals['cache_invalidate'] = _cache_invalidate
80
+
81
+ # Enable translation if available
82
+ if hasattr(cherrypy.tools, 'i18n'):
83
+ from .i18n import (
84
+ format_date,
85
+ format_datetime,
86
+ get_language_name,
87
+ get_timezone_name,
88
+ get_translation,
89
+ list_available_locales,
90
+ list_available_timezones,
91
+ ugettext,
92
+ ungettext,
93
+ )
94
+
95
+ env.add_extension('jinja2.ext.i18n')
96
+ env.install_gettext_callables(ugettext, ungettext, newstyle=True)
97
+ env.filters['format_date'] = format_date
98
+ env.filters['format_datetime'] = format_datetime
99
+ env.globals['get_language_name'] = get_language_name
100
+ env.globals['get_translation'] = get_translation
101
+ env.globals['list_available_locales'] = list_available_locales
102
+ env.globals['list_available_timezones'] = list_available_timezones
103
+ env.globals['get_timezone_name'] = get_timezone_name
104
+
105
+ # Update globals, filters and tests
106
+ env.globals.update(globals)
107
+ env.filters.update(filters)
108
+ env.tests.update(tests)
109
+
110
+ # Enable JinjaX if available
111
+ try:
112
+ import jinjax
113
+
114
+ env.add_extension(jinjax.JinjaX)
115
+ catalog = jinjax.Catalog(jinja_env=env, root_url="/static/components/")
116
+ catalog.add_folder(importlib.resources.files(package_name) / 'components')
117
+ catalog.add_folder(importlib.resources.files(__package__) / '..' / 'components')
118
+ except ImportError:
119
+ pass
120
+
121
+ return env
122
+
123
+ def render_request(self, template, context={}, env=_UNDEFINED, extra_processor=_UNDEFINED):
124
+ """
125
+ Render template for a given cherrypy request.
126
+ """
127
+ request = cherrypy.serving.request
128
+ if env is _UNDEFINED:
129
+ env = request.config.get('tools.jinja2.env')
130
+ if extra_processor is _UNDEFINED:
131
+ extra_processor = request.config.get('tools.jinja2.extra_processor')
132
+ # Execute extra processor if defined.
133
+ new_context = {}
134
+ if extra_processor:
135
+ new_context.update(extra_processor())
136
+ new_context.update(context)
137
+ # Render templates
138
+ return self.render(env=env, template=template, context=new_context)
139
+
140
+ def render(self, env, template, context={}):
141
+ """
142
+ Lower level function used to render a template using the given jinja2 environment, template(s) and variable context.
143
+ """
144
+ # Get the right templates
145
+ if isinstance(template, (list, tuple)):
146
+ names = [t.format(**context) for t in template]
147
+ tmpl = env.select_template(names)
148
+ else:
149
+ tmpl = env.get_template(template)
150
+ out = tmpl.render(context)
151
+
152
+ # With JinjaX > 0.60 render explicitly here.
153
+ if 'catalog' in env.globals and getattr(env.globals['catalog'], '_emit_assets_later', False):
154
+ return self._finalize_assets(catalog=env.globals['catalog'], html=out)
155
+ return out
156
+
157
+
158
+ cherrypy.tools.jinja2 = Jinja2Tool()