diff --git a/esphome/__main__.py b/esphome/__main__.py index acb2a9ef46..d9f9ee6333 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2784,14 +2784,15 @@ def run_esphome(argv): # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. config: ConfigType | None = None - cache_eligible = ( - args.command in ("upload", "logs") - and not command_line_substitutions - # An explicit CLI toolchain must run the per-platform validators; - # the cache was validated under whatever the last compile used - and args.toolchain is None + cache_write_eligible = ( + args.command in ("upload", "logs") and not command_line_substitutions ) - if cache_eligible: + # An explicit CLI toolchain must run the per-platform validators; the + # cache was validated under whatever the last compile used. Only the + # read is gated: the refresh below may still save the freshly + # validated config, and its sidecar records the resolved toolchain. + cache_read_eligible = cache_write_eligible and args.toolchain is None + if cache_read_eligible: from esphome.compiled_config import load_compiled_config config = load_compiled_config(conf_path) @@ -2825,7 +2826,7 @@ def run_esphome(argv): # Refresh the cache so the next upload/logs hits the fast path # instead of re-running read_config. - if cache_eligible and cache_missed: + if cache_write_eligible and cache_missed: from esphome.compiled_config import save_compiled_config_and_sidecar save_compiled_config_and_sidecar(config) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 907e59bac7..102e78b3d9 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2537,7 +2537,7 @@ def platformio_version_constraint(value): return constraints -def check_supported_toolchain( +def _check_supported_toolchain( platform_name: str, supported: tuple[Toolchain, ...] ) -> None: """Raise when the resolved ``CORE.toolchain`` is not in ``supported``. @@ -2582,7 +2582,7 @@ def resolve_toolchain( def validator(config: ConfigType) -> ConfigType: if CORE.toolchain is None: CORE.toolchain = config.get(CONF_TOOLCHAIN, default) - check_supported_toolchain(platform_name, supported) + _check_supported_toolchain(platform_name, supported) return config return validator @@ -2593,8 +2593,11 @@ def require_platformio_toolchain( ) -> Callable[[ConfigType], ConfigType]: """Reject a CLI-selected toolchain other than PlatformIO. - For platforms with only the PlatformIO backend; without this a - ``--toolchain`` they cannot serve would silently build with PlatformIO. + For platforms with only the PlatformIO backend. Without this a + ``--toolchain`` they cannot serve would either build with PlatformIO + while claiming another backend, or (for a toolchain another platform + owns, like ``esp-idf``) dispatch to a native backend that cannot + build this platform at all. """ return resolve_toolchain( platform_name, (Toolchain.PLATFORMIO,), Toolchain.PLATFORMIO diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 6f98de2642..9f8633667e 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -3191,7 +3191,7 @@ def test_check_supported_toolchain_unresolved_is_an_ordering_bug() -> None: CORE.toolchain = None with pytest.raises(Invalid, match="not resolved before RP2 validation"): - cv.check_supported_toolchain("RP2", (Toolchain.PLATFORMIO,)) + cv._check_supported_toolchain("RP2", (Toolchain.PLATFORMIO,)) @pytest.mark.parametrize( diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index a809d3b4c8..8f55daba97 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7429,3 +7429,27 @@ def test_compile_program_unclaimed_native_toolchain_raises( CORE.toolchain = Toolchain.ARDUINO # esp32 has no arduino-native backend with pytest.raises(EsphomeError, match="no platform backend claimed"): compile_program(MockArgs(), {}) + + +def test_cli_toolchain_still_refreshes_the_validated_config_cache( + tmp_path: Path, +) -> None: + """An explicit --toolchain gates only the cache read; the freshly + validated config is still saved, and its sidecar records the resolved + toolchain for a later plain run.""" + from esphome.__main__ import run_esphome + + conf = tmp_path / "device.yaml" + conf.write_text("esphome:\n name: t\n") + argv = ["esphome", "--toolchain", "platformio", "logs", str(conf)] + with ( + patch("esphome.compiled_config.load_compiled_config") as mock_load, + patch("esphome.config.read_config", return_value={CONF_ESPHOME: {}}), + patch("esphome.compiled_config.save_compiled_config_and_sidecar") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", {"logs": Mock(return_value=0)} + ), + ): + assert run_esphome(argv) == 0 + mock_load.assert_not_called() + mock_save.assert_called_once()