"""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")) == []