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.
This commit is contained in:
J. Nick Koston
2026-08-22 11:04:35 -05:00
parent 70b8573110
commit 57e738adf8
5 changed files with 256 additions and 497 deletions
+53 -88
View File
@@ -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
+4 -4
View File
@@ -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(
+49 -36
View File
@@ -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
+137 -369
View File
@@ -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
@@ -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: