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.
- cherrypy_foundation/__init__.py +0 -0
- cherrypy_foundation/components/ColorModes.jinja +70 -0
- cherrypy_foundation/components/Datatable.css +47 -0
- cherrypy_foundation/components/Datatable.jinja +63 -0
- cherrypy_foundation/components/Datatable.js +358 -0
- cherrypy_foundation/components/Field.css +10 -0
- cherrypy_foundation/components/Field.jinja +66 -0
- cherrypy_foundation/components/Field.js +56 -0
- cherrypy_foundation/components/Fields.jinja +4 -0
- cherrypy_foundation/components/Flash.jinja +13 -0
- cherrypy_foundation/components/Icon.jinja +3 -0
- cherrypy_foundation/components/LocaleSelection.jinja +13 -0
- cherrypy_foundation/components/LocaleSelection.js +26 -0
- cherrypy_foundation/components/SideBySideMultiSelect.css +25 -0
- cherrypy_foundation/components/SideBySideMultiSelect.jinja +9 -0
- cherrypy_foundation/components/SideBySideMultiSelect.js +9 -0
- cherrypy_foundation/components/Typeahead.css +55 -0
- cherrypy_foundation/components/Typeahead.jinja +106 -0
- cherrypy_foundation/components/Typeahead.js +8 -0
- cherrypy_foundation/components/__init__.py +51 -0
- cherrypy_foundation/components/tests/__init__.py +0 -0
- cherrypy_foundation/components/tests/test_static.py +90 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css +9262 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css.map +95 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css +6 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js +4846 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js.map +1 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/color-modes.js +80 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.css +849 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_both.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.js +14073 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.css +556 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.js +1700 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.min.js +8 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.js +2944 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.css +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.js +1202 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.js +11577 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.min.js +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.css +194 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.js +1861 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.js +75023 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.min.js +3 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/vfs_fonts.js +6 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.css +53 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.js +485 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.min.js +4 -0
- cherrypy_foundation/components/vendor/jquery/jquery.min.js +2 -0
- cherrypy_foundation/components/vendor/multi/LICENSE +7 -0
- cherrypy_foundation/components/vendor/multi/README.md +109 -0
- cherrypy_foundation/components/vendor/multi/multi.css +95 -0
- cherrypy_foundation/components/vendor/multi/multi.js +328 -0
- cherrypy_foundation/components/vendor/popper/popper.js +1825 -0
- cherrypy_foundation/components/vendor/popper/popper.min.js +6 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.css +1 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.js +10 -0
- cherrypy_foundation/error_page.py +94 -0
- cherrypy_foundation/flash.py +50 -0
- cherrypy_foundation/form.py +119 -0
- cherrypy_foundation/logging.py +103 -0
- cherrypy_foundation/passwd.py +65 -0
- cherrypy_foundation/plugins/__init__.py +0 -0
- cherrypy_foundation/plugins/db.py +286 -0
- cherrypy_foundation/plugins/ldap.py +257 -0
- cherrypy_foundation/plugins/restapi.py +74 -0
- cherrypy_foundation/plugins/scheduler.py +287 -0
- cherrypy_foundation/plugins/smtp.py +223 -0
- cherrypy_foundation/plugins/tests/__init__.py +0 -0
- cherrypy_foundation/plugins/tests/test_db.py +118 -0
- cherrypy_foundation/plugins/tests/test_ldap.py +451 -0
- cherrypy_foundation/plugins/tests/test_scheduler.py +100 -0
- cherrypy_foundation/plugins/tests/test_scheduler_db.py +107 -0
- cherrypy_foundation/plugins/tests/test_smtp.py +140 -0
- cherrypy_foundation/sessions.py +93 -0
- cherrypy_foundation/tests/__init__.py +72 -0
- cherrypy_foundation/tests/templates/test_flash.html +9 -0
- cherrypy_foundation/tests/templates/test_form.html +16 -0
- cherrypy_foundation/tests/templates/test_url.html +15 -0
- cherrypy_foundation/tests/test_error_page.py +78 -0
- cherrypy_foundation/tests/test_flash.py +61 -0
- cherrypy_foundation/tests/test_form.py +148 -0
- cherrypy_foundation/tests/test_logging.py +78 -0
- cherrypy_foundation/tests/test_passwd.py +51 -0
- cherrypy_foundation/tests/test_sessions.py +89 -0
- cherrypy_foundation/tests/test_url.py +161 -0
- cherrypy_foundation/tools/__init__.py +0 -0
- cherrypy_foundation/tools/auth.py +263 -0
- cherrypy_foundation/tools/auth_mfa.py +249 -0
- cherrypy_foundation/tools/i18n.py +529 -0
- cherrypy_foundation/tools/jinja2.py +158 -0
- cherrypy_foundation/tools/ratelimit.py +265 -0
- cherrypy_foundation/tools/secure_headers.py +119 -0
- cherrypy_foundation/tools/sessions_timeout.py +167 -0
- cherrypy_foundation/tools/tests/__init__.py +0 -0
- cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/messages.pot +2 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +9 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
- cherrypy_foundation/tools/tests/test_auth.py +110 -0
- cherrypy_foundation/tools/tests/test_auth_mfa.py +369 -0
- cherrypy_foundation/tools/tests/test_i18n.py +247 -0
- cherrypy_foundation/tools/tests/test_jinja2.py +153 -0
- cherrypy_foundation/tools/tests/test_ratelimit.py +109 -0
- cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
- cherrypy_foundation/url.py +66 -0
- cherrypy_foundation/widgets.py +48 -0
- cherrypy_foundation-1.0.0.dist-info/METADATA +71 -0
- cherrypy_foundation-1.0.0.dist-info/RECORD +136 -0
- cherrypy_foundation-1.0.0.dist-info/WHEEL +5 -0
- cherrypy_foundation-1.0.0.dist-info/licenses/LICENSE.md +674 -0
- 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()
|