diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index b6dcfef3d2..7764f29961 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -23,6 +23,8 @@ from esphome.platformio.extra_script import apply_extra_script from esphome.platformio.library import ( DEFAULT_BUILD_INCLUDE_DIR, DEFAULT_BUILD_SRC_FILTER, + ESPHOME_DATA_KEY, + ESPHOME_DATA_LINK_FLAGS_KEY, LIBRARY_HEADER_SUFFIXES, SRC_FILE_EXTENSIONS, ConvertedLibrary, @@ -317,20 +319,19 @@ def _external_short_name(name: str) -> str: return short.partition("#")[0].removesuffix(".git") -def _warn_unfulfilled_provides( +def _check_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, - ) + 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): + raise EsphomeError( + "provides() skipped these dependencies but nothing added them: " + f"{', '.join(missing)}; the build is missing libraries" + ) def resolve_libraries( @@ -470,11 +471,18 @@ def resolve_libraries( ) if isinstance(manifest_name := component.data.get("name"), str): converted_manifest_names.add(manifest_name) - converted.append( - _library_info( - component.get_require_name(), component.source_dir, component.data + lib = _library_info( + component.get_require_name(), component.source_dir, component.data + ) + # Extra-script LINKFLAGS travel outside build.flags (see + # ESPHOME_DATA_LINK_FLAGS_KEY); dropping them would link wrong + # with no stated cause + lib.link_flags.extend( + component.data.get(ESPHOME_DATA_KEY, {}).get( + ESPHOME_DATA_LINK_FLAGS_KEY, [] ) ) + converted.append(lib) _add_bundled_dependencies(component) backend = LibraryBackend( @@ -503,7 +511,7 @@ def resolve_libraries( bundled_names.add(name) bundled.append(_bundled_library(framework_path, name)) - _warn_unfulfilled_provides( + _check_unfulfilled_provides( backend.provided_requests, bundled_names | converted_manifest_names | external_short_names, ) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index d8b76cbf01..7f82d4dd5b 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -943,9 +943,11 @@ def _warn_unsatisfied_versionless( continue 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 - _LOGGER.debug( - "Version-less dependency %s of %s satisfied by manifest name only", + # name counts, not just ones the requester can reach, so a + # coincidental name collision must stay visible + _LOGGER.warning( + "Version-less dependency %s of %s assumed satisfied by a " + "resolved library's manifest name only", dep_name, requester, ) diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index 7614b5bc3d..aed7ef3d3d 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -716,28 +716,34 @@ 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_unfulfilled_provides_promise_raises(tmp_path: Path) -> None: + """A provides()-skipped dependency nothing added can only surface as + 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"}) + assert str(err.value).count("Wire") == 1 + assert "Hash" not in str(err.value) + component._check_unfulfilled_provides(["Hash"], {"Hash"}) -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.""" +def test_extra_script_link_flags_reach_the_library(tmp_path: Path) -> None: + """LINKFLAGS captured by an extra script travel outside build.flags and + must reach the library's link flags, matching the ESP-IDF backend.""" framework = _make_framework(tmp_path) - converted = _webserver(tmp_path, {"build": {}, "dependencies": {"Wire": "*"}}) + converted = _webserver( + tmp_path, + { + "build": {}, + component.ESPHOME_DATA_KEY: { + component.ESPHOME_DATA_LINK_FLAGS_KEY: ["-Wl,--wrap=foo"] + }, + }, + ) with _emitting_converter(converted): libs = _resolve(framework) - assert "Wire" in [lib.name for lib in libs] - assert "provides() skipped dependency" not in caplog.text + (webserver,) = (lib for lib in libs if "ESPAsyncWebServer" in lib.name) + assert "-Wl,--wrap=foo" in webserver.link_flags def test_bundled_dependency_platform_rejection_is_debug( @@ -908,6 +914,8 @@ def test_dict_shorthand_dependency_skips_registry_through_real_converter( names = [lib.name for lib in libs] assert "Wire" in names assert any("locallib" in n.lower() for n in names) + # The walk populated provided_requests for the skip; the backend added + # the bundled copy, so the reconciliation passed without raising @pytest.mark.parametrize(