From bdbdaa7ddcfa608e19a2ac8b1ae69982ada026ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 22:03:47 -0500 Subject: [PATCH] Reconcile version-less provides() skips and quiet the drop warning for treeless backends A version-less dependency the walk skips on the backend's promise is now recorded like the versioned path, and a bundled candidate the backend knowingly skips through the platform filter counts as satisfied, so the post-emit reconciliation covers both paths without raising on legitimate builds. The manifest-name warning deduplicates like its sibling, the final drop warning stays at debug for backends without provides() since they can never supply a bundled name, and a bundled library missing both manifests leaves a debug trace instead of silently building on defaults. --- esphome/arduino/library.py | 15 ++++++++++++- esphome/platformio/library.py | 11 ++++++++-- tests/unit_tests/test_arduino_library.py | 24 +++++++++++++++++++++ tests/unit_tests/test_platformio_library.py | 21 ++++++++++++++---- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 7764f29961..30c5549e69 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -264,6 +264,10 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary: data = parse_library_json(manifest_json) else: manifest = lib_dir / "library.properties" + if not manifest.is_file(): + # Defaults still build core libraries correctly, but a missing + # manifest can also mean a torn framework extraction + _LOGGER.debug("Bundled library %s has no manifest; using defaults", name) data = parse_library_properties(manifest) if manifest.is_file() else {} if isinstance(data, dict): # Bundled manifest deps are never walked; make the skip visible @@ -384,6 +388,9 @@ def resolve_libraries( converted: list[ArduinoLibrary] = [] bundled_names = {lib.name for lib in bundled} converted_manifest_names: set[str] = set() + # Bundled candidates skipped on purpose (platform filter); the + # provides() reconciliation must count them as satisfied + knowingly_skipped: 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 @@ -454,6 +461,9 @@ def resolve_libraries( # which fails if the walk stops evaluating these deps. check_library_data(dep, pio_platform, None) except InvalidLibrary as err: + # A knowing skip (platform filter), not a broken promise; + # the reconciliation must accept it as satisfied + knowingly_skipped.add(name) _LOGGER.debug("Skip bundled candidate %s: %s", name, err) continue # Deferred: a later-emitted library's manifest name may satisfy @@ -513,7 +523,10 @@ def resolve_libraries( _check_unfulfilled_provides( backend.provided_requests, - bundled_names | converted_manifest_names | external_short_names, + bundled_names + | converted_manifest_names + | external_short_names + | knowingly_skipped, ) return bundled + converted diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 7f82d4dd5b..80e3fa160a 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -945,6 +945,7 @@ def _warn_unsatisfied_versionless( # 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 + warned.add(dep_name) _LOGGER.warning( "Version-less dependency %s of %s assumed satisfied by a " "resolved library's manifest name only", @@ -959,10 +960,16 @@ def _warn_unsatisfied_versionless( ): # provides() only satisfies owner-less names: the walk's # backend-provided skip has the same owner guard, so an - # owner-qualified version-less dep was added by nobody + # owner-qualified version-less dep was added by nobody. + # Recorded so the backend's post-emit reconciliation covers + # this path like the versioned one + backend.provided_requests.append(dep_name) continue warned.add(dep_name) - _LOGGER.warning( + # A backend with no framework tree can never supply a bundled + # name, so for it this is unactionable noise + log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug + log( "Dependency %s of %s has no version to resolve and nothing " "provides it; skipping", dep_name, diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index aed7ef3d3d..24e78ea7da 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -918,6 +918,30 @@ def test_dict_shorthand_dependency_skips_registry_through_real_converter( # the bundled copy, so the reconciliation passed without raising +def test_versionless_provides_skip_is_reconciled_through_real_converter( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A truly version-less bare-name dependency the walk skips on the + backend's promise is recorded and fulfilled by the bundled copy.""" + framework = _make_framework(tmp_path) + _local_lib(tmp_path, ["Wire"]) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path / ".esphome")) + libs = _resolve(framework) + assert "Wire" in [lib.name for lib in libs] + + +def test_platform_filtered_bundled_candidate_does_not_break_reconciliation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A bundled candidate the backend knowingly skips (platform filter) + counts as satisfied; the promise reconciliation must not raise.""" + framework = _make_framework(tmp_path) + _local_lib(tmp_path, [{"name": "Wire", "platforms": ["espressif32"]}]) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path / ".esphome")) + libs = _resolve(framework) + assert "Wire" not in [lib.name for lib in libs] + + @pytest.mark.parametrize( ("bad_name", "message"), [ diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index bbe730dd34..0a16b118fc 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -998,8 +998,8 @@ def test_versionless_ignored_dependency_stays_quiet( def test_versionless_dependency_without_provider_warns( tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture ) -> None: - """When no backend tree can supply a version-less dependency, the drop - is a warning, not a debug line.""" + """A backend whose tree could supply the name warns on the drop; one + without provides() can never act on it, so it stays at debug.""" _patch_download_with_manifests( monkeypatch, tmp_path, @@ -1011,13 +1011,24 @@ def test_versionless_dependency_without_provider_warns( } }, ) - convert_libraries([Library("esphome/A", None, None)], _backend()) + convert_libraries( + [Library("esphome/A", None, None)], _backend(provides=lambda name: False) + ) assert ( caplog.text.count( "Hash of esphome/A has no version to resolve and nothing provides it" ) == 1 ) + caplog.clear() + with caplog.at_level(logging.DEBUG): + convert_libraries([Library("esphome/A", None, None)], _backend()) + records = [ + r + for r in caplog.records + if "has no version to resolve and nothing provides it" in r.message + ] + assert records and all(r.levelno == logging.DEBUG for r in records) def test_url_version_dependency_is_not_substituted_by_provides( @@ -1117,7 +1128,9 @@ def test_versionless_url_ish_dependency_name_warns_cleanly( tmp_path, {"esphome/A": {"name": "A", "dependencies": [{"name": "file://"}]}}, ) - convert_libraries([Library("esphome/A", None, None)], _backend()) + convert_libraries( + [Library("esphome/A", None, None)], _backend(provides=lambda name: False) + ) assert ( "file:// of esphome/A has no version to resolve and nothing provides it" in caplog.text