Merge branch 'esp8266-native-build-spec' into esp8266-native-ninja-emission

This commit is contained in:
J. Nick Koston
2026-08-22 11:43:02 -05:00
37 changed files with 202 additions and 525 deletions
+6 -16
View File
@@ -865,11 +865,8 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
RuntimeError,
ValueError,
) as err:
# The firmware already built; idedata is a bonus artifact here.
# Broad on purpose: a vanished compiler (OSError), a failed
# include probe (RuntimeError), or a truncated compile DB
# (ValueError/LookupError) must not fail a successful build
# either.
# Broad on purpose: the firmware already built; an idedata
# failure must not fail a successful build.
_LOGGER.warning("Could not generate idedata: %s", err)
else:
from esphome.platformio import toolchain
@@ -2737,12 +2734,8 @@ def run_esphome(argv):
cache_write_eligible = (
args.command in ("upload", "logs") and not command_line_substitutions
)
# An explicit CLI toolchain must run the per-platform validators; the
# cache was validated under whatever the last compile used. Only the
# read is gated: the refresh below still saves the freshly validated
# config. The sidecar is only written when none exists; a
# compile-written one keeps the compile's toolchain (the firmware on
# disk was built by it), which upload/logs then restore.
# An explicit --toolchain must re-run the per-platform validators, so
# gate only the cache read; the refresh below still saves the result.
cache_read_eligible = cache_write_eligible and args.toolchain is None
if cache_read_eligible:
from esphome.compiled_config import load_compiled_config
@@ -2768,11 +2761,8 @@ def run_esphome(argv):
return 2
CORE.config = config
# Every platform resolves the toolchain during validation now, but the
# compiled-config cache fast path skips validation entirely and a
# sidecar written before the toolchain field existed restores nothing;
# this fallback covers that path. Must run before the cache refresh
# below so its sidecar records the same toolchain a compile would.
# The cache fast path skips validation, and legacy sidecars lack the
# toolchain field. Must run before the cache refresh below.
if CORE.toolchain is None:
CORE.toolchain = Toolchain.PLATFORMIO
+28 -68
View File
@@ -1,25 +1,12 @@
"""Arduino-core backend for the shared PlatformIO library converter.
Turns the libraries registered via ``cg.add_library()`` into build inputs for
a native Arduino build. Bare names that exist under the framework's bundled
``libraries/`` directory (ESP8266WiFi, Wire, SPI, ...) are read straight from
the framework tree; everything else goes through the shared
resolution/download pipeline in ``esphome.platformio.library``. Nothing here
is core-specific: the caller names the PlatformIO platform, MCU, and cache
key of the Arduino core it builds.
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.
Known deviations: flat-layout (``library.properties``, no ``src/``)
libraries get the recursive default source filter rather than PlatformIO's
root-only Arduino-1.0 filter (no bundled library is affected), and the
Arduino ``dot_a_linkage`` property is honored even though PlatformIO
ignores it. Bundled libraries never run a manifest ``extraScript`` (a
warning names the library if one declares it). Manifest ``-I`` build
flags join the global include path rather than staying private to the
library's own sources as under PlatformIO.
Mirrors PlatformIO's ``lib_ldf_mode=off`` behavior: each library builds into
its own static archive and every library's include dir joins one global
include 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.
"""
from __future__ import annotations
@@ -101,12 +88,8 @@ def _warn_properties_depends(name: str, data: object) -> None:
def _manifest_build(name: str, data: object) -> dict:
"""The manifest's ``build`` section, validated by name.
A bare json.load imposes no shape; a malformed manifest must name the
library instead of an AttributeError deep in a traceback (and must do so
before apply_extra_script dereferences the same section).
"""
"""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")
@@ -119,9 +102,8 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
# PIO's source-dir resolution: manifest srcDir, else src/Src, else the root
if "srcDir" in build:
# An explicitly declared srcDir (falsy included) that does not
# resolve is unambiguously a manifest/tree error; a silently empty
# source set would surface as link errors far from the cause
# 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()
@@ -138,11 +120,8 @@ def _library_info(name: str, read_path: Path, data: dict) -> ArduinoLibrary:
# PlatformIO shell-lexes each build.flags entry
flag_tokens = lex_build_flags(build.get("flags", []), f"library {name}")
# build.libArchive is PIO behavior; dot_a_linkage is honored as a
# deliberate extra (Arduino IDE's property, which PIO ignores) so
# properties-only libraries can opt out of archiving too. Both parse
# through the same strict table: bool("false") is True, and a typo'd
# value must not silently change link semantics.
# dot_a_linkage (Arduino IDE's property, ignored by PIO) is a deliberate
# extra. Strict parse: bool("false") is True.
def _parse_archive(key: str, raw: object) -> bool:
if isinstance(raw, bool):
return raw
@@ -233,9 +212,7 @@ def _bundled_library(framework_path: Path, name: str) -> ArduinoLibrary:
manifest = lib_dir / "library.properties"
data = parse_library_properties(manifest) if manifest.is_file() else {}
if isinstance(data, dict):
# The dependency walk never runs for bundled libraries (a no-op for
# the ESP8266 core, whose bundled manifests declare none); on a core
# where one does, the skip must be visible before link errors
# Bundled manifest deps are never walked; make the skip visible
if data.get("dependencies"):
_LOGGER.warning(
"Bundled library %s declares dependencies, which are not "
@@ -296,10 +273,8 @@ def resolve_libraries(
# PlatformIO's lib_ignore covers framework-bundled libraries too; the
# shared converter only filters the registry/git ones.
lib_ignore = lib_ignore_set()
# One memoized answer to "does the framework bundle this name?" for the
# classification loop, the provides hook, and the dependency walk: the
# safety guard and the dir probe must stay fused (path traversal), and
# common names (Wire, SPI) are asked repeatedly
# Memoized "does the framework bundle this name?"; the safety guard and
# dir probe must stay fused (path traversal)
_provided = functools.cache(
lambda name: (
_is_safe_library_name(name)
@@ -309,16 +284,11 @@ def resolve_libraries(
for library in CORE.platformio_libraries.values():
if is_lib_ignored(library.name, lib_ignore):
continue
# Only a bare name with a matching framework directory is bundled: a
# version pin means a registry package ("pngle@1.1.0"), and a bare
# name without the directory resolves from the registry at the
# latest version, matching PlatformIO (a typo fails loudly as a
# registry lookup error).
# 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):
# A bundled library's own manifest dependencies are not walked.
# PlatformIO would walk them even under lib_ldf_mode=off, but no
# library bundled with the ESP8266 core declares any, so the walk
# is a no-op there; core add_library() calls list what they need.
# 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)
@@ -328,9 +298,8 @@ def resolve_libraries(
converted_manifest_names: set[str] = set()
# Ordered set of bundled dependency names to add once conversion is done
pending_bundled: dict[str, None] = {}
# Short names of the separately-requested externals: a manifest
# dependency matching one is already in the build, not a bundled name to
# add (a duplicate archive shows up as duplicate-symbol link errors)
# 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
}
@@ -360,18 +329,14 @@ def resolve_libraries(
):
continue
if dep.get("owner") or not _provided(name):
# The converter resolves owner-qualified and non-bundled
# names from the registry; an owner-less name that exists in
# the framework tree prefers the bundled copy ({"Wire": "*"}
# normalizes to version="*"), matching PlatformIO's
# process_dependencies. The shared walk's post-emit
# reconciliation reports any real drops.
# 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
if not dependency_is_usable(dep, pio_platform, "arduino", component.name):
continue
# Deferred: a later-emitted converted library may satisfy this
# name (its manifest name is only known at its own emit), and
# adding the bundled copy too would double the archive
# 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:
@@ -388,9 +353,6 @@ def resolve_libraries(
_add_bundled_dependencies(component)
if external:
# Every converter drop path raises (an incompatible top-level is a
# RuntimeError, resolution and download failures raise), so the
# return needs no re-verification here.
convert_libraries(
external,
LibraryBackend(
@@ -398,10 +360,8 @@ def resolve_libraries(
framework="arduino",
emit=_emit,
cache_key=cache_key,
# The graph walk must not resolve a bundled name from the
# registry ({"Wire": "*"} in a manifest); the bundled copy
# is added by _add_bundled_dependencies after emit. Unsafe
# names are simply not provided.
# The walk must not resolve bundled names from the registry;
# _add_bundled_dependencies adds them after emit
provides=_provided,
),
)
+14 -38
View File
@@ -6,14 +6,9 @@ ESP-IDF install in ``esphome.espidf.framework``):
<cache>/arduino8266/frameworks/<version>/ framework-arduinoespressif8266
<cache>/arduino8266/toolchains/<version>/ toolchain-xtensa (gcc 10.3)
ninja itself comes from PATH or the ninja PyPI wheel (a requirements.txt
dependency), so only the two packages above are downloaded, via the shared
PlatformIO-registry installer in ``esphome.platformio.registry``.
Sources default to the PlatformIO registry (the exact packages the PlatformIO
toolchain has always used, so the bits are identical); the
``ESPHOME_ARDUINO8266_*_MIRRORS`` environment variables override the URLs with
``{VERSION}`` / ``{SYSTEM}`` substitution.
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
@@ -31,9 +26,8 @@ from esphome.platformio.registry import install_package
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
# gcc 10.3, the toolchain Arduino core 3.x builds with. The compile flags in
# the build generator are tuned to it; treat version changes as a full
# reinstall (the install dir is keyed on the version).
# 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(
@@ -56,31 +50,20 @@ MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
def framework_package_version(ver: Version) -> str:
"""Map an Arduino core version (e.g. 3.1.2) to its package version.
"""Map an Arduino core version to its registry package version (3.1.2 ->
3.30102.0; the leading 3 is the package major).
The PlatformIO registry's encoding for cores newer than 2.6.2 (3.1.2 ->
3.30102.0, and 2.7.4 -> 3.20704.0: the leading 3 is the package major,
not the core major). Exact registry names only from 3.0.2 up: 2.6.3,
3.0.0 and 3.0.1 ship as 3.20603.200130 / 3.30000.210519 /
3.30001.210627, which this formula cannot produce. Safe for the
PlatformIO caller (a ~ range) and for check_and_install (floored at
MIN_FRAMEWORK_VERSION); an exact lookup below that floor must not use
this helper. A future core 4.x needs its own encoding and toolchain pin
rather than a registry lookup for a package that cannot exist.
Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor
at MIN_FRAMEWORK_VERSION.
"""
if ver.major > 3:
# Backend-neutral: this also fires on the PlatformIO validation path
# (via _format_framework_arduino_version), where switching toolchains
# would not help
raise EsphomeError(
f"Arduino core {ver} is not supported yet; "
"the newest known core series is 3.x"
)
if ver <= Version(2, 6, 2):
# Same boundary as _format_framework_arduino_version's era guard (a
# 2.6.2 pre-release sorts above 2.6.2 and belongs to this encoding).
# Older cores use the 1.x/2.x package-major encodings; never encode
# them wrongly for a caller that skipped that guard
# 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)"
@@ -141,10 +124,7 @@ def check_and_install(framework_version: Version) -> InstalledPaths:
)
# Sentinel: "resolve for me" (None is a real value meaning disabled).
# The native run_compile (a later PR in the chain) will resolve once and
# thread the result so one build never pays the PATH scan and runnability
# probe three times.
# Sentinel: "resolve for me"; None is a real value meaning disabled.
CCACHE_UNRESOLVED: Any = object()
@@ -176,8 +156,7 @@ def get_build_env(
def ccache_path() -> str | None:
"""The ccache binary to prefix compiles with, or None when disabled.
Deliberately uncached (matching espidf): the decision reads
ESPHOME_CCACHE_ENABLE and PATH, which can change between builds in a
Deliberately uncached: env/PATH can change between builds in a
long-lived host process.
"""
return resolve_ccache_path()
@@ -186,10 +165,7 @@ def ccache_path() -> str | None:
def ccache_env(ccache: str | None = CCACHE_UNRESOLVED) -> dict[str, str]:
"""Return ccache settings for the build subprocess (not os.environ).
Mirrors ``espidf.framework._ccache_env``: cache under the machine-global
tools dir, depend mode (gcc emits depfiles via -MMD), and CCACHE_BASEDIR
scoped to the build dir so devices share framework cache entries. Values
the user already set in the environment are respected.
Values the user already set in the environment are respected.
"""
if ccache is CCACHE_UNRESOLVED:
ccache = ccache_path()
+16 -41
View File
@@ -210,14 +210,10 @@ class _BuildConfig:
def _lexed_build_flags() -> list[str]:
"""Shell-lex every ``CORE.build_flags`` entry the way PlatformIO's
``ParseFlags`` does, so a knob in ``"-DKNOB -DOTHER"``, a spaced
``"-D KNOB"``, and quoted bodies all read identically everywhere.
"""Shell-lex ``CORE.build_flags`` as PlatformIO's ``ParseFlags`` does,
sorted so duplicate defines resolve deterministically.
Sorted so duplicate defines resolve the same way every run (the winner
feeds the linker-script preprocessor line, which is also the cache
stamp). Lex once per build and pass the result to ``_flag_defines`` and
``_project_flags`` so a malformed entry warns once, not per consumer.
Lex once per build; consumers share the tokens.
"""
return lex_build_flags(sorted(CORE.build_flags), "esphome")
@@ -278,9 +274,8 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig:
# 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 would otherwise win the sorted pick and end in the SDK header's
# #error, and a conflicting pair would resolve arbitrarily; both are
# config errors, not build-time surprises
# 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)}")
if len(vtables_knobs) > 1:
@@ -313,11 +308,8 @@ def _resolve_build_config(defines: dict[str, str]) -> _BuildConfig:
mmu = sorted(body for name, body in defines.items() if name.startswith("MMU_"))
else:
if "MMU_IRAM_SIZE" in defines or "MMU_ICACHE_SIZE" in defines:
# PlatformIO only warns here and appends its defaults last so
# they win the compile line; in this generator the user's
# tokens would come last instead, compiling against a memory
# layout the linker script does not implement. Refuse rather
# than reproduce the upstream footgun with worse odds.
# Unlike PlatformIO (whose defaults win the compile line), user
# MMU_* here would win the compile but not the linker script; refuse.
raise EsphomeError(
"Custom MMU_IRAM_SIZE/MMU_ICACHE_SIZE build flags require "
"-DPIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"
@@ -387,10 +379,7 @@ def _defines_flags(
return [
f"-D{d}"
for d in (
# Upstream reads this from the board manifest (build.f_cpu),
# where all 45 supported boards ship 80000000L, overridable via
# board_build.f_cpu; published configs pin 160000000L for
# timing-sensitive integrations, so the override is honored
# Every supported board ships 80 MHz; board_build.f_cpu overrides
f"F_CPU={_pio_option('board_build.f_cpu', '80000000L')}",
"__ets__",
"ICACHE_FLASH",
@@ -423,15 +412,9 @@ def _project_flags(
) -> tuple[list[str], list[str], list[Path], list[str]]:
"""Split the ESPHome build flags into compile, linker, -L, and -l lists.
Every entry is shell-lexed the way PlatformIO's ``ParseFlags`` does, so a
linker flag anywhere in an entry reaches the link line and
``build_unflags`` matches individual tokens (``-Os`` inside ``-Os -g3``).
Only the flag forms ESPHome emits are classified (``-Wl,``/``-L``/``-l``
and compile flags); plain-form ``-T``/``-u``/``-Xlinker`` raise (inert on
a ``-c`` compile line), unlike full ParseFlags which routes them to the
link line. The returned ``compile_flags`` and ``link_flags`` are already
``_shell_token``-quoted; ``lib_dirs`` and ``libs`` are raw and the caller
must quote them at emission.
Plain-form linker flags (``-T``/``-u``/``-Xlinker``) 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] = []
@@ -528,25 +511,19 @@ def generate_ld_scripts(
# incremental builds when nothing changed.
output = ld_dir / "local.eagle.app.v6.common.ld"
stamp = ld_dir / ".local.eagle.app.v6.common.ld.stamp"
# The surgery constants are inputs too: an edit to build_surgery.py must
# invalidate existing build dirs, not wait for an esphome clean.
# The header's and compiler's size and mtime cover an in-place
# framework or toolchain re-extraction at the same versioned path,
# which the command line alone would not notice
# 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)}"
# One fingerprint instead of enumerating surgery internals here, so
# any behavioral edit in build_surgery self-invalidates the cache
+ f" {build_surgery.surgery_fingerprint()}"
)
def _cached_ld_is_valid() -> bool:
# A damaged cache (unreadable, non-UTF-8, truncated, externally
# edited) must regenerate, not abort the build or be reused on
# existence alone (the SECTIONS check below only guards generation)
# Any damaged cache regenerates; never abort the build over it
try:
if not (
output.is_file()
@@ -601,9 +578,7 @@ def generate_ld_scripts(
write_file_if_changed(output, content)
stamp.write_text(stamp_content, encoding="utf-8")
elif stderr_note.is_file():
# The diagnostic must not vanish for the life of the build dir just
# because the script is cached; best-effort like every other cache
# read here (a damaged note must not abort an incremental build)
# Re-emit cached preprocessor warnings on cache hits; best-effort
with contextlib.suppress(OSError, UnicodeDecodeError):
_LOGGER.warning(
"Linker-script preprocessor: %s",
+3 -8
View File
@@ -21,14 +21,9 @@ def main() -> 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)
# Expand the response file here instead of passing @rspfile: GNU ar
# treats backslashes in response files as escapes, corrupting Windows
# paths ("sub\a.o" -> "suba.o").
# One path per line (rspfile_content = $in_newline). ninja shell-quotes
# a path containing specials, so undo a simple surrounding quote per
# line. Expanding into argv trades away the OS command-line length
# limit rspfiles dodge; the relative object paths here stay far
# below it.
# GNU ar treats backslashes in response files as escapes (corrupts
# Windows paths), so expand the rspfile into argv, stripping the
# simple surrounding quote ninja adds to special paths.
objects = [
line[1:-1]
if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\""
+6 -19
View File
@@ -1,13 +1,5 @@
"""Shared ccache policy for build backends.
``ccache_defaults_env`` serves the backends that export ``CCACHE_*`` into a
build subprocess (native ESP-IDF and Arduino); ``resolve_ccache_path``
carries the probe and enable rules (PlatformIO and the native Arduino
build). The ESP-IDF backend keeps ``IDF_CCACHE_ENABLE`` as a
higher-precedence override and falls back to the shared resolver (probe
included) when it is unset; PlatformIO feeds its SCons wrapper script
through env channels instead of ``CCACHE_*`` defaults.
"""
"""Shared ccache policy for build backends: env-knob parsing, binary
resolution, and default ``CCACHE_*`` values."""
from __future__ import annotations
@@ -50,12 +42,8 @@ def parse_enable_env(name: str) -> bool | None:
def resolve_ccache_path() -> str | None:
"""The ccache binary to wrap compiles with, or None when disabled.
Shared policy for every backend: on by default when a runnable ccache is
on PATH, ``ESPHOME_CCACHE_ENABLE=0`` opts out, and an explicit ``=1``
warns when no binary is found and skips the runnability probe;
any other value warns and is treated as unset. The
Windows extended-length prefix is stripped before probing so the probe
validates the exact string the build will execute (#18399).
An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the
Windows extended-length prefix is stripped before probing (#18399).
"""
import shutil
@@ -85,9 +73,8 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
"""
from esphome.core import CORE
# build_path is set during preload for every config-loading command; unset
# means the caller built the environment too early. Fail loudly rather
# than silently drop CCACHE_BASEDIR (losing cross-device cache hits).
# 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"
+4 -9
View File
@@ -123,10 +123,8 @@ 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 denylist is
# sturdier than trying to enumerate compiler names: launchers are few and
# stable, while compilers (cross prefixes, versioned names, icx, armcc, ...)
# are an open set.
# 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"})
@@ -300,17 +298,14 @@ def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> d
representative = _pick_entry(entries)
cxx_path, defines, rep_includes, cxx_flags = parse_entry(representative, launcher)
if _is_launcher(cxx_path):
# Checked before the toolchain probe (which would fail opaquely on
# a launcher) so the unusable compile DB is named, and never
# cached or conflated with "nothing built yet"
# Reject before the toolchain probe, which would fail opaquely on
# a launcher; never cache the unusable compile DB
raise EsphomeError(
f"compile_commands.json names the launcher {cxx_path} as the "
"compiler; the compile database is unusable"
)
# Seed with the representative's includes so it is not parsed twice
# (per-file -c/-o arguments make every command distinct, so memoizing
# whole commands would never hit)
build_includes: dict[str, None] = dict.fromkeys(
rep_includes if _is_esphome_src(representative["file"]) else ()
)
+8 -21
View File
@@ -25,11 +25,7 @@ def _ninja_runs(binary: str) -> bool:
def find_ninja() -> Path:
"""Locate the ninja binary: a runnable PATH hit first, else the ninja
PyPI wheel.
The wheel is a requirements.txt dependency, so pip has already
integrity-checked it; no download logic is needed here.
"""
PyPI wheel."""
if binary := shutil.which("ninja"):
binary = strip_win_long_path_prefix(binary)
if _ninja_runs(binary):
@@ -58,13 +54,9 @@ def escape(value: Path | str) -> str:
def quote_arg(tok: str) -> str:
"""Wrap a token in double quotes with the Windows argv rule.
Same escaping rule as ``subprocess.list2cmdline``: a backslash run
doubles only immediately before a quote (or the closing quote), and the
quote itself is escaped. CreateProcess-only; POSIX sh collapses
backslash runs inside double quotes, so shell_token single-quotes
there instead. ``$`` must already be doubled for ninja.
"""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)
@@ -78,16 +70,11 @@ _NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]")
def shell_token(tok: str, force: bool = False) -> str:
"""Quote a lexed token only when needed; ``force`` always quotes.
"""Re-quote a lexed token for the platform shell; ``force`` always quotes.
Lexing strips the quoting a user wrote (``-DX="a b"`` becomes the single
token ``-DX=a b``); re-quote on the way out so the compiler receives the
same argv element SCons would pass under PlatformIO. Ninja hands POSIX
commands to ``/bin/sh -c`` and Windows commands to CreateProcess, so the
quoting style is chosen per platform: single quotes on POSIX (sh expands
nothing inside them, matching SCons's no-shell spawn) and the argv rule
on Windows. ``$`` is doubled first in either case because ninja expands
``$`` before the command reaches the shell.
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)):
+1 -5
View File
@@ -4,11 +4,7 @@ from __future__ import annotations
def format_bar(used: int, total: int) -> str:
"""Match PlatformIO's ``_format_availale_bytes`` (pioupload.py) exactly.
The upstream helper's name really is spelled that way; keep the citation
verbatim so it stays greppable in the PlatformIO source.
"""
"""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)
+3 -4
View File
@@ -105,10 +105,9 @@ def _refresh_sidecar() -> bool:
and CORE.toolchain is not None
and old.toolchain != CORE.toolchain.value
):
# The config was validated under a different toolchain than
# the compile's, and platforms normalize toolchain-sensitive
# keys (e.g. the esp32 board name) differently; caching it
# would disagree with the sidecar until the next compile
# 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",
+3 -8
View File
@@ -366,14 +366,9 @@ BOARDS = {
}
# Per-board Arduino core build metadata for the native (PlatformIO-free)
# toolchain: the variant directory (supplies pins_arduino.h) and the
# board-identity defines the PlatformIO builder passes via build.extra_flags.
# Valid for platform 4.x only (older tags differ, e.g. esp8285's variant);
# the native toolchain's validator enforces that pairing by requiring core
# >= 3.1.1 and rejecting a custom platform_version.
# -DESP8266 and -DARDUINO_ARCH_ESP8266 are shared by every board and added by
# the generator; only the per-board defines are listed here.
# 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):
+2 -8
View File
@@ -84,8 +84,6 @@ def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str
"""
for segment in _TESTING_SEGMENT_SIZES:
if segment not in segments and _segment_line_re(segment).search(content):
# A known segment left unpatched would keep its real memory limit
# and silently under-provision the testing build
raise RuntimeError(
f"Testing-mode segment {segment} is present in the linker "
"script but was not selected for patching"
@@ -111,12 +109,8 @@ def segment_length(content: str, segment_name: str) -> int | None:
def surgery_fingerprint() -> str:
"""Fingerprint of this module's source, covering every behavioral input.
Linker-script caches include it so an edit here invalidates them; hashing
the source over-invalidates on comment edits, which is the safe direction.
Native-toolchain-only, like ``segment_length``; no script twin.
"""
"""Hash of this module's source; linker-script caches include it so an
edit here invalidates them."""
import inspect
import sys
+4 -14
View File
@@ -2540,12 +2540,8 @@ def platformio_version_constraint(value):
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, so a ``--toolchain`` a platform
cannot serve always fails by name instead of silently building with a
different backend.
"""
"""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
@@ -2591,14 +2587,8 @@ def resolve_toolchain(
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. Without this a
``--toolchain`` they cannot serve would either build with PlatformIO
while claiming another backend, or (for a toolchain another platform
owns, like ``esp-idf``) dispatch to a native backend that cannot
build this platform at all.
"""
"""Reject a CLI-selected toolchain other than PlatformIO, for platforms
with only the PlatformIO backend."""
return resolve_toolchain(
platform_name, (Toolchain.PLATFORMIO,), Toolchain.PLATFORMIO
)
+2 -6
View File
@@ -985,12 +985,8 @@ class EsphomeCore:
@property
def using_toolchain_arduino(self):
"""The native (PlatformIO-free) ESP8266 Arduino build backend.
Unlike ``using_arduino`` (the target *framework*, true for any
platform compiling Arduino code), this is a build *toolchain*
choice, like its ``using_toolchain_*`` siblings.
"""
"""The native ESP8266 Arduino build toolchain (unlike
``using_arduino``, which is the target framework)."""
return self.toolchain == Toolchain.ARDUINO
@property
+3 -11
View File
@@ -566,11 +566,7 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No
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. Every dispatch site that tests a
# specific using_toolchain_* as a stand-in for "native" (project
# writing, compile, upload, firmware paths) must agree with this
# gate: a toolchain treated as native here must never fall through
# to a PlatformIO code path there.
# otherwise be silently ignored.
for key, val in pio_options.items():
vals = [val] if isinstance(val, str) else val
if key == CONF_BUILD_FLAGS:
@@ -600,12 +596,8 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No
# discovered dependencies
cg.add_platformio_option(key, vals)
elif key in NATIVE_ARDUINO_PIO_OPTIONS and CORE.using_toolchain_arduino:
# Real-world knobs many published ESP8266 configs rely on:
# f_cpu 160000000L for timing-sensitive integrations, and a
# custom ldscript to reserve a filesystem region or correct
# a board's flash size. The esp8266 native generator reads
# both; other native toolchains have no equivalent and fall
# through to the warning.
# The esp8266 native generator reads these; other native
# toolchains have no equivalent and fall through to the warning.
cg.add_platformio_option(key, val)
elif key != "upload_speed":
# upload_speed needs no handling: it is read from the raw
+2 -4
View File
@@ -88,10 +88,8 @@ 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, so one entry can carry a
# flag and its argument (e.g. "-include cp_custom_alloc.h"); bare
# -I/-L/-l/-D tokens re-glue to their argument ("-I foo" -> "-Ifoo") so
# prefix classifiers below still route them.
# 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}",
+2 -7
View File
@@ -1155,11 +1155,8 @@ 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.
"""
# Honor an explicit choice already in the environment (opt-out or opt-in).
# IDF_CCACHE_ENABLE (this backend's native knob) wins over the shared
# ESPHOME_CCACHE_ENABLE, which resolve_ccache_path parses; without it a
# user disabling ccache to debug a miscompile would silently keep it
# enabled here.
# 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:
return {}
@@ -1167,8 +1164,6 @@ def _ccache_env() -> dict[str, str]:
# ESP-IDF silently skips ccache without the binary; don't enable it.
return {}
# ccache is enabled past here; the shared helper carries the CCACHE_*
# policy (and the fail-loud build_path guard).
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
if idf_knob is None:
# An unparsable IDF_CCACHE_ENABLE must not leak to idf.py as truthy
+2 -4
View File
@@ -199,10 +199,8 @@ def run_command(
def tool_version_runs(binary: str, warning: str) -> bool:
"""Probe ``binary --version``; on failure warn with ``warning`` % binary.
``shutil.which`` proves existence, not runnability: on Windows it also
matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose
target is gone. Callers probe once and fall back instead of failing
every build step with an opaque OS error.
``shutil.which`` proves existence, not runnability (Windows .bat/.cmd
shims, stale package-manager shims).
"""
try:
subprocess.run(
+3 -6
View File
@@ -141,9 +141,7 @@ class _FakeSConsEnv:
def Append(self, **kwargs) -> None: # noqa: N802 (SCons API name)
for key, value in kwargs.items():
if key not in _CAPTURED_KEYS:
# Diagnosable from the build log when a script configures
# something this shim does not translate; once per key so a
# loop of Appends cannot spam
# 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(
@@ -239,9 +237,8 @@ def run_extra_script(
)
return ExtraScriptResult()
except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught
# Discard any partial capture: folding half a script's flags into the
# build could produce wrong-output firmware that links cleanly. The
# warning plus the resulting loud link error point back here.
# 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,
+15 -31
View File
@@ -47,11 +47,8 @@ DEFAULT_BUILD_SRC_FILTER = (
DEFAULT_BUILD_SRC_DIRS = "src"
DEFAULT_BUILD_INCLUDE_DIR = "include"
DEFAULT_BUILD_FLAGS = []
# Source suffix -> compiler kind, PlatformIO's CSUFFIXES/CXXSUFFIXES/ASSUFFIXES
# split. Native build generators map the kind to their compile rules. "asm"
# deliberately merges SCons's AS (.s/.asm) and ASPP (.S/.spp/.sx) sets: the
# ninja rules compile all of them as assembler-with-cpp, whose asm-mode
# preprocessor passes non-directive text through unchanged.
# Suffix -> compiler kind (PlatformIO's CSUFFIXES/CXXSUFFIXES/ASSUFFIXES).
# "asm" merges SCons's AS and ASPP sets: all compile as assembler-with-cpp.
SOURCE_KIND_FOR_SUFFIX: dict[str, str] = {
".c": "c",
".cpp": "cxx",
@@ -301,10 +298,9 @@ class LibraryBackend:
framework: str
emit: Callable[["ConvertedLibrary"], None]
cache_key: str
# When set, an owner-less manifest dependency this returns True for is
# skipped by the graph walk: the backend provides it outside the
# registry (e.g. a library bundled with the Arduino core), mirroring
# PlatformIO's process_dependencies preference for bundled builders.
# 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).
provides: Callable[[str], bool] | None = None
@@ -633,13 +629,8 @@ def join_flag_args(tokens: Iterable[str], owner: str) -> list[str]:
def dependency_is_usable(
dep: dict, platform: str | None, framework: str, requester: str
) -> bool:
"""Whether a manifest dependency passes the compatibility filter.
The routine cross-platform skip logs at debug; any other
``InvalidLibrary`` cause is a dropped dependency and warns naming the
requester (unreachable from ``check_library_data`` today, which raises
only for the platform filter).
"""
"""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:
@@ -699,9 +690,7 @@ def normalize_dependencies(
continue
normalized.append(entry)
elif isinstance(entry, str) and entry:
# PIO also accepts a bare list of names ("dependencies":
# ["Wire"]); dropping them here would hide a real dependency
# from every caller's visibility warning
# PIO also accepts a bare list of names ("dependencies": ["Wire"])
normalized.append({"name": entry})
else:
_LOGGER.warning(
@@ -1025,10 +1014,8 @@ def convert_libraries(
component.data.get("dependencies"), component.name
):
if "name" not in dependency or "version" not in dependency:
# Version-less deps cannot resolve from the registry.
# Deferred: only the final resolution set can tell a real
# drop from a name another manifest resolves later, so the
# reconciliation after emit owns the warning
# Version-less deps cannot resolve from the registry; the
# post-emit reconciliation owns the drop warning
_LOGGER.debug(
"Skip version-less dependency %r of %s",
dependency.get("name"),
@@ -1056,9 +1043,8 @@ def convert_libraries(
# The backend adds it from its own tree; resolving it here
# would fetch a same-named registry package instead
if (pin := dependency.get("version")) and pin != "*":
# The declared constraint is discarded for the bundled
# copy; a too-old bundled library must not surface as
# link errors with no stated cause
# 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",
@@ -1126,11 +1112,9 @@ def convert_libraries(
for component in components.values():
backend.emit(component)
# A version-less dependency is satisfied when its request key resolved,
# a resolved component's manifest name matches, or the backend provides
# it from its own tree (e.g. the arduino bundled libraries, added by the
# backend after emit). Anything else is a real drop that would otherwise
# surface as link errors far from the cause.
# 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:
+13 -30
View File
@@ -1,9 +1,5 @@
"""Install packages from the PlatformIO registry without PlatformIO.
Native toolchains install the exact registry packages the PlatformIO backend
uses, so the bits are identical, but resolve and verify them with esphome's
own download machinery instead of importing the platformio package.
"""
"""Install packages from the PlatformIO registry without importing the
platformio package (identical bits, esphome's own download machinery)."""
from __future__ import annotations
@@ -33,11 +29,9 @@ _REGISTRY_URL = (
def get_systype() -> str:
"""The registry system tag for the current host.
A transliteration of ``platformio.util.get_systype()``, honoring the same
``PLATFORMIO_SYSTEM_TYPE`` override, so this module never imports the
platformio package. One deviation: windows-arm64 maps straight to
``windows_amd64``: the registry ships no arm64 toolchains and those hosts
run x86 binaries via emulation, which upstream leaves to the override.
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
@@ -100,10 +94,8 @@ def registry_download(package: str, version: str) -> tuple[str, str, int | None]
f"Unexpected package registry response for {package}: "
f"{str(ver)[:200]}"
)
# Only a MISSING key means "any system"; an explicitly empty
# list must not match (a wrong-architecture download would be
# cached as a good install). A bare string would make ``in`` a
# substring test.
# 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 = ["*"]
@@ -138,12 +130,8 @@ def registry_download(package: str, version: str) -> tuple[str, str, int | None]
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: a marked tree that later
lost files (manual deletion, antivirus quarantine) must fail by name
instead of surfacing as an opaque toolchain error.
"""
"""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(
@@ -177,21 +165,16 @@ def install_package(
return
from filelock import FileLock
# The cache is machine-global; serialize concurrent cold builds so one
# process cannot wipe the directory another is extracting into (same
# filelock pattern as platformio/toolchain.py and git.py).
# Serialize concurrent cold builds (same filelock pattern as git.py).
dest.parent.mkdir(parents=True, exist_ok=True)
# fallback_to_soft would silently degrade to an existence lock on a
# flock-less filesystem; a hard-killed run would then hang every later
# build forever (same hazard git.py documents).
# 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")
# A persistent download location (not a temp dir) so an interrupted
# download resumes across esphome runs via download_with_resume's
# .part file, mirroring the espidf dist/ convention.
# 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)
+5 -13
View File
@@ -781,10 +781,7 @@ def test_write_project_missing_src_dir_raises(tmp_path: Path) -> None:
def test_build_config_custom_mmu_without_knob_raises() -> None:
"""Custom MMU sizes without the CUSTOM knob would compile against a
layout the linker script does not implement; refuse instead of warning
(PlatformIO warns, but its defaults win the compile line; ours would
not)."""
"""Custom MMU sizes without the CUSTOM knob are refused."""
with pytest.raises(EsphomeError, match="PIO_FRAMEWORK_ARDUINO_MMU_CUSTOM"):
_resolve("-DMMU_IRAM_SIZE=0xC000")
@@ -833,8 +830,7 @@ def test_flag_tables_match_platformio_builder() -> None:
"-free",
"-fipa-pta",
]
# The -u block is where the deliberate -u _scanf_float omission lives;
# pinned in full so "restoring" it fails here first
# Pins the deliberate -u _scanf_float omission
assert arduino8266._LINKFLAGS == [
"-Os",
"-nostdlib",
@@ -927,8 +923,7 @@ def test_write_project_unknown_board_fails_by_name(tmp_path: Path) -> None:
def test_unflag_tokens_join_spaced_entries() -> None:
"""A spaced build_unflags entry ("-D FOO") removes -DFOO, and no bare half leaks into the
unflag set to collaterally drop unrelated tokens."""
"""Spaced build_unflags entries ("-D FOO") match the joined token."""
CORE.build_unflags = {"-D FOO", "-l bar"}
tokens = arduino8266._unflag_tokens()
assert tokens == {"-DFOO", "-lbar"}
@@ -950,8 +945,7 @@ def test_flag_defines_respects_unflags() -> None:
def test_vtables_unknown_raises() -> None:
"""A typo'd knob would win the sorted pick and die in the SDK header's
#error; fail by name at generation instead."""
"""An unknown VTABLES_IN_* knob fails by name."""
with pytest.raises(EsphomeError, match="Unknown VTABLES_IN_.*BANANA"):
_resolve("-DVTABLES_IN_BANANA")
@@ -1044,9 +1038,7 @@ def test_lexed_build_flags_shared_between_consumers(
"tok", ["-Tcustom.ld", "-Xlinker", "-u", "-e", "-s", "-static", "-nostartfiles"]
)
def test_project_flags_rejects_plain_linker_forms(tok: str) -> None:
"""A plain-form linker flag would land on the -c compile line where it
is inert; refuse naming the -Wl, form instead of shipping firmware that
silently lacks the requested link behavior."""
"""Plain-form linker flags are refused, naming the -Wl, form."""
_set_flags(tok)
with pytest.raises(EsphomeError, match="use the -Wl, form"):
arduino8266._project_flags(set(), arduino8266._lexed_build_flags())
@@ -3,6 +3,8 @@
from __future__ import annotations
from pathlib import Path
import subprocess
import sys
from unittest.mock import MagicMock, patch
import pytest
@@ -50,8 +52,6 @@ def test_unknown_mode(capsys: pytest.CaptureFixture[str]) -> None:
def test_runs_as_script(tmp_path: Path) -> None:
"""The ninja rules invoke the file as a plain script."""
import subprocess
import sys
src = tmp_path / "a.bin"
src.write_text("x")
@@ -429,8 +429,7 @@ def test_load_or_build_idedata_corrupted_cache_is_logged(
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 by name,
before the toolchain probe could fail opaquely, and never cached."""
"""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(
+1 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
from unittest.mock import MagicMock, patch
@@ -84,7 +85,6 @@ def test_shell_token_quotes_shell_metacharacters() -> None:
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."""
import subprocess
if sys.platform == "win32":
pytest.skip("POSIX sh quoting")
@@ -2,8 +2,13 @@
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,
@@ -110,11 +115,6 @@ def test_board_build_covers_every_board() -> None:
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."""
import importlib.util
from pathlib import Path as _Path
import sys
from esphome.components.esp8266 import build_surgery
first = build_surgery.surgery_fingerprint()
assert first == build_surgery.surgery_fingerprint()
@@ -124,7 +124,7 @@ def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None:
# 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")
Path(build_surgery.__file__).read_text(encoding="utf-8")
+ "\nEXTRA_BEHAVIORAL_INPUT = 1\n",
encoding="utf-8",
)
@@ -26,9 +26,8 @@ def test_framework_package_version() -> None:
# 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))
# Cores up to 2.6.2 use other encodings; the helper is total, not wrong,
# and its boundary matches the PlatformIO era guard: a 2.6.2 pre-release
# sorts above 2.6.2 and keeps the package-major-3 encoding
# 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"
@@ -102,9 +101,8 @@ def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None:
def test_ccache_path_delegates_uncached(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The wrapper delegates to the shared policy (covered in
build_helpers/test_ccache.py) on every call: the env/PATH decision
must not freeze for the process lifetime in a long-lived host."""
"""Delegates on every call; the env/PATH decision must not freeze for
the process lifetime."""
monkeypatch.delenv("ESPHOME_CCACHE_ENABLE", raising=False)
with patch.object(
framework, "resolve_ccache_path", return_value="/usr/bin/ccache"
+14 -30
View File
@@ -12,7 +12,13 @@ import pytest
from esphome.arduino import library as component
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_ESP8266
from esphome.core import CORE, EsphomeError, Library
from esphome.platformio.library import ConvertedLibrary, LibraryBackend
import esphome.platformio.library as pio_library
from esphome.platformio.library import (
ConvertedLibrary,
IncompatiblePlatform,
InvalidLibrary,
LibraryBackend,
)
@pytest.fixture(autouse=True)
@@ -212,9 +218,7 @@ def test_resolve_libraries_bundled(tmp_path: Path) -> None:
def test_resolve_libraries_registry_name_is_external(
tmp_path: Path, version: str | None
) -> None:
"""A name that is not bundled reaches the converter: bare resolves from
the registry at the latest version (matching PlatformIO and the
documented libraries: key) and a version pin is a registry package."""
"""A name that is not bundled reaches the converter, bare or pinned."""
framework = _make_framework(tmp_path)
_add_library("pngle", version)
with patch.object(component, "convert_libraries", return_value=[]) as mock_convert:
@@ -410,12 +414,8 @@ def test_bundled_dependency_nonplatform_rejection_warns(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""An InvalidLibrary whose cause is not the platform filter is visible."""
from esphome.platformio.library import InvalidLibrary
framework = _make_framework(tmp_path)
converted = _webserver(tmp_path, {"build": {}, "dependencies": [{"name": "Wire"}]})
import esphome.platformio.library as pio_library
with (
_emitting_converter(converted),
patch.object(
@@ -466,9 +466,7 @@ def test_library_info_lib_archive_parse(
def test_bundled_dependency_dict_shorthand_prefers_bundled(tmp_path: Path) -> None:
"""The {"Wire": "*"} dict shorthand (version="*", no owner) must resolve
to the bundled library, matching PIO's process_dependencies, instead of
being routed to the registry."""
"""The {"Wire": "*"} dict shorthand resolves to the bundled library."""
framework = _make_framework(tmp_path)
converted = _webserver(tmp_path, {"build": {}, "dependencies": {"Wire": "*"}})
with _emitting_converter(converted):
@@ -481,12 +479,8 @@ def test_bundled_dependency_platform_rejection_is_debug(
) -> None:
"""The typed IncompatiblePlatform (the routine cross-platform skip)
stays at debug regardless of message wording."""
from esphome.platformio.library import IncompatiblePlatform
framework = _make_framework(tmp_path)
converted = _webserver(tmp_path, {"build": {}, "dependencies": [{"name": "Wire"}]})
import esphome.platformio.library as pio_library
with (
_emitting_converter(converted),
patch.object(
@@ -626,11 +620,8 @@ def test_bundled_library_non_dict_manifest_skips_probes_and_raises(
def test_dict_shorthand_dependency_skips_registry_through_real_converter(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""{"Wire": "*"} in a real manifest must never reach the registry: the
graph walk skips backend-provided names and the bundled copy is added
after emit (no converter mock; a registry touch fails the test)."""
import esphome.platformio.library as pio_library
"""{"Wire": "*"} resolves to the bundled copy without touching the
registry (real converter)."""
framework = _make_framework(tmp_path)
_local_lib(tmp_path, {"Wire": "*"})
# Pin the component cache to tmp_path (data_dir honors an ambient
@@ -702,8 +693,6 @@ def test_pinned_bundled_dependency_substitution_warns(
) -> None:
"""A non-* version pin on a backend-provided dependency is discarded
for the bundled copy; the substitution must be visible."""
import esphome.platformio.library as pio_library
framework = _make_framework(tmp_path)
_local_lib(tmp_path, {"Wire": "^2.0.0"})
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path / ".esphome"))
@@ -720,9 +709,7 @@ def test_pinned_bundled_dependency_substitution_warns(
def test_transitively_resolved_dependency_does_not_warn(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A version-less dependency the walk resolved as another library's
registry dependency is present in the build; the skipping warning must
stay quiet for it."""
"""A dependency the walk already resolved does not warn."""
framework = _make_framework(tmp_path)
_add_library("ESP32Async/ESPAsyncWebServer", "3.9.6")
ws, tcp = _ws_tcp_pair(tmp_path)
@@ -754,9 +741,8 @@ def test_external_short_name(spec: str, expected: str) -> None:
def test_converted_manifest_name_suppresses_bundled_dependency(
tmp_path: Path,
) -> None:
"""A dependency name a converted library's manifest provides is not
also added from the framework tree (a duplicate archive would surface
as duplicate-symbol link errors), even when the provider emits later."""
"""A name a converted library's manifest provides is not also added
from the framework tree, even when the provider emits later."""
framework = _make_framework(tmp_path)
_add_library("ESP32Async/ESPAsyncWebServer", "3.9.6")
# Requested under a different short name; only the manifest says "Wire"
@@ -814,8 +800,6 @@ def test_versionless_dependency_with_provider_stays_quiet(
) -> None:
"""With a provides backend the version-less skip is routine (debug) and
the bundled copy is picked up after emit."""
import esphome.platformio.library as pio_library
framework = _make_framework(tmp_path)
_local_lib(tmp_path, [{"name": "Wire"}])
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path / ".esphome"))
+1 -2
View File
@@ -643,8 +643,7 @@ 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: platforms normalize toolchain-sensitive
keys differently and the sidecar keeps the compile's toolchain."""
must not overwrite the cache."""
yaml_path = _bare_yaml(tmp_path)
_prime_core(tmp_path)
CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}}
+3 -14
View File
@@ -1,3 +1,4 @@
import importlib
import json
import logging
from pathlib import Path
@@ -48,6 +49,7 @@ from esphome.const import (
TYPE_GIT,
TYPE_LOCAL,
Framework,
Toolchain,
)
from esphome.core import (
CORE,
@@ -3169,9 +3171,6 @@ def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None:
def test_require_platformio_toolchain() -> None:
"""Platforms with only the PlatformIO backend reject other toolchains."""
from esphome.const import Toolchain
from esphome.core import CORE
validator = cv.require_platformio_toolchain("RP2")
CORE.toolchain = None
config: dict = {}
@@ -3186,9 +3185,6 @@ def test_require_platformio_toolchain() -> None:
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."""
from esphome.const import Toolchain
from esphome.core import CORE
CORE.toolchain = None
with pytest.raises(Invalid, match="not resolved before RP2 validation"):
cv._check_supported_toolchain("RP2", (Toolchain.PLATFORMIO,))
@@ -3209,14 +3205,7 @@ def test_check_supported_toolchain_unresolved_is_an_ordering_bug() -> None:
def test_every_platformio_only_platform_rejects_arduino_toolchain(
platform: str, minimal_config: dict
) -> None:
"""The invariant every native-toolchain gate relies on: a platform that
cannot serve a CLI toolchain rejects it at validation (esp32, esp8266,
and nrf52 pin this in their own suites)."""
import importlib
from esphome.const import Toolchain
from esphome.core import CORE
"""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'"):
+2 -2
View File
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock
import pytest
from esphome.components import esp32 as esp32_module
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
@@ -15,6 +16,7 @@ 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,
@@ -1072,8 +1074,6 @@ def test_idf_component_download_passes_salt() -> None:
def test_emit_idf_component_wires_esp32_target(tmp_path, monkeypatch):
"""Emitting a component resolves the esp32 variant into the shared
extraScript helper."""
from esphome.components import esp32 as esp32_module
from esphome.espidf.component import _emit_idf_component
monkeypatch.setattr(esp32_module, "get_esp32_variant", lambda: "ESP32")
(tmp_path / "src").mkdir()
@@ -1395,8 +1395,6 @@ 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 (
# The gate defers to the shared resolver (which carries the PATH
# lookup, ESPHOME_CCACHE_ENABLE parse, and runnability probe)
patch("esphome.espidf.framework.resolve_ccache_path", return_value=which),
patch(
"esphome.espidf.framework.get_idf_tools_path",
+2 -29
View File
@@ -101,6 +101,7 @@ from esphome.const import (
CONF_WEB_SERVER,
CONF_WIFI,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_BK72XX,
PLATFORM_ESP32,
@@ -7139,7 +7140,7 @@ def test_warn_source_tree_mismatch_falls_back_when_stat_fails(
RuntimeError("Could not query builtin include dirs"),
ValueError("no C++ translation unit found"),
KeyError("command"),
None, # replaced with EsphomeError inside (import is function-local)
None, # replaced with EsphomeError inside
],
)
def test_compile_program_espidf_idedata_failure_does_not_fail_build(
@@ -7147,14 +7148,6 @@ def test_compile_program_espidf_idedata_failure_does_not_fail_build(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A post-compile idedata error is a warning: the firmware already built."""
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE, EsphomeError
if error is None:
error = EsphomeError("compile database is unusable")
CORE.toolchain = Toolchain.ESP_IDF
@@ -7178,14 +7171,6 @@ def test_compile_program_espidf_idedata_success_is_silent(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The healthy path: idedata generated, nothing to warn about."""
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
@@ -7207,14 +7192,6 @@ def test_compile_program_espidf_idedata_none_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A silent None from the post-compile idedata refresh is made visible."""
from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE
CORE.toolchain = Toolchain.ESP_IDF
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp32",
@@ -7235,8 +7212,6 @@ def test_compile_program_espidf_idedata_none_warns(
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."""
from esphome.__main__ import run_esphome
conf = tmp_path / "device.yaml"
conf.write_text("esphome:\n name: t\n")
argv = ["esphome", "--toolchain", "arduino", "logs", str(conf)]
@@ -7255,8 +7230,6 @@ def test_cli_toolchain_still_refreshes_the_validated_config_cache(
"""An explicit --toolchain gates only the cache read; the freshly
validated config is still saved so a later plain run keeps the fast
path (an existing compile-written sidecar keeps its toolchain)."""
from esphome.__main__ import run_esphome
conf = tmp_path / "device.yaml"
conf.write_text("esphome:\n name: t\n")
argv = ["esphome", "--toolchain", "platformio", "logs", str(conf)]
+4 -6
View File
@@ -7,8 +7,10 @@ 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,
@@ -22,8 +24,9 @@ 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
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain
from esphome.core import CORE, EsphomeError
from esphome.framework_helpers import get_python_env_executable_path
@@ -558,7 +561,6 @@ 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 = (
@@ -570,7 +572,6 @@ 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 = (
@@ -623,9 +624,6 @@ def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> N
def test_resolve_toolchain_rejects_unsupported() -> None:
"""A --toolchain nRF52 cannot serve fails instead of degrading silently."""
from esphome.components.nrf52 import _resolve_toolchain
import esphome.config_validation as cv
from esphome.const import Toolchain
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
@@ -2,19 +2,25 @@
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 (
ExtraScriptResult,
_FakeSConsEnv,
apply_extra_script,
captured_as_build_flags,
run_extra_script,
)
from esphome.platformio.library import ConvertedLibrary as IDFComponent, URLSource
def test_extra_script_captures_libpath_libs_and_defines(tmp_path):
from esphome.platformio.extra_script import (
captured_as_build_flags,
run_extra_script,
)
(tmp_path / "src" / "esp32").mkdir(parents=True)
script = tmp_path / "extra_script.py"
@@ -58,10 +64,6 @@ def test_extra_script_libpath_relative_resolves_against_library_dir(
"""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.platformio.extra_script import (
ExtraScriptResult,
captured_as_build_flags,
)
(tmp_path / "lib" / "esp32").mkdir(parents=True)
elsewhere = tmp_path.parent / "not_the_library_dir"
@@ -76,10 +78,6 @@ def test_extra_script_libpath_relative_resolves_against_library_dir(
def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
from esphome.platformio.extra_script import (
ExtraScriptResult,
captured_as_build_flags,
)
outside = tmp_path.parent / "system_lib"
outside.mkdir(exist_ok=True)
@@ -90,7 +88,6 @@ def test_extra_script_libpath_absolute_outside_library_dir(tmp_path):
def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
from esphome.platformio.extra_script import run_extra_script
script = tmp_path / "broken.py"
script.write_text("raise RuntimeError('boom')\n")
@@ -106,7 +103,6 @@ def test_extra_script_failure_returns_empty_result(tmp_path, caplog):
def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
from esphome.platformio.extra_script import apply_extra_script
library_dir = tmp_path / "lib"
library_dir.mkdir()
@@ -117,8 +113,6 @@ def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
c.path = library_dir
c.data = {"build": {"extraScript": "../evil.py"}}
from esphome.core import EsphomeError
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
@@ -127,7 +121,6 @@ def test_apply_extra_script_path_traversal_is_rejected(tmp_path):
def test_apply_extra_script_merges_into_existing_flags(tmp_path):
from esphome.platformio.extra_script import apply_extra_script
(tmp_path / "src").mkdir()
script = tmp_path / "extra.py"
@@ -146,8 +139,6 @@ def test_apply_extra_script_merges_into_existing_flags(tmp_path):
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."""
from esphome.core import EsphomeError
from esphome.platformio.extra_script import apply_extra_script
(tmp_path / "src").mkdir()
script = tmp_path / "extra.py"
@@ -164,7 +155,6 @@ def test_apply_extra_script_malformed_flags_raises(tmp_path) -> None:
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."""
from esphome.platformio.extra_script import apply_extra_script
(tmp_path / "src").mkdir()
script = tmp_path / "extra.py"
@@ -180,7 +170,6 @@ def test_apply_extra_script_callable_target_and_str_flags(tmp_path) -> None:
def test_apply_extra_script_no_script_and_no_flags(tmp_path) -> None:
from esphome.platformio.extra_script import apply_extra_script
# No extraScript declared: nothing happens, the target is never resolved
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
@@ -203,9 +192,6 @@ def test_apply_extra_script_no_script_and_no_flags(tmp_path) -> None:
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."""
import logging
from esphome.platformio.extra_script import apply_extra_script
caplog.set_level(logging.DEBUG)
script = tmp_path / "extra.py"
@@ -223,7 +209,6 @@ def test_apply_extra_script_ignores_uncaptured_env_calls(tmp_path, caplog) -> No
def test_apply_extra_script_swallows_script_errors(tmp_path, caplog) -> None:
"""A raising extra-script is best-effort: logged and skipped."""
from esphome.platformio.extra_script import apply_extra_script
script = tmp_path / "extra.py"
script.write_text("raise RuntimeError('boom')\n")
@@ -237,7 +222,6 @@ def test_apply_extra_script_swallows_script_errors(tmp_path, caplog) -> None:
def test_apply_extra_script_pio_platform(tmp_path) -> None:
"""The backend's platform token is exposed to the script as PIOPLATFORM."""
from esphome.platformio.extra_script import apply_extra_script
script = tmp_path / "extra.py"
script.write_text("env.Append(LIBS=[env.get('PIOPLATFORM')])\n")
@@ -251,8 +235,6 @@ def test_apply_extra_script_pio_platform(tmp_path) -> None:
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."""
from esphome.core import EsphomeError
from esphome.platformio.extra_script import apply_extra_script
c = IDFComponent("owner/name", "1.0", source=URLSource("http://dummy"))
c.path = tmp_path
@@ -264,7 +246,6 @@ def test_apply_extra_script_missing_script_raises(tmp_path) -> None:
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."""
from esphome.platformio.extra_script import run_extra_script
script = tmp_path / "extra.py"
script.write_text("env.Append(LIBS=['algobsec'])\nraise RuntimeError('boom')\n")
@@ -278,7 +259,6 @@ def test_run_extra_script_failure_discards_partial_capture(tmp_path, caplog) ->
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."""
from esphome.platformio.extra_script import run_extra_script
script = tmp_path / "extra.py"
script.write_text("def broken(:\n")
@@ -291,7 +271,6 @@ def test_run_extra_script_syntax_error_is_best_effort(tmp_path, caplog) -> None:
def test_unsupported_env_method_warns_once(caplog) -> None:
"""Repeated calls to the same unsupported method warn only once."""
from esphome.platformio.extra_script import _FakeSConsEnv
env = _FakeSConsEnv(
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
@@ -304,7 +283,6 @@ def test_unsupported_env_method_warns_once(caplog) -> None:
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."""
from esphome.platformio.extra_script import run_extra_script
script = tmp_path / "extra.py"
script.write_text("import sys\nenv.Append(LIBS=['x'])\nsys.exit(3)\n")
@@ -317,7 +295,6 @@ def test_run_extra_script_sys_exit_is_best_effort(tmp_path, caplog) -> None:
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."""
from esphome.platformio.extra_script import run_extra_script
script = tmp_path / "extra.py"
script.write_text("import sys\nenv.Append(LIBS=['algobsec'])\nsys.exit(0)\n")
@@ -330,10 +307,6 @@ def test_run_extra_script_sys_exit_zero_is_success(tmp_path, caplog) -> None:
def test_run_extra_script_unreadable_raises(tmp_path) -> None:
"""An unreadable declared script is a broken package, like a missing one."""
from unittest.mock import patch
from esphome.core import EsphomeError
from esphome.platformio.extra_script import run_extra_script
script = tmp_path / "extra.py"
script.write_text("")
@@ -348,7 +321,6 @@ def test_run_extra_script_unreadable_raises(tmp_path) -> None:
def test_run_extra_script_bad_encoding_is_best_effort(tmp_path, caplog) -> None:
"""Undecodable content warns and skips, like a SyntaxError."""
from esphome.platformio.extra_script import run_extra_script
script = tmp_path / "extra.py"
script.write_bytes(b"\xff\xfe\x00bad")
@@ -361,7 +333,6 @@ def test_run_extra_script_bad_encoding_is_best_effort(tmp_path, caplog) -> None:
def test_uncaptured_append_key_warns_once(caplog) -> None:
"""A loop of Appends to the same uncaptured key warns once."""
from esphome.platformio.extra_script import _FakeSConsEnv
env = _FakeSConsEnv(
board_mcu="esp8266", pio_env="esphome_esp8266", pio_platform="espressif8266"
+7 -11
View File
@@ -13,6 +13,7 @@ import pytest
from esphome.core import EsphomeError, Library
import esphome.platformio.library as lib
from esphome.platformio.library import (
SOURCE_KIND_FOR_SUFFIX,
ConvertedLibrary,
GitSource,
InvalidLibrary,
@@ -23,6 +24,8 @@ from esphome.platformio.library import (
_resolve_registry_version,
check_library_data,
convert_libraries,
join_flag_args,
split_flag_entry,
)
@@ -539,7 +542,6 @@ def test_convert_libraries_skips_incompatible_dependency(tmp_path, monkeypatch):
def test_split_flag_entry_unbalanced_quote_is_clean() -> None:
"""A malformed flags entry raises EsphomeError, not a raw ValueError."""
from esphome.platformio.library import split_flag_entry
assert split_flag_entry('-DX="a b"', "library x") == ["-DX=a b"]
with pytest.raises(EsphomeError, match=r"Malformed build flag.*library x"):
@@ -548,7 +550,6 @@ def test_split_flag_entry_unbalanced_quote_is_clean() -> None:
def test_join_flag_args_reglues_spaced_define() -> None:
"""A spaced -D re-glues to its argument, as ParseFlags does."""
from esphome.platformio.library import join_flag_args
assert join_flag_args(["-D", "FOO=1", "-Os"], "x") == ["-DFOO=1", "-Os"]
@@ -556,7 +557,6 @@ def test_join_flag_args_reglues_spaced_define() -> None:
def test_join_flag_args_trailing_bare_flag_warns(
caplog: pytest.LogCaptureFixture,
) -> None:
from esphome.platformio.library import join_flag_args
assert join_flag_args(["-Os", "-l"], "library x") == ["-Os"]
assert "Ignoring trailing '-l'" in caplog.text
@@ -576,7 +576,6 @@ def test_lex_build_flags_dangling_flag_does_not_cross_entries(
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."""
from esphome.platformio.library import split_flag_entry
with pytest.raises(EsphomeError, match="Malformed build flag"):
split_flag_entry({"esp32": ["-DX"]}, "lib x")
@@ -587,7 +586,6 @@ def test_split_flag_entry_non_string_is_clean() -> None:
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)."""
from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX
assert set(SOURCE_KIND_FOR_SUFFIX.values()) == {"c", "cxx", "asm"}
assert SOURCE_KIND_FOR_SUFFIX[".s"] == "asm"
@@ -681,9 +679,8 @@ def test_walk_warns_for_nonplatform_invalid_library(
def test_versionless_owner_qualified_dependency_warns_despite_provides(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""The backend's provides() only covers owner-less names (the walk's
backend-provided skip has the same guard), so an owner-qualified
version-less dependency that nobody adds must still warn."""
"""An owner-qualified version-less dependency is not satisfied by
provides(); it must still warn."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
@@ -758,9 +755,8 @@ def test_versionless_url_ish_dependency_name_warns_cleanly(
def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
tmp_path, monkeypatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A bare dependency name satisfied by a component requested under an
owner-qualified spec (manifest names match) is not a drop; a nameless
entry is skipped without a reconciliation warning."""
"""A bare name satisfied by an owner-qualified component's manifest
name is not a drop."""
_patch_download_with_manifests(
monkeypatch,
tmp_path,
-2
View File
@@ -1051,8 +1051,6 @@ def test_clean_all_removes_global_arduino8266_install(
config_dir = tmp_path / "config"
config_dir.mkdir()
from esphome.writer import clean_all
with caplog.at_level("INFO"):
clean_all([str(config_dir)])