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:
1 parent
824d6192a1
commit
fd62d850b7
3 files changed
+222
-18
No files matched your search
@@ -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
@@ -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)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in new issue
Block a user