cherrypy-foundation 1.0.0a12__py3-none-any.whl → 1.0.0a14__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/components/ColorModes.jinja +5 -4
- cherrypy_foundation/components/LocaleSelection.jinja +13 -0
- cherrypy_foundation/components/LocaleSelection.js +26 -0
- cherrypy_foundation/tests/__init__.py +72 -0
- cherrypy_foundation/tests/templates/test_flash.html +2 -1
- cherrypy_foundation/tests/templates/test_form.html +2 -1
- cherrypy_foundation/tests/templates/test_url.html +1 -1
- cherrypy_foundation/tools/i18n.py +231 -156
- cherrypy_foundation/tools/jinja2.py +14 -1
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +2 -1
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +3 -2
- cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
- cherrypy_foundation/tools/tests/test_i18n.py +34 -2
- cherrypy_foundation/tools/tests/test_jinja2.py +33 -3
- cherrypy_foundation/url.py +2 -3
- {cherrypy_foundation-1.0.0a12.dist-info → cherrypy_foundation-1.0.0a14.dist-info}/METADATA +1 -1
- {cherrypy_foundation-1.0.0a12.dist-info → cherrypy_foundation-1.0.0a14.dist-info}/RECORD +20 -20
- cherrypy_foundation/tools/tests/locales/en/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/en/LC_MESSAGES/messages.po +0 -15
- cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +0 -11
- {cherrypy_foundation-1.0.0a12.dist-info → cherrypy_foundation-1.0.0a14.dist-info}/WHEEL +0 -0
- {cherrypy_foundation-1.0.0a12.dist-info → cherrypy_foundation-1.0.0a14.dist-info}/licenses/LICENSE.md +0 -0
- {cherrypy_foundation-1.0.0a12.dist-info → cherrypy_foundation-1.0.0a14.dist-info}/top_level.txt +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
{# def header="Toggle theme", light_label="Light", dark_label="Dark", auto_label="Auto" #}
|
|
1
2
|
{#css vendor/bootstrap5/css/bootstrap.min.css #}
|
|
2
3
|
{#js vendor/popper/popper.min.js, vendor/bootstrap5/js/bootstrap.min.js, vendor/bootstrap5/js/color-modes.js #}
|
|
3
4
|
<svg class="d-none" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
</svg>
|
|
22
23
|
<li>
|
|
23
24
|
<span id="bd-theme" class="dropdown-item disabled">
|
|
24
|
-
<span id="bd-theme-text">{
|
|
25
|
+
<span id="bd-theme-text">{{ header }}</span>
|
|
25
26
|
<svg aria-hidden="true"
|
|
26
27
|
class="theme-icon-active visually-hidden"
|
|
27
28
|
width="16"
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
<use href="#sun-fill">
|
|
41
42
|
</use>
|
|
42
43
|
</svg>
|
|
43
|
-
{
|
|
44
|
+
{{ light_label }}
|
|
44
45
|
</button>
|
|
45
46
|
</li>
|
|
46
47
|
<li>
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
<use href="#moon-stars-fill">
|
|
53
54
|
</use>
|
|
54
55
|
</svg>
|
|
55
|
-
{
|
|
56
|
+
{{ dark_label }}
|
|
56
57
|
</button>
|
|
57
58
|
</li>
|
|
58
59
|
<li>
|
|
@@ -64,6 +65,6 @@
|
|
|
64
65
|
<use href="#circle-half">
|
|
65
66
|
</use>
|
|
66
67
|
</svg>
|
|
67
|
-
{
|
|
68
|
+
{{ auto_label }}
|
|
68
69
|
</button>
|
|
69
70
|
</li>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{# def header="Language selection" #}
|
|
2
|
+
<li>
|
|
3
|
+
<h6 class="dropdown-header">{{ header }}</h6>
|
|
4
|
+
</li>
|
|
5
|
+
{% set cur_locale = get_translation().locale %}
|
|
6
|
+
{% for locale in list_available_locales() %}
|
|
7
|
+
<li>
|
|
8
|
+
<button aria-pressed="{{ 'true' if cur_locale == locale else 'false' }}"
|
|
9
|
+
class="dropdown-item btn-locale{{ ' active' if cur_locale == locale else '' }}"
|
|
10
|
+
data-locale="{{ locale.language }}"
|
|
11
|
+
type="button">{{ locale.display_name.capitalize() }}</button>
|
|
12
|
+
</li>
|
|
13
|
+
{% endfor %}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Handle locale selection.
|
|
3
|
+
*/
|
|
4
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
5
|
+
const LOCALE_COOKIE_NAME = 'locale';
|
|
6
|
+
const ONE_YEAR = 60 * 60 * 24 * 365;
|
|
7
|
+
|
|
8
|
+
function setLocaleCookie(locale) {
|
|
9
|
+
document.cookie = `${LOCALE_COOKIE_NAME}=${locale}; path=/; max-age=${ONE_YEAR}; SameSite=Lax`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function onLanguageClick(event) {
|
|
13
|
+
const locale = event.target.dataset.locale;
|
|
14
|
+
if (!locale) return;
|
|
15
|
+
|
|
16
|
+
setLocaleCookie(locale);
|
|
17
|
+
|
|
18
|
+
// Reload page so backend can use the new language
|
|
19
|
+
window.location.reload();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
document.querySelectorAll('.btn-locale').forEach(btn => {
|
|
23
|
+
btn.addEventListener('click', onLanguageClick);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# CherryPy foundation
|
|
2
|
+
# Copyright (C) 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 os
|
|
18
|
+
import tempfile
|
|
19
|
+
import unittest
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
|
|
22
|
+
from selenium import webdriver
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SeleniumUnitTest:
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def _session_id(self):
|
|
29
|
+
if hasattr(self, 'cookies') and self.cookies:
|
|
30
|
+
for unused, value in self.cookies:
|
|
31
|
+
for part in value.split(';'):
|
|
32
|
+
key, unused, value = part.partition('=')
|
|
33
|
+
if key == 'session_id':
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def selenium(self, headless=True, implicitly_wait=3):
|
|
38
|
+
"""
|
|
39
|
+
Decorator to load selenium for a test.
|
|
40
|
+
"""
|
|
41
|
+
# Skip selenium test is display is not available.
|
|
42
|
+
if not os.environ.get('DISPLAY', False):
|
|
43
|
+
raise unittest.SkipTest("selenium require a display")
|
|
44
|
+
# Start selenium driver
|
|
45
|
+
options = webdriver.ChromeOptions()
|
|
46
|
+
if headless:
|
|
47
|
+
options.add_argument('--headless')
|
|
48
|
+
options.add_argument('--disable-gpu')
|
|
49
|
+
options.add_argument('--window-size=1280,800')
|
|
50
|
+
options.add_argument('--no-sandbox')
|
|
51
|
+
options.add_argument('--disable-dev-shm-usage')
|
|
52
|
+
options.add_argument('--lang=en-US')
|
|
53
|
+
driver = webdriver.Chrome(options=options)
|
|
54
|
+
try:
|
|
55
|
+
# If logged in, reuse the same session id.
|
|
56
|
+
if self._session_id:
|
|
57
|
+
driver.get(f'{self.baseurl}/login/')
|
|
58
|
+
driver.add_cookie({"name": "session_id", "value": self.session_id})
|
|
59
|
+
# Configure download folder
|
|
60
|
+
download = os.path.join(os.path.expanduser('~'), 'Downloads')
|
|
61
|
+
os.makedirs(download, exist_ok=True)
|
|
62
|
+
self._selenium_download_dir = tempfile.mkdtemp(dir=download, prefix='selenium-download-')
|
|
63
|
+
driver.execute_cdp_cmd(
|
|
64
|
+
'Page.setDownloadBehavior', {'behavior': 'allow', 'downloadPath': self._selenium_download_dir}
|
|
65
|
+
)
|
|
66
|
+
# Set default wait.
|
|
67
|
+
driver.implicitly_wait(implicitly_wait)
|
|
68
|
+
yield driver
|
|
69
|
+
finally:
|
|
70
|
+
# Code to release resource, e.g.:
|
|
71
|
+
driver.close()
|
|
72
|
+
driver = None
|
|
@@ -14,95 +14,107 @@
|
|
|
14
14
|
# You should have received a copy of the GNU General Public License
|
|
15
15
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
|
|
17
|
-
"""
|
|
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):
|
|
18
25
|
|
|
19
|
-
|
|
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``
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
3. HTTP Accept-Language headers
|
|
24
|
-
4. `tools.i18n.default` value
|
|
31
|
+
Translations are loaded using Babel and gettext, and the resolved locale is
|
|
32
|
+
available through ``i18n.get_translation()`` during request handling.
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
---------------------------------------------------------------------------
|
|
35
|
+
Basic usage
|
|
36
|
+
---------------------------------------------------------------------------
|
|
29
37
|
|
|
30
|
-
|
|
38
|
+
Within Python code, mark translatable strings using ``ugettext`` or
|
|
39
|
+
``ungettext``:
|
|
31
40
|
|
|
32
|
-
from i18n import
|
|
41
|
+
from i18n import gettext as _, ngettext
|
|
33
42
|
|
|
34
|
-
class MyController
|
|
43
|
+
class MyController:
|
|
35
44
|
@cherrypy.expose
|
|
36
45
|
def index(self):
|
|
37
46
|
locale = cherrypy.response.i18n.locale
|
|
38
|
-
s1 = _(u
|
|
39
|
-
s2 =
|
|
40
|
-
|
|
41
|
-
|
|
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])
|
|
42
54
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
hopefully the response object is available then).
|
|
55
|
+
---------------------------------------------------------------------------
|
|
56
|
+
Lazy translations
|
|
57
|
+
---------------------------------------------------------------------------
|
|
47
58
|
|
|
48
|
-
|
|
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:
|
|
49
62
|
|
|
50
|
-
from i18n_tool import
|
|
63
|
+
from i18n_tool import gettext_lazy
|
|
51
64
|
|
|
52
65
|
class Model:
|
|
53
|
-
|
|
54
|
-
name = ugettext_lazy(u'Name of the model')
|
|
66
|
+
name = gettext_lazy(u"Model name")
|
|
55
67
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
---------------------------------------------------------------------------
|
|
69
|
+
Templates
|
|
70
|
+
---------------------------------------------------------------------------
|
|
59
71
|
|
|
72
|
+
For template rendering, i18n integrate with jinja2.
|
|
60
73
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
tools.i18n.domain = Your gettext domain (e.g. application name)
|
|
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') }}
|
|
68
80
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
---------------------------------------------------------------------------
|
|
82
|
+
Configuration
|
|
83
|
+
---------------------------------------------------------------------------
|
|
72
84
|
|
|
73
|
-
Example
|
|
85
|
+
Example CherryPy configuration:
|
|
74
86
|
|
|
75
87
|
[/]
|
|
76
88
|
tools.i18n.on = True
|
|
77
|
-
tools.i18n.default =
|
|
78
|
-
tools.i18n.mo_dir =
|
|
79
|
-
tools.i18n.domain =
|
|
89
|
+
tools.i18n.default = "en_US"
|
|
90
|
+
tools.i18n.mo_dir = "/path/to/i18n"
|
|
91
|
+
tools.i18n.domain = "myapp"
|
|
80
92
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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:
|
|
84
96
|
|
|
85
|
-
|
|
97
|
+
<mo_dir>/<language>/LC_MESSAGES/<domain>.mo
|
|
86
98
|
|
|
87
|
-
:License: BSD
|
|
88
|
-
:Author: Thorsten Weimann <thorsten.weimann (at) gmx (dot) net>
|
|
89
|
-
:Date: 2010-02-08
|
|
90
99
|
"""
|
|
91
100
|
|
|
92
|
-
|
|
101
|
+
import logging
|
|
93
102
|
import os
|
|
94
|
-
import threading
|
|
95
103
|
from contextlib import contextmanager
|
|
104
|
+
from contextvars import ContextVar
|
|
96
105
|
from functools import lru_cache
|
|
106
|
+
from gettext import NullTranslations, translation
|
|
97
107
|
|
|
98
108
|
import cherrypy
|
|
99
109
|
import pytz
|
|
100
110
|
from babel import dates
|
|
101
111
|
from babel.core import Locale, get_global
|
|
102
|
-
from babel.support import LazyProxy,
|
|
112
|
+
from babel.support import LazyProxy, Translations
|
|
103
113
|
|
|
104
|
-
|
|
105
|
-
|
|
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)
|
|
106
118
|
|
|
107
119
|
|
|
108
120
|
def _get_config(key, default=None):
|
|
@@ -122,21 +134,20 @@ def preferred_lang(lang):
|
|
|
122
134
|
with i18n.preferred_lang('fr'):
|
|
123
135
|
i18n.gettext('some string')
|
|
124
136
|
"""
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
prev_trans = getattr(_current, 'translation', None)
|
|
137
|
+
if not (lang is None or isinstance(lang, str)):
|
|
138
|
+
raise ValueError(lang)
|
|
128
139
|
try:
|
|
129
|
-
# Update
|
|
140
|
+
# Update preferred lang and clear translation.
|
|
130
141
|
if lang:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
142
|
+
token_l = _preferred_lang.set((lang,))
|
|
143
|
+
else:
|
|
144
|
+
token_l = _preferred_lang.set(tuple())
|
|
145
|
+
token_t = _translation.set(None)
|
|
135
146
|
yield
|
|
136
147
|
finally:
|
|
137
148
|
# Restore previous value
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
_preferred_lang.reset(token_l)
|
|
150
|
+
_translation.reset(token_t)
|
|
140
151
|
|
|
141
152
|
|
|
142
153
|
@contextmanager
|
|
@@ -147,22 +158,24 @@ def preferred_timezone(timezone):
|
|
|
147
158
|
with i18n.preferred_lang('America/Montreal'):
|
|
148
159
|
i18n.format_datetime(...)
|
|
149
160
|
"""
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
prev_tzinfo = getattr(_current, 'tzinfo', None)
|
|
161
|
+
if not (timezone is None or isinstance(timezone, str)):
|
|
162
|
+
raise ValueError(timezone)
|
|
153
163
|
try:
|
|
154
|
-
# Update
|
|
155
|
-
|
|
156
|
-
|
|
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)
|
|
157
170
|
yield
|
|
158
171
|
finally:
|
|
159
172
|
# Restore previous value
|
|
160
|
-
|
|
161
|
-
|
|
173
|
+
_preferred_timezone.reset(token_t)
|
|
174
|
+
_tzinfo.reset(token_z)
|
|
162
175
|
|
|
163
176
|
|
|
164
|
-
@lru_cache(maxsize=
|
|
165
|
-
def _search_translation(dirname, domain, *locales):
|
|
177
|
+
@lru_cache(maxsize=32)
|
|
178
|
+
def _search_translation(dirname, domain, *locales, sourcecode_lang='en'):
|
|
166
179
|
"""
|
|
167
180
|
Loads the first existing translations for known locale.
|
|
168
181
|
|
|
@@ -170,29 +183,37 @@ def _search_translation(dirname, domain, *locales):
|
|
|
170
183
|
langs : List
|
|
171
184
|
List of languages as returned by `parse_accept_language_header`.
|
|
172
185
|
dirname : String
|
|
173
|
-
|
|
174
|
-
Might be a list of directories.
|
|
186
|
+
A single directory of the translations (`tools.i18n.mo_dir`).
|
|
175
187
|
domain : String
|
|
176
|
-
Gettext domain of the catalog (`tools.
|
|
188
|
+
Gettext domain of the catalog (`tools.i18n.domain`).
|
|
177
189
|
|
|
178
190
|
:returns: Translations, the corresponding Locale object.
|
|
179
191
|
"""
|
|
180
192
|
if not isinstance(locales, (list, tuple)):
|
|
181
|
-
locales =
|
|
182
|
-
|
|
183
|
-
#
|
|
184
|
-
if t.__class__ is NullTranslations:
|
|
185
|
-
return None
|
|
186
|
-
# Assign prefered local to this translation to know the current locale.
|
|
187
|
-
trans_locale = Locale.parse(t.files[0].split('/')[-3])
|
|
193
|
+
locales = tuple(locales)
|
|
194
|
+
|
|
195
|
+
# Loop on each locales to find the best matching translation.
|
|
188
196
|
for locale in locales:
|
|
189
197
|
try:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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)
|
|
196
217
|
return t
|
|
197
218
|
|
|
198
219
|
|
|
@@ -200,23 +221,37 @@ def get_language_name(lang_code):
|
|
|
200
221
|
"""
|
|
201
222
|
Translate the language code into it's language display name.
|
|
202
223
|
"""
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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)
|
|
206
230
|
|
|
207
231
|
|
|
208
232
|
def get_timezone():
|
|
209
233
|
"""
|
|
210
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
|
+
|
|
211
242
|
"""
|
|
212
243
|
# When tzinfo is defined, use it
|
|
213
|
-
tzinfo =
|
|
244
|
+
tzinfo = _tzinfo.get()
|
|
214
245
|
if tzinfo is not None:
|
|
215
246
|
return tzinfo
|
|
216
247
|
# Otherwise search for a valid timezone.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
+
)
|
|
220
255
|
for timezone in preferred_timezone:
|
|
221
256
|
try:
|
|
222
257
|
tzinfo = dates.get_timezone(timezone)
|
|
@@ -226,8 +261,8 @@ def get_timezone():
|
|
|
226
261
|
# If we can't find a valid timezone using the default and preferred value, fall back to server timezone.
|
|
227
262
|
if tzinfo is None:
|
|
228
263
|
tzinfo = dates.get_timezone(None)
|
|
229
|
-
|
|
230
|
-
return
|
|
264
|
+
_tzinfo.set(tzinfo)
|
|
265
|
+
return tzinfo
|
|
231
266
|
|
|
232
267
|
|
|
233
268
|
def get_translation():
|
|
@@ -235,37 +270,48 @@ def get_translation():
|
|
|
235
270
|
Get the best translation for the current context.
|
|
236
271
|
"""
|
|
237
272
|
# When translation is defined, use it
|
|
238
|
-
translation =
|
|
273
|
+
translation = _translation.get()
|
|
239
274
|
if translation is not None:
|
|
240
275
|
return translation
|
|
241
276
|
|
|
242
277
|
# Otherwise, we need to search the translation.
|
|
243
278
|
# `preferred_lang` should always has a sane value within a cherrypy request because of hooks
|
|
244
279
|
# But we also need to support calls outside cherrypy.
|
|
280
|
+
sourcecode_lang = _get_config('tools.i18n.sourcecode_lang', 'en')
|
|
245
281
|
default = _get_config('tools.i18n.default')
|
|
246
|
-
preferred_lang =
|
|
282
|
+
preferred_lang = _preferred_lang.get()
|
|
283
|
+
if default and default not in preferred_lang:
|
|
284
|
+
preferred_lang = (
|
|
285
|
+
*preferred_lang,
|
|
286
|
+
default,
|
|
287
|
+
)
|
|
247
288
|
mo_dir = _get_config('tools.i18n.mo_dir')
|
|
248
|
-
domain = _get_config('tools.i18n.domain')
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
trans.locale = Locale('en')
|
|
253
|
-
_current.translation = trans
|
|
254
|
-
return _current.translation
|
|
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
|
|
255
293
|
|
|
256
294
|
|
|
257
295
|
def list_available_locales():
|
|
258
296
|
"""
|
|
259
297
|
Return a list of available translations.
|
|
260
298
|
"""
|
|
261
|
-
|
|
262
|
-
|
|
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')
|
|
263
306
|
if not mo_dir:
|
|
264
307
|
return
|
|
265
308
|
for lang in os.listdir(mo_dir):
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
269
315
|
|
|
270
316
|
|
|
271
317
|
def list_available_timezones():
|
|
@@ -278,7 +324,7 @@ def list_available_timezones():
|
|
|
278
324
|
|
|
279
325
|
|
|
280
326
|
# Public translation functions
|
|
281
|
-
def
|
|
327
|
+
def gettext(message):
|
|
282
328
|
"""Standard translation function. You can use it in all your exposed
|
|
283
329
|
methods and everywhere where the response object is available.
|
|
284
330
|
|
|
@@ -289,13 +335,13 @@ def ugettext(message):
|
|
|
289
335
|
:returns: The translated message.
|
|
290
336
|
:rtype: Unicode
|
|
291
337
|
"""
|
|
292
|
-
return get_translation().
|
|
338
|
+
return get_translation().gettext(message)
|
|
293
339
|
|
|
294
340
|
|
|
295
|
-
|
|
341
|
+
ugettext = gettext
|
|
296
342
|
|
|
297
343
|
|
|
298
|
-
def
|
|
344
|
+
def ngettext(singular, plural, num):
|
|
299
345
|
"""Like ugettext, but considers plural forms.
|
|
300
346
|
|
|
301
347
|
:parameters:
|
|
@@ -310,34 +356,29 @@ def ungettext(singular, plural, num):
|
|
|
310
356
|
:returns: The translated message as singular or plural.
|
|
311
357
|
:rtype: Unicode
|
|
312
358
|
"""
|
|
313
|
-
return get_translation().
|
|
359
|
+
return get_translation().ngettext(singular, plural, num)
|
|
314
360
|
|
|
315
361
|
|
|
316
|
-
|
|
362
|
+
ungettext = ngettext
|
|
317
363
|
|
|
318
364
|
|
|
319
365
|
def gettext_lazy(message):
|
|
320
|
-
"""Like
|
|
366
|
+
"""Like gettext, but lazy.
|
|
321
367
|
|
|
322
368
|
:returns: A proxy for the translation object.
|
|
323
369
|
:rtype: LazyProxy
|
|
324
370
|
"""
|
|
325
371
|
|
|
326
372
|
def func():
|
|
327
|
-
return get_translation().
|
|
373
|
+
return get_translation().gettext(message)
|
|
328
374
|
|
|
329
375
|
return LazyProxy(func, enable_cache=False)
|
|
330
376
|
|
|
331
377
|
|
|
332
378
|
def format_datetime(datetime=None, format='medium', tzinfo=None):
|
|
333
379
|
"""
|
|
334
|
-
|
|
335
|
-
The timezone used to format the date is determine with the following priorities:
|
|
336
|
-
* value of tzinfo
|
|
337
|
-
* value of get_timezone()
|
|
338
|
-
* default server time.
|
|
380
|
+
Wrapper around babel format_datetime to use current locale and current timezone.
|
|
339
381
|
"""
|
|
340
|
-
# When formating date or english, let use en_GB as it's to most complete translation.
|
|
341
382
|
return dates.format_datetime(
|
|
342
383
|
datetime=datetime,
|
|
343
384
|
format=format,
|
|
@@ -348,14 +389,19 @@ def format_datetime(datetime=None, format='medium', tzinfo=None):
|
|
|
348
389
|
|
|
349
390
|
def format_date(datetime=None, format='medium', tzinfo=None):
|
|
350
391
|
"""
|
|
351
|
-
|
|
392
|
+
Wrapper around babel format_date to provide a default locale.
|
|
352
393
|
"""
|
|
353
|
-
|
|
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
|
+
)
|
|
354
401
|
|
|
355
402
|
|
|
356
|
-
def get_timezone_name(tzinfo):
|
|
357
|
-
|
|
358
|
-
return dates.get_timezone_name(tzinfo, width='long', locale=locale)
|
|
403
|
+
def get_timezone_name(tzinfo, width='long'):
|
|
404
|
+
return dates.get_timezone_name(tzinfo, width=width, locale=get_translation().locale)
|
|
359
405
|
|
|
360
406
|
|
|
361
407
|
def _load_default(mo_dir, domain, default, **kwargs):
|
|
@@ -363,35 +409,60 @@ def _load_default(mo_dir, domain, default, **kwargs):
|
|
|
363
409
|
Initialize the language using the default value from the configuration.
|
|
364
410
|
"""
|
|
365
411
|
# Clear current translation
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
cherrypy.request._i18n_lang_func = kwargs.get('lang', kwargs.get('func', False))
|
|
369
|
-
cherrypy.request._i18n_tzinfo_func = kwargs.get('tzinfo', False)
|
|
412
|
+
_preferred_lang.set(tuple())
|
|
413
|
+
_preferred_timezone.set(tuple())
|
|
370
414
|
# Clear current translation
|
|
371
|
-
|
|
415
|
+
_translation.set(None)
|
|
372
416
|
# Clear current timezone
|
|
373
|
-
|
|
417
|
+
_tzinfo.set(None)
|
|
374
418
|
|
|
375
419
|
|
|
376
420
|
def _load_accept_language(**kwargs):
|
|
377
421
|
"""
|
|
378
|
-
When running within a request, load the
|
|
422
|
+
When running within a request, load the preferred language from Accept-Language header.
|
|
379
423
|
"""
|
|
380
|
-
|
|
381
424
|
if cherrypy.request.headers.elements('Accept-Language'):
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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)
|
|
388
459
|
|
|
389
460
|
|
|
390
461
|
def _load_func_language(**kwargs):
|
|
391
462
|
"""
|
|
392
|
-
When running a request where a current user is found, load
|
|
463
|
+
When running a request where a current user is found, load preferred language from user preferences.
|
|
393
464
|
"""
|
|
394
|
-
func =
|
|
465
|
+
func = _get_config('tools.i18n.lang', default=_get_config('tools.i18n.func'))
|
|
395
466
|
if not func:
|
|
396
467
|
return
|
|
397
468
|
try:
|
|
@@ -401,16 +472,16 @@ def _load_func_language(**kwargs):
|
|
|
401
472
|
if not lang:
|
|
402
473
|
return
|
|
403
474
|
# Add custom lang to preferred_lang
|
|
404
|
-
|
|
475
|
+
_preferred_lang.set((lang,))
|
|
405
476
|
# Clear current translation
|
|
406
|
-
|
|
477
|
+
_translation.set(None)
|
|
407
478
|
|
|
408
479
|
|
|
409
480
|
def _load_func_tzinfo(**kwargs):
|
|
410
481
|
"""
|
|
411
|
-
When running a request, load the
|
|
482
|
+
When running a request, load the preferred timezone information from user preferences.
|
|
412
483
|
"""
|
|
413
|
-
func =
|
|
484
|
+
func = _get_config('tools.i18n.tzinfo')
|
|
414
485
|
if not func:
|
|
415
486
|
return
|
|
416
487
|
try:
|
|
@@ -420,9 +491,9 @@ def _load_func_tzinfo(**kwargs):
|
|
|
420
491
|
if not tzinfo:
|
|
421
492
|
return
|
|
422
493
|
# Add custom lang to preferred_lang
|
|
423
|
-
|
|
494
|
+
_preferred_timezone.set((tzinfo,))
|
|
424
495
|
# Clear current translation
|
|
425
|
-
|
|
496
|
+
_tzinfo.set(None)
|
|
426
497
|
|
|
427
498
|
|
|
428
499
|
def _set_content_language(**kwargs):
|
|
@@ -431,9 +502,12 @@ def _set_content_language(**kwargs):
|
|
|
431
502
|
language of `cherrypy.response.i18n.locale`.
|
|
432
503
|
"""
|
|
433
504
|
if 'Content-Language' not in cherrypy.response.headers:
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
437
511
|
|
|
438
512
|
|
|
439
513
|
class I18nTool(cherrypy.Tool):
|
|
@@ -446,6 +520,7 @@ class I18nTool(cherrypy.Tool):
|
|
|
446
520
|
cherrypy.Tool._setup(self)
|
|
447
521
|
# Attach additional hooks as different priority to update preferred lang with more accurate preferences.
|
|
448
522
|
cherrypy.request.hooks.attach('before_handler', _load_accept_language, priority=60)
|
|
523
|
+
cherrypy.request.hooks.attach('before_handler', _load_cookie_language, priority=70)
|
|
449
524
|
cherrypy.request.hooks.attach('before_handler', _load_func_language, priority=75)
|
|
450
525
|
cherrypy.request.hooks.attach('before_handler', _load_func_tzinfo, priority=75)
|
|
451
526
|
cherrypy.request.hooks.attach('before_finalize', _set_content_language)
|
|
@@ -80,7 +80,17 @@ class Jinja2Tool(cherrypy.Tool):
|
|
|
80
80
|
|
|
81
81
|
# Enable translation if available
|
|
82
82
|
if hasattr(cherrypy.tools, 'i18n'):
|
|
83
|
-
from .i18n import
|
|
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
|
+
)
|
|
84
94
|
|
|
85
95
|
env.add_extension('jinja2.ext.i18n')
|
|
86
96
|
env.install_gettext_callables(ugettext, ungettext, newstyle=True)
|
|
@@ -88,6 +98,9 @@ class Jinja2Tool(cherrypy.Tool):
|
|
|
88
98
|
env.filters['format_datetime'] = format_datetime
|
|
89
99
|
env.globals['get_language_name'] = get_language_name
|
|
90
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
|
|
91
104
|
|
|
92
105
|
# Update globals, filters and tests
|
|
93
106
|
env.globals.update(globals)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="{{ get_translation().locale }}">
|
|
3
|
+
<head>
|
|
4
|
+
<title>test-jinja2</title>
|
|
5
|
+
{{ catalog.render_assets() }}
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<!-- Selector -->
|
|
9
|
+
<div class="dropdown">
|
|
10
|
+
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
11
|
+
Dropdown button
|
|
12
|
+
</button>
|
|
13
|
+
<ul class="dropdown-menu">
|
|
14
|
+
<LocaleSelection />
|
|
15
|
+
</ul>
|
|
16
|
+
</div>
|
|
17
|
+
{{ get_language_name(get_translation().locale) }}<br/>
|
|
18
|
+
{% trans %}Some text to translate{% endtrans %}<br/>
|
|
19
|
+
{{ my_datetime | format_datetime(format='full') }}<br/>
|
|
20
|
+
{{ my_date | format_date(format='full') }}
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
@@ -38,7 +38,6 @@ class TestI18n(unittest.TestCase):
|
|
|
38
38
|
def test_search_translation_en(self):
|
|
39
39
|
# Load default translation return translation
|
|
40
40
|
t = i18n._search_translation(self.mo_dir, 'messages', 'en')
|
|
41
|
-
self.assertIsInstance(t, gettext.GNUTranslations)
|
|
42
41
|
self.assertEqual("en", t.locale.language)
|
|
43
42
|
# Test translation object
|
|
44
43
|
self.assertEqual(TEXT_EN, t.gettext(TEXT_EN))
|
|
@@ -54,7 +53,8 @@ class TestI18n(unittest.TestCase):
|
|
|
54
53
|
def test_search_translation_invalid(self):
|
|
55
54
|
# Load invalid translation return None
|
|
56
55
|
t = i18n._search_translation(self.mo_dir, 'messages', 'tr')
|
|
57
|
-
|
|
56
|
+
# Return Null translations.
|
|
57
|
+
self.assertIn('NullTranslations', str(t.__class__))
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
class Root:
|
|
@@ -76,6 +76,7 @@ class AbstractI18nTest(helper.CPWebCase):
|
|
|
76
76
|
'tools.i18n.default': cls.default_lang,
|
|
77
77
|
'tools.i18n.mo_dir': importlib.resources.files(__package__) / 'locales',
|
|
78
78
|
'tools.i18n.domain': 'messages',
|
|
79
|
+
'tools.i18n.cookie_name': 'locale',
|
|
79
80
|
}
|
|
80
81
|
)
|
|
81
82
|
cherrypy.tree.mount(Root(), '/')
|
|
@@ -117,6 +118,20 @@ class TestI18nWebCase(AbstractI18nTest):
|
|
|
117
118
|
self.assertHeaderItemValue("Content-Language", "fr-CA")
|
|
118
119
|
self.assertInBody(TEXT_FR)
|
|
119
120
|
|
|
121
|
+
def test_language_en_US_POSIX(self):
|
|
122
|
+
# When calling with locale variant
|
|
123
|
+
self.getPage("/", headers=[("Accept-Language", "en-US-POSIX")])
|
|
124
|
+
self.assertStatus('200 OK')
|
|
125
|
+
# Tehn page return en-US
|
|
126
|
+
self.assertHeaderItemValue("Content-Language", "en-US")
|
|
127
|
+
|
|
128
|
+
def test_cookie_fr(self):
|
|
129
|
+
# When calling with locale variant
|
|
130
|
+
self.getPage("/", headers=[("Accept-Language", "en-US"), ("Cookie", "locale=fr")])
|
|
131
|
+
self.assertStatus('200 OK')
|
|
132
|
+
# Tehn page return en-US
|
|
133
|
+
self.assertHeaderItemValue("Content-Language", "fr")
|
|
134
|
+
|
|
120
135
|
def test_with_preferred_lang(self):
|
|
121
136
|
# Given a default lang 'en'
|
|
122
137
|
date = datetime.fromtimestamp(1680111611, timezone.utc)
|
|
@@ -168,6 +183,23 @@ class TestI18nWebCase(AbstractI18nTest):
|
|
|
168
183
|
with i18n.preferred_lang('de_CH'):
|
|
169
184
|
self.assertEqual('29. März 2023, 19:40:11 MESZ', i18n.format_datetime(date, format='long'))
|
|
170
185
|
|
|
186
|
+
def test_list_available_locales(self):
|
|
187
|
+
self.assertEqual(['de', 'en', 'fr'], sorted([str(l) for l in i18n.list_available_locales()]))
|
|
188
|
+
|
|
189
|
+
def test_list_available_timezones(self):
|
|
190
|
+
timezones = i18n.list_available_timezones()
|
|
191
|
+
self.assertIn('America/Toronto', timezones)
|
|
192
|
+
|
|
193
|
+
def test_get_timezone_name(self):
|
|
194
|
+
with i18n.preferred_lang('en'):
|
|
195
|
+
self.assertEqual('Eastern Time', i18n.get_timezone_name('America/Toronto'))
|
|
196
|
+
self.assertEqual('ET', i18n.get_timezone_name('America/Toronto', width='short'))
|
|
197
|
+
self.assertEqual('Eastern Time', i18n.get_timezone_name('America/Toronto', width='long'))
|
|
198
|
+
with i18n.preferred_lang('fr'):
|
|
199
|
+
self.assertEqual('heure de l’Est nord-américain', i18n.get_timezone_name('America/Toronto'))
|
|
200
|
+
self.assertEqual('HE', i18n.get_timezone_name('America/Toronto', width='short'))
|
|
201
|
+
self.assertEqual('heure de l’Est nord-américain', i18n.get_timezone_name('America/Toronto', width='long'))
|
|
202
|
+
|
|
171
203
|
|
|
172
204
|
class TestI18nDefaultLangWebCase(AbstractI18nTest):
|
|
173
205
|
default_lang = 'FR'
|
|
@@ -21,6 +21,10 @@ import cherrypy
|
|
|
21
21
|
from cherrypy.test import helper
|
|
22
22
|
from parameterized import parameterized
|
|
23
23
|
|
|
24
|
+
from cherrypy_foundation.components import StaticMiddleware
|
|
25
|
+
from cherrypy_foundation.tests import SeleniumUnitTest
|
|
26
|
+
from cherrypy_foundation.url import url_for
|
|
27
|
+
|
|
24
28
|
from .. import i18n # noqa
|
|
25
29
|
from .. import jinja2 # noqa
|
|
26
30
|
|
|
@@ -38,9 +42,18 @@ def extra_processor():
|
|
|
38
42
|
return {'var2': 'bar'}
|
|
39
43
|
|
|
40
44
|
|
|
41
|
-
@cherrypy.tools.
|
|
45
|
+
@cherrypy.tools.i18n(on=False)
|
|
46
|
+
@cherrypy.tools.sessions(on=False)
|
|
47
|
+
class Static:
|
|
48
|
+
|
|
49
|
+
components = StaticMiddleware()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cherrypy.tools.jinja2(on=False, env=env, extra_processor=extra_processor)
|
|
42
53
|
class Root:
|
|
43
54
|
|
|
55
|
+
static = Static()
|
|
56
|
+
|
|
44
57
|
@cherrypy.expose
|
|
45
58
|
@cherrypy.tools.jinja2(template='test_jinja2.html')
|
|
46
59
|
def index(self):
|
|
@@ -52,12 +65,13 @@ class Root:
|
|
|
52
65
|
return {'var1': 'test-jinjax'}
|
|
53
66
|
|
|
54
67
|
@cherrypy.expose
|
|
55
|
-
@cherrypy.tools.jinja2(template='
|
|
68
|
+
@cherrypy.tools.jinja2(template='test_jinjax_i18n.html')
|
|
56
69
|
@cherrypy.tools.i18n(
|
|
57
70
|
default='fr',
|
|
58
71
|
default_timezone='America/Toronto',
|
|
59
72
|
mo_dir=importlib.resources.files(__package__) / 'locales',
|
|
60
73
|
domain='messages',
|
|
74
|
+
cookie_name='locale', # For LocaleSelection
|
|
61
75
|
)
|
|
62
76
|
def localized(self):
|
|
63
77
|
return {
|
|
@@ -66,7 +80,7 @@ class Root:
|
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
|
|
69
|
-
class Jinja2Test(helper.CPWebCase):
|
|
83
|
+
class Jinja2Test(helper.CPWebCase, SeleniumUnitTest):
|
|
70
84
|
default_lang = None
|
|
71
85
|
interactive = False
|
|
72
86
|
|
|
@@ -95,6 +109,7 @@ class Jinja2Test(helper.CPWebCase):
|
|
|
95
109
|
# Then the page is render dynamically using page context
|
|
96
110
|
self.assertInBody('<a class="btn btn-primary" href="http://example.com">foo</a>')
|
|
97
111
|
|
|
112
|
+
@skipUnless(HAS_JINJAX, reason='Required jinjax')
|
|
98
113
|
@parameterized.expand(
|
|
99
114
|
[
|
|
100
115
|
('server_default', {}, 'fr'),
|
|
@@ -121,3 +136,18 @@ class Jinja2Test(helper.CPWebCase):
|
|
|
121
136
|
self.assertInBody('Some text to translate')
|
|
122
137
|
self.assertInBody('Wednesday, November 26, 2025, 6:16:00\u202fAM Eastern Standard Time')
|
|
123
138
|
self.assertInBody('Monday, December 22, 2025')
|
|
139
|
+
|
|
140
|
+
@skipUnless(HAS_JINJAX, reason='Required jinjax')
|
|
141
|
+
def test_get_page_i18n_selenium(self):
|
|
142
|
+
# Given a localized page render with jinja2
|
|
143
|
+
with self.selenium() as driver:
|
|
144
|
+
# When querying the page
|
|
145
|
+
driver.get(url_for('localized'))
|
|
146
|
+
# Then page load without error in english (enforced chronium lang)
|
|
147
|
+
self.assertFalse(driver.get_log('browser'))
|
|
148
|
+
self.assertEqual('en_US', driver.find_element('css selector', 'html').get_attribute('lang'))
|
|
149
|
+
# When user select a language
|
|
150
|
+
btn = driver.find_element('css selector', 'button[data-locale=fr]')
|
|
151
|
+
btn.click()
|
|
152
|
+
# Then page is reloaded with in French.
|
|
153
|
+
self.assertEqual('fr', driver.find_element('css selector', 'html').get_attribute('lang'))
|
cherrypy_foundation/url.py
CHANGED
|
@@ -60,8 +60,7 @@ def url_for(*args, _relative=None, _base=None, **kwargs):
|
|
|
60
60
|
elif not path.startswith('.'):
|
|
61
61
|
path = urljoin('/', path)
|
|
62
62
|
# Outside a request, use cherrypy.tools.proxy config
|
|
63
|
-
if not cherrypy.request.app:
|
|
64
|
-
|
|
65
|
-
_base = cherrypy.config.get('tools.proxy.base', None)
|
|
63
|
+
if not cherrypy.request.app and _base is None:
|
|
64
|
+
_base = cherrypy.config.get('tools.proxy.base', None)
|
|
66
65
|
# Use cherrypy to build the URL
|
|
67
66
|
return cherrypy.url(path=path, qs=qs, relative=_relative, base=_base)
|
|
@@ -4,9 +4,9 @@ cherrypy_foundation/flash.py,sha256=fFRbutUX6c1lVHqjehmO9y98dJgmfNCjhd76t2mth2s,
|
|
|
4
4
|
cherrypy_foundation/form.py,sha256=8c9dO0o47sK3CBosTkGXoVRtzNQwY0aw0vNZfTqmhvo,3994
|
|
5
5
|
cherrypy_foundation/logging.py,sha256=YIOK5ZAZLCv52YDdP66yBYpEX1C336JnI3wnrTKl1Lw,3468
|
|
6
6
|
cherrypy_foundation/passwd.py,sha256=ZGdrBNKtLP75l01W6VEd8cIjSQ3guJ_YVPEfbSew7T0,2144
|
|
7
|
-
cherrypy_foundation/url.py,sha256=
|
|
7
|
+
cherrypy_foundation/url.py,sha256=IjDADMIZTZbQ5mECSsoz_MsyJvA85amV-93J_E3hbb4,2854
|
|
8
8
|
cherrypy_foundation/widgets.py,sha256=0B5Y2V6x5Ufl6ExR3tc0Olrzj7N4TAAOtqGq_MUxBG0,1549
|
|
9
|
-
cherrypy_foundation/components/ColorModes.jinja,sha256=
|
|
9
|
+
cherrypy_foundation/components/ColorModes.jinja,sha256=Ai2fy1qHFwEgutvyvvGjKJmffcBdNb7wmY20DJqZ8R4,3528
|
|
10
10
|
cherrypy_foundation/components/Datatable.css,sha256=7wSwgdA61vYCdEuQ0bp2o0oSvu5mGLN1c6ovCUSe718,947
|
|
11
11
|
cherrypy_foundation/components/Datatable.jinja,sha256=K-24vpN5TO8Jk2COvuC5uBaCj3PFLd6ubr28JHFq3KM,3375
|
|
12
12
|
cherrypy_foundation/components/Datatable.js,sha256=jiV78bJPNMTLcjrX03vaYcsEdRbI6ctzSvCo880H8Hw,12072
|
|
@@ -16,6 +16,8 @@ cherrypy_foundation/components/Field.js,sha256=SFixZ62WlLq7SSCEazMAGhSnc9EnQ1wg6
|
|
|
16
16
|
cherrypy_foundation/components/Fields.jinja,sha256=_8Or6DOlciKjRao-sdBpLs--bx5V-K80X3hDEA1VKCQ,165
|
|
17
17
|
cherrypy_foundation/components/Flash.jinja,sha256=COy44drQsXpbZajjJS4w_9NMPYFdMfUNdfhd7SbFdYA,442
|
|
18
18
|
cherrypy_foundation/components/Icon.jinja,sha256=Z1RGYBg5xlDEoUy3glqb_k_LEjkJHeCxQXqDEvWzEF4,135
|
|
19
|
+
cherrypy_foundation/components/LocaleSelection.jinja,sha256=NHhQGI6CCJl4LWl-eteu_AIg1_qc1MusuA1fKcrjluQ,509
|
|
20
|
+
cherrypy_foundation/components/LocaleSelection.js,sha256=-iTZD5ejg1R9F3tZASTH7dkgd19upke-pzOhsZ9K_LA,670
|
|
19
21
|
cherrypy_foundation/components/SideBySideMultiSelect.css,sha256=_poMY9O8rvDsOh01pQLf9qtg1Gm4eCM2HsM_ekC5zkk,503
|
|
20
22
|
cherrypy_foundation/components/SideBySideMultiSelect.jinja,sha256=sud1WP-6JzuP7ZLRr-JQqvgMRWZRlXvxUJfguFr_klk,478
|
|
21
23
|
cherrypy_foundation/components/SideBySideMultiSelect.js,sha256=5YMz1pgkXeWC_SRRfDbQI3X-c4PuxiTIpbWJt9sY7Rc,197
|
|
@@ -91,43 +93,41 @@ cherrypy_foundation/plugins/tests/test_ldap.py,sha256=j0QUIxqeEBgN4VLvczUlw0xMM_
|
|
|
91
93
|
cherrypy_foundation/plugins/tests/test_scheduler.py,sha256=I-ZuQhMvCCvqFDwukwsyz_UkdJJ8JSLTkAanUo24GCE,3564
|
|
92
94
|
cherrypy_foundation/plugins/tests/test_scheduler_db.py,sha256=-iZnLgb3qsJzs0p7JAw6TQswRtExCmHAtYXqRNPZn7U,3101
|
|
93
95
|
cherrypy_foundation/plugins/tests/test_smtp.py,sha256=qs5yezIpSXkBmLmFlqckfPW7NmntHZxQjDSkdQG_dNE,4183
|
|
94
|
-
cherrypy_foundation/tests/__init__.py,sha256=
|
|
96
|
+
cherrypy_foundation/tests/__init__.py,sha256=VIBhEMPDIaffF7lYzA5KevcVNzALz4kykBBaO-gdKHY,2818
|
|
95
97
|
cherrypy_foundation/tests/test_error_page.py,sha256=8yLK8OGbJIdUjilFIHMNBZadLKHrXnD6KSmQ3Da4LaQ,2399
|
|
96
98
|
cherrypy_foundation/tests/test_flash.py,sha256=JqZDAgazlNnP3HcPFmFOWbPeDDMzc6z5fHNe-pBTin0,1976
|
|
97
99
|
cherrypy_foundation/tests/test_form.py,sha256=sWsPWyXwAVkCeP5t0qIHc0Oi32Zi3kztoQ_wlDR9STc,4326
|
|
98
100
|
cherrypy_foundation/tests/test_passwd.py,sha256=gC5O4yhHyU1YRYuDc0pG0T_5zvrG2qrr6P822iyK3Rg,1956
|
|
99
101
|
cherrypy_foundation/tests/test_url.py,sha256=W-RTKQuxYS2KXxCYTTtnKcxfdP9F6Fp3QKY_sBTnBmE,6434
|
|
100
|
-
cherrypy_foundation/tests/templates/test_flash.html,sha256=
|
|
101
|
-
cherrypy_foundation/tests/templates/test_form.html,sha256=
|
|
102
|
-
cherrypy_foundation/tests/templates/test_url.html,sha256=
|
|
102
|
+
cherrypy_foundation/tests/templates/test_flash.html,sha256=b1S4I9v0n-Y1yoTUh2ZKNysR1NMrqv8ldvqONtmInzw,213
|
|
103
|
+
cherrypy_foundation/tests/templates/test_form.html,sha256=ywaLKgWqvBdbdfnBgYmi1ihA3xK8X5YtjomA6atnJUg,206
|
|
104
|
+
cherrypy_foundation/tests/templates/test_url.html,sha256=427G6AnA6zUfjPoLxsVHy3U2e_XxG4ntpZX3DIjO18Q,512
|
|
103
105
|
cherrypy_foundation/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
104
106
|
cherrypy_foundation/tools/auth.py,sha256=lTSajxCiReMzm-Fl-xhTByi4yFnInEWOoNsmUMnHQhs,9761
|
|
105
107
|
cherrypy_foundation/tools/auth_mfa.py,sha256=VaLvBz9wo6jTx-2mCGqFXPxl-z14f8UMWvd6_xeXd40,9212
|
|
106
108
|
cherrypy_foundation/tools/errors.py,sha256=ELpAj0N9kIxC22QW5xDQJz60zMpCwgm-Twu2WpELM1A,1005
|
|
107
|
-
cherrypy_foundation/tools/i18n.py,sha256=
|
|
108
|
-
cherrypy_foundation/tools/jinja2.py,sha256=
|
|
109
|
+
cherrypy_foundation/tools/i18n.py,sha256=pP3QrGCHiNHVDpjdKNFKErOCm3eRYZL6W_nlz9j8eD8,17128
|
|
110
|
+
cherrypy_foundation/tools/jinja2.py,sha256=xrHfk814GjlWhfqJcXTA6I2PQ3YqsSz4eHcCh9sG05U,5969
|
|
109
111
|
cherrypy_foundation/tools/ratelimit.py,sha256=pT7vZRmjltNeuiQpdyXOmnpG9BcXjLaj-AXJ0e2x_zw,8300
|
|
110
112
|
cherrypy_foundation/tools/secure_headers.py,sha256=Yh-iA_Js4MUsx5nq4ilbc-iWy90ZC0oMb3TJJD_UwYo,3921
|
|
111
113
|
cherrypy_foundation/tools/sessions_timeout.py,sha256=6iBWJntPMk_Qt94fBSfBISf1IXInSh-1XrxLbKXFV-g,7408
|
|
112
114
|
cherrypy_foundation/tools/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
113
115
|
cherrypy_foundation/tools/tests/test_auth.py,sha256=oeM5t38M8DUC9dYn59dcf00jdGY6Ry0jZWhQd_PYQUk,2847
|
|
114
116
|
cherrypy_foundation/tools/tests/test_auth_mfa.py,sha256=911hnBbdg5CKb613uIBrlggoTAyBU9SoL7Sxd-tIKS0,15008
|
|
115
|
-
cherrypy_foundation/tools/tests/test_i18n.py,sha256=
|
|
116
|
-
cherrypy_foundation/tools/tests/test_jinja2.py,sha256=
|
|
117
|
+
cherrypy_foundation/tools/tests/test_i18n.py,sha256=9rK4_1HVTk2dtTh9egIymx4SP8EO8_igBQkCD4YiIys,10758
|
|
118
|
+
cherrypy_foundation/tools/tests/test_jinja2.py,sha256=gLwaGeQn1Wt7Ximc_5ESHSm499vCeyqhEhPYUPJnamk,5545
|
|
117
119
|
cherrypy_foundation/tools/tests/test_ratelimit.py,sha256=rrqybwMbh1GFlF2-Ut57zHPAc1uqX88aqea6VS_6p5E,3449
|
|
118
120
|
cherrypy_foundation/tools/tests/components/Button.jinja,sha256=uSLp1GpEIgZNXK_GWglu0E_a1c3jHpDLI66MRfMqGhE,95
|
|
119
121
|
cherrypy_foundation/tools/tests/locales/messages.pot,sha256=5K9piTRL7H5MxDXFIWJsCccSJRA0HwfCQQU8b8VYo30,40
|
|
120
122
|
cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo,sha256=bsJTVL4OefevkxeHDS3VcW3egP6Yq18LFXwjSyoqIng,336
|
|
121
123
|
cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po,sha256=glzYY96jmCaGyxYtMqmNF-1q-OYWXBIkMysvbXO4L6E,351
|
|
122
|
-
cherrypy_foundation/tools/tests/locales/en/LC_MESSAGES/messages.mo,sha256=cdyG2Js1TIU6eenDX1ICH8uP45yvl0OLN0-SMUXTBa4,259
|
|
123
|
-
cherrypy_foundation/tools/tests/locales/en/LC_MESSAGES/messages.po,sha256=JCpiRLLHUSYQhzta8ZYjfB50NmpwPGNCTNwo2Glww14,322
|
|
124
124
|
cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo,sha256=u3_kl_nqZ3FNaSyKVQKmu4KJzN3xOxxJNVmcdhw37jA,327
|
|
125
125
|
cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po,sha256=6_Sk9Igqm7dtpyyS701p5qc4DvOJE7TRT0ajRZctFAQ,342
|
|
126
|
-
cherrypy_foundation/tools/tests/templates/test_jinja2.html,sha256=
|
|
127
|
-
cherrypy_foundation/tools/tests/templates/
|
|
128
|
-
cherrypy_foundation/tools/tests/templates/
|
|
129
|
-
cherrypy_foundation-1.0.
|
|
130
|
-
cherrypy_foundation-1.0.
|
|
131
|
-
cherrypy_foundation-1.0.
|
|
132
|
-
cherrypy_foundation-1.0.
|
|
133
|
-
cherrypy_foundation-1.0.
|
|
126
|
+
cherrypy_foundation/tools/tests/templates/test_jinja2.html,sha256=s1bHmy-lyf0YW0t-LOx3ugILV2kqFoBNYxziWgrZbo0,216
|
|
127
|
+
cherrypy_foundation/tools/tests/templates/test_jinjax.html,sha256=NImzIW0mUHxilFd61PSoxFC-yu1nayEVwv-5zlgD9yo,179
|
|
128
|
+
cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html,sha256=yre8j7HBjpTQZHpM0PuB3ASGD3O4vKkJ-y72Fm6STgY,771
|
|
129
|
+
cherrypy_foundation-1.0.0a14.dist-info/licenses/LICENSE.md,sha256=trSLYs5qlaow_bBwsLTRKpmTXsXzFksM_YUCMqrgAJQ,35149
|
|
130
|
+
cherrypy_foundation-1.0.0a14.dist-info/METADATA,sha256=4Iv0mZTcV0ApGQk9UOB0tiTopgR_J7FG31KifV9nl0E,2023
|
|
131
|
+
cherrypy_foundation-1.0.0a14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
132
|
+
cherrypy_foundation-1.0.0a14.dist-info/top_level.txt,sha256=B1vQPTLYhpKJ6W0JkRCWyAf8RPcnwJWdYxixv75-4ew,20
|
|
133
|
+
cherrypy_foundation-1.0.0a14.dist-info/RECORD,,
|
|
Binary file
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
msgid ""
|
|
2
|
-
msgstr ""
|
|
3
|
-
"Project-Id-Version: \n"
|
|
4
|
-
"POT-Creation-Date: \n"
|
|
5
|
-
"PO-Revision-Date: \n"
|
|
6
|
-
"Last-Translator: \n"
|
|
7
|
-
"Language-Team: \n"
|
|
8
|
-
"Language: en\n"
|
|
9
|
-
"MIME-Version: 1.0\n"
|
|
10
|
-
"Content-Type: text/plain; charset=UTF-8\n"
|
|
11
|
-
"Content-Transfer-Encoding: 8bit\n"
|
|
12
|
-
"X-Generator: Poedit 3.6\n"
|
|
13
|
-
|
|
14
|
-
msgid "Some text to translate"
|
|
15
|
-
msgstr ""
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
<html lang="{{ get_translation().locale }}">
|
|
2
|
-
<head>
|
|
3
|
-
<title>test-jinja2</title>
|
|
4
|
-
</head>
|
|
5
|
-
<body>
|
|
6
|
-
{{ get_language_name(get_translation().locale) }}<br/>
|
|
7
|
-
{% trans %}Some text to translate{% endtrans %}<br/>
|
|
8
|
-
{{ my_datetime | format_datetime(format='full') }}<br/>
|
|
9
|
-
{{ my_date | format_date(format='full') }}
|
|
10
|
-
</body>
|
|
11
|
-
</html>
|
|
File without changes
|
|
File without changes
|
{cherrypy_foundation-1.0.0a12.dist-info → cherrypy_foundation-1.0.0a14.dist-info}/top_level.txt
RENAMED
|
File without changes
|