Primer commit del proyecto RSS

This commit is contained in:
jlimolina 2025-05-24 14:37:58 +02:00
commit 27c9515d29
1568 changed files with 252311 additions and 0 deletions

View file

@ -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))

View file

@ -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)})'

View file

@ -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))

View file

@ -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,
)

View file

@ -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__}()"

View file

@ -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]

View file

@ -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)}')>"
)

View file

@ -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,
)