Files
Home-Assistant/custom_components/maintenance_supporter/helpers/dates.py
T
2026-07-08 10:43:39 -04:00

178 lines
6.3 KiB
Python

"""Calendar-aware interval arithmetic (no external dependencies).
Used by the scheduling model so an interval can be expressed in days, weeks,
months, or years. Month/year steps are calendar-aware and clamp the day to the
target month's last day (e.g. Jan 31 + 1 month -> Feb 28/29), avoiding the
drift you get from approximating "monthly" as 30 days.
"""
from __future__ import annotations
import calendar
from collections.abc import Iterator
from datetime import date, timedelta
from .workday import is_business_day
INTERVAL_UNITS = ("days", "weeks", "months", "years")
def parse_iso_date(value: str | None) -> date | None:
"""ISO date string → ``date``; ``None`` when absent or malformed."""
if not value:
return None
try:
return date.fromisoformat(value)
except (ValueError, TypeError):
return None
def _add_months(anchor: date, months: int) -> date:
"""Advance ``anchor`` by ``months`` calendar months, clamping the day."""
total = anchor.month - 1 + months
year = anchor.year + total // 12
month = total % 12 + 1
last_day = calendar.monthrange(year, month)[1]
return date(year, month, min(anchor.day, last_day))
def add_interval(anchor: date, n: int, unit: str = "days") -> date:
"""Return ``anchor`` advanced by ``n`` of ``unit``.
``unit`` is one of days / weeks / months / years; anything else (or None)
falls back to days, which keeps existing day-based tasks working unchanged.
"""
if unit == "weeks":
return anchor + timedelta(weeks=n)
if unit == "months":
return _add_months(anchor, n)
if unit == "years":
return _add_months(anchor, n * 12)
return anchor + timedelta(days=n)
def nth_weekday_of_month(year: int, month: int, nth: int, weekday: int) -> date | None:
"""The ``nth`` ``weekday`` of ``(year, month)``; ``None`` if it doesn't exist.
``weekday`` is 0=Mon … 6=Sun (``date.weekday()``). ``nth`` is 1..5, or -1 for
the last occurrence. A 5th occurrence that the month doesn't have → ``None``.
"""
last_day = calendar.monthrange(year, month)[1]
if nth == -1:
anchor = date(year, month, last_day)
return anchor - timedelta(days=(anchor.weekday() - weekday) % 7)
first = date(year, month, 1)
day = 1 + (weekday - first.weekday()) % 7 + (nth - 1) * 7
if day > last_day:
return None
return date(year, month, day)
def next_weekday_in_set(ref: date, weekdays: tuple[int, ...], *, inclusive: bool) -> date | None:
"""Next date on/after ``ref`` whose weekday is in ``weekdays`` (0=Mon…6=Sun).
``inclusive`` includes ``ref`` itself; otherwise the search starts the next
day. ``None`` when ``weekdays`` is empty.
"""
if not weekdays:
return None
start = ref if inclusive else ref + timedelta(days=1)
for offset in range(7):
candidate = start + timedelta(days=offset)
if candidate.weekday() in weekdays:
return candidate
return None # pragma: no cover (unreachable: a non-empty weekday set always matches within 7 days)
def _iter_months(year: int, month: int, limit: int = 60) -> Iterator[tuple[int, int]]:
"""Yield ``(year, month)`` forward from the given month, ``limit`` times."""
for _ in range(limit):
yield year, month
month += 1
if month > 12:
month = 1
year += 1
def next_nth_weekday(
ref: date,
nth: int,
weekday: int,
months: tuple[int, ...] | None = None,
*,
inclusive: bool,
) -> date | None:
"""Next ``nth``-``weekday``-of-month occurrence on/after ``ref``.
Optionally restricted to ``months`` (1=Jan…12=Dec). ``None`` if no occurrence
is found within a bounded horizon (e.g. a 5th weekday restricted to a month
that never has one).
"""
for year, month in _iter_months(ref.year, ref.month):
if months and month not in months:
continue
occ = nth_weekday_of_month(year, month, nth, weekday)
if occ is not None and (occ >= ref if inclusive else occ > ref):
return occ
return None
def next_day_of_month(
ref: date,
day: int,
months: tuple[int, ...] | None = None,
*,
inclusive: bool,
) -> date | None:
"""Next ``day``-of-month occurrence on/after ``ref`` (day clamped to month).
``day`` 1..31 is clamped to the month's length (e.g. 31 → Feb 28/29);
``day == -1`` means the LAST day of the month (#83). Optionally restricted
to ``months``.
"""
for year, month in _iter_months(ref.year, ref.month):
if months and month not in months:
continue
month_len = calendar.monthrange(year, month)[1]
clamped = month_len if day == -1 else min(day, month_len)
candidate = date(year, month, clamped)
if candidate >= ref if inclusive else candidate > ref:
return candidate
return None
def roll_back_to_business_day(d: date) -> date:
"""Roll a date back to the preceding business day (business days unchanged).
Used by the day-of-month schedule's ``business`` flag (#83): "last business
day of the month" = last day rolled back past non-business days. What
counts as a business day comes from :mod:`.workday`: plain Mon-Fri by
default, or the user's Workday integration configuration (public holidays,
custom working weekdays) when one is set up.
Bounded: a pathological provider that never yields a business day within
two weeks returns *d* unchanged rather than walking off into the past.
"""
candidate = d
for _ in range(14):
if is_business_day(candidate):
return candidate
candidate -= timedelta(days=1)
return d
def interval_span_days(n: int | None, unit: str = "days") -> int:
"""Length of one ``n``-``unit`` interval in days, calendar-aware.
Measured as the distance from a fixed reference date to that date advanced
by the interval, so months/years reflect real calendar lengths instead of
the raw count. Used so things like the warning window aren't capped by the
bare interval *count* for month/year tasks (issue #58/#59 — a 6-month task
must not collapse a 14-day warning to ``min(14, 6)``). Returns 0 when there
is no positive interval.
"""
if not n or n <= 0:
return 0
ref = date(2001, 1, 1) # common (non-leap) year; representative span
return (add_interval(ref, n, unit) - ref).days