test(ci): D1/D2/D3 — repo-owned HA API-drift sweep with a CI job (t37)

The unit suite validates production modules against hand-written stubs, so a
call site that no longer matches real Home Assistant stays green — that is how
D1, D2 and D3 reached master. This adds the check that would have caught them.

`tests/ha_api_drift.py` (stdlib-only, documented inline) parses every module of
custom_components/nvidia_shield and resolves each Home-Assistant dependency
against an INSTALLED homeassistant:

1. every `homeassistant.*` import — module present, attribute present (or a
   submodule of that name exists, which is what `from package import submodule`
   means) — catches D1/D2 and any future move;
2. every keyword argument passed to a known HA API (HA_CALL_OWNERS: the
   ConfigFlow/ConfigEntries APIs we call) must exist in the real signature —
   catches the `options_updates` class of bug;
3. every `self.<method>()` call in a class that inherits from an HA class is
   compared with the real definition: a coroutine must be awaited and an
   awaited call must be a coroutine — catches D3.

`tests/test_ha_api_drift.py` runs it against the production tree, asserts the
sweep is not vacuous (module/import/argument/await counters), and pins the red
proof with the four pre-fix forms, plus the green proof for the fixed forms. It
skips cleanly when homeassistant is absent, so the stdlib-only jobs are
unaffected.

`.gitea/workflows/test.yml` gains the `ha-api-drift` job — the one place Home
Assistant is installed — guarded by an explicit Python >= 3.13 check (Home
Assistant 2026.x requires it) and the same PEP 668 retry the test job uses.

Red proof against the real pre-fix revisions (svn-style checkout of
`git show HEAD:…` into a temp dir, and the pre-t35 config_flow.py for the kwarg):

  media_player.py:965: broken-import: from homeassistant.components.media_player
      import BrowseMediaSource: …BrowseMediaSource does not exist
  diagnostics.py:93: broken-import: from homeassistant.util.diagnostic import
      async_redact_data: module 'homeassistant.util.diagnostic' is not importable
  config_flow.py:425: missing-await: self._async_handle_discovery_without_unique_id()
      is a coroutine on homeassistant.config_entries.ConfigFlow
  config_flow.py:700: unknown-kwarg: async_update_reload_and_abort() has no
      'options_updates' parameter (exit 1 on that tree, exit 0 on this one)

Verified: variant 1 289 passed / 2 skipped; variant 2 (androidtvremote2) 290
passed / 1 skipped; ruff clean; `UV_PYTHON=3.13 uvx --with homeassistant --with
pytest-asyncio pytest tests/test_ha_api_drift.py -q` → 4 passed (the bare form of
that command needs the pin here only because this sandbox's default Python is
3.15 and pycares has no wheel for it / no CMake to build it; CI pins the
interpreter explicitly).
This commit is contained in:
James Mackay committed 2026-10-10 21:20:28 +01:00
1 parent 60fb4f7557
commit 824d6192a1
3 files changed
+577 -1

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
"""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
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: 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
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)