Merge branch 'esp8266-native-library-backend' into esp8266-native-build-spec

This commit is contained in:
J. Nick Koston
2026-08-25 22:39:44 -05:00
3 changed files with 51 additions and 50 deletions
+36 -29
View File
@@ -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
+9 -13
View File
@@ -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)
+6 -8
View File
@@ -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)