mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 22:56:19 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d34efecf2 | ||
|
|
c4096d44d8 | ||
|
|
68f3a6b9a5 | ||
|
|
662bf7d7f0 | ||
|
|
0d71ab8efb | ||
|
|
4ce4768ebd | ||
|
|
fb13327922 | ||
|
|
4c62420f1b | ||
|
|
ca3f31643f | ||
|
|
d770004e0e | ||
|
|
b28efcd545 | ||
|
|
6b6d27f905 | ||
|
|
603c3539a3 | ||
|
|
f248a85b51 | ||
|
|
5a3d7e3292 | ||
|
|
c6d329db64 | ||
|
|
bdb203d742 | ||
|
|
f509516d60 | ||
|
|
4c856949c2 | ||
|
|
c90a5f4cff | ||
|
|
964fc1ef3f |
+9
-23
@@ -857,20 +857,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
toolchain.create_factory_bin()
|
||||
toolchain.create_ota_bin()
|
||||
toolchain.create_elf_copy()
|
||||
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
|
||||
|
||||
try:
|
||||
if toolchain.get_idedata() is None:
|
||||
_LOGGER.warning("No idedata was generated for this build")
|
||||
except IDEDATA_BEST_EFFORT_ERRORS as err:
|
||||
# The firmware already built; an idedata failure must not fail
|
||||
# a successful build.
|
||||
_LOGGER.warning(
|
||||
"Could not generate idedata: %s (IDE, clang-tidy, and "
|
||||
"memory-analysis data will be unavailable for this build)",
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Idedata failure detail", exc_info=True)
|
||||
toolchain.get_idedata()
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -2734,14 +2721,10 @@ def run_esphome(argv):
|
||||
# Skipped when -s overrides are passed, since the cache was written
|
||||
# against the previous substitution set.
|
||||
config: ConfigType | None = None
|
||||
cache_write_eligible = (
|
||||
cache_eligible = (
|
||||
args.command in ("upload", "logs") and not command_line_substitutions
|
||||
)
|
||||
# An explicit --toolchain must re-run the per-platform validators, so
|
||||
# gate only the cache read; the refresh below saves the result unless
|
||||
# the sidecar records a different toolchain.
|
||||
cache_read_eligible = cache_write_eligible and args.toolchain is None
|
||||
if cache_read_eligible:
|
||||
if cache_eligible:
|
||||
from esphome.compiled_config import load_compiled_config
|
||||
|
||||
config = load_compiled_config(conf_path)
|
||||
@@ -2765,14 +2748,17 @@ def run_esphome(argv):
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
# The cache fast path skips validation, and legacy sidecars lack the
|
||||
# toolchain field. Must run before the cache refresh below.
|
||||
# Fallback for platforms whose validators didn't set the toolchain
|
||||
# (only the esp32 component reads esp32.framework.toolchain). All
|
||||
# other platforms only support PlatformIO today. Must run before the
|
||||
# cache refresh below so its sidecar records the same toolchain a
|
||||
# compile would.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config.
|
||||
if cache_write_eligible and cache_missed:
|
||||
if cache_eligible and cache_missed:
|
||||
from esphome.compiled_config import save_compiled_config_and_sidecar
|
||||
|
||||
save_compiled_config_and_sidecar(config)
|
||||
|
||||
@@ -1,511 +0,0 @@
|
||||
"""Arduino-core backend for the shared PlatformIO library converter.
|
||||
|
||||
Bundled names build straight from the framework tree; everything else goes
|
||||
through ``esphome.platformio.library``. Mirrors ``lib_ldf_mode=off``: each
|
||||
library builds its own archive; all include dirs join one global path.
|
||||
|
||||
Deviations from PlatformIO: flat-layout libraries get the recursive default
|
||||
source filter; ``dot_a_linkage`` is honored; bundled libraries never run a
|
||||
manifest ``extraScript``; manifest ``-I`` flags join the global include path;
|
||||
``precompiled``/``ldflags`` properties are refused by name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from esphome.core import CORE, EsphomeError, Library
|
||||
from esphome.helpers import walk_files
|
||||
from esphome.platformio.extra_script import apply_extra_script
|
||||
from esphome.platformio.library import (
|
||||
DEFAULT_BUILD_INCLUDE_DIR,
|
||||
DEFAULT_BUILD_SRC_FILTER,
|
||||
LIBRARY_HEADER_SUFFIXES,
|
||||
SRC_FILE_EXTENSIONS,
|
||||
ConvertedLibrary,
|
||||
InvalidLibrary,
|
||||
LibraryBackend,
|
||||
_url_or_none,
|
||||
check_library_data,
|
||||
collect_filtered_files,
|
||||
convert_libraries,
|
||||
ensure_list,
|
||||
is_lib_ignored,
|
||||
lex_build_flags,
|
||||
lib_ignore_set,
|
||||
normalize_dependencies,
|
||||
parse_library_json,
|
||||
parse_library_properties,
|
||||
warn_properties_depends,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArduinoLibrary:
|
||||
"""One resolved library, ready for the ninja generator."""
|
||||
|
||||
name: str
|
||||
sources: list[Path] = field(default_factory=list)
|
||||
include_dirs: list[Path] = field(default_factory=list)
|
||||
# Extra compile flags private to this library's own sources
|
||||
flags: list[str] = field(default_factory=list)
|
||||
# PlatformIO's build.libArchive / Arduino's dot_a_linkage: when False the
|
||||
# objects go to the linker directly (symbols nothing references survive)
|
||||
lib_archive: bool = True
|
||||
# Link inputs the library contributes (-L dirs / -l libs, e.g. from
|
||||
# precompiled vendor blobs) and -Wl, options for the firmware link
|
||||
link_dirs: list[Path] = field(default_factory=list)
|
||||
link_libs: list[str] = field(default_factory=list)
|
||||
link_flags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# Filename-plain names only: leading alnum/underscore, then word chars,
|
||||
# dot, space, plus, or hyphen. An allowlist excludes separators, drive
|
||||
# colons, and dot-only names by shape instead of enumerating them.
|
||||
_SAFE_LIBRARY_NAME_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_. +-]*\Z")
|
||||
|
||||
|
||||
def _is_safe_library_name(name: object) -> bool:
|
||||
"""Whether a name may be joined under the framework's libraries dir."""
|
||||
return isinstance(name, str) and _SAFE_LIBRARY_NAME_RE.fullmatch(name) is not None
|
||||
|
||||
|
||||
def _manifest_build(name: str, data: object) -> dict:
|
||||
"""The manifest's ``build`` section; a malformed manifest must fail
|
||||
naming the library, not with an AttributeError."""
|
||||
build = data.get("build", {}) if isinstance(data, dict) else None
|
||||
if not isinstance(build, dict):
|
||||
raise EsphomeError(f"Library {name} has a malformed manifest")
|
||||
return build
|
||||
|
||||
|
||||
def _resolve_src_dir(name: str, read_path: Path, build: dict) -> str:
|
||||
"""Resolve PIO's source dir: manifest srcDir, else src/Src, else the root."""
|
||||
if "srcDir" not in build:
|
||||
return next((d for d in ("src", "Src") if (read_path / d).is_dir()), ".")
|
||||
# A declared srcDir (falsy included) that does not resolve is a
|
||||
# manifest error
|
||||
src_dir = build["srcDir"]
|
||||
if not (isinstance(src_dir, str) and src_dir and (read_path / src_dir).is_dir()):
|
||||
raise EsphomeError(
|
||||
f"Library {name} declares srcDir {src_dir!r} which does not exist"
|
||||
)
|
||||
return src_dir
|
||||
|
||||
|
||||
def _reject_unsupported_link_fields(name: str, data: dict) -> None:
|
||||
# PIO's Arduino lib builder honors these; building without them would
|
||||
# fail at link with no stated cause. library.properties values are
|
||||
# strings, so "false" (the spec's explicit opt-out) is not a declaration.
|
||||
precompiled = data.get("precompiled")
|
||||
if precompiled and str(precompiled).strip().lower() != "false":
|
||||
raise EsphomeError(
|
||||
f"Library {name} declares precompiled, which this backend does not support"
|
||||
)
|
||||
if data.get("ldflags"):
|
||||
raise EsphomeError(
|
||||
f"Library {name} declares ldflags, which this backend does not support"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_lib_archive(name: str, data: dict, build: dict) -> bool:
|
||||
"""build.libArchive, else dot_a_linkage (Arduino IDE's property, ignored
|
||||
by PIO -- a deliberate extra), else archive."""
|
||||
|
||||
# Strict parse: bool("false") is True
|
||||
def _parse(key: str, raw: object) -> bool:
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
value = str(raw).strip().lower()
|
||||
if value in ("true", "false"):
|
||||
return value == "true"
|
||||
raise EsphomeError(f"Library {name} has a malformed {key} value {raw!r}")
|
||||
|
||||
if "libArchive" in build:
|
||||
return _parse("libArchive", build["libArchive"])
|
||||
if "dot_a_linkage" in data:
|
||||
return _parse("dot_a_linkage", data["dot_a_linkage"])
|
||||
return True
|
||||
|
||||
|
||||
def _classify_build_flags(
|
||||
name: str, read_path: Path, lib: ArduinoLibrary, flag_tokens: list[str]
|
||||
) -> list[str]:
|
||||
"""Route the lexed build.flags into the library's flag lists.
|
||||
|
||||
Returns the ``-I`` arguments for the include-dir resolution.
|
||||
"""
|
||||
include_flags: list[str] = []
|
||||
for tok in flag_tokens:
|
||||
if tok.startswith("-I"):
|
||||
include_flags.append(tok[2:])
|
||||
elif tok.startswith("-L"):
|
||||
link_dir = (read_path / tok[2:]).resolve()
|
||||
if not link_dir.is_dir():
|
||||
# Kept anyway (the linker ignores missing -L dirs); the
|
||||
# warning names the culprit before a bare "cannot find -lfoo"
|
||||
_LOGGER.warning(
|
||||
"Library %s declares library dir %s which does not exist",
|
||||
name,
|
||||
tok[2:],
|
||||
)
|
||||
lib.link_dirs.append(link_dir)
|
||||
elif tok.startswith("-l"):
|
||||
lib.link_libs.append(tok[2:])
|
||||
elif tok.startswith("-Wl,"):
|
||||
lib.link_flags.append(tok)
|
||||
else:
|
||||
lib.flags.append(tok)
|
||||
return include_flags
|
||||
|
||||
|
||||
def _resolve_include_dirs(
|
||||
name: str,
|
||||
read_path: Path,
|
||||
lib: ArduinoLibrary,
|
||||
build: dict,
|
||||
src_dir: str,
|
||||
include_flags: list[str],
|
||||
) -> None:
|
||||
include_dir = build.get("includeDir", DEFAULT_BUILD_INCLUDE_DIR)
|
||||
if not isinstance(include_dir, str):
|
||||
raise EsphomeError(f"Library {name} has a malformed includeDir")
|
||||
for d, explicit in [
|
||||
(include_dir, "includeDir" in build),
|
||||
(src_dir, False), # _resolve_src_dir already validated it
|
||||
*((flag, True) for flag in include_flags),
|
||||
]:
|
||||
if (path := (read_path / d)).is_dir():
|
||||
lib.include_dirs.append(path.resolve())
|
||||
elif explicit:
|
||||
# Warn-and-drop is intended (unlike srcDir, which raises): a
|
||||
# missing include dir is harmless until a header is actually
|
||||
# needed, and the compile names it then
|
||||
_LOGGER.warning(
|
||||
"Library %s declares include dir %s which does not exist", name, d
|
||||
)
|
||||
|
||||
|
||||
def _collect_lib_sources(
|
||||
name: str,
|
||||
read_path: Path,
|
||||
lib: ArduinoLibrary,
|
||||
src_dir: str,
|
||||
src_filter: list[str],
|
||||
) -> None:
|
||||
matched = collect_filtered_files(read_path / src_dir, src_filter)
|
||||
lib.sources = sorted(
|
||||
path.resolve()
|
||||
for f in matched
|
||||
if (path := Path(f)).suffix in SRC_FILE_EXTENSIONS
|
||||
)
|
||||
# A source-like suffix the case-sensitive map rejects (.CPP, .ino) is a
|
||||
# dropped compilation unit that surfaces as undefined symbols at link;
|
||||
# headers and metadata files fall through silently (header-only
|
||||
# libraries are routine)
|
||||
source_like = {s.lower() for s in SRC_FILE_EXTENSIONS} | {".ino"}
|
||||
if dropped := [
|
||||
Path(f).name
|
||||
for f in matched
|
||||
if Path(f).suffix not in SRC_FILE_EXTENSIONS
|
||||
and Path(f).suffix.lower() in source_like
|
||||
]:
|
||||
_LOGGER.warning(
|
||||
"Library %s: %d file(s) with unmapped source suffixes are not compiled: %s",
|
||||
name,
|
||||
len(dropped),
|
||||
", ".join(sorted(dropped)),
|
||||
)
|
||||
if not lib.sources and not any(
|
||||
Path(f).suffix.lower() in LIBRARY_HEADER_SUFFIXES for f in matched
|
||||
):
|
||||
# Matched headers mean a header-only library; a filter matching
|
||||
# nothing (or only inert files) is a manifest/tree problem whether
|
||||
# or not it was declared. The truly empty tree raises via
|
||||
# _assert_tree_has_code.
|
||||
_LOGGER.warning("Library %s: no source files matched", name)
|
||||
|
||||
|
||||
def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
|
||||
"""Resolve one library's sources, include dirs, and flags (PIO semantics)."""
|
||||
build = _manifest_build(name, data)
|
||||
_reject_unsupported_link_fields(name, data)
|
||||
src_dir = _resolve_src_dir(name, read_path, build)
|
||||
src_filter = ensure_list(build.get("srcFilter", DEFAULT_BUILD_SRC_FILTER))
|
||||
if not all(isinstance(entry, str) for entry in src_filter):
|
||||
raise EsphomeError(f"Library {name} has a malformed srcFilter")
|
||||
lib = ArduinoLibrary(name=name, lib_archive=_resolve_lib_archive(name, data, build))
|
||||
# PlatformIO shell-lexes each build.flags entry
|
||||
include_flags = _classify_build_flags(
|
||||
name, read_path, lib, lex_build_flags(build.get("flags", []), f"library {name}")
|
||||
)
|
||||
_resolve_include_dirs(name, read_path, lib, build, src_dir, include_flags)
|
||||
_collect_lib_sources(name, read_path, lib, src_dir, src_filter)
|
||||
return lib
|
||||
|
||||
|
||||
def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
|
||||
"""A library bundled with the Arduino core, read from the framework tree.
|
||||
|
||||
``library.json`` wins over ``library.properties`` when both exist, as in
|
||||
PlatformIO's LibBuilderFactory; only the JSON manifest can carry a
|
||||
``build`` section (srcDir, srcFilter, flags).
|
||||
"""
|
||||
lib_dir = framework_path / "libraries" / name
|
||||
manifest_json = lib_dir / "library.json"
|
||||
if manifest_json.is_file():
|
||||
data = parse_library_json(manifest_json)
|
||||
else:
|
||||
manifest = lib_dir / "library.properties"
|
||||
data = parse_library_properties(manifest) if manifest.is_file() else {}
|
||||
if isinstance(data, dict):
|
||||
# Bundled manifest deps are never walked; make the skip visible
|
||||
if data.get("dependencies"):
|
||||
_LOGGER.warning(
|
||||
"Bundled library %s declares dependencies, which are not "
|
||||
"resolved automatically; add them with add_library() if needed",
|
||||
name,
|
||||
)
|
||||
warn_properties_depends(name, data)
|
||||
build = data.get("build")
|
||||
if isinstance(build, dict) and build.get("extraScript"):
|
||||
# apply_extra_script only runs on the converted path; building
|
||||
# without the script's flags would miscompile
|
||||
raise EsphomeError(
|
||||
f"Bundled library {name} declares an extraScript, which is "
|
||||
"not run for bundled libraries"
|
||||
)
|
||||
lib = _library_info(name, lib_dir, data)
|
||||
_assert_tree_has_code(
|
||||
name,
|
||||
lib_dir,
|
||||
"the framework install may be incomplete (run 'esphome clean-all')",
|
||||
)
|
||||
return lib
|
||||
|
||||
|
||||
def _assert_tree_has_code(name: str, root: Path, hint: str) -> None:
|
||||
"""An empty or half-extracted tree can never link; fail by name (a
|
||||
warning would scroll away and resurface as undefined symbols)."""
|
||||
if not any(
|
||||
Path(p).suffix in SRC_FILE_EXTENSIONS
|
||||
or Path(p).suffix.lower() in LIBRARY_HEADER_SUFFIXES
|
||||
for p in walk_files(root)
|
||||
):
|
||||
raise EsphomeError(f"Library {name} has no sources or headers; {hint}")
|
||||
|
||||
|
||||
def _external_short_name(name: str) -> str:
|
||||
"""The short library name of a requested spec.
|
||||
|
||||
"owner/Name" and plain names take the last path segment; the
|
||||
"Name=<url>" custom-name form takes the declared name (the URL tail is
|
||||
a repository path, not a library name). Git tails (".git", "#ref") are
|
||||
stripped like the walk's own URL normalization -- deliberately further
|
||||
than CORE.add_library's keying, because the comparand here is a manifest
|
||||
dependency name, never a spec.
|
||||
"""
|
||||
head, sep, tail = name.partition("=")
|
||||
if sep and "://" in tail:
|
||||
return head
|
||||
short = name.rsplit("/", maxsplit=1)[-1]
|
||||
return short.partition("#")[0].removesuffix(".git")
|
||||
|
||||
|
||||
def _warn_unfulfilled_provides(
|
||||
provided_requests: list[str], satisfied: set[str]
|
||||
) -> None:
|
||||
"""Reconcile the provides() promise: every dependency the walk skipped
|
||||
on the backend's word must have been added from the framework tree (or
|
||||
knowingly satisfied by a converted/external library); an unfulfilled
|
||||
promise would surface only as undefined symbols at link."""
|
||||
for name in provided_requests:
|
||||
if name not in satisfied:
|
||||
_LOGGER.warning(
|
||||
"provides() skipped dependency %s but nothing added it; "
|
||||
"the build is missing a library",
|
||||
name,
|
||||
)
|
||||
|
||||
|
||||
def resolve_libraries(
|
||||
framework_path: Path, *, pio_platform: str, board_mcu: str, cache_key: str
|
||||
) -> list[ArduinoLibrary]:
|
||||
"""Resolve every ``cg.add_library()`` entry into an :class:`ArduinoLibrary`.
|
||||
|
||||
``pio_platform``/``board_mcu`` filter manifests the way PlatformIO would
|
||||
for that core (e.g. ``espressif8266``/``esp8266``); ``cache_key`` keys the
|
||||
shared converter's download cache.
|
||||
|
||||
The returned list is not topologically sorted, so the caller must link
|
||||
the archives inside one ``--start-group``/``--end-group`` pair (the
|
||||
bundled-first grouping is incidental).
|
||||
"""
|
||||
bundled: list[ArduinoLibrary] = []
|
||||
external: list[Library] = []
|
||||
# PlatformIO's lib_ignore covers framework-bundled libraries too; the
|
||||
# shared converter only filters the registry/git ones.
|
||||
lib_ignore = lib_ignore_set()
|
||||
# Exact on-disk directory names, so membership is case-sensitive on
|
||||
# every filesystem (a per-name is_dir() probe would match "wire" on
|
||||
# macOS/Windows and build the bundled Wire twice); the safety guard
|
||||
# stays fused with the lookup (path traversal)
|
||||
libraries_dir = framework_path / "libraries"
|
||||
if not libraries_dir.is_dir():
|
||||
# Falling back to the registry would fail later with a misleading
|
||||
# package-not-found error for every bundled name
|
||||
raise EsphomeError(
|
||||
f"{libraries_dir} is missing; the framework install may be "
|
||||
"incomplete (run 'esphome clean-all')"
|
||||
)
|
||||
bundled_dir_names = frozenset(p.name for p in libraries_dir.iterdir() if p.is_dir())
|
||||
|
||||
def _provided(name: object) -> bool:
|
||||
return _is_safe_library_name(name) and name in bundled_dir_names
|
||||
|
||||
for library in CORE.platformio_libraries.values():
|
||||
if is_lib_ignored(library.name, lib_ignore):
|
||||
continue
|
||||
# Bundled only for a bare name with a matching framework dir; pinned
|
||||
# or unmatched names resolve from the registry, as under PlatformIO.
|
||||
if not library.repository and not library.version and _provided(library.name):
|
||||
# Bundled libraries' own manifest deps are not walked (none of
|
||||
# the ESP8266 core's declare any; _bundled_library warns if one does)
|
||||
bundled.append(_bundled_library(framework_path, library.name))
|
||||
else:
|
||||
external.append(library)
|
||||
|
||||
converted: list[ArduinoLibrary] = []
|
||||
bundled_names = {lib.name for lib in bundled}
|
||||
converted_manifest_names: set[str] = set()
|
||||
# Ordered set of bundled dependency names to add once conversion is done
|
||||
pending_bundled: dict[str, None] = {}
|
||||
# Deps matching a separately-requested external are already in the build
|
||||
# (a duplicate archive means duplicate-symbol link errors)
|
||||
external_short_names = {
|
||||
_external_short_name(lib.name) for lib in external if lib.name
|
||||
}
|
||||
|
||||
def _add_bundled_dependencies(component: ConvertedLibrary) -> None:
|
||||
# A version-less bare-name dependency ("Hash" in ESPAsyncWebServer)
|
||||
# is a core-bundled library; the shared converter skips it because
|
||||
# it cannot be resolved from the registry.
|
||||
for dep in normalize_dependencies(
|
||||
component.data.get("dependencies"), component.name
|
||||
):
|
||||
name = dep.get("name")
|
||||
if isinstance(name, str) and "/" in name:
|
||||
owner, _, pkg = name.partition("/")
|
||||
if _is_safe_library_name(owner) and _is_safe_library_name(pkg):
|
||||
# PIO's owner-qualified spelling ("Owner/Pkg"); the
|
||||
# converter resolves it from the registry
|
||||
continue
|
||||
if not _is_safe_library_name(name):
|
||||
# The name becomes a path component under the framework
|
||||
# tree; never join a traversal or a non-string
|
||||
_LOGGER.warning(
|
||||
"Ignoring malformed dependency entry %r of library %s",
|
||||
dep,
|
||||
component.name,
|
||||
)
|
||||
continue
|
||||
if name in external_short_names:
|
||||
if _provided(name):
|
||||
# A bundled copy really is suppressed; an accidental
|
||||
# short-name collision would surface as link errors
|
||||
_LOGGER.warning(
|
||||
"Dependency %s of %s is assumed satisfied by a "
|
||||
"requested external library; the bundled copy is "
|
||||
"not added",
|
||||
name,
|
||||
component.name,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Dependency %s of %s assumed satisfied by a requested "
|
||||
"external library",
|
||||
name,
|
||||
component.name,
|
||||
)
|
||||
continue
|
||||
if name in bundled_names or is_lib_ignored(name, lib_ignore):
|
||||
continue
|
||||
if _url_or_none(dep.get("version")) is not None:
|
||||
# A URL names one specific source (the walk resolves it as
|
||||
# git); the bundled copy must never be added on top
|
||||
continue
|
||||
if dep.get("owner") or not _provided(name):
|
||||
# Owner-less names in the framework tree prefer the bundled
|
||||
# copy (PIO's process_dependencies); everything else resolves
|
||||
# via the converter, and the walk reports any real drops
|
||||
continue
|
||||
try:
|
||||
# framework=None: the walk already ran dependency_is_usable
|
||||
# on this entry and warned for any non-platform cause, so
|
||||
# debug here is what keeps one manifest fault from warning
|
||||
# twice. That invariant is pinned by
|
||||
# test_nonplatform_rejection_warns_once_through_real_converter,
|
||||
# which fails if the walk stops evaluating these deps.
|
||||
check_library_data(dep, pio_platform, None)
|
||||
except InvalidLibrary as err:
|
||||
_LOGGER.debug("Skip bundled candidate %s: %s", name, err)
|
||||
continue
|
||||
# Deferred: a later-emitted library's manifest name may satisfy
|
||||
# this; adding now could double the archive
|
||||
pending_bundled.setdefault(name)
|
||||
|
||||
def _emit(component: ConvertedLibrary) -> None:
|
||||
apply_extra_script(
|
||||
component, board_mcu=lambda: board_mcu, pio_platform=pio_platform
|
||||
)
|
||||
_assert_tree_has_code(
|
||||
component.get_require_name(),
|
||||
component.source_dir,
|
||||
"the download may be incomplete (run 'esphome clean-all')",
|
||||
)
|
||||
if isinstance(manifest_name := component.data.get("name"), str):
|
||||
converted_manifest_names.add(manifest_name)
|
||||
converted.append(
|
||||
_library_info(
|
||||
component.get_require_name(), component.source_dir, component.data
|
||||
)
|
||||
)
|
||||
_add_bundled_dependencies(component)
|
||||
|
||||
backend = LibraryBackend(
|
||||
platform=pio_platform,
|
||||
framework="arduino",
|
||||
emit=_emit,
|
||||
cache_key=cache_key,
|
||||
# The walk must not resolve bundled names from the registry;
|
||||
# _add_bundled_dependencies adds them after emit
|
||||
provides=_provided,
|
||||
)
|
||||
if external:
|
||||
convert_libraries(external, backend)
|
||||
for name in pending_bundled:
|
||||
if name in converted_manifest_names:
|
||||
# Manifest-name evidence: the converted library is this library,
|
||||
# so the bundled copy would double the archive. Warn like the
|
||||
# external_short_names twin: a coincidental collision would
|
||||
# otherwise surface only as link errors
|
||||
_LOGGER.warning(
|
||||
"Dependency %s is assumed satisfied by a converted library's "
|
||||
"manifest name; the bundled copy is not added",
|
||||
name,
|
||||
)
|
||||
continue
|
||||
bundled_names.add(name)
|
||||
bundled.append(_bundled_library(framework_path, name))
|
||||
|
||||
_warn_unfulfilled_provides(
|
||||
backend.provided_requests,
|
||||
bundled_names | converted_manifest_names | external_short_names,
|
||||
)
|
||||
|
||||
return bundled + converted
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Native (PlatformIO-free) build support for the ESP8266 Arduino core.
|
||||
|
||||
This package downloads the Arduino ESP8266 core and the xtensa-lx106
|
||||
toolchain, generates a ninja build for them plus the ESPHome sources, and
|
||||
drives the build directly — the ESP8266 equivalent of ``esphome.espidf``.
|
||||
|
||||
Deliberately importable without the esp8266 component to avoid circular
|
||||
imports; the component wires these modules in via lazy imports.
|
||||
"""
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Download and install the Arduino ESP8266 core, toolchain, and ninja.
|
||||
|
||||
Artifacts land in a machine-global cache (shared across projects, like the
|
||||
ESP-IDF install in ``esphome.espidf.framework``):
|
||||
|
||||
<cache>/arduino8266/frameworks/<version>/ framework-arduinoespressif8266
|
||||
<cache>/arduino8266/toolchains/<version>/ toolchain-xtensa (gcc 10.3)
|
||||
|
||||
Packages come from the PlatformIO registry (identical bits to the PlatformIO
|
||||
backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes
|
||||
from PATH or the ninja PyPI wheel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||
from esphome.build_helpers.ninja import find_ninja
|
||||
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import EsphomeError, Version
|
||||
from esphome.framework_helpers import str_to_lst_of_str
|
||||
from esphome.platformio.registry import install_package, prefetch_packages
|
||||
|
||||
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
|
||||
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
|
||||
# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
|
||||
# generator's compile flags are tuned to it.
|
||||
TOOLCHAIN_VERSION = "2.100300.220621"
|
||||
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
|
||||
)
|
||||
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "")
|
||||
)
|
||||
|
||||
|
||||
def get_arduino8266_tools_path() -> Path:
|
||||
# Machine-global so all projects share one install; see
|
||||
# espidf.framework.get_idf_tools_path for the location rationale.
|
||||
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
|
||||
|
||||
|
||||
# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the
|
||||
# encoder below cannot name 3.0.0/3.0.1 either (see its docstring)
|
||||
MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
|
||||
|
||||
|
||||
def framework_package_version(ver: Version) -> str:
|
||||
"""Map an Arduino core version to its registry package version (3.1.2 ->
|
||||
3.30102.0; the leading 3 is the package major).
|
||||
|
||||
Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor
|
||||
at MIN_FRAMEWORK_VERSION.
|
||||
"""
|
||||
if ver.major > 3:
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} is not supported yet; "
|
||||
"the newest known core series is 3.x"
|
||||
)
|
||||
if ver <= Version(2, 6, 2):
|
||||
# Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same
|
||||
# boundary as _format_framework_arduino_version's era guard)
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} uses an older package encoding than this "
|
||||
"helper implements (newer than 2.6.2)"
|
||||
)
|
||||
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
|
||||
|
||||
def get_framework_path(package_version: str) -> Path:
|
||||
return get_arduino8266_tools_path() / "frameworks" / package_version
|
||||
|
||||
|
||||
def get_toolchain_path() -> Path:
|
||||
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
|
||||
|
||||
|
||||
class InstalledPaths(NamedTuple):
|
||||
"""Locations of the installed framework, toolchain, and ninja binary."""
|
||||
|
||||
framework: Path
|
||||
toolchain: Path
|
||||
ninja: Path
|
||||
|
||||
|
||||
def check_and_install(framework_version: Version) -> InstalledPaths:
|
||||
"""Ensure framework, toolchain, and ninja are installed; return their paths."""
|
||||
if framework_version < MIN_FRAMEWORK_VERSION:
|
||||
# Config validation enforces this too; keep the module honest when
|
||||
# called directly.
|
||||
raise EsphomeError(
|
||||
f"The native toolchain requires the Arduino core "
|
||||
f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}"
|
||||
)
|
||||
# Probe the cheap local dependency before ~110 MB of downloads
|
||||
ninja_path = find_ninja()
|
||||
package_version = framework_package_version(framework_version)
|
||||
framework_path = get_framework_path(package_version)
|
||||
downloads_dir = get_arduino8266_tools_path() / "downloads"
|
||||
toolchain_path = get_toolchain_path()
|
||||
# One spec per package: the prefetch and the installs must agree
|
||||
specs = (
|
||||
(
|
||||
FRAMEWORK_PACKAGE,
|
||||
package_version,
|
||||
framework_path,
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
("cores/esp8266", "tools/sdk", "libraries"),
|
||||
),
|
||||
(
|
||||
TOOLCHAIN_PACKAGE,
|
||||
TOOLCHAIN_VERSION,
|
||||
toolchain_path,
|
||||
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
# xtensa-lx106-elf pins the target: every gcc package has a bin/
|
||||
("bin", "xtensa-lx106-elf"),
|
||||
),
|
||||
)
|
||||
# Fetch both archives at once; the installs below verify and extract
|
||||
prefetch_packages([spec[:4] for spec in specs], downloads_dir)
|
||||
for name, version, dest, mirrors, expect in specs:
|
||||
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||
return InstalledPaths(
|
||||
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
|
||||
)
|
||||
|
||||
|
||||
def toolchain_tool(toolchain_path: Path, name: str) -> Path:
|
||||
"""Path to one toolchain tool (gcc, g++, ar, size, addr2line, ...).
|
||||
|
||||
The single owner of the ``bin/xtensa-lx106-elf-<name>`` layout and the
|
||||
Windows suffix, so a toolchain package bump touches one spot.
|
||||
"""
|
||||
suffix = ".exe" if os.name == "nt" else ""
|
||||
return toolchain_path / "bin" / f"xtensa-lx106-elf-{name}{suffix}"
|
||||
|
||||
|
||||
def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
# Drop empty entries: a trailing separator from an absent PATH would
|
||||
# make the shell search the current directory for tools
|
||||
parts = [
|
||||
str(toolchain_path / "bin"),
|
||||
*filter(None, env.get("PATH", "").split(os.pathsep)),
|
||||
]
|
||||
env["PATH"] = os.pathsep.join(parts)
|
||||
env.update(ccache_env(ccache))
|
||||
return env
|
||||
|
||||
|
||||
def ccache_env(ccache: str | None) -> dict[str, str]:
|
||||
"""Return ccache settings for the build subprocess (not os.environ).
|
||||
|
||||
``ccache`` is the pre-resolved binary (resolve_ccache_path), or None
|
||||
when disabled. Values the user already set in the environment are
|
||||
respected.
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
@@ -1,742 +0,0 @@
|
||||
"""Build specification for the native ESP8266 Arduino toolchain.
|
||||
|
||||
Transliterates the PlatformIO build spec for the Arduino ESP8266 framework
|
||||
(``framework-arduinoespressif8266/tools/platformio-build.py`` plus
|
||||
``platform-espressif8266/builder/main.py``): the flag sets, defines, and
|
||||
linker-script generation deliberately match what PlatformIO produces so the
|
||||
binaries stay near-identical between the two toolchains. The ninja emission
|
||||
(``write_project``) builds on these pieces.
|
||||
|
||||
The ``PIO_FRAMEWORK_ARDUINO_*`` knob defines (lwIP variant, NONOS SDK
|
||||
version, MMU layout, exceptions, waveform phase) keep working: they are read
|
||||
from the build flags with the same precedence as the PlatformIO builder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from esphome.arduino8266.framework import toolchain_tool
|
||||
from esphome.build_helpers.ninja import shell_token as _shell_token
|
||||
from esphome.components.esp8266 import build_surgery
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
from esphome.platformio.library import lex_build_flags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from esphome.arduino8266.framework import InstalledPaths
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Values that land unquoted on generated command lines are shape-checked
|
||||
# against these before use
|
||||
_MMU_VALUE_RE = re.compile(r"(?:0[xX][0-9a-fA-F]+|\d+)[uUlL]*")
|
||||
_MMU_HEX_VALUE_RE = re.compile(r"0[xX][0-9a-fA-F]+[uUlL]*")
|
||||
# Only these land in the preprocessed script's ``len =`` fields, which
|
||||
# build_surgery's segment parser reads back as hex; the other MMU_* macros
|
||||
# (MMU_EXTERNAL_HEAP=128) are consumed by mmu_iram.h and may be decimal
|
||||
_MMU_SEGMENT_SIZE_NAMES = ("MMU_IRAM_SIZE", "MMU_ICACHE_SIZE")
|
||||
_BOARD_NAME_RE = re.compile(r"[\w.-]+")
|
||||
_F_CPU_RE = re.compile(r"\d+L?")
|
||||
_FLASH_LD_NAME_RE = re.compile(r"[\w.-]+\.ld")
|
||||
|
||||
# Every supported board ships this clock; board_build.f_cpu overrides
|
||||
_DEFAULT_F_CPU = "80000000L"
|
||||
|
||||
# The SDK linker-script template and the preprocessed copy the build links
|
||||
# against; the cache stamp and stderr sidecars derive from the output name
|
||||
_COMMON_LD_HEADER = "eagle.app.v6.common.ld.h"
|
||||
_COMMON_LD_NAME = "local.eagle.app.v6.common.ld"
|
||||
# Testing mode shadows the SDK flash ld with a patched copy under this name
|
||||
_TESTING_LD_PREFIX = "testing_"
|
||||
|
||||
# The recovery hint for a half-extracted or damaged framework cache
|
||||
_CLEAN_HINT = "run 'esphome clean-all' and retry"
|
||||
|
||||
|
||||
def _sdk_ld_dir(framework: Path) -> Path:
|
||||
return framework / "tools" / "sdk" / "ld"
|
||||
|
||||
|
||||
def _apply_surgery(fn, *args: object) -> str:
|
||||
"""Run one build_surgery edit, naming a failed anchor instead of a
|
||||
traceback (the surgery module raises bare RuntimeError so its
|
||||
``.py.script`` twins stay importable without esphome)."""
|
||||
try:
|
||||
return fn(*args)
|
||||
except RuntimeError as err:
|
||||
raise EsphomeError(str(err)) from err
|
||||
|
||||
|
||||
# From platformio-build.py. Knob suffix -> SDK define; the first entry is
|
||||
# the default (dicts preserve insertion order). With multiple SDK knobs set
|
||||
# (a pathological config) ties break by table order, since upstream's
|
||||
# tie-break depends on define order and is not reproducible here.
|
||||
_NONOSDK_VERSIONS = {
|
||||
"SDK22x_190703": "NONOSDK22x_190703",
|
||||
"SDK221": "NONOSDK221",
|
||||
"SDK22x_190313": "NONOSDK22x_190313",
|
||||
"SDK22x_191024": "NONOSDK22x_191024",
|
||||
"SDK22x_191105": "NONOSDK22x_191105",
|
||||
"SDK22x_191122": "NONOSDK22x_191122",
|
||||
"SDK305": "NONOSDK305",
|
||||
}
|
||||
|
||||
|
||||
class _LwipVariant(NamedTuple):
|
||||
"""One lwIP build variant: the defines and the prebuilt library that
|
||||
was compiled with them."""
|
||||
|
||||
tcp_mss: int
|
||||
features: int
|
||||
ipv6: int
|
||||
lib: str
|
||||
|
||||
|
||||
# Knob define -> variant; first match wins, in insertion order (as in
|
||||
# platformio-build.py)
|
||||
_LWIP_VARIANTS = {
|
||||
"PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY": _LwipVariant(
|
||||
536, 1, 1, "lwip6-536-feat"
|
||||
),
|
||||
"PIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_HIGHER_BANDWIDTH": _LwipVariant(
|
||||
1460, 1, 1, "lwip6-1460-feat"
|
||||
),
|
||||
"PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH": _LwipVariant(
|
||||
1460, 1, 0, "lwip2-1460-feat"
|
||||
),
|
||||
"PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY_LOW_FLASH": _LwipVariant(
|
||||
536, 0, 0, "lwip2-536"
|
||||
),
|
||||
"PIO_FRAMEWORK_ARDUINO_LWIP2_HIGHER_BANDWIDTH_LOW_FLASH": _LwipVariant(
|
||||
1460, 0, 0, "lwip2-1460"
|
||||
),
|
||||
}
|
||||
# The default is PIO_FRAMEWORK_ARDUINO_LWIP2_LOW_MEMORY's variant: upstream
|
||||
# has no branch for that spelling (it is the else), so any listed knob wins
|
||||
# over it -- sntp emits LOW_MEMORY while esp8266 always emits
|
||||
# HIGHER_BANDWIDTH_LOW_FLASH, and the latter must win as under PlatformIO
|
||||
_LWIP_DEFAULT = _LwipVariant(536, 1, 0, "lwip2-536-feat")
|
||||
|
||||
# Knob define -> MMU_* defines; first match wins, in insertion order (as
|
||||
# in platformio-build.py)
|
||||
_MMU_VARIANTS = {
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48": (
|
||||
"MMU_IRAM_SIZE=0xC000",
|
||||
"MMU_ICACHE_SIZE=0x4000",
|
||||
),
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM48_SECHEAP_SHARED": (
|
||||
"MMU_IRAM_SIZE=0xC000",
|
||||
"MMU_ICACHE_SIZE=0x4000",
|
||||
"MMU_IRAM_HEAP",
|
||||
),
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_CACHE16_IRAM32_SECHEAP_NOTSHARED": (
|
||||
"MMU_IRAM_SIZE=0x8000",
|
||||
"MMU_ICACHE_SIZE=0x4000",
|
||||
"MMU_SEC_HEAP_SIZE=0x4000",
|
||||
"MMU_SEC_HEAP=0x40108000",
|
||||
),
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_128K": (
|
||||
"MMU_IRAM_SIZE=0x8000",
|
||||
"MMU_ICACHE_SIZE=0x8000",
|
||||
"MMU_EXTERNAL_HEAP=128",
|
||||
),
|
||||
# Upstream really does cap the 1024K option's heap knob at 256
|
||||
# (platformio-build.py's MMU_EXTERNAL_1024K branch); transliterated
|
||||
# verbatim
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_EXTERNAL_1024K": (
|
||||
"MMU_IRAM_SIZE=0x8000",
|
||||
"MMU_ICACHE_SIZE=0x8000",
|
||||
"MMU_EXTERNAL_HEAP=256",
|
||||
),
|
||||
}
|
||||
# From platformio-build.py: the invariant framework defines every TU gets
|
||||
# (ARDUINO=10805 encodes the IDE compatibility level); the board, flash-mode,
|
||||
# knob, and MMU defines are composed around them in _defines_flags, in
|
||||
# upstream's order.
|
||||
_FRAMEWORK_DEFINES = ("__ets__", "ICACHE_FLASH", "_GNU_SOURCE", "ARDUINO=10805")
|
||||
_ARCH_DEFINES = ("ESP8266", "ARDUINO_ARCH_ESP8266")
|
||||
|
||||
# Upstream reads these from the board manifest (build.mmu_iram_size etc.);
|
||||
# no supported board sets them, so the platformio-build.py defaults are
|
||||
# hardcoded here rather than drift
|
||||
_MMU_DEFAULT = ("MMU_IRAM_SIZE=0x8000", "MMU_ICACHE_SIZE=0x8000")
|
||||
|
||||
# Upstream's CXXFLAGS (-fno-rtti, the -std level, -f(no-)exceptions) and the
|
||||
# trailing stdc++/m/c/gcc system libs are composed at emission
|
||||
# (write_project) from CORE.cpp_standard and _BuildConfig.exceptions.
|
||||
_ASFLAGS = ["-mlongcalls", "-mtext-section-literals"]
|
||||
_CFLAGS = [
|
||||
"-std=gnu17",
|
||||
"-Wpointer-arith",
|
||||
"-Wno-implicit-function-declaration",
|
||||
"-Wl,-EL",
|
||||
"-fno-inline-functions",
|
||||
"-nostdlib",
|
||||
]
|
||||
_CCFLAGS = [
|
||||
"-Os",
|
||||
"-mlongcalls",
|
||||
"-mtext-section-literals",
|
||||
"-falign-functions=4",
|
||||
"-U__STRICT_ANSI__",
|
||||
"-ffunction-sections",
|
||||
"-fdata-sections",
|
||||
"-Wall",
|
||||
"-Werror=return-type",
|
||||
"-free",
|
||||
"-fipa-pta",
|
||||
]
|
||||
# Upstream's -u _scanf_float is deliberately absent: it is re-added from
|
||||
# KEY_SCANF_FLOAT at emission (the remove_float_scanf extra script's job).
|
||||
_LINKFLAGS = [
|
||||
"-Os",
|
||||
"-nostdlib",
|
||||
"-Wl,--no-check-sections",
|
||||
"-Wl,-static",
|
||||
"-Wl,--gc-sections",
|
||||
"-Wl,-wrap,system_restart_local",
|
||||
"-Wl,-wrap,spi_flash_read",
|
||||
"-u",
|
||||
"app_entry",
|
||||
"-u",
|
||||
"_printf_float",
|
||||
"-u",
|
||||
"_DebugExceptionVector",
|
||||
"-u",
|
||||
"_DoubleExceptionVector",
|
||||
"-u",
|
||||
"_KernelExceptionVector",
|
||||
"-u",
|
||||
"_NMIExceptionVector",
|
||||
"-u",
|
||||
"_UserExceptionVector",
|
||||
]
|
||||
_SYSTEM_LIBS_PRE_LWIP = ["hal", "phy", "pp", "net80211"]
|
||||
_SYSTEM_LIBS_POST_LWIP = [
|
||||
"wpa",
|
||||
"crypto",
|
||||
"main",
|
||||
"wps",
|
||||
"bearssl",
|
||||
"espnow",
|
||||
"smartconfig",
|
||||
"airkiss",
|
||||
"wpa2",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BuildConfig:
|
||||
"""Knob-derived build configuration (PIO_FRAMEWORK_ARDUINO_* defines)."""
|
||||
|
||||
nonosdk: str
|
||||
lwip_lib: str
|
||||
exceptions: bool
|
||||
vtables: str
|
||||
fp_in_irom: bool
|
||||
knob_defines: list[str]
|
||||
mmu_defines: list[str]
|
||||
|
||||
|
||||
def _lexed_build_flags() -> list[str]:
|
||||
"""Shell-lex ``CORE.build_flags`` as PlatformIO's ``ParseFlags`` does,
|
||||
sorted so duplicate defines resolve deterministically.
|
||||
|
||||
Lex once per build; consumers share the tokens.
|
||||
"""
|
||||
# The funnel warns and drops empty glued arguments (-D "") itself
|
||||
return lex_build_flags(sorted(CORE.build_flags), "esphome")
|
||||
|
||||
|
||||
def _flag_defines(unflags: set[str], tokens: list[str]) -> dict[str, str]:
|
||||
"""Map define name -> full ``NAME[=VALUE]`` for every -D build flag.
|
||||
|
||||
``tokens`` comes from one ``_lexed_build_flags()`` call shared with
|
||||
``_project_flags``, which already warned about and dropped any bare "-D".
|
||||
"""
|
||||
defines: dict[str, str] = {}
|
||||
for tok in tokens:
|
||||
# An unflagged knob must not drive lwIP/SDK/MMU selection while
|
||||
# being absent from the compile line
|
||||
if tok in unflags:
|
||||
continue
|
||||
if tok.startswith("-D"):
|
||||
body = tok[2:]
|
||||
defines[body.split("=", 1)[0]] = body
|
||||
return defines
|
||||
|
||||
|
||||
def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig:
|
||||
nonosdk = next(
|
||||
(
|
||||
define
|
||||
for name, define in _NONOSDK_VERSIONS.items()
|
||||
if f"PIO_FRAMEWORK_ARDUINO_ESPRESSIF_{name}" in defines
|
||||
),
|
||||
next(iter(_NONOSDK_VERSIONS.values())),
|
||||
)
|
||||
# Same compile-line/linked-artifact split as the lwIP knobs below: a
|
||||
# raw NONOSDK* would define a second SDK macro while the link still
|
||||
# resolves against the knob's libraries
|
||||
if raw_sdk := sorted(n for n in defines if n.startswith("NONOSDK")):
|
||||
raise EsphomeError(
|
||||
f"{', '.join(raw_sdk)} are set by the "
|
||||
"PIO_FRAMEWORK_ARDUINO_ESPRESSIF_SDK* knobs; drop the raw "
|
||||
"build flags"
|
||||
)
|
||||
|
||||
lwip = next(
|
||||
(variant for knob, variant in _LWIP_VARIANTS.items() if knob in defines),
|
||||
_LWIP_DEFAULT,
|
||||
)
|
||||
|
||||
# The lwIP triple selects a prebuilt library; a raw override would win
|
||||
# the compile line (user tokens come last here) while the link still
|
||||
# pulls the library built for the knob's values
|
||||
if owned := sorted(
|
||||
n for n in ("TCP_MSS", "LWIP_FEATURES", "LWIP_IPV6") if n in defines
|
||||
):
|
||||
raise EsphomeError(
|
||||
f"{', '.join(owned)} are set by the PIO_FRAMEWORK_ARDUINO_LWIP2_* "
|
||||
"knobs; drop the raw build flags"
|
||||
)
|
||||
knob_defines = [
|
||||
f"{nonosdk}=1",
|
||||
f"TCP_MSS={lwip.tcp_mss}",
|
||||
f"LWIP_FEATURES={lwip.features}",
|
||||
f"LWIP_IPV6={lwip.ipv6}",
|
||||
]
|
||||
if "PIO_FRAMEWORK_ARDUINO_WAVEFORM_LOCKED_PHASE" in defines:
|
||||
knob_defines.append("WAVEFORM_LOCKED_PHASE=1")
|
||||
|
||||
# Sorted so the pick is deterministic: the dict is built from a set of
|
||||
# build flags, whose iteration order varies between processes.
|
||||
vtables_knobs = sorted(name for name in defines if name.startswith("VTABLES_IN_"))
|
||||
known_vtables = {"VTABLES_IN_FLASH", "VTABLES_IN_DRAM", "VTABLES_IN_IRAM"}
|
||||
# A typo'd or conflicting knob would otherwise fail obscurely in the
|
||||
# SDK header's #error
|
||||
if unknown := [k for k in vtables_knobs if k not in known_vtables]:
|
||||
raise EsphomeError(f"Unknown VTABLES_IN_* define(s): {', '.join(unknown)}")
|
||||
# A body (e.g. VTABLES_IN_FLASH=0) would split the compile line from the
|
||||
# linker script, which always defines the bare name
|
||||
if valued := [defines[k] for k in vtables_knobs if defines[k] not in (k, f"{k}=1")]:
|
||||
raise EsphomeError(f"VTABLES_IN_* defines take no value: {', '.join(valued)}")
|
||||
if len(vtables_knobs) > 1:
|
||||
raise EsphomeError(
|
||||
f"Conflicting VTABLES_IN_* defines: {', '.join(vtables_knobs)}"
|
||||
)
|
||||
vtables = vtables_knobs[0] if vtables_knobs else "VTABLES_IN_FLASH"
|
||||
|
||||
mmu_knob = next((knob for knob in _MMU_VARIANTS if knob in defines), None)
|
||||
if mmu_knob is not None:
|
||||
if raw := sorted(n for n in defines if n.startswith("MMU_")):
|
||||
# Same compile-line/linker-script split as the no-knob case below
|
||||
fix = (
|
||||
f"drop {mmu_knob} to use the custom sizes"
|
||||
if "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines
|
||||
else "drop the raw MMU_* build flags or use "
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"
|
||||
)
|
||||
raise EsphomeError(f"{', '.join(raw)} conflict with {mmu_knob}; {fix}")
|
||||
mmu = list(_MMU_VARIANTS[mmu_knob])
|
||||
elif "PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM" in defines:
|
||||
if "MMU_IRAM_SIZE" not in defines or "MMU_ICACHE_SIZE" not in defines:
|
||||
raise EsphomeError(
|
||||
"PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM requires MMU_IRAM_SIZE and "
|
||||
"MMU_ICACHE_SIZE build flags"
|
||||
)
|
||||
for name in _MMU_SEGMENT_SIZE_NAMES:
|
||||
# A bare -Dname would preprocess to len = 1 and fail far away
|
||||
if "=" not in defines[name]:
|
||||
raise EsphomeError(
|
||||
f"{name} must be a hex literal (e.g. 0x8000), got (no value)"
|
||||
)
|
||||
for name, body in defines.items():
|
||||
if not name.startswith("MMU_") or "=" not in body:
|
||||
# Valueless flags (MMU_IRAM_HEAP) are legitimate switches
|
||||
continue
|
||||
# Every valued MMU_* reaches the linker-script preprocessor; a
|
||||
# bare or non-numeric value would corrupt it and fail far away
|
||||
# in ld. The two segment sizes must additionally be hex:
|
||||
# build_surgery's segment parser cannot read decimal back.
|
||||
value = body.partition("=")[2]
|
||||
rule = (
|
||||
_MMU_HEX_VALUE_RE if name in _MMU_SEGMENT_SIZE_NAMES else _MMU_VALUE_RE
|
||||
)
|
||||
if not rule.fullmatch(value):
|
||||
shape = (
|
||||
"a hex literal (e.g. 0x8000)"
|
||||
if name in _MMU_SEGMENT_SIZE_NAMES
|
||||
else "a numeric literal"
|
||||
)
|
||||
raise EsphomeError(
|
||||
f"{name} must be {shape}, got {value or '(no value)'}"
|
||||
)
|
||||
# Sorted so build.ninja and the linker-script stamp stay
|
||||
# byte-stable across runs (the flag set has no deterministic
|
||||
# iteration order).
|
||||
mmu = sorted(body for name, body in defines.items() if name.startswith("MMU_"))
|
||||
else:
|
||||
if raw := sorted(n for n in defines if n.startswith("MMU_")):
|
||||
# Unlike PlatformIO (whose defaults win the compile line), user
|
||||
# MMU_* here would win the compile but not the linker script;
|
||||
# refuse them all, like the knob branch above.
|
||||
raise EsphomeError(
|
||||
f"Raw {', '.join(raw)} build flags require "
|
||||
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"
|
||||
)
|
||||
mmu = list(_MMU_DEFAULT)
|
||||
|
||||
return _BuildConfig(
|
||||
nonosdk=nonosdk,
|
||||
lwip_lib=lwip.lib,
|
||||
exceptions="PIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS" in defines,
|
||||
vtables=vtables,
|
||||
fp_in_irom="FP_IN_IROM" in defines,
|
||||
knob_defines=knob_defines,
|
||||
mmu_defines=mmu,
|
||||
)
|
||||
|
||||
|
||||
def _pio_option(key: str, default: str) -> str:
|
||||
"""A platformio_options value the native build honors (str-normalized).
|
||||
|
||||
core/config.py routes these into ``CORE.platformio_options`` under the
|
||||
arduino toolchain and already collapses a repeated option to its last
|
||||
value (like a later platformio.ini line), so a scalar always arrives.
|
||||
"""
|
||||
value = CORE.platformio_options.get(key)
|
||||
if value is None:
|
||||
return default
|
||||
value = str(value).strip()
|
||||
if not value:
|
||||
raise EsphomeError(f"platformio_options {key} is empty")
|
||||
return value
|
||||
|
||||
|
||||
def _defines_flags(
|
||||
config: _BuildConfig, flash_mode: str, board: str, board_defines: tuple[str, ...]
|
||||
) -> list[str]:
|
||||
r"""The framework/board -D tokens for the compile line.
|
||||
|
||||
The returned tokens already carry shell-level escaping (the board
|
||||
defines embed ``\"``), so they must be emitted unquoted; wrapping
|
||||
them in ``_shell_token`` would deliver literal backslashes to gcc.
|
||||
"""
|
||||
if not _BOARD_NAME_RE.fullmatch(board):
|
||||
# The name lands unquoted in two -D bodies; reject it by name
|
||||
# instead of corrupting the compile line
|
||||
raise EsphomeError(f"Invalid board name {board!r}")
|
||||
# Every supported board ships 80 MHz; board_build.f_cpu overrides
|
||||
f_cpu = _pio_option("board_build.f_cpu", _DEFAULT_F_CPU)
|
||||
if not _F_CPU_RE.fullmatch(f_cpu):
|
||||
# The value lands unquoted on the compile line; reject by name
|
||||
# instead of corrupting it
|
||||
raise EsphomeError(f"Invalid board_build.f_cpu value {f_cpu!r}")
|
||||
return [
|
||||
f"-D{d}"
|
||||
for d in (
|
||||
f"F_CPU={f_cpu}",
|
||||
*_FRAMEWORK_DEFINES,
|
||||
f'ARDUINO_BOARD=\\"PLATFORMIO_{board.upper()}\\"',
|
||||
f'ARDUINO_BOARD_ID=\\"{board}\\"',
|
||||
f"FLASHMODE_{flash_mode.upper()}",
|
||||
"LWIP_OPEN_SRC",
|
||||
*config.knob_defines,
|
||||
config.vtables,
|
||||
# User-supplied bodies re-quote like every other user token
|
||||
# (a no-op for real MMU values)
|
||||
*(_shell_token(d) for d in config.mmu_defines),
|
||||
*_ARCH_DEFINES,
|
||||
*board_defines,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _unflag_tokens() -> set[str]:
|
||||
"""``build_unflags`` entries shell-lexed to tokens, as PlatformIO matches."""
|
||||
# Lexed like _lexed_build_flags reads build_flags, so "-D FOO" removes
|
||||
# -DFOO in both spellings (PlatformIO's ProcessUnFlags parses the same
|
||||
# way) and no bare half can collaterally drop an unrelated token
|
||||
return set(lex_build_flags(list(CORE.build_unflags), "esphome build_unflags"))
|
||||
|
||||
|
||||
def _project_flags(
|
||||
unflags: set[str], tokens: list[str]
|
||||
) -> tuple[list[str], list[str], list[Path], list[str]]:
|
||||
"""Split the ESPHome build flags into compile, linker, -L, and -l lists.
|
||||
|
||||
Plain-form linker flags (``_PLAIN_LINKER_FLAGS``/``_PLAIN_LINKER_PREFIXES``)
|
||||
raise: they would be inert on the ``-c`` compile line.
|
||||
``compile_flags``/``link_flags`` come back shell-quoted;
|
||||
``lib_dirs``/``libs`` are raw, quote at emission.
|
||||
"""
|
||||
compile_flags: list[str] = []
|
||||
link_flags: list[str] = []
|
||||
lib_dirs: list[Path] = []
|
||||
libs: list[str] = []
|
||||
for tok in tokens:
|
||||
if tok in unflags:
|
||||
continue
|
||||
# _lexed_build_flags warned about and dropped any bare -I/-D/-L/-l
|
||||
if tok.startswith("-Wl,"):
|
||||
link_flags.append(_shell_token(tok))
|
||||
elif tok.startswith("-L"):
|
||||
lib_dirs.append(Path(tok[2:]))
|
||||
elif tok.startswith("-l"):
|
||||
libs.append(tok[2:])
|
||||
else:
|
||||
if tok.startswith(_PLAIN_DRIVER_LINK_PREFIXES):
|
||||
# Driver options with no -Wl, spelling; ld would reject them
|
||||
raise EsphomeError(
|
||||
f"Link flag {tok} in build_flags is not supported by the "
|
||||
"native toolchain"
|
||||
)
|
||||
if tok in _PLAIN_LINKER_FLAGS or tok.startswith(_PLAIN_LINKER_PREFIXES):
|
||||
raise EsphomeError(
|
||||
f"Linker flag {tok} in build_flags is not routed to the "
|
||||
"link line; use the -Wl, form"
|
||||
)
|
||||
if tok.startswith("-") and not tok.startswith(_COMPILE_FLAG_PREFIXES):
|
||||
# The linker deny lists are not exhaustive; an unlisted
|
||||
# link-only spelling would be inert on the -c compile line,
|
||||
# so at least surface the odd shape
|
||||
_LOGGER.warning(
|
||||
"Build flag %s is not a recognized compile-flag shape; "
|
||||
"it is passed to the compile line only",
|
||||
tok,
|
||||
)
|
||||
compile_flags.append(_shell_token(tok))
|
||||
return compile_flags, link_flags, lib_dirs, libs
|
||||
|
||||
|
||||
# Plain-form linker flags rejected by _project_flags: inert on a -c compile
|
||||
# line, so the firmware would silently lack the requested link behavior
|
||||
# Best-effort, not exhaustive: an unlisted link-only spelling still falls
|
||||
# through to the compile line, with a warning from the shape check
|
||||
_COMPILE_FLAG_PREFIXES = (
|
||||
"-D",
|
||||
"-I",
|
||||
"-U",
|
||||
"-W",
|
||||
"-f",
|
||||
"-m",
|
||||
"-O",
|
||||
"-g",
|
||||
"-std=",
|
||||
"-include",
|
||||
)
|
||||
_PLAIN_LINKER_FLAGS = (
|
||||
"-u",
|
||||
"-e",
|
||||
"-s",
|
||||
"-static",
|
||||
"-nostartfiles",
|
||||
"-nodefaultlibs",
|
||||
"-nostdlib",
|
||||
"-rdynamic",
|
||||
)
|
||||
_PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker")
|
||||
# Driver options, not ld options: -Wl, has no equivalent for these
|
||||
_PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=")
|
||||
|
||||
|
||||
def _stat_sig(path: Path) -> str:
|
||||
"""Size and mtime cache-stamp signature for one input file.
|
||||
|
||||
Absent stays deterministic ("missing": the spawn names it); unreadable
|
||||
forces a cache miss every run rather than pinning the stamp to a
|
||||
constant that can never notice a later edit.
|
||||
"""
|
||||
try:
|
||||
st = path.stat()
|
||||
return f"{st.st_size}:{st.st_mtime_ns}"
|
||||
except FileNotFoundError:
|
||||
return "missing"
|
||||
except OSError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not stat %s (%s); regenerating the linker script every "
|
||||
"build. Run 'esphome clean-all' to reinstall the framework.",
|
||||
path,
|
||||
err,
|
||||
)
|
||||
return f"unreadable:{os.urandom(8).hex()}"
|
||||
|
||||
|
||||
def _write_note(path: Path, text: str, *, warn: bool = False) -> bool:
|
||||
"""Best-effort bookkeeping write; a failure never fails the build.
|
||||
|
||||
``warn`` marks notes whose loss drops a diagnostic on later cached
|
||||
builds; a lost stamp only costs a cache miss and stays at debug.
|
||||
Returns whether the write persisted, so a lost warn note can veto
|
||||
the cache stamp and keep the diagnostic re-derivable.
|
||||
"""
|
||||
try:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
except OSError as err:
|
||||
log = _LOGGER.warning if warn else _LOGGER.debug
|
||||
log("Could not write %s: %s", path, err)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def generate_ld_scripts(
|
||||
paths: InstalledPaths, config: _BuildConfig, flash_ld_name: str
|
||||
) -> None:
|
||||
"""Generate the common linker script (and testing-mode flash ld copy).
|
||||
|
||||
Runs the same preprocessor invocation as the PlatformIO builder over
|
||||
``eagle.app.v6.common.ld.h``, then applies ESPHome's surgeries: the wifi
|
||||
rate-table DRAM relocation, and enlarged memory segments in testing mode.
|
||||
"""
|
||||
if not _FLASH_LD_NAME_RE.fullmatch(flash_ld_name):
|
||||
# Joined under the SDK and build ld dirs; never a path or traversal
|
||||
raise EsphomeError(f"Invalid flash linker script name {flash_ld_name!r}")
|
||||
framework = paths.framework
|
||||
gcc = toolchain_tool(paths.toolchain, "gcc")
|
||||
ld_dir = CORE.relative_pioenvs_path(CORE.name, "ld")
|
||||
mkdir_p(ld_dir)
|
||||
|
||||
cmd = [str(gcc), "-CC", "-E", "-P", f"-D{config.vtables}"]
|
||||
cmd += [f"-D{d}" for d in config.mmu_defines]
|
||||
if config.fp_in_irom:
|
||||
cmd.append("-DFP_IN_IROM")
|
||||
header = _sdk_ld_dir(framework) / _COMMON_LD_HEADER
|
||||
cmd += [str(header), "-o", "-"]
|
||||
|
||||
# The inputs are the command line (defines + framework version, which is
|
||||
# baked into the paths) plus testing mode; skip the preprocessor spawn on
|
||||
# incremental builds when nothing changed.
|
||||
output = ld_dir / _COMMON_LD_NAME
|
||||
stamp = ld_dir / f".{_COMMON_LD_NAME}.stamp"
|
||||
# Stamp includes the header/gcc stat (catches in-place re-extraction)
|
||||
# and the surgery fingerprint (a build_surgery edit invalidates old
|
||||
# build dirs)
|
||||
stamp_content = (
|
||||
" ".join(cmd)
|
||||
+ f" testing={CORE.testing_mode}"
|
||||
+ f" header={_stat_sig(header)}"
|
||||
+ f" gcc={_stat_sig(gcc)}"
|
||||
+ f" {build_surgery.surgery_fingerprint()}"
|
||||
)
|
||||
|
||||
def _cached_ld_is_valid() -> bool:
|
||||
# Any damaged cache regenerates; never abort the build over it. The
|
||||
# stamp records the sha256 of the content written, so an externally
|
||||
# edited script regenerates too.
|
||||
try:
|
||||
if not (output.is_file() and stamp.is_file()):
|
||||
return False
|
||||
inputs, sep, digest = stamp.read_text(encoding="utf-8").rpartition(
|
||||
" content="
|
||||
)
|
||||
return (
|
||||
bool(sep)
|
||||
and inputs == stamp_content
|
||||
and hashlib.sha256(output.read_bytes()).hexdigest() == digest
|
||||
)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return False
|
||||
|
||||
stderr_note = ld_dir / f".{_COMMON_LD_NAME}.stderr"
|
||||
if not _cached_ld_is_valid():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
# Localized gcc diagnostics on a non-UTF-8 console must
|
||||
# degrade, not UnicodeDecodeError the build
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
except OSError as err:
|
||||
# A half-extracted or half-deleted toolchain cache reaches here
|
||||
raise EsphomeError(f"Could not run {gcc}: {err}; {_CLEAN_HINT}") from err
|
||||
if result.returncode != 0:
|
||||
raise EsphomeError(f"Generating the linker script failed:\n{result.stderr}")
|
||||
note_persisted = True
|
||||
if result.stderr.strip():
|
||||
# Preprocessor warnings on the success path must reach the user
|
||||
# on this and every later cached build (see the re-emit below)
|
||||
_LOGGER.warning("Linker-script preprocessor: %s", result.stderr.strip())
|
||||
note_persisted = _write_note(stderr_note, result.stderr.strip(), warn=True)
|
||||
else:
|
||||
try:
|
||||
stderr_note.unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
# A kept stale note would re-emit an obsolete diagnostic on
|
||||
# every cache hit; skip the stamp so -E re-derives the truth
|
||||
_LOGGER.warning(
|
||||
"Could not remove %s (%s); the linker script will "
|
||||
"regenerate every build until it is removable; %s",
|
||||
stderr_note,
|
||||
err,
|
||||
_CLEAN_HINT,
|
||||
)
|
||||
note_persisted = False
|
||||
if "SECTIONS" not in result.stdout:
|
||||
# A degenerate zero-exit run must not be stamped as a good cache
|
||||
raise EsphomeError(
|
||||
f"Generated linker script is missing its SECTIONS block; {_CLEAN_HINT}"
|
||||
)
|
||||
content = _apply_surgery(build_surgery.relocate_ratetable, result.stdout)
|
||||
if CORE.testing_mode:
|
||||
content = _apply_surgery(
|
||||
build_surgery.apply_testing_memory_patches, content, ("iram1_0_seg",)
|
||||
)
|
||||
write_file_if_changed(output, content)
|
||||
if note_persisted:
|
||||
# An unstamped cache re-runs -E next build, re-deriving the
|
||||
# diagnostic the lost note would have re-emitted
|
||||
_write_note(
|
||||
stamp,
|
||||
f"{stamp_content} content={hashlib.sha256(content.encode('utf-8')).hexdigest()}",
|
||||
)
|
||||
elif stderr_note.is_file():
|
||||
# Re-emit cached preprocessor warnings on cache hits
|
||||
try:
|
||||
_LOGGER.warning(
|
||||
"Linker-script preprocessor: %s",
|
||||
stderr_note.read_text(encoding="utf-8"),
|
||||
)
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
_LOGGER.warning(
|
||||
"A cached linker-script preprocessor diagnostic exists at %s "
|
||||
"but could not be read: %s",
|
||||
stderr_note,
|
||||
err,
|
||||
)
|
||||
|
||||
if CORE.testing_mode:
|
||||
_generate_testing_flash_ld(framework, ld_dir, flash_ld_name)
|
||||
|
||||
|
||||
def _generate_testing_flash_ld(
|
||||
framework: Path, ld_dir: Path, flash_ld_name: str
|
||||
) -> None:
|
||||
"""A patched copy of the flash ld in the build dir; resolved through the
|
||||
same -L path as the SDK original it shadows."""
|
||||
flash_ld = _sdk_ld_dir(framework) / flash_ld_name
|
||||
try:
|
||||
flash_ld_text = flash_ld.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
# Same half-extracted-cache hazard as the preprocessor spawn
|
||||
raise EsphomeError(f"Could not read {flash_ld}: {err}; {_CLEAN_HINT}") from err
|
||||
patched_flash_ld = _apply_surgery(
|
||||
build_surgery.apply_testing_memory_patches,
|
||||
flash_ld_text,
|
||||
("dram0_0_seg", "irom0_0_seg"),
|
||||
)
|
||||
write_file_if_changed(
|
||||
ld_dir / f"{_TESTING_LD_PREFIX}{flash_ld_name}", patched_flash_ld
|
||||
)
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Tiny cross-platform build steps invoked from the generated ninja file.
|
||||
|
||||
Plain script (not ``python -m``): it runs from ninja with whatever Python
|
||||
started esphome and must not depend on the package being importable.
|
||||
|
||||
Subcommands:
|
||||
ar <ar-binary> <archive> <rspfile> remove stale archive, then ``ar rc``
|
||||
copy <src> <dst> copy a file
|
||||
|
||||
The ar rspfile carries one object path per line (the generating rule must
|
||||
use ``$in_newline``, never ``$in``).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _read_rspfile(rspfile: str) -> list[str]:
|
||||
r"""The object paths listed in ``rspfile``, unquoted.
|
||||
|
||||
GNU ar treats backslashes in response files as escapes (corrupts
|
||||
Windows paths), so the caller expands the list into argv; strip the
|
||||
simple surrounding quote ninja adds to special paths, then undo
|
||||
ninja's POSIX escape for an embedded quote ('a'\\''b.o' -> a'b.o).
|
||||
"""
|
||||
return [
|
||||
line[1:-1].replace("'\\''", "'")
|
||||
if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\""
|
||||
else line
|
||||
for line in Path(rspfile).read_text(encoding="utf-8").splitlines()
|
||||
if line
|
||||
]
|
||||
|
||||
|
||||
def _run_ar(ar: str, archive: str, rspfile: str) -> int:
|
||||
# Remove first: ``ar rc`` replaces members but never drops ones whose
|
||||
# source was removed from the build, which would leak stale objects.
|
||||
Path(archive).unlink(missing_ok=True)
|
||||
objects = _read_rspfile(rspfile)
|
||||
if not objects:
|
||||
# An empty archive would "succeed" here and fail far away at link
|
||||
print(f"ar: no objects listed in {rspfile} for {archive}", file=sys.stderr)
|
||||
return 1
|
||||
# Batch by argv length: expanding the rspfile gives back the Windows
|
||||
# 32767-char command-line limit it existed to avoid. "rc" creates,
|
||||
# "q" appends the remainder.
|
||||
op = "rc"
|
||||
ok = False
|
||||
try:
|
||||
while objects:
|
||||
batch = [objects.pop(0)]
|
||||
batch_len = len(batch[0])
|
||||
while objects and batch_len + len(objects[0]) < 25000:
|
||||
batch_len += len(objects[0]) + 1
|
||||
batch.append(objects.pop(0))
|
||||
rc = subprocess.run(
|
||||
[ar, op, archive, *batch], check=False, close_fds=False
|
||||
).returncode
|
||||
if rc != 0:
|
||||
return rc
|
||||
op = "q"
|
||||
ok = True
|
||||
return 0
|
||||
finally:
|
||||
if not ok:
|
||||
# Any failure (bad exit, missing ar binary, interrupt) must not
|
||||
# leave a truncated archive behind
|
||||
Path(archive).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _run_copy(src: str, dst: str) -> int:
|
||||
try:
|
||||
shutil.copyfile(src, dst)
|
||||
except OSError as err:
|
||||
# Never leave a partially written output (e.g. a firmware image);
|
||||
# SameFileError means dst IS src, where unlinking destroys the input
|
||||
if not isinstance(err, shutil.SameFileError):
|
||||
Path(dst).unlink(missing_ok=True)
|
||||
raise
|
||||
return 0
|
||||
|
||||
|
||||
# mode -> (handler, expected operand count); surplus argv means a
|
||||
# mis-specified ninja rule and must error, not silently drop operands
|
||||
_MODES = {"ar": (_run_ar, 3), "copy": (_run_copy, 2)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if entry := _MODES.get(mode):
|
||||
handler, argc = entry
|
||||
args = sys.argv[2:]
|
||||
if len(args) != argc:
|
||||
print(
|
||||
f"build_tool {mode}: expected {argc} arguments, got {len(args)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return handler(*args)
|
||||
print(f"unknown build_tool mode: {mode}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
@@ -1 +0,0 @@
|
||||
"""Build helpers shared by the native (non-PlatformIO) toolchains."""
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Shared ccache policy for build backends: env-knob parsing, binary
|
||||
resolution, and default ``CCACHE_*`` values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
from esphome.helpers import FALSY_BOOL_STRINGS, TRUTHY_BOOL_STRINGS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# cv.boolean's spelling tables plus the 1/0 env convention
|
||||
TRUTHY_ENV_STRINGS = TRUTHY_BOOL_STRINGS | {"1"}
|
||||
FALSY_ENV_STRINGS = FALSY_BOOL_STRINGS | {"0"}
|
||||
|
||||
|
||||
def _ccache_runs(ccache: str) -> bool:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs."""
|
||||
return tool_version_runs(
|
||||
ccache,
|
||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||
)
|
||||
|
||||
|
||||
def parse_enable_env(name: str) -> bool | None:
|
||||
"""Strictly parse an on/off environment knob; None when unset or invalid.
|
||||
|
||||
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
|
||||
1/true/yes/on and 0/false/no/off count; anything else warns and reads
|
||||
as unset so the caller's default policy applies.
|
||||
"""
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return None
|
||||
lowered = raw.strip().lower()
|
||||
if not lowered:
|
||||
# ENV KNOB= (Docker/CI) has always read as a disable
|
||||
return False
|
||||
if lowered in TRUTHY_ENV_STRINGS:
|
||||
return True
|
||||
if lowered in FALSY_ENV_STRINGS:
|
||||
return False
|
||||
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_ccache_path() -> str | None:
|
||||
"""The ccache binary to wrap compiles with, or None when disabled.
|
||||
|
||||
An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the
|
||||
Windows extended-length prefix is stripped before probing (#18399).
|
||||
"""
|
||||
import shutil
|
||||
|
||||
explicit = parse_enable_env("ESPHOME_CCACHE_ENABLE")
|
||||
if explicit is False:
|
||||
return None
|
||||
ccache = shutil.which("ccache")
|
||||
if ccache is None:
|
||||
if explicit:
|
||||
_LOGGER.warning(
|
||||
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
|
||||
"compiling without ccache"
|
||||
)
|
||||
return None
|
||||
ccache = strip_win_long_path_prefix(ccache)
|
||||
if not explicit and not _ccache_runs(ccache):
|
||||
return None
|
||||
return ccache
|
||||
|
||||
|
||||
def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
"""Default ``CCACHE_*`` values for a build subprocess (not os.environ).
|
||||
|
||||
Values the user already set in the environment are respected. Depend
|
||||
mode is on: both native backends emit depfiles (-MMD / CMake), which
|
||||
keeps cache-miss overhead low.
|
||||
"""
|
||||
from esphome.core import CORE
|
||||
|
||||
# An unset build_path means the env was built before preload; fail loudly
|
||||
# rather than silently drop CCACHE_BASEDIR.
|
||||
if CORE.build_path is None:
|
||||
raise ValueError(
|
||||
"CORE.build_path must be set before constructing the build environment"
|
||||
)
|
||||
defaults = {
|
||||
"CCACHE_DIR": str(cache_dir),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Platform-neutral helpers for ninja-driven native builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ninja_runs(binary: str) -> bool:
|
||||
"""Whether the ninja found on PATH actually runs (see tool_version_runs)."""
|
||||
return tool_version_runs(
|
||||
binary,
|
||||
"Ignoring ninja at %s because it failed to run; "
|
||||
"falling back to the bundled wheel",
|
||||
)
|
||||
|
||||
|
||||
def find_ninja() -> Path:
|
||||
"""Locate the ninja binary: a runnable PATH hit first, else the ninja
|
||||
PyPI wheel."""
|
||||
if binary := shutil.which("ninja"):
|
||||
binary = strip_win_long_path_prefix(binary)
|
||||
if _ninja_runs(binary):
|
||||
return Path(binary)
|
||||
import_error: ImportError | None = None
|
||||
try:
|
||||
import ninja
|
||||
except ImportError as err:
|
||||
import_error = err
|
||||
wheel_binary = None
|
||||
else:
|
||||
wheel_binary = Path(ninja.BIN_DIR) / (
|
||||
"ninja.exe" if os.name == "nt" else "ninja"
|
||||
)
|
||||
if wheel_binary is None or not wheel_binary.is_file():
|
||||
raise EsphomeError(
|
||||
"ninja not found on PATH or in the ninja package; reinstall the "
|
||||
"esphome Python environment"
|
||||
) from import_error
|
||||
return wheel_binary
|
||||
|
||||
|
||||
def escape(value: Path | str) -> str:
|
||||
"""Escape a path or token for a ninja file."""
|
||||
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
|
||||
|
||||
|
||||
def quote_arg(tok: str) -> str:
|
||||
"""Quote with the CreateProcess argv rule (as ``subprocess.list2cmdline``):
|
||||
backslash runs double only before a quote. Windows-only; ``$`` must
|
||||
already be doubled for ninja.
|
||||
"""
|
||||
quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
|
||||
quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
|
||||
return f'"{quoted}"'
|
||||
|
||||
|
||||
# Force-quote any token containing a character outside the shlex.quote-style
|
||||
# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, `
|
||||
# and friends would be re-parsed as shell syntax.
|
||||
_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]")
|
||||
|
||||
|
||||
def shell_token(tok: str, force: bool = False) -> str:
|
||||
"""Re-quote a lexed token for the platform shell; ``force`` always quotes.
|
||||
|
||||
Single quotes on POSIX (/bin/sh), the argv rule on Windows
|
||||
(CreateProcess). ``$`` is doubled first because ninja expands it before
|
||||
the command reaches the shell.
|
||||
"""
|
||||
tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
|
||||
if not (force or not tok or _NEEDS_QUOTE.search(tok)):
|
||||
return tok
|
||||
# An empty token must become '' / "" or it vanishes from the argv
|
||||
if os.name == "nt":
|
||||
return quote_arg(tok)
|
||||
# shlex.quote's rule; inlined because the $-doubled token must not be
|
||||
# re-examined for safe characters
|
||||
return "'" + tok.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def quote_path(value: Path | str) -> str:
|
||||
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
|
||||
return shell_token(str(value), force=True)
|
||||
@@ -1,24 +0,0 @@
|
||||
"""The PlatformIO-format size bar shared by the native toolchains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def format_bar(used: int, total: int) -> str:
|
||||
"""Match PlatformIO's ``_format_availale_bytes`` (sic, pioupload.py) exactly."""
|
||||
pct_raw = used / total if total else 0
|
||||
blocks = 10
|
||||
filled = min(int(round(blocks * pct_raw)), blocks)
|
||||
progress = "=" * filled
|
||||
return (
|
||||
f"[{progress:<{blocks}}] {pct_raw: 6.1%} "
|
||||
f"(used {used:d} bytes from {total:d} bytes)"
|
||||
)
|
||||
|
||||
|
||||
def print_size_line(label: str, used: int, total: int) -> None:
|
||||
"""One PlatformIO-format summary line (``RAM``/``Flash``).
|
||||
|
||||
The label padding is part of the format: ``script/ci_memory_impact_extract.py``
|
||||
matches these lines verbatim.
|
||||
"""
|
||||
print(f"{label + ':':<7}{format_bar(used, total)}")
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Machine-global tools cache location shared by the native backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def tools_cache_path(env_var: str, subdir: str) -> Path:
|
||||
"""A backend's machine-global tools directory, with an env override.
|
||||
|
||||
A blank/whitespace override is treated as unset: ``Path("")`` resolves
|
||||
to the CWD, which ``clean-all`` would then delete.
|
||||
"""
|
||||
import platformdirs
|
||||
|
||||
from esphome.helpers import get_str_env
|
||||
|
||||
if prefix := get_str_env(env_var, "").strip():
|
||||
# resolve(): symlinked prefixes otherwise trip idf.py's
|
||||
# venv-mismatch warning on every build
|
||||
return Path(prefix).expanduser().resolve()
|
||||
# appauthor=False keeps the Windows path short (no vendor segment);
|
||||
# deep IDF trees run into MAX_PATH otherwise
|
||||
return (
|
||||
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir
|
||||
).resolve()
|
||||
|
||||
|
||||
# (env override, cache subdir) per native backend. writer.clean_all wipes
|
||||
# every entry via tools_cache_path, so listing a cache here is the single
|
||||
# step that registers it for removal; the backends' own path getters use
|
||||
# the same named pairs so the two cannot drift.
|
||||
IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf")
|
||||
SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
|
||||
ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
|
||||
TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE)
|
||||
@@ -100,21 +100,6 @@ def _refresh_sidecar() -> bool:
|
||||
)
|
||||
return False
|
||||
if old is not None and old.can_apply_to_core():
|
||||
if (
|
||||
old.toolchain is not None
|
||||
and CORE.toolchain is not None
|
||||
and old.toolchain != CORE.toolchain.value
|
||||
):
|
||||
# Platforms normalize toolchain-sensitive keys differently;
|
||||
# never cache a config validated under a different toolchain
|
||||
# than the compile's
|
||||
_LOGGER.debug(
|
||||
"Not caching: config validated with toolchain %r but the "
|
||||
"last compile used %r",
|
||||
CORE.toolchain.value,
|
||||
old.toolchain,
|
||||
)
|
||||
return False
|
||||
# Compile-written; nothing to refresh.
|
||||
return True
|
||||
if CORE.build_path is not None and CORE.build_path.exists():
|
||||
|
||||
@@ -1073,11 +1073,19 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ESP_IDF)
|
||||
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
||||
# Runs before _detect_variant so downstream validators can rely on
|
||||
# CORE.toolchain instead of re-resolving it from the config dict.
|
||||
_resolve_toolchain = cv.resolve_toolchain("ESP32", _TOOLCHAINS, Toolchain.ESP_IDF)
|
||||
def _validate_toolchain(value) -> Toolchain:
|
||||
return Toolchain(
|
||||
cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_toolchain(value: ConfigType) -> ConfigType:
|
||||
# Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default.
|
||||
# Runs before _detect_variant so downstream validators can rely on
|
||||
# CORE.toolchain instead of re-resolving it from the config dict.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF)
|
||||
return value
|
||||
|
||||
|
||||
def _check_versions(config: ConfigType) -> ConfigType:
|
||||
|
||||
@@ -35,7 +35,7 @@ from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .boards import BOARDS, ESP8266_LD_SCRIPTS, board_ld_script
|
||||
from .boards import BOARDS, ESP8266_LD_SCRIPTS
|
||||
from .const import (
|
||||
CONF_EARLY_PIN_INIT,
|
||||
CONF_ENABLE_SERIAL,
|
||||
@@ -44,7 +44,6 @@ from .const import (
|
||||
KEY_BOARD,
|
||||
KEY_ESP8266,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_LDSCRIPT,
|
||||
KEY_PIN_INITIAL_STATES,
|
||||
KEY_SERIAL1_REQUIRED,
|
||||
KEY_SERIAL_REQUIRED,
|
||||
@@ -137,16 +136,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
|
||||
return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
if ver <= cv.Version(2, 6, 2):
|
||||
return f"~2.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
# Same encoding the native toolchain uses for its package download, so a
|
||||
# version bump cannot drift between the two paths.
|
||||
from esphome.arduino8266.framework import framework_package_version
|
||||
|
||||
try:
|
||||
return f"~{framework_package_version(ver)}"
|
||||
except EsphomeError as err:
|
||||
# Anchor the 4.x rejection to the framework version line instead of
|
||||
# aborting with a bare traceback-level error
|
||||
raise cv.Invalid(str(err), path=[CONF_VERSION]) from err
|
||||
return f"~3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
|
||||
|
||||
# NOTE: Keep this in mind when updating the recommended version:
|
||||
@@ -256,9 +246,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
|
||||
}
|
||||
),
|
||||
# Until the native toolchain lands, PlatformIO is the only backend;
|
||||
# reject a --toolchain this platform cannot serve yet.
|
||||
cv.require_platformio_toolchain("ESP8266"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
@@ -410,28 +397,17 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
if config[CONF_BOARD] in BOARDS:
|
||||
board_data = BOARDS[config[CONF_BOARD]]
|
||||
ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]]
|
||||
flash_size = BOARDS[config[CONF_BOARD]][KEY_FLASH_SIZE]
|
||||
ld_scripts = ESP8266_LD_SCRIPTS[flash_size]
|
||||
|
||||
if ver <= cv.Version(2, 3, 0):
|
||||
# No ld script support
|
||||
ld_script = None
|
||||
elif ver <= cv.Version(2, 4, 2):
|
||||
# Old ld script path; the modern per-board override names do
|
||||
# not exist in this core's SDK, so it cannot be honored
|
||||
if KEY_LDSCRIPT in board_data:
|
||||
_LOGGER.warning(
|
||||
"Board %s pins %s, which Arduino core %s cannot honor; "
|
||||
"using the default flash layout",
|
||||
config[CONF_BOARD],
|
||||
board_data[KEY_LDSCRIPT],
|
||||
ver,
|
||||
)
|
||||
# Old ld script path
|
||||
ld_script = ld_scripts[0]
|
||||
else:
|
||||
# A per-board override preserves a layout the board shipped
|
||||
# with (see d1_wroom_02 in boards.py)
|
||||
ld_script = board_ld_script(board_data)
|
||||
ld_script = ld_scripts[1]
|
||||
|
||||
if ld_script is not None:
|
||||
cg.add_platformio_option("board_build.ldscript", ld_script)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from .const import KEY_FLASH_SIZE, KEY_LDSCRIPT
|
||||
|
||||
FLASH_SIZE_1_MB = 2**20
|
||||
FLASH_SIZE_512_KB = FLASH_SIZE_1_MB // 2
|
||||
FLASH_SIZE_2_MB = 2 * FLASH_SIZE_1_MB
|
||||
@@ -166,8 +164,7 @@ ESP8266_BOARD_PINS = {
|
||||
}
|
||||
|
||||
"""
|
||||
BOARDS generate with (preserve per-board KEY_LDSCRIPT overrides such as
|
||||
d1_wroom_02; the recipe emits only name/flash_size):
|
||||
BOARDS generate with:
|
||||
|
||||
git clone https://github.com/platformio/platform-espressif8266
|
||||
for x in platform-espressif8266/boards/*.json; do
|
||||
@@ -185,19 +182,6 @@ for x in platform-espressif8266/boards/*.json; do
|
||||
done | sort
|
||||
"""
|
||||
|
||||
|
||||
def board_ld_script(board_data: dict) -> str:
|
||||
"""The modern (core > 2.4.2) flash linker script for a board: its
|
||||
shipped-layout override, else the size default (the no-FS layout).
|
||||
|
||||
Single source of truth for the PlatformIO pinning in __init__ and the
|
||||
native generator's fallback, so the per-board rule cannot drift.
|
||||
"""
|
||||
return board_data.get(
|
||||
KEY_LDSCRIPT, ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]][1]
|
||||
)
|
||||
|
||||
|
||||
BOARDS = {
|
||||
"agruminolemon": {
|
||||
"name": "Lifely Agrumino Lemon v4",
|
||||
@@ -215,15 +199,6 @@ BOARDS = {
|
||||
"name": "WeMos D1 mini Pro",
|
||||
"flash_size": FLASH_SIZE_16_MB,
|
||||
},
|
||||
"d1_wroom_02": {
|
||||
"name": "WeMos D1 ESP-WROOM-02",
|
||||
"flash_size": FLASH_SIZE_2_MB,
|
||||
# This board joined BOARDS after shipping with the manifest default
|
||||
# (64 KB filesystem region); the flash-size default (2m.ld) would
|
||||
# move _FS_end and with it the preferences sector, wiping existing
|
||||
# devices' flash-backed state on update.
|
||||
KEY_LDSCRIPT: "eagle.flash.2m64.ld",
|
||||
},
|
||||
"d1": {
|
||||
"name": "WEMOS D1 R1",
|
||||
"flash_size": FLASH_SIZE_4_MB,
|
||||
@@ -385,112 +360,3 @@ BOARDS = {
|
||||
"flash_size": FLASH_SIZE_4_MB,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Per-board variant dir + identity defines from platform-espressif8266 4.x
|
||||
# build.extra_flags; the shared -DESP8266/-DARDUINO_ARCH_ESP8266 are added
|
||||
# by the generator.
|
||||
#
|
||||
# Regenerate ESP8266_BOARD_BUILD with (v4.2.1 is the platform version the
|
||||
# native toolchain mirrors; regenerate against the tag when bumping it):
|
||||
#
|
||||
# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266
|
||||
# python3 - <<'EOF'
|
||||
# import json, glob, os
|
||||
# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")):
|
||||
# b = json.load(open(f))["build"]
|
||||
# extra = b["extra_flags"]
|
||||
# extra = extra.split() if isinstance(extra, str) else extra
|
||||
# defines = [
|
||||
# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266")
|
||||
# ]
|
||||
# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "")
|
||||
# board = os.path.splitext(os.path.basename(f))[0]
|
||||
# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},')
|
||||
# EOF
|
||||
ESP8266_BOARD_BUILD = {
|
||||
"agruminolemon": {
|
||||
"variant": "agruminolemonv4",
|
||||
"defines": ("ARDUINO_ESP8266_AGRUMINO_LEMON_V4",),
|
||||
},
|
||||
"d1": {"variant": "d1", "defines": ("ARDUINO_ESP8266_WEMOS_D1R1",)},
|
||||
"d1_mini": {"variant": "d1_mini", "defines": ("ARDUINO_ESP8266_WEMOS_D1MINI",)},
|
||||
"d1_mini_lite": {
|
||||
"variant": "d1_mini",
|
||||
"defines": ("ARDUINO_ESP8266_WEMOS_D1MINILITE",),
|
||||
},
|
||||
"d1_mini_pro": {
|
||||
"variant": "d1_mini",
|
||||
"defines": ("ARDUINO_ESP8266_WEMOS_D1MINIPRO",),
|
||||
},
|
||||
"d1_wroom_02": {
|
||||
"variant": "d1_mini",
|
||||
"defines": ("ARDUINO_ESP8266_WEMOS_D1WROOM02",),
|
||||
},
|
||||
"eduinowifi": {
|
||||
"variant": "eduinowifi",
|
||||
"defines": ("ARDUINO_ESP8266_SCHIRMILABS_EDUINO_WIFI",),
|
||||
},
|
||||
"esp01": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)},
|
||||
"esp01_1m": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)},
|
||||
"esp07": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP07",)},
|
||||
"esp07s": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP07",)},
|
||||
"esp12e": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP12",)},
|
||||
"esp210": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP210",)},
|
||||
"esp8285": {"variant": "esp8285", "defines": ("ARDUINO_ESP8266_ESP01",)},
|
||||
"esp_wroom_02": {
|
||||
"variant": "nodemcu",
|
||||
"defines": ("ARDUINO_ESP8266_ESP_WROOM_02",),
|
||||
},
|
||||
"espduino": {"variant": "ESPDuino", "defines": ("ARDUINO_ESP8266_ESP13",)},
|
||||
"espectro": {"variant": "espectro", "defines": ("ARDUINO_ESP8266_ESPECTRO_CORE",)},
|
||||
"espino": {"variant": "espino", "defines": ("ARDUINO_ESP8266_ESP12",)},
|
||||
"espinotee": {"variant": "espinotee", "defines": ("ARDUINO_ESP8266_ESP13",)},
|
||||
"espmxdevkit": {
|
||||
"variant": "esp8285",
|
||||
"defines": ("ARDUINO_ESP8266_ESP01", "LED_BUILTIN=16"),
|
||||
},
|
||||
"espresso_lite_v1": {
|
||||
"variant": "espresso_lite_v1",
|
||||
"defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V1",),
|
||||
},
|
||||
"espresso_lite_v2": {
|
||||
"variant": "espresso_lite_v2",
|
||||
"defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V2",),
|
||||
},
|
||||
"gen4iod": {"variant": "generic", "defines": ("ARDUINO_GEN4_IOD",)},
|
||||
"heltec_wifi_kit_8": {
|
||||
"variant": "wifi_kit_8",
|
||||
"defines": ("ARDUINO_wifi_kit_8",),
|
||||
},
|
||||
"huzzah": {"variant": "adafruit", "defines": ("ARDUINO_ESP8266_ADAFRUIT_HUZZAH",)},
|
||||
"inventone": {"variant": "inventone", "defines": ("ARDUINO_ESP8266_INVENT_ONE",)},
|
||||
"modwifi": {"variant": "generic", "defines": ("ARDUINO_MOD_WIFI_ESP8266",)},
|
||||
"nodemcu": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU",)},
|
||||
"nodemcuv2": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU_ESP12E",)},
|
||||
"oak": {"variant": "oak", "defines": ("ARDUINO_ESP8266_OAK",)},
|
||||
"phoenix_v1": {
|
||||
"variant": "phoenix_v1",
|
||||
"defines": ("ARDUINO_ESP8266_PHOENIX_V1",),
|
||||
},
|
||||
"phoenix_v2": {
|
||||
"variant": "phoenix_v2",
|
||||
"defines": ("ARDUINO_ESP8266_PHOENIX_V2",),
|
||||
},
|
||||
"sonoff_basic": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_BASIC",)},
|
||||
"sonoff_s20": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_S20",)},
|
||||
"sonoff_sv": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_SV",)},
|
||||
"sonoff_th": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_TH",)},
|
||||
"sparkfunBlynk": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)},
|
||||
"thing": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)},
|
||||
"thingdev": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING_DEV",)},
|
||||
"wifi_slot": {"variant": "wifi_slot", "defines": ("ARDUINO_AMPERKA_WIFI_SLOT",)},
|
||||
"wifiduino": {"variant": "wifiduino", "defines": ("ARDUINO_WIFIDUINO_ESP8266",)},
|
||||
"wifinfo": {"variant": "wifinfo", "defines": ("ARDUINO_WIFINFO",)},
|
||||
"wio_link": {"variant": "wiolink", "defines": ("ARDUINO_ESP8266_WIO_LINK",)},
|
||||
"wio_node": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",)},
|
||||
"xinabox_cw01": {
|
||||
"variant": "xinabox",
|
||||
"defines": ("ARDUINO_ESP8266_XINABOX_CW01",),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Linker-script surgery shared with the native (PlatformIO-free) toolchain.
|
||||
|
||||
These mirror the PlatformIO extra scripts in this directory
|
||||
(``relocate_ratetable.py.script`` and ``testing_mode.py.script``), which run
|
||||
inside SCons and must stay self-contained. The native build generator applies
|
||||
the same patches to the linker scripts it generates, so the logic lives here
|
||||
as plain functions. Keep both in sync when changing either.
|
||||
``segment_length`` is native-toolchain-only and has no script twin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
# Move the NONOS SDK wifi rate tables from flash to DRAM; see
|
||||
# relocate_ratetable.py.script for the full background (NONOS SDK issue 320).
|
||||
RATETABLE_RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)"
|
||||
_RATETABLE_COMMENT = (
|
||||
"/* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */"
|
||||
)
|
||||
# Match the whole line: "_data_start" is also a substring of the
|
||||
# "_dport0_data_start" line in the earlier .dport0.data section
|
||||
_RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE)
|
||||
|
||||
# Memory sizes for testing mode (allow larger builds for CI component grouping)
|
||||
TESTING_IRAM_SIZE = "0x200000" # 2MB
|
||||
TESTING_DRAM_SIZE = "0x200000" # 2MB
|
||||
TESTING_FLASH_SIZE = "0x2000000" # 32MB
|
||||
|
||||
|
||||
def relocate_ratetable(content: str) -> str:
|
||||
"""Insert the rate-table DRAM rule into a generated common linker script."""
|
||||
if RATETABLE_RULE in content:
|
||||
return content
|
||||
match = _RATETABLE_ANCHOR.search(content)
|
||||
if match is None:
|
||||
raise RuntimeError(
|
||||
"'_data_start' anchor not found in the generated linker script; "
|
||||
"cannot apply wifi rate table DRAM relocation "
|
||||
"(has the Arduino core linker script changed?)"
|
||||
)
|
||||
insert_pos = match.end()
|
||||
return (
|
||||
content[:insert_pos]
|
||||
+ f"\n {_RATETABLE_COMMENT}"
|
||||
+ f"\n {RATETABLE_RULE}"
|
||||
+ content[insert_pos:]
|
||||
)
|
||||
|
||||
|
||||
_TESTING_SEGMENT_SIZES = {
|
||||
"iram1_0_seg": TESTING_IRAM_SIZE,
|
||||
"dram0_0_seg": TESTING_DRAM_SIZE,
|
||||
"irom0_0_seg": TESTING_FLASH_SIZE,
|
||||
}
|
||||
|
||||
|
||||
def _segment_line_re(segment_name: str) -> re.Pattern[str]:
|
||||
"""The MEMORY line for one segment: ``<seg> : org = 0x..., len = 0x...``.
|
||||
|
||||
Anchored to the start of the line so a name never matches inside a
|
||||
longer one (``ram0_0_seg`` must not read ``dram0_0_seg``). The size
|
||||
group stops at the hex digits, leaving any ``ul`` suffix (from the
|
||||
preprocessed ``MMU_IRAM_SIZE``) in place.
|
||||
"""
|
||||
return re.compile(
|
||||
rf"(^[ \t]*{re.escape(segment_name)}"
|
||||
r"\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)"
|
||||
r"(0x[0-9a-fA-F]+)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str:
|
||||
"""Enlarge the named memory segments so grouped CI test builds can link.
|
||||
|
||||
Each caller passes the segments its linker script defines: the
|
||||
generated common ld carries ``iram1_0_seg``; the flash ld carries
|
||||
``dram0_0_seg`` and ``irom0_0_seg``. A segment that fails to match
|
||||
raises, since a silently kept real memory limit would fail grouped
|
||||
builds far from the cause.
|
||||
"""
|
||||
for segment in _TESTING_SEGMENT_SIZES:
|
||||
if segment not in segments and _segment_line_re(segment).search(content):
|
||||
raise RuntimeError(
|
||||
f"Testing-mode segment {segment} is present in the linker "
|
||||
"script but was not selected for patching"
|
||||
)
|
||||
for segment in segments:
|
||||
if segment not in _TESTING_SEGMENT_SIZES:
|
||||
raise RuntimeError(f"Unknown testing-mode segment {segment!r}")
|
||||
content, count = _segment_line_re(segment).subn(
|
||||
rf"\g<1>{_TESTING_SEGMENT_SIZES[segment]}", content
|
||||
)
|
||||
if count == 0:
|
||||
raise RuntimeError(
|
||||
f"Testing-mode memory patch failed: segment {segment} "
|
||||
"not found (has the Arduino core linker script changed?)"
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def segment_length(content: str, segment_name: str) -> int | None:
|
||||
"""Read a memory segment's length from linker script content."""
|
||||
match = _segment_line_re(segment_name).search(content)
|
||||
return int(match.group(2), 16) if match else None
|
||||
|
||||
|
||||
def surgery_fingerprint() -> str:
|
||||
"""Hash of this module's source; linker-script caches include it so an
|
||||
edit here invalidates them."""
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
source = inspect.getsource(sys.modules[__name__])
|
||||
return hashlib.sha256(source.encode()).hexdigest()
|
||||
@@ -15,9 +15,6 @@ CONF_ENABLE_SERIAL1 = "enable_serial1"
|
||||
KEY_WAVEFORM_REQUIRED = "waveform_required"
|
||||
KEY_SERIAL_REQUIRED = "serial_required"
|
||||
KEY_SERIAL1_REQUIRED = "serial1_required"
|
||||
# Set for the native (non-PlatformIO) toolchain's build generator
|
||||
KEY_FLASH_MODE = "flash_mode"
|
||||
KEY_SCANF_FLOAT = "scanf_float"
|
||||
|
||||
# esp8266 namespace is already defined by arduino, manually prefix esphome
|
||||
esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266")
|
||||
@@ -71,6 +68,3 @@ def enable_serial1() -> None:
|
||||
enable_serial1()
|
||||
"""
|
||||
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
|
||||
|
||||
|
||||
KEY_LDSCRIPT = "ldscript"
|
||||
|
||||
@@ -37,7 +37,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address,
|
||||
}
|
||||
),
|
||||
cv.require_platformio_toolchain("host"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ FRAMEWORK_SCHEMA = cv.All(
|
||||
_check_debug_order,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(_notify_old_style, cv.require_platformio_toolchain("LibreTiny"))
|
||||
CONFIG_SCHEMA = cv.All(_notify_old_style)
|
||||
|
||||
BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
@@ -314,7 +314,6 @@ BASE_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
BASE_SCHEMA.add_extra(_detect_variant)
|
||||
BASE_SCHEMA.add_extra(cv.require_platformio_toolchain("LibreTiny"))
|
||||
BASE_SCHEMA.add_extra(_update_core_data)
|
||||
|
||||
|
||||
|
||||
@@ -125,8 +125,10 @@ def set_core_data(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF)
|
||||
_resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF)
|
||||
def _resolve_toolchain(config: ConfigType) -> ConfigType:
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF)
|
||||
return config
|
||||
|
||||
|
||||
def set_framework(config: ConfigType) -> ConfigType:
|
||||
@@ -168,7 +170,10 @@ BOOTLOADERS = [
|
||||
]
|
||||
|
||||
|
||||
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
||||
def _validate_toolchain(value) -> Toolchain:
|
||||
return Toolchain(
|
||||
cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value)
|
||||
)
|
||||
|
||||
|
||||
def _detect_bootloader(config: ConfigType) -> ConfigType:
|
||||
|
||||
@@ -7,7 +7,8 @@ import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from esphome.build_helpers.tools_cache import SDK_NRF_TOOLS_CACHE, tools_cache_path
|
||||
import platformdirs
|
||||
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.core import CORE, EsphomeError
|
||||
@@ -20,6 +21,7 @@ from esphome.framework_helpers import (
|
||||
run_command_ok,
|
||||
str_to_lst_of_str,
|
||||
)
|
||||
from esphome.helpers import get_str_env
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,9 +51,15 @@ SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str(
|
||||
|
||||
|
||||
def get_sdk_nrf_tools_path() -> Path:
|
||||
# Machine-global (OS user cache dir) so all projects share one install;
|
||||
# see espidf.framework.get_idf_tools_path for the location rationale.
|
||||
return tools_cache_path(*SDK_NRF_TOOLS_CACHE)
|
||||
# A blank ESPHOME_SDK_NRF_PREFIX must be treated as unset: Path("")
|
||||
# resolves to the CWD, which clean-all would then delete.
|
||||
if prefix := get_str_env("ESPHOME_SDK_NRF_PREFIX", "").strip():
|
||||
path = Path(prefix).expanduser()
|
||||
else:
|
||||
# Machine-global (OS user cache dir) so all projects share one install;
|
||||
# see espidf.framework.get_idf_tools_path for the location rationale.
|
||||
path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def _needs_venv_rebuild(
|
||||
|
||||
@@ -312,7 +312,6 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT),
|
||||
_detect_variant,
|
||||
cv.require_platformio_toolchain("RP2"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ from esphome.const import (
|
||||
CONF_SETUP_PRIORITY,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_SUBSCRIBE_QOS,
|
||||
CONF_TOOLCHAIN,
|
||||
CONF_TOPIC,
|
||||
CONF_TYPE,
|
||||
CONF_TYPE_ID,
|
||||
@@ -76,7 +75,6 @@ from esphome.const import (
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
Framework,
|
||||
Toolchain,
|
||||
__version__ as ESPHOME_VERSION,
|
||||
)
|
||||
from esphome.core import (
|
||||
@@ -93,13 +91,7 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
|
||||
from esphome.helpers import (
|
||||
FALSY_BOOL_STRINGS,
|
||||
TRUTHY_BOOL_STRINGS,
|
||||
add_class_to_obj,
|
||||
docs_url,
|
||||
list_starts_with,
|
||||
)
|
||||
from esphome.helpers import add_class_to_obj, docs_url, list_starts_with
|
||||
from esphome.schema_extractors import (
|
||||
SCHEMA_EXTRACT,
|
||||
schema_extractor,
|
||||
@@ -114,9 +106,6 @@ from esphome.util import parse_esphome_version # noqa: F401
|
||||
from esphome.voluptuous_schema import _Schema
|
||||
from esphome.yaml_util import SensitiveStr, make_data_base
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@@ -587,9 +576,9 @@ def boolean(value):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
value = value.lower()
|
||||
if value in TRUTHY_BOOL_STRINGS:
|
||||
if value in ("true", "yes", "on", "enable"):
|
||||
return True
|
||||
if value in FALSY_BOOL_STRINGS:
|
||||
if value in ("false", "no", "off", "disable"):
|
||||
return False
|
||||
raise Invalid(
|
||||
f"Expected boolean value, but cannot convert {value} to a boolean. Please use 'true' or 'false'"
|
||||
@@ -2543,63 +2532,6 @@ def platformio_version_constraint(value):
|
||||
return constraints
|
||||
|
||||
|
||||
def _check_supported_toolchain(
|
||||
platform_name: str, supported: tuple[Toolchain, ...]
|
||||
) -> None:
|
||||
"""Raise when the resolved ``CORE.toolchain`` is not in ``supported``
|
||||
(one message shape for every platform)."""
|
||||
toolchain = CORE.toolchain
|
||||
if toolchain is None:
|
||||
# A caller ran the check before resolving; an ordering bug, not a
|
||||
# user error
|
||||
raise Invalid(f"Toolchain was not resolved before {platform_name} validation")
|
||||
if toolchain not in supported:
|
||||
names = ", ".join(f"'{tc.value}'" for tc in supported)
|
||||
raise Invalid(
|
||||
f"Unsupported toolchain "
|
||||
f"'{toolchain.value}' for "
|
||||
f"{platform_name}. Supported: {names}."
|
||||
)
|
||||
|
||||
|
||||
def toolchain_enum(supported: tuple[Toolchain, ...]) -> Callable[[str], Toolchain]:
|
||||
"""Schema validator for a platform's ``toolchain`` config key."""
|
||||
|
||||
def validator(value: str) -> Toolchain:
|
||||
return Toolchain(one_of(*supported, lower=True)(value))
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def resolve_toolchain(
|
||||
platform_name: str, supported: tuple[Toolchain, ...], default: Toolchain
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Resolve ``CORE.toolchain`` (CLI > YAML > default) and reject one the
|
||||
platform cannot serve.
|
||||
|
||||
Add to the platform's validation chain before anything that reads
|
||||
``CORE.toolchain``.
|
||||
"""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = config.get(CONF_TOOLCHAIN, default)
|
||||
_check_supported_toolchain(platform_name, supported)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def require_platformio_toolchain(
|
||||
platform_name: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Reject a CLI-selected toolchain other than PlatformIO, for platforms
|
||||
with only the PlatformIO backend."""
|
||||
return resolve_toolchain(
|
||||
platform_name, (Toolchain.PLATFORMIO,), Toolchain.PLATFORMIO
|
||||
)
|
||||
|
||||
|
||||
def require_framework_version(
|
||||
*,
|
||||
max_version=False,
|
||||
|
||||
@@ -21,14 +21,6 @@ class Toolchain(StrEnum):
|
||||
PLATFORMIO = "platformio"
|
||||
ESP_IDF = "esp-idf"
|
||||
SDK_NRF = "sdk-nrf"
|
||||
# ESP8266: the Arduino core built directly (no PlatformIO)
|
||||
ARDUINO = "arduino"
|
||||
|
||||
|
||||
# Toolchains that drive their build natively and never read platformio.ini.
|
||||
# SDK_NRF is absent on purpose: the zephyr backend keeps consuming
|
||||
# platformio_options.
|
||||
NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO})
|
||||
|
||||
|
||||
class Platform(StrEnum):
|
||||
|
||||
@@ -21,7 +21,6 @@ from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
NATIVE_TOOLCHAINS,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
@@ -983,19 +982,6 @@ class EsphomeCore:
|
||||
def using_toolchain_sdk_nrf(self):
|
||||
return self.toolchain == Toolchain.SDK_NRF
|
||||
|
||||
@property
|
||||
def using_toolchain_arduino(self):
|
||||
"""The native ESP8266 Arduino build toolchain (unlike
|
||||
``using_arduino``, which is the target framework)."""
|
||||
return self.toolchain == Toolchain.ARDUINO
|
||||
|
||||
@property
|
||||
def using_native_toolchain(self):
|
||||
"""Whether the selected toolchain builds natively, without reading
|
||||
``platformio.ini`` (see ``NATIVE_TOOLCHAINS`` in ``esphome.const``;
|
||||
keep its membership in sync with ``write_cpp_file``'s dispatch)."""
|
||||
return self.toolchain in NATIVE_TOOLCHAINS
|
||||
|
||||
@property
|
||||
def using_zephyr(self):
|
||||
return self.target_framework == "zephyr"
|
||||
@@ -1109,8 +1095,6 @@ class EsphomeCore:
|
||||
return build_flag
|
||||
|
||||
def add_build_unflag(self, build_unflag: str) -> None:
|
||||
# No warning for using_toolchain_arduino: the native ESP8266 build
|
||||
# honors build_unflags (token-level, matching PlatformIO).
|
||||
if self.using_toolchain_esp_idf:
|
||||
# The native ESP-IDF build generator does not consume build_unflags
|
||||
_LOGGER.warning(
|
||||
|
||||
+10
-40
@@ -555,24 +555,12 @@ def _add_library_str(lib: str) -> None:
|
||||
cg.add_library(lib, None)
|
||||
|
||||
|
||||
# platformio_options keys the native ESP8266 Arduino generator (a later PR
|
||||
# in this chain) will honor; its ignored-option warning will consume the same
|
||||
# list so the two cannot drift
|
||||
NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscript"})
|
||||
# The full set that survives into CORE.platformio_options under the native
|
||||
# arduino toolchain: lib_ignore is the only specially-translated key below
|
||||
# that is stored rather than translated away. Consumed by the esp8266 native
|
||||
# backend (later in this chain) for its ignored-option warning; defined here
|
||||
# so it stays adjacent to the routing.
|
||||
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"}
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None:
|
||||
if CORE.using_native_toolchain:
|
||||
# The native builds don't read platformio.ini; honor the options
|
||||
# with a native equivalent and warn about the rest, which would
|
||||
# otherwise be silently ignored.
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# The native ESP-IDF build doesn't read platformio.ini; honor the
|
||||
# options with a native equivalent and warn about the rest, which
|
||||
# would otherwise be silently ignored.
|
||||
for key, val in pio_options.items():
|
||||
vals = [val] if isinstance(val, str) else val
|
||||
if key == CONF_BUILD_FLAGS:
|
||||
@@ -585,41 +573,23 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No
|
||||
)
|
||||
for flag in vals:
|
||||
cg.add_build_flag(flag)
|
||||
elif key == "build_unflags":
|
||||
# Native equivalent: add_build_unflag (honored token-level by
|
||||
# the arduino generator; the IDF generator warns there)
|
||||
for flag in vals:
|
||||
CORE.add_build_unflag(flag)
|
||||
elif key == "lib_deps":
|
||||
# Routed through the regular library mechanism so the
|
||||
# libraries reach the native backend's converter (IDF
|
||||
# components, or the ESP8266 native library resolution)
|
||||
# Routed through the regular library mechanism so the libraries
|
||||
# are converted to IDF components like any other PIO library
|
||||
for lib in vals:
|
||||
_add_library_str(lib)
|
||||
elif key == "lib_ignore":
|
||||
# Read by the shared library conversion (lib_ignore_set in
|
||||
# platformio/library.py); filters top-level libraries and
|
||||
# discovered dependencies
|
||||
# Read by the PIO-library-to-IDF-component conversion
|
||||
# (generate_idf_components); filters both top-level libraries
|
||||
# and dependencies discovered during conversion
|
||||
cg.add_platformio_option(key, vals)
|
||||
elif (
|
||||
key in NATIVE_ARDUINO_PIO_OPTIONS
|
||||
and CORE.using_toolchain_arduino
|
||||
and vals
|
||||
):
|
||||
# The esp8266 native generator reads these as scalars; the
|
||||
# schema also permits the list form, where the last value
|
||||
# wins like a later platformio.ini line (an empty list falls
|
||||
# through to the ignored-option warning). Other native
|
||||
# toolchains have no equivalent and fall through too.
|
||||
cg.add_platformio_option(key, vals[-1])
|
||||
elif key != "upload_speed":
|
||||
# upload_speed needs no handling: it is read from the raw
|
||||
# config at upload time (upload_using_esptool)
|
||||
_LOGGER.warning(
|
||||
"esphome->platformio_options->%s is ignored when building with "
|
||||
"the native '%s' toolchain",
|
||||
"the native ESP-IDF toolchain",
|
||||
key,
|
||||
CORE.toolchain.value,
|
||||
)
|
||||
return
|
||||
# Add includes at the very end, so that they override everything
|
||||
|
||||
@@ -23,12 +23,6 @@ from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.build_helpers.idedata import (
|
||||
get_toolchain_includes,
|
||||
parse_entry,
|
||||
reject_launcher_compiler,
|
||||
)
|
||||
|
||||
TIDY_PROJECT_NAME = "esphome_tidy"
|
||||
|
||||
# A do-nothing C++ app: just enough for IDF to configure a valid project. It's
|
||||
@@ -421,12 +415,13 @@ def _idedata_from_tidy_project(compile_commands: Path) -> dict:
|
||||
"""
|
||||
import json
|
||||
|
||||
from esphome.espidf.idedata import _get_toolchain_includes, _parse_entry
|
||||
|
||||
entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
|
||||
entry = next((e for e in entries if e["file"].endswith("tidy.cpp")), None)
|
||||
if entry is None:
|
||||
raise RuntimeError(f"tidy.cpp not found in {compile_commands}")
|
||||
cxx_path, defines, includes, cxx_flags = parse_entry(entry)
|
||||
reject_launcher_compiler(cxx_path)
|
||||
cxx_path, defines, includes, cxx_flags = _parse_entry(entry)
|
||||
|
||||
return {
|
||||
"cxx_path": cxx_path,
|
||||
@@ -434,7 +429,7 @@ def _idedata_from_tidy_project(compile_commands: Path) -> dict:
|
||||
"defines": defines,
|
||||
"includes": {
|
||||
"build": includes,
|
||||
"toolchain": get_toolchain_includes(cxx_path),
|
||||
"toolchain": _get_toolchain_includes(cxx_path),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+55
-25
@@ -20,7 +20,6 @@ from esphome.platformio.library import (
|
||||
DEFAULT_BUILD_SRC_FILTER,
|
||||
ESPHOME_DATA_EXTRA_CMAKE_KEY,
|
||||
ESPHOME_DATA_KEY,
|
||||
ESPHOME_DATA_LINK_FLAGS_KEY,
|
||||
SRC_FILE_EXTENSIONS,
|
||||
ConvertedLibrary as IDFComponent,
|
||||
LibraryBackend,
|
||||
@@ -28,7 +27,6 @@ from esphome.platformio.library import (
|
||||
collect_filtered_files,
|
||||
convert_libraries,
|
||||
ensure_list,
|
||||
lex_build_flags,
|
||||
split_list_by_condition,
|
||||
)
|
||||
|
||||
@@ -42,6 +40,37 @@ def _idf_framework() -> str:
|
||||
return "arduino" if CORE.using_arduino else "espidf"
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
def generate_cmakelists_txt(component: IDFComponent) -> str:
|
||||
"""
|
||||
Generate a CMakeLists.txt file for an ESP-IDF component.
|
||||
@@ -56,6 +85,10 @@ 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
|
||||
@@ -89,12 +122,26 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
|
||||
build_src_filter = ensure_list(
|
||||
component.data.get("build", {}).get("srcFilter", DEFAULT_BUILD_SRC_FILTER)
|
||||
)
|
||||
# PlatformIO shell-lexes each build.flags entry; bare -I/-L/-l/-D tokens
|
||||
# re-glue to their argument so the prefix classifiers below route them.
|
||||
build_flags = lex_build_flags(
|
||||
component.data.get("build", {}).get("flags", DEFAULT_BUILD_FLAGS),
|
||||
f"library {component.name}",
|
||||
build_flags = ensure_list(
|
||||
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
|
||||
|
||||
# List all sources files
|
||||
build_src_files = collect_filtered_files(
|
||||
@@ -206,16 +253,6 @@ def generate_cmakelists_txt(component: IDFComponent) -> str:
|
||||
content += f" {str_build_flag}\n"
|
||||
content += ")\n"
|
||||
|
||||
# Extra-script LINKFLAGS: routed to the link line; in
|
||||
# target_compile_options they would be silently ineffective
|
||||
if link_flags := component.data.get(ESPHOME_DATA_KEY, {}).get(
|
||||
ESPHOME_DATA_LINK_FLAGS_KEY, []
|
||||
):
|
||||
content += "target_link_options(${COMPONENT_LIB} INTERFACE\n"
|
||||
for link_flag in link_flags:
|
||||
content += f" {escape_entry(link_flag)}\n"
|
||||
content += ")\n"
|
||||
|
||||
# Add custom CMake scripts
|
||||
content += "\n".join(
|
||||
component.data.get(ESPHOME_DATA_KEY, {}).get(ESPHOME_DATA_EXTRA_CMAKE_KEY, [])
|
||||
@@ -262,14 +299,7 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
|
||||
|
||||
def _emit_idf_component(component: IDFComponent) -> None:
|
||||
"""Write the ESP-IDF build files for a resolved library into its cache dir."""
|
||||
from esphome.components.esp32 import get_esp32_variant
|
||||
from esphome.platformio.extra_script import apply_extra_script
|
||||
|
||||
apply_extra_script(
|
||||
component,
|
||||
board_mcu=lambda: variant_to_idf_target(get_esp32_variant()),
|
||||
pio_platform="espressif32",
|
||||
)
|
||||
_apply_extra_script(component)
|
||||
write_file_if_changed(
|
||||
component.path / "CMakeLists.txt",
|
||||
generate_cmakelists_txt(component),
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Run a PlatformIO ``extraScript`` against a captured SCons-env stand-in.
|
||||
|
||||
PlatformIO libraries occasionally configure per-target link/build state
|
||||
via a Python ``extraScript`` declared in ``library.json``'s ``build``
|
||||
section instead of static fields. The script runs under SCons during
|
||||
PIO's build and mutates the active ``Environment`` (``env.Append``,
|
||||
``env.Replace``, …) — chiefly to set ``LIBPATH``/``LIBS`` per chip MCU.
|
||||
|
||||
ESPHome's PIO→IDF converter doesn't run SCons, so these scripts were
|
||||
previously ignored and any library
|
||||
relying on them failed to link under ``toolchain: esp-idf``. This
|
||||
module provides a small shim that ``exec``s an extra-script with a
|
||||
fake ``env`` object, captures the common ``env.Append(...)`` calls,
|
||||
and returns the captured vars so the caller can fold them back into
|
||||
the library's generated CMakeLists.
|
||||
|
||||
Caveats
|
||||
-------
|
||||
* Only the ``env.Append`` API is captured. ``env.Replace``,
|
||||
``env.Prepend``, ``env.AddPreAction``, SCons file generators, and any
|
||||
arbitrary I/O are silently no-ops. Scripts that depend on those will
|
||||
produce incomplete output.
|
||||
* Running arbitrary Python from third-party libraries is a non-trivial
|
||||
trust decision. The shim does no sandboxing — anything in the
|
||||
script's process can run. Use only with libraries whose source you
|
||||
trust.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# 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"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtraScriptResult:
|
||||
"""Build-var deltas captured from a PIO extra-script ``env.Append`` call."""
|
||||
|
||||
libpath: list[str] = field(default_factory=list)
|
||||
libs: list[str] = field(default_factory=list)
|
||||
cppdefines: list[str | tuple[str, str]] = field(default_factory=list)
|
||||
linkflags: list[str] = field(default_factory=list)
|
||||
cppflags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class _FakeSConsEnv:
|
||||
"""Minimal stand-in for SCons ``Environment`` exposed to extra-scripts.
|
||||
|
||||
Implements just enough surface area to let scripts query ``BOARD_MCU``
|
||||
/ ``PIOENV`` and call ``env.Append(LIBPATH=…, LIBS=…, …)``. Every
|
||||
other env method swallows silently so unrelated calls don't raise
|
||||
``AttributeError`` and abort the script.
|
||||
"""
|
||||
|
||||
def __init__(self, *, board_mcu: str, pio_env: str) -> None:
|
||||
self._vars: dict[str, str] = {
|
||||
"BOARD_MCU": board_mcu,
|
||||
"PIOPLATFORM": "espressif32",
|
||||
"PIOENV": pio_env,
|
||||
}
|
||||
self.result = ExtraScriptResult()
|
||||
|
||||
# ----- SCons env API the common scripts use -----
|
||||
|
||||
def get(self, key: str, default: str | None = None) -> str | None:
|
||||
return self._vars.get(key, default)
|
||||
|
||||
def Append(self, **kwargs) -> None: # noqa: N802 (SCons API name)
|
||||
for key, value in kwargs.items():
|
||||
if key not in _CAPTURED_KEYS:
|
||||
continue
|
||||
items = list(value) if isinstance(value, (list, tuple)) else [value]
|
||||
bucket = getattr(self.result, key.lower())
|
||||
bucket.extend(items)
|
||||
|
||||
# ----- Everything else is a no-op so unsupported scripts don't crash -----
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
def _noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
return _noop
|
||||
|
||||
|
||||
def run_extra_script(
|
||||
script_path: Path, *, library_dir: Path, idf_target: str
|
||||
) -> ExtraScriptResult:
|
||||
"""Execute ``script_path`` with a fake SCons env and return captured vars.
|
||||
|
||||
``idf_target`` is the active ESP-IDF target name (e.g. ``esp32``,
|
||||
``esp32s3``); it's exposed to the script as PlatformIO's
|
||||
``BOARD_MCU`` so chip-conditional logic resolves the same way it
|
||||
would under PIO. The script runs with ``library_dir`` as the
|
||||
process CWD so relative-path lookups (``join``, ``realpath``,
|
||||
``open``) resolve against the library tree.
|
||||
|
||||
On any exception inside the script we log at debug level and return
|
||||
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}")
|
||||
code = compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec")
|
||||
old_cwd = Path.cwd()
|
||||
try:
|
||||
os.chdir(library_dir)
|
||||
exec( # noqa: S102 pylint: disable=exec-used
|
||||
code,
|
||||
{
|
||||
"Import": lambda *_args: None, # SCons-side import; harmless here
|
||||
"env": env,
|
||||
"__file__": str(script_path),
|
||||
"__name__": "__pio_extra_script__",
|
||||
},
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
_LOGGER.warning("PIO extra-script %s raised %s; skipping", script_path, e)
|
||||
return ExtraScriptResult()
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
return env.result
|
||||
|
||||
|
||||
def captured_as_build_flags(
|
||||
result: ExtraScriptResult, *, library_dir: Path
|
||||
) -> list[str]:
|
||||
"""Translate captured env vars into the ``-L`` / ``-l`` / ``-D`` /
|
||||
raw-flag form ``_generate_cmakelists_txt`` already knows how to consume.
|
||||
|
||||
``LIBPATH`` entries are made relative to ``library_dir`` so the
|
||||
generated CMakeLists is portable; absolute paths outside the library
|
||||
tree are kept as-is (CMake handles absolute paths in
|
||||
``target_link_directories`` fine).
|
||||
"""
|
||||
flags: list[str] = []
|
||||
library_root = library_dir.resolve()
|
||||
for path in result.libpath:
|
||||
# Anchor relative paths to library_dir (not the current CWD, which
|
||||
# has been restored by the time we get here). Joining an absolute
|
||||
# path against library_dir returns the absolute path unchanged.
|
||||
resolved = (library_dir / path).resolve()
|
||||
try:
|
||||
flags.append(f"-L{resolved.relative_to(library_root)}")
|
||||
except ValueError:
|
||||
flags.append(f"-L{resolved}")
|
||||
flags.extend(f"-l{lib}" for lib in result.libs)
|
||||
for define in result.cppdefines:
|
||||
if isinstance(define, tuple) and len(define) == 2:
|
||||
flags.append(f"-D{define[0]}={define[1]}")
|
||||
else:
|
||||
flags.append(f"-D{define}")
|
||||
flags.extend(result.linkflags)
|
||||
flags.extend(result.cppflags)
|
||||
return flags
|
||||
+71
-106
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from ctypes.util import find_library
|
||||
from functools import partial
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -12,29 +11,23 @@ import re
|
||||
import shutil
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from esphome.build_helpers.ccache import (
|
||||
ccache_defaults_env,
|
||||
parse_enable_env,
|
||||
resolve_ccache_path,
|
||||
)
|
||||
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import Version
|
||||
import platformdirs
|
||||
|
||||
from esphome.core import CORE, Version
|
||||
from esphome.framework_helpers import (
|
||||
PathType,
|
||||
archive_extract_all,
|
||||
create_venv,
|
||||
download_from_mirrors,
|
||||
download_with_resume,
|
||||
failure_reason,
|
||||
get_python_env_executable_path,
|
||||
get_system_python_path,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
run_command,
|
||||
run_command_ok,
|
||||
str_to_lst_of_str,
|
||||
)
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.helpers import get_bool_env, get_str_env, write_file_if_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -95,10 +88,22 @@ def get_idf_tools_path() -> Path:
|
||||
Returns:
|
||||
Path object pointing to the ESP-IDF tools directory
|
||||
"""
|
||||
# Machine-global so all projects share the multi-GB install instead of
|
||||
# a per-config-directory copy; see build_helpers.tools_cache.tools_cache_path
|
||||
# for the env-override and normalization rules.
|
||||
return tools_cache_path(*IDF_TOOLS_CACHE)
|
||||
# Treat an empty/whitespace ESPHOME_ESP_IDF_PREFIX as unset: Path("")
|
||||
# resolves to the CWD, which would install into (and let clean-all delete)
|
||||
# the working directory by accident.
|
||||
if prefix := get_str_env("ESPHOME_ESP_IDF_PREFIX", "").strip():
|
||||
path = Path(prefix).expanduser()
|
||||
else:
|
||||
# Machine-global so all projects share the multi-GB install instead of
|
||||
# a per-config-directory copy. The user cache dir (not ~/.esphome)
|
||||
# avoids colliding with data_dir when configs live in the home dir.
|
||||
# appauthor=False drops the redundant <author>\ segment on Windows
|
||||
# (which otherwise repeats "esphome\esphome\") to keep the path short.
|
||||
path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
|
||||
# Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``)
|
||||
# doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which
|
||||
# otherwise warns that the venv interpreter path doesn't match the install.
|
||||
return path.resolve()
|
||||
|
||||
|
||||
# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply
|
||||
@@ -685,18 +690,6 @@ def _patch_tools_json_demote_unused_tools(framework_path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _download_tool(
|
||||
dist_path: Path, entry: dict, tracker: Callable[[int], None]
|
||||
) -> None:
|
||||
download_with_resume(
|
||||
entry["url"],
|
||||
dist_path / entry["dest"],
|
||||
sha256=entry["sha256"],
|
||||
size=entry["size"],
|
||||
progress=tracker,
|
||||
)
|
||||
|
||||
|
||||
def _prefetch_idf_tool_archives(
|
||||
framework_path: Path,
|
||||
targets_str: str,
|
||||
@@ -709,10 +702,10 @@ def _prefetch_idf_tool_archives(
|
||||
which makes large archives effectively impossible to fetch on unstable
|
||||
connections (#17703). This asks the framework's idf_tools (via
|
||||
``get_tool_downloads.py``) which archives the coming install needs, then
|
||||
downloads them into ``<IDF_TOOLS_PATH>/dist`` with
|
||||
``download_with_resume``, a few at a time under one combined progress
|
||||
bar. The installer then finds the verified archives already in place
|
||||
("file ... is already downloaded") and never touches the network.
|
||||
downloads each into ``<IDF_TOOLS_PATH>/dist`` with
|
||||
``download_with_resume``. The installer then finds the verified archives
|
||||
already in place ("file ... is already downloaded") and never touches the
|
||||
network.
|
||||
|
||||
Strictly best-effort: any failure here just logs and returns, leaving
|
||||
``idf_tools.py install`` to download whatever is missing exactly as
|
||||
@@ -734,60 +727,30 @@ def _prefetch_idf_tool_archives(
|
||||
)
|
||||
return
|
||||
dist_path = get_idf_tools_path() / "dist"
|
||||
entries = []
|
||||
seen_dests: set[str] = set()
|
||||
for entry in json.loads(stdout):
|
||||
if (dist_path / entry["dest"]).is_file():
|
||||
continue
|
||||
# Never download unverified: an entry without sha256/size is
|
||||
# left to the installer, which fails loudly on a bad archive.
|
||||
# Checked before the dedupe so it cannot shadow a verifiable
|
||||
# duplicate of the same dest.
|
||||
if not (entry.get("sha256") and entry.get("size")):
|
||||
_LOGGER.warning(
|
||||
"Tool %s has no sha256/size in the download list; "
|
||||
"leaving it to the installer",
|
||||
entry["name"],
|
||||
entries = [
|
||||
entry
|
||||
for entry in json.loads(stdout)
|
||||
if not (dist_path / entry["dest"]).is_file()
|
||||
]
|
||||
for index, entry in enumerate(entries, start=1):
|
||||
_LOGGER.info(
|
||||
"Downloading %s (%d/%d) ...", entry["name"], index, len(entries)
|
||||
)
|
||||
try:
|
||||
download_with_resume(
|
||||
entry["url"],
|
||||
dist_path / entry["dest"],
|
||||
sha256=entry["sha256"],
|
||||
size=entry["size"],
|
||||
)
|
||||
continue
|
||||
if entry["dest"] in seen_dests:
|
||||
# Two workers on one .part file would interleave
|
||||
# seek/truncate writes; mirror the library prefetch's dedupe
|
||||
continue
|
||||
seen_dests.add(entry["dest"])
|
||||
entries.append(entry)
|
||||
if not entries:
|
||||
return
|
||||
_LOGGER.info(
|
||||
"Downloading %d ESP-IDF tool archive(s): %s",
|
||||
len(entries),
|
||||
", ".join(entry["name"] for entry in entries),
|
||||
)
|
||||
|
||||
# No sequential fallback here: skipping the prefetch would lose the
|
||||
# resume workaround for #17703, and every entry has a size (above).
|
||||
# A failed archive is retried by the installer itself (without
|
||||
# resume); keep prefetching the rest.
|
||||
failures = run_batch_downloads(
|
||||
"Downloading ESP-IDF tools",
|
||||
[
|
||||
(
|
||||
entry["name"],
|
||||
entry["size"],
|
||||
partial(_download_tool, dist_path, entry),
|
||||
)
|
||||
for entry in entries
|
||||
],
|
||||
)
|
||||
for name, e in failures:
|
||||
# failure_reason: a message-less exception must not log blank
|
||||
_LOGGER.warning("Could not prefetch %s: %s", name, failure_reason(e))
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=e)
|
||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# Keep prefetching the remaining archives; the installer
|
||||
# will retry this one itself (without resume).
|
||||
_LOGGER.warning("Could not prefetch %s: %s", entry["name"], e)
|
||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# The installer downloads anything missing itself; never let the
|
||||
# prefetch become a new way for the install to fail.
|
||||
_LOGGER.warning("ESP-IDF tool prefetch failed: %s", e)
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=True)
|
||||
|
||||
|
||||
def _check_esphome_idf_framework_install(
|
||||
@@ -1182,10 +1145,8 @@ def check_esp_idf_install(
|
||||
def _ccache_env() -> dict[str, str]:
|
||||
"""Return ccache settings for ESP-IDF compiles.
|
||||
|
||||
Enabled by default whenever a runnable ``ccache`` binary is on PATH.
|
||||
``IDF_CCACHE_ENABLE=0`` opts out and ``=1`` forces it on; when that knob
|
||||
is unset the shared ``ESPHOME_CCACHE_ENABLE`` applies (same 0/1 forms,
|
||||
unrecognized values warn and count as unset). The cache lives under
|
||||
Enabled by default whenever the ``ccache`` binary is on PATH; set
|
||||
``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under
|
||||
the IDF tools path (the machine-global cache dir, or
|
||||
``ESPHOME_ESP_IDF_PREFIX``), so it is shared across all projects and removed
|
||||
by ``esphome clean-all`` along with the framework.
|
||||
@@ -1200,29 +1161,33 @@ def _ccache_env() -> dict[str, str]:
|
||||
Only values the user has not already set in the environment are returned, so
|
||||
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
|
||||
"""
|
||||
# IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
||||
# ESPHOME_CCACHE_ENABLE.
|
||||
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
|
||||
if idf_knob is False:
|
||||
# The raw value (e.g. "disable") is still inherited by idf.py via
|
||||
# os.environ, where a non-false-constant string reads as truthy;
|
||||
# export the canonical off spelling instead
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
if idf_knob is True:
|
||||
# Forced on skips the runnability verdict, but still resolve for
|
||||
# the "no ccache binary on PATH" warning
|
||||
resolve_ccache_path()
|
||||
elif resolve_ccache_path() is None:
|
||||
# ESP-IDF silently skips ccache without the binary; export the
|
||||
# canonical off spelling so an unparsable inherited value (or a
|
||||
# probe-rejected ccache idf.py would still find) cannot enable it
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
# Honor an explicit choice already in the environment (opt-out or opt-in).
|
||||
if "IDF_CCACHE_ENABLE" in os.environ:
|
||||
if not get_bool_env("IDF_CCACHE_ENABLE"):
|
||||
return {}
|
||||
elif shutil.which("ccache") is None:
|
||||
# ESP-IDF silently skips ccache without the binary; don't enable it.
|
||||
return {}
|
||||
|
||||
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
||||
# Exactly one canonical spelling ever reaches idf.py, whatever the
|
||||
# accepted input spelling was ("enable", "yes", ...)
|
||||
env["IDF_CCACHE_ENABLE"] = "1"
|
||||
return env
|
||||
# ccache is enabled past here. build_path is set during preload for every
|
||||
# config-loading command, so it being unset means a caller built the IDF env
|
||||
# too early -- fail loudly rather than silently drop CCACHE_BASEDIR (which
|
||||
# would quietly cost cross-device cache hits).
|
||||
if CORE.build_path is None:
|
||||
raise ValueError(
|
||||
"CORE.build_path must be set before constructing the ESP-IDF build "
|
||||
"environment"
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"IDF_CCACHE_ENABLE": "1",
|
||||
"CCACHE_DIR": str(get_idf_tools_path() / "ccache"),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
# Don't override CCACHE_* values the user already set in their environment.
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
|
||||
|
||||
def get_framework_env(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Derive idedata from a native (non-PlatformIO) build's ``compile_commands.json``.
|
||||
"""Derive idedata from an ESP-IDF native-toolchain ``compile_commands.json``.
|
||||
|
||||
PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native
|
||||
toolchains have no such command, but each build produces a
|
||||
``compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS for ESP-IDF, ninja's
|
||||
compdb tool otherwise). This module turns that file into the same fields
|
||||
consumers (IDE integration, clang-tidy) expect:
|
||||
PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native ESP-IDF
|
||||
toolchain has no such command, but its CMake build emits
|
||||
``build/compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS). This module
|
||||
turns that file into the same fields consumers (IDE integration, clang-tidy)
|
||||
expect:
|
||||
|
||||
{cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}}
|
||||
"""
|
||||
@@ -18,19 +18,6 @@ from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.helpers import write_file
|
||||
|
||||
# Everything idedata generation may raise after a successful link; idedata
|
||||
# is a bonus artifact, so consumers warn instead of failing the build
|
||||
IDEDATA_BEST_EFFORT_ERRORS = (
|
||||
EsphomeError,
|
||||
LookupError,
|
||||
OSError,
|
||||
RuntimeError,
|
||||
ValueError,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# C++ translation-unit suffixes used to identify ESPHome source files.
|
||||
@@ -43,8 +30,12 @@ _ESPHOME_SRC_MARKER = "/src/esphome/"
|
||||
|
||||
|
||||
def _is_esphome_src(file: str) -> bool:
|
||||
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
|
||||
``/`` first since Windows compile DBs use backslashes."""
|
||||
"""Whether ``file`` is an ESPHome C++ translation unit.
|
||||
|
||||
``compile_commands.json`` ``file`` paths use the OS-native separator, so on
|
||||
Windows they contain backslashes; normalize to ``/`` before testing the
|
||||
marker, otherwise no source matches and the build-include union is empty.
|
||||
"""
|
||||
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
|
||||
_CXX_SUFFIXES
|
||||
)
|
||||
@@ -115,8 +106,11 @@ def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
||||
|
||||
|
||||
def _pick_entry(entries: list[dict]) -> dict:
|
||||
"""Pick a representative ESPHome C++ TU; all share the same component
|
||||
flags/defines."""
|
||||
"""Pick a representative ESPHome C++ translation unit.
|
||||
|
||||
All ESPHome sources share the same component flags/defines, so any one of
|
||||
them yields the cxx_path / cxx_flags / defines we need.
|
||||
"""
|
||||
for entry in entries:
|
||||
if _is_esphome_src(entry["file"]):
|
||||
return entry
|
||||
@@ -126,46 +120,25 @@ def _pick_entry(entries: list[dict]) -> dict:
|
||||
raise ValueError("no C++ translation unit found in compile_commands.json")
|
||||
|
||||
|
||||
# Compiler launchers that may prefix a compile command; a closed launcher
|
||||
# denylist beats enumerating compiler names, an open set.
|
||||
_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
|
||||
|
||||
|
||||
def _is_launcher(token: str) -> bool:
|
||||
return Path(token).stem.lower() in _LAUNCHER_STEMS
|
||||
|
||||
|
||||
def parse_entry(
|
||||
entry: dict, launcher: str | None = None
|
||||
) -> tuple[str, list[str], list[str], list[str]]:
|
||||
def _parse_entry(entry: dict) -> 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)
|
||||
|
||||
def _include(raw: str) -> str:
|
||||
# Resolve against the entry's ``directory`` so cached idedata works
|
||||
# from any cwd; emit forward slashes to match the JSON's own entries
|
||||
# Include paths in compile_commands are interpreted relative to the
|
||||
# entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them
|
||||
# so the cached idedata is usable regardless of the consumer's cwd.
|
||||
# Emit forward slashes (``normpath`` yields ``\`` on Windows) so the
|
||||
# paths match the absolute, already-forward-slash entries in the JSON.
|
||||
raw = raw.strip()
|
||||
if raw and not Path(raw).is_absolute():
|
||||
raw = os.path.normpath(directory / raw)
|
||||
return raw.replace("\\", "/")
|
||||
|
||||
# A launcher-wrapped command ("ccache g++ ...") names the compiler second
|
||||
if launcher is not None and tokens[:1] == [launcher]:
|
||||
tokens = tokens[1:]
|
||||
if not tokens:
|
||||
# An empty command, or one that was only the launcher; fail by name
|
||||
raise ValueError(f"empty compile command for {entry.get('file')}")
|
||||
if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
|
||||
# Stale DB built with a launcher this run no longer configures; the
|
||||
# real compiler is the next token
|
||||
_LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
|
||||
tokens = tokens[1:]
|
||||
# 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("\\", "/")
|
||||
# Enforced here so no caller can record ccache as the compiler
|
||||
reject_launcher_compiler(cxx_path)
|
||||
defines: list[str] = []
|
||||
includes: list[str] = []
|
||||
cxx_flags: list[str] = []
|
||||
@@ -195,7 +168,7 @@ def parse_entry(
|
||||
return cxx_path, defines, includes, cxx_flags
|
||||
|
||||
|
||||
def get_toolchain_includes(cxx_path: str) -> list[str]:
|
||||
def _get_toolchain_includes(cxx_path: str) -> list[str]:
|
||||
"""Query the compiler for its builtin ``#include <...>`` search dirs."""
|
||||
result = subprocess.run(
|
||||
[cxx_path, "-E", "-x", "c++", "-", "-v"],
|
||||
@@ -246,128 +219,26 @@ def _cc_path_from_cxx(cxx_path: str) -> str:
|
||||
return f"{stem}{suffix}"
|
||||
|
||||
|
||||
def _cache_usable(cached: object) -> bool:
|
||||
"""Check a cached idedata dict against the guarantees of the write path.
|
||||
|
||||
Caches written by older versions predate the launcher rejection and the
|
||||
include-union shape; serving one would bypass both. The dict check also
|
||||
keeps "in" from substring-matching a bare JSON string.
|
||||
"""
|
||||
if not isinstance(cached, dict) or "cc_path" not in cached:
|
||||
return False
|
||||
cxx_path = cached.get("cxx_path")
|
||||
if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
|
||||
return False
|
||||
includes = cached.get("includes")
|
||||
return isinstance(includes, dict) and isinstance(includes.get("build"), list)
|
||||
|
||||
|
||||
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, OSError) as err:
|
||||
# A recurring cause (interrupted write, disk full) would otherwise
|
||||
# look like unexplained slow builds
|
||||
_LOGGER.warning("Discarding unreadable idedata cache %s: %s", cache, err)
|
||||
else:
|
||||
if _cache_usable(cached):
|
||||
# Re-stamp so a relocated build dir cannot serve a stale ELF path
|
||||
cached["prog_path"] = str(elf_path)
|
||||
return cached
|
||||
_LOGGER.debug("Regenerating idedata: cache %s fails validation", cache)
|
||||
|
||||
data = idedata_from_build(compile_commands, launcher)
|
||||
data["prog_path"] = str(elf_path)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Atomic so a crash mid-write cannot leave a truncated cache
|
||||
write_file(cache, json.dumps(data, indent=2) + "\n")
|
||||
return data
|
||||
|
||||
|
||||
def reject_launcher_compiler(cxx_path: str) -> None:
|
||||
"""Reject a compile DB naming a launcher (ccache) as the compiler; it
|
||||
must never be probed, cached, or consumed."""
|
||||
if _is_launcher(cxx_path):
|
||||
raise EsphomeError(
|
||||
f"compile_commands.json names the launcher {cxx_path} as the "
|
||||
"compiler; the compile database is unusable"
|
||||
)
|
||||
|
||||
|
||||
def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> dict:
|
||||
def idedata_from_build(compile_commands: Path) -> dict:
|
||||
"""Parse compile_commands.json into the idedata fields consumers expect.
|
||||
|
||||
A single compile entry only carries the include set its own translation
|
||||
unit was built with (per-component under ESP-IDF), but consumers
|
||||
(clang-tidy) analyze ESPHome headers that transitively pull in other
|
||||
components. So take cxx_path / cxx_flags / defines from a representative
|
||||
ESPHome TU, but union the include dirs across all ESPHome TUs to get a
|
||||
project-wide superset (as PlatformIO's idedata provides).
|
||||
A single ESP-IDF compile entry only carries its own component's REQUIRES
|
||||
include set, but consumers (clang-tidy) analyze ESPHome headers that
|
||||
transitively pull in other components. So take cxx_path / cxx_flags /
|
||||
defines from a representative ESPHome TU, but union the include dirs across
|
||||
all ESPHome TUs to get a project-wide superset (as PlatformIO's idedata
|
||||
provides).
|
||||
"""
|
||||
entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
|
||||
if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries):
|
||||
# A TypeError here would escape IDEDATA_BEST_EFFORT_ERRORS
|
||||
raise EsphomeError(f"{compile_commands} is not a compile-command list")
|
||||
cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries))
|
||||
|
||||
representative = _pick_entry(entries)
|
||||
cxx_path, defines, rep_includes, cxx_flags = parse_entry(representative, launcher)
|
||||
|
||||
# Seed with the representative's includes so it is not parsed twice
|
||||
has_esphome_tu = _is_esphome_src(representative["file"])
|
||||
build_includes: dict[str, None] = dict.fromkeys(
|
||||
rep_includes if has_esphome_tu else ()
|
||||
)
|
||||
|
||||
def _shape(entry: dict) -> str:
|
||||
# directory + command minus TU-specific paths: same shape means the
|
||||
# same include set, so tokenize once per shape. Response-file
|
||||
# commands never dedupe (the .rsp contents differ per object)
|
||||
command = entry["command"]
|
||||
directory = entry.get("directory", "")
|
||||
if "@" in command:
|
||||
return f"unique:{directory}|{entry.get('output') or command}"
|
||||
stripped = command.replace(entry.get("file", ""), "").replace(
|
||||
entry.get("output", ""), ""
|
||||
)
|
||||
return f"{directory}|{stripped}"
|
||||
|
||||
seen_shapes = {_shape(representative)}
|
||||
build_includes: dict[str, None] = {}
|
||||
for entry in entries:
|
||||
if entry is representative or not _is_esphome_src(entry["file"]):
|
||||
if not _is_esphome_src(entry["file"]):
|
||||
continue
|
||||
has_esphome_tu = True
|
||||
if (shape := _shape(entry)) in seen_shapes:
|
||||
_LOGGER.debug("Include union: %s shares a command shape", entry["file"])
|
||||
continue
|
||||
seen_shapes.add(shape)
|
||||
for inc in parse_entry(entry, launcher)[2]:
|
||||
for inc in _parse_entry(entry)[2]:
|
||||
build_includes.setdefault(inc, None)
|
||||
|
||||
if not has_esphome_tu:
|
||||
# An arbitrary fallback TU breaks clang-tidy/IDE consumers, and a
|
||||
# warning would be cached into permanence; call sites downgrade this
|
||||
raise EsphomeError(
|
||||
f"No ESPHome translation unit found in {compile_commands}; "
|
||||
"refusing to cache unusable idedata"
|
||||
)
|
||||
|
||||
return {
|
||||
"cc_path": _cc_path_from_cxx(cxx_path),
|
||||
"cxx_path": cxx_path,
|
||||
@@ -375,6 +246,6 @@ def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> d
|
||||
"defines": defines,
|
||||
"includes": {
|
||||
"build": list(build_includes),
|
||||
"toolchain": get_toolchain_includes(cxx_path),
|
||||
"toolchain": _get_toolchain_includes(cxx_path),
|
||||
},
|
||||
}
|
||||
@@ -28,8 +28,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.build_helpers.size_summary import print_size_line
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_SIZE_SUFFIXES = {"K": 1024, "M": 1024 * 1024}
|
||||
|
||||
@@ -69,6 +67,18 @@ 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:
|
||||
"""Match PlatformIO's ``_format_availale_bytes`` (pioupload.py) exactly."""
|
||||
pct_raw = used / total if total else 0
|
||||
blocks = 10
|
||||
filled = min(int(round(blocks * pct_raw)), blocks)
|
||||
progress = "=" * filled
|
||||
return (
|
||||
f"[{progress:<{blocks}}] {pct_raw: 6.1%} "
|
||||
f"(used {used:d} bytes from {total:d} bytes)"
|
||||
)
|
||||
|
||||
|
||||
def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
|
||||
"""Print PlatformIO-shaped RAM and Flash one-liners.
|
||||
|
||||
@@ -89,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_size_line("RAM", 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:
|
||||
@@ -99,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_size_line("Flash", image_size, app_size)
|
||||
print(f"Flash: {_format_bar(image_size, app_size)}")
|
||||
|
||||
@@ -526,15 +526,32 @@ 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.build_helpers.idedata import load_or_build_idedata
|
||||
from esphome.espidf.idedata import idedata_from_build
|
||||
|
||||
# No launcher: CMake excludes CMAKE_<LANG>_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"),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def create_factory_bin() -> bool:
|
||||
|
||||
+19
-269
@@ -1,8 +1,7 @@
|
||||
"""Generic toolchain installation helpers shared across framework implementations."""
|
||||
|
||||
from collections.abc import Callable, Iterable, Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import ExitStack, contextmanager, suppress
|
||||
from collections.abc import Iterable
|
||||
from contextlib import ExitStack
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
@@ -11,7 +10,6 @@ import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import IO, TYPE_CHECKING
|
||||
|
||||
@@ -26,7 +24,6 @@ PathType = str | os.PathLike
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Attempts per mirror URL before falling through to the next mirror; only
|
||||
# mid-stream drops retry (resuming when the server gave a validator),
|
||||
# connect errors move on to the next mirror immediately.
|
||||
@@ -201,30 +198,6 @@ def run_command(
|
||||
return False, None, None
|
||||
|
||||
|
||||
def tool_version_runs(binary: str, warning: str) -> bool:
|
||||
"""Probe ``binary --version``; on failure warn with ``warning`` % binary.
|
||||
|
||||
``shutil.which`` proves existence, not runnability (Windows .bat/.cmd
|
||||
shims, stale package-manager shims).
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
[binary, "--version"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
# Repo-wide convention (posix_spawn fast path)
|
||||
close_fds=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
# The cause (permission denied, missing DLL, timeout) is the one
|
||||
# detail the user needs to fix it
|
||||
_LOGGER.warning("%s (%s)", warning % binary, err)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_command_ok(*args, **kwargs) -> bool:
|
||||
"""
|
||||
Execute a command and return only the success status.
|
||||
@@ -726,11 +699,7 @@ def _response_validator(resp: "requests.Response") -> str | None:
|
||||
|
||||
|
||||
def _stream_response_to_file(
|
||||
resp: "requests.Response",
|
||||
f: IO[bytes],
|
||||
offset: int,
|
||||
size: int | None = None,
|
||||
progress: Callable[[int], None] | None = None,
|
||||
resp: "requests.Response", f: IO[bytes], offset: int, size: int | None = None
|
||||
) -> None:
|
||||
"""Stream an open ``_open_ranged`` response body into ``f`` at ``offset``.
|
||||
|
||||
@@ -738,182 +707,21 @@ def _stream_response_to_file(
|
||||
(effective offset 0) discards the stale bytes. ``offset`` also seeds the
|
||||
progress bar so a resumed download shows overall progress. ``size`` is
|
||||
the known full file size; when None it is derived from the response's
|
||||
content-length, and without either there is no bar. With ``progress``
|
||||
set no bar is drawn here; the callback gets the absolute byte count.
|
||||
content-length, and without either there is no progress bar.
|
||||
"""
|
||||
f.seek(offset)
|
||||
f.truncate(offset)
|
||||
total_size = size or offset + _content_length(resp)
|
||||
downloaded = offset
|
||||
own_bar: ProgressBar | None = None
|
||||
if progress is None:
|
||||
own_bar = ProgressBar("Downloading") if total_size > 0 else None
|
||||
progress = (
|
||||
(lambda done: own_bar.update(done / total_size))
|
||||
if own_bar
|
||||
else (lambda _: None)
|
||||
)
|
||||
progress(downloaded)
|
||||
progress = ProgressBar("Downloading") if total_size > 0 else None
|
||||
for chunk in resp.iter_content(chunk_size=256 * 1024):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
progress(downloaded)
|
||||
if own_bar is not None:
|
||||
own_bar.update(1)
|
||||
|
||||
|
||||
# Concurrent downloads per batch; enough to hide latency without
|
||||
# hammering the host or the mirrors.
|
||||
BATCH_DOWNLOAD_WORKERS = 4
|
||||
|
||||
|
||||
def run_batch_downloads(
|
||||
header: str,
|
||||
jobs: list[tuple[str, int, Callable[[Callable[[int], None]], None]]],
|
||||
max_workers: int = BATCH_DOWNLOAD_WORKERS,
|
||||
) -> list[tuple[str, BaseException]]:
|
||||
"""Run ``(name, size, fetch)`` download jobs concurrently under one bar.
|
||||
|
||||
Each ``fetch(tracker)`` reports absolute byte counts; the bar total is
|
||||
the sum of the sizes. Failures are returned after the bar is done so
|
||||
warnings never land on its row. Ctrl-C drops queued jobs and aborts
|
||||
in-flight ones at their next progress tick or backoff boundary (a
|
||||
parked socket read defers that by its timeout, and an in-progress
|
||||
archive extraction runs to completion); resumable destinations
|
||||
(``download_with_resume``) keep their fetched ``.part`` bytes.
|
||||
``jobs`` must be non-empty.
|
||||
"""
|
||||
progress = _BatchDownloadProgress(header, sum(size for _, size, _ in jobs))
|
||||
cancelled = threading.Event()
|
||||
|
||||
def _run(
|
||||
name: str, fetch: Callable[[Callable[[int], None]], None]
|
||||
) -> tuple[str, BaseException] | None:
|
||||
tracker = progress.tracker()
|
||||
|
||||
def checked(done: int) -> None:
|
||||
if cancelled.is_set():
|
||||
raise _BatchDownloadCancelled
|
||||
tracker(done)
|
||||
|
||||
try:
|
||||
fetch(checked)
|
||||
except (_BatchDownloadCancelled, Exception) as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# A cancelled job reports like a failure: an abandoned download
|
||||
# must never read as completed if a caller sees the list after
|
||||
# Ctrl-C
|
||||
failure = (name, err)
|
||||
else:
|
||||
return None
|
||||
# A bar-frame write failure must not displace the download error
|
||||
with suppress(Exception):
|
||||
tracker(0)
|
||||
return failure
|
||||
|
||||
ex = ThreadPoolExecutor(max_workers=max_workers)
|
||||
try:
|
||||
with progress.logging_guard():
|
||||
futures = [ex.submit(_run, name, fetch) for name, _, fetch in jobs]
|
||||
return [failure for f in futures if (failure := f.result()) is not None]
|
||||
except BaseException:
|
||||
# Without this the non-daemon workers download to completion before
|
||||
# the interpreter can exit, making Ctrl-C ineffective for minutes
|
||||
cancelled.set()
|
||||
raise
|
||||
finally:
|
||||
ex.shutdown(wait=True, cancel_futures=True)
|
||||
progress.done()
|
||||
|
||||
|
||||
class _BatchDownloadCancelled(BaseException):
|
||||
"""Raised inside a download job to abandon it after Ctrl-C.
|
||||
|
||||
BaseException, like KeyboardInterrupt: a broad ``except Exception`` in
|
||||
the download layers must not convert an abort into a retry.
|
||||
"""
|
||||
|
||||
|
||||
class _BatchDownloadProgress:
|
||||
"""One bar across several concurrent downloads, summing tracker bytes.
|
||||
|
||||
The lock also serialises stderr writes so workers never interleave
|
||||
frames; a ``total`` of 0 draws nothing. Call ``done()`` at the end so a
|
||||
bar short of 100% still ends its line.
|
||||
"""
|
||||
|
||||
def __init__(self, header: str, total: int) -> None:
|
||||
self._bar = ProgressBar(header) if total > 0 else None
|
||||
self._total = total
|
||||
self._sum = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def tracker(self) -> Callable[[int], None]:
|
||||
if self._bar is None:
|
||||
return lambda _: None
|
||||
last = 0
|
||||
|
||||
def update(done: int) -> None:
|
||||
nonlocal last
|
||||
with self._lock:
|
||||
self._sum += done - last
|
||||
last = done
|
||||
self._bar.update(min(self._sum / self._total, 1))
|
||||
|
||||
return update
|
||||
|
||||
def done(self) -> None:
|
||||
if self._bar is not None:
|
||||
self._bar.done()
|
||||
|
||||
@contextmanager
|
||||
def logging_guard(self) -> Iterator[None]:
|
||||
r"""End a partial bar row before any log record while active.
|
||||
|
||||
Worker warnings (mirror retries) share stderr with the bar's \r
|
||||
frames; without this the record lands mid-row and the next frame
|
||||
overwrites it. A handler-level filter runs just before emit, so
|
||||
only a tiny window remains for a concurrent frame.
|
||||
"""
|
||||
the_bar = self._bar
|
||||
if the_bar is None:
|
||||
yield
|
||||
return
|
||||
lock = self._lock
|
||||
|
||||
class _EndRow(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
with lock:
|
||||
the_bar.interrupt()
|
||||
return True
|
||||
|
||||
end_row = _EndRow()
|
||||
handlers = logging.getLogger().handlers
|
||||
for handler in handlers:
|
||||
handler.addFilter(end_row)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for handler in handlers:
|
||||
handler.removeFilter(end_row)
|
||||
|
||||
|
||||
def _part_path(dest: Path) -> Path:
|
||||
"""The in-progress sidecar ``download_with_resume`` streams into."""
|
||||
return dest.with_name(dest.name + ".part")
|
||||
|
||||
|
||||
def _cancellable_sleep(
|
||||
delay: float, progress: Callable[[int], None] | None, done: int
|
||||
) -> None:
|
||||
"""Backoff sleep that still observes a batch cancellation tick."""
|
||||
if progress is None:
|
||||
time.sleep(delay)
|
||||
return
|
||||
end = time.monotonic() + delay
|
||||
while (remaining := end - time.monotonic()) > 0:
|
||||
progress(done) # raises when the batch was cancelled
|
||||
time.sleep(min(0.5, remaining))
|
||||
if progress is not None:
|
||||
progress.update(downloaded / total_size)
|
||||
if progress is not None:
|
||||
progress.update(1)
|
||||
|
||||
|
||||
def download_with_resume(
|
||||
@@ -926,7 +734,6 @@ def download_with_resume(
|
||||
attempts: int = 5,
|
||||
timeout: int = 30,
|
||||
retry_connect_errors: bool = True,
|
||||
progress: Callable[[int], None] | None = None,
|
||||
) -> None:
|
||||
"""Download ``url`` to ``dest``, resuming partial downloads.
|
||||
|
||||
@@ -949,9 +756,6 @@ def download_with_resume(
|
||||
of consuming attempts — for callers with their own fallback, like
|
||||
``download_from_mirrors``.
|
||||
|
||||
``progress`` replaces the built-in bar: it receives the absolute bytes of
|
||||
``dest`` obtained so far (see ``BatchDownloadProgress``).
|
||||
|
||||
Raises EsphomeError when all attempts are exhausted.
|
||||
"""
|
||||
# Imported lazily: requests is a heavy import (~85ms) and is only needed
|
||||
@@ -963,7 +767,7 @@ def download_with_resume(
|
||||
ensure_happy_eyeballs()
|
||||
|
||||
dest = Path(dest)
|
||||
part = _part_path(dest)
|
||||
part = dest.with_name(dest.name + ".part")
|
||||
meta = part.with_name(part.name + ".meta")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
last_error: Exception | None = None
|
||||
@@ -975,8 +779,6 @@ def download_with_resume(
|
||||
if dest.is_file() and (sha256 is not None or size is not None):
|
||||
try:
|
||||
_verify_file(dest, sha256, size)
|
||||
if progress is not None:
|
||||
progress(size if size is not None else dest.stat().st_size)
|
||||
return
|
||||
except EsphomeError:
|
||||
dest.unlink()
|
||||
@@ -1022,7 +824,7 @@ def download_with_resume(
|
||||
# Recorded so a later run can prove an If-Range
|
||||
# resume of this part file safe.
|
||||
_write_download_meta(meta, url, validator, expected_total)
|
||||
_stream_response_to_file(resp, f, offset, size, progress)
|
||||
_stream_response_to_file(resp, f, offset, size)
|
||||
# else: a previous run already wrote every byte (or more) but
|
||||
# was killed before the rename below. Skip the network entirely
|
||||
# — a Range request past EOF would draw HTTP 416 — and let
|
||||
@@ -1031,10 +833,6 @@ def download_with_resume(
|
||||
|
||||
expected_size = size if size is not None else expected_total
|
||||
_verify_file(part, sha256, expected_size or None)
|
||||
if progress is not None:
|
||||
# Also credits a part file an earlier run completed without
|
||||
# streaming anything this time.
|
||||
progress(expected_size or part.stat().st_size)
|
||||
if not expected_size and sha256 is None:
|
||||
# No sha, no size, and the server sent no usable
|
||||
# content-length: nothing can prove the download complete
|
||||
@@ -1082,11 +880,11 @@ def download_with_resume(
|
||||
|
||||
raise EsphomeError(
|
||||
f"Failed to download {url} after {attempts} attempts: "
|
||||
f"{failure_reason(last_error)}"
|
||||
f"{_failure_reason(last_error)}"
|
||||
) from last_error
|
||||
|
||||
|
||||
def failure_reason(e: BaseException) -> str:
|
||||
def _failure_reason(e: Exception) -> str:
|
||||
"""Format a download exception for the aggregated error message.
|
||||
|
||||
``requests`` appends " for url: <url>" to HTTP errors; the URL is already
|
||||
@@ -1102,7 +900,7 @@ def _spent_attempts_error(e: Exception, attempts: int) -> Exception:
|
||||
the sweep classifies it as permanent."""
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
err = EsphomeError(f"failed after {attempts} attempts: {failure_reason(e)}")
|
||||
err = EsphomeError(f"failed after {attempts} attempts: {_failure_reason(e)}")
|
||||
err.__cause__ = e
|
||||
return err
|
||||
|
||||
@@ -1113,7 +911,6 @@ def _try_mirrors_once(
|
||||
f: IO[bytes] | None,
|
||||
timeout: int,
|
||||
failures: list[tuple[str, Exception]],
|
||||
progress: Callable[[int], None] | None = None,
|
||||
) -> str | None:
|
||||
"""Single pass over the resolved mirror ``urls``, one try per URL.
|
||||
|
||||
@@ -1142,7 +939,6 @@ def _try_mirrors_once(
|
||||
# next mirror immediately; only mid-stream drops
|
||||
# retry-with-resume on the same URL.
|
||||
retry_connect_errors=False,
|
||||
progress=progress,
|
||||
)
|
||||
return url
|
||||
except (requests.RequestException, OSError, EsphomeError) as e:
|
||||
@@ -1184,7 +980,7 @@ def _try_mirrors_once(
|
||||
if offset == 0:
|
||||
validator = _response_validator(resp)
|
||||
expected_total = _content_length(resp)
|
||||
_stream_response_to_file(resp, f, offset, progress=progress)
|
||||
_stream_response_to_file(resp, f, offset)
|
||||
|
||||
if expected_total and f.tell() != expected_total:
|
||||
raise EsphomeError(
|
||||
@@ -1233,7 +1029,6 @@ def download_from_mirrors(
|
||||
substitutions: dict[str, str],
|
||||
target: io.RawIOBase | IO[bytes] | PathType,
|
||||
timeout: int = 30,
|
||||
progress: Callable[[int], None] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Download file from multiple mirrors with substitution support.
|
||||
@@ -1243,8 +1038,6 @@ def download_from_mirrors(
|
||||
substitutions: Dictionary of substitutions to apply to URLs
|
||||
target: Target file path or file-like object
|
||||
timeout: Download timeout in seconds
|
||||
progress: Passed through to the download (see ``download_with_resume``);
|
||||
replaces the built-in per-file bar
|
||||
|
||||
Returns:
|
||||
The source URL.
|
||||
@@ -1309,9 +1102,7 @@ def download_from_mirrors(
|
||||
for sweep in range(1, _MIRROR_SWEEP_ATTEMPTS + 1):
|
||||
sweep_failures: list[tuple[str, Exception]] = []
|
||||
if (
|
||||
url := _try_mirrors_once(
|
||||
urls, path_target, f, timeout, sweep_failures, progress
|
||||
)
|
||||
url := _try_mirrors_once(urls, path_target, f, timeout, sweep_failures)
|
||||
) is not None:
|
||||
return url
|
||||
failures.extend(sweep_failures)
|
||||
@@ -1328,19 +1119,12 @@ def download_from_mirrors(
|
||||
_LOGGER.warning(
|
||||
"Download of %s failed (%s); retrying in %d seconds (attempt %d/%d)",
|
||||
transient[0],
|
||||
failure_reason(transient[1]),
|
||||
_failure_reason(transient[1]),
|
||||
delay,
|
||||
sweep + 1,
|
||||
_MIRROR_SWEEP_ATTEMPTS,
|
||||
)
|
||||
# Tick with the bytes already on disk so a combined bar holds
|
||||
# steady during the backoff instead of rewinding to zero
|
||||
if f is not None:
|
||||
done = f.tell()
|
||||
else:
|
||||
part = _part_path(path_target)
|
||||
done = part.stat().st_size if part.is_file() else 0
|
||||
_cancellable_sleep(delay, progress, done)
|
||||
time.sleep(delay)
|
||||
|
||||
# 4. Report every attempted URL if all mirrors failed. failures spans
|
||||
# all sweeps (deduplicated by URL and reason), so neither an early
|
||||
@@ -1349,7 +1133,7 @@ def download_from_mirrors(
|
||||
seen: set[tuple[str, str]] = set()
|
||||
attempts = ""
|
||||
for url, e in failures:
|
||||
reason = failure_reason(e)
|
||||
reason = _failure_reason(e)
|
||||
if (url, reason) not in seen:
|
||||
seen.add((url, reason))
|
||||
attempts += f"\n {url}\n {reason}"
|
||||
@@ -1363,37 +1147,3 @@ def download_from_mirrors(
|
||||
f"No mirror URL template matched the provided substitutions:{details}"
|
||||
)
|
||||
raise ValueError("download_from_mirrors called with an empty mirrors list")
|
||||
|
||||
|
||||
def strip_win_long_path_prefix(path: str) -> str:
|
||||
r"""Strip the Windows extended-length path prefix from ``path``.
|
||||
|
||||
Handles both forms documented at
|
||||
https://learn.microsoft.com/windows/win32/fileio/naming-a-file:
|
||||
|
||||
* ``\\?\C:\path\to\file`` -> ``C:\path\to\file``
|
||||
* ``\\?\UNC\server\share\path`` -> ``\\server\share\path``
|
||||
|
||||
The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with
|
||||
``sys.executable`` already prefixed with ``\\?\``. That prefix propagates
|
||||
into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from
|
||||
the environment, falling back to ``os.path.normpath(sys.executable)``)
|
||||
and ends up baked into SCons-emitted command lines for build steps such
|
||||
as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand
|
||||
the ``\\?\`` prefix, so the build fails with
|
||||
"The system cannot find the path specified." Stripping the prefix early
|
||||
keeps the path shell-quotable.
|
||||
|
||||
Also applied to the ccache path exported by the ccache helpers, which
|
||||
``shutil.which`` can return with the same prefix.
|
||||
|
||||
No-op on non-Windows platforms.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return path
|
||||
if path.startswith("\\\\?\\UNC\\"):
|
||||
# \\?\UNC\server\share\... -> \\server\share\...
|
||||
return "\\\\" + path[len("\\\\?\\UNC\\") :]
|
||||
if path.startswith("\\\\?\\"):
|
||||
return path[len("\\\\?\\") :]
|
||||
return path
|
||||
|
||||
+1
-18
@@ -31,12 +31,6 @@ SockAddr = IPv4SockAddr | IPv6SockAddr
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# cv.boolean's closed spelling tables, shared with the strict env-knob
|
||||
# parser (build_helpers.ccache.parse_enable_env). The legacy get_bool_env
|
||||
# below keeps its own laxer table for backward compatibility.
|
||||
TRUTHY_BOOL_STRINGS = frozenset({"true", "yes", "on", "enable"})
|
||||
FALSY_BOOL_STRINGS = frozenset({"false", "no", "off", "disable"})
|
||||
|
||||
IS_MACOS = platform.system() == "Darwin"
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
IS_LINUX = platform.system() == "Linux"
|
||||
@@ -735,22 +729,11 @@ class ProgressBar:
|
||||
sys.stderr.flush()
|
||||
|
||||
def done(self) -> None:
|
||||
# No frame drawn, or the 100% frame already ended its own line
|
||||
if not self.enabled or self.last_progress is None or self.last_progress == 100:
|
||||
if not self.enabled:
|
||||
return
|
||||
sys.stderr.write("\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
def interrupt(self) -> None:
|
||||
"""End a mid-row frame so the next write starts on its own row.
|
||||
|
||||
The next ``update()`` redraws the bar; a finished bar stays done.
|
||||
"""
|
||||
if self.last_progress == 100:
|
||||
return
|
||||
self.done()
|
||||
self.last_progress = None
|
||||
|
||||
|
||||
def docs_url(path: str) -> str:
|
||||
"""Return the URL to the documentation for a given path."""
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
"""Run a PlatformIO library ``extraScript`` against a fake SCons env.
|
||||
|
||||
The shim execs the script with a stand-in ``env``, captures ``env.Append``
|
||||
calls (everything else is a logged no-op), and folds the result into the
|
||||
library's build flags. No sandboxing: the script runs with full process
|
||||
access, so it carries the same trust as the library's own source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.platformio.library import ESPHOME_DATA_KEY, ESPHOME_DATA_LINK_FLAGS_KEY
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from esphome.platformio.library import ConvertedLibrary
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def apply_extra_script(
|
||||
component: ConvertedLibrary,
|
||||
board_mcu: Callable[[], str],
|
||||
pio_platform: str,
|
||||
) -> None:
|
||||
"""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:
|
||||
return
|
||||
if not isinstance(extra_script, str):
|
||||
# A list/dict value would raise an opaque TypeError on the join below
|
||||
raise EsphomeError(
|
||||
f"extraScript of library {component.name} must be a string, "
|
||||
f"got {type(extra_script).__name__}"
|
||||
)
|
||||
# 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):
|
||||
# More hostile than a missing script; must not be quieter than it
|
||||
raise EsphomeError(
|
||||
f"extraScript {extra_script} of library {component.name} escapes "
|
||||
"the library directory"
|
||||
)
|
||||
if not script_path.is_file():
|
||||
# A declared-but-absent script is a broken or half-downloaded
|
||||
# package, not an unsupported script; PlatformIO fails on it too
|
||||
raise EsphomeError(
|
||||
f"extraScript {extra_script} of library {component.name} not found"
|
||||
)
|
||||
result = run_extra_script(
|
||||
script_path,
|
||||
library_dir=source_path,
|
||||
board_mcu=board_mcu(),
|
||||
pio_platform=pio_platform,
|
||||
)
|
||||
if link_flags := _str_entries(result.linkflags, "LINKFLAGS"):
|
||||
# Kept apart from build.flags: the CMake emitters route those to
|
||||
# target_compile_options, where a link flag is silently ineffective
|
||||
esphome_data = component.data.setdefault(ESPHOME_DATA_KEY, {})
|
||||
esphome_data.setdefault(ESPHOME_DATA_LINK_FLAGS_KEY, []).extend(link_flags)
|
||||
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]
|
||||
elif not isinstance(flags, list):
|
||||
# A null/dict value coerced through a list wrapper would inject a
|
||||
# non-string into the compiler command line; fail naming the library
|
||||
raise EsphomeError(
|
||||
f"Library {component.name} has a malformed build.flags "
|
||||
f"({type(flags).__name__}); expected a string or list"
|
||||
)
|
||||
component.data["build"]["flags"] = [*flags, *extra_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(
|
||||
{"CPPPATH", "LIBPATH", "LIBS", "CPPDEFINES", "LINKFLAGS", "CPPFLAGS"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtraScriptResult:
|
||||
"""Build-var deltas captured from a PIO extra-script ``env.Append`` call."""
|
||||
|
||||
cpppath: list[str] = field(default_factory=list)
|
||||
libpath: list[str] = field(default_factory=list)
|
||||
libs: list[str] = field(default_factory=list)
|
||||
cppdefines: list[CppDefine] = field(default_factory=list)
|
||||
linkflags: list[str] = field(default_factory=list)
|
||||
cppflags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class CppDefine(NamedTuple):
|
||||
"""One normalized CPPDEFINES entry; a ``value`` of None is a bare -DNAME."""
|
||||
|
||||
name: str
|
||||
value: str | None = None
|
||||
|
||||
|
||||
def _cppdefine(entry: Any) -> CppDefine | None:
|
||||
"""Normalize one CPPDEFINES element, or warn and drop an unsupported
|
||||
shape; formatting those blind would hand the compiler garbage like
|
||||
``-D{'FOO': '1'}``."""
|
||||
if isinstance(entry, str):
|
||||
return CppDefine(entry)
|
||||
if (
|
||||
isinstance(entry, (tuple, list))
|
||||
and len(entry) == 2
|
||||
and isinstance(entry[0], (str, int))
|
||||
and isinstance(entry[1], (str, int, type(None)))
|
||||
):
|
||||
value = entry[1]
|
||||
return CppDefine(str(entry[0]), None if value is None else str(value))
|
||||
_LOGGER.warning("Ignoring unsupported CPPDEFINES entry %r", entry)
|
||||
return None
|
||||
|
||||
|
||||
def _cppdefines_items(value: Any) -> list[CppDefine]:
|
||||
"""Normalize SCons ``processDefines`` spellings into ``CppDefine``s: a
|
||||
bare 2-tuple is one ``name=value`` pair, a dict maps names to values, a
|
||||
list is element-wise."""
|
||||
if isinstance(value, tuple) and len(value) == 2:
|
||||
elements: list[Any] = [value]
|
||||
elif isinstance(value, dict):
|
||||
elements = list(value.items())
|
||||
else:
|
||||
elements = list(value) if isinstance(value, (list, tuple)) else [value]
|
||||
return [d for e in elements if (d := _cppdefine(e)) is not None]
|
||||
|
||||
|
||||
class _FakeSConsEnv:
|
||||
"""Minimal SCons ``Environment`` stand-in: ``get`` and ``Append`` work;
|
||||
every other method is a swallowed no-op so scripts don't abort."""
|
||||
|
||||
def __init__(self, *, board_mcu: str, pio_env: str, pio_platform: str) -> None:
|
||||
self._vars: dict[str, str] = {
|
||||
"BOARD_MCU": board_mcu,
|
||||
"PIOPLATFORM": pio_platform,
|
||||
"PIOENV": pio_env,
|
||||
}
|
||||
self.result = ExtraScriptResult()
|
||||
self._warned_methods: set[str] = set()
|
||||
self._warned_keys: set[str] = set()
|
||||
self._warned_gets: set[str] = set()
|
||||
|
||||
# ----- SCons env API the common scripts use -----
|
||||
|
||||
def get(self, key: str, default: str | None = None) -> str | None:
|
||||
if key not in self._vars and key not in self._warned_gets:
|
||||
# A script branching on an unmodelled var silently takes the
|
||||
# default branch; make that diagnosable from a normal build log
|
||||
self._warned_gets.add(key)
|
||||
_LOGGER.warning(
|
||||
"PIO extra-script env.get(%r) is not modelled; returning the default",
|
||||
key,
|
||||
)
|
||||
return self._vars.get(key, default)
|
||||
|
||||
def __getitem__(self, key: str) -> str:
|
||||
# Scripts also read env["BOARD_MCU"]; an unmodelled subscript
|
||||
# degrades one branch instead of discarding the whole capture
|
||||
if key not in self._vars and key not in self._warned_gets:
|
||||
self._warned_gets.add(key)
|
||||
_LOGGER.warning(
|
||||
"PIO extra-script env[%r] is not modelled; returning ''", key
|
||||
)
|
||||
return self._vars.get(key, "")
|
||||
|
||||
def Append(self, **kwargs) -> None: # noqa: N802 (SCons API name)
|
||||
self._add(kwargs, prepend=False)
|
||||
|
||||
def Prepend(self, **kwargs) -> None: # noqa: N802 (SCons API name)
|
||||
self._add(kwargs, prepend=True)
|
||||
|
||||
def _add(self, kwargs: dict[str, Any], *, prepend: bool) -> None:
|
||||
for key, value in kwargs.items():
|
||||
if key not in _CAPTURED_KEYS:
|
||||
# Warn once per key so a loop of Appends cannot spam
|
||||
if key not in self._warned_keys:
|
||||
self._warned_keys.add(key)
|
||||
_LOGGER.warning(
|
||||
"PIO extra-script env.Append(%s=...) is not captured; ignoring",
|
||||
key,
|
||||
)
|
||||
continue
|
||||
if key == "CPPDEFINES":
|
||||
items = _cppdefines_items(value)
|
||||
else:
|
||||
items = list(value) if isinstance(value, (list, tuple)) else [value]
|
||||
bucket = getattr(self.result, key.lower())
|
||||
if prepend:
|
||||
# SCons order: new values ahead of what is already there
|
||||
# (scripts prepend LIBS for static-link symbol resolution)
|
||||
bucket[:0] = items
|
||||
else:
|
||||
bucket.extend(items)
|
||||
|
||||
# Dedup is not modelled; a repeated flag is harmless on the command line
|
||||
AppendUnique = Append
|
||||
PrependUnique = Prepend
|
||||
|
||||
# ----- Everything else is a no-op so unsupported scripts don't crash -----
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if name.startswith("__") and name.endswith("__"):
|
||||
# Protocol probes (copy, pickle, iteration) are not script calls
|
||||
raise AttributeError(name)
|
||||
if name not in self._warned_methods:
|
||||
# Warn on access, not call: hasattr()/truthiness branches would
|
||||
# otherwise silently take the wrong path; a script whose whole
|
||||
# effect is env.Replace() stays diagnosable either way
|
||||
self._warned_methods.add(name)
|
||||
_LOGGER.warning("PIO extra-script env.%s is not supported; ignoring", name)
|
||||
|
||||
def _noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
return _noop
|
||||
|
||||
|
||||
def run_extra_script(
|
||||
script_path: Path,
|
||||
*,
|
||||
library_dir: Path,
|
||||
board_mcu: str,
|
||||
pio_platform: str,
|
||||
) -> ExtraScriptResult:
|
||||
"""Execute ``script_path`` with a fake SCons env, ``library_dir`` as CWD.
|
||||
|
||||
A crashed script warns and returns an empty result, never a partial
|
||||
capture."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu=board_mcu,
|
||||
pio_env=f"esphome_{board_mcu}",
|
||||
pio_platform=pio_platform,
|
||||
)
|
||||
try:
|
||||
source = script_path.read_text(encoding="utf-8")
|
||||
except OSError as err:
|
||||
# An unreadable declared script is a broken package, exactly like a
|
||||
# missing one; must not be quieter than that case
|
||||
raise EsphomeError(f"extraScript {script_path} is unreadable: {err}") from err
|
||||
except UnicodeDecodeError as e:
|
||||
# A content problem, best-effort like a SyntaxError below
|
||||
_LOGGER.warning(
|
||||
"PIO extra-script %s (in %s) is not UTF-8 (%r); ignoring its output",
|
||||
script_path,
|
||||
library_dir.name,
|
||||
e,
|
||||
)
|
||||
return ExtraScriptResult()
|
||||
old_cwd = Path.cwd()
|
||||
try:
|
||||
# Inside the try: a SyntaxError in a vendored script is just as
|
||||
# best-effort as a runtime failure
|
||||
code = compile(source, str(script_path), "exec")
|
||||
os.chdir(library_dir)
|
||||
exec( # noqa: S102 pylint: disable=exec-used
|
||||
code,
|
||||
{
|
||||
"Import": lambda *_args: None, # SCons-side import; harmless here
|
||||
"env": env,
|
||||
"__file__": str(script_path),
|
||||
"__name__": "__pio_extra_script__",
|
||||
},
|
||||
)
|
||||
except SystemExit as e:
|
||||
if not e.code:
|
||||
# sys.exit() / sys.exit(0) is a normal PlatformIO script ending;
|
||||
# the capture is complete
|
||||
return env.result
|
||||
_LOGGER.warning(
|
||||
"PIO extra-script %s (in %s) exited with status %r; ignoring its output",
|
||||
script_path,
|
||||
library_dir.name,
|
||||
e.code,
|
||||
)
|
||||
return ExtraScriptResult()
|
||||
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# Discard any partial capture: half-applied flags could build wrong
|
||||
# firmware that links cleanly.
|
||||
_LOGGER.warning(
|
||||
"PIO extra-script %s (in %s) raised %r; ignoring its output",
|
||||
script_path,
|
||||
library_dir.name,
|
||||
e,
|
||||
)
|
||||
return ExtraScriptResult()
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
return env.result
|
||||
|
||||
|
||||
def _str_entries(bucket: list, kind: str) -> list[str]:
|
||||
# Third-party scripts legally append SCons nodes, ints, or dicts;
|
||||
# stringifying those into flags would hand the compiler garbage
|
||||
good = [entry for entry in bucket if isinstance(entry, str)]
|
||||
for entry in bucket:
|
||||
if not isinstance(entry, str):
|
||||
_LOGGER.warning("Ignoring unsupported %s entry %r", kind, entry)
|
||||
return good
|
||||
|
||||
|
||||
def captured_as_build_flags(
|
||||
result: ExtraScriptResult, *, library_dir: Path
|
||||
) -> list[str]:
|
||||
"""Translate captured env vars into -L/-l/-D/raw build flags; path
|
||||
entries anchor to ``library_dir`` so the build files stay portable."""
|
||||
flags: list[str] = []
|
||||
library_root = library_dir.resolve()
|
||||
|
||||
def _anchored(path: str) -> str:
|
||||
# Anchor relative paths to library_dir; the script's CWD has been
|
||||
# restored by now
|
||||
resolved = (library_dir / path).resolve()
|
||||
try:
|
||||
return str(resolved.relative_to(library_root))
|
||||
except ValueError:
|
||||
return str(resolved)
|
||||
|
||||
# shlex.quote so a spaced path survives lex_build_flags as one token
|
||||
flags.extend(
|
||||
f"-I{shlex.quote(_anchored(path))}"
|
||||
for path in _str_entries(result.cpppath, "CPPPATH")
|
||||
)
|
||||
flags.extend(
|
||||
f"-L{shlex.quote(_anchored(path))}"
|
||||
for path in _str_entries(result.libpath, "LIBPATH")
|
||||
)
|
||||
flags.extend(f"-l{shlex.quote(lib)}" for lib in _str_entries(result.libs, "LIBS"))
|
||||
for define in result.cppdefines:
|
||||
if define.value is None:
|
||||
# {"FOO": None} / ("FOO", None) is a bare -DFOO in SCons
|
||||
flags.append(shlex.quote(f"-D{define.name}"))
|
||||
else:
|
||||
flags.append(shlex.quote(f"-D{define.name}={define.value}"))
|
||||
# Each captured entry is one argv token in SCons; quote so the
|
||||
# lex_build_flags round-trip cannot split a spaced value into two.
|
||||
# LINKFLAGS are deliberately absent: they travel via
|
||||
# ESPHOME_DATA_LINK_FLAGS_KEY straight to the link line.
|
||||
flags.extend(shlex.quote(f) for f in _str_entries(result.cppflags, "CPPFLAGS"))
|
||||
return flags
|
||||
+141
-530
@@ -13,9 +13,8 @@ regardless of which toolchain consumes the result.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
import glob
|
||||
import hashlib
|
||||
import itertools
|
||||
@@ -31,13 +30,7 @@ from urllib.request import url2pathname
|
||||
|
||||
from esphome import git
|
||||
from esphome.core import CORE, EsphomeError, Library
|
||||
from esphome.framework_helpers import (
|
||||
archive_extract_all,
|
||||
download_from_mirrors,
|
||||
failure_reason,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
)
|
||||
from esphome.framework_helpers import archive_extract_all, download_from_mirrors, rmdir
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,43 +47,25 @@ DEFAULT_BUILD_SRC_FILTER = (
|
||||
DEFAULT_BUILD_SRC_DIRS = "src"
|
||||
DEFAULT_BUILD_INCLUDE_DIR = "include"
|
||||
DEFAULT_BUILD_FLAGS = []
|
||||
# Suffix -> compiler kind (PlatformIO's CSUFFIXES/CXXSUFFIXES/ASSUFFIXES);
|
||||
# "asm" merges SCons's AS and ASPP sets. Per CXXSUFFIXES .C/.C++ are C++
|
||||
# here, even where SCons demotes .C on case-insensitive filesystems.
|
||||
SOURCE_KIND_FOR_SUFFIX: dict[str, str] = {
|
||||
".c": "c",
|
||||
".cpp": "cxx",
|
||||
".cc": "cxx",
|
||||
".cxx": "cxx",
|
||||
".c++": "cxx",
|
||||
".C": "cxx",
|
||||
".C++": "cxx",
|
||||
".S": "asm",
|
||||
".spp": "asm",
|
||||
".SPP": "asm",
|
||||
".sx": "asm",
|
||||
".s": "asm",
|
||||
".asm": "asm",
|
||||
".ASM": "asm",
|
||||
}
|
||||
SRC_FILE_EXTENSIONS = list(SOURCE_KIND_FOR_SUFFIX)
|
||||
# Suffixes that count as headers when probing whether a library has any
|
||||
# usable files at all (compare against Path.suffix.lower())
|
||||
LIBRARY_HEADER_SUFFIXES = frozenset(
|
||||
{".h", ".hpp", ".hh", ".hxx", ".inc", ".ipp", ".tcc"}
|
||||
)
|
||||
SRC_FILE_EXTENSIONS = [
|
||||
".c",
|
||||
".cpp",
|
||||
".cc",
|
||||
".cxx",
|
||||
".c++",
|
||||
".S",
|
||||
".spp",
|
||||
".SPP",
|
||||
".sx",
|
||||
".s",
|
||||
".asm",
|
||||
".ASM",
|
||||
]
|
||||
|
||||
DOMAIN = "pio_components"
|
||||
|
||||
# Marks a cache dir whose archive finished extracting; a missing marker
|
||||
# means a torn extraction that must be redone
|
||||
_EXTRACTED_MARKER = ".esphome_extracted"
|
||||
|
||||
ESPHOME_DATA_KEY = "ESPHOME"
|
||||
ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE"
|
||||
# Captured extra-script LINKFLAGS; kept apart from build.flags so they reach
|
||||
# the link line (target_link_options), not target_compile_options
|
||||
ESPHOME_DATA_LINK_FLAGS_KEY = "LINK_FLAGS"
|
||||
|
||||
|
||||
class Source:
|
||||
@@ -109,13 +84,12 @@ class Source:
|
||||
|
||||
|
||||
class URLSource(Source):
|
||||
def __init__(self, url: str, size: int | None = None):
|
||||
def __init__(self, url: str):
|
||||
self.url = url
|
||||
# Archive size as reported by the registry, when known; sizes the
|
||||
# combined prefetch bar without any extra network probe
|
||||
self.size = size
|
||||
|
||||
def _cache_dir(self, dir_suffix: str, salt: str, namespace: str) -> Path:
|
||||
def download(
|
||||
self, dir_suffix: str, force: bool = False, salt: str = "", namespace: str = ""
|
||||
) -> Path:
|
||||
# Namespace the cache per backend (e.g. pio_components/idf, .../zephyr) so
|
||||
# the build files each backend writes into the library dir can't collide.
|
||||
base_dir = Path(CORE.data_dir) / DOMAIN
|
||||
@@ -125,40 +99,22 @@ class URLSource(Source):
|
||||
h.update(self.url.encode())
|
||||
if salt:
|
||||
h.update(salt.encode())
|
||||
return base_dir / h.hexdigest()[:8] / dir_suffix
|
||||
|
||||
def is_cached(self, dir_suffix: str, salt: str = "", namespace: str = "") -> bool:
|
||||
"""Whether a completed extraction already exists for this source."""
|
||||
return (
|
||||
self._cache_dir(dir_suffix, salt, namespace) / _EXTRACTED_MARKER
|
||||
).is_file()
|
||||
|
||||
def download(
|
||||
self,
|
||||
dir_suffix: str,
|
||||
force: bool = False,
|
||||
salt: str = "",
|
||||
namespace: str = "",
|
||||
progress: Callable[[int], None] | None = None,
|
||||
) -> Path:
|
||||
path = self._cache_dir(dir_suffix, salt, namespace)
|
||||
path = base_dir / h.hexdigest()[:8] / dir_suffix
|
||||
# Marker file written last to signal a complete extraction. Using a
|
||||
# marker (instead of just `path.is_dir()`) means an interrupted
|
||||
# extraction is correctly detected and re-run on the next invocation,
|
||||
# and lets us extract directly into ``path`` — avoiding a
|
||||
# post-extraction rename that races with antivirus on Windows.
|
||||
extracted_marker = path / _EXTRACTED_MARKER
|
||||
extracted_marker = path / ".esphome_extracted"
|
||||
if not extracted_marker.is_file() or force:
|
||||
rmdir(path, msg=f"Clean up library directory {path}")
|
||||
|
||||
# Download in temporary file
|
||||
with tempfile.NamedTemporaryFile() as tmp:
|
||||
if progress is None:
|
||||
# A batch caller draws one combined bar and logs the list
|
||||
_LOGGER.info("Downloading %s ...", self.url)
|
||||
_LOGGER.info("Downloading %s ...", self.url)
|
||||
_LOGGER.debug("Location: %s", path)
|
||||
|
||||
download_from_mirrors([self.url], {}, tmp.file, progress=progress)
|
||||
download_from_mirrors([self.url], {}, tmp.file)
|
||||
|
||||
_LOGGER.debug("Extracting archive to %s ...", path)
|
||||
archive_extract_all(tmp.file, path)
|
||||
@@ -247,11 +203,6 @@ class InvalidLibrary(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class IncompatiblePlatform(InvalidLibrary):
|
||||
"""The routine cross-platform skip, typed so callers need not match
|
||||
message text."""
|
||||
|
||||
|
||||
class ConvertedLibrary:
|
||||
"""A resolved PlatformIO library plus its parsed manifest and on-disk path.
|
||||
|
||||
@@ -331,12 +282,6 @@ class LibraryBackend:
|
||||
framework: str
|
||||
emit: Callable[["ConvertedLibrary"], None]
|
||||
cache_key: str
|
||||
# Owner-less dependency names this returns True for are skipped by the
|
||||
# walk; the backend supplies them outside the registry (e.g. core-bundled
|
||||
# libraries). The walk records every skipped name in provided_requests
|
||||
# so the backend can reconcile its promise after resolving.
|
||||
provides: Callable[[str], bool] | None = None
|
||||
provided_requests: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def ensure_list[T](obj: T | list[T]) -> list[T]:
|
||||
@@ -456,7 +401,7 @@ def split_list_by_condition(
|
||||
return matched, non_matched
|
||||
|
||||
|
||||
def check_library_data(data: dict, platform: str | None, framework: str | None):
|
||||
def check_library_data(data: dict, platform: str | None, framework: str):
|
||||
"""
|
||||
Check whether a library manifest is compatible with the target toolchain.
|
||||
|
||||
@@ -473,8 +418,7 @@ def check_library_data(data: dict, platform: str | None, framework: str | None):
|
||||
for targets (e.g. Zephyr) where PIO manifests rarely declare the
|
||||
platform yet portable libraries still build.
|
||||
framework: The active framework name (e.g. ``espidf``, ``arduino``,
|
||||
``zephyr``) the manifest is expected to declare. ``None`` skips
|
||||
the framework check (and its warning), mirroring ``platform``.
|
||||
``zephyr``) the manifest is expected to declare.
|
||||
|
||||
Raises:
|
||||
InvalidLibrary: If the library does not support the target platform.
|
||||
@@ -483,29 +427,24 @@ def check_library_data(data: dict, platform: str | None, framework: str | None):
|
||||
if isinstance(platforms, str):
|
||||
platforms = [a.strip() for a in platforms.split(",")]
|
||||
platforms = ensure_list(platforms)
|
||||
if not all(isinstance(pf, str) for pf in platforms):
|
||||
# A real (non-platform) manifest problem; callers warn, not skip
|
||||
raise InvalidLibrary(f"Malformed platforms value: {platforms!r}")
|
||||
|
||||
# Check if library supports the target platform
|
||||
valid_platforms = platform is None or "*" in platforms or platform in platforms
|
||||
|
||||
if not valid_platforms:
|
||||
raise IncompatiblePlatform(f"Unsupported library platforms: {platforms}")
|
||||
raise InvalidLibrary(f"Unsupported library platforms: {platforms}")
|
||||
|
||||
frameworks = data.get("frameworks", "*")
|
||||
if isinstance(frameworks, str):
|
||||
frameworks = [a.strip() for a in frameworks.split(",")]
|
||||
frameworks = ensure_list(frameworks)
|
||||
if not all(isinstance(fw, str) for fw in frameworks):
|
||||
raise InvalidLibrary(f"Malformed frameworks value: {frameworks!r}")
|
||||
|
||||
# Check if library declares the active framework. PIO library manifests
|
||||
# often list only "arduino" even when the library actually compiles fine
|
||||
# under the target framework, and there's no way to opt out of the check at
|
||||
# this layer. Warn instead of failing so the user isn't forced to fork the
|
||||
# library to fix the manifest.
|
||||
valid_framework = framework is None or "*" in frameworks or framework in frameworks
|
||||
valid_framework = "*" in frameworks or framework in frameworks
|
||||
|
||||
if not valid_framework:
|
||||
_LOGGER.warning(
|
||||
@@ -516,7 +455,7 @@ def check_library_data(data: dict, platform: str | None, framework: str | None):
|
||||
)
|
||||
|
||||
|
||||
def parse_library_json(library_json_path: PathType):
|
||||
def _parse_library_json(library_json_path: PathType):
|
||||
"""
|
||||
Load and parse a JSON file describing a library.
|
||||
|
||||
@@ -530,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.
|
||||
|
||||
@@ -579,10 +518,9 @@ def _make_registry_client() -> Any:
|
||||
|
||||
def _resolve_registry_version(
|
||||
owner: str | None, pkgname: str, requirements: set[str]
|
||||
) -> tuple[str, str, str, str, int | None]:
|
||||
) -> tuple[str, str, str, str]:
|
||||
"""Resolve a registry package to the single highest version satisfying ALL
|
||||
the given requirements; return ``(owner, name, version, download_url,
|
||||
size)`` (``size`` is None when the registry omits it).
|
||||
the given requirements; return ``(owner, name, version, download_url)``.
|
||||
|
||||
Intersecting every requirement (rather than resolving each consumer in
|
||||
isolation) makes the result independent of processing order and guarantees
|
||||
@@ -612,130 +550,22 @@ def _resolve_registry_version(
|
||||
pkgfile = registry.pick_compatible_pkg_file(best["files"])
|
||||
if not pkgfile:
|
||||
raise RuntimeError(f"No package file for {owner}/{name}@{best['name']}")
|
||||
return owner, name, best["name"], pkgfile["download_url"], pkgfile.get("size")
|
||||
return owner, name, best["name"], pkgfile["download_url"]
|
||||
|
||||
|
||||
def split_flag_entry(entry: Any, owner: str) -> list[str]:
|
||||
"""``shlex.split`` with a clean error naming the offending flags entry."""
|
||||
# Late import: shlex is only needed when actually lexing flags
|
||||
import shlex
|
||||
|
||||
try:
|
||||
return shlex.split(entry)
|
||||
except (ValueError, AttributeError, TypeError) as err:
|
||||
# AttributeError/TypeError: a dict or number from a third-party
|
||||
# manifest; name the entry instead of an opaque shlex traceback
|
||||
raise EsphomeError(f"Malformed build flag {entry!r} in {owner}: {err}") from err
|
||||
|
||||
|
||||
def lex_build_flags(entries: str | list[str], owner: str) -> list[str]:
|
||||
"""Shell-lex ``build.flags`` entries the way PlatformIO's ParseFlags
|
||||
does; bare -I/-L/-l/-D tokens re-glue to their argument."""
|
||||
# Lex per entry as ParseFlags does: a dangling -I must warn, not absorb
|
||||
# the next entry's first token
|
||||
return [
|
||||
token
|
||||
for entry in ensure_list(entries)
|
||||
for token in join_flag_args(split_flag_entry(entry, owner), owner)
|
||||
]
|
||||
|
||||
|
||||
# Flags whose argument may follow as a separate token; ParseFlags glues them
|
||||
BARE_ARG_FLAGS = frozenset({"-I", "-L", "-l", "-D"})
|
||||
|
||||
|
||||
def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]:
|
||||
"""Join a bare ``-I``/``-L``/``-l``/``-D`` with its following token, as
|
||||
PlatformIO's ParseFlags does. A trailing or empty argument is warned and
|
||||
dropped: the bare flag would make gcc eat the next flag."""
|
||||
out: list[str] = []
|
||||
it = iter(tokens)
|
||||
for tok in it:
|
||||
if tok in BARE_ARG_FLAGS:
|
||||
arg = next(it, None)
|
||||
if arg is None:
|
||||
_LOGGER.warning("Ignoring trailing '%s' in %s build flags", tok, owner)
|
||||
break
|
||||
if not arg:
|
||||
_LOGGER.warning(
|
||||
"Ignoring '%s' with empty argument in %s build flags", tok, owner
|
||||
)
|
||||
continue
|
||||
tok += arg
|
||||
out.append(tok)
|
||||
return out
|
||||
|
||||
|
||||
def warn_properties_depends(name: str, data: object) -> None:
|
||||
"""Warn for ``depends=``-only manifests; the walk reads only the JSON
|
||||
``dependencies`` key, so they would otherwise drop silently."""
|
||||
if isinstance(data, dict) and not data.get("dependencies") and data.get("depends"):
|
||||
# INFO: common and unactionable for transitive libraries; a WARNING
|
||||
# on every build would train users to ignore the stream
|
||||
_LOGGER.info(
|
||||
"Library %s declares dependencies via library.properties "
|
||||
"depends=, which are not resolved automatically; add them with "
|
||||
"add_library() if needed",
|
||||
name,
|
||||
)
|
||||
|
||||
|
||||
def dependency_is_usable(
|
||||
dep: dict, platform: str | None, framework: str, requester: str
|
||||
) -> bool:
|
||||
"""Compatibility filter for a manifest dependency: platform mismatches
|
||||
skip at debug, any other ``InvalidLibrary`` warns naming the requester."""
|
||||
try:
|
||||
check_library_data(dep, platform, framework)
|
||||
except IncompatiblePlatform as e:
|
||||
_LOGGER.debug("Skip dependency %s of %s: %s", dep.get("name"), requester, e)
|
||||
return False
|
||||
except InvalidLibrary as e:
|
||||
_LOGGER.warning(
|
||||
"Skipping dependency %s of %s: %s", dep.get("name"), requester, e
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
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."""
|
||||
name = entry.get("name")
|
||||
if (
|
||||
isinstance(name, str)
|
||||
and name
|
||||
and ("version" not in entry or isinstance(entry["version"], str))
|
||||
):
|
||||
return True
|
||||
_LOGGER.warning(
|
||||
"Ignoring unrecognized dependency entry %r of %s", entry, manifest_name
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def normalize_dependencies(
|
||||
dependencies: Any, manifest_name: str = "manifest"
|
||||
) -> list[dict]:
|
||||
def _normalize_dependencies(dependencies: Any) -> list[dict]:
|
||||
"""Normalize a library manifest's ``dependencies`` to a list of dicts.
|
||||
|
||||
PIO's library.json accepts the list-of-dicts form, the shorthand dict
|
||||
form (``{"owner/Name": "version_spec"}``), bare name strings inside the
|
||||
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.
|
||||
PIO's library.json accepts both the list-of-dicts form and the shorthand
|
||||
dict form (``{"owner/Name": "version_spec"}``); normalize the latter so
|
||||
callers see a uniform list.
|
||||
"""
|
||||
if not dependencies:
|
||||
return []
|
||||
if isinstance(dependencies, str):
|
||||
# A plain string is one or more comma-separated names; iterating it
|
||||
# as a list would shred it into one-character "libraries"
|
||||
return [{"name": n.strip()} for n in dependencies.split(",") if n.strip()]
|
||||
if isinstance(dependencies, dict):
|
||||
normalized = []
|
||||
for raw_name, spec in dependencies.items():
|
||||
if isinstance(raw_name, str) and "/" in raw_name:
|
||||
if "/" in raw_name:
|
||||
owner, pkgname = raw_name.split("/", 1)
|
||||
else:
|
||||
owner, pkgname = None, raw_name
|
||||
@@ -744,31 +574,9 @@ def normalize_dependencies(
|
||||
entry.update(spec)
|
||||
else:
|
||||
entry["version"] = spec
|
||||
if _valid_dependency_entry(entry, manifest_name):
|
||||
normalized.append(entry)
|
||||
normalized.append(entry)
|
||||
return normalized
|
||||
if not isinstance(dependencies, (list, tuple)):
|
||||
_LOGGER.warning(
|
||||
"Ignoring unrecognized dependencies %r of %s",
|
||||
dependencies,
|
||||
manifest_name,
|
||||
)
|
||||
return []
|
||||
normalized = []
|
||||
for entry in dependencies:
|
||||
if isinstance(entry, dict):
|
||||
if _valid_dependency_entry(entry, manifest_name):
|
||||
normalized.append(entry)
|
||||
elif isinstance(entry, str) and entry:
|
||||
# PIO also accepts a bare list of names ("dependencies": ["Wire"])
|
||||
normalized.append({"name": entry})
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Ignoring unrecognized dependency entry %r of %s",
|
||||
entry,
|
||||
manifest_name,
|
||||
)
|
||||
return normalized
|
||||
return [d for d in dependencies if isinstance(d, dict)]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -880,145 +688,6 @@ 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 _warn_unsatisfied_versionless(
|
||||
skipped_versionless: list[tuple[Any, Any, str]],
|
||||
components: dict[str, ConvertedLibrary],
|
||||
backend: LibraryBackend,
|
||||
) -> None:
|
||||
"""Warn for version-less deps nothing satisfied (request key, manifest
|
||||
name, or backend provides()); a silent drop surfaces as link errors far
|
||||
from the cause."""
|
||||
resolved_manifest_names = {c.data.get("name") for c in components.values()}
|
||||
warned: set[str] = set()
|
||||
for dep_name, dep_owner, requester in skipped_versionless:
|
||||
if not isinstance(dep_name, str) or not dep_name or dep_name in warned:
|
||||
continue
|
||||
if dep_name in components:
|
||||
# A version-less dep's request key is the name itself
|
||||
continue
|
||||
if dep_name in resolved_manifest_names:
|
||||
# Name-only evidence: any resolved component with this manifest
|
||||
# name counts, not just ones the requester can reach
|
||||
_LOGGER.debug(
|
||||
"Version-less dependency %s of %s satisfied by manifest name only",
|
||||
dep_name,
|
||||
requester,
|
||||
)
|
||||
continue
|
||||
if (
|
||||
not dep_owner
|
||||
and backend.provides is not None
|
||||
and backend.provides(dep_name)
|
||||
):
|
||||
# provides() only satisfies owner-less names: the walk's
|
||||
# backend-provided skip has the same owner guard, so an
|
||||
# owner-qualified version-less dep was added by nobody
|
||||
continue
|
||||
warned.add(dep_name)
|
||||
_LOGGER.warning(
|
||||
"Dependency %s of %s has no version to resolve and nothing "
|
||||
"provides it; skipping",
|
||||
dep_name,
|
||||
requester,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_source(
|
||||
component: ConvertedLibrary,
|
||||
salt: str,
|
||||
namespace: str,
|
||||
tracker: Callable[[int], None],
|
||||
) -> None:
|
||||
# Straight to URLSource: only it takes progress, and mutating the
|
||||
# shared component from a worker is the authoritative loop's job
|
||||
component.source.download(
|
||||
component.get_sanitized_name(), salt=salt, namespace=namespace, progress=tracker
|
||||
)
|
||||
|
||||
|
||||
def _prefetch_wave(
|
||||
wave: list[tuple[str, ConvertedLibrary]], salt: str, namespace: str
|
||||
) -> None:
|
||||
"""Best-effort parallel download of a wave's registry archives.
|
||||
|
||||
The walk's own ``download()`` stays authoritative; duplicate URLs
|
||||
prefetch once so two threads never share a cache directory. Archives
|
||||
whose size the registry did not report are left to the sequential
|
||||
loop, whose per-file bars don't interleave. A node a sibling in the
|
||||
same wave supersedes has its archive fetched in vain (knowing better
|
||||
would need the manifests being downloaded).
|
||||
"""
|
||||
try:
|
||||
components: list[ConvertedLibrary] = []
|
||||
seen: set[str] = set()
|
||||
for _key, component in wave:
|
||||
source = component.source
|
||||
if not isinstance(source, URLSource) or not source.size:
|
||||
continue
|
||||
if source.url in seen:
|
||||
continue
|
||||
seen.add(source.url)
|
||||
try:
|
||||
cached = source.is_cached(
|
||||
component.get_sanitized_name(), salt=salt, namespace=namespace
|
||||
)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# Best-effort: a failing probe prefetches (and re-downloads)
|
||||
_LOGGER.debug("Cache probe for %s failed: %s", component.name, err)
|
||||
cached = False
|
||||
if cached:
|
||||
# A warm build must stay silent
|
||||
continue
|
||||
components.append(component)
|
||||
if not components:
|
||||
return
|
||||
# Single-item waves (a dependency chain discovers one archive per
|
||||
# wave) go through the same runner: one download method, one bar
|
||||
_LOGGER.info(
|
||||
"Downloading %d library archive(s): %s",
|
||||
len(components),
|
||||
", ".join(c.name for c in components),
|
||||
)
|
||||
failures = run_batch_downloads(
|
||||
"Downloading libraries",
|
||||
[
|
||||
(c.name, c.source.size, partial(_fetch_source, c, salt, namespace))
|
||||
for c in components
|
||||
],
|
||||
)
|
||||
for name, err in failures:
|
||||
# The sequential call below retries and raises the real error
|
||||
_LOGGER.warning(
|
||||
"Prefetch of %s failed (retrying sequentially): %s",
|
||||
name,
|
||||
failure_reason(err),
|
||||
)
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=err)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
# Same policy as the ESP-IDF twin: the prefetch must never become a
|
||||
# new way for the build to fail
|
||||
_LOGGER.warning("Library prefetch failed: %s", err)
|
||||
_LOGGER.debug("Prefetch failure detail", exc_info=True)
|
||||
|
||||
|
||||
def convert_libraries(
|
||||
libraries: list[Library], backend: LibraryBackend
|
||||
) -> list[ConvertedLibrary]:
|
||||
@@ -1044,7 +713,10 @@ def convert_libraries(
|
||||
"""
|
||||
nodes: dict[str, _LibNode] = {}
|
||||
|
||||
lib_ignore = lib_ignore_set()
|
||||
lib_ignore = {
|
||||
name.split("/")[-1].lower()
|
||||
for name in CORE.platformio_options.get("lib_ignore", [])
|
||||
}
|
||||
|
||||
# The generated build files inside the shared cache bake in the dependency
|
||||
# wiring, which lib_ignore changes; salt the cache path so configs with
|
||||
@@ -1056,6 +728,11 @@ def convert_libraries(
|
||||
else ""
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
|
||||
key, kind, locator = _node_key(name, version, repository)
|
||||
node = nodes.get(key) or _LibNode(key=key, is_git=kind == "git")
|
||||
@@ -1104,7 +781,7 @@ def convert_libraries(
|
||||
top_level = [
|
||||
add_spec(library.name, library.version, library.repository)
|
||||
for library in libraries
|
||||
if not is_lib_ignored(library.name, lib_ignore)
|
||||
if not is_ignored(library.name)
|
||||
]
|
||||
|
||||
# Collect + resolve to a fixpoint: a node is (re)resolved whenever its
|
||||
@@ -1113,171 +790,107 @@ def convert_libraries(
|
||||
components: dict[str, ConvertedLibrary] = {}
|
||||
resolved_requirements: dict[str, frozenset[str]] = {}
|
||||
top_level_keys = set(top_level)
|
||||
# (name, owner, requester) reconciled against the final resolution set
|
||||
skipped_versionless: list[tuple[Any, Any, str]] = []
|
||||
worklist = deque(dict.fromkeys(top_level))
|
||||
while worklist:
|
||||
# Drain the frontier sequentially (spec resolution mutates shared
|
||||
# state), then prefetch the wave in parallel
|
||||
wave: list[tuple[str, ConvertedLibrary]] = []
|
||||
while worklist:
|
||||
key = worklist.popleft()
|
||||
node = nodes[key]
|
||||
key = worklist.popleft()
|
||||
node = nodes[key]
|
||||
|
||||
# Re-resolve only when the requirement set grew; requirements
|
||||
# only ever grow, so the fixpoint converges and cycles terminate
|
||||
requirements = frozenset(node.requirements)
|
||||
if resolved_requirements.get(key) == requirements:
|
||||
continue
|
||||
resolved_requirements[key] = requirements
|
||||
# A node is queued once per referring edge; skip the (uncached) registry
|
||||
# lookup + download + dependency walk unless its requirement set grew
|
||||
# since the last resolve. Requirements only ever grow, so this still
|
||||
# converges the fixpoint and terminates dependency cycles.
|
||||
requirements = frozenset(node.requirements)
|
||||
if resolved_requirements.get(key) == requirements:
|
||||
continue
|
||||
resolved_requirements[key] = requirements
|
||||
|
||||
if node.is_git:
|
||||
component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref))
|
||||
elif node.is_local:
|
||||
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
|
||||
else:
|
||||
owner, name, version, url, size = _resolve_registry_version(
|
||||
node.owner, node.pkgname, node.requirements
|
||||
)
|
||||
component = ConvertedLibrary(
|
||||
_owner_pkgname_to_name(owner, name), version, URLSource(url, size)
|
||||
)
|
||||
wave.append((key, component))
|
||||
_prefetch_wave(wave, salt, backend.cache_key)
|
||||
for key, component in wave:
|
||||
node = nodes[key]
|
||||
if frozenset(node.requirements) != resolved_requirements[key]:
|
||||
# Requirements grew mid-wave: skip parsing a manifest the
|
||||
# next wave will re-resolve and replace
|
||||
worklist.append(key)
|
||||
continue
|
||||
component.download(salt=salt, namespace=backend.cache_key)
|
||||
if node.is_git:
|
||||
component = ConvertedLibrary(key, "*", GitSource(node.url, node.ref))
|
||||
elif node.is_local:
|
||||
component = ConvertedLibrary(key, "*", LocalSource(node.local_path))
|
||||
else:
|
||||
owner, name, version, url = _resolve_registry_version(
|
||||
node.owner, node.pkgname, node.requirements
|
||||
)
|
||||
component = ConvertedLibrary(
|
||||
_owner_pkgname_to_name(owner, name), version, URLSource(url)
|
||||
)
|
||||
component.download(salt=salt, namespace=backend.cache_key)
|
||||
|
||||
source_dir = component.source_dir
|
||||
library_json_path = source_dir / "library.json"
|
||||
library_properties_path = source_dir / "library.properties"
|
||||
source_dir = component.source_dir
|
||||
library_json_path = source_dir / "library.json"
|
||||
library_properties_path = source_dir / "library.properties"
|
||||
has_json = library_json_path.is_file()
|
||||
has_properties = library_properties_path.is_file()
|
||||
if not has_json and not has_properties and not node.is_local:
|
||||
# The shared cache can hold a broken copy (e.g. a clone or an
|
||||
# extraction interrupted by a killed process). Force one
|
||||
# re-download so a bad cache entry self-heals instead of failing
|
||||
# every build until the user runs a full clean. A local source is
|
||||
# read in place, so there is nothing to re-download.
|
||||
_LOGGER.warning(
|
||||
"Library %s at %s is missing library.json and library.properties; "
|
||||
"re-downloading",
|
||||
key,
|
||||
source_dir,
|
||||
)
|
||||
component.download(force=True, salt=salt, namespace=backend.cache_key)
|
||||
has_json = library_json_path.is_file()
|
||||
has_properties = library_properties_path.is_file()
|
||||
if not has_json and not has_properties and not node.is_local:
|
||||
# An interrupted clone/extraction self-heals with one forced
|
||||
# re-download; a local source has nothing to re-download
|
||||
_LOGGER.warning(
|
||||
"Library %s at %s is missing library.json and library.properties; "
|
||||
"re-downloading",
|
||||
key,
|
||||
source_dir,
|
||||
)
|
||||
component.download(force=True, salt=salt, namespace=backend.cache_key)
|
||||
has_json = library_json_path.is_file()
|
||||
has_properties = library_properties_path.is_file()
|
||||
if has_json:
|
||||
component.data = parse_library_json(library_json_path)
|
||||
elif has_properties:
|
||||
component.data = parse_library_properties(library_properties_path)
|
||||
else:
|
||||
# Local sources are user input (EsphomeError); a registry/git
|
||||
# miss means a corrupt cache (RuntimeError)
|
||||
error_cls = EsphomeError if node.is_local else RuntimeError
|
||||
raise error_cls(
|
||||
f"Invalid PIO library {key}: missing library.json and "
|
||||
f"library.properties in {source_dir}"
|
||||
)
|
||||
if has_json:
|
||||
component.data = _parse_library_json(library_json_path)
|
||||
elif has_properties:
|
||||
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;
|
||||
# for registry/git a missing manifest means a corrupt cache, which
|
||||
# is not user error, so keep RuntimeError.
|
||||
error_cls = EsphomeError if node.is_local else RuntimeError
|
||||
raise error_cls(
|
||||
f"Invalid PIO library {key}: missing library.json and "
|
||||
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
|
||||
raise EsphomeError(f"Library {key} has a malformed manifest")
|
||||
warn_properties_depends(component.name, component.data)
|
||||
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.
|
||||
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))
|
||||
continue
|
||||
components[key] = component
|
||||
|
||||
try:
|
||||
check_library_data(component.data, backend.platform, backend.framework)
|
||||
except InvalidLibrary as e:
|
||||
# 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
|
||||
if isinstance(e, IncompatiblePlatform):
|
||||
_LOGGER.debug("Skip incompatible dependency %s: %s", key, str(e))
|
||||
else:
|
||||
_LOGGER.warning("Skipping dependency %s: %s", key, str(e))
|
||||
# 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")):
|
||||
if "name" not in dependency or "version" not in dependency:
|
||||
continue
|
||||
components[key] = component
|
||||
|
||||
# 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"), component.name
|
||||
):
|
||||
if "version" not in dependency:
|
||||
# Version-less deps cannot resolve from the registry; the
|
||||
# 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,
|
||||
)
|
||||
if not is_lib_ignored(
|
||||
dependency.get("name"), lib_ignore
|
||||
) and dependency_is_usable(
|
||||
dependency, backend.platform, backend.framework, component.name
|
||||
):
|
||||
# A platform-filtered or ignored dep is deliberately
|
||||
# absent, not a drop to reconcile
|
||||
skipped_versionless.append(
|
||||
(
|
||||
dependency.get("name"),
|
||||
dependency.get("owner"),
|
||||
component.name,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not dependency_is_usable(
|
||||
dependency, backend.platform, backend.framework, component.name
|
||||
):
|
||||
continue
|
||||
dep_name = _owner_pkgname_to_name(
|
||||
dependency.get("owner"), dependency.get("name")
|
||||
)
|
||||
if is_lib_ignored(dep_name, lib_ignore):
|
||||
_LOGGER.debug("Skip ignored dependency %s", dep_name)
|
||||
continue
|
||||
# The version field may actually be a URL (git/archive
|
||||
# dependency), which names one specific source; it must not
|
||||
# be substituted with a same-named bundled library below.
|
||||
dep_version = dependency["version"]
|
||||
dep_url = _url_or_none(dep_version)
|
||||
if dep_url is not None:
|
||||
dep_version = None
|
||||
elif (
|
||||
backend.provides is not None
|
||||
and not dependency.get("owner")
|
||||
and backend.provides(dep_name)
|
||||
):
|
||||
# The backend adds it from its own tree; resolving it here
|
||||
# would fetch a same-named registry package instead
|
||||
if dep_version and dep_version != "*":
|
||||
# The version pin is discarded for the bundled copy;
|
||||
# make the substitution visible
|
||||
_LOGGER.warning(
|
||||
"Dependency %s pins version %s; using the library "
|
||||
"bundled with the framework instead",
|
||||
dep_name,
|
||||
dep_version,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("Skip backend-provided dependency %s", dep_name)
|
||||
backend.provided_requests.append(dep_name)
|
||||
continue
|
||||
dep_key = add_spec(dep_name, dep_version, dep_url)
|
||||
node.edges.add(dep_key)
|
||||
worklist.append(dep_key)
|
||||
try:
|
||||
check_library_data(dependency, backend.platform, backend.framework)
|
||||
except InvalidLibrary as e:
|
||||
_LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e))
|
||||
continue
|
||||
dep_name = _owner_pkgname_to_name(
|
||||
dependency.get("owner"), dependency.get("name")
|
||||
)
|
||||
if is_ignored(dep_name):
|
||||
_LOGGER.debug("Skip ignored dependency %s", dep_name)
|
||||
continue
|
||||
# The version field may actually be a URL (git/archive dependency).
|
||||
dep_version = dependency["version"]
|
||||
dep_url = _url_or_none(dep_version)
|
||||
if dep_url is not None:
|
||||
dep_version = None
|
||||
dep_key = add_spec(dep_name, dep_version, dep_url)
|
||||
node.edges.add(dep_key)
|
||||
worklist.append(dep_key)
|
||||
|
||||
# A git or local source wins over the same component requested from the
|
||||
# registry. That's intentional, but warn so the dropped registry spec isn't
|
||||
@@ -1328,6 +941,4 @@ def convert_libraries(
|
||||
for component in components.values():
|
||||
backend.emit(component)
|
||||
|
||||
_warn_unsatisfied_versionless(skipped_versionless, components, backend)
|
||||
|
||||
return [components[key] for key in top_level if key in components]
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
"""Install packages from the PlatformIO registry without importing the
|
||||
platformio package (identical bits, esphome's own download machinery)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Collection
|
||||
from functools import cache, partial
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
from typing import NamedTuple
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import (
|
||||
archive_extract_all,
|
||||
download_from_mirrors,
|
||||
download_with_resume,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_REGISTRY_URL = (
|
||||
"https://api.registry.platformio.org/v3/packages/platformio/tool/{package}"
|
||||
)
|
||||
|
||||
|
||||
def get_systype() -> str:
|
||||
"""The registry system tag for the current host.
|
||||
|
||||
Transliterates ``platformio.util.get_systype()`` (same
|
||||
``PLATFORMIO_SYSTEM_TYPE`` override). Deviation: windows-arm64 maps to
|
||||
``windows_amd64`` (no arm64 toolchains; x86 emulation).
|
||||
"""
|
||||
if systype := os.environ.get("PLATFORMIO_SYSTEM_TYPE"):
|
||||
return systype
|
||||
system = platform.system().lower()
|
||||
arch = platform.machine().lower()
|
||||
if system == "windows":
|
||||
if not arch: # same fallback as upstream (platformio issue #4353)
|
||||
arch = "x86_" + platform.architecture()[0]
|
||||
if "x86" in arch:
|
||||
arch = "amd64" if "64" in arch else "x86"
|
||||
elif arch == "arm64":
|
||||
arch = "amd64"
|
||||
if arch == "aarch64" and platform.architecture()[0] == "32bit":
|
||||
# 64-bit kernel with a 32-bit userland (e.g. 32-bit Raspberry Pi OS)
|
||||
arch = "armv7l"
|
||||
return f"{system}_{arch}" if arch else system
|
||||
|
||||
|
||||
@cache
|
||||
def registry_download(package: str, version: str) -> tuple[str, str, int | None]:
|
||||
"""Resolve a package's download URL, sha256, and size via the registry.
|
||||
|
||||
The metadata fetch goes through ``download_from_mirrors`` so it shares
|
||||
the retry, backoff, and error reporting of every other download here.
|
||||
Cached per process so the prefetch and the install resolve each package
|
||||
once (failures are not cached; the install retries them).
|
||||
"""
|
||||
buf = io.BytesIO()
|
||||
download_from_mirrors([_REGISTRY_URL], {"package": package}, buf)
|
||||
try:
|
||||
data = json.loads(buf.getvalue())
|
||||
except ValueError as err:
|
||||
raise EsphomeError(
|
||||
f"The package registry returned invalid JSON for {package}: {err}"
|
||||
) from err
|
||||
if not isinstance(data, dict):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(data)[:200]}"
|
||||
)
|
||||
systype = get_systype()
|
||||
versions = data.get("versions")
|
||||
if not isinstance(versions, list):
|
||||
# A schema change or an error/captive-portal payload must not be
|
||||
# reported as "version not found"
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(data)[:200]}"
|
||||
)
|
||||
for ver in versions:
|
||||
if not isinstance(ver, dict):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(data)[:200]}"
|
||||
)
|
||||
if ver.get("name") != version:
|
||||
continue
|
||||
files = ver.get("files")
|
||||
if not isinstance(files, list):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(ver)[:200]}"
|
||||
)
|
||||
for file in files:
|
||||
if not isinstance(file, dict):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: "
|
||||
f"{str(ver)[:200]}"
|
||||
)
|
||||
# Only a missing key means "any system"; an empty list must not
|
||||
# match, and a bare string would make ``in`` a substring test.
|
||||
systems = file.get("system")
|
||||
if systems is None:
|
||||
systems = ["*"]
|
||||
elif isinstance(systems, str):
|
||||
systems = [systems]
|
||||
elif not isinstance(systems, list):
|
||||
# An int would make ``in`` a TypeError and a dict a key test
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: "
|
||||
f"{str(file)[:200]}"
|
||||
)
|
||||
if "*" in systems or systype in systems:
|
||||
sha256 = (file.get("checksum") or {}).get("sha256")
|
||||
if not sha256:
|
||||
# Never extract an unverified archive; the registry
|
||||
# publishes a checksum for every package file.
|
||||
raise EsphomeError(
|
||||
f"The package registry returned no sha256 for "
|
||||
f"{package} {version}; refusing the unverified download"
|
||||
)
|
||||
url = file.get("download_url")
|
||||
if not url:
|
||||
raise EsphomeError(
|
||||
f"The package registry returned no download URL for "
|
||||
f"{package} {version}"
|
||||
)
|
||||
return (url, sha256, file.get("size"))
|
||||
raise EsphomeError(
|
||||
f"No {package} {version} build for this platform ({systype})"
|
||||
)
|
||||
raise EsphomeError(f"{package} {version} not found in the package registry")
|
||||
|
||||
|
||||
def _check_layout(name: str, dest: Path, expect: Collection[str]) -> None:
|
||||
"""Raise when an install tree is missing an expected directory (runs on
|
||||
fresh extracts and on marker hits)."""
|
||||
for rel in expect:
|
||||
if not (dest / rel).is_dir():
|
||||
raise EsphomeError(
|
||||
f"{name} at {dest} is missing the expected {rel} "
|
||||
"directory; run 'esphome clean-all' and retry"
|
||||
)
|
||||
|
||||
|
||||
class _PendingArchive(NamedTuple):
|
||||
name: str
|
||||
version: str
|
||||
dest: Path
|
||||
url: str
|
||||
sha256: str
|
||||
size: int
|
||||
|
||||
|
||||
def prefetch_packages(
|
||||
packages: list[tuple[str, str, Path, list[str]]], downloads_dir: Path
|
||||
) -> None:
|
||||
"""Download pending package archives in parallel under one combined bar.
|
||||
|
||||
``packages`` holds ``(name, version, dest, mirrors)`` per package. Purely
|
||||
an optimization: ``install_package`` verifies every archive and
|
||||
re-downloads anything this pass left unfinished. Mirror overrides and
|
||||
registry entries without a size stay on the sequential path so its
|
||||
per-file bars remain trustworthy. Each fetch holds the same per-dest
|
||||
lock as ``install_package``: the archive's ``.part`` file is shared, and
|
||||
two concurrent writers would truncate each other's bytes.
|
||||
"""
|
||||
from filelock import FileLock
|
||||
|
||||
pending: list[_PendingArchive] = []
|
||||
seen: set[str] = set()
|
||||
for name, version, dest, mirrors in packages:
|
||||
if mirrors or (dest / ".esphome_extracted").is_file():
|
||||
continue
|
||||
archive_name = f"{name}-{version}"
|
||||
if archive_name in seen:
|
||||
# A duplicate entry would race itself between two workers
|
||||
continue
|
||||
seen.add(archive_name)
|
||||
try:
|
||||
url, sha256, size = registry_download(name, version)
|
||||
except EsphomeError as err:
|
||||
# The sequential install reports the real failure with context
|
||||
_LOGGER.debug("Prefetch resolve for %s failed: %s", name, err)
|
||||
continue
|
||||
if not size:
|
||||
continue
|
||||
archive = downloads_dir / archive_name
|
||||
if archive.is_file() and archive.stat().st_size == size:
|
||||
continue
|
||||
pending.append(_PendingArchive(name, version, dest, url, sha256, size))
|
||||
if len(pending) < 2:
|
||||
return
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
_LOGGER.info(
|
||||
"Downloading %d package archive(s): %s",
|
||||
len(pending),
|
||||
", ".join(entry.name for entry in pending),
|
||||
)
|
||||
|
||||
def _fetch(entry: _PendingArchive, tracker: Callable[[int], None]) -> None:
|
||||
entry.dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with FileLock(f"{entry.dest}.lock", fallback_to_soft=False):
|
||||
download_with_resume(
|
||||
entry.url,
|
||||
downloads_dir / f"{entry.name}-{entry.version}",
|
||||
sha256=entry.sha256,
|
||||
size=entry.size,
|
||||
progress=tracker,
|
||||
)
|
||||
|
||||
failures = run_batch_downloads(
|
||||
"Downloading packages",
|
||||
[(entry.name, entry.size, partial(_fetch, entry)) for entry in pending],
|
||||
)
|
||||
for name, err in failures:
|
||||
if isinstance(err, (EsphomeError, OSError)):
|
||||
# Expected download failures: install_package retries this one
|
||||
# itself, with a visible bar
|
||||
_LOGGER.debug("Prefetch of %s failed: %s", name, err)
|
||||
else:
|
||||
# Anything else is a programming error that would otherwise
|
||||
# become a permanent silent no-op
|
||||
_LOGGER.warning("Prefetch of %s failed: %r", name, err)
|
||||
|
||||
|
||||
def install_package(
|
||||
name: str,
|
||||
version: str,
|
||||
dest: Path,
|
||||
mirrors: list[str],
|
||||
downloads_dir: Path,
|
||||
expect: Collection[str],
|
||||
) -> None:
|
||||
"""Download, verify, and extract one package if not already installed.
|
||||
|
||||
The registry path is integrity-checked against the sha256 the registry
|
||||
publishes; a mirror override (URL templates with ``{VERSION}``/``{SYSTEM}``
|
||||
substitution) is trusted as configured. ``downloads_dir`` holds the
|
||||
archive between runs so an interrupted download resumes.
|
||||
"""
|
||||
if not expect:
|
||||
# Layout validation before marker.touch() is the only guard against
|
||||
# caching a truncated mirror archive as a good install
|
||||
raise ValueError("install_package requires a non-empty expect")
|
||||
marker = dest / ".esphome_extracted"
|
||||
if marker.is_file():
|
||||
_check_layout(name, dest, expect)
|
||||
return
|
||||
from filelock import FileLock
|
||||
|
||||
# Serialize concurrent cold builds (same filelock pattern as git.py).
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A soft-lock fallback would turn a hard-killed run into a permanent
|
||||
# hang (see git.py).
|
||||
with FileLock(f"{dest}.lock", fallback_to_soft=False):
|
||||
if marker.is_file():
|
||||
# Another process finished the install while we waited
|
||||
return
|
||||
rmdir(dest, msg=f"Clean up incomplete {name} install")
|
||||
# Persistent location so an interrupted download resumes across runs.
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive = downloads_dir / f"{name}-{version}"
|
||||
_LOGGER.info("Downloading %s %s ...", name, version)
|
||||
if mirrors:
|
||||
_LOGGER.warning(
|
||||
"Downloading %s from a mirror override; checksum verification "
|
||||
"is skipped for mirrors",
|
||||
name,
|
||||
)
|
||||
download_from_mirrors(
|
||||
mirrors, {"VERSION": version, "SYSTEM": get_systype()}, archive
|
||||
)
|
||||
else:
|
||||
url, sha256, size = registry_download(name, version)
|
||||
download_with_resume(url, archive, sha256=sha256, size=size)
|
||||
_LOGGER.info("Extracting %s ...", name)
|
||||
archive_extract_all(archive, dest, progress_header="Extracting")
|
||||
# Validate the layout before recording success, so an unexpected
|
||||
# package is never cached as a working install.
|
||||
_check_layout(name, dest, expect)
|
||||
marker.touch()
|
||||
archive.unlink(missing_ok=True)
|
||||
@@ -4,18 +4,19 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import platformdirs
|
||||
|
||||
from esphome.build_helpers.ccache import resolve_ccache_path
|
||||
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix
|
||||
from esphome.helpers import (
|
||||
add_git_ceiling_directory,
|
||||
copy_file_if_changed,
|
||||
get_bool_env,
|
||||
rmtree,
|
||||
write_file,
|
||||
)
|
||||
@@ -40,6 +41,40 @@ _PIO_PYTHON_STAMP_LOCK = ".esphome.pio.stamp.lock"
|
||||
_PIO_PYTHON_STAMP_SCHEMA = "0"
|
||||
|
||||
|
||||
def _strip_win_long_path_prefix(path: str) -> str:
|
||||
r"""Strip the Windows extended-length path prefix from ``path``.
|
||||
|
||||
Handles both forms documented at
|
||||
https://learn.microsoft.com/windows/win32/fileio/naming-a-file:
|
||||
|
||||
* ``\\?\C:\path\to\file`` -> ``C:\path\to\file``
|
||||
* ``\\?\UNC\server\share\path`` -> ``\\server\share\path``
|
||||
|
||||
The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with
|
||||
``sys.executable`` already prefixed with ``\\?\``. That prefix propagates
|
||||
into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from
|
||||
the environment, falling back to ``os.path.normpath(sys.executable)``)
|
||||
and ends up baked into SCons-emitted command lines for build steps such
|
||||
as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand
|
||||
the ``\\?\`` prefix, so the build fails with
|
||||
"The system cannot find the path specified." Stripping the prefix early
|
||||
keeps the path shell-quotable.
|
||||
|
||||
Also applied to the ccache path exported by ``_ccache_env()``, which
|
||||
``shutil.which`` can return with the same prefix.
|
||||
|
||||
No-op on non-Windows platforms.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return path
|
||||
if path.startswith("\\\\?\\UNC\\"):
|
||||
# \\?\UNC\server\share\... -> \\server\share\...
|
||||
return "\\\\" + path[len("\\\\?\\UNC\\") :]
|
||||
if path.startswith("\\\\?\\"):
|
||||
return path[len("\\\\?\\") :]
|
||||
return path
|
||||
|
||||
|
||||
def get_platformio_config() -> "ProjectConfig | None":
|
||||
"""Return PlatformIO's ``ProjectConfig``, or None when PlatformIO is absent."""
|
||||
try:
|
||||
@@ -203,6 +238,32 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
|
||||
_write_pio_stamp_python(stamp_file, current)
|
||||
|
||||
|
||||
def _ccache_runs(ccache: str) -> bool:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs.
|
||||
|
||||
``shutil.which`` proves existence, not runnability: on Windows it also
|
||||
matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose
|
||||
target is gone. Wrapping compiles around such a find fails every compile
|
||||
step with an opaque OS error, so probe once and fall back to compiling
|
||||
without ccache when the probe fails.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
[ccache, "--version"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
_LOGGER.warning(
|
||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||
ccache,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ccache_env() -> dict[str, str]:
|
||||
r"""Return ccache settings for PlatformIO builds.
|
||||
|
||||
@@ -221,7 +282,7 @@ def _ccache_env() -> dict[str, str]:
|
||||
runs fine through ``CreateProcess``, which is how ESP-IDF invokes it,
|
||||
but SCons runs every compile through ``cmd.exe``, which fails on it with
|
||||
"The system cannot find the path specified." (#18399), so the prefix is
|
||||
stripped here with ``strip_win_long_path_prefix()`` before the
|
||||
stripped here with ``_strip_win_long_path_prefix()`` before the
|
||||
runnability probe, which therefore validates the exact string the build
|
||||
will execute.
|
||||
``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the
|
||||
@@ -247,8 +308,22 @@ def _ccache_env() -> dict[str, str]:
|
||||
build dir. The other ``CCACHE_*`` values the user already set in the
|
||||
environment are respected.
|
||||
"""
|
||||
ccache_path = resolve_ccache_path()
|
||||
explicit = "ESPHOME_CCACHE_ENABLE" in os.environ
|
||||
if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"):
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
ccache_path = shutil.which("ccache")
|
||||
if ccache_path is None:
|
||||
if explicit:
|
||||
_LOGGER.warning(
|
||||
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
|
||||
"compiling without ccache"
|
||||
)
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
# Strip before probing so the probe validates (and the failure warning
|
||||
# names) the exact string the build will execute through cmd.exe.
|
||||
ccache_path = _strip_win_long_path_prefix(ccache_path)
|
||||
# An explicit opt-in skips the runnability probe.
|
||||
if not explicit and not _ccache_runs(ccache_path):
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
env = {
|
||||
"ESPHOME_CCACHE_ENABLE": "1",
|
||||
@@ -310,7 +385,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
|
||||
# Strip the Windows extended-length path prefix from sys.executable so it
|
||||
# doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted
|
||||
# command lines run through cmd.exe.
|
||||
python_exe = strip_win_long_path_prefix(sys.executable)
|
||||
python_exe = _strip_win_long_path_prefix(sys.executable)
|
||||
if python_exe != sys.executable:
|
||||
# Only override PYTHONEXEPATH when we actually stripped a prefix.
|
||||
# PlatformIO's get_pythonexe_path() reads this and falls back to
|
||||
|
||||
+15
-51
@@ -288,13 +288,11 @@ def copy_src_tree():
|
||||
# Source file removed, delete target
|
||||
p.unlink()
|
||||
if target not in generated_files:
|
||||
_LOGGER.debug("Source removed: %s", target)
|
||||
sources_changed = True
|
||||
else:
|
||||
src_file = source_files_copy.pop(target)
|
||||
with src_file.path() as src_path:
|
||||
if copy_file_if_changed(src_path, p) and target not in generated_files:
|
||||
_LOGGER.debug("Source changed: %s", target)
|
||||
sources_changed = True
|
||||
|
||||
# Now copy new files
|
||||
@@ -305,25 +303,21 @@ def copy_src_tree():
|
||||
copy_file_if_changed(src_path, dst_path)
|
||||
and target not in generated_files
|
||||
):
|
||||
_LOGGER.debug("Source added: %s", target)
|
||||
sources_changed = True
|
||||
|
||||
# Finally copy defines
|
||||
if write_file_if_changed(
|
||||
CORE.relative_src_path("esphome", "core", "defines.h"), generate_defines_h()
|
||||
):
|
||||
_LOGGER.debug("Source changed: esphome/core/defines.h")
|
||||
sources_changed = True
|
||||
write_file_if_changed(CORE.relative_build_path("README.txt"), ESPHOME_README_TXT)
|
||||
if write_file_if_changed(
|
||||
CORE.relative_src_path("esphome.h"), ESPHOME_H_FORMAT.format(include_s)
|
||||
):
|
||||
_LOGGER.debug("Source changed: esphome.h")
|
||||
sources_changed = True
|
||||
if write_file_if_changed(
|
||||
CORE.relative_src_path("esphome", "core", "version.h"), generate_version_h()
|
||||
):
|
||||
_LOGGER.debug("Source changed: esphome/core/version.h")
|
||||
sources_changed = True
|
||||
|
||||
# Generate new build_info files if needed
|
||||
@@ -338,13 +332,18 @@ def copy_src_tree():
|
||||
|
||||
# Defensively force a rebuild if the build_info files don't exist, or if
|
||||
# there was a config change which didn't actually cause a source change
|
||||
if _build_info_stale(
|
||||
build_info_data_h_path,
|
||||
build_info_data_cpp_path,
|
||||
build_info_json_path,
|
||||
config_hash,
|
||||
):
|
||||
if not build_info_data_h_path.exists() or not build_info_data_cpp_path.exists():
|
||||
sources_changed = True
|
||||
else:
|
||||
try:
|
||||
existing = json.loads(build_info_json_path.read_text(encoding="utf-8"))
|
||||
if (
|
||||
existing.get("config_hash") != config_hash
|
||||
or existing.get("esphome_version") != __version__
|
||||
):
|
||||
sources_changed = True
|
||||
except (json.JSONDecodeError, KeyError, OSError):
|
||||
sources_changed = True
|
||||
|
||||
# Write build_info header and JSON metadata
|
||||
if sources_changed:
|
||||
@@ -398,38 +397,6 @@ def generate_version_h():
|
||||
)
|
||||
|
||||
|
||||
def _build_info_stale(
|
||||
h_path: Path, cpp_path: Path, json_path: Path, config_hash: int
|
||||
) -> bool:
|
||||
"""Whether the build-info sources must regenerate (missing or stale)."""
|
||||
if not h_path.exists() or not cpp_path.exists():
|
||||
_LOGGER.debug("Build info files missing; regenerating")
|
||||
return True
|
||||
try:
|
||||
existing = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
_LOGGER.debug("Build info JSON unreadable; regenerating")
|
||||
return True
|
||||
if not isinstance(existing, dict):
|
||||
# Valid JSON that is not an object (truncated or hand-edited) is
|
||||
# stale, not a traceback
|
||||
_LOGGER.debug("Build info JSON malformed; regenerating")
|
||||
return True
|
||||
if (
|
||||
existing.get("config_hash") != config_hash
|
||||
or existing.get("esphome_version") != __version__
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Build info stale (config_hash %s -> %s, version %s -> %s)",
|
||||
existing.get("config_hash"),
|
||||
config_hash,
|
||||
existing.get("esphome_version"),
|
||||
__version__,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_build_info() -> tuple[int, int, str, str]:
|
||||
"""Calculate build_info values from current config.
|
||||
|
||||
@@ -690,17 +657,14 @@ def clean_all(configuration: list[str]):
|
||||
# the per-config loop above can't reach. Wipe the default cache root
|
||||
# (also catches leftovers from older install layouts), then the resolved
|
||||
# install paths for the ESPHOME_*_PREFIX overrides (docker/add-on/CI)
|
||||
# that live outside it. Every backend's cache is listed in
|
||||
# TOOLS_CACHE_SPECS, so registering one there is the only step.
|
||||
# that live outside it.
|
||||
import platformdirs
|
||||
|
||||
from esphome.build_helpers.tools_cache import TOOLS_CACHE_SPECS, tools_cache_path
|
||||
from esphome.components.nrf52.framework import get_sdk_nrf_tools_path
|
||||
from esphome.espidf.framework import get_idf_tools_path
|
||||
|
||||
cache_root = Path(platformdirs.user_cache_dir("esphome", appauthor=False)).resolve()
|
||||
install_paths = [cache_root] + [
|
||||
tools_cache_path(*spec) for spec in TOOLS_CACHE_SPECS
|
||||
]
|
||||
for install_path in install_paths:
|
||||
for install_path in (cache_root, get_idf_tools_path(), get_sdk_nrf_tools_path()):
|
||||
if install_path.is_dir():
|
||||
_LOGGER.info("Deleting %s", install_path)
|
||||
rmtree(install_path)
|
||||
|
||||
@@ -28,7 +28,6 @@ smpclient==7.2.0
|
||||
requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.11.3 # native esp-idf toolchain global cache dir
|
||||
ninja==1.13.0 # native esp8266 arduino toolchain build driver
|
||||
filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
|
||||
# esp-idf >= 5.0 requires this
|
||||
|
||||
@@ -525,21 +525,13 @@ def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# Native-build infra: changes under esphome/espidf/, the shared
|
||||
# esphome/build_helpers/ package, or the modules the native ESP-IDF build
|
||||
# imports affect every esp32 IDF build (now the default toolchain) but aren't
|
||||
# ESP-IDF infra: changes under esphome/espidf/ or to the IDF build generator
|
||||
# affect every esp32 IDF build (now the default toolchain) but aren't
|
||||
# components, so the component matrix wouldn't otherwise force any esp32
|
||||
# compile. When they change we fold the `esp32` component into the matrix so
|
||||
# the default native-IDF build path is still compiled on an infra-only PR.
|
||||
ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/", "esphome/build_helpers/")
|
||||
ESP_IDF_INFRA_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"esphome/build_gen/espidf.py",
|
||||
"esphome/framework_helpers.py",
|
||||
"esphome/platformio/library.py",
|
||||
"esphome/platformio/extra_script.py",
|
||||
}
|
||||
)
|
||||
ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES = ("esphome/espidf/",)
|
||||
ESP_IDF_INFRA_TRIGGER_FILES = frozenset({"esphome/build_gen/espidf.py"})
|
||||
|
||||
|
||||
def _esp_idf_infra_changed(files: list[str]) -> bool:
|
||||
|
||||
@@ -131,20 +131,6 @@ def test_esp32_rejects_unsupported_toolchains(
|
||||
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
|
||||
|
||||
|
||||
def test_esp32_rejects_unsupported_cli_toolchain(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A --toolchain the platform cannot serve fails instead of silently
|
||||
building with PlatformIO (the CLI path bypasses the YAML validator)."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
from esphome.components.esp32 import CONFIG_SCHEMA
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
CONFIG_SCHEMA({"variant": VARIANT_ESP32})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "error_match"),
|
||||
[
|
||||
|
||||
@@ -1120,14 +1120,7 @@ def test_should_run_esp32_platformio_with_branch() -> None:
|
||||
(["esphome/espidf/runner.py"], True),
|
||||
(["esphome/espidf/framework.py"], True),
|
||||
(["esphome/build_gen/espidf.py"], True),
|
||||
# Shared native-build modules the IDF build imports -> trigger
|
||||
(["esphome/build_helpers/idedata.py"], True),
|
||||
(["esphome/platformio/library.py"], True),
|
||||
(["esphome/framework_helpers.py"], True),
|
||||
(["esphome/platformio/extra_script.py"], True),
|
||||
# PlatformIO build gen, its toolchain, and the esp32 component are
|
||||
# NOT IDF-infra triggers
|
||||
(["esphome/platformio/toolchain.py"], False),
|
||||
# PlatformIO build gen and esp32 component are NOT IDF-infra triggers
|
||||
(["esphome/build_gen/platformio.py"], False),
|
||||
(["esphome/components/esp32/__init__.py"], False),
|
||||
(["README.md"], False),
|
||||
@@ -1139,16 +1132,6 @@ def test_esp_idf_infra_changed(changed_files: list[str], expected: bool) -> None
|
||||
assert determine_jobs._esp_idf_infra_changed(changed_files) is expected
|
||||
|
||||
|
||||
def test_esp_idf_infra_trigger_paths_exist() -> None:
|
||||
"""A renamed or moved trigger module must fail here, not silently stop
|
||||
forcing the esp32 IDF compile."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
for file in determine_jobs.ESP_IDF_INFRA_TRIGGER_FILES:
|
||||
assert (repo_root / file).is_file(), f"trigger file {file} moved or renamed"
|
||||
for prefix in determine_jobs.ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES:
|
||||
assert (repo_root / prefix).is_dir(), f"trigger dir {prefix} moved or renamed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
|
||||
@@ -9,7 +9,7 @@ from esphome.analyze_memory.toolchain import (
|
||||
find_idedata_path,
|
||||
idedata_candidates,
|
||||
)
|
||||
from esphome.build_helpers.idedata import _cc_path_from_cxx
|
||||
from esphome.espidf.idedata import _cc_path_from_cxx
|
||||
from esphome.platformio.toolchain import IDEData
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,247 +0,0 @@
|
||||
"""Tests for the ninja build-tool helper script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_gen import build_tool
|
||||
|
||||
|
||||
def test_ar_removes_stale_archive(tmp_path: Path) -> None:
|
||||
archive = tmp_path / "lib.a"
|
||||
archive.write_text("stale")
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
rsp.write_text("a.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
assert not archive.exists()
|
||||
# The rspfile is expanded by the shim (GNU ar would escape backslashes)
|
||||
assert mock_run.call_args[0][0] == ["ar-bin", "rc", str(archive), "a.o"]
|
||||
|
||||
|
||||
def test_copy(tmp_path: Path) -> None:
|
||||
src = tmp_path / "firmware.bin"
|
||||
src.write_text("data")
|
||||
dst = tmp_path / "firmware.factory.bin"
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["build_tool", "copy", str(src), str(dst)]
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
assert dst.read_text() == "data"
|
||||
|
||||
|
||||
def test_unknown_mode(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with patch.object(build_tool.sys, "argv", ["build_tool", "bogus"]):
|
||||
assert build_tool.main() == 1
|
||||
assert "unknown build_tool mode" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_runs_as_script(tmp_path: Path) -> None:
|
||||
"""The ninja rules invoke the file as a plain script."""
|
||||
|
||||
src = tmp_path / "a.bin"
|
||||
src.write_text("x")
|
||||
dst = tmp_path / "b.bin"
|
||||
result = subprocess.run(
|
||||
[sys.executable, build_tool.__file__, "copy", str(src), str(dst)],
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert dst.read_text() == "x"
|
||||
|
||||
|
||||
def test_ar_expands_rspfile_without_escaping(tmp_path) -> None:
|
||||
"""Backslash paths survive: the shim expands the rspfile itself instead
|
||||
of letting GNU ar treat backslashes as escapes."""
|
||||
rsp = tmp_path / "objs.rsp"
|
||||
rsp.write_text("obj/a.o\nsub\\b.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(tmp_path / "lib.a"), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
assert mock_run.call_args[0][0] == [
|
||||
"ar-bin",
|
||||
"rc",
|
||||
str(tmp_path / "lib.a"),
|
||||
"obj/a.o",
|
||||
"sub\\b.o",
|
||||
]
|
||||
|
||||
|
||||
def test_ar_unquotes_ninja_escaped_paths(tmp_path: Path) -> None:
|
||||
"""The shim strips a simple surrounding quote, since ninja shell-
|
||||
quotes special rsp paths, so ar sees the real filename."""
|
||||
rsp = tmp_path / "t.rsp"
|
||||
rsp.write_text("'obj/a b.o'\nobj/c.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys, "argv", ["bt", "ar", "/usr/bin/ar", "lib.a", str(rsp)]
|
||||
),
|
||||
patch.object(build_tool.subprocess, "run") as mock_run,
|
||||
):
|
||||
mock_run.return_value.returncode = 0
|
||||
rc = build_tool.main()
|
||||
assert rc == 0
|
||||
assert mock_run.call_args.args[0] == [
|
||||
"/usr/bin/ar",
|
||||
"rc",
|
||||
"lib.a",
|
||||
"obj/a b.o",
|
||||
"obj/c.o",
|
||||
]
|
||||
|
||||
|
||||
def test_ar_empty_object_list_fails(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A lost object list is an error here, not undefined symbols at link."""
|
||||
rsp = tmp_path / "t.rsp"
|
||||
rsp.write_text("\n\n")
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["bt", "ar", "/usr/bin/ar", "lib.a", str(rsp)]
|
||||
):
|
||||
rc = build_tool.main()
|
||||
assert rc == 1
|
||||
assert "no objects listed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_ar_batches_long_object_lists(tmp_path: Path) -> None:
|
||||
"""The expanded argv must stay under the Windows 32767-char limit: a
|
||||
long object list creates with rc, then appends with q."""
|
||||
archive = tmp_path / "lib.a"
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
objects = [f"dir/{'x' * 120}_{i}.o" for i in range(400)]
|
||||
rsp.write_text("\n".join(objects) + "\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess, "run", return_value=MagicMock(returncode=0)
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 0
|
||||
calls = [c[0][0] for c in mock_run.call_args_list]
|
||||
assert len(calls) > 1
|
||||
assert calls[0][1] == "rc"
|
||||
assert all(c[1] == "q" for c in calls[1:])
|
||||
assert [o for c in calls for o in c[3:]] == objects
|
||||
assert all(sum(len(a) + 1 for a in c) < 32000 for c in calls)
|
||||
|
||||
|
||||
def test_ar_batch_failure_stops(tmp_path: Path) -> None:
|
||||
"""A failing batch propagates its exit code without running the rest."""
|
||||
archive = tmp_path / "lib.a"
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
rsp.write_text("\n".join(f"{'y' * 200}_{i}.o" for i in range(300)) + "\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess,
|
||||
"run",
|
||||
side_effect=lambda cmd, **kw: (
|
||||
archive.write_text("partial"),
|
||||
MagicMock(returncode=3),
|
||||
)[1],
|
||||
) as mock_run,
|
||||
):
|
||||
assert build_tool.main() == 3
|
||||
assert mock_run.call_count == 1
|
||||
# The failed batch must not leave a truncated archive behind
|
||||
assert not archive.exists()
|
||||
|
||||
|
||||
def test_ar_exception_leaves_no_partial_archive(tmp_path: Path) -> None:
|
||||
"""A missing ar binary mid-loop must not leave a truncated archive from
|
||||
earlier successful batches."""
|
||||
archive = tmp_path / "lib.a"
|
||||
rsp = tmp_path / "lib.a.rsp"
|
||||
rsp.write_text("a.o\n")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "ar", "ar-bin", str(archive), str(rsp)],
|
||||
),
|
||||
patch.object(
|
||||
build_tool.subprocess,
|
||||
"run",
|
||||
side_effect=lambda cmd, **kw: (
|
||||
archive.write_text("partial"),
|
||||
(_ for _ in ()).throw(FileNotFoundError("no ar")),
|
||||
),
|
||||
),
|
||||
pytest.raises(FileNotFoundError),
|
||||
):
|
||||
build_tool.main()
|
||||
assert not archive.exists()
|
||||
|
||||
|
||||
def test_surplus_arguments_error(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A mis-specified ninja rule passing extra operands errors instead of
|
||||
silently dropping them."""
|
||||
with patch.object(
|
||||
build_tool.sys, "argv", ["build_tool", "copy", "a", "b", "extra"]
|
||||
):
|
||||
assert build_tool.main() == 1
|
||||
assert "expected 2 arguments, got 3" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_copy_same_file_keeps_the_input(tmp_path: Path) -> None:
|
||||
"""A same-file copy (dst IS src) must not unlink the input."""
|
||||
src = tmp_path / "firmware.bin"
|
||||
src.write_bytes(b"image")
|
||||
with (
|
||||
patch.object(
|
||||
build_tool.sys, "argv", ["build_tool", "copy", str(src), str(src)]
|
||||
),
|
||||
pytest.raises(shutil.SameFileError),
|
||||
):
|
||||
build_tool.main()
|
||||
assert src.read_bytes() == b"image"
|
||||
|
||||
|
||||
def test_copy_failure_leaves_no_partial_output(tmp_path: Path) -> None:
|
||||
"""A failed copy unlinks the destination; a partial firmware image must
|
||||
never be left on disk."""
|
||||
dst = tmp_path / "firmware.factory.bin"
|
||||
dst.write_text("stale")
|
||||
with (
|
||||
patch.object(build_tool.shutil, "copyfile", side_effect=OSError("disk full")),
|
||||
patch.object(
|
||||
build_tool.sys,
|
||||
"argv",
|
||||
["build_tool", "copy", str(tmp_path / "src.bin"), str(dst)],
|
||||
),
|
||||
pytest.raises(OSError),
|
||||
):
|
||||
build_tool.main()
|
||||
assert not dst.exists()
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Tests for the shared ccache policy in esphome.build_helpers.ccache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import ccache
|
||||
|
||||
|
||||
def test_resolve_opt_out() -> None:
|
||||
with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
|
||||
|
||||
def test_resolve_no_binary(caplog: pytest.LogCaptureFixture) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("shutil.which", return_value=None),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
assert "no ccache binary" not in caplog.text
|
||||
|
||||
|
||||
def test_resolve_probe_failure() -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
|
||||
|
||||
def test_resolve_explicit_skips_probe_and_warns_missing(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch.object(ccache, "_ccache_runs", side_effect=AssertionError),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() == "/usr/bin/ccache"
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
|
||||
patch("shutil.which", return_value=None),
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
assert "no ccache binary is on PATH" in caplog.text
|
||||
|
||||
|
||||
def test_probe_spawns_with_close_fds_false() -> None:
|
||||
with patch("esphome.framework_helpers.subprocess.run") as mock_run:
|
||||
assert ccache._ccache_runs("/usr/bin/ccache") is True
|
||||
assert mock_run.call_args.kwargs["close_fds"] is False
|
||||
|
||||
|
||||
def test_defaults_env(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("esphome.core.CORE", SimpleNamespace(build_path=tmp_path / "b")),
|
||||
patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True),
|
||||
):
|
||||
env = ccache.ccache_defaults_env(tmp_path / "cache")
|
||||
assert env["CCACHE_DIR"] == str(tmp_path / "cache")
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
assert "CCACHE_NOHASHDIR" not in env # user value respected
|
||||
|
||||
|
||||
def test_defaults_env_requires_build_path() -> None:
|
||||
with (
|
||||
patch("esphome.core.CORE", SimpleNamespace(build_path=None)),
|
||||
pytest.raises(ValueError, match="build_path"),
|
||||
):
|
||||
ccache.ccache_defaults_env(Path("/x"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["no", "off", "false", "0"])
|
||||
def test_resolve_opt_out_synonyms(value: str) -> None:
|
||||
"""Every recognized falsy spelling disables ccache."""
|
||||
with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": value}):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
|
||||
|
||||
def test_resolve_unrecognized_value_warns_and_probes(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An unparsable ESPHOME_CCACHE_ENABLE is treated as unset: it must not
|
||||
silently enable ccache or skip the runnability probe."""
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "enabled"}),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch.object(ccache, "_ccache_runs", return_value=False) as mock_probe,
|
||||
):
|
||||
assert ccache.resolve_ccache_path() is None
|
||||
mock_probe.assert_called_once()
|
||||
assert "unrecognized ESPHOME_CCACHE_ENABLE" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("1", True),
|
||||
("enable", True),
|
||||
("ON", True),
|
||||
("0", False),
|
||||
("disable", False),
|
||||
("Off", False),
|
||||
("maybe", None),
|
||||
# ENV KNOB= (Docker/CI) has always read as a disable
|
||||
("", False),
|
||||
(" ", False),
|
||||
],
|
||||
)
|
||||
def test_parse_enable_env_spelling_tables(
|
||||
monkeypatch: pytest.MonkeyPatch, raw: str, expected: bool | None
|
||||
) -> None:
|
||||
"""cv.boolean's spelling tables plus the 1/0 env convention."""
|
||||
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw)
|
||||
assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected
|
||||
@@ -1,678 +0,0 @@
|
||||
"""Tests for esphome.build_helpers.idedata (compile_commands.json -> idedata)."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import idedata
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so
|
||||
# tests exercise the same is-absolute / normalize behavior as a real compile DB
|
||||
# (a drive-qualified path on Windows, a leading slash elsewhere).
|
||||
ABS = "C:/" if os.name == "nt" else "/"
|
||||
|
||||
|
||||
def _entry(directory: str, file: str, command: str) -> dict:
|
||||
return {"directory": directory, "file": file, "command": command}
|
||||
|
||||
|
||||
def test_parse_entry_extracts_fields() -> None:
|
||||
"""cxx_path, defines, includes and remaining flags are split apart."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 "
|
||||
f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
assert cxx_path == "/tools/xtensa-esp32-elf-g++"
|
||||
assert "USE_ESP32" in defines
|
||||
assert "ESPHOME_LOG_LEVEL=5" in defines
|
||||
assert f"{ABS}inc/a" in includes
|
||||
assert f"{ABS}sys/b" in includes
|
||||
assert "-std=gnu++20" in cxx_flags
|
||||
# input/output files and their flags are not treated as flags
|
||||
assert "-c" not in cxx_flags
|
||||
assert "-o" not in cxx_flags
|
||||
assert "app.cpp" not in cxx_flags
|
||||
assert "app.cpp.o" not in cxx_flags
|
||||
|
||||
|
||||
def test_parse_entry_space_separated_args() -> None:
|
||||
"""``-D X`` / ``-I path`` (separate arg) and ``-isystem<path>`` (joined)."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/x.cpp",
|
||||
f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp",
|
||||
)
|
||||
|
||||
_, defines, includes, _ = idedata.parse_entry(entry)
|
||||
|
||||
assert "FOO=1" in defines
|
||||
assert f"{ABS}inc/sep" in includes
|
||||
assert f"{ABS}sys/joined" in includes
|
||||
|
||||
|
||||
def test_parse_entry_resolves_relative_includes() -> None:
|
||||
"""Relative includes are resolved against the entry's ``directory``."""
|
||||
directory = f"{ABS}build/proj"
|
||||
entry = _entry(
|
||||
directory,
|
||||
f"{directory}/src/esphome/x.cpp",
|
||||
"g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, includes, _ = idedata.parse_entry(entry)
|
||||
|
||||
def resolved(rel: str) -> str:
|
||||
# parse_entry emits forward slashes for consistency (normpath would
|
||||
# yield backslashes on Windows).
|
||||
return os.path.normpath(Path(directory) / rel).replace("\\", "/")
|
||||
|
||||
assert resolved("config") in includes
|
||||
assert resolved("../shared") in includes # ../ normalized away
|
||||
assert resolved("rel/sys") in includes
|
||||
# nothing is left relative
|
||||
assert all(Path(inc).is_absolute() for inc in includes)
|
||||
|
||||
|
||||
def test_parse_entry_skips_dependency_flags() -> None:
|
||||
"""Dependency-generation flags (and their args) are dropped."""
|
||||
entry = _entry(
|
||||
"/build",
|
||||
"/build/src/esphome/x.cpp",
|
||||
"g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata.parse_entry(entry)
|
||||
|
||||
for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"):
|
||||
assert tok not in cxx_flags
|
||||
|
||||
|
||||
def test_expand_response_files(tmp_path: Path) -> None:
|
||||
"""``@file`` arguments are inlined relative to the directory."""
|
||||
rsp = tmp_path / "flags.rsp"
|
||||
rsp.write_text("-DFROM_RSP -I/rsp/inc")
|
||||
|
||||
tokens = idedata._expand_response_files(
|
||||
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
|
||||
)
|
||||
|
||||
assert "-DFROM_RSP" in tokens
|
||||
assert "-I/rsp/inc" in tokens
|
||||
assert not any(t.startswith("@") for t in tokens)
|
||||
|
||||
|
||||
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
|
||||
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
|
||||
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
|
||||
assert "@nope.rsp" in tokens
|
||||
|
||||
|
||||
def test_pick_entry_prefers_esphome_tu() -> None:
|
||||
"""A ``/src/esphome/`` C++ TU is picked over other compile entries."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
|
||||
|
||||
|
||||
def test_pick_entry_falls_back_to_any_cxx_tu() -> None:
|
||||
"""With no ``/src/esphome/`` TU present, the first C++ entry is the fallback."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("x.cpp")
|
||||
|
||||
|
||||
def test_is_esphome_src_handles_backslash_paths() -> None:
|
||||
r"""The src marker must match Windows ``\src\esphome\`` paths too.
|
||||
|
||||
compile_commands ``file`` entries use the OS-native separator; if the
|
||||
marker only matched forward slashes no source would match on Windows and
|
||||
the build-include union would be silently empty.
|
||||
"""
|
||||
assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp")
|
||||
assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp")
|
||||
# non-esphome and non-C++ still rejected regardless of separator
|
||||
assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp")
|
||||
assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "launcher"),
|
||||
[
|
||||
("", None),
|
||||
# A command that is only the launcher strips to nothing
|
||||
("/usr/bin/ccache", "/usr/bin/ccache"),
|
||||
],
|
||||
)
|
||||
def test_parse_entry_empty_command_raises(command: str, launcher: str | None) -> None:
|
||||
"""A blank (or launcher-only) command fails with a named ValueError,
|
||||
not an IndexError."""
|
||||
entry = {"directory": "/b", "file": "/b/src/x.cpp", "command": command}
|
||||
with pytest.raises(ValueError, match="empty compile command"):
|
||||
idedata.parse_entry(entry, launcher)
|
||||
|
||||
|
||||
def test_idedata_from_build_empty_includes_raises(tmp_path: Path) -> None:
|
||||
"""A compile DB with no ESPHome TU is never usable idedata and must
|
||||
not be cached (call sites downgrade the raise to a build warning)."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
_entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/other/lib.cpp",
|
||||
"/tools/g++ -c other/lib.cpp -o lib.o",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
with (
|
||||
patch.object(idedata, "get_toolchain_includes", return_value=[]),
|
||||
pytest.raises(EsphomeError, match="No ESPHome translation unit found"),
|
||||
):
|
||||
idedata.idedata_from_build(compile_commands)
|
||||
|
||||
|
||||
def test_idedata_from_build_rsp_commands_never_dedupe(tmp_path: Path) -> None:
|
||||
"""Per-object response files strip to one shape while holding different
|
||||
include sets; @-commands must tokenize per TU."""
|
||||
entries = []
|
||||
for name in ("a", "b"):
|
||||
rsp = tmp_path / f"{name}.cpp.o.rsp"
|
||||
rsp.write_text(f"-I{ABS}inc/{name}")
|
||||
file = f"{ABS}build/src/esphome/core/{name}.cpp"
|
||||
entries.append(
|
||||
{
|
||||
"directory": str(tmp_path),
|
||||
"file": file,
|
||||
"command": f"/tools/g++ @{rsp.name} -c {file} -o {name}.o",
|
||||
"output": f"{name}.o",
|
||||
}
|
||||
)
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
joined = " ".join(data["includes"]["build"])
|
||||
assert "inc/a" in joined and "inc/b" in joined
|
||||
|
||||
|
||||
def test_idedata_from_build_dedupes_identical_command_shapes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Translation units sharing one ninja rule (same command modulo
|
||||
file/output) carry
|
||||
identical includes, so only one per shape is tokenized; a differing
|
||||
shape still contributes its includes."""
|
||||
|
||||
def _tu(name: str, inc: str) -> dict:
|
||||
# ninja's compdb embeds the file and output strings verbatim
|
||||
file = f"{ABS}build/src/esphome/core/{name}.cpp"
|
||||
return _entry(
|
||||
f"{ABS}build", file, f"/tools/g++ -I{ABS}inc/{inc} -c {file} -o {name}.o"
|
||||
) | {"output": f"{name}.o"}
|
||||
|
||||
entries = [_tu(name, "shared") for name in ("application", "component", "helpers")]
|
||||
entries.append(_tu("extra", "extra"))
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
with (
|
||||
patch.object(idedata, "get_toolchain_includes", return_value=[]),
|
||||
patch.object(idedata, "parse_entry", wraps=idedata.parse_entry) as spy,
|
||||
):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
includes = set(data["includes"]["build"])
|
||||
assert f"{ABS}inc/shared".replace("\\", "/") in {
|
||||
i.replace("\\", "/") for i in includes
|
||||
}
|
||||
assert any("inc/extra" in i for i in includes)
|
||||
# Representative + one distinct shape; the two same-shape duplicates
|
||||
# are never tokenized
|
||||
assert spy.call_count == 2
|
||||
|
||||
|
||||
def test_idedata_from_build(tmp_path: Path) -> None:
|
||||
"""Full transform: representative entry + include union + toolchain dirs."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
entries = [
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/core/app.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
),
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/sensor/s.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o",
|
||||
),
|
||||
# non-esphome TU: its includes must not leak into the union
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/managed_components/x/x.c",
|
||||
f"gcc -I{ABS}inc/managed -c x.c",
|
||||
),
|
||||
]
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr=(
|
||||
"ignored\n"
|
||||
"#include <...> search starts here:\n"
|
||||
" /tc/inc/c++\n"
|
||||
" /tc/inc\n"
|
||||
"End of search list.\n"
|
||||
"more ignored\n"
|
||||
),
|
||||
)
|
||||
with patch.object(idedata.subprocess, "run", return_value=fake_proc):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
|
||||
assert data["cxx_path"] == "g++"
|
||||
assert "USE_ESP32" in data["defines"]
|
||||
assert "-std=gnu++20" in data["cxx_flags"]
|
||||
# include dirs unioned across all esphome TUs
|
||||
assert f"{ABS}inc/core" in data["includes"]["build"]
|
||||
assert f"{ABS}inc/sensor" in data["includes"]["build"]
|
||||
# the non-esphome TU is excluded from the union
|
||||
assert f"{ABS}inc/managed" not in data["includes"]["build"]
|
||||
# toolchain search dirs parsed from the compiler's -v output
|
||||
assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"]
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_on_probe_failure() -> None:
|
||||
"""A failed compiler probe is a hard error, not a silent empty list."""
|
||||
fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found")
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata.get_toolchain_includes("/bad/compiler")
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_when_no_dirs_found() -> None:
|
||||
"""Markers present but no dirs (anomalous output) also raises."""
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr="#include <...> search starts here:\nEnd of search list.\n",
|
||||
)
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata.get_toolchain_includes("/some/compiler")
|
||||
|
||||
|
||||
# ESP-IDF's compile_commands.json on Windows mixes literal backslash path
|
||||
# separators in the compiler path with shell ``\"`` quote-escaping in defines,
|
||||
# which only the real Windows argv parser handles. These exercise that path.
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
|
||||
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
|
||||
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
|
||||
|
||||
tokens = idedata._split_command(command)
|
||||
|
||||
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
|
||||
assert '-DVER="1.2.3"' in tokens
|
||||
assert "-IC:/inc/a" in tokens
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_empty_returns_empty() -> None:
|
||||
"""An empty or blank command tokenizes to ``[]`` (e.g. an empty response file).
|
||||
|
||||
Guards against ``CommandLineToArgvW("")`` returning the current process name
|
||||
instead of an empty list.
|
||||
"""
|
||||
assert idedata._split_command("") == []
|
||||
assert idedata._split_command(" ") == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_parse_entry_normalizes_windows_cxx_path() -> None:
|
||||
"""A backslash compiler path is emitted forward-slashed; define unescaped."""
|
||||
entry = _entry(
|
||||
r"C:\b",
|
||||
r"C:\b\src\esphome\x.cpp",
|
||||
r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, _ = idedata.parse_entry(entry)
|
||||
|
||||
assert cxx_path == "C:/esp/bin/g++.exe"
|
||||
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"]
|
||||
|
||||
|
||||
def test_parse_entry_recovers_from_unconfigured_launcher(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A stale compile DB built with a launcher this run no longer configures
|
||||
still yields the real compiler (the next token), not the launcher."""
|
||||
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",
|
||||
)
|
||||
caplog.set_level(logging.DEBUG)
|
||||
cxx_path, _, _, _ = idedata.parse_entry(entry)
|
||||
assert cxx_path == "/tools/xtensa-lx106-elf-g++"
|
||||
assert "Stripping unconfigured launcher" in caplog.text
|
||||
|
||||
|
||||
def test_parse_entry_rejects_launcher_without_program() -> None:
|
||||
"""A launcher followed only by flags is rejected in the parser itself,
|
||||
so no caller can record ccache as the compiler."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
"/opt/homebrew/bin/ccache -c a.cpp -o a.o",
|
||||
)
|
||||
with pytest.raises(EsphomeError, match="compile database is unusable"):
|
||||
idedata.parse_entry(entry)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_load_or_build_idedata_rebuilds_when_compile_db_newer(tmp_path: Path) -> None:
|
||||
"""A compile DB newer than the cache forces regeneration."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache.json"
|
||||
cache.write_text(json.dumps({"cc_path": "stale"}))
|
||||
os.utime(compile_commands, (cache.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 data["cc_path"] != "stale"
|
||||
|
||||
|
||||
def test_load_or_build_idedata_rebuilds_non_dict_cache(tmp_path: Path) -> None:
|
||||
"""Valid JSON that is not an object is regenerated, never handed out.
|
||||
|
||||
A bare string would otherwise pass the cc_path check by substring.
|
||||
"""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "cache.json"
|
||||
for bad in ('"cc_path is a string"', "[]", "42"):
|
||||
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 isinstance(data, dict)
|
||||
assert "cc_path" in data
|
||||
|
||||
|
||||
def test_is_launcher_matches_only_known_launchers() -> None:
|
||||
"""Compilers of any shape pass; only the closed launcher set matches."""
|
||||
for token in ("/t/g++-13", "gcc-8.4.0", "clang++-17", "armcc", "icx", "cc"):
|
||||
assert not idedata._is_launcher(token)
|
||||
for token in ("/opt/homebrew/bin/ccache", "CCACHE.EXE", "distcc", "sccache"):
|
||||
assert idedata._is_launcher(token)
|
||||
|
||||
|
||||
def test_load_or_build_idedata_corrupted_cache_is_logged(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A truncated cache is diagnosable, not a silent slow-build cause."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text('{"cc_path": trunc')
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cxx_path"] == "/tools/g++"
|
||||
assert "Discarding unreadable idedata cache" in caplog.text
|
||||
|
||||
|
||||
def test_load_or_build_idedata_discards_unreadable_cache_file(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An OSError on the cache read (permissions, I/O) regenerates like a
|
||||
parse failure instead of aborting the consumer."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text("{}")
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
real_read_text = Path.read_text
|
||||
|
||||
def fail_cache_read(self: Path, *args: object, **kwargs: object) -> str:
|
||||
# chmod(0) cannot revoke read access on Windows, so fault the read
|
||||
# itself for a platform-independent OSError
|
||||
if self == cache:
|
||||
raise OSError("permission denied")
|
||||
return real_read_text(self, *args, **kwargs)
|
||||
|
||||
with (
|
||||
patch.object(idedata, "get_toolchain_includes", return_value=[]),
|
||||
patch.object(Path, "read_text", fail_cache_read),
|
||||
):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cxx_path"] == "/tools/g++"
|
||||
assert "Discarding unreadable idedata cache" in caplog.text
|
||||
|
||||
|
||||
def test_load_or_build_idedata_never_caches_a_launcher(tmp_path: Path) -> None:
|
||||
"""A compile DB naming a launcher as the compiler is rejected, never cached."""
|
||||
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",
|
||||
"/opt/homebrew/bin/ccache -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
cache = tmp_path / "c.json"
|
||||
# No probe patch needed: the launcher is rejected before the probe runs
|
||||
with pytest.raises(EsphomeError, match="compile database is unusable"):
|
||||
idedata.load_or_build_idedata(compile_commands, tmp_path / "f.elf", cache)
|
||||
assert not cache.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cached",
|
||||
(
|
||||
{"cc_path": "/x/gcc", "cxx_path": "/opt/homebrew/bin/ccache"},
|
||||
{"cc_path": "/x/gcc", "cxx_path": "/tools/g++"},
|
||||
{"cc_path": "/x/gcc", "cxx_path": "/tools/g++", "includes": {}},
|
||||
),
|
||||
ids=("launcher-cxx", "no-includes", "no-build-list"),
|
||||
)
|
||||
def test_load_or_build_idedata_regenerates_invalid_cache(
|
||||
tmp_path: Path, cached: dict
|
||||
) -> None:
|
||||
"""A cache written by an older version fails validation and regenerates."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text(json.dumps(cached))
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
assert data["cxx_path"] == "/tools/g++"
|
||||
assert "includes" in data
|
||||
|
||||
|
||||
def test_load_or_build_idedata_cache_hit_restamps_prog_path(tmp_path: Path) -> None:
|
||||
"""A served cache carries the current ELF path, not the one it was written with."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cc_path": "/tools/gcc",
|
||||
"cxx_path": "/tools/g++",
|
||||
"includes": {"build": [], "toolchain": []},
|
||||
"prog_path": "/old/location/firmware.elf",
|
||||
}
|
||||
)
|
||||
)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "firmware.elf", cache
|
||||
)
|
||||
assert data["prog_path"] == str(tmp_path / "firmware.elf")
|
||||
|
||||
|
||||
def test_idedata_from_build_non_list_compile_db_raises(tmp_path: Path) -> None:
|
||||
"""Valid JSON that is not a list raises by name, inside the best-effort tuple."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
for bad in ("{}", "null", '"text"', '["a", "b"]', "[1, 2]"):
|
||||
compile_commands.write_text(bad)
|
||||
with pytest.raises(EsphomeError, match="not a compile-command list"):
|
||||
idedata.idedata_from_build(compile_commands)
|
||||
|
||||
|
||||
def test_idedata_from_build_same_file_rsp_commands_never_dedupe(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Two objects built from one source with different .rsp files keep both
|
||||
include sets; the rsp sentinel keys on the output, not the source."""
|
||||
file = f"{ABS}build/src/esphome/core/shared.cpp"
|
||||
entries = []
|
||||
for name in ("a", "b"):
|
||||
rsp = tmp_path / f"{name}.o.rsp"
|
||||
rsp.write_text(f"-I{ABS}inc/{name}")
|
||||
entries.append(
|
||||
{
|
||||
"directory": str(tmp_path),
|
||||
"file": file,
|
||||
"command": f"/tools/g++ @{rsp.name} -c {file} -o {name}.o",
|
||||
"output": f"{name}.o",
|
||||
}
|
||||
)
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
with patch.object(idedata, "get_toolchain_includes", return_value=[]):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
joined = " ".join(data["includes"]["build"])
|
||||
assert "inc/a" in joined and "inc/b" in joined
|
||||
|
||||
|
||||
def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None:
|
||||
"""A valid cache newer than the compile DB is served without re-parsing."""
|
||||
compile_commands = _write_compile_commands(tmp_path)
|
||||
cache = tmp_path / "c.json"
|
||||
cache.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"cc_path": "/tools/gcc",
|
||||
"cxx_path": "/tools/g++",
|
||||
"includes": {"build": ["/inc"], "toolchain": []},
|
||||
"cached": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
os.utime(cache, (compile_commands.stat().st_mtime + 5,) * 2)
|
||||
with patch.object(idedata, "idedata_from_build") as mock_build:
|
||||
data = idedata.load_or_build_idedata(
|
||||
compile_commands, tmp_path / "f.elf", cache
|
||||
)
|
||||
mock_build.assert_not_called()
|
||||
assert data["cached"] is True
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Tests for esphome.build_helpers.ninja."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import ninja as ninja_helper
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
|
||||
def test_find_ninja_prefers_path(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch("shutil.which", return_value=str(tmp_path / "ninja")),
|
||||
patch.object(ninja_helper, "_ninja_runs", return_value=True),
|
||||
):
|
||||
assert ninja_helper.find_ninja() == tmp_path / "ninja"
|
||||
|
||||
|
||||
def test_find_ninja_falls_back_to_wheel(tmp_path: Path) -> None:
|
||||
"""Without a PATH entry, the ninja PyPI wheel's binary is used."""
|
||||
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
|
||||
(tmp_path / binary_name).touch()
|
||||
wheel = MagicMock(BIN_DIR=str(tmp_path))
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
patch.dict(sys.modules, {"ninja": wheel}),
|
||||
):
|
||||
assert ninja_helper.find_ninja() == tmp_path / binary_name
|
||||
|
||||
|
||||
def test_find_ninja_package_not_installed() -> None:
|
||||
"""A missing ninja package raises the actionable message, not ImportError."""
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
patch.dict(sys.modules, {"ninja": None}),
|
||||
pytest.raises(EsphomeError, match="ninja not found"),
|
||||
):
|
||||
ninja_helper.find_ninja()
|
||||
|
||||
|
||||
def test_find_ninja_missing_everywhere(tmp_path: Path) -> None:
|
||||
wheel = MagicMock(BIN_DIR=str(tmp_path))
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
patch.dict(sys.modules, {"ninja": wheel}),
|
||||
pytest.raises(EsphomeError, match="ninja not found"),
|
||||
):
|
||||
ninja_helper.find_ninja()
|
||||
|
||||
|
||||
def test_escape_ninja_specials() -> None:
|
||||
assert ninja_helper.escape("a b:c$d") == "a$ b$:c$$d"
|
||||
|
||||
|
||||
def _q(tok: str) -> str:
|
||||
"""The platform's shell_token quote wrapper (argv rule on Windows)."""
|
||||
return f'"{tok}"' if os.name == "nt" else f"'{tok}'"
|
||||
|
||||
|
||||
def test_quote_arg_windows_argv_rule() -> None:
|
||||
# Backslash runs double only before a quote (subprocess.list2cmdline rule)
|
||||
assert ninja_helper.quote_arg('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"'
|
||||
assert ninja_helper.quote_arg("a b\\") == '"a b\\\\"'
|
||||
|
||||
|
||||
def test_shell_token_quotes_only_when_needed() -> None:
|
||||
assert ninja_helper.shell_token("-Os") == "-Os"
|
||||
assert ninja_helper.shell_token("-DP=C:\\x y") == _q("-DP=C:\\x y")
|
||||
assert ninja_helper.shell_token("plain", force=True) == _q("plain")
|
||||
|
||||
|
||||
def test_shell_token_quotes_shell_metacharacters() -> None:
|
||||
"""Tokens like -DMASK=(1<<3) must not reach /bin/sh -c bare."""
|
||||
assert ninja_helper.shell_token("-DMASK=(1<<3)") == _q("-DMASK=(1<<3)")
|
||||
assert ninja_helper.shell_token("-DX=a;b") == _q("-DX=a;b")
|
||||
assert ninja_helper.shell_token("-DX=$HOME") == _q("-DX=$$HOME")
|
||||
|
||||
|
||||
def test_shell_token_posix_roundtrips_through_sh() -> None:
|
||||
"""Backslash runs, $, backticks, and quotes must reach the compiler
|
||||
exactly as lexed once ninja un-doubles $$ and /bin/sh strips quotes."""
|
||||
|
||||
if sys.platform == "win32":
|
||||
pytest.skip("POSIX sh quoting")
|
||||
for tok in ("-DP=a\\\\b", "-DX=$VAR", "-DY=`date`", "-DZ=it's", '-DC="q"'):
|
||||
quoted = ninja_helper.shell_token(tok).replace("$$", "$")
|
||||
out = subprocess.run(
|
||||
["/bin/sh", "-c", f'printf "%s" {quoted}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert out.stdout == tok
|
||||
|
||||
|
||||
def test_quote_path_force_quotes() -> None:
|
||||
assert ninja_helper.quote_path(Path("a b")) == _q("a b")
|
||||
assert ninja_helper.quote_path("simple") == _q("simple")
|
||||
|
||||
|
||||
def test_shell_token_empty_token_is_quoted() -> None:
|
||||
"""An empty argv element must survive as an explicit pair of quotes."""
|
||||
assert ninja_helper.shell_token("") == _q("")
|
||||
|
||||
|
||||
def test_find_ninja_probes_path_hit(tmp_path: Path) -> None:
|
||||
"""A broken PATH shim falls back to the wheel instead of failing every
|
||||
build later."""
|
||||
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
|
||||
(tmp_path / binary_name).touch()
|
||||
wheel = MagicMock(BIN_DIR=str(tmp_path))
|
||||
with (
|
||||
patch("shutil.which", return_value="/broken/ninja"),
|
||||
patch.object(ninja_helper, "_ninja_runs", return_value=False),
|
||||
patch.dict(sys.modules, {"ninja": wheel}),
|
||||
):
|
||||
assert ninja_helper.find_ninja() == tmp_path / binary_name
|
||||
|
||||
|
||||
def test_ninja_probe_failure_warns(caplog: pytest.LogCaptureFixture) -> None:
|
||||
with patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")):
|
||||
assert ninja_helper._ninja_runs("/broken/ninja") is False
|
||||
assert "failed to run" in caplog.text
|
||||
|
||||
|
||||
def test_ninja_probe_success() -> None:
|
||||
with patch("esphome.framework_helpers.subprocess.run") as mock_run:
|
||||
assert ninja_helper._ninja_runs("/usr/bin/ninja") is True
|
||||
assert mock_run.call_args.kwargs["close_fds"] is False
|
||||
|
||||
|
||||
def test_shell_token_windows_branch_uses_argv_rule() -> None:
|
||||
"""The nt branch quotes with the CreateProcess argv rule (the ubuntu
|
||||
coverage run never takes it naturally)."""
|
||||
with patch.object(os, "name", "nt"):
|
||||
assert ninja_helper.shell_token("a b") == '"a b"'
|
||||
assert ninja_helper.shell_token("", force=True) == '""'
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Tests for the shared PlatformIO-format size bar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers.size_summary import format_bar, print_size_line
|
||||
|
||||
|
||||
def test_format_bar_zero_total() -> None:
|
||||
"""A zero total must not divide by zero."""
|
||||
assert format_bar(0, 0) == "[ ] 0.0% (used 0 bytes from 0 bytes)"
|
||||
|
||||
|
||||
def test_print_size_line_label_padding(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""The label column is exactly what ci_memory_impact_extract.py greps."""
|
||||
print_size_line("RAM", 47932, 180736)
|
||||
print_size_line("Flash", 888511, 1835008)
|
||||
out = capsys.readouterr().out.splitlines()
|
||||
assert out[0].startswith("RAM: [")
|
||||
assert out[1].startswith("Flash: [")
|
||||
assert "26.5% (used 47932 bytes from 180736 bytes)" in out[0]
|
||||
@@ -1,15 +0,0 @@
|
||||
"""Tests for the per-board linker-script rule."""
|
||||
|
||||
from esphome.components.esp8266.boards import BOARDS, board_ld_script
|
||||
|
||||
|
||||
def test_d1_wroom_02_keeps_its_shipped_layout() -> None:
|
||||
"""The override must survive a BOARDS regeneration or key typo: the
|
||||
2m.ld default moves _FS_end and the preferences sector on deployed
|
||||
devices."""
|
||||
assert board_ld_script(BOARDS["d1_wroom_02"]) == "eagle.flash.2m64.ld"
|
||||
|
||||
|
||||
def test_default_boards_use_the_flash_size_layout() -> None:
|
||||
assert board_ld_script(BOARDS["d1_mini"]) == "eagle.flash.4m.ld"
|
||||
assert board_ld_script(BOARDS["esp01_1m"]) == "eagle.flash.1m.ld"
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Tests for the linker-script surgery shared with the native toolchain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp8266 import build_surgery
|
||||
from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD
|
||||
from esphome.components.esp8266.build_surgery import (
|
||||
RATETABLE_RULE,
|
||||
apply_testing_memory_patches,
|
||||
relocate_ratetable,
|
||||
segment_length,
|
||||
)
|
||||
|
||||
_COMMON_LD_SNIPPET = """\
|
||||
.dport0.data : ALIGN(4)
|
||||
{
|
||||
_dport0_data_start = ABSOLUTE(.);
|
||||
} >dport0_0_seg :dport0_0_phdr
|
||||
.data : ALIGN(4)
|
||||
{
|
||||
_data_start = ABSOLUTE(.);
|
||||
*(.data)
|
||||
} >dram0_0_seg :dram0_0_phdr
|
||||
"""
|
||||
|
||||
# Shaped like the real SDK flash ld scripts: no iram1_0_seg (that lives in
|
||||
# the generated common ld only)
|
||||
_FLASH_LD_SNIPPET = """\
|
||||
MEMORY
|
||||
{
|
||||
dport0_0_seg : org = 0x3FF00000, len = 0x10
|
||||
dram0_0_seg : org = 0x3FFE8000, len = 0x14000
|
||||
irom0_0_seg : org = 0x40201010, len = 0xfeff0
|
||||
}
|
||||
"""
|
||||
|
||||
# Shaped like the preprocessed common ld: MMU_IRAM_SIZE expands with a ul
|
||||
# suffix the patcher must leave in place
|
||||
_COMMON_LD_MEMORY_SNIPPET = """\
|
||||
MEMORY
|
||||
{
|
||||
iram1_0_seg : org = 0x40100000, len = 0x8000ul
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def test_relocate_ratetable_inserts_after_data_start() -> None:
|
||||
patched = relocate_ratetable(_COMMON_LD_SNIPPET)
|
||||
assert RATETABLE_RULE in patched
|
||||
# Inserted after the .data section's anchor, not the .dport0.data one
|
||||
# (whose closing brace bounds the decoy block)
|
||||
assert RATETABLE_RULE not in patched[: patched.index("} >dport0_0_seg")]
|
||||
assert patched.index(RATETABLE_RULE) < patched.index("*(.data)")
|
||||
# Idempotent on an already-patched script
|
||||
assert relocate_ratetable(patched) == patched
|
||||
|
||||
|
||||
def test_relocate_ratetable_requires_anchor() -> None:
|
||||
with pytest.raises(RuntimeError, match="_data_start"):
|
||||
relocate_ratetable("SECTIONS { }")
|
||||
|
||||
|
||||
def test_testing_memory_patches_enlarge_segments() -> None:
|
||||
patched = apply_testing_memory_patches(
|
||||
_FLASH_LD_SNIPPET, ("dram0_0_seg", "irom0_0_seg")
|
||||
)
|
||||
assert segment_length(patched, "dram0_0_seg") == 0x200000
|
||||
assert segment_length(patched, "irom0_0_seg") == 0x2000000
|
||||
# Untouched segments keep their sizes
|
||||
assert segment_length(patched, "dport0_0_seg") == 0x10
|
||||
|
||||
|
||||
def test_testing_memory_patches_keep_ul_suffix() -> None:
|
||||
"""The common ld's preprocessed sizes carry a ul suffix; the patch must
|
||||
replace only the hex digits, as testing_mode.py.script does."""
|
||||
patched = apply_testing_memory_patches(_COMMON_LD_MEMORY_SNIPPET, ("iram1_0_seg",))
|
||||
assert "len = 0x200000ul" in patched
|
||||
assert segment_length(patched, "iram1_0_seg") == 0x200000
|
||||
|
||||
|
||||
def test_segment_length_requires_whole_name() -> None:
|
||||
"""A name must match its own line, never inside a longer segment name."""
|
||||
assert segment_length(_FLASH_LD_SNIPPET, "ram0_0_seg") is None
|
||||
|
||||
|
||||
def test_testing_memory_patches_unknown_segment_raises() -> None:
|
||||
with pytest.raises(RuntimeError, match="Unknown testing-mode segment"):
|
||||
apply_testing_memory_patches("MEMORY { }", ("bogus_seg",))
|
||||
|
||||
|
||||
def test_segment_length() -> None:
|
||||
assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0
|
||||
assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None
|
||||
|
||||
|
||||
def test_testing_memory_patches_missing_segment_raises() -> None:
|
||||
"""A named segment the patch could not find raises instead of silently
|
||||
keeping the real memory limits."""
|
||||
with pytest.raises(RuntimeError, match="dram0_0_seg"):
|
||||
apply_testing_memory_patches("MEMORY { }", ("dram0_0_seg",))
|
||||
|
||||
|
||||
def test_board_build_covers_every_board() -> None:
|
||||
"""Every supported board has native build metadata (the table may carry
|
||||
extras that BOARDS does not expose)."""
|
||||
assert set(BOARDS) <= set(ESP8266_BOARD_BUILD)
|
||||
|
||||
|
||||
def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None:
|
||||
"""The properties the linker-script cache depends on: the fingerprint is
|
||||
stable across calls and changes when the module's source changes."""
|
||||
|
||||
first = build_surgery.surgery_fingerprint()
|
||||
assert first == build_surgery.surgery_fingerprint()
|
||||
assert len(first) == 64
|
||||
int(first, 16) # sha256 hex digest
|
||||
|
||||
# A modified copy of the module must fingerprint differently
|
||||
copy = tmp_path / "build_surgery_variant.py"
|
||||
copy.write_text(
|
||||
Path(build_surgery.__file__).read_text(encoding="utf-8")
|
||||
+ "\nEXTRA_BEHAVIORAL_INPUT = 1\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("build_surgery_variant", copy)
|
||||
variant = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = variant
|
||||
try:
|
||||
spec.loader.exec_module(variant)
|
||||
assert variant.surgery_fingerprint() != first
|
||||
finally:
|
||||
del sys.modules[spec.name]
|
||||
|
||||
|
||||
def test_testing_memory_patches_present_but_unselected_raises() -> None:
|
||||
"""A known segment left off the caller's list must fail, not silently
|
||||
keep its real memory limit."""
|
||||
with pytest.raises(RuntimeError, match="not selected"):
|
||||
apply_testing_memory_patches(_FLASH_LD_SNIPPET, ("dram0_0_seg",))
|
||||
@@ -1285,7 +1285,6 @@ async def test_add_platformio_options_native_idf(
|
||||
await config._add_platformio_options(
|
||||
{
|
||||
"build_flags": "-DSINGLE_FLAG", # string and list forms both valid
|
||||
"build_unflags": ["-Os"],
|
||||
"lib_deps": ["bblanchon/ArduinoJson@7.4.2"],
|
||||
"lib_ignore": "libsodium",
|
||||
"upload_speed": "115200",
|
||||
@@ -1295,7 +1294,6 @@ async def test_add_platformio_options_native_idf(
|
||||
|
||||
assert "-DSINGLE_FLAG" in CORE.build_flags
|
||||
assert "ArduinoJson" in CORE.platformio_libraries
|
||||
assert "-Os" in CORE.build_unflags
|
||||
# lib_ignore is stored (listified) for generate_idf_components to read;
|
||||
# nothing else lands in platformio_options on the native toolchain.
|
||||
assert CORE.platformio_options == {"lib_ignore": ["libsodium"]}
|
||||
@@ -1391,50 +1389,3 @@ def test_esphome_build_internals_are_yaml_only() -> None:
|
||||
assert markers[field].visibility is cv.Visibility.ADVANCED, field
|
||||
# A regular device-config field stays on the main form.
|
||||
assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_platformio_options_native_arduino(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The native ESP8266 Arduino toolchain honors board_build.f_cpu (a
|
||||
real-world overclock knob) and warns about the rest like native IDF."""
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp8266",
|
||||
KEY_TARGET_FRAMEWORK: "arduino",
|
||||
}
|
||||
|
||||
await config._add_platformio_options(
|
||||
{
|
||||
"board_build.f_cpu": "160000000L",
|
||||
# The schema also permits the list form; the last value wins
|
||||
# and reaches the generator as a scalar
|
||||
"board_build.ldscript": ["eagle.flash.2m.ld", "eagle.flash.4m2m.ld"],
|
||||
"board_build.filesystem": "littlefs",
|
||||
"upload_speed": "115200",
|
||||
}
|
||||
)
|
||||
|
||||
assert CORE.platformio_options["board_build.f_cpu"] == "160000000L"
|
||||
assert CORE.platformio_options["board_build.ldscript"] == "eagle.flash.4m2m.ld"
|
||||
assert "board_build.f_cpu is ignored" not in caplog.text
|
||||
assert "board_build.ldscript is ignored" not in caplog.text
|
||||
assert (
|
||||
"esphome->platformio_options->board_build.filesystem is ignored" in caplog.text
|
||||
)
|
||||
# An empty list for an honored key is not a scalar; it falls through
|
||||
# to the ignored-option warning instead of an IndexError
|
||||
await config._add_platformio_options({"board_build.ldscript": []})
|
||||
assert "board_build.ldscript is ignored" in caplog.text
|
||||
assert "'arduino' toolchain" in caplog.text
|
||||
assert "upload_speed" not in caplog.text
|
||||
|
||||
|
||||
def test_esp8266_rejects_unsupported_cli_toolchain() -> None:
|
||||
"""Until the native backend lands, ESP8266 serves only PlatformIO."""
|
||||
from esphome.components.esp8266 import CONFIG_SCHEMA
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
CONFIG_SCHEMA({"board": "nodemcuv2"})
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Tests for esphome.arduino8266.framework (downloads and environment)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.arduino8266 import framework
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _build_path(tmp_path: Path) -> None:
|
||||
CORE.build_path = tmp_path
|
||||
|
||||
|
||||
def test_framework_package_version() -> None:
|
||||
assert framework.framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0"
|
||||
assert framework.framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0"
|
||||
# 2.6.3+ cores use the same package-major-3 encoding (PlatformIO path)
|
||||
assert framework.framework_package_version(cv.Version(2, 7, 4)) == "3.20704.0"
|
||||
# A future major bump needs its own encoding, not a doomed registry lookup
|
||||
with pytest.raises(EsphomeError, match="not supported yet"):
|
||||
framework.framework_package_version(cv.Version(4, 0, 0))
|
||||
# The boundary matches the PlatformIO era guard; a 2.6.2 pre-release
|
||||
# keeps this encoding
|
||||
with pytest.raises(EsphomeError, match="older package encoding"):
|
||||
framework.framework_package_version(cv.Version(2, 6, 2))
|
||||
assert framework.framework_package_version(cv.Version(2, 6, 2, "b1")) == "3.20602.0"
|
||||
assert framework.framework_package_version(cv.Version(2, 6, 3)) == "3.20603.0"
|
||||
|
||||
|
||||
def test_format_framework_arduino_version_pins_all_series() -> None:
|
||||
"""The esp8266 component's PIO source formatter across every encoding
|
||||
era, including the 4.x rejection it now shares with the installer."""
|
||||
from esphome.components.esp8266 import _format_framework_arduino_version as fmt
|
||||
|
||||
assert fmt(cv.Version(2, 4, 1)) == "~1.20401.0"
|
||||
assert fmt(cv.Version(2, 6, 2)) == "~2.20602.0"
|
||||
assert fmt(cv.Version(2, 7, 4)) == "~3.20704.0"
|
||||
assert fmt(cv.Version(3, 1, 2)) == "~3.30102.0"
|
||||
# Anchored to the framework version line, not a bare EsphomeError
|
||||
with pytest.raises(cv.Invalid, match="not supported yet") as excinfo:
|
||||
fmt(cv.Version(4, 0, 0))
|
||||
assert excinfo.value.path == ["version"]
|
||||
|
||||
|
||||
def test_tools_path_default_and_prefix(tmp_path: Path) -> None:
|
||||
with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}):
|
||||
assert framework.get_arduino8266_tools_path() == tmp_path.resolve()
|
||||
# A blank prefix must be treated as unset, not as the CWD
|
||||
with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": " "}):
|
||||
path = framework.get_arduino8266_tools_path()
|
||||
assert path.name == "arduino8266"
|
||||
assert path != Path.cwd()
|
||||
|
||||
|
||||
def test_check_and_install_returns_paths(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}),
|
||||
patch.object(framework, "install_package") as mock_install,
|
||||
patch.object(framework, "prefetch_packages") as mock_prefetch,
|
||||
patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"),
|
||||
):
|
||||
paths = framework.check_and_install(cv.Version(3, 1, 2))
|
||||
assert paths.framework == tmp_path / "frameworks" / "3.30102.0"
|
||||
assert paths.toolchain == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION
|
||||
assert paths.ninja == tmp_path / "ninja"
|
||||
assert mock_install.call_count == 2
|
||||
# Full argument pinning: a copy-paste swap between the two near-identical
|
||||
# calls (mirrors, destination) must not stay green
|
||||
fw_call, tc_call = mock_install.call_args_list
|
||||
assert fw_call.args == (
|
||||
framework.FRAMEWORK_PACKAGE,
|
||||
"3.30102.0",
|
||||
tmp_path / "frameworks" / "3.30102.0",
|
||||
framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
assert fw_call.kwargs["expect"] == ("cores/esp8266", "tools/sdk", "libraries")
|
||||
assert tc_call.args == (
|
||||
framework.TOOLCHAIN_PACKAGE,
|
||||
framework.TOOLCHAIN_VERSION,
|
||||
tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION,
|
||||
framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
assert tc_call.kwargs["expect"] == ("bin", "xtensa-lx106-elf")
|
||||
# The prefetch sees the same package specs as the installs
|
||||
assert mock_prefetch.call_args.args == (
|
||||
[
|
||||
(
|
||||
framework.FRAMEWORK_PACKAGE,
|
||||
"3.30102.0",
|
||||
tmp_path / "frameworks" / "3.30102.0",
|
||||
framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
),
|
||||
(
|
||||
framework.TOOLCHAIN_PACKAGE,
|
||||
framework.TOOLCHAIN_VERSION,
|
||||
tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION,
|
||||
framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
),
|
||||
],
|
||||
tmp_path / "downloads",
|
||||
)
|
||||
|
||||
|
||||
def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None:
|
||||
with patch.object(framework, "ccache_env", return_value={"CCACHE_DIR": "x"}):
|
||||
env = framework.get_build_env(tmp_path, None)
|
||||
assert env["PATH"].startswith(str(tmp_path / "bin") + os.pathsep)
|
||||
assert env["CCACHE_DIR"] == "x"
|
||||
|
||||
|
||||
def test_ccache_env(tmp_path: Path) -> None:
|
||||
assert framework.ccache_env(None) == {}
|
||||
with patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True):
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
# User-set values are respected; the rest get defaults
|
||||
assert "CCACHE_NOHASHDIR" not in env
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve())
|
||||
assert env["CCACHE_DIR"].endswith("ccache")
|
||||
|
||||
|
||||
def test_check_and_install_rejects_old_core(tmp_path: Path) -> None:
|
||||
"""Calling the installer below the floor fails before any download."""
|
||||
with pytest.raises(EsphomeError, match=">= 3.1.1"):
|
||||
framework.check_and_install(cv.Version(3, 0, 2))
|
||||
|
||||
|
||||
def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None:
|
||||
"""An absent PATH must not leave a trailing separator (an empty entry
|
||||
means the current directory to the shell)."""
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch.object(framework, "ccache_env", return_value={}),
|
||||
):
|
||||
env = framework.get_build_env(tmp_path, None)
|
||||
assert env["PATH"] == str(tmp_path / "bin")
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ, {"PATH": f"/usr/bin{os.pathsep}{os.pathsep}/bin"}, clear=True
|
||||
),
|
||||
patch.object(framework, "ccache_env", return_value={}),
|
||||
):
|
||||
env = framework.get_build_env(tmp_path, None)
|
||||
assert env["PATH"].split(os.pathsep) == [str(tmp_path / "bin"), "/usr/bin", "/bin"]
|
||||
|
||||
|
||||
def test_ccache_env_accepts_a_preresolved_path() -> None:
|
||||
"""The caller resolves ccache once and threads it through; None means
|
||||
resolved-and-disabled."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
assert framework.ccache_env(None) == {}
|
||||
env = framework.ccache_env("/usr/bin/ccache")
|
||||
assert env["CCACHE_DIR"].endswith("ccache")
|
||||
|
||||
|
||||
def test_toolchain_tool_layout(tmp_path: Path) -> None:
|
||||
"""One owner for the bin/xtensa-lx106-elf-<name> layout."""
|
||||
tool = framework.toolchain_tool(tmp_path, "addr2line")
|
||||
assert tool.parent == tmp_path / "bin"
|
||||
assert tool.name.startswith("xtensa-lx106-elf-addr2line")
|
||||
assert (tool.suffix == ".exe") is (os.name == "nt")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,6 @@ def _write_storage(
|
||||
esp_platform: str | None = "ESP32",
|
||||
core_platform: str | None = "esp32",
|
||||
build_path: str | None = "/build/lite_test",
|
||||
toolchain: str | None = None,
|
||||
) -> None:
|
||||
"""Write a vanilla StorageJSON sidecar for the cache tests."""
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -89,7 +88,6 @@ def _write_storage(
|
||||
"no_mdns": False,
|
||||
"framework": "arduino",
|
||||
"core_platform": core_platform,
|
||||
"toolchain": toolchain,
|
||||
}
|
||||
storage_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
@@ -631,35 +629,6 @@ def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) ->
|
||||
assert load_compiled_config(yaml_path) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sidecar_toolchain", "saved"),
|
||||
[
|
||||
("esp-idf", False),
|
||||
("platformio", True),
|
||||
(None, True), # legacy sidecar without the field: guard is inert
|
||||
],
|
||||
)
|
||||
def test_save_compiled_config_and_sidecar_toolchain_mismatch(
|
||||
tmp_path: Path, sidecar_toolchain: str | None, saved: bool
|
||||
) -> None:
|
||||
"""A config validated under a different toolchain than the compile's
|
||||
must not overwrite the cache."""
|
||||
yaml_path = _bare_yaml(tmp_path)
|
||||
_prime_core(tmp_path)
|
||||
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
_write_storage(
|
||||
tmp_path / ".esphome" / "storage" / "lite_test.yaml.json",
|
||||
toolchain=sidecar_toolchain,
|
||||
)
|
||||
|
||||
save_compiled_config_and_sidecar(CORE.config)
|
||||
|
||||
cache = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.json"
|
||||
assert cache.exists() is saved
|
||||
assert (load_compiled_config(yaml_path) is not None) is saved
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["upload", "logs"])
|
||||
def test_run_esphome_upload_and_logs_refresh_cache_on_fallback(
|
||||
tmp_path: Path, command: str
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -49,7 +48,6 @@ from esphome.const import (
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
Framework,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
@@ -3167,46 +3165,3 @@ def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None:
|
||||
|
||||
with pytest.raises(Invalid, match="is not a file"):
|
||||
cv.file_("/original/config/headers")
|
||||
|
||||
|
||||
def test_require_platformio_toolchain() -> None:
|
||||
"""Platforms with only the PlatformIO backend reject other toolchains."""
|
||||
validator = cv.require_platformio_toolchain("RP2")
|
||||
CORE.toolchain = None
|
||||
config: dict = {}
|
||||
assert validator(config) is config
|
||||
assert CORE.toolchain == Toolchain.PLATFORMIO
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino' for RP2"):
|
||||
validator(config)
|
||||
|
||||
|
||||
def test_check_supported_toolchain_unresolved_is_an_ordering_bug() -> None:
|
||||
"""Calling the check before resolution fails naming the ordering bug,
|
||||
not a user-facing unsupported-toolchain error."""
|
||||
CORE.toolchain = None
|
||||
with pytest.raises(Invalid, match="not resolved before RP2 validation"):
|
||||
cv._check_supported_toolchain("RP2", (Toolchain.PLATFORMIO,))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "minimal_config"),
|
||||
[
|
||||
("host", {}),
|
||||
("rp2", {"board": "rpipicow"}),
|
||||
("bk72xx", {"board": "generic-bk7231n-qfn32-tuya"}),
|
||||
("rtl87xx", {"board": "generic-rtl8710bn-2mb-788k"}),
|
||||
("ln882x", {"board": "generic-ln882h"}),
|
||||
# The legacy stub platform must reject too, not just the chip families
|
||||
("libretiny", {}),
|
||||
],
|
||||
)
|
||||
def test_every_platformio_only_platform_rejects_arduino_toolchain(
|
||||
platform: str, minimal_config: dict
|
||||
) -> None:
|
||||
"""A platform that cannot serve a CLI toolchain rejects it at validation."""
|
||||
module = importlib.import_module(f"esphome.components.{platform}")
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
module.CONFIG_SCHEMA(dict(minimal_config))
|
||||
|
||||
@@ -6,9 +6,9 @@ from unittest.mock import patch
|
||||
|
||||
from hypothesis import given
|
||||
import pytest
|
||||
from strategies import mac_addr_strings
|
||||
|
||||
from esphome import const, core
|
||||
from tests.unit_tests.strategies import mac_addr_strings
|
||||
|
||||
|
||||
class TestHexInt:
|
||||
@@ -958,24 +958,6 @@ class TestEsphomeCore:
|
||||
target.toolchain = const.Toolchain.ESP_IDF
|
||||
assert target.using_toolchain_sdk_nrf is False
|
||||
|
||||
def test_using_toolchain_arduino(self, target):
|
||||
"""A toolchain choice, distinct from the arduino target framework."""
|
||||
target.toolchain = const.Toolchain.ARDUINO
|
||||
assert target.using_toolchain_arduino is True
|
||||
target.toolchain = const.Toolchain.PLATFORMIO
|
||||
assert target.using_toolchain_arduino is False
|
||||
|
||||
def test_using_native_toolchain(self, target):
|
||||
"""True exactly for the toolchains that never read platformio.ini."""
|
||||
target.toolchain = const.Toolchain.ESP_IDF
|
||||
assert target.using_native_toolchain is True
|
||||
target.toolchain = const.Toolchain.ARDUINO
|
||||
assert target.using_native_toolchain is True
|
||||
target.toolchain = const.Toolchain.PLATFORMIO
|
||||
assert target.using_native_toolchain is False
|
||||
target.toolchain = const.Toolchain.SDK_NRF
|
||||
assert target.using_native_toolchain is False
|
||||
|
||||
def test_add_library__extracts_short_name_from_path(self, target):
|
||||
"""Test add_library extracts short name from library paths like owner/lib."""
|
||||
target.data[const.KEY_CORE] = {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"""Tests for esphome.espidf.clang_tidy tidy-project generation."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.espidf import clang_tidy
|
||||
from esphome.espidf.clang_tidy import _Settings, _setup_core, _write_tidy_project
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -67,35 +64,3 @@ def test_setup_core_sets_arduino_env(
|
||||
_setup_core(tmp_path / "proj", _settings(target_framework=target_framework))
|
||||
|
||||
assert os.environ["ESPHOME_ARDUINO_COMPONENT"] == expected
|
||||
|
||||
|
||||
def test_idedata_from_tidy_project(tmp_path) -> None:
|
||||
"""The tidy TU's compile entry is assembled into consumer-shaped idedata."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"directory": str(tmp_path),
|
||||
"file": str(tmp_path / "main" / "tidy.cpp"),
|
||||
"command": "/tc/xtensa-esp32-elf-g++ -DUSE_ESP32 "
|
||||
f"-I{tmp_path}/inc -c main/tidy.cpp -o tidy.o",
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
"esphome.espidf.clang_tidy.get_toolchain_includes", return_value=["/tc/inc"]
|
||||
):
|
||||
data = clang_tidy._idedata_from_tidy_project(compile_commands)
|
||||
assert data["cxx_path"] == "/tc/xtensa-esp32-elf-g++"
|
||||
assert data["defines"] == ["USE_ESP32"]
|
||||
assert data["includes"]["toolchain"] == ["/tc/inc"]
|
||||
assert any(inc.endswith("/inc") for inc in data["includes"]["build"])
|
||||
|
||||
|
||||
def test_idedata_from_tidy_project_missing_tu_raises(tmp_path) -> None:
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
compile_commands.write_text(json.dumps([]))
|
||||
with pytest.raises(RuntimeError, match="tidy.cpp not found"):
|
||||
clang_tidy._idedata_from_tidy_project(compile_commands)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import esp32 as esp32_module
|
||||
from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
@@ -16,24 +16,21 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE, Library
|
||||
from esphome.espidf.component import (
|
||||
_emit_idf_component,
|
||||
generate_cmakelists_txt,
|
||||
generate_idf_component_yml,
|
||||
generate_idf_components,
|
||||
)
|
||||
import esphome.platformio.library
|
||||
from esphome.platformio.library import (
|
||||
ESPHOME_DATA_KEY,
|
||||
ESPHOME_DATA_LINK_FLAGS_KEY,
|
||||
ConvertedLibrary as IDFComponent,
|
||||
GitSource,
|
||||
URLSource,
|
||||
_node_key,
|
||||
_normalize_dependencies,
|
||||
_parse_library_json,
|
||||
_parse_library_properties,
|
||||
_resolve_registry_version,
|
||||
collect_filtered_files,
|
||||
normalize_dependencies,
|
||||
parse_library_json,
|
||||
parse_library_properties,
|
||||
split_list_by_condition,
|
||||
)
|
||||
|
||||
@@ -294,25 +291,6 @@ 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_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."""
|
||||
src_dir = tmp_component.path / "src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "main.c").write_text("int main() {}")
|
||||
|
||||
tmp_component.data = {
|
||||
ESPHOME_DATA_KEY: {ESPHOME_DATA_LINK_FLAGS_KEY: ["-Wl,--gc-sections"]}
|
||||
}
|
||||
|
||||
content = generate_cmakelists_txt(tmp_component)
|
||||
assert (
|
||||
'target_link_options(${COMPONENT_LIB} INTERFACE\n "-Wl,--gc-sections"\n)'
|
||||
in content
|
||||
)
|
||||
assert "target_compile_options" not in content
|
||||
|
||||
|
||||
def test_generate_cmakelists_txt_space_separated_classified_flags(tmp_component):
|
||||
# Space-separated -I/-L/-l entries routed to INCLUDE_DIRS and the link
|
||||
# handling before the shlex split was added; splitting must not leak
|
||||
@@ -391,11 +369,133 @@ def test_generate_idf_component_yml_missing_path_raises(tmp_component):
|
||||
generate_idf_component_yml(tmp_component)
|
||||
|
||||
|
||||
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
|
||||
from esphome.espidf.extra_script import captured_as_build_flags, run_extra_script
|
||||
|
||||
(tmp_path / "src" / "esp32").mkdir(parents=True)
|
||||
script = tmp_path / "extra_script.py"
|
||||
script.write_text(
|
||||
"Import('env')\n"
|
||||
"mcu = env.get('BOARD_MCU')\n"
|
||||
"env.Append(\n"
|
||||
" LIBPATH=[join('src', mcu)],\n"
|
||||
" LIBS=['algobsec'],\n"
|
||||
" CPPDEFINES=['FOO', ('BAR', '1')],\n"
|
||||
" LINKFLAGS=['-Wl,--gc-sections'],\n"
|
||||
")\n"
|
||||
)
|
||||
# The script uses bare ``join`` (PIO's extra-scripts run inside SCons
|
||||
# where this is in scope). Inject it via the script header so the
|
||||
# shim's exec namespace can resolve it.
|
||||
script.write_text("from os.path import join\n" + script.read_text())
|
||||
|
||||
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
|
||||
|
||||
assert result.libpath == [str(Path("src") / "esp32")]
|
||||
assert result.libs == ["algobsec"]
|
||||
assert ("BAR", "1") in result.cppdefines
|
||||
assert "FOO" in result.cppdefines
|
||||
assert result.linkflags == ["-Wl,--gc-sections"]
|
||||
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
sep = os.sep
|
||||
assert f"-Lsrc{sep}esp32" in flags
|
||||
assert "-lalgobsec" in flags
|
||||
assert "-DFOO" in flags
|
||||
assert "-DBAR=1" in flags
|
||||
assert "-Wl,--gc-sections" in flags
|
||||
|
||||
|
||||
def test_extra_script_libpath_relative_resolves_against_library_dir(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Relative LIBPATH entries must resolve against ``library_dir``, not the
|
||||
caller's CWD (the shim restores CWD before ``captured_as_build_flags``
|
||||
runs)."""
|
||||
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
|
||||
|
||||
(tmp_path / "lib" / "esp32").mkdir(parents=True)
|
||||
elsewhere = tmp_path.parent / "not_the_library_dir"
|
||||
elsewhere.mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(elsewhere)
|
||||
|
||||
result = ExtraScriptResult(libpath=["lib/esp32"])
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
|
||||
sep = os.sep
|
||||
assert flags == [f"-Llib{sep}esp32"]
|
||||
|
||||
|
||||
def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
|
||||
from esphome.espidf.extra_script import ExtraScriptResult, captured_as_build_flags
|
||||
|
||||
outside = tmp_path.parent / "system_lib"
|
||||
outside.mkdir(exist_ok=True)
|
||||
result = ExtraScriptResult(libpath=[str(outside)])
|
||||
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
assert flags == [f"-L{outside.resolve()}"]
|
||||
|
||||
|
||||
def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
|
||||
from esphome.espidf.extra_script import run_extra_script
|
||||
|
||||
script = tmp_path / "broken.py"
|
||||
script.write_text("raise RuntimeError('boom')\n")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = run_extra_script(script, library_dir=tmp_path, idf_target="esp32")
|
||||
|
||||
assert result.libpath == []
|
||||
assert result.libs == []
|
||||
assert "broken.py" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
|
||||
from esphome.espidf.component import _apply_extra_script
|
||||
|
||||
library_dir = tmp_path / "lib"
|
||||
library_dir.mkdir()
|
||||
outside = tmp_path / "evil.py"
|
||||
outside.write_text("env.Append(LIBS=['pwned'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = library_dir
|
||||
c.data = {"build": {"extraScript": "../evil.py"}}
|
||||
|
||||
_apply_extra_script(c)
|
||||
|
||||
# Nothing was folded into flags: the traversal was rejected before
|
||||
# the script could run.
|
||||
assert "flags" not in c.data["build"]
|
||||
|
||||
|
||||
def test_apply_extra_script_merges_into_existing_flags(tmp_path, monkeypatch):
|
||||
from esphome.components import esp32 as esp32_module
|
||||
|
||||
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
|
||||
|
||||
from esphome.espidf.component import _apply_extra_script
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=['algobsec'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}}
|
||||
|
||||
_apply_extra_script(c)
|
||||
|
||||
assert "-DEXISTING" in c.data["build"]["flags"]
|
||||
assert "-lalgobsec" in c.data["build"]["flags"]
|
||||
|
||||
|
||||
def test_parse_library_json(tmp_path):
|
||||
f = tmp_path / "library.json"
|
||||
f.write_text(json.dumps({"name": "test"}))
|
||||
|
||||
result = parse_library_json(f)
|
||||
result = _parse_library_json(f)
|
||||
assert result["name"] == "test"
|
||||
|
||||
|
||||
@@ -410,7 +510,7 @@ empty=
|
||||
"""
|
||||
)
|
||||
|
||||
result = parse_library_properties(f)
|
||||
result = _parse_library_properties(f)
|
||||
|
||||
assert result["name"] == "Test"
|
||||
assert result["version"] == "1.0"
|
||||
@@ -580,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 == [
|
||||
@@ -635,7 +735,7 @@ def _patch_registry(monkeypatch, versions):
|
||||
|
||||
def test_resolve_registry_version_intersects_constraints(monkeypatch):
|
||||
_patch_registry(monkeypatch, ["1.10018.1", "1.10021.0", "1.10021.1"])
|
||||
owner, name, version, url, _size = _resolve_registry_version(
|
||||
owner, name, version, url = _resolve_registry_version(
|
||||
"esphome", "libsodium", {"==1.10021.0", "^1.10018.1"}
|
||||
)
|
||||
assert (owner, name, version) == ("esphome", "libsodium", "1.10021.0")
|
||||
@@ -644,9 +744,7 @@ def test_resolve_registry_version_intersects_constraints(monkeypatch):
|
||||
|
||||
def test_resolve_registry_version_picks_highest_satisfying(monkeypatch):
|
||||
_patch_registry(monkeypatch, ["1.0.0", "1.5.0", "2.0.0"])
|
||||
_owner, _name, version, _url, _size = _resolve_registry_version(
|
||||
"o", "p", {"^1.0.0"}
|
||||
)
|
||||
_owner, _name, version, _url = _resolve_registry_version("o", "p", {"^1.0.0"})
|
||||
assert version == "1.5.0"
|
||||
|
||||
|
||||
@@ -696,7 +794,7 @@ def test_generate_idf_components_dedupes_shared_dependency(
|
||||
resolve_calls.append(pkgname)
|
||||
captured[f"{owner}/{pkgname}"] = set(requirements)
|
||||
version = "1.10021.0" if pkgname == "C" else "1.0.0"
|
||||
return owner, pkgname, version, f"http://x/{pkgname}.tar.gz", None
|
||||
return owner, pkgname, version, f"http://x/{pkgname}.tar.gz"
|
||||
|
||||
monkeypatch.setattr(
|
||||
esphome.platformio.library, "_resolve_registry_version", fake_resolve
|
||||
@@ -755,7 +853,7 @@ def test_generate_idf_components_lib_ignore_filters_top_level_and_dependencies(
|
||||
|
||||
def fake_resolve(owner, pkgname, requirements):
|
||||
resolve_calls.append(pkgname)
|
||||
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz", None
|
||||
return owner, pkgname, "1.0.0", f"http://x/{pkgname}.tar.gz"
|
||||
|
||||
monkeypatch.setattr(
|
||||
esphome.platformio.library, "_resolve_registry_version", fake_resolve
|
||||
@@ -811,7 +909,6 @@ def test_generate_idf_components_handles_dependency_cycle(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -869,7 +966,6 @@ def test_generate_idf_components_git_overrides_registry_warns(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -906,7 +1002,6 @@ def test_generate_idf_components_missing_manifest_raises(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -951,7 +1046,6 @@ def test_generate_idf_components_warns_on_noncanonical_duplicate(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -985,7 +1079,6 @@ def test_generate_idf_components_incompatible_top_level_raises(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1022,7 +1115,6 @@ def test_generate_idf_components_incompatible_dependency_skipped(
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1098,33 +1190,3 @@ 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_emit_idf_component_wires_esp32_target(tmp_path, monkeypatch):
|
||||
"""Emitting a component resolves the esp32 variant into the shared
|
||||
extraScript helper."""
|
||||
|
||||
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
|
||||
(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"}}
|
||||
_emit_idf_component(c)
|
||||
assert c.data["build"]["flags"] == ["-lesp32"]
|
||||
|
||||
|
||||
def test_build_flags_dangling_flag_does_not_cross_entries(
|
||||
tmp_path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Each entry is lexed independently, as ParseFlags does: a dangling -I ending one
|
||||
entry warns instead of absorbing the next entry's first token."""
|
||||
(tmp_path / "src").mkdir()
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"flags": ["-Wall -I", "-DFOO=1"]}}
|
||||
content = generate_cmakelists_txt(c)
|
||||
assert "FOO=1" in content
|
||||
assert "-I-DFOO" not in content
|
||||
assert "Ignoring trailing '-I'" in caplog.text
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
import importlib.util
|
||||
import io
|
||||
@@ -15,7 +14,7 @@ import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -888,78 +887,6 @@ _PREFETCH_JSON = json.dumps(
|
||||
)
|
||||
|
||||
|
||||
def test_prefetch_leaves_unverifiable_entries_to_the_installer(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An entry missing sha256 or size must not download unverified; the
|
||||
installer handles it and fails loudly on a bad archive."""
|
||||
entries = json.loads(_PREFETCH_JSON)
|
||||
del entries[0]["sha256"]
|
||||
del entries[1]["size"]
|
||||
entries.append(
|
||||
{
|
||||
"name": "gcc@14.2.0",
|
||||
"url": "https://example.com/gcc.tar.gz",
|
||||
"size": 67,
|
||||
"sha256": "ef" * 32,
|
||||
"dest": "gcc.tar.gz",
|
||||
}
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, json.dumps(entries), ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
|
||||
):
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
assert [call[0][0] for call in download.call_args_list] == [
|
||||
"https://example.com/gcc.tar.gz"
|
||||
]
|
||||
assert download.call_args[1]["sha256"] == "ef" * 32
|
||||
progress_cls.assert_called_once_with("Downloading ESP-IDF tools", 67)
|
||||
assert "cmake@3.30.2 has no sha256/size" in caplog.text
|
||||
assert "ninja@1.12.1 has no sha256/size" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_all_entries_unverifiable_is_a_noop(tmp_path: Path) -> None:
|
||||
entries = json.loads(_PREFETCH_JSON)
|
||||
for entry in entries:
|
||||
del entry["sha256"]
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, json.dumps(entries), ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
):
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_dedupes_entries_by_dest(tmp_path: Path) -> None:
|
||||
"""Two entries resolving to one dest would interleave writes into the
|
||||
same .part file; only the first downloads."""
|
||||
entries = json.loads(_PREFETCH_JSON)
|
||||
dup = dict(entries[0]) | {"name": "cmake-alias@3.30.2"}
|
||||
entries.append(dup)
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, json.dumps(entries), ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch("esphome.framework_helpers._BatchDownloadProgress"),
|
||||
):
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
dests = [call[0][1].name for call in download.call_args_list]
|
||||
assert dests.count("cmake-3.30.2.tar.gz") == 1
|
||||
|
||||
|
||||
def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(
|
||||
@@ -968,58 +895,16 @@ def test_prefetch_downloads_each_archive_with_resume(tmp_path: Path) -> None:
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
|
||||
):
|
||||
# Materialize the lazy mock before threads race its first creation
|
||||
tracker = progress_cls.return_value.tracker.return_value
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
|
||||
dist = get_idf_tools_path() / "dist"
|
||||
# Archives download concurrently, so the call order is not fixed.
|
||||
calls = {call[0]: call[1] for call in download.call_args_list}
|
||||
assert set(calls) == {
|
||||
("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz"),
|
||||
("https://example.com/ninja.zip", dist / "ninja.zip"),
|
||||
}
|
||||
kwargs = calls[("https://example.com/cmake.tar.gz", dist / "cmake-3.30.2.tar.gz")]
|
||||
assert kwargs["sha256"] == "ab" * 32
|
||||
assert kwargs["size"] == 123
|
||||
# every archive reports into the one combined progress bar via the
|
||||
# cancellation-checked wrapper; verify it delegates to the tracker
|
||||
progress_cls.assert_called_once_with("Downloading ESP-IDF tools", 123 + 45)
|
||||
before = tracker.call_count
|
||||
for kw in calls.values():
|
||||
kw["progress"](7)
|
||||
assert tracker.call_count == before + len(calls)
|
||||
|
||||
|
||||
def test_prefetch_downloads_archives_concurrently(tmp_path: Path) -> None:
|
||||
"""More than one archive fans out over a bounded thread pool."""
|
||||
entries = [
|
||||
{
|
||||
"name": f"tool{i}@1",
|
||||
"url": f"https://example.com/tool{i}.tar.gz",
|
||||
"size": 10,
|
||||
"sha256": "ab" * 32,
|
||||
"dest": f"tool{i}.tar.gz",
|
||||
}
|
||||
for i in range(6)
|
||||
]
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, json.dumps(entries), ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume") as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch(
|
||||
"esphome.framework_helpers.ThreadPoolExecutor", wraps=ThreadPoolExecutor
|
||||
) as pool,
|
||||
):
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
|
||||
pool.assert_called_once_with(max_workers=4)
|
||||
assert download.call_count == 6
|
||||
assert download.call_count == 2
|
||||
assert download.call_args_list[0][0] == (
|
||||
"https://example.com/cmake.tar.gz",
|
||||
dist / "cmake-3.30.2.tar.gz",
|
||||
)
|
||||
assert download.call_args_list[0][1] == {"sha256": "ab" * 32, "size": 123}
|
||||
|
||||
|
||||
def test_prefetch_skips_already_downloaded_archives(tmp_path: Path) -> None:
|
||||
@@ -1079,11 +964,6 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
|
||||
) -> None:
|
||||
"""A single archive failing its download must not abort the prefetch of
|
||||
the remaining archives."""
|
||||
|
||||
def _fail_cmake_download(url: str, *args, **kwargs) -> None:
|
||||
if "cmake" in url:
|
||||
raise OSError("network down")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
@@ -1091,7 +971,7 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
|
||||
),
|
||||
patch(
|
||||
"esphome.espidf.framework.download_with_resume",
|
||||
side_effect=_fail_cmake_download,
|
||||
side_effect=[OSError("network down"), None],
|
||||
) as download,
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
):
|
||||
@@ -1101,27 +981,6 @@ def test_prefetch_one_failed_archive_does_not_stop_the_rest(
|
||||
assert "Could not prefetch cmake@3.30.2" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_finishes_progress_bar_and_cancels_queue(tmp_path: Path) -> None:
|
||||
"""The batch bar is closed out after the pool, and the pool is shut down
|
||||
with cancel_futures so Ctrl-C does not drain every queued archive."""
|
||||
with (
|
||||
patch(
|
||||
"esphome.espidf.framework.run_command",
|
||||
return_value=(True, _PREFETCH_JSON, ""),
|
||||
),
|
||||
patch("esphome.espidf.framework.download_with_resume"),
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
patch("esphome.framework_helpers._BatchDownloadProgress") as progress_cls,
|
||||
patch("esphome.framework_helpers.ThreadPoolExecutor") as pool_cls,
|
||||
):
|
||||
pool = MagicMock(wraps=ThreadPoolExecutor(max_workers=2))
|
||||
pool_cls.return_value = pool
|
||||
_prefetch_idf_tool_archives(tmp_path, "esp32", ["required"], None)
|
||||
|
||||
pool.shutdown.assert_called_once_with(wait=True, cancel_futures=True)
|
||||
progress_cls.return_value.done.assert_called_once_with()
|
||||
|
||||
|
||||
def test_prefetch_passes_targets_and_tools_to_script(tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(
|
||||
@@ -1536,14 +1395,13 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No
|
||||
|
||||
def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None):
|
||||
return (
|
||||
patch("esphome.espidf.framework.resolve_ccache_path", return_value=which),
|
||||
patch("esphome.espidf.framework.shutil.which", return_value=which),
|
||||
patch(
|
||||
"esphome.espidf.framework.get_idf_tools_path",
|
||||
return_value=tmp_path / "tools",
|
||||
),
|
||||
# ccache_defaults_env (build_helpers.ccache) reads CORE at call time
|
||||
patch(
|
||||
"esphome.core.CORE",
|
||||
"esphome.espidf.framework.CORE",
|
||||
SimpleNamespace(build_path=build_path),
|
||||
),
|
||||
)
|
||||
@@ -1564,8 +1422,7 @@ def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None:
|
||||
# build_path is None here too: a disabled cache must not require it.
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, None, None)
|
||||
with patch.dict("os.environ", {}, clear=True), p1, p2, p3:
|
||||
# Canonical off, so an inherited/unparsable value cannot enable it
|
||||
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
|
||||
assert _ccache_env() == {}
|
||||
|
||||
|
||||
def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None:
|
||||
@@ -1573,64 +1430,20 @@ def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None:
|
||||
# short-circuits before build_path is needed.
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None)
|
||||
with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3:
|
||||
# The canonical off spelling is exported: the raw value is inherited
|
||||
# by idf.py, where a spelling like "disable" would read as truthy
|
||||
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
|
||||
assert _ccache_env() == {}
|
||||
|
||||
|
||||
def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None:
|
||||
# Explicit IDF_CCACHE_ENABLE=1 forces it on; the probe verdict is
|
||||
# ignored but the resolver still runs for its no-binary warning.
|
||||
# Explicit IDF_CCACHE_ENABLE=1 forces it on without probing PATH. It's
|
||||
# already in the environment, so it isn't re-emitted, but the rest is.
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build")
|
||||
with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3:
|
||||
env = _ccache_env()
|
||||
assert env["IDF_CCACHE_ENABLE"] == "1"
|
||||
assert "IDF_CCACHE_ENABLE" not in env
|
||||
assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache")
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
|
||||
|
||||
def test_ccache_env_honors_shared_esphome_opt_out(tmp_path: Path) -> None:
|
||||
"""ESPHOME_CCACHE_ENABLE=0 disables ccache here too; the shared policy
|
||||
must not apply to every backend except this one."""
|
||||
_p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
|
||||
env_vars = {"ESPHOME_CCACHE_ENABLE": "0", "PATH": "/usr/bin"}
|
||||
with patch.dict("os.environ", env_vars, clear=True), p2, p3:
|
||||
# The real resolver runs so the opt-out parse is exercised
|
||||
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["off", "no"])
|
||||
def test_ccache_env_idf_knob_parses_strictly(tmp_path: Path, value: str) -> None:
|
||||
"""IDF_CCACHE_ENABLE uses the same strict table as the shared knob, so
|
||||
"off" disables instead of reading as truthy."""
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
|
||||
with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": value}, clear=True), p1, p2, p3:
|
||||
assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
|
||||
def test_ccache_env_idf_knob_unrecognized_warns_and_defers(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An unparsable IDF_CCACHE_ENABLE warns, defers to the shared resolver,
|
||||
and is not forwarded to idf.py as truthy."""
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
|
||||
env_vars = {"IDF_CCACHE_ENABLE": "enabled"}
|
||||
with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3:
|
||||
env = _ccache_env()
|
||||
assert "unrecognized IDF_CCACHE_ENABLE" in caplog.text
|
||||
assert env["IDF_CCACHE_ENABLE"] == "1"
|
||||
|
||||
|
||||
def test_ccache_env_idf_knob_wins_over_shared_opt_out(tmp_path: Path) -> None:
|
||||
"""IDF_CCACHE_ENABLE=1 takes precedence over ESPHOME_CCACHE_ENABLE=0."""
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build")
|
||||
env_vars = {"IDF_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_ENABLE": "0"}
|
||||
with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3:
|
||||
env = _ccache_env()
|
||||
assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache")
|
||||
assert env["IDF_CCACHE_ENABLE"] == "1"
|
||||
|
||||
|
||||
def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None:
|
||||
# User-set CCACHE_* values must not be clobbered; unset ones still default.
|
||||
p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build")
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Tests for esphome.espidf.idedata (compile_commands.json -> idedata)."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.espidf import idedata
|
||||
|
||||
# An absolute, forward-slash (shlex-safe) path prefix valid on the host OS, so
|
||||
# tests exercise the same is-absolute / normalize behavior as a real compile DB
|
||||
# (a drive-qualified path on Windows, a leading slash elsewhere).
|
||||
ABS = "C:/" if os.name == "nt" else "/"
|
||||
|
||||
|
||||
def _entry(directory: str, file: str, command: str) -> dict:
|
||||
return {"directory": directory, "file": file, "command": command}
|
||||
|
||||
|
||||
def test_parse_entry_extracts_fields() -> None:
|
||||
"""cxx_path, defines, includes and remaining flags are split apart."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/core/application.cpp",
|
||||
f"/tools/xtensa-esp32-elf-g++ -DUSE_ESP32 -DESPHOME_LOG_LEVEL=5 "
|
||||
f"-I{ABS}inc/a -isystem {ABS}sys/b -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, cxx_flags = idedata._parse_entry(entry)
|
||||
|
||||
assert cxx_path == "/tools/xtensa-esp32-elf-g++"
|
||||
assert "USE_ESP32" in defines
|
||||
assert "ESPHOME_LOG_LEVEL=5" in defines
|
||||
assert f"{ABS}inc/a" in includes
|
||||
assert f"{ABS}sys/b" in includes
|
||||
assert "-std=gnu++20" in cxx_flags
|
||||
# input/output files and their flags are not treated as flags
|
||||
assert "-c" not in cxx_flags
|
||||
assert "-o" not in cxx_flags
|
||||
assert "app.cpp" not in cxx_flags
|
||||
assert "app.cpp.o" not in cxx_flags
|
||||
|
||||
|
||||
def test_parse_entry_space_separated_args() -> None:
|
||||
"""``-D X`` / ``-I path`` (separate arg) and ``-isystem<path>`` (joined)."""
|
||||
entry = _entry(
|
||||
f"{ABS}build",
|
||||
f"{ABS}build/src/esphome/x.cpp",
|
||||
f"g++ -D FOO=1 -I {ABS}inc/sep -isystem{ABS}sys/joined -c x.cpp",
|
||||
)
|
||||
|
||||
_, defines, includes, _ = idedata._parse_entry(entry)
|
||||
|
||||
assert "FOO=1" in defines
|
||||
assert f"{ABS}inc/sep" in includes
|
||||
assert f"{ABS}sys/joined" in includes
|
||||
|
||||
|
||||
def test_parse_entry_resolves_relative_includes() -> None:
|
||||
"""Relative includes are resolved against the entry's ``directory``."""
|
||||
directory = f"{ABS}build/proj"
|
||||
entry = _entry(
|
||||
directory,
|
||||
f"{directory}/src/esphome/x.cpp",
|
||||
"g++ -Iconfig -I../shared -isystem rel/sys -c x.cpp",
|
||||
)
|
||||
|
||||
_, _, includes, _ = idedata._parse_entry(entry)
|
||||
|
||||
def resolved(rel: str) -> str:
|
||||
# _parse_entry emits forward slashes for consistency (normpath would
|
||||
# yield backslashes on Windows).
|
||||
return os.path.normpath(Path(directory) / rel).replace("\\", "/")
|
||||
|
||||
assert resolved("config") in includes
|
||||
assert resolved("../shared") in includes # ../ normalized away
|
||||
assert resolved("rel/sys") in includes
|
||||
# nothing is left relative
|
||||
assert all(Path(inc).is_absolute() for inc in includes)
|
||||
|
||||
|
||||
def test_parse_entry_skips_dependency_flags() -> None:
|
||||
"""Dependency-generation flags (and their args) are dropped."""
|
||||
entry = _entry(
|
||||
"/build",
|
||||
"/build/src/esphome/x.cpp",
|
||||
"g++ -MD -MT x.cpp.o -MF x.cpp.o.d -c x.cpp -o x.cpp.o",
|
||||
)
|
||||
|
||||
_, _, _, cxx_flags = idedata._parse_entry(entry)
|
||||
|
||||
for tok in ("-MD", "-MT", "x.cpp.o", "-MF", "x.cpp.o.d", "-c", "-o", "x.cpp"):
|
||||
assert tok not in cxx_flags
|
||||
|
||||
|
||||
def test_expand_response_files(tmp_path: Path) -> None:
|
||||
"""``@file`` arguments are inlined relative to the directory."""
|
||||
rsp = tmp_path / "flags.rsp"
|
||||
rsp.write_text("-DFROM_RSP -I/rsp/inc")
|
||||
|
||||
tokens = idedata._expand_response_files(
|
||||
["g++", f"@{rsp.name}", "-c", "x.cpp"], tmp_path
|
||||
)
|
||||
|
||||
assert "-DFROM_RSP" in tokens
|
||||
assert "-I/rsp/inc" in tokens
|
||||
assert not any(t.startswith("@") for t in tokens)
|
||||
|
||||
|
||||
def test_expand_response_files_keeps_literal_when_missing(tmp_path: Path) -> None:
|
||||
"""An unreadable ``@file`` token is kept verbatim rather than dropped."""
|
||||
tokens = idedata._expand_response_files(["g++", "@nope.rsp"], tmp_path)
|
||||
assert "@nope.rsp" in tokens
|
||||
|
||||
|
||||
def test_pick_entry_prefers_esphome_tu() -> None:
|
||||
"""A ``/src/esphome/`` C++ TU is picked over other compile entries."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/src/esphome/core/app.cpp", "g++ -c app.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("app.cpp")
|
||||
|
||||
|
||||
def test_pick_entry_falls_back_to_any_cxx_tu() -> None:
|
||||
"""With no ``/src/esphome/`` TU present, the first C++ entry is the fallback."""
|
||||
entries = [
|
||||
_entry("/b", "/b/managed_components/foo/foo.c", "gcc -c foo.c"),
|
||||
_entry("/b", "/b/components/x/x.cpp", "g++ -c x.cpp"),
|
||||
]
|
||||
assert idedata._pick_entry(entries)["file"].endswith("x.cpp")
|
||||
|
||||
|
||||
def test_is_esphome_src_handles_backslash_paths() -> None:
|
||||
r"""The src marker must match Windows ``\src\esphome\`` paths too.
|
||||
|
||||
compile_commands ``file`` entries use the OS-native separator; if the
|
||||
marker only matched forward slashes no source would match on Windows and
|
||||
the build-include union would be silently empty.
|
||||
"""
|
||||
assert idedata._is_esphome_src(r"C:\b\src\esphome\core\app.cpp")
|
||||
assert idedata._is_esphome_src("/b/src/esphome/core/app.cpp")
|
||||
# non-esphome and non-C++ still rejected regardless of separator
|
||||
assert not idedata._is_esphome_src(r"C:\b\managed_components\x\x.cpp")
|
||||
assert not idedata._is_esphome_src(r"C:\b\src\esphome\core\app.h")
|
||||
|
||||
|
||||
def test_idedata_from_build(tmp_path: Path) -> None:
|
||||
"""Full transform: representative entry + include union + toolchain dirs."""
|
||||
compile_commands = tmp_path / "compile_commands.json"
|
||||
entries = [
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/core/app.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/core -std=gnu++20 -c app.cpp -o app.cpp.o",
|
||||
),
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/src/esphome/sensor/s.cpp",
|
||||
f"g++ -DUSE_ESP32 -I{ABS}inc/sensor -c s.cpp -o s.cpp.o",
|
||||
),
|
||||
# non-esphome TU: its includes must not leak into the union
|
||||
_entry(
|
||||
f"{ABS}b",
|
||||
f"{ABS}b/managed_components/x/x.c",
|
||||
f"gcc -I{ABS}inc/managed -c x.c",
|
||||
),
|
||||
]
|
||||
compile_commands.write_text(json.dumps(entries))
|
||||
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr=(
|
||||
"ignored\n"
|
||||
"#include <...> search starts here:\n"
|
||||
" /tc/inc/c++\n"
|
||||
" /tc/inc\n"
|
||||
"End of search list.\n"
|
||||
"more ignored\n"
|
||||
),
|
||||
)
|
||||
with patch.object(idedata.subprocess, "run", return_value=fake_proc):
|
||||
data = idedata.idedata_from_build(compile_commands)
|
||||
|
||||
assert data["cxx_path"] == "g++"
|
||||
assert "USE_ESP32" in data["defines"]
|
||||
assert "-std=gnu++20" in data["cxx_flags"]
|
||||
# include dirs unioned across all esphome TUs
|
||||
assert f"{ABS}inc/core" in data["includes"]["build"]
|
||||
assert f"{ABS}inc/sensor" in data["includes"]["build"]
|
||||
# the non-esphome TU is excluded from the union
|
||||
assert f"{ABS}inc/managed" not in data["includes"]["build"]
|
||||
# toolchain search dirs parsed from the compiler's -v output
|
||||
assert data["includes"]["toolchain"] == ["/tc/inc/c++", "/tc/inc"]
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_on_probe_failure() -> None:
|
||||
"""A failed compiler probe is a hard error, not a silent empty list."""
|
||||
fake_proc = MagicMock(returncode=1, stderr="xtensa-esp32-elf-g++: not found")
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata._get_toolchain_includes("/bad/compiler")
|
||||
|
||||
|
||||
def test_get_toolchain_includes_raises_when_no_dirs_found() -> None:
|
||||
"""Markers present but no dirs (anomalous output) also raises."""
|
||||
fake_proc = MagicMock(
|
||||
returncode=0,
|
||||
stderr="#include <...> search starts here:\nEnd of search list.\n",
|
||||
)
|
||||
with (
|
||||
patch.object(idedata.subprocess, "run", return_value=fake_proc),
|
||||
pytest.raises(RuntimeError, match="builtin include dirs"),
|
||||
):
|
||||
idedata._get_toolchain_includes("/some/compiler")
|
||||
|
||||
|
||||
# ESP-IDF's compile_commands.json on Windows mixes literal backslash path
|
||||
# separators in the compiler path with shell ``\"`` quote-escaping in defines,
|
||||
# which only the real Windows argv parser handles. These exercise that path.
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_preserves_paths_and_unescapes_quotes() -> None:
|
||||
r"""Backslash paths survive while ``\"`` define-quoting is unescaped."""
|
||||
command = r"C:\esp\bin\riscv32-esp-elf-g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp"
|
||||
|
||||
tokens = idedata._split_command(command)
|
||||
|
||||
assert tokens[0] == r"C:\esp\bin\riscv32-esp-elf-g++.exe"
|
||||
assert '-DVER="1.2.3"' in tokens
|
||||
assert "-IC:/inc/a" in tokens
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_split_command_empty_returns_empty() -> None:
|
||||
"""An empty or blank command tokenizes to ``[]`` (e.g. an empty response file).
|
||||
|
||||
Guards against ``CommandLineToArgvW("")`` returning the current process name
|
||||
instead of an empty list.
|
||||
"""
|
||||
assert idedata._split_command("") == []
|
||||
assert idedata._split_command(" ") == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows argv tokenization")
|
||||
def test_parse_entry_normalizes_windows_cxx_path() -> None:
|
||||
"""A backslash compiler path is emitted forward-slashed; define unescaped."""
|
||||
entry = _entry(
|
||||
r"C:\b",
|
||||
r"C:\b\src\esphome\x.cpp",
|
||||
r"C:\esp\bin\g++.exe -DVER=\"1.2.3\" -IC:/inc/a -c x.cpp",
|
||||
)
|
||||
|
||||
cxx_path, defines, includes, _ = idedata._parse_entry(entry)
|
||||
|
||||
assert cxx_path == "C:/esp/bin/g++.exe"
|
||||
assert "\\" not in cxx_path
|
||||
assert 'VER="1.2.3"' in defines
|
||||
assert "C:/inc/a" in includes
|
||||
@@ -140,7 +140,7 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
|
||||
compile_commands.write_text("[]")
|
||||
|
||||
with patch(
|
||||
"esphome.build_helpers.idedata.idedata_from_build",
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
@@ -151,6 +151,114 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
|
||||
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
|
||||
|
||||
|
||||
def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
|
||||
"""A cache at least as new as the compile DB is reused without regenerating."""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cc_path": "cached-gcc", "cxx_path": "cached"}')
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch("esphome.espidf.idedata.idedata_from_build") as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_not_called()
|
||||
assert result == {"cc_path": "cached-gcc", "cxx_path": "cached"}
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_cache_without_cc_path(setup_core: Path) -> None:
|
||||
"""A cache predating cc_path is rebuilt even though it is newer.
|
||||
|
||||
Such a cache stays newer than the compile DB forever, so consumers that
|
||||
derive the binutils paths from cc_path would keep failing on it.
|
||||
"""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cxx_path": "cached"}')
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cc_path": "gcc", "cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result["cc_path"] == "gcc"
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -> None:
|
||||
"""A compile DB newer than the cache forces regeneration."""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text('{"cxx_path": "stale"}')
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache_mtime = cache.stat().st_mtime
|
||||
os.utime(compile_commands, (cache_mtime + 1, cache_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "fresh"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cached", ['"cc_path is a string"', "[]", "42"])
|
||||
def test_get_idedata_regenerates_on_non_dict_cache(
|
||||
setup_core: Path, cached: str
|
||||
) -> None:
|
||||
"""A newer cache holding valid JSON that is not an object is regenerated.
|
||||
|
||||
A bare string would otherwise pass the cc_path check by substring and be
|
||||
handed to consumers expecting a dict.
|
||||
"""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(cached)
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cc_path": "gcc", "cxx_path": "g++"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
|
||||
"""An unparseable (but newer) cache falls back to regeneration."""
|
||||
compile_commands, cache = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text("{not json")
|
||||
cc_mtime = compile_commands.stat().st_mtime
|
||||
os.utime(cache, (cc_mtime + 1, cc_mtime + 1))
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "regen"},
|
||||
) as mock_transform:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
|
||||
"""The idedata exposes prog_path (the ELF) so consumers like build-action
|
||||
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
|
||||
@@ -159,7 +267,7 @@ def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
|
||||
compile_commands.write_text("[]")
|
||||
|
||||
with patch(
|
||||
"esphome.build_helpers.idedata.idedata_from_build",
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "g++"},
|
||||
):
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
@@ -998,10 +998,10 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
|
||||
assert "100%" in captured.err
|
||||
assert "Done" in captured.err
|
||||
|
||||
# done() after the 100% frame adds nothing; that frame ended its line
|
||||
# Test done method
|
||||
progress.done()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.err == ""
|
||||
assert captured.err == "\n"
|
||||
|
||||
# Test same progress doesn't update
|
||||
progress.update(0.5)
|
||||
@@ -1010,10 +1010,6 @@ def test_progress_bar(capsys: CaptureFixture[str]) -> None:
|
||||
# Should only see one update (second call shouldn't write)
|
||||
assert captured.err.count("50%") == 1
|
||||
|
||||
# done() after a mid-way frame ends the line
|
||||
progress.done()
|
||||
assert capsys.readouterr().err == "\n"
|
||||
|
||||
|
||||
# Tests for SHA256 authentication
|
||||
@pytest.mark.usefixtures("mock_time")
|
||||
|
||||
@@ -12,8 +12,6 @@ from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock, Mock, call, patch
|
||||
import zipfile
|
||||
|
||||
@@ -24,7 +22,6 @@ from esphome import framework_helpers
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import (
|
||||
_7z_extract_all,
|
||||
_BatchDownloadProgress,
|
||||
_detect_archive_root,
|
||||
_rename_with_retry,
|
||||
_tar_extract_all,
|
||||
@@ -39,7 +36,6 @@ from esphome.framework_helpers import (
|
||||
get_python_env_executable_path,
|
||||
get_system_python_path,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
run_command,
|
||||
run_command_ok,
|
||||
str_to_lst_of_str,
|
||||
@@ -1115,220 +1111,6 @@ class TestDownloadWithResume:
|
||||
assert mock_get.call_args[1]["headers"] == {}
|
||||
assert dest.read_bytes() == b"data"
|
||||
|
||||
def test_progress_callback_reports_absolute_bytes(self, tmp_path: Path) -> None:
|
||||
"""With a callback no bar is drawn; the callback sees the running
|
||||
byte count of this file, then its final verified size."""
|
||||
dest = tmp_path / "tool.tar.gz"
|
||||
resp = _mock_response(b"")
|
||||
resp.headers = {"content-length": "7"}
|
||||
resp.iter_content.return_value = [b"1234", b"567"]
|
||||
seen: list[int] = []
|
||||
with (
|
||||
patch("requests.get", return_value=resp),
|
||||
patch("esphome.framework_helpers.ProgressBar") as bar_cls,
|
||||
):
|
||||
download_with_resume(
|
||||
"https://example.com/t", dest, size=7, progress=seen.append
|
||||
)
|
||||
assert seen == [0, 4, 7, 7]
|
||||
bar_cls.assert_not_called()
|
||||
|
||||
def test_progress_callback_seeds_with_resume_offset(self, tmp_path: Path) -> None:
|
||||
dest = tmp_path / "tool.tar.gz"
|
||||
(tmp_path / "tool.tar.gz.part").write_bytes(b"12345")
|
||||
good = hashlib.sha256(b"12345678").hexdigest()
|
||||
seen: list[int] = []
|
||||
with patch("requests.get", return_value=_resumed_response(b"678")):
|
||||
download_with_resume(
|
||||
"https://example.com/t", dest, sha256=good, size=8, progress=seen.append
|
||||
)
|
||||
assert seen[0] == 5
|
||||
assert seen[-1] == 8
|
||||
|
||||
def test_progress_callback_credits_already_complete_download(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""A verified dest from an earlier run still counts toward the batch."""
|
||||
dest = tmp_path / "tool.tar.gz"
|
||||
dest.write_bytes(b"12345678")
|
||||
seen: list[int] = []
|
||||
with patch("requests.get") as mock_get:
|
||||
download_with_resume(
|
||||
"https://example.com/t", dest, size=8, progress=seen.append
|
||||
)
|
||||
mock_get.assert_not_called()
|
||||
assert seen == [8]
|
||||
|
||||
|
||||
def test_run_batch_downloads_ctrl_c_aborts_in_flight_jobs() -> None:
|
||||
"""Ctrl-C cancels in-flight downloads at their next tick instead of
|
||||
letting non-daemon workers download to completion."""
|
||||
started = threading.Event()
|
||||
ticks: list[int] = []
|
||||
|
||||
def interrupter(tracker) -> None:
|
||||
started.wait(5)
|
||||
raise KeyboardInterrupt
|
||||
|
||||
def slow_download(tracker) -> None:
|
||||
started.set()
|
||||
for i in range(500):
|
||||
tracker(i)
|
||||
ticks.append(i)
|
||||
time.sleep(0.01)
|
||||
|
||||
t0 = time.monotonic()
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
run_batch_downloads(
|
||||
"Downloading",
|
||||
[("boom", 0, interrupter), ("slow", 0, slow_download)],
|
||||
max_workers=2,
|
||||
)
|
||||
# Uncancelled, slow_download alone takes ~5s
|
||||
assert time.monotonic() - t0 < 3
|
||||
assert len(ticks) < 500
|
||||
|
||||
|
||||
def test_cancellation_escapes_broad_except_in_fetch() -> None:
|
||||
"""A fetch that wraps its work in except Exception cannot swallow the
|
||||
Ctrl-C sentinel (it is a BaseException)."""
|
||||
from esphome.framework_helpers import _BatchDownloadCancelled
|
||||
|
||||
started = threading.Event()
|
||||
swallowed = []
|
||||
|
||||
def interrupter(tracker) -> None:
|
||||
started.wait(5)
|
||||
raise KeyboardInterrupt
|
||||
|
||||
def greedy_fetch(tracker) -> None:
|
||||
started.set()
|
||||
try:
|
||||
for i in range(500):
|
||||
tracker(i)
|
||||
time.sleep(0.01)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
swallowed.append(err)
|
||||
|
||||
t0 = time.monotonic()
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
run_batch_downloads(
|
||||
"Downloading",
|
||||
[("boom", 0, interrupter), ("greedy", 0, greedy_fetch)],
|
||||
max_workers=2,
|
||||
)
|
||||
assert time.monotonic() - t0 < 3
|
||||
assert not swallowed
|
||||
assert issubclass(_BatchDownloadCancelled, BaseException)
|
||||
assert not issubclass(_BatchDownloadCancelled, Exception)
|
||||
|
||||
|
||||
def test_logging_guard_ends_the_bar_row_before_a_record() -> None:
|
||||
r"""A worker warning gets its own line instead of the bar's \r row."""
|
||||
stream = io.StringIO()
|
||||
stream.isatty = lambda: True # type: ignore[method-assign]
|
||||
with patch("esphome.helpers.sys.stderr", stream):
|
||||
progress = _BatchDownloadProgress("Downloading", 10)
|
||||
progress.tracker()(5)
|
||||
with progress.logging_guard():
|
||||
logging.getLogger("esphome.test").warning("mirror retry")
|
||||
# The partial 50% frame ended its line before the record was emitted
|
||||
assert stream.getvalue().endswith("50% \n")
|
||||
# And the next tick redraws the frame on a fresh row
|
||||
progress.tracker()(2)
|
||||
assert stream.getvalue().endswith("70% ")
|
||||
|
||||
|
||||
def test_logging_guard_without_a_bar_is_a_no_op() -> None:
|
||||
"""An unknown total draws no bar; the guard passes records through."""
|
||||
from esphome.framework_helpers import _BatchDownloadProgress
|
||||
|
||||
progress = _BatchDownloadProgress("Downloading", 0)
|
||||
with progress.logging_guard():
|
||||
logging.getLogger("esphome.test").warning("plain record")
|
||||
|
||||
|
||||
def test_cancellable_sleep_sleeps_between_ticks() -> None:
|
||||
"""An uncancelled backoff actually waits out its delay in slices."""
|
||||
from esphome.framework_helpers import _cancellable_sleep
|
||||
|
||||
ticks: list[int] = []
|
||||
t0 = time.monotonic()
|
||||
_cancellable_sleep(0.05, ticks.append, 3)
|
||||
assert time.monotonic() - t0 >= 0.05
|
||||
assert ticks and all(t == 3 for t in ticks)
|
||||
|
||||
|
||||
def test_cancellable_sleep_aborts_at_the_tick() -> None:
|
||||
"""A backoff sleep observes the cancellation raise promptly."""
|
||||
from esphome.framework_helpers import _BatchDownloadCancelled, _cancellable_sleep
|
||||
|
||||
def cancelled_tick(done: int) -> None:
|
||||
raise _BatchDownloadCancelled
|
||||
|
||||
t0 = time.monotonic()
|
||||
with pytest.raises(_BatchDownloadCancelled):
|
||||
_cancellable_sleep(30, cancelled_tick, 0)
|
||||
assert time.monotonic() - t0 < 1
|
||||
|
||||
|
||||
class Test_BatchDownloadProgress:
|
||||
def test_sums_trackers_into_one_bar(self) -> None:
|
||||
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
|
||||
progress = _BatchDownloadProgress("Downloading", 100)
|
||||
a = progress.tracker()
|
||||
b = progress.tracker()
|
||||
a(10)
|
||||
b(20)
|
||||
a(30)
|
||||
a(0) # a restart from zero takes that file's bytes back out
|
||||
bar_cls.assert_called_once_with("Downloading")
|
||||
updates = [c[0][0] for c in bar_cls.return_value.update.call_args_list]
|
||||
assert updates == [0.1, 0.3, 0.5, 0.2]
|
||||
|
||||
def test_clamps_at_one(self) -> None:
|
||||
"""Sizes are advisory; an over-delivering server never pushes past 100%."""
|
||||
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
|
||||
progress = _BatchDownloadProgress("Downloading", 10)
|
||||
progress.tracker()(25)
|
||||
assert bar_cls.return_value.update.call_args[0][0] == 1
|
||||
|
||||
def test_unknown_total_draws_nothing(self) -> None:
|
||||
with patch("esphome.framework_helpers.ProgressBar") as bar_cls:
|
||||
progress = _BatchDownloadProgress("Downloading", 0)
|
||||
progress.tracker()(5)
|
||||
progress.done()
|
||||
bar_cls.assert_not_called()
|
||||
|
||||
def test_done_ends_an_unfinished_bar(self) -> None:
|
||||
"""A batch that stops short of 100% (a failed archive) still ends its
|
||||
line so the next log message starts on a fresh row."""
|
||||
stream = io.StringIO()
|
||||
stream.isatty = lambda: True # type: ignore[method-assign]
|
||||
with patch("esphome.helpers.sys.stderr", stream):
|
||||
progress = _BatchDownloadProgress("Downloading", 10)
|
||||
progress.tracker()(5)
|
||||
progress.done()
|
||||
assert stream.getvalue().endswith("50% \n")
|
||||
|
||||
def test_done_before_any_frame_writes_nothing(self) -> None:
|
||||
"""A batch aborted before any tracker fired must not emit a stray
|
||||
newline for a bar that was never drawn."""
|
||||
stream = io.StringIO()
|
||||
stream.isatty = lambda: True # type: ignore[method-assign]
|
||||
with patch("esphome.helpers.sys.stderr", stream):
|
||||
_BatchDownloadProgress("Downloading", 10).done()
|
||||
assert stream.getvalue() == ""
|
||||
|
||||
def test_done_after_full_bar_adds_nothing(self) -> None:
|
||||
stream = io.StringIO()
|
||||
stream.isatty = lambda: True # type: ignore[method-assign]
|
||||
with patch("esphome.helpers.sys.stderr", stream):
|
||||
progress = _BatchDownloadProgress("Downloading", 10)
|
||||
progress.tracker()(10)
|
||||
progress.done()
|
||||
assert stream.getvalue().endswith("100% Done...\r\n")
|
||||
|
||||
|
||||
class TestDownloadFromMirrors:
|
||||
def test_success_returns_url_and_writes_content(self, tmp_path: Path) -> None:
|
||||
@@ -1341,22 +1123,6 @@ class TestDownloadFromMirrors:
|
||||
assert url == "https://example.com/f"
|
||||
assert target.read_bytes() == b"filedata"
|
||||
|
||||
def test_file_object_target_reports_progress(self) -> None:
|
||||
"""The library prefetch's production path: a file-object target
|
||||
streams through the mirror fallback and ticks the tracker."""
|
||||
buf = io.BytesIO()
|
||||
ticks: list[int] = []
|
||||
with patch(
|
||||
"requests.get",
|
||||
return_value=_mock_response(b"filedata"),
|
||||
):
|
||||
url = download_from_mirrors(
|
||||
["https://example.com/f"], {}, buf, progress=ticks.append
|
||||
)
|
||||
assert url == "https://example.com/f"
|
||||
assert buf.getvalue() == b"filedata"
|
||||
assert ticks and ticks[-1] == len(b"filedata")
|
||||
|
||||
def test_substitutions_applied_to_url(self, tmp_path: Path) -> None:
|
||||
with patch(
|
||||
"requests.get",
|
||||
@@ -1702,27 +1468,6 @@ class TestDownloadFromMirrors:
|
||||
assert mock_get.call_count == 2
|
||||
mock_sleep.assert_called_once_with(2)
|
||||
|
||||
def test_backoff_tick_reports_partial_bytes(self, tmp_path: Path) -> None:
|
||||
"""The backoff tick carries the bytes already in the part file, so a
|
||||
combined bar holds steady instead of rewinding to zero."""
|
||||
dest = tmp_path / "out.bin"
|
||||
(tmp_path / "out.bin.part").write_bytes(b"12345")
|
||||
ticks: list[int] = []
|
||||
with (
|
||||
patch(
|
||||
"requests.get",
|
||||
side_effect=[
|
||||
req.ConnectionError("down"),
|
||||
_mock_response(b"data"),
|
||||
],
|
||||
),
|
||||
patch("esphome.framework_helpers._cancellable_sleep") as mock_sleep,
|
||||
):
|
||||
download_from_mirrors(
|
||||
["https://mirror1.com/f"], {}, dest, progress=ticks.append
|
||||
)
|
||||
assert mock_sleep.call_args == call(2, ticks.append, 5)
|
||||
|
||||
def test_permanent_failure_does_not_retry_sweep(self, tmp_path: Path) -> None:
|
||||
"""An HTTP 404 will not heal on its own; fail after a single pass."""
|
||||
with (
|
||||
@@ -2308,37 +2053,3 @@ class TestGetProjectCxxCompileFlags:
|
||||
def test_empty_flags(self) -> None:
|
||||
with patch("esphome.core.CORE", _make_core_cxx(set())):
|
||||
assert get_project_cxx_compile_flags() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "input_path", "expected"),
|
||||
[
|
||||
# win32: drive-letter extended-length prefix is stripped
|
||||
(
|
||||
"win32",
|
||||
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
),
|
||||
# win32: UNC extended-length prefix is translated to a regular UNC path
|
||||
(
|
||||
"win32",
|
||||
"\\\\?\\UNC\\server\\share\\python.exe",
|
||||
"\\\\server\\share\\python.exe",
|
||||
),
|
||||
# win32: paths without the prefix are returned unchanged
|
||||
(
|
||||
"win32",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
),
|
||||
# non-win32: prefix is left alone (no-op)
|
||||
("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"),
|
||||
("darwin", "/usr/bin/python3", "/usr/bin/python3"),
|
||||
],
|
||||
)
|
||||
def test_strip_win_long_path_prefix(
|
||||
platform: str, input_path: str, expected: str
|
||||
) -> None:
|
||||
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
|
||||
with patch("esphome.framework_helpers.sys.platform", platform):
|
||||
assert framework_helpers.strip_win_long_path_prefix(input_path) == expected
|
||||
|
||||
@@ -1117,20 +1117,6 @@ def test_progressbar_enabled_on_pipe_with_dashboard(monkeypatch) -> None:
|
||||
assert bar.enabled is True
|
||||
|
||||
|
||||
def test_progressbar_interrupt_keeps_finished_bar_done(monkeypatch) -> None:
|
||||
"""interrupt() on a bar whose 100% frame already ended its own line
|
||||
must not reset it, or the next tick would redraw a second Done row."""
|
||||
stream = MagicMock(spec=io.TextIOWrapper)
|
||||
stream.isatty.return_value = True
|
||||
monkeypatch.setattr(CORE, "dashboard", False)
|
||||
|
||||
bar = ProgressBar("Uploading", stream=stream)
|
||||
bar.update(1)
|
||||
assert bar.last_progress == 100
|
||||
bar.interrupt()
|
||||
assert bar.last_progress == 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("seconds", "expected"),
|
||||
[
|
||||
|
||||
+22
-118
@@ -17,7 +17,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from pytest import CaptureFixture
|
||||
import serial
|
||||
from zeroconf import ServiceStateChange
|
||||
|
||||
from esphome import __main__ as main, yaml_util
|
||||
@@ -27,7 +26,6 @@ from esphome.__main__ import (
|
||||
_make_crystal_freq_callback,
|
||||
_redact_with_legacy_fallback,
|
||||
_resolve_network_devices,
|
||||
_should_subscribe_states,
|
||||
_split_network_devices,
|
||||
_unresolved_default_error,
|
||||
_validate_bootloader_binary,
|
||||
@@ -71,21 +69,19 @@ from esphome.__main__ import (
|
||||
)
|
||||
from esphome.address_cache import AddressCache
|
||||
from esphome.bundle import BUNDLE_EXTENSION, BundleFile, BundleResult
|
||||
from esphome.components import esp32, esp8266, mqtt
|
||||
from esphome.components import esp32, esp8266
|
||||
from esphome.components.esp32 import (
|
||||
KEY_ESP32,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32,
|
||||
get_esp32_variant,
|
||||
)
|
||||
from esphome.config import Config
|
||||
from esphome.const import (
|
||||
CONF_API,
|
||||
CONF_AUTH,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DISABLED,
|
||||
CONF_DISCOVER_IP,
|
||||
CONF_ESPHOME,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
@@ -107,7 +103,6 @@ from esphome.const import (
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
@@ -572,6 +567,8 @@ def test_command_config__no_defaults_dumps_user_snapshot(
|
||||
) -> None:
|
||||
"""``--no-defaults`` dumps ``config.user_config`` instead of the
|
||||
validated config, so schema defaults don't leak into the output."""
|
||||
from esphome.config import Config
|
||||
|
||||
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
|
||||
args = MockArgs()
|
||||
args.show_secrets = True
|
||||
@@ -624,6 +621,8 @@ def test_command_config__no_defaults_skips_strip_default_ids(
|
||||
) -> None:
|
||||
"""When ``--no-defaults`` is set, ``strip_default_ids`` isn't run --
|
||||
the user snapshot is already free of schema-injected IDs."""
|
||||
from esphome.config import Config
|
||||
|
||||
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
|
||||
args = MockArgs()
|
||||
args.show_secrets = True
|
||||
@@ -3441,6 +3440,9 @@ def test_get_port_type() -> None:
|
||||
|
||||
def test_mqtt_reexports_discover_ip() -> None:
|
||||
"""The old import path must keep working for external code."""
|
||||
from esphome.components import mqtt
|
||||
from esphome.const import CONF_DISCOVER_IP
|
||||
|
||||
assert mqtt.CONF_DISCOVER_IP is CONF_DISCOVER_IP
|
||||
|
||||
|
||||
@@ -5907,6 +5909,8 @@ class MockSerial:
|
||||
chunk = self.chunks[self.chunk_index]
|
||||
if chunk is MOCK_SERIAL_END:
|
||||
# Sentinel means we're done - simulate port closed
|
||||
import serial
|
||||
|
||||
raise serial.SerialException("Port closed")
|
||||
# Respect the requested size and keep any remaining bytes
|
||||
if size <= 0:
|
||||
@@ -5920,6 +5924,8 @@ class MockSerial:
|
||||
# Entire chunk consumed; advance to the next one
|
||||
self.chunk_index += 1
|
||||
return data # type: ignore[return-value]
|
||||
import serial
|
||||
|
||||
raise serial.SerialException("Port closed")
|
||||
|
||||
|
||||
@@ -6778,6 +6784,8 @@ def test_parse_args_argcomplete_only_runs_when_completing() -> None:
|
||||
|
||||
def test_should_subscribe_states_default() -> None:
|
||||
"""Test that states are shown by default when nothing is set."""
|
||||
from esphome.__main__ import _should_subscribe_states
|
||||
|
||||
args = parse_args(["esphome", "logs", "device.yaml"])
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("ESPHOME_LOG_STATES", None)
|
||||
@@ -6786,6 +6794,8 @@ def test_should_subscribe_states_default() -> None:
|
||||
|
||||
def test_should_subscribe_states_env_suppresses() -> None:
|
||||
"""Test that ESPHOME_LOG_STATES=false suppresses states by default."""
|
||||
from esphome.__main__ import _should_subscribe_states
|
||||
|
||||
args = parse_args(["esphome", "logs", "device.yaml"])
|
||||
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}):
|
||||
assert _should_subscribe_states(args) is False
|
||||
@@ -6793,6 +6803,8 @@ def test_should_subscribe_states_env_suppresses() -> None:
|
||||
|
||||
def test_should_subscribe_states_env_enables() -> None:
|
||||
"""Test that ESPHOME_LOG_STATES=true enables states by default."""
|
||||
from esphome.__main__ import _should_subscribe_states
|
||||
|
||||
args = parse_args(["esphome", "logs", "device.yaml"])
|
||||
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}):
|
||||
assert _should_subscribe_states(args) is True
|
||||
@@ -6800,6 +6812,8 @@ def test_should_subscribe_states_env_enables() -> None:
|
||||
|
||||
def test_should_subscribe_states_flag_overrides_env() -> None:
|
||||
"""Test that --states overrides ESPHOME_LOG_STATES=false."""
|
||||
from esphome.__main__ import _should_subscribe_states
|
||||
|
||||
args = parse_args(["esphome", "logs", "--states", "device.yaml"])
|
||||
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "false"}):
|
||||
assert _should_subscribe_states(args) is True
|
||||
@@ -6807,6 +6821,8 @@ def test_should_subscribe_states_flag_overrides_env() -> None:
|
||||
|
||||
def test_should_subscribe_states_no_flag_overrides_env() -> None:
|
||||
"""Test that --no-states overrides ESPHOME_LOG_STATES=true."""
|
||||
from esphome.__main__ import _should_subscribe_states
|
||||
|
||||
args = parse_args(["esphome", "logs", "--no-states", "device.yaml"])
|
||||
with patch.dict(os.environ, {"ESPHOME_LOG_STATES": "true"}):
|
||||
assert _should_subscribe_states(args) is False
|
||||
@@ -7119,118 +7135,6 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails(
|
||||
assert not caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
FileNotFoundError("no such compiler"),
|
||||
RuntimeError("Could not query builtin include dirs"),
|
||||
ValueError("no C++ translation unit found"),
|
||||
KeyError("command"),
|
||||
None, # replaced with EsphomeError inside
|
||||
],
|
||||
)
|
||||
def test_compile_program_espidf_idedata_failure_does_not_fail_build(
|
||||
error: Exception,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A post-compile idedata error is a warning: the firmware already built."""
|
||||
if error is None:
|
||||
error = EsphomeError("compile database is unusable")
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp32",
|
||||
KEY_TARGET_FRAMEWORK: "esp-idf",
|
||||
}
|
||||
with (
|
||||
patch("esphome.espidf.toolchain.run_compile", return_value=0),
|
||||
patch("esphome.espidf.toolchain.create_factory_bin"),
|
||||
patch("esphome.espidf.toolchain.create_ota_bin"),
|
||||
patch("esphome.espidf.toolchain.create_elf_copy"),
|
||||
patch("esphome.espidf.toolchain.get_idedata", side_effect=error),
|
||||
patch("esphome.__main__._check_and_emit_build_info"),
|
||||
):
|
||||
assert compile_program(MagicMock(), {}) == 0
|
||||
assert "Could not generate idedata" in caplog.text
|
||||
|
||||
|
||||
def test_compile_program_espidf_idedata_success_is_silent(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The healthy path: idedata generated, nothing to warn about."""
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp32",
|
||||
KEY_TARGET_FRAMEWORK: "esp-idf",
|
||||
}
|
||||
with (
|
||||
patch("esphome.espidf.toolchain.run_compile", return_value=0),
|
||||
patch("esphome.espidf.toolchain.create_factory_bin"),
|
||||
patch("esphome.espidf.toolchain.create_ota_bin"),
|
||||
patch("esphome.espidf.toolchain.create_elf_copy"),
|
||||
patch("esphome.espidf.toolchain.get_idedata", return_value={"cc_path": "x"}),
|
||||
patch("esphome.__main__._check_and_emit_build_info"),
|
||||
):
|
||||
assert compile_program(MagicMock(), {}) == 0
|
||||
assert "idedata" not in caplog.text
|
||||
|
||||
|
||||
def test_compile_program_espidf_idedata_none_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A silent None from the post-compile idedata refresh is made visible."""
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: "esp32",
|
||||
KEY_TARGET_FRAMEWORK: "esp-idf",
|
||||
}
|
||||
with (
|
||||
patch("esphome.espidf.toolchain.run_compile", return_value=0),
|
||||
patch("esphome.espidf.toolchain.create_factory_bin"),
|
||||
patch("esphome.espidf.toolchain.create_ota_bin"),
|
||||
patch("esphome.espidf.toolchain.create_elf_copy"),
|
||||
patch("esphome.espidf.toolchain.get_idedata", return_value=None),
|
||||
patch("esphome.__main__._check_and_emit_build_info"),
|
||||
):
|
||||
assert compile_program(MagicMock(), {}) == 0
|
||||
assert "No idedata was generated" in caplog.text
|
||||
|
||||
|
||||
def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None:
|
||||
"""An explicit --toolchain must run the per-platform validators, so the
|
||||
upload/logs fast path becomes a cache miss."""
|
||||
conf = tmp_path / "device.yaml"
|
||||
conf.write_text("esphome:\n name: t\n")
|
||||
argv = ["esphome", "--toolchain", "arduino", "logs", str(conf)]
|
||||
with (
|
||||
patch("esphome.compiled_config.load_compiled_config") as mock_cache,
|
||||
patch("esphome.config.read_config", return_value=None) as mock_read,
|
||||
):
|
||||
assert run_esphome(argv) == 2
|
||||
mock_cache.assert_not_called()
|
||||
mock_read.assert_called_once()
|
||||
|
||||
|
||||
def test_cli_toolchain_still_refreshes_the_validated_config_cache(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An explicit --toolchain gates only the cache read; with a matching
|
||||
sidecar the freshly validated config is still saved."""
|
||||
conf = tmp_path / "device.yaml"
|
||||
conf.write_text("esphome:\n name: t\n")
|
||||
argv = ["esphome", "--toolchain", "platformio", "logs", str(conf)]
|
||||
with (
|
||||
patch("esphome.compiled_config.load_compiled_config") as mock_load,
|
||||
patch("esphome.config.read_config", return_value={CONF_ESPHOME: {}}),
|
||||
patch("esphome.compiled_config.save_compiled_config_and_sidecar") as mock_save,
|
||||
patch.dict(
|
||||
"esphome.__main__.POST_CONFIG_ACTIONS", {"logs": Mock(return_value=0)}
|
||||
),
|
||||
):
|
||||
assert run_esphome(argv) == 0
|
||||
mock_load.assert_not_called()
|
||||
mock_save.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
|
||||
"""The config comment dumps with sorted keys: voluptuous fills schema
|
||||
|
||||
@@ -7,10 +7,8 @@ import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import platformdirs
|
||||
import pytest
|
||||
|
||||
from esphome.components.nrf52 import _resolve_toolchain
|
||||
from esphome.components.nrf52.framework import (
|
||||
_PLATFORMIO_PENV_REQUIREMENTS,
|
||||
_REQUIREMENTS,
|
||||
@@ -24,9 +22,8 @@ from esphome.components.nrf52.framework import (
|
||||
get_sdk_nrf_tools_path,
|
||||
setup_platformio_python_env,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.config_validation import Version
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import get_python_env_executable_path
|
||||
|
||||
@@ -561,6 +558,7 @@ def testget_tools_path_blank_env_falls_back_to_default(
|
||||
Path("") would resolve to the working directory, which clean-all could
|
||||
then delete by accident.
|
||||
"""
|
||||
import platformdirs
|
||||
|
||||
monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value)
|
||||
expected = (
|
||||
@@ -572,6 +570,7 @@ def testget_tools_path_blank_env_falls_back_to_default(
|
||||
def testget_tools_path_default_is_global_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import platformdirs
|
||||
|
||||
monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False)
|
||||
expected = (
|
||||
@@ -620,11 +619,3 @@ def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> N
|
||||
assert not python.exists()
|
||||
|
||||
assert _needs_venv_rebuild(python, sentinel, "abc123")
|
||||
|
||||
|
||||
def test_resolve_toolchain_rejects_unsupported() -> None:
|
||||
"""A --toolchain nRF52 cannot serve fails instead of degrading silently."""
|
||||
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
|
||||
_resolve_toolchain({})
|
||||
|
||||
@@ -1,542 +0,0 @@
|
||||
"""Tests for the shared extraScript machinery (platformio.extra_script)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.platformio.extra_script import (
|
||||
CppDefine,
|
||||
ExtraScriptResult,
|
||||
_FakeSConsEnv,
|
||||
apply_extra_script,
|
||||
captured_as_build_flags,
|
||||
run_extra_script,
|
||||
)
|
||||
from esphome.platformio.library import (
|
||||
ESPHOME_DATA_KEY,
|
||||
ESPHOME_DATA_LINK_FLAGS_KEY,
|
||||
ConvertedLibrary as IDFComponent,
|
||||
URLSource,
|
||||
lex_build_flags,
|
||||
)
|
||||
|
||||
|
||||
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
|
||||
|
||||
(tmp_path / "src" / "esp32").mkdir(parents=True)
|
||||
script = tmp_path / "extra_script.py"
|
||||
script.write_text(
|
||||
"Import('env')\n"
|
||||
"mcu = env.get('BOARD_MCU')\n"
|
||||
"env.Append(\n"
|
||||
" LIBPATH=[join('src', mcu)],\n"
|
||||
" LIBS=['algobsec'],\n"
|
||||
" CPPDEFINES=['FOO', ('BAR', '1')],\n"
|
||||
" LINKFLAGS=['-Wl,--gc-sections'],\n"
|
||||
")\n"
|
||||
)
|
||||
# The script uses bare ``join`` (PIO's extra-scripts run inside SCons
|
||||
# where this is in scope). Inject it via the script header so the
|
||||
# shim's exec namespace can resolve it.
|
||||
script.write_text("from os.path import join\n" + script.read_text())
|
||||
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
|
||||
assert result.libpath == [str(Path("src") / "esp32")]
|
||||
assert result.libs == ["algobsec"]
|
||||
assert CppDefine("BAR", "1") in result.cppdefines
|
||||
assert CppDefine("FOO") in result.cppdefines
|
||||
assert result.linkflags == ["-Wl,--gc-sections"]
|
||||
|
||||
# Lex like the consumer does: quoting makes raw strings platform-varying
|
||||
tokens = lex_build_flags(
|
||||
captured_as_build_flags(result, library_dir=tmp_path), "test"
|
||||
)
|
||||
sep = os.sep
|
||||
assert f"-Lsrc{sep}esp32" in tokens
|
||||
assert "-lalgobsec" in tokens
|
||||
assert "-DFOO" in tokens
|
||||
assert "-DBAR=1" in tokens
|
||||
# LINKFLAGS travel via the link-flags channel, never the compile flags
|
||||
assert "-Wl,--gc-sections" not in tokens
|
||||
|
||||
|
||||
def test_extra_script_libpath_relative_resolves_against_library_dir(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
"""Relative LIBPATH entries must resolve against ``library_dir``, not the
|
||||
caller's CWD (the shim restores CWD before ``captured_as_build_flags``
|
||||
runs)."""
|
||||
|
||||
(tmp_path / "lib" / "esp32").mkdir(parents=True)
|
||||
elsewhere = tmp_path.parent / "not_the_library_dir"
|
||||
elsewhere.mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(elsewhere)
|
||||
|
||||
result = ExtraScriptResult(libpath=["lib/esp32"])
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
|
||||
sep = os.sep
|
||||
assert lex_build_flags(flags, "test") == [f"-Llib{sep}esp32"]
|
||||
|
||||
|
||||
def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
|
||||
|
||||
outside = tmp_path.parent / "system_lib"
|
||||
outside.mkdir(exist_ok=True)
|
||||
result = ExtraScriptResult(libpath=[str(outside)])
|
||||
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
assert lex_build_flags(flags, "test") == [f"-L{outside.resolve()}"]
|
||||
|
||||
|
||||
def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
|
||||
|
||||
script = tmp_path / "broken.py"
|
||||
script.write_text("raise RuntimeError('boom')\n")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
|
||||
assert result.libpath == []
|
||||
assert result.libs == []
|
||||
assert "broken.py" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
|
||||
|
||||
library_dir = tmp_path / "lib"
|
||||
library_dir.mkdir()
|
||||
outside = tmp_path / "evil.py"
|
||||
outside.write_text("env.Append(LIBS=['pwned'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = library_dir
|
||||
c.data = {"build": {"extraScript": "../evil.py"}}
|
||||
|
||||
with pytest.raises(EsphomeError, match="escapes the library directory"):
|
||||
apply_extra_script(c, board_mcu=lambda: "esp32", pio_platform="espressif32")
|
||||
# Nothing was folded into flags: the traversal was rejected before
|
||||
# the script could run.
|
||||
assert "flags" not in c.data["build"]
|
||||
|
||||
|
||||
def test_apply_extra_script_merges_into_existing_flags(tmp_path):
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=['algobsec'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py", "flags": ["-DEXISTING"]}}
|
||||
|
||||
apply_extra_script(c, board_mcu=lambda: "esp32", pio_platform="espressif32")
|
||||
|
||||
assert "-DEXISTING" in c.data["build"]["flags"]
|
||||
assert "-lalgobsec" in c.data["build"]["flags"]
|
||||
|
||||
|
||||
def test_apply_extra_script_malformed_flags_raises(tmp_path) -> None:
|
||||
"""A null/dict build.flags fails naming the library instead of injecting
|
||||
a non-string into the compiler command line."""
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=['algobsec'])\n")
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py", "flags": None}}
|
||||
|
||||
with pytest.raises(EsphomeError, match="malformed build.flags"):
|
||||
apply_extra_script(c, board_mcu=lambda: "esp32", pio_platform="espressif32")
|
||||
|
||||
|
||||
def test_apply_extra_script_callable_target_and_str_flags(tmp_path) -> None:
|
||||
"""The shared helper resolves the board_mcu callable lazily and normalizes
|
||||
a string ``build.flags`` value into a list before extending it."""
|
||||
|
||||
(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, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
assert c.data["build"]["flags"] == ["-DBASE=1", "-lesp8266"]
|
||||
|
||||
|
||||
def test_captured_nonstring_buckets_warn_and_skip(tmp_path, caplog) -> None:
|
||||
"""Non-string LIBS/LINKFLAGS/CPPFLAGS/LIBPATH entries (legal SCons
|
||||
nodes) are skipped by name instead of stringified into garbage flags."""
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text(
|
||||
"env.Append(LIBS=['m', 42], LINKFLAGS=['-Wl,-x', {'no': 1}], "
|
||||
"CPPFLAGS=['-Os', 3.5], LIBPATH=['libs', 7])\n"
|
||||
)
|
||||
(tmp_path / "libs").mkdir()
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "extra.py"}}
|
||||
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
flags = c.data["build"]["flags"]
|
||||
assert "-lm" in flags and "-Os" in flags
|
||||
assert c.data[ESPHOME_DATA_KEY][ESPHOME_DATA_LINK_FLAGS_KEY] == ["-Wl,-x"]
|
||||
assert not any("42" in f or "no" in f or "3.5" in f for f in flags)
|
||||
assert "Ignoring unsupported LIBS entry 42" in caplog.text
|
||||
assert "Ignoring unsupported LINKFLAGS entry {'no': 1}" in caplog.text
|
||||
assert "Ignoring unsupported LIBPATH entry 7" in caplog.text
|
||||
|
||||
|
||||
def test_captured_dict_cppdefines_warn_and_skip(tmp_path, caplog) -> None:
|
||||
"""A dict CPPDEFINES entry (legal SCons) must warn and skip; formatting
|
||||
it blind would hand the compiler -D{'FOO': '1'} garbage."""
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text(
|
||||
"env.Append(CPPDEFINES=[{'FOO': '1'}, ('BAR', 2), ['BAZ', 3], 'PLAIN'])\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, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
assert c.data["build"]["flags"] == ["-DBAR=2", "-DBAZ=3", "-DPLAIN"]
|
||||
assert "Ignoring unsupported CPPDEFINES entry" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_subscript_env_read(tmp_path) -> None:
|
||||
"""Scripts also read env["BOARD_MCU"]; the subscript form must work or
|
||||
the broad handler discards every flag the script captured."""
|
||||
(tmp_path / "src").mkdir()
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=[env['BOARD_MCU']])\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, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
assert c.data["build"]["flags"] == ["-lesp8266"]
|
||||
|
||||
|
||||
def test_apply_extra_script_no_script_and_no_flags(tmp_path) -> None:
|
||||
|
||||
# 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,
|
||||
board_mcu=lambda: pytest.fail("target resolved without a script"),
|
||||
pio_platform="espressif8266",
|
||||
)
|
||||
|
||||
# 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, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
assert "flags" not in c.data["build"]
|
||||
|
||||
|
||||
def test_apply_extra_script_ignores_uncaptured_env_calls(tmp_path, caplog) -> None:
|
||||
"""Un-captured env vars and unsupported env methods are skipped but
|
||||
diagnosable from the build log."""
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
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, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
assert c.data["build"]["flags"] == ["-lsingle"]
|
||||
assert "env.Append(UNCAPTURED=...) is not captured" in caplog.text
|
||||
assert "env.Replace is not supported" in caplog.text
|
||||
|
||||
|
||||
def test_apply_extra_script_swallows_script_errors(tmp_path, caplog) -> None:
|
||||
"""A raising extra-script is best-effort: logged and skipped."""
|
||||
|
||||
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, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
assert "flags" not in c.data["build"]
|
||||
assert "ignoring its output" 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."""
|
||||
|
||||
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, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
assert c.data["build"]["flags"] == ["-lespressif8266"]
|
||||
|
||||
|
||||
def test_apply_extra_script_missing_script_raises(tmp_path) -> None:
|
||||
"""A declared but absent extraScript is a broken package and fails by
|
||||
name, as it would under PlatformIO."""
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": "nope.py"}}
|
||||
with pytest.raises(EsphomeError, match="nope.py of library owner/name not found"):
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", (["a.py"], {"esp32": "a.py"}), ids=("list", "dict"))
|
||||
def test_apply_extra_script_non_string_raises(tmp_path, bad) -> None:
|
||||
"""A non-string extraScript fails naming the library, not with a TypeError."""
|
||||
|
||||
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
|
||||
c.path = tmp_path
|
||||
c.data = {"build": {"extraScript": bad}}
|
||||
with pytest.raises(EsphomeError, match="of library owner/name must be a string"):
|
||||
apply_extra_script(c, board_mcu=lambda: "esp8266", pio_platform="espressif8266")
|
||||
|
||||
|
||||
def test_extra_script_cpppath_captured_as_include_flags(tmp_path, monkeypatch):
|
||||
"""CPPPATH entries translate to -I flags anchored like LIBPATH."""
|
||||
|
||||
(tmp_path / "include").mkdir()
|
||||
outside = tmp_path.parent / "system_inc"
|
||||
outside.mkdir(exist_ok=True)
|
||||
elsewhere = tmp_path.parent / "not_the_library_dir"
|
||||
elsewhere.mkdir(exist_ok=True)
|
||||
monkeypatch.chdir(elsewhere)
|
||||
|
||||
result = ExtraScriptResult(cpppath=["include", str(outside), 7])
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
|
||||
assert lex_build_flags(flags, "test") == ["-Iinclude", f"-I{outside.resolve()}"]
|
||||
|
||||
|
||||
def test_extra_script_spaced_paths_survive_relexing(tmp_path):
|
||||
"""-I/-L paths with spaces round-trip through lex_build_flags as one token."""
|
||||
(tmp_path / "my libs").mkdir()
|
||||
result = ExtraScriptResult(cpppath=["my libs"], libpath=["my libs"])
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
assert lex_build_flags(flags, "test") == ["-Imy libs", "-Lmy libs"]
|
||||
|
||||
|
||||
def test_run_extra_script_failure_discards_partial_capture(tmp_path, caplog) -> None:
|
||||
"""A crashed script yields an empty result: half-applied flags could
|
||||
build wrong-output firmware that links cleanly."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("env.Append(LIBS=['algobsec'])\nraise RuntimeError('boom')\n")
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
assert result.libs == []
|
||||
assert "ignoring its output" in caplog.text
|
||||
|
||||
|
||||
def test_run_extra_script_syntax_error_is_best_effort(tmp_path, caplog) -> None:
|
||||
"""A vendored script that does not even compile warns and skips instead
|
||||
of aborting the build."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("def broken(:\n")
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
assert result.libs == []
|
||||
assert "ignoring its output" in caplog.text
|
||||
|
||||
|
||||
def test_unsupported_env_method_warns_once(caplog) -> None:
|
||||
"""Repeated calls to the same unsupported method warn only once."""
|
||||
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
env.Replace(CC="clang")
|
||||
env.Replace(CC="gcc")
|
||||
assert caplog.text.count("env.Replace is not supported") == 1
|
||||
|
||||
|
||||
def test_run_extra_script_sys_exit_is_best_effort(tmp_path, caplog) -> None:
|
||||
"""A nonzero sys.exit() in a vendored script must not kill the esphome
|
||||
run, and its output is discarded."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("import sys\nenv.Append(LIBS=['x'])\nsys.exit(3)\n")
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
assert result.libs == []
|
||||
assert "exited with status 3" in caplog.text
|
||||
|
||||
|
||||
def test_run_extra_script_sys_exit_zero_is_success(tmp_path, caplog) -> None:
|
||||
"""sys.exit(0) is a normal PlatformIO script ending: the capture is kept."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("import sys\nenv.Append(LIBS=['algobsec'])\nsys.exit(0)\n")
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
assert result.libs == ["algobsec"]
|
||||
assert "ignoring its output" not in caplog.text
|
||||
|
||||
|
||||
def test_run_extra_script_unreadable_raises(tmp_path) -> None:
|
||||
"""An unreadable declared script is a broken package, like a missing one."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_text("")
|
||||
with (
|
||||
patch("pathlib.Path.read_text", side_effect=OSError("denied")),
|
||||
pytest.raises(EsphomeError, match="is unreadable"),
|
||||
):
|
||||
run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
|
||||
|
||||
def test_run_extra_script_bad_encoding_is_best_effort(tmp_path, caplog) -> None:
|
||||
"""Undecodable content warns and skips, like a SyntaxError."""
|
||||
|
||||
script = tmp_path / "extra.py"
|
||||
script.write_bytes(b"\xff\xfe\x00bad")
|
||||
result = run_extra_script(
|
||||
script, library_dir=tmp_path, board_mcu="esp32", pio_platform="espressif32"
|
||||
)
|
||||
assert result.libs == []
|
||||
assert "is not UTF-8" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ("Prepend", "AppendUnique", "PrependUnique"))
|
||||
def test_append_variants_capture_like_append(method: str) -> None:
|
||||
"""Prepend/AppendUnique/PrependUnique write the captured keys too."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
getattr(env, method)(LIBS=["algobsec"], LIBPATH=["lib"])
|
||||
assert env.result.libs == ["algobsec"]
|
||||
assert env.result.libpath == ["lib"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ("Prepend", "PrependUnique"))
|
||||
def test_prepend_inserts_ahead_of_existing(method: str) -> None:
|
||||
"""Prepend keeps SCons order: new values land ahead of what is already
|
||||
captured (scripts prepend LIBS for static-link symbol resolution)."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
env.Append(LIBS=["m"])
|
||||
getattr(env, method)(LIBS=["algobsec", "bsec"])
|
||||
assert env.result.libs == ["algobsec", "bsec", "m"]
|
||||
|
||||
|
||||
def test_env_get_unknown_key_warns_once(caplog) -> None:
|
||||
"""A script branching on an unmodelled env var is diagnosable."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
assert env.get("BOARD") is None
|
||||
assert env.get("BOARD", "d1") == "d1"
|
||||
assert env.get("BOARD_MCU") == "esp8266"
|
||||
assert caplog.text.count("env.get('BOARD') is not modelled") == 1
|
||||
assert "BOARD_MCU" not in caplog.text
|
||||
|
||||
|
||||
def test_spaced_cppflag_survives_relexing(tmp_path) -> None:
|
||||
"""A captured argv token with a space stays one token after lexing."""
|
||||
result = ExtraScriptResult(
|
||||
cppflags=["-include my hdr.h"],
|
||||
cppdefines=[CppDefine("MSG", '"hello world"'), CppDefine("PLAIN")],
|
||||
)
|
||||
flags = captured_as_build_flags(result, library_dir=tmp_path)
|
||||
assert lex_build_flags(flags, "test") == [
|
||||
'-DMSG="hello world"',
|
||||
"-DPLAIN",
|
||||
"-include my hdr.h",
|
||||
]
|
||||
|
||||
|
||||
def test_env_attribute_access_warns_without_call(caplog) -> None:
|
||||
"""hasattr()/truthiness on an unsupported method is diagnosable; dunder
|
||||
protocol probes stay silent."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
assert env.GetProjectOption
|
||||
assert caplog.text.count("env.GetProjectOption is not supported") == 1
|
||||
assert not hasattr(env, "__deepcopy__")
|
||||
assert "__deepcopy__" not in caplog.text
|
||||
|
||||
|
||||
def test_env_unmodelled_subscript_degrades_one_branch(caplog) -> None:
|
||||
"""env[...] on an unmodelled var returns '' instead of KeyError
|
||||
discarding the whole capture."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
assert env["PIOFRAMEWORK"] == ""
|
||||
assert env["PIOFRAMEWORK"] == ""
|
||||
assert caplog.text.count("env['PIOFRAMEWORK'] is not modelled") == 1
|
||||
assert env["BOARD_MCU"] == "esp8266"
|
||||
env.Append(LIBS=["still_captured"])
|
||||
assert env.result.libs == ["still_captured"]
|
||||
|
||||
|
||||
def test_cppdefines_scons_spellings(tmp_path) -> None:
|
||||
"""A bare 2-tuple is one name=value pair, a dict maps names to values,
|
||||
and a None value is a bare define (SCons processDefines)."""
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
env.Append(CPPDEFINES=("FOO", "1"))
|
||||
env.Append(CPPDEFINES={"BAR": "2", "BAZ": None})
|
||||
env.Append(CPPDEFINES=["PLAIN"])
|
||||
flags = captured_as_build_flags(env.result, library_dir=tmp_path)
|
||||
assert lex_build_flags(flags, "test") == [
|
||||
"-DFOO=1",
|
||||
"-DBAR=2",
|
||||
"-DBAZ",
|
||||
"-DPLAIN",
|
||||
]
|
||||
|
||||
|
||||
def test_uncaptured_append_key_warns_once(caplog) -> None:
|
||||
"""A loop of Appends to the same uncaptured key warns once."""
|
||||
|
||||
env = _FakeSConsEnv(
|
||||
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
|
||||
)
|
||||
env.Append(RANLIBFLAGS=["a"])
|
||||
env.Append(RANLIBFLAGS=["b"])
|
||||
assert caplog.text.count("env.Append(RANLIBFLAGS=...) is not captured") == 1
|
||||
@@ -10,10 +10,9 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import CORE, EsphomeError, Library
|
||||
from esphome.core import EsphomeError, Library
|
||||
import esphome.platformio.library as lib
|
||||
from esphome.platformio.library import (
|
||||
SOURCE_KIND_FOR_SUFFIX,
|
||||
ConvertedLibrary,
|
||||
GitSource,
|
||||
InvalidLibrary,
|
||||
@@ -24,18 +23,12 @@ from esphome.platformio.library import (
|
||||
_resolve_registry_version,
|
||||
check_library_data,
|
||||
convert_libraries,
|
||||
join_flag_args,
|
||||
split_flag_entry,
|
||||
)
|
||||
|
||||
|
||||
def _backend(emit=lambda component: None, provides=None) -> LibraryBackend:
|
||||
def _backend(emit=lambda component: None) -> LibraryBackend:
|
||||
return LibraryBackend(
|
||||
platform="espressif32",
|
||||
framework="espidf",
|
||||
emit=emit,
|
||||
cache_key="idf",
|
||||
provides=provides,
|
||||
platform="espressif32", framework="espidf", emit=emit, cache_key="idf"
|
||||
)
|
||||
|
||||
|
||||
@@ -157,15 +150,11 @@ def test_localsource_download_returns_empty_build_dir(setup_core: Path) -> None:
|
||||
assert plain != out
|
||||
|
||||
|
||||
def test_urlsource_download_extracts_then_reuses_marker(
|
||||
setup_core, monkeypatch, caplog
|
||||
):
|
||||
def test_urlsource_download_extracts_then_reuses_marker(setup_core, monkeypatch):
|
||||
monkeypatch.setattr(lib, "rmdir", lambda path, msg="": None)
|
||||
dl_calls: list[list[str]] = []
|
||||
monkeypatch.setattr(
|
||||
lib,
|
||||
"download_from_mirrors",
|
||||
lambda urls, headers, f, progress=None: dl_calls.append(urls),
|
||||
lib, "download_from_mirrors", lambda urls, headers, f: dl_calls.append(urls)
|
||||
)
|
||||
|
||||
def fake_extract(fileobj, path):
|
||||
@@ -184,12 +173,6 @@ def test_urlsource_download_extracts_then_reuses_marker(
|
||||
assert out2 == out
|
||||
assert len(dl_calls) == 1
|
||||
|
||||
# A batch caller passes a tracker and owns the messaging; no per-file INFO
|
||||
caplog.set_level("INFO")
|
||||
src.download("mylib-batch", progress=lambda done: None)
|
||||
assert len(dl_calls) == 2
|
||||
assert "Downloading" not in caplog.text
|
||||
|
||||
|
||||
def test_resolve_registry_version_raises_without_pkg_file(monkeypatch):
|
||||
registry = lib._make_registry_client()
|
||||
@@ -223,7 +206,6 @@ def _patch_registry_resolve(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
pkgname,
|
||||
"1.0.0",
|
||||
f"http://x/{pkgname}.tar.gz",
|
||||
None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -243,38 +225,6 @@ def _patch_download_with_manifests(monkeypatch, tmp_path, manifests, *, properti
|
||||
_patch_registry_resolve(monkeypatch)
|
||||
|
||||
|
||||
def test_wave_requirement_growth_defers_the_superseded_download(tmp_path, monkeypatch):
|
||||
"""A's manifest constrains B while B sits in the same wave: B's
|
||||
drain-time resolution is superseded, so its download defers to the
|
||||
next wave instead of fetching a version that is immediately replaced."""
|
||||
download_names: list[str] = []
|
||||
manifests = {
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"build": {},
|
||||
"dependencies": {"esphome/B": ">=1.0"},
|
||||
},
|
||||
"esphome/B": {"name": "B", "build": {}},
|
||||
}
|
||||
|
||||
def fake_download(self, force=False, salt="", namespace="", progress=None):
|
||||
download_names.append(self.name)
|
||||
self.path = tmp_path / self.get_require_name()
|
||||
self.path.mkdir(parents=True, exist_ok=True)
|
||||
(self.path / "library.json").write_text(json.dumps(manifests[self.name]))
|
||||
|
||||
monkeypatch.setattr(ConvertedLibrary, "download", fake_download)
|
||||
# Hermetic: the stubbed registry reports no size, so no batch prefetch
|
||||
_patch_registry_resolve(monkeypatch)
|
||||
top = convert_libraries(
|
||||
[Library("esphome/A", "1.0.0", None), Library("esphome/B", None, None)],
|
||||
_backend(),
|
||||
)
|
||||
assert sorted(c.name for c in top) == ["esphome/A", "esphome/B"]
|
||||
# B downloads exactly once, after its requirement set stabilized
|
||||
assert download_names.count("esphome/B") == 1
|
||||
|
||||
|
||||
def test_convert_libraries_parses_library_properties(tmp_path, monkeypatch):
|
||||
# A manifest provided as library.properties (Arduino style) instead of
|
||||
# library.json must still be parsed and converted.
|
||||
@@ -342,10 +292,7 @@ def _patch_download_without_manifest(
|
||||
calls: list[bool] = []
|
||||
|
||||
def fake_download(
|
||||
self: ConvertedLibrary,
|
||||
force: bool = False,
|
||||
salt: str = "",
|
||||
namespace: str = "",
|
||||
self: ConvertedLibrary, force: bool = False, salt: str = "", namespace: str = ""
|
||||
) -> None:
|
||||
calls.append(force)
|
||||
self.path = tmp_path / self.get_require_name()
|
||||
@@ -584,499 +531,3 @@ 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."""
|
||||
|
||||
assert split_flag_entry('-DX="a b"', "library x") == ["-DX=a b"]
|
||||
with pytest.raises(EsphomeError, match=r"Malformed build flag.*library x"):
|
||||
split_flag_entry('-DX="unclosed', "library x")
|
||||
|
||||
|
||||
def test_join_flag_args_reglues_spaced_define() -> None:
|
||||
"""A spaced -D re-glues to its argument, as ParseFlags does."""
|
||||
|
||||
assert join_flag_args(["-D", "FOO=1", "-Os"], "x") == ["-DFOO=1", "-Os"]
|
||||
|
||||
|
||||
def test_join_flag_args_trailing_bare_flag_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
|
||||
assert join_flag_args(["-Os", "-l"], "library x") == ["-Os"]
|
||||
assert "Ignoring trailing '-l'" in caplog.text
|
||||
|
||||
|
||||
def test_lex_build_flags_dangling_flag_does_not_cross_entries(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Each entry is lexed independently, as ParseFlags does: a dangling -I
|
||||
ending one entry warns instead of absorbing the next entry's first token."""
|
||||
from esphome.platformio.library import lex_build_flags
|
||||
|
||||
assert lex_build_flags(["-Wall -I", "-DFOO=1"], "lib x") == ["-Wall", "-DFOO=1"]
|
||||
assert "Ignoring trailing '-I'" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_wave_downloads_registry_archives_in_parallel(
|
||||
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Registry archives in one wave download concurrently, deduped by URL;
|
||||
git/local sources and failures are left to the sequential call."""
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_download(
|
||||
self, dir_suffix, force=False, salt="", namespace="", progress=None
|
||||
):
|
||||
calls.append(self.url)
|
||||
if progress is not None:
|
||||
progress(0)
|
||||
if "boom" in self.url:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(URLSource, "download", fake_download)
|
||||
wave = [
|
||||
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
|
||||
("b", ConvertedLibrary("b", "1.0", URLSource("https://x/b.tar.gz", 1))),
|
||||
# Duplicate URL must prefetch once (two threads must never extract
|
||||
# into the same cache directory)
|
||||
("b2", ConvertedLibrary("b2", "1.0", URLSource("https://x/b.tar.gz", 1))),
|
||||
("c", ConvertedLibrary("c", "1.0", URLSource("https://x/boom.tar.gz", 1))),
|
||||
("g", ConvertedLibrary("g", "*", lib.GitSource("https://x/g.git", None))),
|
||||
]
|
||||
lib._prefetch_wave(wave, "", "idf")
|
||||
assert sorted(calls) == [
|
||||
"https://x/a.tar.gz",
|
||||
"https://x/b.tar.gz",
|
||||
"https://x/boom.tar.gz",
|
||||
]
|
||||
# The failure surfaces at default verbosity, after the bar
|
||||
assert "Prefetch of c failed (retrying sequentially)" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_wave_unknown_size_left_to_sequential(
|
||||
setup_core, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Archives without a registry-reported size skip the batch (their
|
||||
sequential per-file bars don't interleave); the known subset still
|
||||
prefetches."""
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
URLSource,
|
||||
"download",
|
||||
lambda self, dir_suffix, force=False, salt="", namespace="", progress=None: (
|
||||
calls.append(self.url)
|
||||
),
|
||||
)
|
||||
wave = [
|
||||
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
|
||||
("b", ConvertedLibrary("b", "1.0", URLSource("https://x/b.tar.gz", 1))),
|
||||
("u", ConvertedLibrary("u", "1.0", URLSource("https://x/u.tar.gz"))),
|
||||
]
|
||||
lib._prefetch_wave(wave, "", "idf")
|
||||
assert sorted(calls) == ["https://x/a.tar.gz", "https://x/b.tar.gz"]
|
||||
|
||||
|
||||
def test_join_flag_args_empty_argument_warns_and_drops(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An empty glued argument is dropped: a bare -D would eat the next flag."""
|
||||
assert lib.lex_build_flags('-D "" -DFOO', "build_flags") == ["-DFOO"]
|
||||
assert "Ignoring '-D' with empty argument in build_flags" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_wave_cache_probe_failure_still_prefetches(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The cache probe is best-effort; a failing probe prefetches anyway."""
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
URLSource,
|
||||
"download",
|
||||
lambda self, dir_suffix, **kw: calls.append(self.url),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
URLSource,
|
||||
"is_cached",
|
||||
lambda self, *a, **kw: (_ for _ in ()).throw(RuntimeError("no core")),
|
||||
)
|
||||
wave = [
|
||||
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
|
||||
("b", ConvertedLibrary("b", "1.0", URLSource("https://x/b.tar.gz", 1))),
|
||||
]
|
||||
lib._prefetch_wave(wave, "", "idf")
|
||||
assert sorted(calls) == ["https://x/a.tar.gz", "https://x/b.tar.gz"]
|
||||
|
||||
|
||||
def test_prefetch_wave_internal_error_never_fails_the_build(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The blanket guard keeps a prefetch bug from failing the walk."""
|
||||
monkeypatch.setattr(
|
||||
lib,
|
||||
"run_batch_downloads",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("bug")),
|
||||
)
|
||||
wave = [
|
||||
("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1))),
|
||||
("b", ConvertedLibrary("b", "1.0", URLSource("https://x/b.tar.gz", 1))),
|
||||
]
|
||||
lib._prefetch_wave(wave, "", "idf")
|
||||
assert "Library prefetch failed: bug" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_wave_warm_cache_is_silent(
|
||||
setup_core, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Already-extracted archives download nothing; a warm build must not
|
||||
print a Downloading line or draw a bar."""
|
||||
monkeypatch.setattr(
|
||||
URLSource,
|
||||
"download",
|
||||
lambda self, dir_suffix, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("downloaded")
|
||||
),
|
||||
)
|
||||
wave = []
|
||||
for name in ("a", "b", "c"):
|
||||
comp = ConvertedLibrary(name, "1.0", URLSource(f"https://x/{name}.tar.gz", 1))
|
||||
marker_dir = comp.source._cache_dir(comp.get_sanitized_name(), "", "idf")
|
||||
marker_dir.mkdir(parents=True)
|
||||
(marker_dir / ".esphome_extracted").touch()
|
||||
wave.append((name, comp))
|
||||
lib._prefetch_wave(wave, "", "idf")
|
||||
assert "Downloading" not in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_wave_single_archive_uses_the_batch(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A dependency chain discovers one archive per wave; it downloads
|
||||
through the same runner so there is one download method and one bar."""
|
||||
caplog.set_level("INFO")
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
URLSource,
|
||||
"download",
|
||||
lambda self, dir_suffix, force=False, salt="", namespace="", progress=None: (
|
||||
calls.append(self.url)
|
||||
),
|
||||
)
|
||||
lib._prefetch_wave(
|
||||
[("a", ConvertedLibrary("a", "1.0", URLSource("https://x/a.tar.gz", 1)))],
|
||||
"",
|
||||
"idf",
|
||||
)
|
||||
assert calls == ["https://x/a.tar.gz"]
|
||||
assert "Downloading 1 library archive(s): a" in caplog.text
|
||||
|
||||
|
||||
def test_normalize_dependencies_forms(caplog) -> None:
|
||||
"""Every PIO-legal spelling normalizes; unrecognizable entries warn."""
|
||||
from esphome.platformio.library import normalize_dependencies
|
||||
|
||||
assert normalize_dependencies(
|
||||
["Wire", {"name": "SPI"}, 5, "", {"version": "1.0"}], "libx"
|
||||
) == [
|
||||
{"name": "Wire"},
|
||||
{"name": "SPI"},
|
||||
]
|
||||
# The int, the empty string, and the nameless dict all warn
|
||||
assert caplog.text.count("unrecognized dependency entry") == 3
|
||||
# A plain string is names, never iterated into characters
|
||||
assert normalize_dependencies("Wire, SPI") == [
|
||||
{"name": "Wire"},
|
||||
{"name": "SPI"},
|
||||
]
|
||||
assert normalize_dependencies("Wire") == [{"name": "Wire"}]
|
||||
# A non-iterable value fails by manifest name, never a bare TypeError
|
||||
assert normalize_dependencies(5, "libx") == []
|
||||
assert "Ignoring unrecognized dependencies 5 of libx" in caplog.text
|
||||
# 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
|
||||
# A container or numeric version would raise from set.add() or fail
|
||||
# opaquely in the registry; both spellings warn and drop
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"manifest", [["not", "a", "manifest"], {"name": "A", "build": "src"}]
|
||||
)
|
||||
def test_convert_libraries_malformed_manifest_raises(
|
||||
tmp_path, monkeypatch, manifest
|
||||
) -> None:
|
||||
"""A manifest without the expected dict shape fails by library name
|
||||
before any backend dereferences data/build."""
|
||||
_patch_download_with_manifests(monkeypatch, tmp_path, {"esphome/A": manifest})
|
||||
with pytest.raises(EsphomeError, match="has a malformed manifest"):
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
|
||||
|
||||
def test_walk_warns_for_properties_only_depends(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A manifest declaring dependencies only as library.properties depends=
|
||||
warns in the shared walk, so every backend reports the drop."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": "name=A\nversion=1.0\ndepends=Wire, SPI\n"},
|
||||
properties=("esphome/A",),
|
||||
)
|
||||
caplog.set_level("INFO")
|
||||
convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
|
||||
assert "declares dependencies via library.properties" in caplog.text
|
||||
|
||||
|
||||
def test_walk_warns_for_nonplatform_invalid_library(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A dependency dropped for any cause other than the routine platform
|
||||
filter is visible in every backend."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"dependencies": [{"name": "B", "version": "1.0", "platforms": [123]}],
|
||||
}
|
||||
},
|
||||
)
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert "Skipping dependency B of esphome/A: Malformed platforms" in caplog.text
|
||||
|
||||
|
||||
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", "frameworks": [None]},
|
||||
},
|
||||
)
|
||||
convert_libraries([Library("esphome/A", "1.0.0", None)], _backend())
|
||||
assert "Malformed frameworks" in caplog.text
|
||||
assert "Skipping dependency" in caplog.text
|
||||
|
||||
|
||||
def test_split_flag_entry_non_string_is_clean() -> None:
|
||||
"""A dict or number from a third-party manifest fails naming the entry,
|
||||
not with an opaque shlex traceback."""
|
||||
|
||||
with pytest.raises(EsphomeError, match="Malformed build flag"):
|
||||
split_flag_entry({"esp32": ["-DX"]}, "lib x")
|
||||
with pytest.raises(EsphomeError, match="Malformed build flag 5"):
|
||||
split_flag_entry(5, "lib x")
|
||||
|
||||
|
||||
def test_source_kind_map_shape() -> None:
|
||||
"""The kind values the native compile rules key on, and the deliberate
|
||||
AS/ASPP merge (.s and .S both map to asm)."""
|
||||
|
||||
assert set(SOURCE_KIND_FOR_SUFFIX.values()) == {"c", "cxx", "asm"}
|
||||
assert SOURCE_KIND_FOR_SUFFIX[".s"] == "asm"
|
||||
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_versionless_platform_filtered_dependency_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A version-less dependency the platform filter excludes is
|
||||
deliberately absent, not a drop to warn about."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"dependencies": [{"name": "Hash", "platforms": "espressif8266"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
|
||||
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:
|
||||
"""When no backend tree can supply a version-less dependency, the drop
|
||||
is a warning, not a debug line."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
# The duplicate entry warns once (reconciliation dedup)
|
||||
"dependencies": [{"name": "Hash"}, {"name": "Hash"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert (
|
||||
caplog.text.count(
|
||||
"Hash of esphome/A has no version to resolve and nothing provides it"
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_url_version_dependency_is_not_substituted_by_provides(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A URL-valued version names one specific source; the backend-provided
|
||||
skip must not replace it with the bundled copy."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"dependencies": [
|
||||
{"name": "Hash", "version": "https://github.com/o/Hash.git"}
|
||||
],
|
||||
},
|
||||
"o/Hash": {"name": "Hash"},
|
||||
},
|
||||
)
|
||||
emitted: list[str] = []
|
||||
convert_libraries(
|
||||
[Library("esphome/A", "1.0.0", None)],
|
||||
_backend(emit=lambda c: emitted.append(c.name), provides=lambda name: True),
|
||||
)
|
||||
assert "Skip backend-provided" not in caplog.text
|
||||
assert "using the library bundled" not in caplog.text
|
||||
assert any("o/hash" in n.lower() for n in emitted)
|
||||
|
||||
|
||||
def test_versionless_owner_qualified_dependency_warns_despite_provides(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An owner-qualified version-less dependency is not satisfied by
|
||||
provides(); it must still warn."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {
|
||||
"name": "A",
|
||||
"dependencies": [{"name": "Wire", "owner": "Foo"}],
|
||||
}
|
||||
},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None)],
|
||||
_backend(provides=lambda name: name == "Wire"),
|
||||
)
|
||||
assert "Wire of esphome/A has no version to resolve" in caplog.text
|
||||
|
||||
|
||||
def test_versionless_provided_dependency_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""An owner-less version-less dependency the backend provides is added
|
||||
by the backend after emit; no reconciliation warning."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "Wire"}]}},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None)],
|
||||
_backend(provides=lambda name: name == "Wire"),
|
||||
)
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
|
||||
def test_versionless_dependency_requested_top_level_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A version-less dependency the config also requests top-level is in
|
||||
the build; no drop warning even without a provides backend."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "Hash"}]},
|
||||
"Hash": {"name": "Hash"},
|
||||
},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("Hash", None, None)],
|
||||
_backend(),
|
||||
)
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
|
||||
def test_versionless_url_ish_dependency_name_warns_cleanly(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A malformed URL-ish dependency name falls to the drop warning, never
|
||||
a RuntimeError out of the key parser."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{"esphome/A": {"name": "A", "dependencies": [{"name": "file://"}]}},
|
||||
)
|
||||
convert_libraries([Library("esphome/A", None, None)], _backend())
|
||||
assert (
|
||||
"file:// of esphome/A has no version to resolve and nothing provides it"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
|
||||
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A bare name satisfied by an owner-qualified component's manifest
|
||||
name is not a drop."""
|
||||
_patch_download_with_manifests(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
{
|
||||
"esphome/A": {"name": "A", "dependencies": [{"name": "B"}]},
|
||||
"esphome/B": {"name": "B"},
|
||||
},
|
||||
)
|
||||
convert_libraries(
|
||||
[Library("esphome/A", None, None), Library("esphome/B", None, None)],
|
||||
_backend(),
|
||||
)
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
@@ -1,688 +0,0 @@
|
||||
"""Tests for esphome.platformio.registry (PIO-registry package installs)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.platformio import registry
|
||||
|
||||
|
||||
def test_registry_download_resolves_once_per_process() -> None:
|
||||
"""The prefetch and the install share one metadata resolve per package."""
|
||||
calls: list[dict] = []
|
||||
payload = {
|
||||
"versions": [
|
||||
{
|
||||
"name": "1.0.0",
|
||||
"files": [
|
||||
{
|
||||
"download_url": "http://x/pkg.tar.gz",
|
||||
"checksum": {"sha256": "ab" * 32},
|
||||
"size": 5,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def fake_download(mirrors, substitutions, target):
|
||||
calls.append(substitutions)
|
||||
target.write(json.dumps(payload).encode())
|
||||
return mirrors[0]
|
||||
|
||||
with patch.object(registry, "download_from_mirrors", side_effect=fake_download):
|
||||
first = registry.registry_download("o/pkg", "1.0.0")
|
||||
second = registry.registry_download("o/pkg", "1.0.0")
|
||||
assert first == second
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_registry_cache():
|
||||
# registry_download memoizes per process; tests reuse package names
|
||||
registry.registry_download.cache_clear()
|
||||
yield
|
||||
registry.registry_download.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("system", "machine", "expected"),
|
||||
[
|
||||
("Darwin", "arm64", "darwin_arm64"),
|
||||
("Darwin", "x86_64", "darwin_x86_64"),
|
||||
("Windows", "AMD64", "windows_amd64"),
|
||||
# Deviation from upstream: auto-mapped to the emulated-x86 packages
|
||||
("Windows", "ARM64", "windows_amd64"),
|
||||
("Windows", "x86", "windows_x86"),
|
||||
("Linux", "x86_64", "linux_x86_64"),
|
||||
("Linux", "aarch64", "linux_aarch64"),
|
||||
("Linux", "i686", "linux_i686"),
|
||||
("Linux", "armv7l", "linux_armv7l"),
|
||||
# Unknown hosts pass through like upstream; the registry lookup
|
||||
# then fails naming the tag
|
||||
("FreeBSD", "amd64", "freebsd_amd64"),
|
||||
],
|
||||
)
|
||||
def test_get_systype(system: str, machine: str, expected: str) -> None:
|
||||
with (
|
||||
patch("platform.system", return_value=system),
|
||||
patch("platform.machine", return_value=machine),
|
||||
patch("platform.architecture", return_value=("64bit", "")),
|
||||
):
|
||||
assert registry.get_systype() == expected
|
||||
|
||||
|
||||
def test_get_systype_env_override() -> None:
|
||||
"""PLATFORMIO_SYSTEM_TYPE wins, exactly as in upstream get_systype()."""
|
||||
with patch.dict(os.environ, {"PLATFORMIO_SYSTEM_TYPE": "windows_amd64"}):
|
||||
assert registry.get_systype() == "windows_amd64"
|
||||
|
||||
|
||||
def test_get_systype_aarch64_32bit_userland() -> None:
|
||||
"""A 32-bit userland on a 64-bit arm kernel gets armv7l binaries."""
|
||||
with (
|
||||
patch("platform.system", return_value="Linux"),
|
||||
patch("platform.machine", return_value="aarch64"),
|
||||
patch("platform.architecture", return_value=("32bit", "")),
|
||||
):
|
||||
assert registry.get_systype() == "linux_armv7l"
|
||||
|
||||
|
||||
def test_get_systype_windows_empty_machine() -> None:
|
||||
"""An empty machine string falls back to the architecture bits."""
|
||||
with (
|
||||
patch("platform.system", return_value="Windows"),
|
||||
patch("platform.machine", return_value=""),
|
||||
patch("platform.architecture", return_value=("64bit", "")),
|
||||
):
|
||||
assert registry.get_systype() == "windows_amd64"
|
||||
|
||||
|
||||
def _registry_response(files: list[dict]):
|
||||
"""Patch the shared downloader to serve a canned registry response."""
|
||||
payload = {"versions": [{"name": "1.0.0", "files": files}]}
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps(payload).encode())
|
||||
return mirrors[0].format(**substitutions)
|
||||
|
||||
return patch.object(registry, "download_from_mirrors", side_effect=fake_download)
|
||||
|
||||
|
||||
def test_registry_download_uses_shared_downloader() -> None:
|
||||
"""The metadata fetch delegates its retries and error reporting to
|
||||
download_from_mirrors; failures surface unchanged."""
|
||||
with (
|
||||
patch.object(
|
||||
registry,
|
||||
"download_from_mirrors",
|
||||
side_effect=EsphomeError("Failed to download from all mirrors"),
|
||||
) as mock_download,
|
||||
pytest.raises(EsphomeError, match="Failed to download from all mirrors"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
(mirrors, substitutions, _), _ = mock_download.call_args
|
||||
assert mirrors == [registry._REGISTRY_URL]
|
||||
assert substitutions == {"package": "pkg"}
|
||||
|
||||
|
||||
def test_registry_download_invalid_json_is_clean() -> None:
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(b"<html>not json</html>")
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="invalid JSON"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_matches_system() -> None:
|
||||
with (
|
||||
_registry_response(
|
||||
[
|
||||
{"system": ["windows_amd64"], "download_url": "http://x/win"},
|
||||
{
|
||||
"system": ["linux_x86_64"],
|
||||
"download_url": "http://x/linux",
|
||||
"checksum": {"sha256": "abc123"},
|
||||
"size": 42,
|
||||
},
|
||||
]
|
||||
),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0") == (
|
||||
"http://x/linux",
|
||||
"abc123",
|
||||
42,
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_bare_string_system() -> None:
|
||||
"""A bare-string system tag is an exact match, not a substring test."""
|
||||
with (
|
||||
_registry_response(
|
||||
[
|
||||
{"system": "linux_x86", "download_url": "http://x/x86"},
|
||||
{
|
||||
"system": "linux_x86_64",
|
||||
"download_url": "http://x/x86_64",
|
||||
"checksum": {"sha256": "abc"},
|
||||
},
|
||||
]
|
||||
),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0")[0] == "http://x/x86_64"
|
||||
|
||||
|
||||
def test_registry_download_wildcard_system() -> None:
|
||||
with _registry_response(
|
||||
[
|
||||
{
|
||||
"system": "*",
|
||||
"download_url": "http://x/any",
|
||||
"checksum": {"sha256": "abc"},
|
||||
"size": 7,
|
||||
}
|
||||
]
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0") == (
|
||||
"http://x/any",
|
||||
"abc",
|
||||
7,
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_missing_checksum_raises() -> None:
|
||||
"""An unverifiable archive is refused, never silently extracted."""
|
||||
with (
|
||||
_registry_response([{"system": "*", "download_url": "http://x/any"}]),
|
||||
pytest.raises(EsphomeError, match="no sha256"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_no_system_match() -> None:
|
||||
with (
|
||||
_registry_response(
|
||||
[{"system": ["windows_amd64"], "download_url": "http://x/win"}]
|
||||
),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
pytest.raises(EsphomeError, match="No pkg 1.0.0 build"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_version_not_found() -> None:
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(
|
||||
json.dumps({"versions": [{"name": "2.0.0", "files": []}]}).encode()
|
||||
)
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="not found"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_install_package_skips_when_marker_exists(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "pkg"
|
||||
(dest / "payload").mkdir(parents=True)
|
||||
(dest / ".esphome_extracted").touch()
|
||||
with patch.object(registry, "download_from_mirrors") as mock_download:
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_install_package_marker_hit_rechecks_layout(tmp_path: Path) -> None:
|
||||
"""A marked install that later lost files fails by name instead of
|
||||
surfacing as an opaque toolchain error."""
|
||||
dest = tmp_path / "pkg"
|
||||
dest.mkdir()
|
||||
(dest / ".esphome_extracted").touch()
|
||||
with pytest.raises(EsphomeError, match="missing the expected payload"):
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
|
||||
|
||||
def test_install_package_downloads_via_mirrors(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "pkg"
|
||||
mirrors = ["http://mirror/{VERSION}/{SYSTEM}.tar.gz"]
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors") as mock_download,
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
# Extraction is expected to create the directory
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, mirrors, tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
assert mock_download.call_args[0][0] is mirrors
|
||||
assert mock_download.call_args[0][1] == {
|
||||
"VERSION": "1.0.0",
|
||||
"SYSTEM": "linux_x86_64",
|
||||
}
|
||||
assert (dest / ".esphome_extracted").is_file()
|
||||
|
||||
|
||||
def test_install_package_downloads_via_registry(tmp_path: Path) -> None:
|
||||
"""The registry path downloads with the registry's sha256 and size."""
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(
|
||||
registry,
|
||||
"registry_download",
|
||||
return_value=("http://x/pkg.tar.gz", "abc123", 42),
|
||||
),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz"
|
||||
assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42}
|
||||
|
||||
|
||||
def test_install_package_validates_expected_layout(tmp_path: Path) -> None:
|
||||
"""The success marker is only written when the extracted tree is usable."""
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors"),
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "bin").mkdir(parents=True)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("bin",)
|
||||
)
|
||||
assert (dest / ".esphome_extracted").is_file()
|
||||
|
||||
|
||||
def test_install_package_unexpected_layout_raises(tmp_path: Path) -> None:
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors"),
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
pytest.raises(EsphomeError, match="missing the expected bin"),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("bin",)
|
||||
)
|
||||
assert not (dest / ".esphome_extracted").exists()
|
||||
|
||||
|
||||
def test_install_package_marker_rechecked_under_lock(tmp_path: Path) -> None:
|
||||
"""A concurrent install finishing while we wait for the lock is detected."""
|
||||
dest = tmp_path / "pkg"
|
||||
marker = dest / ".esphome_extracted"
|
||||
|
||||
@contextmanager
|
||||
def _fake_lock(*_a, **_kw):
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
marker.touch()
|
||||
yield
|
||||
|
||||
with (
|
||||
patch("filelock.FileLock", _fake_lock),
|
||||
patch.object(registry, "download_from_mirrors") as mock_download,
|
||||
patch.object(registry, "rmdir") as mock_rmdir,
|
||||
):
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
mock_rmdir.assert_not_called()
|
||||
|
||||
|
||||
def test_install_package_uses_hard_lock(tmp_path: Path) -> None:
|
||||
"""The install lock must never degrade to a soft (existence) lock."""
|
||||
dest = tmp_path / "pkg"
|
||||
with (
|
||||
patch("filelock.FileLock") as mock_lock,
|
||||
patch.object(registry, "download_from_mirrors"),
|
||||
patch.object(registry, "archive_extract_all") as mock_extract,
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
):
|
||||
mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("payload",)
|
||||
)
|
||||
assert mock_lock.call_args.kwargs["fallback_to_soft"] is False
|
||||
|
||||
|
||||
def test_registry_download_empty_system_list_does_not_match() -> None:
|
||||
"""An explicitly empty system list must not act as a wildcard."""
|
||||
with (
|
||||
_registry_response([{"system": [], "download_url": "http://x/any"}]),
|
||||
patch.object(registry, "get_systype", return_value="linux_x86_64"),
|
||||
pytest.raises(EsphomeError, match="No pkg 1.0.0 build"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_unexpected_payload_is_named() -> None:
|
||||
"""An error envelope without a versions list is not 'version not found'."""
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps({"message": "rate limited"}).encode())
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_missing_system_key_matches_any() -> None:
|
||||
"""A file with no system key at all serves every host."""
|
||||
with _registry_response(
|
||||
[{"download_url": "http://x/any", "checksum": {"sha256": "abc"}, "size": 1}]
|
||||
):
|
||||
assert registry.registry_download("pkg", "1.0.0") == ("http://x/any", "abc", 1)
|
||||
|
||||
|
||||
def test_registry_download_missing_files_list_is_named() -> None:
|
||||
"""A version entry without a files list is an unexpected payload, not a
|
||||
missing platform build."""
|
||||
with (
|
||||
_registry_response(None),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_missing_download_url_is_named() -> None:
|
||||
with (
|
||||
_registry_response([{"system": "*", "checksum": {"sha256": "abc"}, "size": 1}]),
|
||||
pytest.raises(EsphomeError, match="no download URL"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_install_package_empty_expect_rejected(tmp_path: Path) -> None:
|
||||
"""Layout validation is the only guard before marker.touch(), so an
|
||||
empty expect is a caller bug, not a lenient install."""
|
||||
with pytest.raises(ValueError, match="non-empty expect"):
|
||||
registry.install_package(
|
||||
"pkg", "1.0.0", tmp_path / "pkg", [], tmp_path / "dl", expect=()
|
||||
)
|
||||
|
||||
|
||||
def test_registry_download_non_dict_version_entry_is_named() -> None:
|
||||
"""A versions list of bare strings is an unexpected payload, not an
|
||||
AttributeError traceback."""
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps({"versions": ["1.0.0", "2.0.0"]}).encode())
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_non_dict_file_entry_is_named() -> None:
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(
|
||||
json.dumps(
|
||||
{"versions": [{"name": "1.0.0", "files": ["a.tar.gz"]}]}
|
||||
).encode()
|
||||
)
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_non_dict_payload_is_named() -> None:
|
||||
"""A JSON array answer is an unexpected payload at the outermost level."""
|
||||
|
||||
def fake_download(mirrors: list[str], substitutions: dict, target) -> str:
|
||||
target.write(json.dumps(["1.0.0"]).encode())
|
||||
return "http://x"
|
||||
|
||||
with (
|
||||
patch.object(registry, "download_from_mirrors", side_effect=fake_download),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def test_registry_download_non_list_system_is_named() -> None:
|
||||
"""A system field that is neither missing, str, nor list is an
|
||||
unexpected payload, not a TypeError from the ``in`` test."""
|
||||
with (
|
||||
_registry_response([{"system": 5, "checksum": {"sha256": "abc"}, "size": 1}]),
|
||||
pytest.raises(EsphomeError, match="Unexpected package registry response"),
|
||||
):
|
||||
registry.registry_download("pkg", "1.0.0")
|
||||
|
||||
|
||||
def _resolve_for(sizes: dict[str, int | None]):
|
||||
def resolve(name: str, version: str):
|
||||
size = sizes[name]
|
||||
if size == -1:
|
||||
raise EsphomeError("registry down")
|
||||
return (f"http://x/{name}.tar.gz", "abc123", size)
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def test_prefetch_packages_downloads_pending_in_parallel(tmp_path: Path) -> None:
|
||||
"""Two uninstalled packages download together under one combined bar,
|
||||
with the registry's sha256 and size and a batch progress tracker."""
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
assert mock_download.call_count == 2
|
||||
# Locking makes worker completion order nondeterministic
|
||||
calls = sorted(mock_download.call_args_list, key=lambda c: c[0][0])
|
||||
for call, (name, version, size) in zip(
|
||||
calls, [("a", "1.0", 10), ("b", "2.0", 20)], strict=True
|
||||
):
|
||||
assert call[0][0] == f"http://x/{name}.tar.gz"
|
||||
assert call[0][1] == tmp_path / "dl" / f"{name}-{version}"
|
||||
assert call[1]["sha256"] == "abc123"
|
||||
assert call[1]["size"] == size
|
||||
assert callable(call[1]["progress"])
|
||||
|
||||
|
||||
def test_prefetch_packages_dedupes_duplicate_entries(tmp_path: Path) -> None:
|
||||
"""Duplicate (name, version) entries would race each other between two
|
||||
workers; only one survives (and one is too few to parallelize)."""
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_single_pending_skips(tmp_path: Path) -> None:
|
||||
"""One pending package has nothing to parallelize; the sequential
|
||||
install keeps its own bar."""
|
||||
marker_dest = tmp_path / "a"
|
||||
marker_dest.mkdir()
|
||||
(marker_dest / ".esphome_extracted").touch()
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", marker_dest, []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_mirror_and_sizeless_stay_sequential(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Mirror overrides and size-less registry entries are left to the
|
||||
sequential path so its per-file bars stay trustworthy."""
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry,
|
||||
"registry_download",
|
||||
side_effect=_resolve_for({"b": None, "c": 30}),
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", ["http://mirror/{VERSION}"]),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
("c", "3.0", tmp_path / "c", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_resolve_failure_defers_to_install(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A registry failure only skips the prefetch; install_package reports
|
||||
the real error with context."""
|
||||
caplog.set_level("DEBUG")
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": -1, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
assert "Prefetch resolve for a failed" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_packages_complete_archive_skipped(tmp_path: Path) -> None:
|
||||
"""An archive already fully downloaded is not re-fetched."""
|
||||
dl = tmp_path / "dl"
|
||||
dl.mkdir()
|
||||
(dl / "a-1.0").write_bytes(b"x" * 10)
|
||||
with (
|
||||
patch.object(registry, "download_with_resume") as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
dl,
|
||||
)
|
||||
mock_download.assert_not_called()
|
||||
|
||||
|
||||
def test_prefetch_packages_download_failure_is_debug(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A failed prefetch download is logged and left for install_package."""
|
||||
caplog.set_level("DEBUG")
|
||||
with (
|
||||
patch.object(
|
||||
registry, "download_with_resume", side_effect=OSError("boom")
|
||||
) as mock_download,
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
assert mock_download.call_count == 2
|
||||
assert "Prefetch of a failed" in caplog.text
|
||||
assert "Prefetch of b failed" in caplog.text
|
||||
|
||||
|
||||
def test_prefetch_packages_unexpected_failure_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A programming error (not a download failure) surfaces at WARNING
|
||||
instead of becoming a permanent silent no-op."""
|
||||
with (
|
||||
patch.object(
|
||||
registry, "download_with_resume", side_effect=TypeError("bad call")
|
||||
),
|
||||
patch.object(
|
||||
registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20})
|
||||
),
|
||||
):
|
||||
registry.prefetch_packages(
|
||||
[
|
||||
("a", "1.0", tmp_path / "a", []),
|
||||
("b", "2.0", tmp_path / "b", []),
|
||||
],
|
||||
tmp_path / "dl",
|
||||
)
|
||||
assert "TypeError" in caplog.text
|
||||
@@ -431,8 +431,8 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -469,7 +469,7 @@ def test_ccache_env_disabled_without_binary(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, env_vars, clear=True),
|
||||
patch("shutil.which", return_value=None),
|
||||
patch.object(toolchain.shutil, "which", return_value=None),
|
||||
caplog.at_level("WARNING"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
@@ -494,8 +494,8 @@ def test_ccache_env_disabled_when_probe_fails(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run", side_effect=probe_error),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run", side_effect=probe_error),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -508,8 +508,8 @@ def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run") as mock_probe,
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run") as mock_probe,
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -537,9 +537,9 @@ def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None:
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
# shutil.which is patched, so the win32 code path of the real
|
||||
# implementation (which crashes on a POSIX host) is never reached.
|
||||
patch("esphome.framework_helpers.sys.platform", "win32"),
|
||||
patch("shutil.which", return_value=prefixed),
|
||||
patch("esphome.framework_helpers.subprocess.run") as mock_probe,
|
||||
patch("esphome.platformio.toolchain.sys.platform", "win32"),
|
||||
patch.object(toolchain.shutil, "which", return_value=prefixed),
|
||||
patch.object(toolchain.subprocess, "run") as mock_probe,
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -555,7 +555,7 @@ def test_ccache_env_opt_out(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -568,7 +568,7 @@ def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -587,8 +587,8 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, user_env, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run"),
|
||||
):
|
||||
env = toolchain._ccache_env()
|
||||
|
||||
@@ -606,8 +606,8 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run"),
|
||||
):
|
||||
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
|
||||
mock_run_external_process.return_value = 0
|
||||
@@ -628,8 +628,8 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run"),
|
||||
pytest.raises(ValueError, match="CORE.build_path must be set"),
|
||||
):
|
||||
toolchain._ccache_env()
|
||||
@@ -642,8 +642,8 @@ def test_run_platformio_cli_merges_caller_env(
|
||||
CORE.build_path = str(setup_core / "build" / "test")
|
||||
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/ccache"),
|
||||
patch("esphome.framework_helpers.subprocess.run"),
|
||||
patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"),
|
||||
patch.object(toolchain.subprocess, "run"),
|
||||
):
|
||||
mock_run_external_process.return_value = 0
|
||||
toolchain.run_platformio_cli(
|
||||
@@ -800,7 +800,9 @@ def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None:
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("shutil.which", return_value="\\\\?\\" + sys.executable),
|
||||
patch.object(
|
||||
toolchain.shutil, "which", return_value="\\\\?\\" + sys.executable
|
||||
),
|
||||
):
|
||||
os.environ.pop("ESPHOME_CCACHE_ENABLE", None)
|
||||
env = toolchain._ccache_env()
|
||||
@@ -841,6 +843,40 @@ def test_ccache_wrapper_through_cmd_exe(
|
||||
assert marker.read_text() == "compiled"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "input_path", "expected"),
|
||||
[
|
||||
# win32: drive-letter extended-length prefix is stripped
|
||||
(
|
||||
"win32",
|
||||
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
),
|
||||
# win32: UNC extended-length prefix is translated to a regular UNC path
|
||||
(
|
||||
"win32",
|
||||
"\\\\?\\UNC\\server\\share\\python.exe",
|
||||
"\\\\server\\share\\python.exe",
|
||||
),
|
||||
# win32: paths without the prefix are returned unchanged
|
||||
(
|
||||
"win32",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||
),
|
||||
# non-win32: prefix is left alone (no-op)
|
||||
("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"),
|
||||
("darwin", "/usr/bin/python3", "/usr/bin/python3"),
|
||||
],
|
||||
)
|
||||
def test_strip_win_long_path_prefix(
|
||||
platform: str, input_path: str, expected: str
|
||||
) -> None:
|
||||
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
|
||||
with patch("esphome.platformio.toolchain.sys.platform", platform):
|
||||
assert toolchain._strip_win_long_path_prefix(input_path) == expected
|
||||
|
||||
|
||||
def test_run_platformio_cli_strips_win_long_path_prefix(
|
||||
setup_core: Path, mock_run_external_process: Mock
|
||||
) -> None:
|
||||
@@ -864,7 +900,7 @@ def test_run_platformio_cli_strips_win_long_path_prefix(
|
||||
# so the stdlib sees it too) would send shutil.which down the Windows
|
||||
# code path, which crashes on a POSIX host.
|
||||
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=False),
|
||||
patch("esphome.framework_helpers.sys.platform", "win32"),
|
||||
patch("esphome.platformio.toolchain.sys.platform", "win32"),
|
||||
patch("esphome.platformio.toolchain.sys.executable", prefixed_exe),
|
||||
):
|
||||
# Pop any pre-existing PYTHONEXEPATH so the assertion below reflects
|
||||
@@ -896,7 +932,7 @@ def test_run_platformio_cli_does_not_set_pythonexepath_without_strip(
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {}, clear=False),
|
||||
patch("esphome.framework_helpers.sys.platform", "linux"),
|
||||
patch("esphome.platformio.toolchain.sys.platform", "linux"),
|
||||
patch("esphome.platformio.toolchain.sys.executable", plain_exe),
|
||||
):
|
||||
os.environ.pop("PYTHONEXEPATH", None)
|
||||
|
||||
@@ -126,20 +126,3 @@ 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_print_summary_flash_line(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A partition table with an app row yields the Flash line in the exact
|
||||
padded shape script/ci_memory_impact_extract.py greps."""
|
||||
size_json = _write_size_json(tmp_path, _esp32_size_data())
|
||||
partitions = tmp_path / "partitions.csv"
|
||||
partitions.write_text(
|
||||
"# name, type, subtype, offset, size, flags\n"
|
||||
"app0, app, ota_0, 0x10000, 0x1C0000,\n"
|
||||
)
|
||||
print_summary(size_json, partitions)
|
||||
out = capsys.readouterr().out
|
||||
assert "Flash: " in out
|
||||
assert "(used 827455 bytes from 1835008 bytes)" in out
|
||||
|
||||
@@ -68,8 +68,7 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
|
||||
test_clean_all_partial_exists) install their own inner patch which
|
||||
stacks on top of this one and wins for the duration of their block.
|
||||
|
||||
Also pin ``ESPHOME_ESP_IDF_PREFIX``, ``ESPHOME_SDK_NRF_PREFIX`` and
|
||||
``ESPHOME_ARDUINO8266_PREFIX`` to
|
||||
Also pin ``ESPHOME_ESP_IDF_PREFIX`` and ``ESPHOME_SDK_NRF_PREFIX`` to
|
||||
nonexistent tmp dirs, and patch ``platformdirs.user_cache_dir``, for the
|
||||
same reason: ``clean_all`` removes the machine-global toolchain installs
|
||||
and their default cache root, which otherwise resolve to the real
|
||||
@@ -78,7 +77,6 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
|
||||
pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent"
|
||||
idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent"
|
||||
sdk_nrf_root = tmp_path_factory.mktemp("isolated_sdk_nrf") / "nonexistent"
|
||||
arduino8266_root = tmp_path_factory.mktemp("isolated_arduino8266") / "nonexistent"
|
||||
cache_root = tmp_path_factory.mktemp("isolated_cache") / "nonexistent"
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.get.side_effect = lambda section, option: (
|
||||
@@ -94,7 +92,6 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any:
|
||||
{
|
||||
"ESPHOME_ESP_IDF_PREFIX": str(idf_root),
|
||||
"ESPHOME_SDK_NRF_PREFIX": str(sdk_nrf_root),
|
||||
"ESPHOME_ARDUINO8266_PREFIX": str(arduino8266_root),
|
||||
},
|
||||
),
|
||||
patch("platformdirs.user_cache_dir", return_value=str(cache_root)),
|
||||
@@ -1036,28 +1033,6 @@ def test_clean_all_removes_global_idf_install(
|
||||
assert str(idf_install.resolve()) in caplog.text
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_all_removes_global_arduino8266_install(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""clean_all removes the machine-global native arduino8266 install dir."""
|
||||
arduino8266_install = tmp_path / "arduino8266_install"
|
||||
(arduino8266_install / "frameworks").mkdir(parents=True)
|
||||
monkeypatch.setenv("ESPHOME_ARDUINO8266_PREFIX", str(arduino8266_install))
|
||||
|
||||
config_dir = tmp_path / "config"
|
||||
config_dir.mkdir()
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
clean_all([str(config_dir)])
|
||||
|
||||
assert not arduino8266_install.exists()
|
||||
assert str(arduino8266_install.resolve()) in caplog.text
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_clean_all_removes_global_sdk_nrf_install(
|
||||
mock_core: MagicMock,
|
||||
@@ -2487,31 +2462,3 @@ def test_copy_src_tree_ignores_removed_generated_file(
|
||||
# file was removed and regenerated, not that it triggered sources_changed.
|
||||
new_json = json.loads(build_info_json_path.read_text())
|
||||
assert new_json["config_hash"] == 0xDEADBEEF
|
||||
|
||||
|
||||
def test_build_info_stale_branches(tmp_path: Path) -> None:
|
||||
"""Missing files, an unreadable JSON, a hash or version mismatch each
|
||||
regenerate; a matching record does not."""
|
||||
import json as json_mod
|
||||
|
||||
from esphome.const import __version__
|
||||
from esphome.writer import _build_info_stale
|
||||
|
||||
h = tmp_path / "build_info_data.h"
|
||||
cpp = tmp_path / "build_info_data.cpp"
|
||||
info = tmp_path / "build_info.json"
|
||||
assert _build_info_stale(h, cpp, info, 1) is True # files missing
|
||||
h.write_text("")
|
||||
cpp.write_text("")
|
||||
assert _build_info_stale(h, cpp, info, 1) is True # JSON unreadable
|
||||
info.write_text("not json")
|
||||
assert _build_info_stale(h, cpp, info, 1) is True
|
||||
# Valid JSON that is not an object is stale, not an AttributeError
|
||||
info.write_text("[]")
|
||||
assert _build_info_stale(h, cpp, info, 1) is True
|
||||
info.write_text(json_mod.dumps({"config_hash": 2, "esphome_version": __version__}))
|
||||
assert _build_info_stale(h, cpp, info, 1) is True # hash mismatch
|
||||
info.write_text(json_mod.dumps({"config_hash": 1, "esphome_version": "0.0.0"}))
|
||||
assert _build_info_stale(h, cpp, info, 1) is True # version mismatch
|
||||
info.write_text(json_mod.dumps({"config_hash": 1, "esphome_version": __version__}))
|
||||
assert _build_info_stale(h, cpp, info, 1) is False
|
||||
|
||||
Reference in New Issue
Block a user