-
Notifications
You must be signed in to change notification settings - Fork 714
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Backported CalendarIntervalTrigger from master
- Loading branch information
Showing
6 changed files
with
421 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,186 @@ | ||
from __future__ import annotations | ||
|
||
from datetime import date, datetime, time, timedelta, tzinfo | ||
from typing import Any | ||
|
||
from tzlocal import get_localzone | ||
|
||
from apscheduler.triggers.base import BaseTrigger | ||
from apscheduler.util import ( | ||
asdate, | ||
aszoneinfo, | ||
timezone_repr, | ||
) | ||
|
||
|
||
class CalendarIntervalTrigger(BaseTrigger): | ||
""" | ||
Runs the task on specified calendar-based intervals always at the same exact time of | ||
day. | ||
When calculating the next date, the ``years`` and ``months`` parameters are first | ||
added to the previous date while keeping the day of the month constant. This is | ||
repeated until the resulting date is valid. After that, the ``weeks`` and ``days`` | ||
parameters are added to that date. Finally, the date is combined with the given time | ||
(hour, minute, second) to form the final datetime. | ||
This means that if the ``days`` or ``weeks`` parameters are not used, the task will | ||
always be executed on the same day of the month at the same wall clock time, | ||
assuming the date and time are valid. | ||
If the resulting datetime is invalid due to a daylight saving forward shift, the | ||
date is discarded and the process moves on to the next date. If instead the datetime | ||
is ambiguous due to a backward DST shift, the earlier of the two resulting datetimes | ||
is used. | ||
If no previous run time is specified when requesting a new run time (like when | ||
starting for the first time or resuming after being paused), ``start_date`` is used | ||
as a reference and the next valid datetime equal to or later than the current time | ||
will be returned. Otherwise, the next valid datetime starting from the previous run | ||
time is returned, even if it's in the past. | ||
.. warning:: Be wary of setting a start date near the end of the month (29. – 31.) | ||
if you have ``months`` specified in your interval, as this will skip the months | ||
when those days do not exist. Likewise, setting the start date on the leap day | ||
(February 29th) and having ``years`` defined may cause some years to be skipped. | ||
Users are also discouraged from using a time inside the target timezone's DST | ||
switching period (typically around 2 am) since a date could either be skipped or | ||
repeated due to the specified wall clock time either occurring twice or not at | ||
all. | ||
:param years: number of years to wait | ||
:param months: number of months to wait | ||
:param weeks: number of weeks to wait | ||
:param days: number of days to wait | ||
:param hour: hour to run the task at | ||
:param minute: minute to run the task at | ||
:param second: second to run the task at | ||
:param start_date: first date to trigger on (defaults to current date if omitted) | ||
:param end_date: latest possible date to trigger on | ||
:param timezone: time zone to use for calculating the next fire time (defaults | ||
to scheduler timezone if created via the scheduler, otherwise the local time | ||
zone) | ||
:param jitter: delay the job execution by ``jitter`` seconds at most | ||
""" | ||
|
||
__slots__ = ( | ||
"years", | ||
"months", | ||
"weeks", | ||
"days", | ||
"start_date", | ||
"end_date", | ||
"timezone", | ||
"jitter", | ||
"_time", | ||
) | ||
|
||
def __init__( | ||
self, | ||
*, | ||
years: int = 0, | ||
months: int = 0, | ||
weeks: int = 0, | ||
days: int = 0, | ||
hour: int = 0, | ||
minute: int = 0, | ||
second: int = 0, | ||
start_date: date | str | None = None, | ||
end_date: date | str | None = None, | ||
timezone: str | tzinfo | None = None, | ||
jitter: int | None = None, | ||
): | ||
if timezone: | ||
self.timezone = aszoneinfo(timezone) | ||
else: | ||
self.timezone = aszoneinfo(get_localzone()) | ||
|
||
self.years = years | ||
self.months = months | ||
self.weeks = weeks | ||
self.days = days | ||
self.start_date = asdate(start_date) or date.today() | ||
self.end_date = asdate(end_date) | ||
self.jitter = jitter | ||
self._time = time(hour, minute, second, tzinfo=self.timezone) | ||
|
||
if self.years == self.months == self.weeks == self.days == 0: | ||
raise ValueError("interval must be at least 1 day long") | ||
|
||
if self.end_date and self.start_date > self.end_date: | ||
raise ValueError("end_date cannot be earlier than start_date") | ||
|
||
def get_next_fire_time( | ||
self, previous_fire_time: datetime | None, now: datetime | ||
) -> datetime | None: | ||
while True: | ||
if previous_fire_time: | ||
year, month = previous_fire_time.year, previous_fire_time.month | ||
while True: | ||
month += self.months | ||
year += self.years + (month - 1) // 12 | ||
month = (month - 1) % 12 + 1 | ||
try: | ||
next_date = date(year, month, previous_fire_time.day) | ||
except ValueError: | ||
pass # Nonexistent date | ||
else: | ||
next_date += timedelta(self.days + self.weeks * 7) | ||
break | ||
else: | ||
next_date = self.start_date | ||
|
||
# Don't return any date past end_date | ||
if self.end_date and next_date > self.end_date: | ||
return None | ||
|
||
# Combine the date with the designated time and normalize the result | ||
timestamp = datetime.combine(next_date, self._time).timestamp() | ||
next_time = datetime.fromtimestamp(timestamp, self.timezone) | ||
|
||
# Check if the time is off due to normalization and a forward DST shift | ||
if next_time.timetz() != self._time: | ||
previous_fire_time = next_time.date() | ||
else: | ||
return self._apply_jitter(next_time, self.jitter, now) | ||
|
||
def __getstate__(self) -> dict[str, Any]: | ||
return { | ||
"version": 1, | ||
"interval": [self.years, self.months, self.weeks, self.days], | ||
"time": [self._time.hour, self._time.minute, self._time.second], | ||
"start_date": self.start_date, | ||
"end_date": self.end_date, | ||
"timezone": self.timezone, | ||
"jitter": self.jitter, | ||
} | ||
|
||
def __setstate__(self, state: dict[str, Any]) -> None: | ||
if state.get("version", 1) > 1: | ||
raise ValueError( | ||
f"Got serialized data for version {state['version']} of " | ||
f"{self.__class__.__name__}, but only versions up to 1 can be handled" | ||
) | ||
|
||
self.years, self.months, self.weeks, self.days = state["interval"] | ||
self.start_date = state["start_date"] | ||
self.end_date = state["end_date"] | ||
self.timezone = state["timezone"] | ||
self.jitter = state["jitter"] | ||
self._time = time(*state["time"], tzinfo=self.timezone) | ||
|
||
def __repr__(self) -> str: | ||
fields = [] | ||
for field in "years", "months", "weeks", "days": | ||
value = getattr(self, field) | ||
if value > 0: | ||
fields.append(f"{field}={value}") | ||
|
||
fields.append(f"time={self._time.isoformat()!r}") | ||
fields.append(f"start_date='{self.start_date}'") | ||
if self.end_date: | ||
fields.append(f"end_date='{self.end_date}'") | ||
|
||
fields.append(f"timezone={timezone_repr(self.timezone)!r}") | ||
return f'{self.__class__.__name__}({", ".join(fields)})' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
:mod:`apscheduler.triggers.calendarinterval` | ||
============================================ | ||
|
||
.. automodule:: apscheduler.triggers.calendarinterval | ||
|
||
API | ||
--- | ||
|
||
Trigger alias for :meth:`~apscheduler.schedulers.base.BaseScheduler.add_job`: | ||
``calendarinterval`` | ||
|
||
.. autoclass:: CalendarIntervalTrigger | ||
:show-inheritance: | ||
|
||
Examples | ||
-------- | ||
|
||
:: | ||
|
||
from datetime import datetime | ||
|
||
from apscheduler.schedulers.blocking import BlockingScheduler | ||
|
||
|
||
def job_function(): | ||
print("Hello World") | ||
|
||
sched = BlockingScheduler() | ||
|
||
# Schedule job_function to be called every two months at 16:49:00 | ||
sched.add_job(job_function, 'calendarinterval', months=2, hour=16, minute=49) | ||
|
||
sched.start() | ||
|
||
|
||
You can use ``start_date`` and ``end_date`` to limit the total time in which the | ||
schedule runs:: | ||
|
||
# The same as before, but starts on 2010-10-10 and stops on 2014-06-15 | ||
sched.add_job( | ||
job_function, | ||
'calendarinterval', | ||
months=2, | ||
hour=16, | ||
minute=49, | ||
start_date='2010-10-10', | ||
end_date='2014-06-15' | ||
) | ||
|
||
|
||
The ``jitter`` option enables you to add a random component to the execution time. | ||
This might be useful if you have multiple servers and don't want them to run a job at | ||
the exact same moment or if you want to prevent multiple jobs with similar options from | ||
always running concurrently:: | ||
|
||
# Run `job_function` every two months with an extra delay picked randomly between 0 | ||
# and 120 seconds | ||
sched.add_job(job_function, 'calendarinterval', months=2, jitter=120) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.