diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index f1871c7302..a5d4a6f515 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -99,14 +99,18 @@ def _resolve_src_dir(name: str, read_path: Path, build: dict) -> str: def _reject_unsupported_link_fields(name: str, data: dict) -> None: - for dropped_key in ("precompiled", "ldflags"): - if data.get(dropped_key): - # PIO's Arduino lib builder honors these; building without them - # would fail at link with no stated cause - raise EsphomeError( - f"Library {name} declares {dropped_key}, which this backend " - "does not support" - ) + # PIO's Arduino lib builder honors these; building without them would + # fail at link with no stated cause. library.properties values are + # strings, so "false" (the spec's explicit opt-out) is not a declaration. + precompiled = data.get("precompiled") + if precompiled and str(precompiled).strip().lower() != "false": + raise EsphomeError( + f"Library {name} declares precompiled, which this backend does not support" + ) + if data.get("ldflags"): + raise EsphomeError( + f"Library {name} declares ldflags, which this backend does not support" + ) def _resolve_lib_archive(name: str, data: dict, build: dict) -> bool: @@ -218,18 +222,14 @@ def _collect_lib_sources( len(dropped), ", ".join(sorted(dropped)), ) - if ( - not lib.sources - and ("srcFilter" in build or "srcDir" in build) - and not any(Path(f).suffix.lower() in LIBRARY_HEADER_SUFFIXES for f in matched) + if not lib.sources and not any( + Path(f).suffix.lower() in LIBRARY_HEADER_SUFFIXES for f in matched ): - # Matched headers mean a header-only library; a declared filter - # matching nothing (or only inert files) is a manifest/tree problem. - # The truly empty tree raises via _assert_tree_has_code. - _LOGGER.warning( - "Library %s declares srcFilter/srcDir but no source files matched", - name, - ) + # Matched headers mean a header-only library; a filter matching + # nothing (or only inert files) is a manifest/tree problem whether + # or not it was declared. The truly empty tree raises via + # _assert_tree_has_code. + _LOGGER.warning("Library %s: no source files matched", name) def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary: diff --git a/esphome/build_gen/build_tool.py b/esphome/build_gen/build_tool.py index dbf988c1c7..d2497acce6 100644 --- a/esphome/build_gen/build_tool.py +++ b/esphome/build_gen/build_tool.py @@ -67,9 +67,11 @@ def _run_ar(ar: str, archive: str, rspfile: str) -> int: def _run_copy(src: str, dst: str) -> int: try: shutil.copyfile(src, dst) - except OSError: - # Never leave a partially written output (e.g. a firmware image) - Path(dst).unlink(missing_ok=True) + except OSError as err: + # Never leave a partially written output (e.g. a firmware image); + # SameFileError means dst IS src, where unlinking destroys the input + if not isinstance(err, shutil.SameFileError): + Path(dst).unlink(missing_ok=True) raise return 0 diff --git a/tests/unit_tests/build_gen/test_build_tool.py b/tests/unit_tests/build_gen/test_build_tool.py index 5698e8521e..05bc2483de 100644 --- a/tests/unit_tests/build_gen/test_build_tool.py +++ b/tests/unit_tests/build_gen/test_build_tool.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +import shutil import subprocess import sys from unittest.mock import MagicMock, patch @@ -188,6 +189,20 @@ def test_surplus_arguments_error(capsys: pytest.CaptureFixture[str]) -> None: assert "expected 2 arguments, got 3" in capsys.readouterr().err +def test_copy_same_file_keeps_the_input(tmp_path: Path) -> None: + """A same-file copy (dst IS src) must not unlink the input.""" + src = tmp_path / "firmware.bin" + src.write_bytes(b"image") + with ( + patch.object( + build_tool.sys, "argv", ["build_tool", "copy", str(src), str(src)] + ), + pytest.raises(shutil.SameFileError), + ): + build_tool.main() + assert src.read_bytes() == b"image" + + def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None: """A failed copy unlinks the destination; a partial firmware image must never be left on disk.""" diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index c99a62816f..9a349c2ea6 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -189,7 +189,7 @@ def test_library_info_declared_filter_matches_nothing_warns( data = {"build": {"srcFilter": ["+"]}} lib = component._library_info("x", read_path, data) assert not lib.sources - assert "declares srcFilter/srcDir but no source files matched" in caplog.text + assert "no source files matched" in caplog.text def test_empty_converted_tree_raises_at_emit(tmp_path: Path) -> None: @@ -606,6 +606,44 @@ def test_library_info_unsupported_link_fields_raise(tmp_path: Path) -> None: component._library_info("x", read_path, {"ldflags": "-lfoo", "build": {}}) +@pytest.mark.parametrize("value", ["false", "False", " false ", "", False, None]) +def test_library_info_precompiled_opt_out_accepted( + tmp_path: Path, value: object +) -> None: + """Manifest values are strings; precompiled=false is the spec's + explicit opt-out, not a declaration.""" + read_path = tmp_path / "lib" + (read_path / "src").mkdir(parents=True) + (read_path / "src" / "stub.cpp").write_text("") + data = {"build": {}} + if value is not None: + data["precompiled"] = value + component._library_info("x", read_path, data) + + +@pytest.mark.parametrize("value", ["full", True, "weird"]) +def test_library_info_precompiled_set_raises(tmp_path: Path, value: object) -> None: + """Both full (Arduino's other legal value) and unknown spellings fail safe.""" + read_path = tmp_path / "lib" + (read_path / "src").mkdir(parents=True) + (read_path / "src" / "stub.cpp").write_text("") + with pytest.raises(EsphomeError, match="declares precompiled"): + component._library_info("x", read_path, {"precompiled": value, "build": {}}) + + +def test_library_info_default_filter_matching_nothing_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The empty-match warning is not gated on a declared srcFilter/srcDir; + a default-filter src/ holding only inert files warns too.""" + read_path = tmp_path / "lib" + (read_path / "src").mkdir(parents=True) + (read_path / "src" / "keywords.txt").write_text("") + lib = component._library_info("x", read_path, {"build": {}}) + assert not lib.sources + assert "no source files matched" in caplog.text + + def test_library_info_unmapped_sources_warn( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: