From f0f6671c83dda173840150adfd7af335c3c99bff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Aug 2026 17:52:27 -0500 Subject: [PATCH] Pin that the native upload path imports no platform component package --- .../lazy_imports/native_upload_fast_path.py | 65 +++++++++++++++++++ tests/unit_tests/test_lazy_imports.py | 21 ++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py diff --git a/tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py b/tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py new file mode 100644 index 0000000000..ee9751de80 --- /dev/null +++ b/tests/unit_tests/fixtures/lazy_imports/native_upload_fast_path.py @@ -0,0 +1,65 @@ +"""Run the native-toolchain serial-upload path and report heavy modules. + +Executed as a subprocess by test_lazy_imports.py: heavy module names come +in on argv, the ones found in sys.modules afterwards go out on stdout. +``upload_using_esptool`` dispatches native toolchains through a +toolchain-keyed table; if someone routes it back through the platform +component packages (esp32 or esp8266), this reports the leak. +""" + +import os +from pathlib import Path +import sys +import tempfile +from unittest.mock import patch + +from _leak_report import print_leaked_modules + +from esphome.__main__ import upload_using_esptool +from esphome.const import ( + CONF_ESPHOME, + KEY_CORE, + KEY_ESP32, + KEY_TARGET_PLATFORM, + KEY_VARIANT, + Toolchain, +) +from esphome.core import CORE + +# An ambient ESPHOME_USE_SUBPROCESS would route past the patched +# run_external_command into run_external_process and confuse the checks. +os.environ.pop("ESPHOME_USE_SUBPROCESS", None) + +config = {CONF_ESPHOME: {"platformio_options": {}}} + +with tempfile.TemporaryDirectory() as build_dir: + CORE.name = "leaktest" + CORE.build_path = build_dir + + for platform, toolchain, backend in ( + ("esp8266", Toolchain.ARDUINO, "esphome.arduino8266.toolchain"), + ("esp32", Toolchain.ESP_IDF, "esphome.espidf.toolchain"), + ): + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + if platform == "esp32": + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32S3"} + CORE.toolchain = toolchain + + import importlib + + image = importlib.import_module(backend).get_factory_firmware_path() + image.parent.mkdir(parents=True, exist_ok=True) + image.write_bytes(b"\x00") + + with patch("esphome.__main__.run_external_command", return_value=0) as mock_run: + rc = upload_using_esptool(config, "/dev/ttyUSB0", None, None) + + # Fail loudly if the upload path stopped doing its work; otherwise + # an empty leak list could just mean nothing ran. + if rc != 0: + sys.exit(f"upload_using_esptool({platform}) returned {rc}") + cmd = list(mock_run.call_args[0][1:]) + if str(image) not in [str(Path(c)) for c in cmd]: + sys.exit(f"native factory image did not reach esptool: {cmd}") + +print_leaked_modules() diff --git a/tests/unit_tests/test_lazy_imports.py b/tests/unit_tests/test_lazy_imports.py index b6878c33a2..736cd524d2 100644 --- a/tests/unit_tests/test_lazy_imports.py +++ b/tests/unit_tests/test_lazy_imports.py @@ -175,6 +175,27 @@ def test_esptool_upload_fast_path_does_not_import_heavy_modules( ) +def test_native_upload_fast_path_does_not_import_heavy_modules( + fixture_path: Path, + probe_env: dict[str, str], +) -> None: + """The native-toolchain serial upload dispatches through a + toolchain-keyed backend table; resolving the flash image must not drag + in the esp32 or esp8266 component package or the validation stack. + """ + leaked = _leaked_from_fixture( + fixture_path, + probe_env, + "native_upload_fast_path.py", + extra=("esphome.components.esp8266",), + ) + assert not leaked, ( + f"native upload_using_esptool pulls in heavy modules: {leaked}. " + "The upload fast path skips validation; importing a platform " + "component package executes its codegen module by design." + ) + + def test_api_client_does_not_import_heavy_modules() -> None: """``esphome.api_client`` is on the logs fast path and must stay light.