Apply PR #144 review round 2: settings-based service kwargs, required settings, shim removal, hardened AST scan

P2: services._generation_kwargs now reads runtime.settings.horizon_days
instead of re-implementing the bool/non-int/<1 fallback against
runtime.options; drop the now-unused CONF_HORIZON_DAYS/DEFAULT_HORIZON_DAYS
imports. Remove NestQuestRuntimeData.options (kept only for that read; the
admin-id path reads entry.options directly) and the matching test assertion.

P3: _run_horizon_materialization's settings param is now REQUIRED (drop the
'| None = None' default + the NestQuestSettings() substitution branch);
update the test_timezones callers to pass NestQuestSettings() explicitly.

P3: __post_init__ normalises notify_target via _resolve_optional_str (strip)
so a hand-built NestQuestSettings(notify_target=' x ') behaves like the
options flow; keep the docstring accurate.

P3: delete the custom_components/nestquest/settings.py shim (settings is a
new module with no legacy import path) and import NestQuestSettings from
.core.settings directly in __init__.py, in alphabetical position.

P3: harden the AST scan for importlib.import_module('homeassistant...')
(string-based import) and call keyword names like hass= (ast.keyword);
add negative-control cases for each; explain in the failure message why
.config/.options/.data are banned (HA config-entry attributes with no
legitimate core use).

P3: drive from_options and from_options_resilient from one _FIELDS table of
(field, key, default, validator) to avoid three-way drift.

uv run pytest -q: 1573 passed.
This commit is contained in:
2026-09-21 15:42:26 -04:00
parent 33102f8806
commit d75ddc19bd
7 changed files with 221 additions and 176 deletions
+10 -12
View File
@@ -24,7 +24,7 @@ from .const import (
LOGGER, LOGGER,
PLATFORMS, PLATFORMS,
) )
from .settings import NestQuestSettings from .core.settings import NestQuestSettings
from .frontend import async_register_frontend from .frontend import async_register_frontend
from .coordinator import NestQuestCoordinator from .coordinator import NestQuestCoordinator
from .db import NestQuestDatabase, make_database from .db import NestQuestDatabase, make_database
@@ -237,7 +237,6 @@ class NestQuestRuntimeData:
"""Runtime data stored on a NestQuest config entry.""" """Runtime data stored on a NestQuest config entry."""
entry_id: str entry_id: str
options: dict[str, Any]
settings: NestQuestSettings settings: NestQuestSettings
database: NestQuestDatabase database: NestQuestDatabase
remove_update_listener: Callable[[], Any] remove_update_listener: Callable[[], Any]
@@ -270,7 +269,7 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
async def _run_horizon_materialization( async def _run_horizon_materialization(
hass: HomeAssistant, hass: HomeAssistant,
database: NestQuestDatabase, database: NestQuestDatabase,
settings: NestQuestSettings | None = None, settings: NestQuestSettings,
) -> None: ) -> None:
"""Materialize the rolling horizon ``[today, today + horizon_days]``. """Materialize the rolling horizon ``[today, today + horizon_days]``.
@@ -281,14 +280,12 @@ async def _run_horizon_materialization(
This is the ONE generation path: the startup backfill, the daily rollover This is the ONE generation path: the startup backfill, the daily rollover
listener and the ``regenerate`` service all funnel through it, so there is listener and the ``regenerate`` service all funnel through it, so there is
no duplicated materialization logic. ``settings`` carries the entry's no duplicated materialization logic. ``settings`` (REQUIRED — there is no
validated ``horizon_days``; when omitted a default silent default; every caller already passes the entry's validated
:class:`NestQuestSettings` is used (its horizon is the configured :class:`NestQuestSettings`) carries the configured ``horizon_days``. The
default). The window itself comes from :meth:`settings.horizon_window` window itself comes from :meth:`settings.horizon_window` so the helper is
so the helper is real and shared (not recomputed inline here). real and shared (not recomputed inline here).
""" """
if settings is None:
settings = NestQuestSettings()
time_zone = ZoneInfo(hass.config.time_zone) time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date() today = datetime.datetime.now(time_zone).date()
start, end = settings.horizon_window(today) start, end = settings.horizon_window(today)
@@ -329,7 +326,9 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None:
their own; they resolve whichever config entry's runtime data is their own; they resolve whichever config entry's runtime data is
currently live at call time, so none of them can hold a stale handle currently live at call time, so none of them can hold a stale handle
to a closed connection after another entry unloads. The record also to a closed connection after another entry unloads. The record also
carries that entry's options (e.g. the configured horizon). carries that entry's validated ``settings`` (e.g. the configured
horizon) so service handlers read horizon days off it rather than
re-reading HA config.
""" """
for runtime_data in hass.data.get(DOMAIN, {}).values(): for runtime_data in hass.data.get(DOMAIN, {}).values():
database = getattr(runtime_data, "database", None) database = getattr(runtime_data, "database", None)
@@ -505,7 +504,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
runtime_data = NestQuestRuntimeData( runtime_data = NestQuestRuntimeData(
entry_id=entry.entry_id, entry_id=entry.entry_id,
options=dict(entry.options),
settings=settings, settings=settings,
database=database, database=database,
remove_update_listener=remove_update_listener, remove_update_listener=remove_update_listener,
+87 -133
View File
@@ -142,13 +142,81 @@ def _resolve_optional_str(value: Any) -> str:
padded value (e.g. one migrated from an older schema) by normalizing padded value (e.g. one migrated from an older schema) by normalizing
it rather than crashing setup. The ``None``-as-absent mapping lives it rather than crashing setup. The ``None``-as-absent mapping lives
ONLY in :meth:`NestQuestSettings.from_options` (via ONLY in :meth:`NestQuestSettings.from_options` (via
:func:`_option_value`). :func:`_option_value`). :meth:`NestQuestSettings.__post_init__`
applies this SAME normalizer so a hand-built
``NestQuestSettings(notify_target=' x ')`` is stripped to ``'x'``
exactly like the options-flow path (no padded value can slip through
direct construction either).
""" """
if not isinstance(value, str): if not isinstance(value, str):
raise ValueError(f"notify_target must be a string, got {value!r}") raise ValueError(f"notify_target must be a string, got {value!r}")
return value.strip() return value.strip()
#: The single field table the two builders share. Each row is
#: ``(field_name, CONF_* key, default, validator)``. ``from_options``
#: applies every validator (raising on the first invalid value) while
#: ``from_options_resilient`` wraps each in its own try/except so a single
#: bad sibling falls back to its default alone. Driving both builders
#: from this table avoids three-way drift between ``__post_init__``'s
#: inline checks and the two option builders' field lists: the only
#: places that enumerate the fields are this table and
#: ``__post_init__`` (which validates a hand-built instance without the
#: None->default mapping, so it stays inline).
_FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
("horizon_days", CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, _resolve_horizon_days),
(
"day_rollover_time",
CONF_DAY_ROLLOVER_TIME,
DEFAULT_DAY_ROLLOVER_TIME,
lambda v: _validate_hhmm(v, field="day_rollover_time"),
),
("notify_target", CONF_NOTIFY_TARGET, "", _resolve_optional_str),
(
"morning_summary_time",
CONF_MORNING_SUMMARY_TIME,
DEFAULT_MORNING_SUMMARY_TIME,
lambda v: _validate_hhmm(v, field="morning_summary_time"),
),
(
"afternoon_reminder_time",
CONF_AFTERNOON_REMINDER_TIME,
DEFAULT_AFTERNOON_REMINDER_TIME,
lambda v: _validate_hhmm(v, field="afternoon_reminder_time"),
),
(
"end_of_day_report_time",
CONF_END_OF_DAY_REPORT_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
lambda v: _validate_hhmm(v, field="end_of_day_report_time"),
),
(
"morning_summary_enabled",
CONF_MORNING_SUMMARY_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
(
"afternoon_reminder_enabled",
CONF_AFTERNOON_REMINDER_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
(
"end_of_day_report_enabled",
CONF_END_OF_DAY_REPORT_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
(
"celebration_enabled",
CONF_CELEBRATION_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
),
)
@dataclass(frozen=True) @dataclass(frozen=True)
class NestQuestSettings: class NestQuestSettings:
"""Explicit, HA-free configuration carried into core paths. """Explicit, HA-free configuration carried into core paths.
@@ -181,14 +249,20 @@ class NestQuestSettings:
# cannot carry an invalid value: the same rules from_options # cannot carry an invalid value: the same rules from_options
# applies, but WITHOUT the None->default mapping (that lives # applies, but WITHOUT the None->default mapping (that lives
# only in from_options via _option_value). A None horizon, time, # only in from_options via _option_value). A None horizon, time,
# toggle, or notify target raises here rather than later blowing # or toggle raises here rather than later blowing up
# up horizon_window or the rollover listener. # horizon_window or the rollover listener.
_resolve_horizon_days(self.horizon_days) _resolve_horizon_days(self.horizon_days)
_validate_hhmm(self.day_rollover_time, field="day_rollover_time") _validate_hhmm(self.day_rollover_time, field="day_rollover_time")
if not isinstance(self.notify_target, str): # ``notify_target`` is normalised the SAME way as from_options
raise ValueError( # (via _resolve_optional_str): a non-str is rejected and a
f"notify_target must be a string, got {self.notify_target!r}" # padded/whitespace-only value is STRIPPED to its trim. This
) # keeps a hand-built ``NestQuestSettings(notify_target=' x ')``
# behaving like the options flow (which strips on persist) so a
# padded value cannot slip through direct construction either.
# The dataclass is frozen, so the stripped value is written via
# object.__setattr__.
stripped_notify_target = _resolve_optional_str(self.notify_target)
object.__setattr__(self, "notify_target", stripped_notify_target)
_validate_hhmm(self.morning_summary_time, field="morning_summary_time") _validate_hhmm(self.morning_summary_time, field="morning_summary_time")
_validate_hhmm( _validate_hhmm(
self.afternoon_reminder_time, field="afternoon_reminder_time" self.afternoon_reminder_time, field="afternoon_reminder_time"
@@ -259,66 +333,10 @@ class NestQuestSettings:
MappingProxyType({}) if options is None else MappingProxyType(options) MappingProxyType({}) if options is None else MappingProxyType(options)
) )
return cls( return cls(
horizon_days=_resolve_horizon_days( **{
_option_value(opts, CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS) field: validator(_option_value(opts, key, default))
), for field, key, default, validator in _FIELDS
day_rollover_time=_validate_hhmm( }
_option_value(
opts, CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME
),
field="day_rollover_time",
),
notify_target=_resolve_optional_str(
_option_value(opts, CONF_NOTIFY_TARGET, "")
),
morning_summary_time=_validate_hhmm(
_option_value(
opts,
CONF_MORNING_SUMMARY_TIME,
DEFAULT_MORNING_SUMMARY_TIME,
),
field="morning_summary_time",
),
afternoon_reminder_time=_validate_hhmm(
_option_value(
opts,
CONF_AFTERNOON_REMINDER_TIME,
DEFAULT_AFTERNOON_REMINDER_TIME,
),
field="afternoon_reminder_time",
),
end_of_day_report_time=_validate_hhmm(
_option_value(
opts,
CONF_END_OF_DAY_REPORT_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
),
field="end_of_day_report_time",
),
morning_summary_enabled=_resolve_bool(
_option_value(
opts, CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED
)
),
afternoon_reminder_enabled=_resolve_bool(
_option_value(
opts,
CONF_AFTERNOON_REMINDER_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
)
),
end_of_day_report_enabled=_resolve_bool(
_option_value(
opts,
CONF_END_OF_DAY_REPORT_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
)
),
celebration_enabled=_resolve_bool(
_option_value(
opts, CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED
)
),
) )
@classmethod @classmethod
@@ -345,13 +363,10 @@ class NestQuestSettings:
MappingProxyType({}) if options is None else MappingProxyType(options) MappingProxyType({}) if options is None else MappingProxyType(options)
) )
resolved: dict[str, Any] = {} resolved: dict[str, Any] = {}
for field, key, default, validator in _FIELDS:
def _resolve(
field: str, key: str, default: Any, fn: Callable[[Any], Any]
) -> None:
raw = _option_value(opts, key, default) raw = _option_value(opts, key, default)
try: try:
resolved[field] = fn(raw) resolved[field] = validator(raw)
except ValueError as err: except ValueError as err:
LOGGER.warning( LOGGER.warning(
"NestQuest option %s is invalid (%s); falling back " "NestQuest option %s is invalid (%s); falling back "
@@ -361,65 +376,4 @@ class NestQuestSettings:
default, default,
) )
resolved[field] = default resolved[field] = default
_resolve(
"horizon_days",
CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS,
_resolve_horizon_days,
)
_resolve(
"day_rollover_time",
CONF_DAY_ROLLOVER_TIME,
DEFAULT_DAY_ROLLOVER_TIME,
lambda v: _validate_hhmm(v, field="day_rollover_time"),
)
_resolve(
"notify_target",
CONF_NOTIFY_TARGET,
"",
_resolve_optional_str,
)
_resolve(
"morning_summary_time",
CONF_MORNING_SUMMARY_TIME,
DEFAULT_MORNING_SUMMARY_TIME,
lambda v: _validate_hhmm(v, field="morning_summary_time"),
)
_resolve(
"afternoon_reminder_time",
CONF_AFTERNOON_REMINDER_TIME,
DEFAULT_AFTERNOON_REMINDER_TIME,
lambda v: _validate_hhmm(v, field="afternoon_reminder_time"),
)
_resolve(
"end_of_day_report_time",
CONF_END_OF_DAY_REPORT_TIME,
DEFAULT_END_OF_DAY_REPORT_TIME,
lambda v: _validate_hhmm(v, field="end_of_day_report_time"),
)
_resolve(
"morning_summary_enabled",
CONF_MORNING_SUMMARY_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
)
_resolve(
"afternoon_reminder_enabled",
CONF_AFTERNOON_REMINDER_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
)
_resolve(
"end_of_day_report_enabled",
CONF_END_OF_DAY_REPORT_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
)
_resolve(
"celebration_enabled",
CONF_CELEBRATION_ENABLED,
DEFAULT_AUTOMATION_ENABLED,
_resolve_bool,
)
return cls(**resolved) return cls(**resolved)
+1 -8
View File
@@ -24,8 +24,6 @@ from . import children as children_layer
from . import completion as completion_layer from . import completion as completion_layer
from . import quest_definitions as quest_definitions_layer from . import quest_definitions as quest_definitions_layer
from .const import ( from .const import (
CONF_HORIZON_DAYS,
DEFAULT_HORIZON_DAYS,
DOMAIN, DOMAIN,
DOMAIN_SERVICES, DOMAIN_SERVICES,
QUEST_WINDOWS, QUEST_WINDOWS,
@@ -258,12 +256,7 @@ async def _refresh_entities(runtime: Any) -> None:
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]: def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
time_zone = ZoneInfo(hass.config.time_zone) time_zone = ZoneInfo(hass.config.time_zone)
today = datetime.datetime.now(time_zone).date() today = datetime.datetime.now(time_zone).date()
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS) return {"today": today, "horizon_days": runtime.settings.horizon_days}
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
horizon_days = DEFAULT_HORIZON_DAYS
else:
horizon_days = value
return {"today": today, "horizon_days": horizon_days}
def _actor_kwargs(call: Any) -> dict[str, Any]: def _actor_kwargs(call: Any) -> dict[str, Any]:
-13
View File
@@ -1,13 +0,0 @@
"""Re-export of :mod:`custom_components.nestquest.core.settings` for the integration package.
The explicit, Home-Assistant-free settings object lives in
:mod:`custom_components.nestquest.core.settings` (introduced in task
4dd8f870); this module re-exports it so the integration constructs the
settings object from ``entry.options`` and threads it into the core
paths without core ever reading HA config itself.
"""
from __future__ import annotations
from .core.settings import NestQuestSettings
__all__ = ["NestQuestSettings"]
+107 -3
View File
@@ -353,6 +353,19 @@ def test_direct_construction_rejects_non_int_horizon(value: Any) -> None:
def test_direct_construction_strips_notify_target() -> None:
"""__post_init__ normalises notify_target the SAME way from_options does.
P3: a hand-built ``NestQuestSettings(notify_target=' x ')`` is
stripped to ``'x'`` (and a whitespace-only value to ``''``) so direct
construction behaves like the options flow; a padded value cannot
slip through __post_init__ either.
"""
assert NestQuestSettings(notify_target=" notify.x ").notify_target == "notify.x"
assert NestQuestSettings(notify_target=" ").notify_target == ""
assert NestQuestSettings(notify_target="notify.x").notify_target == "notify.x"
def test_settings_is_frozen() -> None: def test_settings_is_frozen() -> None:
"""The dataclass is frozen so a stale settings object cannot be mutated.""" """The dataclass is frozen so a stale settings object cannot be mutated."""
s = NestQuestSettings() s = NestQuestSettings()
@@ -440,6 +453,22 @@ _CONFIG_ATTRS = frozenset({"config", "options", "data"})
#: Dynamic config-entry reads via getattr/hasattr with a string argument. #: Dynamic config-entry reads via getattr/hasattr with a string argument.
_GETATTR_FUNCS = frozenset({"getattr", "hasattr"}) _GETATTR_FUNCS = frozenset({"getattr", "hasattr"})
#: The dynamic-import function the string-based import bypass uses.
#: ``importlib.import_module("homeassistant...")`` reaches the HA package
#: without an ``import homeassistant`` statement the static import check
#: sees, so the scanner must recognise the call shape too.
_IMPORT_MODULE_FUNCS = frozenset({"import_module"})
def _is_import_module_call(node: ast.Call) -> bool:
"""True for ``importlib.import_module(...)`` or a bare ``import_module(...)``."""
func = node.func
if isinstance(func, ast.Attribute):
return func.attr in _IMPORT_MODULE_FUNCS
if isinstance(func, ast.Name):
return func.id in _IMPORT_MODULE_FUNCS
return False
def _scan_source_for_ha_config( def _scan_source_for_ha_config(
source: str, *, name: str = "<snippet>" source: str, *, name: str = "<snippet>"
@@ -450,14 +479,24 @@ def _scan_source_for_ha_config(
chain check missed): chain check missed):
- any ``import homeassistant`` / ``from homeassistant ...``; - any ``import homeassistant`` / ``from homeassistant ...``;
- any ``importlib.import_module('homeassistant...')`` (a string-based
import that reaches HA without an ``import homeassistant`` statement
the static import check can see);
- any ``Name``/``arg`` whose id is ``hass`` or ``config_entry`` - any ``Name``/``arg`` whose id is ``hass`` or ``config_entry``
(HA-specific identifiers with no legitimate core use); (HA-specific identifiers with no legitimate core use);
- any call keyword whose name is ``hass`` or ``config_entry``
(catches ``foo(hass=...)`` / ``handler(config_entry=...)`` — the
bare-Name check misses keyword arguments);
- any attribute access whose ``attr`` is ``hass``/``entry``/ - any attribute access whose ``attr`` is ``hass``/``entry``/
``config_entry`` (catches ``self.hass``, ``self.entry``, ``config_entry`` (catches ``self.hass``, ``self.entry``,
``obj.config_entry`` — root names the old check could not see); ``obj.config_entry`` — root names the old check could not see);
- any attribute access whose ``attr`` is ``config``/``options``/ - any attribute access whose ``attr`` is ``config``/``options``/
``data`` (catches ``hass.config``, ``config_entry.options``, ``data`` — these are Home Assistant config-entry attributes with no
``self.entry.options``, ``runtime.options`` regardless of root); legitimate core use (``.config`` reads ``hass.config``, ``.options``
reads ``config_entry.options``, ``.data`` reads ``config_entry.data``);
flagging the attribute ANYWHERE (regardless of root) is what catches
``runtime.options``, which the old root-only check missed. No core
module currently reads any of these attributes;
- any ``getattr``/``hasattr`` call whose string argument is - any ``getattr``/``hasattr`` call whose string argument is
``'config'``/``'options'``/``'data'`` (catches the dynamic read ``'config'``/``'options'``/``'data'`` (catches the dynamic read
``getattr(hass, 'config')`` the static attribute check cannot see). ``getattr(hass, 'config')`` the static attribute check cannot see).
@@ -501,6 +540,16 @@ def _scan_source_for_ha_config(
offenders.append( offenders.append(
f"{name}:{node.lineno} forbidden parameter: {node.arg!r}" f"{name}:{node.lineno} forbidden parameter: {node.arg!r}"
) )
elif isinstance(node, ast.keyword):
# A call keyword like ``foo(hass=...)``: the bare-Name check
# sees the value, not the keyword's own name, so a HA object
# could be passed by keyword without tripping it. ``arg`` is
# ``None`` for ``**kwargs`` unpacking, which is not a name.
if node.arg is not None and node.arg in _FORBIDDEN_NAMES:
offenders.append(
f"{name}:{node.lineno} forbidden keyword argument: "
f"{node.arg!r}"
)
elif isinstance(node, ast.Attribute): elif isinstance(node, ast.Attribute):
if node.attr in _FORBIDDEN_ATTRS: if node.attr in _FORBIDDEN_ATTRS:
offenders.append( offenders.append(
@@ -512,6 +561,19 @@ def _scan_source_for_ha_config(
f".{node.attr}" f".{node.attr}"
) )
elif isinstance(node, ast.Call): elif isinstance(node, ast.Call):
if _is_import_module_call(node):
# The first positional argument is the module string; a
# constant ``'homeassistant...'`` is the bypass shape.
for arg in node.args:
if (
isinstance(arg, ast.Constant)
and isinstance(arg.value, str)
and _is_homeassistant(arg.value)
):
offenders.append(
f"{name}:{node.lineno} dynamic homeassistant "
f"import: import_module({arg.value!r})"
)
func = node.func func = node.func
if isinstance(func, ast.Name) and func.id in _GETATTR_FUNCS: if isinstance(func, ast.Name) and func.id in _GETATTR_FUNCS:
for arg in node.args: for arg in node.args:
@@ -543,7 +605,13 @@ def test_core_modules_have_no_ha_config_reads() -> None:
config-entry attribute reads ``.config``/``.options``/``.data``, and config-entry attribute reads ``.config``/``.options``/``.data``, and
dynamic ``getattr``/``hasattr`` reads of them — so a chain rooted at dynamic ``getattr``/``hasattr`` reads of them — so a chain rooted at
``self`` or ``runtime`` (which the old root-only check missed) is now ``self`` or ``runtime`` (which the old root-only check missed) is now
caught. Docstring mentions pass: AST only sees code. caught. ``.config``/``.options``/``.data`` are banned because they are
Home Assistant config-entry attributes with no legitimate core use:
core receives the resolved values via the explicit
:class:`NestQuestSettings` object the integration builds from
``entry.options``, so any read of those attributes in ``core/`` is a
config-entry coupling the settings object exists to remove.
Docstring mentions pass: AST only sees code.
""" """
py_files = sorted(CORE_PKG.rglob("*.py")) py_files = sorted(CORE_PKG.rglob("*.py"))
assert py_files, "core package not found" assert py_files, "core package not found"
@@ -570,12 +638,48 @@ def test_ast_scan_flags_each_bypass_shape() -> None:
"getattr(hass,'config')": "x = getattr(hass, 'config')\n", "getattr(hass,'config')": "x = getattr(hass, 'config')\n",
"runtime.options": "x = runtime.options\n", "runtime.options": "x = runtime.options\n",
"hasattr(obj,'data')": "x = hasattr(obj, 'data')\n", "hasattr(obj,'data')": "x = hasattr(obj, 'data')\n",
"importlib.import_module('homeassistant')": (
"import importlib\nimportlib.import_module('homeassistant')\n"
),
"import_module('homeassistant.helpers')": (
"from importlib import import_module\n"
"import_module('homeassistant.helpers')\n"
),
"handler(hass=...)": "handler(hass=obj)\n",
"handler(config_entry=...)": "handler(config_entry=obj)\n",
} }
for label, src in snippets.items(): for label, src in snippets.items():
offenders = _scan_source_for_ha_config(src, name=label) offenders = _scan_source_for_ha_config(src, name=label)
assert offenders, f"scanner missed {label!r}: {src!r}" assert offenders, f"scanner missed {label!r}: {src!r}"
def test_ast_scan_allows_non_homeassistant_import_module() -> None:
"""A dynamic import of a NON-homeassistant module is not flagged.
``importlib.import_module`` is a legitimate way to load a non-HA
module lazily; only a ``homeassistant...`` target is the bypass shape.
"""
src = (
"import importlib\n"
"m = importlib.import_module('custom_components.nestquest.core')\n"
)
assert _scan_source_for_ha_config(src, name="non-ha-import") == []
def test_ast_scan_allows_non_forbidden_keyword_arguments() -> None:
"""A call with non-forbidden keyword arguments is not flagged.
Only ``hass=``/``config_entry=`` are forbidden as keyword names; an
unrelated keyword like ``hass_data=`` or ``entry_id=`` must not
false-positive (and ``**kwargs`` unpacking has ``arg=None``).
"""
src = (
"foo(entry_id=1, name='x')\n"
"bar(**kwargs)\n"
)
assert _scan_source_for_ha_config(src, name="non-forbidden-kwargs") == []
def test_ast_scan_allows_legitimate_entry_loop_var() -> None: def test_ast_scan_allows_legitimate_entry_loop_var() -> None:
"""A bare ``entry`` loop variable (a domain concept) is NOT flagged. """A bare ``entry`` loop variable (a domain concept) is NOT flagged.
+3 -3
View File
@@ -36,12 +36,12 @@ async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
async def test_setup_runtime_data_has_entry_id_and_options(hass, make_entry) -> None: async def test_setup_runtime_data_has_entry_id_and_settings(hass, make_entry) -> None:
entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry) entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry)
await async_setup_entry(hass, entry) await async_setup_entry(hass, entry)
runtime = entry.runtime_data runtime = entry.runtime_data
assert runtime.entry_id == entry.entry_id assert runtime.entry_id == entry.entry_id
assert runtime.options == {"horizon_days": 7} assert runtime.settings.horizon_days == 7
assert callable(runtime.remove_update_listener) assert callable(runtime.remove_update_listener)
@@ -534,7 +534,7 @@ def test_from_options_horizon_validates_and_defaults() -> None:
a malformed value RAISES (the integration's ``from_options_resilient`` a malformed value RAISES (the integration's ``from_options_resilient``
handles the per-field fall-back at setup, not this strict path). handles the per-field fall-back at setup, not this strict path).
""" """
from custom_components.nestquest.settings import NestQuestSettings from custom_components.nestquest.core.settings import NestQuestSettings
assert NestQuestSettings.from_options({CONF_HORIZON_DAYS: 5}).horizon_days == 5 assert NestQuestSettings.from_options({CONF_HORIZON_DAYS: 5}).horizon_days == 5
assert ( assert (
+13 -4
View File
@@ -14,6 +14,7 @@ from types import SimpleNamespace
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS
from custom_components.nestquest.core.settings import NestQuestSettings
from custom_components.nestquest.dao_children import ChildrenDao from custom_components.nestquest.dao_children import ChildrenDao
from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.dao_instances import QuestInstancesDao
from custom_components.nestquest.db import NestQuestDatabase from custom_components.nestquest.db import NestQuestDatabase
@@ -147,7 +148,9 @@ def test_horizon_uses_newyork_local_date_after_local_midnight(
import custom_components.nestquest as nestquest import custom_components.nestquest as nestquest
child_id = await _seed_daily(database, local_today.isoformat()) child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database) await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range( records = await QuestInstancesDao(database).list_by_date_range(
child_id, child_id,
local_today.isoformat(), local_today.isoformat(),
@@ -187,7 +190,9 @@ def test_horizon_uses_local_date_not_utc_when_host_ahead(
import custom_components.nestquest as nestquest import custom_components.nestquest as nestquest
child_id = await _seed_daily(database, local_today.isoformat()) child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database) await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range( records = await QuestInstancesDao(database).list_by_date_range(
child_id, child_id,
local_today.isoformat(), local_today.isoformat(),
@@ -231,7 +236,9 @@ def test_horizon_resolves_spring_forward_day_without_off_by_one(
requested_batches.append(requested) requested_batches.append(requested)
local_today = instant.date() local_today = instant.date()
child_id = await _seed_daily(database, local_today.isoformat()) child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database) await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range( records = await QuestInstancesDao(database).list_by_date_range(
child_id, child_id,
local_today.isoformat(), local_today.isoformat(),
@@ -280,7 +287,9 @@ def test_horizon_resolves_fall_back_day_without_off_by_one(
requested_batches.append(requested) requested_batches.append(requested)
local_today = instant.date() local_today = instant.date()
child_id = await _seed_daily(database, local_today.isoformat()) child_id = await _seed_daily(database, local_today.isoformat())
await nestquest._run_horizon_materialization(hass, database) await nestquest._run_horizon_materialization(
hass, database, NestQuestSettings()
)
records = await QuestInstancesDao(database).list_by_date_range( records = await QuestInstancesDao(database).list_by_date_range(
child_id, child_id,
local_today.isoformat(), local_today.isoformat(),