diff --git a/esphome/__main__.py b/esphome/__main__.py index f87fee49d2f..707ad036096 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Protocol # cause them to be loaded before external components are processed, resulting # in the built-in version being used instead of the external component one. from esphome import const, platform_hooks +from esphome.build_helpers.native import native_backend from esphome.const import ( ALLOWED_NAME_CHARS, ARGUMENT_HELP_DEVICE, @@ -971,7 +972,7 @@ def upload_using_esptool( if file is not None: flash_images = [FlashImage(path=file, offset="0x0")] - elif (native := _native_toolchain_module()) is not None: + elif (native := native_backend()) is not None: # Every native backend supplies its own 0x0 flash image (bootloader # and partitions included where the target needs them) image = native.get_factory_firmware_path() @@ -1966,32 +1967,10 @@ def command_update_all(args: ArgsProtocol) -> int | None: return run_multiple_configs(files, build_command) -# Native build backend per (target platform, toolchain); keyed here so -# the serial upload/logs fast path never imports the platform package -_NATIVE_TOOLCHAIN_MODULES = { - ("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain", - ("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain", -} - - -def _native_toolchain_module(): - """The native build backend module for the resolved toolchain.""" - if not CORE.using_native_toolchain: - return None - key = (CORE.target_platform, CORE.toolchain) - if (module_path := _NATIVE_TOOLCHAIN_MODULES.get(key)) is None: - # Degrading to the PlatformIO path would build with the wrong backend - raise EsphomeError( - f"Toolchain '{CORE.toolchain.value}' has no native build backend " - f"module for platform {CORE.target_platform}" - ) - return importlib.import_module(module_path) - - def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: import json - native_toolchain = _native_toolchain_module() + native_toolchain = native_backend() if native_toolchain is not None: # Native toolchains derive idedata from the build's @@ -2036,7 +2015,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: from esphome.analyze_memory.ram_strings import RamStringsAnalyzer # Refuse an unsupported toolchain before paying for a full compile - native_toolchain = _native_toolchain_module() + native_toolchain = native_backend() if native_toolchain is None and not CORE.using_toolchain_platformio: _LOGGER.error( "analyze-memory is not supported with the '%s' toolchain on %s; " diff --git a/esphome/build_helpers/native.py b/esphome/build_helpers/native.py new file mode 100644 index 00000000000..cde526b8033 --- /dev/null +++ b/esphome/build_helpers/native.py @@ -0,0 +1,34 @@ +"""Resolution of the native (non-PlatformIO) build backend for a config. + +Kept deliberately light: the serial upload and logs fast path resolves a +backend for its artifact paths alone, so importing this must not pull in a +platform component package or the backend itself. +""" + +from __future__ import annotations + +import importlib +from types import ModuleType + +from esphome.const import Toolchain +from esphome.core import CORE, EsphomeError + +# Native build backend per (target platform, toolchain) +NATIVE_TOOLCHAIN_MODULES = { + ("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain", + ("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain", +} + + +def native_backend() -> ModuleType | None: + """The native build backend module for the resolved toolchain.""" + if not CORE.using_native_toolchain: + return None + key = (CORE.target_platform, CORE.toolchain) + if (module_path := NATIVE_TOOLCHAIN_MODULES.get(key)) is None: + # Degrading to the PlatformIO path would build with the wrong backend + raise EsphomeError( + f"Toolchain '{CORE.toolchain.value}' has no native build backend " + f"module for platform {CORE.target_platform}" + ) + return importlib.import_module(module_path) diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index fd8e5299b70..527fe2c917a 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -5,6 +5,7 @@ import re import subprocess from typing import Any +from esphome.build_helpers.native import native_backend from esphome.build_helpers.pch import pch_extra_scripts import esphome.codegen as cg import esphome.config_validation as cv @@ -311,15 +312,6 @@ CONFIG_SCHEMA = cv.All( ) -def native_toolchain_module(): - """The native build backend for the resolved toolchain, if any.""" - if not CORE.using_toolchain_arduino: - return None - from esphome.arduino8266 import toolchain - - return toolchain - - def check_rosetta() -> None: """Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac. @@ -518,7 +510,7 @@ async def finalize_serial_config() -> None: # PlatformIO toolchain. def run_compile(args, config: ConfigType) -> bool: # Positive check: the native backend only runs when explicitly resolved - toolchain = native_toolchain_module() + toolchain = native_backend() if toolchain is None: return False if toolchain.run_compile(config, CORE.verbose) != 0: @@ -591,7 +583,7 @@ ESP8266_EXCEPTION_CODES = { def _resolve_decode_tools(config: ConfigType) -> tuple[str, str] | None: """``(addr2line, elf)`` for this build, or None after warning why.""" - if (native_toolchain := native_toolchain_module()) is not None: + if (native_toolchain := native_backend()) is not None: addr2line = native_toolchain.get_addr2line_path() elf = native_toolchain.get_elf_path() for path in (addr2line, elf): diff --git a/tests/unit_tests/components/esp8266/test_toolchain_validation.py b/tests/unit_tests/components/esp8266/test_toolchain_validation.py index 4e3cfcbfd08..951217c8a10 100644 --- a/tests/unit_tests/components/esp8266/test_toolchain_validation.py +++ b/tests/unit_tests/components/esp8266/test_toolchain_validation.py @@ -23,6 +23,9 @@ from esphome.const import ( CONF_SOURCE, CONF_TOOLCHAIN, CONF_VERSION, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP8266, Toolchain, ) from esphome.core import CORE, EsphomeError @@ -31,9 +34,11 @@ from esphome.types import ConfigType @pytest.fixture(autouse=True) def _arduino_toolchain() -> Generator[None]: - # The suite-wide reset_core fixture clears CORE.toolchain after each test - # The decode-tool cache lives in CORE.data, which reset_core clears + # The suite-wide reset_core fixture clears both after each test; the + # shared backend resolver reads the platform as well as the toolchain, + # and the decode-tool cache lives in CORE.data CORE.toolchain = Toolchain.ARDUINO + CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = PLATFORM_ESP8266 yield diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index d689ac1a5a0..b5e7181269b 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -25,7 +25,6 @@ from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, _make_crystal_freq_callback, - _native_toolchain_module, _redact_with_legacy_fallback, _resolve_network_devices, _should_subscribe_states, @@ -7676,21 +7675,24 @@ def test_native_toolchain_table_serves_every_native_toolchain() -> None: """Every member of NATIVE_TOOLCHAINS has a backend entry; a gap would surface as a targeted EsphomeError on the one affected config, and this pin keeps the table from drifting when a toolchain is added.""" + from esphome.build_helpers.native import NATIVE_TOOLCHAIN_MODULES from esphome.const import NATIVE_TOOLCHAINS - assert {tc for _, tc in main._NATIVE_TOOLCHAIN_MODULES} == set(NATIVE_TOOLCHAINS) + assert {tc for _, tc in NATIVE_TOOLCHAIN_MODULES} == set(NATIVE_TOOLCHAINS) def test_native_toolchain_module_missing_backend_raises(tmp_path: Path) -> None: """A native toolchain missing from the backend table is a bug and must fail, not silently degrade to the PlatformIO path.""" + from esphome.build_helpers import native + setup_core(platform=PLATFORM_ESP8266, tmp_path=tmp_path, name="test_device") CORE.toolchain = Toolchain.ARDUINO with ( - patch.dict(main._NATIVE_TOOLCHAIN_MODULES, clear=True), + patch.dict(native.NATIVE_TOOLCHAIN_MODULES, clear=True), pytest.raises(EsphomeError, match="no native build backend"), ): - _native_toolchain_module() + native.native_backend() def test_command_analyze_memory_unsupported_toolchain(