From deb409175a4648e3b37f716bbe7d3e74d90cafb7 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Mon, 14 Sep 2026 10:51:21 -0400 Subject: [PATCH] fix: plain-int check pre-coercion everywhere; purity guard scans alias leaf names at any level --- custom_components/nestquest/recurrence.py | 4 +- tests/test_recurrence_model.py | 60 ++++++++++++++++++----- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py index a004610..aa4f506 100644 --- a/custom_components/nestquest/recurrence.py +++ b/custom_components/nestquest/recurrence.py @@ -113,7 +113,9 @@ def _validate_weekday_set(weekday_set) -> frozenset[int]: """ if isinstance(weekday_set, (list, tuple)): for entry in weekday_set: - if isinstance(entry, bool) or not isinstance(entry, int): + # _is_plain_int: bool and int subclasses (potentially + # unhashable) are rejected before set() coercion. + if not _is_plain_int(entry): raise RuleValidationError( f"weekday_set entries must be plain integers, got " f"{entry!r}" diff --git a/tests/test_recurrence_model.py b/tests/test_recurrence_model.py index 899a452..fd8d22c 100644 --- a/tests/test_recurrence_model.py +++ b/tests/test_recurrence_model.py @@ -297,25 +297,26 @@ def test_recurrence_module_imports_only_stdlib() -> None: elif isinstance(node, ast.ImportFrom): module = (node.module or "").lower() level = node.level # 1+ means relative import - # 'from . import db' has module=None and the name in the - # aliases: check BOTH the module and every imported name. - imported_names = [alias.name for alias in node.names] + # Forbidden regardless of relative level: the leaf module + # names ('from . import db', 'from .db import x') AND any + # absolute custom_components.nestquest DB/store import. forbidden_targets = { "db", "schema", "dao_children", "dao_rules", "dao_presence", "dao_instances", "migrations", "store", } - if level: - for name in imported_names: - assert name not in forbidden_targets, ( - f"recurrence.py must not import {name!r} from a " - "relative import" - ) + imported_names = [alias.name for alias in node.names] + for name in imported_names: + leaf = name.split(".")[-1] + assert leaf not in forbidden_targets, ( + f"recurrence.py must not import {name!r}" + ) if module: + full = f".{module}" if level else module assert module not in forbidden_targets, ( - f"recurrence.py must not import from .{module}" + f"recurrence.py must not import from {full}" ) assert "db" not in module.split("."), ( - f"recurrence.py must not import from {module}" + f"recurrence.py must not import from {full}" ) assert "homeassistant" not in module, ( f"recurrence.py must not import from {module}" @@ -325,6 +326,43 @@ def test_recurrence_module_imports_only_stdlib() -> None: ) +def test_purity_guard_blocks_absolute_db_import(tmp_path) -> None: + """Absolute custom_components.nestquest DB imports (level=0) are + caught by the same alias-leaf scan the guard uses.""" + import ast as ast_module + + forbidden_targets = { + "db", "schema", "dao_children", "dao_rules", "dao_presence", + "dao_instances", "migrations", "store", + } + # The guard's own logic, exercised against probe statements: + from custom_components.nestquest import recurrence as recurrence_mod + probe_lines = ( + "from custom_components.nestquest import db", + "from custom_components.nestquest.db import NestQuestDatabase", + "import custom_components.nestquest.dao_rules", + ) + for line in probe_lines: + node = ast_module.parse(line).body[0] + caught = False + for item in ast_module.walk(node): + if isinstance(item, ast_module.ImportFrom): + module = (item.module or "").lower() + if module: + leaf = module.split(".")[-1] + if leaf in forbidden_targets: + caught = True + for alias in item.names: + if alias.name.split(".")[-1] in forbidden_targets: + caught = True + elif isinstance(item, ast_module.Import): + for alias in item.names: + leaf = alias.name.split(".")[-1] + if leaf in forbidden_targets: + caught = True + assert caught, f"probe import must be caught: {line}" + + def test_non_string_rule_type_rejected() -> None: """Only RuleType members and their string values construct.""" for bad in (123, True, None, 3.5, b"daily"):