From 6cdc34178de11e81a21cf48050c4a34bc176326c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 22:22:48 -0500 Subject: [PATCH] Reconcile provides() promises against the final manifests only The walk records across re-resolutions, so a dependency a re-resolved manifest no longer declares would have failed the build by name. The reconciliation now intersects the recordings with the dependency names of the manifests actually emitted, keeping the raise for real broken promises. The manifest-name warning also gains the treeless-backend gate its sibling got, since those backends cannot act on it either. --- esphome/arduino/library.py | 14 +++++++++++--- esphome/platformio/library.py | 6 ++++-- tests/unit_tests/test_arduino_library.py | 9 +++++++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 30c5549e69..fbf84eb2e8 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -324,14 +324,16 @@ def _external_short_name(name: str) -> str: def _check_unfulfilled_provides( - provided_requests: list[str], satisfied: set[str] + provided_requests: list[str], satisfied: set[str], still_requested: set[str] ) -> None: """Reconcile the provides() promise: every dependency the walk skipped on the backend's word must have been added from the framework tree (or knowingly satisfied by a converted/external library). An unfulfilled promise can only surface as undefined symbols at link, so it fails - here by name like the other can-never-link checks in this module.""" - if missing := sorted(set(provided_requests) - satisfied): + here by name like the other can-never-link checks in this module. The + walk records across re-resolutions, so a name no final manifest still + requests is stale state, never a failure.""" + if missing := sorted((set(provided_requests) & still_requested) - satisfied): raise EsphomeError( "provides() skipped these dependencies but nothing added them: " f"{', '.join(missing)}; the build is missing libraries" @@ -391,6 +393,9 @@ def resolve_libraries( # Bundled candidates skipped on purpose (platform filter); the # provides() reconciliation must count them as satisfied knowingly_skipped: set[str] = set() + # Dependency names of the manifests actually emitted; a walk recording + # for a since-re-resolved manifest must not fail the reconciliation + final_dep_names: set[str] = set() # Ordered set of bundled dependency names to add once conversion is done pending_bundled: dict[str, None] = {} # Deps matching a separately-requested external are already in the build @@ -407,6 +412,8 @@ def resolve_libraries( component.data.get("dependencies"), component.name ): name = dep.get("name") + if isinstance(name, str): + final_dep_names.add(name) if isinstance(name, str) and "/" in name: owner, _, pkg = name.partition("/") if _is_safe_library_name(owner) and _is_safe_library_name(pkg): @@ -527,6 +534,7 @@ def resolve_libraries( | converted_manifest_names | external_short_names | knowingly_skipped, + final_dep_names, ) return bundled + converted diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 80e3fa160a..3b0f5d6c76 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -944,9 +944,11 @@ def _warn_unsatisfied_versionless( if dep_name in resolved_manifest_names: # Name-only evidence: any resolved component with this manifest # name counts, not just ones the requester can reach, so a - # coincidental name collision must stay visible + # coincidental name collision must stay visible where the user + # could pin it; treeless backends cannot act on it warned.add(dep_name) - _LOGGER.warning( + log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug + log( "Version-less dependency %s of %s assumed satisfied by a " "resolved library's manifest name only", dep_name, diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index 24e78ea7da..eed86db5a7 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -721,10 +721,15 @@ def test_unfulfilled_provides_promise_raises(tmp_path: Path) -> None: undefined symbols at link, so it fails here by name, deduplicated; satisfied ones pass silently.""" with pytest.raises(EsphomeError, match="Wire") as err: - component._check_unfulfilled_provides(["Wire", "Wire", "Hash"], {"Hash"}) + component._check_unfulfilled_provides( + ["Wire", "Wire", "Hash"], {"Hash"}, {"Wire", "Hash"} + ) assert str(err.value).count("Wire") == 1 assert "Hash" not in str(err.value) - component._check_unfulfilled_provides(["Hash"], {"Hash"}) + component._check_unfulfilled_provides(["Hash"], {"Hash"}, {"Hash"}) + # A recording for a since-re-resolved manifest is stale walk state, + # never a failure: no final manifest still requests Wire + component._check_unfulfilled_provides(["Wire"], set(), set()) def test_extra_script_link_flags_reach_the_library(tmp_path: Path) -> None: