clockwork 0.1.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.
@@ -0,0 +1,537 @@
1
+ from clockwork import Date, elapsed_time
2
+ from iterlab import to_iter
3
+ from pathpilot import Folder
4
+ from clockwork.chronicle import Logger
5
+ from contextlib import redirect_stdout
6
+ from schedule import Scheduler, CancelJob
7
+ import datetime
8
+ import time
9
+ import uuid
10
+ import os
11
+
12
+
13
+
14
+
15
+ #+---------------------------------------------------------------------------+
16
+ # Classes
17
+ #+---------------------------------------------------------------------------+
18
+
19
+ class PrerequisiteError(Exception):
20
+ ''' exception used to indicate a prerequisite condition has not been met '''
21
+ def __init__(self, message):
22
+ super().__init__(message)
23
+
24
+
25
+
26
+ class ContinueFailedJob(object):
27
+ ''' can be returned to continue running a failed job '''
28
+ pass
29
+
30
+
31
+
32
+ class SmartScheduler(Scheduler):
33
+ '''
34
+ Description
35
+ --------------------
36
+ Custom implementation of schedule.Scheduler designed to interact with SmartJob objects.
37
+ This gives the user complete control over the behavior of individual jobs.
38
+ '''
39
+
40
+ def __init__(self):
41
+ super().__init__()
42
+
43
+
44
+ def _run_job(self, job):
45
+ ret = job.run()
46
+ if ret is CancelJob:
47
+ self.cancel_job(job)
48
+ elif ret is ContinueFailedJob:
49
+ job.last_run = datetime.datetime.now()
50
+ job._schedule_next_run()
51
+
52
+
53
+
54
+ class SmartJob(object):
55
+ '''
56
+ Description
57
+ --------------------
58
+ job object designed to be used in conjunction with SmartScheduler as the 'job' argument
59
+ in self._run_job()
60
+
61
+ Class Attributes
62
+ --------------------
63
+ verbose : bool
64
+ If True, class content is printed to console.
65
+ disable_print : bool
66
+ If True, printing is suppressed while func runs. Must be True if verbose is True.
67
+ cascade_status : str
68
+ cascade status
69
+
70
+ Instance Attributes
71
+ --------------------
72
+ name : str
73
+ name of job
74
+ at : str
75
+ at time string
76
+ expiry : Date
77
+ If not None, job will be set inactive and stop running after this datetime
78
+ func : func
79
+ function to run
80
+ args : tuple
81
+ func arguments
82
+ kwargs : dict
83
+ func key word arguments
84
+ cancel_on_failure : bool
85
+ If True, the job will be cancelled if it raises an exception.
86
+ cancel_on_completion : bool
87
+ If True, the job will be cancelled if it completed successfully (i.e. job will run only once).
88
+ notify_on_failure : bool
89
+ If True, an email notification is sent to my inbox that includes the traceback. cancel_on_failure
90
+ and cascade are automatically set to True if this argument is True (prevents endless spam).
91
+ restrict_to_business_hours : bool
92
+ If True, the job will only execute during business hours. This is a more restrictive version of restrict_to_business_days.
93
+ restrict_to_business_days : bool
94
+ If True, the job will only execute during weekdays (i.e. not weekends).
95
+ cascade : bool
96
+ If True, when the job (denoted by the 'name' argument) is reflected multiple times in the jobs table
97
+ due to having multiple 'at' values, changes in activiation in one will cascade to all others. For example,
98
+ consider the job named 'my job' which is scheduled at 8:00 AM and 5:00 PM that is cancelled on completion.
99
+ If the 8:00 AM completes successfully then the job with that 'at' time will be set to inactive and have its
100
+ status updated in the table accordingly. Under default behavior, the 5:00 PM run will be unaffected by the
101
+ completion of the 8:00 AM run, however, if cascade is set to True then the 5:00 PM run will also receive
102
+ the same updates.
103
+ attempts : int
104
+ If greater than 1, the job will be attempted this number of times before being cancelled.
105
+ status : str | None
106
+ current status of the job
107
+ '''
108
+
109
+ verbose = True
110
+ disable_print = True
111
+ cascade_status = 'cancelled on cascade'
112
+
113
+
114
+ def __init__(self, name, at, expiry, func, args=(), kwargs={}, cancel_on_failure=False, cancel_on_completion=False,
115
+ notify_on_failure=False, restrict_to_business_hours=False, restrict_to_business_days=False, cascade=True,
116
+ attempts=1):
117
+
118
+ if self.verbose and not self.disable_print:
119
+ raise NotImplementedError('if SmartJob.verbose is True then disable_print must be True to suppress intra-function prints.')
120
+
121
+ self.name = name
122
+ self.at = at
123
+ self.expiry = expiry
124
+ self.func = func
125
+ self.args = args
126
+ self.kwargs = kwargs
127
+ self.cancel_on_failure = cancel_on_failure
128
+ self.cancel_on_completion = cancel_on_completion
129
+ self.notify_on_failure = notify_on_failure
130
+ self.cascade = cascade
131
+ self.restrict_to_business_hours = restrict_to_business_hours
132
+ self.restrict_to_business_days = restrict_to_business_days
133
+ self.attempts = attempts
134
+ self.status = None
135
+
136
+
137
+
138
+ def update_table(self, active):
139
+
140
+ def exe(sql, status):
141
+ TaskMaster.db.execute(sql, (active, status, self.name, self.at))
142
+
143
+ sql = self.db.update_query(
144
+ tbl_name='jobs',
145
+ update_cols=['active','status'],
146
+ where_cols=['name','at']
147
+ )
148
+ exe(sql, self.status)
149
+
150
+ if self.cascade:
151
+ exe(sql.replace('[at] = ?', '[at] <> ?'), self.cascade_status)
152
+ for name, at in TaskMaster.jobs.keys():
153
+ if name == self.name:
154
+ TaskMaster.jobs[name, at].status = self.cascade_status
155
+
156
+
157
+
158
+ def send_email_notification(self):
159
+ raise NotImplementedError
160
+
161
+
162
+
163
+ def __call__(self):
164
+
165
+ logger = getattr(TaskMaster.logger, 'logger', None)
166
+
167
+ def update_status(status, set_inactive=False):
168
+ self.status = status
169
+ if logger: logger.info(f"{self.name} status update: '{self.status}'")
170
+ self.update_table(active=0 if set_inactive else 1)
171
+
172
+ if self.expiry and self.expiry < Date():
173
+ update_status('cancelled on expiration', set_inactive=True)
174
+ if self.verbose:
175
+ print('Killing', end=' ')
176
+ print(self.name, end='')
177
+ print(f' @ {Date()} ->', end=' ')
178
+ print('Job Cancelled')
179
+
180
+ # cancel job if it was cancelled on cascasde or if it was set inactive after being scheduled
181
+ if self.status == self.cascade_status or not TaskMaster.is_active(self.name, self.at):
182
+ return CancelJob
183
+
184
+ while True:
185
+ try:
186
+ start = time.time()
187
+ if logger: logger.info(f'{self.name} start')
188
+
189
+ if self.verbose:
190
+ print('Running', end=' ')
191
+ print(self.name, end='')
192
+ print(f' @ {Date()} ->', end=' ')
193
+
194
+ if self.restrict_to_business_hours and not Date().is_business_hours:
195
+ raise PrerequisiteError('cannot execute outside of business hours')
196
+
197
+ if self.restrict_to_business_days and not Date().is_business_day:
198
+ raise PrerequisiteError('cannot execute during the weekend')
199
+
200
+ if self.disable_print:
201
+ with redirect_stdout(open(os.devnull, 'w')):
202
+ out = self.func(*self.args, **self.kwargs)
203
+ else:
204
+ out = self.func(*self.args, **self.kwargs)
205
+
206
+ if self.verbose:
207
+ print(f'Complete {elapsed_time(time.time() - start)}', end='')
208
+ print(' (Job Cancelled)') if self.cancel_on_completion else print('')
209
+
210
+ if logger: logger.info(f'{self.name} complete')
211
+
212
+ if self.cancel_on_completion:
213
+ update_status('cancelled on completion', set_inactive=True)
214
+
215
+ return CancelJob if self.cancel_on_completion else out
216
+
217
+ except Exception as e:
218
+
219
+ if isinstance(e, PrerequisiteError):
220
+ if self.verbose: print(f'PrerequisiteError: {e}')
221
+ if logger: logger.exception('see traceback below')
222
+ return ContinueFailedJob
223
+
224
+ if self.attempts > 0: self.attempts -= 1
225
+ self.cancel_on_failure = True if self.attempts == 0 else False
226
+
227
+ # wait 5 seconds then try again
228
+ if self.attempts > 0:
229
+ time.sleep(5)
230
+ continue
231
+
232
+ if self.verbose:
233
+ if self.cancel_on_failure:
234
+ print(f'Job Cancelled: {e}')
235
+ else:
236
+ print(f'Failed: {e}')
237
+
238
+ if logger: logger.exception('see traceback below')
239
+
240
+ if self.cancel_on_failure:
241
+ update_status('cancelled on failure', set_inactive=False)
242
+
243
+ if self.notify_on_failure:
244
+ self.send_email_notification()
245
+
246
+
247
+ return CancelJob if self.cancel_on_failure else ContinueFailedJob
248
+
249
+
250
+
251
+ class TaskMaster(object):
252
+ '''
253
+ Description
254
+ --------------------
255
+ Class provides a user-friendly means of using the SmartScheduler implementation of
256
+ schedule.Scheduler to schedule SmartJobs
257
+
258
+ Class Attributes
259
+ --------------------
260
+ db : SQLiteFile
261
+ database
262
+ smart_scheduler : SmartScheduler
263
+ SmartScheduler instance
264
+ jobs : dict
265
+ ...
266
+ logger : None | Logger
267
+ logger
268
+
269
+
270
+ Instance Attributes
271
+ --------------------
272
+ None
273
+ '''
274
+
275
+ jobs = {}
276
+ logger = None
277
+
278
+
279
+ @classmethod
280
+ def set_inactive(cls, name, status=None):
281
+ cls.db.execute(
282
+ 'UPDATE jobs SET active = ?, status = ? WHERE name = ?',
283
+ (0, status or 'manual intervention', name)
284
+ )
285
+
286
+
287
+ @classmethod
288
+ def clear_all(cls):
289
+ cls.clear_table()
290
+ if cls.logger is not None:
291
+ cls.logger.clear()
292
+
293
+
294
+ @classmethod
295
+ def build_table(cls):
296
+ '''
297
+ Columns
298
+ ----------
299
+ name : str
300
+ SmartJob.name
301
+ at : str
302
+ SmartJob.at
303
+ active : binary
304
+ If 1, the job is active and able to be scheduled.
305
+ If 0, the job is inactive and unable to be scheduled. This value is obtained via one of the following avenues
306
+ 1) job completed succesfully and was cancelled
307
+ 2) job obained this status via cascade from a job that satisfied criteria in 1)
308
+ 3) job was manually set inactive via TaskMaster.set_inactive method
309
+ expiry : str
310
+ SmartJob.expiry
311
+ status : str
312
+ SmartJob.status
313
+ '''
314
+
315
+ sql = """
316
+ CREATE TABLE IF NOT EXISTS
317
+ jobs (
318
+ name TEXT,
319
+ at TEXT,
320
+ active INTEGER,
321
+ status TEXT,
322
+ expiry TEXT,
323
+ PRIMARY KEY(name, at)
324
+ )"""
325
+
326
+ cls.db.execute(sql)
327
+
328
+
329
+
330
+ @classmethod
331
+ def clear_table(cls, warn=False):
332
+ cls.db.clear_tables(warn=warn)
333
+
334
+
335
+
336
+ @classmethod
337
+ def add(cls, func, every=None, at=None, interval=1, start=None, expiry=None, **kwargs):
338
+ '''
339
+ Description
340
+ ------------
341
+ schedule a job
342
+
343
+ https://schedule.readthedocs.io/en/stable/
344
+ schedule.every(10).minutes.do(job)
345
+ schedule.every().hour.do(job)
346
+ schedule.every().day.at('10:30').do(job)
347
+ schedule.every().monday.do(job)
348
+ schedule.every().wednesday.at('13:15').do(job)
349
+ schedule.every().minute.at(':17').do(job)
350
+
351
+ Parameters
352
+ ------------
353
+ func : func
354
+ SmartJob func argument
355
+ every : str
356
+ string representation of schedule.Job property (e.g. 'minutes', 'hour', 'day' etc.).
357
+ If None and cancel_on_completion kwarg is True, every and interval will be set to
358
+ 'second' and 1, respectively, so that the job will run ASAP once the 'start' criteria
359
+ has been met (if applicable).
360
+ at : str | iter
361
+ time_str argument passed to schedule.Job.at(time_str). Times may be passed in
362
+ '%I:%M%p' format (e.g ['06:15 AM', '12:15 PM', '06:15 PM']). If argument is an
363
+ iterable then the job will be scheduled at each constituent time.
364
+ interval : int
365
+ schedule.Scheduler.every interval argument
366
+ start : Date
367
+ If not None, job will be not be added until this datetime
368
+ expiry : Date
369
+ If not None, job will be set inactive and stop running after this datetime
370
+ kwargs : keyword arguments
371
+ keyword arguments passed to SmartJob.__init__
372
+
373
+ Returns
374
+ ------------
375
+ None
376
+ '''
377
+
378
+ if not hasattr(cls, 'db'):
379
+ cls.db = Folder().parent.join('Data', 'SQLite', read_only=False).join('taskmaster.sqlite')
380
+ cls.db.connect()
381
+ cls.db.enable_foreign_keys()
382
+
383
+ if not hasattr(cls, 'smart_scheduler'):
384
+ cls.smart_scheduler = SmartScheduler()
385
+
386
+ now = Date()
387
+ if (start and start > now) or (expiry and expiry < now): return
388
+ expiry_str = expiry.dt.strftime('%Y-%m-%d %I:%M:%S.{} %p')\
389
+ .format('%03d' % (expiry.dt.microsecond / 1000))\
390
+ if expiry else None
391
+
392
+ cls.build_table()
393
+ name = kwargs.pop('name', f'Unnamed {uuid.uuid4().hex}')
394
+
395
+ if every is None:
396
+ if kwargs.get('cancel_on_completion'):
397
+ every, interval = 'second', 1
398
+ else:
399
+ raise Exception("'every' argument cannot be None")
400
+
401
+ for at in to_iter(at):
402
+ job = getattr(cls.smart_scheduler.every(interval), every)
403
+ if at is not None:
404
+ try:
405
+ time_str = datetime.datetime.strptime(
406
+ at.upper().replace(' ',''), '%I:%M%p'
407
+ ).strftime('%H:%M')
408
+ except:
409
+ time_str = at
410
+ job = job.at(time_str)
411
+ else:
412
+ at = 'N/A'
413
+
414
+ # if already scheduled or inactive then do not schedule
415
+ if (name, at) in cls.jobs or not cls.is_active(name, at): continue
416
+ cls.db.insert('jobs', (name, at, 1, None, expiry_str))
417
+ cls.jobs[name, at] = SmartJob(name, at, expiry, func, **kwargs)
418
+ job.do(cls.jobs[name, at])
419
+
420
+
421
+ @classmethod
422
+ def run(cls, wait=0):
423
+ while True:
424
+ cls.smart_scheduler.run_pending()
425
+ time.sleep(wait)
426
+
427
+
428
+ @classmethod
429
+ def is_active(cls, name, at=None):
430
+ where_cols, params = ['name'], [name]
431
+ if at is not None:
432
+ where_cols.append('at')
433
+ params.append(at)
434
+ sql = cls.db.select_query(
435
+ tbl_name='jobs',
436
+ select_cols='active',
437
+ where_cols=where_cols
438
+ )
439
+ try:
440
+ active = int(cls.db.c.execute(sql, tuple(params)).fetchone()[0])
441
+ if active == 0: return False
442
+ except:
443
+ pass
444
+ return True
445
+
446
+
447
+
448
+ class FileMonitor(object):
449
+ '''
450
+ Description
451
+ --------------------
452
+ Monitors a folder for new files and passes latest and 2nd latest file names to user-defined function.
453
+ Class is intended to be used in conjunction with TaskMaster as a func argument which allows for file
454
+ monitoring at regular intervals.
455
+
456
+ Class Attributes
457
+ --------------------
458
+ None
459
+
460
+ Instance Attributes
461
+ --------------------
462
+ func : func
463
+ Custom function that takes the latest and second-latest file names in a folder as the first and second arguments, respectively.
464
+ folder : Folder object
465
+ folder to monitor for new files.
466
+ pick_file_kwargs : dict
467
+ Key word arguments for file_tools.folder.pick_file
468
+ verbose : bool
469
+ If True, information is printed to the console.
470
+ latest_file : File
471
+ the latest file
472
+ '''
473
+
474
+ def __init__(self, func, folder, pick_file_kwargs={}, verbose=False):
475
+ self.func = func
476
+ self.folder = folder
477
+ self.pick_file_kwargs = pick_file_kwargs
478
+ self.verbose = verbose
479
+ self.latest_file = self.pick_file()
480
+
481
+
482
+ def pick_file(self):
483
+ return self.folder.pick_file(**self.pick_file_kwargs)
484
+
485
+
486
+ def __call__(self):
487
+ latest_file = self.pick_file()
488
+
489
+ if latest_file != self.latest_file:
490
+ if self.verbose:
491
+ print('new file detected:')
492
+ print('\t*', latest_file)
493
+ print('\t*', self.latest_file)
494
+ print()
495
+
496
+ self.func(latest_file, self.latest_file)
497
+ self.latest_file = latest_file
498
+ else:
499
+ raise PrerequisiteError('no new files have been detected')
500
+
501
+
502
+ def ad_hoc(self):
503
+ ''' runs self.func using latest and 2nd latest file '''
504
+ new_file = self.pick_file()
505
+
506
+ kwargs = self.pick_file_kwargs.copy()
507
+ kwargs['func'] = 1
508
+ prior_file = self.folder.pick_file(**kwargs)
509
+
510
+ if self.verbose:
511
+ print('Ad hoc files:')
512
+ print('\t*', new_file)
513
+ print('\t*', prior_file)
514
+ print()
515
+
516
+ self.func(new_file, prior_file)
517
+
518
+
519
+
520
+ if __name__ == '__main__':
521
+
522
+ def test_func():
523
+ time.sleep(3)
524
+
525
+ TaskMaster.logger = Logger('TaskMaster')
526
+
527
+ TaskMaster.add(
528
+ func=test_func,
529
+ every='seconds',
530
+ at=None,
531
+ interval=10,
532
+ start=None,
533
+ expiry=None,
534
+ # **kwargs
535
+ )
536
+
537
+ TaskMaster.run()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Zachary Einck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.1
2
+ Name: clockwork
3
+ Version: 0.1.0
4
+ Summary: Toolkit for time-related operations including scheduling, logging, date manipulation, and more.
5
+ Home-page: https://github.com/zteinck/clockwork
6
+ License: MIT
7
+ Author: Zachary Einck
8
+ Author-email: zacharyeinck@gmail.com
9
+ Requires-Python: >=3.8,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Dist: holidays
18
+ Requires-Dist: iterlab
19
+ Requires-Dist: pathpilot
20
+ Requires-Dist: schedule
21
+ Requires-Dist: textwrap3
22
+ Project-URL: Repository, https://github.com/zteinck/clockwork
23
+ Description-Content-Type: text/markdown
24
+
25
+ # clockwork
26
+ `clockwork` is a library that provides a multitude of time-related functionalities, facilitating tasks such as scheduling, logging, date manipulation, and more.
@@ -0,0 +1,8 @@
1
+ clockwork/__init__.py,sha256=CRl9a6xE4YxLsdnLTKHDIGmsyaysRFFYyNVJLGsvfnE,104
2
+ clockwork/chronicle.py,sha256=-BRa62T-a_DQN12hSzDcVlpb0-y-vGLa_XljzReEq28,3019
3
+ clockwork/clockwork.py,sha256=MEfWPGA1oEINJGT7HBDNPm8tVGGrpJFCC1KeBjrkYG4,23755
4
+ clockwork/taskmaster.py,sha256=g33otNzVtLLi4Dadyit3A7QRabPFxcJjOgN5n7tnU8I,18025
5
+ clockwork-0.1.0.dist-info/LICENSE,sha256=e2vk_CZyvQY50wY_UPKw03gYPnT8BV9nG2lgmPfUlnU,1091
6
+ clockwork-0.1.0.dist-info/METADATA,sha256=XPr1J1nyXCXX3LT7z7pUwBGJwaaGW-7AbJHgl7B-SPM,1055
7
+ clockwork-0.1.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
8
+ clockwork-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any