Surface every dependency-drop path the walk can take

The converted path warns for a properties-only depends= spelling (the
walk reads the JSON key). A dependency name is validated before
becoming a path component: non-strings and separator names are
malformed entries, never joined (apply_extra_script already guards the
same shape). normalize_dependencies coerces PIO's bare string-list form
to name dicts instead of dropping it before every visibility warning. A
non-* version pin discarded for a backend-provided bundled copy warns
naming the substitution. The -I global-include promotion and the
start-group link-order contract are documented.
This commit is contained in:
J. Nick Koston
2026-08-22 00:18:35 -05:00
parent 44b14d8655
commit 35c77165ca
4 changed files with 163 additions and 4 deletions
+25 -2
View File
@@ -13,7 +13,9 @@ libraries get the recursive default source filter rather than PlatformIO's
root-only Arduino-1.0 filter (no bundled library is affected), and the
Arduino ``dot_a_linkage`` property is honored even though PlatformIO
ignores it. Bundled libraries never run a manifest ``extraScript`` (a
warning names the library if one declares it).
warning names the library if one declares it). Manifest ``-I`` build
flags join the global include path rather than staying private to the
library's own sources as under PlatformIO.
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
@@ -230,6 +232,10 @@ def resolve_libraries(
``pio_platform``/``board_mcu`` filter manifests the way PlatformIO would
for that core (e.g. ``espressif8266``/``esp8266``); ``cache_key`` keys the
shared converter's download cache.
The returned order is unordered with respect to link dependencies
(bundled dependencies precede their dependents); the caller must link
the archives inside one ``--start-group``/``--end-group`` pair.
"""
bundled: list[ArduinoLibrary] = []
external: list[Library] = []
@@ -270,9 +276,26 @@ def resolve_libraries(
# A version-less bare-name dependency ("Hash" in ESPAsyncWebServer)
# is a core-bundled library; the shared converter skips it because
# it cannot be resolved from the registry.
if not component.data.get("dependencies") and component.data.get("depends"):
# A properties-only manifest spells dependencies depends=; the
# walk below reads the JSON key, so those are not resolved
_LOGGER.warning(
"Library %s declares dependencies via library.properties "
"depends=, which are not resolved automatically; add them "
"with add_library() if needed",
component.name,
)
for dep in normalize_dependencies(component.data.get("dependencies")):
name = dep.get("name")
if not name:
if (
not name
or not isinstance(name, str)
or "/" in name
or "\\" in name
or name in (".", "..")
):
# The name becomes a path component under the framework
# tree; never join a traversal or a non-string
_LOGGER.warning(
"Ignoring malformed dependency entry %r of library %s",
dep,
+21 -1
View File
@@ -651,7 +651,16 @@ def normalize_dependencies(dependencies: Any) -> list[dict]:
entry["version"] = spec
normalized.append(entry)
return normalized
return [d for d in dependencies if isinstance(d, dict)]
normalized = []
for entry in dependencies:
if isinstance(entry, dict):
normalized.append(entry)
elif isinstance(entry, str) and entry:
# PIO also accepts a bare list of names ("dependencies":
# ["Wire"]); dropping them here would hide a real dependency
# from every caller's visibility warning
normalized.append({"name": entry})
return normalized
@dataclass
@@ -985,6 +994,17 @@ def convert_libraries(
):
# The backend adds it from its own tree; resolving it here
# would fetch a same-named registry package instead
if (pin := dependency.get("version")) and pin != "*":
# The declared constraint is discarded for the bundled
# copy; a too-old bundled library must not surface as
# link errors with no stated cause
_LOGGER.warning(
"Dependency %s pins version %s; using the library "
"bundled with the framework instead",
dep_name,
pin,
)
else:
_LOGGER.debug("Skip backend-provided dependency %s", dep_name)
continue
# The version field may actually be a URL (git/archive dependency).
+105
View File
@@ -815,3 +815,108 @@ def test_emit_validates_manifest_before_extra_script(tmp_path: Path) -> None:
cache_key="arduino8266",
)
mock_extra.assert_not_called()
def test_converted_properties_depends_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A converted library shipping only the properties depends= spelling
is visible, not a silent bundled-dependency drop."""
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": {}, "depends": "Wire,SPI"},
)
with _emitting_converter(converted):
component.resolve_libraries(
framework,
pio_platform="espressif8266",
board_mcu="esp8266",
cache_key="arduino8266",
)
assert "declares dependencies via library.properties" in caplog.text
@pytest.mark.parametrize("bad_name", [1, "../escape", "a/b", ".."])
def test_bundled_dependency_bad_name_is_malformed(
tmp_path: Path, bad_name: object, caplog: pytest.LogCaptureFixture
) -> None:
"""A dependency name becomes a path component; a traversal or a
non-string is a malformed entry, never joined."""
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": [{"name": bad_name}]},
)
with _emitting_converter(converted):
component.resolve_libraries(
framework,
pio_platform="espressif8266",
board_mcu="esp8266",
cache_key="arduino8266",
)
assert "Ignoring malformed dependency entry" in caplog.text
def test_bundled_dependency_string_list_form(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The bare string-list dependency form (PIO-legal) resolves to the
bundled library instead of vanishing in normalization."""
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": ["Wire"]},
)
with _emitting_converter(converted):
libs = component.resolve_libraries(
framework,
pio_platform="espressif8266",
board_mcu="esp8266",
cache_key="arduino8266",
)
assert "Wire" in [lib.name for lib in libs]
def test_pinned_bundled_dependency_substitution_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A non-* version pin on a backend-provided dependency is discarded
for the bundled copy; the substitution must be visible."""
import esphome.platformio.library as pio_library
framework = _make_framework(tmp_path)
local_lib = tmp_path / "locallib"
(local_lib / "src").mkdir(parents=True)
(local_lib / "src" / "local.cpp").write_text("")
(local_lib / "library.json").write_text(
'{"name": "LocalLib", "version": "1.0.0", "dependencies": {"Wire": "^2.0.0"}}'
)
_add_library(local_lib.as_uri(), None)
CORE.config_path = tmp_path / "test.yaml"
CORE.config_path.write_text("")
with patch.object(
pio_library,
"_resolve_registry_version",
side_effect=AssertionError("registry touched"),
):
libs = component.resolve_libraries(
framework,
pio_platform="espressif8266",
board_mcu="esp8266",
cache_key="arduino8266",
)
assert "Wire" in [lib.name for lib in libs]
assert "pins version ^2.0.0; using the library bundled" in caplog.text
@@ -590,3 +590,14 @@ def test_source_kind_map_shape() -> None:
assert SOURCE_KIND_FOR_SUFFIX[".S"] == "asm"
assert SOURCE_KIND_FOR_SUFFIX[".c"] == "c"
assert SOURCE_KIND_FOR_SUFFIX[".cpp"] == "cxx"
def test_normalize_dependencies_string_entries() -> None:
from esphome.platformio.library import normalize_dependencies
"""PIO's bare string-list form coerces to name dicts; other non-dict
entries still drop."""
assert normalize_dependencies(["Wire", {"name": "SPI"}, 5, ""]) == [
{"name": "Wire"},
{"name": "SPI"},
]