pax_global_header00006660000000000000000000000064147114324120014511gustar00rootroot0000000000000052 comment=2b67279daf0c7505477d645edcb46b36f5dd5bc6 cuu508-cronsim-2b67279/000077500000000000000000000000001471143241200144765ustar00rootroot00000000000000cuu508-cronsim-2b67279/.github/000077500000000000000000000000001471143241200160365ustar00rootroot00000000000000cuu508-cronsim-2b67279/.github/workflows/000077500000000000000000000000001471143241200200735ustar00rootroot00000000000000cuu508-cronsim-2b67279/.github/workflows/pytest.yml000066400000000000000000000013001471143241200221400ustar00rootroot00000000000000name: Tests on: [push] jobs: test: runs-on: ubuntu-20.04 strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies if: ${{ matrix.python-version == '3.7' || matrix.python-version == '3.8' }} run: | python -m pip install --upgrade pip pip install backports.zoneinfo - name: Run tests run: python -m unittest tests/test_* - name: Run mypy run: pip install mypy && mypy --strict cronsim/ cuu508-cronsim-2b67279/.gitignore000066400000000000000000000000461471143241200164660ustar00rootroot00000000000000__pycache__ cronsim.egg-info dist venvcuu508-cronsim-2b67279/.mypy.ini000066400000000000000000000000431471143241200162500ustar00rootroot00000000000000[mypy] files = cronsim/cronsim.py cuu508-cronsim-2b67279/CHANGELOG.md000066400000000000000000000021651471143241200163130ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. ## 2.6 - 2024-10-02 - Add support for iteration backwards in time (#2) ## 2.5 - 2023-07-01 - Add support for "LW" in the day-of-month field (#1) ## 2.4.1 - 2023-04-27 - [explain] Fix month-day formatting ("January 1st" -> "January 1") - [explain] Change ordinal formatting to use words instead of numerals for 1-9 - [explain] Fix digital time formatting (use HH:MM instead H:MM) ## 2.4 - 2023-04-26 - Add explain() method which describes the expression in human language - Remove python 3.7 support ## 2.3 - 2022-09-28 - Add type hints - Remove python 3.6 support (EOL) ## 2.2 - 2022-09-22 - Make validation error messages similar to Debian cron error messages - Change day-of-month and day-of-week handling to mimic Debian cron more closely ## 2.1 - 2022-04-30 - Add support for "L" in the day-of-week field - Fix crash when "¹" passed in the input ## 2.0 - 2021-11-15 - Rewrite to use zoneinfo (or backports.zoneinfo) instead of pytz - Add minimal type hints - Make CronSimError importable from the top level ## 1.0 - 2021-10-15 - Initial release cuu508-cronsim-2b67279/LICENSE000066400000000000000000000027141471143241200155070ustar00rootroot00000000000000Copyright (c) 2021, Pēteris Caune All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.cuu508-cronsim-2b67279/MANIFEST.in000066400000000000000000000000301471143241200162250ustar00rootroot00000000000000include cronsim/py.typedcuu508-cronsim-2b67279/README.md000066400000000000000000000135711471143241200157640ustar00rootroot00000000000000# CronSim [![Tests](https://github.com/cuu508/cronsim/actions/workflows/pytest.yml/badge.svg)](https://github.com/cuu508/cronsim/actions/workflows/pytest.yml) Cron Sim(ulator), a cron expression parser and evaluator. Works with Python 3.8+. CronSim is written for and being used in [Healthchecks](https://github.com/healthchecks/healthchecks/) (a cron job monitoring service). Development priorities: * Correctness. CronSim tries to match Debian's cron as closely as possible, including its quirky behaviour during DST transitions. * Readability. Prefer simple over clever. * Minimalism. Don't implement features that Healthchecks will not use (for example, the seconds field in cron expressions). ## Installation ``` pip install cronsim ``` ## Usage ```python from datetime import datetime from cronsim import CronSim it = CronSim("0 0 * 2 MON#5", datetime(2020, 1, 1)) for x in range(0, 5): print(next(it)) ``` Produces: ``` 2044-02-29 00:00:00 2072-02-29 00:00:00 2112-02-29 00:00:00 2140-02-29 00:00:00 2168-02-29 00:00:00 ``` To iterate backwards in time, add `reverse=True` in the constructor: ```python from datetime import datetime from cronsim import CronSim it = CronSim("0 0 * 2 MON#5", datetime(2020, 1, 1), reverse=True) print(next(it)) ``` Produces: ``` 2016-02-29 00:00:00 ``` If CronSim receives an invalid cron expression, it raises `cronsim.CronSimError`: ```python from datetime import datetime from cronsim import CronSim CronSim("123 * * * *", datetime(2020, 1, 1)) ``` Produces: ``` cronsim.cronsim.CronSimError: Bad minute ``` If CronSim cannot find a matching datetime in the next 50 years from the start date or from the previous match, it stops iteration by raising `StopIteration`: ```python from datetime import datetime from cronsim import CronSim # Every minute of 1st and 21st of month, # if it is also the *last Monday* of the month: it = CronSim("* * */20 * 1L", datetime(2020, 1, 1)) print(next(it)) ``` Produces: ``` StopIteration ``` ## CronSim Works With zoneinfo CronSim starting from version 2.0 is designed to work with timezones provided by the zoneinfo module. A previous version, CronSim 1.0, was designed for pytz and relied on its following non-standard features: * the non-standard `is_dst` flag in the `localize()` method * the `pytz.AmbiguousTimeError` and `pytz.NonExistentTimeError` exceptions * the `normalize()` method ## Supported Cron Expression Features CronSim aims to match [Debian's cron implementation](https://salsa.debian.org/debian/cron/-/tree/master/) (which itself is based on Paul Vixie's cron, with modifications). If CronSim evaluates an expression differently from Debian's cron, that's a bug. CronSim is open to adding support for non-standard syntax features, as long as they don't conflict or interfere with the standard syntax. ## DST Transitions CronSim handles Daylight Saving Time transitions the same as Debian's cron. Debian has special handling for jobs with a granularity greater than one hour: ``` Local time changes of less than three hours, such as those caused by the start or end of Daylight Saving Time, are handled specially. This only applies to jobs that run at a specific time and jobs that are run with a granularity greater than one hour. Jobs that run more fre- quently are scheduled normally. If time has moved forward, those jobs that would have run in the inter- val that has been skipped will be run immediately. Conversely, if time has moved backward, care is taken to avoid running jobs twice. ``` See test cases in `test_cronsim.py`, `TestDstTransitions` class for examples of this special handling. ## Cron Expression Feature Matrix | Feature | Debian | Quartz | croniter | cronsim | | ------------------------------------ | :----: | :----: | :------: | :-----: | | Seconds in the 6th field | No | Yes | Yes | No | | "L" as the day-of-month | No | Yes | Yes | Yes | | "LW" as the day-of-month | No | Yes | No | Yes | | "L" in the day-of-week field | No | Yes | No | Yes | | Nth weekday of month | No | Yes | Yes | Yes | **Seconds in the 6th field**: an optional sixth field specifying seconds. Supports the same syntax features as the minutes field. Quartz Scheduler expects seconds in the first field, croniter expects seconds in the last field. Quartz example: `*/15 * * * * *` (every 15 seconds). **"L" as the day-of-month**: support for the "L" special character in the day-of-month field. Interpreted as "the last day of the month". Example: `0 0 L * *` (at the midnight of the last day of every month). **"LW" as the day-of-month**: support for the "LW" special value in the day-of-month field. Interpreted as "the last weekday (Mon-Fri) of the month". Example: `0 0 LW * *` (at the midnight of the last weekday of every month). **"L" in the day-of-week field**: support for the "{weekday}L" syntax. For example, "5L" means "the last Friday of the month". Example: `0 0 * * 6L` (at the midnight of the last Saturday of every month). **Nth weekday of month**: support for "{weekday}#{nth}" syntax. For example, "MON#1" means "first Monday of the month", "MON#2" means "second Monday of the month". Example: `0 0 * * MON#1` (at midnight of the first monday of every month). ## The `explain()` Method Starting from version 2.4, the CronSim objects have an `explain()` method which generates a text description of the supplied cron expression. ```python from datetime import datetime from cronsim import CronSim expr = CronSim("*/5 9-17 * * *", datetime.now()) print(expr.explain()) ``` Outputs: ``` Every fifth minute from 09:00 through 17:59 ``` The text descriptions are available in English only. The text descriptions use the 24-hour time format ("23:00" instead of "11:00 PM"). For examples of generated descriptions see `tests/test_explain.py`. cuu508-cronsim-2b67279/cronsim/000077500000000000000000000000001471143241200161505ustar00rootroot00000000000000cuu508-cronsim-2b67279/cronsim/__init__.py000066400000000000000000000001061471143241200202560ustar00rootroot00000000000000from .cronsim import CronSim as CronSim, CronSimError as CronSimError cuu508-cronsim-2b67279/cronsim/cronsim.py000066400000000000000000000362201471143241200201770ustar00rootroot00000000000000from __future__ import annotations import calendar from datetime import date, datetime, time from datetime import timedelta as td from datetime import timezone from enum import IntEnum from typing import Set, Tuple, Union, cast UTC = timezone.utc SpecItem = Union[int, Tuple[int, int]] RANGES = [ range(0, 60), range(0, 24), range(1, 32), range(1, 13), range(0, 8), ] SYMBOLIC_DAYS = "SUN MON TUE WED THU FRI SAT".split() SYMBOLIC_MONTHS = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split() DAYS_IN_MONTH = [-1, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] FIELD_NAMES = ["minute", "hour", "day-of-month", "month", "day-of-week"] class CronSimError(Exception): pass class Field(IntEnum): MINUTE = 0 HOUR = 1 DAY = 2 MONTH = 3 DOW = 4 def msg(self) -> str: return "Bad %s" % FIELD_NAMES[self] def _int(self, value: str) -> int: if value == "": raise CronSimError(self.msg()) for ch in value: if ch not in "0123456789": raise CronSimError(self.msg()) return int(value) def int(self, s: str) -> int: if self == Field.MONTH and s in SYMBOLIC_MONTHS: return SYMBOLIC_MONTHS.index(s) + 1 if self == Field.DOW and s in SYMBOLIC_DAYS: return SYMBOLIC_DAYS.index(s) v = self._int(s) if v not in RANGES[self]: raise CronSimError(self.msg()) return v def parse(self, s: str) -> Set[SpecItem]: if s == "*": return set(RANGES[self]) if "," in s: result = set() for term in s.split(","): result.update(self.parse(term)) return result if self == Field.DOW and "L" in s: value = s[:-1] if not value.isdigit(): raise CronSimError(self.msg()) dow = self.int(s[:-1]) return {(dow, CronSim.LAST)} if "#" in s and self == Field.DOW: term, nth_str = s.split("#", maxsplit=1) nth = self._int(nth_str) if nth < 1 or nth > 5: raise CronSimError(self.msg()) nth_tuple = (self.int(term), nth) return {nth_tuple} if "/" in s: term, step_str = s.split("/", maxsplit=1) step = self._int(step_str) if step == 0: raise CronSimError(self.msg()) items = self.parse(term) if items == {CronSim.LAST} or items == {CronSim.LAST_WEEKDAY}: return items if len(items) == 1: start = items.pop() assert isinstance(start, int) end = max(RANGES[self]) tail = range(start, end + 1) return set(tail[::step]) # items is an unordered set, so sort it before taking # every step-th item. Then convert it back to set. return set(sorted(items)[::step]) if "-" in s: start_str, end_str = s.split("-", maxsplit=1) start = self.int(start_str) end = self.int(end_str) if end < start: raise CronSimError(self.msg()) return set(range(start, end + 1)) if self == Field.DAY and s == "LW": return {CronSim.LAST_WEEKDAY} if self == Field.DAY and s == "L": return {CronSim.LAST} return {self.int(s)} def is_imaginary(dt: datetime) -> bool: return dt != dt.astimezone(UTC).astimezone(dt.tzinfo) def last_weekday(year: int, month: int) -> int: """Return the date of the last weekday of a given year and month.""" first_dow, last_date = calendar.monthrange(year, month) last_dow = (first_dow + last_date - 1) % 7 if last_dow == 6: return last_date - 2 elif last_dow == 5: return last_date - 1 return last_date class CronSim(object): LAST = -1000 LAST_WEEKDAY = -1001 def __init__(self, expr: str, dt: datetime, reverse: bool = False): self.dt = dt.replace(second=0, microsecond=0) self.tick_direction = -1 if reverse else 1 self.parts = expr.upper().split() if len(self.parts) != 5: raise CronSimError("Wrong number of fields") # In Debian cron, if either the day-of-month or the day-of-week field # starts with a star, then there is an "AND" relationship between them. # Otherwise it's "OR". self.day_and = self.parts[2].startswith("*") or self.parts[4].startswith("*") self.minutes = cast(Set[int], Field.MINUTE.parse(self.parts[0])) self.hours = cast(Set[int], Field.HOUR.parse(self.parts[1])) self.days = cast(Set[int], Field.DAY.parse(self.parts[2])) self.months = cast(Set[int], Field.MONTH.parse(self.parts[3])) self.weekdays = Field.DOW.parse(self.parts[4]) if len(self.days) and min(self.days) > 29: # Check if we have any month with enough days if min(self.days) > max(DAYS_IN_MONTH[month] for month in self.months): raise CronSimError(Field.DAY.msg()) self.fixup_tz = None if self.dt.tzinfo in (None, UTC): # No special DST handling for UTC pass else: if not self.parts[0].startswith("*") and not self.parts[1].startswith("*"): # Will use special handling for jobs that run at specific time, or # with a granularity greater than one hour (to mimic Debian cron). self.fixup_tz = self.dt.tzinfo self.dt = self.dt.replace(tzinfo=None) def tick(self, minutes: int = 1) -> None: """Roll self.dt in `tick_direction` by 1 or more minutes and fix timezone.""" # Tick should only receive positive values. # Receiving a negative value or zero means a coding error. assert minutes > 0 if self.dt.tzinfo not in (None, UTC): as_utc = self.dt.astimezone(UTC) as_utc += td(minutes=minutes * self.tick_direction) self.dt = as_utc.astimezone(self.dt.tzinfo) else: self.dt += td(minutes=minutes * self.tick_direction) def advance_minute(self) -> bool: """Roll forward the minute component until it satisfies the constraints. Return False if the minute meets contraints without modification. Return True if self.dt was rolled forward. """ if self.dt.minute in self.minutes: return False if len(self.minutes) == 1: # An optimization for the special case where self.minutes has exactly # one element. Instead of advancing one minute per iteration, # make a jump from the current minute to the target minute. (target_minute,) = self.minutes delta = (target_minute - self.dt.minute) % 60 self.tick(minutes=delta) while self.dt.minute not in self.minutes: self.tick() if self.dt.minute == 0: # Break out to re-check month, day and hour break return True def reverse_minute(self) -> bool: """Roll backward the minute component until it satisfies the constraints.""" if self.dt.minute in self.minutes: return False if len(self.minutes) == 1: # An optimization for the special case where self.minutes has exactly # one element. Instead of advancing one minute per iteration, # make a jump from the current minute to the target minute. (target_minute,) = self.minutes delta = (self.dt.minute - target_minute) % 60 self.tick(minutes=delta) while self.dt.minute not in self.minutes: self.tick() if self.dt.minute == 59: # Break out to re-check month, day and hour break return True def advance_hour(self) -> bool: """Roll forward the hour component until it satisfies the constraints. Return False if the hour meets contraints without modification. Return True if self.dt was rolled forward. """ if self.dt.hour in self.hours: return False self.dt = self.dt.replace(minute=0) while self.dt.hour not in self.hours: self.tick(minutes=60) if self.dt.hour == 0: # break out to re-check month and day break return True def reverse_hour(self) -> bool: """Roll backward the hour component until it satisfies the constraints.""" if self.dt.hour in self.hours: return False self.dt = self.dt.replace(minute=59) while self.dt.hour not in self.hours: self.tick(minutes=60) if self.dt.hour == 23: # break out to re-check month and day break return True def match_dom(self, d: date) -> bool: """Return True is day-of-month matches.""" if d.day in self.days: return True # Optimization: there are no months with fewer than 28 days. # If 28th is Sunday, the last weekday of the month is the 26th. # Any date before 26th cannot be the the last weekday of the month. if self.LAST_WEEKDAY in self.days and d.day >= 26: if d.day == last_weekday(d.year, d.month): return True # Optimization: there are no months with fewer than 28 days, # so any date before 28th cannot be the the last day of the month if self.LAST in self.days and d.day >= 28: _, last = calendar.monthrange(d.year, d.month) if d.day == last: return True return False def match_dow(self, d: date) -> bool: """Return True is day-of-week matches.""" dow = d.weekday() + 1 if dow in self.weekdays or dow % 7 in self.weekdays: return True if (dow, self.LAST) in self.weekdays or (dow % 7, self.LAST) in self.weekdays: _, last = calendar.monthrange(d.year, d.month) if d.day + 7 > last: # Same day next week would be outside this month. # So this is the last one this month. return True idx = (d.day + 6) // 7 if (dow, idx) in self.weekdays or (dow % 7, idx) in self.weekdays: return True return False def match_day(self, d: date) -> bool: if self.day_and: return self.match_dom(d) and self.match_dow(d) return self.match_dom(d) or self.match_dow(d) def advance_day(self) -> bool: """Roll forward the day component until it satisfies the constraints. This method advances the date until it matches either the day-of-month, or the day-of-week constraint. Return False if the day meets contraints without modification. Return True if self.dt was rolled forward. """ needle = self.dt.date() if self.match_day(needle): return False while not self.match_day(needle): needle += td(days=1) if needle.day == 1: # We're in a different month now, break out to re-check month # This significantly speeds up the "0 0 * 2 MON#5" case break self.dt = datetime.combine(needle, time(), tzinfo=self.dt.tzinfo) return True def reverse_day(self) -> bool: """Roll backward the day component until it satisfies the constraints.""" needle = self.dt.date() if self.match_day(needle): return False month = needle.month while not self.match_day(needle): needle -= td(days=1) if needle.month != month: # We're in a different month now, break out to re-check month # This significantly speeds up the "0 0 * 2 MON#5" case break self.dt = datetime.combine(needle, time(23, 59), tzinfo=self.dt.tzinfo) return True def advance_month(self) -> None: """Roll forward the month component until it satisfies the constraints.""" if self.dt.month in self.months: return needle = self.dt.date() while needle.month not in self.months: needle = (needle.replace(day=1) + td(days=32)).replace(day=1) self.dt = datetime.combine(needle, time(), tzinfo=self.dt.tzinfo) def reverse_month(self) -> None: """Roll backward the month component until it satisfies the constraints.""" if self.dt.month in self.months: return needle = self.dt.date() while needle.month not in self.months: # We need the last day of the previous month. # Go to the start of this month, and then reverse by one extra day: needle = needle.replace(day=1) needle -= td(days=1) self.dt = datetime.combine(needle, time(23, 59), tzinfo=self.dt.tzinfo) def __iter__(self) -> "CronSim": return self def advance(self) -> None: """Advance self.dt forward until all constraints are satisfied.""" start_year = self.dt.year while True: self.advance_month() if self.dt.year > start_year + 50: # Give up if there is no match for 50 years. # It would be nice to detect "this will never yield any results" # situations in a more intelligent way. raise StopIteration if self.advance_day(): continue if self.advance_hour(): continue if self.advance_minute(): continue break def reverse(self) -> None: """Advance self.dt backward until all constraints are satisfied.""" # If we are iterating backwards, and a single tick landed us into an # imaginary or ambiguous datetime, step backwards more until we are out of the # problematic period. if self.fixup_tz: result = self.dt.replace(tzinfo=self.fixup_tz, fold=0) while is_imaginary(result): self.dt -= td(minutes=1) result = self.dt.replace(tzinfo=self.fixup_tz) start_year = self.dt.year while True: self.reverse_month() if self.dt.year < start_year - 50: raise StopIteration if self.reverse_day(): continue if self.reverse_hour(): continue if self.reverse_minute(): continue break def __next__(self) -> datetime: self.tick() if self.tick_direction == 1: self.advance() else: self.reverse() # The last step is to check if we need to fix up an imaginary # or ambiguous date. if self.fixup_tz: result = self.dt.replace(tzinfo=self.fixup_tz, fold=0) while is_imaginary(result): self.dt += td(minutes=1) result = self.dt.replace(tzinfo=self.fixup_tz) return result return self.dt def explain(self) -> str: from cronsim.explain import Expression return Expression(self.parts).explain() cuu508-cronsim-2b67279/cronsim/explain.py000066400000000000000000000467211471143241200201740ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from typing import Generator @dataclass(frozen=True) class Sequence: start: int | None = None stop: int | None = None step: int = 1 nth: int | None = None def is_star(self) -> bool: """Return True if this sequence describes a wildcard.""" return self.start is None and self.step == 1 def is_single(self) -> bool: """Return True if this sequence describes a a single, specific value.""" return self.start is not None and self.start == self.stop def join(l: list[str]) -> str: """Join together strings using commas and 'and' as separators. >>> join(["a"]) 'a' >>> join(["a", "b"]) 'a and b' >>> join(["a", "b", "c"]) 'a, b, and c' """ if len(l) == 1: return l[0] if len(l) == 2: return f"{l[0]} and {l[1]}" head = ", ".join(l[:-1]) return f"{head}, and {l[-1]}" ORDINALS = { -1: "last", 1: "first", 2: "second", 3: "third", 4: "fourth", 5: "fifth", 6: "sixth", 7: "seventh", 8: "eighth", 9: "ninth", } def ordinal(x: int) -> str: """Format integer as an ordinal number. >>> ordinal(1) 'first' >>> ordinal(15) '15th' """ return ORDINALS.get(x, f"{x}th") def format_time(h: int, m: int) -> str: """Format hours and minutes as HH:MM. >>> format_time(0, 0) '00:00' >>> format_time(1, 23) '01:23' """ return f"{h:02d}:{m:02d}" class Field(object): name = "FIXME" symbolic: list[str] = [] min_value = 0 max_value = 0 def __init__(self, value: str): self.value = value self.parsed: list[Sequence] = [] for seq in self.parse(value): if seq not in self.parsed: self.parsed.append(seq) # If the field contains a single numeric value, # store it in self.single_value self.single_value = self.parsed[0].start for seq in self.parsed: if seq.start != self.single_value or seq.stop != self.single_value: self.single_value = None break # Does the field cover the full range? self.star = all(seq.is_star() for seq in self.parsed) # Are there any single values? self.any_singles = any(seq.is_single() for seq in self.parsed) # Are all values single values? self.all_singles = all(seq.is_single() for seq in self.parsed) def parse(self, value: str) -> Generator[Sequence, None, None]: """Parse a single field of a cron expression into Sequence objects.""" for term in value.split(","): if term == "*": yield Sequence() elif isinstance(self, Weekday) and term.endswith("L"): v = self._int(term[:-1]) yield Sequence(start=v, stop=v, nth=-1) elif "#" in term: term, nth = term.split("#") v = self._int(term) yield Sequence(start=v, stop=v, nth=int(nth)) elif "/" in term: term, step_str = term.split("/") step = int(step_str) if term == "*": yield Sequence(step=step) elif term == "LW": yield Sequence(start=-2, stop=-2, nth=-1) elif term == "L": yield Sequence(start=-1, stop=-1, nth=-1) else: if "-" in term: start_str, stop_str = term.split("-") start, stop = self._int(start_str), self._int(stop_str) else: start = self._int(term) stop = self.max_value if start <= self.min_value and stop >= self.max_value: yield Sequence(step=step) else: yield Sequence(start=start, stop=stop, step=step) elif "-" in term: start_str, stop_str = term.split("-") start, stop = self._int(start_str), self._int(stop_str) if start + 1 == stop: # treat a 2-long sequence as two single values: yield Sequence(start=start, stop=start) yield Sequence(start=stop, stop=stop) elif start <= self.min_value and stop >= self.max_value: yield Sequence() else: yield Sequence(start=start, stop=stop) elif term == "LW": yield Sequence(start=-2, stop=-2, nth=-1) elif term == "L": yield Sequence(start=-1, stop=-1, nth=-1) else: v = self._int(term) yield Sequence(start=v, stop=v) def _int(self, value: str) -> int: """Convert a value from a cron expression to an integer. For month and weekday fields, this takes care of converting JAN, FEB, ... and MON, TUE, ... """ if value in self.symbolic: return self.symbolic.index(value) return int(value) def singles(self) -> list[int]: """Return a list only the single values in this field.""" return [seq.start for seq in self.parsed if isinstance(seq.start, int)] def label(self, idx: int) -> str: """Convert an integer value to a string for display. >>> label(1) '1' """ return str(idx) def format_single(self, value: int) -> str: """Format a single value for display. >>> format_single(1) 'minute 1' """ return f"{self.name} {self.label(value)}" def format_nth(self, value: int, nth: int) -> str: """Format nth-weekday-of-month and L values. Implemented in Month and Weekday subclasses. """ raise NotImplementedError def format_every(self, step: int = 1) -> str: """Format wildcard and wildcard-with-step values. >>> format_every(1) 'every minute' >>> format_every(5) 'every 5th minute' """ if step == 1: return f"every {self.name}" return f"every {ordinal(step)} {self.name}" def format_seq(self, start: int, stop: int, step: int = 1) -> str: """Format a sequence. >>> format_seq(1, 10) 'every minute from 1 through 10' >>> format_seq(1, 10, 2) 'every 2nd minute from 1 through 10' """ start_str = self.label(start) stop_str = self.label(stop) if step == 1: return f"every {self.name} from {start_str} through {stop_str}" return f"every {ordinal(step)} {self.name} from {start_str} through {stop_str}" def format(self) -> str: """Format every component of this field, and join them together.""" parts = [] for seq in self.parsed: if seq.start is None: parts.append(self.format_every(seq.step)) elif seq.stop != seq.start: assert seq.stop is not None parts.append(self.format_seq(seq.start, seq.stop, seq.step)) elif seq.nth is not None: parts.append(self.format_nth(seq.start, seq.nth)) else: parts.append(self.format_single(seq.start)) return join(parts) def __str__(self) -> str: """Return a human-friendly representation of this field. Subclasses can override this method to add prepositions ("at", "on", "in"). """ return self.format() class Minute(Field): name = "minute" min_value = 0 max_value = 59 def format(self) -> str: """Format the minute field. This method adds special handling when all values are single values. Instead of "minute 1, minute 3, and minute 5", it produces "minutes 1, 3, and 5". """ if self.all_singles and len(self.parsed) > 1: labels = [self.label(v) for v in self.singles()] return f"minutes {join(labels)}" return super().format() def __str__(self) -> str: """Return a human-friendly representation of the minute field. This method adds the 'at' preposition to the formatted string if the field has any single values. For example, if the field has a single range (1-5), the result will be "every minute from 1 through 5". But if the field also has a single field (1-5,10), the result will be "at every minute from 1 through 5 and minute 10". """ result = super().__str__() if self.any_singles: return "at " + result return result class Hour(Field): name = "hour" min_value = 0 max_value = 23 def format(self) -> str: """Format the hour field. This method adds special handling when all values are single values. Instead of "hour 1, hour 3, and hour 5", it produces "hours 1, 3, and 5". """ if self.all_singles and len(self.parsed) > 1: labels = [self.label(v) for v in self.singles()] return f"hours {join(labels)}" return super().format() def __str__(self) -> str: """Return a human-friendly representation of the minute field. This method adds the 'past' preposition to the formatted string. For example, if the hour field has a single value (5), the result will be "past hour 5". """ return "past " + super().__str__() class Day(Field): name = "day of month" min_value = 1 max_value = 31 def format_single(self, value: int) -> str: """Format a single day-of-month value for display. >>> format_single(2) 'the 2nd day of month' """ return f"the {ordinal(value)} day of month" def format_nth(self, value: int, nth: int) -> str: """Format the L and LW values. >>> format_nth(-1) 'the last day of the month' >>> format_nth(-2) 'the last weekday of the month' """ if nth == -1 and value == -1: return "the last day of the month" if nth == -1 and value == -2: return "the last weekday of the month" return super().format_nth(value, nth) def format(self) -> str: """Format the day field. This method adds special handling for the case when all values are single values. For example, instead of "the 1st day of month, the 3rd day of month, and the 5th day of month" it produces "the 1st, 3rd, and 5th day of month". We cannot apply this optimization if the single values contain "-2" (the special value for "last weekday of the month"). """ if self.all_singles and len(self.parsed) > 1: singles = self.singles() if -2 not in singles: labels = [f"the {ordinal(v)}" for v in singles] return f"{join(labels)} day of month" return super().format() def __str__(self) -> str: """Return a human-friendly representation of the day of month field. This method unconditionally adds the 'on' preposition. """ return "on " + super().__str__() class Month(Field): name = "month" min_value = 1 max_value = 12 symbolic = "_ JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split() labels = "_ January February March April May June July August September October November December".split() def label(self, idx: int) -> str: """Convert an integer value to a month name. >>> label(1) 'January' """ return self.labels[idx] def format_single(self, value: int) -> str: """Format a single month value for display. >>> format_single(2) 'February' """ return self.label(value) def __str__(self) -> str: """Return a human-friendly representation of the month field. This method unconditionally adds the 'in' preposition. """ return "in " + super().__str__() class Weekday(Field): name = "day of week" min_value = 0 max_value = 7 symbolic = "SUN MON TUE WED THU FRI SAT SUN".split() labels = "Sunday Monday Tuesday Wednesday Thursday Friday Saturday Sunday".split() def label(self, idx: int) -> str: """Convert an integer value to a day of week name. >>> label(1) 'Monday' """ return self.labels[idx] def format_single(self, value: int) -> str: """Format a single month value for display. >>> format_single(2) 'Tuesday' """ return self.label(value) def format_nth(self, value: int, nth: int) -> str: """Format the nth-weekday-of-month value. >>> format_nth(2, -1) 'the last Tuesday of the month' >>> format_nth(2, 4) 'the 4th Tuesday of the month' """ label = self.label(value) if nth == -1: return f"the last {label} of the month" return f"the {ordinal(nth)} {label} of the month" def format_seq(self, start: int, stop: int, step: int = 1) -> str: """Format a sequence of weekdays. >>> format_seq(1, 3) 'Monday through Wednesday' >>> format_seq(1, 7, 2) 'evary 2nd day of week from Monday through Sunday' """ if step == 1: # "Monday through Friday" # instead of "every day of week from Monday through Friday" return f"{self.label(start)} through {self.label(stop)}" return super().format_seq(start, stop, step) def __str__(self) -> str: """Return a human-friendly representation of the month field. This method unconditionally adds the 'on' preposition. """ return "on " + super().__str__() class Expression(object): def __init__(self, parts: list[str]): self.minute = Minute(parts[0]) self.hour = Hour(parts[1]) self.day = Day(parts[2]) self.month = Month(parts[3]) self.dow = Weekday(parts[4]) self.day_and = parts[2].startswith("*") or parts[4].startswith("*") def optimized_times(self) -> tuple[str, bool] | None: """Apply formatting optimizations for hours and minutes. If both hours and minutes contain only a few single values, format them as "at HH:MM, HH:MM, HH:MM, and HH:MM" If hours have a single value, and minutes have a single sequence with step 1, format them as "every minute from HH:MM through HH:MM". If minutes have a single "*/" sequence and hours have a sequence with step 1, format them as "every nth minute from HH:00 through HH:59" If no special optimizations apply, return None. """ # at 11:00, 11:30, ... if self.hour.all_singles and self.minute.all_singles: minute_terms, hour_terms = self.minute.singles(), self.hour.singles() if len(minute_terms) * len(hour_terms) <= 4: times = [] for h in sorted(hour_terms): for m in sorted(minute_terms): times.append(format_time(h, m)) return "at " + join(times), True # every minute from 11:00 through 11:10 if self.hour.single_value and len(self.minute.parsed) == 1: seq = self.minute.parsed[0] if seq.start is not None and seq.stop is not None and seq.step == 1: hhmm1 = format_time(self.hour.single_value, seq.start) hhmm2 = format_time(self.hour.single_value, seq.stop) return f"every minute from {hhmm1} through {hhmm2}", False # every minute from 9:00 through 17:59 if len(self.hour.parsed) == 1 and len(self.minute.parsed) == 1: mseq = self.minute.parsed[0] if mseq.start is None: hseq = self.hour.parsed[0] if hseq.start is not None and hseq.stop is not None and hseq.step == 1: hhmm1 = format_time(hseq.start, 0) hhmm2 = format_time(hseq.stop, 59) return f"{self.minute} from {hhmm1} through {hhmm2}", False return None def optimized_dates(self) -> str | None: """Apply formatting optimizations for specific dates. If day-of-month is L, format it as "on the last day of ". If day-of-month is LW, format it as "on the last weekday of ". If month and day-of-month each have a single value (for example, month 2 and day-of-month 1), format them as "February 1st". If day-of-month has a single value (for example, 2), format it as "on the 2nd day of ". If no special optimizations apply, return None. """ if self.dow.star: if self.day.single_value == -1: return f"on the last day of {self.month.format()}" if self.day.single_value == -2: return f"on the last weekday of {self.month.format()}" if self.month.single_value and self.day.single_value: return f"on {self.month.format()} {self.day.single_value}" if self.day.single_value: date_ord = ordinal(self.day.single_value) return f"on the {date_ord} day of {self.month.format()}" return None def translate_time(self) -> tuple[str, bool]: """Convert the minute and hour fields to text.""" if self.hour.star: if self.minute.star: return "every minute", False if self.minute.single_value == 0: return "at the start of every hour", False return f"{self.minute} of every hour", False if times := self.optimized_times(): return times return f"{self.minute} {self.hour}", False def translate_date(self, allow_every_day: bool) -> str: """Convert the day, month, and weekday fields to text.""" if dates := self.optimized_dates(): return dates if allow_every_day: if self.day.star and self.dow.star and self.month.all_singles: # At ... every day in January return f"every day {self.month}" parts: list[Field | str] = [] if not self.day.star: parts.append(self.day) if not self.dow.star: if not self.day.star and self.day_and: parts.append("if it's") elif not self.day.star and not self.day_and: parts.append("and") parts.append(self.dow) if not self.month.star: parts.append(self.month) return " ".join(str(part) for part in parts) def explain(self) -> str: """Convert the full expression to text.""" time, allow_every_day = self.translate_time() if date := self.translate_date(allow_every_day): result = f"{time} {date}" elif allow_every_day: result = f"{time} every day" else: result = f"{time}" return result[0].upper() + result[1:] def explain(expr: str) -> str: """Convert the given cron expression to human-friendly description. >>> explain("0 0 15 JAN-FEB *") 'At 00:00 on the 15th day of January and February' """ parts = expr.upper().split() return Expression(parts).explain() # For quick testing, you can run this script from shell like so: # python explain.py "0 0 15 JAN-FEB *" if __name__ == "__main__": import sys if len(sys.argv) == 2: print(explain(sys.argv[1])) cuu508-cronsim-2b67279/cronsim/py.typed000066400000000000000000000000001471143241200176350ustar00rootroot00000000000000cuu508-cronsim-2b67279/setup.py000066400000000000000000000026621471143241200162160ustar00rootroot00000000000000# coding: utf-8 from __future__ import annotations from setuptools import find_packages, setup setup( name="cronsim", version="2.6", url="https://github.com/cuu508/cronsim", license="BSD", author="Pēteris Caune", author_email="cuu508@monkeyseemonkeydo.lv", description="Cron expression parser and evaluator", long_description=open("README.md").read(), long_description_content_type="text/markdown", keywords="cron,cronjob,crontab,schedule", packages=find_packages(), include_package_data=True, platforms="any", python_requires=">= 3.8", zip_safe=False, project_urls={ "Changelog": "https://github.com/cuu508/cronsim/blob/main/CHANGELOG.md" }, classifiers=[ # As from http://pypi.python.org/pypi?%3Aaction=list_classifiers # 'Development Status :: 1 - Planning', # 'Development Status :: 2 - Pre-Alpha', # "Development Status :: 3 - Alpha", # "Development Status :: 4 - Beta", "Development Status :: 5 - Production/Stable", # 'Development Status :: 6 - Mature', # 'Development Status :: 7 - Inactive', "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries :: Python Modules", ], ) cuu508-cronsim-2b67279/tests/000077500000000000000000000000001471143241200156405ustar00rootroot00000000000000cuu508-cronsim-2b67279/tests/test_cronsim.py000066400000000000000000000614771471143241200207420ustar00rootroot00000000000000from __future__ import annotations import sys import unittest from datetime import datetime, timezone from itertools import product from typing import Iterator if sys.version_info >= (3, 9): from zoneinfo import ZoneInfo else: from backports.zoneinfo import ZoneInfo from cronsim import CronSim, CronSimError NOW = datetime(2020, 1, 1) class TestParse(unittest.TestCase): def test_it_parses_stars(self) -> None: w = CronSim("* * * * *", NOW) self.assertEqual(w.minutes, set(range(0, 60))) self.assertEqual(w.hours, set(range(0, 24))) self.assertEqual(w.days, set(range(1, 32))) self.assertEqual(w.months, set(range(1, 13))) self.assertEqual(w.weekdays, set(range(0, 8))) def test_it_parses_numbers(self) -> None: w = CronSim("1 * * * *", NOW) self.assertEqual(w.minutes, {1}) def test_it_parses_weekday(self) -> None: w = CronSim("* * * * 1", NOW) self.assertEqual(w.weekdays, {1}) def test_it_handles_0_sunday(self) -> None: w = CronSim("* * * * 0", NOW) self.assertEqual(w.weekdays, {0}) def test_it_parses_list(self) -> None: w = CronSim("1,2,3 * * * *", NOW) self.assertEqual(w.minutes, {1, 2, 3}) def test_it_parses_interval(self) -> None: w = CronSim("1-3 * * * *", NOW) self.assertEqual(w.minutes, {1, 2, 3}) def test_it_parses_two_intervals(self) -> None: w = CronSim("1-3,7-9 * * * *", NOW) self.assertEqual(w.minutes, {1, 2, 3, 7, 8, 9}) def test_it_parses_step(self) -> None: w = CronSim("*/15 * * * *", NOW) self.assertEqual(w.minutes, {0, 15, 30, 45}) def test_it_parses_interval_with_step(self) -> None: w = CronSim("0-10/2 * * * *", NOW) self.assertEqual(w.minutes, {0, 2, 4, 6, 8, 10}) def test_it_parses_start_with_step(self) -> None: w = CronSim("5/15 * * * *", NOW) self.assertEqual(w.minutes, {5, 20, 35, 50}) def test_it_parses_day_l(self) -> None: w = CronSim("* * L * *", NOW) self.assertEqual(w.days, {CronSim.LAST}) def test_it_parses_day_lw(self) -> None: w = CronSim("* * LW * *", NOW) self.assertEqual(w.days, {CronSim.LAST_WEEKDAY}) def test_it_parses_day_lowercase_l(self) -> None: w = CronSim("* * l * *", NOW) self.assertEqual(w.days, {CronSim.LAST}) def test_it_parses_day_lowercase_lw(self) -> None: w = CronSim("* * lw * *", NOW) self.assertEqual(w.days, {CronSim.LAST_WEEKDAY}) def test_it_parses_unrestricted_day_restricted_dow(self) -> None: w = CronSim("* * * * 1", NOW) self.assertEqual(w.days, set(range(1, 32))) self.assertEqual(w.weekdays, {1}) self.assertTrue(w.day_and) def test_it_parses_restricted_day_unrestricted_dow(self) -> None: w = CronSim("* * 1 * *", NOW) self.assertEqual(w.days, {1}) self.assertEqual(w.weekdays, {0, 1, 2, 3, 4, 5, 6, 7}) self.assertTrue(w.day_and) def test_it_parses_nth_weekday(self) -> None: w = CronSim("* * * * 1#2", NOW) self.assertEqual(w.weekdays, {(1, 2)}) def test_it_parses_symbolic_weekday(self) -> None: w = CronSim("* * * * MON", NOW) self.assertEqual(w.weekdays, {1}) def test_it_parses_lowercase_symbolic_weekday(self) -> None: w = CronSim("* * * * mon", NOW) self.assertEqual(w.weekdays, {1}) def test_it_parses_symbolic_month(self) -> None: w = CronSim("* * * JAN *", NOW) self.assertEqual(w.months, {1}) def test_it_parses_weekday_range_from_zero(self) -> None: w = CronSim("* * * * 0-2", NOW) self.assertEqual(w.weekdays, {0, 1, 2}) def test_it_parses_sun_tue(self) -> None: w = CronSim("* * * * sun-tue", NOW) self.assertEqual(w.weekdays, {0, 1, 2}) def test_it_starts_weekday_step_from_zero(self) -> None: w = CronSim("* * * * */2", NOW) self.assertEqual(w.weekdays, {0, 2, 4, 6}) def test_it_accepts_l_with_step(self) -> None: w = CronSim("* * L/2 * *", NOW) self.assertEqual(w.days, {CronSim.LAST}) def test_it_accepts_lw_with_step(self) -> None: w = CronSim("* * LW/2 * *", NOW) self.assertEqual(w.days, {CronSim.LAST_WEEKDAY}) def test_it_handles_a_mix_of_ints_and_tuples(self) -> None: w = CronSim("* * * * 1,2,3#1", NOW) self.assertEqual(w.weekdays, {1, 2, (3, 1)}) def test_it_accepts_weekday_7(self) -> None: w = CronSim("* * * * 7", NOW) self.assertEqual(w.weekdays, {7}) def test_it_accepts_weekday_l(self) -> None: w = CronSim("* * * * 5L", NOW) self.assertEqual(w.weekdays, {(5, CronSim.LAST)}) class TestValidation(unittest.TestCase): def test_it_rejects_4_components(self) -> None: with self.assertRaisesRegex(CronSimError, "Wrong number of fields"): CronSim("* * * *", NOW) def test_it_rejects_bad_values(self) -> None: patterns = ( "%s * * * *", "* %s * * *", "* * %s * *", "* * * %s * ", "* * * * %s", "* * * * * %s", "1-%s * * * *", "%s-60 * * * *", "* * 1-%s * *", "* * 1,%s * *", "* * %s/1 * *", "* * * %s-DEC *", "* * * JAN-%s *", "* * * * %s-SUN", "* * * * MON-%s", ) bad_values = ( "-1", "61", "ABC", "2/", "/2", "2#", "#2", "1##1", "1//2", "¹", "LL", "LWX", ) for pattern, s in product(patterns, bad_values): with self.assertRaises(CronSimError): CronSim(pattern % s, NOW) def test_it_rejects_lopsided_range(self) -> None: with self.assertRaisesRegex(CronSimError, "Bad day-of-month"): CronSim("* * 5-1 * *", NOW) def test_it_rejects_underscores(self) -> None: with self.assertRaisesRegex(CronSimError, "Bad minute"): CronSim("1-1_0 * * * *", NOW) def test_it_rejects_zero_step(self) -> None: with self.assertRaisesRegex(CronSimError, "Bad minute"): CronSim("*/0 * * * *", NOW) def test_it_rejects_zero_nth(self) -> None: with self.assertRaisesRegex(CronSimError, "Bad day-of-week"): CronSim("* * * * 1#0", NOW) def test_it_rejects_big_nth(self) -> None: with self.assertRaises(CronSimError): CronSim("* * * * 1#6", NOW) def test_it_checks_day_of_month_range(self) -> None: with self.assertRaisesRegex(CronSimError, "Bad day-of-month"): CronSim("* * 30 2 *", NOW) with self.assertRaisesRegex(CronSimError, "Bad day-of-month"): CronSim("* * 31 4 *", NOW) def test_it_rejects_dow_l_range(self) -> None: with self.assertRaisesRegex(CronSimError, "Bad day-of-week"): CronSim("* * * * 5L-6", NOW) def test_it_rejects_dow_l_hash(self) -> None: with self.assertRaisesRegex(CronSimError, "Bad day-of-week"): CronSim("* * * * 5L#1", NOW) def test_it_rejects_dow_l_slash(self) -> None: with self.assertRaisesRegex(CronSimError, "Bad day-of-week"): CronSim("* * * * 5L/3", NOW) def test_it_rejects_symbolic_dow_l(self) -> None: with self.assertRaisesRegex(CronSimError, "Bad day-of-week"): CronSim("* * * * MONL", NOW) class TestIterator(unittest.TestCase): def test_it_handles_l(self) -> None: dt = next(CronSim("1 1 L * *", NOW)) self.assertEqual(dt.isoformat(), "2020-01-31T01:01:00") def test_it_handles_lw(self) -> None: dt = next(CronSim("1 1 LW 5 *", NOW)) self.assertEqual(dt.isoformat(), "2020-05-29T01:01:00") def test_it_handles_last_friday(self) -> None: it = CronSim("1 1 * * 5L", NOW) self.assertEqual(next(it).isoformat(), "2020-01-31T01:01:00") self.assertEqual(next(it).isoformat(), "2020-02-28T01:01:00") self.assertEqual(next(it).isoformat(), "2020-03-27T01:01:00") self.assertEqual(next(it).isoformat(), "2020-04-24T01:01:00") self.assertEqual(next(it).isoformat(), "2020-05-29T01:01:00") self.assertEqual(next(it).isoformat(), "2020-06-26T01:01:00") self.assertEqual(next(it).isoformat(), "2020-07-31T01:01:00") self.assertEqual(next(it).isoformat(), "2020-08-28T01:01:00") self.assertEqual(next(it).isoformat(), "2020-09-25T01:01:00") self.assertEqual(next(it).isoformat(), "2020-10-30T01:01:00") self.assertEqual(next(it).isoformat(), "2020-11-27T01:01:00") self.assertEqual(next(it).isoformat(), "2020-12-25T01:01:00") def test_it_handles_last_sunday_two_notations(self) -> None: for pattern in ("1 1 * * 0L", "1 1 * * 7L"): dt = next(CronSim(pattern, NOW)) self.assertEqual(dt.isoformat(), "2020-01-26T01:01:00") def test_it_handles_nth_weekday(self) -> None: dt = next(CronSim("1 1 * * 1#2", NOW)) self.assertEqual(dt.isoformat(), "2020-01-13T01:01:00") def test_it_handles_dow_star(self) -> None: # "First Sunday of the month" it = CronSim("1 1 1-7 * */7", NOW) self.assertEqual(next(it).isoformat(), "2020-01-05T01:01:00") self.assertEqual(next(it).isoformat(), "2020-02-02T01:01:00") self.assertEqual(next(it).isoformat(), "2020-03-01T01:01:00") self.assertEqual(next(it).isoformat(), "2020-04-05T01:01:00") def test_it_handles_dom_star(self) -> None: # "First Monday of the month" it = CronSim("1 1 */100,1-7 * MON", NOW) self.assertEqual(next(it).isoformat(), "2020-01-06T01:01:00") self.assertEqual(next(it).isoformat(), "2020-02-03T01:01:00") self.assertEqual(next(it).isoformat(), "2020-03-02T01:01:00") self.assertEqual(next(it).isoformat(), "2020-04-06T01:01:00") def test_it_handles_no_matches(self) -> None: # The first date of the month *and* the fourth Monday of the month # will never yield results: it = CronSim("1 1 */100 * MON#4", NOW) with self.assertRaises(StopIteration): next(it) def test_it_handles_every_x_weekdays(self) -> None: # "every 3rd weekday" means "every 3rd weekday starting from Sunday" it = CronSim("1 1 * * */3", NOW) self.assertEqual(next(it).isoformat(), "2020-01-01T01:01:00") self.assertEqual(next(it).isoformat(), "2020-01-04T01:01:00") self.assertEqual(next(it).isoformat(), "2020-01-05T01:01:00") self.assertEqual(next(it).isoformat(), "2020-01-08T01:01:00") class TestDstTransitions(unittest.TestCase): tz = ZoneInfo("Europe/Riga") # For reference, DST changes in Europe/Riga in 2021: # DST begins (clock moves 1 hour forward) on March 28, 3AM: # 2021-03-28T02:59:00+02:00 # 2021-03-28T04:00:00+03:00 # 2021-03-28T04:01:00+03:00 # DST ends (clock moves 1 hour backward) on October 31, 4AM: # 2021-10-31T03:59:00+03:00 # 2021-10-31T03:00:00+02:00 # 2021-10-31T03:01:00+02:00 def assertNextEqual(self, iterator: Iterator[datetime], expected_iso: str) -> None: self.assertEqual(next(iterator).isoformat(), expected_iso) def test_001_every_hour_mar(self) -> None: now = datetime(2021, 3, 28, 1, 30, tzinfo=self.tz) w = CronSim("0 * * * *", now) self.assertNextEqual(w, "2021-03-28T02:00:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") def test_001_every_hour_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) w = CronSim("0 * * * *", now) self.assertNextEqual(w, "2021-10-31T02:00:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+02:00") self.assertNextEqual(w, "2021-10-31T04:00:00+02:00") def test_002_every_30_minutes_mar(self) -> None: now = datetime(2021, 3, 28, 2, 10, tzinfo=self.tz) w = CronSim("*/30 * * * *", now) self.assertNextEqual(w, "2021-03-28T02:30:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") self.assertNextEqual(w, "2021-03-28T04:30:00+03:00") def test_002_every_30_minutes_oct(self) -> None: now = datetime(2021, 10, 31, 2, 10, tzinfo=self.tz) w = CronSim("*/30 * * * *", now) self.assertNextEqual(w, "2021-10-31T02:30:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+03:00") self.assertNextEqual(w, "2021-10-31T03:30:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+02:00") self.assertNextEqual(w, "2021-10-31T03:30:00+02:00") self.assertNextEqual(w, "2021-10-31T04:00:00+02:00") def test_003_every_15_minutes_mar(self) -> None: now = datetime(2021, 3, 28, 2, 40, tzinfo=self.tz) w = CronSim("*/15 * * * *", now) self.assertNextEqual(w, "2021-03-28T02:45:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") def test_003_every_15_minutes_oct(self) -> None: now = datetime(2021, 10, 31, 2, 40, tzinfo=self.tz) w = CronSim("*/15 * * * *", now) self.assertNextEqual(w, "2021-10-31T02:45:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+03:00") self.assertNextEqual(w, "2021-10-31T03:15:00+03:00") self.assertNextEqual(w, "2021-10-31T03:30:00+03:00") self.assertNextEqual(w, "2021-10-31T03:45:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+02:00") self.assertNextEqual(w, "2021-10-31T03:15:00+02:00") self.assertNextEqual(w, "2021-10-31T03:30:00+02:00") self.assertNextEqual(w, "2021-10-31T03:45:00+02:00") self.assertNextEqual(w, "2021-10-31T04:00:00+02:00") def test_004_every_2_hours_mar(self) -> None: now = datetime(2021, 3, 28, 1, 30, tzinfo=self.tz) w = CronSim("0 */2 * * *", now) self.assertNextEqual(w, "2021-03-28T02:00:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") self.assertNextEqual(w, "2021-03-28T06:00:00+03:00") def test_004_every_2_hours_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) w = CronSim("0 */2 * * *", now) self.assertNextEqual(w, "2021-10-31T02:00:00+03:00") self.assertNextEqual(w, "2021-10-31T04:00:00+02:00") self.assertNextEqual(w, "2021-10-31T06:00:00+02:00") def test_005_30_minutes_past_every_2_hours_mar(self) -> None: now = datetime(2021, 3, 28, 1, 30, tzinfo=self.tz) w = CronSim("30 */2 * * *", now) self.assertNextEqual(w, "2021-03-28T02:30:00+02:00") self.assertNextEqual(w, "2021-03-28T04:30:00+03:00") def test_005_30_minutes_past_every_2_hours_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) w = CronSim("30 */2 * * *", now) self.assertNextEqual(w, "2021-10-31T02:30:00+03:00") self.assertNextEqual(w, "2021-10-31T04:30:00+02:00") def test_006_every_3_hours_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) w = CronSim("0 */3 * * *", now) self.assertNextEqual(w, "2021-10-31T03:00:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+02:00") self.assertNextEqual(w, "2021-10-31T06:00:00+02:00") def test_008_at_1_2_3_4_5_mar(self) -> None: now = datetime(2021, 3, 28, 1, 30, tzinfo=self.tz) w = CronSim("0 1,2,3,4,5 * * *", now) self.assertNextEqual(w, "2021-03-28T02:00:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") self.assertNextEqual(w, "2021-03-28T05:00:00+03:00") def test_008_at_1_2_3_4_5_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) w = CronSim("0 1,2,3,4,5 * * *", now) self.assertNextEqual(w, "2021-10-31T02:00:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+03:00") self.assertNextEqual(w, "2021-10-31T04:00:00+02:00") def test_009_30_past_1_2_3_4_5_mar(self) -> None: now = datetime(2021, 3, 28, 1, 30, tzinfo=self.tz) w = CronSim("30 1,2,3,4,5 * * *", now) self.assertNextEqual(w, "2021-03-28T02:30:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") self.assertNextEqual(w, "2021-03-28T04:30:00+03:00") def test_009_30_past_1_2_3_4_5_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) w = CronSim("30 1,2,3,4,5 * * *", now) self.assertNextEqual(w, "2021-10-31T02:30:00+03:00") self.assertNextEqual(w, "2021-10-31T03:30:00+03:00") self.assertNextEqual(w, "2021-10-31T04:30:00+02:00") def test_010_at_2_mar(self) -> None: now = datetime(2021, 3, 28, 1, 30, tzinfo=self.tz) w = CronSim("0 2 * * *", now) self.assertNextEqual(w, "2021-03-28T02:00:00+02:00") self.assertNextEqual(w, "2021-03-29T02:00:00+03:00") def test_010_at_2_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) w = CronSim("0 2 * * *", now) self.assertNextEqual(w, "2021-10-31T02:00:00+03:00") self.assertNextEqual(w, "2021-11-01T02:00:00+02:00") def test_011_at_3_mar(self) -> None: now = datetime(2021, 3, 27, 1, 30, tzinfo=self.tz) w = CronSim("0 3 * * *", now) self.assertNextEqual(w, "2021-03-27T03:00:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") self.assertNextEqual(w, "2021-03-29T03:00:00+03:00") def test_011_at_3_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) w = CronSim("0 3 * * *", now) self.assertNextEqual(w, "2021-10-31T03:00:00+03:00") self.assertNextEqual(w, "2021-11-01T03:00:00+02:00") def test_012_at_4_mar(self) -> None: now = datetime(2021, 3, 27, 1, 30, tzinfo=self.tz) w = CronSim("0 4 * * *", now) self.assertNextEqual(w, "2021-03-27T04:00:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") self.assertNextEqual(w, "2021-03-29T04:00:00+03:00") def test_012_at_4_oct(self) -> None: now = datetime(2021, 10, 30, 1, 30, tzinfo=self.tz) w = CronSim("0 4 * * *", now) self.assertNextEqual(w, "2021-10-30T04:00:00+03:00") self.assertNextEqual(w, "2021-10-31T04:00:00+02:00") self.assertNextEqual(w, "2021-11-01T04:00:00+02:00") def test_014_every_hour_enumerated_mar(self) -> None: now = datetime(2021, 3, 28, 1, 30, tzinfo=self.tz) w = CronSim( "0 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23 * * *", now ) self.assertNextEqual(w, "2021-03-28T02:00:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") self.assertNextEqual(w, "2021-03-28T05:00:00+03:00") def test_014_every_hour_enumerated_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) w = CronSim( "0 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23 * * *", now ) self.assertNextEqual(w, "2021-10-31T02:00:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+03:00") self.assertNextEqual(w, "2021-10-31T04:00:00+02:00") def test_015_every_other_hour_enumerated_mar(self) -> None: now = datetime(2021, 3, 28, 0, 30, tzinfo=self.tz) w = CronSim("0 1,3,5,7,9,11,13,15,17,19,21,23 * * *", now) self.assertNextEqual(w, "2021-03-28T01:00:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") self.assertNextEqual(w, "2021-03-28T05:00:00+03:00") def test_015_every_other_hour_enumerated_oct(self) -> None: now = datetime(2021, 10, 31, 0, 30, tzinfo=self.tz) w = CronSim("0 1,3,5,7,9,11,13,15,17,19,21,23 * * *", now) self.assertNextEqual(w, "2021-10-31T01:00:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+03:00") self.assertNextEqual(w, "2021-10-31T05:00:00+02:00") def test_016_at_1_to_5_mar(self) -> None: now = datetime(2021, 3, 28, 1, 30, tzinfo=self.tz) w = CronSim("0 1-5 * * *", now) self.assertNextEqual(w, "2021-03-28T02:00:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") self.assertNextEqual(w, "2021-03-28T05:00:00+03:00") def test_016_at_1_to_5_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) w = CronSim("0 1-5 * * *", now) self.assertNextEqual(w, "2021-10-31T02:00:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+03:00") self.assertNextEqual(w, "2021-10-31T04:00:00+02:00") def test_at_3_15_mar(self) -> None: now = datetime(2021, 3, 27, 0, 0, tzinfo=self.tz) w = CronSim("15 3 * * *", now) self.assertNextEqual(w, "2021-03-27T03:15:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") self.assertNextEqual(w, "2021-03-29T03:15:00+03:00") def test_at_3_15_oct(self) -> None: now = datetime(2021, 10, 30, 0, 0, tzinfo=self.tz) w = CronSim("15 3 * * *", now) self.assertNextEqual(w, "2021-10-30T03:15:00+03:00") self.assertNextEqual(w, "2021-10-31T03:15:00+03:00") self.assertNextEqual(w, "2021-11-01T03:15:00+02:00") def test_every_minute_mar(self) -> None: now = datetime(2021, 3, 28, 2, 58, tzinfo=self.tz) w = CronSim("* * * * *", now) self.assertNextEqual(w, "2021-03-28T02:59:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") def test_every_minute_oct(self) -> None: now = datetime(2021, 10, 31, 3, 58, fold=0, tzinfo=self.tz) w = CronSim("* * * * *", now) self.assertNextEqual(w, "2021-10-31T03:59:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+02:00") self.assertNextEqual(w, "2021-10-31T03:01:00+02:00") def test_every_minute_from_1_to_6_mar(self) -> None: now = datetime(2021, 3, 28, 2, 58, tzinfo=self.tz) w = CronSim("* 1-6 * * *", now) self.assertNextEqual(w, "2021-03-28T02:59:00+02:00") self.assertNextEqual(w, "2021-03-28T04:00:00+03:00") def test_every_minute_from_1_to_6_oct(self) -> None: now = datetime(2021, 10, 31, 3, 58, fold=0, tzinfo=self.tz) w = CronSim("* 1-6 * * *", now) self.assertNextEqual(w, "2021-10-31T03:59:00+03:00") self.assertNextEqual(w, "2021-10-31T03:00:00+02:00") self.assertNextEqual(w, "2021-10-31T03:01:00+02:00") class TestOptimizations(unittest.TestCase): def test_it_skips_fixup_for_naive_datetimes(self) -> None: w = CronSim("1 1 L * *", NOW) self.assertIsNone(w.fixup_tz) def test_it_skips_fixup_for_utc_datetimes(self) -> None: now = NOW.replace(tzinfo=timezone.utc) w = CronSim("1 1 L * *", now) self.assertIsNone(w.fixup_tz) class TestExplain(unittest.TestCase): def test_it_works(self) -> None: result = CronSim("* * * * *", NOW).explain() self.assertEqual(result, "Every minute") class TestReverse(unittest.TestCase): samples = [ "* * * * *", "0 * * * *", "*/30 * * * *", "*/15 * * * *", "0 */2 * * *", "30 */2 * * *", "0 */3 * * *", "0 1,2,3,4,5 * * *", "30 1,2,3,4,5 * * *", "0 2 * * *", "0 3 * * *", "0 4 * * *", "0 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23 * * *", "0 1,3,5,7,9,11,13,15,17,19,21,23 * * *", "0 1-5 * * *", "15 3 * * *", "* 1-6 * * *", "1 1 L * *", "1 1 LW 5 *", "1 1 * * 5L", "1 1 * * 0L", "1 1 * * 7L", "1 1 * * 1#2", "1 1 1-7 * */7", "1 1 */100,1-7 * MON", "1 1 * * */3", ] tz = ZoneInfo("Europe/Riga") def _test(self, expr, now): it = CronSim(expr, now) crumbs = [next(it) for i in range(0, 5)] reverse_it = CronSim(expr, crumbs.pop(), reverse=True) while crumbs: self.assertEqual(next(reverse_it), crumbs.pop()) def test_it_handles_naive_datetime(self) -> None: for sample in self.samples: self._test(sample, NOW) def test_it_handles_utc(self) -> None: now = NOW.replace(tzinfo=timezone.utc) for sample in self.samples: self._test(sample, now) def test_it_handles_dst_mar(self) -> None: now = datetime(2021, 3, 28, 1, 30, tzinfo=self.tz) for sample in self.samples: self._test(sample, now) def test_it_handles_dst_oct(self) -> None: now = datetime(2021, 10, 31, 1, 30, tzinfo=self.tz) for sample in self.samples: self._test(sample, now) def test_it_handles_no_matches(self) -> None: # The first date of the month *and* the fourth Monday of the month # will never yield results: it = CronSim("1 1 */100 * MON#4", NOW, reverse=True) with self.assertRaises(StopIteration): next(it) if __name__ == "__main__": unittest.main() cuu508-cronsim-2b67279/tests/test_explain.py000066400000000000000000000272231471143241200207170ustar00rootroot00000000000000from __future__ import annotations import unittest from cronsim.explain import explain class TestBase(unittest.TestCase): """ * * * 1 * | Every minute in January """ def test(self) -> None: for line in self.__doc__.split("\n"): if "|" not in line: continue expr, desc = line.split("|") expr, desc = expr.strip(), desc.strip() with self.subTest(): self.assertEqual(explain(expr), desc, expr) class TestEveryMinute(TestBase): """ * * * * * | Every minute */1 * * * * | Every minute 0/1 * * * * | Every minute 0-59 * * * * | Every minute * * 1/1 * * | Every minute * * * JAN-DEC * | Every minute """ class TestMinuteField(TestBase): """ 0 * * * * | At the start of every hour 0,0 * * * * | At the start of every hour 0,0 */1 * * * | At the start of every hour 0,0 0/1 * * * | At the start of every hour 5 * * * * | At minute 5 of every hour 5,5 * * * * | At minute 5 of every hour 5,10 * * * * | At minutes 5 and 10 of every hour 5,7,9 * * * * | At minutes 5, 7, and 9 of every hour */5 * * * * | Every fifth minute of every hour 0/5 * * * * | Every fifth minute of every hour */5,*/5 * * * * | Every fifth minute of every hour 0-30/5 * * * * | Every fifth minute from 0 through 30 of every hour 0-59/5 * * * * | Every fifth minute of every hour 1/5 * * * * | Every fifth minute from 1 through 59 of every hour 1,*/5 * * * * | At minute 1 and every fifth minute of every hour */5,1 * * * * | At every fifth minute and minute 1 of every hour 0-10 * * * * | Every minute from 0 through 10 of every hour 0-10,20-30 * * * * | Every minute from 0 through 10 and every minute from 20 through 30 of every hour 20-30,*/15 * * * * | Every minute from 20 through 30 and every 15th minute of every hour 1,20-30,*/15 * * * * | At minute 1, every minute from 20 through 30, and every 15th minute of every hour """ class TestHourField(TestBase): """ * 0 * * * | Every minute from 00:00 through 00:59 0-59 0 * * * | Every minute from 00:00 through 00:59 * 2,4 * * * | Every minute past hours 2 and 4 * */2 * * * | Every minute past every second hour * 0/2 * * * | Every minute past every second hour * */2,*/2 * * * | Every minute past every second hour * */3 * * * | Every minute past every third hour * */4 * * * | Every minute past every fourth hour * 1/4 * * * | Every minute past every fourth hour from 1 through 23 * 1-10/4 * * * | Every minute past every fourth hour from 1 through 10 * 1,*/4 * * * | Every minute past hour 1 and every fourth hour * 1-4 * * * | Every minute from 01:00 through 04:59 * 0-4,23 * * * | Every minute past every hour from 0 through 4 and hour 23 * 0-7,18-23 * * * | Every minute past every hour from 0 through 7 and every hour from 18 through 23 * 1,9-12,*/4 * * * | Every minute past hour 1, every hour from 9 through 12, and every fourth hour 0 */3 * * * | At minute 0 past every third hour 0 * * * * | At the start of every hour 0 */1 * * * | At the start of every hour 0 0/1 * * * | At the start of every hour 10 9-17 * * * | At minute 10 past every hour from 9 through 17 """ class TestDayField(TestBase): """ 0 0 1 * * | At 00:00 on the first day of every month 0 0 1,1 * * | At 00:00 on the first day of every month 0 0 1,15 * * | At 00:00 on the first and the 15th day of month 0 0 1,3,5 * * | At 00:00 on the first, the third, and the fifth day of month 0 0 1,3,10-20 * * | At 00:00 on the first day of month, the third day of month, and every day of month from 10 through 20 0 0 1-15 * * | At 00:00 on every day of month from 1 through 15 0 0 1-15,30 * * | At 00:00 on every day of month from 1 through 15 and the 30th day of month 0 0 */5 * * | At 00:00 on every fifth day of month 0 0 0/5 * * | At 00:00 on every fifth day of month 0 0 1/5 * * | At 00:00 on every fifth day of month 0 0 2/5 * * | At 00:00 on every fifth day of month from 2 through 31 0 0 2-10/5 * * | At 00:00 on every fifth day of month from 2 through 10 0 0 1-5,*/5 * * | At 00:00 on every day of month from 1 through 5 and every fifth day of month 0 0 1,L * * | At 00:00 on the first and the last day of month 0 0 1,2,L * * | At 00:00 on the first, the second, and the last day of month 0 0 L * * | At 00:00 on the last day of every month 0 0 L/2 * * | At 00:00 on the last day of every month 0 0 L * MON | At 00:00 on the last day of the month and on Monday 0 0 LW * * | At 00:00 on the last weekday of every month 0 0 LW/2 * * | At 00:00 on the last weekday of every month 0 0 LW * MON | At 00:00 on the last weekday of the month and on Monday 0 0 L,LW * * | At 00:00 on the last day of the month and the last weekday of the month """ class TestMonthField(TestBase): """ * * * 1 * | Every minute in January * * 15 JAN-FEB * | Every minute on the 15th day of January and February 0 0 * 1 * | At 00:00 every day in January 0 0 * 1,1 * | At 00:00 every day in January 0 0 * JAN * | At 00:00 every day in January 0 0 * 1-2 * | At 00:00 every day in January and February 0 0 * JAN-FEB * | At 00:00 every day in January and February 0 0 15 JAN-FEB * | At 00:00 on the 15th day of January and February 0 0 * 1-3 * | At 00:00 in every month from January through March 0 0 * */2 * | At 00:00 in every second month 0 0 * 1/1 * | At 00:00 every day 0 0 * 1/2 * | At 00:00 in every second month 0 0 * 3/2 * | At 00:00 in every second month from March through December 0 0 * 1-6/2 * | At 00:00 in every second month from January through June 0 0 * 1-2,12 * | At 00:00 every day in January, February, and December 0 0 * 1-3,12 * | At 00:00 in every month from January through March and December """ class TestSingleDateInMonth(TestBase): """ 0 0 1 1-2 * | At 00:00 on the first day of January and February 0 0 1 JAN-FEB * | At 00:00 on the first day of January and February 0 0 1 1-3 * | At 00:00 on the first day of every month from January through March 0 0 1 */2 * | At 00:00 on the first day of every second month 0 0 1 1/2 * | At 00:00 on the first day of every second month 0 0 1 3/2 * | At 00:00 on the first day of every second month from March through December 0 0 1 1-6/2 * | At 00:00 on the first day of every second month from January through June 0 0 1 1-2,12 * | At 00:00 on the first day of January, February, and December 0 0 1 1-3,12 * | At 00:00 on the first day of every month from January through March and December 0 0 1 1 1 | At 00:00 on the first day of month and on Monday in January 0 0 1 1 1-5 | At 00:00 on the first day of month and on Monday through Friday in January 0 0 1-2 1 1-5 | At 00:00 on the first and the second day of month and on Monday through Friday in January """ class TestWeekdayField(TestBase): """ * * * * 1#2 | Every minute on the second Monday of the month * * * * 1L | Every minute on the last Monday of the month 0 0 * * 1 | At 00:00 on Monday 0 0 * * 1,1 | At 00:00 on Monday 0 0 * * MON | At 00:00 on Monday 0 0 * * 1#2 | At 00:00 on the second Monday of the month 0 0 * * MON#2 | At 00:00 on the second Monday of the month 0 0 * * 1L | At 00:00 on the last Monday of the month 0 0 * * 1-2 | At 00:00 on Monday and Tuesday 0 0 * * 1,2 | At 00:00 on Monday and Tuesday 0 0 * * MON,TUE | At 00:00 on Monday and Tuesday 0 0 * * 1-3 | At 00:00 on Monday through Wednesday 0 0 * * MON-WED | At 00:00 on Monday through Wednesday 0 0 * * 1-3,5 | At 00:00 on Monday through Wednesday and Friday 0 0 * * */2 | At 00:00 on every second day of week 0 0 * * 0/2 | At 00:00 on every second day of week 0 0 * * 1/2 | At 00:00 on every second day of week from Monday through Sunday 0 0 * * 1-7 | At 00:00 on Monday through Sunday 0 0 * * 1-7/1 | At 00:00 on Monday through Sunday 0 0 * * 0-6 | At 00:00 on Sunday through Saturday 0 0 * * 0-6/1 | At 00:00 on Sunday through Saturday """ class TestDateCombinations(TestBase): """ 0 0 15 * * | At 00:00 on the 15th day of every month 0 0 15 1 1 | At 00:00 on the 15th day of month and on Monday in January 0 0 * 1 1 | At 00:00 on Monday in January 0 0 * JAN-FEB * | At 00:00 every day in January and February 0 0 * JAN-MAR * | At 00:00 in every month from January through March 0 0 15 JAN-FEB * | At 00:00 on the 15th day of January and February 0 0 1 JAN-FEB * | At 00:00 on the first day of January and February 0 0 1,2 JAN-FEB * | At 00:00 on the first and the second day of month in January and February 0 0 * * 1-5 | At 00:00 on Monday through Friday 0 0 L JAN * | At 00:00 on the last day of January 0 0 LW JAN * | At 00:00 on the last weekday of January """ class TestSpecificTimes(TestBase): """ 0 0 * * * | At 00:00 every day 0 2 * * * | At 02:00 every day 0,30 13,14 * * * | At 13:00, 13:30, 14:00, and 14:30 every day 0,15,30,45 2 * * * | At 02:00, 02:15, 02:30, and 02:45 every day 0,15,30,45 2,3 * * * | At minutes 0, 15, 30, and 45 past hours 2 and 3 0-10 11 * * * | Every minute from 11:00 through 11:10 * 9-17 * * * | Every minute from 09:00 through 17:59 */2 9-17 * * * | Every second minute from 09:00 through 17:59 """ class TestSpecificDates(TestBase): """ 0 0 1 1 * | At 00:00 on January 1 0 0 15 1 * | At 00:00 on January 15 0 0 L 1 * | At 00:00 on the last day of January 0 0 LW 1 * | At 00:00 on the last weekday of January """ class TestFunkySchedules(TestBase): """ 0 0 1-7 * */7 | At 00:00 on every day of month from 1 through 7 if it's on every seventh day of week 0 0 */100,1-7 * MON | At 00:00 on every 100th day of month and every day of month from 1 through 7 if it's on Monday """ class TestSmoke(TestBase): """ 30-59/5 2,4,6 1-10 1-3 * | Every fifth minute from 30 through 59 past hours 2, 4, and 6 on every day of month from 1 through 10 in every month from January through March 0/15 9-17 1,10 * * | Every 15th minute from 09:00 through 17:59 on the first and the 10th day of month * * * * 1,2,3 | Every minute on Monday, Tuesday, and Wednesday 0 0 1 1/2 * * | At 00:00 on the first day of every second month 0 0 1 1/2,12 * * | At 00:00 on the first day of every second month and December """ if __name__ == "__main__": unittest.main()