diff options
Diffstat (limited to '.venv/lib/python3.12/site-packages/apscheduler/triggers')
9 files changed, 1247 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/apscheduler/triggers/__init__.py b/.venv/lib/python3.12/site-packages/apscheduler/triggers/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/.venv/lib/python3.12/site-packages/apscheduler/triggers/__init__.py diff --git a/.venv/lib/python3.12/site-packages/apscheduler/triggers/base.py b/.venv/lib/python3.12/site-packages/apscheduler/triggers/base.py new file mode 100644 index 00000000..917af8ca --- /dev/null +++ b/.venv/lib/python3.12/site-packages/apscheduler/triggers/base.py @@ -0,0 +1,35 @@ +import random +from abc import ABCMeta, abstractmethod +from datetime import timedelta + + +class BaseTrigger(metaclass=ABCMeta): + """Abstract base class that defines the interface that every trigger must implement.""" + + __slots__ = () + + @abstractmethod + def get_next_fire_time(self, previous_fire_time, now): + """ + Returns the next datetime to fire on, If no such datetime can be calculated, returns + ``None``. + + :param datetime.datetime previous_fire_time: the previous time the trigger was fired + :param datetime.datetime now: current datetime + """ + + def _apply_jitter(self, next_fire_time, jitter, now): + """ + Randomize ``next_fire_time`` by adding a random value (the jitter). + + :param datetime.datetime|None next_fire_time: next fire time without jitter applied. If + ``None``, returns ``None``. + :param int|None jitter: maximum number of seconds to add to ``next_fire_time`` + (if ``None`` or ``0``, returns ``next_fire_time``) + :param datetime.datetime now: current datetime + :return datetime.datetime|None: next fire time with a jitter. + """ + if next_fire_time is None or not jitter: + return next_fire_time + + return next_fire_time + timedelta(seconds=random.uniform(0, jitter)) diff --git a/.venv/lib/python3.12/site-packages/apscheduler/triggers/calendarinterval.py b/.venv/lib/python3.12/site-packages/apscheduler/triggers/calendarinterval.py new file mode 100644 index 00000000..cd860489 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/apscheduler/triggers/calendarinterval.py @@ -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, + astimezone, + 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 = astimezone(timezone) + else: + self.timezone = astimezone(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)})' diff --git a/.venv/lib/python3.12/site-packages/apscheduler/triggers/combining.py b/.venv/lib/python3.12/site-packages/apscheduler/triggers/combining.py new file mode 100644 index 00000000..653f9b57 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/apscheduler/triggers/combining.py @@ -0,0 +1,114 @@ +from apscheduler.triggers.base import BaseTrigger +from apscheduler.util import obj_to_ref, ref_to_obj + + +class BaseCombiningTrigger(BaseTrigger): + __slots__ = ("triggers", "jitter") + + def __init__(self, triggers, jitter=None): + self.triggers = triggers + self.jitter = jitter + + def __getstate__(self): + return { + "version": 1, + "triggers": [ + (obj_to_ref(trigger.__class__), trigger.__getstate__()) + for trigger in self.triggers + ], + "jitter": self.jitter, + } + + def __setstate__(self, state): + 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.jitter = state["jitter"] + self.triggers = [] + for clsref, state in state["triggers"]: + cls = ref_to_obj(clsref) + trigger = cls.__new__(cls) + trigger.__setstate__(state) + self.triggers.append(trigger) + + def __repr__(self): + return "<{}({}{})>".format( + self.__class__.__name__, + self.triggers, + f", jitter={self.jitter}" if self.jitter else "", + ) + + +class AndTrigger(BaseCombiningTrigger): + """ + Always returns the earliest next fire time that all the given triggers can agree on. + The trigger is considered to be finished when any of the given triggers has finished its + schedule. + + Trigger alias: ``and`` + + .. warning:: This trigger should only be used to combine triggers that fire on + specific times of day, such as + :class:`~apscheduler.triggers.cron.CronTrigger` and + class:`~apscheduler.triggers.calendarinterval.CalendarIntervalTrigger`. + Attempting to use it with + :class:`~apscheduler.triggers.interval.IntervalTrigger` will likely result in + the scheduler hanging as it tries to find a fire time that matches exactly + between fire times produced by all the given triggers. + + :param list triggers: triggers to combine + :param int|None jitter: delay the job execution by ``jitter`` seconds at most + """ + + __slots__ = () + + def get_next_fire_time(self, previous_fire_time, now): + while True: + fire_times = [ + trigger.get_next_fire_time(previous_fire_time, now) + for trigger in self.triggers + ] + if None in fire_times: + return None + elif min(fire_times) == max(fire_times): + return self._apply_jitter(fire_times[0], self.jitter, now) + else: + now = max(fire_times) + + def __str__(self): + return "and[{}]".format(", ".join(str(trigger) for trigger in self.triggers)) + + +class OrTrigger(BaseCombiningTrigger): + """ + Always returns the earliest next fire time produced by any of the given triggers. + The trigger is considered finished when all the given triggers have finished their schedules. + + Trigger alias: ``or`` + + :param list triggers: triggers to combine + :param int|None jitter: delay the job execution by ``jitter`` seconds at most + + .. note:: Triggers that depends on the previous fire time, such as the interval trigger, may + seem to behave strangely since they are always passed the previous fire time produced by + any of the given triggers. + """ + + __slots__ = () + + def get_next_fire_time(self, previous_fire_time, now): + fire_times = [ + trigger.get_next_fire_time(previous_fire_time, now) + for trigger in self.triggers + ] + fire_times = [fire_time for fire_time in fire_times if fire_time is not None] + if fire_times: + return self._apply_jitter(min(fire_times), self.jitter, now) + else: + return None + + def __str__(self): + return "or[{}]".format(", ".join(str(trigger) for trigger in self.triggers)) diff --git a/.venv/lib/python3.12/site-packages/apscheduler/triggers/cron/__init__.py b/.venv/lib/python3.12/site-packages/apscheduler/triggers/cron/__init__.py new file mode 100644 index 00000000..03be8196 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/apscheduler/triggers/cron/__init__.py @@ -0,0 +1,289 @@ +from datetime import datetime, timedelta + +from tzlocal import get_localzone + +from apscheduler.triggers.base import BaseTrigger +from apscheduler.triggers.cron.fields import ( + DEFAULT_VALUES, + BaseField, + DayOfMonthField, + DayOfWeekField, + MonthField, + WeekField, +) +from apscheduler.util import ( + astimezone, + convert_to_datetime, + datetime_ceil, + datetime_repr, +) + + +class CronTrigger(BaseTrigger): + """ + Triggers when current time matches all specified time constraints, + similarly to how the UNIX cron scheduler works. + + :param int|str year: 4-digit year + :param int|str month: month (1-12) + :param int|str day: day of month (1-31) + :param int|str week: ISO week (1-53) + :param int|str day_of_week: number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun) + :param int|str hour: hour (0-23) + :param int|str minute: minute (0-59) + :param int|str second: second (0-59) + :param datetime|str start_date: earliest possible date/time to trigger on (inclusive) + :param datetime|str end_date: latest possible date/time to trigger on (inclusive) + :param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (defaults + to scheduler timezone) + :param int|None jitter: delay the job execution by ``jitter`` seconds at most + + .. note:: The first weekday is always **monday**. + """ + + FIELD_NAMES = ( + "year", + "month", + "day", + "week", + "day_of_week", + "hour", + "minute", + "second", + ) + FIELDS_MAP = { + "year": BaseField, + "month": MonthField, + "week": WeekField, + "day": DayOfMonthField, + "day_of_week": DayOfWeekField, + "hour": BaseField, + "minute": BaseField, + "second": BaseField, + } + + __slots__ = "timezone", "start_date", "end_date", "fields", "jitter" + + def __init__( + self, + year=None, + month=None, + day=None, + week=None, + day_of_week=None, + hour=None, + minute=None, + second=None, + start_date=None, + end_date=None, + timezone=None, + jitter=None, + ): + if timezone: + self.timezone = astimezone(timezone) + elif isinstance(start_date, datetime) and start_date.tzinfo: + self.timezone = astimezone(start_date.tzinfo) + elif isinstance(end_date, datetime) and end_date.tzinfo: + self.timezone = astimezone(end_date.tzinfo) + else: + self.timezone = get_localzone() + + self.start_date = convert_to_datetime(start_date, self.timezone, "start_date") + self.end_date = convert_to_datetime(end_date, self.timezone, "end_date") + + self.jitter = jitter + + values = dict( + (key, value) + for (key, value) in locals().items() + if key in self.FIELD_NAMES and value is not None + ) + self.fields = [] + assign_defaults = False + for field_name in self.FIELD_NAMES: + if field_name in values: + exprs = values.pop(field_name) + is_default = False + assign_defaults = not values + elif assign_defaults: + exprs = DEFAULT_VALUES[field_name] + is_default = True + else: + exprs = "*" + is_default = True + + field_class = self.FIELDS_MAP[field_name] + field = field_class(field_name, exprs, is_default) + self.fields.append(field) + + @classmethod + def from_crontab(cls, expr, timezone=None): + """ + Create a :class:`~CronTrigger` from a standard crontab expression. + + See https://en.wikipedia.org/wiki/Cron for more information on the format accepted here. + + :param expr: minute, hour, day of month, month, day of week + :param datetime.tzinfo|str timezone: time zone to use for the date/time calculations ( + defaults to scheduler timezone) + :return: a :class:`~CronTrigger` instance + + """ + values = expr.split() + if len(values) != 5: + raise ValueError(f"Wrong number of fields; got {len(values)}, expected 5") + + return cls( + minute=values[0], + hour=values[1], + day=values[2], + month=values[3], + day_of_week=values[4], + timezone=timezone, + ) + + def _increment_field_value(self, dateval, fieldnum): + """ + Increments the designated field and resets all less significant fields to their minimum + values. + + :type dateval: datetime + :type fieldnum: int + :return: a tuple containing the new date, and the number of the field that was actually + incremented + :rtype: tuple + """ + + values = {} + i = 0 + while i < len(self.fields): + field = self.fields[i] + if not field.REAL: + if i == fieldnum: + fieldnum -= 1 + i -= 1 + else: + i += 1 + continue + + if i < fieldnum: + values[field.name] = field.get_value(dateval) + i += 1 + elif i > fieldnum: + values[field.name] = field.get_min(dateval) + i += 1 + else: + value = field.get_value(dateval) + maxval = field.get_max(dateval) + if value == maxval: + fieldnum -= 1 + i -= 1 + else: + values[field.name] = value + 1 + i += 1 + + difference = datetime(**values) - dateval.replace(tzinfo=None) + dateval = datetime.fromtimestamp( + dateval.timestamp() + difference.total_seconds(), self.timezone + ) + return dateval, fieldnum + + def _set_field_value(self, dateval, fieldnum, new_value): + values = {} + for i, field in enumerate(self.fields): + if field.REAL: + if i < fieldnum: + values[field.name] = field.get_value(dateval) + elif i > fieldnum: + values[field.name] = field.get_min(dateval) + else: + values[field.name] = new_value + + return datetime(**values, tzinfo=self.timezone, fold=dateval.fold) + + def get_next_fire_time(self, previous_fire_time, now): + if previous_fire_time: + start_date = min(now, previous_fire_time + timedelta(microseconds=1)) + if start_date == previous_fire_time: + start_date += timedelta(microseconds=1) + else: + start_date = max(now, self.start_date) if self.start_date else now + + fieldnum = 0 + next_date = datetime_ceil(start_date).astimezone(self.timezone) + while 0 <= fieldnum < len(self.fields): + field = self.fields[fieldnum] + curr_value = field.get_value(next_date) + next_value = field.get_next_value(next_date) + + if next_value is None: + # No valid value was found + next_date, fieldnum = self._increment_field_value( + next_date, fieldnum - 1 + ) + elif next_value > curr_value: + # A valid, but higher than the starting value, was found + if field.REAL: + next_date = self._set_field_value(next_date, fieldnum, next_value) + fieldnum += 1 + else: + next_date, fieldnum = self._increment_field_value( + next_date, fieldnum + ) + else: + # A valid value was found, no changes necessary + fieldnum += 1 + + # Return if the date has rolled past the end date + if self.end_date and next_date > self.end_date: + return None + + if fieldnum >= 0: + next_date = self._apply_jitter(next_date, self.jitter, now) + return min(next_date, self.end_date) if self.end_date else next_date + + def __getstate__(self): + return { + "version": 2, + "timezone": self.timezone, + "start_date": self.start_date, + "end_date": self.end_date, + "fields": self.fields, + "jitter": self.jitter, + } + + def __setstate__(self, state): + # This is for compatibility with APScheduler 3.0.x + if isinstance(state, tuple): + state = state[1] + + if state.get("version", 1) > 2: + raise ValueError( + f"Got serialized data for version {state['version']} of " + f"{self.__class__.__name__}, but only versions up to 2 can be handled" + ) + + self.timezone = astimezone(state["timezone"]) + self.start_date = state["start_date"] + self.end_date = state["end_date"] + self.fields = state["fields"] + self.jitter = state.get("jitter") + + def __str__(self): + options = [f"{f.name}='{f}'" for f in self.fields if not f.is_default] + return "cron[{}]".format(", ".join(options)) + + def __repr__(self): + options = [f"{f.name}='{f}'" for f in self.fields if not f.is_default] + if self.start_date: + options.append(f"start_date={datetime_repr(self.start_date)!r}") + if self.end_date: + options.append(f"end_date={datetime_repr(self.end_date)!r}") + if self.jitter: + options.append(f"jitter={self.jitter}") + + return "<{} ({}, timezone='{}')>".format( + self.__class__.__name__, + ", ".join(options), + self.timezone, + ) diff --git a/.venv/lib/python3.12/site-packages/apscheduler/triggers/cron/expressions.py b/.venv/lib/python3.12/site-packages/apscheduler/triggers/cron/expressions.py new file mode 100644 index 00000000..0d84ec23 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/apscheduler/triggers/cron/expressions.py @@ -0,0 +1,285 @@ +"""This module contains the expressions applicable for CronTrigger's fields.""" + +__all__ = ( + "AllExpression", + "RangeExpression", + "WeekdayRangeExpression", + "WeekdayPositionExpression", + "LastDayOfMonthExpression", +) + +import re +from calendar import monthrange + +from apscheduler.util import asint + +WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +MONTHS = [ + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", +] + + +class AllExpression: + value_re = re.compile(r"\*(?:/(?P<step>\d+))?$") + + def __init__(self, step=None): + self.step = asint(step) + if self.step == 0: + raise ValueError("Increment must be higher than 0") + + def validate_range(self, field_name): + from apscheduler.triggers.cron.fields import MAX_VALUES, MIN_VALUES + + value_range = MAX_VALUES[field_name] - MIN_VALUES[field_name] + if self.step and self.step > value_range: + raise ValueError( + f"the step value ({self.step}) is higher than the total range of the " + f"expression ({value_range})" + ) + + def get_next_value(self, date, field): + start = field.get_value(date) + minval = field.get_min(date) + maxval = field.get_max(date) + start = max(start, minval) + + if not self.step: + next = start + else: + distance_to_next = (self.step - (start - minval)) % self.step + next = start + distance_to_next + + if next <= maxval: + return next + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.step == other.step + + def __str__(self): + if self.step: + return "*/%d" % self.step + return "*" + + def __repr__(self): + return f"{self.__class__.__name__}({self.step})" + + +class RangeExpression(AllExpression): + value_re = re.compile(r"(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$") + + def __init__(self, first, last=None, step=None): + super().__init__(step) + first = asint(first) + last = asint(last) + if last is None and step is None: + last = first + if last is not None and first > last: + raise ValueError( + "The minimum value in a range must not be higher than the maximum" + ) + self.first = first + self.last = last + + def validate_range(self, field_name): + from apscheduler.triggers.cron.fields import MAX_VALUES, MIN_VALUES + + super().validate_range(field_name) + if self.first < MIN_VALUES[field_name]: + raise ValueError( + f"the first value ({self.first}) is lower than the minimum value ({MIN_VALUES[field_name]})" + ) + if self.last is not None and self.last > MAX_VALUES[field_name]: + raise ValueError( + f"the last value ({self.last}) is higher than the maximum value ({MAX_VALUES[field_name]})" + ) + value_range = (self.last or MAX_VALUES[field_name]) - self.first + if self.step and self.step > value_range: + raise ValueError( + f"the step value ({self.step}) is higher than the total range of the " + f"expression ({value_range})" + ) + + def get_next_value(self, date, field): + startval = field.get_value(date) + minval = field.get_min(date) + maxval = field.get_max(date) + + # Apply range limits + minval = max(minval, self.first) + maxval = min(maxval, self.last) if self.last is not None else maxval + nextval = max(minval, startval) + + # Apply the step if defined + if self.step: + distance_to_next = (self.step - (nextval - minval)) % self.step + nextval += distance_to_next + + return nextval if nextval <= maxval else None + + def __eq__(self, other): + return ( + isinstance(other, self.__class__) + and self.first == other.first + and self.last == other.last + ) + + def __str__(self): + if self.last != self.first and self.last is not None: + range = "%d-%d" % (self.first, self.last) + else: + range = str(self.first) + + if self.step: + return "%s/%d" % (range, self.step) + + return range + + def __repr__(self): + args = [str(self.first)] + if self.last != self.first and self.last is not None or self.step: + args.append(str(self.last)) + + if self.step: + args.append(str(self.step)) + + return "{}({})".format(self.__class__.__name__, ", ".join(args)) + + +class MonthRangeExpression(RangeExpression): + value_re = re.compile(r"(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?", re.IGNORECASE) + + def __init__(self, first, last=None): + try: + first_num = MONTHS.index(first.lower()) + 1 + except ValueError: + raise ValueError(f'Invalid month name "{first}"') + + if last: + try: + last_num = MONTHS.index(last.lower()) + 1 + except ValueError: + raise ValueError(f'Invalid month name "{last}"') + else: + last_num = None + + super().__init__(first_num, last_num) + + def __str__(self): + if self.last != self.first and self.last is not None: + return f"{MONTHS[self.first - 1]}-{MONTHS[self.last - 1]}" + return MONTHS[self.first - 1] + + def __repr__(self): + args = [f"'{MONTHS[self.first]}'"] + if self.last != self.first and self.last is not None: + args.append(f"'{MONTHS[self.last - 1]}'") + return "{}({})".format(self.__class__.__name__, ", ".join(args)) + + +class WeekdayRangeExpression(RangeExpression): + value_re = re.compile(r"(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?", re.IGNORECASE) + + def __init__(self, first, last=None): + try: + first_num = WEEKDAYS.index(first.lower()) + except ValueError: + raise ValueError(f'Invalid weekday name "{first}"') + + if last: + try: + last_num = WEEKDAYS.index(last.lower()) + except ValueError: + raise ValueError(f'Invalid weekday name "{last}"') + else: + last_num = None + + super().__init__(first_num, last_num) + + def __str__(self): + if self.last != self.first and self.last is not None: + return f"{WEEKDAYS[self.first]}-{WEEKDAYS[self.last]}" + return WEEKDAYS[self.first] + + def __repr__(self): + args = [f"'{WEEKDAYS[self.first]}'"] + if self.last != self.first and self.last is not None: + args.append(f"'{WEEKDAYS[self.last]}'") + return "{}({})".format(self.__class__.__name__, ", ".join(args)) + + +class WeekdayPositionExpression(AllExpression): + options = ["1st", "2nd", "3rd", "4th", "5th", "last"] + value_re = re.compile( + r"(?P<option_name>{}) +(?P<weekday_name>(?:\d+|\w+))".format("|".join(options)), + re.IGNORECASE, + ) + + def __init__(self, option_name, weekday_name): + super().__init__(None) + try: + self.option_num = self.options.index(option_name.lower()) + except ValueError: + raise ValueError(f'Invalid weekday position "{option_name}"') + + try: + self.weekday = WEEKDAYS.index(weekday_name.lower()) + except ValueError: + raise ValueError(f'Invalid weekday name "{weekday_name}"') + + def get_next_value(self, date, field): + # Figure out the weekday of the month's first day and the number of days in that month + first_day_wday, last_day = monthrange(date.year, date.month) + + # Calculate which day of the month is the first of the target weekdays + first_hit_day = self.weekday - first_day_wday + 1 + if first_hit_day <= 0: + first_hit_day += 7 + + # Calculate what day of the month the target weekday would be + if self.option_num < 5: + target_day = first_hit_day + self.option_num * 7 + else: + target_day = first_hit_day + ((last_day - first_hit_day) // 7) * 7 + + if target_day <= last_day and target_day >= date.day: + return target_day + + def __eq__(self, other): + return ( + super().__eq__(other) + and self.option_num == other.option_num + and self.weekday == other.weekday + ) + + def __str__(self): + return f"{self.options[self.option_num]} {WEEKDAYS[self.weekday]}" + + def __repr__(self): + return f"{self.__class__.__name__}('{self.options[self.option_num]}', '{WEEKDAYS[self.weekday]}')" + + +class LastDayOfMonthExpression(AllExpression): + value_re = re.compile(r"last", re.IGNORECASE) + + def __init__(self): + super().__init__(None) + + def get_next_value(self, date, field): + return monthrange(date.year, date.month)[1] + + def __str__(self): + return "last" + + def __repr__(self): + return f"{self.__class__.__name__}()" diff --git a/.venv/lib/python3.12/site-packages/apscheduler/triggers/cron/fields.py b/.venv/lib/python3.12/site-packages/apscheduler/triggers/cron/fields.py new file mode 100644 index 00000000..4c081797 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/apscheduler/triggers/cron/fields.py @@ -0,0 +1,149 @@ +"""Fields represent CronTrigger options which map to :class:`~datetime.datetime` fields.""" + +__all__ = ( + "MIN_VALUES", + "MAX_VALUES", + "DEFAULT_VALUES", + "BaseField", + "WeekField", + "DayOfMonthField", + "DayOfWeekField", +) + +import re +from calendar import monthrange + +from apscheduler.triggers.cron.expressions import ( + AllExpression, + LastDayOfMonthExpression, + MonthRangeExpression, + RangeExpression, + WeekdayPositionExpression, + WeekdayRangeExpression, +) + +MIN_VALUES = { + "year": 1970, + "month": 1, + "day": 1, + "week": 1, + "day_of_week": 0, + "hour": 0, + "minute": 0, + "second": 0, +} +MAX_VALUES = { + "year": 9999, + "month": 12, + "day": 31, + "week": 53, + "day_of_week": 6, + "hour": 23, + "minute": 59, + "second": 59, +} +DEFAULT_VALUES = { + "year": "*", + "month": 1, + "day": 1, + "week": "*", + "day_of_week": "*", + "hour": 0, + "minute": 0, + "second": 0, +} +SEPARATOR = re.compile(" *, *") + + +class BaseField: + REAL = True + COMPILERS = [AllExpression, RangeExpression] + + def __init__(self, name, exprs, is_default=False): + self.name = name + self.is_default = is_default + self.compile_expressions(exprs) + + def get_min(self, dateval): + return MIN_VALUES[self.name] + + def get_max(self, dateval): + return MAX_VALUES[self.name] + + def get_value(self, dateval): + return getattr(dateval, self.name) + + def get_next_value(self, dateval): + smallest = None + for expr in self.expressions: + value = expr.get_next_value(dateval, self) + if smallest is None or (value is not None and value < smallest): + smallest = value + + return smallest + + def compile_expressions(self, exprs): + self.expressions = [] + + # Split a comma-separated expression list, if any + for expr in SEPARATOR.split(str(exprs).strip()): + self.compile_expression(expr) + + def compile_expression(self, expr): + for compiler in self.COMPILERS: + match = compiler.value_re.match(expr) + if match: + compiled_expr = compiler(**match.groupdict()) + + try: + compiled_expr.validate_range(self.name) + except ValueError as e: + raise ValueError( + f"Error validating expression {expr!r}: {e}" + ) from None + + self.expressions.append(compiled_expr) + return + + raise ValueError(f'Unrecognized expression "{expr}" for field "{self.name}"') + + def __eq__(self, other): + return ( + isinstance(self, self.__class__) and self.expressions == other.expressions + ) + + def __str__(self): + expr_strings = (str(e) for e in self.expressions) + return ",".join(expr_strings) + + def __repr__(self): + return f"{self.__class__.__name__}('{self.name}', '{self}')" + + +class WeekField(BaseField): + REAL = False + + def get_value(self, dateval): + return dateval.isocalendar()[1] + + +class DayOfMonthField(BaseField): + COMPILERS = BaseField.COMPILERS + [ + WeekdayPositionExpression, + LastDayOfMonthExpression, + ] + + def get_max(self, dateval): + return monthrange(dateval.year, dateval.month)[1] + + +class DayOfWeekField(BaseField): + REAL = False + COMPILERS = BaseField.COMPILERS + [WeekdayRangeExpression] + + def get_value(self, dateval): + return dateval.weekday() + + +class MonthField(BaseField): + COMPILERS = BaseField.COMPILERS + [MonthRangeExpression] diff --git a/.venv/lib/python3.12/site-packages/apscheduler/triggers/date.py b/.venv/lib/python3.12/site-packages/apscheduler/triggers/date.py new file mode 100644 index 00000000..a9302da5 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/apscheduler/triggers/date.py @@ -0,0 +1,51 @@ +from datetime import datetime + +from tzlocal import get_localzone + +from apscheduler.triggers.base import BaseTrigger +from apscheduler.util import astimezone, convert_to_datetime, datetime_repr + + +class DateTrigger(BaseTrigger): + """ + Triggers once on the given datetime. If ``run_date`` is left empty, current time is used. + + :param datetime|str run_date: the date/time to run the job at + :param datetime.tzinfo|str timezone: time zone for ``run_date`` if it doesn't have one already + """ + + __slots__ = "run_date" + + def __init__(self, run_date=None, timezone=None): + timezone = astimezone(timezone) or get_localzone() + if run_date is not None: + self.run_date = convert_to_datetime(run_date, timezone, "run_date") + else: + self.run_date = datetime.now(timezone) + + def get_next_fire_time(self, previous_fire_time, now): + return self.run_date if previous_fire_time is None else None + + def __getstate__(self): + return {"version": 1, "run_date": self.run_date} + + def __setstate__(self, state): + # This is for compatibility with APScheduler 3.0.x + if isinstance(state, tuple): + state = state[1] + + if state.get("version", 1) > 1: + raise ValueError( + f"Got serialized data for version {state['version']} of " + f"{self.__class__.__name__}, but only version 1 can be handled" + ) + + self.run_date = state["run_date"] + + def __str__(self): + return f"date[{datetime_repr(self.run_date)}]" + + def __repr__(self): + return ( + f"<{self.__class__.__name__} (run_date='{datetime_repr(self.run_date)}')>" + ) diff --git a/.venv/lib/python3.12/site-packages/apscheduler/triggers/interval.py b/.venv/lib/python3.12/site-packages/apscheduler/triggers/interval.py new file mode 100644 index 00000000..9264c4ac --- /dev/null +++ b/.venv/lib/python3.12/site-packages/apscheduler/triggers/interval.py @@ -0,0 +1,138 @@ +import random +from datetime import datetime, timedelta +from math import ceil + +from tzlocal import get_localzone + +from apscheduler.triggers.base import BaseTrigger +from apscheduler.util import ( + astimezone, + convert_to_datetime, + datetime_repr, +) + + +class IntervalTrigger(BaseTrigger): + """ + Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` + + interval otherwise. + + :param int weeks: number of weeks to wait + :param int days: number of days to wait + :param int hours: number of hours to wait + :param int minutes: number of minutes to wait + :param int seconds: number of seconds to wait + :param datetime|str start_date: starting point for the interval calculation + :param datetime|str end_date: latest possible date/time to trigger on + :param datetime.tzinfo|str timezone: time zone to use for the date/time calculations + :param int|None jitter: delay the job execution by ``jitter`` seconds at most + """ + + __slots__ = ( + "timezone", + "start_date", + "end_date", + "interval", + "interval_length", + "jitter", + ) + + def __init__( + self, + weeks=0, + days=0, + hours=0, + minutes=0, + seconds=0, + start_date=None, + end_date=None, + timezone=None, + jitter=None, + ): + self.interval = timedelta( + weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds + ) + self.interval_length = self.interval.total_seconds() + if self.interval_length == 0: + self.interval = timedelta(seconds=1) + self.interval_length = 1 + + if timezone: + self.timezone = astimezone(timezone) + elif isinstance(start_date, datetime) and start_date.tzinfo: + self.timezone = astimezone(start_date.tzinfo) + elif isinstance(end_date, datetime) and end_date.tzinfo: + self.timezone = astimezone(end_date.tzinfo) + else: + self.timezone = get_localzone() + + start_date = start_date or (datetime.now(self.timezone) + self.interval) + self.start_date = convert_to_datetime(start_date, self.timezone, "start_date") + self.end_date = convert_to_datetime(end_date, self.timezone, "end_date") + + self.jitter = jitter + + def get_next_fire_time(self, previous_fire_time, now): + if previous_fire_time: + next_fire_time = previous_fire_time.timestamp() + self.interval_length + elif self.start_date > now: + next_fire_time = self.start_date.timestamp() + else: + timediff = now.timestamp() - self.start_date.timestamp() + next_interval_num = int(ceil(timediff / self.interval_length)) + next_fire_time = ( + self.start_date.timestamp() + self.interval_length * next_interval_num + ) + + if self.jitter is not None: + next_fire_time += random.uniform(0, self.jitter) + + if not self.end_date or next_fire_time <= self.end_date.timestamp(): + return datetime.fromtimestamp(next_fire_time, tz=self.timezone) + + def __getstate__(self): + return { + "version": 2, + "timezone": astimezone(self.timezone), + "start_date": self.start_date, + "end_date": self.end_date, + "interval": self.interval, + "jitter": self.jitter, + } + + def __setstate__(self, state): + # This is for compatibility with APScheduler 3.0.x + if isinstance(state, tuple): + state = state[1] + + if state.get("version", 1) > 2: + raise ValueError( + f"Got serialized data for version {state['version']} of " + f"{self.__class__.__name__}, but only versions up to 2 can be handled" + ) + + self.timezone = state["timezone"] + self.start_date = state["start_date"] + self.end_date = state["end_date"] + self.interval = state["interval"] + self.interval_length = self.interval.total_seconds() + self.jitter = state.get("jitter") + + def __str__(self): + return f"interval[{self.interval!s}]" + + def __repr__(self): + options = [ + f"interval={self.interval!r}", + f"start_date={datetime_repr(self.start_date)!r}", + ] + if self.end_date: + options.append(f"end_date={datetime_repr(self.end_date)!r}") + if self.jitter: + options.append(f"jitter={self.jitter}") + + return "<{} ({}, timezone='{}')>".format( + self.__class__.__name__, + ", ".join(options), + self.timezone, + ) |