mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
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:
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user