summaryrefslogtreecommitdiff
path: root/src/apscheduler/triggers/calendarinterval.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/apscheduler/triggers/calendarinterval.py')
-rw-r--r--src/apscheduler/triggers/calendarinterval.py145
1 files changed, 145 insertions, 0 deletions
diff --git a/src/apscheduler/triggers/calendarinterval.py b/src/apscheduler/triggers/calendarinterval.py
new file mode 100644
index 0000000..c6d63da
--- /dev/null
+++ b/src/apscheduler/triggers/calendarinterval.py
@@ -0,0 +1,145 @@
+from datetime import date, datetime, time, timedelta, tzinfo
+from typing import Optional, Union
+
+from ..abc import Trigger
+from ..marshalling import marshal_date, marshal_timezone, unmarshal_date, unmarshal_timezone
+from ..util import timezone_repr
+from ..validators import as_date, as_timezone, require_state_version
+
+
+class CalendarIntervalTrigger(Trigger):
+ """
+ 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 where 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
+ """
+
+ __slots__ = ('years', 'months', 'weeks', 'days', 'start_date', 'end_date', 'timezone', '_time',
+ '_last_fire_date')
+
+ 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: Union[date, str, None] = None,
+ end_date: Union[date, str, None] = None,
+ timezone: Union[str, tzinfo] = 'local'):
+ self.years = years
+ self.months = months
+ self.weeks = weeks
+ self.days = days
+ self.timezone = as_timezone(timezone)
+ self.start_date = as_date(start_date) or datetime.now(self.timezone).date()
+ self.end_date = as_date(end_date)
+ self._time = time(hour, minute, second, tzinfo=timezone)
+ self._last_fire_date: Optional[date] = None
+
+ if self.years == self.months == self.weeks == self.days == 0:
+ raise ValueError('interval must be at least 1 day long')
+
+ if self.start_date and self.end_date and self.start_date > self.end_date:
+ raise ValueError('end_date cannot be earlier than start_date')
+
+ def next(self) -> Optional[datetime]:
+ previous_date: date = self._last_fire_date
+ while True:
+ if previous_date:
+ year, month = previous_date.year, previous_date.month
+ while True:
+ month += self.months
+ year += self.years + (month - 1) // 12
+ month = (month - 1) % 12 + 1
+ try:
+ next_date = date(year, month, previous_date.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.time() != self._time:
+ previous_date = next_time.date()
+ else:
+ self._last_fire_date = next_date
+ return next_time
+
+ def __getstate__(self):
+ return {
+ 'version': 1,
+ 'interval': [self.years, self.months, self.weeks, self.days],
+ 'time': [self._time.hour, self._time.minute, self._time.second],
+ 'start_date': marshal_date(self.start_date),
+ 'end_date': marshal_date(self.end_date),
+ 'timezone': marshal_timezone(self.timezone),
+ 'last_fire_date': marshal_date(self._last_fire_date)
+ }
+
+ def __setstate__(self, state):
+ require_state_version(self, state, 1)
+ self.years, self.months, self.weeks, self.days = state['interval']
+ self.start_date = unmarshal_date(state['start_date'])
+ self.end_date = unmarshal_date(state['end_date'])
+ self.timezone = unmarshal_timezone(state['timezone'])
+ self._time = time(*state['time'], tzinfo=self.timezone)
+ self._last_fire_date = unmarshal_date(state['last_fire_date'])
+
+ def __repr__(self):
+ 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)})'