Two defects shipped with a green suite: button.py imported a helper that had been deleted (no harness ever loaded the file), and the drift sweep only judged `homeassistant.*`, so a relative import of a name that no longer exists was invisible to it. Both are now impossible to ship silently. * tests/test_module_coverage.py loads EVERY *.py file of the package, in a clean interpreter, under the stub harness — exhaustive by construction (a new platform is covered the moment the file exists; there is no list and no special case). It asserts the reported set equals the files on disk, so a silently skipped module fails too; * entity_stublib.load_entity_modules() now also loads button.py (the named gap); the sweep is what keeps that list from needing to be complete; * ha_api_drift gained pass 5: the package's own relative imports. Pure AST, no homeassistant required, with its own entry point `scan_package_imports()` for the fast suite — it checks the target file exists and defines every imported name (submodule imports still resolve), instead of trusting that the modules a harness happened to load are the ones that matter; * the file walk in both AST passes is recursive, and the non-vacuity floors moved with it (modules 10 → 20, relative_imports ≥ 100 — a shallow-glob regression trips the floor instead of looking green); * two permanent red proofs, both on a copy of the tree with the reverted `from .helpers import discover_adb_host` reference in button.py: the stub-harness sweep fails with "FAIL button.py", and the static pass fails with "button.py:1: broken-relative-import: … not defined in helpers.py". Manual runs confirm the engine's other direction on the real pre-fix tree (c588a7c: exit 1 with the t40 entity-contract finding) and exit 0 on HEAD. No Home-Assistant API drift (23 modules, 58 imports, 148 relative imports, 36 call arguments, 6 awaited HA calls). Verified: 334 passed / 2 skipped, 335 / 1 with androidtvremote2, ruff clean. Note (pre-existing, not introduced here): running a SUBSET of test files in an order the suite was not written for (e.g. test_init.py alone after test_standalone_setup.py) fails 3 import-step tests with "ModuleNotFoundError: No module named 'voluptuous'" — the per-file stub scoping assumes the suite's order. Verified identical on the pre-t41 tree (60d884b), so it is left alone here.
127 lines
4.2 KiB
Python
127 lines
4.2 KiB
Python
"""Entity-contract shape checks (t40) — the defect class stubs cannot see.
|
|
|
|
The stub suites drive entities through hand-written fakes and replace
|
|
``async_write_ha_state``, so a non-property ``extra_state_attributes`` looks
|
|
perfectly healthy there while a live install raises
|
|
``TypeError: 'method' object is not iterable`` on every state write and the
|
|
entity never renders.
|
|
|
|
The check lives in ``tests/ha_api_drift.py`` (``scan_entity_contracts``) so this
|
|
suite and the drift sweep share ONE implementation — and the CI drift job, which
|
|
runs against a real Home Assistant, gates the same code path.
|
|
"""
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
import ha_api_drift
|
|
from conftest import _SHIELD_DIR
|
|
|
|
_PLAIN_DEF = " def extra_state_attributes(self) -> dict:"
|
|
|
|
#: The t40 shape: HA reads ``extra_state_attributes`` as an attribute
|
|
#: (``attr |= self.extra_state_attributes``), so a method here is a crash.
|
|
_PRE_FIX = f'''
|
|
from homeassistant.components.media_player import MediaPlayerEntity
|
|
|
|
|
|
class Player(MediaPlayerEntity):
|
|
{_PLAIN_DEF}
|
|
return {{"app_id": "x"}}
|
|
'''
|
|
|
|
_FIXED = f'''
|
|
from homeassistant.components.media_player import MediaPlayerEntity
|
|
|
|
|
|
class Player(MediaPlayerEntity):
|
|
@property
|
|
{_PLAIN_DEF}
|
|
return {{"app_id": "x"}}
|
|
'''
|
|
|
|
_INHERITED_BASE = '''
|
|
from homeassistant.components.sensor import SensorEntity
|
|
|
|
|
|
class Base(SensorEntity):
|
|
"""Our own intermediate base — subclasses are entities too."""
|
|
|
|
|
|
class Child(Base):
|
|
def native_value(self):
|
|
return "x"
|
|
'''
|
|
|
|
|
|
def _findings(tmp_path, source: str):
|
|
(tmp_path / "module.py").write_text(source, encoding="utf-8")
|
|
return ha_api_drift.scan_entity_contracts(tmp_path)
|
|
|
|
|
|
def test_no_entity_attribute_in_the_tree_is_a_plain_method():
|
|
"""t40: this is the assertion that fails on the pre-fix media_player.py."""
|
|
assert ha_api_drift.scan_entity_contracts() == []
|
|
|
|
|
|
def test_the_check_flags_the_pre_fix_shape(tmp_path):
|
|
expected_line = _PRE_FIX.splitlines().index(_PLAIN_DEF) + 1
|
|
|
|
findings = _findings(tmp_path, _PRE_FIX)
|
|
|
|
assert [finding.kind for finding in findings] == ["entity-attribute-is-a-method"]
|
|
assert findings[0].lineno == expected_line
|
|
assert "Player.extra_state_attributes" in findings[0].detail
|
|
assert "@property" in findings[0].detail
|
|
|
|
|
|
def test_the_check_accepts_the_property_form(tmp_path):
|
|
assert _findings(tmp_path, _FIXED) == []
|
|
|
|
|
|
def test_the_check_follows_our_own_intermediate_bases(tmp_path):
|
|
"""NvidiaShieldSensorBase-style bases must not hide their subclasses."""
|
|
findings = _findings(tmp_path, _INHERITED_BASE)
|
|
|
|
assert [finding.detail.split()[0] for finding in findings] == ["Child.native_value"]
|
|
|
|
|
|
def test_the_tree_classification_is_not_vacuous():
|
|
"""A classifier that sees no entities would pass every check above."""
|
|
classified: set[str] = set()
|
|
for path in sorted(_SHIELD_DIR.glob("*.py")):
|
|
classified |= ha_api_drift.entity_class_names(ast.parse(path.read_text(encoding="utf-8")))
|
|
|
|
assert {
|
|
"NvidiaShieldMediaPlayer",
|
|
"NvidiaShieldCurrentAppSensor",
|
|
"NvidiaShieldNowPlayingSensor",
|
|
"NvidiaShieldRemoteEntity",
|
|
"NvidiaShieldPairAdbButton",
|
|
} <= classified
|
|
|
|
|
|
def test_the_sweep_reports_the_same_findings_as_this_check(tmp_path):
|
|
"""One implementation: the sweep's report carries the contract pass too."""
|
|
(tmp_path / "module.py").write_text(_PRE_FIX, encoding="utf-8")
|
|
|
|
report = ha_api_drift.scan(tmp_path)
|
|
kinds = [finding.kind for finding in report.findings]
|
|
|
|
# The contract pass runs FIRST (pure AST). What the API passes add after it
|
|
# depends on the interpreter: with homeassistant installed they judge the
|
|
# module's imports, without it the sweep says it cannot judge (both are
|
|
# covered here so the check holds in either variant).
|
|
assert kinds[0] == "entity-attribute-is-a-method"
|
|
rest = set(kinds[1:])
|
|
if ha_api_drift.homeassistant_available():
|
|
# the API passes ran and found nothing wrong with this module
|
|
assert rest == set(), rest
|
|
else:
|
|
assert "no-homeassistant" in rest
|
|
|
|
|
|
def test_the_check_ignores_missing_paths_instead_of_raising():
|
|
"""scan() owns the bad-path finding; the pass just skips."""
|
|
assert ha_api_drift.scan_entity_contracts(Path("/nonexistent-does-not-exist")) == []
|