cherrypy-foundation 1.0.0a1__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/Field.jinja +14 -6
- cherrypy_foundation/components/__init__.py +1 -1
- cherrypy_foundation/error_page.py +18 -15
- cherrypy_foundation/plugins/db.py +34 -8
- cherrypy_foundation/plugins/ldap.py +28 -22
- cherrypy_foundation/plugins/scheduler.py +197 -84
- cherrypy_foundation/plugins/smtp.py +71 -45
- cherrypy_foundation/plugins/tests/test_db.py +2 -2
- cherrypy_foundation/plugins/tests/test_scheduler.py +58 -50
- cherrypy_foundation/plugins/tests/test_smtp.py +9 -6
- cherrypy_foundation/tests/templates/test_form.html +10 -0
- cherrypy_foundation/tests/test_form.py +119 -0
- cherrypy_foundation/tools/auth.py +28 -11
- cherrypy_foundation/tools/auth_mfa.py +10 -13
- cherrypy_foundation/tools/errors.py +1 -1
- cherrypy_foundation/tools/i18n.py +15 -6
- cherrypy_foundation/tools/jinja2.py +15 -12
- cherrypy_foundation/tools/ratelimit.py +5 -9
- cherrypy_foundation/tools/secure_headers.py +0 -4
- cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +10 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +8 -0
- cherrypy_foundation/tools/tests/test_auth.py +1 -1
- cherrypy_foundation/tools/tests/test_auth_mfa.py +367 -0
- cherrypy_foundation/tools/tests/test_jinja2.py +123 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/METADATA +1 -1
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/RECORD +31 -23
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/WHEEL +0 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/licenses/LICENSE.md +0 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/top_level.txt +0 -0
|
@@ -29,16 +29,24 @@
|
|
|
29
29
|
{% set input_class, label_class, floating_supported, label_always_last = bootstrap_class_table.get(field.widget.__class__.__name__) or ('', 'form-label', False, False) %}
|
|
30
30
|
{% set label_last = (floating and floating_supported) or label_always_last %}
|
|
31
31
|
{% do attrs.add_class(input_class) %}
|
|
32
|
-
{# Render label an widget #}
|
|
33
32
|
{# Used for input-group #}
|
|
34
33
|
{% set prepend = attrs.get('prepend', None) %}
|
|
35
34
|
{% set append = attrs.get('append', None) %}
|
|
36
35
|
{% set input_group = prepend or append %}
|
|
37
|
-
{
|
|
38
|
-
{%
|
|
39
|
-
|
|
36
|
+
{# Build container & label specific attributes #}
|
|
37
|
+
{% set container_attrs = attrs.__class__({'class': 'mb-2 form-field'}) %}
|
|
38
|
+
{% set label_attrs = attrs.__class__({'class': label_class}) %}
|
|
39
|
+
{% for key, value in attrs.as_dict.items() %}
|
|
40
|
+
{% if key.startswith('container-') or key.startswith('container_') %}
|
|
41
|
+
{% do container_attrs.set(**{key[10:]: value}) %}
|
|
42
|
+
{% endif %}
|
|
43
|
+
{% if key.startswith('label-') or key.startswith('label_') %}
|
|
44
|
+
{% do label_attrs.set(**{key[6:]: value}) %}
|
|
45
|
+
{% endif %}
|
|
46
|
+
{% endfor %}
|
|
47
|
+
<div {{ container_attrs.as_dict | xmlattr }}>
|
|
40
48
|
{% if floating and floating_supported %}<div class="form-floating">{% endif %}
|
|
41
|
-
{% if not label_last %}{{ field.label(
|
|
49
|
+
{% if not label_last %}{{ field.label(**label_attrs.as_dict) }}{% endif %}
|
|
42
50
|
{% if input_group %}
|
|
43
51
|
<div class="input-group">
|
|
44
52
|
{% if prepend %}<span class="input-group-text">{{ prepend }}</span>{% endif %}
|
|
@@ -48,7 +56,7 @@
|
|
|
48
56
|
{% if append %}<span class="input-group-text">{{ append }}</span>{% endif %}
|
|
49
57
|
</div>
|
|
50
58
|
{% endif %}
|
|
51
|
-
{% if label_last %}{{ field.label(
|
|
59
|
+
{% if label_last %}{{ field.label(**label_attrs.as_dict) }}{% endif %}
|
|
52
60
|
{% for error in field.errors %}<div class="invalid-feedback">{{ error }}</div>{% endfor %}
|
|
53
61
|
{% if field.description %}<div class="form-text small test-secondary">{{ field.description }}</div>{% endif %}
|
|
54
62
|
{% if floating and floating_supported %}</div>{% endif %}
|
|
@@ -19,9 +19,6 @@ import logging
|
|
|
19
19
|
|
|
20
20
|
import cherrypy
|
|
21
21
|
|
|
22
|
-
# Define the logger
|
|
23
|
-
logger = logging.getLogger(__name__)
|
|
24
|
-
|
|
25
22
|
HTML_ERROR_TEMPLATE = '''<!DOCTYPE html>
|
|
26
23
|
<html>
|
|
27
24
|
<head>
|
|
@@ -53,39 +50,45 @@ HTML_ERROR_TEMPLATE = '''<!DOCTYPE html>
|
|
|
53
50
|
'''
|
|
54
51
|
|
|
55
52
|
|
|
56
|
-
def error_page(
|
|
53
|
+
def error_page(status='', message='', traceback='', version=''):
|
|
57
54
|
"""
|
|
58
55
|
Error page handler to handle Plain Text (text/plain), Json (application/json) or HTML (text/html) output.
|
|
59
56
|
|
|
60
57
|
If available, uses Jina2 environment to generate error page using `error_page.html`.
|
|
61
58
|
"""
|
|
62
59
|
# Log server error exception
|
|
63
|
-
if
|
|
64
|
-
|
|
65
|
-
'error page
|
|
60
|
+
if status.startswith('500'):
|
|
61
|
+
cherrypy.log(
|
|
62
|
+
f'error page status={status} message={message}\n{traceback}', context='ERROR-PAGE', severity=logging.WARNING
|
|
66
63
|
)
|
|
67
64
|
|
|
68
65
|
# Replace message by generic one for 404. Default implementation leak path info.
|
|
69
|
-
if
|
|
70
|
-
|
|
66
|
+
if status == '404 Not Found':
|
|
67
|
+
message = 'Nothing matches the given URI'
|
|
71
68
|
|
|
72
69
|
# Check expected response type.
|
|
73
70
|
mtype = cherrypy.serving.response.headers.get('Content-Type') or cherrypy.tools.accept.callable(
|
|
74
71
|
['text/html', 'text/plain', 'application/json']
|
|
75
72
|
)
|
|
76
73
|
if mtype == 'text/plain':
|
|
77
|
-
return
|
|
74
|
+
return message
|
|
78
75
|
elif mtype == 'application/json':
|
|
79
|
-
return json.dumps({'message':
|
|
76
|
+
return json.dumps({'message': message, 'status': status})
|
|
80
77
|
elif mtype == 'text/html':
|
|
78
|
+
context = {'status': status, 'message': message, 'traceback': traceback, 'version': version}
|
|
81
79
|
if hasattr(cherrypy.tools, 'jinja2'):
|
|
82
80
|
# Try to build a nice error page with Jinja2 env
|
|
83
81
|
try:
|
|
84
|
-
return cherrypy.tools.jinja2.render_request(template='error_page.html',
|
|
82
|
+
return cherrypy.tools.jinja2.render_request(template='error_page.html', context=context)
|
|
85
83
|
except Exception:
|
|
86
|
-
|
|
84
|
+
cherrypy.log(
|
|
85
|
+
'fail to render error page with jinja2',
|
|
86
|
+
context='ERROR-PAGE',
|
|
87
|
+
severity=logging.ERROR,
|
|
88
|
+
traceback=True,
|
|
89
|
+
)
|
|
87
90
|
# Fallback to built-int HTML error page
|
|
88
|
-
return HTML_ERROR_TEMPLATE %
|
|
91
|
+
return HTML_ERROR_TEMPLATE % context
|
|
89
92
|
|
|
90
93
|
# Fallback to raw error message.
|
|
91
|
-
return
|
|
94
|
+
return message
|
|
@@ -23,8 +23,6 @@ from sqlalchemy.engine import Engine
|
|
|
23
23
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
24
24
|
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
|
|
25
25
|
|
|
26
|
-
logger = logging.getLogger(__name__)
|
|
27
|
-
|
|
28
26
|
|
|
29
27
|
@event.listens_for(Engine, 'connect')
|
|
30
28
|
def _set_sqlite_journal_mode_wal(connection, connection_record):
|
|
@@ -168,6 +166,10 @@ class SQLA(SimplePlugin):
|
|
|
168
166
|
_session = None
|
|
169
167
|
_engine = None
|
|
170
168
|
|
|
169
|
+
@property
|
|
170
|
+
def engine(self):
|
|
171
|
+
return self._engine
|
|
172
|
+
|
|
171
173
|
def start(self):
|
|
172
174
|
if self.uri is None:
|
|
173
175
|
return
|
|
@@ -180,14 +182,22 @@ class SQLA(SimplePlugin):
|
|
|
180
182
|
self.clear_sessions()
|
|
181
183
|
# Associate our session to our engine
|
|
182
184
|
self.get_session().configure(bind=self._engine)
|
|
183
|
-
self.bus.log("
|
|
185
|
+
self.bus.log("database session plugin started")
|
|
186
|
+
|
|
187
|
+
# This is slightly lower priority to get database started first.
|
|
188
|
+
start.priority = 45
|
|
184
189
|
|
|
185
190
|
def stop(self):
|
|
186
191
|
if self._session:
|
|
187
192
|
self.clear_sessions()
|
|
188
193
|
if self._engine:
|
|
189
194
|
self._engine.dispose()
|
|
190
|
-
self.bus.log("
|
|
195
|
+
self.bus.log("database session plugin stopped")
|
|
196
|
+
|
|
197
|
+
def graceful(self):
|
|
198
|
+
"""Reload of subscribers."""
|
|
199
|
+
self.stop()
|
|
200
|
+
self.start()
|
|
191
201
|
|
|
192
202
|
def create_all(self):
|
|
193
203
|
try:
|
|
@@ -242,11 +252,27 @@ class SQLA(SimplePlugin):
|
|
|
242
252
|
try:
|
|
243
253
|
# When terminating, raise an error if objects are not commit.
|
|
244
254
|
if self._session.dirty or self._session.new or self._session.deleted:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
'
|
|
248
|
-
|
|
255
|
+
cherrypy.log(
|
|
256
|
+
'database session dirty; uncommitted objects detected — potential application bug',
|
|
257
|
+
context='DB',
|
|
258
|
+
severity=logging.ERROR,
|
|
249
259
|
)
|
|
260
|
+
if self._session.dirty:
|
|
261
|
+
changes = ', '.join([str(_get_model_changes(obj)) for obj in self._session.dirty])
|
|
262
|
+
cherrypy.log(
|
|
263
|
+
f'database session dirty_objects={self._session.dirty}', context='DB', severity=logging.ERROR
|
|
264
|
+
)
|
|
265
|
+
cherrypy.log(f'database session pending_changes={changes}', context='DB', severity=logging.ERROR)
|
|
266
|
+
if self._session.new:
|
|
267
|
+
cherrypy.log(
|
|
268
|
+
f'database session new_objects={self._session.new}', context='DB', severity=logging.ERROR
|
|
269
|
+
)
|
|
270
|
+
if self._session.deleted:
|
|
271
|
+
cherrypy.log(
|
|
272
|
+
f'database session deleted_objects={self._session.deleted}',
|
|
273
|
+
context='DB',
|
|
274
|
+
severity=logging.ERROR,
|
|
275
|
+
)
|
|
250
276
|
raise SQLAlchemyError('session is dirty')
|
|
251
277
|
finally:
|
|
252
278
|
self._session.rollback()
|
|
@@ -21,8 +21,6 @@ import ldap3
|
|
|
21
21
|
from cherrypy.process.plugins import SimplePlugin
|
|
22
22
|
from ldap3.core.exceptions import LDAPException, LDAPInvalidCredentialsResult
|
|
23
23
|
|
|
24
|
-
logger = logging.getLogger(__name__)
|
|
25
|
-
|
|
26
24
|
_safe = ldap3.utils.conv.escape_filter_chars
|
|
27
25
|
|
|
28
26
|
|
|
@@ -126,6 +124,11 @@ class LdapPlugin(SimplePlugin):
|
|
|
126
124
|
if hasattr(self, '_pool'):
|
|
127
125
|
self._pool.unbind()
|
|
128
126
|
|
|
127
|
+
def graceful(self):
|
|
128
|
+
"""Reload of subscribers."""
|
|
129
|
+
self.stop()
|
|
130
|
+
self.start()
|
|
131
|
+
|
|
129
132
|
def authenticate(self, username, password):
|
|
130
133
|
"""
|
|
131
134
|
Check if the given credential as valid according to LDAP.
|
|
@@ -148,9 +151,9 @@ class LdapPlugin(SimplePlugin):
|
|
|
148
151
|
search_filter = f"(&{self.user_filter}(|{attr_filter}))"
|
|
149
152
|
response = self._search(conn, search_filter)
|
|
150
153
|
if not response:
|
|
151
|
-
|
|
154
|
+
cherrypy.log(f"lookup failed username={username} reason=not_found", context='LDAP')
|
|
152
155
|
return False
|
|
153
|
-
|
|
156
|
+
cherrypy.log(f"lookup successful username={username}", context='LDAP')
|
|
154
157
|
user_dn = response[0]['dn']
|
|
155
158
|
|
|
156
159
|
# Use a separate connection to validate credentials
|
|
@@ -163,17 +166,21 @@ class LdapPlugin(SimplePlugin):
|
|
|
163
166
|
client_strategy=ldap3.ASYNC,
|
|
164
167
|
)
|
|
165
168
|
if not login_conn.bind():
|
|
166
|
-
|
|
169
|
+
cherrypy.log(
|
|
170
|
+
f'ldap authentication failed username={username} reason=wrong_password',
|
|
171
|
+
context='LDAP',
|
|
172
|
+
severity=logging.WARNING,
|
|
173
|
+
)
|
|
167
174
|
return False
|
|
168
175
|
|
|
169
176
|
# Get username
|
|
170
177
|
attrs = response[0]['attributes']
|
|
171
178
|
new_username = first_attribute(attrs, self.username_attribute)
|
|
172
179
|
if not new_username:
|
|
173
|
-
|
|
174
|
-
"
|
|
175
|
-
|
|
176
|
-
|
|
180
|
+
cherrypy.log(
|
|
181
|
+
f"object missing username attribute user_dn={user_dn} attribute={self.username_attribute}",
|
|
182
|
+
context='LDAP',
|
|
183
|
+
severity=logging.WARNING,
|
|
177
184
|
)
|
|
178
185
|
return False
|
|
179
186
|
|
|
@@ -189,17 +196,15 @@ class LdapPlugin(SimplePlugin):
|
|
|
189
196
|
self.group_filter,
|
|
190
197
|
)
|
|
191
198
|
# Search LDAP Server for matching groups.
|
|
192
|
-
|
|
193
|
-
"check
|
|
194
|
-
|
|
195
|
-
' '.join(self.required_group),
|
|
199
|
+
cherrypy.log(
|
|
200
|
+
f"group check start username={user_value} required_groups={' '.join(self.required_group)}",
|
|
201
|
+
context='LDAP',
|
|
196
202
|
)
|
|
197
203
|
response = self._search(conn, group_filter, attributes=['cn'])
|
|
198
204
|
if not response:
|
|
199
|
-
|
|
200
|
-
"
|
|
201
|
-
|
|
202
|
-
' '.join(self.required_group),
|
|
205
|
+
cherrypy.log(
|
|
206
|
+
f"group check failed username={user_value} required_groups={' '.join(self.required_group)}",
|
|
207
|
+
context='LDAP',
|
|
203
208
|
)
|
|
204
209
|
return False
|
|
205
210
|
|
|
@@ -215,7 +220,9 @@ class LdapPlugin(SimplePlugin):
|
|
|
215
220
|
except LDAPInvalidCredentialsResult:
|
|
216
221
|
return False
|
|
217
222
|
except LDAPException:
|
|
218
|
-
|
|
223
|
+
cherrypy.log(
|
|
224
|
+
f"unexpected error username={username}", context='LDAP', severity=logging.ERROR, traceback=True
|
|
225
|
+
)
|
|
219
226
|
return None
|
|
220
227
|
|
|
221
228
|
def search(self, filter, attributes=ldap3.ALL_ATTRIBUTES, search_base=None, paged_size=None):
|
|
@@ -228,11 +235,10 @@ class LdapPlugin(SimplePlugin):
|
|
|
228
235
|
search_scope = {'base': ldap3.BASE, 'onelevel': ldap3.LEVEL, 'subtree': ldap3.SUBTREE}.get(
|
|
229
236
|
self.scope, ldap3.SUBTREE
|
|
230
237
|
)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
)
|
|
238
|
+
search_base = search_base or self.base_dn
|
|
239
|
+
cherrypy.log(f"search {self.uri}/{search_base}?{search_scope}?{filter}", context='LDAP')
|
|
234
240
|
msg_id = conn.search(
|
|
235
|
-
search_base=search_base
|
|
241
|
+
search_base=search_base,
|
|
236
242
|
search_filter=filter,
|
|
237
243
|
search_scope=search_scope,
|
|
238
244
|
time_limit=self.timeout,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Scheduler plugins for 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
|
|
@@ -13,147 +13,260 @@
|
|
|
13
13
|
#
|
|
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
|
-
|
|
17
|
-
'''
|
|
18
|
-
Created on Mar. 23, 2021
|
|
19
|
-
|
|
20
|
-
@author: Patrik Dufresne <patrik@ikus-soft.com>
|
|
21
|
-
'''
|
|
16
|
+
import copy
|
|
22
17
|
import logging
|
|
23
|
-
from datetime import datetime
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from functools import wraps
|
|
20
|
+
from threading import Event, RLock
|
|
24
21
|
|
|
25
22
|
import cherrypy
|
|
26
|
-
from apscheduler.
|
|
27
|
-
from apscheduler.jobstores.memory import MemoryJobStore
|
|
23
|
+
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED, EVENT_JOB_SUBMITTED
|
|
28
24
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
29
25
|
from cherrypy.process.plugins import SimplePlugin
|
|
30
26
|
|
|
31
27
|
logger = logging.getLogger(__name__)
|
|
32
28
|
|
|
33
29
|
|
|
34
|
-
def
|
|
30
|
+
def clear_db_sessions(func):
|
|
35
31
|
"""
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if any job are still running.
|
|
32
|
+
A decorator that ensures database connections that have become unusable, or are obsolete, are closed
|
|
33
|
+
before and after the job is executed.
|
|
39
34
|
"""
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
@wraps(func)
|
|
37
|
+
def func_wrapper(*args, **kwargs):
|
|
38
|
+
cherrypy.db.clear_sessions()
|
|
44
39
|
try:
|
|
45
|
-
func(*args, **kwargs)
|
|
40
|
+
result = func(*args, **kwargs)
|
|
46
41
|
finally:
|
|
47
42
|
cherrypy.db.clear_sessions()
|
|
48
|
-
|
|
43
|
+
return result
|
|
49
44
|
|
|
50
|
-
|
|
51
|
-
return wrapper
|
|
45
|
+
return func_wrapper
|
|
52
46
|
|
|
53
47
|
|
|
54
48
|
class Scheduler(SimplePlugin):
|
|
55
49
|
"""
|
|
56
50
|
Plugins to run Job at fixed time (cronjob) and to schedule task to be run on by one.
|
|
51
|
+
|
|
52
|
+
Configuration:
|
|
53
|
+
- jobstore. Take value 'memory' (default), 'db' to use SQLalchemy database in combination with cherrypy.db. Or any callable function to create an instance of BaseJobStore
|
|
57
54
|
"""
|
|
58
55
|
|
|
56
|
+
jobstores = {"default": {"type": "memory"}}
|
|
57
|
+
executors = {"default": {"type": "threadpool", "max_workers": 10}}
|
|
58
|
+
job_defaults = {"coalesce": False, "max_instances": 1}
|
|
59
|
+
timezone = None
|
|
60
|
+
|
|
59
61
|
def __init__(self, bus):
|
|
60
62
|
super().__init__(bus)
|
|
61
|
-
self._scheduler =
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
self._scheduler = None
|
|
64
|
+
# Let keep track of running jobs manually.
|
|
65
|
+
# This is required for unit testing to wait for all running task.
|
|
66
|
+
self._lock = RLock()
|
|
67
|
+
self._event = Event()
|
|
68
|
+
self._submitted_jobs = set()
|
|
69
|
+
self._completed_jobs = set()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def _running_jobs(self):
|
|
73
|
+
with self._lock:
|
|
74
|
+
return self._submitted_jobs
|
|
75
|
+
|
|
76
|
+
def _track_running_jobs(self, event):
|
|
77
|
+
"""
|
|
78
|
+
This listener keep track of running jobs.
|
|
79
|
+
This is required to implement wait for tasks mostly used during unit testing.
|
|
80
|
+
"""
|
|
81
|
+
with self._lock:
|
|
82
|
+
# Special care must be taken to handle race condition
|
|
83
|
+
# the EVENT_JOB_EXECUTED may get called before EVENT_JOB_SUBMITTED
|
|
84
|
+
if event.code == EVENT_JOB_SUBMITTED:
|
|
85
|
+
if event.job_id in self._completed_jobs:
|
|
86
|
+
self._completed_jobs.remove(event.job_id)
|
|
87
|
+
else:
|
|
88
|
+
self._submitted_jobs.add(event.job_id)
|
|
89
|
+
elif event.code in (EVENT_JOB_EXECUTED, EVENT_JOB_ERROR):
|
|
90
|
+
if event.job_id in self._submitted_jobs:
|
|
91
|
+
self._submitted_jobs.remove(event.job_id)
|
|
92
|
+
else:
|
|
93
|
+
self._completed_jobs.add(event.job_id)
|
|
94
|
+
# Wakeup wait_for_tasks.
|
|
95
|
+
self._event.set()
|
|
96
|
+
|
|
97
|
+
def _drop_table(self, target, conn, **kwargs):
|
|
98
|
+
"""
|
|
99
|
+
Callback listener to drop table.
|
|
100
|
+
This is required because SQLAlchemyJobStore creates the table manually.
|
|
101
|
+
"""
|
|
102
|
+
from sqlalchemy.sql import ddl
|
|
103
|
+
|
|
104
|
+
# Here we need to use private API to loop over all sqlalchemy job store.
|
|
105
|
+
for jobstore in self._scheduler._jobstores.values():
|
|
106
|
+
table = getattr(jobstore, 'jobs_t', None)
|
|
107
|
+
if table is not None:
|
|
108
|
+
conn.execute(ddl.DropTable(table, if_exists=True))
|
|
109
|
+
|
|
110
|
+
def _create_table(self, target, conn, **kwargs):
|
|
111
|
+
"""
|
|
112
|
+
Callback listener to drop table.
|
|
113
|
+
This is required because SQLAlchemyJobStore creates the table manually.
|
|
114
|
+
"""
|
|
115
|
+
from sqlalchemy.sql import ddl
|
|
116
|
+
|
|
117
|
+
# Here we need to use private API to loop over all sqlalchemy job store.
|
|
118
|
+
for jobstore in self._scheduler._jobstores.values():
|
|
119
|
+
table = getattr(jobstore, 'jobs_t', None)
|
|
120
|
+
if table is not None:
|
|
121
|
+
conn.execute(ddl.CreateTable(table, if_not_exists=True))
|
|
122
|
+
|
|
123
|
+
def _db_events(self, listen_or_remove):
|
|
124
|
+
"""
|
|
125
|
+
Listen on database events to create and drop tables as required.
|
|
126
|
+
"""
|
|
127
|
+
assert listen_or_remove in ['listen', 'remove']
|
|
128
|
+
try:
|
|
129
|
+
from sqlalchemy import event
|
|
130
|
+
|
|
131
|
+
if getattr(cherrypy, 'db', False):
|
|
132
|
+
base = cherrypy.db.get_base()
|
|
133
|
+
func = getattr(event, listen_or_remove)
|
|
134
|
+
func(base.metadata, 'after_drop', self._drop_table)
|
|
135
|
+
func(base.metadata, 'after_create', self._create_table)
|
|
136
|
+
except ImportError:
|
|
137
|
+
# Nothing to do if sqlalchemy is not available
|
|
138
|
+
pass
|
|
64
139
|
|
|
65
140
|
def _create_scheduler(self):
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
),
|
|
76
|
-
'scheduled': ThreadPoolExecutor(
|
|
77
|
-
max_workers=1,
|
|
78
|
-
pool_kwargs={'thread_name_prefix': 'scheduler-scheduled'},
|
|
79
|
-
),
|
|
80
|
-
},
|
|
141
|
+
"""
|
|
142
|
+
Create a new APScheduler base on plugin configuration.
|
|
143
|
+
"""
|
|
144
|
+
# Create the scheduler with deepcopy of configuration.
|
|
145
|
+
scheduler = BackgroundScheduler(
|
|
146
|
+
jobstores=copy.deepcopy(self.jobstores),
|
|
147
|
+
executors=copy.deepcopy(self.executors),
|
|
148
|
+
job_defaults=copy.deepcopy(self.job_defaults),
|
|
149
|
+
timezone=self.timezone,
|
|
81
150
|
)
|
|
151
|
+
# Add listener to keep track of running jobs.
|
|
152
|
+
scheduler.add_listener(self._track_running_jobs, (EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_SUBMITTED))
|
|
153
|
+
# Start the scheduler.
|
|
154
|
+
scheduler.start()
|
|
155
|
+
return scheduler
|
|
82
156
|
|
|
83
157
|
def start(self):
|
|
84
|
-
self.bus.log('Start Scheduler
|
|
85
|
-
|
|
86
|
-
self.
|
|
87
|
-
|
|
88
|
-
self.
|
|
158
|
+
self.bus.log('Start Scheduler plugin')
|
|
159
|
+
# Create apscheduler
|
|
160
|
+
self._scheduler = self._create_scheduler()
|
|
161
|
+
# Register drop_table event (when using database)
|
|
162
|
+
self._db_events('listen')
|
|
163
|
+
# Then register new channels
|
|
164
|
+
self.bus.subscribe('scheduler:add_job', self.add_job)
|
|
165
|
+
self.bus.subscribe('scheduler:add_job_daily', self.add_job_daily)
|
|
166
|
+
self.bus.subscribe('scheduler:add_job_now', self.add_job_now)
|
|
167
|
+
self.bus.subscribe('scheduler:remove_job', self.remove_job)
|
|
168
|
+
|
|
169
|
+
# Slightly lower priority to start after database, but before other plugins.
|
|
170
|
+
start.priority = 49
|
|
89
171
|
|
|
90
172
|
def stop(self):
|
|
91
|
-
self.bus.log('Stop Scheduler
|
|
92
|
-
|
|
93
|
-
self.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
self.
|
|
100
|
-
self.
|
|
101
|
-
self.
|
|
173
|
+
self.bus.log('Stop Scheduler plugin')
|
|
174
|
+
# Unregister drop_table event (when using database)
|
|
175
|
+
self._db_events('remove')
|
|
176
|
+
# Shutdown the scheduler & wait for running task to complete.
|
|
177
|
+
if self._scheduler:
|
|
178
|
+
self._scheduler.shutdown(wait=True)
|
|
179
|
+
self._scheduler = None
|
|
180
|
+
self.bus.unsubscribe('scheduler:add_job', self.add_job)
|
|
181
|
+
self.bus.unsubscribe('scheduler:add_job_daily', self.add_job_daily)
|
|
182
|
+
self.bus.unsubscribe('scheduler:add_job_now', self.add_job_now)
|
|
183
|
+
self.bus.unsubscribe('scheduler:remove_job', self.remove_job)
|
|
102
184
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
"""
|
|
107
|
-
|
|
185
|
+
stop.priority = 51
|
|
186
|
+
|
|
187
|
+
def graceful(self):
|
|
188
|
+
"""Reload of subscribers."""
|
|
189
|
+
self.stop()
|
|
190
|
+
self.start()
|
|
108
191
|
|
|
109
|
-
def
|
|
192
|
+
def add_job(self, func, *args, **kwargs):
|
|
110
193
|
"""
|
|
111
|
-
|
|
194
|
+
Called via engine.publish('scheduler:add_job', ...)
|
|
195
|
+
OR directly on the plugin instance.
|
|
112
196
|
"""
|
|
113
|
-
|
|
197
|
+
if not self._scheduler:
|
|
198
|
+
raise RuntimeError("Scheduler not running; cannot add job.")
|
|
114
199
|
|
|
115
|
-
|
|
116
|
-
return self._running
|
|
200
|
+
return self._scheduler.add_job(func, *args, **kwargs)
|
|
117
201
|
|
|
118
|
-
def
|
|
202
|
+
def add_job_daily(self, execution_time, func, *args, **kwargs):
|
|
119
203
|
"""
|
|
120
|
-
|
|
204
|
+
A convenience wrapper around add_job() that ensures
|
|
205
|
+
the job runs daily.
|
|
121
206
|
"""
|
|
122
|
-
assert hasattr(job, '__call__'), 'job must be callable'
|
|
123
207
|
hour, minute = execution_time.split(':', 2)
|
|
124
|
-
self.
|
|
125
|
-
func=
|
|
126
|
-
name=
|
|
208
|
+
return self.add_job(
|
|
209
|
+
func=func,
|
|
210
|
+
name=getattr(func, '__name__', str(func)),
|
|
127
211
|
args=args,
|
|
128
212
|
kwargs=kwargs,
|
|
129
213
|
trigger='cron',
|
|
130
214
|
hour=hour,
|
|
131
215
|
minute=minute,
|
|
132
|
-
|
|
133
|
-
executor='scheduled',
|
|
216
|
+
misfire_grace_time=None,
|
|
134
217
|
)
|
|
135
218
|
|
|
136
|
-
def
|
|
219
|
+
def add_job_now(self, func, *args, **kwargs):
|
|
137
220
|
"""
|
|
138
|
-
|
|
221
|
+
A convenience wrapper around add_job() that ensures
|
|
222
|
+
the job runs immediately upon being added.
|
|
139
223
|
"""
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
name=task.__name__,
|
|
224
|
+
return self.add_job(
|
|
225
|
+
func=func,
|
|
226
|
+
name=getattr(func, '__name__', str(func)),
|
|
144
227
|
args=args,
|
|
145
228
|
kwargs=kwargs,
|
|
146
|
-
next_run_time=datetime.now(),
|
|
229
|
+
next_run_time=datetime.now(timezone.utc),
|
|
230
|
+
misfire_grace_time=None,
|
|
147
231
|
)
|
|
148
232
|
|
|
149
|
-
def
|
|
233
|
+
def get_jobs(self, jobstore=None):
|
|
234
|
+
"""
|
|
235
|
+
Return list of scheduled jobs.
|
|
236
|
+
"""
|
|
237
|
+
if not self._scheduler:
|
|
238
|
+
raise RuntimeError("Scheduler not running; cannot get jobs.")
|
|
239
|
+
return self._scheduler.get_jobs(jobstore=jobstore)
|
|
240
|
+
|
|
241
|
+
def remove_job(self, job, jobstore=None):
|
|
150
242
|
"""
|
|
151
243
|
Remove the given job from scheduler.
|
|
152
244
|
"""
|
|
153
245
|
# Search for a matching job
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
246
|
+
for j in self._scheduler.get_jobs(jobstore=jobstore):
|
|
247
|
+
if j.func == job:
|
|
248
|
+
self._scheduler.remove_job(job_id=j.id, jobstore=jobstore)
|
|
249
|
+
|
|
250
|
+
def remove_all_jobs(self, jobstore=None):
|
|
251
|
+
"""
|
|
252
|
+
Remove all jobs from scheduler.
|
|
253
|
+
"""
|
|
254
|
+
self._scheduler.remove_all_jobs(jobstore=jobstore)
|
|
255
|
+
|
|
256
|
+
def wait_for_jobs(self, jobstore=None):
|
|
257
|
+
"""
|
|
258
|
+
Used to wait for all running jobs to complete.
|
|
259
|
+
"""
|
|
260
|
+
# Wait until the queue is empty.
|
|
261
|
+
while any(
|
|
262
|
+
job for job in self._scheduler.get_jobs(jobstore=jobstore) if job.next_run_time < datetime.now(timezone.utc)
|
|
263
|
+
):
|
|
264
|
+
self._event.wait(timeout=1)
|
|
265
|
+
self._event.clear()
|
|
266
|
+
# Wait until all jobs are completed.
|
|
267
|
+
while self._running_jobs:
|
|
268
|
+
self._event.wait(timeout=1)
|
|
269
|
+
self._event.clear()
|
|
157
270
|
|
|
158
271
|
|
|
159
272
|
# Register Scheduler plugin
|