mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
[api] Move runtime log client out of the component package (#18043)
This commit is contained in:
+91
-4
@@ -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
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user