feat: add ScheduleRule model with validation and serialization (#25)
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
"""The ScheduleRule model: rule types, validation, and serialization.
|
||||
|
||||
A :class:`ScheduleRule` describes WHEN a recurring chore fires — a pure
|
||||
data structure with no Home Assistant and no database imports. The
|
||||
evaluation engine (:mod:`.recurrence`, Feature 04) consumes it;
|
||||
the DAO layer (Feature 02) serializes its dict form into the
|
||||
``schedule_rules`` table.
|
||||
|
||||
Type model: one enum per rule family. ``MONTHLY_DAY`` ("the 15th of
|
||||
every month") and ``MONTHLY_WEEKDAY`` ("the second Tuesday of every
|
||||
month") are distinct types rather than one monthly type with optional
|
||||
fields, so every field combination a rule can carry is decided by the
|
||||
rule type alone and validation is total: a rule either constructs, or
|
||||
raises :class:`RuleValidationError` naming the offending fields.
|
||||
|
||||
Validation policy (fail fast at construction — the schema's CHECK
|
||||
constraints are a second line of defense, but invalid rules must never
|
||||
reach storage):
|
||||
|
||||
- ``interval`` >= 1 for every rule.
|
||||
- ``weekday_set`` is required for WEEKLY and CUSTOM_DAYS, forbidden
|
||||
elsewhere; each element must be 0 (Monday) .. 6 (Sunday).
|
||||
- ``day_of_month`` 1..31 is required for MONTHLY_DAY.
|
||||
- ``nth_weekday`` in -1..5 (1 = first, 5 = last-possible, -1 = last)
|
||||
and a weekday 0..6 are required for MONTHLY_WEEKDAY.
|
||||
- ``month`` 1..12 is required for YEARLY.
|
||||
- ``start_date`` is a strict ISO date; ``end_date`` is optional and,
|
||||
when present, must be on or after ``start_date``.
|
||||
- ``due_time`` is optional and must be HH:MM 24-hour.
|
||||
|
||||
Month-end policy (documented for Feature 04): a MONTHLY_DAY rule for
|
||||
the 31st does NOT fire in a 30-day month, and a YEARLY rule for
|
||||
February 29th does NOT fire in a non-leap year — the engine skips
|
||||
instead of clamping to month end, because clamping silently moves
|
||||
"the 31st" to "the 30th" and double-fires tasks. ``weekday_in_month``
|
||||
(True = Nth weekday, False = Nth-from-last for negative n) resolves
|
||||
the MONTHLY_WEEKDAY position.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
#: Date-shape policy: strict YYYY-MM-DD, matching the DAO layers.
|
||||
_DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
|
||||
class RuleType(enum.Enum):
|
||||
"""The six recurrence shapes the engine supports."""
|
||||
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY_DAY = "monthly_day"
|
||||
MONTHLY_WEEKDAY = "monthly_weekday"
|
||||
YEARLY = "yearly"
|
||||
CUSTOM_DAYS = "custom_days"
|
||||
|
||||
|
||||
class RuleValidationError(ValueError):
|
||||
"""Raised when a ScheduleRule field combination is invalid."""
|
||||
|
||||
|
||||
def _validate_date(value: str, field: str) -> None:
|
||||
"""Raise unless ``value`` is a strict YYYY-MM-DD calendar date."""
|
||||
try:
|
||||
parsed = datetime.datetime.strptime(value, _DATE_FORMAT).date()
|
||||
except (TypeError, ValueError):
|
||||
raise RuleValidationError(
|
||||
f"{field} must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||
) from None
|
||||
if parsed.isoformat() != value:
|
||||
raise RuleValidationError(
|
||||
f"{field} must be a strict YYYY-MM-DD date, got {value!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_weekday_set(weekday_set) -> None:
|
||||
"""Raise unless ``weekday_set`` is a non-empty 0-6 integer set."""
|
||||
if not isinstance(weekday_set, (set, frozenset)):
|
||||
raise RuleValidationError(
|
||||
"weekday_set must be a set of weekday integers, got "
|
||||
f"{type(weekday_set).__name__}"
|
||||
)
|
||||
if not weekday_set:
|
||||
raise RuleValidationError(
|
||||
"weekday_set must not be empty for weekly/custom rules"
|
||||
)
|
||||
for entry in weekday_set:
|
||||
if not isinstance(entry, int) or isinstance(entry, bool):
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be integers, got {entry!r}"
|
||||
)
|
||||
if not 0 <= entry <= 6:
|
||||
raise RuleValidationError(
|
||||
f"weekday_set entries must be 0 (Monday)..6 (Sunday), "
|
||||
f"got {entry!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_monthday_set(nth_weekday: int | None, weekday: int | None) -> None:
|
||||
"""Validate the MONTHLY_WEEKDAY position fields."""
|
||||
if nth_weekday is None or not isinstance(nth_weekday, int) or (
|
||||
isinstance(nth_weekday, bool)
|
||||
):
|
||||
raise RuleValidationError(
|
||||
"nth_weekday must be an integer -1..5 for monthly_weekday "
|
||||
f"rules, got {nth_weekday!r}"
|
||||
)
|
||||
if not (-1 <= nth_weekday <= 5) or nth_weekday == 0:
|
||||
raise RuleValidationError(
|
||||
f"nth_weekday must be -1 (last) or 1..5, got {nth_weekday!r}"
|
||||
)
|
||||
if weekday is None:
|
||||
raise RuleValidationError(
|
||||
"monthly_weekday rules require a weekday (0..6) naming the "
|
||||
"day of week"
|
||||
)
|
||||
if not isinstance(weekday, int) or isinstance(weekday, bool) or not (
|
||||
0 <= weekday <= 6
|
||||
):
|
||||
raise RuleValidationError(
|
||||
f"weekday must be an integer 0 (Monday)..6 (Sunday), got "
|
||||
f"{weekday!r}"
|
||||
)
|
||||
|
||||
|
||||
class ScheduleRule:
|
||||
"""An immutable, validated recurrence rule.
|
||||
|
||||
Construct with keyword arguments; invalid combinations raise
|
||||
:class:`RuleValidationError` at construction time. The fields map
|
||||
one-to-one onto the ``schedule_rules`` table columns.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"rule_type",
|
||||
"interval",
|
||||
"weekday_set",
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
"start_date",
|
||||
"end_date",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rule_type: RuleType | str,
|
||||
*,
|
||||
interval: int = 1,
|
||||
weekday_set: frozenset[int] | set[int] | None = None,
|
||||
day_of_month: int | None = None,
|
||||
nth_weekday: int | None = None,
|
||||
nth_weekday_weekday: int | None = None,
|
||||
month: int | None = None,
|
||||
start_date: str = "1970-01-01",
|
||||
end_date: str | None = None,
|
||||
) -> None:
|
||||
if isinstance(rule_type, str):
|
||||
try:
|
||||
rule_type = RuleType(rule_type)
|
||||
except ValueError:
|
||||
raise RuleValidationError(
|
||||
f"rule_type must be one of "
|
||||
f"{[rt.value for rt in RuleType]}, got {rule_type!r}"
|
||||
) from None
|
||||
if not isinstance(interval, int) or isinstance(interval, bool):
|
||||
raise RuleValidationError(
|
||||
f"interval must be an integer >= 1, got {interval!r}"
|
||||
)
|
||||
if interval < 1:
|
||||
raise RuleValidationError(
|
||||
f"interval must be >= 1, got {interval!r}"
|
||||
)
|
||||
|
||||
_validate_date(start_date, "start_date")
|
||||
if end_date is not None:
|
||||
_validate_date(end_date, "end_date")
|
||||
if end_date < start_date:
|
||||
raise RuleValidationError(
|
||||
f"end_date {end_date!r} must be on or after "
|
||||
f"start_date {start_date!r}"
|
||||
)
|
||||
|
||||
if rule_type is RuleType.WEEKLY:
|
||||
if weekday_set is None:
|
||||
raise RuleValidationError(
|
||||
"weekly rules require weekday_set"
|
||||
)
|
||||
_validate_weekday_set(weekday_set)
|
||||
weekday_set = frozenset(weekday_set)
|
||||
elif rule_type is RuleType.CUSTOM_DAYS:
|
||||
if weekday_set is None:
|
||||
raise RuleValidationError(
|
||||
"custom_days rules require weekday_set"
|
||||
)
|
||||
_validate_weekday_set(weekday_set)
|
||||
weekday_set = frozenset(weekday_set)
|
||||
if day_of_month is not None:
|
||||
raise RuleValidationError(
|
||||
"custom_days rules must not set day_of_month"
|
||||
)
|
||||
if nth_weekday is not None:
|
||||
raise RuleValidationError(
|
||||
"custom_days rules must not set nth_weekday"
|
||||
)
|
||||
if month is not None:
|
||||
raise RuleValidationError(
|
||||
"custom_days rules must not set month"
|
||||
)
|
||||
elif rule_type is RuleType.MONTHLY_DAY:
|
||||
if day_of_month is None or not isinstance(day_of_month, int) or (
|
||||
isinstance(day_of_month, bool)
|
||||
):
|
||||
raise RuleValidationError(
|
||||
"monthly_day rules require day_of_month (1..31)"
|
||||
)
|
||||
if not 1 <= day_of_month <= 31:
|
||||
raise RuleValidationError(
|
||||
f"day_of_month must be 1..31, got {day_of_month!r}"
|
||||
)
|
||||
if weekday_set is not None:
|
||||
raise RuleValidationError(
|
||||
"monthly_day rules must not set weekday_set"
|
||||
)
|
||||
if nth_weekday is not None:
|
||||
raise RuleValidationError(
|
||||
"monthly_day rules must not set nth_weekday"
|
||||
)
|
||||
if nth_weekday_weekday is not None:
|
||||
raise RuleValidationError(
|
||||
"monthly_day rules must not set nth_weekday_weekday"
|
||||
)
|
||||
elif rule_type is RuleType.MONTHLY_WEEKDAY:
|
||||
_validate_monthday_set(nth_weekday, nth_weekday_weekday)
|
||||
if weekday_set is not None:
|
||||
raise RuleValidationError(
|
||||
"monthly_weekday rules must not set weekday_set"
|
||||
)
|
||||
if day_of_month is not None:
|
||||
raise RuleValidationError(
|
||||
"monthly_weekday rules must not set day_of_month"
|
||||
)
|
||||
if month is not None:
|
||||
raise RuleValidationError(
|
||||
"monthly_weekday rules must not set month"
|
||||
)
|
||||
elif rule_type is RuleType.YEARLY:
|
||||
if month is None or not isinstance(month, int) or isinstance(
|
||||
month, bool
|
||||
):
|
||||
raise RuleValidationError(
|
||||
"yearly rules require month (1..12)"
|
||||
)
|
||||
if not 1 <= month <= 12:
|
||||
raise RuleValidationError(
|
||||
f"month must be 1..12, got {month!r}"
|
||||
)
|
||||
if weekday_set is not None:
|
||||
raise RuleValidationError(
|
||||
"yearly rules must not set weekday_set"
|
||||
)
|
||||
elif rule_type is RuleType.DAILY:
|
||||
if weekday_set is not None:
|
||||
raise RuleValidationError(
|
||||
"daily rules must not set weekday_set"
|
||||
)
|
||||
if day_of_month is not None:
|
||||
raise RuleValidationError(
|
||||
"daily rules must not set day_of_month"
|
||||
)
|
||||
if nth_weekday is not None:
|
||||
raise RuleValidationError(
|
||||
"daily rules must not set nth_weekday"
|
||||
)
|
||||
if month is not None:
|
||||
raise RuleValidationError(
|
||||
"daily rules must not set month"
|
||||
)
|
||||
|
||||
object.__setattr__(self, "rule_type", rule_type)
|
||||
object.__setattr__(self, "interval", interval)
|
||||
object.__setattr__(self, "weekday_set", weekday_set)
|
||||
object.__setattr__(self, "day_of_month", day_of_month)
|
||||
object.__setattr__(self, "nth_weekday", nth_weekday)
|
||||
object.__setattr__(self, "nth_weekday_weekday", nth_weekday_weekday)
|
||||
object.__setattr__(self, "month", month)
|
||||
object.__setattr__(self, "start_date", start_date)
|
||||
object.__setattr__(self, "end_date", end_date)
|
||||
|
||||
rule_type: RuleType
|
||||
interval: int
|
||||
weekday_set: frozenset[int] | None
|
||||
day_of_month: int | None
|
||||
nth_weekday: int | None
|
||||
nth_weekday_weekday: int | None
|
||||
month: int | None
|
||||
start_date: str
|
||||
end_date: str | None
|
||||
|
||||
def __setattr__(self, name, value) -> None:
|
||||
raise AttributeError(
|
||||
"ScheduleRule is immutable; construct a new instance instead"
|
||||
)
|
||||
|
||||
def __delattr__(self, name) -> None:
|
||||
raise AttributeError("ScheduleRule is immutable")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Return the lossless dict form (also the DAO storage shape)."""
|
||||
return {
|
||||
"rule_type": self.rule_type.value,
|
||||
"interval": self.interval,
|
||||
"weekday_set": sorted(self.weekday_set) if self.weekday_set else None,
|
||||
"day_of_month": self.day_of_month,
|
||||
"nth_weekday": self.nth_weekday,
|
||||
"nth_weekday_weekday": self.nth_weekday_weekday,
|
||||
"month": self.month,
|
||||
"start_date": self.start_date,
|
||||
"end_date": self.end_date,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ScheduleRule":
|
||||
"""Rebuild a rule from :meth:`to_dict` output.
|
||||
|
||||
Raises :class:`RuleValidationError` on unknown keys or invalid
|
||||
combinations, exactly like construction.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
raise RuleValidationError(
|
||||
f"rule data must be a dict, got {type(data).__name__}"
|
||||
)
|
||||
allowed = {
|
||||
"rule_type",
|
||||
"interval",
|
||||
"weekday_set",
|
||||
"day_of_month",
|
||||
"nth_weekday",
|
||||
"nth_weekday_weekday",
|
||||
"month",
|
||||
"start_date",
|
||||
"end_date",
|
||||
}
|
||||
unknown = set(data) - allowed
|
||||
if unknown:
|
||||
raise RuleValidationError(
|
||||
f"unknown rule fields: {sorted(unknown)}"
|
||||
)
|
||||
missing = {"rule_type"} - set(data)
|
||||
if missing:
|
||||
raise RuleValidationError(
|
||||
f"missing required rule fields: {sorted(missing)}"
|
||||
)
|
||||
weekday_set = data.get("weekday_set")
|
||||
if isinstance(weekday_set, list):
|
||||
weekday_set = set(weekday_set)
|
||||
return cls(
|
||||
rule_type=data["rule_type"],
|
||||
interval=data.get("interval", 1),
|
||||
weekday_set=weekday_set,
|
||||
day_of_month=data.get("day_of_month"),
|
||||
nth_weekday=data.get("nth_weekday"),
|
||||
nth_weekday_weekday=data.get("nth_weekday_weekday"),
|
||||
month=data.get("month"),
|
||||
start_date=data.get("start_date", "1970-01-01"),
|
||||
end_date=data.get("end_date"),
|
||||
)
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
if not isinstance(other, ScheduleRule):
|
||||
return NotImplemented
|
||||
return all(
|
||||
getattr(self, slot) == getattr(other, slot)
|
||||
for slot in self.__slots__
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(
|
||||
tuple(
|
||||
(
|
||||
frozenset(getattr(self, slot))
|
||||
if isinstance(getattr(self, slot), frozenset)
|
||||
else getattr(self, slot)
|
||||
)
|
||||
for slot in self.__slots__
|
||||
)
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
fields = ", ".join(
|
||||
f"{slot}={getattr(self, slot)!r}" for slot in self.__slots__
|
||||
)
|
||||
return f"ScheduleRule({fields})"
|
||||
Reference in New Issue
Block a user