Validate shorthand dependencies, honor lib_ignore for version-less deps, warn on non-platform component drops

The dict-shorthand dependency form now runs the same name validation as
the list form, so an empty key or a spec overriding name with a
non-string warns instead of raising a bare TypeError from the key
builder. A lib_ignore'd version-less dependency is deliberately excluded
and no longer draws the reconciliation warning. The walk's
component-level InvalidLibrary handler mirrors dependency_is_usable:
cross-platform skips stay at debug, any other cause warns. A malformed
libArchive/dot_a_linkage value now fails naming the library like the
sibling build fields, and the suffix map gains SCons's case-sensitive .C
and .C++ so those sources compile as C++ instead of silently dropping
out of the archive.
This commit is contained in:
J. Nick Koston
2026-08-22 11:49:25 -05:00
parent c7a96f77d2
commit 3242196f9c
4 changed files with 110 additions and 35 deletions
+1 -7
View File
@@ -128,13 +128,7 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
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,
key,
raw,
)
return True
raise EsphomeError(f"Library {name} has a malformed {key} value {raw!r}")
if "libArchive" in build:
lib_archive = _parse_archive("libArchive", build["libArchive"])
+29 -9
View File
@@ -55,6 +55,8 @@ SOURCE_KIND_FOR_SUFFIX: dict[str, str] = {
".cc": "cxx",
".cxx": "cxx",
".c++": "cxx",
".C": "cxx",
".C++": "cxx",
".S": "asm",
".spp": "asm",
".SPP": "asm",
@@ -664,7 +666,7 @@ def normalize_dependencies(
if isinstance(dependencies, dict):
normalized = []
for raw_name, spec in dependencies.items():
if "/" in raw_name:
if isinstance(raw_name, str) and "/" in raw_name:
owner, pkgname = raw_name.split("/", 1)
else:
owner, pkgname = None, raw_name
@@ -673,6 +675,13 @@ def normalize_dependencies(
entry.update(spec)
else:
entry["version"] = spec
if not isinstance(name := entry.get("name"), str) or not name:
_LOGGER.warning(
"Ignoring unrecognized dependency entry %r of %s",
entry,
manifest_name,
)
continue
normalized.append(entry)
return normalized
normalized = []
@@ -922,7 +931,7 @@ def convert_libraries(
components: dict[str, ConvertedLibrary] = {}
resolved_requirements: dict[str, frozenset[str]] = {}
top_level_keys = set(top_level)
# (name, requester) pairs reconciled against the final resolution set
# (name, owner, requester) reconciled against the final resolution set
skipped_versionless: list[tuple[Any, Any, str]] = []
worklist = deque(dict.fromkeys(top_level))
while worklist:
@@ -996,14 +1005,19 @@ def convert_libraries(
try:
check_library_data(component.data, backend.platform, backend.framework)
except InvalidLibrary as e:
# Skip an incompatible transitive dependency, but fail fast if a
# top-level library the build explicitly requested is incompatible.
# Fail fast if a top-level library the build explicitly requested
# is incompatible; the routine cross-platform skip stays at
# debug, any other cause warns (a silent drop resurfaces as
# undefined symbols at link)
if key in top_level_keys:
raise RuntimeError(
f"Requested library {key} is not compatible with "
f"{backend.framework}: {e}"
) from e
_LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e))
if isinstance(e, IncompatiblePlatform):
_LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e))
else:
_LOGGER.warning("Skipping dependency %s: %s", key, str(e))
continue
components[key] = component
@@ -1015,15 +1029,21 @@ def convert_libraries(
):
if "name" not in dependency or "version" not in dependency:
# Version-less deps cannot resolve from the registry; the
# post-emit reconciliation owns the drop warning
# post-emit reconciliation owns the drop warning. An
# is_lib_ignored name is deliberately excluded, not a drop.
_LOGGER.debug(
"Skip version-less dependency %r of %s",
dependency.get("name"),
component.name,
)
skipped_versionless.append(
(dependency.get("name"), dependency.get("owner"), component.name)
)
if not is_lib_ignored(dependency.get("name"), lib_ignore):
skipped_versionless.append(
(
dependency.get("name"),
dependency.get("owner"),
component.name,
)
)
continue
if not dependency_is_usable(
dependency, backend.platform, backend.framework, component.name
+25 -18
View File
@@ -441,28 +441,32 @@ def test_library_info_falsy_declared_src_dir_raises(
@pytest.mark.parametrize(
("value", "expected", "warns"),
("value", "expected"),
[
(False, False, False),
("false", False, False),
("False", False, False),
("true", True, False),
("archive-me", True, True),
(False, False),
("false", False),
("False", False),
("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_library_info_lib_archive_malformed_raises(tmp_path: Path) -> None:
"""A typo'd libArchive fails by name like the other build fields."""
read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True)
with pytest.raises(EsphomeError, match="malformed libArchive value 'archive-me'"):
component._library_info("x", read_path, {"build": {"libArchive": "archive-me"}})
def test_bundled_dependency_dict_shorthand_prefers_bundled(tmp_path: Path) -> None:
@@ -535,27 +539,30 @@ def test_library_info_malformed_build_fields_are_named(
@pytest.mark.parametrize(
("value", "expected", "warns"),
("value", "expected"),
[
("true", True, False),
("False", False, False),
("yes", True, True),
("true", True),
("False", False),
],
)
def test_library_info_dot_a_linkage_parses_strictly(
tmp_path: Path,
value: str,
expected: bool,
warns: bool,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The dot_a_linkage property uses the same strict table as libArchive; a typo warns
and keeps the archive default instead of silently flipping linkage."""
"""The dot_a_linkage property uses the same strict table as libArchive."""
read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True)
lib = component._library_info("x", read_path, {"dot_a_linkage": value, "build": {}})
assert lib.lib_archive is expected
assert ("unrecognized dot_a_linkage" in caplog.text) is warns
def test_library_info_dot_a_linkage_malformed_raises(tmp_path: Path) -> None:
"""A typo'd dot_a_linkage must not silently flip link semantics."""
read_path = tmp_path / "lib"
(read_path / "src").mkdir(parents=True)
with pytest.raises(EsphomeError, match="malformed dot_a_linkage value 'yes'"):
component._library_info("x", read_path, {"dot_a_linkage": "yes", "build": {}})
def test_bundled_library_properties_depends_warns(
+55 -1
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import pytest
from esphome.core import EsphomeError, Library
from esphome.core import CORE, EsphomeError, Library
import esphome.platformio.library as lib
from esphome.platformio.library import (
SOURCE_KIND_FOR_SUFFIX,
@@ -540,6 +540,36 @@ def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch):
assert top[0].dependencies == []
def test_convert_libraries_warns_for_nonplatform_invalid_dependency_component(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A dependency component dropped for any cause other than the platform
filter warns; only the routine cross-platform skip stays at debug."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{
"esphome/A": {
"name": "A",
"dependencies": [{"name": "C", "owner": "esphome", "version": "1.0"}],
},
"esphome/C": {"name": "C"},
},
)
real = lib.check_library_data
def flaky(data, platform, framework):
# Fail only on C's resolved manifest, not on A's dependency entry
if data.get("name") == "C" and "version" not in data:
raise InvalidLibrary("manifest is corrupt")
return real(data, platform, framework)
monkeypatch.setattr(lib, "check_library_data", flaky)
convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
assert "manifest is corrupt" in caplog.text
assert "Skipping dependency" in caplog.text
def test_split_flag_entry_unbalanced_quote_is_clean() -> None:
"""A malformed flags entry raises EsphomeError, not a raw ValueError."""
@@ -592,6 +622,9 @@ def test_source_kind_map_shape() -> None:
assert SOURCE_KIND_FOR_SUFFIX[".S"] == "asm"
assert SOURCE_KIND_FOR_SUFFIX[".c"] == "c"
assert SOURCE_KIND_FOR_SUFFIX[".cpp"] == "cxx"
# SCons's case-sensitive C++ suffixes: PIO compiles .C as C++
assert SOURCE_KIND_FOR_SUFFIX[".C"] == "cxx"
assert SOURCE_KIND_FOR_SUFFIX[".C++"] == "cxx"
def test_normalize_dependencies_forms(caplog) -> None:
@@ -612,6 +645,12 @@ def test_normalize_dependencies_forms(caplog) -> None:
{"name": "SPI"},
]
assert normalize_dependencies("Wire") == [{"name": "Wire"}]
# The dict-shorthand form validates names like the list form: an empty
# key and a spec overriding name with a non-string both warn and drop
assert normalize_dependencies(
{"": "1.0", "Wire": {"name": 123, "version": "1.0"}, "SPI": "*"}, "libx"
) == [{"name": "SPI", "owner": None, "version": "*"}]
assert caplog.text.count("unrecognized dependency entry") == 5
@pytest.mark.parametrize(
@@ -627,6 +666,21 @@ def test_convert_libraries_malformed_manifest_raises(
convert_libraries([Library("esphome/A", None, None)], _backend())
def test_versionless_ignored_dependency_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A lib_ignore'd version-less dependency is deliberately excluded, not
a drop; no reconciliation warning."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
{"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]}},
)
CORE.platformio_options = {"lib_ignore": ["Hash"]}
convert_libraries([Library("esphome/A", None, None)], _backend())
assert "has no version to resolve" not in caplog.text
def test_versionless_dependency_without_provider_warns(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None: