diff --git a/esphome/__main__.py b/esphome/__main__.py index c641dff0c2..8e6d88f26e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -863,11 +863,18 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: try: if toolchain.get_idedata() is None: _LOGGER.warning("No idedata was generated for this build") - except (EsphomeError, OSError, RuntimeError, ValueError) as err: + except ( + EsphomeError, + LookupError, + OSError, + RuntimeError, + ValueError, + ) as err: # The firmware already built; idedata is a bonus artifact here. # Broad on purpose: a vanished compiler (OSError), a failed # include probe (RuntimeError), or a truncated compile DB - # (ValueError) must not fail a successful build either. + # (ValueError/LookupError) must not fail a successful build + # either. _LOGGER.warning("Could not generate idedata: %s", err) else: from esphome.platformio import toolchain diff --git a/esphome/platformio/extra_script.py b/esphome/platformio/extra_script.py index 422cb84ee5..4a0a0dc7c5 100644 --- a/esphome/platformio/extra_script.py +++ b/esphome/platformio/extra_script.py @@ -131,6 +131,7 @@ class _FakeSConsEnv: } self.result = ExtraScriptResult() self._warned_methods: set[str] = set() + self._warned_keys: set[str] = set() # ----- SCons env API the common scripts use ----- @@ -141,11 +142,14 @@ class _FakeSConsEnv: for key, value in kwargs.items(): if key not in _CAPTURED_KEYS: # Diagnosable from the build log when a script configures - # something this shim does not translate - _LOGGER.warning( - "PIO extra-script env.Append(%s=...) is not captured; ignoring", - key, - ) + # something this shim does not translate; once per key so a + # loop of Appends cannot spam + if key not in self._warned_keys: + self._warned_keys.add(key) + _LOGGER.warning( + "PIO extra-script env.Append(%s=...) is not captured; ignoring", + key, + ) continue items = list(value) if isinstance(value, (list, tuple)) else [value] bucket = getattr(self.result, key.lower()) @@ -194,10 +198,19 @@ def run_extra_script( ) try: source = script_path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError) as err: + except OSError 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 + except UnicodeDecodeError as e: + # A content problem, best-effort like a SyntaxError below + _LOGGER.warning( + "PIO extra-script %s (in %s) is not UTF-8 (%r); ignoring its output", + script_path, + library_dir.name, + e, + ) + return ExtraScriptResult() old_cwd = Path.cwd() try: # Inside the try: a SyntaxError in a vendored script is just as diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 53a2d7faac..be8209b7c9 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7265,6 +7265,7 @@ def test_command_idedata_incompatible_toolchain(tmp_path: Path) -> None: FileNotFoundError("no such compiler"), RuntimeError("Could not query builtin include dirs"), ValueError("no C++ translation unit found"), + KeyError("command"), None, # replaced with EsphomeError inside (import is function-local) ], ) diff --git a/tests/unit_tests/test_platformio_extra_script.py b/tests/unit_tests/test_platformio_extra_script.py index 753fda45a9..7e904183f1 100644 --- a/tests/unit_tests/test_platformio_extra_script.py +++ b/tests/unit_tests/test_platformio_extra_script.py @@ -330,12 +330,42 @@ def test_run_extra_script_sys_exit_zero_is_success(tmp_path, caplog) -> None: def test_run_extra_script_unreadable_raises(tmp_path) -> None: """An unreadable declared script is a broken package, like a missing one.""" + from unittest.mock import patch + 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"): + script.write_text("") + with ( + patch("pathlib.Path.read_text", side_effect=OSError("denied")), + pytest.raises(EsphomeError, match="is unreadable"), + ): run_extra_script( script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32" ) + + +def test_run_extra_script_bad_encoding_is_best_effort(tmp_path, caplog) -> None: + """Undecodable content warns and skips, like a SyntaxError.""" + from esphome.platformio.extra_script import run_extra_script + + script = tmp_path / "extra.py" + script.write_bytes(b"\xff\xfe\x00bad") + result = run_extra_script( + script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32" + ) + assert result.libs == [] + assert "is not UTF-8" in caplog.text + + +def test_uncaptured_append_key_warns_once(caplog) -> None: + """A loop of Appends to the same uncaptured key warns once.""" + from esphome.platformio.extra_script import _FakeSConsEnv + + env = _FakeSConsEnv( + board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266" + ) + env.Append(CPPPATH=["a"]) + env.Append(CPPPATH=["b"]) + assert caplog.text.count("env.Append(CPPPATH=...) is not captured") == 1