251 files

This commit is contained in:
Home Assistant Version Control
2026-10-16 12:38:25 +00:00
parent 3acf0cdf8d
commit 64eb0c647c
251 changed files with 6380 additions and 14560 deletions
@@ -24,6 +24,7 @@ so the engine stays pure and sync.
from __future__ import annotations
import calendar
import logging
import statistics
from collections.abc import Callable, Mapping, Sequence
@@ -318,6 +319,7 @@ class Schedule:
today: date,
times_performed: int = 0,
due_override: date | None = None,
planned_day: int | None = None,
) -> date | None:
"""The next due date, or None for manual / archived one-time tasks.
@@ -341,6 +343,7 @@ class Schedule:
today=today,
times_performed=times_performed,
due_override=due_override,
planned_day=planned_day,
)
except (OverflowError, ValueError):
return None
@@ -354,6 +357,7 @@ class Schedule:
today: date,
times_performed: int,
due_override: date | None,
planned_day: int | None = None,
) -> date | None:
""":meth:`next_due` without the end-of-calendar guard."""
# Finite series exhausted by completion count → terminally done. Checked
@@ -375,6 +379,7 @@ class Schedule:
created_at=created_at,
last_planned_due=last_planned_due,
today=today,
planned_day=planned_day,
)
if self.kind == KIND_ONE_TIME:
return result
@@ -450,6 +455,7 @@ class Schedule:
created_at: date | None,
last_planned_due: date | None,
today: date,
planned_day: int | None = None,
) -> date | None:
"""The raw next due date before the seasonal window is applied."""
if self.kind == KIND_ONE_TIME:
@@ -488,22 +494,38 @@ class Schedule:
periods = 1 if days_gap < 0 else (days_gap // step) + 1
return anchor + timedelta(days=periods * step)
# Calendar units (months/years): step until past last_performed.
# Multiply from the ORIGINAL anchor instead of iterating on the
# clamped result — iterative adds let February permanently drag a
# day-31 anchor to day 28 (Jan 31 → Feb 28 → Mar 28 …); n×interval
# from the anchor recovers the intended day per target month
# (Jan 31 + 2 months = Mar 31). The clamp can still stick across
# CYCLES when a February due itself becomes the next anchor — the
# day_of_month kind is the tool for hard month-end pinning.
# Multiply from the anchor instead of iterating on the clamped
# result (Jan 31 + 2 months = Mar 31, not Mar 28). Across cycles a
# clamped February due becomes the next anchor; ``planned_day``
# (see carry_planned_day) remembers the day it stands for, so the
# series goes back to the 31st instead of staying on the 28th.
day = planned_day if last_planned_due is not None and planned_day and planned_day > anchor.day else None
candidate = anchor
for n in range(1, _MAX_PLANNED_STEPS + 1):
candidate = add_interval(anchor, n * every, self.unit)
candidate = add_interval(anchor, n * every, self.unit, day=day)
if candidate > last_performed:
return candidate
return candidate
return add_interval(last_performed, every, self.unit)
def carry_planned_day(self, intended: int | None, grid: date) -> int | None:
"""The day of month a new planned anchor stands for, or None when it is
the anchor's own day.
``grid`` is the occurrence just completed or skipped, ``intended`` the
day the series was on before it. A monthly or yearly series on the
29th-31st lands on a short month's last day; without this the clamped
date became the anchor and the series stayed on the 28th for good.
"""
if self.kind != KIND_INTERVAL or self.anchor != "planned" or self.unit not in ("months", "years"):
return None
if intended is None or not 1 <= intended <= 31 or intended <= grid.day:
return None
if grid.day != calendar.monthrange(grid.year, grid.month)[1]:
return None # not a clamped month end: the grid's own day is meant
return intended
def _calendar_occurrence(self, ref: date, *, inclusive: bool) -> date | None:
"""Next EFFECTIVE occurrence of a calendar kind on/after ``ref``.
@@ -724,6 +746,7 @@ def preview_occurrences(
series_ended = False
lp = last_performed
lpd: date | None = None
pday: int | None = None
times = times_performed
for _ in range(count):
nxt = schedule.next_due(
@@ -732,6 +755,7 @@ def preview_occurrences(
last_planned_due=lpd,
today=today,
times_performed=times,
planned_day=pday,
)
if nxt is None:
# A calendar entity that merely ran out of KNOWN events has not
@@ -741,6 +765,7 @@ def preview_occurrences(
if occurrences and nxt <= occurrences[-1]: # pragma: no cover
break # safety net: the engine must advance — never loop
occurrences.append(nxt)
pday = schedule.carry_planned_day(pday or (lpd or lp or today).day, nxt)
lp = nxt
lpd = nxt
times += 1