[api] Move runtime log client out of the component package (#18043)

This commit is contained in:
J. Nick Koston
2026-08-03 19:57:52 -05:00
committed by GitHub
parent e8dadf2852
commit 4424af8c5b
12 changed files with 315 additions and 197 deletions
@@ -1,17 +1,34 @@
"""Tests for esphome.components.api.client."""
"""Tests for esphome.api_client."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import asyncio
from unittest.mock import AsyncMock, Mock, patch
import pytest
from esphome import api_client
from esphome.components import esp32
from esphome.components.api import client as api_client
from esphome.const import CONF_PORT, KEY_CORE, KEY_TARGET_PLATFORM
from esphome.const import (
CONF_ENCRYPTION,
CONF_KEY,
CONF_PORT,
KEY_CORE,
KEY_TARGET_PLATFORM,
)
from esphome.core import CORE, EsphomeError
def test_component_shim_reexports_runtime_client() -> None:
"""The old import paths must keep working for external code."""
from esphome.components import api
from esphome.components.api import client as shim
assert shim.run_logs is api_client.run_logs
assert shim.async_run_logs is api_client.async_run_logs
assert api.CONF_ENCRYPTION is CONF_ENCRYPTION
def test_decoder_swallows_esphome_error() -> None:
"""A failing stack-trace decode must not propagate.
@@ -166,3 +183,73 @@ async def test_async_run_logs_passes_deep_sleep(
await api_client.async_run_logs(config, ["1.2.3.4"])
assert mock_run.call_args.kwargs["deep_sleep"] is expected_deep_sleep
@pytest.mark.asyncio
async def test_async_run_logs_full_flow(caplog) -> None:
"""Drive async_run_logs end to end with a fake connection.
Covers the encryption key extraction, the multi-address banner, the
missing-stacktrace-analyzer fallback, the on_log handler, and the
stop() cleanup in the finally block.
"""
caplog.set_level("INFO", logger="esphome.api_client")
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "host"}
config = {
"esphome": {"name": "test"},
"api": {CONF_PORT: 6053, CONF_ENCRYPTION: {CONF_KEY: "psk123"}},
}
stop = AsyncMock()
run_started = asyncio.Event()
async def fake_async_run(*args, **kwargs):
run_started.set()
return stop
mock_run = AsyncMock(side_effect=fake_async_run)
printed: list[str] = []
with (
patch.object(api_client, "async_run", mock_run),
patch.object(api_client, "APIClient") as mock_client,
patch.object(api_client, "safe_print", printed.append),
):
task = asyncio.get_running_loop().create_task(
api_client.async_run_logs(config, ["1.2.3.4", "5.6.7.8"])
)
# Let the task run up to the forever-wait; the timeout fails the
# test instead of hanging it if the task dies early.
async with asyncio.timeout(1):
await run_started.wait()
on_log = mock_run.call_args.args[1]
on_log(Mock(message=b"[I][main:001] hello world\nPC: 0x40104960"))
# Cancellation is the real termination path; stop() must still run.
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
# Both addresses reach APIClient, along with the noise key.
assert mock_client.call_args.kwargs["noise_psk"] == "psk123"
assert mock_client.call_args.kwargs["addresses"] == ["1.2.3.4", "5.6.7.8"]
assert "1.2.3.4 or 5.6.7.8" in caplog.text
# host has no stacktrace analyzer; the fallback message is logged.
assert "Stacktrace analysis is unavailable" in caplog.text
# The log message was printed with a timestamp prefix.
assert any("hello world" in line for line in printed)
# stop() ran in the finally block despite the cancellation.
stop.assert_awaited_once()
def test_run_logs_suppresses_keyboard_interrupt() -> None:
"""Ctrl-C during log streaming exits cleanly instead of tracebacking."""
with patch.object(
api_client,
"async_run_logs",
AsyncMock(side_effect=KeyboardInterrupt),
) as mock_run:
api_client.run_logs(
{"esphome": {"name": "test"}}, ["1.2.3.4"], subscribe_states=False
)
assert mock_run.call_args.kwargs["subscribe_states"] is False
+29 -4
View File
@@ -31,11 +31,16 @@ HEAVY_MODULES = (
)
def test_main_module_does_not_import_heavy_modules() -> None:
"""A bare ``import esphome.__main__`` must not drag in validation/codegen."""
def _leaked_heavy_modules(module: str) -> str:
"""Import ``module`` in a subprocess and report the heavy modules it pulled.
Any ``esphome.components.*`` package counts as heavy: executing a
component package drags in codegen/validation machinery by design.
"""
check = (
"import sys; import esphome.__main__; "
f"import sys; import {module}; "
f"leaked = [m for m in {HEAVY_MODULES!r} if m in sys.modules]; "
"leaked += [m for m in sys.modules if m.startswith('esphome.components.')]; "
"print(','.join(leaked))"
)
result = subprocess.run(
@@ -44,10 +49,30 @@ def test_main_module_does_not_import_heavy_modules() -> None:
text=True,
check=True,
)
leaked = result.stdout.strip()
return result.stdout.strip()
def test_main_module_does_not_import_heavy_modules() -> None:
"""A bare ``import esphome.__main__`` must not drag in validation/codegen."""
leaked = _leaked_heavy_modules("esphome.__main__")
assert not leaked, (
f"esphome.__main__ imports heavy modules at top level: {leaked}. "
"Import them lazily inside the command that needs them instead; "
"every esphome invocation (including each parallel dashboard "
"upload subprocess) pays for top-level imports."
)
def test_api_client_does_not_import_heavy_modules() -> None:
"""``esphome.api_client`` is on the logs fast path and must stay light.
Importing it must not execute any component package (the api package
pulls the whole validation stack: logger, esp32, writer, config,
jinja2, voluptuous).
"""
leaked = _leaked_heavy_modules("esphome.api_client")
assert not leaked, (
f"esphome.api_client imports heavy modules at top level: {leaked}. "
"The logs fast path skips validation; importing the validation "
"stack anyway defeats the validated-config cache."
)
+9 -9
View File
@@ -2918,7 +2918,7 @@ def test_show_logs_no_logger() -> None:
show_logs(CORE.config, args, devices)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api(
mock_run_logs: Mock,
) -> None:
@@ -2944,7 +2944,7 @@ def test_show_logs_api(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_no_states(
mock_run_logs: Mock,
) -> None:
@@ -2971,7 +2971,7 @@ def test_show_logs_api_no_states(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_with_fqdn_mdns_disabled(
mock_run_logs: Mock,
) -> None:
@@ -2998,7 +2998,7 @@ def test_show_logs_api_with_fqdn_mdns_disabled(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_with_mqtt_fallback(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
@@ -4974,7 +4974,7 @@ def test_upload_program_ota_mqttip_deduplication(
assert "192.168.1.100" in call_args[0]
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_static_ip_with_mqttip(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
@@ -5013,7 +5013,7 @@ def test_show_logs_api_static_ip_with_mqttip(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_multiple_mqttip_resolves_once(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
@@ -5096,7 +5096,7 @@ def test_upload_program_ota_mqtt_timeout_fallback(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_show_logs_api_mqtt_timeout_fallback(
mock_run_logs: Mock,
mock_mqtt_get_ip: Mock,
@@ -6468,7 +6468,7 @@ def test_should_subscribe_states_no_flag_overrides_env() -> None:
assert _should_subscribe_states(args) is False
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_command_run_passes_no_states_to_show_logs(
mock_run_logs: Mock,
) -> None:
@@ -6506,7 +6506,7 @@ def test_command_run_passes_no_states_to_show_logs(
)
@patch("esphome.components.api.client.run_logs")
@patch("esphome.api_client.run_logs")
def test_command_run_defaults_subscribe_states_true(
mock_run_logs: Mock,
) -> None: