diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 443438a725..1c5840cd10 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -8,6 +8,11 @@ resolution/download pipeline in ``esphome.platformio.library``. Nothing here is core-specific: the caller names the PlatformIO platform, MCU, and cache key of the Arduino core it builds. +Known deviation: flat-layout (``library.properties``, no ``src/``) libraries +get the recursive default source filter rather than PlatformIO's root-only +Arduino-1.0 filter; no bundled library is affected, only user-supplied ones +carrying sources in unusual subdirectories. + Mirrors PlatformIO's ``lib_ldf_mode=off`` behavior: each library builds into its own static archive and every library's include dir joins one global include path. @@ -52,6 +57,9 @@ class ArduinoLibrary: include_dirs: list[Path] = field(default_factory=list) # Extra compile flags private to this library's own sources flags: list[str] = field(default_factory=list) + # PlatformIO's build.libArchive / Arduino's dot_a_linkage: when False the + # objects go to the linker directly (symbols nothing references survive) + lib_archive: bool = True # Link inputs the library contributes (-L dirs / -l libs, e.g. from # precompiled vendor blobs) and -Wl, options for the firmware link link_dirs: list[Path] = field(default_factory=list) @@ -79,7 +87,15 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary: # PlatformIO shell-lexes each build.flags entry flag_tokens = lex_build_flags(build.get("flags", []), f"library {name}") - lib = ArduinoLibrary(name=name) + # PIO precedence: build.libArchive, else the Arduino-format + # dot_a_linkage property, else archive (PlatformIO's default) + if "libArchive" in build: + lib_archive = bool(build["libArchive"]) + elif "dot_a_linkage" in data: + lib_archive = str(data["dot_a_linkage"]).lower() == "true" + else: + lib_archive = True + lib = ArduinoLibrary(name=name, lib_archive=lib_archive) include_flags: list[str] = [] for tok in flag_tokens: if tok.startswith("-I"): @@ -190,13 +206,30 @@ def resolve_libraries( # it cannot be resolved from the registry. for dep in normalize_dependencies(component.data.get("dependencies")): name = dep.get("name") - if ( - not name - or dep.get("owner") - or "version" in dep - or name in bundled_names - or is_lib_ignored(name, lib_ignore) - ): + if not name: + _LOGGER.warning( + "Ignoring malformed dependency entry %r of library %s", + dep, + component.name, + ) + continue + if name in bundled_names or is_lib_ignored(name, lib_ignore): + continue + if "version" in dep: + # The converter resolves versioned deps from the registry. + # Note: the dict shorthand {"Wire": "*"} normalizes to + # version="*" and takes this path even for a bundled name; + # use the list form for bundled dependencies. + continue + if dep.get("owner"): + # Owner but no version: the converter skips it too, so this + # is the only place the drop can be made visible + _LOGGER.warning( + "Dependency %s of library %s has an owner but no version " + "to resolve; skipping", + name, + component.name, + ) continue if not (framework_path / "libraries" / name).is_dir(): # The shared converter skips version-less deps too, so this diff --git a/esphome/build_gen/build_tool.py b/esphome/build_gen/build_tool.py index 692d006661..4cdbb88c28 100644 --- a/esphome/build_gen/build_tool.py +++ b/esphome/build_gen/build_tool.py @@ -24,13 +24,24 @@ def main() -> int: # Expand the response file here instead of passing @rspfile: GNU ar # treats backslashes in response files as escapes, corrupting Windows # paths ("sub\a.o" -> "suba.o"). - # One path per line (rspfile_content = $in_newline, written without - # escaping), so a path containing a space survives. Expanding into - # argv trades away the OS command-line length limit rspfiles dodge; - # the relative object paths used here stay far below it. - objects = Path(rspfile).read_text(encoding="utf-8").splitlines() + # One path per line (rspfile_content = $in_newline). ninja shell-quotes + # a path containing specials, so undo a simple surrounding quote per + # line. Expanding into argv trades away the OS command-line length + # limit rspfiles dodge; the relative object paths here stay far + # below it. + objects = [ + line[1:-1] + if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\"" + else line + for line in Path(rspfile).read_text(encoding="utf-8").splitlines() + if line + ] + if not objects: + # An empty archive would "succeed" here and fail far away at link + print(f"ar: no objects listed in {rspfile} for {archive}", file=sys.stderr) + return 1 return subprocess.run( - [ar, "rc", archive, *filter(None, objects)], check=False, close_fds=False + [ar, "rc", archive, *objects], check=False, close_fds=False ).returncode if mode == "copy": src, dst = sys.argv[2:4] diff --git a/tests/unit_tests/build_gen/test_build_tool.py b/tests/unit_tests/build_gen/test_build_tool.py index 0c8eb00f9d..875ffcb773 100644 --- a/tests/unit_tests/build_gen/test_build_tool.py +++ b/tests/unit_tests/build_gen/test_build_tool.py @@ -87,3 +87,40 @@ def test_ar_expands_rspfile_without_escaping(tmp_path) -> None: "obj/a.o", "sub\\b.o", ] + + +def test_ar_unquotes_ninja_escaped_paths(tmp_path: Path) -> None: + """The shim strips a simple surrounding quote, since ninja shell- + quotes special rsp paths, so ar sees the real filename.""" + rsp = tmp_path / "t.rsp" + rsp.write_text("'obj/a b.o'\nobj/c.o\n") + with ( + patch.object( + build_tool.sys, "argv", ["bt", "ar", "/usr/bin/ar", "lib.a", str(rsp)] + ), + patch.object(build_tool.subprocess, "run") as mock_run, + ): + mock_run.return_value.returncode = 0 + rc = build_tool.main() + assert rc == 0 + assert mock_run.call_args.args[0] == [ + "/usr/bin/ar", + "rc", + "lib.a", + "obj/a b.o", + "obj/c.o", + ] + + +def test_ar_empty_object_list_fails( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A lost object list is an error here, not undefined symbols at link.""" + rsp = tmp_path / "t.rsp" + rsp.write_text("\n\n") + with patch.object( + build_tool.sys, "argv", ["bt", "ar", "/usr/bin/ar", "lib.a", str(rsp)] + ): + rc = build_tool.main() + assert rc == 1 + assert "no objects listed" in capsys.readouterr().err diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index a4c19f8c31..81e17490aa 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -356,3 +356,56 @@ def test_bundled_library_prefers_library_json(tmp_path: Path) -> None: ) lib = component._bundled_library(framework, "GDBStub") assert [s.name for s in lib.sources] == ["gdb.cpp"] + + +def test_library_info_lib_archive_flag(tmp_path: Path) -> None: + """Both libArchive (library.json) and dot_a_linkage (properties) reach + the generator's contract; default is archive.""" + read_path = tmp_path / "lib" + (read_path / "src").mkdir(parents=True) + assert component._library_info("x", read_path, {}).lib_archive is True + assert ( + component._library_info( + "x", read_path, {"build": {"libArchive": False}} + ).lib_archive + is False + ) + assert ( + component._library_info("x", read_path, {"dot_a_linkage": "false"}).lib_archive + is False + ) + assert ( + component._library_info("x", read_path, {"dot_a_linkage": "true"}).lib_archive + is True + ) + + +def test_resolve_libraries_dep_warnings( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """Nameless and owner-without-version dependencies are dropped loudly.""" + framework = _make_framework(tmp_path) + _add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") + lib_dir = tmp_path / "converted" / "webserver" + (lib_dir / "src").mkdir(parents=True) + converted = _converted( + "esp32async__ESPAsyncWebServer", + lib_dir, + { + "build": {}, + "dependencies": [ + {"owner": "someone"}, + {"name": "Orphan", "owner": "someone"}, + ], + }, + ) + with _emitting_converter(converted): + component.resolve_libraries( + framework, + pio_platform="espressif8266", + board_mcu="esp8266", + cache_key="arduino8266", + ) + assert "malformed dependency entry" in caplog.text + assert "Orphan" in caplog.text + assert "owner but no version" in caplog.text