mirror of
https://github.com/esphome/esphome.git
synced 2026-10-01 00:40:21 +00:00
[host] Build natively with ninja instead of PlatformIO (#19214)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
co-authored by
J. Nick Koston
parent
bb7af14012
commit
32e11c4f4a
+15
-21
@@ -400,14 +400,9 @@ jobs:
|
||||
matrix:
|
||||
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
|
||||
env:
|
||||
# What the cache steps persist; libdeps is excluded (keyed per xdist
|
||||
# worker and env, it never crosses runs).
|
||||
INTEGRATION_PIO_CACHE_PATH: |
|
||||
~/.esphome-integration-tests/platformio/platforms
|
||||
~/.esphome-integration-tests/platformio/packages
|
||||
~/.esphome-integration-tests/platformio/appstate.json
|
||||
~/.esphome-integration-tests/platformio/.cache
|
||||
~/.esphome-integration-tests/platformio/.esphome.pio.stamp.json
|
||||
# Registry libraries (noise-c, libsodium, ArduinoJson, lvgl) the host builds
|
||||
# download, shared per xdist worker by tests/integration/conftest.py
|
||||
INTEGRATION_LIBRARY_CACHE_PATH: ~/.esphome-integration-tests/pio_components
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -427,14 +422,13 @@ jobs:
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Restore integration PlatformIO cache
|
||||
# Native platform + toolchain installed by shared_platformio_cache in
|
||||
# tests/integration/conftest.py; a miss self-heals, so no restore-keys.
|
||||
id: pio-cache
|
||||
- name: Restore integration library cache
|
||||
# A miss or a changed pin self-heals with a download, so no restore-keys
|
||||
id: library-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_PIO_CACHE_PATH }}
|
||||
key: integration-pio-v1-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'tests/integration/fixtures/cache_init.yaml', 'esphome/components/host/__init__.py') }}
|
||||
path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }}
|
||||
key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }}
|
||||
- name: Restore Python virtual environment
|
||||
id: cache-venv
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
@@ -502,16 +496,16 @@ jobs:
|
||||
# A full cron period of margin for the weekly refresh
|
||||
retention-days: 14
|
||||
- name: Print ccache statistics
|
||||
# esphome stores the PlatformIO ccache under the machine-global cache
|
||||
# dir (see _ccache_env() in esphome/platformio/toolchain.py).
|
||||
run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
|
||||
- name: Save integration PlatformIO cache
|
||||
# esphome stores the host build's ccache under the machine-global
|
||||
# cache dir (see get_build_env() in esphome/host/toolchain.py).
|
||||
run: CCACHE_DIR="$HOME/.cache/esphome/host/ccache" ccache -s
|
||||
- name: Save integration library cache
|
||||
# Bucket 0 only; the others would race the same immutable key.
|
||||
if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.pio-cache.outputs.cache-hit != 'true'
|
||||
if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ env.INTEGRATION_PIO_CACHE_PATH }}
|
||||
key: ${{ steps.pio-cache.outputs.cache-primary-key }}
|
||||
path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }}
|
||||
key: ${{ steps.library-cache.outputs.cache-primary-key }}
|
||||
|
||||
import-time:
|
||||
name: Check import esphome.__main__ time
|
||||
|
||||
+7
-16
@@ -1714,26 +1714,12 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if exit_code != 0:
|
||||
return exit_code
|
||||
if CORE.is_host:
|
||||
_LOGGER.info(
|
||||
"Successfully compiled program to path '%s'", _host_program_path(config)
|
||||
)
|
||||
_LOGGER.info("Successfully compiled program to path '%s'", CORE.firmware_bin)
|
||||
else:
|
||||
_LOGGER.info("Successfully compiled program.")
|
||||
return 0
|
||||
|
||||
|
||||
def _host_program_path(config: ConfigType) -> str:
|
||||
"""Return the compiled host ELF path."""
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
return str(toolchain.get_elf_path())
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
|
||||
# Memoized by compile_program's own call; this is a dict lookup
|
||||
return str(get_idedata(config).firmware_elf_path)
|
||||
|
||||
|
||||
def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
# Get devices, resolving special identifiers like OTA
|
||||
devices = choose_upload_log_host(
|
||||
@@ -1778,7 +1764,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
return exit_code
|
||||
_LOGGER.info("Successfully compiled program.")
|
||||
if CORE.is_host:
|
||||
program_path = _host_program_path(config)
|
||||
program_path = str(CORE.firmware_bin)
|
||||
_LOGGER.info("Running program from path '%s'", program_path)
|
||||
return run_external_process(program_path)
|
||||
|
||||
@@ -2024,6 +2010,11 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
CORE.target_platform,
|
||||
)
|
||||
return 1
|
||||
if (
|
||||
check_supported := getattr(analysis_toolchain, "check_analysis_supported", None)
|
||||
) is not None:
|
||||
# Raises with the reason; before the compile, not after it
|
||||
check_supported()
|
||||
|
||||
# Always compile to ensure fresh data (fast if no changes - just relinks)
|
||||
exit_code = write_cpp(config)
|
||||
|
||||
+33
-15
@@ -2,7 +2,9 @@
|
||||
|
||||
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.
|
||||
library builds its own archive; all include dirs join one global path. The
|
||||
host build reuses it without a framework tree: nothing is bundled there and
|
||||
every name resolves from the registry.
|
||||
|
||||
Deviations from PlatformIO: flat-layout libraries get the recursive default
|
||||
source filter; ``dot_a_linkage`` is honored; bundled libraries never run a
|
||||
@@ -342,13 +344,24 @@ def _check_unfulfilled_provides(
|
||||
|
||||
|
||||
def resolve_libraries(
|
||||
framework_path: Path, *, pio_platform: str, board_mcu: str, cache_key: str
|
||||
framework_path: Path | None,
|
||||
*,
|
||||
pio_platform: str,
|
||||
board_mcu: str,
|
||||
cache_key: str,
|
||||
framework: str | None = "arduino",
|
||||
manifest_optional: bool = False,
|
||||
) -> 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.
|
||||
shared converter's download cache. ``framework`` is the manifest
|
||||
framework token the compatibility check warns about; None skips it.
|
||||
A None ``framework_path`` means no core-bundled libraries exist (the
|
||||
host build): every name resolves from the registry.
|
||||
``manifest_optional`` accepts libraries without a manifest, built with
|
||||
PlatformIO's default layout.
|
||||
|
||||
The returned list is not topologically sorted, so the caller must link
|
||||
the archives inside one ``--start-group``/``--end-group`` pair (the
|
||||
@@ -359,18 +372,22 @@ 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()
|
||||
# Exact directory names keep membership case-sensitive everywhere
|
||||
# (an is_dir() probe would match "wire" on macOS/Windows and build
|
||||
# the bundled Wire twice)
|
||||
libraries_dir = framework_path / "libraries"
|
||||
if not libraries_dir.is_dir():
|
||||
# A registry fallback would fail later with a misleading
|
||||
# package-not-found error per bundled name
|
||||
raise EsphomeError(
|
||||
f"{libraries_dir} is missing; the framework install may be "
|
||||
"incomplete (run 'esphome clean-all')"
|
||||
bundled_dir_names: frozenset[str] = frozenset()
|
||||
if framework_path is not None:
|
||||
# Exact directory names keep membership case-sensitive everywhere
|
||||
# (an is_dir() probe would match "wire" on macOS/Windows and build
|
||||
# the bundled Wire twice)
|
||||
libraries_dir = framework_path / "libraries"
|
||||
if not libraries_dir.is_dir():
|
||||
# A registry fallback would fail later with a misleading
|
||||
# package-not-found error per 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()
|
||||
)
|
||||
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
|
||||
@@ -497,12 +514,13 @@ def resolve_libraries(
|
||||
|
||||
backend = LibraryBackend(
|
||||
platform=pio_platform,
|
||||
framework="arduino",
|
||||
framework=framework,
|
||||
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,
|
||||
manifest_optional=manifest_optional,
|
||||
)
|
||||
if external:
|
||||
convert_libraries(external, backend)
|
||||
|
||||
@@ -17,7 +17,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||
from esphome.build_helpers.ccache import ccache_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
|
||||
@@ -143,17 +143,5 @@ def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]:
|
||||
*filter(None, env.get("PATH", "").split(os.pathsep)),
|
||||
]
|
||||
env["PATH"] = os.pathsep.join(parts)
|
||||
env.update(ccache_env(ccache))
|
||||
env.update(ccache_env(ccache, ARDUINO8266_TOOLS_CACHE))
|
||||
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")
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from esphome.build_helpers.ccache import resolve_ccache_path
|
||||
from esphome.build_helpers.ccache import resolve_absolute_ccache_path
|
||||
from esphome.build_helpers.native import warn_ignored_platformio_options
|
||||
from esphome.build_helpers.ninja import refresh_compile_commands
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import write_file
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -28,20 +28,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_MAX_RAM_SIZE = 81920
|
||||
|
||||
|
||||
def _warn_ignored_platformio_options() -> None:
|
||||
"""Warn for component-added platformio options the native build drops."""
|
||||
from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
|
||||
consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
for key in sorted(CORE.platformio_options or {}):
|
||||
if key not in consumed:
|
||||
_LOGGER.warning(
|
||||
"platformio_options->%s is ignored when building with the "
|
||||
"native 'arduino' toolchain",
|
||||
key,
|
||||
)
|
||||
|
||||
|
||||
_RAM_SECTIONS = (".data", ".rodata", ".bss")
|
||||
_FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata")
|
||||
|
||||
@@ -84,21 +70,17 @@ def get_readelf_path() -> Path:
|
||||
def run_compile(config: ConfigType, verbose: bool) -> int:
|
||||
from esphome.arduino8266 import framework
|
||||
from esphome.build_gen import arduino8266 as build_gen
|
||||
from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
|
||||
|
||||
_warn_ignored_platformio_options()
|
||||
warn_ignored_platformio_options(NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS)
|
||||
paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
|
||||
# Resolved once: the probe is not free and three consumers need it
|
||||
ccache = resolve_ccache_path()
|
||||
build_gen.write_project(paths, ccache)
|
||||
ccache = resolve_absolute_ccache_path()
|
||||
ninja_changed = build_gen.write_project(paths, ccache)
|
||||
|
||||
build_dir = get_build_dir()
|
||||
env = framework.get_build_env(paths.toolchain, ccache)
|
||||
|
||||
# The DB is a pure function of build.ninja; regenerate when it is older
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
ninja_file = build_dir / "build.ninja"
|
||||
if not compdb.is_file() or compdb.stat().st_mtime < ninja_file.stat().st_mtime:
|
||||
_write_compile_commands(paths.ninja, build_dir, env)
|
||||
refresh_compile_commands(paths.ninja, build_dir, env, ninja_changed)
|
||||
|
||||
cmd = [str(paths.ninja)]
|
||||
if verbose:
|
||||
@@ -140,42 +122,6 @@ def run_compile(config: ConfigType, verbose: bool) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _write_compile_commands(
|
||||
ninja_path: Path, build_dir: Path, env: dict[str, str]
|
||||
) -> None:
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
result = subprocess.run(
|
||||
[str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Drop any stale database so consumers (IDE integration, clang-tidy,
|
||||
# the memory analyzer) can't silently read outdated data.
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}")
|
||||
try:
|
||||
entries = json.loads(result.stdout)
|
||||
except ValueError as err:
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
f"ninja produced an unparsable compile database: {err} "
|
||||
f"(output starts {result.stdout[:120]!r})"
|
||||
) from err
|
||||
if not entries:
|
||||
# compdb exits 0 with [] for unknown rule names; a renamed compile
|
||||
# rule must fail the build, not silently strand every consumer
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
"ninja produced an empty compile database; the generator's rule "
|
||||
"names no longer match"
|
||||
)
|
||||
write_file(compdb, result.stdout)
|
||||
|
||||
|
||||
def _parse_app_size(build_dir: Path, paths: InstalledPaths) -> int | None:
|
||||
"""Read the app flash budget (irom0_0_seg length) from the linker script."""
|
||||
from esphome.build_gen.arduino8266 import get_flash_ld_path
|
||||
@@ -253,7 +199,7 @@ def get_idedata(ccache: str | None = None) -> dict | None:
|
||||
|
||||
# A disabled ccache resolves to None without spawning anything, so
|
||||
# re-resolving here costs nothing when the caller has no answer
|
||||
launcher = ccache or resolve_ccache_path()
|
||||
launcher = ccache or resolve_absolute_ccache_path()
|
||||
return load_or_build_idedata(
|
||||
get_build_dir() / "compile_commands.json",
|
||||
get_elf_path(),
|
||||
|
||||
@@ -14,7 +14,6 @@ from the build flags with the same precedence as the PlatformIO builder.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import logging
|
||||
@@ -23,7 +22,6 @@ from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, NamedTuple
|
||||
|
||||
from esphome.arduino8266.framework import toolchain_tool
|
||||
@@ -32,6 +30,14 @@ from esphome.build_helpers.ninja import (
|
||||
quote_path as _q,
|
||||
shell_token as _shell_token,
|
||||
)
|
||||
from esphome.build_helpers.ninja_gen import (
|
||||
ar_rule_lines,
|
||||
collect_sources,
|
||||
compile_edges,
|
||||
compile_rule_lines,
|
||||
library_edges,
|
||||
tool_lines,
|
||||
)
|
||||
from esphome.components.esp8266 import build_surgery
|
||||
from esphome.components.esp8266.boards import (
|
||||
BOARDS,
|
||||
@@ -46,12 +52,9 @@ from esphome.components.esp8266.const import (
|
||||
KEY_SCANF_FLOAT,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import (
|
||||
get_project_cxx_compile_flags,
|
||||
strip_win_long_path_prefix,
|
||||
)
|
||||
from esphome.framework_helpers import get_project_cxx_compile_flags
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX, lex_build_flags
|
||||
from esphome.platformio.library import lex_build_flags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from esphome.arduino8266.framework import InstalledPaths
|
||||
@@ -643,14 +646,6 @@ _PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker")
|
||||
_PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=")
|
||||
|
||||
|
||||
def _collect_sources(root: Path, exclude: Collection[str] = frozenset()) -> list[Path]:
|
||||
return sorted(
|
||||
p
|
||||
for p in root.rglob("*")
|
||||
if p.suffix in SOURCE_KIND_FOR_SUFFIX and p.name not in exclude
|
||||
)
|
||||
|
||||
|
||||
def _stat_sig(path: Path) -> str:
|
||||
"""Size and mtime cache-stamp signature for one input file.
|
||||
|
||||
@@ -868,34 +863,6 @@ def _generate_testing_flash_ld(
|
||||
)
|
||||
|
||||
|
||||
def _ninja_compile_edges(
|
||||
lines: list[str],
|
||||
sources: list[Path],
|
||||
root: Path,
|
||||
group: str,
|
||||
flags: str = "",
|
||||
) -> list[str]:
|
||||
"""Emit compile edges for ``sources``; return the object paths."""
|
||||
objects = []
|
||||
for src in sources:
|
||||
rel = src.relative_to(root).as_posix()
|
||||
obj = f"obj/{group}/{rel}.o"
|
||||
escaped_obj = _e(obj)
|
||||
lines.append(
|
||||
f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}"
|
||||
)
|
||||
if flags:
|
||||
lines.append(f" flags = {flags}")
|
||||
# Escaped once here: the returned paths only ever appear in build
|
||||
# statements (archive/link inputs), which use ninja escaping.
|
||||
objects.append(escaped_obj)
|
||||
return objects
|
||||
|
||||
|
||||
def _common_parent(paths: list[Path]) -> Path:
|
||||
return Path(os.path.commonpath([str(p.parent) for p in paths]))
|
||||
|
||||
|
||||
class _FlagSets(NamedTuple):
|
||||
cflags: list[str]
|
||||
cxxflags: list[str]
|
||||
@@ -1082,48 +1049,14 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
+ ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"]
|
||||
)
|
||||
|
||||
build_tool = Path(__file__).parent / "build_tool.py"
|
||||
|
||||
# $in/$out stay unquoted: ninja escapes its built-in path variables
|
||||
# itself; only literal paths need _q().
|
||||
lines = [
|
||||
"# Auto-generated by ESPHome",
|
||||
"ninja_required_version = 1.5",
|
||||
f"cc = {_q(toolchain_tool(paths.toolchain, 'gcc'))}",
|
||||
f"cxx = {_q(toolchain_tool(paths.toolchain, 'g++'))}",
|
||||
# The NSIS launcher starts Python with a \\?\ extended-length path
|
||||
# that cmd.exe cannot spawn; same strip every other emitted binary
|
||||
# path gets
|
||||
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
|
||||
f"buildtool = {_q(build_tool)}",
|
||||
f"ccache = {_q(ccache) if ccache else ''}",
|
||||
"",
|
||||
# Rule names match SOURCE_KIND_FOR_SUFFIX values (c, cxx, asm, aspp)
|
||||
"rule c",
|
||||
" command = $ccache $cc -MMD -MF $out.d $cflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = CC $out",
|
||||
"rule cxx",
|
||||
" command = $ccache $cxx -MMD -MF $out.d $cxxflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = CXX $out",
|
||||
"rule aspp",
|
||||
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $asflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = AS $out",
|
||||
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
|
||||
# depfile and no $flags (defines/includes) either
|
||||
"rule asm",
|
||||
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
|
||||
" description = AS $out",
|
||||
"rule ar",
|
||||
f" command = $python $buildtool ar {_q(toolchain_tool(paths.toolchain, 'ar'))} $out $out.rsp",
|
||||
" rspfile = $out.rsp",
|
||||
" rspfile_content = $in_newline",
|
||||
" description = AR $out",
|
||||
*tool_lines(
|
||||
(toolchain_tool(paths.toolchain, "gcc"),),
|
||||
(toolchain_tool(paths.toolchain, "g++"),),
|
||||
ccache,
|
||||
),
|
||||
*compile_rule_lines(),
|
||||
*ar_rule_lines(toolchain_tool(paths.toolchain, "ar")),
|
||||
"rule link",
|
||||
" command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group",
|
||||
" rspfile = $out.rsp",
|
||||
@@ -1153,16 +1086,15 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
core_exclude |= _CORE_EXCLUDE_WAVEFORM
|
||||
|
||||
archives = []
|
||||
direct_objs: list[str] = []
|
||||
# variant_dir existence was already enforced with the include dirs
|
||||
variant_sources = _collect_sources(variant_dir)
|
||||
variant_sources = collect_sources(variant_dir)
|
||||
if variant_sources:
|
||||
objs = _ninja_compile_edges(lines, variant_sources, variant_dir, "variant")
|
||||
objs = compile_edges(lines, variant_sources, variant_dir, "variant")
|
||||
lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}")
|
||||
archives.append("libFrameworkArduinoVariant.a")
|
||||
|
||||
core_objs = _ninja_compile_edges(
|
||||
lines, _collect_sources(core_dir, core_exclude), core_dir, "core"
|
||||
core_objs = compile_edges(
|
||||
lines, collect_sources(core_dir, core_exclude), core_dir, "core"
|
||||
)
|
||||
if not core_objs:
|
||||
# An empty archive would link into a wall of undefined references
|
||||
@@ -1173,32 +1105,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
lines.append(f"build libFrameworkArduino.a: ar {' '.join(core_objs)}")
|
||||
archives.append("libFrameworkArduino.a")
|
||||
|
||||
for lib in libraries:
|
||||
if not lib.sources:
|
||||
# Header-only libraries are legitimate; the log makes an empty
|
||||
# srcFilter or broken tree traceable before link errors do.
|
||||
_LOGGER.debug(
|
||||
"Library %s has no source files; contributing includes only",
|
||||
lib.name,
|
||||
)
|
||||
continue
|
||||
lib_root = _common_parent(lib.sources)
|
||||
objs = _ninja_compile_edges(
|
||||
lines,
|
||||
lib.sources,
|
||||
lib_root,
|
||||
f"lib/{lib.name}",
|
||||
flags=" ".join(_shell_token(f) for f in lib.flags),
|
||||
)
|
||||
if not lib.lib_archive:
|
||||
# libArchive: false / dot_a_linkage=false: hand the objects to
|
||||
# the linker directly so unreferenced-but-required symbols
|
||||
# (exception handlers, weak overrides) survive
|
||||
direct_objs.extend(objs)
|
||||
continue
|
||||
archive = f"lib{lib.name}.a"
|
||||
lines.append(f"build {_e(archive)}: ar {' '.join(objs)}")
|
||||
archives.append(archive)
|
||||
lib_archives, direct_objs = library_edges(lines, libraries)
|
||||
archives += lib_archives
|
||||
|
||||
# One source of truth with the PlatformIO path: esp8266/__init__ pins
|
||||
# build_src_flags (the throw_stubs force-include); -include paths
|
||||
@@ -1221,8 +1129,8 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
|
||||
# One shared variable instead of repeating the flags line on every src
|
||||
# edge (hundreds of edges in a real project)
|
||||
lines.append(f"srcflags = {src_extra}")
|
||||
src_objs = _ninja_compile_edges(
|
||||
lines, _collect_sources(src_dir), src_dir, "src", flags="$srcflags"
|
||||
src_objs = compile_edges(
|
||||
lines, collect_sources(src_dir), src_dir, "src", flags="$srcflags"
|
||||
)
|
||||
|
||||
ld_deps = [f"ld/{_COMMON_LD_NAME}"]
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Native ninja build generator for the host platform.
|
||||
|
||||
Emits ``build.ninja`` under ``.pioenvs/<name>/``: every source in the
|
||||
generated ``src/`` tree plus the resolved registry libraries compiles with
|
||||
the machine's compiler and links into ``program``, the name PlatformIO's
|
||||
native platform produced. Build flags route the way SCons's ``ParseFlags``
|
||||
did under PlatformIO: ``-D``/``-I``/``-std=``/``-W`` shapes reach the
|
||||
compile lines only, ``-l``/``-L``/``-Wl,`` the link line only, everything
|
||||
else both.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from esphome.build_helpers.ninja import escape as _e, quote_path as _q, shell_token
|
||||
from esphome.build_helpers.ninja_gen import (
|
||||
PATH_ARG_FLAGS,
|
||||
Flag,
|
||||
anchor_path_flag,
|
||||
ar_rule_lines,
|
||||
collect_sources,
|
||||
compile_edges,
|
||||
compile_rule_lines,
|
||||
library_edges,
|
||||
tool_lines,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import get_project_cxx_compile_flags
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
from esphome.host.toolchain import PROGRAM_NAME, HostCompilers, find_tool, get_build_dir
|
||||
from esphome.platformio.library import lex_build_flags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from esphome.arduino.library import ArduinoLibrary
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The PlatformIO platform the host built under; registry manifests declare
|
||||
# compatibility against it, as lib_compat_mode=strict checked before
|
||||
PIO_PLATFORM = "native"
|
||||
# Namespaces the shared library download cache (pio_components/host/)
|
||||
LIBRARY_CACHE_KEY = "host"
|
||||
|
||||
# Flag shapes that only the compiler understands; dropped from the link line
|
||||
_COMPILE_ONLY_PREFIXES = ("-D", "-U", "-I", "-std=", "-W", *PATH_ARG_FLAGS)
|
||||
# Flag shapes that only the linker consumes; inert on a -c compile line
|
||||
_LINK_ONLY_PREFIXES = ("-l", "-L", "-Wl,")
|
||||
# Link-only flags whose argument is the next token
|
||||
_LINK_ONLY_ARG_FLAGS = ("-framework", "-Xlinker", "-z")
|
||||
|
||||
|
||||
def parse_flags(entries: Iterable[str], owner: str) -> list[Flag]:
|
||||
"""Lex build flag entries into flags, each with its argument.
|
||||
|
||||
Entries are a set, so their order is not the user's: a flag and the
|
||||
argument it takes as the next token must share one entry.
|
||||
"""
|
||||
flags: list[Flag] = []
|
||||
for entry in entries:
|
||||
it = iter(lex_build_flags(entry, owner))
|
||||
for tok in it:
|
||||
if tok not in PATH_ARG_FLAGS and tok not in _LINK_ONLY_ARG_FLAGS:
|
||||
flags.append((tok,))
|
||||
continue
|
||||
arg = next(it, None)
|
||||
# A path never starts with "-"; that is the next flag
|
||||
if arg is None or (tok in PATH_ARG_FLAGS and arg.startswith("-")):
|
||||
raise EsphomeError(
|
||||
f"{owner} build flags have '{tok}' with no argument; write "
|
||||
f"the flag and its argument as one entry"
|
||||
)
|
||||
flags.append((tok, arg))
|
||||
return flags
|
||||
|
||||
|
||||
def split_flags(flags: list[Flag]) -> tuple[list[Flag], list[Flag]]:
|
||||
"""Route build flags to the compile and link lines."""
|
||||
compile_flags: list[Flag] = []
|
||||
link_flags: list[Flag] = []
|
||||
for flag in flags:
|
||||
name = flag[0]
|
||||
if len(flag) > 1:
|
||||
(compile_flags if name in PATH_ARG_FLAGS else link_flags).append(flag)
|
||||
elif name.startswith(_LINK_ONLY_PREFIXES):
|
||||
# Checked before the compile prefixes: -Wl, would match -W
|
||||
link_flags.append(flag)
|
||||
elif name.startswith(_COMPILE_ONLY_PREFIXES):
|
||||
compile_flags.append(flag)
|
||||
else:
|
||||
# -g, -O, -f*, -m*, -pthread, --coverage: both lines, as SCons
|
||||
compile_flags.append(flag)
|
||||
link_flags.append(flag)
|
||||
return compile_flags, link_flags
|
||||
|
||||
|
||||
def _is_std(flag: Flag) -> bool:
|
||||
return flag[0].startswith("-std=")
|
||||
|
||||
|
||||
def _is_cxx_std(flag: Flag) -> bool:
|
||||
return _is_std(flag) and "++" in flag[0]
|
||||
|
||||
|
||||
def _anchored_flags(entries: Iterable[str], owner: str) -> list[Flag]:
|
||||
build_path = Path(CORE.build_path)
|
||||
return [
|
||||
anchor_path_flag(flag, build_path)
|
||||
for flag in parse_flags(sorted(entries), owner)
|
||||
]
|
||||
|
||||
|
||||
def _flag_lists() -> tuple[list[str], list[str], list[str]]:
|
||||
"""The C, C++, and link flag lists (raw tokens), build_unflags applied.
|
||||
|
||||
``cg.set_cpp_standard`` wins over any ``-std=`` in the build flags for
|
||||
C++ compiles, as PlatformIO's unflag of every other standard did; C
|
||||
compiles never see a C++ standard.
|
||||
"""
|
||||
# The funnel warns and drops empty glued arguments (-D "") itself
|
||||
compile_flags, link_flags = split_flags(
|
||||
_anchored_flags(CORE.build_flags, "esphome")
|
||||
)
|
||||
cflags = [f for f in compile_flags if not _is_cxx_std(f)]
|
||||
cxx_std = CORE.cpp_standard
|
||||
cxxflags = [f for f in compile_flags if not (cxx_std and _is_std(f))]
|
||||
if cxx_std:
|
||||
cxxflags.insert(0, (f"-std={cxx_std}",))
|
||||
cxxflags += [(tok,) for tok in get_project_cxx_compile_flags()]
|
||||
|
||||
# A flag is removed whole, with its argument, as PlatformIO did
|
||||
unflags = set(_anchored_flags(CORE.build_unflags, "esphome build_unflags"))
|
||||
# An unflag that hits nothing (a typo, or -DUSE_FOO against
|
||||
# -DUSE_FOO=1) must be visible, since the user believes the flag is
|
||||
# gone while it still drives the build
|
||||
if unmatched := sorted(unflags - set(cflags) - set(cxxflags) - set(link_flags)):
|
||||
_LOGGER.warning(
|
||||
"build_unflags entries matched no build flag: %s",
|
||||
", ".join(" ".join(flag) for flag in unmatched),
|
||||
)
|
||||
|
||||
def keep(flags: list[Flag]) -> list[str]:
|
||||
return [tok for flag in flags if flag not in unflags for tok in flag]
|
||||
|
||||
return keep(cflags), keep(cxxflags), keep(link_flags)
|
||||
|
||||
|
||||
def _resolve_host_libraries() -> list[ArduinoLibrary]:
|
||||
"""Every ``cg.add_library()`` entry, fetched from the registry.
|
||||
|
||||
The host has no framework, so nothing is bundled and no framework
|
||||
compatibility check applies; the platform check keeps the strict
|
||||
manifest gate PlatformIO's native platform enforced. Manifest-less
|
||||
libraries (a bare git checkout) build with PlatformIO's default
|
||||
layout, as they did under its native platform.
|
||||
"""
|
||||
if not CORE.platformio_libraries:
|
||||
return []
|
||||
from esphome.arduino.library import resolve_libraries
|
||||
|
||||
return resolve_libraries(
|
||||
None,
|
||||
pio_platform=PIO_PLATFORM,
|
||||
board_mcu="host",
|
||||
cache_key=LIBRARY_CACHE_KEY,
|
||||
framework=None,
|
||||
manifest_optional=True,
|
||||
)
|
||||
|
||||
|
||||
def _file_macro_maps(build_dir: Path) -> list[str]:
|
||||
"""Flags that keep ``__FILE__`` relative to the build path.
|
||||
|
||||
PlatformIO compiled ``src/x.cpp`` from the build path, and tools name
|
||||
things after that spelling (CodSpeed's benchmark ids). Here a source
|
||||
reaches the compiler by its absolute path, or relative to the build
|
||||
directory when ccache rewrites it.
|
||||
"""
|
||||
build_path = Path(CORE.build_path)
|
||||
prefixes = (build_path, Path(os.path.relpath(build_path, build_dir)))
|
||||
return [
|
||||
shell_token(f"-fmacro-prefix-map={prefix}{os.sep}=", force=True)
|
||||
for prefix in prefixes
|
||||
]
|
||||
|
||||
|
||||
def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
|
||||
"""Write the ninja build for the current configuration.
|
||||
|
||||
``ccache`` is the caller's already-resolved binary (None when disabled).
|
||||
Returns True when ``build.ninja`` changed, so the caller can skip work
|
||||
derived purely from it (the compile database) on unchanged builds.
|
||||
"""
|
||||
build_dir = get_build_dir()
|
||||
mkdir_p(build_dir)
|
||||
src_dir = CORE.relative_src_path()
|
||||
if not src_dir.is_dir():
|
||||
# Generated project state, not install state: clean-all would not help
|
||||
raise EsphomeError(f"Generated source directory {src_dir} is missing")
|
||||
|
||||
cflags, cxxflags, link_flags = _flag_lists()
|
||||
libraries = _resolve_host_libraries()
|
||||
|
||||
include_dirs = [src_dir]
|
||||
for lib in libraries:
|
||||
include_dirs += lib.include_dirs
|
||||
includes = [f"-I{_q(d)}" for d in include_dirs]
|
||||
includes += _file_macro_maps(build_dir)
|
||||
|
||||
# SCons's link line: $LINKFLAGS $SOURCES $_LIBDIRFLAGS $_LIBFLAGS, so
|
||||
# -L and -l trail the objects while every other link token leads
|
||||
lib_dirs = [Path(t[2:]) for t in link_flags if t.startswith("-L")]
|
||||
libs = [t for t in link_flags if t.startswith("-l")]
|
||||
linkflags = [shell_token(t) for t in link_flags if not t.startswith(("-L", "-l"))]
|
||||
for lib in libraries:
|
||||
lib_dirs += lib.link_dirs
|
||||
libs += [f"-l{name}" for name in lib.link_libs]
|
||||
linkflags += [shell_token(f) for f in lib.link_flags]
|
||||
|
||||
# PlatformIO's ASPPCOM passes only -D/-I user flags to assembly
|
||||
asflags = [t for t in cflags if t.startswith(("-D", "-I"))]
|
||||
|
||||
lines = [
|
||||
*tool_lines(compilers.cc, compilers.cxx, ccache),
|
||||
*compile_rule_lines(),
|
||||
"rule link",
|
||||
" command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags",
|
||||
" rspfile = $out.rsp",
|
||||
" rspfile_content = $in_newline",
|
||||
" description = LINK $out",
|
||||
]
|
||||
if any(lib.sources and lib.lib_archive for lib in libraries):
|
||||
# Resolved only when an archive is built, so a system without
|
||||
# binutils still links a library-free configuration
|
||||
lines += ar_rule_lines(find_tool("AR", ("ar",)))
|
||||
lines += [
|
||||
"",
|
||||
f"cflags = {' '.join([*map(shell_token, cflags), *includes])}",
|
||||
f"cxxflags = {' '.join([*map(shell_token, cxxflags), *includes])}",
|
||||
f"asflags = {' '.join([*map(shell_token, asflags), *includes])}",
|
||||
f"linkflags = {' '.join(linkflags)}",
|
||||
f"libdirflags = {' '.join(f'-L{_q(d)}' for d in lib_dirs)}",
|
||||
f"libflags = {' '.join(shell_token(lib) for lib in libs)}",
|
||||
"",
|
||||
]
|
||||
|
||||
archives, direct_objs = library_edges(lines, libraries)
|
||||
|
||||
src_objs = compile_edges(lines, collect_sources(src_dir), src_dir, "src")
|
||||
if not src_objs:
|
||||
raise EsphomeError(f"No source files found under {src_dir}")
|
||||
|
||||
# Archives are not topologically sorted; GNU ld needs the group to
|
||||
# resolve references between them. ld64 loads archives iteratively and
|
||||
# rejects the option, so macOS lists them bare.
|
||||
archive_tokens = [shell_token(a) for a in archives]
|
||||
if archive_tokens and sys.platform != "darwin":
|
||||
archive_tokens = ["-Wl,--start-group", *archive_tokens, "-Wl,--end-group"]
|
||||
lines.append(
|
||||
f"build {PROGRAM_NAME}: link {' '.join(src_objs + direct_objs)} | "
|
||||
f"{' '.join(_e(a) for a in archives)}"
|
||||
)
|
||||
lines.append(f" archives = {' '.join(archive_tokens)}")
|
||||
lines.append(f"default {PROGRAM_NAME}")
|
||||
lines.append("")
|
||||
|
||||
return write_file_if_changed(build_dir / "build.ninja", "\n".join(lines))
|
||||
@@ -68,6 +68,15 @@ def resolve_ccache_path() -> str | None:
|
||||
return ccache
|
||||
|
||||
|
||||
def resolve_absolute_ccache_path() -> str | None:
|
||||
"""``resolve_ccache_path`` for the ninja backends, which run their
|
||||
commands from the build directory, where a relative path is lost."""
|
||||
from esphome.build_helpers.ninja import absolute_tool
|
||||
|
||||
ccache = resolve_ccache_path()
|
||||
return absolute_tool(ccache) if ccache else None
|
||||
|
||||
|
||||
def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
"""Default ``CCACHE_*`` values for a build subprocess (not os.environ).
|
||||
|
||||
@@ -84,9 +93,24 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
"CORE.build_path must be set before constructing the build environment"
|
||||
)
|
||||
defaults = {
|
||||
"CCACHE_DIR": str(cache_dir),
|
||||
# ccache expands $VAR in its settings; $$ is a literal $
|
||||
"CCACHE_DIR": str(cache_dir).replace("$", "$$"),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()).replace("$", "$$"),
|
||||
}
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
|
||||
|
||||
def ccache_env(ccache: str | None, tools_cache: tuple[str, str]) -> dict[str, str]:
|
||||
"""The ccache settings for a build subprocess (not os.environ).
|
||||
|
||||
``ccache`` is the pre-resolved binary (resolve_ccache_path), or None when
|
||||
disabled; ``tools_cache`` is the backend's tools cache spec, which holds
|
||||
its ccache dir.
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
from esphome.build_helpers.tools_cache import tools_cache_path
|
||||
|
||||
return ccache_defaults_env(tools_cache_path(*tools_cache) / "ccache")
|
||||
|
||||
@@ -7,16 +7,21 @@ platform component package or the backend itself.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
import importlib
|
||||
import logging
|
||||
from types import ModuleType
|
||||
|
||||
from esphome.const import Toolchain
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Native build backend per (target platform, toolchain)
|
||||
NATIVE_TOOLCHAIN_MODULES = {
|
||||
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
|
||||
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
|
||||
("host", Toolchain.HOST): "esphome.host.toolchain",
|
||||
}
|
||||
|
||||
|
||||
@@ -50,3 +55,20 @@ def analysis_backend() -> ModuleType | None:
|
||||
return native
|
||||
module_path = ANALYSIS_TOOLCHAIN_MODULES.get((CORE.target_platform, CORE.toolchain))
|
||||
return importlib.import_module(module_path) if module_path else None
|
||||
|
||||
|
||||
def warn_ignored_platformio_options(consumed: Collection[str]) -> None:
|
||||
"""Warn for component-added platformio options a native build drops.
|
||||
|
||||
User-supplied keys were already routed or warned about by
|
||||
``core/config.py``; what survives into ``CORE.platformio_options`` came
|
||||
from ``cg.add_platformio_option`` calls in components.
|
||||
"""
|
||||
for key in sorted(CORE.platformio_options or {}):
|
||||
if key not in consumed:
|
||||
_LOGGER.warning(
|
||||
"platformio_options->%s is ignored when building with the "
|
||||
"native '%s' toolchain",
|
||||
key,
|
||||
CORE.toolchain.value,
|
||||
)
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
from esphome.helpers import write_file_if_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,11 +26,26 @@ def _ninja_runs(binary: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
# Compile rule names the generators emit; ninja's compdb tool is asked for
|
||||
# exactly these, so a renamed rule fails the build instead of stranding idedata
|
||||
COMPILE_RULES = ("c", "cxx", "aspp", "asm")
|
||||
|
||||
|
||||
def absolute_tool(tool: str | Path) -> str:
|
||||
"""A tool path that still resolves from the build directory.
|
||||
|
||||
``shutil.which`` returns a relative path for a relative PATH entry, and
|
||||
ninja runs the commands from ``.pioenvs/<name>``. Symlinks are kept:
|
||||
ccache's compiler links depend on the name they are called by.
|
||||
"""
|
||||
return strip_win_long_path_prefix(str(Path(tool).absolute()))
|
||||
|
||||
|
||||
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)
|
||||
binary = absolute_tool(binary)
|
||||
if _ninja_runs(binary):
|
||||
return Path(binary)
|
||||
import_error: ImportError | None = None
|
||||
@@ -49,8 +67,18 @@ def find_ninja() -> Path:
|
||||
|
||||
|
||||
def escape(value: Path | str) -> str:
|
||||
"""Escape a path or token for a ninja file."""
|
||||
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
|
||||
"""Escape a path or token for a ninja file.
|
||||
|
||||
ninja has no escape for ``|`` or a line break in a path, so those fail
|
||||
here by name instead of producing a build file ninja misreads.
|
||||
"""
|
||||
text = str(value)
|
||||
if bad := next((c for c in "|\r\n" if c in text), None):
|
||||
raise EsphomeError(
|
||||
f"Path {text!r} contains {bad!r}, which a ninja build file cannot "
|
||||
"express; rename the file or directory"
|
||||
)
|
||||
return text.replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
|
||||
|
||||
|
||||
def quote_arg(tok: str) -> str:
|
||||
@@ -90,3 +118,65 @@ def shell_token(tok: str, force: bool = False) -> str:
|
||||
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)
|
||||
|
||||
|
||||
def refresh_compile_commands(
|
||||
ninja_path: Path, build_dir: Path, env: dict[str, str], ninja_changed: bool
|
||||
) -> None:
|
||||
"""Regenerate the compile DB (a pure function of build.ninja) when stale.
|
||||
|
||||
Freshness rides a stamp: the DB itself is written through
|
||||
write_file_if_changed (its mtime feeds the idedata cache), so a
|
||||
regeneration with identical content would stay "stale" forever. An
|
||||
interrupted previous run may have rewritten the manifest without
|
||||
regenerating the DB, hence the mtime comparison.
|
||||
"""
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
compdb_stamp = build_dir / ".compile_commands.stamp"
|
||||
ninja_file = build_dir / "build.ninja"
|
||||
if (
|
||||
ninja_changed
|
||||
or not compdb.is_file()
|
||||
or not compdb_stamp.is_file()
|
||||
or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime
|
||||
):
|
||||
write_compile_commands(ninja_path, build_dir, env)
|
||||
compdb_stamp.touch()
|
||||
|
||||
|
||||
def write_compile_commands(
|
||||
ninja_path: Path, build_dir: Path, env: dict[str, str]
|
||||
) -> None:
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
result = subprocess.run(
|
||||
[str(ninja_path), "-C", str(build_dir), "-t", "compdb", *COMPILE_RULES],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
close_fds=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Drop any stale database so consumers (IDE integration, clang-tidy,
|
||||
# the memory analyzer) can't silently read outdated data
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}")
|
||||
try:
|
||||
entries = json.loads(result.stdout)
|
||||
except ValueError as err:
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
f"ninja produced an unparsable compile database: {err} "
|
||||
f"(output starts {result.stdout[:120]!r})"
|
||||
) from err
|
||||
if not entries:
|
||||
# compdb exits 0 with [] for unknown rule names; a renamed compile
|
||||
# rule must fail the build, not silently strand every consumer
|
||||
compdb.unlink(missing_ok=True)
|
||||
raise EsphomeError(
|
||||
"ninja produced an empty compile database; the generator's rule "
|
||||
"names no longer match"
|
||||
)
|
||||
# write_file_if_changed keeps the mtime stable on no-op builds so the
|
||||
# idedata cache stays valid
|
||||
write_file_if_changed(compdb, result.stdout)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""The parts of a ``build.ninja`` every native backend emits the same way.
|
||||
|
||||
Rule names match ``SOURCE_KIND_FOR_SUFFIX`` values (c, cxx, asm, aspp) and
|
||||
``ninja.COMPILE_RULES``, which the compile database is asked for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection, Sequence
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from esphome.build_helpers.ninja import (
|
||||
escape as _e,
|
||||
quote_path as _q,
|
||||
shell_token as _shell_token,
|
||||
)
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix
|
||||
from esphome.platformio.library import SOURCE_KIND_FOR_SUFFIX
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from esphome.arduino.library import ArduinoLibrary
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_BUILD_TOOL = Path(__file__).parent.parent / "build_gen" / "build_tool.py"
|
||||
|
||||
|
||||
def collect_sources(root: Path, exclude: Collection[str] = frozenset()) -> list[Path]:
|
||||
return sorted(
|
||||
p
|
||||
for p in root.rglob("*")
|
||||
if p.suffix in SOURCE_KIND_FOR_SUFFIX and p.name not in exclude
|
||||
)
|
||||
|
||||
|
||||
def common_parent(paths: list[Path]) -> Path:
|
||||
return Path(os.path.commonpath([str(p.parent) for p in paths]))
|
||||
|
||||
|
||||
def _command(words: Sequence[Path | str]) -> str:
|
||||
program, *args = words
|
||||
return " ".join([_q(program), *(_shell_token(str(a)) for a in args)])
|
||||
|
||||
|
||||
def tool_lines(
|
||||
cc: Sequence[Path | str], cxx: Sequence[Path | str], ccache: str | None
|
||||
) -> list[str]:
|
||||
"""The file header: the compilers and the helper tools as variables.
|
||||
|
||||
A compiler is its program followed by any arguments it always takes.
|
||||
"""
|
||||
return [
|
||||
"# Auto-generated by ESPHome",
|
||||
"ninja_required_version = 1.5",
|
||||
f"cc = {_command(cc)}",
|
||||
f"cxx = {_command(cxx)}",
|
||||
# The NSIS launcher starts Python with a \\?\ extended-length path
|
||||
# that cmd.exe cannot spawn; same strip every other emitted binary
|
||||
# path gets
|
||||
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
|
||||
f"buildtool = {_q(_BUILD_TOOL)}",
|
||||
f"ccache = {_q(ccache) if ccache else ''}",
|
||||
"",
|
||||
]
|
||||
|
||||
|
||||
def compile_rule_lines() -> list[str]:
|
||||
"""The compile rules; ``$own_includes`` is empty unless an edge sets it."""
|
||||
return [
|
||||
"rule c",
|
||||
" command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = CC $out",
|
||||
"rule cxx",
|
||||
" command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = CXX $out",
|
||||
"rule aspp",
|
||||
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
|
||||
" depfile = $out.d",
|
||||
" deps = gcc",
|
||||
" description = AS $out",
|
||||
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
|
||||
# depfile and no $flags (defines/includes) either
|
||||
"rule asm",
|
||||
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
|
||||
" description = AS $out",
|
||||
]
|
||||
|
||||
|
||||
def ar_rule_lines(ar: Path | str) -> list[str]:
|
||||
return [
|
||||
"rule ar",
|
||||
f" command = $python $buildtool ar {_q(ar)} $out $out.rsp",
|
||||
" rspfile = $out.rsp",
|
||||
" rspfile_content = $in_newline",
|
||||
" description = AR $out",
|
||||
]
|
||||
|
||||
|
||||
def compile_edges(
|
||||
lines: list[str],
|
||||
sources: list[Path],
|
||||
root: Path,
|
||||
group: str,
|
||||
flags: str = "",
|
||||
own_includes: str = "",
|
||||
) -> list[str]:
|
||||
"""Emit compile edges for ``sources``; return the object paths."""
|
||||
objects = []
|
||||
for src in sources:
|
||||
rel = src.relative_to(root).as_posix()
|
||||
obj = f"obj/{group}/{rel}.o"
|
||||
escaped_obj = _e(obj)
|
||||
lines.append(
|
||||
f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}"
|
||||
)
|
||||
if own_includes:
|
||||
lines.append(f" own_includes = {own_includes}")
|
||||
if flags:
|
||||
lines.append(f" flags = {flags}")
|
||||
# Escaped once here: the returned paths only ever appear in build
|
||||
# statements (archive/link inputs), which use ninja escaping
|
||||
objects.append(escaped_obj)
|
||||
return objects
|
||||
|
||||
|
||||
def library_edges(
|
||||
lines: list[str], libraries: list[ArduinoLibrary]
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Emit every library's compile and archive edges.
|
||||
|
||||
Returns the archive names and the objects that link directly. A
|
||||
library's own include dirs lead its compile lines, as PlatformIO searched
|
||||
them first: the include path is one global list, so another library's
|
||||
header of the same name would shadow them.
|
||||
"""
|
||||
archives: list[str] = []
|
||||
direct_objs: list[str] = []
|
||||
for lib in libraries:
|
||||
if not lib.sources:
|
||||
# Header-only libraries are legitimate; the log makes an empty
|
||||
# srcFilter or broken tree traceable before link errors do
|
||||
_LOGGER.debug(
|
||||
"Library %s has no source files; contributing includes only",
|
||||
lib.name,
|
||||
)
|
||||
continue
|
||||
objs = compile_edges(
|
||||
lines,
|
||||
lib.sources,
|
||||
common_parent(lib.sources),
|
||||
f"lib/{lib.name}",
|
||||
flags=" ".join(_shell_token(f) for f in lib.flags),
|
||||
own_includes=" ".join(f"-I{_q(d)}" for d in lib.include_dirs),
|
||||
)
|
||||
if not lib.lib_archive:
|
||||
# libArchive: false / dot_a_linkage=false: hand the objects to
|
||||
# the linker directly so unreferenced-but-required symbols
|
||||
# (exception handlers, weak overrides) survive
|
||||
direct_objs.extend(objs)
|
||||
continue
|
||||
archive = f"lib{lib.name}.a"
|
||||
lines.append(f"build {_e(archive)}: ar {' '.join(objs)}")
|
||||
archives.append(archive)
|
||||
return archives, direct_objs
|
||||
|
||||
|
||||
# One build flag: the flag, plus its argument when that is a separate token
|
||||
Flag = tuple[str, ...]
|
||||
|
||||
# Flags whose path operand is the next token; gcc also takes it glued on
|
||||
PATH_ARG_FLAGS = ("-include", "-imacros", "-isystem", "-iquote", "-idirafter")
|
||||
# Flags whose path operand is glued on
|
||||
PATH_PREFIXES = ("-I", "-L", *PATH_ARG_FLAGS)
|
||||
|
||||
|
||||
def _anchor(path: str, base: Path) -> str:
|
||||
if not path or Path(path).is_absolute():
|
||||
return path
|
||||
return str(base / path)
|
||||
|
||||
|
||||
def anchor_path_flag(flag: Flag, base: Path) -> Flag:
|
||||
"""Anchor a flag's relative path operand at ``base``.
|
||||
|
||||
PlatformIO ran the compiler from the build path; ninja runs it from
|
||||
``.pioenvs/<name>``, where a relative operand would point elsewhere.
|
||||
"""
|
||||
name, *args = flag
|
||||
if args:
|
||||
if name in PATH_ARG_FLAGS:
|
||||
return (name, _anchor(args[0], base))
|
||||
return flag
|
||||
for prefix in PATH_PREFIXES:
|
||||
if name.startswith(prefix):
|
||||
return (prefix + _anchor(name[len(prefix) :], base),)
|
||||
return flag
|
||||
@@ -33,4 +33,11 @@ def tools_cache_path(env_var: str, subdir: str) -> Path:
|
||||
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)
|
||||
# The host backend installs nothing; the entry only holds its ccache dir
|
||||
HOST_TOOLS_CACHE = ("ESPHOME_HOST_PREFIX", "host")
|
||||
TOOLS_CACHE_SPECS = (
|
||||
IDF_TOOLS_CACHE,
|
||||
SDK_NRF_TOOLS_CACHE,
|
||||
ARDUINO8266_TOOLS_CACHE,
|
||||
HOST_TOOLS_CACHE,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import sys
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -8,9 +10,9 @@ from esphome.const import (
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_HOST,
|
||||
ThreadModel,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .const import KEY_HOST
|
||||
@@ -37,7 +39,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address,
|
||||
}
|
||||
),
|
||||
cv.require_platformio_toolchain("host"),
|
||||
# The host builds with the machine's own compiler through ninja; there
|
||||
# is no PlatformIO backend, so a CLI --toolchain must name this one
|
||||
cv.resolve_toolchain("host", (Toolchain.HOST,), Toolchain.HOST),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
@@ -48,16 +52,23 @@ async def to_code(config: ConfigType) -> None:
|
||||
# The prefs file finds stored preferences by key, so key migration is possible
|
||||
cg.add_define("USE_PREFERENCE_KEY_LOOKUP")
|
||||
cg.add_define("USE_ESPHOME_HOST_MAC_ADDRESS", config[CONF_MAC_ADDRESS].parts)
|
||||
cg.add_build_flag("-std=gnu++20")
|
||||
cg.set_cpp_standard("gnu++20")
|
||||
cg.add_define("ESPHOME_BOARD", "host")
|
||||
cg.add_define("ESPHOME_VARIANT", "HOST")
|
||||
cg.add_define(ThreadModel.MULTI_ATOMICS)
|
||||
cg.add_platformio_option("platform", "platformio/native")
|
||||
cg.add_platformio_option("lib_ldf_mode", "off")
|
||||
cg.add_platformio_option("lib_compat_mode", "strict")
|
||||
cg.add_platformio_option("extra_scripts", ["pre:ccache.py"])
|
||||
|
||||
|
||||
# Called by writer.py
|
||||
def copy_files() -> None:
|
||||
copy_ccache_script()
|
||||
# Called by __main__.compile_program; True means this platform built the
|
||||
# program itself instead of falling through to the PlatformIO toolchain.
|
||||
def run_compile(args: object, config: ConfigType) -> bool:
|
||||
from esphome.host import toolchain
|
||||
|
||||
if sys.platform == "win32":
|
||||
# Here, not in validation: `esphome config` must keep working
|
||||
raise EsphomeError(
|
||||
"The host platform uses POSIX interfaces and does not build on "
|
||||
"Windows; use WSL or a Linux or macOS machine"
|
||||
)
|
||||
if toolchain.run_compile(config, CORE.verbose) != 0:
|
||||
raise EsphomeError("Host build failed")
|
||||
return True
|
||||
|
||||
+3
-1
@@ -23,12 +23,14 @@ class Toolchain(StrEnum):
|
||||
SDK_NRF = "sdk-nrf"
|
||||
# ESP8266: the Arduino core built directly (no PlatformIO)
|
||||
ARDUINO = "arduino"
|
||||
# host: the system C/C++ compiler driven by ninja (no PlatformIO)
|
||||
HOST = "host"
|
||||
|
||||
|
||||
# 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})
|
||||
NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST})
|
||||
|
||||
|
||||
class Platform(StrEnum):
|
||||
|
||||
@@ -996,6 +996,12 @@ class EsphomeCore:
|
||||
``using_arduino``, which is the target framework)."""
|
||||
return self.toolchain == Toolchain.ARDUINO
|
||||
|
||||
@property
|
||||
def using_toolchain_host(self):
|
||||
"""The native host build toolchain: the system compiler driven by
|
||||
ninja (the only toolchain the host platform serves)."""
|
||||
return self.toolchain == Toolchain.HOST
|
||||
|
||||
@property
|
||||
def using_native_toolchain(self):
|
||||
"""Whether the selected toolchain builds natively, without reading
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Native (PlatformIO-free) build support for the host platform.
|
||||
|
||||
Builds the generated sources with the machine's own C/C++ compiler through a
|
||||
ninja file and drives the build directly -- the host equivalent of
|
||||
``esphome.espidf``. Nothing is downloaded: the compiler comes from PATH and
|
||||
registry libraries go through the shared library converter.
|
||||
|
||||
Deliberately importable without the host component to avoid circular
|
||||
imports; the component wires these modules in via lazy imports.
|
||||
"""
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Native host build driver (the PlatformIO ``run`` equivalent).
|
||||
|
||||
The compiler and binutils come from PATH (``CC``/``CXX``/``AR``/``OBJDUMP``/
|
||||
``READELF`` override the lookup, like make and CMake), ninja from PATH or
|
||||
the ninja PyPI wheel, and ccache is used when found. The build lives under
|
||||
``.pioenvs/<name>/`` so ``CORE.firmware_bin`` and the clean paths stay the
|
||||
ones the PlatformIO build used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import NamedTuple
|
||||
|
||||
from esphome.build_helpers.ccache import ccache_env, resolve_absolute_ccache_path
|
||||
from esphome.build_helpers.native import warn_ignored_platformio_options
|
||||
from esphome.build_helpers.ninja import (
|
||||
absolute_tool,
|
||||
find_ninja,
|
||||
refresh_compile_commands,
|
||||
)
|
||||
from esphome.build_helpers.tools_cache import HOST_TOOLS_CACHE
|
||||
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# The output name PlatformIO's native platform produced; CORE.firmware_bin
|
||||
# and the integration-test harness resolve it by this name
|
||||
PROGRAM_NAME = "program"
|
||||
|
||||
# platformio_options keys the host build reads (lib_ignore feeds the library
|
||||
# converter); anything else has no native equivalent and is warned about
|
||||
CONSUMED_PIO_OPTIONS = frozenset({"lib_ignore"})
|
||||
|
||||
|
||||
class HostCompilers(NamedTuple):
|
||||
"""The resolved C and C++ compiler commands: the program, then any
|
||||
arguments its override carried (``CC="gcc -m32"``)."""
|
||||
|
||||
cc: tuple[str, ...]
|
||||
cxx: tuple[str, ...]
|
||||
|
||||
|
||||
def find_command(env_var: str, candidates: tuple[str, ...]) -> tuple[str, ...]:
|
||||
"""Resolve a build tool: ``env_var`` when set, else the first candidate
|
||||
found on PATH. Returns the program and the override's arguments.
|
||||
|
||||
An override that does not resolve fails by name rather than falling back
|
||||
silently to a different compiler than the user asked for.
|
||||
"""
|
||||
if override := os.environ.get(env_var, "").strip():
|
||||
program, *args = shlex.split(override)
|
||||
if Path(program).stem == "ccache" and args:
|
||||
# The build adds ccache itself; twice would hide the compiler
|
||||
program, *args = args
|
||||
# which() accepts an absolute path as well as a bare program name
|
||||
if (resolved := shutil.which(program)) is None:
|
||||
raise EsphomeError(
|
||||
f"{env_var}={override!r} does not name a runnable program"
|
||||
)
|
||||
return (absolute_tool(resolved), *args)
|
||||
for name in candidates:
|
||||
if (found := shutil.which(name)) is not None:
|
||||
return (absolute_tool(found),)
|
||||
raise EsphomeError(
|
||||
f"{candidates[0]} not found on PATH (tried {', '.join(candidates)}); "
|
||||
f"install it or set {env_var}"
|
||||
)
|
||||
|
||||
|
||||
def find_tool(env_var: str, candidates: tuple[str, ...]) -> str:
|
||||
"""Resolve a build tool that is called as a bare program."""
|
||||
program, *args = find_command(env_var, candidates)
|
||||
if args:
|
||||
raise EsphomeError(f"{env_var} must name a program without arguments")
|
||||
return program
|
||||
|
||||
|
||||
def find_compilers() -> HostCompilers:
|
||||
"""The C and C++ compilers the build uses (gcc first, as PlatformIO did)."""
|
||||
return HostCompilers(
|
||||
cc=find_command("CC", ("gcc", "clang", "cc")),
|
||||
cxx=find_command("CXX", ("g++", "clang++", "c++")),
|
||||
)
|
||||
|
||||
|
||||
def get_build_dir() -> Path:
|
||||
return CORE.relative_pioenvs_path(CORE.name)
|
||||
|
||||
|
||||
def get_elf_path() -> Path:
|
||||
return get_build_dir() / PROGRAM_NAME
|
||||
|
||||
|
||||
def get_objdump_path() -> Path:
|
||||
return Path(find_tool("OBJDUMP", ("objdump",)))
|
||||
|
||||
|
||||
def get_readelf_path() -> Path:
|
||||
return Path(find_tool("READELF", ("readelf",)))
|
||||
|
||||
|
||||
def check_analysis_supported() -> None:
|
||||
"""Refuse analyze-memory where the program is not an ELF file.
|
||||
|
||||
Called before the compile, so an unsupported machine fails at once.
|
||||
"""
|
||||
if sys.platform != "linux":
|
||||
raise EsphomeError(
|
||||
"analyze-memory reads ELF files; the host build on "
|
||||
f"{sys.platform} produces a different format"
|
||||
)
|
||||
|
||||
|
||||
def get_build_env(ccache: str | None) -> dict[str, str]:
|
||||
return {**os.environ, **ccache_env(ccache, HOST_TOOLS_CACHE)}
|
||||
|
||||
|
||||
def run_compile(config: ConfigType, verbose: bool) -> int:
|
||||
from esphome.build_gen import host as build_gen
|
||||
|
||||
warn_ignored_platformio_options(CONSUMED_PIO_OPTIONS)
|
||||
# Probe the cheap local dependencies before resolving libraries
|
||||
ninja_path = find_ninja()
|
||||
compilers = find_compilers()
|
||||
# Resolved once per build: the resolution probes PATH and spawns the
|
||||
# runnability check, and three consumers need the same answer
|
||||
ccache = resolve_absolute_ccache_path()
|
||||
ninja_changed = build_gen.write_project(compilers, ccache)
|
||||
|
||||
build_dir = get_build_dir()
|
||||
env = get_build_env(ccache)
|
||||
refresh_compile_commands(ninja_path, build_dir, env, ninja_changed)
|
||||
|
||||
cmd = [str(ninja_path)]
|
||||
if verbose:
|
||||
cmd.append("-v")
|
||||
if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT):
|
||||
cmd += ["-j", str(jobs)]
|
||||
# The explicit target, not the default statement: a generator defect
|
||||
# that drops it fails loudly with "unknown target" instead of a green
|
||||
# no-op run that leaves a stale program in place
|
||||
cmd.append(PROGRAM_NAME)
|
||||
|
||||
_LOGGER.debug("Running: %s", " ".join(cmd))
|
||||
# cwd instead of -C also drops the "Entering directory" banner
|
||||
rc = subprocess.run(
|
||||
cmd, cwd=build_dir, env=env, check=False, close_fds=False
|
||||
).returncode
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
elf = get_elf_path()
|
||||
if not elf.is_file():
|
||||
# ninja refused a manifest missing the target above; this covers a
|
||||
# rule that ran but wrote elsewhere
|
||||
_LOGGER.error("Build produced no %s", elf)
|
||||
return 1
|
||||
|
||||
from esphome.build_helpers.idedata import warn_if_idedata_missing
|
||||
|
||||
warn_if_idedata_missing(lambda: _load_idedata(ccache))
|
||||
return 0
|
||||
|
||||
|
||||
def get_idedata() -> dict | None:
|
||||
"""Derive idedata from the build's compile_commands.json.
|
||||
|
||||
Same contract as ``espidf.toolchain.get_idedata``: the fields IDE
|
||||
integrations, clang-tidy, and the memory analyzer expect. Returns None
|
||||
when nothing has been built yet.
|
||||
"""
|
||||
# Deliberately uncached: env/PATH can change between builds in a
|
||||
# long-lived host process
|
||||
return _load_idedata(resolve_absolute_ccache_path())
|
||||
|
||||
|
||||
def _load_idedata(ccache: str | None) -> dict | None:
|
||||
from esphome.build_helpers.idedata import load_or_build_idedata
|
||||
|
||||
return load_or_build_idedata(
|
||||
get_build_dir() / "compile_commands.json",
|
||||
get_elf_path(),
|
||||
CORE.relative_internal_path("idedata", f"{CORE.name}.json"),
|
||||
# The compile DB's commands carry the same ccache prefix the ninja
|
||||
# rules were generated with
|
||||
launcher=ccache,
|
||||
)
|
||||
@@ -360,7 +360,7 @@ class LibraryBackend:
|
||||
"""
|
||||
|
||||
platform: str | None
|
||||
framework: str
|
||||
framework: str | None
|
||||
emit: Callable[["ConvertedLibrary"], None]
|
||||
cache_key: str
|
||||
# Owner-less names this returns True for are skipped by the walk;
|
||||
@@ -368,6 +368,10 @@ class LibraryBackend:
|
||||
# reconciles provided_requests after resolving
|
||||
provides: Callable[[str], bool] | None = None
|
||||
provided_requests: set[str] = field(default_factory=set)
|
||||
# Accept a library without library.json/library.properties, as
|
||||
# PlatformIO does (its defaults: src/ or the root, plus include/). Off
|
||||
# for backends whose emitted build files need the manifest.
|
||||
manifest_optional: bool = False
|
||||
|
||||
|
||||
def ensure_list[T](obj: T | list[T]) -> list[T]:
|
||||
@@ -859,6 +863,11 @@ def _url_or_none(value: Any) -> str | None:
|
||||
return value if parsed.scheme and parsed.netloc else None
|
||||
|
||||
|
||||
# URL schemes that name a local library folder; symlink:// is PlatformIO's
|
||||
# spelling for one it links instead of copying, which is the same to us
|
||||
_LOCAL_SCHEMES = ("file", "symlink")
|
||||
|
||||
|
||||
def _node_key(
|
||||
name: str | None, version: str | None, repository: str | None
|
||||
) -> tuple[str, str, tuple[str | None, str | None]]:
|
||||
@@ -896,7 +905,7 @@ def _node_key(
|
||||
scheme = urlsplit(candidate).scheme
|
||||
except ValueError:
|
||||
scheme = ""
|
||||
if scheme == "file" or _url_or_none(candidate):
|
||||
if scheme in _LOCAL_SCHEMES or _url_or_none(candidate):
|
||||
name, repository = custom_name, candidate
|
||||
else:
|
||||
# Anything with ``://`` was meant to be a URL; failing it fast
|
||||
@@ -905,7 +914,7 @@ def _node_key(
|
||||
if repository:
|
||||
is_git_prefixed = repository.startswith("git+")
|
||||
split_result = urlsplit(repository.removeprefix("git+"))
|
||||
if split_result.scheme == "file" and not is_git_prefixed:
|
||||
if (scheme := split_result.scheme) in _LOCAL_SCHEMES and not is_git_prefixed:
|
||||
# A plain file:// URL points at a local library directory. A local
|
||||
# file URL is written file:///absolute/path (empty host) or, less
|
||||
# commonly, file://localhost/path. Anything else -- a real host, or
|
||||
@@ -913,8 +922,8 @@ def _node_key(
|
||||
# rejected rather than silently resolved to the wrong directory.
|
||||
if split_result.netloc not in ("", "localhost"):
|
||||
raise RuntimeError(
|
||||
f"Unsupported host in file:// library URL '{repository}'; "
|
||||
"use an absolute path, e.g. file:///path/to/lib"
|
||||
f"Unsupported host in {scheme}:// library URL '{repository}'; "
|
||||
f"use an absolute path, e.g. {scheme}:///path/to/lib"
|
||||
)
|
||||
# Validate the URL path itself (always POSIX-style, leading slash),
|
||||
# not the OS path: on Windows a "/foo" path is not is_absolute()
|
||||
@@ -924,8 +933,8 @@ def _node_key(
|
||||
url_path = split_result.path
|
||||
if not url_path.startswith("/") or not PurePosixPath(url_path).name:
|
||||
raise RuntimeError(
|
||||
f"file:// library URL '{repository}' must be an absolute "
|
||||
"directory path, e.g. file:///path/to/lib"
|
||||
f"{scheme}:// library URL '{repository}' must be an absolute "
|
||||
f"directory path, e.g. {scheme}:///path/to/lib"
|
||||
)
|
||||
path = url2pathname(url_path)
|
||||
return (name or PurePosixPath(url_path).name), "local", (path, None)
|
||||
@@ -1244,9 +1253,16 @@ def convert_libraries(
|
||||
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:
|
||||
if (
|
||||
not has_json
|
||||
and not has_properties
|
||||
and not node.is_local
|
||||
and not backend.manifest_optional
|
||||
):
|
||||
# An interrupted clone/extraction self-heals with one forced
|
||||
# re-download; a local source has nothing to re-download
|
||||
# re-download; a local source has nothing to re-download.
|
||||
# A backend accepting manifest-less libraries cannot tell
|
||||
# one from a torn download and would re-fetch every build.
|
||||
_LOGGER.warning(
|
||||
"Library %s at %s is missing library.json and library.properties; "
|
||||
"re-downloading",
|
||||
@@ -1260,6 +1276,12 @@ def convert_libraries(
|
||||
component.data = parse_library_json(library_json_path)
|
||||
elif has_properties:
|
||||
component.data = parse_library_properties(library_properties_path)
|
||||
elif backend.manifest_optional:
|
||||
_LOGGER.debug(
|
||||
"Library %s has no manifest; using PlatformIO's default layout",
|
||||
key,
|
||||
)
|
||||
component.data = {"name": component.name}
|
||||
else:
|
||||
# Local sources are user input (EsphomeError); a registry/git
|
||||
# miss means a corrupt cache (RuntimeError)
|
||||
@@ -1286,7 +1308,7 @@ def convert_libraries(
|
||||
# cross-platform skip stays at debug, other causes warn
|
||||
if key in top_level_keys:
|
||||
reason = (
|
||||
f"is not compatible with {backend.framework}"
|
||||
f"is not compatible with {backend.framework or backend.platform}"
|
||||
if isinstance(e, IncompatiblePlatform)
|
||||
else "has a malformed manifest"
|
||||
)
|
||||
|
||||
@@ -155,7 +155,7 @@ class StorageJSON:
|
||||
self.framework = framework
|
||||
# The core platform of this firmware. Like "esp32", "rp2", "host" etc.
|
||||
self.core_platform = core_platform
|
||||
# The toolchain used for the build ("platformio" / "esp-idf")
|
||||
# The toolchain used for the build ("platformio" / "esp-idf" / "host")
|
||||
self.toolchain = toolchain
|
||||
# The area of the node
|
||||
self.area = area
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ smpclient==7.2.0
|
||||
requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.11.12 # native esp-idf toolchain global cache dir
|
||||
ninja==1.13.2 # native esp8266 arduino toolchain build driver
|
||||
ninja==1.13.2 # native host and esp8266 arduino toolchain build driver
|
||||
filelock==4.0.1 # 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
|
||||
|
||||
+10
-11
@@ -22,8 +22,8 @@ from esphome.__main__ import command_compile, parse_args
|
||||
from esphome.config import validate_config
|
||||
from esphome.const import CONF_PLATFORM
|
||||
from esphome.core import CORE
|
||||
from esphome.host.toolchain import get_elf_path
|
||||
from esphome.loader import get_component, get_platform
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
from tests.testing_helpers import ComponentManifestOverride, set_testing_manifest
|
||||
|
||||
# This must coincide with the version in /platformio.ini
|
||||
@@ -220,7 +220,7 @@ def create_host_config(
|
||||
friendly_name: str,
|
||||
libraries: str | list[str],
|
||||
includes: list[str],
|
||||
platformio_options: dict,
|
||||
build_flags: list[str],
|
||||
) -> dict:
|
||||
"""Create an ESPHome host configuration for C++ builds.
|
||||
|
||||
@@ -229,7 +229,7 @@ def create_host_config(
|
||||
friendly_name: Human-readable name
|
||||
libraries: PlatformIO library specification(s)
|
||||
includes: List of include folders for the build
|
||||
platformio_options: Dict of platformio_options to set
|
||||
build_flags: Compiler/linker flags for the build
|
||||
|
||||
Returns:
|
||||
Configuration dict for ESPHome
|
||||
@@ -239,7 +239,7 @@ def create_host_config(
|
||||
"name": config_name,
|
||||
"friendly_name": friendly_name,
|
||||
"libraries": libraries,
|
||||
"platformio_options": platformio_options,
|
||||
"build_flags": build_flags,
|
||||
"includes": includes,
|
||||
},
|
||||
HOST_KEY: {},
|
||||
@@ -403,13 +403,12 @@ def compile_and_get_binary(
|
||||
return EXIT_COMPILE_ERROR, None
|
||||
|
||||
# After a successful compilation, locate the executable:
|
||||
idedata = get_idedata(config)
|
||||
if idedata is None:
|
||||
program_path = get_elf_path()
|
||||
if not program_path.is_file():
|
||||
print("Cannot find executable")
|
||||
return EXIT_NO_EXECUTABLE, None
|
||||
|
||||
program_path: str = idedata.raw["prog_path"]
|
||||
return EXIT_OK, program_path
|
||||
return EXIT_OK, str(program_path)
|
||||
|
||||
|
||||
def build_and_run(
|
||||
@@ -419,7 +418,7 @@ def build_and_run(
|
||||
config_prefix: str,
|
||||
friendly_name: str,
|
||||
libraries: str | list[str],
|
||||
platformio_options: dict,
|
||||
build_flags: list[str],
|
||||
main_entry: str,
|
||||
label: str = "build",
|
||||
build_only: bool = False,
|
||||
@@ -438,7 +437,7 @@ def build_and_run(
|
||||
config_prefix: Prefix for the config name (e.g. "cpptests", "cppbench")
|
||||
friendly_name: Human-readable name for the config
|
||||
libraries: PlatformIO library specification(s)
|
||||
platformio_options: PlatformIO options dict
|
||||
build_flags: Compiler/linker flags for the build
|
||||
main_entry: Name of the main entry file (e.g. "main.cpp")
|
||||
label: Label for log messages
|
||||
build_only: If True, print binary path and return without running
|
||||
@@ -488,7 +487,7 @@ def build_and_run(
|
||||
config_name: str = f"{config_prefix}-" + hash_components(components)
|
||||
|
||||
config = create_host_config(
|
||||
config_name, friendly_name, libraries, includes, platformio_options
|
||||
config_name, friendly_name, libraries, includes, build_flags
|
||||
)
|
||||
|
||||
exit_code, program_path = compile_and_get_binary(
|
||||
|
||||
+11
-19
@@ -25,19 +25,14 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core"
|
||||
# allow benchmarks to compile on the host platform.
|
||||
STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs"
|
||||
|
||||
PLATFORMIO_OPTIONS = {
|
||||
"build_flags": [
|
||||
"-Os", # match firmware optimization level (detects inlining regressions)
|
||||
"-g", # debug symbols for profiling
|
||||
"-ffunction-sections", # required for dead-code stripping with -Os
|
||||
"-fdata-sections", # required for dead-code stripping with -Os
|
||||
"-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish()
|
||||
f"-I{STUBS_DIR}", # stub headers for ESP32-only components
|
||||
],
|
||||
# Use deep+ LDF mode to ensure PlatformIO detects the benchmark
|
||||
# library dependency from nested includes.
|
||||
"lib_ldf_mode": "deep+",
|
||||
}
|
||||
BUILD_FLAGS = [
|
||||
"-Os", # match firmware optimization level (detects inlining regressions)
|
||||
"-g", # debug symbols for profiling
|
||||
"-ffunction-sections", # required for dead-code stripping with -Os
|
||||
"-fdata-sections", # required for dead-code stripping with -Os
|
||||
"-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish()
|
||||
f"-I{STUBS_DIR}", # stub headers for ESP32-only components
|
||||
]
|
||||
|
||||
|
||||
def run_benchmarks(selected_components: list[str], build_only: bool = False) -> int:
|
||||
@@ -46,7 +41,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) ->
|
||||
# containing {"lib_path": "/path/to/google_benchmark"}.
|
||||
lib_config_json = os.environ.get("BENCHMARK_LIB_CONFIG")
|
||||
|
||||
pio_options = PLATFORMIO_OPTIONS
|
||||
build_flags = BUILD_FLAGS
|
||||
if lib_config_json:
|
||||
lib_config = json.loads(lib_config_json)
|
||||
benchmark_lib = f"benchmark=symlink://{lib_config['lib_path']}"
|
||||
@@ -61,10 +56,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) ->
|
||||
"-DCODSPEED_ANALYSIS",
|
||||
f'-DCODSPEED_ROOT_DIR=\\"{project_root}\\"',
|
||||
]
|
||||
pio_options = {
|
||||
**PLATFORMIO_OPTIONS,
|
||||
"build_flags": PLATFORMIO_OPTIONS["build_flags"] + codspeed_flags,
|
||||
}
|
||||
build_flags = BUILD_FLAGS + codspeed_flags
|
||||
else:
|
||||
benchmark_lib = PLATFORMIO_GOOGLE_BENCHMARK_LIB
|
||||
|
||||
@@ -77,7 +69,7 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) ->
|
||||
config_prefix="cppbench",
|
||||
friendly_name="CPP Benchmarks",
|
||||
libraries=benchmark_lib,
|
||||
platformio_options=pio_options,
|
||||
build_flags=build_flags,
|
||||
main_entry="main.cpp",
|
||||
label="benchmarks",
|
||||
build_only=build_only,
|
||||
|
||||
+11
-19
@@ -15,24 +15,16 @@ from helpers import get_all_components, root_path
|
||||
# Path to /tests/components
|
||||
COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components"
|
||||
|
||||
PLATFORMIO_OPTIONS = {
|
||||
"build_type": "debug",
|
||||
"build_unflags": [
|
||||
"-Os", # remove size-opt flag
|
||||
],
|
||||
"build_flags": [
|
||||
"-Og", # optimize for debug
|
||||
"-DESPHOME_DEBUG", # enable debug assertions
|
||||
# Enable the address and undefined behavior sanitizers
|
||||
"-fsanitize=address",
|
||||
"-fsanitize=undefined",
|
||||
"-fno-omit-frame-pointer",
|
||||
],
|
||||
"debug_build_flags": [ # only for debug builds
|
||||
"-g3", # max debug info
|
||||
"-ggdb3",
|
||||
],
|
||||
}
|
||||
BUILD_FLAGS = [
|
||||
"-Og", # optimize for debug
|
||||
"-g3", # max debug info
|
||||
"-ggdb3",
|
||||
"-DESPHOME_DEBUG", # enable debug assertions
|
||||
# Enable the address and undefined behavior sanitizers
|
||||
"-fsanitize=address",
|
||||
"-fsanitize=undefined",
|
||||
"-fno-omit-frame-pointer",
|
||||
]
|
||||
|
||||
|
||||
def run_tests(selected_components: list[str]) -> int:
|
||||
@@ -47,7 +39,7 @@ def run_tests(selected_components: list[str]) -> int:
|
||||
config_prefix="cpptests",
|
||||
friendly_name="CPP Unit Tests",
|
||||
libraries=PLATFORMIO_GOOGLE_TEST_LIB,
|
||||
platformio_options=PLATFORMIO_OPTIONS,
|
||||
build_flags=BUILD_FLAGS,
|
||||
main_entry="main.cpp",
|
||||
label="unit tests",
|
||||
)
|
||||
|
||||
@@ -111,14 +111,25 @@ INTEGRATION_TESTS_SPLIT_THRESHOLD = 10
|
||||
INTEGRATION_TESTS_SPLIT_BUCKETS = 5
|
||||
INTEGRATION_TESTS_TARGET_BUCKET_WEIGHT = 360.0
|
||||
|
||||
# platformio and aioesphomeapi (requirements.txt), the pytest stack
|
||||
# (requirements_test.txt) and the fixture every session compiles; a change
|
||||
# to any runs the full matrix
|
||||
# aioesphomeapi (requirements.txt), the pytest stack (requirements_test.txt)
|
||||
# and the native host build backend every test compiles with; a change to
|
||||
# any runs the full matrix
|
||||
INTEGRATION_TESTS_TRIGGER_FILES = frozenset(
|
||||
{
|
||||
"requirements.txt",
|
||||
"requirements_test.txt",
|
||||
"tests/integration/fixtures/cache_init.yaml",
|
||||
"esphome/arduino/library.py",
|
||||
"esphome/build_gen/build_tool.py",
|
||||
"esphome/build_gen/host.py",
|
||||
"esphome/build_helpers/ccache.py",
|
||||
"esphome/build_helpers/idedata.py",
|
||||
"esphome/build_helpers/native.py",
|
||||
"esphome/build_helpers/ninja.py",
|
||||
"esphome/build_helpers/ninja_gen.py",
|
||||
"esphome/build_helpers/tools_cache.py",
|
||||
"esphome/framework_helpers.py",
|
||||
"esphome/host/toolchain.py",
|
||||
"esphome/platformio/library.py",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -240,7 +251,7 @@ def determine_integration_tests(branch: str | None = None) -> tuple[bool, list[s
|
||||
- conftest.py, types.py, const.py, entity_utils.py, state_utils.py, etc.
|
||||
|
||||
4. A file in INTEGRATION_TESTS_TRIGGER_FILES changed
|
||||
- The dependency pins and the session init fixture affect every test
|
||||
- The dependency pins and the host build backend affect every test
|
||||
|
||||
Returns (run_all=False, [test_files...]) when:
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
@@ -35,7 +34,7 @@ from esphome.helpers import (
|
||||
write_file,
|
||||
write_file_if_changed,
|
||||
)
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
from esphome.host.toolchain import get_elf_path
|
||||
|
||||
from .const import (
|
||||
API_CONNECTION_TIMEOUT,
|
||||
@@ -82,16 +81,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
INTEGRATION_TESTS_ROOT = Path.home() / ".esphome-integration-tests"
|
||||
|
||||
|
||||
def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
"""Get environment variables for PlatformIO with shared cache."""
|
||||
def _get_build_env() -> dict[str, str]:
|
||||
"""Environment for an ``esphome compile`` subprocess."""
|
||||
env = os.environ.copy()
|
||||
env["PLATFORMIO_CORE_DIR"] = str(cache_dir)
|
||||
env["PLATFORMIO_CACHE_DIR"] = str(cache_dir / ".cache")
|
||||
# libdeps is keyed only by env name (the device name), and fixtures share
|
||||
# names; two xdist workers first-compiling the same name race pio pkg
|
||||
# install in the same directory. Keep libdeps per worker.
|
||||
worker = os.environ.get("PYTEST_XDIST_WORKER", "master")
|
||||
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker)
|
||||
# Prevent cache cleaning during integration tests
|
||||
env["ESPHOME_SKIP_CLEAN_BUILD"] = "1"
|
||||
# Cap each compile's -j so several xdist workers do not each spawn a
|
||||
@@ -110,53 +102,41 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
return env
|
||||
|
||||
|
||||
# Registry libraries (noise-c, lvgl, ...) download into <data_dir>/pio_components,
|
||||
# and every integration test gets its own data dir. Share one download cache
|
||||
# per xdist worker instead: the converter has no cross-process lock, so
|
||||
# workers must never write the same dir, while a worker's later tests (and
|
||||
# later runs) reuse what it fetched.
|
||||
LIBRARY_CACHE_ROOT = INTEGRATION_TESTS_ROOT / "pio_components"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def shared_platformio_cache() -> Generator[Path]:
|
||||
"""Initialize a shared PlatformIO cache for all integration tests."""
|
||||
# Use a dedicated directory for integration tests to avoid conflicts.
|
||||
test_cache_dir = INTEGRATION_TESTS_ROOT
|
||||
cache_dir = test_cache_dir / "platformio"
|
||||
def shared_library_cache() -> Path:
|
||||
"""This worker's shared registry-library download cache."""
|
||||
worker = os.environ.get("PYTEST_XDIST_WORKER", "master")
|
||||
# Keyed by checkout too: two sessions on one machine share worker ids
|
||||
cache_dir = LIBRARY_CACHE_ROOT / _REPO_KEY / worker
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
# Use a lock file in the home directory to ensure only one process initializes the cache
|
||||
# This is needed when running with pytest-xdist
|
||||
# The lock file must be in a directory that already exists to avoid race conditions
|
||||
lock_file = Path.home() / ".esphome-integration-tests-init.lock"
|
||||
|
||||
# Always acquire the lock to ensure cache is ready before proceeding
|
||||
with lock_file.open("w") as lock_fd:
|
||||
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
|
||||
|
||||
# Check if the native platform is installed (the actual indicator of a populated cache)
|
||||
native_platform = cache_dir / "platforms" / "native"
|
||||
if not native_platform.exists():
|
||||
# Create the test cache directory if it doesn't exist
|
||||
test_cache_dir.mkdir(exist_ok=True)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Use the cache_init fixture for initialization
|
||||
init_dir = Path(tmpdir)
|
||||
fixture_path = Path(__file__).parent / "fixtures" / "cache_init.yaml"
|
||||
config_path = init_dir / "cache_init.yaml"
|
||||
config_path.write_text(
|
||||
fixture_path.read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
|
||||
# Run compilation to populate the cache
|
||||
# We must succeed here to avoid race conditions where multiple
|
||||
# tests try to populate the same cache directory simultaneously
|
||||
env = _get_platformio_env(cache_dir)
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "esphome", "compile", str(config_path)],
|
||||
check=True,
|
||||
cwd=init_dir,
|
||||
env=env,
|
||||
close_fds=False,
|
||||
)
|
||||
|
||||
# Lock is held until here, ensuring cache is fully populated before any test proceeds
|
||||
|
||||
yield cache_dir
|
||||
def _link_library_cache(config_dir: Path, cache_dir: Path) -> None:
|
||||
"""Point a config dir's library download cache at the shared one
|
||||
(blocking, run in executor)."""
|
||||
data_dir = config_dir / ".esphome"
|
||||
data_dir.mkdir(exist_ok=True)
|
||||
link = data_dir / "pio_components"
|
||||
if link.is_symlink():
|
||||
if link.resolve() == cache_dir.resolve():
|
||||
return
|
||||
# A shared build dir was linked by another worker; writing through
|
||||
# it would put two workers in one cache
|
||||
link.unlink()
|
||||
elif link.exists():
|
||||
# A real dir from a run predating the shared cache; nothing in it is
|
||||
# worth more than a re-download
|
||||
rmtree(link)
|
||||
link.symlink_to(cache_dir, target_is_directory=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
@@ -238,18 +218,17 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s
|
||||
content = content.replace("api:", f"api:\n port: {unused_tcp_port}")
|
||||
|
||||
# Add debug build flags for integration tests to enable assertions
|
||||
if "esphome:" in content and "platformio_options:" not in content:
|
||||
# Add platformio_options with debug flags after esphome:
|
||||
if "esphome:" in content and "build_flags:" not in content:
|
||||
# Add build_flags with debug flags after esphome:
|
||||
content = content.replace(
|
||||
"esphome:",
|
||||
"esphome:\n"
|
||||
" # Enable assertions for integration tests\n"
|
||||
" platformio_options:\n"
|
||||
" build_flags:\n"
|
||||
' - "-DDEBUG" # Enable assert() statements\n'
|
||||
' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n'
|
||||
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
|
||||
' - "-g" # Add debug symbols',
|
||||
" build_flags:\n"
|
||||
' - "-DDEBUG" # Enable assert() statements\n'
|
||||
' - "-DESPHOME_DEBUG" # Enable ESPHOME_DEBUG_ASSERT checks\n'
|
||||
' - "-DESPHOME_DEBUG_API" # Enable API protocol asserts\n'
|
||||
' - "-g" # Add debug symbols',
|
||||
)
|
||||
|
||||
# Replace external component path placeholder if present
|
||||
@@ -283,8 +262,8 @@ async def write_yaml_config(
|
||||
yield _write_config
|
||||
|
||||
|
||||
# Deliberately not CI-cached (ci.yml caches only platformio/ subpaths); stale
|
||||
# dirs for a fixture are pruned when its content hash changes.
|
||||
# Deliberately not CI-cached; stale dirs for a fixture are pruned when its
|
||||
# content hash changes.
|
||||
SHARED_BUILDS_ROOT = INTEGRATION_TESTS_ROOT / "builds"
|
||||
|
||||
# In the dir name (not just the hash) so pruning stays inside this checkout
|
||||
@@ -470,8 +449,7 @@ def _resolve_compiled_binary(config_path: Path) -> Path:
|
||||
)
|
||||
if config is None:
|
||||
raise RuntimeError(f"Failed to read config from {config_path}")
|
||||
idedata = get_idedata(config)
|
||||
binary_path = Path(idedata.firmware_elf_path)
|
||||
binary_path = get_elf_path()
|
||||
if not binary_path.exists():
|
||||
raise RuntimeError(f"Compiled binary not found at {binary_path}")
|
||||
return binary_path
|
||||
@@ -480,19 +458,22 @@ def _resolve_compiled_binary(config_path: Path) -> Path:
|
||||
@pytest_asyncio.fixture
|
||||
async def compile_esphome(
|
||||
integration_test_dir: Path,
|
||||
shared_platformio_cache: Path,
|
||||
shared_library_cache: Path,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> AsyncGenerator[CompileFunction]:
|
||||
"""Compile an ESPHome configuration and return the binary path."""
|
||||
|
||||
async def _compile(config_path: Path) -> Path:
|
||||
# Use the shared PlatformIO cache for faster compilation
|
||||
# This avoids re-downloading dependencies for each test
|
||||
env = _get_platformio_env(shared_platformio_cache)
|
||||
env = _get_build_env()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
name = _shared_yaml_name(request)
|
||||
if name is None:
|
||||
# Share the library download cache so a test never re-fetches
|
||||
# what an earlier one already pulled from the registry
|
||||
await loop.run_in_executor(
|
||||
None, _link_library_cache, integration_test_dir, shared_library_cache
|
||||
)
|
||||
await _run_esphome_compile(config_path, integration_test_dir, env)
|
||||
return await loop.run_in_executor(
|
||||
None, _resolve_compiled_binary, config_path
|
||||
@@ -568,6 +549,9 @@ async def compile_esphome(
|
||||
await loop.run_in_executor(
|
||||
None, write_file_if_changed, shared_config, content
|
||||
)
|
||||
await loop.run_in_executor(
|
||||
None, _link_library_cache, shared_dir, shared_library_cache
|
||||
)
|
||||
await _run_esphome_compile(shared_config, shared_dir, env)
|
||||
if built is None or not built.exists():
|
||||
built = await loop.run_in_executor(
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
esphome:
|
||||
name: api-action-timeout-test
|
||||
# Use a short timeout for testing (500ms instead of 30s)
|
||||
platformio_options:
|
||||
build_flags:
|
||||
- "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500"
|
||||
build_flags:
|
||||
- "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500"
|
||||
|
||||
host:
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
esphome:
|
||||
name: cache-init
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: "IIevImVI42I0FGos5nLqFK91jrJehrgidI0ArwMLr8w="
|
||||
|
||||
logger:
|
||||
@@ -1,8 +1,7 @@
|
||||
esphome:
|
||||
name: fnv1-hash-object-id-test
|
||||
platformio_options:
|
||||
build_flags:
|
||||
- "-DDEBUG"
|
||||
build_flags:
|
||||
- "-DDEBUG"
|
||||
on_boot:
|
||||
- lambda: |-
|
||||
using esphome::fnv1_hash_object_id;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
esphome:
|
||||
name: fnv1a-hash-test
|
||||
platformio_options:
|
||||
build_flags:
|
||||
- "-DDEBUG"
|
||||
build_flags:
|
||||
- "-DDEBUG"
|
||||
on_boot:
|
||||
- lambda: |-
|
||||
using esphome::fnv1a_hash;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
esphome:
|
||||
name: micros-to-millis-test
|
||||
platformio_options:
|
||||
build_flags:
|
||||
- "-DDEBUG"
|
||||
build_flags:
|
||||
- "-DDEBUG"
|
||||
on_boot:
|
||||
- lambda: |-
|
||||
using esphome::micros_to_millis;
|
||||
|
||||
@@ -2143,10 +2143,7 @@ def test_no_orphan_integration_fixtures() -> None:
|
||||
fixtures_dir = (Path(__file__).parent.parent / "integration" / "fixtures").resolve()
|
||||
fixtures = list(fixtures_dir.glob("*.yaml"))
|
||||
assert fixtures, f"no fixtures found under {fixtures_dir}"
|
||||
# cache_init is covered via INTEGRATION_TESTS_TRIGGER_FILES instead
|
||||
orphans = [
|
||||
f.stem for f in fixtures if f.stem != "cache_init" and f.stem not in mapping
|
||||
]
|
||||
orphans = [f.stem for f in fixtures if f.stem not in mapping]
|
||||
assert not orphans, f"fixtures invisible to CI test selection: {orphans}"
|
||||
|
||||
|
||||
|
||||
@@ -604,7 +604,7 @@ def test_write_project_libraries_and_variant(
|
||||
)
|
||||
_set_flags("-DPIO_FRAMEWORK_ARDUINO_ENABLE_EXCEPTIONS")
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.arduino8266"):
|
||||
with caplog.at_level(logging.DEBUG, logger="esphome.build_helpers.ninja_gen"):
|
||||
content = _write_ninja(
|
||||
paths, libraries=[library, headers_only], ccache="/cc/ccache"
|
||||
)
|
||||
@@ -616,6 +616,9 @@ def test_write_project_libraries_and_variant(
|
||||
assert "libHeadersOnly.a" not in content
|
||||
assert "Library HeadersOnly has no source files" in caplog.text
|
||||
assert " flags = -DMYLIB=1" in content
|
||||
# A library's own include dirs lead its compile lines
|
||||
assert " own_includes = -I" in content
|
||||
assert "$own_includes $cxxflags $flags" in content
|
||||
assert "-lalgobsec" in content
|
||||
# Library link flags reach the firmware link line; .cc compiles as C++
|
||||
assert "-Wl,--wrap=malloc" in content
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Tests for esphome.build_gen.host (the host ninja generator)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.arduino.library import ArduinoLibrary
|
||||
from esphome.build_gen import host as build_gen
|
||||
from esphome.build_helpers.ninja import escape as _e, quote_path as _q
|
||||
from esphome.build_helpers.ninja_gen import Flag
|
||||
from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_HOST
|
||||
from esphome.core import CORE, EsphomeError, Library
|
||||
from esphome.host.toolchain import PROGRAM_NAME, HostCompilers
|
||||
|
||||
COMPILERS = HostCompilers(cc=("/usr/bin/gcc", "-m32"), cxx=("/usr/bin/g++",))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _core(tmp_path: Path) -> None:
|
||||
CORE.build_path = tmp_path
|
||||
CORE.name = "dev"
|
||||
CORE.cpp_standard = "gnu++20"
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST}
|
||||
|
||||
|
||||
def _make_src(tmp_path: Path, *names: str) -> Path:
|
||||
src = tmp_path / "src"
|
||||
for name in names:
|
||||
path = src / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("")
|
||||
return src
|
||||
|
||||
|
||||
def _render(ccache: str | None = None) -> tuple[bool, str]:
|
||||
changed = build_gen.write_project(COMPILERS, ccache)
|
||||
ninja = CORE.build_path / ".pioenvs" / "dev" / "build.ninja"
|
||||
return changed, ninja.read_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entries", "compile_flags", "link_flags"),
|
||||
[
|
||||
(
|
||||
["-DUSE_HOST", "-Iinc", "-Ufoo", "-Wall"],
|
||||
[("-DUSE_HOST",), ("-Iinc",), ("-Ufoo",), ("-Wall",)],
|
||||
[],
|
||||
),
|
||||
(["-std=gnu++20"], [("-std=gnu++20",)], []),
|
||||
(
|
||||
["-lssl", "-L/opt/lib", "-Wl,--gc-sections"],
|
||||
[],
|
||||
[("-lssl",), ("-L/opt/lib",), ("-Wl,--gc-sections",)],
|
||||
),
|
||||
# A link-only flag travels with its argument
|
||||
(
|
||||
["-Xlinker --wrap=malloc", "-z noexecstack", "-framework Cocoa"],
|
||||
[],
|
||||
[
|
||||
("-Xlinker", "--wrap=malloc"),
|
||||
("-z", "noexecstack"),
|
||||
("-framework", "Cocoa"),
|
||||
],
|
||||
),
|
||||
# Both lines, as SCons routes unclassified flags
|
||||
(
|
||||
["-g -O2", "-fsanitize=address", "-pthread", "--coverage"],
|
||||
[
|
||||
("-g",),
|
||||
("-O2",),
|
||||
("-fsanitize=address",),
|
||||
("-pthread",),
|
||||
("--coverage",),
|
||||
],
|
||||
[
|
||||
("-g",),
|
||||
("-O2",),
|
||||
("-fsanitize=address",),
|
||||
("-pthread",),
|
||||
("--coverage",),
|
||||
],
|
||||
),
|
||||
# A flag with a path argument travels with it, glued on or not
|
||||
(
|
||||
["-include pre.h", "-isystem /x", "-isystem/y", "-iquotez"],
|
||||
[("-include", "pre.h"), ("-isystem", "/x"), ("-isystem/y",), ("-iquotez",)],
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_split_flags(
|
||||
entries: list[str], compile_flags: list[Flag], link_flags: list[Flag]
|
||||
) -> None:
|
||||
flags = build_gen.parse_flags(entries, "esphome")
|
||||
assert build_gen.split_flags(flags) == (compile_flags, link_flags)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entries",
|
||||
[
|
||||
# The flag is the last token of its entry
|
||||
["-g -include"],
|
||||
# Entries are a set: the next entry is not this flag's argument
|
||||
["-include", "pre.h"],
|
||||
["-framework", "-pthread"],
|
||||
# A path never starts with "-"
|
||||
["-include -pthread"],
|
||||
],
|
||||
)
|
||||
def test_parse_flags_refuses_a_flag_without_its_argument(entries: list[str]) -> None:
|
||||
with pytest.raises(EsphomeError, match="with no argument; write the flag"):
|
||||
build_gen.parse_flags(entries, "esphome")
|
||||
|
||||
|
||||
def test_parse_flags_accepts_a_linker_argument_that_is_a_flag() -> None:
|
||||
assert build_gen.parse_flags(["-Xlinker --wrap=malloc"], "esphome") == [
|
||||
("-Xlinker", "--wrap=malloc")
|
||||
]
|
||||
|
||||
|
||||
def test_flag_lists_route_the_standard() -> None:
|
||||
"""cpp_standard wins for C++; C never sees a C++ standard."""
|
||||
CORE.build_flags = {
|
||||
"-DUSE_HOST",
|
||||
"-Iinc",
|
||||
"-Wl,-x",
|
||||
"-g",
|
||||
"-lssl",
|
||||
"-std=c++17",
|
||||
"-std=gnu17",
|
||||
}
|
||||
CORE.cxx_build_flags = {"-Wno-volatile"}
|
||||
cflags, cxxflags, link_flags = build_gen._flag_lists()
|
||||
# The relative include is anchored at the build path
|
||||
inc = f"-I{CORE.build_path / 'inc'}"
|
||||
assert cflags == ["-DUSE_HOST", inc, "-g", "-std=gnu17"]
|
||||
assert cxxflags == ["-std=gnu++20", "-DUSE_HOST", inc, "-g", "-Wno-volatile"]
|
||||
assert link_flags == ["-Wl,-x", "-g", "-lssl"]
|
||||
|
||||
|
||||
def test_flag_lists_without_cpp_standard_keeps_user_std() -> None:
|
||||
CORE.cpp_standard = None
|
||||
CORE.build_flags = {"-std=c++17"}
|
||||
cflags, cxxflags, _link = build_gen._flag_lists()
|
||||
assert cflags == []
|
||||
assert cxxflags == ["-std=c++17"]
|
||||
|
||||
|
||||
def test_flag_lists_apply_unflags(caplog: pytest.LogCaptureFixture) -> None:
|
||||
CORE.build_flags = {"-g", "-DUSE_HOST"}
|
||||
CORE.build_unflags = {"-g", "-Onope"}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cflags, cxxflags, link_flags = build_gen._flag_lists()
|
||||
assert "-g" not in cflags + cxxflags + link_flags
|
||||
assert "-DUSE_HOST" in cflags
|
||||
assert "build_unflags entries matched no build flag: -Onope" in caplog.text
|
||||
|
||||
|
||||
def test_resolve_host_libraries_without_libraries() -> None:
|
||||
with patch("esphome.arduino.library.resolve_libraries") as resolve:
|
||||
assert build_gen._resolve_host_libraries() == []
|
||||
resolve.assert_not_called()
|
||||
|
||||
|
||||
def test_resolve_host_libraries_is_framework_less() -> None:
|
||||
CORE.add_library(Library(name="lvgl/lvgl", version="9.5.0"))
|
||||
lib = ArduinoLibrary(name="lvgl")
|
||||
with patch(
|
||||
"esphome.arduino.library.resolve_libraries", return_value=[lib]
|
||||
) as resolve:
|
||||
assert build_gen._resolve_host_libraries() == [lib]
|
||||
resolve.assert_called_once_with(
|
||||
None,
|
||||
pio_platform="native",
|
||||
board_mcu="host",
|
||||
cache_key="host",
|
||||
framework=None,
|
||||
manifest_optional=True,
|
||||
)
|
||||
|
||||
|
||||
def test_write_project_requires_generated_sources(tmp_path: Path) -> None:
|
||||
with pytest.raises(EsphomeError, match="Generated source directory .* is missing"):
|
||||
build_gen.write_project(COMPILERS, None)
|
||||
_make_src(tmp_path, "esphome.h")
|
||||
with pytest.raises(EsphomeError, match="No source files found"):
|
||||
build_gen.write_project(COMPILERS, None)
|
||||
|
||||
|
||||
def test_write_project_emits_every_source_kind(tmp_path: Path) -> None:
|
||||
src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S", "y.s", "h.h")
|
||||
CORE.build_flags = {"-DUSE_HOST", "-g"}
|
||||
changed, ninja = _render(ccache="/usr/bin/ccache")
|
||||
assert changed is True
|
||||
# A compiler override's arguments follow the program
|
||||
assert f"cc = {_q('/usr/bin/gcc')} -m32\n" in ninja
|
||||
assert f"cxx = {_q('/usr/bin/g++')}" in ninja
|
||||
assert f"ccache = {_q('/usr/bin/ccache')}" in ninja
|
||||
assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}" in ninja
|
||||
assert (
|
||||
f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}" in ninja
|
||||
)
|
||||
assert "build obj/src/x.S.o: aspp " in ninja
|
||||
assert "build obj/src/y.s.o: asm " in ninja
|
||||
assert "h.h" not in ninja
|
||||
# __FILE__ stays relative to the build path, as it was under PlatformIO,
|
||||
# whether the source arrives absolute or rewritten by ccache
|
||||
sep = os.sep
|
||||
maps = (
|
||||
f"{_q(f'-fmacro-prefix-map={tmp_path}{sep}=')} "
|
||||
f"{_q(f'-fmacro-prefix-map=..{sep}..{sep}=')}"
|
||||
)
|
||||
assert f"cflags = -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja
|
||||
assert f"cxxflags = -std=gnu++20 -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja
|
||||
# Assembly gets the defines and includes only
|
||||
assert f"asflags = -DUSE_HOST -I{_q(src)} {maps}\n" in ninja
|
||||
assert "linkflags = -g\n" in ninja
|
||||
assert "libdirflags = \n" in ninja
|
||||
assert "libflags = \n" in ninja
|
||||
assert "rule ar" not in ninja
|
||||
assert (
|
||||
f"build {PROGRAM_NAME}: link obj/src/esphome/core/a.c.o obj/src/main.cpp.o "
|
||||
f"obj/src/x.S.o obj/src/y.s.o | \n archives = \ndefault {PROGRAM_NAME}\n"
|
||||
) in ninja
|
||||
# Unchanged content reports no change so the compile DB can be reused
|
||||
changed, _ = _render(ccache="/usr/bin/ccache")
|
||||
assert changed is False
|
||||
|
||||
|
||||
def test_write_project_without_ccache(tmp_path: Path) -> None:
|
||||
_make_src(tmp_path, "main.cpp")
|
||||
_changed, ninja = _render()
|
||||
assert "ccache = \n" in ninja
|
||||
|
||||
|
||||
def test_write_project_routes_user_link_flags(tmp_path: Path) -> None:
|
||||
_make_src(tmp_path, "main.cpp")
|
||||
lib_dir = tmp_path / "opt" / "lib"
|
||||
# Forward slashes: build flags are lexed like a POSIX shell line, which
|
||||
# reads a backslash as an escape
|
||||
CORE.build_flags = {
|
||||
f"-L{lib_dir.as_posix()}",
|
||||
"-lcrypto",
|
||||
"-Wl,-framework,Security",
|
||||
}
|
||||
_changed, ninja = _render()
|
||||
assert "linkflags = -Wl,-framework,Security\n" in ninja
|
||||
assert f"libdirflags = -L{_q(lib_dir)}\n" in ninja
|
||||
assert "libflags = -lcrypto\n" in ninja
|
||||
|
||||
|
||||
def _libraries(tmp_path: Path) -> list[ArduinoLibrary]:
|
||||
lib_dir = tmp_path / "libs"
|
||||
for name in ("foo/src/a.cpp", "foo/src/sub/b.c", "bare/x.cpp"):
|
||||
path = lib_dir / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("")
|
||||
archived = ArduinoLibrary(
|
||||
name="foo",
|
||||
sources=[lib_dir / "foo/src/a.cpp", lib_dir / "foo/src/sub/b.c"],
|
||||
include_dirs=[lib_dir / "foo/src"],
|
||||
flags=["-DFOO=1"],
|
||||
link_dirs=[lib_dir / "foo/lib"],
|
||||
link_libs=["bar"],
|
||||
link_flags=["-Wl,--gc-sections"],
|
||||
)
|
||||
direct = ArduinoLibrary(
|
||||
name="bare", sources=[lib_dir / "bare/x.cpp"], lib_archive=False
|
||||
)
|
||||
header_only = ArduinoLibrary(name="hdr", include_dirs=[lib_dir / "hdr"])
|
||||
return [archived, direct, header_only]
|
||||
|
||||
|
||||
def test_write_project_with_libraries(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
src = _make_src(tmp_path, "main.cpp")
|
||||
libs = _libraries(tmp_path)
|
||||
with (
|
||||
patch.object(build_gen, "_resolve_host_libraries", return_value=libs),
|
||||
patch.object(build_gen, "find_tool", return_value="/usr/bin/ar") as find_tool,
|
||||
patch.object(build_gen.sys, "platform", "linux"),
|
||||
caplog.at_level(logging.DEBUG),
|
||||
):
|
||||
_changed, ninja = _render()
|
||||
find_tool.assert_called_once_with("AR", ("ar",))
|
||||
assert (
|
||||
f"rule ar\n command = $python $buildtool ar {_q('/usr/bin/ar')} $out $out.rsp"
|
||||
in ninja
|
||||
)
|
||||
lib_dir = tmp_path / "libs"
|
||||
# Every library's include dir joins the global include path
|
||||
assert f"-I{_q(src)} -I{_q(lib_dir / 'foo/src')} -I{_q(lib_dir / 'hdr')}" in ninja
|
||||
assert "linkflags = -Wl,--gc-sections\n" in ninja
|
||||
assert f"libdirflags = -L{_q(lib_dir / 'foo/lib')}\n" in ninja
|
||||
assert "libflags = -lbar\n" in ninja
|
||||
# Library sources compile with the library's own flags, rooted at their
|
||||
# common parent; its own include dirs lead the line so another library's
|
||||
# header of the same name cannot shadow them
|
||||
own = f"-I{_q(lib_dir / 'foo/src')}"
|
||||
assert "$own_includes $cxxflags $flags" in ninja
|
||||
assert (
|
||||
f"build obj/lib/foo/a.cpp.o: cxx {_e(lib_dir / 'foo/src/a.cpp')}\n"
|
||||
f" own_includes = {own}\n flags = -DFOO=1\n" in ninja
|
||||
)
|
||||
assert (
|
||||
f"build obj/lib/foo/sub/b.c.o: c {_e(lib_dir / 'foo/src/sub/b.c')}\n"
|
||||
f" own_includes = {own}\n flags = -DFOO=1\n" in ninja
|
||||
)
|
||||
assert "build libfoo.a: ar obj/lib/foo/a.cpp.o obj/lib/foo/sub/b.c.o\n" in ninja
|
||||
# libArchive: false objects link directly; the archive is an order-only
|
||||
# input wrapped in a group for GNU ld
|
||||
assert (
|
||||
f"build {PROGRAM_NAME}: link obj/src/main.cpp.o obj/lib/bare/x.cpp.o "
|
||||
"| libfoo.a\n"
|
||||
" archives = -Wl,--start-group libfoo.a -Wl,--end-group\n"
|
||||
) in ninja
|
||||
assert "Library hdr has no source files" in caplog.text
|
||||
|
||||
|
||||
def test_write_project_darwin_links_archives_bare(tmp_path: Path) -> None:
|
||||
_make_src(tmp_path, "main.cpp")
|
||||
libs = _libraries(tmp_path)
|
||||
with (
|
||||
patch.object(build_gen, "_resolve_host_libraries", return_value=libs),
|
||||
patch.object(build_gen, "find_tool", return_value="/usr/bin/ar"),
|
||||
patch.object(build_gen.sys, "platform", "darwin"),
|
||||
):
|
||||
_changed, ninja = _render()
|
||||
assert " archives = libfoo.a\n" in ninja
|
||||
assert "--start-group" not in ninja
|
||||
|
||||
|
||||
def test_build_unflags_remove_a_flag_with_its_argument(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Only the named pair goes; the same path under another flag stays."""
|
||||
CORE.build_flags = {"-iquote inc", "-isystem inc", "-Xlinker -dead_strip", "-g"}
|
||||
CORE.build_unflags = {"-isystem inc", "-Xlinker -dead_strip"}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cflags, _cxxflags, link_flags = build_gen._flag_lists()
|
||||
assert cflags == ["-g", "-iquote", str(CORE.build_path / "inc")]
|
||||
assert link_flags == ["-g"]
|
||||
assert "matched no build flag" not in caplog.text
|
||||
|
||||
|
||||
def test_build_unflags_warn_when_nothing_matches(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
CORE.build_flags = {"-g"}
|
||||
CORE.build_unflags = {"-isystem inc", "-DNOPE"}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
build_gen._flag_lists()
|
||||
assert "matched no build flag: -DNOPE, -isystem " in caplog.text
|
||||
|
||||
|
||||
def test_build_unflags_match_anchored_paths(caplog: pytest.LogCaptureFixture) -> None:
|
||||
CORE.build_flags = {"-Iinc", "-g"}
|
||||
CORE.build_unflags = {"-Iinc"}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
cflags, _cxxflags, _link = build_gen._flag_lists()
|
||||
assert cflags == ["-g"]
|
||||
assert "matched no build flag" not in caplog.text
|
||||
@@ -120,3 +120,48 @@ def test_parse_enable_env_spelling_tables(
|
||||
"""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
|
||||
|
||||
|
||||
def test_resolve_absolute_ccache_path(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Anchor the path: ninja runs from the build directory."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch.object(ccache, "resolve_ccache_path", return_value="bin/ccache"):
|
||||
resolved = ccache.resolve_absolute_ccache_path()
|
||||
assert Path(resolved) == tmp_path / "bin" / "ccache"
|
||||
with patch.object(ccache, "resolve_ccache_path", return_value=None):
|
||||
assert ccache.resolve_absolute_ccache_path() is None
|
||||
|
||||
|
||||
def test_ccache_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from esphome.core import CORE
|
||||
|
||||
CORE.build_path = tmp_path / "build"
|
||||
spec = ("ESPHOME_TEST_PREFIX", "test")
|
||||
monkeypatch.setenv("ESPHOME_TEST_PREFIX", str(tmp_path / "cache"))
|
||||
monkeypatch.setenv("CCACHE_NOHASHDIR", "false")
|
||||
for key in ("CCACHE_DIR", "CCACHE_BASEDIR", "CCACHE_DEPEND"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
# None means resolved and disabled
|
||||
assert ccache.ccache_env(None, spec) == {}
|
||||
env = ccache.ccache_env("/usr/bin/ccache", spec)
|
||||
# User-set values are respected; the rest get defaults
|
||||
assert "CCACHE_NOHASHDIR" not in env
|
||||
assert env["CCACHE_DEPEND"] == "1"
|
||||
assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache")
|
||||
assert env["CCACHE_BASEDIR"] == str((tmp_path / "build").resolve())
|
||||
|
||||
|
||||
def test_ccache_defaults_env_escapes_a_dollar(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A literal $ is doubled, since ccache expands $VAR in its settings."""
|
||||
from esphome.core import CORE
|
||||
|
||||
CORE.build_path = tmp_path / "a$b"
|
||||
for key in ("CCACHE_DIR", "CCACHE_BASEDIR"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
env = ccache.ccache_defaults_env(tmp_path / "c$d")
|
||||
assert env["CCACHE_DIR"].endswith("c$$d")
|
||||
assert env["CCACHE_BASEDIR"].endswith("a$$b")
|
||||
|
||||
@@ -676,3 +676,36 @@ def test_load_or_build_idedata_cache_hit_skips_rebuild(tmp_path: Path) -> None:
|
||||
)
|
||||
mock_build.assert_not_called()
|
||||
assert data["cached"] is True
|
||||
|
||||
|
||||
def test_warn_if_idedata_missing_is_silent_on_success(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
idedata.warn_if_idedata_missing(lambda: {"cc_path": "gcc"})
|
||||
assert "idedata" not in caplog.text
|
||||
|
||||
|
||||
def test_warn_if_idedata_missing_warns_on_none(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
idedata.warn_if_idedata_missing(lambda: None)
|
||||
assert "No idedata was generated for this build" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error", [EsphomeError("db unusable"), ValueError("bad"), OSError("gone")]
|
||||
)
|
||||
def test_warn_if_idedata_missing_downgrades_failures(
|
||||
caplog: pytest.LogCaptureFixture, error: Exception
|
||||
) -> None:
|
||||
"""The firmware already built; a broken idedata must not fail the build."""
|
||||
|
||||
def broken() -> dict | None:
|
||||
raise error
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
idedata.warn_if_idedata_missing(broken)
|
||||
assert f"Could not generate idedata: {error}" in caplog.text
|
||||
assert "Idedata failure detail" in caplog.text
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Tests for esphome.build_helpers.native."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import native
|
||||
from esphome.const import Toolchain
|
||||
from esphome.core import CORE
|
||||
|
||||
|
||||
def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Options a native build drops are warned by name; consumed ones stay quiet."""
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
CORE.platformio_options = {
|
||||
"lib_ignore": ["x"],
|
||||
"board_build.filesystem": "littlefs",
|
||||
}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
native.warn_ignored_platformio_options({"lib_ignore"})
|
||||
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
|
||||
assert "native 'arduino' toolchain" in caplog.text
|
||||
assert "lib_ignore" not in caplog.text
|
||||
|
||||
|
||||
def test_warn_ignored_platformio_options_without_options(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
CORE.platformio_options = None
|
||||
with caplog.at_level(logging.WARNING):
|
||||
native.warn_ignored_platformio_options(())
|
||||
assert caplog.text == ""
|
||||
@@ -141,3 +141,126 @@ def test_shell_token_windows_branch_uses_argv_rule() -> None:
|
||||
with patch.object(os, "name", "nt"):
|
||||
assert ninja_helper.shell_token("a b") == '"a b"'
|
||||
assert ninja_helper.shell_token("", force=True) == '""'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("char", ["|", "\n", "\r"])
|
||||
def test_escape_rejects_what_ninja_cannot_express(char: str) -> None:
|
||||
with pytest.raises(EsphomeError, match="a ninja build file cannot express"):
|
||||
ninja_helper.escape(f"src/a{char}b.cpp")
|
||||
|
||||
|
||||
def test_escape_keeps_a_hash() -> None:
|
||||
"""A # is only a comment at the start of a ninja line."""
|
||||
assert ninja_helper.escape("src/a#b c.cpp") == "src/a#b$ c.cpp"
|
||||
|
||||
|
||||
def test_absolute_tool_anchors_a_relative_path(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Anchor the path: ninja runs from the build directory."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert Path(ninja_helper.absolute_tool("bin/ninja")) == tmp_path / "bin" / "ninja"
|
||||
|
||||
|
||||
def test_find_ninja_returns_an_absolute_path(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with (
|
||||
patch("shutil.which", return_value="bin/ninja"),
|
||||
patch.object(ninja_helper, "_ninja_runs", return_value=True),
|
||||
):
|
||||
assert ninja_helper.find_ninja() == tmp_path / "bin" / "ninja"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def compdb_dir(tmp_path: Path) -> Path:
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
(build_dir / "build.ninja").write_text("rule x\n")
|
||||
return build_dir
|
||||
|
||||
|
||||
def test_refresh_compile_commands_regenerates_when_stale(compdb_dir: Path) -> None:
|
||||
ninja = Path("ninja")
|
||||
compdb = compdb_dir / "compile_commands.json"
|
||||
stamp = compdb_dir / ".compile_commands.stamp"
|
||||
with patch.object(ninja_helper, "write_compile_commands") as write:
|
||||
# A rewritten manifest always regenerates
|
||||
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, True)
|
||||
assert write.call_count == 1
|
||||
assert stamp.is_file()
|
||||
# No compile DB yet
|
||||
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
|
||||
assert write.call_count == 2
|
||||
compdb.write_text("[]")
|
||||
# Fresh stamp: nothing to do
|
||||
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
|
||||
assert write.call_count == 2
|
||||
# A manifest newer than the stamp (interrupted previous run)
|
||||
os.utime(stamp, (1, 1))
|
||||
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
|
||||
assert write.call_count == 3
|
||||
# A missing stamp regenerates too
|
||||
stamp.unlink()
|
||||
ninja_helper.refresh_compile_commands(ninja, compdb_dir, {}, False)
|
||||
assert write.call_count == 4
|
||||
|
||||
|
||||
def _completed(rc: int = 0, stdout: str = "", stderr: str = "") -> MagicMock:
|
||||
return MagicMock(returncode=rc, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
def test_write_compile_commands_success(compdb_dir: Path) -> None:
|
||||
entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]'
|
||||
with patch("subprocess.run", return_value=_completed(stdout=entries)) as run:
|
||||
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {"A": "1"})
|
||||
assert (compdb_dir / "compile_commands.json").read_text() == entries
|
||||
# Every compile rule, so preprocessed assembly reaches the database too
|
||||
assert run.call_args.args[0] == [
|
||||
"ninja",
|
||||
"-C",
|
||||
str(compdb_dir),
|
||||
"-t",
|
||||
"compdb",
|
||||
"c",
|
||||
"cxx",
|
||||
"aspp",
|
||||
"asm",
|
||||
]
|
||||
assert run.call_args.kwargs["env"] == {"A": "1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "message"),
|
||||
[
|
||||
(_completed(rc=1, stderr="boom"), "Could not generate compile_commands.json"),
|
||||
# A parse failure names its cause, not the rule-name story
|
||||
(_completed(stdout="not json"), "unparsable compile database.*not json"),
|
||||
(_completed(stdout="[]"), "empty compile database"),
|
||||
],
|
||||
)
|
||||
def test_write_compile_commands_failures_drop_stale_db(
|
||||
compdb_dir: Path, result: MagicMock, message: str
|
||||
) -> None:
|
||||
compdb = compdb_dir / "compile_commands.json"
|
||||
compdb.write_text("[stale]")
|
||||
with (
|
||||
patch("subprocess.run", return_value=result),
|
||||
pytest.raises(EsphomeError, match=message),
|
||||
):
|
||||
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {})
|
||||
assert not compdb.exists()
|
||||
|
||||
|
||||
def test_write_compile_commands_keeps_the_mtime_of_an_unchanged_db(
|
||||
compdb_dir: Path,
|
||||
) -> None:
|
||||
"""The idedata cache is keyed on the DB's mtime."""
|
||||
entries = '[{"file": "a.cpp", "command": "g++ -c a.cpp"}]'
|
||||
compdb = compdb_dir / "compile_commands.json"
|
||||
compdb.write_text(entries)
|
||||
os.utime(compdb, (1000, 1000))
|
||||
with patch("subprocess.run", return_value=_completed(stdout=entries)):
|
||||
ninja_helper.write_compile_commands(Path("ninja"), compdb_dir, {})
|
||||
assert compdb.stat().st_mtime == 1000
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for esphome.build_helpers.ninja_gen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.build_helpers import ninja_gen
|
||||
from esphome.build_helpers.ninja_gen import Flag
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flag", "anchored"),
|
||||
[
|
||||
(("-Iinc",), ("-I{base}/inc",)),
|
||||
(("-Llib",), ("-L{base}/lib",)),
|
||||
(("-include", "pre.h"), ("-include", "{base}/pre.h")),
|
||||
(("-isystem", "sys"), ("-isystem", "{base}/sys")),
|
||||
# The glued spelling of a flag that takes a path
|
||||
(("-isystemsys",), ("-isystem{base}/sys",)),
|
||||
(("-includepre.h",), ("-include{base}/pre.h",)),
|
||||
# An absolute operand is never changed
|
||||
(("-I{base}/abs",), ("-I{base}/abs",)),
|
||||
(("-include", "{base}/abs.h"), ("-include", "{base}/abs.h")),
|
||||
# Not a path
|
||||
(("-DUSE_HOST",), ("-DUSE_HOST",)),
|
||||
(("-lssl",), ("-lssl",)),
|
||||
(("-framework", "Cocoa"), ("-framework", "Cocoa")),
|
||||
(("-I",), ("-I",)),
|
||||
],
|
||||
)
|
||||
def test_anchor_path_flag(tmp_path: Path, flag: Flag, anchored: Flag) -> None:
|
||||
"""Relative operands resolve from the build path, as under PlatformIO."""
|
||||
|
||||
def fill(tokens: Flag) -> Flag:
|
||||
return tuple(
|
||||
str(Path(tok.replace("{base}", str(tmp_path))))
|
||||
if "{base}" in tok and not tok.startswith("-")
|
||||
else tok.replace("{base}/", f"{tmp_path}{os.sep}")
|
||||
for tok in tokens
|
||||
)
|
||||
|
||||
assert ninja_gen.anchor_path_flag(fill(flag), tmp_path) == fill(anchored)
|
||||
|
||||
|
||||
def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None:
|
||||
for name in ("b.cpp", "a.c", "skip.cpp", "notes.txt", "sub/c.S"):
|
||||
path = tmp_path / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("")
|
||||
assert ninja_gen.collect_sources(tmp_path, {"skip.cpp"}) == [
|
||||
tmp_path / "a.c",
|
||||
tmp_path / "b.cpp",
|
||||
tmp_path / "sub" / "c.S",
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for the host platform's native-toolchain wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import host
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_HOST,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _core_data() -> None:
|
||||
CORE.data[KEY_CORE] = {}
|
||||
|
||||
|
||||
def test_schema_resolves_the_host_toolchain() -> None:
|
||||
"""The host serves exactly one toolchain; it is picked without config."""
|
||||
config = host.CONFIG_SCHEMA({})
|
||||
assert CORE.toolchain is Toolchain.HOST
|
||||
assert CORE.using_native_toolchain
|
||||
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == PLATFORM_HOST
|
||||
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "host"
|
||||
assert "mac_address" in config
|
||||
|
||||
|
||||
def test_schema_rejects_the_platformio_toolchain() -> None:
|
||||
"""There is no PlatformIO backend left to honor --toolchain platformio."""
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'platformio' for host"):
|
||||
host.CONFIG_SCHEMA({})
|
||||
|
||||
|
||||
def test_to_code_configures_the_native_build() -> None:
|
||||
config = host.CONFIG_SCHEMA({"mac_address": "AA:BB:CC:DD:EE:FF"})
|
||||
asyncio.run(host.to_code(config))
|
||||
assert "-DUSE_HOST" in CORE.build_flags
|
||||
# The standard travels through cpp_standard, not a -std= build flag,
|
||||
# so the generator can keep it off the C compile line
|
||||
assert CORE.cpp_standard == "gnu++20"
|
||||
assert not any(flag.startswith("-std=") for flag in CORE.build_flags)
|
||||
assert not CORE.platformio_options
|
||||
define_names = {define.name for define in CORE.defines}
|
||||
assert {"ESPHOME_BOARD", "ESPHOME_VARIANT", "USE_ESPHOME_HOST_MAC_ADDRESS"} <= (
|
||||
define_names
|
||||
)
|
||||
|
||||
|
||||
def test_run_compile_hook_claims_the_build() -> None:
|
||||
config = {"esphome": {}}
|
||||
with (
|
||||
patch.object(host.sys, "platform", "linux"),
|
||||
patch("esphome.host.toolchain.run_compile", return_value=0) as run,
|
||||
):
|
||||
assert host.run_compile(object(), config) is True
|
||||
run.assert_called_once_with(config, CORE.verbose)
|
||||
|
||||
|
||||
def test_run_compile_hook_raises_on_failure() -> None:
|
||||
with (
|
||||
patch.object(host.sys, "platform", "linux"),
|
||||
patch("esphome.host.toolchain.run_compile", return_value=2),
|
||||
pytest.raises(EsphomeError, match="Host build failed"),
|
||||
):
|
||||
host.run_compile(object(), {"esphome": {}})
|
||||
|
||||
|
||||
def test_run_compile_hook_refuses_windows() -> None:
|
||||
"""The host sources use POSIX interfaces; fail before building, by name."""
|
||||
with (
|
||||
patch.object(host.sys, "platform", "win32"),
|
||||
patch("esphome.host.toolchain.run_compile") as run,
|
||||
pytest.raises(EsphomeError, match="does not build on Windows"),
|
||||
):
|
||||
host.run_compile(object(), {"esphome": {}})
|
||||
run.assert_not_called()
|
||||
@@ -112,17 +112,6 @@ def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None:
|
||||
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"):
|
||||
@@ -148,18 +137,20 @@ def test_get_build_env_without_path_has_no_empty_entry(tmp_path: 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")
|
||||
|
||||
|
||||
def test_get_build_env_uses_the_arduino8266_ccache_dir(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_ARDUINO8266_PREFIX", str(tmp_path / "cache"))
|
||||
monkeypatch.delenv("CCACHE_DIR", raising=False)
|
||||
env = framework.get_build_env(tmp_path / "toolchain", "/usr/bin/ccache")
|
||||
assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache")
|
||||
# None means resolved and disabled
|
||||
assert "CCACHE_DIR" not in framework.get_build_env(tmp_path / "toolchain", None)
|
||||
|
||||
@@ -16,6 +16,7 @@ from esphome.const import (
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_FRAMEWORK_VERSION,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
|
||||
@@ -80,7 +81,7 @@ def test_run_compile_build_failure(tmp_path: Path) -> None:
|
||||
patch.object(
|
||||
toolchain.subprocess, "run", return_value=MagicMock(returncode=2)
|
||||
) as mock_run,
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "refresh_compile_commands") as mock_compdb,
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=True) == 2
|
||||
cmd = mock_run.call_args[0][0]
|
||||
@@ -100,7 +101,7 @@ def test_run_compile_success(tmp_path: Path) -> None:
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
) as mock_run,
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "refresh_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "_print_size_summary") as mock_size,
|
||||
patch.object(toolchain, "get_idedata") as mock_idedata,
|
||||
):
|
||||
@@ -116,39 +117,12 @@ def test_run_compile_success(tmp_path: Path) -> None:
|
||||
# cwd, not -C, so ninja prints no "Entering directory" banner
|
||||
assert "-C" not in cmd
|
||||
assert call[1]["cwd"] is not None
|
||||
mock_compdb.assert_called_once()
|
||||
# An unchanged manifest is passed on, so the shared refresh can skip
|
||||
assert mock_compdb.call_args.args[3] is False
|
||||
mock_size.assert_called_once()
|
||||
mock_idedata.assert_called_once()
|
||||
|
||||
|
||||
def test_run_compile_regenerates_stale_compdb(tmp_path: Path) -> None:
|
||||
"""An interrupted run can leave build.ninja newer than the compile DB;
|
||||
mere existence must not skip regeneration."""
|
||||
build_dir = toolchain.get_build_dir()
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / "build.ninja").write_text("")
|
||||
compdb = build_dir / "compile_commands.json"
|
||||
compdb.write_text("[]")
|
||||
os.utime(compdb, ((build_dir / "build.ninja").stat().st_mtime - 5,) * 2)
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(
|
||||
returncode=0, stdout="ninja: no work to do.\n", stderr=""
|
||||
),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
mock_compdb.assert_called_once()
|
||||
|
||||
|
||||
def test_run_compile_missing_artifact_fails(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@@ -164,7 +138,7 @@ def test_run_compile_missing_artifact_fails(
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "refresh_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary") as mock_size,
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
@@ -188,7 +162,7 @@ def test_run_compile_warns_when_idedata_fails(
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "refresh_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata", return_value=None),
|
||||
):
|
||||
@@ -196,63 +170,6 @@ def test_run_compile_warns_when_idedata_fails(
|
||||
assert "No idedata was generated for this build" in caplog.text
|
||||
|
||||
|
||||
def test_write_compile_commands(tmp_path: Path) -> None:
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
entries = '[{"file": "a.cpp", "command": "cc"}]\n'
|
||||
with patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=entries),
|
||||
):
|
||||
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
|
||||
assert (build_dir / "compile_commands.json").read_text() == entries
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stdout", "match"),
|
||||
[
|
||||
("[]\n", "empty compile database"),
|
||||
# A parse failure names its cause, not the rule-name story
|
||||
("not json", "unparsable compile database.*not json"),
|
||||
],
|
||||
)
|
||||
def test_write_compile_commands_bad_db_raises(
|
||||
tmp_path: Path, stdout: str, match: str
|
||||
) -> None:
|
||||
"""An empty or unparsable compile database fails the build with its
|
||||
actual cause and drops any stale database."""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
(build_dir / "compile_commands.json").write_text("[stale]")
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout=stdout),
|
||||
),
|
||||
pytest.raises(EsphomeError, match=match),
|
||||
):
|
||||
toolchain._write_compile_commands(tmp_path / "ninja", build_dir, {})
|
||||
assert not (build_dir / "compile_commands.json").exists()
|
||||
|
||||
|
||||
def test_write_compile_commands_failure_removes_stale_db(tmp_path: Path) -> None:
|
||||
"""A failed compdb run must not leave a stale database behind."""
|
||||
stale = tmp_path / "compile_commands.json"
|
||||
stale.write_text("[]")
|
||||
with (
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=1, stderr="boom"),
|
||||
),
|
||||
pytest.raises(EsphomeError, match="compile_commands"),
|
||||
):
|
||||
toolchain._write_compile_commands(tmp_path / "ninja", tmp_path, {})
|
||||
assert not stale.exists()
|
||||
|
||||
|
||||
def test_parse_app_size(tmp_path: Path) -> None:
|
||||
ld = tmp_path / "eagle.flash.4m.ld"
|
||||
ld.write_text("MEMORY\n{\n irom0_0_seg : org = 0x40201010, len = 0xfeff0\n}\n")
|
||||
@@ -342,7 +259,9 @@ def test_get_idedata_delegates(tmp_path: Path) -> None:
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata",
|
||||
return_value={"cc_path": "x"},
|
||||
) as mock_load,
|
||||
patch.object(toolchain, "resolve_ccache_path", return_value="/cc/ccache"),
|
||||
patch.object(
|
||||
toolchain, "resolve_absolute_ccache_path", return_value="/cc/ccache"
|
||||
),
|
||||
):
|
||||
assert toolchain.get_idedata() == {"cc_path": "x"}
|
||||
compile_commands, elf, cache = mock_load.call_args[0]
|
||||
@@ -359,43 +278,12 @@ def test_get_idedata_no_ccache(tmp_path: Path) -> None:
|
||||
patch(
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata", return_value={}
|
||||
) as mock_load,
|
||||
patch.object(toolchain, "resolve_ccache_path", return_value=None),
|
||||
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None),
|
||||
):
|
||||
toolchain.get_idedata()
|
||||
assert mock_load.call_args.kwargs["launcher"] is None
|
||||
|
||||
|
||||
def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None:
|
||||
"""An unchanged build.ninja means the compile DB is already current."""
|
||||
build_dir = toolchain.get_build_dir()
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
# write_project (stubbed below) always leaves a build.ninja behind
|
||||
(build_dir / "build.ninja").write_text("# manifest")
|
||||
|
||||
def run(regenerate_expected: bool) -> None:
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=False),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert mock_compdb.called == regenerate_expected
|
||||
|
||||
# Missing compile DB: regenerated even though build.ninja is unchanged
|
||||
run(regenerate_expected=True)
|
||||
# Present compile DB + unchanged build.ninja: skipped
|
||||
(build_dir / "compile_commands.json").write_text("[]")
|
||||
run(regenerate_expected=False)
|
||||
|
||||
|
||||
def test_print_size_summary_unparsable_section(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
@@ -447,32 +335,6 @@ def test_print_size_summary_missing_section_skips_summary(
|
||||
assert "missing section(s) .bss" in caplog.text
|
||||
|
||||
|
||||
def test_warn_ignored_platformio_options(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Component-added options the native build drops are warned by name;
|
||||
the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags,
|
||||
flash_mode) stay quiet."""
|
||||
CORE.platformio_options = {
|
||||
"board_build.ldscript": "eagle.flash.4m2m.ld",
|
||||
"board_build.f_cpu": "160000000L",
|
||||
"board_build.filesystem": "littlefs",
|
||||
"board_build.flash_mode": "dio",
|
||||
"build_src_flags": "-include throw_stubs.h",
|
||||
"lib_ignore": ["Updater"],
|
||||
"upload_speed": "460800",
|
||||
}
|
||||
toolchain._warn_ignored_platformio_options()
|
||||
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
|
||||
assert "native 'arduino' toolchain" in caplog.text
|
||||
assert "board_build.ldscript is ignored" not in caplog.text
|
||||
assert "board_build.f_cpu is ignored" not in caplog.text
|
||||
assert "lib_ignore" not in caplog.text
|
||||
assert "build_src_flags" not in caplog.text
|
||||
assert "flash_mode" not in caplog.text
|
||||
# Component-added upload_speed never gets read under the native
|
||||
# toolchain, so it must warn
|
||||
assert "upload_speed" in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_idedata_error_does_not_fail_build(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
@@ -486,7 +348,7 @@ def test_run_compile_idedata_error_does_not_fail_build(
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "refresh_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(
|
||||
toolchain,
|
||||
@@ -512,7 +374,7 @@ def test_run_compile_skipped_size_summary_names_consequence(
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "_write_compile_commands"),
|
||||
patch.object(toolchain, "refresh_compile_commands"),
|
||||
patch.object(toolchain, "_print_size_summary", return_value=False),
|
||||
patch.object(toolchain, "get_idedata", return_value=None),
|
||||
):
|
||||
@@ -541,8 +403,50 @@ def test_get_idedata_accepts_preresolved_ccache() -> None:
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata",
|
||||
return_value={"ok": True},
|
||||
) as mock_build,
|
||||
patch.object(toolchain, "resolve_ccache_path") as mock_resolve,
|
||||
patch.object(toolchain, "resolve_absolute_ccache_path") as mock_resolve,
|
||||
):
|
||||
assert toolchain.get_idedata("/usr/bin/ccache") == {"ok": True}
|
||||
mock_resolve.assert_not_called()
|
||||
assert mock_build.call_args.kwargs["launcher"] == "/usr/bin/ccache"
|
||||
|
||||
|
||||
def test_run_compile_warns_about_dropped_platformio_options(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Component-added options the native build drops are warned by name;
|
||||
the honored ones (lib_ignore, f_cpu, ldscript, build_src_flags,
|
||||
flash_mode) stay quiet."""
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
CORE.platformio_options = {
|
||||
"board_build.ldscript": "eagle.flash.4m2m.ld",
|
||||
"board_build.f_cpu": "160000000L",
|
||||
"board_build.filesystem": "littlefs",
|
||||
"board_build.flash_mode": "dio",
|
||||
"build_src_flags": "-include throw_stubs.h",
|
||||
"lib_ignore": ["Updater"],
|
||||
"upload_speed": "460800",
|
||||
}
|
||||
with (
|
||||
patch.object(framework, "check_and_install", return_value=_paths(tmp_path)),
|
||||
patch.object(framework, "get_build_env", return_value={}),
|
||||
patch("esphome.build_gen.arduino8266.write_project", return_value=True),
|
||||
patch.object(
|
||||
toolchain.subprocess,
|
||||
"run",
|
||||
return_value=MagicMock(returncode=0, stdout="", stderr=""),
|
||||
),
|
||||
patch.object(toolchain, "refresh_compile_commands") as mock_compdb,
|
||||
patch.object(toolchain, "_print_size_summary"),
|
||||
patch.object(toolchain, "get_idedata"),
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert "platformio_options->board_build.filesystem is ignored" in caplog.text
|
||||
assert "platformio_options->upload_speed is ignored" in caplog.text
|
||||
assert "native 'arduino' toolchain" in caplog.text
|
||||
assert "board_build.ldscript is ignored" not in caplog.text
|
||||
assert "board_build.f_cpu is ignored" not in caplog.text
|
||||
assert "lib_ignore" not in caplog.text
|
||||
assert "build_src_flags" not in caplog.text
|
||||
assert "flash_mode" not in caplog.text
|
||||
# A rewritten manifest is passed on, so the compile DB is regenerated
|
||||
assert mock_compdb.call_args.args[3] is True
|
||||
|
||||
@@ -1160,3 +1160,29 @@ def test_versionless_dependency_with_provider_stays_quiet(
|
||||
libs = _resolve(framework)
|
||||
assert "Wire" in [lib.name for lib in libs]
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
|
||||
def test_resolve_libraries_without_framework_is_all_external() -> None:
|
||||
"""The host build has no core tree: nothing is bundled, the framework
|
||||
check is skipped, and every name reaches the converter."""
|
||||
_add_library("Wire", None)
|
||||
_add_library("lvgl/lvgl", "9.5.0")
|
||||
|
||||
def fake_convert(libraries: list, backend: LibraryBackend) -> list:
|
||||
assert backend.platform == "native"
|
||||
assert backend.framework is None
|
||||
assert backend.cache_key == "host"
|
||||
assert backend.provides("Wire") is False
|
||||
return []
|
||||
|
||||
with patch.object(component, "convert_libraries", side_effect=fake_convert) as mock:
|
||||
libs = component.resolve_libraries(
|
||||
None,
|
||||
pio_platform="native",
|
||||
board_mcu="host",
|
||||
cache_key="host",
|
||||
framework=None,
|
||||
)
|
||||
assert libs == []
|
||||
(libraries, _backend), _ = mock.call_args
|
||||
assert [lib.name for lib in libraries] == ["Wire", "lvgl/lvgl"]
|
||||
|
||||
@@ -976,6 +976,14 @@ class TestEsphomeCore:
|
||||
target.toolchain = const.Toolchain.SDK_NRF
|
||||
assert target.using_native_toolchain is False
|
||||
|
||||
def test_using_toolchain_host(self, target):
|
||||
"""The host's only toolchain: the system compiler driven by ninja."""
|
||||
target.toolchain = const.Toolchain.HOST
|
||||
assert target.using_toolchain_host is True
|
||||
assert target.using_native_toolchain is True
|
||||
target.toolchain = const.Toolchain.PLATFORMIO
|
||||
assert target.using_toolchain_host 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] = {
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Tests for esphome.host.toolchain (the native host build driver)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.const import (
|
||||
CONF_COMPILE_PROCESS_LIMIT,
|
||||
CONF_ESPHOME,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_PLATFORM,
|
||||
PLATFORM_HOST,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.host import toolchain
|
||||
from esphome.host.toolchain import PROGRAM_NAME
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _core(tmp_path: Path) -> None:
|
||||
CORE.config_path = tmp_path / "dev.yaml"
|
||||
CORE.build_path = tmp_path
|
||||
CORE.name = "dev"
|
||||
CORE.toolchain = Toolchain.HOST
|
||||
CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST}
|
||||
|
||||
|
||||
def _abs(path: str) -> str:
|
||||
"""What the toolchain makes of a tool path (a drive is added on Windows)."""
|
||||
return str(Path(path).absolute())
|
||||
|
||||
|
||||
def test_find_command_prefers_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CXX", "/opt/clang++")
|
||||
with patch("shutil.which", side_effect={"/opt/clang++": "/opt/clang++"}.get):
|
||||
assert toolchain.find_command("CXX", ("g++",)) == (_abs("/opt/clang++"),)
|
||||
|
||||
|
||||
def test_find_command_keeps_the_override_arguments(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``CC="gcc -m32"`` is a command, as make and CMake read it."""
|
||||
monkeypatch.setenv("CC", "gcc -m32 '--sysroot=/opt/my sdk'")
|
||||
with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get):
|
||||
assert toolchain.find_command("CC", ("cc",)) == (
|
||||
_abs("/usr/bin/gcc"),
|
||||
"-m32",
|
||||
"--sysroot=/opt/my sdk",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("override", "expected_args"),
|
||||
[("ccache gcc", ()), ("/opt/bin/ccache gcc -m32", ("-m32",))],
|
||||
)
|
||||
def test_find_command_drops_a_ccache_prefix(
|
||||
monkeypatch: pytest.MonkeyPatch, override: str, expected_args: tuple[str, ...]
|
||||
) -> None:
|
||||
"""The build adds ccache itself; the compiler is the word after it."""
|
||||
monkeypatch.setenv("CC", override)
|
||||
with patch("shutil.which", side_effect={"gcc": "/usr/bin/gcc"}.get):
|
||||
assert toolchain.find_command("CC", ("cc",)) == (
|
||||
_abs("/usr/bin/gcc"),
|
||||
*expected_args,
|
||||
)
|
||||
|
||||
|
||||
def test_find_command_accepts_a_compiler_named_ccache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Alone, the word is the program: ccache's compiler links work that way."""
|
||||
monkeypatch.setenv("CC", "ccache")
|
||||
with patch("shutil.which", side_effect={"ccache": "/usr/bin/ccache"}.get):
|
||||
assert toolchain.find_command("CC", ("cc",)) == (_abs("/usr/bin/ccache"),)
|
||||
|
||||
|
||||
def test_find_command_env_override_must_run(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A broken override fails by name instead of silently using another compiler."""
|
||||
monkeypatch.setenv("CXX", "nope++")
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
pytest.raises(EsphomeError, match="CXX='nope\\+\\+' does not name"),
|
||||
):
|
||||
toolchain.find_command("CXX", ("g++",))
|
||||
|
||||
|
||||
def test_find_command_blank_override_is_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CC", " ")
|
||||
with patch("shutil.which", side_effect={"clang": "/usr/bin/clang"}.get):
|
||||
assert toolchain.find_command("CC", ("gcc", "clang")) == (
|
||||
_abs("/usr/bin/clang"),
|
||||
)
|
||||
|
||||
|
||||
def test_find_command_first_candidate_wins(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CC", raising=False)
|
||||
table = {"gcc": "/usr/bin/gcc", "clang": "/usr/bin/clang"}
|
||||
with patch("shutil.which", side_effect=table.get):
|
||||
assert toolchain.find_command("CC", ("gcc", "clang")) == (_abs("/usr/bin/gcc"),)
|
||||
|
||||
|
||||
def test_find_command_none_found(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CC", raising=False)
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
pytest.raises(
|
||||
EsphomeError,
|
||||
match=r"gcc not found on PATH \(tried gcc, clang\); install it or set CC",
|
||||
),
|
||||
):
|
||||
toolchain.find_command("CC", ("gcc", "clang"))
|
||||
|
||||
|
||||
def test_find_command_makes_a_relative_hit_absolute(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Anchor the path: ninja runs from the build directory."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("CC", "./toolchain/gcc")
|
||||
with patch("shutil.which", return_value="./toolchain/gcc"):
|
||||
(found,) = toolchain.find_command("CC", ("gcc",))
|
||||
assert Path(found) == Path("toolchain/gcc").absolute()
|
||||
|
||||
|
||||
def test_find_tool_refuses_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("AR", "ar --plugin x")
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/ar"),
|
||||
pytest.raises(EsphomeError, match="AR must name a program without arguments"),
|
||||
):
|
||||
toolchain.find_tool("AR", ("ar",))
|
||||
|
||||
|
||||
def test_find_compilers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for var in ("CC", "CXX"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
table = {"gcc": "/usr/bin/gcc", "g++": "/usr/bin/g++"}
|
||||
with patch("shutil.which", side_effect=table.get):
|
||||
assert toolchain.find_compilers() == toolchain.HostCompilers(
|
||||
cc=(_abs("/usr/bin/gcc"),), cxx=(_abs("/usr/bin/g++"),)
|
||||
)
|
||||
|
||||
|
||||
def test_build_paths(tmp_path: Path) -> None:
|
||||
"""The PlatformIO layout is kept: CORE.firmware_bin resolves the same file."""
|
||||
assert toolchain.get_build_dir() == tmp_path / ".pioenvs" / "dev"
|
||||
assert toolchain.get_elf_path() == CORE.firmware_bin
|
||||
|
||||
|
||||
def test_binutils_paths(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for var in ("OBJDUMP", "READELF"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
table = {"objdump": "/usr/bin/objdump", "readelf": "/usr/bin/readelf"}
|
||||
with patch("shutil.which", side_effect=table.get):
|
||||
assert toolchain.get_objdump_path() == Path(_abs("/usr/bin/objdump"))
|
||||
assert toolchain.get_readelf_path() == Path(_abs("/usr/bin/readelf"))
|
||||
|
||||
|
||||
def test_get_build_env_merges_without_leaking(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("ESPHOME_HOST_PREFIX", str(tmp_path / "cache"))
|
||||
monkeypatch.delenv("CCACHE_DIR", raising=False)
|
||||
monkeypatch.setenv("KEEP_ME", "1")
|
||||
env = toolchain.get_build_env("/usr/bin/ccache")
|
||||
assert env["KEEP_ME"] == "1"
|
||||
assert env["CCACHE_DIR"] == str((tmp_path / "cache").resolve() / "ccache")
|
||||
assert "CCACHE_DIR" not in os.environ
|
||||
|
||||
|
||||
def test_get_build_env_without_ccache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CCACHE_DIR", raising=False)
|
||||
assert "CCACHE_DIR" not in toolchain.get_build_env(None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def compile_env(tmp_path: Path) -> Generator[SimpleNamespace]:
|
||||
"""Stub everything run_compile resolves; the build dir holds a manifest."""
|
||||
build_dir = tmp_path / ".pioenvs" / "dev"
|
||||
build_dir.mkdir(parents=True)
|
||||
(build_dir / "build.ninja").write_text("rule x\n")
|
||||
compilers = toolchain.HostCompilers(("gcc",), ("g++",))
|
||||
with (
|
||||
patch.object(toolchain, "find_ninja", return_value=Path("/usr/bin/ninja")),
|
||||
patch.object(toolchain, "find_compilers", return_value=compilers),
|
||||
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=None),
|
||||
patch("esphome.build_gen.host.write_project", return_value=True) as project,
|
||||
patch.object(toolchain, "refresh_compile_commands") as refresh,
|
||||
patch.object(
|
||||
toolchain, "_load_idedata", return_value={"cc_path": "gcc"}
|
||||
) as ide,
|
||||
patch("subprocess.run") as run,
|
||||
):
|
||||
yield SimpleNamespace(
|
||||
build_dir=build_dir,
|
||||
compilers=compilers,
|
||||
write_project=project,
|
||||
refresh=refresh,
|
||||
idedata=ide,
|
||||
run=run,
|
||||
)
|
||||
|
||||
|
||||
def _completed(rc: int = 0) -> SimpleNamespace:
|
||||
return SimpleNamespace(returncode=rc, stdout="", stderr="")
|
||||
|
||||
|
||||
def test_run_compile_builds_and_reports_success(compile_env: SimpleNamespace) -> None:
|
||||
elf = compile_env.build_dir / PROGRAM_NAME
|
||||
|
||||
def build(cmd: list[str], **kwargs: object) -> SimpleNamespace:
|
||||
elf.write_text("")
|
||||
return _completed()
|
||||
|
||||
compile_env.run.side_effect = build
|
||||
config = {CONF_ESPHOME: {CONF_COMPILE_PROCESS_LIMIT: 4}}
|
||||
assert toolchain.run_compile(config, verbose=True) == 0
|
||||
|
||||
compile_env.write_project.assert_called_once_with(compile_env.compilers, None)
|
||||
compile_env.refresh.assert_called_once()
|
||||
assert compile_env.refresh.call_args.args[3] is True
|
||||
compile_env.run.assert_called_once()
|
||||
assert compile_env.run.call_args.args[0] == [
|
||||
str(Path("/usr/bin/ninja")),
|
||||
"-v",
|
||||
"-j",
|
||||
"4",
|
||||
PROGRAM_NAME,
|
||||
]
|
||||
assert compile_env.run.call_args.kwargs["cwd"] == compile_env.build_dir
|
||||
compile_env.idedata.assert_called_once_with(None)
|
||||
|
||||
|
||||
def test_run_compile_defaults(compile_env: SimpleNamespace) -> None:
|
||||
"""No verbosity and no process limit: just the ninja target."""
|
||||
(compile_env.build_dir / PROGRAM_NAME).write_text("")
|
||||
compile_env.run.return_value = _completed()
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert compile_env.run.call_args.args[0] == [
|
||||
str(Path("/usr/bin/ninja")),
|
||||
PROGRAM_NAME,
|
||||
]
|
||||
# The build inherits stdio and never captures: progress must stream
|
||||
kwargs = compile_env.run.call_args.kwargs
|
||||
assert kwargs["check"] is False
|
||||
assert kwargs["close_fds"] is False
|
||||
assert "capture_output" not in kwargs
|
||||
|
||||
|
||||
def test_run_compile_warns_about_dropped_platformio_options(
|
||||
compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
(compile_env.build_dir / PROGRAM_NAME).write_text("")
|
||||
compile_env.run.return_value = _completed()
|
||||
CORE.platformio_options = {"lib_ignore": ["x"], "board_build.f_cpu": "1"}
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
assert "platformio_options->board_build.f_cpu is ignored" in caplog.text
|
||||
assert "native 'host' toolchain" in caplog.text
|
||||
assert "lib_ignore" not in caplog.text
|
||||
|
||||
|
||||
def test_run_compile_passes_the_resolved_ccache(compile_env: SimpleNamespace) -> None:
|
||||
(compile_env.build_dir / PROGRAM_NAME).write_text("")
|
||||
compile_env.run.return_value = _completed()
|
||||
with patch.object(
|
||||
toolchain, "resolve_absolute_ccache_path", return_value="/usr/bin/ccache"
|
||||
):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
|
||||
compile_env.write_project.assert_called_once_with(
|
||||
compile_env.compilers, "/usr/bin/ccache"
|
||||
)
|
||||
compile_env.idedata.assert_called_once_with("/usr/bin/ccache")
|
||||
|
||||
|
||||
def test_run_compile_build_failure_returns_code(compile_env: SimpleNamespace) -> None:
|
||||
compile_env.run.return_value = _completed(rc=3)
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 3
|
||||
compile_env.idedata.assert_not_called()
|
||||
|
||||
|
||||
def test_run_compile_missing_program_fails(
|
||||
compile_env: SimpleNamespace, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A green ninja run that produced no program fails by name."""
|
||||
compile_env.run.return_value = _completed()
|
||||
with caplog.at_level(logging.ERROR):
|
||||
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 1
|
||||
assert "Build produced no" in caplog.text
|
||||
compile_env.idedata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None])
|
||||
def test_get_idedata_resolves_ccache(tmp_path: Path, ccache: str | None) -> None:
|
||||
with (
|
||||
patch.object(toolchain, "resolve_absolute_ccache_path", return_value=ccache),
|
||||
patch(
|
||||
"esphome.build_helpers.idedata.load_or_build_idedata", return_value={"x": 1}
|
||||
) as load,
|
||||
):
|
||||
assert toolchain.get_idedata() == {"x": 1}
|
||||
load.assert_called_once_with(
|
||||
tmp_path / ".pioenvs" / "dev" / "compile_commands.json",
|
||||
tmp_path / ".pioenvs" / "dev" / PROGRAM_NAME,
|
||||
CORE.relative_internal_path("idedata", "dev.json"),
|
||||
launcher=ccache,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", ["darwin", "win32"])
|
||||
def test_check_analysis_supported_refuses_non_elf(platform: str) -> None:
|
||||
with (
|
||||
patch.object(toolchain.sys, "platform", platform),
|
||||
pytest.raises(EsphomeError, match=f"the host build on {platform}"),
|
||||
):
|
||||
toolchain.check_analysis_supported()
|
||||
|
||||
|
||||
def test_check_analysis_supported_accepts_linux() -> None:
|
||||
with patch.object(toolchain.sys, "platform", "linux"):
|
||||
toolchain.check_analysis_supported()
|
||||
+183
-29
@@ -7805,52 +7805,206 @@ async def test_wrap_to_code_comment_is_insertion_order_independent() -> None:
|
||||
assert second.index("a: 2") < second.index("z: 1")
|
||||
|
||||
|
||||
def test_host_program_path_platformio_toolchain() -> None:
|
||||
"""Host + PlatformIO toolchain reads the memoized idedata path."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
idedata = SimpleNamespace(firmware_elf_path="/build/x/.pioenvs/x/program")
|
||||
with patch(
|
||||
"esphome.platformio.toolchain.get_idedata", return_value=idedata
|
||||
) as mock_get:
|
||||
assert main._host_program_path({}) == "/build/x/.pioenvs/x/program"
|
||||
mock_get.assert_called_once_with({})
|
||||
|
||||
|
||||
def test_host_program_path_esp_idf_toolchain() -> None:
|
||||
"""Host + native ESP-IDF toolchain asks the espidf toolchain for the ELF."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
with patch(
|
||||
"esphome.espidf.toolchain.get_elf_path", return_value=Path("/b/app.elf")
|
||||
):
|
||||
assert main._host_program_path({}) == str(Path("/b/app.elf"))
|
||||
|
||||
|
||||
def test_command_compile_host_logs_program_path(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""command_compile on host logs the compiled program path."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
|
||||
with (
|
||||
patch.object(main, "write_cpp", return_value=0),
|
||||
patch.object(main, "compile_program", return_value=0),
|
||||
patch.object(main, "_host_program_path", return_value="/b/program"),
|
||||
caplog.at_level(logging.INFO),
|
||||
):
|
||||
assert main.command_compile(SimpleNamespace(only_generate=False), {}) == 0
|
||||
assert "Successfully compiled program to path '/b/program'" in caplog.text
|
||||
assert f"Successfully compiled program to path '{CORE.firmware_bin}'" in caplog.text
|
||||
|
||||
|
||||
def test_command_run_host_executes_program(caplog: pytest.LogCaptureFixture) -> None:
|
||||
def test_command_run_host_executes_program(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""command_run on host logs and executes the compiled program directly."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
|
||||
with (
|
||||
patch.object(main, "write_cpp", return_value=0),
|
||||
patch.object(main, "compile_program", return_value=0),
|
||||
patch.object(main, "_host_program_path", return_value="/b/program"),
|
||||
patch.object(main, "run_external_process", return_value=0) as mock_run,
|
||||
caplog.at_level(logging.INFO),
|
||||
):
|
||||
assert main.command_run(SimpleNamespace(), {}) == 0
|
||||
mock_run.assert_called_with("/b/program")
|
||||
assert "Running program from path '/b/program'" in caplog.text
|
||||
program = str(CORE.firmware_bin)
|
||||
mock_run.assert_called_with(program)
|
||||
assert f"Running program from path '{program}'" in caplog.text
|
||||
|
||||
|
||||
def test_write_cpp_file_project_generation_follows_host_toolchain() -> None:
|
||||
"""Only PlatformIO gets a platformio.ini; ESP-IDF writes its CMake project
|
||||
here, and the other native builds generate theirs at compile time."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
with (
|
||||
patch("esphome.writer.write_cpp"),
|
||||
patch("esphome.build_gen.platformio.write_project") as mock_pio,
|
||||
patch("esphome.build_gen.espidf.write_project") as mock_idf,
|
||||
):
|
||||
CORE.toolchain = Toolchain.HOST
|
||||
assert main.write_cpp_file() == 0
|
||||
mock_pio.assert_not_called()
|
||||
mock_idf.assert_not_called()
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
assert main.write_cpp_file() == 0
|
||||
mock_pio.assert_called_once()
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
assert main.write_cpp_file() == 0
|
||||
mock_idf.assert_called_once()
|
||||
|
||||
|
||||
def test_compile_program_host_uses_the_platform_hook() -> None:
|
||||
"""The host component's run_compile claims the build."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
CORE.toolchain = Toolchain.HOST
|
||||
with (
|
||||
patch("esphome.components.host.run_compile", return_value=True) as hook,
|
||||
patch("esphome.__main__._check_and_emit_build_info") as build_info,
|
||||
):
|
||||
assert compile_program(MagicMock(), {}) == 0
|
||||
hook.assert_called_once()
|
||||
build_info.assert_called_once()
|
||||
|
||||
|
||||
def test_compile_program_native_toolchain_needs_a_backend() -> None:
|
||||
"""A native toolchain no hook claims must not fall through to PlatformIO."""
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
CORE.toolchain = Toolchain.HOST
|
||||
with (
|
||||
patch("esphome.platformio.toolchain.run_compile") as mock_pio,
|
||||
# A platform package without a run_compile hook
|
||||
patch.dict(sys.modules, {"esphome.components.host": SimpleNamespace()}),
|
||||
pytest.raises(EsphomeError, match="no platform backend claimed the build"),
|
||||
):
|
||||
compile_program(MagicMock(), {})
|
||||
mock_pio.assert_not_called()
|
||||
|
||||
|
||||
def test_native_backend_resolves_host() -> None:
|
||||
from esphome.build_helpers.native import native_backend
|
||||
from esphome.host import toolchain as host_toolchain
|
||||
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
assert native_backend() is None
|
||||
CORE.toolchain = Toolchain.HOST
|
||||
assert native_backend() is host_toolchain
|
||||
# A native toolchain without a backend for the platform must not degrade
|
||||
CORE.toolchain = Toolchain.ARDUINO
|
||||
with pytest.raises(
|
||||
EsphomeError, match="has no native build backend module for platform host"
|
||||
):
|
||||
native_backend()
|
||||
|
||||
|
||||
def test_command_idedata_host_prints_json(capsys: CaptureFixture) -> None:
|
||||
setup_core(platform=PLATFORM_HOST)
|
||||
CORE.toolchain = Toolchain.HOST
|
||||
data = {"cxx_path": "g++", "prog_path": "/build/program"}
|
||||
with patch("esphome.host.toolchain.get_idedata", return_value=data) as mock_get:
|
||||
assert command_idedata(MagicMock(), CORE.config) == 0
|
||||
mock_get.assert_called_once_with()
|
||||
assert json.loads(capsys.readouterr().out) == data
|
||||
|
||||
|
||||
def _native_host_tools(tmp_path: Path) -> tuple[Path, Path, Path]:
|
||||
objdump, readelf, elf = (tmp_path / n for n in ("objdump", "readelf", "program"))
|
||||
for tool in (objdump, readelf, elf):
|
||||
tool.write_text("")
|
||||
return objdump, readelf, elf
|
||||
|
||||
|
||||
def test_command_analyze_memory_native_toolchain(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
mock_get_esphome_components: Mock,
|
||||
mock_memory_analyzer_cli: Mock,
|
||||
mock_ram_strings_analyzer: Mock,
|
||||
) -> None:
|
||||
"""A native backend supplies its own binutils and ELF, with no idedata."""
|
||||
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
|
||||
CORE.toolchain = Toolchain.HOST
|
||||
objdump, readelf, elf = _native_host_tools(tmp_path)
|
||||
config = {CONF_ESPHOME: {CONF_NAME: "dev"}}
|
||||
with (
|
||||
patch("esphome.host.toolchain.get_objdump_path", return_value=objdump),
|
||||
patch("esphome.host.toolchain.get_readelf_path", return_value=readelf),
|
||||
patch("esphome.host.toolchain.get_elf_path", return_value=elf),
|
||||
patch("esphome.host.toolchain.check_analysis_supported"),
|
||||
):
|
||||
assert command_analyze_memory(MockArgs(), config) == 0
|
||||
mock_memory_analyzer_cli.assert_called_once_with(
|
||||
str(elf), str(objdump), str(readelf), set(), idedata=None
|
||||
)
|
||||
mock_ram_strings_analyzer.assert_called_once_with(
|
||||
str(elf), objdump_path=str(objdump), platform="host"
|
||||
)
|
||||
assert "Mock Memory Report" in capfd.readouterr().out
|
||||
|
||||
|
||||
def test_command_analyze_memory_native_missing_tool_fails(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
) -> None:
|
||||
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
|
||||
CORE.toolchain = Toolchain.HOST
|
||||
objdump, readelf, elf = _native_host_tools(tmp_path)
|
||||
readelf.unlink()
|
||||
with (
|
||||
patch("esphome.host.toolchain.get_objdump_path", return_value=objdump),
|
||||
patch("esphome.host.toolchain.get_readelf_path", return_value=readelf),
|
||||
patch("esphome.host.toolchain.get_elf_path", return_value=elf),
|
||||
patch("esphome.host.toolchain.check_analysis_supported"),
|
||||
caplog.at_level(logging.ERROR),
|
||||
):
|
||||
assert command_analyze_memory(MockArgs(), {}) == 1
|
||||
assert f"{readelf} is missing; the toolchain install may be incomplete" in (
|
||||
caplog.text
|
||||
)
|
||||
|
||||
|
||||
def test_command_analyze_memory_host_missing_elf_fails(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
) -> None:
|
||||
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
|
||||
CORE.toolchain = Toolchain.HOST
|
||||
objdump, readelf, elf = _native_host_tools(tmp_path)
|
||||
elf.unlink()
|
||||
with (
|
||||
patch("esphome.host.toolchain.get_objdump_path", return_value=objdump),
|
||||
patch("esphome.host.toolchain.get_readelf_path", return_value=readelf),
|
||||
patch("esphome.host.toolchain.get_elf_path", return_value=elf),
|
||||
patch("esphome.host.toolchain.check_analysis_supported"),
|
||||
caplog.at_level(logging.ERROR),
|
||||
):
|
||||
assert command_analyze_memory(MockArgs(), {}) == 1
|
||||
assert f"{elf} is missing; compile the configuration first" in caplog.text
|
||||
|
||||
|
||||
def test_command_analyze_memory_host_refuses_before_compiling(
|
||||
tmp_path: Path,
|
||||
mock_write_cpp: Mock,
|
||||
mock_compile_program: Mock,
|
||||
) -> None:
|
||||
"""A machine whose host program is not ELF fails before the compile."""
|
||||
setup_core(platform=PLATFORM_HOST, tmp_path=tmp_path, name="dev")
|
||||
CORE.toolchain = Toolchain.HOST
|
||||
with (
|
||||
patch("esphome.host.toolchain.sys.platform", "darwin"),
|
||||
pytest.raises(EsphomeError, match="analyze-memory reads ELF files"),
|
||||
):
|
||||
command_analyze_memory(MockArgs(), {})
|
||||
mock_write_cpp.assert_not_called()
|
||||
mock_compile_program.assert_not_called()
|
||||
|
||||
@@ -407,6 +407,28 @@ def test_convert_libraries_redownloads_when_manifest_missing(
|
||||
assert top[0].data["name"] == "A"
|
||||
|
||||
|
||||
def test_convert_libraries_manifest_optional_uses_default_layout(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A backend accepting manifest-less libraries (the host build, like
|
||||
PlatformIO's native platform) gets a default manifest instead of a
|
||||
re-download that would repeat every build."""
|
||||
calls = _patch_download_without_manifest(
|
||||
monkeypatch, tmp_path, manifest_on_force=True
|
||||
)
|
||||
emitted: list[ConvertedLibrary] = []
|
||||
backend = _backend(emit=emitted.append)
|
||||
backend.manifest_optional = True
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="esphome.platformio.library"):
|
||||
top = convert_libraries([Library("esphome/A", "1.0.0", None)], backend)
|
||||
|
||||
assert calls == [False]
|
||||
assert top[0].data == {"name": "esphome/A"}
|
||||
assert emitted == top
|
||||
assert "has no manifest; using PlatformIO's default layout" in caplog.text
|
||||
|
||||
|
||||
def test_convert_libraries_raises_when_manifest_missing_after_retry(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -1251,3 +1273,50 @@ def test_versionless_dependency_matching_resolved_manifest_name_stays_quiet(
|
||||
_backend(),
|
||||
)
|
||||
assert "has no version to resolve" not in caplog.text
|
||||
|
||||
|
||||
def test_convert_libraries_symlink_url_resolves_as_local(setup_core: Path) -> None:
|
||||
"""symlink:// is PlatformIO's other spelling for a local library folder."""
|
||||
src = setup_core / "lib_dev"
|
||||
(src / "src").mkdir(parents=True)
|
||||
(src / "library.json").write_text(json.dumps({"name": "benchmark"}))
|
||||
url = src.as_uri().replace("file://", "symlink://", 1)
|
||||
|
||||
# Both the name=URL form and an explicit repository take the scheme
|
||||
for library in (
|
||||
Library(f"benchmark={url}", None, None),
|
||||
Library("benchmark", None, url),
|
||||
):
|
||||
top = convert_libraries([library], _backend())
|
||||
assert isinstance(top[0].source, LocalSource)
|
||||
assert top[0].source_path == src
|
||||
|
||||
|
||||
def test_convert_libraries_incompatible_names_the_platform_without_a_framework(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""The host has no framework; the error must not read 'compatible with None'."""
|
||||
src = setup_core / "lib_dev"
|
||||
src.mkdir()
|
||||
(src / "library.json").write_text(
|
||||
json.dumps({"name": "Only32", "platforms": "espressif32"})
|
||||
)
|
||||
backend = _backend()
|
||||
backend.platform = "native"
|
||||
backend.framework = None
|
||||
with pytest.raises(RuntimeError, match="Only32 is not compatible with native"):
|
||||
convert_libraries([Library("Only32", None, src.as_uri())], backend)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "message"),
|
||||
[
|
||||
("symlink://host/lib", "Unsupported host in symlink:// library URL"),
|
||||
("symlink:lib_dev", "symlink:// library URL .* must be an absolute"),
|
||||
],
|
||||
)
|
||||
def test_convert_libraries_symlink_url_errors_name_the_scheme(
|
||||
setup_core: Path, url: str, message: str
|
||||
) -> None:
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
convert_libraries([Library("benchmark", None, url)], _backend())
|
||||
|
||||
Reference in New Issue
Block a user