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_months import add_months from .dates_day_of_week import day_of_week 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