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.
This commit is contained in:
J. Nick Koston
2026-08-25 22:22:48 -05:00
parent bdbdaa7ddc
commit 6cdc34178d
3 changed files with 22 additions and 7 deletions
+11 -3
View File
@@ -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
+4 -2
View File
@@ -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,
+7 -2
View File
@@ -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: