From 2521f29782e8c994e5cba95e2f477e2c3a50a2ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 22:09:36 -0500 Subject: [PATCH] Apply the 18559 simplify pass: surgery wrapper, shared clean hint, resilient write helper, unified knob lookups, test scaffolding --- esphome/build_gen/arduino8266.py | 137 +++++++++--------- esphome/helpers.py | 10 +- .../unit_tests/build_gen/test_arduino8266.py | 128 ++++++++-------- 3 files changed, 135 insertions(+), 140 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index fddf9d4fa5..604bc0fb88 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -28,7 +28,7 @@ from esphome.build_helpers.ninja import shell_token as _shell_token from esphome.components.esp8266 import build_surgery from esphome.core import CORE, EsphomeError from esphome.helpers import mkdir_p, write_file_if_changed -from esphome.platformio.library import BARE_ARG_FLAGS, lex_build_flags +from esphome.platformio.library import lex_build_flags, raise_on_empty_arg_flags if TYPE_CHECKING: from esphome.arduino8266.framework import InstalledPaths @@ -49,6 +49,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 + # From platformio-build.py. Knob suffix -> SDK define; the first entry is # the default (dicts preserve insertion order). With multiple SDK knobs set @@ -213,13 +233,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 @@ -242,11 +257,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 @@ -257,11 +275,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 @@ -496,21 +513,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: @@ -532,7 +534,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 @@ -584,9 +586,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(): @@ -599,24 +599,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()}", @@ -636,22 +626,25 @@ 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 + ) 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/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 0547a53eac..e79d549481 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -62,7 +62,33 @@ def _set_flags(*flags: str) -> None: 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: @@ -100,9 +126,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" @@ -119,9 +143,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", @@ -134,7 +156,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"], @@ -240,12 +262,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: @@ -272,9 +289,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") @@ -283,7 +298,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, @@ -318,7 +333,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" @@ -352,7 +367,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() @@ -365,7 +380,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"), @@ -380,21 +395,14 @@ 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_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 @@ -404,9 +412,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 @@ -416,7 +422,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) @@ -426,9 +432,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 @@ -437,9 +441,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 @@ -447,9 +449,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 expected = ( @@ -461,7 +461,7 @@ def test_project_flags_requotes_lexed_defines() -> 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 @@ -475,20 +475,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: @@ -647,9 +641,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()) @@ -695,7 +687,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)") @@ -707,7 +699,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") @@ -743,19 +735,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" @@ -770,7 +764,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" @@ -785,7 +779,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") @@ -928,7 +922,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( @@ -950,7 +944,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( @@ -996,7 +990,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 @@ -1024,7 +1018,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)")