diff --git a/esphome/espidf/component.py b/esphome/espidf/component.py index aa6f10c261..68c9398e19 100644 --- a/esphome/espidf/component.py +++ b/esphome/espidf/component.py @@ -27,6 +27,8 @@ from esphome.platformio.library import ( collect_filtered_files, convert_libraries, ensure_list, + join_flag_args, + split_flag_entry, split_list_by_condition, ) @@ -41,34 +43,10 @@ def _idf_framework() -> str: def _apply_extra_script(component: IDFComponent) -> None: - """Run a PIO ``extraScript`` and fold its captured env vars into - ``component.data["build"]["flags"]`` so the existing -L/-l/-D - extraction in ``generate_cmakelists_txt`` picks them up.""" - extra_script = component.data.get("build", {}).get("extraScript") - if not extra_script: - return - # Resolve and confine to the library's source dir so a malicious - # library.json can't escape (e.g. ``"extraScript": "../../etc/passwd"``). - source_path = component.source_dir - library_root = source_path.resolve() - script_path = (source_path / extra_script).resolve() - if not script_path.is_relative_to(library_root) or not script_path.is_file(): - return from esphome.components.esp32 import get_esp32_variant - from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script + from esphome.espidf.extra_script import apply_extra_script - idf_target = variant_to_idf_target(get_esp32_variant()) - result = run_extra_script( - script_path, library_dir=source_path, idf_target=idf_target - ) - extra_flags = captured_as_build_flags(result, library_dir=source_path) - if not extra_flags: - return - flags = component.data.setdefault("build", {}).setdefault("flags", []) - if isinstance(flags, str): - flags = [flags] - flags.extend(extra_flags) - component.data["build"]["flags"] = flags + apply_extra_script(component, lambda: variant_to_idf_target(get_esp32_variant())) def generate_cmakelists_txt(component: IDFComponent) -> str: @@ -85,10 +63,6 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: Returns: str: The complete CMakeLists.txt content as a string """ - # Late import: this module loads with the esp32 platform on every - # validate/compile, but shlex is only needed when generating component - # CMakeLists. - import shlex def escape_entry(p: PathType) -> str: # In CMakeLists.txt, backslashes need to be escaped @@ -126,22 +100,17 @@ def generate_cmakelists_txt(component: IDFComponent) -> str: component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS) ) # PlatformIO shell-lexes each build.flags entry, so one entry can carry a - # flag and its argument (e.g. "-include cp_custom_alloc.h"). Split the - # same way; emitting such an entry as a single quoted compile option - # hands the compiler one argv with an embedded space. - build_flags = [token for entry in build_flags for token in shlex.split(entry)] - # Re-glue bare -I/-L/-l tokens to their argument ("-I foo" -> "-Ifoo") so - # the prefix classifiers below still route them to INCLUDE_DIRS and the - # link handling. - tokens, build_flags = build_flags, [] - i = 0 - while i < len(tokens): - if tokens[i] in ("-I", "-L", "-l") and i + 1 < len(tokens): - build_flags.append(tokens[i] + tokens[i + 1]) - i += 2 - else: - build_flags.append(tokens[i]) - i += 1 + # flag and its argument (e.g. "-include cp_custom_alloc.h"); bare + # -I/-L/-l tokens re-glue to their argument ("-I foo" -> "-Ifoo") so the + # prefix classifiers below still route them. + build_flags = join_flag_args( + ( + token + for entry in build_flags + for token in split_flag_entry(entry, f"library {component.name}") + ), + f"library {component.name}", + ) # List all sources files build_src_files = collect_filtered_files( diff --git a/esphome/espidf/extra_script.py b/esphome/espidf/extra_script.py index 487fef7cc1..6ca06ad5ba 100644 --- a/esphome/espidf/extra_script.py +++ b/esphome/espidf/extra_script.py @@ -28,13 +28,74 @@ Caveats from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field import logging import os from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from esphome.platformio.library import ConvertedLibrary _LOGGER = logging.getLogger(__name__) + +def apply_extra_script( + component: ConvertedLibrary, + idf_target: str | Callable[[], str], + pio_platform: str = "espressif32", +) -> None: + """Run a library's PIO ``extraScript`` and fold its captured env vars into + ``component.data["build"]["flags"]`` so the backend's -L/-l/-D extraction + picks them up. Shared by the ESP-IDF and ESP8266 Arduino backends. + + ``idf_target`` may be a callable so a backend whose target lookup needs + build state (the esp32 variant) resolves it only when a script will run. + ``pio_platform`` is exposed to the script as PlatformIO's ``PIOPLATFORM``. + """ + extra_script = component.data.get("build", {}).get("extraScript") + if not extra_script: + return + # Resolve and confine to the library's source dir so a malicious + # library.json can't escape (e.g. ``"extraScript": "../../etc/passwd"``). + source_path = component.source_dir + library_root = source_path.resolve() + script_path = (source_path / extra_script).resolve() + if not script_path.is_relative_to(library_root): + _LOGGER.warning( + "Ignoring extraScript %s of library %s: it escapes the library directory", + extra_script, + component.name, + ) + return + if not script_path.is_file(): + # The script's captured -L/-l/-D flags are lost; surface that here + # instead of as undefined references at link time + _LOGGER.warning( + "extraScript %s of library %s not found; skipping", + extra_script, + component.name, + ) + return + if callable(idf_target): + idf_target = idf_target() + result = run_extra_script( + script_path, + library_dir=source_path, + idf_target=idf_target, + pio_platform=pio_platform, + ) + extra_flags = captured_as_build_flags(result, library_dir=source_path) + if not extra_flags: + return + flags = component.data.setdefault("build", {}).setdefault("flags", []) + if isinstance(flags, str): + flags = [flags] + flags.extend(extra_flags) + component.data["build"]["flags"] = flags + + # Keys we know how to translate back into ESPHome's build-flag pipeline. # Other env.Append kwargs are recorded but ignored downstream. _CAPTURED_KEYS = frozenset({"LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"}) @@ -60,10 +121,10 @@ class _FakeSConsEnv: ``AttributeError`` and abort the script. """ - def __init__(self, *, board_mcu: str, pio_env: str) -> None: + def __init__(self, *, board_mcu: str, pio_env: str, pio_platform: str) -> None: self._vars: dict[str, str] = { "BOARD_MCU": board_mcu, - "PIOPLATFORM": "espressif32", + "PIOPLATFORM": pio_platform, "PIOENV": pio_env, } self.result = ExtraScriptResult() @@ -91,7 +152,11 @@ class _FakeSConsEnv: def run_extra_script( - script_path: Path, *, library_dir: Path, idf_target: str + script_path: Path, + *, + library_dir: Path, + idf_target: str, + pio_platform: str = "espressif32", ) -> ExtraScriptResult: """Execute ``script_path`` with a fake SCons env and return captured vars. @@ -106,7 +171,11 @@ def run_extra_script( an empty result — extra-scripts are best-effort, and an unsupported script shouldn't block the build. """ - env = _FakeSConsEnv(board_mcu=idf_target, pio_env=f"esphome_{idf_target}") + env = _FakeSConsEnv( + board_mcu=idf_target, + pio_env=f"esphome_{idf_target}", + pio_platform=pio_platform, + ) code = compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec") old_cwd = Path.cwd() try: diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py index 0047d568e2..fd01b4fb28 100644 --- a/esphome/espidf/idedata.py +++ b/esphome/espidf/idedata.py @@ -15,6 +15,7 @@ import json import logging import os from pathlib import Path +import re import shlex import subprocess @@ -120,7 +121,14 @@ def _pick_entry(entries: list[dict]) -> dict: raise ValueError("no C++ translation unit found in compile_commands.json") -def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: +# The compiler basename a compile_commands entry must lead with (an +# optional target-triple prefix ends in one of these) +_COMPILER_STEM = re.compile(r"(?:gcc|g\+\+|cc|c\+\+|clang|clang\+\+)$") + + +def _parse_entry( + entry: dict, launcher: str | None = None +) -> tuple[str, list[str], list[str], list[str]]: """Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags).""" directory = Path(entry["directory"]) tokens = _expand_response_files(_split_command(entry["command"]), directory) @@ -136,6 +144,18 @@ def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: raw = os.path.normpath(directory / raw) return raw.replace("\\", "/") + # A launcher-wrapped command ("ccache g++ ...") names the compiler + # second. The caller passes the exact launcher it configured into the + # build, so this is a comparison, not a guess by name. + if launcher is not None and tokens[0] == launcher: + tokens = tokens[1:] + if not _COMPILER_STEM.search(Path(tokens[0]).stem): + # A stale compile DB built with a launcher the current run no longer + # configures would otherwise cache the launcher as the compiler path + _LOGGER.warning( + "compile_commands entry does not start with a compiler: %s", + tokens[0], + ) # token0 is the compiler path; the rest of the command already uses forward # slashes on Windows, so normalize it too for a consistent idedata file. cxx_path = tokens[0].replace("\\", "/") @@ -219,7 +239,44 @@ def _cc_path_from_cxx(cxx_path: str) -> str: return f"{stem}{suffix}" -def idedata_from_build(compile_commands: Path) -> dict: +def load_or_build_idedata( + compile_commands: Path, + elf_path: Path, + cache: Path, + launcher: str | None = None, +) -> dict | None: + """Return idedata for a compile_commands.json build, cached on mtime. + + Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None + when the compile DB doesn't exist yet (nothing was built). ``launcher`` + is the compiler-launcher path (ccache) the build was generated with, if + any; commands in the compile DB are prefixed with it. + """ + if not compile_commands.is_file(): + _LOGGER.debug("No %s yet; skipping idedata generation", compile_commands) + return None + + if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime: + try: + cached = json.loads(cache.read_text(encoding="utf-8")) + except ValueError: + pass + else: + # Caches written before cc_path was emitted stay newer than + # compile_commands.json forever, so rebuild them on the field rather + # than on the timestamp. Check the type too: a corrupted cache can + # still be valid JSON, and "in" would match a substring of a string. + if isinstance(cached, dict) and "cc_path" in cached: + return cached + + data = idedata_from_build(compile_commands, launcher) + data["prog_path"] = str(elf_path) + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return data + + +def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> dict: """Parse compile_commands.json into the idedata fields consumers expect. A single ESP-IDF compile entry only carries its own component's REQUIRES @@ -230,13 +287,13 @@ def idedata_from_build(compile_commands: Path) -> dict: provides). """ entries = json.loads(Path(compile_commands).read_text(encoding="utf-8")) - cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries)) + cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries), launcher) build_includes: dict[str, None] = {} for entry in entries: if not _is_esphome_src(entry["file"]): continue - for inc in _parse_entry(entry)[2]: + for inc in _parse_entry(entry, launcher)[2]: build_includes.setdefault(inc, None) return { diff --git a/esphome/espidf/size_summary.py b/esphome/espidf/size_summary.py index 7a5305ff0c..9af2dcc9e4 100644 --- a/esphome/espidf/size_summary.py +++ b/esphome/espidf/size_summary.py @@ -67,7 +67,7 @@ def _find_app_partition_size(partitions_csv: Path) -> int: raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}") -def _format_bar(used: int, total: int) -> str: +def format_bar(used: int, total: int) -> str: """Match PlatformIO's ``_format_availale_bytes`` (pioupload.py) exactly.""" pct_raw = used / total if total else 0 blocks = 10 @@ -99,7 +99,7 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: ram_used = ram_region.get("used") ram_total = ram_region.get("size") if ram_total and ram_used is not None: - print(f"RAM: {_format_bar(ram_used, ram_total)}") + print(f"RAM: {format_bar(ram_used, ram_total)}") image_size = data.get("image_size") if image_size is None or partitions_csv is None: @@ -109,4 +109,4 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None: except ValueError as e: _LOGGER.debug("Skipping Flash summary: %s", e) return - print(f"Flash: {_format_bar(image_size, app_size)}") + print(f"Flash: {format_bar(image_size, app_size)}") diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 07ba03e2cf..c21a2ad716 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -526,32 +526,15 @@ def get_idedata() -> dict | None: idedata fields IDE integrations and clang-tidy expect, cached alongside the PlatformIO idedata path. Returns None if the compile DB doesn't exist yet. """ - from esphome.espidf.idedata import idedata_from_build + from esphome.espidf.idedata import load_or_build_idedata - compile_commands = CORE.relative_build_path("build", "compile_commands.json") - if not compile_commands.is_file(): - _LOGGER.debug("No %s yet; skipping idedata generation", compile_commands) - return None - - cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json") - if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime: - try: - cached = json.loads(cache.read_text(encoding="utf-8")) - except ValueError: - pass - else: - # Caches written before cc_path was emitted stay newer than - # compile_commands.json forever, so rebuild them on the field rather - # than on the timestamp. Check the type too: a corrupted cache can - # still be valid JSON, and "in" would match a substring of a string. - if isinstance(cached, dict) and "cc_path" in cached: - return cached - - data = idedata_from_build(compile_commands) - data["prog_path"] = str(get_elf_path()) - cache.parent.mkdir(parents=True, exist_ok=True) - cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - return data + # No launcher: CMake excludes CMAKE__COMPILER_LAUNCHER (ccache) + # from the exported compile database, unlike ninja's compdb dump. + return load_or_build_idedata( + CORE.relative_build_path("build", "compile_commands.json"), + get_elf_path(), + CORE.relative_internal_path("idedata", f"{CORE.name}.json"), + ) def create_factory_bin() -> bool: diff --git a/esphome/platformio/library.py b/esphome/platformio/library.py index ee0a758a31..753bbdd8a3 100644 --- a/esphome/platformio/library.py +++ b/esphome/platformio/library.py @@ -13,7 +13,7 @@ regardless of which toolchain consumes the result. """ from collections import deque -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass, field import glob import hashlib @@ -469,7 +469,7 @@ def _parse_library_json(library_json_path: PathType): return json.load(fp) -def _parse_library_properties(library_properties_path: PathType): +def parse_library_properties(library_properties_path: PathType): """ Parse a key-value platformio .properties style file into a dictionary. @@ -553,7 +553,33 @@ def _resolve_registry_version( return owner, name, best["name"], pkgfile["download_url"] -def _normalize_dependencies(dependencies: Any) -> list[dict]: +def split_flag_entry(entry: str, owner: str) -> list[str]: + """``shlex.split`` with a clean error naming the offending flags entry.""" + import shlex + + try: + return shlex.split(entry) + except ValueError as err: + raise EsphomeError(f"Malformed build flag {entry!r} in {owner}: {err}") from err + + +def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]: + """Join a bare ``-I``/``-L``/``-l``/``-D`` with its following token, + the way PlatformIO's ParseFlags lexes them.""" + out: list[str] = [] + it = iter(tokens) + for tok in it: + if tok in ("-I", "-L", "-l", "-D"): + arg = next(it, None) + if arg is None: + _LOGGER.warning("Ignoring trailing '%s' in %s build flags", tok, owner) + break + tok += arg + out.append(tok) + return out + + +def normalize_dependencies(dependencies: Any) -> list[dict]: """Normalize a library manifest's ``dependencies`` to a list of dicts. PIO's library.json accepts both the list-of-dicts form and the shorthand @@ -688,6 +714,24 @@ def _node_key( return name, "registry", (owner, pkgname) +def lib_ignore_set() -> set[str]: + """The ``lib_ignore`` names from ``esphome->platformio_options``, + normalized to lowercase short names (the part after the ``/``).""" + return { + name.split("/")[-1].lower() + for name in CORE.platformio_options.get("lib_ignore", []) + } + + +def is_lib_ignored(name: str | None, lib_ignore: set[str]) -> bool: + """Whether ``name`` matches the normalized ``lib_ignore`` set.""" + return ( + bool(lib_ignore) + and name is not None + and (name.split("/")[-1].lower() in lib_ignore) + ) + + def convert_libraries( libraries: list[Library], backend: LibraryBackend ) -> list[ConvertedLibrary]: @@ -713,10 +757,7 @@ def convert_libraries( """ nodes: dict[str, _LibNode] = {} - lib_ignore = { - name.split("/")[-1].lower() - for name in CORE.platformio_options.get("lib_ignore", []) - } + lib_ignore = lib_ignore_set() # The generated build files inside the shared cache bake in the dependency # wiring, which lib_ignore changes; salt the cache path so configs with @@ -729,9 +770,7 @@ def convert_libraries( ) def is_ignored(name: str | None) -> bool: - if not lib_ignore or name is None: - return False - return name.split("/")[-1].lower() in lib_ignore + return is_lib_ignored(name, lib_ignore) def add_spec(name: str | None, version: str | None, repository: str | None) -> str: key, kind, locator = _node_key(name, version, repository) @@ -840,7 +879,7 @@ def convert_libraries( if has_json: component.data = _parse_library_json(library_json_path) elif has_properties: - component.data = _parse_library_properties(library_properties_path) + component.data = parse_library_properties(library_properties_path) else: # For a local library a missing manifest is user input, so raise # EsphomeError (clean CLI message) like the missing-directory case; @@ -869,7 +908,7 @@ def convert_libraries( # Requirements changed (we got past the short-circuit above), so # (re)walk this component's dependencies. node.edges = set() - for dependency in _normalize_dependencies(component.data.get("dependencies")): + for dependency in normalize_dependencies(component.data.get("dependencies")): if "name" not in dependency or "version" not in dependency: continue try: diff --git a/tests/unit_tests/test_espidf_component.py b/tests/unit_tests/test_espidf_component.py index f9e048f6f4..92b13495c8 100644 --- a/tests/unit_tests/test_espidf_component.py +++ b/tests/unit_tests/test_espidf_component.py @@ -26,11 +26,11 @@ from esphome.platformio.library import ( GitSource, URLSource, _node_key, - _normalize_dependencies, _parse_library_json, - _parse_library_properties, _resolve_registry_version, collect_filtered_files, + normalize_dependencies, + parse_library_properties, split_list_by_condition, ) @@ -510,7 +510,7 @@ empty= """ ) - result = _parse_library_properties(f) + result = parse_library_properties(f) assert result["name"] == "Test" assert result["version"] == "1.0" @@ -680,22 +680,22 @@ def test_node_key_registry_bare_name(): def test_normalize_dependencies_none(): - assert _normalize_dependencies(None) == [] + assert normalize_dependencies(None) == [] def test_normalize_dependencies_list_form(): deps = [{"name": "foo", "version": "1.0"}] - assert _normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}] + assert normalize_dependencies(deps) == [{"name": "foo", "version": "1.0"}] def test_normalize_dependencies_dict_form(): - out = _normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"}) + out = normalize_dependencies({"nanopb/Nanopb": "^0.4.91", "BareName": "1.2.3"}) assert {"name": "Nanopb", "owner": "nanopb", "version": "^0.4.91"} in out assert {"name": "BareName", "owner": None, "version": "1.2.3"} in out def test_normalize_dependencies_dict_form_nested_spec(): - out = _normalize_dependencies( + out = normalize_dependencies( {"nanopb/Nanopb": {"version": "^0.4.91", "platforms": "espidf"}} ) assert out == [ @@ -1190,3 +1190,92 @@ def test_idf_component_download_passes_salt() -> None: "owner/name", force=True, salt="abcd1234", namespace="idf" ) assert c.path == Path("/converted/owner/name") + + +def test_apply_extra_script_callable_target_and_str_flags(tmp_path) -> None: + """The shared helper resolves a callable idf_target lazily and normalizes + a string ``build.flags`` value into a list before extending it.""" + from esphome.espidf.extra_script import apply_extra_script + + (tmp_path / "src").mkdir() + script = tmp_path / "extra.py" + script.write_text("env.Append(LIBS=[env.get('BOARD_MCU')])\n") + + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": "extra.py", "flags": "-DBASE=1"}} + + apply_extra_script(c, lambda: "esp8266") + + assert c.data["build"]["flags"] == ["-DBASE=1", "-lesp8266"] + + +def test_apply_extra_script_no_script_and_no_flags(tmp_path) -> None: + from esphome.espidf.extra_script import apply_extra_script + + # No extraScript declared: nothing happens, the target is never resolved + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {}} + apply_extra_script(c, lambda: pytest.fail("target resolved without a script")) + + # A script that captures nothing leaves the flags untouched + script = tmp_path / "noop.py" + script.write_text("pass\n") + c.data = {"build": {"extraScript": "noop.py"}} + apply_extra_script(c, "esp8266") + assert "flags" not in c.data["build"] + + +def test_apply_extra_script_ignores_uncaptured_env_calls(tmp_path) -> None: + """Un-captured env vars and unsupported env methods are silent no-ops.""" + from esphome.espidf.extra_script import apply_extra_script + + script = tmp_path / "extra.py" + script.write_text( + "env.Replace(CC='clang')\nenv.Append(UNCAPTURED=['x'], LIBS='single')\n" + ) + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": "extra.py"}} + apply_extra_script(c, "esp8266") + assert c.data["build"]["flags"] == ["-lsingle"] + + +def test_apply_extra_script_swallows_script_errors(tmp_path, caplog) -> None: + """A raising extra-script is best-effort: logged and skipped.""" + from esphome.espidf.extra_script import apply_extra_script + + script = tmp_path / "extra.py" + script.write_text("raise RuntimeError('boom')\n") + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": "extra.py"}} + apply_extra_script(c, "esp8266") + assert "flags" not in c.data["build"] + assert "skipping" in caplog.text + + +def test_apply_extra_script_pio_platform(tmp_path) -> None: + """The backend's platform token is exposed to the script as PIOPLATFORM.""" + from esphome.espidf.extra_script import apply_extra_script + + script = tmp_path / "extra.py" + script.write_text("env.Append(LIBS=[env.get('PIOPLATFORM')])\n") + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": "extra.py"}} + apply_extra_script(c, "esp8266", pio_platform="espressif8266") + assert c.data["build"]["flags"] == ["-lespressif8266"] + + +def test_apply_extra_script_missing_script_logged(tmp_path, caplog) -> None: + """A declared but absent extraScript is skipped with a visible warning: + its captured link flags are lost.""" + from esphome.espidf.extra_script import apply_extra_script + + c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy")) + c.path = tmp_path + c.data = {"build": {"extraScript": "nope.py"}} + apply_extra_script(c, "esp8266") + assert "not found" in caplog.text diff --git a/tests/unit_tests/test_espidf_idedata.py b/tests/unit_tests/test_espidf_idedata.py index 1088517ed1..6d218f09b7 100644 --- a/tests/unit_tests/test_espidf_idedata.py +++ b/tests/unit_tests/test_espidf_idedata.py @@ -262,3 +262,101 @@ def test_parse_entry_normalizes_windows_cxx_path() -> None: assert "\\" not in cxx_path assert 'VER="1.2.3"' in defines assert "C:/inc/a" in includes + + +def test_parse_entry_strips_launcher_prefix() -> None: + """A launcher-wrapped compile names the compiler second; the exact + configured launcher is stripped, not anything ccache-shaped.""" + entry = _entry( + f"{ABS}build", + f"{ABS}build/src/esphome/core/application.cpp", + "/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -DUSE_ESP8266 " + "-c app.cpp -o app.cpp.o", + ) + cxx_path, defines, _, _ = idedata._parse_entry( + entry, launcher="/opt/homebrew/bin/ccache" + ) + assert cxx_path == "/tools/xtensa-lx106-elf-g++" + assert defines == ["USE_ESP8266"] + # Without a configured launcher nothing is stripped, even a token that + # happens to be named ccache -- but the surprise is warned about + cxx_path, _, _, _ = idedata._parse_entry(entry) + assert cxx_path == "/opt/homebrew/bin/ccache" + + +def test_parse_entry_warns_when_first_token_is_not_a_compiler( + caplog: pytest.LogCaptureFixture, +) -> None: + entry = _entry( + f"{ABS}build", + f"{ABS}build/src/esphome/core/application.cpp", + "/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -c a.cpp -o a.o", + ) + idedata._parse_entry(entry) + assert "does not start with a compiler" in caplog.text + caplog.clear() + idedata._parse_entry(entry, launcher="/opt/homebrew/bin/ccache") + assert "does not start with a compiler" not in caplog.text + + +def _write_compile_commands(tmp_path: Path) -> Path: + compile_commands = tmp_path / "compile_commands.json" + compile_commands.write_text( + json.dumps( + [ + _entry( + f"{ABS}build", + f"{ABS}build/src/esphome/core/application.cpp", + "/tools/g++ -DUSE_ESP8266 -c app.cpp -o app.cpp.o", + ) + ] + ) + ) + return compile_commands + + +def test_load_or_build_idedata_missing_compile_db(tmp_path: Path) -> None: + assert ( + idedata.load_or_build_idedata( + tmp_path / "compile_commands.json", tmp_path / "f.elf", tmp_path / "c.json" + ) + is None + ) + + +def test_load_or_build_idedata_builds_and_caches(tmp_path: Path) -> None: + compile_commands = _write_compile_commands(tmp_path) + cache = tmp_path / "cache" / "test.json" + with patch.object( + idedata, "_get_toolchain_includes", return_value=["/toolchain/include"] + ): + data = idedata.load_or_build_idedata( + compile_commands, tmp_path / "firmware.elf", cache + ) + assert data["cc_path"] == "/tools/gcc" + assert data["prog_path"] == str(tmp_path / "firmware.elf") + assert json.loads(cache.read_text()) == data + + # A fresh cache is served without re-parsing the compile DB + os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2) + with patch.object(idedata, "idedata_from_build") as mock_build: + assert ( + idedata.load_or_build_idedata( + compile_commands, tmp_path / "firmware.elf", cache + ) + == data + ) + mock_build.assert_not_called() + + +def test_load_or_build_idedata_rebuilds_bad_cache(tmp_path: Path) -> None: + compile_commands = _write_compile_commands(tmp_path) + cache = tmp_path / "cache.json" + for bad in ("not json", json.dumps({"no_cc_path": True})): + cache.write_text(bad) + os.utime(cache, (compile_commands.stat().st_mtime + 10,) * 2) + with patch.object(idedata, "_get_toolchain_includes", return_value=[]): + data = idedata.load_or_build_idedata( + compile_commands, tmp_path / "f.elf", cache + ) + assert "cc_path" in data diff --git a/tests/unit_tests/test_platformio_library.py b/tests/unit_tests/test_platformio_library.py index 0eede78656..f1a55dca60 100644 --- a/tests/unit_tests/test_platformio_library.py +++ b/tests/unit_tests/test_platformio_library.py @@ -531,3 +531,25 @@ def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch): top = convert_libraries([Library("esphome/A", "1.0.0", None)], _backend()) assert top[0].dependencies == [] + + +def test_split_flag_entry_unbalanced_quote_is_clean() -> None: + """A malformed flags entry raises EsphomeError, not a raw ValueError.""" + from esphome.platformio.library import split_flag_entry + + assert split_flag_entry('-DX="a b"', "library x") == ["-DX=a b"] + # join_flag_args re-glues a spaced -D like ParseFlags does + from esphome.platformio.library import join_flag_args + + assert join_flag_args(["-D", "FOO=1", "-Os"], "x") == ["-DFOO=1", "-Os"] + with pytest.raises(EsphomeError, match=r"Malformed build flag.*library x"): + split_flag_entry('-DX="unclosed', "library x") + + +def test_join_flag_args_trailing_bare_flag_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + from esphome.platformio.library import join_flag_args + + assert join_flag_args(["-Os", "-l"], "library x") == ["-Os"] + assert "Ignoring trailing '-l'" in caplog.text diff --git a/tests/unit_tests/test_size_summary.py b/tests/unit_tests/test_size_summary.py index 933be88476..e65195aab5 100644 --- a/tests/unit_tests/test_size_summary.py +++ b/tests/unit_tests/test_size_summary.py @@ -126,3 +126,33 @@ def test_print_summary_handles_no_memory_types( size_json = _write_size_json(tmp_path, {"image_size": 0}) print_summary(size_json, partitions_csv=None) assert capsys.readouterr().out == "" + + +def test_format_bar_zero_total() -> None: + """A zero total must not divide by zero.""" + from esphome.espidf.size_summary import format_bar + + assert format_bar(0, 0) == "[ ] 0.0% (used 0 bytes from 0 bytes)" + + +def test_print_summary_flash_line( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """image_size + a factory app partition produce the Flash line.""" + size_json = tmp_path / "esp_idf_size.json" + size_json.write_text( + json.dumps( + { + "memory_types": {"DRAM": {"used": 100, "size": 200}}, + "image_size": 500, + } + ) + ) + partitions = tmp_path / "partitions.csv" + partitions.write_text( + "# name, type, subtype, offset, size\napp0, app, factory, 0x10000, 0x100000\n" + ) + print_summary(size_json, partitions) + out = capsys.readouterr().out + assert "RAM: [===== ] 50.0% (used 100 bytes from 200 bytes)" in out + assert "Flash: [ ] 0.0% (used 500 bytes from 1048576 bytes)" in out