mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
187 lines
6.8 KiB
Python
187 lines
6.8 KiB
Python
"""Guard the platform CLI-hook registry in ``esphome.platform_hooks``.
|
|
|
|
The registry lets the logs/upload fast path skip importing platform
|
|
packages that don't provide a hook; these tests fail when a platform
|
|
gains or loses a hook without the registry being updated, and pin down
|
|
that the fast path really avoids the import.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import logging
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from esphome import platform_hooks
|
|
from esphome.const import PLATFORM_BK72XX, PLATFORM_ESP32, Platform
|
|
|
|
|
|
def test_no_unregistered_platform_exposes_a_hook() -> None:
|
|
"""Every platform hook the packages expose must be registered.
|
|
|
|
Behavioural on purpose: a hook added as a re-export, an assignment,
|
|
or an ``async def`` is invisible to source scanning but very visible
|
|
to ``hasattr``, and an unregistered hook is silently never called.
|
|
The registered direction is covered by
|
|
test_every_registered_pair_resolves below.
|
|
"""
|
|
for platform in frozenset(Platform):
|
|
module = importlib.import_module(f"esphome.components.{platform}")
|
|
for hook, registered in platform_hooks.PLATFORM_HOOKS.items():
|
|
if hasattr(module, hook):
|
|
assert platform in registered, (
|
|
f"{platform} exposes {hook} but is not registered for it. "
|
|
"Update esphome/platform_hooks.py."
|
|
)
|
|
|
|
|
|
def test_registered_platform_resolves_hook() -> None:
|
|
hook = platform_hooks.get_platform_hook(PLATFORM_ESP32, "process_stacktrace")
|
|
from esphome.components import esp32
|
|
|
|
assert hook is esp32.process_stacktrace
|
|
|
|
|
|
def test_every_registered_pair_resolves() -> None:
|
|
"""Each registered platform must actually expose the hook at runtime.
|
|
|
|
Text scanning can miss re-exports or decorated definitions; this is
|
|
the behavioural check for the direction that matters when the CLI
|
|
runs.
|
|
"""
|
|
for hook, platforms in platform_hooks.PLATFORM_HOOKS.items():
|
|
for platform in platforms:
|
|
assert callable(platform_hooks.get_platform_hook(platform, hook)), (
|
|
f"{platform} is registered for {hook} but does not expose it"
|
|
)
|
|
|
|
|
|
def test_external_platform_falls_back_to_probe(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Out-of-tree target platforms keep working via the dynamic probe."""
|
|
module = type("FakePlatform", (), {"show_logs": staticmethod(lambda *a: True)})
|
|
imported: list[str] = []
|
|
|
|
def fake_import(name: str):
|
|
imported.append(name)
|
|
return module
|
|
|
|
monkeypatch.setattr(platform_hooks, "import_module", fake_import)
|
|
hook = platform_hooks.get_platform_hook("my_external_chip", "show_logs")
|
|
assert hook is module.show_logs
|
|
assert imported == ["esphome.components.my_external_chip"]
|
|
|
|
|
|
def test_external_platform_missing_module_degrades(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A warm-cache run may not have the external package importable.
|
|
|
|
Skipping a behavior-changing hook is visible at warning; losing
|
|
stacktrace decoding is cosmetic and stays at debug.
|
|
"""
|
|
monkeypatch.setattr(
|
|
platform_hooks,
|
|
"import_module",
|
|
Mock(
|
|
side_effect=ModuleNotFoundError(
|
|
"not found", name="esphome.components.my_external_chip"
|
|
)
|
|
),
|
|
)
|
|
assert platform_hooks.get_platform_hook("my_external_chip", "show_logs") is None
|
|
assert "not importable" in caplog.text
|
|
assert any(r.levelname == "WARNING" for r in caplog.records)
|
|
|
|
caplog.clear()
|
|
assert (
|
|
platform_hooks.get_platform_hook("my_external_chip", "process_stacktrace")
|
|
is None
|
|
)
|
|
assert not any(r.levelname == "WARNING" for r in caplog.records)
|
|
|
|
|
|
def test_external_platform_without_hook_logs_debug(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""The common no-hook case stays quiet but diagnosable."""
|
|
caplog.set_level("DEBUG", logger="esphome.platform_hooks")
|
|
module = type("ExternalPlatform", (), {}) # imports fine, no hook
|
|
monkeypatch.setattr(platform_hooks, "import_module", Mock(return_value=module))
|
|
assert platform_hooks.get_platform_hook("my_external_chip", "show_logs") is None
|
|
assert "does not expose" in caplog.text
|
|
assert not any(r.levelname == "WARNING" for r in caplog.records)
|
|
|
|
|
|
def test_stale_registry_entry_warns(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""A vendored tree where a registered hook vanished must say so."""
|
|
module = type("StalePlatform", (), {}) # registered but no hook
|
|
monkeypatch.setattr(platform_hooks, "import_module", Mock(return_value=module))
|
|
assert platform_hooks.get_platform_hook("nrf52", "show_logs") is None
|
|
assert "no longer exposes it" in caplog.text
|
|
|
|
|
|
def test_external_platform_broken_dependency_raises(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A missing dependency inside the external package must surface."""
|
|
monkeypatch.setattr(
|
|
platform_hooks,
|
|
"import_module",
|
|
Mock(side_effect=ModuleNotFoundError("not found", name="some_missing_dep")),
|
|
)
|
|
with pytest.raises(ModuleNotFoundError, match="not found"):
|
|
platform_hooks.get_platform_hook("my_external_chip", "show_logs")
|
|
|
|
|
|
def test_lookup_miss_does_not_import_platform_package(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The whole point: probing a platform without hooks must not import it."""
|
|
monkeypatch.setattr(
|
|
platform_hooks,
|
|
"import_module",
|
|
Mock(side_effect=AssertionError("platform package imported on registry miss")),
|
|
)
|
|
assert platform_hooks.get_platform_hook(PLATFORM_ESP32, "show_logs") is None
|
|
|
|
|
|
def test_get_stacktrace_handler_resolves_registered_platform() -> None:
|
|
hook = platform_hooks.get_stacktrace_handler(PLATFORM_ESP32)
|
|
from esphome.components import esp32
|
|
|
|
assert hook is esp32.process_stacktrace
|
|
|
|
|
|
def test_get_stacktrace_handler_reports_missing_analyzer(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
caplog.set_level("INFO", logger="esphome.platform_hooks")
|
|
assert platform_hooks.get_stacktrace_handler(PLATFORM_BK72XX) is None
|
|
assert "no compatible analyzer" in caplog.text
|
|
# A capability gap is ordinary; it must not warn.
|
|
assert not any(r.levelno >= logging.WARNING for r in caplog.records)
|
|
|
|
|
|
def test_get_stacktrace_handler_reports_import_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
platform_hooks,
|
|
"import_module",
|
|
Mock(side_effect=ImportError("broken install")),
|
|
)
|
|
assert platform_hooks.get_stacktrace_handler(PLATFORM_ESP32) is None
|
|
assert "failed to import: broken install" in caplog.text
|
|
# A broken install is a real breakage; it must warn, not inform.
|
|
assert any(r.levelno == logging.WARNING for r in caplog.records)
|