From 35c77165ca74174aa0c58807ad2825abb68c570c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 00:18:35 -0500 Subject: [PATCH] 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. --- esphome/arduino/library.py | 27 ++++- esphome/platformio/library.py | 24 ++++- tests/unit_tests/test_arduino_library.py | 105 ++++++++++++++++++++ tests/unit_tests/test_platformio_library.py | 11 ++ 4 files changed, 163 insertions(+), 4 deletions(-) diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 75122ab6fc..ef87369a72 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -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, diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index b6172b0c41..8965939d9e 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -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,7 +994,18 @@ def convert_libraries( ): # The backend adds it from its own tree; resolving it here # would fetch a same-named registry package instead - _LOGGER.debug("Skip backend-provided dependency %s", dep_name) + 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). dep_version = dependency["version"] diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index 4a593413e3..0ae772b4ff 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -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 diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 7788c87383..401d1575b2 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -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"}, + ]