diff --git a/esphome/platformio/extra_script.py b/esphome/platformio/extra_script.py index 4f6a7df29f..422cb84ee5 100644 --- a/esphome/platformio/extra_script.py +++ b/esphome/platformio/extra_script.py @@ -192,13 +192,17 @@ def run_extra_script( pio_env=f"esphome_{board_mcu}", pio_platform=pio_platform, ) + try: + source = script_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as err: + # An unreadable declared script is a broken package, exactly like a + # missing one; must not be quieter than that case + raise EsphomeError(f"extraScript {script_path} is unreadable: {err}") from err old_cwd = Path.cwd() try: - # Inside the try: a SyntaxError or bad encoding in a vendored script - # is just as best-effort as a runtime failure - code = compile( - script_path.read_text(encoding="utf-8"), str(script_path), "exec" - ) + # Inside the try: a SyntaxError in a vendored script is just as + # best-effort as a runtime failure + code = compile(source, str(script_path), "exec") os.chdir(library_dir) exec( # noqa: S102 pylint: disable=exec-used code, @@ -209,7 +213,19 @@ def run_extra_script( "__name__": "__pio_extra_script__", }, ) - except (SystemExit, Exception) as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + except SystemExit as e: + if not e.code: + # sys.exit() / sys.exit(0) is a normal PlatformIO script ending; + # the capture is complete + return env.result + _LOGGER.warning( + "PIO extra-script %s (in %s) exited with status %r; ignoring its output", + script_path, + library_dir.name, + e.code, + ) + return ExtraScriptResult() + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught # Discard any partial capture: folding half a script's flags into the # build could produce wrong-output firmware that links cleanly. The # warning plus the resulting loud link error point back here. diff --git a/tests/unit_tests/test_platformio_extra_script.py b/tests/unit_tests/test_platformio_extra_script.py index ea352a8561..753fda45a9 100644 --- a/tests/unit_tests/test_platformio_extra_script.py +++ b/tests/unit_tests/test_platformio_extra_script.py @@ -302,13 +302,40 @@ def test_unsupported_env_method_warns_once(caplog) -> None: def test_run_extra_script_sys_exit_is_best_effort(tmp_path, caplog) -> None: - """sys.exit() in a vendored script must not kill the esphome run.""" + """A nonzero sys.exit() in a vendored script must not kill the esphome + run, and its output is discarded.""" from esphome.platformio.extra_script import run_extra_script script = tmp_path / "extra.py" - script.write_text("import sys\nsys.exit(3)\n") + script.write_text("import sys\nenv.Append(LIBS=['x'])\nsys.exit(3)\n") result = run_extra_script( script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32" ) assert result.libs == [] - assert "ignoring its output" in caplog.text + assert "exited with status 3" in caplog.text + + +def test_run_extra_script_sys_exit_zero_is_success(tmp_path, caplog) -> None: + """sys.exit(0) is a normal PlatformIO script ending: the capture is kept.""" + from esphome.platformio.extra_script import run_extra_script + + script = tmp_path / "extra.py" + script.write_text("import sys\nenv.Append(LIBS=['algobsec'])\nsys.exit(0)\n") + result = run_extra_script( + script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32" + ) + assert result.libs == ["algobsec"] + assert "ignoring its output" not in caplog.text + + +def test_run_extra_script_unreadable_raises(tmp_path) -> None: + """An unreadable declared script is a broken package, like a missing one.""" + from esphome.core import EsphomeError + from esphome.platformio.extra_script import run_extra_script + + script = tmp_path / "extra.py" + script.write_bytes(b"\xff\xfe\x00bad") + with pytest.raises(EsphomeError, match="is unreadable"): + run_extra_script( + script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32" + )