test(ci): F4 — entity-contract pass in the drift sweep + real-HA state-write proof (t40)
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.
This commit is contained in:
1 parent
acc7bc3d01
commit
be3abae3e7
3 files changed
+460
-1
No files matched your search
+188
-1
@@ -30,7 +30,14 @@ dependency against an INSTALLED homeassistant:
|
||||
must exist in the real signature — the ``options_updates`` class of bug;
|
||||
3. every ``self.<method>()`` call inside a class that inherits from an HA class
|
||||
is compared against the real definition: a coroutine must be awaited, and an
|
||||
awaited call must actually be a coroutine.
|
||||
awaited call must actually be a coroutine;
|
||||
4. every class inheriting from an HA entity base has its HA-READ attributes
|
||||
(``extra_state_attributes``, ``available``, …) declared as properties — HA
|
||||
merges them with ``attr |= self.extra_state_attributes``, so a plain method
|
||||
raises ``TypeError: 'method' object is not iterable`` on every state write and
|
||||
the entity can never render (t40 live install: ``media_player.py:828``). The
|
||||
stub suites call the method happily; this pass is pure AST and, unlike 1-3,
|
||||
needs no homeassistant at all.
|
||||
|
||||
Known limits (deliberate, and why the sweep still catches the real classes):
|
||||
``**kwargs`` splats cannot be checked statically, calls on objects other than
|
||||
@@ -439,6 +446,183 @@ def parse_requires_python(spec: str | None) -> tuple[int, ...] | None:
|
||||
return tuple(int(part) for part in match.group(1).split("."))
|
||||
|
||||
|
||||
# ----------------------------------------------------------- entity contracts
|
||||
|
||||
#: Attributes Home Assistant reads as ATTRIBUTES — it never calls them. A plain
|
||||
#: method with one of these names is invisible to the stub suites (permissive
|
||||
#: stubs, and the harness replaces ``async_write_ha_state``) and fatal under real
|
||||
#: HA: ``Entity.__async_calculate_state`` merges ``extra_state_attributes`` with
|
||||
#: ``attr |= self.extra_state_attributes``, which raises
|
||||
#: ``TypeError: 'method' object is not iterable`` on every state write. That was
|
||||
#: the t40 live-install blocker (``media_player.py:828``).
|
||||
ENTITY_ATTRIBUTES = frozenset(
|
||||
{
|
||||
"activity",
|
||||
"activity_list",
|
||||
"app_id",
|
||||
"app_name",
|
||||
"assumed_state",
|
||||
"attribution",
|
||||
"available",
|
||||
"capability_attributes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"device_info",
|
||||
"entity_category",
|
||||
"entity_picture",
|
||||
"extra_state_attributes",
|
||||
"icon",
|
||||
"is_volume_muted",
|
||||
"last_reset",
|
||||
"media_album_name",
|
||||
"media_artist",
|
||||
"media_content_id",
|
||||
"media_content_type",
|
||||
"media_duration",
|
||||
"media_image_url",
|
||||
"media_position",
|
||||
"media_title",
|
||||
"name",
|
||||
"native_unit_of_measurement",
|
||||
"native_value",
|
||||
"options",
|
||||
"should_poll",
|
||||
"sound_mode",
|
||||
"source",
|
||||
"source_list",
|
||||
"state",
|
||||
"state_attributes",
|
||||
"supported_features",
|
||||
"translation_key",
|
||||
"unique_id",
|
||||
"unit_of_measurement",
|
||||
"volume_level",
|
||||
}
|
||||
)
|
||||
|
||||
#: Entity base classes Home Assistant ships. A class counts as an entity when its
|
||||
#: transitive bases include one of these, so our own intermediate bases
|
||||
#: (``NvidiaShieldSensorBase``) are covered as well.
|
||||
HA_ENTITY_BASES = frozenset(
|
||||
{
|
||||
"AlarmControlPanelEntity",
|
||||
"BinarySensorEntity",
|
||||
"ButtonEntity",
|
||||
"CalendarEntity",
|
||||
"CameraEntity",
|
||||
"ClimateEntity",
|
||||
"CoverEntity",
|
||||
"DeviceTrackerEntity",
|
||||
"Entity",
|
||||
"EventEntity",
|
||||
"FanEntity",
|
||||
"HumidifierEntity",
|
||||
"ImageEntity",
|
||||
"LawnMowerEntity",
|
||||
"LightEntity",
|
||||
"LockEntity",
|
||||
"MediaPlayerEntity",
|
||||
"NotifyEntity",
|
||||
"NumberEntity",
|
||||
"RemoteEntity",
|
||||
"SelectEntity",
|
||||
"SensorEntity",
|
||||
"SirenEntity",
|
||||
"SwitchEntity",
|
||||
"TextEntity",
|
||||
"TodoListEntity",
|
||||
"UpdateEntity",
|
||||
"VacuumEntity",
|
||||
"ValveEntity",
|
||||
"WaterHeaterEntity",
|
||||
"WeatherEntity",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_attribute_decorated(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
||||
"""True when the method is declared ``@property`` / ``@cached_property``."""
|
||||
for decorator in node.decorator_list:
|
||||
name = decorator.id if isinstance(decorator, ast.Name) else getattr(decorator, "attr", "")
|
||||
if name in {"property", "cached_property"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _class_bases(node: ast.ClassDef) -> set[str]:
|
||||
"""The names in a class's base list (``**kwargs``-style bases are ignored)."""
|
||||
return {
|
||||
base.id if isinstance(base, ast.Name) else getattr(base, "attr", "")
|
||||
for base in node.bases
|
||||
}
|
||||
|
||||
|
||||
def entity_class_names(tree: ast.Module) -> set[str]:
|
||||
"""Names of the classes in one module that inherit from an HA entity base."""
|
||||
bases = {
|
||||
node.name: _class_bases(node) for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
|
||||
}
|
||||
entities = {name for name, parents in bases.items() if parents & HA_ENTITY_BASES}
|
||||
# Transitive: our own intermediate bases (NvidiaShieldSensorBase → SensorEntity)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for name, parents in bases.items():
|
||||
if name not in entities and parents & entities:
|
||||
entities.add(name)
|
||||
changed = True
|
||||
return entities
|
||||
|
||||
|
||||
def _scan_entity_contracts(paths, report: Report) -> None:
|
||||
"""t40: flag HA-read attributes declared as plain methods.
|
||||
|
||||
The stub suites cannot see this class of defect — they call the method
|
||||
successfully, because the harnesses replace ``async_write_ha_state`` — and
|
||||
only real HA turns it into a TypeError. Pure AST, so it also runs when
|
||||
homeassistant is not installed.
|
||||
"""
|
||||
for root in paths:
|
||||
root = Path(root)
|
||||
if not root.exists():
|
||||
continue # scan() reports bad-path for the same target
|
||||
files = sorted(root.glob("*.py")) if root.is_dir() else [root]
|
||||
for file in files:
|
||||
try:
|
||||
tree = ast.parse(file.read_text(encoding="utf-8"), filename=str(file))
|
||||
except (OSError, SyntaxError):
|
||||
continue
|
||||
entity_classes = entity_class_names(tree)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef) or node.name not in entity_classes:
|
||||
continue
|
||||
for item in node.body:
|
||||
if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
if item.name not in ENTITY_ATTRIBUTES or _is_attribute_decorated(item):
|
||||
continue
|
||||
report.findings.append(
|
||||
Finding(
|
||||
str(file),
|
||||
item.lineno,
|
||||
"entity-attribute-is-a-method",
|
||||
f"{node.name}.{item.name} is a plain method, but HA reads it as an "
|
||||
"attribute (``attr |= self.<name>``) — declare it @property",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def scan_entity_contracts(paths=None) -> list[Finding]:
|
||||
"""The entity-contract pass on its own (t40) — the fast suite's entry point."""
|
||||
if paths is None:
|
||||
paths = [SHIELD_DIR]
|
||||
elif isinstance(paths, (str, Path)):
|
||||
paths = [paths]
|
||||
report = Report()
|
||||
_scan_entity_contracts(paths, report)
|
||||
return report.findings
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- scan
|
||||
|
||||
|
||||
@@ -455,6 +639,9 @@ def scan(paths=None) -> Report:
|
||||
paths = [paths]
|
||||
|
||||
report = Report()
|
||||
# t40: pure-AST shape check, so it runs even without homeassistant — that is
|
||||
# the whole point of a contract stubs cannot enforce.
|
||||
_scan_entity_contracts(paths, report)
|
||||
if not homeassistant_available():
|
||||
# Neither a false green nor a wall of phantom findings: with no package
|
||||
# to resolve against, every import would "fail", so the sweep reports
|
||||
|
||||
Reference in new issue
Block a user