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,140 @@
|
|
|
1
|
+
# SMTP Plugins for cherrypy
|
|
2
|
+
# Copyright (C) 2022-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
|
+
from unittest import mock, skipUnless
|
|
18
|
+
|
|
19
|
+
import cherrypy
|
|
20
|
+
from cherrypy.test import helper
|
|
21
|
+
from parameterized import parameterized
|
|
22
|
+
|
|
23
|
+
from .. import smtp # noqa
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SmtpPluginTest(helper.CPWebCase):
|
|
27
|
+
@classmethod
|
|
28
|
+
def setup_server(cls):
|
|
29
|
+
cherrypy.config.update(
|
|
30
|
+
{
|
|
31
|
+
'smtp.server': '__default__',
|
|
32
|
+
'smtp.username': 'username',
|
|
33
|
+
'smtp.password': 'password',
|
|
34
|
+
'smtp.email_from': 'Test <email_from@test.com>',
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def test_send_mail(self):
|
|
39
|
+
# Given a valid smtp server
|
|
40
|
+
with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
|
|
41
|
+
# When publishing a send_mail
|
|
42
|
+
cherrypy.engine.publish('send_mail', to='target@test.com', subject='subjet', message='body')
|
|
43
|
+
# Then smtplib is called to send the mail.
|
|
44
|
+
smtplib.SMTP.assert_called_once_with('__default__', 25)
|
|
45
|
+
smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
|
|
46
|
+
smtplib.SMTP.return_value.quit.assert_called_once_with()
|
|
47
|
+
|
|
48
|
+
def test_send_mail_with_to_tuple(self):
|
|
49
|
+
# Given a valid smtp server
|
|
50
|
+
with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
|
|
51
|
+
# When publishing a send_mail
|
|
52
|
+
cherrypy.engine.publish(
|
|
53
|
+
'send_mail',
|
|
54
|
+
to=('A name', 'target@test.com'),
|
|
55
|
+
subject='subjet',
|
|
56
|
+
message='body',
|
|
57
|
+
bcc=('A bcc name', 'bcc@test.com'),
|
|
58
|
+
reply_to=('A Reply Name', 'replyto@test.com'),
|
|
59
|
+
)
|
|
60
|
+
# Then smtplib is called to send the mail.
|
|
61
|
+
smtplib.SMTP.assert_called_once_with('__default__', 25)
|
|
62
|
+
smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
|
|
63
|
+
smtplib.SMTP.return_value.quit.assert_called_once_with()
|
|
64
|
+
|
|
65
|
+
@skipUnless(hasattr(cherrypy, 'scheduler'), reason='Required scheduler')
|
|
66
|
+
def test_queue_mail(self):
|
|
67
|
+
with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
|
|
68
|
+
# Given a mail being queued.
|
|
69
|
+
cherrypy.engine.publish('queue_mail', to='target@test.com', subject='subjet', message='body')
|
|
70
|
+
# When waiting for all task to be processed
|
|
71
|
+
cherrypy.scheduler.wait_for_jobs()
|
|
72
|
+
# Then smtplib is called to send the mail.
|
|
73
|
+
smtplib.SMTP.assert_called_once_with('__default__', 25)
|
|
74
|
+
smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
|
|
75
|
+
smtplib.SMTP.return_value.quit.assert_called_once_with()
|
|
76
|
+
|
|
77
|
+
def test_html2plaintext(self):
|
|
78
|
+
"""
|
|
79
|
+
Check if this convertion is working fine.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
html = """<html>
|
|
83
|
+
<head>
|
|
84
|
+
<style type="text/css">
|
|
85
|
+
body { font-family:Helvetica; }
|
|
86
|
+
</style>
|
|
87
|
+
</head>
|
|
88
|
+
<body>
|
|
89
|
+
<h1>Hi!</h1>
|
|
90
|
+
<p id="test"
|
|
91
|
+
class="mb-2 text-center">
|
|
92
|
+
How are you?<br/>
|
|
93
|
+
Here is the <a href="https://www.python.org">link</a> you wanted.
|
|
94
|
+
</p>
|
|
95
|
+
</body>
|
|
96
|
+
</html>
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
expected = """**Hi!**
|
|
100
|
+
How are you?
|
|
101
|
+
Here is the link [1] you wanted.
|
|
102
|
+
|
|
103
|
+
[1] https://www.python.org"""
|
|
104
|
+
self.assertEqual(expected, smtp._html2plaintext(html))
|
|
105
|
+
|
|
106
|
+
def test_formataddr(self):
|
|
107
|
+
self.assertEqual('test@test.com', smtp._formataddr('test@test.com'))
|
|
108
|
+
self.assertEqual('TEST <test@test.com>', smtp._formataddr(('TEST', 'test@test.com')))
|
|
109
|
+
self.assertEqual(
|
|
110
|
+
'test2@test.com, TEST3 <test3@test.com>', smtp._formataddr(['test2@test.com', ('TEST3', 'test3@test.com')])
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@parameterized.expand(
|
|
114
|
+
[
|
|
115
|
+
(None, None, None),
|
|
116
|
+
(None, 'test2@test.com', 'test2@test.com'),
|
|
117
|
+
('test2@test.com', None, 'test2@test.com'),
|
|
118
|
+
('test1@test.com', 'test2@test.com', 'test1@test.com, test2@test.com'),
|
|
119
|
+
]
|
|
120
|
+
)
|
|
121
|
+
def test_with_bcc(self, smtp_bcc, bcc, expected_bcc):
|
|
122
|
+
# Given a valid smtp server
|
|
123
|
+
with mock.patch(smtp.__name__ + '.smtplib') as smtplib:
|
|
124
|
+
# Given bcc is defined at plugin level.
|
|
125
|
+
cherrypy.smtp.bcc = smtp_bcc
|
|
126
|
+
# When publishing a send_mail with Bcc
|
|
127
|
+
cherrypy.engine.publish(
|
|
128
|
+
'send_mail',
|
|
129
|
+
to=('A name', 'target@test.com'),
|
|
130
|
+
subject='subjet',
|
|
131
|
+
message='body',
|
|
132
|
+
bcc=bcc,
|
|
133
|
+
)
|
|
134
|
+
# Then message is sent
|
|
135
|
+
smtplib.SMTP.assert_called_once_with('__default__', 25)
|
|
136
|
+
smtplib.SMTP.return_value.send_message.assert_called_once_with(mock.ANY)
|
|
137
|
+
msg = smtplib.SMTP.return_value.send_message.call_args.args[0]
|
|
138
|
+
smtplib.SMTP.return_value.quit.assert_called_once_with()
|
|
139
|
+
# Message include our expected Bcc
|
|
140
|
+
self.assertEqual(expected_bcc, msg['Bcc'])
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Cherrypy-foundation
|
|
2
|
+
# Copyright (C) 2025-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
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
|
|
23
|
+
import cherrypy
|
|
24
|
+
import zc.lockfile
|
|
25
|
+
from cherrypy.lib import locking
|
|
26
|
+
from cherrypy.lib.sessions import FileSession as CPFileSession
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FileSession(CPFileSession):
|
|
30
|
+
"""
|
|
31
|
+
Override implementation of cherrpy file session to improve file locking.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def acquire_lock(self, path=None):
|
|
35
|
+
"""Acquire an exclusive lock on the currently-loaded session data."""
|
|
36
|
+
# See Issue https://github.com/cherrypy/cherrypy/issues/2065
|
|
37
|
+
|
|
38
|
+
if path is None:
|
|
39
|
+
path = self._get_file_path()
|
|
40
|
+
path += self.LOCK_SUFFIX
|
|
41
|
+
checker = locking.LockChecker(self.id, self.lock_timeout)
|
|
42
|
+
while not checker.expired():
|
|
43
|
+
try:
|
|
44
|
+
self.lock = zc.lockfile.LockFile(path)
|
|
45
|
+
except zc.lockfile.LockError:
|
|
46
|
+
# Sleep for 1ms only.
|
|
47
|
+
time.sleep(0.001)
|
|
48
|
+
else:
|
|
49
|
+
break
|
|
50
|
+
self.locked = True
|
|
51
|
+
if self.debug:
|
|
52
|
+
cherrypy.log('Lock acquired.', 'TOOLS.SESSIONS')
|
|
53
|
+
|
|
54
|
+
def clean_up(self):
|
|
55
|
+
"""Also clean-up left over lock files."""
|
|
56
|
+
# See Issue https://github.com/cherrypy/cherrypy/issues/1855
|
|
57
|
+
|
|
58
|
+
# Clean-up session files.
|
|
59
|
+
CPFileSession.clean_up(self)
|
|
60
|
+
|
|
61
|
+
# Then clean-up any orphane lock files.
|
|
62
|
+
suffix_len = len(self.LOCK_SUFFIX)
|
|
63
|
+
files = os.listdir(self.storage_path)
|
|
64
|
+
lock_files = [
|
|
65
|
+
fname for fname in files if fname.startswith(self.SESSION_PREFIX) and fname.endswith(self.LOCK_SUFFIX)
|
|
66
|
+
]
|
|
67
|
+
for fname in lock_files:
|
|
68
|
+
session_file = fname[:-suffix_len]
|
|
69
|
+
if session_file not in files:
|
|
70
|
+
filepath = os.path.join(self.storage_path, fname)
|
|
71
|
+
try:
|
|
72
|
+
os.unlink(filepath)
|
|
73
|
+
except Exception as e:
|
|
74
|
+
cherrypy.log(f'Error deleting {filepath}: {e}', 'TOOLS.SESSIONS', severity=logging.WARNING)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@contextmanager
|
|
78
|
+
def session_lock():
|
|
79
|
+
"""
|
|
80
|
+
Acquire session lock as required. Support re-intrant lock.
|
|
81
|
+
"""
|
|
82
|
+
s = cherrypy.serving.session
|
|
83
|
+
if s.locking == 'explicit' and not s.locked:
|
|
84
|
+
s.acquire_lock()
|
|
85
|
+
try:
|
|
86
|
+
yield s
|
|
87
|
+
finally:
|
|
88
|
+
# When explicit, we want to save the session (with also release the lock.)
|
|
89
|
+
if s.locking == 'explicit':
|
|
90
|
+
s.save()
|
|
91
|
+
cherrypy.serving.request._sessionsaved = True
|
|
92
|
+
else:
|
|
93
|
+
yield s
|
|
@@ -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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>test-form</title>
|
|
5
|
+
</head>
|
|
6
|
+
<body>
|
|
7
|
+
{% if form.error_message %}
|
|
8
|
+
<p>
|
|
9
|
+
{{ form.error_message }}
|
|
10
|
+
</p>
|
|
11
|
+
{% endif %}
|
|
12
|
+
<form method="post" action="/">
|
|
13
|
+
<Fields form={{ form }} />
|
|
14
|
+
</form>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<title>test-url</title>
|
|
5
|
+
</head>
|
|
6
|
+
<body>
|
|
7
|
+
Empty: {{ url_for("", **kwargs) }}<br/>
|
|
8
|
+
Dot: {{ url_for(".", **kwargs) }}<br/>
|
|
9
|
+
Dot page: {{ url_for(".", "my-page", **kwargs) }}<br/>
|
|
10
|
+
Slash: {{ url_for("/", **kwargs) }}<br/>
|
|
11
|
+
Page: {{ url_for("my-page", **kwargs) }}<br/>
|
|
12
|
+
Slash page: {{ url_for("/my-page", **kwargs) }}<br/>
|
|
13
|
+
Query: {{ url_for("my-page", foo='1', bar='test with space', **kwargs) }}<br/>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# CherryPy
|
|
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 cherrypy
|
|
18
|
+
from cherrypy.test import helper
|
|
19
|
+
from parameterized import parameterized
|
|
20
|
+
|
|
21
|
+
from ..error_page import error_page
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Root:
|
|
25
|
+
|
|
26
|
+
@cherrypy.expose
|
|
27
|
+
def index(self):
|
|
28
|
+
return "OK"
|
|
29
|
+
|
|
30
|
+
@cherrypy.expose
|
|
31
|
+
def not_found(self):
|
|
32
|
+
raise cherrypy.NotFound()
|
|
33
|
+
|
|
34
|
+
@cherrypy.expose
|
|
35
|
+
def not_found_custom(self):
|
|
36
|
+
raise cherrypy.HTTPError(404, message='My error message')
|
|
37
|
+
|
|
38
|
+
@cherrypy.expose
|
|
39
|
+
def html_error(self):
|
|
40
|
+
raise cherrypy.HTTPError(400, message='My error message')
|
|
41
|
+
|
|
42
|
+
@cherrypy.expose
|
|
43
|
+
@cherrypy.tools.json_out()
|
|
44
|
+
def json_error(self):
|
|
45
|
+
raise cherrypy.HTTPError(400, message='json error message')
|
|
46
|
+
|
|
47
|
+
@cherrypy.expose
|
|
48
|
+
@cherrypy.tools.response_headers(headers=[('Content-Type', 'text/plain')])
|
|
49
|
+
def text_error(self):
|
|
50
|
+
raise cherrypy.HTTPError(400, message='text error message')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ErrorPageTest(helper.CPWebCase):
|
|
54
|
+
interactive = False
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def setup_server(cls):
|
|
58
|
+
cherrypy.config.update(
|
|
59
|
+
{
|
|
60
|
+
'error_page.default': error_page,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
cherrypy.tree.mount(Root(), '/')
|
|
64
|
+
|
|
65
|
+
@parameterized.expand(
|
|
66
|
+
[
|
|
67
|
+
('/not_found', '<p>Nothing matches the given URI</p>'),
|
|
68
|
+
('/not_found_custom', '<p>My error message</p>'),
|
|
69
|
+
('/html_error', '<p>My error message</p>'),
|
|
70
|
+
('/json_error', '{"message": "json error message", "status": "400 Bad Request"}'),
|
|
71
|
+
('/text_error', 'text error message'),
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
def test_error_page(self, page, expect_body):
|
|
75
|
+
# When query return an error
|
|
76
|
+
self.getPage(page)
|
|
77
|
+
# then error page adjust the content
|
|
78
|
+
self.assertInBody(expect_body)
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
import importlib
|
|
17
|
+
from unittest import skipUnless
|
|
18
|
+
|
|
19
|
+
import cherrypy
|
|
20
|
+
from cherrypy.test import helper
|
|
21
|
+
|
|
22
|
+
import cherrypy_foundation.tools.jinja2 # noqa
|
|
23
|
+
from cherrypy_foundation.flash import flash, get_flashed_messages
|
|
24
|
+
|
|
25
|
+
HAS_JINJAX = importlib.util.find_spec("jinjax") is not None
|
|
26
|
+
|
|
27
|
+
env = cherrypy.tools.jinja2.create_env(
|
|
28
|
+
package_name=__package__,
|
|
29
|
+
globals={'get_flashed_messages': get_flashed_messages},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@cherrypy.tools.sessions(locking='explicit')
|
|
34
|
+
@cherrypy.tools.jinja2(env=env)
|
|
35
|
+
class Root:
|
|
36
|
+
|
|
37
|
+
@cherrypy.expose
|
|
38
|
+
@cherrypy.tools.jinja2(template='test_flash.html')
|
|
39
|
+
def index(self):
|
|
40
|
+
flash('default flash message', level='info')
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@skipUnless(HAS_JINJAX, reason='Required jinjax')
|
|
45
|
+
class FormTest(helper.CPWebCase):
|
|
46
|
+
default_lang = None
|
|
47
|
+
interactive = False
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def setup_server(cls):
|
|
51
|
+
cherrypy.tree.mount(Root(), '/')
|
|
52
|
+
|
|
53
|
+
def test_get_flash(self):
|
|
54
|
+
# Given a page returning a flash message
|
|
55
|
+
# When querying the page that include this form
|
|
56
|
+
self.getPage("/")
|
|
57
|
+
self.assertStatus(200)
|
|
58
|
+
# Then page display the message.
|
|
59
|
+
self.assertInBody('test-flash')
|
|
60
|
+
self.assertInBody('<div class="alert alert-info alert-dismissible fade show"')
|
|
61
|
+
self.assertInBody('default flash message')
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
import importlib
|
|
17
|
+
from unittest import skipUnless
|
|
18
|
+
from urllib.parse import urlencode
|
|
19
|
+
|
|
20
|
+
import cherrypy
|
|
21
|
+
from cherrypy.test import helper
|
|
22
|
+
from parameterized import parameterized
|
|
23
|
+
from wtforms.fields import BooleanField, PasswordField, StringField, SubmitField
|
|
24
|
+
from wtforms.validators import InputRequired, Length
|
|
25
|
+
|
|
26
|
+
import cherrypy_foundation.tools.jinja2 # noqa
|
|
27
|
+
from cherrypy_foundation.form import CherryForm
|
|
28
|
+
from cherrypy_foundation.tools.i18n import gettext_lazy as _
|
|
29
|
+
|
|
30
|
+
HAS_JINJAX = importlib.util.find_spec("jinjax") is not None
|
|
31
|
+
|
|
32
|
+
env = cherrypy.tools.jinja2.create_env(
|
|
33
|
+
package_name=__package__,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LoginForm(CherryForm):
|
|
38
|
+
login = StringField(
|
|
39
|
+
_('User'),
|
|
40
|
+
validators=[
|
|
41
|
+
InputRequired(),
|
|
42
|
+
Length(max=256, message=_('User too long.')),
|
|
43
|
+
],
|
|
44
|
+
render_kw={
|
|
45
|
+
"placeholder": _('User'),
|
|
46
|
+
"autocorrect": "off",
|
|
47
|
+
"autocapitalize": "none",
|
|
48
|
+
"autocomplete": "off",
|
|
49
|
+
"autofocus": "autofocus",
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
password = PasswordField(
|
|
53
|
+
_('Password'),
|
|
54
|
+
validators=[
|
|
55
|
+
InputRequired(),
|
|
56
|
+
Length(max=256, message=_('Password too long.')),
|
|
57
|
+
],
|
|
58
|
+
render_kw={"placeholder": _("Password")},
|
|
59
|
+
)
|
|
60
|
+
persistent = BooleanField(
|
|
61
|
+
_('Remember me'),
|
|
62
|
+
# All `label-*` are assigned to the label tag.
|
|
63
|
+
render_kw={'container_class': 'col-sm-6', 'label-attr': 'FOO'},
|
|
64
|
+
)
|
|
65
|
+
submit = SubmitField(
|
|
66
|
+
_('Login'),
|
|
67
|
+
# All `container-*` are assigned to the container tag.
|
|
68
|
+
render_kw={"class": "btn-primary float-end", 'container_class': 'col-sm-6', 'container-attr': 'BAR'},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@cherrypy.tools.sessions()
|
|
73
|
+
@cherrypy.tools.jinja2(env=env)
|
|
74
|
+
class Root:
|
|
75
|
+
|
|
76
|
+
@cherrypy.expose
|
|
77
|
+
def index(self, **kwargs):
|
|
78
|
+
if 'login' not in cherrypy.session:
|
|
79
|
+
raise cherrypy.HTTPRedirect('/login')
|
|
80
|
+
return 'OK'
|
|
81
|
+
|
|
82
|
+
@cherrypy.expose
|
|
83
|
+
@cherrypy.tools.jinja2(template='test_form.html')
|
|
84
|
+
def login(self, **kwargs):
|
|
85
|
+
form = LoginForm()
|
|
86
|
+
if form.validate_on_submit():
|
|
87
|
+
# login user with cherrypy.tools.auth
|
|
88
|
+
cherrypy.session['login'] = True
|
|
89
|
+
raise cherrypy.HTTPRedirect('/')
|
|
90
|
+
return {'form': form}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@skipUnless(HAS_JINJAX, reason='Required jinjax')
|
|
94
|
+
class FormTest(helper.CPWebCase):
|
|
95
|
+
default_lang = None
|
|
96
|
+
interactive = False
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def setup_server(cls):
|
|
100
|
+
cherrypy.tree.mount(Root(), '/')
|
|
101
|
+
|
|
102
|
+
def test_get_form(self):
|
|
103
|
+
# Given a form
|
|
104
|
+
# When querying the page that include this form
|
|
105
|
+
self.getPage("/login")
|
|
106
|
+
self.assertStatus(200)
|
|
107
|
+
# Then each field is render properly.
|
|
108
|
+
# 1. Check title
|
|
109
|
+
self.assertInBody('test-form')
|
|
110
|
+
# 2. Check user field
|
|
111
|
+
self.assertInBody('<label class="form-label" for="login">User</label>')
|
|
112
|
+
self.assertInBody(
|
|
113
|
+
'<input autocapitalize="none" autocomplete="off" autocorrect="off" autofocus="autofocus" class="form-control" id="login" maxlength="256" name="login" placeholder="User" required type="text" value="">'
|
|
114
|
+
)
|
|
115
|
+
# 3. Check password
|
|
116
|
+
self.assertInBody('<label class="form-label" for="password">Password</label>')
|
|
117
|
+
self.assertInBody(
|
|
118
|
+
'<input class="form-control" id="password" maxlength="256" name="password" placeholder="Password" required type="password" value="">'
|
|
119
|
+
)
|
|
120
|
+
# 4 Check remember me
|
|
121
|
+
self.assertInBody(
|
|
122
|
+
'<input class="form-check-input" container-class="col-sm-6" id="persistent" label-attr="FOO" name="persistent" type="checkbox" value="y">'
|
|
123
|
+
)
|
|
124
|
+
self.assertInBody('<label attr="FOO" class="form-check-label" for="persistent">Remember me</label>')
|
|
125
|
+
# 5. check submit button (regex matches because class could have different order with jinjax<=0.57)
|
|
126
|
+
self.assertInBody('<div attr="BAR"')
|
|
127
|
+
self.assertMatchesBody(
|
|
128
|
+
'<input class="(btn-primary ?|float-end ?|btn ?){3}" container-attr="BAR" container-class="col-sm-6" id="submit" name="submit" type="submit" value="Login">'
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@parameterized.expand(
|
|
132
|
+
[
|
|
133
|
+
('myuser', 'mypassword', 0, 303, False),
|
|
134
|
+
('myuser', '', 0, 200, 'Password: This field is required.'),
|
|
135
|
+
('', 'mypassword', 0, 200, 'User: This field is required.'),
|
|
136
|
+
]
|
|
137
|
+
)
|
|
138
|
+
def test_post_form(self, login, password, persistent, expect_status, expect_error):
|
|
139
|
+
# Given a page with a form.
|
|
140
|
+
# When data is sent to the form.
|
|
141
|
+
self.getPage(
|
|
142
|
+
"/login", method='POST', body=urlencode({'login': login, 'password': password, 'persistent': persistent})
|
|
143
|
+
)
|
|
144
|
+
# Then page return a status
|
|
145
|
+
self.assertStatus(expect_status)
|
|
146
|
+
# Then page may return an error
|
|
147
|
+
if expect_error:
|
|
148
|
+
self.assertInBody(expect_error)
|