diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 456a8ff0d1..b6dcfef3d2 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -317,6 +317,22 @@ def _external_short_name(name: str) -> str: return short.partition("#")[0].removesuffix(".git") +def _warn_unfulfilled_provides( + provided_requests: list[str], satisfied: 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 would surface only as undefined symbols at link.""" + for name in provided_requests: + if name not in satisfied: + _LOGGER.warning( + "provides() skipped dependency %s but nothing added it; " + "the build is missing a library", + name, + ) + + def resolve_libraries( framework_path: Path, *, pio_platform: str, board_mcu: str, cache_key: str ) -> list[ArduinoLibrary]: @@ -430,8 +446,11 @@ def resolve_libraries( continue try: # framework=None: the walk already ran dependency_is_usable - # on this entry (and warned for any non-platform cause); - # re-checking with a framework would warn twice + # on this entry and warned for any non-platform cause, so + # debug here is what keeps one manifest fault from warning + # twice. That invariant is pinned by + # test_nonplatform_rejection_warns_once_through_real_converter, + # which fails if the walk stops evaluating these deps. check_library_data(dep, pio_platform, None) except InvalidLibrary as err: _LOGGER.debug("Skip bundled candidate %s: %s", name, err) @@ -458,19 +477,17 @@ def resolve_libraries( ) _add_bundled_dependencies(component) + backend = LibraryBackend( + platform=pio_platform, + framework="arduino", + emit=_emit, + cache_key=cache_key, + # The walk must not resolve bundled names from the registry; + # _add_bundled_dependencies adds them after emit + provides=_provided, + ) if external: - convert_libraries( - external, - LibraryBackend( - platform=pio_platform, - framework="arduino", - emit=_emit, - cache_key=cache_key, - # The walk must not resolve bundled names from the registry; - # _add_bundled_dependencies adds them after emit - provides=_provided, - ), - ) + convert_libraries(external, backend) for name in pending_bundled: if name in converted_manifest_names: # Manifest-name evidence: the converted library is this library, @@ -486,4 +503,9 @@ def resolve_libraries( bundled_names.add(name) bundled.append(_bundled_library(framework_path, name)) + _warn_unfulfilled_provides( + backend.provided_requests, + bundled_names | converted_manifest_names | external_short_names, + ) + return bundled + converted diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index fd94197c93..96a8550ac5 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -351,8 +351,10 @@ class LibraryBackend: cache_key: str # Owner-less dependency names this returns True for are skipped by the # walk; the backend supplies them outside the registry (e.g. core-bundled - # libraries). + # libraries). The walk records every skipped name in provided_requests + # so the backend can reconcile its promise after resolving. provides: Callable[[str], bool] | None = None + provided_requests: list[str] = field(default_factory=list) def ensure_list[T](obj: T | list[T]) -> list[T]: @@ -1303,6 +1305,7 @@ def convert_libraries( ) else: _LOGGER.debug("Skip backend-provided dependency %s", dep_name) + backend.provided_requests.append(dep_name) continue dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index f2264db3ac..9427320634 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -716,6 +716,30 @@ def test_bundled_dependency_dict_shorthand_prefers_bundled(tmp_path: Path) -> No assert "Wire" in [lib.name for lib in libs] +def test_unfulfilled_provides_promise_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The walk records every provides()-skipped dependency; one nothing + added must warn instead of surfacing as link errors, while satisfied + ones stay silent.""" + component._warn_unfulfilled_provides(["Wire", "Hash"], {"Hash"}) + assert "provides() skipped dependency Wire but nothing added it" in caplog.text + assert "Hash" not in caplog.text + + +def test_fulfilled_provides_promise_is_silent_end_to_end( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The normal path: the walk records the skip and the backend adds the + bundled copy, so the reconciliation stays quiet.""" + framework = _make_framework(tmp_path) + converted = _webserver(tmp_path, {"build": {}, "dependencies": {"Wire": "*"}}) + with _emitting_converter(converted): + libs = _resolve(framework) + assert "Wire" in [lib.name for lib in libs] + assert "provides() skipped dependency" not in caplog.text + + def test_bundled_dependency_platform_rejection_is_debug( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: