Give the fake env real membership, widen the manifest gate, route zephyr through the shared lexer

This commit is contained in:
J. Nick Koston
2026-08-23 18:12:14 -05:00
parent 76ddbe5ebd
commit a027d06231
8 changed files with 117 additions and 23 deletions
+6 -1
View File
@@ -28,6 +28,7 @@ from esphome.platformio.library import (
collect_filtered_files,
convert_libraries,
ensure_list,
lex_build_flags,
split_list_by_condition,
)
@@ -80,7 +81,11 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str:
build_include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR)
build_src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER))
build_flags = ensure_list(build.get("flags", DEFAULT_BUILD_FLAGS))
# The shared lexer re-glues spaced entries and drops bare/empty
# arguments, same as the espidf emitter
build_flags = lex_build_flags(
build.get("flags", DEFAULT_BUILD_FLAGS), component.name
)
src_files = collect_filtered_files(
read_path / Path(build_src_dir), build_src_filter
+4 -2
View File
@@ -58,8 +58,10 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
"""
def escape_entry(p: PathType) -> str:
# In CMakeLists.txt, backslashes need to be escaped
return f'"{str(p)}"'.replace("\\", "\\\\")
# In CMakeLists.txt, backslashes and embedded quotes need escaping
# (a quoted define value reaches here via the shlex round-trip)
escaped = str(p).replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
def escape_path(p: PathType) -> str:
# CMake uses forward slashes for paths on every platform and treats
+10 -1
View File
@@ -33,7 +33,7 @@ def apply_extra_script(
"""Run a library's ``extraScript`` and fold its captured env vars into
``build.flags``; ``board_mcu`` is a callable so it resolves lazily."""
extra_script = component.data.get("build", {}).get("extraScript")
if not extra_script:
if extra_script is None or extra_script == "":
return
if not isinstance(extra_script, str):
# A list/dict value would raise an opaque TypeError on the join below
@@ -170,6 +170,15 @@ class _FakeSConsEnv:
)
return self._vars.get(key, default)
def __contains__(self, key: object) -> bool:
# Without this, "KEY" in env falls back to the legacy sequence
# protocol: __getitem__(0), (1), ... never raises, so it loops
# forever flooding the log
return key in self._vars
def __iter__(self):
return iter(self._vars)
def __getitem__(self, key: str) -> str:
# Scripts also read env["BOARD_MCU"]; an unmodelled subscript
# degrades one branch instead of discarding the whole capture
+28 -18
View File
@@ -656,15 +656,15 @@ def dependency_is_usable(
def _valid_dependency_entry(entry: dict, manifest_name: str) -> bool:
"""Whether a normalized entry carries a usable name (non-empty string)
and version (string, if present); invalid entries warn naming the
manifest."""
"""Whether a normalized entry carries a usable name (non-empty string),
version (string, if present), and owner (string, if present); invalid
entries warn naming the manifest."""
name = entry.get("name")
if (
isinstance(name, str)
and name
and ("version" not in entry or isinstance(entry["version"], str))
):
owner = entry.get("owner")
name_ok = isinstance(name, str) and name
version_ok = "version" not in entry or isinstance(entry["version"], str)
owner_ok = owner is None or isinstance(owner, str)
if name_ok and version_ok and owner_ok:
return True
_LOGGER.warning(
"Ignoring unrecognized dependency entry %r of %s", entry, manifest_name
@@ -683,7 +683,7 @@ def normalize_dependencies(
so callers see a uniform list. ``manifest_name`` names the manifest in the
warning for entries that cannot be normalized.
"""
if not dependencies:
if dependencies is None:
return []
if isinstance(dependencies, str):
# A plain string is one or more comma-separated names; iterating it
@@ -1004,11 +1004,19 @@ 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
# A bare json.load imposes no shape; every backend dereferences
# these fields, so validate once here and name the library
malformed = not isinstance(component.data, dict)
if not malformed:
build = component.data.get("build", {})
malformed = (
not isinstance(build, dict)
or not isinstance(component.data.get(ESPHOME_DATA_KEY, {}), dict)
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")
warn_properties_depends(component.name, component.data)
@@ -1018,10 +1026,12 @@ def convert_libraries(
# An explicitly requested library fails fast; the routine
# cross-platform skip stays at debug, other causes warn
if key in top_level_keys:
raise RuntimeError(
f"Requested library {key} is not compatible with "
f"{backend.framework}: {e}"
) from e
reason = (
f"is not compatible with {backend.framework}"
if isinstance(e, IncompatiblePlatform)
else "has a malformed manifest"
)
raise RuntimeError(f"Requested library {key} {reason}: {e}") from e
if isinstance(e, IncompatiblePlatform):
_LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e))
else:
+13
View File
@@ -294,6 +294,19 @@ def test_generate_cmakelists_txt_multi_token_flag(tmp_component):
assert ' "-include"\n "cp_custom_alloc.h"\n' in content
def test_generate_cmakelists_txt_escapes_embedded_quotes(tmp_component):
"""A define value carrying a literal quote survives into CMake as an
escaped quote, not a prematurely-terminated string."""
src_dir = tmp_component.path / "src"
src_dir.mkdir()
(src_dir / "main.c").write_text("int main() {}")
# shlex keeps the backslash-escaped quotes as literal characters
tmp_component.data = {"build": {"flags": ['-DMSG=\\"hi\\"']}}
content = generate_cmakelists_txt(tmp_component)
assert '"-DMSG=\\"hi\\""' in content
def test_generate_cmakelists_txt_extra_script_link_flags(tmp_component):
"""Captured extra-script LINKFLAGS come out as target_link_options, not
compile options where they would be silently ineffective."""
@@ -461,6 +461,27 @@ def test_prepend_inserts_ahead_of_existing(method: str) -> None:
assert env.result.libs == ["algobsec", "bsec", "m"]
def test_env_membership_and_iteration(tmp_path) -> None:
"""Membership tests and for-loops must use the mapping protocol; the
legacy sequence fallback through __getitem__ would loop forever."""
env = _FakeSConsEnv(
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
)
assert "BOARD_MCU" in env
assert "NOPE" not in env
assert sorted(env) == ["BOARD_MCU", "PIOENV", "PIOPLATFORM"]
def test_apply_extra_script_non_string_falsey_raises(tmp_path) -> None:
"""A falsey non-string extraScript (false, 0, []) is a malformed
manifest, not an absent script."""
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
c.data = {"build": {"extraScript": False}}
with pytest.raises(EsphomeError, match="must be a string"):
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
def test_env_get_unknown_key_warns_once(caplog) -> None:
"""A script branching on an unmodelled env var is diagnosable."""
env = _FakeSConsEnv(
+19 -1
View File
@@ -614,10 +614,28 @@ def test_normalize_dependencies_forms(caplog) -> None:
assert normalize_dependencies({"Foo": ["1.0", "2.0"]}, "libx") == []
assert normalize_dependencies([{"name": "Foo", "version": 1}], "libx") == []
assert caplog.text.count("unrecognized dependency entry") == 7
# A non-string owner would stringify into a malformed registry name
assert (
normalize_dependencies(
[{"name": "Foo", "owner": {"bad": 1}, "version": "1.0"}], "libx"
)
== []
)
# A falsey scalar (0, false) is malformed, not an empty list
assert normalize_dependencies(0, "libx") == []
assert "Ignoring unrecognized dependencies 0 of libx" in caplog.text
@pytest.mark.parametrize(
"manifest", [["not", "a", "manifest"], {"name": "A", "build": "src"}]
"manifest",
[
["not", "a", "manifest"],
{"name": "A", "build": "src"},
{"name": "A", "ESPHOME": "yes"},
{"name": "A", "build": {"srcDir": 123}},
{"name": "A", "build": {"includeDir": ["inc"]}},
{"name": "A", "build": {"srcFilter": {"+": "src"}}},
],
)
def test_convert_libraries_malformed_manifest_raises(
tmp_path, monkeypatch, manifest
+16
View File
@@ -66,6 +66,22 @@ def test_generate_cmakelists_txt_flags_and_includes(tmp_path):
assert "-lm" 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."""
c = _make_component(tmp_path)
(tmp_path / "src").mkdir()
(tmp_path / "src" / "a.c").write_text("")
(tmp_path / "include").mkdir()
c.data = {"build": {"flags": "-I include -DBAR=1"}}
out = generate_cmakelists_txt(c)
assert str((tmp_path / "include").resolve()).replace("\\", "\\\\") in out
assert "-DBAR=1" in out
assert "-I include" not in out
def test_generate_zephyr_modules_collects_all_dirs_and_writes(tmp_path, monkeypatch):
# Two converted libraries: one top-level, one transitive dependency. The
# converter calls backend.emit for both; generate_zephyr_modules must return