diff --git a/esphome/components/zephyr/library.py b/esphome/components/zephyr/library.py index b339ae45b0..d09f0afedb 100644 --- a/esphome/components/zephyr/library.py +++ b/esphome/components/zephyr/library.py @@ -39,11 +39,13 @@ ZEPHYR_FRAMEWORK = "zephyr" def _escape(p: PathType) -> str: - # In CMakeLists.txt, backslashes need to be escaped (mirrors the ESP-IDF - # backend's escape_entry). Doubling -- rather than rewriting '\' -> '/' -- - # preserves content, so it's safe for arbitrary build flags (e.g. a -D value - # containing a backslash) as well as Windows paths. - return f'"{str(p)}"'.replace("\\", "\\\\") + # In CMakeLists.txt, backslashes and embedded quotes need escaping + # (mirrors the ESP-IDF backend's escape_entry; the lex round-trip makes + # a literal quote in a -D value reachable). Doubling backslashes -- + # rather than rewriting '\' -> '/' -- preserves content, so it's safe + # for arbitrary build flags as well as Windows paths. + escaped = str(p).replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' def generate_module_yml(component: ConvertedLibrary) -> str: diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index 521337ff84..6c36abcb49 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -714,6 +714,10 @@ def normalize_dependencies( list, and a plain (possibly comma-separated) string; normalize them all so callers see a uniform list. ``manifest_name`` names the manifest in the warning for entries that cannot be normalized. + + Bare-name spellings carry no version; the dependency walk later drops + version-less entries at DEBUG, since they are usually names of bundled + framework libraries (Wire, SPI) that need no registry install. """ if dependencies is None: return [] @@ -1133,15 +1137,25 @@ def convert_libraries( malformed = not isinstance(component.data, dict) if not malformed: build = component.data.get("build", {}) + esphome_data = component.data.get(ESPHOME_DATA_KEY, {}) malformed = ( not isinstance(build, dict) - or not isinstance(component.data.get(ESPHOME_DATA_KEY, {}), dict) + or not isinstance(esphome_data, dict) + or not isinstance( + esphome_data.get(ESPHOME_DATA_LINK_FLAGS_KEY, []), list + ) or not isinstance(build.get("srcDir", ""), str) or not isinstance(build.get("includeDir", ""), str) or not isinstance(build.get("srcFilter", ""), (str, list)) ) if malformed: - raise EsphomeError(f"Library {key} has a malformed manifest") + # Fail fast only for a library the user asked for; a defect + # in an unrequested corner of the graph must not block the + # build + if key in top_level_keys: + raise EsphomeError(f"Library {key} has a malformed manifest") + _LOGGER.warning("Skipping dependency %s: malformed manifest", key) + continue warn_properties_depends(component.name, component.data) try: diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index a565229bb1..ef24f99953 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -826,6 +826,7 @@ def test_normalize_dependencies_forms(caplog) -> None: {"name": "A", "build": {"srcDir": 123}}, {"name": "A", "build": {"includeDir": ["inc"]}}, {"name": "A", "build": {"srcFilter": {"+": "src"}}}, + {"name": "A", "ESPHOME": {"LINK_FLAGS": "-Wl,-x"}}, ], ) def test_convert_libraries_malformed_manifest_raises( @@ -838,6 +839,29 @@ def test_convert_libraries_malformed_manifest_raises( convert_libraries([Library("esphome/A", None, None)], _backend()) +def test_convert_libraries_malformed_transitive_dep_skips( + tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture +) -> None: + """A malformed manifest on a dependency the user never asked for warns + and skips; only a top-level library fails the build.""" + _patch_download_with_manifests( + monkeypatch, + tmp_path, + { + "esphome/A": { + "name": "A", + "dependencies": [{"name": "B", "owner": "esphome", "version": "1.0"}], + }, + "esphome/B": {"name": "B", "build": {"srcDir": 123}}, + }, + ) + components = convert_libraries([Library("esphome/A", None, None)], _backend()) + names = [c.name for c in components] + assert "esphome/A" in names + assert "esphome/B" not in names + assert "Skipping dependency esphome/B: malformed manifest" in caplog.text + + def test_walk_warns_for_properties_only_depends( tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit_tests/test_zephyr_library.py b/tests/unit_tests/test_zephyr_library.py index 0d899ec91d..765e505c38 100644 --- a/tests/unit_tests/test_zephyr_library.py +++ b/tests/unit_tests/test_zephyr_library.py @@ -66,6 +66,18 @@ def test_generate_cmakelists_txt_flags_and_includes(tmp_path): assert "-lm" in out +def test_generate_cmakelists_txt_escapes_embedded_quotes(tmp_path): + """A define value carrying a literal quote (reachable via the lex + round-trip) survives as an escaped quote, not a broken CMake string.""" + c = _make_component(tmp_path) + (tmp_path / "src").mkdir() + (tmp_path / "src" / "a.c").write_text("") + c.data = {"build": {"flags": ['-DMSG=\\"hi\\"']}} + + out = generate_cmakelists_txt(c) + assert '"-DMSG=\\"hi\\""' in out + + def test_generate_cmakelists_txt_lexes_spaced_flags(tmp_path): """A spaced -I entry routes to include dirs instead of landing verbatim in compile options; same shared lexer as the espidf emitter."""