Carry lib_archive, warn on unresolvable deps, unquote rsp lines, fail empty archives, note deviations

This commit is contained in:
J. Nick Koston
2026-08-20 20:41:17 -05:00
parent eb0cb06951
commit fdbd4157ff
4 changed files with 148 additions and 14 deletions
+41 -8
View File
@@ -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
+17 -6
View File
@@ -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]
@@ -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
+53
View File
@@ -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