diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 1ac6001..03ea899 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -63,17 +63,40 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Check the runner Python can host Home Assistant + - name: Provision the interpreter Home Assistant needs + uses: actions/setup-python@v5 + with: + # Home Assistant 2026.10.1 declares Requires-Python >= 3.14.2 (PyPI + # metadata; tests/ha_api_drift.py holds our copy of that number and the + # drift suite pins it against the installed distribution), so the + # runner's own python3 cannot install the version this gate must run + # against. This resolves the newest 3.14.x patch; the guard below + # refuses anything still short of the floor rather than letting the + # install fail halfway through. + python-version: "3.14" + + - name: Check the interpreter satisfies Home Assistant's floor run: | set -euo pipefail - # Home Assistant 2026.x requires Python >= 3.13; a newer runner just - # works. Fail loudly with the fix instead of a confusing resolver - # error when the image is older. - if ! python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 13) else 1)'; then - echo "Home Assistant needs Python >= 3.13 but this runner has $(python3 -V)." - echo "Add 'actions/setup-python@v5' with python-version '3.13' to this job." - exit 1 - fi + # The floor comes from tests/ha_api_drift.py — one source of truth for + # this guard and the metadata pin in tests/test_ha_api_drift.py. + python3 - <<'PY' + import sys + from pathlib import Path + + sys.path.insert(0, str(Path("tests").resolve())) + from ha_api_drift import HOMEASSISTANT_PYTHON_FLOOR as FLOOR # noqa: E402 + + needed = ".".join(str(part) for part in FLOOR) + if sys.version_info < FLOOR: + print( + f"Home Assistant needs Python >= {needed}, but this job runs " + f"{sys.version.split()[0]}." + ) + print(f"Fix: raise actions/setup-python in this job to a >= {needed} build.") + raise SystemExit(1) + print(f"interpreter ok: Python {sys.version.split()[0]} satisfies >= {needed}") + PY - name: Install Home Assistant + test tooling run: | diff --git a/tests/ha_api_drift.py b/tests/ha_api_drift.py index 75aae24..c163d6b 100644 --- a/tests/ha_api_drift.py +++ b/tests/ha_api_drift.py @@ -39,7 +39,17 @@ method our own class overrides is not resolved against HA. Those gaps are visible in the ``Report`` counters, which the pytest wrapper asserts on so the sweep cannot silently become vacuous. -Without homeassistant installed nothing can be resolved; the pytest wrapper +The standalone entry point (``python tests/ha_api_drift.py [path …]``) exits +non-zero on findings *and* on counters below ``FLOORS`` — an empty or +mis-targeted directory is a failure, never a green sweep. ``FLOORS`` is the one +source of truth for both that entry point and the pytest wrapper, and the +interpreter floor Home Assistant itself declares lives here too +(``HOMEASSISTANT_PYTHON_FLOOR``), where CI's guard reads it instead of repeating +the number. + +Without homeassistant installed the sweep cannot resolve anything: ``scan()`` +returns a single ``no-homeassistant`` finding, the counters stay at zero (so the +floors fail on purpose) and nothing is ever reported green. The pytest wrapper (``tests/test_ha_api_drift.py``) skips cleanly in that case, and CI runs one job with homeassistant installed for exactly this check. """ @@ -48,7 +58,9 @@ from __future__ import annotations import ast import importlib +import importlib.util import inspect +import re from dataclasses import dataclass, field from pathlib import Path @@ -76,6 +88,25 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = { "async_add_executor_job": ("homeassistant.core", "HomeAssistant"), } +#: Non-vacuity floors — the ONE source of truth for the standalone entry point +#: and the pytest wrapper (t39 F1). They sit below the current walk (13 modules, +#: 60 imports, 35 checked arguments, 7 awaited HA calls) so a broken or +#: mis-targeted walk fails loudly instead of printing zeros and exiting 0. +FLOORS: dict[str, int] = { + "modules": 10, + "imports": 40, + "call_args": 20, + "awaited_calls": 5, +} + +#: The interpreter floor Home Assistant itself declares: PyPI metadata for +#: 2026.10.1 is ``Requires-Python >=3.14.2``, so a 3.13 runner cannot even +#: install the version this sweep must run against. CI's ``ha-api-drift`` job +#: provisions a matching interpreter and guards with THIS constant, so the +#: number is written once; tests/test_ha_api_drift.py pins it against the +#: installed distribution's own metadata. +HOMEASSISTANT_PYTHON_FLOOR: tuple[int, int, int] = (3, 14, 2) + _MISSING = object() @@ -102,6 +133,19 @@ class Report: call_args: int = 0 awaited_calls: int = 0 + def floor_violations(self, floors: dict[str, int] | None = None) -> list[str]: + """Counters below the non-vacuity floors — a sweep nobody should trust. + + The floors default to module-level ``FLOORS`` so the standalone entry + point and the pytest wrapper cannot drift apart (t39 F1). + """ + floors = FLOORS if floors is None else floors + return [ + f"only {getattr(self, name)} {name} (floor: {floor})" + for name, floor in floors.items() + if getattr(self, name) < floor + ] + def __str__(self) -> str: if not self.findings: return ( @@ -372,9 +416,31 @@ def _scan_awaits(tree: ast.AST, path: str, report: Report) -> None: _AwaitVisitor(bases, path, report).visit(statement) +# ------------------------------------------------------ floors and versioning + + +def parse_requires_python(spec: str | None) -> tuple[int, ...] | None: + """Parse a ``>=X.Y[.Z]`` Requires-Python string into a version tuple. + + The CI interpreter guard and the metadata pin in the pytest wrapper both + need the same reading of Home Assistant's own declaration. + """ + if not spec: + return None + match = re.match(r"\s*>=\s*(\d+(?:\.\d+)*)", spec) + if match is None: + return None + return tuple(int(part) for part in match.group(1).split(".")) + + # ----------------------------------------------------------------------- scan +def homeassistant_available() -> bool: + """True when a real homeassistant is importable in this interpreter.""" + return importlib.util.find_spec("homeassistant") is not None + + def scan(paths=None) -> Report: """Resolve every Home-Assistant API use in the production tree.""" if paths is None: @@ -383,8 +449,35 @@ def scan(paths=None) -> Report: paths = [paths] report = Report() + if not homeassistant_available(): + # Neither a false green nor a wall of phantom findings: with no package + # to resolve against, every import would "fail", so the sweep reports + # that it cannot judge and leaves the counters at zero — which trips the + # floors below on purpose. + report.findings.append( + Finding( + "", + 0, + "no-homeassistant", + "homeassistant is not installed in this interpreter, so nothing " + "can be resolved; install it (uvx --with homeassistant --python " + "3.14 ...) or run tests/test_ha_api_drift.py, which skips cleanly", + ) + ) + return report + for root in paths: root = Path(root) + if not root.exists(): + report.findings.append( + Finding( + str(root), + 0, + "bad-path", + "path does not exist — nothing was scanned", + ) + ) + continue files = sorted(root.glob("*.py")) if root.is_dir() else [root] for file in files: report.modules += 1 @@ -402,4 +495,14 @@ if __name__ == "__main__": # pragma: no cover - manual red-proof entry point targets = sys.argv[1:] or None result = scan(targets) print(result) - raise SystemExit(1 if result.findings else 0) + violations = result.floor_violations() + if result.findings or violations: + for line in violations: + print(f"FLOOR: {line}", file=sys.stderr) + print( + f"drift sweep FAILED ({len(result.findings)} finding(s), " + f"{len(violations)} floor violation(s))", + file=sys.stderr, + ) + raise SystemExit(1) + raise SystemExit(0) diff --git a/tests/test_ha_api_drift.py b/tests/test_ha_api_drift.py index 0552419..d8c0fee 100644 --- a/tests/test_ha_api_drift.py +++ b/tests/test_ha_api_drift.py @@ -11,6 +11,9 @@ The sweep itself lives in ``tests/ha_api_drift.py`` (documented there). from __future__ import annotations +import ast +import subprocess +import sys from pathlib import Path import ha_api_drift @@ -77,13 +80,12 @@ def test_the_sweep_is_not_vacuous(): report = ha_api_drift.scan() - # Floors, not targets: they sit below today's walk (13 modules, 60 imports, - # 35 checked arguments, 7 awaited HA calls) so a broken walker shows up as a - # failure instead of a silently green sweep. - assert report.modules >= 10, report - assert report.imports >= 40, report - assert report.call_args >= 20, report - assert report.awaited_calls >= 5, report + # Floors, not targets: the numbers live in ha_api_drift.FLOORS — one source + # of truth for this wrapper and the standalone entry point (t39 F1) — so a + # broken or mis-targeted walk fails here instead of looking green. + violations = report.floor_violations() + + assert violations == [], "\n".join(violations) def test_the_sweep_flags_the_pre_fix_defects(tmp_path): @@ -128,3 +130,79 @@ def test_the_sweep_accepts_the_fixed_forms(tmp_path): report = ha_api_drift.scan(tmp_path) assert report.findings == [], "\n" + str(report) + + +def _run_drift_module(*paths): + """Run the standalone entry point in this (HA-bearing) interpreter.""" + return subprocess.run( + [ + sys.executable, + str(Path(ha_api_drift.__file__).resolve()), + *[str(path) for path in paths], + ], + capture_output=True, + text=True, + check=False, + ) + + +def test_the_standalone_entry_point_passes_on_the_production_tree(): + """F1: the CLI is the sweep, with the same verdict on the real tree.""" + result = _run_drift_module() + + assert result.returncode == 0, result.stderr + assert "no Home-Assistant API drift" in result.stdout + + +def test_the_standalone_entry_point_fails_on_an_empty_directory(tmp_path): + """t39 F1: zeros must not exit 0 — the floors apply to the CLI too.""" + result = _run_drift_module(tmp_path) + + assert result.returncode != 0 + assert "0 modules" in result.stdout # the counter that used to look green + assert "FLOOR:" in result.stderr + assert "drift sweep FAILED" in result.stderr + + +def test_the_standalone_entry_point_fails_on_a_missing_path(tmp_path): + """A mis-specified path is a failure, not a silent zero-module sweep.""" + result = _run_drift_module(tmp_path / "not-a-directory") + + assert result.returncode != 0 + assert "bad-path" in result.stdout + + +def test_no_floor_constants_are_duplicated_in_this_wrapper(): + """F1: the floors come from ha_api_drift.FLOORS — nowhere else.""" + source = Path(__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + + local_ints = [ + target.id + for node in tree.body + if isinstance(node, ast.Assign) + for target in node.targets + if isinstance(target, ast.Name) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, int) + ] + + assert local_ints == [], f"floor-like constants belong in ha_api_drift: {local_ints}" + assert "floor_violations" in source + + +def test_the_declared_python_floor_never_lags_homeassistant(): + """F2: the CI guard's floor is HA's own Requires-Python, not a guess.""" + _require_installed_homeassistant() + + import importlib.metadata as metadata + + declared = metadata.metadata("homeassistant")["Requires-Python"] + parsed = ha_api_drift.parse_requires_python(declared) + + assert parsed is not None, f"unparsable Requires-Python: {declared!r}" + assert tuple(parsed) <= ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR, ( + f"tests/ha_api_drift.py declares {ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR} " + f"but the installed homeassistant requires {declared} — raise the constant " + f"and the setup-python version in .gitea/workflows/test.yml" + )