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.
Files changed (31) hide show
  1. cherrypy_foundation/components/Field.jinja +14 -6
  2. cherrypy_foundation/components/__init__.py +1 -1
  3. cherrypy_foundation/error_page.py +18 -15
  4. cherrypy_foundation/plugins/db.py +34 -8
  5. cherrypy_foundation/plugins/ldap.py +28 -22
  6. cherrypy_foundation/plugins/scheduler.py +197 -84
  7. cherrypy_foundation/plugins/smtp.py +71 -45
  8. cherrypy_foundation/plugins/tests/test_db.py +2 -2
  9. cherrypy_foundation/plugins/tests/test_scheduler.py +58 -50
  10. cherrypy_foundation/plugins/tests/test_smtp.py +9 -6
  11. cherrypy_foundation/tests/templates/test_form.html +10 -0
  12. cherrypy_foundation/tests/test_form.py +119 -0
  13. cherrypy_foundation/tools/auth.py +28 -11
  14. cherrypy_foundation/tools/auth_mfa.py +10 -13
  15. cherrypy_foundation/tools/errors.py +1 -1
  16. cherrypy_foundation/tools/i18n.py +15 -6
  17. cherrypy_foundation/tools/jinja2.py +15 -12
  18. cherrypy_foundation/tools/ratelimit.py +5 -9
  19. cherrypy_foundation/tools/secure_headers.py +0 -4
  20. cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
  21. cherrypy_foundation/tools/tests/templates/test_jinja2.html +10 -0
  22. cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
  23. cherrypy_foundation/tools/tests/templates/test_jinjax.html +8 -0
  24. cherrypy_foundation/tools/tests/test_auth.py +1 -1
  25. cherrypy_foundation/tools/tests/test_auth_mfa.py +367 -0
  26. cherrypy_foundation/tools/tests/test_jinja2.py +123 -0
  27. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/METADATA +1 -1
  28. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/RECORD +31 -23
  29. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/WHEEL +0 -0
  30. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a2.dist-info}/licenses/LICENSE.md +0 -0
  31. {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
- {% set container_class = attrs.get('container_class', 'col-12') %}
38
- {% do attrs.__delitem__('container_class') %}
39
- <div class="mb-2 form-field {{ container_class }}">
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(class=label_class) }}{% endif %}
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(class=label_class) }}{% endif %}
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 %}
@@ -1,4 +1,4 @@
1
- # udb, A web interface to manage IT network
1
+ # Cherrypy Foundation
2
2
  # Copyright (C) 2025 IKUS Software inc.
3
3
  #
4
4
  # This program is free software: you can redistribute it and/or modify
@@ -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(**kwargs):
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 kwargs.get('status', '').startswith('500'):
64
- logger.error(
65
- 'error page: %s %s\n%s' % (kwargs.get('status', ''), kwargs.get('message', ''), kwargs.get('traceback', ''))
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 kwargs.get('status', '') == '404 Not Found':
70
- kwargs['message'] = 'Nothing matches the given URI'
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 kwargs.get('message')
74
+ return message
78
75
  elif mtype == 'application/json':
79
- return json.dumps({'message': kwargs.get('message', ''), 'status': kwargs.get('status', '')})
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', vars=kwargs)
82
+ return cherrypy.tools.jinja2.render_request(template='error_page.html', context=context)
85
83
  except Exception:
86
- logger.exception('fail to render error page')
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 % kwargs
91
+ return HTML_ERROR_TEMPLATE % context
89
92
 
90
93
  # Fallback to raw error message.
91
- return kwargs.get('message')
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("Database session plugin started.")
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("Database session plugin stopped.")
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
- changes = ', '.join([str(_get_model_changes(obj)) for obj in self._session.dirty])
246
- logger.exception(
247
- 'session is dirty, some database object(s) are not commited, this indicate a bug in the application '
248
- 'dirty %s new %s deleted %s' % (changes, self._session.new, self._session.deleted)
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
- logger.info("user %s not found in LDAP", username)
154
+ cherrypy.log(f"lookup failed username={username} reason=not_found", context='LDAP')
152
155
  return False
153
- logger.info("user %s found in LDAP", username)
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
- logger.warning("LDAP authentication failed for user %s", username)
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
- logger.info(
174
- "user object %s was found but the username attribute %s doesn't exists",
175
- user_dn,
176
- self.username_attribute,
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
- logger.info(
193
- "check if user %s is member of any group %s",
194
- user_value,
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
- logger.info(
200
- "user %s was found but is not member of any group(s) %s",
201
- user_value,
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
- logger.exception("can't validate user %s credentials", username)
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
- logger.debug(
232
- "search ldap server: {}/{}?{}?{}".format(self.uri, search_base or self.base_dn, search_scope, filter)
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 or self.base_dn,
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) 2022-2025 IKUS Software
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.executors.pool import ThreadPoolExecutor
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 catch_exception(scheduler, func):
30
+ def clear_db_sessions(func):
35
31
  """
36
- Wrapper function to execute job.
37
- Primarly to handle database connection rollback and also to keep track
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
- def wrapper(*args, **kwargs):
42
- ident = object()
43
- scheduler._running.append(ident)
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
- scheduler._running.remove(ident)
43
+ return result
49
44
 
50
- wrapper._func = func
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 = self._create_scheduler()
62
- self._scheduler.start(paused=True)
63
- self._running = []
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
- return BackgroundScheduler(
67
- jobstores={
68
- 'default': MemoryJobStore(),
69
- 'scheduled': MemoryJobStore(),
70
- },
71
- executors={
72
- 'default': ThreadPoolExecutor(
73
- max_workers=1,
74
- pool_kwargs={'thread_name_prefix': 'scheduler-default'},
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 plugins')
85
- self._scheduler.resume()
86
- self.bus.subscribe('schedule_job', self.schedule_job)
87
- self.bus.subscribe('schedule_task', self.schedule_task)
88
- self.bus.subscribe('unschedule_job', self.unschedule_job)
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 plugins')
92
- self._scheduler.pause()
93
- self.bus.unsubscribe('schedule_job', self.schedule_job)
94
- self.bus.unsubscribe('unschedule_job', self.unschedule_job)
95
- self.bus.unsubscribe('schedule_task', self.schedule_task)
96
-
97
- def exit(self):
98
- # Shutdown scheduler and create a new one in case the engine get started again.
99
- self._scheduler.shutdown(wait=True)
100
- self._scheduler = self._create_scheduler()
101
- self._scheduler.start(paused=True)
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
- def list_jobs(self):
104
- """
105
- Return list of scheduled jobs.
106
- """
107
- return self._scheduler.get_jobs(jobstore='scheduled')
185
+ stop.priority = 51
186
+
187
+ def graceful(self):
188
+ """Reload of subscribers."""
189
+ self.stop()
190
+ self.start()
108
191
 
109
- def list_tasks(self):
192
+ def add_job(self, func, *args, **kwargs):
110
193
  """
111
- Return list of tasks.
194
+ Called via engine.publish('scheduler:add_job', ...)
195
+ OR directly on the plugin instance.
112
196
  """
113
- return self._scheduler.get_jobs(jobstore='default')
197
+ if not self._scheduler:
198
+ raise RuntimeError("Scheduler not running; cannot add job.")
114
199
 
115
- def is_job_running(self):
116
- return self._running
200
+ return self._scheduler.add_job(func, *args, **kwargs)
117
201
 
118
- def schedule_job(self, execution_time, job, *args, **kwargs):
202
+ def add_job_daily(self, execution_time, func, *args, **kwargs):
119
203
  """
120
- Add the given scheduled job to the scheduler.
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._scheduler.add_job(
125
- func=catch_exception(self, job),
126
- name=job.__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
- jobstore='scheduled',
133
- executor='scheduled',
216
+ misfire_grace_time=None,
134
217
  )
135
218
 
136
- def schedule_task(self, task, *args, **kwargs):
219
+ def add_job_now(self, func, *args, **kwargs):
137
220
  """
138
- Add the given task to be execute immediately in background.
221
+ A convenience wrapper around add_job() that ensures
222
+ the job runs immediately upon being added.
139
223
  """
140
- assert hasattr(task, '__call__'), 'task must be callable'
141
- self._scheduler.add_job(
142
- func=catch_exception(self, task),
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 unschedule_job(self, job):
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
- job_id = next((j.id for j in self._scheduler.get_jobs(jobstore='scheduled') if j.func._func == job), None)
155
- if job_id:
156
- self._scheduler.remove_job(job_id=job_id, jobstore='scheduled')
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