The F1 blocker was invisible to every existing test: the stub harnesses call the
entity and replace ``async_write_ha_state``, so a non-property
``extra_state_attributes`` looked perfectly healthy. Two new gates close that
hole.
1. ``ha_api_drift.scan_entity_contracts()`` — a pure-AST pass (so it needs no
homeassistant, unlike passes 1-3) that walks every class inheriting from an
HA entity base, including our own intermediate ``NvidiaShieldSensorBase``, and
flags any HA-READ attribute (``extra_state_attributes``, ``available``,
``state``, ``native_value``, ``media_*``, …) declared as a plain method
instead of ``@property``. It is wired into ``scan()`` before the
homeassistant gate and exported for the fast suite, so ONE implementation
backs the sweep, the CLI red proof and tests/test_entity_contract.py (the t39
F1 lesson).
Red proof on the pre-fix tree:
media_player.py:828: entity-attribute-is-a-method:
NvidiaShieldMediaPlayer.extra_state_attributes is a plain method, but HA
reads it as an attribute (``attr |= self.<name>``) — declare it @property
…and HEAD is clean (13 modules, 60 imports, 35 call arguments, 7 awaited).
2. tests/test_ha_api_drift.py now builds each entity under REAL homeassistant
(real ``PlatformData``, real ``async_write_ha_state``) and asserts HA's own
``__async_calculate_state`` succeeds and that the media player's app_id/
app_name survive the merge — for the media player, both sensors, the remote
and the pairing button. A coverage test compares that matrix against the
sweep's classification, so a new entity class cannot quietly skip it.
Verified: 296 passed / 2 skipped (fast suite), 16 passed (drift suite under
Python 3.14 + homeassistant 2026.10.1), ruff clean; the entity-contract red
proof above reproduces exactly the reported line.
121 lines
3.9 KiB
Python
121 lines
3.9 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). Without homeassistant installed the
|
|
# sweep then adds "cannot judge the API surface" — that path is covered in
|
|
# tests/test_ha_api_drift.py.
|
|
assert kinds[0] == "entity-attribute-is-a-method"
|
|
assert "no-homeassistant" in kinds
|
|
|
|
|
|
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")) == []
|