diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 55635dcf5f..e33fde8deb 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -66,6 +66,11 @@ class ArduinoLibrary: link_flags: list[str] = field(default_factory=list) +# Source-like suffixes the case-sensitive suffix map rejects +_UNMAPPED_SOURCE_SUFFIXES = frozenset( + {s.lower() for s in SRC_FILE_EXTENSIONS} | {".ino"} +) + # Filename-plain names: an allowlist excludes separators, drive colons, # and dot-only names by shape _SAFE_LIBRARY_NAME_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_. +-]*\Z") @@ -195,30 +200,32 @@ def _collect_lib_sources( src_dir: str, src_filter: list[str], ) -> None: - matched = collect_filtered_files(read_path / src_dir, src_filter) - lib.sources = sorted( - path.resolve() - for f in matched - if (path := Path(f)).suffix in SRC_FILE_EXTENSIONS - ) - # A source-like suffix the case-sensitive map rejects (.CPP, .ino) - # is a dropped compilation unit; headers fall through silently - source_like = {s.lower() for s in SRC_FILE_EXTENSIONS} | {".ino"} - if dropped := [ - Path(f).name - for f in matched - if Path(f).suffix not in SRC_FILE_EXTENSIONS - and Path(f).suffix.lower() in source_like - ]: + root = read_path / src_dir + resolved_root = root.resolve() + sources: list[Path] = [] + dropped: list[str] = [] + saw_header = False + for f in collect_filtered_files(root, src_filter): + path = Path(f) + suffix = path.suffix + if suffix in SRC_FILE_EXTENSIONS: + # Re-root on the resolved dir instead of a realpath() per file + sources.append(resolved_root / path.relative_to(root)) + elif suffix.lower() in _UNMAPPED_SOURCE_SUFFIXES: + # A source-like suffix the case-sensitive map rejects (.CPP, + # .ino) is a dropped compilation unit; headers fall through + dropped.append(path.name) + elif suffix.lower() in LIBRARY_HEADER_SUFFIXES: + saw_header = True + lib.sources = sorted(sources) + if dropped: _LOGGER.warning( "Library %s: %d file(s) with unmapped source suffixes are not compiled: %s", name, len(dropped), ", ".join(sorted(dropped)), ) - if not lib.sources and not any( - Path(f).suffix.lower() in LIBRARY_HEADER_SUFFIXES for f in matched - ): + if not lib.sources and not saw_header: # Matched headers mean header-only; a filter matching nothing is # a manifest/tree problem (a truly empty tree raises elsewhere) _LOGGER.warning("Library %s: no source files matched", name) @@ -253,12 +260,12 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary: manifest_json = lib_dir / "library.json" if manifest_json.is_file(): data = parse_library_json(manifest_json) + elif (manifest := lib_dir / "library.properties").is_file(): + data = parse_library_properties(manifest) else: - manifest = lib_dir / "library.properties" - if not manifest.is_file(): - # Defaults build core libraries; can also mean a torn extraction - _LOGGER.debug("Bundled library %s has no manifest; using defaults", name) - data = parse_library_properties(manifest) if manifest.is_file() else {} + # Defaults build core libraries; can also mean a torn extraction + _LOGGER.debug("Bundled library %s has no manifest; using defaults", name) + data = {} if isinstance(data, dict): # Bundled manifest deps are never walked; make the skip visible if data.get("dependencies"): @@ -312,7 +319,7 @@ def _external_short_name(name: str) -> str: def _check_unfulfilled_provides( - provided_requests: list[str], satisfied: set[str], still_requested: set[str] + provided_requests: set[str], satisfied: set[str], still_requested: set[str] ) -> None: """Fail by name when a walk-skipped dependency was never added. @@ -320,7 +327,7 @@ def _check_unfulfilled_provides( at link. The walk records across re-resolutions, so a name no final manifest still requests is stale state, never a failure. """ - if missing := sorted((set(provided_requests) & still_requested) - satisfied): + if missing := sorted((provided_requests & still_requested) - satisfied): raise EsphomeError( "provides() skipped these dependencies but nothing added them: " f"{', '.join(missing)}; the build is missing libraries" @@ -395,10 +402,10 @@ def resolve_libraries( for dep in normalize_dependencies( component.data.get("dependencies"), component.name ): - name = dep.get("name") - if isinstance(name, str): - final_dep_names.add(name) - if isinstance(name, str) and "/" in name: + # normalize_dependencies guarantees a non-empty str name + name = dep["name"] + final_dep_names.add(name) + if "/" in name: owner, _, pkg = name.partition("/") if _is_safe_library_name(owner) and _is_safe_library_name(pkg): # Owner-qualified; the converter resolves it from the registry diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index f4d706d69e..64785c105a 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -337,7 +337,7 @@ class LibraryBackend: # the backend supplies them itself (e.g. core-bundled libraries) and # reconciles provided_requests after resolving provides: Callable[[str], bool] | None = None - provided_requests: list[str] = field(default_factory=list) + provided_requests: set[str] = field(default_factory=set) def ensure_list[T](obj: T | list[T]) -> list[T]: @@ -932,6 +932,8 @@ def _warn_unsatisfied_versionless( """Warn for version-less deps nothing satisfied; a silent drop surfaces as link errors far from the cause.""" resolved_manifest_names = {c.data.get("name") for c in components.values()} + # A treeless backend can never supply a bundled name; noise for it + log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug warned: set[str] = set() for dep_name, dep_owner, requester in skipped_versionless: if not isinstance(dep_name, str) or not dep_name or dep_name in warned: @@ -943,7 +945,6 @@ def _warn_unsatisfied_versionless( # Name-only evidence: a coincidental collision must stay # visible where the user could pin it warned.add(dep_name) - log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug log( "Version-less dependency %s of %s assumed satisfied by a " "resolved library's manifest name only", @@ -958,11 +959,9 @@ def _warn_unsatisfied_versionless( ): # provides() only satisfies owner-less names (same guard as # the walk's skip); record for the post-emit reconciliation - backend.provided_requests.append(dep_name) + backend.provided_requests.add(dep_name) continue warned.add(dep_name) - # A treeless backend can never supply a bundled name; noise for it - log = _LOGGER.warning if backend.provides is not None else _LOGGER.debug log( "Dependency %s of %s has no version to resolve and nothing " "provides it; skipping", @@ -1252,23 +1251,20 @@ def convert_libraries( if "version" not in dependency: # Cannot resolve from the registry; the post-emit # reconciliation owns the drop warning + dep_name = dependency.get("name") _LOGGER.debug( "Skip version-less dependency %r of %s", - dependency.get("name"), + dep_name, component.name, ) if not is_lib_ignored( - dependency.get("name"), lib_ignore + dep_name, lib_ignore ) and dependency_is_usable( dependency, backend.platform, backend.framework, component.name ): # Filtered or ignored deps are deliberately absent skipped_versionless.append( - ( - dependency.get("name"), - dependency.get("owner"), - component.name, - ) + (dep_name, dependency.get("owner"), component.name) ) continue if not dependency_is_usable( @@ -1304,7 +1300,7 @@ def convert_libraries( ) else: _LOGGER.debug("Skip backend-provided dependency %s", dep_name) - backend.provided_requests.append(dep_name) + backend.provided_requests.add(dep_name) continue dep_key = add_spec(dep_name, dep_version, dep_url) node.edges.add(dep_key) diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index eed86db5a7..6969eebed4 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -718,18 +718,18 @@ def test_bundled_dependency_dict_shorthand_prefers_bundled(tmp_path: Path) -> No def test_unfulfilled_provides_promise_raises(tmp_path: Path) -> None: """A provides()-skipped dependency nothing added can only surface as - undefined symbols at link, so it fails here by name, deduplicated; - satisfied ones pass silently.""" + undefined symbols at link, so it fails here by name; satisfied ones + pass silently.""" with pytest.raises(EsphomeError, match="Wire") as err: component._check_unfulfilled_provides( - ["Wire", "Wire", "Hash"], {"Hash"}, {"Wire", "Hash"} + {"Wire", "Hash"}, {"Hash"}, {"Wire", "Hash"} ) assert str(err.value).count("Wire") == 1 assert "Hash" not in str(err.value) - component._check_unfulfilled_provides(["Hash"], {"Hash"}, {"Hash"}) + component._check_unfulfilled_provides({"Hash"}, {"Hash"}, {"Hash"}) # A recording for a since-re-resolved manifest is stale walk state, # never a failure: no final manifest still requests Wire - component._check_unfulfilled_provides(["Wire"], set(), set()) + component._check_unfulfilled_provides({"Wire"}, set(), set()) def test_extra_script_link_flags_reach_the_library(tmp_path: Path) -> None: @@ -988,9 +988,7 @@ def test_owner_qualified_dependency_is_silent( assert "malformed" not in caplog.text -def test_bundled_dependency_string_list_form( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: +def test_bundled_dependency_string_list_form(tmp_path: Path) -> None: """The bare string-list dependency form (PIO-legal) resolves to the bundled library instead of vanishing in normalization.""" framework = _make_framework(tmp_path)