cherrypy-foundation 1.0.0__py3-none-any.whl → 1.0.0a2__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 +4 -5
- cherrypy_foundation/components/Datatable.jinja +2 -2
- cherrypy_foundation/components/Datatable.js +2 -2
- cherrypy_foundation/components/Field.jinja +2 -4
- cherrypy_foundation/components/Fields.jinja +2 -0
- cherrypy_foundation/components/Typeahead.css +1 -6
- cherrypy_foundation/components/Typeahead.jinja +2 -2
- cherrypy_foundation/components/__init__.py +2 -2
- cherrypy_foundation/components/tests/test_static.py +1 -1
- cherrypy_foundation/error_page.py +3 -3
- cherrypy_foundation/flash.py +15 -17
- cherrypy_foundation/form.py +2 -2
- cherrypy_foundation/logging.py +2 -2
- cherrypy_foundation/passwd.py +2 -2
- cherrypy_foundation/plugins/db.py +1 -1
- cherrypy_foundation/plugins/ldap.py +25 -27
- cherrypy_foundation/plugins/restapi.py +1 -1
- cherrypy_foundation/plugins/scheduler.py +3 -14
- cherrypy_foundation/plugins/smtp.py +2 -8
- cherrypy_foundation/plugins/tests/test_db.py +2 -2
- cherrypy_foundation/plugins/tests/test_ldap.py +3 -76
- cherrypy_foundation/plugins/tests/test_scheduler.py +1 -1
- cherrypy_foundation/plugins/tests/test_smtp.py +1 -31
- cherrypy_foundation/tests/__init__.py +0 -72
- cherrypy_foundation/tests/templates/test_form.html +1 -7
- cherrypy_foundation/tests/test_error_page.py +1 -7
- cherrypy_foundation/tests/test_form.py +11 -40
- cherrypy_foundation/tests/test_passwd.py +2 -2
- cherrypy_foundation/tools/auth.py +27 -31
- cherrypy_foundation/tools/auth_mfa.py +83 -87
- cherrypy_foundation/tools/errors.py +27 -0
- cherrypy_foundation/tools/i18n.py +151 -235
- cherrypy_foundation/tools/jinja2.py +2 -15
- cherrypy_foundation/tools/ratelimit.py +18 -32
- cherrypy_foundation/tools/secure_headers.py +1 -1
- cherrypy_foundation/tools/sessions_timeout.py +21 -23
- cherrypy_foundation/tools/tests/locales/en/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/{de → en}/LC_MESSAGES/messages.po +2 -2
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +1 -2
- cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +2 -3
- cherrypy_foundation/tools/tests/test_auth.py +3 -20
- cherrypy_foundation/tools/tests/test_auth_mfa.py +4 -6
- cherrypy_foundation/tools/tests/test_i18n.py +6 -81
- cherrypy_foundation/tools/tests/test_jinja2.py +5 -35
- cherrypy_foundation/tools/tests/test_ratelimit.py +2 -2
- cherrypy_foundation/url.py +25 -25
- cherrypy_foundation/widgets.py +2 -2
- cherrypy_foundation-1.0.0a2.dist-info/METADATA +42 -0
- {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/RECORD +53 -64
- {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/WHEEL +1 -1
- cherrypy_foundation/components/Flash.jinja +0 -13
- cherrypy_foundation/components/LocaleSelection.jinja +0 -13
- cherrypy_foundation/components/LocaleSelection.js +0 -26
- cherrypy_foundation/plugins/tests/test_scheduler_db.py +0 -107
- cherrypy_foundation/sessions.py +0 -93
- cherrypy_foundation/tests/templates/test_flash.html +0 -9
- cherrypy_foundation/tests/templates/test_url.html +0 -15
- cherrypy_foundation/tests/test_flash.py +0 -61
- cherrypy_foundation/tests/test_logging.py +0 -78
- cherrypy_foundation/tests/test_sessions.py +0 -89
- cherrypy_foundation/tests/test_url.py +0 -161
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +0 -22
- cherrypy_foundation/tools/tests/test_secure_headers.py +0 -200
- cherrypy_foundation-1.0.0.dist-info/METADATA +0 -71
- {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/licenses/LICENSE.md +0 -0
- {cherrypy_foundation-1.0.0.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/top_level.txt +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Ratelimit tools for cherrypy
|
|
2
|
-
# Copyright (C) 2022-
|
|
2
|
+
# Copyright (C) 2022-2025 Patrik Dufresne
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -22,8 +22,6 @@ from collections import namedtuple
|
|
|
22
22
|
|
|
23
23
|
import cherrypy
|
|
24
24
|
|
|
25
|
-
from cherrypy_foundation.sessions import session_lock
|
|
26
|
-
|
|
27
25
|
Tracker = namedtuple('Tracker', ['token', 'hits', 'timeout'])
|
|
28
26
|
|
|
29
27
|
|
|
@@ -150,27 +148,9 @@ class FileRateLimit(_DataStore):
|
|
|
150
148
|
class Ratelimit(cherrypy.Tool):
|
|
151
149
|
CONTEXT = 'TOOLS.RATELIMIT'
|
|
152
150
|
|
|
153
|
-
_datastores = {}
|
|
154
|
-
|
|
155
151
|
def __init__(self, priority=60):
|
|
156
152
|
super().__init__('before_handler', self.check_ratelimit, 'ratelimit', priority)
|
|
157
153
|
|
|
158
|
-
def _get_datastore(self, storage_class, **kwargs):
|
|
159
|
-
"""
|
|
160
|
-
Create new datastore or return existing datastore.
|
|
161
|
-
"""
|
|
162
|
-
# Default to RAM if storage class is not defined.
|
|
163
|
-
if storage_class is None:
|
|
164
|
-
kwargs = {}
|
|
165
|
-
storage_class = RamRateLimit
|
|
166
|
-
# Lookup for matching storage.
|
|
167
|
-
key = (storage_class, str(kwargs))
|
|
168
|
-
datastore = self._datastores.get(key)
|
|
169
|
-
if datastore is None:
|
|
170
|
-
# Create new storage if not found.
|
|
171
|
-
self._datastores[key] = datastore = storage_class(**kwargs)
|
|
172
|
-
return datastore
|
|
173
|
-
|
|
174
154
|
def check_ratelimit(
|
|
175
155
|
self,
|
|
176
156
|
delay=3600,
|
|
@@ -181,8 +161,7 @@ class Ratelimit(cherrypy.Tool):
|
|
|
181
161
|
methods=None,
|
|
182
162
|
debug=False,
|
|
183
163
|
hit=1,
|
|
184
|
-
|
|
185
|
-
**storage_kwargs,
|
|
164
|
+
**conf,
|
|
186
165
|
):
|
|
187
166
|
"""
|
|
188
167
|
Verify the ratelimit. By default return a 429 HTTP error code (Too Many Request). After 25 request within the same hour.
|
|
@@ -195,8 +174,9 @@ class Ratelimit(cherrypy.Tool):
|
|
|
195
174
|
scope: if specify, define the scope of rate limit. Default to path_info.
|
|
196
175
|
methods: if specify, only the methods in the list will be rate limited.
|
|
197
176
|
"""
|
|
198
|
-
|
|
199
|
-
|
|
177
|
+
assert delay > 0, 'invalid delay'
|
|
178
|
+
|
|
179
|
+
# Check if limit is enabled
|
|
200
180
|
if limit <= 0:
|
|
201
181
|
return
|
|
202
182
|
|
|
@@ -207,8 +187,13 @@ class Ratelimit(cherrypy.Tool):
|
|
|
207
187
|
cherrypy.log(f'skip rate limit for HTTP method {request.method}', context=self.CONTEXT)
|
|
208
188
|
return
|
|
209
189
|
|
|
210
|
-
#
|
|
211
|
-
datastore = self
|
|
190
|
+
# If datastore is not pass as configuration, create it for the first time.
|
|
191
|
+
datastore = getattr(self, '_ratelimit_datastore', None)
|
|
192
|
+
if datastore is None:
|
|
193
|
+
# Create storage using storage class
|
|
194
|
+
storage_class = conf.get('storage_class', RamRateLimit)
|
|
195
|
+
datastore = storage_class(**conf)
|
|
196
|
+
self._ratelimit_datastore = datastore
|
|
212
197
|
|
|
213
198
|
# Identifier: prefer authenticated user; else client IP
|
|
214
199
|
identifier = getattr(cherrypy.serving.request, 'login', None) or cherrypy.request.remote.ip
|
|
@@ -241,8 +226,7 @@ class Ratelimit(cherrypy.Tool):
|
|
|
241
226
|
cherrypy.log(f'block access to path_info={request.path_info}', context=self.CONTEXT)
|
|
242
227
|
if logout:
|
|
243
228
|
if hasattr(cherrypy.serving, 'session'):
|
|
244
|
-
|
|
245
|
-
session.clear()
|
|
229
|
+
cherrypy.serving.session.clear()
|
|
246
230
|
raise cherrypy.HTTPRedirect("/")
|
|
247
231
|
raise cherrypy.HTTPError(return_status)
|
|
248
232
|
|
|
@@ -256,10 +240,12 @@ class Ratelimit(cherrypy.Tool):
|
|
|
256
240
|
|
|
257
241
|
def reset(self):
|
|
258
242
|
"""
|
|
259
|
-
Used to reset the ratelimit.
|
|
243
|
+
Used to reset the ratelimit.
|
|
260
244
|
"""
|
|
261
|
-
|
|
262
|
-
|
|
245
|
+
datastore = getattr(self, '_ratelimit_datastore', False)
|
|
246
|
+
if not datastore:
|
|
247
|
+
return
|
|
248
|
+
datastore.reset()
|
|
263
249
|
|
|
264
250
|
|
|
265
251
|
cherrypy.tools.ratelimit = Ratelimit()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Session timeout tool for cherrypy
|
|
2
|
-
# Copyright (C) 2012-
|
|
2
|
+
# Copyright (C) 2012-2025 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -21,8 +21,6 @@ import time
|
|
|
21
21
|
import cherrypy
|
|
22
22
|
from cherrypy.lib import httputil
|
|
23
23
|
|
|
24
|
-
from cherrypy_foundation.sessions import session_lock
|
|
25
|
-
|
|
26
24
|
SESSION_PERSISTENT = '_session_persistent'
|
|
27
25
|
SESSION_START_TIME = '_session_start_time'
|
|
28
26
|
|
|
@@ -59,13 +57,13 @@ class SessionsTimeout(cherrypy.Tool):
|
|
|
59
57
|
absolute_timeout = int(cfg.get('tools.sessions_timeout.absolute_timeout', SESSION_DEFAULT_ABSOLUTE_TIMEOUT))
|
|
60
58
|
return idle_timeout, persistent_timeout, absolute_timeout
|
|
61
59
|
|
|
62
|
-
def _set_cookie_max_age(self,
|
|
60
|
+
def _set_cookie_max_age(self, max_age: int) -> None:
|
|
63
61
|
"""Adjust only Max-Age and Expires for the session cookie, keep other flags intact."""
|
|
64
62
|
cookie_name = self._cookie_name()
|
|
65
63
|
cookie = cherrypy.serving.response.cookie
|
|
66
64
|
# Ensure the cookie key exists (CherryPy sets it when session id is issued/regenerated).
|
|
67
65
|
if cookie_name not in cookie:
|
|
68
|
-
cookie[cookie_name] = session.id
|
|
66
|
+
cookie[cookie_name] = cherrypy.serving.session.id
|
|
69
67
|
cookie[cookie_name]['max-age'] = str(max(0, max_age))
|
|
70
68
|
cookie[cookie_name]['expires'] = httputil.HTTPDate(time.time() + max(0, max_age))
|
|
71
69
|
|
|
@@ -75,21 +73,24 @@ class SessionsTimeout(cherrypy.Tool):
|
|
|
75
73
|
return 0
|
|
76
74
|
return int(math.ceil(seconds / 60.0))
|
|
77
75
|
|
|
78
|
-
def _ensure_start_time(self
|
|
76
|
+
def _ensure_start_time(self) -> None:
|
|
79
77
|
"""Ensure the session has a stable start time anchor for absolute timeout."""
|
|
78
|
+
session = cherrypy.serving.session
|
|
80
79
|
if SESSION_START_TIME not in session or not isinstance(session.get(SESSION_START_TIME), datetime.datetime):
|
|
81
80
|
session[SESSION_START_TIME] = session.now()
|
|
82
81
|
|
|
83
|
-
def _expire_and_restart(self,
|
|
82
|
+
def _expire_and_restart(self, make_persistent: bool | None = None) -> None:
|
|
84
83
|
"""Expire current session and start a fresh one. Optionally set persistent flag."""
|
|
84
|
+
session = cherrypy.serving.session
|
|
85
85
|
session.clear()
|
|
86
86
|
session.regenerate()
|
|
87
87
|
session[SESSION_START_TIME] = session.now()
|
|
88
88
|
if make_persistent is not None:
|
|
89
89
|
session[SESSION_PERSISTENT] = bool(make_persistent)
|
|
90
90
|
|
|
91
|
-
def _update_session_timeout(self
|
|
92
|
-
|
|
91
|
+
def _update_session_timeout(self) -> None:
|
|
92
|
+
session = cherrypy.serving.session
|
|
93
|
+
self._ensure_start_time()
|
|
93
94
|
|
|
94
95
|
now: datetime.datetime = session.now()
|
|
95
96
|
start: datetime.datetime = session[SESSION_START_TIME]
|
|
@@ -119,9 +120,9 @@ class SessionsTimeout(cherrypy.Tool):
|
|
|
119
120
|
# Expired due to either sliding window or absolute cap
|
|
120
121
|
# Start a fresh session; do NOT automatically re-mark persistent here.
|
|
121
122
|
# Authentication/Remember me logic elsewhere can call set_persistent(True) again after login.
|
|
122
|
-
self._expire_and_restart(
|
|
123
|
+
self._expire_and_restart()
|
|
123
124
|
# After regeneration, set a sensible session.timeout baseline:
|
|
124
|
-
session.timeout = idle_timeout_min
|
|
125
|
+
cherrypy.serving.session.timeout = idle_timeout_min
|
|
125
126
|
return
|
|
126
127
|
|
|
127
128
|
# Apply per-request remaining minutes to CherryPy's session timeout
|
|
@@ -129,7 +130,7 @@ class SessionsTimeout(cherrypy.Tool):
|
|
|
129
130
|
|
|
130
131
|
if is_persistent:
|
|
131
132
|
# Keep cookie lifetime aligned with remaining time so it can survive browser restarts.
|
|
132
|
-
self._set_cookie_max_age(
|
|
133
|
+
self._set_cookie_max_age(remaining_sec)
|
|
133
134
|
else:
|
|
134
135
|
# For non-persistent, let CherryPy manage the session cookie (session or transient cookie).
|
|
135
136
|
# If you want strict enforcement client-side too, uncomment the next line:
|
|
@@ -142,26 +143,23 @@ class SessionsTimeout(cherrypy.Tool):
|
|
|
142
143
|
return
|
|
143
144
|
|
|
144
145
|
# Ensure configured values are honored (method signature kept for CherryPy Tool interface)
|
|
145
|
-
|
|
146
|
-
self._update_session_timeout(session)
|
|
146
|
+
self._update_session_timeout()
|
|
147
147
|
|
|
148
148
|
def set_persistent(self, value: bool = True) -> None:
|
|
149
149
|
"""Mark current session as persistent (or not) and reset the start window."""
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
150
|
+
session = cherrypy.serving.session
|
|
151
|
+
session[SESSION_PERSISTENT] = bool(value)
|
|
152
|
+
# Reset absolute window anchor when the persistence policy changes
|
|
153
|
+
session[SESSION_START_TIME] = session.now()
|
|
154
|
+
self._update_session_timeout()
|
|
155
155
|
|
|
156
156
|
def is_persistent(self) -> bool:
|
|
157
157
|
"""Return True if the session is marked persistent."""
|
|
158
|
-
|
|
159
|
-
return bool(session.get(SESSION_PERSISTENT, False))
|
|
158
|
+
return bool(cherrypy.serving.session.get(SESSION_PERSISTENT, False))
|
|
160
159
|
|
|
161
160
|
def get_session_start_time(self) -> datetime.datetime | None:
|
|
162
161
|
"""Return the session start time if any."""
|
|
163
|
-
|
|
164
|
-
return session.get(SESSION_START_TIME, None)
|
|
162
|
+
return cherrypy.serving.session.get(SESSION_START_TIME, None)
|
|
165
163
|
|
|
166
164
|
|
|
167
165
|
cherrypy.tools.sessions_timeout = SessionsTimeout()
|
|
Binary file
|
|
@@ -5,11 +5,11 @@ msgstr ""
|
|
|
5
5
|
"PO-Revision-Date: \n"
|
|
6
6
|
"Last-Translator: \n"
|
|
7
7
|
"Language-Team: \n"
|
|
8
|
-
"Language:
|
|
8
|
+
"Language: en\n"
|
|
9
9
|
"MIME-Version: 1.0\n"
|
|
10
10
|
"Content-Type: text/plain; charset=UTF-8\n"
|
|
11
11
|
"Content-Transfer-Encoding: 8bit\n"
|
|
12
12
|
"X-Generator: Poedit 3.6\n"
|
|
13
13
|
|
|
14
14
|
msgid "Some text to translate"
|
|
15
|
-
msgstr "
|
|
15
|
+
msgstr ""
|
|
@@ -0,0 +1,11 @@
|
|
|
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>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# CherryPy
|
|
2
|
-
# Copyright (C)
|
|
2
|
+
# Copyright (C) 2025 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -14,12 +14,11 @@
|
|
|
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
18
|
from collections import namedtuple
|
|
19
19
|
from urllib.parse import urlencode
|
|
20
20
|
|
|
21
21
|
import cherrypy
|
|
22
|
-
from cherrypy.lib.sessions import FileSession
|
|
23
22
|
from cherrypy.test import helper
|
|
24
23
|
|
|
25
24
|
from .. import auth # noqa
|
|
@@ -43,7 +42,7 @@ def user_from_key_func(userkey):
|
|
|
43
42
|
return None
|
|
44
43
|
|
|
45
44
|
|
|
46
|
-
@cherrypy.tools.sessions(
|
|
45
|
+
@cherrypy.tools.sessions()
|
|
47
46
|
@cherrypy.tools.auth(
|
|
48
47
|
user_lookup_func=user_lookup_func,
|
|
49
48
|
user_from_key_func=user_from_key_func,
|
|
@@ -69,24 +68,8 @@ class Root:
|
|
|
69
68
|
class AuthManagerTest(helper.CPWebCase):
|
|
70
69
|
interactive = False
|
|
71
70
|
|
|
72
|
-
@classmethod
|
|
73
|
-
def setup_class(cls):
|
|
74
|
-
cls.tempdir = tempfile.TemporaryDirectory(prefix='cherrypy-foundation-', suffix='-session')
|
|
75
|
-
cls.session_dir = cls.tempdir.name
|
|
76
|
-
super().setup_class()
|
|
77
|
-
|
|
78
|
-
@classmethod
|
|
79
|
-
def teardown_class(cls):
|
|
80
|
-
cls.tempdir.cleanup()
|
|
81
|
-
super().teardown_class()
|
|
82
|
-
|
|
83
71
|
@classmethod
|
|
84
72
|
def setup_server(cls):
|
|
85
|
-
cherrypy.config.update(
|
|
86
|
-
{
|
|
87
|
-
'tools.sessions.storage_path': cls.session_dir,
|
|
88
|
-
}
|
|
89
|
-
)
|
|
90
73
|
cherrypy.tree.mount(Root(), '/')
|
|
91
74
|
|
|
92
75
|
def test_auth_redirect(self):
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# CherryPy
|
|
2
|
-
# Copyright (C)
|
|
2
|
+
# Copyright (C) 2025 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -14,6 +14,7 @@
|
|
|
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
|
import datetime
|
|
18
19
|
from collections import namedtuple
|
|
19
20
|
from urllib.parse import urlencode
|
|
@@ -22,8 +23,6 @@ import cherrypy
|
|
|
22
23
|
from cherrypy.lib.sessions import RamSession
|
|
23
24
|
from cherrypy.test import helper
|
|
24
25
|
|
|
25
|
-
from cherrypy_foundation.sessions import session_lock
|
|
26
|
-
|
|
27
26
|
from ..auth import AUTH_LAST_PASSWORD_AT
|
|
28
27
|
from ..auth_mfa import (
|
|
29
28
|
MFA_CODE_TIME,
|
|
@@ -65,7 +64,7 @@ def user_from_key_func(userkey):
|
|
|
65
64
|
return None
|
|
66
65
|
|
|
67
66
|
|
|
68
|
-
@cherrypy.tools.sessions(
|
|
67
|
+
@cherrypy.tools.sessions()
|
|
69
68
|
@cherrypy.tools.auth(
|
|
70
69
|
user_lookup_func=user_lookup_func,
|
|
71
70
|
user_from_key_func=user_from_key_func,
|
|
@@ -104,8 +103,7 @@ class Root:
|
|
|
104
103
|
code = cherrypy.tools.auth_mfa.generate_code()
|
|
105
104
|
# Here the code should be send by email, SMS or any other means.
|
|
106
105
|
# For our test we store it in the session.
|
|
107
|
-
|
|
108
|
-
session['code'] = code
|
|
106
|
+
cherrypy.serving.session['code'] = code
|
|
109
107
|
html += "<p>new verification code sent to your email</p>\n"
|
|
110
108
|
return html
|
|
111
109
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# CherryPy
|
|
2
|
-
# Copyright (C)
|
|
2
|
+
# Copyright (C) 2025 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -38,6 +38,7 @@ 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)
|
|
41
42
|
self.assertEqual("en", t.locale.language)
|
|
42
43
|
# Test translation object
|
|
43
44
|
self.assertEqual(TEXT_EN, t.gettext(TEXT_EN))
|
|
@@ -53,8 +54,7 @@ class TestI18n(unittest.TestCase):
|
|
|
53
54
|
def test_search_translation_invalid(self):
|
|
54
55
|
# Load invalid translation return None
|
|
55
56
|
t = i18n._search_translation(self.mo_dir, 'messages', 'tr')
|
|
56
|
-
|
|
57
|
-
self.assertIn('NullTranslations', str(t.__class__))
|
|
57
|
+
self.assertIsNone(t)
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
class Root:
|
|
@@ -76,7 +76,6 @@ 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',
|
|
80
79
|
}
|
|
81
80
|
)
|
|
82
81
|
cherrypy.tree.mount(Root(), '/')
|
|
@@ -84,13 +83,6 @@ class AbstractI18nTest(helper.CPWebCase):
|
|
|
84
83
|
|
|
85
84
|
class TestI18nWebCase(AbstractI18nTest):
|
|
86
85
|
|
|
87
|
-
def test_language_with_invalid(self):
|
|
88
|
-
# Query the page without login-in
|
|
89
|
-
self.getPage("/", headers=[("Accept-Language", "invalid")])
|
|
90
|
-
self.assertStatus('200 OK')
|
|
91
|
-
self.assertHeaderItemValue("Content-Language", "en")
|
|
92
|
-
self.assertInBody(TEXT_EN)
|
|
93
|
-
|
|
94
86
|
def test_language_with_unknown(self):
|
|
95
87
|
# Query the page without login-in
|
|
96
88
|
self.getPage("/", headers=[("Accept-Language", "it")])
|
|
@@ -101,13 +93,13 @@ class TestI18nWebCase(AbstractI18nTest):
|
|
|
101
93
|
def test_language_en(self):
|
|
102
94
|
self.getPage("/", headers=[("Accept-Language", "en-US,en;q=0.8")])
|
|
103
95
|
self.assertStatus('200 OK')
|
|
104
|
-
self.assertHeaderItemValue("Content-Language", "en
|
|
96
|
+
self.assertHeaderItemValue("Content-Language", "en")
|
|
105
97
|
self.assertInBody(TEXT_EN)
|
|
106
98
|
|
|
107
99
|
def test_language_en_fr(self):
|
|
108
100
|
self.getPage("/", headers=[("Accept-Language", "en-US,en;q=0.8,fr-CA;q=0.8")])
|
|
109
101
|
self.assertStatus('200 OK')
|
|
110
|
-
self.assertHeaderItemValue("Content-Language", "en
|
|
102
|
+
self.assertHeaderItemValue("Content-Language", "en")
|
|
111
103
|
self.assertInBody(TEXT_EN)
|
|
112
104
|
|
|
113
105
|
def test_language_fr(self):
|
|
@@ -115,22 +107,8 @@ class TestI18nWebCase(AbstractI18nTest):
|
|
|
115
107
|
self.assertInBody(TEXT_EN)
|
|
116
108
|
self.getPage("/", headers=[("Accept-Language", "fr-CA;q=0.8,fr;q=0.6")])
|
|
117
109
|
self.assertStatus('200 OK')
|
|
118
|
-
self.assertHeaderItemValue("Content-Language", "fr-CA")
|
|
119
|
-
self.assertInBody(TEXT_FR)
|
|
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
110
|
self.assertHeaderItemValue("Content-Language", "fr")
|
|
111
|
+
self.assertInBody(TEXT_FR)
|
|
134
112
|
|
|
135
113
|
def test_with_preferred_lang(self):
|
|
136
114
|
# Given a default lang 'en'
|
|
@@ -147,59 +125,6 @@ class TestI18nWebCase(AbstractI18nTest):
|
|
|
147
125
|
self.assertEqual(TEXT_EN, i18n.ugettext(TEXT_EN))
|
|
148
126
|
self.assertIn('March', i18n.format_datetime(date, format='long'))
|
|
149
127
|
|
|
150
|
-
def test_format_datetime_locales(self):
|
|
151
|
-
date = datetime.fromtimestamp(1680111611, timezone.utc)
|
|
152
|
-
with i18n.preferred_timezone('utc'):
|
|
153
|
-
with i18n.preferred_lang('fr'):
|
|
154
|
-
self.assertEqual('29 mars 2023, 17:40:11 TU', i18n.format_datetime(date, format='long'))
|
|
155
|
-
with i18n.preferred_lang('en'):
|
|
156
|
-
self.assertEqual('March 29, 2023, 5:40:11\u202fPM UTC', i18n.format_datetime(date, format='long'))
|
|
157
|
-
with i18n.preferred_lang('en_US'):
|
|
158
|
-
self.assertEqual('March 29, 2023, 5:40:11\u202fPM UTC', i18n.format_datetime(date, format='long'))
|
|
159
|
-
with i18n.preferred_lang('en_GB'):
|
|
160
|
-
self.assertEqual('29 March 2023, 17:40:11 UTC', i18n.format_datetime(date, format='long'))
|
|
161
|
-
with i18n.preferred_lang('en_CH'):
|
|
162
|
-
self.assertEqual('29 March 2023, 17:40:11 UTC', i18n.format_datetime(date, format='long'))
|
|
163
|
-
with i18n.preferred_lang('de'):
|
|
164
|
-
self.assertEqual('29. März 2023, 17:40:11 UTC', i18n.format_datetime(date, format='long'))
|
|
165
|
-
with i18n.preferred_lang('de_CH'):
|
|
166
|
-
self.assertEqual('29. März 2023, 17:40:11 UTC', i18n.format_datetime(date, format='long'))
|
|
167
|
-
|
|
168
|
-
with i18n.preferred_timezone('Europe/Paris'):
|
|
169
|
-
with i18n.preferred_lang('fr'):
|
|
170
|
-
self.assertEqual('29 mars 2023, 19:40:11 +0200', i18n.format_datetime(date, format='long'))
|
|
171
|
-
with i18n.preferred_lang('fr_CH'):
|
|
172
|
-
self.assertEqual('29 mars 2023, 19:40:11 +0200', i18n.format_datetime(date, format='long'))
|
|
173
|
-
with i18n.preferred_lang('en'):
|
|
174
|
-
self.assertEqual('March 29, 2023, 7:40:11\u202fPM +0200', i18n.format_datetime(date, format='long'))
|
|
175
|
-
with i18n.preferred_lang('en_US'):
|
|
176
|
-
self.assertEqual('March 29, 2023, 7:40:11\u202fPM +0200', i18n.format_datetime(date, format='long'))
|
|
177
|
-
with i18n.preferred_lang('en_GB'):
|
|
178
|
-
self.assertEqual('29 March 2023, 19:40:11 CEST', i18n.format_datetime(date, format='long'))
|
|
179
|
-
with i18n.preferred_lang('en_CH'):
|
|
180
|
-
self.assertEqual('29 March 2023, 19:40:11 CEST', i18n.format_datetime(date, format='long'))
|
|
181
|
-
with i18n.preferred_lang('de'):
|
|
182
|
-
self.assertEqual('29. März 2023, 19:40:11 MESZ', i18n.format_datetime(date, format='long'))
|
|
183
|
-
with i18n.preferred_lang('de_CH'):
|
|
184
|
-
self.assertEqual('29. März 2023, 19:40:11 MESZ', i18n.format_datetime(date, format='long'))
|
|
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
|
-
|
|
203
128
|
|
|
204
129
|
class TestI18nDefaultLangWebCase(AbstractI18nTest):
|
|
205
130
|
default_lang = 'FR'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
#
|
|
2
|
-
# Copyright (C)
|
|
1
|
+
# CherryPy Foundation
|
|
2
|
+
# Copyright (C) 2025 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -21,10 +21,6 @@ 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
|
-
|
|
28
24
|
from .. import i18n # noqa
|
|
29
25
|
from .. import jinja2 # noqa
|
|
30
26
|
|
|
@@ -42,18 +38,9 @@ def extra_processor():
|
|
|
42
38
|
return {'var2': 'bar'}
|
|
43
39
|
|
|
44
40
|
|
|
45
|
-
@cherrypy.tools.
|
|
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)
|
|
41
|
+
@cherrypy.tools.jinja2(env=env, extra_processor=extra_processor)
|
|
53
42
|
class Root:
|
|
54
43
|
|
|
55
|
-
static = Static()
|
|
56
|
-
|
|
57
44
|
@cherrypy.expose
|
|
58
45
|
@cherrypy.tools.jinja2(template='test_jinja2.html')
|
|
59
46
|
def index(self):
|
|
@@ -65,13 +52,12 @@ class Root:
|
|
|
65
52
|
return {'var1': 'test-jinjax'}
|
|
66
53
|
|
|
67
54
|
@cherrypy.expose
|
|
68
|
-
@cherrypy.tools.jinja2(template='
|
|
55
|
+
@cherrypy.tools.jinja2(template='test_jinja2_i18n.html')
|
|
69
56
|
@cherrypy.tools.i18n(
|
|
70
57
|
default='fr',
|
|
71
58
|
default_timezone='America/Toronto',
|
|
72
59
|
mo_dir=importlib.resources.files(__package__) / 'locales',
|
|
73
60
|
domain='messages',
|
|
74
|
-
cookie_name='locale', # For LocaleSelection
|
|
75
61
|
)
|
|
76
62
|
def localized(self):
|
|
77
63
|
return {
|
|
@@ -80,7 +66,7 @@ class Root:
|
|
|
80
66
|
}
|
|
81
67
|
|
|
82
68
|
|
|
83
|
-
class Jinja2Test(helper.CPWebCase
|
|
69
|
+
class Jinja2Test(helper.CPWebCase):
|
|
84
70
|
default_lang = None
|
|
85
71
|
interactive = False
|
|
86
72
|
|
|
@@ -109,7 +95,6 @@ class Jinja2Test(helper.CPWebCase, SeleniumUnitTest):
|
|
|
109
95
|
# Then the page is render dynamically using page context
|
|
110
96
|
self.assertInBody('<a class="btn btn-primary" href="http://example.com">foo</a>')
|
|
111
97
|
|
|
112
|
-
@skipUnless(HAS_JINJAX, reason='Required jinjax')
|
|
113
98
|
@parameterized.expand(
|
|
114
99
|
[
|
|
115
100
|
('server_default', {}, 'fr'),
|
|
@@ -136,18 +121,3 @@ class Jinja2Test(helper.CPWebCase, SeleniumUnitTest):
|
|
|
136
121
|
self.assertInBody('Some text to translate')
|
|
137
122
|
self.assertInBody('Wednesday, November 26, 2025, 6:16:00\u202fAM Eastern Standard Time')
|
|
138
123
|
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'))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# CherryPy
|
|
2
|
-
# Copyright (C)
|
|
2
|
+
# Copyright (C) 2025 IKUS Software
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
5
5
|
# it under the terms of the GNU General Public License as published by
|
|
@@ -49,7 +49,7 @@ class RateLimitTest(helper.CPWebCase):
|
|
|
49
49
|
|
|
50
50
|
@classmethod
|
|
51
51
|
def setup_server(cls):
|
|
52
|
-
rate_limit_storage_class =
|
|
52
|
+
rate_limit_storage_class = ratelimit.RamRateLimit
|
|
53
53
|
if cls.rate_limit_dir:
|
|
54
54
|
rate_limit_storage_class = ratelimit.FileRateLimit
|
|
55
55
|
cherrypy.config.update(
|