[core] Add the arduino toolchain seam and validate --toolchain on every platform (#18556)

This commit is contained in:
J. Nick Koston
2026-08-26 11:16:02 +12:00
committed by GitHub
parent c272c4c1a6
commit 370e8fffed
19 changed files with 370 additions and 43 deletions
+14
View File
@@ -132,6 +132,20 @@ def test_esp32_rejects_unsupported_toolchains(
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
def test_esp32_rejects_unsupported_cli_toolchain(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A --toolchain the platform cannot serve fails instead of silently
building with PlatformIO (the CLI path bypasses the YAML validator)."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
CONFIG_SCHEMA({"variant": VARIANT_ESP32})
@pytest.mark.parametrize(
("config", "error_match"),
[
+49
View File
@@ -1285,6 +1285,7 @@ async def test_add_platformio_options_native_idf(
await config._add_platformio_options(
{
"build_flags": "-DSINGLE_FLAG", # string and list forms both valid
"build_unflags": ["-Os"],
"lib_deps": ["bblanchon/ArduinoJson@7.4.2"],
"lib_ignore": "libsodium",
"upload_speed": "115200",
@@ -1294,6 +1295,7 @@ async def test_add_platformio_options_native_idf(
assert "-DSINGLE_FLAG" in CORE.build_flags
assert "ArduinoJson" in CORE.platformio_libraries
assert "-Os" in CORE.build_unflags
# lib_ignore is stored (listified) for generate_idf_components to read;
# nothing else lands in platformio_options on the native toolchain.
assert CORE.platformio_options == {"lib_ignore": ["libsodium"]}
@@ -1389,3 +1391,50 @@ def test_esphome_build_internals_are_yaml_only() -> None:
assert markers[field].visibility is cv.Visibility.ADVANCED, field
# A regular device-config field stays on the main form.
assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None
@pytest.mark.asyncio
async def test_add_platformio_options_native_arduino(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The native ESP8266 Arduino toolchain honors board_build.f_cpu (a
real-world overclock knob) and warns about the rest like native IDF."""
CORE.toolchain = Toolchain.ARDUINO
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp8266",
KEY_TARGET_FRAMEWORK: "arduino",
}
await config._add_platformio_options(
{
"board_build.f_cpu": "160000000L",
# The schema also permits the list form; the last value wins
# and reaches the generator as a scalar
"board_build.ldscript": ["eagle.flash.2m.ld", "eagle.flash.4m2m.ld"],
"board_build.filesystem": "littlefs",
"upload_speed": "115200",
}
)
assert CORE.platformio_options["board_build.f_cpu"] == "160000000L"
assert CORE.platformio_options["board_build.ldscript"] == "eagle.flash.4m2m.ld"
assert "board_build.f_cpu is ignored" not in caplog.text
assert "board_build.ldscript is ignored" not in caplog.text
assert (
"esphome->platformio_options->board_build.filesystem is ignored" in caplog.text
)
# An empty list for an honored key is not a scalar; it falls through
# to the ignored-option warning instead of an IndexError
await config._add_platformio_options({"board_build.ldscript": []})
assert "board_build.ldscript is ignored" in caplog.text
assert "'arduino' toolchain" in caplog.text
assert "upload_speed" not in caplog.text
def test_esp8266_rejects_unsupported_cli_toolchain() -> None:
"""Until the native backend lands, ESP8266 serves only PlatformIO."""
from esphome.components.esp8266 import CONFIG_SCHEMA
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
CONFIG_SCHEMA({"board": "nodemcuv2"})
+31
View File
@@ -68,6 +68,7 @@ def _write_storage(
esp_platform: str | None = "ESP32",
core_platform: str | None = "esp32",
build_path: str | None = "/build/lite_test",
toolchain: str | None = None,
) -> None:
"""Write a vanilla StorageJSON sidecar for the cache tests."""
storage_path.parent.mkdir(parents=True, exist_ok=True)
@@ -88,6 +89,7 @@ def _write_storage(
"no_mdns": False,
"framework": "arduino",
"core_platform": core_platform,
"toolchain": toolchain,
}
storage_path.write_text(json.dumps(data), encoding="utf-8")
@@ -629,6 +631,35 @@ def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) ->
assert load_compiled_config(yaml_path) is not None
@pytest.mark.parametrize(
("sidecar_toolchain", "saved"),
[
("esp-idf", False),
("platformio", True),
(None, True), # legacy sidecar without the field: guard is inert
],
)
def test_save_compiled_config_and_sidecar_toolchain_mismatch(
tmp_path: Path, sidecar_toolchain: str | None, saved: bool
) -> None:
"""A config validated under a different toolchain than the compile's
must not overwrite the cache."""
yaml_path = _bare_yaml(tmp_path)
_prime_core(tmp_path)
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
CORE.toolchain = Toolchain.PLATFORMIO
_write_storage(
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json",
toolchain=sidecar_toolchain,
)
save_compiled_config_and_sidecar(CORE.config)
cache = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.json"
assert cache.exists() is saved
assert (load_compiled_config(yaml_path) is not None) is saved
@pytest.mark.parametrize("command", ["upload", "logs"])
def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
tmp_path: Path, command: str
@@ -1,3 +1,4 @@
import importlib
import json
import logging
from pathlib import Path
@@ -48,6 +49,7 @@ from esphome.const import (
TYPE_GIT,
TYPE_LOCAL,
Framework,
Toolchain,
)
from esphome.core import (
CORE,
@@ -3165,3 +3167,46 @@ def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None:
with pytest.raises(Invalid, match="is not a file"):
cv.file_("/original/config/headers")
def test_require_platformio_toolchain() -> None:
"""Platforms with only the PlatformIO backend reject other toolchains."""
validator = cv.require_platformio_toolchain("RP2")
CORE.toolchain = None
config: dict = {}
assert validator(config) is config
assert CORE.toolchain == Toolchain.PLATFORMIO
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino' for RP2"):
validator(config)
def test_check_supported_toolchain_unresolved_is_an_ordering_bug() -> None:
"""Calling the check before resolution fails naming the ordering bug,
not a user-facing unsupported-toolchain error."""
CORE.toolchain = None
with pytest.raises(Invalid, match="not resolved before RP2 validation"):
cv._check_supported_toolchain("RP2", (Toolchain.PLATFORMIO,))
@pytest.mark.parametrize(
("platform", "minimal_config"),
[
("host", {}),
("rp2", {"board": "rpipicow"}),
("bk72xx", {"board": "generic-bk7231n-qfn32-tuya"}),
("rtl87xx", {"board": "generic-rtl8710bn-2mb-788k"}),
("ln882x", {"board": "generic-ln882h"}),
# The legacy stub platform must reject too, not just the chip families
("libretiny", {}),
],
)
def test_every_platformio_only_platform_rejects_arduino_toolchain(
platform: str, minimal_config: dict
) -> None:
"""A platform that cannot serve a CLI toolchain rejects it at validation."""
module = importlib.import_module(f"esphome.components.{platform}")
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino'"):
module.CONFIG_SCHEMA(dict(minimal_config))
+18
View File
@@ -958,6 +958,24 @@ class TestEsphomeCore:
target.toolchain = const.Toolchain.ESP_IDF
assert target.using_toolchain_sdk_nrf is False
def test_using_toolchain_arduino(self, target):
"""A toolchain choice, distinct from the arduino target framework."""
target.toolchain = const.Toolchain.ARDUINO
assert target.using_toolchain_arduino is True
target.toolchain = const.Toolchain.PLATFORMIO
assert target.using_toolchain_arduino is False
def test_using_native_toolchain(self, target):
"""True exactly for the toolchains that never read platformio.ini."""
target.toolchain = const.Toolchain.ESP_IDF
assert target.using_native_toolchain is True
target.toolchain = const.Toolchain.ARDUINO
assert target.using_native_toolchain is True
target.toolchain = const.Toolchain.PLATFORMIO
assert target.using_native_toolchain is False
target.toolchain = const.Toolchain.SDK_NRF
assert target.using_native_toolchain is False
def test_add_library__extracts_short_name_from_path(self, target):
"""Test add_library extracts short name from library paths like owner/lib."""
target.data[const.KEY_CORE] = {
+36
View File
@@ -7195,6 +7195,42 @@ def test_compile_program_espidf_idedata_none_warns(
assert "No idedata was generated" in caplog.text
def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None:
"""An explicit --toolchain must run the per-platform validators, so the
upload/logs fast path becomes a cache miss."""
conf = tmp_path / "device.yaml"
conf.write_text("esphome:\n name: t\n")
argv = ["esphome", "--toolchain", "arduino", "logs", str(conf)]
with (
patch("esphome.compiled_config.load_compiled_config") as mock_cache,
patch("esphome.config.read_config", return_value=None) as mock_read,
):
assert run_esphome(argv) == 2
mock_cache.assert_not_called()
mock_read.assert_called_once()
def test_cli_toolchain_still_refreshes_the_validated_config_cache(
tmp_path: Path,
) -> None:
"""An explicit --toolchain gates only the cache read; with a matching
sidecar the freshly validated config is still saved."""
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()
@pytest.mark.asyncio
async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
"""The config comment dumps with sorted keys: voluptuous fills schema
+12 -3
View File
@@ -7,8 +7,10 @@ import sys
from types import SimpleNamespace
from unittest.mock import patch
import platformdirs
import pytest
from esphome.components.nrf52 import _resolve_toolchain
from esphome.components.nrf52.framework import (
_PLATFORMIO_PENV_REQUIREMENTS,
_REQUIREMENTS,
@@ -22,8 +24,9 @@ from esphome.components.nrf52.framework import (
get_sdk_nrf_tools_path,
setup_platformio_python_env,
)
import esphome.config_validation as cv
from esphome.config_validation import Version
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import get_python_env_executable_path
@@ -560,7 +563,6 @@ def testget_tools_path_blank_env_falls_back_to_default(
Path("") would resolve to the working directory, which clean-all could
then delete by accident.
"""
import platformdirs
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value)
expected = (
@@ -572,7 +574,6 @@ def testget_tools_path_blank_env_falls_back_to_default(
def testget_tools_path_default_is_global_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import platformdirs
monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False)
expected = (
@@ -621,3 +622,11 @@ def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> N
assert not python.exists()
assert _needs_venv_rebuild(python, sentinel, "abc123")
def test_resolve_toolchain_rejects_unsupported() -> None:
"""A --toolchain nRF52 cannot serve fails instead of degrading silently."""
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
_resolve_toolchain({})