diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index d190e87740..f37cf30830 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -50,9 +50,9 @@ from esphome.framework_helpers import ( ) from esphome.helpers import mkdir_p, write_file_if_changed from esphome.platformio.library import ( - BARE_ARG_FLAGS, SOURCE_KIND_FOR_SUFFIX, lex_build_flags, + raise_on_empty_arg_flags, ) if TYPE_CHECKING: @@ -72,7 +72,12 @@ _CORE_EXCLUDE_WAVEFORM = { # Values that land unquoted on generated command lines are shape-checked # against these before use +_MMU_VALUE_RE = re.compile(r"(?:0[xX][0-9a-fA-F]+|\d+)[uUlL]*") _MMU_HEX_VALUE_RE = re.compile(r"0[xX][0-9a-fA-F]+[uUlL]*") +# Only these land in the preprocessed script's ``len =`` fields, which +# build_surgery's segment parser reads back as hex; the other MMU_* macros +# (MMU_EXTERNAL_HEAP=128) are consumed by mmu_iram.h and may be decimal +_MMU_SEGMENT_SIZE_NAMES = ("MMU_IRAM_SIZE", "MMU_ICACHE_SIZE") _BOARD_NAME_RE = re.compile(r"[\w.-]+") _F_CPU_RE = re.compile(r"\d+L?") _FLASH_LD_NAME_RE = re.compile(r"[\w.-]+\.ld") @@ -84,6 +89,26 @@ _DEFAULT_F_CPU = "80000000L" # against; the cache stamp and stderr sidecars derive from the output name _COMMON_LD_HEADER = "eagle.app.v6.common.ld.h" _COMMON_LD_NAME = "local.eagle.app.v6.common.ld" +# Testing mode shadows the SDK flash ld with a patched copy under this name +_TESTING_LD_PREFIX = "testing_" + +# The recovery hint for a half-extracted or damaged framework cache +_CLEAN_HINT = "run 'esphome clean-all' and retry" + + +def _sdk_ld_dir(framework: Path) -> Path: + return framework / "tools" / "sdk" / "ld" + + +def _apply_surgery(fn, *args: object) -> str: + """Run one build_surgery edit, naming a failed anchor instead of a + traceback (the surgery module raises bare RuntimeError so its + ``.py.script`` twins stay importable without esphome).""" + try: + return fn(*args) + except RuntimeError as err: + raise EsphomeError(str(err)) from err + # Every supported board's f_flash is 40 MHz; re-check on a platform bump _FLASH_FREQ_MHZ = 40 @@ -251,13 +276,8 @@ def _lexed_build_flags() -> list[str]: Lex once per build; consumers share the tokens. """ tokens = lex_build_flags(sorted(CORE.build_flags), "esphome") - # The lexer glues '-D ""' to a bare "-D"; gcc would eat the next flag - # as its argument (or add the CWD for -L). Always a typo, so raise for - # every consumer of the shared token list. - if empty := sorted({tok for tok in tokens if tok in BARE_ARG_FLAGS}): - raise EsphomeError( - f"build_flags contain empty-argument flag(s): {', '.join(empty)}" - ) + # Raises for every consumer of the shared token list + raise_on_empty_arg_flags(tokens, "build_flags") return tokens @@ -280,11 +300,14 @@ def _flag_defines(unflags: set[str], tokens: list[str]) -> dict[str, str]: def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: - nonosdk = next(iter(_NONOSDK_VERSIONS.values())) - for name, define in _NONOSDK_VERSIONS.items(): - if f"PIO_FRAMEWORK_ARDUINO_ESPRESSIF_{name}" in defines: - nonosdk = define - break + nonosdk = next( + ( + define + for name, define in _NONOSDK_VERSIONS.items() + if f"PIO_FRAMEWORK_ARDUINO_ESPRESSIF_{name}" in defines + ), + next(iter(_NONOSDK_VERSIONS.values())), + ) # Same compile-line/linked-artifact split as the lwIP knobs below: a # raw NONOSDK* would define a second SDK macro while the link still # resolves against the knob's libraries @@ -295,11 +318,10 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: "build flags" ) - tcp_mss, features, ipv6, lwip_lib = _LWIP_DEFAULT - for knob, variant in _LWIP_VARIANTS.items(): - if knob in defines: - tcp_mss, features, ipv6, lwip_lib = variant - break + tcp_mss, features, ipv6, lwip_lib = next( + (variant for knob, variant in _LWIP_VARIANTS.items() if knob in defines), + _LWIP_DEFAULT, + ) # The lwIP triple selects a prebuilt library; a raw override would win # the compile line (user tokens come last here) while the link still @@ -361,14 +383,21 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: # Valueless flags (MMU_IRAM_HEAP) are legitimate switches continue # Every valued MMU_* reaches the linker-script preprocessor; a - # bare or non-numeric value would corrupt the segment lengths - # and fail far away in ld. Hex only: build_surgery's segment - # parser (and upstream's spellings) cannot read decimal. + # bare or non-numeric value would corrupt it and fail far away + # in ld. The two segment sizes must additionally be hex: + # build_surgery's segment parser cannot read decimal back. value = body.partition("=")[2] - if not _MMU_HEX_VALUE_RE.fullmatch(value): + rule = ( + _MMU_HEX_VALUE_RE if name in _MMU_SEGMENT_SIZE_NAMES else _MMU_VALUE_RE + ) + if not rule.fullmatch(value): + shape = ( + "a hex literal (e.g. 0x8000)" + if name in _MMU_SEGMENT_SIZE_NAMES + else "a numeric literal" + ) raise EsphomeError( - f"{name} must be a hex literal (e.g. 0x8000), got " - f"{value or '(no value)'}" + f"{name} must be {shape}, got {value or '(no value)'}" ) # Sorted so build.ninja and the linker-script stamp stay # byte-stable across runs (the flag set has no deterministic @@ -397,13 +426,14 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig: _INCOMPLETE_INSTALL = "Arduino toolchain install is incomplete" -_CLEAN_HINT = "run 'esphome clean-all' and retry" def _active_flash_ld_name(flash_ld_name: str) -> str: """The flash linker-script filename the link uses (testing mode renames the surgically patched copy).""" - return f"testing_{flash_ld_name}" if CORE.testing_mode else flash_ld_name + return ( + f"{_TESTING_LD_PREFIX}{flash_ld_name}" if CORE.testing_mode else flash_ld_name + ) def _flash_ld_name(board: str) -> str: @@ -569,21 +599,6 @@ def _write_note(path: Path, text: str, *, warn: bool = False) -> None: log("Could not write %s: %s", path, err) -def _write_generated(path: Path, content: str) -> None: - """write_file_if_changed, replacing an unreadable existing copy. - - The recovery is scoped to the comparison read: a damaged cached file is - logged and overwritten, while a genuine write failure still raises. - """ - try: - if path.is_file(): - path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError) as err: - _LOGGER.warning("Replacing damaged generated file %s: %s", path, err) - path.unlink(missing_ok=True) - write_file_if_changed(path, content) - - def generate_ld_scripts( paths: InstalledPaths, config: _BuildConfig, flash_ld_name: str ) -> None: @@ -605,7 +620,7 @@ def generate_ld_scripts( cmd += [f"-D{d}" for d in config.mmu_defines] if config.fp_in_irom: cmd.append("-DFP_IN_IROM") - header = framework / "tools" / "sdk" / "ld" / _COMMON_LD_HEADER + header = _sdk_ld_dir(framework) / _COMMON_LD_HEADER cmd += [str(header), "-o", "-"] # The inputs are the command line (defines + framework version, which is @@ -657,9 +672,7 @@ def generate_ld_scripts( ) except OSError as err: # A half-extracted or half-deleted toolchain cache reaches here - raise EsphomeError( - f"Could not run {gcc}: {err}; run 'esphome clean-all' and retry" - ) from err + raise EsphomeError(f"Could not run {gcc}: {err}; {_CLEAN_HINT}") from err if result.returncode != 0: raise EsphomeError(f"Generating the linker script failed:\n{result.stderr}") if result.stderr.strip(): @@ -672,24 +685,14 @@ def generate_ld_scripts( if "SECTIONS" not in result.stdout: # A degenerate zero-exit run must not be stamped as a good cache raise EsphomeError( - "Generated linker script is missing its SECTIONS block; " - "run 'esphome clean-all' and retry" + f"Generated linker script is missing its SECTIONS block; {_CLEAN_HINT}" ) - try: - content = build_surgery.relocate_ratetable(result.stdout) - except RuntimeError as err: - # The anchor moved in a new core release: a named error, not a - # traceback, and never a silently unrelocated rate table - raise EsphomeError(str(err)) from err + content = _apply_surgery(build_surgery.relocate_ratetable, result.stdout) if CORE.testing_mode: - try: - content = build_surgery.apply_testing_memory_patches( - content, ("iram1_0_seg",) - ) - except RuntimeError as err: - # Same changed-linker-script failure class as the ratetable - raise EsphomeError(str(err)) from err - _write_generated(output, content) + content = _apply_surgery( + build_surgery.apply_testing_memory_patches, content, ("iram1_0_seg",) + ) + write_file_if_changed(output, content) _write_note( stamp, f"{stamp_content} content={hashlib.sha256(content.encode('utf-8')).hexdigest()}", @@ -709,25 +712,28 @@ def generate_ld_scripts( ) if CORE.testing_mode: - # A patched copy of the flash ld in the build dir; resolved through - # the same -L path as the SDK original it shadows. - flash_ld = framework / "tools" / "sdk" / "ld" / flash_ld_name - try: - flash_ld_text = flash_ld.read_text(encoding="utf-8") - except OSError as err: - # Same half-extracted-cache hazard as the gcc spawn above - raise EsphomeError( - f"Could not read {flash_ld}: {err}; run 'esphome clean-all' and retry" - ) from err - try: - patched_flash_ld = build_surgery.apply_testing_memory_patches( - flash_ld_text, - ("dram0_0_seg", "irom0_0_seg"), - ) - except RuntimeError as err: - # Same changed-linker-script failure class as the ratetable - raise EsphomeError(str(err)) from err - _write_generated(ld_dir / f"testing_{flash_ld_name}", patched_flash_ld) + _generate_testing_flash_ld(framework, ld_dir, flash_ld_name) + + +def _generate_testing_flash_ld( + framework: Path, ld_dir: Path, flash_ld_name: str +) -> None: + """A patched copy of the flash ld in the build dir; resolved through the + same -L path as the SDK original it shadows.""" + flash_ld = _sdk_ld_dir(framework) / flash_ld_name + try: + flash_ld_text = flash_ld.read_text(encoding="utf-8") + except OSError as err: + # Same half-extracted-cache hazard as the preprocessor spawn + raise EsphomeError(f"Could not read {flash_ld}: {err}; {_CLEAN_HINT}") from err + patched_flash_ld = _apply_surgery( + build_surgery.apply_testing_memory_patches, + flash_ld_text, + ("dram0_0_seg", "irom0_0_seg"), + ) + write_file_if_changed( + ld_dir / f"{_TESTING_LD_PREFIX}{flash_ld_name}", patched_flash_ld + ) def _ninja_compile_edges( diff --git a/esphome/helpers.py b/esphome/helpers.py index b926979e04..485b1f5b41 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -556,9 +556,17 @@ def write_file_if_changed(path: Path, text: str) -> bool: Returns true if the file was changed. """ + from esphome.core import EsphomeError + src_content = None if path.is_file(): - src_content = read_file(path) + try: + src_content = read_file(path) + except (EsphomeError, UnicodeDecodeError) as err: + # A damaged existing file (unreadable, non-UTF-8) must be + # replaced, not abort the regeneration that would fix it + _LOGGER.warning("Replacing damaged file %s: %s", path, err) + path.unlink(missing_ok=True) if src_content == text: return False write_file(path, text) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 12f076edd4..fd94197c93 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -655,6 +655,21 @@ def lex_build_flags(entries: str | list[str], owner: str) -> list[str]: BARE_ARG_FLAGS = frozenset({"-I", "-L", "-l", "-D"}) +def raise_on_empty_arg_flags(tokens: list[str], owner: str) -> None: + """Reject bare ``-I``/``-D``/``-L``/``-l`` tokens left by an empty glued + argument (``-D ""``). + + Lives next to ``join_flag_args`` because the bare token is its + postcondition: a trailing bare flag is warned and dropped there, so a + surviving one always means an empty argument. gcc would eat the next + flag as the argument (or add the CWD for ``-L``); always a typo. + """ + if empty := sorted({tok for tok in tokens if tok in BARE_ARG_FLAGS}): + raise EsphomeError( + f"{owner} contain empty-argument flag(s): {', '.join(empty)}" + ) + + def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]: """Join a bare ``-I``/``-L``/``-l``/``-D`` with its following token, the way PlatformIO's ParseFlags lexes them.""" diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index b11ca4a0e2..94a0cb50db 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -68,7 +68,33 @@ def _shq(tok: str) -> str: def _resolve(*flags: str): """Set the build flags and resolve the knob config in one step.""" _set_flags(*flags) - return _resolve_build_config(_flag_defines(set(), arduino8266._lexed_build_flags())) + return _resolve_current() + + +def _defines(): + """The -D map for the current build flags.""" + return _flag_defines(set(), arduino8266._lexed_build_flags()) + + +def _resolve_current(): + """Resolve whatever flags are already set (must not clear them).""" + return _resolve_build_config(_defines()) + + +def _split_flags(): + """Classify the current build flags the way write_project does.""" + return arduino8266._project_flags( + arduino8266._unflag_tokens(), arduino8266._lexed_build_flags() + ) + + +def _ok_result(stdout=None, stderr=""): + """A successful preprocessor spawn (defaults to the common ld output).""" + return MagicMock( + returncode=0, + stdout=_COMMON_LD_H_OUTPUT if stdout is None else stdout, + stderr=stderr, + ) def test_build_config_defaults() -> None: @@ -106,9 +132,7 @@ def test_build_config_knobs() -> None: "-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48", "-DVTABLES_IN_DRAM", ) - config = _resolve_build_config( - _flag_defines(set(), arduino8266._lexed_build_flags()) - ) + config = _resolve_current() assert config.nonosdk == "NONOSDK305" assert config.exceptions assert config.vtables == "VTABLES_IN_DRAM" @@ -125,9 +149,7 @@ def test_build_config_mmu_custom_requires_sizes() -> None: "-DMMU_IRAM_SIZE=0xC000", "-DMMU_ICACHE_SIZE=0x4000", ) - config = _resolve_build_config( - _flag_defines(set(), arduino8266._lexed_build_flags()) - ) + config = _resolve_current() # Emitted pre-sorted so build.ninja stays byte-stable across runs assert config.mmu_defines == [ "MMU_ICACHE_SIZE=0x4000", @@ -140,7 +162,7 @@ def test_defines_match_platformio_builder() -> None: _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") assert _defines_flags( - _resolve_build_config(_flag_defines(set(), arduino8266._lexed_build_flags())), + _resolve_current(), "dout", "nodemcuv2", ESP8266_BOARD_BUILD["nodemcuv2"]["defines"], @@ -366,12 +388,7 @@ def test_build_config_lwip_variants( def test_build_config_mmu_variants(knob: str, expected: list[str]) -> None: _set_flags(f"-D{knob}") - assert ( - _resolve_build_config( - _flag_defines(set(), arduino8266._lexed_build_flags()) - ).mmu_defines - == expected - ) + assert _resolve_build_config(_defines()).mmu_defines == expected def test_build_config_waveform_locked_phase() -> None: @@ -398,9 +415,7 @@ SECTIONS def _run_generate_ld_scripts(paths: InstalledPaths) -> Path: - config = _resolve_build_config( - _flag_defines(set(), arduino8266._lexed_build_flags()) - ) + config = _resolve_current() arduino8266.generate_ld_scripts(paths, config, "eagle.flash.4m.ld") return CORE.relative_pioenvs_path(CORE.name, "ld") @@ -409,7 +424,7 @@ def test_generate_ld_scripts(tmp_path: Path) -> None: paths = _make_framework(tmp_path) _set_flags("-DFP_IN_IROM") - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with ( patch.object(arduino8266.subprocess, "run", return_value=result) as mock_run, patch.object(arduino8266._LOGGER, "warning") as mock_warn, @@ -444,7 +459,7 @@ def test_generate_ld_scripts(tmp_path: Path) -> None: def test_generate_ld_scripts_corrupt_cache_regenerates(tmp_path: Path) -> None: """A truncated cached linker script regenerates even with a fresh stamp.""" paths = _make_framework(tmp_path) - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with patch.object(arduino8266.subprocess, "run", return_value=result): ld_dir = _run_generate_ld_scripts(paths) output = ld_dir / "local.eagle.app.v6.common.ld" @@ -476,7 +491,7 @@ def test_generate_ld_scripts_testing_mode(tmp_path: Path) -> None: "}\n" ) CORE.testing_mode = True - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with patch.object(arduino8266.subprocess, "run", return_value=result): ld_dir = _run_generate_ld_scripts(paths) patched = (ld_dir / "testing_eagle.flash.4m.ld").read_text() @@ -579,7 +594,7 @@ def test_generate_ld_scripts_testing_mode_missing_flash_ld_raises( """A missing flash ld in testing mode names the file and the fix.""" paths = _make_framework(tmp_path) CORE.testing_mode = True - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with ( patch.object(arduino8266.subprocess, "run", return_value=result), pytest.raises(EsphomeError, match="Could not read .*clean-all"), @@ -594,12 +609,7 @@ def test_build_config_nonosdk_precedence() -> None: "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK305", "-DPIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK221", ) - assert ( - _resolve_build_config( - _flag_defines(set(), arduino8266._lexed_build_flags()) - ).nonosdk - == "NONOSDK221" - ) + assert _resolve_build_config(_defines()).nonosdk == "NONOSDK221" def test_write_project_build_unflags_apply_to_framework_flags(tmp_path: Path) -> None: @@ -620,9 +630,7 @@ def test_project_flags_trailing_bare_linker_flag_warns( caplog: pytest.LogCaptureFixture, ) -> None: _set_flags("-l") - compile_flags, link_flags, lib_dirs, libs = arduino8266._project_flags( - arduino8266._unflag_tokens(), arduino8266._lexed_build_flags() - ) + compile_flags, link_flags, lib_dirs, libs = _split_flags() assert "Ignoring trailing '-l'" in caplog.text assert not libs assert not lib_dirs @@ -632,9 +640,7 @@ def test_project_flags_trailing_bare_linker_flag_warns( def test_project_flags_lexed_entry_scatters_non_linker_tokens() -> None: _set_flags("-L /d -Wl,-Map=m stray") - compile_flags, link_flags, lib_dirs, libs = arduino8266._project_flags( - arduino8266._unflag_tokens(), arduino8266._lexed_build_flags() - ) + compile_flags, link_flags, lib_dirs, libs = _split_flags() assert lib_dirs == [Path("/d")] assert link_flags == ["-Wl,-Map=m"] assert "stray" in compile_flags @@ -644,7 +650,7 @@ def test_project_flags_lexed_entry_scatters_non_linker_tokens() -> None: def test_flag_defines_lexes_multi_token_entries() -> None: """A knob inside a multi-token entry is detected like PlatformIO does.""" _set_flags("-DPIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH -DFOO=1 -Os") - defines = _flag_defines(set(), arduino8266._lexed_build_flags()) + defines = _defines() assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines assert defines["FOO"] == "FOO=1" config = _resolve_build_config(defines) @@ -654,9 +660,7 @@ def test_flag_defines_lexes_multi_token_entries() -> None: def test_project_flags_lexes_every_entry() -> None: """A linker flag anywhere in an entry reaches the link line (PIO parity).""" _set_flags("-DFOO=1 -lbar") - compile_flags, _link, _dirs, libs = arduino8266._project_flags( - arduino8266._unflag_tokens(), arduino8266._lexed_build_flags() - ) + compile_flags, _link, _dirs, libs = _split_flags() assert libs == ["bar"] assert "-DFOO=1" in compile_flags @@ -665,9 +669,7 @@ def test_project_flags_unflags_match_tokens() -> None: """build_unflags removes a token embedded in a multi-token entry.""" _set_flags("-Os -g3") CORE.build_unflags = {"-Os"} - compile_flags, _link, _dirs, _libs = arduino8266._project_flags( - arduino8266._unflag_tokens(), arduino8266._lexed_build_flags() - ) + compile_flags, _link, _dirs, _libs = _split_flags() assert "-g3" in compile_flags assert "-Os" not in compile_flags @@ -675,9 +677,7 @@ def test_project_flags_unflags_match_tokens() -> None: def test_project_flags_requotes_lexed_defines() -> None: """A quoted spaced value stays one compiler argument after lex/emit.""" _set_flags('-DGREETING="hello world"') - compile_flags, _link, _dirs, _libs = arduino8266._project_flags( - arduino8266._unflag_tokens(), arduino8266._lexed_build_flags() - ) + compile_flags, _link, _dirs, _libs = _split_flags() # shlex folds the quotes (as PIO's ParseFlags does); _shell_token # re-quotes the spaced token so the shell passes one argv element assert compile_flags == [_shq("-DGREETING=hello world")] @@ -697,7 +697,7 @@ def test_write_project_empty_core_raises(tmp_path: Path) -> None: def test_flag_defines_joins_spaced_define() -> None: """A spaced "-D KNOB" entry is detected exactly as PlatformIO detects it.""" _set_flags("-D PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH") - defines = _flag_defines(set(), arduino8266._lexed_build_flags()) + defines = _defines() assert "PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH" in defines assert "" not in defines @@ -780,20 +780,14 @@ def test_build_config_custom_mmu_without_knob_raises() -> None: def test_flag_defines_lexes_quoted_single_tokens() -> None: """A quoted single-token define reads the same as on the compile line.""" _set_flags('-DMMU_SEC_HEAP="0x40108000"') - assert ( - _flag_defines(set(), arduino8266._lexed_build_flags())["MMU_SEC_HEAP"] - == "MMU_SEC_HEAP=0x40108000" - ) + assert _defines()["MMU_SEC_HEAP"] == "MMU_SEC_HEAP=0x40108000" def test_flag_defines_duplicate_defines_resolve_deterministically() -> None: """Duplicate conflicting defines pick the same winner every run (sorted iteration, last writer wins), independent of the set's hash seed.""" _set_flags("-DMMU_IRAM_SIZE=0x8000", "-DMMU_IRAM_SIZE=0xC000") - assert ( - _flag_defines(set(), arduino8266._lexed_build_flags())["MMU_IRAM_SIZE"] - == "MMU_IRAM_SIZE=0xC000" - ) + assert _defines()["MMU_IRAM_SIZE"] == "MMU_IRAM_SIZE=0xC000" def test_flag_tables_match_platformio_builder() -> None: @@ -995,9 +989,7 @@ def test_build_config_mmu_defines_do_not_alias_the_table() -> None: module table for later builds in the same process.""" config = _resolve("-DPIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48") config.mmu_defines.append("MMU_BOGUS") - again = _resolve_build_config( - _flag_defines(set(), arduino8266._lexed_build_flags()) - ) + again = _resolve_current() assert "MMU_BOGUS" not in again.mmu_defines assert all(isinstance(v, tuple) for v in arduino8266._MMU_VARIANTS.values()) @@ -1043,7 +1035,7 @@ def test_generate_ld_scripts_header_change_invalidates_stamp( paths = _make_framework(tmp_path) header = paths.framework / "tools" / "sdk" / "ld" / "eagle.app.v6.common.ld.h" header.write_text("v1") - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with patch.object(arduino8266.subprocess, "run", return_value=result): _run_generate_ld_scripts(paths) header.write_text("v2 (longer)") @@ -1055,7 +1047,7 @@ def test_generate_ld_scripts_header_change_invalidates_stamp( def test_generate_ld_scripts_unreadable_stamp_regenerates(tmp_path: Path) -> None: """A non-UTF-8 stamp is a damaged cache: regenerate, never abort.""" paths = _make_framework(tmp_path) - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with patch.object(arduino8266.subprocess, "run", return_value=result): ld_dir = _run_generate_ld_scripts(paths) (ld_dir / ".local.eagle.app.v6.common.ld.stamp").write_bytes(b"\xff\xfe") @@ -1091,19 +1083,21 @@ def test_write_generated_replaces_damaged_file( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A non-UTF-8 existing copy is logged and overwritten; the write path - still raises for real failures.""" + still raises for real failures (now the shared helper's contract).""" + from esphome.helpers import write_file_if_changed + target = tmp_path / "gen.ld" target.write_bytes(b"\xff\xfe") - arduino8266._write_generated(target, "SECTIONS { }") + write_file_if_changed(target, "SECTIONS { }") assert target.read_text(encoding="utf-8") == "SECTIONS { }" - assert "Replacing damaged generated file" in caplog.text + assert "Replacing damaged file" in caplog.text def test_generate_ld_scripts_edited_output_regenerates(tmp_path: Path) -> None: """The stamp records the content hash, so an externally edited cached script regenerates instead of linking untrusted content.""" paths = _make_framework(tmp_path) - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with patch.object(arduino8266.subprocess, "run", return_value=result): ld_dir = _run_generate_ld_scripts(paths) output = ld_dir / "local.eagle.app.v6.common.ld" @@ -1118,7 +1112,7 @@ def test_generate_ld_scripts_corrupt_output_is_overwritten(tmp_path: Path) -> No """A non-UTF-8 cached script must be overwritten by the regeneration, not abort it (write_file_if_changed reads the old content).""" paths = _make_framework(tmp_path) - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with patch.object(arduino8266.subprocess, "run", return_value=result): ld_dir = _run_generate_ld_scripts(paths) output = ld_dir / "local.eagle.app.v6.common.ld" @@ -1133,7 +1127,7 @@ def test_generate_ld_scripts_unreadable_note_still_warns( ) -> None: """A cached diagnostic that cannot be read must not vanish silently.""" paths = _make_framework(tmp_path) - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="warn!") + result = _ok_result(stderr="warn!") with patch.object(arduino8266.subprocess, "run", return_value=result): ld_dir = _run_generate_ld_scripts(paths) (ld_dir / ".local.eagle.app.v6.common.ld.stderr").write_bytes(b"\xff\xfe") @@ -1182,7 +1176,7 @@ def test_mmu_custom_valueless_switch_accepted_and_others_validated() -> None: "-DMMU_IRAM_HEAP", ) assert "MMU_IRAM_HEAP" in config.mmu_defines - with pytest.raises(EsphomeError, match="MMU_SEC_HEAP_SIZE must be a hex"): + with pytest.raises(EsphomeError, match="MMU_SEC_HEAP_SIZE must be a numeric"): _resolve( "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", "-DMMU_IRAM_SIZE=0x8000", @@ -1191,6 +1185,19 @@ def test_mmu_custom_valueless_switch_accepted_and_others_validated() -> None: ) +def test_mmu_custom_accepts_decimal_non_segment_values() -> None: + """MMU_EXTERNAL_HEAP=128 (the module's own EXTERNAL_128K shape) is a + mmu_iram.h count, not a segment length; decimal is legal there while + the two segment sizes stay hex-only for the surgery parser.""" + config = _resolve( + "-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM", + "-DMMU_IRAM_SIZE=0x8000", + "-DMMU_ICACHE_SIZE=0x8000", + "-DMMU_EXTERNAL_HEAP=128", + ) + assert "MMU_EXTERNAL_HEAP=128" in config.mmu_defines + + def test_mmu_no_knob_rejects_any_raw_mmu_flag() -> None: """The no-knob branch refuses every raw MMU_*, like the knob branch; a lone switch would win the compile line but not the linker script.""" @@ -1301,7 +1308,7 @@ def test_generate_ld_scripts_testing_surgery_failure_is_named( named error, like the ratetable surgery.""" paths = _make_framework(tmp_path) CORE.testing_mode = True - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with ( patch.object(arduino8266.subprocess, "run", return_value=result), patch.object( @@ -1323,7 +1330,7 @@ def test_generate_ld_scripts_testing_flash_ld_surgery_failure_is_named( "MEMORY { }" ) CORE.testing_mode = True - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with ( patch.object(arduino8266.subprocess, "run", return_value=result), patch.object( @@ -1369,7 +1376,7 @@ def test_generate_ld_scripts_unreadable_header_forces_regeneration( raise PermissionError(13, "denied") return real_stat(self, **kwargs) - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with patch.object(Path, "stat", fake_stat): with patch.object( arduino8266.subprocess, "run", return_value=result @@ -1402,7 +1409,7 @@ def test_generate_ld_scripts_gcc_change_invalidates_stamp(tmp_path: Path) -> Non paths = _make_framework(tmp_path) gcc = toolchain_tool(paths.toolchain, "gcc") gcc.write_text("v1") - result = MagicMock(returncode=0, stdout=_COMMON_LD_H_OUTPUT, stderr="") + result = _ok_result() with patch.object(arduino8266.subprocess, "run", return_value=result): _run_generate_ld_scripts(paths) gcc.write_text("v2 (longer)") diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 927f561236..fcf5931245 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -690,6 +690,13 @@ def test_prefetch_wave_unknown_size_falls_back_to_sequential( assert "No Content-Length for https://x/b.tar.gz" in caplog.text +def test_raise_on_empty_arg_flags() -> None: + """A surviving bare flag means an empty glued argument; reject by name.""" + with pytest.raises(EsphomeError, match=r"build_flags contain empty-argument"): + lib.raise_on_empty_arg_flags(["-DFOO", "-D", "-l"], "build_flags") + lib.raise_on_empty_arg_flags(["-DFOO", "-Iinc"], "build_flags") + + def test_content_lengths_head_requests(monkeypatch: pytest.MonkeyPatch) -> None: """Sizes come from HEAD Content-Length; a failing HEAD reads as 0 so the combined bar is skipped rather than wrong."""