Replace HA config reads with an explicit settings object (task 4dd8f870) #144
@@ -24,7 +24,7 @@ from .const import (
|
||||
LOGGER,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .settings import NestQuestSettings
|
||||
from .core.settings import NestQuestSettings
|
||||
from .frontend import async_register_frontend
|
||||
from .coordinator import NestQuestCoordinator
|
||||
from .db import NestQuestDatabase, make_database
|
||||
@@ -237,7 +237,6 @@ class NestQuestRuntimeData:
|
||||
"""Runtime data stored on a NestQuest config entry."""
|
||||
|
||||
entry_id: str
|
||||
options: dict[str, Any]
|
||||
settings: NestQuestSettings
|
||||
database: NestQuestDatabase
|
||||
remove_update_listener: Callable[[], Any]
|
||||
@@ -270,7 +269,7 @@ async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]:
|
||||
async def _run_horizon_materialization(
|
||||
hass: HomeAssistant,
|
||||
database: NestQuestDatabase,
|
||||
settings: NestQuestSettings | None = None,
|
||||
settings: NestQuestSettings,
|
||||
) -> None:
|
||||
"""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
|
||||
listener and the ``regenerate`` service all funnel through it, so there is
|
||||
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).
|
||||
no duplicated materialization logic. ``settings`` (REQUIRED — there is no
|
||||
silent default; every caller already passes the entry's validated
|
||||
:class:`NestQuestSettings`) carries the configured ``horizon_days``. The
|
||||
window itself comes from :meth:`settings.horizon_window` so the helper is
|
||||
real and shared (not recomputed inline here).
|
||||
"""
|
||||
if settings is None:
|
||||
settings = NestQuestSettings()
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
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
|
||||
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
|
||||
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():
|
||||
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()
|
||||
runtime_data = NestQuestRuntimeData(
|
||||
entry_id=entry.entry_id,
|
||||
options=dict(entry.options),
|
||||
settings=settings,
|
||||
database=database,
|
||||
remove_update_listener=remove_update_listener,
|
||||
|
||||
@@ -142,13 +142,81 @@ def _resolve_optional_str(value: Any) -> str:
|
||||
padded value (e.g. one migrated from an older schema) by normalizing
|
||||
it rather than crashing setup. The ``None``-as-absent mapping lives
|
||||
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):
|
||||
raise ValueError(f"notify_target must be a string, got {value!r}")
|
||||
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)
|
||||
class NestQuestSettings:
|
||||
"""Explicit, HA-free configuration carried into core paths.
|
||||
@@ -181,14 +249,20 @@ class NestQuestSettings:
|
||||
# cannot carry an invalid value: the same rules from_options
|
||||
# applies, but WITHOUT the None->default mapping (that lives
|
||||
# only in from_options via _option_value). A None horizon, time,
|
||||
# toggle, or notify target raises here rather than later blowing
|
||||
# up horizon_window or the rollover listener.
|
||||
# or toggle raises here rather than later blowing up
|
||||
# horizon_window or the rollover listener.
|
||||
_resolve_horizon_days(self.horizon_days)
|
||||
_validate_hhmm(self.day_rollover_time, field="day_rollover_time")
|
||||
if not isinstance(self.notify_target, str):
|
||||
raise ValueError(
|
||||
f"notify_target must be a string, got {self.notify_target!r}"
|
||||
)
|
||||
# ``notify_target`` is normalised the SAME way as from_options
|
||||
# (via _resolve_optional_str): a non-str is rejected and a
|
||||
# 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.afternoon_reminder_time, field="afternoon_reminder_time"
|
||||
@@ -259,66 +333,10 @@ class NestQuestSettings:
|
||||
MappingProxyType({}) if options is None else MappingProxyType(options)
|
||||
)
|
||||
return cls(
|
||||
horizon_days=_resolve_horizon_days(
|
||||
_option_value(opts, CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS)
|
||||
),
|
||||
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
|
||||
)
|
||||
),
|
||||
**{
|
||||
field: validator(_option_value(opts, key, default))
|
||||
for field, key, default, validator in _FIELDS
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -345,13 +363,10 @@ class NestQuestSettings:
|
||||
MappingProxyType({}) if options is None else MappingProxyType(options)
|
||||
)
|
||||
resolved: dict[str, Any] = {}
|
||||
|
||||
def _resolve(
|
||||
field: str, key: str, default: Any, fn: Callable[[Any], Any]
|
||||
) -> None:
|
||||
for field, key, default, validator in _FIELDS:
|
||||
raw = _option_value(opts, key, default)
|
||||
try:
|
||||
resolved[field] = fn(raw)
|
||||
resolved[field] = validator(raw)
|
||||
except ValueError as err:
|
||||
LOGGER.warning(
|
||||
"NestQuest option %s is invalid (%s); falling back "
|
||||
@@ -361,65 +376,4 @@ class NestQuestSettings:
|
||||
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)
|
||||
|
||||
@@ -24,8 +24,6 @@ from . import children as children_layer
|
||||
from . import completion as completion_layer
|
||||
from . import quest_definitions as quest_definitions_layer
|
||||
from .const import (
|
||||
CONF_HORIZON_DAYS,
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DOMAIN,
|
||||
DOMAIN_SERVICES,
|
||||
QUEST_WINDOWS,
|
||||
@@ -258,12 +256,7 @@ async def _refresh_entities(runtime: Any) -> None:
|
||||
def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]:
|
||||
time_zone = ZoneInfo(hass.config.time_zone)
|
||||
today = datetime.datetime.now(time_zone).date()
|
||||
value = runtime.options.get(CONF_HORIZON_DAYS, DEFAULT_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}
|
||||
return {"today": today, "horizon_days": runtime.settings.horizon_days}
|
||||
|
||||
|
||||
def _actor_kwargs(call: Any) -> dict[str, Any]:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
"""The dataclass is frozen so a stale settings object cannot be mutated."""
|
||||
s = NestQuestSettings()
|
||||
@@ -440,6 +453,22 @@ _CONFIG_ATTRS = frozenset({"config", "options", "data"})
|
||||
#: Dynamic config-entry reads via getattr/hasattr with a string argument.
|
||||
_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(
|
||||
source: str, *, name: str = "<snippet>"
|
||||
@@ -450,14 +479,24 @@ def _scan_source_for_ha_config(
|
||||
chain check missed):
|
||||
|
||||
- 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``
|
||||
(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``/
|
||||
``config_entry`` (catches ``self.hass``, ``self.entry``,
|
||||
``obj.config_entry`` — root names the old check could not see);
|
||||
- any attribute access whose ``attr`` is ``config``/``options``/
|
||||
``data`` (catches ``hass.config``, ``config_entry.options``,
|
||||
``self.entry.options``, ``runtime.options`` regardless of root);
|
||||
``data`` — these are Home Assistant config-entry attributes with no
|
||||
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
|
||||
``'config'``/``'options'``/``'data'`` (catches the dynamic read
|
||||
``getattr(hass, 'config')`` the static attribute check cannot see).
|
||||
@@ -501,6 +540,16 @@ def _scan_source_for_ha_config(
|
||||
offenders.append(
|
||||
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):
|
||||
if node.attr in _FORBIDDEN_ATTRS:
|
||||
offenders.append(
|
||||
@@ -512,6 +561,19 @@ def _scan_source_for_ha_config(
|
||||
f".{node.attr}"
|
||||
)
|
||||
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
|
||||
if isinstance(func, ast.Name) and func.id in _GETATTR_FUNCS:
|
||||
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
|
||||
dynamic ``getattr``/``hasattr`` reads of them — so a chain rooted at
|
||||
``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"))
|
||||
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",
|
||||
"runtime.options": "x = runtime.options\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():
|
||||
offenders = _scan_source_for_ha_config(src, name=label)
|
||||
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:
|
||||
"""A bare ``entry`` loop variable (a domain concept) is NOT flagged.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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)
|
||||
await async_setup_entry(hass, entry)
|
||||
runtime = entry.runtime_data
|
||||
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)
|
||||
|
||||
|
||||
@@ -534,7 +534,7 @@ def test_from_options_horizon_validates_and_defaults() -> None:
|
||||
a malformed value RAISES (the integration's ``from_options_resilient``
|
||||
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 (
|
||||
|
||||
+13
-4
@@ -14,6 +14,7 @@ from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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_instances import QuestInstancesDao
|
||||
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
|
||||
|
||||
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(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -187,7 +190,9 @@ def test_horizon_uses_local_date_not_utc_when_host_ahead(
|
||||
import custom_components.nestquest as nestquest
|
||||
|
||||
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(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -231,7 +236,9 @@ def test_horizon_resolves_spring_forward_day_without_off_by_one(
|
||||
requested_batches.append(requested)
|
||||
local_today = instant.date()
|
||||
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(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
@@ -280,7 +287,9 @@ def test_horizon_resolves_fall_back_day_without_off_by_one(
|
||||
requested_batches.append(requested)
|
||||
local_today = instant.date()
|
||||
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(
|
||||
child_id,
|
||||
local_today.isoformat(),
|
||||
|
||||
Reference in New Issue
Block a user