Apply PR #144 review fixes: per-field settings fallback, strict validation, hardened AST scan (task 4dd8f870)

P2-1: replace the all-or-nothing from_options try/except in setup with
from_options_resilient, which falls back PER FIELD (logging a warning
naming the bad field) so a malformed sibling (e.g. morning_summary_time
'25:00') cannot silently reset horizon_days/day_rollover_time.
P2-2: drop _run_horizon_materialization's legacy horizon_days keyword and
branch; default settings to NestQuestSettings(); remove the legacy paragraph.
P2-3: _run_horizon_materialization now calls settings.horizon_window(today)
so the helper is real and shared; extend tests to run the integration path
(_run_horizon_materialization + _register_day_rollover_listener) with
horizon_days=3 / day_rollover_time='03:30'.
P2-4: harden the AST scan to forbid hass/config_entry names, .hass/.entry/
.config_entry and .config/.options/.data attributes, and getattr/hasattr
calls with those string args; add a negative-control test for each shape.
P2-5: __post_init__ is strict (rejects None for every field); the
None->default mapping lives ONLY in from_options via _option_value.
P3: remove dead shims (_day_rollover_hour_minute, _configured_horizon_days)
and repoint test_lifecycle at from_options; de-dup the two HH:MM
validators into one using a shared TIME_PATTERN in core/const; make
horizon_window's today required; reword the notify_target docstring to
not claim options-flow parity; document _option_value's None-as-absent
semantics; remove unused test helpers.
This commit is contained in:
2026-09-21 15:37:12 -04:00
parent 345bd200e1
commit 33102f8806
6 changed files with 718 additions and 311 deletions
+15 -56
View File
@@ -19,7 +19,6 @@ from homeassistant.helpers.event import async_track_time_change
from .const import (
CONF_ADMIN_USER_IDS,
CONF_UPDATE_INTERVAL,
DEFAULT_HORIZON_DAYS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
LOGGER,
@@ -268,43 +267,10 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
return owners
def _day_rollover_hour_minute(options: dict[str, Any]) -> tuple[int, int]:
"""Return the (hour, minute) the daily rollover fires at from ``options``.
Thin shim over :class:`NestQuestSettings`: the options flow validates
``day_rollover_time`` as a strict ``HH:MM`` string, so a stored value
never needs re-validation here; a missing key falls back to
:data:`~.const.DEFAULT_DAY_ROLLOVER_TIME`.
"""
return NestQuestSettings.from_options(options).day_rollover_hour_minute
def _configured_horizon_days(options: dict[str, Any]) -> int:
"""Return the configured generation horizon, validated, else the default.
Thin shim over :class:`NestQuestSettings`. The settings object's
``from_options`` re-checks ``horizon_days`` — rejecting bools,
non-ints, and sub-1 values, falling back to
:data:`~.const.DEFAULT_HORIZON_DAYS` — so a malformed option can
never shrink or explode the materialized horizon.
"""
try:
return NestQuestSettings.from_options(options).horizon_days
except ValueError:
# A malformed stored option (a bad time string) must not crash
# startup; the horizon itself was already validated above, so a
# ValueError here can only come from a sibling time field — fall
# back to the default horizon the same way the pre-settings path
# tolerated partial options.
return DEFAULT_HORIZON_DAYS
async def _run_horizon_materialization(
hass: HomeAssistant,
database: NestQuestDatabase,
settings: NestQuestSettings | None = None,
*,
horizon_days: int | None = None,
) -> None:
"""Materialize the rolling horizon ``[today, today + horizon_days]``.
@@ -315,24 +281,18 @@ async def _run_horizon_materialization(
This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic. Callers SHOULD pass a
:class:`NestQuestSettings` instance built from ``entry.options``; the
legacy ``horizon_days`` keyword is kept for callers that have not yet
been converted and overrides the settings value when supplied. When
neither is given the window sizes to
:data:`~.const.DEFAULT_HORIZON_DAYS`.
no duplicated materialization logic. ``settings`` carries the entry's
validated ``horizon_days``; when omitted a default
:class:`NestQuestSettings` is used (its horizon is the configured
default). The window itself comes from :meth:`settings.horizon_window`
so the helper is real and shared (not recomputed inline here).
"""
if settings is not None:
horizon = settings.horizon_days
else:
horizon = horizon_days if horizon_days is not None else DEFAULT_HORIZON_DAYS
if settings is None:
settings = NestQuestSettings()
time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date()
start_date = today.isoformat()
end_date = (
today + datetime.timedelta(days=horizon)
).isoformat()
await _materialize_run(database, start_date, end_date, today=today)
start, end = settings.horizon_window(today)
await _materialize_run(database, start.isoformat(), end.isoformat(), today=today)
def _register_day_rollover_listener(
@@ -507,13 +467,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Build the explicit settings object from entry.options ONCE: the
# horizon materialization and the daily-rollover listener both
# consume it, so core never reads HA config itself. A malformed
# stored option falls back to the defaults (the same way the
# pre-settings path tolerated a partial options dict) rather than
# crashing setup.
try:
settings = NestQuestSettings.from_options(entry.options)
except ValueError:
settings = NestQuestSettings()
# stored option falls back PER FIELD (via from_options_resilient)
# so a single bad sibling option cannot silently reset the user's
# horizon_days or day_rollover_time — the two fields core consumes
# — rather than the all-or-nothing reset a single try/except would
# cause (a malformed morning_summary_time used to wipe horizon_days).
settings = NestQuestSettings.from_options_resilient(entry.options)
remove_update_listener = entry.add_update_listener(_async_update_listener)
remove_time_change_listener = _register_day_rollover_listener(
hass, database, settings