fix(drift): F1+F2 — the sweep refuses false greens, and CI provisions the real floor (t39)

t38 found two holes in the drift gate itself.

F2 — the job could never run where it mattered. Home Assistant 2026.10.1
declares ``Requires-Python >=3.14.2`` (PyPI metadata), so the old guard let a
3.13 runner through and the ``homeassistant>=2026.10`` install then failed with
a resolver error. The job now provisions its own interpreter
(``actions/setup-python@v5`` with ``python-version: "3.14"``) and the guard runs
against the REAL floor, read from ``ha_api_drift.HOMEASSISTANT_PYTHON_FLOOR`` —
one source of truth, pinned by a test against the installed distribution's own
``Requires-Python`` metadata. Verified: on 3.13.16 the guard refuses with
"Home Assistant needs Python >= 3.14.2, but this job runs 3.13.16 / Fix: raise
actions/setup-python…"; on 3.14.8 it reports "interpreter ok"; and
``uvx --python 3.14 --with 'homeassistant>=2026.10'`` resolves 2026.10.1.

F1 — the standalone entry point was a false green. It printed counters and
exited 0 even when it had walked nothing, which is exactly how the manual
red-proof tool gets misused. The non-vacuity floors now live in
``ha_api_drift.FLOORS`` and are enforced in ONE place, ``Report.floor_violations()``,
used by the pytest wrapper and by ``__main__`` (so the constants cannot drift
apart; a test asserts the wrapper holds no floor-like constants of its own).
On top of that the sweep can no longer lie in either direction:

* an empty or mis-targeted directory reports ``FLOOR:`` lines and
  ``drift sweep FAILED`` and exits 1 (a non-existent path is a ``bad-path``
  finding, not a traceback and not a silent zero-module sweep);
* with no homeassistant installed it returns a single ``no-homeassistant``
  finding with zero counters — never a wall of phantom "broken-import"s, never
  a green exit.

Verified in the CI-equivalent environment (Python 3.14.8 + homeassistant
2026.10.1): HEAD exits 0 with "no Home-Assistant API drift (13 modules, 60
imports, 35 call arguments, 7 awaited HA calls)"; the empty directory exits 1
with 4 FLOOR lines and no findings; a missing path exits 1 with the ``bad-path``
finding; and the full pre-fix tree (``git archive 5ede4fe``) exits 1 with
exactly the three t36 drifts (media_player.py:965 BrowseMediaSource,
diagnostics.py:93 homeassistant.util.diagnostic, config_flow.py:425
missing-await) and 0 floor violations.

The fast ``test`` job and ``release.yml`` are untouched; the workflow is
unchanged in shape except the new provisioning + guard steps, and parses as
valid YAML.
This commit is contained in:
James Mackay committed 2026-10-10 21:29:39 +01:00
1 parent 824d6192a1
commit fd62d850b7
3 files changed
+222 -18

No files matched your search

+32 -9
View File
@@ -63,17 +63,40 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - 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: | run: |
set -euo pipefail set -euo pipefail
# Home Assistant 2026.x requires Python >= 3.13; a newer runner just # The floor comes from tests/ha_api_drift.py — one source of truth for
# works. Fail loudly with the fix instead of a confusing resolver # this guard and the metadata pin in tests/test_ha_api_drift.py.
# error when the image is older. python3 - <<'PY'
if ! python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 13) else 1)'; then import sys
echo "Home Assistant needs Python >= 3.13 but this runner has $(python3 -V)." from pathlib import Path
echo "Add 'actions/setup-python@v5' with python-version '3.13' to this job."
exit 1 sys.path.insert(0, str(Path("tests").resolve()))
fi 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 - name: Install Home Assistant + test tooling
run: | run: |
+105 -2
View File
@@ -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 visible in the ``Report`` counters, which the pytest wrapper asserts on so the
sweep cannot silently become vacuous. 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 (``tests/test_ha_api_drift.py``) skips cleanly in that case, and CI runs one job
with homeassistant installed for exactly this check. with homeassistant installed for exactly this check.
""" """
@@ -48,7 +58,9 @@ from __future__ import annotations
import ast import ast
import importlib import importlib
import importlib.util
import inspect import inspect
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -76,6 +88,25 @@ HA_CALL_OWNERS: dict[str, tuple[str, str]] = {
"async_add_executor_job": ("homeassistant.core", "HomeAssistant"), "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() _MISSING = object()
@@ -102,6 +133,19 @@ class Report:
call_args: int = 0 call_args: int = 0
awaited_calls: 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: def __str__(self) -> str:
if not self.findings: if not self.findings:
return ( return (
@@ -372,9 +416,31 @@ def _scan_awaits(tree: ast.AST, path: str, report: Report) -> None:
_AwaitVisitor(bases, path, report).visit(statement) _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 # ----------------------------------------------------------------------- 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: def scan(paths=None) -> Report:
"""Resolve every Home-Assistant API use in the production tree.""" """Resolve every Home-Assistant API use in the production tree."""
if paths is None: if paths is None:
@@ -383,8 +449,35 @@ def scan(paths=None) -> Report:
paths = [paths] paths = [paths]
report = Report() 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(
"<environment>",
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: for root in paths:
root = Path(root) 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] files = sorted(root.glob("*.py")) if root.is_dir() else [root]
for file in files: for file in files:
report.modules += 1 report.modules += 1
@@ -402,4 +495,14 @@ if __name__ == "__main__": # pragma: no cover - manual red-proof entry point
targets = sys.argv[1:] or None targets = sys.argv[1:] or None
result = scan(targets) result = scan(targets)
print(result) 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)
+85 -7
View File
@@ -11,6 +11,9 @@ The sweep itself lives in ``tests/ha_api_drift.py`` (documented there).
from __future__ import annotations from __future__ import annotations
import ast
import subprocess
import sys
from pathlib import Path from pathlib import Path
import ha_api_drift import ha_api_drift
@@ -77,13 +80,12 @@ def test_the_sweep_is_not_vacuous():
report = ha_api_drift.scan() report = ha_api_drift.scan()
# Floors, not targets: they sit below today's walk (13 modules, 60 imports, # Floors, not targets: the numbers live in ha_api_drift.FLOORS — one source
# 35 checked arguments, 7 awaited HA calls) so a broken walker shows up as a # of truth for this wrapper and the standalone entry point (t39 F1) — so a
# failure instead of a silently green sweep. # broken or mis-targeted walk fails here instead of looking green.
assert report.modules >= 10, report violations = report.floor_violations()
assert report.imports >= 40, report
assert report.call_args >= 20, report assert violations == [], "\n".join(violations)
assert report.awaited_calls >= 5, report
def test_the_sweep_flags_the_pre_fix_defects(tmp_path): 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) report = ha_api_drift.scan(tmp_path)
assert report.findings == [], "\n" + str(report) 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"
)