From 70b8573110d2bc78ea49ea3eb9907661ce1253e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 10:46:50 -0500 Subject: [PATCH 1/2] Move version-less drop reconciliation into the walk, harden the name guards The shared walk now defers every version-less skip and reconciles after emit against the final resolution set (request keys, resolved manifest names, and backend.provides(name) per name), so drop visibility is correct for every backend by construction instead of by a comment-level contract, and the arduino backend's duplicate pending_drops pass and its short-name suppression heuristics are deleted. The provides lambda applies _is_safe_library_name so an unsafe manifest name is simply not provided, and the guard also rejects drive-colon names that would escape the tree on Windows. --- esphome/arduino/library.py | 34 +++------- esphome/platformio/library.py | 70 +++++++++++---------- tests/unit_tests/test_platformio_library.py | 34 +++++++++- 3 files changed, 78 insertions(+), 60 deletions(-) diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 1cb0d48868..89de66bbde 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -80,6 +80,7 @@ def _is_safe_library_name(name: object) -> bool: and bool(name) and "/" not in name and "\\" not in name + and ":" not in name # a Windows drive-relative name escapes the tree and name not in (".", "..") ) @@ -294,8 +295,6 @@ def resolve_libraries( # "skipping" warning teaches users to ignore the real one) external_short_names = {lib.name.split("/")[-1] for lib in external if lib.name} - pending_drops: list[tuple[str, str]] = [] - def _add_bundled_dependencies(component: ConvertedLibrary) -> None: # A version-less bare-name dependency ("Hash" in ESPAsyncWebServer) # is a core-bundled library; the shared converter skips it because @@ -347,10 +346,8 @@ def resolve_libraries( ) continue if not bundled_dir.is_dir(): - # Deferred: the walk may still resolve this name as another - # library's transitive registry dependency, and a false - # "skipping" warning teaches users to ignore the real one - pending_drops.append((name, component.name)) + # The shared walk's post-emit reconciliation reports drops + # (it alone knows the final resolution set); nothing to add continue try: check_library_data(dep, pio_platform, "arduino") @@ -394,8 +391,13 @@ def resolve_libraries( 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(), + # is added by _add_bundled_dependencies after emit. Unsafe + # names are simply not provided (the malformed-entry warning + # names them). + provides=lambda name: ( + _is_safe_library_name(name) + and (framework_path / "libraries" / name).is_dir() + ), ), ) if len(resolved) < len(external): @@ -421,20 +423,4 @@ def resolve_libraries( f"{', '.join(dropped) or 'unknown'})" ) - # The shared converter skips version-less deps too, so this is the only - # place a genuine drop can be made visible before the missing sources - # surface as link errors; names the walk resolved anyway stay quiet. - # Split once from the left: strip the sanitized owner prefix only, so a - # library whose own name carries "__" still matches - resolved_short_names = {c.name.split("__", 1)[-1] for c in converted} - for name, requester in pending_drops: - if name in bundled_names or name in resolved_short_names: - continue - _LOGGER.warning( - "Dependency %s of library %s is not bundled with the framework " - "and has no version to resolve; skipping", - name, - requester, - ) - return bundled + converted diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 999b1d3f11..04e1aeb41f 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -907,6 +907,8 @@ def convert_libraries( components: dict[str, ConvertedLibrary] = {} resolved_requirements: dict[str, frozenset[str]] = {} top_level_keys = set(top_level) + # (name, requester) pairs reconciled against the final resolution set + skipped_versionless: list[tuple[Any, str]] = [] worklist = deque(dict.fromkeys(top_level)) while worklist: key = worklist.popleft() @@ -991,40 +993,16 @@ def convert_libraries( component.data.get("dependencies"), component.name ): if "name" not in dependency or "version" not in dependency: - dep_name = dependency.get("name") - if ( - isinstance(dep_name, str) - # _node_key raises for a malformed URL-ish name; that - # entry belongs to the warning below, not a traceback - and "://" not in dep_name - and _node_key(dep_name, None, None)[0] in top_level_keys - ): - # Already requested top-level: present in the build, not - # a drop (a false warning teaches users to ignore the - # real one) - _LOGGER.debug( - "Version-less dependency %r of %s is requested top-level", - dep_name, - component.name, - ) - elif backend.provides is None: - # No backend tree can supply it and the registry cannot - # resolve it: a real drop, not a routine skip - _LOGGER.warning( - "Dependency %r of %s has no version to resolve; skipping", - dep_name, - component.name, - ) - else: - # A provides backend owns the post-emit reporting (the - # arduino backend defers drops and suppresses names the - # walk resolved, which this layer cannot know yet), so - # warning here would duplicate or false-positive - _LOGGER.debug( - "Skip version-less dependency %r of %s", - dep_name, - component.name, - ) + # Version-less deps cannot resolve from the registry. + # Deferred: only the final resolution set can tell a real + # drop from a name another manifest resolves later, so the + # reconciliation after emit owns the warning + _LOGGER.debug( + "Skip version-less dependency %r of %s", + dependency.get("name"), + component.name, + ) + skipped_versionless.append((dependency.get("name"), component.name)) continue try: check_library_data(dependency, backend.platform, backend.framework) @@ -1129,4 +1107,28 @@ def convert_libraries( for component in components.values(): backend.emit(component) + # A version-less dependency is satisfied when its request key resolved, + # a resolved component's manifest name matches, or the backend provides + # it from its own tree (e.g. the arduino bundled libraries, added by the + # backend after emit). Anything else is a real drop that would otherwise + # surface as link errors far from the cause. + resolved_manifest_names = {c.data.get("name") for c in components.values()} + warned: set[str] = set() + for dep_name, requester in skipped_versionless: + if not isinstance(dep_name, str) or not dep_name or dep_name in warned: + continue + if "://" not in dep_name and _node_key(dep_name, None, None)[0] in components: + continue + if dep_name in resolved_manifest_names: + continue + if backend.provides is not None and backend.provides(dep_name): + continue + warned.add(dep_name) + _LOGGER.warning( + "Dependency %s of %s has no version to resolve and nothing " + "provides it; skipping", + dep_name, + requester, + ) + return [components[key] for key in top_level if key in components] diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 4b5b4508a0..d09b291e56 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -620,7 +620,10 @@ def test_versionless_dependency_without_provider_warns( {"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]}}, ) convert_libraries([Library("esphome/A", None, None)], _backend()) - assert "'Hash' of esphome/A has no version to resolve" in caplog.text + assert ( + "Hash of esphome/A has no version to resolve and nothing provides it" + in caplog.text + ) def test_walk_warns_for_nonplatform_invalid_library( @@ -678,4 +681,31 @@ def test_versionless_url_ish_dependency_name_warns_cleanly( {"esphome/A": {"name": "A", "dependencies": [{"name": "file://"}]}}, ) convert_libraries([Library("esphome/A", None, None)], _backend()) - assert "'file://' of esphome/A has no version to resolve" in caplog.text + assert ( + "file:// of esphome/A has no version to resolve and nothing provides it" + in caplog.text + ) + + +def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet( + tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture +) -> None: + """A bare dependency name satisfied by a component requested under an + owner-qualified spec (manifest names match) is not a drop; a nameless + entry is skipped without a reconciliation warning.""" + _patch_download_with_manifests( + monkeypatch, + tmp_path, + { + "esphome/A": { + "name": "A", + "dependencies": [{"name": "B"}, {"version": "1.0"}], + }, + "esphome/B": {"name": "B"}, + }, + ) + convert_libraries( + [Library("esphome/A", None, None), Library("esphome/B", None, None)], + _backend(), + ) + assert "has no version to resolve" not in caplog.text From 57e738adf842cc7534b67347a6164c674aa808a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 22 Aug 2026 11:04:35 -0500 Subject: [PATCH 2/2] Simplify the library backend: share the dependency filter, drop the request-key drop diff, dedupe test scaffolding Validate manifest shape once in the converter (every backend dereferences data/build), share the InvalidLibrary filter as dependency_is_usable(), and let the walk's reconciliation own version-less drop reporting so the arduino backend's request-key diff, owner-no-version warning, and node_key plumbing all go away. One memoized _provided() predicate now answers bundled-name checks at all three sites. Tests gain shared scaffold helpers and lose the assertions that pinned deleted messages. --- esphome/arduino/library.py | 141 ++---- esphome/espidf/component.py | 8 +- esphome/platformio/library.py | 85 ++-- tests/unit_tests/test_arduino_library.py | 506 ++++++-------------- tests/unit_tests/test_platformio_library.py | 13 + 5 files changed, 256 insertions(+), 497 deletions(-) diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 89de66bbde..fabbe6d142 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -25,22 +25,22 @@ include path. from __future__ import annotations from dataclasses import dataclass, field +import functools import logging from pathlib import Path from esphome.core import CORE, EsphomeError, Library +from esphome.helpers import walk_files from esphome.platformio.extra_script import apply_extra_script from esphome.platformio.library import ( DEFAULT_BUILD_INCLUDE_DIR, DEFAULT_BUILD_SRC_FILTER, SRC_FILE_EXTENSIONS, ConvertedLibrary, - IncompatiblePlatform, - InvalidLibrary, LibraryBackend, - check_library_data, collect_filtered_files, convert_libraries, + dependency_is_usable, ensure_list, is_lib_ignored, lex_build_flags, @@ -48,7 +48,6 @@ from esphome.platformio.library import ( normalize_dependencies, parse_library_json, parse_library_properties, - request_key, ) _LOGGER = logging.getLogger(__name__) @@ -85,6 +84,21 @@ def _is_safe_library_name(name: object) -> bool: ) +def _warn_properties_depends(name: str, data: object) -> None: + """Warn when a manifest declares dependencies only as ``depends=``. + + The dependency walk reads the JSON ``dependencies`` key; the raw + ``library.properties`` spelling would otherwise drop silently. + """ + if isinstance(data, dict) and not data.get("dependencies") and data.get("depends"): + _LOGGER.warning( + "Library %s declares dependencies via library.properties " + "depends=, which are not resolved automatically; add them with " + "add_library() if needed", + name, + ) + + def _manifest_build(name: str, data: object) -> dict: """The manifest's ``build`` section, validated by name. @@ -131,8 +145,9 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary: def _parse_archive(key: str, raw: object) -> bool: if isinstance(raw, bool): return raw - if str(raw).strip().lower() in ("true", "false"): - return str(raw).strip().lower() == "true" + value = str(raw).strip().lower() + if value in ("true", "false"): + return value == "true" _LOGGER.warning( "Library %s has an unrecognized %s value %r; assuming true", name, @@ -173,10 +188,14 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary: include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR) if not isinstance(include_dir, str): raise EsphomeError(f"Library {name} has a malformed includeDir") - for d in [include_dir, src_dir, *include_flags]: + for d, explicit in [ + (include_dir, "includeDir" in build), + (src_dir, False), # the srcDir guard above already validated it + *((flag, True) for flag in include_flags), + ]: if (path := (read_path / d)).is_dir(): lib.include_dirs.append(path.resolve()) - elif d in include_flags or (d == include_dir and "includeDir" in build): + elif explicit: # The includeDir/srcDir defaults are probes; an explicitly # declared path that does not resolve is a manifest error _LOGGER.warning( @@ -215,15 +234,14 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary: if isinstance(data, dict): # The dependency walk never runs for bundled libraries (a no-op for # the ESP8266 core, whose bundled manifests declare none); on a core - # where one does, the skip must be visible before link errors. - # "depends" is the library.properties spelling, which the shared - # parser returns raw. - if data.get("dependencies") or data.get("depends"): + # where one does, the skip must be visible before link errors + if data.get("dependencies"): _LOGGER.warning( "Bundled library %s declares dependencies, which are not " "resolved automatically; add them with add_library() if needed", name, ) + _warn_properties_depends(name, data) build = data.get("build") if isinstance(build, dict) and build.get("extraScript"): # apply_extra_script only runs on the converted path; a bundled @@ -235,9 +253,9 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary: ) lib = _library_info(name, lib_dir, data) if not lib.sources and not any( - p.suffix in (".h", ".hpp", ".hh", ".inc") + Path(p).suffix in (".h", ".hpp", ".hh", ".inc") for d in lib.include_dirs - for p in d.rglob("*") + for p in walk_files(d) ): # An empty or half-extracted bundled directory can never link; a # warning would scroll away and resurface as undefined symbols @@ -266,6 +284,16 @@ def resolve_libraries( # PlatformIO's lib_ignore covers framework-bundled libraries too; the # shared converter only filters the registry/git ones. lib_ignore = lib_ignore_set() + # One memoized answer to "does the framework bundle this name?" for the + # classification loop, the provides hook, and the dependency walk: the + # safety guard and the dir probe must stay fused (path traversal), and + # common names (Wire, SPI) are asked repeatedly + _provided = functools.cache( + lambda name: ( + _is_safe_library_name(name) + and (framework_path / "libraries" / name).is_dir() + ) + ) for library in CORE.platformio_libraries.values(): if is_lib_ignored(library.name, lib_ignore): continue @@ -274,12 +302,7 @@ def resolve_libraries( # name without the directory resolves from the registry at the # latest version, matching PlatformIO (a typo fails loudly as a # registry lookup error). - if ( - not library.repository - and not library.version - and _is_safe_library_name(library.name) - and (framework_path / "libraries" / library.name).is_dir() - ): + if not library.repository and not library.version and _provided(library.name): # A bundled library's own manifest dependencies are not walked. # PlatformIO would walk them even under lib_ldf_mode=off, but no # library bundled with the ESP8266 core declares any, so the walk @@ -299,15 +322,7 @@ def resolve_libraries( # A version-less bare-name dependency ("Hash" in ESPAsyncWebServer) # is a core-bundled library; the shared converter skips it because # it cannot be resolved from the registry. - if not component.data.get("dependencies") and component.data.get("depends"): - # A properties-only manifest spells dependencies depends=; the - # walk below reads the JSON key, so those are not resolved - _LOGGER.warning( - "Library %s declares dependencies via library.properties " - "depends=, which are not resolved automatically; add them " - "with add_library() if needed", - component.name, - ) + _warn_properties_depends(component.name, component.data) for dep in normalize_dependencies( component.data.get("dependencies"), component.name ): @@ -327,50 +342,23 @@ def resolve_libraries( or is_lib_ignored(name, lib_ignore) ): continue - bundled_dir = framework_path / "libraries" / name - if "version" in dep and (dep.get("owner") or not bundled_dir.is_dir()): + if "version" in dep and (dep.get("owner") or not _provided(name)): # The converter resolves versioned deps from the registry. An # owner-less versioned name that exists in the framework tree # ({"Wire": "*"} normalizes to version="*") falls through to # the bundled path below, matching PlatformIO's # process_dependencies preference for bundled builders. continue - if dep.get("owner"): - # Owner but no version: the converter skips it too, so this - # is the only place the drop can be made visible - _LOGGER.warning( - "Dependency %s of library %s has an owner but no version " - "to resolve; skipping", - name, - component.name, - ) - continue - if not bundled_dir.is_dir(): + if dep.get("owner") or not _provided(name): # The shared walk's post-emit reconciliation reports drops # (it alone knows the final resolution set); nothing to add continue - try: - check_library_data(dep, pio_platform, "arduino") - except InvalidLibrary as err: - # Rejecting another platform's dependency of a cross-platform - # manifest is routine (every ESPAsyncWebServer build hits - # it), so the platform filter stays at debug; any other - # cause means a dropped dependency and must be visible - if isinstance(err, IncompatiblePlatform): - _LOGGER.debug("Skipping bundled dependency %s: %s", name, err) - else: - _LOGGER.warning( - "Skipping bundled dependency %s of %s: %s", - name, - component.name, - err, - ) + if not dependency_is_usable(dep, pio_platform, "arduino", component.name): continue bundled_names.add(name) 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 ) @@ -382,7 +370,10 @@ def resolve_libraries( _add_bundled_dependencies(component) if external: - resolved = convert_libraries( + # Every converter drop path raises (an incompatible top-level is a + # RuntimeError, resolution and download failures raise), so the + # return needs no re-verification here. + convert_libraries( external, LibraryBackend( platform=pio_platform, @@ -392,35 +383,9 @@ def resolve_libraries( # 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. Unsafe - # names are simply not provided (the malformed-entry warning - # names them). - provides=lambda name: ( - _is_safe_library_name(name) - and (framework_path / "libraries" / name).is_dir() - ), + # names are simply not provided. + provides=_provided, ), ) - if len(resolved) < len(external): - # A requested library the converter dropped always makes the - # firmware wrong (link errors far from the cause), so fail here - # naming the requests. ConvertedLibrary.name is canonical (bare - # "pngle" resolves to "bitbank2__pngle"); diff the request-side - # node keys instead. A None node_key is a converter construction - # path that forgot to set it: a programming error, never - # silently substituted with the mismatched canonical name. - if unkeyed := sorted(c.name for c in resolved if c.node_key is None): - raise EsphomeError( - "ConvertedLibrary without a node_key (a converter bug): " - + ", ".join(unkeyed) - ) - resolved_keys = {c.node_key for c in resolved} - dropped = sorted( - str(lib) for lib in external if request_key(lib) not in resolved_keys - ) - raise EsphomeError( - f"{len(external) - len(resolved)} of {len(external)} requested " - f"libraries were not resolved (missing: " - f"{', '.join(dropped) or 'unknown'})" - ) return bundled + converted diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index bd104dfe9a..d71607714c 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -88,14 +88,14 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: build_src_filter = ensure_list( component.data.get("build", {}).get("srcFilter", DEFAULT_BUILD_SRC_FILTER) ) - build_flags = ensure_list( - component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS) - ) # PlatformIO shell-lexes each build.flags entry, so one entry can carry a # flag and its argument (e.g. "-include cp_custom_alloc.h"); bare # -I/-L/-l/-D tokens re-glue to their argument ("-I foo" -> "-Ifoo") so # prefix classifiers below still route them. - build_flags = lex_build_flags(build_flags, f"library {component.name}") + build_flags = lex_build_flags( + component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS), + f"library {component.name}", + ) # List all sources files build_src_files = collect_filtered_files( diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 04e1aeb41f..db25e5309c 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -229,10 +229,6 @@ class ConvertedLibrary: self.name = name self.version = version self.source = source - # The request-side _node_key this component resolved from; the - # canonical name can differ (bare "pngle" -> "bitbank2__pngle"), so - # callers diff requests against this, not against name - self.node_key: str | None = None self.data = {} self.dependencies: list[ConvertedLibrary] = [] self._path: Path | None = None @@ -594,9 +590,10 @@ def split_flag_entry(entry: Any, owner: str) -> list[str]: def lex_build_flags(entries: str | list[str], owner: str) -> list[str]: """Shell-lex a manifest ``build.flags`` list into joined tokens. - The composition every backend needs: each entry is lexed the way - PlatformIO's ParseFlags does, and bare ``-I``/``-L``/``-l``/``-D`` - tokens re-glue to their argument across the whole stream. + Each entry is lexed the way PlatformIO's ParseFlags does, and bare + ``-I``/``-L``/``-l``/``-D`` tokens re-glue to their argument across the + whole stream. Used by the espidf and arduino backends; zephyr still + classifies raw entries. """ # Join per entry, as SCons's ParseFlags lexes each string independently: # a dangling -I ending one entry must warn, not absorb the next entry's @@ -628,6 +625,29 @@ def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]: return out +def dependency_is_usable( + dep: dict, platform: str | None, framework: str, requester: str +) -> bool: + """Whether a manifest dependency passes the compatibility filter. + + The routine cross-platform skip logs at debug; any other + ``InvalidLibrary`` cause is a dropped dependency and warns naming the + requester (unreachable from ``check_library_data`` today, which raises + only for the platform filter). + """ + try: + check_library_data(dep, platform, framework) + except IncompatiblePlatform as e: + _LOGGER.debug("Skip dependency %s of %s: %s", dep.get("name"), requester, e) + return False + except InvalidLibrary as e: + _LOGGER.warning( + "Skipping dependency %s of %s: %s", dep.get("name"), requester, e + ) + return False + return True + + def normalize_dependencies( dependencies: Any, manifest_name: str = "manifest" ) -> list[dict]: @@ -662,6 +682,16 @@ def normalize_dependencies( normalized = [] for entry in dependencies: if isinstance(entry, dict): + name = entry.get("name") + if "name" in entry and (not isinstance(name, str) or not name): + # A dependency name must be a non-empty string; every + # consumer indexes or joins it + _LOGGER.warning( + "Ignoring unrecognized dependency entry %r of %s", + entry, + manifest_name, + ) + continue normalized.append(entry) elif isinstance(entry, str) and entry: # PIO also accepts a bare list of names ("dependencies": @@ -804,15 +834,6 @@ def is_lib_ignored(name: str | None, lib_ignore: set[str]) -> bool: ) -def request_key(library: Library) -> str: - """The node key a library spec resolves under. - - Pairs with ``ConvertedLibrary.node_key``: diff requests against resolved - components with these, not against the canonical ``name``. - """ - return _node_key(library.name, library.version, library.repository)[0] - - def convert_libraries( libraries: list[Library], backend: LibraryBackend ) -> list[ConvertedLibrary]: @@ -934,7 +955,6 @@ def convert_libraries( component = ConvertedLibrary( _owner_pkgname_to_name(owner, name), version, URLSource(url) ) - component.node_key = key component.download(salt=salt, namespace=backend.cache_key) source_dir = component.source_dir @@ -972,6 +992,13 @@ def convert_libraries( f"library.properties in {source_dir}" ) + if not isinstance(component.data, dict) or not isinstance( + component.data.get("build", {}), dict + ): + # A bare json.load imposes no shape; every backend dereferences + # data/build, so validate once here and name the library + raise EsphomeError(f"Library {key} has a malformed manifest") + try: check_library_data(component.data, backend.platform, backend.framework) except InvalidLibrary as e: @@ -1004,24 +1031,9 @@ def convert_libraries( ) skipped_versionless.append((dependency.get("name"), component.name)) continue - try: - check_library_data(dependency, backend.platform, backend.framework) - except InvalidLibrary as e: - if isinstance(e, IncompatiblePlatform): - # Routine cross-platform skip - _LOGGER.debug( - "Skip dependency %s: %s", dependency.get("name"), str(e) - ) - else: - # Any other cause is a dropped dependency and must be - # visible (unreachable from check_library_data today, - # which raises only for the platform filter) - _LOGGER.warning( - "Skipping dependency %s of %s: %s", - dependency.get("name"), - component.name, - e, - ) + if not dependency_is_usable( + dependency, backend.platform, backend.framework, component.name + ): continue dep_name = _owner_pkgname_to_name( dependency.get("owner"), dependency.get("name") @@ -1117,7 +1129,8 @@ def convert_libraries( for dep_name, requester in skipped_versionless: if not isinstance(dep_name, str) or not dep_name or dep_name in warned: continue - if "://" not in dep_name and _node_key(dep_name, None, None)[0] in components: + if dep_name in components: + # A version-less dep's request key is the name itself continue if dep_name in resolved_manifest_names: continue diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index 863d8664b4..0d3ed004e7 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -3,6 +3,7 @@ from __future__ import annotations from contextlib import contextmanager +import json from pathlib import Path from unittest.mock import patch @@ -16,7 +17,7 @@ from esphome.platformio.library import ConvertedLibrary, LibraryBackend @pytest.fixture(autouse=True) def _reset_libraries() -> None: - CORE.platformio_libraries = {} + # conftest's reset_core fixture clears platformio_libraries after each test CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP8266} @@ -58,6 +59,60 @@ def _emitting_converter(*converted): yield mock_extra +def _converted(name: str, source_dir: Path, data: dict) -> ConvertedLibrary: + converted = ConvertedLibrary(name, "1.0.0", source=None) + converted.path = source_dir + converted.data = data + return converted + + +def _resolve(framework: Path) -> list[component.ArduinoLibrary]: + return component.resolve_libraries( + framework, + pio_platform="espressif8266", + board_mcu="esp8266", + cache_key="arduino8266", + ) + + +def _webserver(tmp_path: Path, data: dict) -> ConvertedLibrary: + """Register ESPAsyncWebServer and return its converted stand-in.""" + _add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") + lib_dir = tmp_path / "converted" / "webserver" + (lib_dir / "src").mkdir(parents=True) + return _converted("esp32async__ESPAsyncWebServer", lib_dir, data) + + +def _local_lib(tmp_path: Path, dependencies: dict | list) -> None: + """Register a local file:// library declaring the given dependencies.""" + local_lib = tmp_path / "locallib" + (local_lib / "src").mkdir(parents=True) + (local_lib / "src" / "local.cpp").write_text("") + (local_lib / "library.json").write_text( + json.dumps( + {"name": "LocalLib", "version": "1.0.0", "dependencies": dependencies} + ) + ) + # as_uri() forms a valid file:// URL on every platform (file:///C:/... + # on Windows; a bare f-string would embed backslashes) + _add_library(local_lib.as_uri(), None) + + +def _ws_tcp_pair(tmp_path: Path) -> tuple[ConvertedLibrary, ConvertedLibrary]: + """Build ESPAsyncWebServer (depending on ESPAsyncTCP) plus resolved TCP.""" + ws_dir = tmp_path / "converted" / "webserver" + (ws_dir / "src").mkdir(parents=True) + tcp_dir = tmp_path / "converted" / "tcp" + (tcp_dir / "src").mkdir(parents=True) + ws = _converted( + "esp32async__ESPAsyncWebServer", + ws_dir, + {"build": {}, "dependencies": [{"name": "ESPAsyncTCP"}]}, + ) + tcp = _converted("esp32async__ESPAsyncTCP", tcp_dir, {"build": {}}) + return ws, tcp + + def test_library_info_src_layout(tmp_path: Path) -> None: framework = _make_framework(tmp_path) lib = component._bundled_library(framework, "ESP8266WiFi") @@ -149,41 +204,25 @@ def test_library_info_no_src_dir(tmp_path: Path) -> None: def test_resolve_libraries_bundled(tmp_path: Path) -> None: framework = _make_framework(tmp_path) _add_library("ESP8266WiFi", None) - libs = component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + libs = _resolve(framework) assert [lib.name for lib in libs] == ["ESP8266WiFi"] -def test_resolve_libraries_bare_registry_name_is_external(tmp_path: Path) -> None: - """A bare name that is not bundled resolves from the registry at the - latest version, matching PlatformIO and the documented libraries: key.""" +@pytest.mark.parametrize("version", [None, "1.1.0"]) +def test_resolve_libraries_registry_name_is_external( + tmp_path: Path, version: str | None +) -> None: + """A name that is not bundled reaches the converter: bare resolves from + the registry at the latest version (matching PlatformIO and the + documented libraries: key) and a version pin is a registry package.""" framework = _make_framework(tmp_path) - _add_library("pngle", None) - with ( - patch.object(component, "convert_libraries", return_value=[]) as mock_convert, - pytest.raises(EsphomeError, match="not resolved"), - ): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + _add_library("pngle", version) + with patch.object(component, "convert_libraries", return_value=[]) as mock_convert: + _resolve(framework) (libraries, _backend), _ = mock_convert.call_args assert [lib.name for lib in libraries] == ["pngle"] -def _converted(name: str, source_dir: Path, data: dict) -> ConvertedLibrary: - converted = ConvertedLibrary(name, "1.0.0", source=None) - converted.path = source_dir - converted.data = data - return converted - - def test_resolve_libraries_external_and_bundled_deps(tmp_path: Path) -> None: framework = _make_framework(tmp_path) _add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") @@ -210,12 +249,7 @@ def test_resolve_libraries_external_and_bundled_deps(tmp_path: Path) -> None: ) with _emitting_converter(converted) as mock_extra: - libs = component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + libs = _resolve(framework) mock_extra.assert_called_once() assert mock_extra.call_args.args == (converted,) @@ -240,38 +274,12 @@ def test_resolve_libraries_bundled_dep_already_present(tmp_path: Path) -> None: ) with _emitting_converter(converted): - libs = component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + libs = _resolve(framework) # Wire appears once (from the explicit registration), not twice assert [lib.name for lib in libs] == ["Wire", "some__External"] -def test_resolve_libraries_versioned_bare_name_is_external(tmp_path: Path) -> None: - """A bare name with a version pin ("pngle@1.1.0") is a registry package, - not a bundled library, and must reach the converter.""" - framework = _make_framework(tmp_path) - _add_library("pngle", "1.1.0") - - with ( - patch.object(component, "convert_libraries", return_value=[]) as mock_convert, - pytest.raises(EsphomeError, match="not resolved"), - ): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) - - (libraries, _backend), _ = mock_convert.call_args - assert [lib.name for lib in libraries] == ["pngle"] - - def test_library_info_trailing_bare_flag_warns( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -316,12 +324,7 @@ def test_resolve_libraries_lib_ignore_covers_bundled(tmp_path: Path) -> None: _add_library("ESP8266WiFi", None) _add_library("Wire", None) CORE.platformio_options = {"lib_ignore": ["Wire"]} - libs = component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + libs = _resolve(framework) assert [lib.name for lib in libs] == ["ESP8266WiFi"] @@ -339,12 +342,7 @@ def test_resolve_libraries_lib_ignore_covers_bundled_dependencies( ) with _emitting_converter(converted): - libs = component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + libs = _resolve(framework) assert [lib.name for lib in libs] == ["some__External"] @@ -389,14 +387,11 @@ def test_library_info_lib_archive_flag(tmp_path: Path) -> None: def test_resolve_libraries_dep_warnings( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """Nameless and owner-without-version dependencies are dropped loudly.""" + """A nameless dependency entry warns; an owner-without-version entry is + left to the shared walk's reconciliation (no local warning).""" 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, + converted = _webserver( + tmp_path, { "build": {}, "dependencies": [ @@ -406,34 +401,9 @@ def test_resolve_libraries_dep_warnings( }, ) with _emitting_converter(converted): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + _resolve(framework) assert "malformed dependency entry" in caplog.text - assert "Orphan" in caplog.text - assert "owner but no version" in caplog.text - - -def test_resolve_libraries_raises_when_converter_drops_a_request( - tmp_path: Path, -) -> None: - """A dropped top-level request always makes the firmware wrong; fail by - name instead of warning toward link errors far from the cause.""" - framework = _make_framework(tmp_path) - _add_library("pngle", "1.0.0") - with ( - patch.object(component, "convert_libraries", return_value=[]), - pytest.raises(EsphomeError, match="1 of 1 requested .*missing: pngle"), - ): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + assert "Orphan" not in caplog.text def test_bundled_dependency_nonplatform_rejection_warns( @@ -443,29 +413,19 @@ def test_bundled_dependency_nonplatform_rejection_warns( from esphome.platformio.library import InvalidLibrary 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": {}, "dependencies": [{"name": "Wire"}]}, - ) + converted = _webserver(tmp_path, {"build": {}, "dependencies": [{"name": "Wire"}]}) + import esphome.platformio.library as pio_library + with ( _emitting_converter(converted), patch.object( - component, + pio_library, "check_library_data", side_effect=InvalidLibrary("manifest is corrupt"), ), ): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) - assert "Skipping bundled dependency Wire" in caplog.text + _resolve(framework) + assert "Skipping dependency Wire" in caplog.text assert "manifest is corrupt" in caplog.text @@ -510,21 +470,9 @@ def test_bundled_dependency_dict_shorthand_prefers_bundled(tmp_path: Path) -> No to the bundled library, matching PIO's process_dependencies, instead of being routed to the registry.""" 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": {}, "dependencies": {"Wire": "*"}}, - ) + converted = _webserver(tmp_path, {"build": {}, "dependencies": {"Wire": "*"}}) with _emitting_converter(converted): - libs = component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + libs = _resolve(framework) assert "Wire" in [lib.name for lib in libs] @@ -536,29 +484,19 @@ def test_bundled_dependency_platform_rejection_is_debug( from esphome.platformio.library import IncompatiblePlatform 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": {}, "dependencies": [{"name": "Wire"}]}, - ) + converted = _webserver(tmp_path, {"build": {}, "dependencies": [{"name": "Wire"}]}) + import esphome.platformio.library as pio_library + with ( _emitting_converter(converted), patch.object( - component, + pio_library, "check_library_data", side_effect=IncompatiblePlatform("nothing about the p-word here"), ), ): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) - assert "Skipping bundled dependency Wire" not in caplog.text + _resolve(framework) + assert "Skipping dependency Wire" not in caplog.text @pytest.mark.parametrize("data", [{"build": "src"}, [], "nope"]) @@ -570,52 +508,6 @@ def test_library_info_malformed_manifest_is_named(tmp_path: Path, data: object) component._library_info("x", read_path, data) -def test_drop_error_maps_requests_by_node_key(tmp_path: Path) -> None: - """A bare request resolving to a canonical name is not falsely reported - missing; the genuinely dropped request is the one named.""" - framework = _make_framework(tmp_path) - _add_library("pngle", None) - _add_library("gone/missing", "1.0.0") - lib_dir = tmp_path / "converted" / "pngle" - (lib_dir / "src").mkdir(parents=True) - resolved = _converted("bitbank2__pngle", lib_dir, {"build": {}}) - resolved.node_key = "pngle" - with ( - patch.object(component, "convert_libraries", return_value=[resolved]), - pytest.raises(EsphomeError) as excinfo, - ): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) - missing = str(excinfo.value).split("missing:")[-1] - assert "gone/missing" in missing - assert "pngle@" not in missing - - -def test_drop_error_without_node_key_is_a_converter_bug(tmp_path: Path) -> None: - """A resolved component missing its node_key must fail as a programming - error, never silently substitute the mismatched canonical name.""" - framework = _make_framework(tmp_path) - _add_library("pngle", None) - _add_library("gone/missing", "1.0.0") - lib_dir = tmp_path / "converted" / "pngle" - (lib_dir / "src").mkdir(parents=True) - resolved = _converted("bitbank2__pngle", lib_dir, {"build": {}}) - with ( - patch.object(component, "convert_libraries", return_value=[resolved]), - pytest.raises(EsphomeError, match="node_key .*bitbank2__pngle"), - ): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) - - def test_bundled_library_with_declared_dependencies_warns( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: @@ -627,12 +519,7 @@ def test_bundled_library_with_declared_dependencies_warns( '{"name": "Wire", "dependencies": [{"name": "SPI"}]}' ) _add_library("Wire", None) - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + _resolve(framework) assert "Bundled library Wire declares dependencies" in caplog.text @@ -686,13 +573,8 @@ def test_bundled_library_properties_depends_warns( wire = framework / "libraries" / "Wire" (wire / "library.properties").write_text("name=Wire\nversion=1.0\ndepends=SPI\n") _add_library("Wire", None) - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) - assert "Bundled library Wire declares dependencies" in caplog.text + _resolve(framework) + assert "Library Wire declares dependencies via library.properties" in caplog.text def test_bundled_library_extra_script_warns( @@ -706,12 +588,7 @@ def test_bundled_library_extra_script_warns( '{"name": "Wire", "build": {"extraScript": "extra.py"}}' ) _add_library("Wire", None) - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + _resolve(framework) assert "declares an extraScript" in caplog.text @@ -719,32 +596,19 @@ def test_dependency_requested_top_level_is_not_a_drop( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A version-less manifest dependency the config separately requests is - already in the build; the skipping warning must not fire for it.""" - from esphome.platformio.library import request_key - + already in the build; it is not probed as a bundled library.""" framework = _make_framework(tmp_path) _add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") _add_library("ESP32Async/ESPAsyncTCP", "2.0.0") - ws_dir = tmp_path / "converted" / "webserver" - (ws_dir / "src").mkdir(parents=True) - tcp_dir = tmp_path / "converted" / "tcp" - (tcp_dir / "src").mkdir(parents=True) - ws = _converted( - "esp32async__ESPAsyncWebServer", - ws_dir, - {"build": {}, "dependencies": [{"name": "ESPAsyncTCP"}]}, - ) - tcp = _converted("esp32async__ESPAsyncTCP", tcp_dir, {"build": {}}) - for conv, lib in zip((ws, tcp), CORE.platformio_libraries.values(), strict=True): - conv.node_key = request_key(lib) + ws, tcp = _ws_tcp_pair(tmp_path) with _emitting_converter(ws, tcp): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) - assert "is not bundled with the framework" not in caplog.text + libs = _resolve(framework) + # Exactly the two converted libraries; no bundled stand-in was added + assert [lib.name for lib in libs] == [ + "esp32async__ESPAsyncWebServer", + "esp32async__ESPAsyncTCP", + ] + assert "Skipping" not in caplog.text def test_bundled_library_non_dict_manifest_skips_probes_and_raises( @@ -768,15 +632,7 @@ def test_dict_shorthand_dependency_skips_registry_through_real_converter( 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": "*"}}' - ) - # as_uri() forms a valid file:// URL on every platform (file:///C:/... - # on Windows; a bare f-string would embed backslashes) - _add_library(local_lib.as_uri(), None) + _local_lib(tmp_path, {"Wire": "*"}) # Pin the component cache to tmp_path (data_dir honors an ambient # ESPHOME_DATA_DIR otherwise) monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path / ".esphome")) @@ -785,85 +641,46 @@ def test_dict_shorthand_dependency_skips_registry_through_real_converter( "_resolve_registry_version", side_effect=AssertionError("registry touched"), ): - libs = component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + libs = _resolve(framework) 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() - - def test_converted_properties_depends_warns( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A converted library shipping only the properties depends= spelling is visible, not a silent bundled-dependency drop.""" 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": {}, "depends": "Wire,SPI"}, - ) + converted = _webserver(tmp_path, {"build": {}, "depends": "Wire,SPI"}) with _emitting_converter(converted): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + _resolve(framework) assert "declares dependencies via library.properties" in caplog.text -@pytest.mark.parametrize("bad_name", [1, "../escape", "a/b", ".."]) +@pytest.mark.parametrize( + ("bad_name", "message"), + [ + # A non-string name never leaves the shared normalizer + (1, "Ignoring unrecognized dependency entry"), + ("../escape", "Ignoring malformed dependency entry"), + ("a/b", "Ignoring malformed dependency entry"), + ("..", "Ignoring malformed dependency entry"), + ], +) def test_bundled_dependency_bad_name_is_malformed( - tmp_path: Path, bad_name: object, caplog: pytest.LogCaptureFixture + tmp_path: Path, bad_name: object, message: str, caplog: pytest.LogCaptureFixture ) -> None: """A dependency name becomes a path component; a traversal or a non-string is a malformed entry, never joined.""" 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": {}, "dependencies": [{"name": bad_name}]}, + converted = _webserver( + tmp_path, {"build": {}, "dependencies": [{"name": bad_name}]} ) with _emitting_converter(converted): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) - assert "Ignoring malformed dependency entry" in caplog.text + _resolve(framework) + assert message in caplog.text def test_bundled_dependency_string_list_form( @@ -872,21 +689,9 @@ def test_bundled_dependency_string_list_form( """The bare string-list dependency form (PIO-legal) resolves to the bundled library instead of vanishing in normalization.""" 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": {}, "dependencies": ["Wire"]}, - ) + converted = _webserver(tmp_path, {"build": {}, "dependencies": ["Wire"]}) with _emitting_converter(converted): - libs = component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + libs = _resolve(framework) assert "Wire" in [lib.name for lib in libs] @@ -900,25 +705,14 @@ def test_pinned_bundled_dependency_substitution_warns( 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": "^2.0.0"}}' - ) - _add_library(local_lib.as_uri(), None) + _local_lib(tmp_path, {"Wire": "^2.0.0"}) monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path / ".esphome")) 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", - ) + libs = _resolve(framework) assert "Wire" in [lib.name for lib in libs] assert "pins version ^2.0.0; using the library bundled" in caplog.text @@ -931,24 +725,14 @@ def test_transitively_resolved_dependency_does_not_warn( stay quiet for it.""" framework = _make_framework(tmp_path) _add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") - ws_dir = tmp_path / "converted" / "webserver" - (ws_dir / "src").mkdir(parents=True) - tcp_dir = tmp_path / "converted" / "tcp" - (tcp_dir / "src").mkdir(parents=True) - ws = _converted( - "esp32async__ESPAsyncWebServer", - ws_dir, - {"build": {}, "dependencies": [{"name": "ESPAsyncTCP"}]}, - ) - tcp = _converted("esp32async__ESPAsyncTCP", tcp_dir, {"build": {}}) + ws, tcp = _ws_tcp_pair(tmp_path) with _emitting_converter(ws, tcp): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) - assert "is not bundled with the framework" not in caplog.text + libs = _resolve(framework) + assert [lib.name for lib in libs] == [ + "esp32async__ESPAsyncWebServer", + "esp32async__ESPAsyncTCP", + ] + assert "Skipping" not in caplog.text def test_empty_bundled_library_warns( @@ -962,12 +746,7 @@ def test_empty_bundled_library_warns( with pytest.raises( EsphomeError, match="Bundled library Empty has no sources or headers" ): - component.resolve_libraries( - framework, - pio_platform="espressif8266", - board_mcu="esp8266", - cache_key="arduino8266", - ) + _resolve(framework) def test_versionless_dependency_with_provider_stays_quiet( @@ -980,24 +759,13 @@ def test_versionless_dependency_with_provider_stays_quiet( 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": [{"name": "Wire"}]}' - ) - _add_library(local_lib.as_uri(), None) + _local_lib(tmp_path, [{"name": "Wire"}]) monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path / ".esphome")) 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", - ) + libs = _resolve(framework) assert "Wire" in [lib.name for lib in libs] assert "has no version to resolve" not in caplog.text diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index d09b291e56..c3a7da6091 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -609,6 +609,19 @@ def test_normalize_dependencies_forms(caplog) -> None: assert normalize_dependencies("Wire") == [{"name": "Wire"}] +@pytest.mark.parametrize( + "manifest", [["not", "a", "manifest"], {"name": "A", "build": "src"}] +) +def test_convert_libraries_malformed_manifest_raises( + tmp_path, monkeypatch, manifest +) -> None: + """A manifest without the expected dict shape fails by library name + before any backend dereferences data/build.""" + _patch_download_with_manifests(monkeypatch, tmp_path, {"esphome/A": manifest}) + with pytest.raises(EsphomeError, match="has a malformed manifest"): + convert_libraries([Library("esphome/A", None, None)], _backend()) + + def test_versionless_dependency_without_provider_warns( tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture ) -> None: