diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index 80e7aa0043..db9480e629 100644 --- a/esphome/arduino/library.py +++ b/esphome/arduino/library.py @@ -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"]) diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index a37c7fe5bf..5fdf46b464 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -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 diff --git a/tests/unit_tests/test_arduino_library.py b/tests/unit_tests/test_arduino_library.py index c310c964d9..6c03bc9345 100644 --- a/tests/unit_tests/test_arduino_library.py +++ b/tests/unit_tests/test_arduino_library.py @@ -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( diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index febaf830a5..fff7b0d677 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -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: