diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index ef87369a72..e360d763bc 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -221,7 +221,20 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary: "run for bundled libraries", name, ) - return _library_info(name, lib_dir, data) + lib = _library_info(name, lib_dir, data) + if not lib.sources and not any( + p.suffix in (".h", ".hpp", ".hh", ".inc") + for d in lib.include_dirs + for p in d.rglob("*") + ): + # An empty or half-extracted bundled directory would otherwise + # become a silent no-op that surfaces as undefined symbols at link + _LOGGER.warning( + "Bundled library %s has no sources or headers; the framework " + "install may be incomplete (run 'esphome clean-all')", + name, + ) + return lib def resolve_libraries( @@ -272,6 +285,8 @@ def resolve_libraries( # "skipping" warning teaches users to ignore the real one) external_short_names = {lib.name.split("/")[-1] for lib in external if lib.name} + pending_drops: list[tuple[str, str]] = [] + def _add_bundled_dependencies(component: ConvertedLibrary) -> None: # A version-less bare-name dependency ("Hash" in ESPAsyncWebServer) # is a core-bundled library; the shared converter skips it because @@ -327,15 +342,10 @@ def resolve_libraries( ) continue if not bundled_dir.is_dir(): - # The shared converter skips version-less deps too, so this - # is the only place the drop can be made visible before the - # missing sources surface as link errors. - _LOGGER.warning( - "Dependency %s of library %s is not bundled with the " - "framework and has no version to resolve; skipping", - name, - component.name, - ) + # Deferred: the walk may still resolve this name as another + # library's transitive registry dependency, and a false + # "skipping" warning teaches users to ignore the real one + pending_drops.append((name, component.name)) continue try: check_library_data(dep, pio_platform, "arduino") @@ -406,4 +416,18 @@ def resolve_libraries( f"{', '.join(dropped) or 'unknown'})" ) + # The shared converter skips version-less deps too, so this is the only + # place a genuine drop can be made visible before the missing sources + # surface as link errors; names the walk resolved anyway stay quiet. + resolved_short_names = {c.name.split("__")[-1] for c in converted} + for name, requester in pending_drops: + if name in bundled_names or name in resolved_short_names: + continue + _LOGGER.warning( + "Dependency %s of library %s is not bundled with the framework " + "and has no version to resolve; skipping", + name, + requester, + ) + return bundled + converted diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 8965939d9e..92bec64ec2 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -628,15 +628,21 @@ def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]: return out -def normalize_dependencies(dependencies: Any) -> list[dict]: +def normalize_dependencies(dependencies: Any, owner: str = "manifest") -> list[dict]: """Normalize a library manifest's ``dependencies`` to a list of dicts. - PIO's library.json accepts both the list-of-dicts form and the shorthand - dict form (``{"owner/Name": "version_spec"}``); normalize the latter so - callers see a uniform list. + PIO's library.json accepts the list-of-dicts form, the shorthand dict + form (``{"owner/Name": "version_spec"}``), bare name strings inside the + list, and a plain (possibly comma-separated) string; normalize them all + so callers see a uniform list. ``owner`` names the manifest in the + warning for entries that cannot be normalized. """ if not dependencies: return [] + if isinstance(dependencies, str): + # A plain string is one or more comma-separated names; iterating it + # as a list would shred it into one-character "libraries" + return [{"name": n.strip()} for n in dependencies.split(",") if n.strip()] if isinstance(dependencies, dict): normalized = [] for raw_name, spec in dependencies.items(): @@ -660,6 +666,10 @@ def normalize_dependencies(dependencies: Any) -> list[dict]: # ["Wire"]); dropping them here would hide a real dependency # from every caller's visibility warning normalized.append({"name": entry}) + else: + _LOGGER.warning( + "Ignoring unrecognized dependency entry %r of %s", entry, owner + ) return normalized @@ -973,8 +983,17 @@ def convert_libraries( # Requirements changed (we got past the short-circuit above), so # (re)walk this component's dependencies. node.edges = set() - for dependency in normalize_dependencies(component.data.get("dependencies")): + for dependency in normalize_dependencies( + component.data.get("dependencies"), component.name + ): if "name" not in dependency or "version" not in dependency: + # Version-less deps cannot resolve from the registry; the + # arduino backend picks bundled ones up after emit + _LOGGER.debug( + "Skip version-less dependency %r of %s", + dependency.get("name"), + component.name, + ) continue try: check_library_data(dependency, backend.platform, backend.framework) diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index 0ae772b4ff..2351143622 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -920,3 +920,48 @@ def test_pinned_bundled_dependency_substitution_warns( ) assert "Wire" in [lib.name for lib in libs] assert "pins version ^2.0.0; using the library bundled" in caplog.text + + +def test_transitively_resolved_dependency_does_not_warn( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A version-less dependency the walk resolved as another library's + registry dependency is present in the build; the skipping warning must + stay quiet for it.""" + framework = _make_framework(tmp_path) + _add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") + ws_dir = tmp_path / "converted" / "webserver" + (ws_dir / "src").mkdir(parents=True) + tcp_dir = tmp_path / "converted" / "tcp" + (tcp_dir / "src").mkdir(parents=True) + ws = _converted( + "esp32async__ESPAsyncWebServer", + ws_dir, + {"build": {}, "dependencies": [{"name": "ESPAsyncTCP"}]}, + ) + tcp = _converted("esp32async__ESPAsyncTCP", tcp_dir, {"build": {}}) + with _emitting_converter(ws, tcp): + component.resolve_libraries( + framework, + pio_platform="espressif8266", + board_mcu="esp8266", + cache_key="arduino8266", + ) + assert "is not bundled with the framework" not in caplog.text + + +def test_empty_bundled_library_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A bundled directory with no sources or headers is a broken install, + not a silent no-op archive.""" + framework = _make_framework(tmp_path) + (framework / "libraries" / "Empty").mkdir() + _add_library("Empty", None) + component.resolve_libraries( + framework, + pio_platform="espressif8266", + board_mcu="esp8266", + cache_key="arduino8266", + ) + assert "Bundled library Empty has no sources or headers" in caplog.text diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 401d1575b2..f3ca4d0170 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -592,12 +592,18 @@ def test_source_kind_map_shape() -> None: assert SOURCE_KIND_FOR_SUFFIX[".cpp"] == "cxx" -def test_normalize_dependencies_string_entries() -> None: +def test_normalize_dependencies_forms(caplog) -> None: + """Every PIO-legal spelling normalizes; unrecognizable entries warn.""" 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, ""]) == [ + assert normalize_dependencies(["Wire", {"name": "SPI"}, 5, ""], "libx") == [ {"name": "Wire"}, {"name": "SPI"}, ] + assert caplog.text.count("unrecognized dependency entry") == 2 + # A plain string is names, never iterated into characters + assert normalize_dependencies("Wire, SPI") == [ + {"name": "Wire"}, + {"name": "SPI"}, + ] + assert normalize_dependencies("Wire") == [{"name": "Wire"}]