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: