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.
209 lines
7.3 KiB
Python
209 lines
7.3 KiB
Python
"""Run the Home-Assistant API-drift sweep (t37).
|
|
|
|
Skips cleanly when homeassistant is not installed — every other test file runs
|
|
against hand-written stubs, and this is the one check that must see the real
|
|
package. CI's `ha-api-drift` job installs it:
|
|
|
|
uvx --with homeassistant --with pytest-asyncio pytest tests/test_ha_api_drift.py -q
|
|
|
|
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
|
|
import pytest
|
|
|
|
pytest.importorskip(
|
|
"homeassistant",
|
|
reason="the API-drift sweep resolves our call sites against a real install",
|
|
)
|
|
|
|
#: The three t36 drifts plus the `options_updates` kwarg t35 removed — the
|
|
#: sweep must flag every one of them, or it cannot protect us from the next.
|
|
PRE_FIX_SOURCES = {
|
|
"d1_browse_media_source.py": (
|
|
"from homeassistant.components.media_player import BrowseMediaSource\n"
|
|
),
|
|
"d2_util_diagnostic.py": (
|
|
"from homeassistant.util.diagnostic import async_redact_data\n"
|
|
),
|
|
"d3_unawaited_discovery.py": (
|
|
"from homeassistant import config_entries\n"
|
|
"\n"
|
|
"\n"
|
|
"class PreFixFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
|
" async def async_step_zeroconf(self, discovery_info):\n"
|
|
" self._async_handle_discovery_without_unique_id()\n"
|
|
),
|
|
"t35_options_updates.py": (
|
|
"from homeassistant import config_entries\n"
|
|
"\n"
|
|
"\n"
|
|
"class PreFixReauthFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
|
" async def async_step_reauth_confirm(self, user_input=None):\n"
|
|
" self.async_update_reload_and_abort(\n"
|
|
" self.entry, options_updates={'protocol_enabled': True}\n"
|
|
" )\n"
|
|
" return self.async_abort(reason='reauth_successful')\n"
|
|
),
|
|
}
|
|
|
|
|
|
def _require_installed_homeassistant():
|
|
"""Skip when sys.modules holds a test stub rather than an install."""
|
|
import sys
|
|
|
|
module = sys.modules["homeassistant"]
|
|
path = getattr(module, "__file__", None)
|
|
if path is None or not Path(path).exists():
|
|
pytest.skip("homeassistant in sys.modules is a test stub, not an install")
|
|
|
|
|
|
def test_no_api_drift_in_the_production_tree():
|
|
"""The gate: every HA import, kwarg and await matches the real package."""
|
|
_require_installed_homeassistant()
|
|
|
|
report = ha_api_drift.scan()
|
|
|
|
assert report.findings == [], "\n" + str(report)
|
|
|
|
|
|
def test_the_sweep_is_not_vacuous():
|
|
"""The counters prove the walk really covered the production tree."""
|
|
_require_installed_homeassistant()
|
|
|
|
report = ha_api_drift.scan()
|
|
|
|
# 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):
|
|
"""Red proof: the t36 drifts and the options_updates kwarg are all caught."""
|
|
_require_installed_homeassistant()
|
|
|
|
for name, source in PRE_FIX_SOURCES.items():
|
|
(tmp_path / name).write_text(source, encoding="utf-8")
|
|
|
|
report = ha_api_drift.scan(tmp_path)
|
|
|
|
found = {(finding.path, finding.kind) for finding in report.findings}
|
|
assert found == {
|
|
("d1_browse_media_source.py", "broken-import"),
|
|
("d2_util_diagnostic.py", "broken-import"),
|
|
("d3_unawaited_discovery.py", "missing-await"),
|
|
("t35_options_updates.py", "unknown-kwarg"),
|
|
}, "\n" + str(report)
|
|
|
|
|
|
def test_the_sweep_accepts_the_fixed_forms(tmp_path):
|
|
"""The same call sites written the way production writes them are clean."""
|
|
_require_installed_homeassistant()
|
|
|
|
(tmp_path / "fixed.py").write_text(
|
|
"from homeassistant.components.media_player import BrowseMedia\n"
|
|
"from homeassistant.helpers.redact import async_redact_data\n"
|
|
"from homeassistant import config_entries\n"
|
|
"\n"
|
|
"\n"
|
|
"class FixedFlow(config_entries.ConfigFlow, domain='nvidia_shield'):\n"
|
|
" async def async_step_zeroconf(self, discovery_info):\n"
|
|
" await self._async_handle_discovery_without_unique_id()\n"
|
|
"\n"
|
|
" def finish(self):\n"
|
|
" return self.async_update_reload_and_abort(\n"
|
|
" self.entry, options={'protocol_enabled': True}\n"
|
|
" )\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
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"
|
|
)
|