cherrypy-foundation 1.0.0a1__py3-none-any.whl → 1.0.0a3__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 (33) hide show
  1. cherrypy_foundation/components/Field.jinja +14 -6
  2. cherrypy_foundation/components/Typeahead.css +6 -1
  3. cherrypy_foundation/components/Typeahead.jinja +2 -2
  4. cherrypy_foundation/components/__init__.py +1 -1
  5. cherrypy_foundation/error_page.py +18 -15
  6. cherrypy_foundation/plugins/db.py +34 -8
  7. cherrypy_foundation/plugins/ldap.py +28 -22
  8. cherrypy_foundation/plugins/scheduler.py +197 -84
  9. cherrypy_foundation/plugins/smtp.py +71 -45
  10. cherrypy_foundation/plugins/tests/test_db.py +2 -2
  11. cherrypy_foundation/plugins/tests/test_scheduler.py +58 -50
  12. cherrypy_foundation/plugins/tests/test_smtp.py +9 -6
  13. cherrypy_foundation/tests/templates/test_form.html +10 -0
  14. cherrypy_foundation/tests/test_form.py +119 -0
  15. cherrypy_foundation/tools/auth.py +28 -11
  16. cherrypy_foundation/tools/auth_mfa.py +10 -13
  17. cherrypy_foundation/tools/errors.py +1 -1
  18. cherrypy_foundation/tools/i18n.py +15 -6
  19. cherrypy_foundation/tools/jinja2.py +15 -12
  20. cherrypy_foundation/tools/ratelimit.py +5 -9
  21. cherrypy_foundation/tools/secure_headers.py +0 -4
  22. cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
  23. cherrypy_foundation/tools/tests/templates/test_jinja2.html +10 -0
  24. cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
  25. cherrypy_foundation/tools/tests/templates/test_jinjax.html +8 -0
  26. cherrypy_foundation/tools/tests/test_auth.py +1 -1
  27. cherrypy_foundation/tools/tests/test_auth_mfa.py +367 -0
  28. cherrypy_foundation/tools/tests/test_jinja2.py +123 -0
  29. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/METADATA +1 -1
  30. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/RECORD +33 -25
  31. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/WHEEL +0 -0
  32. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/licenses/LICENSE.md +0 -0
  33. {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/top_level.txt +0 -0
@@ -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
@@ -100,6 +100,26 @@ def _formataddr(value):
100
100
  return ', '.join(map(_formataddr, value))
101
101
 
102
102
 
103
+ def _send_message(server, encryption, username, password, msg):
104
+ """
105
+ Send message using SMTP.
106
+ """
107
+ host, unused, port = server.partition(':')
108
+ if encryption == 'ssl':
109
+ conn = smtplib.SMTP_SSL(host, port or 465)
110
+ else:
111
+ conn = smtplib.SMTP(host, port or 25)
112
+ try:
113
+ if encryption == 'starttls':
114
+ conn.starttls()
115
+ # Authenticate if required.
116
+ if username:
117
+ conn.login(username, password)
118
+ conn.send_message(msg)
119
+ finally:
120
+ conn.quit()
121
+
122
+
103
123
  class SmtpPlugin(SimplePlugin):
104
124
 
105
125
  server = None
@@ -108,6 +128,32 @@ class SmtpPlugin(SimplePlugin):
108
128
  encryption = None
109
129
  email_from = None
110
130
 
131
+ def _create_msg(self, subject: str, message: str, to=None, cc=None, bcc=None, reply_to=None, headers={}):
132
+ assert subject
133
+ assert message
134
+ assert to or bcc
135
+
136
+ # Record the MIME types of both parts - text/plain and text/html.
137
+ msg = MIMEMultipart('alternative')
138
+ msg['Subject'] = str(subject)
139
+ msg['From'] = _formataddr(self.email_from)
140
+ if to:
141
+ msg['To'] = _formataddr(to)
142
+ if cc:
143
+ msg['Cc'] = _formataddr(cc)
144
+ if bcc:
145
+ msg['Bcc'] = _formataddr(bcc)
146
+ if reply_to:
147
+ msg['Reply-To'] = _formataddr(reply_to)
148
+ msg['Message-ID'] = email.utils.make_msgid()
149
+ if headers:
150
+ for key, value in headers.items():
151
+ msg[key] = value
152
+ text = _html2plaintext(message)
153
+ msg.attach(MIMEText(text, 'plain', 'utf8'))
154
+ msg.attach(MIMEText(message, 'html', 'utf8'))
155
+ return msg
156
+
111
157
  def start(self):
112
158
  self.bus.log('Start SMTP plugin')
113
159
  self.bus.subscribe("send_mail", self.send_mail)
@@ -118,70 +164,50 @@ class SmtpPlugin(SimplePlugin):
118
164
  self.bus.unsubscribe("send_mail", self.send_mail)
119
165
  self.bus.unsubscribe("queue_mail", self.queue_mail)
120
166
 
167
+ def graceful(self):
168
+ """Reload of subscribers."""
169
+ self.stop()
170
+ self.start()
171
+
121
172
  def queue_mail(self, *args, **kwargs):
122
173
  """
123
174
  Queue mail to be sent.
124
175
  """
125
176
  # Skip sending email if smtp server is not configured.
126
177
  if not self.server:
127
- self.bus.log('cannot send email because SMTP Server is not configured')
178
+ cherrypy.log('cannot send email because SMTP Server is not configured', context='SMTP')
128
179
  return
129
180
  if not self.email_from:
130
- self.bus.log('cannot send email because SMTP From is not configured')
181
+ cherrypy.log('cannot send email because SMTP From is not configured', context='SMTP')
131
182
  return
132
- self.bus.publish('schedule_task', self.send_mail, *args, **kwargs)
133
-
134
- def send_mail(self, subject: str, message: str, to=None, cc=None, bcc=None, reply_to=None, headers={}):
183
+ msg = self._create_msg(*args, **kwargs)
184
+ self.bus.publish(
185
+ 'scheduler:add_job_now',
186
+ _send_message,
187
+ server=self.server,
188
+ encryption=self.encryption,
189
+ username=self.username,
190
+ password=self.password,
191
+ msg=msg,
192
+ )
193
+
194
+ def send_mail(self, *args, **kwargs):
135
195
  """
136
196
  Reusable method to be called to send email to the user user.
137
197
  `user` user object where to send the email.
138
198
  """
139
- assert subject
140
- assert message
141
- assert to or bcc
142
-
143
199
  # Skip sending email if smtp server is not configured.
144
200
  if not self.server:
145
- self.bus.log('cannot send email because SMTP Server is not configured')
201
+ cherrypy.log('cannot send email because SMTP Server is not configured', context='SMTP')
146
202
  return
147
203
  if not self.email_from:
148
- self.bus.log('cannot send email because SMTP From is not configured')
204
+ cherrypy.log('cannot send email because SMTP From is not configured', context='SMTP')
149
205
  return
150
206
 
151
- # Record the MIME types of both parts - text/plain and text/html.
152
- msg = MIMEMultipart('alternative')
153
- msg['Subject'] = str(subject)
154
- msg['From'] = _formataddr(self.email_from)
155
- if to:
156
- msg['To'] = _formataddr(to)
157
- if cc:
158
- msg['Cc'] = _formataddr(cc)
159
- if bcc:
160
- msg['Bcc'] = _formataddr(bcc)
161
- if reply_to:
162
- msg['Reply-To'] = _formataddr(reply_to)
163
- msg['Message-ID'] = email.utils.make_msgid()
164
- if headers:
165
- for key, value in headers.items():
166
- msg[key] = value
167
- text = _html2plaintext(message)
168
- msg.attach(MIMEText(text, 'plain', 'utf8'))
169
- msg.attach(MIMEText(message, 'html', 'utf8'))
170
-
171
- host, unused, port = self.server.partition(':')
172
- if self.encryption == 'ssl':
173
- conn = smtplib.SMTP_SSL(host, port or 465)
174
- else:
175
- conn = smtplib.SMTP(host, port or 25)
176
- try:
177
- if self.encryption == 'starttls':
178
- conn.starttls()
179
- # Authenticate if required.
180
- if self.username:
181
- conn.login(self.username, self.password)
182
- conn.send_message(msg)
183
- finally:
184
- conn.quit()
207
+ msg = self._create_msg(*args, **kwargs)
208
+ _send_message(
209
+ server=self.server, encryption=self.encryption, username=self.username, password=self.password, msg=msg
210
+ )
185
211
 
186
212
 
187
213
  # Register SMTP plugin
@@ -93,7 +93,7 @@ else:
93
93
  # Given an empty database
94
94
  self.assertEqual(0, User.query.count())
95
95
  # When calling "/add/" to create a user
96
- self.getPage('/add', method='POST', body=urlencode([('username', 'myuser')]))
96
+ self.getPage('/add', method='POST', body=urlencode({'username': 'myuser'}))
97
97
  self.assertStatus(200)
98
98
  self.assertInBody('OK')
99
99
  # Then user get added to database
@@ -103,7 +103,7 @@ else:
103
103
  # Given a database with a users
104
104
  User(username='user1').add().commit()
105
105
  # When trying to add another user
106
- self.getPage('/add', method='POST', body=urlencode([('username', 'user1')]))
106
+ self.getPage('/add', method='POST', body=urlencode({'username': 'user1'}))
107
107
  # Then an error get raised
108
108
  self.assertStatus(200)
109
109
  self.assertInBody('user_username_unique_ix')
@@ -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
@@ -14,79 +14,87 @@
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
- """
18
- Created on Oct 17, 2015
19
-
20
- @author: Patrik Dufresne <patrik@ikus-soft.com>
21
- """
22
- import importlib.util
23
- import unittest
24
- from time import sleep
17
+ from datetime import datetime, timezone
18
+ from threading import Event
25
19
 
26
20
  import cherrypy
27
21
  from cherrypy.test import helper
28
22
 
29
- HAS_APSCHEDULER = importlib.util.find_spec("apscheduler") is not None
23
+ from .. import scheduler # noqa
24
+
25
+ done = Event()
30
26
 
31
- if HAS_APSCHEDULER:
32
- from .. import scheduler # noqa
27
+
28
+ def a_task(*args, **kwargs):
29
+ done.set()
33
30
 
34
31
 
35
- @unittest.skipUnless(HAS_APSCHEDULER, "apscheduler not installed")
36
32
  class SchedulerPluginTest(helper.CPWebCase):
37
33
  def setUp(self) -> None:
38
- self.called = False
34
+ done.clear()
35
+ cherrypy.scheduler.remove_all_jobs()
39
36
  return super().setUp()
40
37
 
38
+ def tearDown(self):
39
+ return super().tearDown()
40
+
41
41
  @classmethod
42
42
  def setup_server(cls):
43
43
  pass
44
44
 
45
- def test_schedule_job(self):
46
- # Given a scheduler with a specific number of jobs
47
- count = len(cherrypy.scheduler.list_jobs())
48
-
49
- # Given a job schedule every seconds
50
- def a_job(*args, **kwargs):
51
- self.called = True
45
+ def test_add_job(self):
46
+ # Given a scheduled job
47
+ scheduled = cherrypy.engine.publish(
48
+ 'scheduler:add_job',
49
+ a_task,
50
+ name='custom_name',
51
+ args=(1, 2, 3),
52
+ kwargs={'foo': 'bar'},
53
+ next_run_time=datetime.now(timezone.utc),
54
+ misfire_grace_time=None,
55
+ )
56
+ self.assertTrue(scheduled)
57
+ self.assertEqual('custom_name', scheduled[0].name)
58
+ # When waiting for all jobs
59
+ cherrypy.scheduler.wait_for_jobs()
60
+ # Then the job is done
61
+ self.assertTrue(done.is_set())
52
62
 
53
- scheduled = cherrypy.engine.publish('schedule_job', '23:00', a_job, 1, 2, 3, foo=1, bar=2)
63
+ def test_add_job_daily(self):
64
+ # Given a scheduler with a specific number of jobs
65
+ count = len(cherrypy.scheduler.get_jobs())
66
+ # When scheduling a daily job.
67
+ scheduled = cherrypy.engine.publish('scheduler:add_job_daily', '23:00', a_task, 1, 2, 3, foo=1, bar=2)
68
+ # Then the job is scheduled
54
69
  self.assertTrue(scheduled)
55
- self.assertEqual(count + 1, len(cherrypy.scheduler.list_jobs()))
70
+ self.assertEqual('a_task', scheduled[0].name)
71
+ # Then the number of jobs increase.
72
+ self.assertEqual(count + 1, len(cherrypy.scheduler.get_jobs()))
56
73
 
57
- def test_scheduler_task(self):
74
+ def test_add_job_now(self):
58
75
  # Given a task
59
-
60
- def a_task(*args, **kwargs):
61
- self.called = True
62
-
63
76
  # When scheduling that task
64
- scheduled = cherrypy.engine.publish('schedule_task', a_task, 1, 2, 3, foo=1, bar=2)
77
+ scheduled = cherrypy.engine.publish('scheduler:add_job_now', a_task, 1, 2, 3, foo=1, bar=2)
65
78
  self.assertTrue(scheduled)
66
- sleep(1)
67
- while len(cherrypy.scheduler.list_tasks()) >= 1:
68
- sleep(1)
79
+ # When waiting for all tasks
80
+ cherrypy.scheduler.wait_for_jobs()
69
81
  # Then the task get called
70
- self.assertTrue(self.called)
82
+ self.assertTrue(done.is_set())
71
83
 
72
- def test_unschedule_job(self):
84
+ def test_remove_job(self):
73
85
  # Given a scheduler with a specific number of jobs
74
- count = len(cherrypy.scheduler.list_jobs())
75
-
86
+ count = len(cherrypy.scheduler.get_jobs())
76
87
  # Given a job schedule every seconds
77
- def a_job(*args, **kwargs):
78
- self.called = True
79
-
80
- cherrypy.engine.publish('schedule_job', '23:00', a_job, 1, 2, 3, foo=1, bar=2)
81
- self.assertEqual(count + 1, len(cherrypy.scheduler.list_jobs()))
82
- cherrypy.engine.publish('unschedule_job', a_job)
83
- self.assertEqual(count, len(cherrypy.scheduler.list_jobs()))
84
-
85
- def test_unschedule_job_with_invalid_job(self):
88
+ cherrypy.engine.publish('scheduler:add_job_daily', '23:00', a_task, 1, 2, 3, foo=1, bar=2)
89
+ # Then number of job increase
90
+ self.assertEqual(count + 1, len(cherrypy.scheduler.get_jobs()))
91
+ # When the job is unscheduled.
92
+ cherrypy.engine.publish('scheduler:remove_job', a_task)
93
+ # Then the number of job decrease.
94
+ self.assertEqual(count, len(cherrypy.scheduler.get_jobs()))
95
+
96
+ def test_remove_job_with_invalid_job(self):
86
97
  # Given an unschedule job
87
- def a_job(*args, **kwargs):
88
- self.called = True
89
-
90
98
  # When unscheduling an invalid job
91
- cherrypy.engine.publish('unschedule_job', a_job)
92
- # Then no error are raised
99
+ cherrypy.engine.publish('scheduler:remove_job', a_task)
100
+ # Then an error is not raised.