schedule-ng 1.3.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,1001 @@
1
+ """
2
+ Python job scheduling for humans.
3
+
4
+ github.com/Adz-ai/schedule-ng, a maintained fork of github.com/dbader/schedule
5
+
6
+ An in-process scheduler for periodic jobs that uses the builder pattern
7
+ for configuration. Schedule lets you run Python functions (or any other
8
+ callable) periodically at pre-determined intervals using a simple,
9
+ human-friendly syntax.
10
+
11
+ Inspired by Addam Wiggins' article "Rethinking Cron" [1] and the
12
+ "clockwork" Ruby module [2][3].
13
+
14
+ Features:
15
+ - A simple to use API for scheduling jobs.
16
+ - Very lightweight and no external dependencies.
17
+ - Excellent test coverage.
18
+ - Tested on Python 3.9 to 3.14 on Linux, macOS and Windows
19
+
20
+ Usage:
21
+ >>> import schedule
22
+ >>> import time
23
+
24
+ >>> def job(message='stuff'):
25
+ >>> print("I'm working on:", message)
26
+
27
+ >>> schedule.every(10).minutes.do(job)
28
+ >>> schedule.every(5).to(10).days.do(job)
29
+ >>> schedule.every().hour.do(job, message='things')
30
+ >>> schedule.every().day.at("10:30").do(job)
31
+
32
+ >>> while True:
33
+ >>> schedule.run_pending()
34
+ >>> time.sleep(1)
35
+
36
+ [1] https://adam.herokuapp.com/past/2010/4/13/rethinking_cron/
37
+ [2] https://github.com/Rykian/clockwork
38
+ [3] https://adam.herokuapp.com/past/2010/6/30/replace_cron_with_clockwork/
39
+ """
40
+
41
+ from collections.abc import Hashable
42
+ import datetime
43
+ import functools
44
+ import logging
45
+ import random
46
+ import re
47
+ import time
48
+ from typing import Set, List, Optional, Callable, Union
49
+
50
+ __version__ = "1.3.0"
51
+
52
+ logger = logging.getLogger("schedule")
53
+
54
+
55
+ class ScheduleError(Exception):
56
+ """Base schedule exception"""
57
+
58
+ pass
59
+
60
+
61
+ class ScheduleValueError(ScheduleError):
62
+ """Base schedule value error"""
63
+
64
+ pass
65
+
66
+
67
+ class IntervalError(ScheduleValueError):
68
+ """An improper interval was used"""
69
+
70
+ pass
71
+
72
+
73
+ class CancelJob:
74
+ """
75
+ Can be returned from a job to unschedule itself.
76
+ """
77
+
78
+ pass
79
+
80
+
81
+ class Scheduler:
82
+ """
83
+ Objects instantiated by the :class:`Scheduler <Scheduler>` are
84
+ factories to create jobs, keep record of scheduled jobs and
85
+ handle their execution.
86
+ """
87
+
88
+ def __init__(self) -> None:
89
+ self.jobs: List[Job] = []
90
+
91
+ def run_pending(self) -> None:
92
+ """
93
+ Run all jobs that are scheduled to run.
94
+
95
+ Please note that it is *intended behavior that run_pending()
96
+ does not run missed jobs*. For example, if you've registered a job
97
+ that should run every minute and you only call run_pending()
98
+ in one hour increments then your job won't be run 60 times in
99
+ between but only once.
100
+ """
101
+ runnable_jobs = (job for job in self.jobs if job.should_run)
102
+ for job in sorted(runnable_jobs):
103
+ self._run_job(job)
104
+
105
+ def run_all(self, delay_seconds: int = 0) -> None:
106
+ """
107
+ Run all jobs regardless if they are scheduled to run or not.
108
+
109
+ A delay of `delay` seconds is added between each job. This helps
110
+ distribute system load generated by the jobs more evenly
111
+ over time.
112
+
113
+ :param delay_seconds: A delay added between every executed job
114
+ """
115
+ logger.debug(
116
+ "Running *all* %i jobs with %is delay in between",
117
+ len(self.jobs),
118
+ delay_seconds,
119
+ )
120
+ for job in self.jobs[:]:
121
+ self._run_job(job)
122
+ time.sleep(delay_seconds)
123
+
124
+ def get_jobs(self, tag: Optional[Hashable] = None) -> List["Job"]:
125
+ """
126
+ Gets scheduled jobs marked with the given tag, or all jobs
127
+ if tag is omitted.
128
+
129
+ :param tag: An identifier used to identify a subset of
130
+ jobs to retrieve
131
+ """
132
+ if tag is None:
133
+ return self.jobs[:]
134
+ else:
135
+ return [job for job in self.jobs if tag in job.tags]
136
+
137
+ def clear(self, tag: Optional[Hashable] = None) -> None:
138
+ """
139
+ Deletes scheduled jobs marked with the given tag, or all jobs
140
+ if tag is omitted.
141
+
142
+ :param tag: An identifier used to identify a subset of
143
+ jobs to delete
144
+ """
145
+ if tag is None:
146
+ logger.debug("Deleting *all* jobs")
147
+ del self.jobs[:]
148
+ else:
149
+ logger.debug('Deleting all jobs tagged "%s"', tag)
150
+ self.jobs[:] = (job for job in self.jobs if tag not in job.tags)
151
+
152
+ def cancel_job(self, job: "Job") -> None:
153
+ """
154
+ Delete a scheduled job.
155
+
156
+ :param job: The job to be unscheduled
157
+ """
158
+ try:
159
+ logger.debug('Cancelling job "%s"', job)
160
+ self.jobs.remove(job)
161
+ except ValueError:
162
+ logger.debug('Cancelling not-scheduled job "%s"', job)
163
+
164
+ def every(self, interval: int = 1) -> "Job":
165
+ """
166
+ Schedule a new periodic job.
167
+
168
+ :param interval: A quantity of a certain time unit
169
+ :return: An unconfigured :class:`Job <Job>`
170
+ """
171
+ job = Job(interval, self)
172
+ return job
173
+
174
+ def _run_job(self, job: "Job") -> None:
175
+ ret = job.run()
176
+ if isinstance(ret, CancelJob) or ret is CancelJob:
177
+ self.cancel_job(job)
178
+
179
+ def get_next_run(
180
+ self, tag: Optional[Hashable] = None
181
+ ) -> Optional[datetime.datetime]:
182
+ """
183
+ Datetime when the next job should run.
184
+
185
+ :param tag: Filter the next run for the given tag parameter
186
+
187
+ :return: A :class:`~datetime.datetime` object
188
+ or None if no jobs scheduled
189
+ """
190
+ if not self.jobs:
191
+ return None
192
+ jobs_filtered = self.get_jobs(tag)
193
+ if not jobs_filtered:
194
+ return None
195
+ return min(jobs_filtered).next_run
196
+
197
+ next_run = property(get_next_run)
198
+
199
+ @property
200
+ def idle_seconds(self) -> Optional[float]:
201
+ """
202
+ :return: Number of seconds until
203
+ :meth:`next_run <Scheduler.next_run>`
204
+ or None if no jobs are scheduled
205
+ """
206
+ if not self.jobs:
207
+ return None
208
+ next_run = min(self.jobs)._next_run
209
+ if next_run is None:
210
+ return None
211
+ # Subtract in UTC: local times can be an hour off around DST changes.
212
+ return (next_run - _now_utc()).total_seconds()
213
+
214
+
215
+ class Job:
216
+ """
217
+ A periodic job as used by :class:`Scheduler`.
218
+
219
+ :param interval: A quantity of a certain time unit
220
+ :param scheduler: The :class:`Scheduler <Scheduler>` instance that
221
+ this job will register itself with once it has
222
+ been fully configured in :meth:`Job.do()`.
223
+
224
+ Every job runs at a given fixed time interval that is defined by:
225
+
226
+ * a :meth:`time unit <Job.second>`
227
+ * a quantity of `time units` defined by `interval`
228
+
229
+ A job is usually created and returned by :meth:`Scheduler.every`
230
+ method, which also defines its `interval`.
231
+ """
232
+
233
+ def __init__(self, interval: int, scheduler: Optional[Scheduler] = None):
234
+ if interval <= 0:
235
+ raise ScheduleValueError("Interval must be greater than zero")
236
+ self.interval: int = interval # pause interval * unit between runs
237
+ self.latest: Optional[int] = None # upper limit to the interval
238
+ self.job_func: Optional[functools.partial] = None # the job job_func to run
239
+
240
+ # time units, e.g. 'minutes', 'hours', ...
241
+ self.unit: Optional[str] = None
242
+
243
+ # optional time at which this job runs
244
+ self.at_time: Optional[datetime.time] = None
245
+
246
+ # optional time zone of the self.at_time field. Only relevant when at_time is not None
247
+ self.at_time_zone: Optional[datetime.tzinfo] = None
248
+
249
+ # datetime of the last run
250
+ self.last_run: Optional[datetime.datetime] = None
251
+
252
+ # datetime of the next run, in UTC. Exposed in local time as next_run.
253
+ self._next_run: Optional[datetime.datetime] = None
254
+
255
+ # Weekday to run the job at. Only relevant when unit is 'weeks'.
256
+ # For example, when asking 'every week on tuesday' the start_day is 'tuesday'.
257
+ self.start_day: Optional[str] = None
258
+
259
+ # optional time of final run
260
+ self.cancel_after: Optional[datetime.datetime] = None
261
+
262
+ self.tags: Set[Hashable] = set() # unique set of tags for the job
263
+ self.scheduler: Optional[Scheduler] = scheduler # scheduler to register with
264
+
265
+ def __lt__(self, other) -> bool:
266
+ """
267
+ PeriodicJobs are sortable based on the scheduled time they
268
+ run next.
269
+ """
270
+ return self._next_run < other._next_run
271
+
272
+ @property
273
+ def next_run(self) -> Optional[datetime.datetime]:
274
+ """
275
+ Datetime of the next run, as a naive datetime in local time.
276
+ """
277
+ if self._next_run is None:
278
+ return None
279
+ return self._next_run.astimezone().replace(tzinfo=None)
280
+
281
+ @next_run.setter
282
+ def next_run(self, value: Optional[datetime.datetime]) -> None:
283
+ # Naive datetimes are interpreted as local time.
284
+ self._next_run = None if value is None else value.astimezone(_UTC)
285
+
286
+ def __str__(self) -> str:
287
+ if hasattr(self.job_func, "__name__"):
288
+ job_func_name = self.job_func.__name__ # type: ignore
289
+ else:
290
+ job_func_name = repr(self.job_func)
291
+
292
+ return ("Job(interval={}, unit={}, {}do={}, args={}, kwargs={})").format(
293
+ self.interval,
294
+ self.unit,
295
+ "" if self.at_time is None else "at={}, ".format(self.at_time),
296
+ job_func_name,
297
+ "()" if self.job_func is None else self.job_func.args,
298
+ "{}" if self.job_func is None else self.job_func.keywords,
299
+ )
300
+
301
+ def __repr__(self):
302
+ def format_time(t):
303
+ return t.strftime("%Y-%m-%d %H:%M:%S") if t else "[never]"
304
+
305
+ def is_repr(j):
306
+ return not isinstance(j, Job)
307
+
308
+ timestats = "(last run: %s, next run: %s)" % (
309
+ format_time(self.last_run),
310
+ format_time(self.next_run),
311
+ )
312
+
313
+ if hasattr(self.job_func, "__name__"):
314
+ job_func_name = self.job_func.__name__
315
+ else:
316
+ job_func_name = repr(self.job_func)
317
+
318
+ if self.job_func is not None:
319
+ args = [repr(x) if is_repr(x) else str(x) for x in self.job_func.args]
320
+ kwargs = ["%s=%s" % (k, repr(v)) for k, v in self.job_func.keywords.items()]
321
+ call_repr = job_func_name + "(" + ", ".join(args + kwargs) + ")"
322
+ else:
323
+ call_repr = "[None]"
324
+
325
+ if self.at_time is not None:
326
+ return "Every %s %s at %s do %s %s" % (
327
+ self.interval,
328
+ self.unit[:-1] if self.interval == 1 and self.unit else self.unit,
329
+ self.at_time,
330
+ call_repr,
331
+ timestats,
332
+ )
333
+ else:
334
+ fmt = (
335
+ "Every %(interval)s "
336
+ + ("to %(latest)s " if self.latest is not None else "")
337
+ + "%(unit)s do %(call_repr)s %(timestats)s"
338
+ )
339
+
340
+ return fmt % dict(
341
+ interval=self.interval,
342
+ latest=self.latest,
343
+ unit=(
344
+ self.unit[:-1] if self.interval == 1 and self.unit else self.unit
345
+ ),
346
+ call_repr=call_repr,
347
+ timestats=timestats,
348
+ )
349
+
350
+ @property
351
+ def second(self):
352
+ if self.interval != 1:
353
+ raise IntervalError("Use seconds instead of second")
354
+ return self.seconds
355
+
356
+ @property
357
+ def seconds(self):
358
+ self.unit = "seconds"
359
+ return self
360
+
361
+ @property
362
+ def minute(self):
363
+ if self.interval != 1:
364
+ raise IntervalError("Use minutes instead of minute")
365
+ return self.minutes
366
+
367
+ @property
368
+ def minutes(self):
369
+ self.unit = "minutes"
370
+ return self
371
+
372
+ @property
373
+ def hour(self):
374
+ if self.interval != 1:
375
+ raise IntervalError("Use hours instead of hour")
376
+ return self.hours
377
+
378
+ @property
379
+ def hours(self):
380
+ self.unit = "hours"
381
+ return self
382
+
383
+ @property
384
+ def day(self):
385
+ if self.interval != 1:
386
+ raise IntervalError("Use days instead of day")
387
+ return self.days
388
+
389
+ @property
390
+ def days(self):
391
+ self.unit = "days"
392
+ return self
393
+
394
+ @property
395
+ def week(self):
396
+ if self.interval != 1:
397
+ raise IntervalError("Use weeks instead of week")
398
+ return self.weeks
399
+
400
+ @property
401
+ def weeks(self):
402
+ self.unit = "weeks"
403
+ return self
404
+
405
+ @property
406
+ def monday(self):
407
+ if self.interval != 1:
408
+ raise IntervalError(
409
+ "Scheduling .monday() jobs is only allowed for weekly jobs. "
410
+ "Using .monday() on a job scheduled to run every 2 or more weeks "
411
+ "is not supported."
412
+ )
413
+ self.start_day = "monday"
414
+ return self.weeks
415
+
416
+ @property
417
+ def tuesday(self):
418
+ if self.interval != 1:
419
+ raise IntervalError(
420
+ "Scheduling .tuesday() jobs is only allowed for weekly jobs. "
421
+ "Using .tuesday() on a job scheduled to run every 2 or more weeks "
422
+ "is not supported."
423
+ )
424
+ self.start_day = "tuesday"
425
+ return self.weeks
426
+
427
+ @property
428
+ def wednesday(self):
429
+ if self.interval != 1:
430
+ raise IntervalError(
431
+ "Scheduling .wednesday() jobs is only allowed for weekly jobs. "
432
+ "Using .wednesday() on a job scheduled to run every 2 or more weeks "
433
+ "is not supported."
434
+ )
435
+ self.start_day = "wednesday"
436
+ return self.weeks
437
+
438
+ @property
439
+ def thursday(self):
440
+ if self.interval != 1:
441
+ raise IntervalError(
442
+ "Scheduling .thursday() jobs is only allowed for weekly jobs. "
443
+ "Using .thursday() on a job scheduled to run every 2 or more weeks "
444
+ "is not supported."
445
+ )
446
+ self.start_day = "thursday"
447
+ return self.weeks
448
+
449
+ @property
450
+ def friday(self):
451
+ if self.interval != 1:
452
+ raise IntervalError(
453
+ "Scheduling .friday() jobs is only allowed for weekly jobs. "
454
+ "Using .friday() on a job scheduled to run every 2 or more weeks "
455
+ "is not supported."
456
+ )
457
+ self.start_day = "friday"
458
+ return self.weeks
459
+
460
+ @property
461
+ def saturday(self):
462
+ if self.interval != 1:
463
+ raise IntervalError(
464
+ "Scheduling .saturday() jobs is only allowed for weekly jobs. "
465
+ "Using .saturday() on a job scheduled to run every 2 or more weeks "
466
+ "is not supported."
467
+ )
468
+ self.start_day = "saturday"
469
+ return self.weeks
470
+
471
+ @property
472
+ def sunday(self):
473
+ if self.interval != 1:
474
+ raise IntervalError(
475
+ "Scheduling .sunday() jobs is only allowed for weekly jobs. "
476
+ "Using .sunday() on a job scheduled to run every 2 or more weeks "
477
+ "is not supported."
478
+ )
479
+ self.start_day = "sunday"
480
+ return self.weeks
481
+
482
+ def tag(self, *tags: Hashable):
483
+ """
484
+ Tags the job with one or more unique identifiers.
485
+
486
+ Tags must be hashable. Duplicate tags are discarded.
487
+
488
+ :param tags: A unique list of ``Hashable`` tags.
489
+ :return: The invoked job instance
490
+ """
491
+ if not all(isinstance(tag, Hashable) for tag in tags):
492
+ raise TypeError("Tags must be hashable")
493
+ self.tags.update(tags)
494
+ return self
495
+
496
+ def at(self, time_str: str, tz: Union[str, datetime.tzinfo, None] = None) -> "Job":
497
+ """
498
+ Specify a particular time that the job should be run at.
499
+
500
+ :param time_str: A string in one of the following formats:
501
+
502
+ - For daily jobs -> `HH:MM:SS` or `HH:MM`
503
+ - For hourly jobs -> `MM:SS` or `:MM`
504
+ - For minute jobs -> `:SS`
505
+
506
+ The format must make sense given how often the job is
507
+ repeating; for example, a job that repeats every minute
508
+ should not be given a string in the form `HH:MM:SS`. The
509
+ difference between `:MM` and `:SS` is inferred from the
510
+ selected time-unit (e.g. `every().hour.at(':30')` vs.
511
+ `every().minute.at(':30')`).
512
+
513
+ :param tz: The timezone that this timestamp refers to. Can be a
514
+ timezone name such as ``"Europe/Amsterdam"``, or a
515
+ :class:`datetime.tzinfo` such as a :class:`zoneinfo.ZoneInfo` or
516
+ pytz timezone. Names are looked up with pytz if it is installed,
517
+ and with :mod:`zoneinfo` otherwise.
518
+
519
+ :return: The invoked job instance
520
+ """
521
+ if self.unit not in ("days", "hours", "minutes") and not self.start_day:
522
+ raise ScheduleValueError(
523
+ "Invalid unit (valid units are `days`, `hours`, and `minutes`)"
524
+ )
525
+
526
+ if not isinstance(time_str, str):
527
+ raise TypeError("at() should be passed a string")
528
+ if self.unit == "days" or self.start_day:
529
+ if not re.match(r"^[0-2]\d:[0-5]\d(:[0-5]\d)?$", time_str):
530
+ raise ScheduleValueError(
531
+ "Invalid time format for a daily job (valid format is HH:MM(:SS)?)"
532
+ )
533
+ if self.unit == "hours":
534
+ if not re.match(r"^([0-5]\d)?:[0-5]\d$", time_str):
535
+ raise ScheduleValueError(
536
+ "Invalid time format for an hourly job (valid format is (MM)?:SS)"
537
+ )
538
+
539
+ if self.unit == "minutes":
540
+ if not re.match(r"^:[0-5]\d$", time_str):
541
+ raise ScheduleValueError(
542
+ "Invalid time format for a minutely job (valid format is :SS)"
543
+ )
544
+ time_values = time_str.split(":")
545
+ hour: Union[str, int]
546
+ minute: Union[str, int]
547
+ second: Union[str, int]
548
+ if len(time_values) == 3:
549
+ hour, minute, second = time_values
550
+ elif len(time_values) == 2 and self.unit == "minutes":
551
+ hour = 0
552
+ minute = 0
553
+ _, second = time_values
554
+ elif len(time_values) == 2 and self.unit == "hours" and len(time_values[0]):
555
+ hour = 0
556
+ minute, second = time_values
557
+ else:
558
+ hour, minute = time_values
559
+ second = 0
560
+ if self.unit == "days" or self.start_day:
561
+ hour = int(hour)
562
+ if not (0 <= hour <= 23):
563
+ raise ScheduleValueError(
564
+ "Invalid number of hours ({} is not between 0 and 23)".format(hour)
565
+ )
566
+ elif self.unit == "hours":
567
+ hour = 0
568
+ elif self.unit == "minutes":
569
+ hour = 0
570
+ minute = 0
571
+ hour = int(hour)
572
+ minute = int(minute)
573
+ second = int(second)
574
+ at_time = datetime.time(hour, minute, second)
575
+ if tz is not None:
576
+ self.at_time_zone = _parse_timezone(tz)
577
+
578
+ self.at_time = at_time
579
+ return self
580
+
581
+ def to(self, latest: int):
582
+ """
583
+ Schedule the job to run at an irregular (randomized) interval.
584
+
585
+ The job's interval will randomly vary from the value given
586
+ to `every` to `latest`. The range defined is inclusive on
587
+ both ends. For example, `every(A).to(B).seconds` executes
588
+ the job function every N seconds such that A <= N <= B.
589
+
590
+ :param latest: Maximum interval between randomized job runs
591
+ :return: The invoked job instance
592
+ """
593
+ self.latest = latest
594
+ return self
595
+
596
+ def until(
597
+ self,
598
+ until_time: Union[datetime.datetime, datetime.timedelta, datetime.time, str],
599
+ ):
600
+ """
601
+ Schedule job to run until the specified moment.
602
+
603
+ The job is canceled whenever the next run is calculated and it turns out the
604
+ next run is after the until_time. The job is also canceled right before it runs,
605
+ if the current time is after until_time. This latter case can happen when the
606
+ the job was scheduled to run before until_time, but runs after until_time.
607
+
608
+ If until_time is a moment in the past, ScheduleValueError is thrown.
609
+
610
+ :param until_time: A moment in the future representing the latest time a job can
611
+ be run. If only a time is supplied, the date is set to today.
612
+ The following formats are accepted:
613
+
614
+ - datetime.datetime
615
+ - datetime.timedelta
616
+ - datetime.time
617
+ - String in one of the following formats: "%Y-%m-%d %H:%M:%S",
618
+ "%Y-%m-%d %H:%M", "%Y-%m-%d", "%H:%M:%S", "%H:%M"
619
+ as defined by strptime() behaviour. If an invalid string format is passed,
620
+ ScheduleValueError is thrown.
621
+
622
+ :return: The invoked job instance
623
+ """
624
+
625
+ cancel_after: Optional[datetime.datetime]
626
+ if isinstance(until_time, datetime.datetime):
627
+ cancel_after = until_time
628
+ elif isinstance(until_time, datetime.timedelta):
629
+ cancel_after = datetime.datetime.now() + until_time
630
+ elif isinstance(until_time, datetime.time):
631
+ cancel_after = datetime.datetime.combine(
632
+ datetime.datetime.now(), until_time
633
+ )
634
+ elif isinstance(until_time, str):
635
+ cancel_after = self._decode_datetimestr(
636
+ until_time,
637
+ [
638
+ "%Y-%m-%d %H:%M:%S",
639
+ "%Y-%m-%d %H:%M",
640
+ "%Y-%m-%d",
641
+ "%H:%M:%S",
642
+ "%H:%M",
643
+ ],
644
+ )
645
+ if cancel_after is None:
646
+ raise ScheduleValueError("Invalid string format for until()")
647
+ if "-" not in until_time:
648
+ # the until_time is a time-only format. Set the date to today
649
+ now = datetime.datetime.now()
650
+ cancel_after = cancel_after.replace(
651
+ year=now.year, month=now.month, day=now.day
652
+ )
653
+ else:
654
+ raise TypeError(
655
+ "until() takes a string, datetime.datetime, datetime.timedelta, "
656
+ "datetime.time parameter"
657
+ )
658
+ now = (
659
+ datetime.datetime.now(tz=cancel_after.tzinfo)
660
+ if cancel_after.tzinfo is not None
661
+ else datetime.datetime.now()
662
+ )
663
+ if cancel_after < now:
664
+ raise ScheduleValueError(
665
+ "Cannot schedule a job to run until a time in the past"
666
+ )
667
+ self.cancel_after = cancel_after
668
+ return self
669
+
670
+ def do(self, job_func: Callable, *args, **kwargs):
671
+ """
672
+ Specifies the job_func that should be called every time the
673
+ job runs.
674
+
675
+ Any additional arguments are passed on to job_func when
676
+ the job runs.
677
+
678
+ :param job_func: The function to be scheduled
679
+ :return: The invoked job instance
680
+ """
681
+ self.job_func = functools.partial(job_func, *args, **kwargs)
682
+ functools.update_wrapper(self.job_func, job_func)
683
+ self._schedule_next_run()
684
+ if self.scheduler is None:
685
+ raise ScheduleError(
686
+ "Unable to a add job to schedule. "
687
+ "Job is not associated with an scheduler"
688
+ )
689
+ if self not in self.scheduler.jobs:
690
+ self.scheduler.jobs.append(self)
691
+ return self
692
+
693
+ @property
694
+ def should_run(self) -> bool:
695
+ """
696
+ :return: ``True`` if the job should be run now.
697
+ """
698
+ assert self._next_run is not None, "must run _schedule_next_run before"
699
+ return _now_utc() >= self._next_run
700
+
701
+ def run(self):
702
+ """
703
+ Run the job and immediately reschedule it.
704
+ If the job's deadline is reached (configured using .until()), the job is not
705
+ run and CancelJob is returned immediately. If the next scheduled run exceeds
706
+ the job's deadline, CancelJob is returned after the execution. In this latter
707
+ case CancelJob takes priority over any other returned value.
708
+
709
+ :return: The return value returned by the `job_func`, or CancelJob if the job's
710
+ deadline is reached.
711
+
712
+ """
713
+ if self._is_overdue(datetime.datetime.now()):
714
+ logger.debug("Cancelling job %s", self)
715
+ return CancelJob
716
+
717
+ logger.debug("Running job %s", self)
718
+ ret = self.job_func()
719
+ self.last_run = datetime.datetime.now()
720
+ self._schedule_next_run()
721
+
722
+ if self._is_overdue(self._next_run):
723
+ logger.debug("Cancelling job %s", self)
724
+ return CancelJob
725
+ return ret
726
+
727
+ def _schedule_next_run(self) -> None:
728
+ """
729
+ Compute the instant when this job should run next.
730
+ """
731
+ if self.unit not in ("seconds", "minutes", "hours", "days", "weeks"):
732
+ raise ScheduleValueError(
733
+ "Invalid unit (valid units are `seconds`, `minutes`, `hours`, "
734
+ "`days`, and `weeks`)"
735
+ )
736
+ if self.latest is not None:
737
+ if not (self.latest >= self.interval):
738
+ raise ScheduleError(
739
+ "`latest` ({}) is smaller than `interval` ({})".format(
740
+ self.latest, self.interval
741
+ )
742
+ )
743
+ interval = _random_interval(self.interval, self.latest)
744
+ else:
745
+ interval = self.interval
746
+
747
+ period = datetime.timedelta(**{self.unit: interval})
748
+ now = _now_utc()
749
+
750
+ if self.start_day is not None and self.unit != "weeks":
751
+ raise ScheduleValueError("`unit` should be 'weeks'")
752
+
753
+ if self.unit in ("days", "weeks"):
754
+ # Days and weeks follow the wall clock of the job's timezone, so a
755
+ # daily job at 10:30 stays at 10:30 when clocks change. Candidates
756
+ # are compared in UTC, because wall-clock times can repeat.
757
+ wall = _wall_clock(now, self.at_time_zone)
758
+ if self.start_day is not None:
759
+ wall = _move_to_next_weekday(wall, self.start_day)
760
+ if self.at_time is not None:
761
+ wall = wall.replace(
762
+ hour=self.at_time.hour,
763
+ minute=self.at_time.minute,
764
+ second=self.at_time.second,
765
+ microsecond=0,
766
+ )
767
+ if interval != 1:
768
+ wall += period
769
+ next_run = _localize(wall, self.at_time_zone)
770
+ while next_run <= now:
771
+ wall += period
772
+ next_run = _localize(wall, self.at_time_zone)
773
+ else:
774
+ # Shorter units measure elapsed time, which is unaffected by
775
+ # clock changes.
776
+ next_run = now
777
+ if self.at_time is not None:
778
+ # The offset is taken from the current time, which keeps
779
+ # minutes correct in timezones with a non-whole-hour offset.
780
+ current = now.astimezone(self.at_time_zone)
781
+ current = current.replace(second=self.at_time.second, microsecond=0)
782
+ if self.unit == "hours":
783
+ current = current.replace(minute=self.at_time.minute)
784
+ next_run = current.astimezone(_UTC)
785
+ if interval != 1:
786
+ next_run += period
787
+ while next_run <= now:
788
+ next_run += period
789
+
790
+ self._next_run = next_run
791
+
792
+ def _is_overdue(self, when: datetime.datetime):
793
+ if self.cancel_after is None:
794
+ return False
795
+ # Compare in UTC; naive datetimes are in local time.
796
+ return when.astimezone(_UTC) > self.cancel_after.astimezone(_UTC)
797
+
798
+ def _decode_datetimestr(
799
+ self, datetime_str: str, formats: List[str]
800
+ ) -> Optional[datetime.datetime]:
801
+ for f in formats:
802
+ try:
803
+ return datetime.datetime.strptime(datetime_str, f)
804
+ except ValueError:
805
+ pass
806
+ return None
807
+
808
+
809
+ # The following methods are shortcuts for not having to
810
+ # create a Scheduler instance:
811
+
812
+ #: Default :class:`Scheduler <Scheduler>` object
813
+ default_scheduler = Scheduler()
814
+
815
+ #: Default :class:`Jobs <Job>` list
816
+ jobs = default_scheduler.jobs # todo: should this be a copy, e.g. jobs()?
817
+
818
+
819
+ def every(interval: int = 1) -> Job:
820
+ """Calls :meth:`every <Scheduler.every>` on the
821
+ :data:`default scheduler instance <default_scheduler>`.
822
+ """
823
+ return default_scheduler.every(interval)
824
+
825
+
826
+ def run_pending() -> None:
827
+ """Calls :meth:`run_pending <Scheduler.run_pending>` on the
828
+ :data:`default scheduler instance <default_scheduler>`.
829
+ """
830
+ default_scheduler.run_pending()
831
+
832
+
833
+ def run_all(delay_seconds: int = 0) -> None:
834
+ """Calls :meth:`run_all <Scheduler.run_all>` on the
835
+ :data:`default scheduler instance <default_scheduler>`.
836
+ """
837
+ default_scheduler.run_all(delay_seconds=delay_seconds)
838
+
839
+
840
+ def get_jobs(tag: Optional[Hashable] = None) -> List[Job]:
841
+ """Calls :meth:`get_jobs <Scheduler.get_jobs>` on the
842
+ :data:`default scheduler instance <default_scheduler>`.
843
+ """
844
+ return default_scheduler.get_jobs(tag)
845
+
846
+
847
+ def clear(tag: Optional[Hashable] = None) -> None:
848
+ """Calls :meth:`clear <Scheduler.clear>` on the
849
+ :data:`default scheduler instance <default_scheduler>`.
850
+ """
851
+ default_scheduler.clear(tag)
852
+
853
+
854
+ def cancel_job(job: Job) -> None:
855
+ """Calls :meth:`cancel_job <Scheduler.cancel_job>` on the
856
+ :data:`default scheduler instance <default_scheduler>`.
857
+ """
858
+ default_scheduler.cancel_job(job)
859
+
860
+
861
+ def next_run(tag: Optional[Hashable] = None) -> Optional[datetime.datetime]:
862
+ """Calls :meth:`next_run <Scheduler.next_run>` on the
863
+ :data:`default scheduler instance <default_scheduler>`.
864
+ """
865
+ return default_scheduler.get_next_run(tag)
866
+
867
+
868
+ def idle_seconds() -> Optional[float]:
869
+ """Calls :meth:`idle_seconds <Scheduler.idle_seconds>` on the
870
+ :data:`default scheduler instance <default_scheduler>`.
871
+ """
872
+ return default_scheduler.idle_seconds
873
+
874
+
875
+ def repeat(job, *args, **kwargs):
876
+ """
877
+ Decorator to schedule a new periodic job.
878
+
879
+ Any additional arguments are passed on to the decorated function
880
+ when the job runs.
881
+
882
+ :param job: a :class:`Jobs <Job>`
883
+ """
884
+
885
+ def _schedule_decorator(decorated_function):
886
+ job.do(decorated_function, *args, **kwargs)
887
+ return decorated_function
888
+
889
+ return _schedule_decorator
890
+
891
+
892
+ def _random_interval(
893
+ earliest: Union[int, float], latest: Union[int, float]
894
+ ) -> Union[int, float]:
895
+ """
896
+ Pick a random interval between `earliest` and `latest`, inclusive.
897
+
898
+ Whole numbers (including floats such as 2.0) give a whole number, as
899
+ random.randint() did before Python 3.12 stopped accepting floats. Other
900
+ floats give any value in the range.
901
+ """
902
+ if float(earliest).is_integer() and float(latest).is_integer():
903
+ return random.randint(int(earliest), int(latest))
904
+ return random.uniform(earliest, latest)
905
+
906
+
907
+ _UTC = datetime.timezone.utc
908
+
909
+
910
+ def _parse_timezone(tz: Union[str, datetime.tzinfo]) -> datetime.tzinfo:
911
+ if isinstance(tz, datetime.tzinfo):
912
+ return tz
913
+ if not isinstance(tz, str):
914
+ raise ScheduleValueError("Timezone must be a string or a tzinfo object")
915
+ try:
916
+ import pytz
917
+ except ModuleNotFoundError:
918
+ pass
919
+ else:
920
+ # Prefer pytz when installed, for compatibility with schedule 1.2.
921
+ return pytz.timezone(tz)
922
+
923
+ import zoneinfo
924
+
925
+ try:
926
+ return zoneinfo.ZoneInfo(tz)
927
+ except (zoneinfo.ZoneInfoNotFoundError, ValueError) as e:
928
+ raise ScheduleValueError(
929
+ "Unknown timezone {!r}. On Windows, install the tzdata package "
930
+ "for timezone names to work.".format(tz)
931
+ ) from e
932
+
933
+
934
+ def _now_utc() -> datetime.datetime:
935
+ return datetime.datetime.now(_UTC)
936
+
937
+
938
+ def _wall_clock(moment: datetime.datetime, tz) -> datetime.datetime:
939
+ """
940
+ The naive wall-clock time of a UTC moment in `tz`, or in local time if
941
+ `tz` is None.
942
+ """
943
+ return moment.astimezone(tz).replace(tzinfo=None)
944
+
945
+
946
+ def _localize(wall: datetime.datetime, tz) -> datetime.datetime:
947
+ """
948
+ The UTC moment at which the clock in `tz` shows the naive time `wall`.
949
+ Local time is used if `tz` is None.
950
+
951
+ A wall-clock time that happens twice, when clocks go back, resolves to its
952
+ first occurrence. A wall-clock time that does not exist, when clocks go
953
+ forward, is moved forward by the size of the gap: 02:30 becomes 03:30
954
+ when clocks jump from 02:00 to 03:00.
955
+ """
956
+ if tz is None:
957
+ return wall.replace(fold=0).astimezone(_UTC)
958
+ if hasattr(tz, "localize"):
959
+ # pytz timezones need localize() to pick the correct UTC offset.
960
+ import pytz
961
+
962
+ try:
963
+ aware = tz.localize(wall, is_dst=None)
964
+ except pytz.AmbiguousTimeError:
965
+ aware = tz.localize(wall, is_dst=True)
966
+ except pytz.NonExistentTimeError:
967
+ aware = tz.localize(wall, is_dst=False)
968
+ return aware.astimezone(_UTC)
969
+ return wall.replace(tzinfo=tz, fold=0).astimezone(_UTC)
970
+
971
+
972
+ def _move_to_next_weekday(moment: datetime.datetime, weekday: str):
973
+ """
974
+ Move the given timestamp to the nearest given weekday. May be this week
975
+ or next week. If the timestamp is already at the given weekday, it is not
976
+ moved.
977
+ """
978
+ weekday_index = _weekday_index(weekday)
979
+
980
+ days_ahead = weekday_index - moment.weekday()
981
+ if days_ahead < 0:
982
+ # Target day already happened this week, move to next week
983
+ days_ahead += 7
984
+ return moment + datetime.timedelta(days=days_ahead)
985
+
986
+
987
+ def _weekday_index(day: str) -> int:
988
+ weekdays = (
989
+ "monday",
990
+ "tuesday",
991
+ "wednesday",
992
+ "thursday",
993
+ "friday",
994
+ "saturday",
995
+ "sunday",
996
+ )
997
+ if day not in weekdays:
998
+ raise ScheduleValueError(
999
+ "Invalid start day (valid start days are {})".format(weekdays)
1000
+ )
1001
+ return weekdays.index(day)
schedule_ng/py.typed ADDED
File without changes
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: schedule-ng
3
+ Version: 1.3.0
4
+ Summary: Job scheduling for humans. A maintained, drop-in successor to schedule.
5
+ Project-URL: Repository, https://github.com/Adz-ai/schedule-ng
6
+ Project-URL: Issues, https://github.com/Adz-ai/schedule-ng/issues
7
+ Project-URL: Changelog, https://github.com/Adz-ai/schedule-ng/blob/main/HISTORY.rst
8
+ Author-email: Daniel Bader <mail@dbader.org>
9
+ Maintainer: Adarssh
10
+ License-Expression: MIT
11
+ License-File: LICENSE.txt
12
+ Keywords: clockwork,cron,job scheduling,jobs,periodic,schedule,scheduler,scheduling
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.9
26
+ Provides-Extra: timezone
27
+ Requires-Dist: pytz; extra == 'timezone'
28
+ Description-Content-Type: text/x-rst
29
+
30
+ schedule-ng
31
+ ===========
32
+
33
+ .. image:: https://github.com/Adz-ai/schedule-ng/actions/workflows/ci.yml/badge.svg
34
+ :target: https://github.com/Adz-ai/schedule-ng/actions/workflows/ci.yml
35
+
36
+ .. image:: https://img.shields.io/pypi/v/schedule-ng.svg
37
+ :target: https://pypi.org/project/schedule-ng/
38
+
39
+ Python job scheduling for humans. Run Python functions (or any other callable) periodically using a friendly syntax.
40
+
41
+ **schedule-ng is a maintained, drop-in compatible fork of** `schedule <https://github.com/dbader/schedule>`_
42
+ **by Daniel Bader**, which has not had a release since 1.2.2 (May 2024). The API is unchanged: existing code
43
+ keeps working by changing one import.
44
+
45
+ - A simple to use API for scheduling jobs, made for humans.
46
+ - In-process scheduler for periodic jobs. No extra processes needed!
47
+ - Very lightweight and no external dependencies.
48
+ - Excellent test coverage.
49
+ - Tested on Python 3.9 to 3.14 on Linux, macOS and Windows.
50
+
51
+ Usage
52
+ -----
53
+
54
+ .. code-block:: bash
55
+
56
+ $ pip install schedule-ng
57
+
58
+ .. code-block:: python
59
+
60
+ import schedule_ng as schedule
61
+ import time
62
+
63
+ def job():
64
+ print("I'm working...")
65
+
66
+ schedule.every(10).seconds.do(job)
67
+ schedule.every(10).minutes.do(job)
68
+ schedule.every().hour.do(job)
69
+ schedule.every().day.at("10:30").do(job)
70
+ schedule.every(5).to(10).minutes.do(job)
71
+ schedule.every().monday.do(job)
72
+ schedule.every().wednesday.at("13:15").do(job)
73
+ schedule.every().day.at("12:42", "Europe/Amsterdam").do(job)
74
+ schedule.every().minute.at(":17").do(job)
75
+
76
+ def job_with_argument(name):
77
+ print(f"I am {name}")
78
+
79
+ schedule.every(10).seconds.do(job_with_argument, name="Peter")
80
+
81
+ while True:
82
+ schedule.run_pending()
83
+ time.sleep(1)
84
+
85
+ Migrating from schedule
86
+ -----------------------
87
+
88
+ 1. Replace ``schedule`` with ``schedule-ng`` in your dependencies.
89
+ 2. Replace ``import schedule`` with ``import schedule_ng as schedule``.
90
+
91
+ Behaviour, exceptions and the ``"schedule"`` logger name are unchanged. Any behavioural fix is listed in the
92
+ `changelog <https://github.com/Adz-ai/schedule-ng/blob/main/HISTORY.rst>`_.
93
+
94
+ Why a fork?
95
+ -----------
96
+
97
+ ``schedule`` is used by millions of projects every month, but issues and pull requests have gone unanswered
98
+ since 2024. This fork exists to keep it working on new Python versions, fix long-standing bugs and review the
99
+ community's pull requests, while keeping what made ``schedule`` popular: a small, simple, dependency-free API.
100
+
101
+ If the original project becomes active again, this fork will happily contribute its fixes back upstream.
102
+
103
+ Meta
104
+ ----
105
+
106
+ Original author: Daniel Bader - mail@dbader.org. Thanks to Sijmen Huizenga and everyone listed in
107
+ `AUTHORS.rst <https://github.com/Adz-ai/schedule-ng/blob/main/AUTHORS.rst>`_ for their work on ``schedule``.
108
+
109
+ Inspired by `Adam Wiggins' <https://github.com/adamwiggins>`_ article `"Rethinking Cron" <https://adam.herokuapp.com/past/2010/4/13/rethinking_cron/>`_ and the `clockwork <https://github.com/Rykian/clockwork>`_ Ruby module.
110
+
111
+ Distributed under the MIT license. See `LICENSE.txt <https://github.com/Adz-ai/schedule-ng/blob/main/LICENSE.txt>`_ for more information.
@@ -0,0 +1,6 @@
1
+ schedule_ng/__init__.py,sha256=BquBy4xHbZwsfTJjCUocZzRRotx_olyzxU9MW6vAN54,33854
2
+ schedule_ng/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ schedule_ng-1.3.0.dist-info/METADATA,sha256=bo_HYXyHOyZazXQwn5XOKQTQ70UFmKHTsuqQKtReY4g,4414
4
+ schedule_ng-1.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ schedule_ng-1.3.0.dist-info/licenses/LICENSE.txt,sha256=oS1kzxXgr0r8dvUqmgoDmUQrtDcFXAXOng74_x4sVvA,1143
6
+ schedule_ng-1.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2013 Daniel Bader (http://dbader.org)
4
+ Copyright (c) 2026 schedule-ng contributors
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.