Functional Weave
Code in Rust

dates.recurrence@1.0.0

impl/python.py

3,833 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import List

from .dates_add_days import days_from_civil, days_in_month, epoch_day_from_iso, iso_from_epoch_day, parse_iso_date  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_add_months import add_months  ← from dates.add-months ^1.0.0 · built alongside by fune
from .dates_day_of_week import day_of_week  ← from dates.day-of-week ^1.0.0 · built alongside by fune
from .dates_recurrence_types import RecurrenceRule


def _is_int(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def _check_range(name: str, value: object, low: int, high: int, kind: str) -> None:
    if not _is_int(value) or value < low or value > high:  # type: ignore[operator]
        raise ValueError("%s must be %s-%s for %s, received %s" % (name, low, high, kind, value))


def _date_in_month(index: int, rule: RecurrenceRule) -> int:
    """The date of the ``day``-th (clamped) or last ``weekday`` in a month index counted from year 0."""
    year = index // 12
    month = index - year * 12 + 1
    if year > 9999:
        raise ValueError("the schedule runs past 9999-12-31, outside the supported range 0001-01-01 to 9999-12-31")
    length = days_in_month(year, month)
    if rule.kind == "monthly-on-day":
        return days_from_civil(year, month, min(rule.day, length))
    last = days_from_civil(year, month, length)
    last_weekday = day_of_week(iso_from_epoch_day(last))
    return last - (last_weekday - rule.weekday) % 7


def recurrence(rule: RecurrenceRule, start_iso: str, count: int) -> List[str]:
    """The first ``count`` dates of a rule on or after ``start_iso``.

    Monthly dates are clamped from the rule's day every time, never from the
    previous date, so the 31st does not decay to the 28th after February.
    """
    if not _is_int(count) or count < 0:
        raise ValueError("count must be a non-negative integer, received %s" % (count,))
    if not _is_int(rule.interval) or rule.interval < 1:
        raise ValueError("interval must be a positive integer, received %s" % (rule.interval,))
    kind = rule.kind
    if kind == "monthly-on-day":
        if rule.day is None:
            raise ValueError("day must be 1-31 for monthly-on-day, received null")
        _check_range("day", rule.day, 1, 31, kind)
        if rule.weekday is not None:
            raise ValueError("weekday does not apply to monthly-on-day")
    elif kind == "last-weekday-of-month":
        if rule.weekday is None:
            raise ValueError("weekday must be 1-7 for last-weekday-of-month, received null")
        _check_range("weekday", rule.weekday, 1, 7, kind)
        if rule.day is not None:
            raise ValueError("day does not apply to last-weekday-of-month")
    elif kind in ("every-days", "every-weeks", "yearly"):
        if rule.day is not None:
            raise ValueError("day does not apply to %s" % (kind,))
        if rule.weekday is not None:
            raise ValueError("weekday does not apply to %s" % (kind,))
    else:
        raise ValueError(
            'unknown recurrence kind "%s": expected monthly-on-day, last-weekday-of-month, every-days, every-weeks or yearly'
            % (kind,)
        )

    start = epoch_day_from_iso(start_iso)
    out: List[str] = []
    if kind in ("every-days", "every-weeks"):
        step = 7 * rule.interval if kind == "every-weeks" else rule.interval
        for k in range(count):
            out.append(iso_from_epoch_day(start + k * step))
    elif kind == "yearly":
        # Always from the anchor, so 29 February comes back in leap years.
        for k in range(count):
            out.append(add_months(start_iso, 12 * rule.interval * k))
    else:
        date = parse_iso_date(start_iso)
        first = date.year * 12 + (date.month - 1)
        if count > 0 and _date_in_month(first, rule) < start:
            first += 1
        for k in range(count):
            out.append(iso_from_epoch_day(_date_in_month(first + k * rule.interval, rule)))
    return out