diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 208c0e71ad..75122ab6fc 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -71,13 +71,22 @@ class ArduinoLibrary: link_flags: list[str] = field(default_factory=list) -def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary: - """Resolve one library's sources, include dirs, and flags (PIO semantics).""" +def _manifest_build(name: str, data: object) -> dict: + """The manifest's ``build`` section, validated by name. + + A bare json.load imposes no shape; a malformed manifest must name the + library instead of an AttributeError deep in a traceback (and must do so + before apply_extra_script dereferences the same section). + """ build = data.get("build", {}) if isinstance(data, dict) else None if not isinstance(build, dict): - # A bare json.load imposes no shape; name the library instead of an - # AttributeError deep in a traceback raise EsphomeError(f"Library {name} has a malformed manifest") + return build + + +def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary: + """Resolve one library's sources, include dirs, and flags (PIO semantics).""" + build = _manifest_build(name, data) # PIO's source-dir resolution: manifest srcDir, else src/Src, else the root if "srcDir" in build: @@ -326,6 +335,7 @@ def resolve_libraries( bundled.append(_bundled_library(framework_path, name)) def _emit(component: ConvertedLibrary) -> None: + _manifest_build(component.get_require_name(), component.data) apply_extra_script( component, board_mcu=lambda: board_mcu, pio_platform=pio_platform ) @@ -344,6 +354,10 @@ def resolve_libraries( framework="arduino", emit=_emit, cache_key=cache_key, + # The graph walk must not resolve a bundled name from the + # registry ({"Wire": "*"} in a manifest); the bundled copy + # is added by _add_bundled_dependencies after emit + provides=lambda name: (framework_path / "libraries" / name).is_dir(), ), ) if len(resolved) < len(external): diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index cc02c6b490..b6172b0c41 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -300,6 +300,11 @@ class LibraryBackend: framework: str emit: Callable[["ConvertedLibrary"], None] cache_key: str + # When set, an owner-less manifest dependency this returns True for is + # skipped by the graph walk: the backend provides it outside the + # registry (e.g. a library bundled with the Arduino core), mirroring + # PlatformIO's process_dependencies preference for bundled builders. + provides: Callable[[str], bool] | None = None def ensure_list[T](obj: T | list[T]) -> list[T]: @@ -973,6 +978,15 @@ def convert_libraries( if is_lib_ignored(dep_name, lib_ignore): _LOGGER.debug("Skip ignored dependency %s", dep_name) continue + if ( + backend.provides is not None + and not dependency.get("owner") + and backend.provides(dep_name) + ): + # The backend adds it from its own tree; resolving it here + # would fetch a same-named registry package instead + _LOGGER.debug("Skip backend-provided dependency %s", dep_name) + continue # The version field may actually be a URL (git/archive dependency). dep_version = dependency["version"] dep_url = _url_or_none(dep_version) diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index 2af23a87a8..195bd781c0 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -757,3 +757,59 @@ def test_bundled_library_non_dict_manifest_skips_probes_and_raises( (wire / "library.json").write_text('["not", "a", "manifest"]') with pytest.raises(EsphomeError, match="Library Wire has a malformed manifest"): component._bundled_library(framework, "Wire") + + +def test_dict_shorthand_dependency_skips_registry_through_real_converter( + tmp_path: Path, +) -> None: + """{"Wire": "*"} in a real manifest must never reach the registry: the + graph walk skips backend-provided names and the bundled copy is added + after emit (no converter mock; a registry touch fails the test).""" + import esphome.platformio.library as pio_library + + framework = _make_framework(tmp_path) + local_lib = tmp_path / "locallib" + (local_lib / "src").mkdir(parents=True) + (local_lib / "src" / "local.cpp").write_text("") + (local_lib / "library.json").write_text( + '{"name": "LocalLib", "version": "1.0.0", "dependencies": {"Wire": "*"}}' + ) + _add_library(f"file://{local_lib}", None) + # The real converter writes its component cache under the config dir + CORE.config_path = tmp_path / "test.yaml" + CORE.config_path.write_text("") + with patch.object( + pio_library, + "_resolve_registry_version", + side_effect=AssertionError("registry touched"), + ): + libs = component.resolve_libraries( + framework, + pio_platform="espressif8266", + board_mcu="esp8266", + cache_key="arduino8266", + ) + names = [lib.name for lib in libs] + assert "Wire" in names + assert any("locallib" in n.lower() for n in names) + + +def test_emit_validates_manifest_before_extra_script(tmp_path: Path) -> None: + """A malformed build section fails by name before apply_extra_script + dereferences it.""" + framework = _make_framework(tmp_path) + _add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") + lib_dir = tmp_path / "converted" / "webserver" + (lib_dir / "src").mkdir(parents=True) + converted = _converted("esp32async__ESPAsyncWebServer", lib_dir, {"build": "src"}) + with ( + _emitting_converter(converted) as mock_extra, + pytest.raises(EsphomeError, match="has a malformed manifest"), + ): + component.resolve_libraries( + framework, + pio_platform="espressif8266", + board_mcu="esp8266", + cache_key="arduino8266", + ) + mock_extra.assert_not_called()