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

This commit is contained in:
J. Nick Koston
2026-08-21 12:53:46 -05:00
3 changed files with 151 additions and 26 deletions
+47 -24
View File
@@ -32,6 +32,7 @@ from esphome.platformio.library import (
DEFAULT_BUILD_SRC_FILTER,
SRC_FILE_EXTENSIONS,
ConvertedLibrary,
IncompatiblePlatform,
InvalidLibrary,
LibraryBackend,
check_library_data,
@@ -73,16 +74,19 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
build = data.get("build", {})
# PIO's source-dir resolution: manifest srcDir, else src/Src, else the root
src_dir = build.get("srcDir") or next(
(d for d in ("src", "Src") if (read_path / d).is_dir()), "."
)
if "srcDir" in build and not (read_path / src_dir).is_dir():
# Unlike the default probes, an explicitly declared srcDir that does
# not resolve is unambiguously a manifest/tree error; a silently
# empty source set would surface as link errors far from the cause
raise EsphomeError(
f"Library {name} declares srcDir {src_dir} which does not exist"
)
if "srcDir" in build:
# An explicitly declared srcDir (falsy included) that does not
# resolve is unambiguously a manifest/tree error; a silently empty
# source set would surface as link errors far from the cause
src_dir = build["srcDir"]
if not (
isinstance(src_dir, str) and src_dir and (read_path / src_dir).is_dir()
):
raise EsphomeError(
f"Library {name} declares srcDir {src_dir!r} which does not exist"
)
else:
src_dir = next((d for d in ("src", "Src") if (read_path / d).is_dir()), ".")
src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER))
# PlatformIO shell-lexes each build.flags entry
@@ -92,7 +96,20 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
# deliberate extra (Arduino IDE's property, which PIO ignores) so
# properties-only libraries can opt out of archiving too
if "libArchive" in build:
lib_archive = bool(build["libArchive"])
raw_archive = build["libArchive"]
if isinstance(raw_archive, bool):
lib_archive = raw_archive
elif str(raw_archive).strip().lower() in ("true", "false"):
lib_archive = str(raw_archive).strip().lower() == "true"
else:
# bool("false") is True; an unparsable value must not silently
# archive a library whose author disabled archiving
_LOGGER.warning(
"Library %s has an unrecognized libArchive value %r; assuming true",
name,
raw_archive,
)
lib_archive = True
elif "dot_a_linkage" in data:
lib_archive = str(data["dot_a_linkage"]).lower() == "true"
else:
@@ -192,9 +209,10 @@ def resolve_libraries(
and "/" not in library.name
and (framework_path / "libraries" / library.name).is_dir()
):
# A bundled library's own manifest dependencies are deliberately
# not walked (PlatformIO's lib_ldf_mode=off does not either);
# core add_library() calls list what they need explicitly.
# 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
# is a no-op there; core add_library() calls list what they need.
bundled.append(_bundled_library(framework_path, library.name))
else:
external.append(library)
@@ -217,11 +235,13 @@ def resolve_libraries(
continue
if name in bundled_names or is_lib_ignored(name, lib_ignore):
continue
if "version" in dep:
# The converter resolves versioned deps from the registry.
# Note: the dict shorthand {"Wire": "*"} normalizes to
# version="*" and takes this path even for a bundled name;
# use the list form for bundled dependencies.
bundled_dir = framework_path / "libraries" / name
if "version" in dep and (dep.get("owner") or not bundled_dir.is_dir()):
# 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
@@ -233,7 +253,7 @@ def resolve_libraries(
component.name,
)
continue
if not (framework_path / "libraries" / name).is_dir():
if not bundled_dir.is_dir():
# The shared converter skips version-less deps too, so this
# is the only place the drop can be made visible before the
# missing sources surface as link errors.
@@ -251,7 +271,7 @@ def resolve_libraries(
# 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 "platform" in str(err).lower():
if isinstance(err, IncompatiblePlatform):
_LOGGER.debug("Skipping bundled dependency %s: %s", name, err)
else:
_LOGGER.warning(
@@ -287,12 +307,15 @@ def resolve_libraries(
)
if len(resolved) < len(external):
# A requested library the converter dropped would otherwise
# surface only as link errors far from the cause
# surface only as link errors far from the cause; name the
# requests that went missing, not just the survivors
resolved_names = {c.name for c in resolved}
dropped = [str(lib) for lib in external if lib.name not in resolved_names]
_LOGGER.warning(
"%d of %d requested libraries were not resolved (resolved: %s)",
"%d of %d requested libraries were not resolved (missing: %s)",
len(external) - len(resolved),
len(external),
", ".join(sorted(c.name for c in resolved)) or "none",
", ".join(sorted(dropped)) or "unknown",
)
return bundled + converted
+9 -1
View File
@@ -209,6 +209,14 @@ class InvalidLibrary(Exception):
pass
class IncompatiblePlatform(InvalidLibrary):
"""The manifest's platform filter rejected the target platform.
A distinct type so callers can treat the routine cross-platform skip
differently from other manifest problems without matching message text.
"""
class ConvertedLibrary:
"""A resolved PlatformIO library plus its parsed manifest and on-disk path.
@@ -438,7 +446,7 @@ def check_library_data(data: dict, platform: str | None, framework: str):
valid_platforms = platform is None or "*" in platforms or platform in platforms
if not valid_platforms:
raise InvalidLibrary(f"Unsupported library platforms: {platforms}")
raise IncompatiblePlatform(f"Unsupported library platforms: {platforms}")
frameworks = data.get("frameworks", "*")
if isinstance(frameworks, str):
+95 -1
View File
@@ -291,7 +291,7 @@ def test_library_info_missing_declared_src_dir_raises(tmp_path: Path) -> None:
"""An explicitly declared srcDir that does not exist is a manifest error."""
read_path = tmp_path / "lib"
read_path.mkdir()
with pytest.raises(EsphomeError, match="srcDir nosrc which does not exist"):
with pytest.raises(EsphomeError, match="srcDir 'nosrc' which does not exist"):
component._library_info("x", read_path, {"build": {"srcDir": "nosrc"}})
@@ -425,6 +425,8 @@ def test_resolve_libraries_warns_when_converter_drops_a_request(
cache_key="arduino8266",
)
assert "1 of 1 requested libraries were not resolved" in caplog.text
# The actionable fact is which request went missing, not the survivors
assert "missing: pngle" in caplog.text
def test_bundled_dependency_nonplatform_rejection_warns(
@@ -458,3 +460,95 @@ def test_bundled_dependency_nonplatform_rejection_warns(
)
assert "Skipping bundled dependency Wire" in caplog.text
assert "manifest is corrupt" in caplog.text
@pytest.mark.parametrize("declared", ["", None])
def test_library_info_falsy_declared_src_dir_raises(
tmp_path: Path, declared: str | None
) -> None:
"""A declared-but-falsy srcDir must not silently fall back to the probe."""
read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True)
with pytest.raises(EsphomeError, match="does not exist"):
component._library_info("x", read_path, {"build": {"srcDir": declared}})
@pytest.mark.parametrize(
("value", "expected", "warns"),
[
(False, False, False),
("false", False, False),
("False", False, False),
("true", True, False),
("archive-me", True, True),
],
)
def test_library_info_lib_archive_parse(
tmp_path: Path,
value: object,
expected: bool,
warns: bool,
caplog: pytest.LogCaptureFixture,
) -> None:
"""bool("false") is True; the string forms must parse, not coerce."""
read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True)
lib = component._library_info("x", read_path, {"build": {"libArchive": value}})
assert lib.lib_archive is expected
assert ("unrecognized libArchive" in caplog.text) is warns
def test_bundled_dependency_dict_shorthand_prefers_bundled(tmp_path: Path) -> None:
"""The {"Wire": "*"} dict shorthand (version="*", no owner) must resolve
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": "*"}},
)
with _emitting_converter(converted):
libs = component.resolve_libraries(
framework,
pio_platform="espressif8266",
board_mcu="esp8266",
cache_key="arduino8266",
)
assert "Wire" in [lib.name for lib in libs]
def test_bundled_dependency_platform_rejection_is_debug(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""The typed IncompatiblePlatform (the routine cross-platform skip)
stays at debug regardless of message wording."""
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"}]},
)
with (
_emitting_converter(converted),
patch.object(
component,
"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