Files
esphome/script/test_helpers.py
T
J. Nick Koston dd52c91290 Add inner iteration loops to amortize CodSpeed instrumentation overhead
Sub-microsecond benchmarks are dominated by the ~60ns per-iteration
valgrind start/stop cost in CodSpeed simulation mode. Add kInnerIterations
(1000) inner loops to all fast benchmarks so the actual work dominates.
Move DoNotOptimize calls outside inner loops to prevent artificial overhead.

Also address review feedback:
- Use tokenless CodSpeed (public repo, no CODSPEED_TOKEN needed)
- Fix warning message to show component-specific path
- Fix stray ". :" in error message
- Verify pinned SHA on re-runs to prevent stale checkouts
2026-03-16 23:32:49 -10:00

396 lines
15 KiB
Python

"""Shared helpers for C++ unit test and benchmark build scripts."""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
import subprocess
import sys
from helpers import get_all_dependencies
import yaml
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.loader import get_component
from esphome.platformio_api import get_idedata
# This must coincide with the version in /platformio.ini
PLATFORMIO_GOOGLE_TEST_LIB = "google/googletest@^1.15.2"
# Google Benchmark library for PlatformIO
# Format: name=repository_url (see esphome/core/config.py library parsing)
PLATFORMIO_GOOGLE_BENCHMARK_LIB = (
"benchmark=https://github.com/google/benchmark.git#v1.9.1"
)
# Key names for the base config sections
ESPHOME_KEY = "esphome"
HOST_KEY = "host"
LOGGER_KEY = "logger"
# Base config keys that are always present and must not be fully overridden
# by component benchmark.yaml files. esphome: allows sub-key merging.
BASE_CONFIG_KEYS = frozenset({ESPHOME_KEY, HOST_KEY, LOGGER_KEY})
# Shared build flag — enables timezone code paths for testing/benchmarking.
USE_TIME_TIMEZONE_FLAG = "-DUSE_TIME_TIMEZONE"
# Components whose to_code should always run during C++ test/benchmark builds.
# These are the minimal infrastructure components needed for host compilation.
# Note: "core" is the esphome core config module (esphome/core/config.py),
# which registers under package name "core" not "esphome".
BASE_CODEGEN_COMPONENTS = {"core", "host", "logger"}
# Exit codes
EXIT_OK = 0
EXIT_SKIPPED = 1
EXIT_COMPILE_ERROR = 2
EXIT_CONFIG_ERROR = 3
EXIT_NO_EXECUTABLE = 4
# Name of the per-component YAML config file in benchmark directories
BENCHMARK_YAML_FILENAME = "benchmark.yaml"
def hash_components(components: list[str]) -> str:
"""Create a short hash of component names for unique config naming."""
key = ",".join(components)
return hashlib.sha256(key.encode()).hexdigest()[:16]
def filter_components_with_files(components: list[str], tests_dir: Path) -> list[str]:
"""Filter out components that do not have .cpp or .h files in the tests dir.
Args:
components: List of component names to check
tests_dir: Base directory containing component test/benchmark folders
Returns:
Filtered list of components that have test files
"""
filtered_components: list[str] = []
for component in components:
test_dir = tests_dir / component
if test_dir.is_dir() and (
any(test_dir.glob("*.cpp")) or any(test_dir.glob("*.h"))
):
filtered_components.append(component)
else:
print(
f"WARNING: No files found for component '{component}' in {test_dir}, skipping.",
file=sys.stderr,
)
return filtered_components
def get_platform_components(components: list[str], tests_dir: Path) -> list[str]:
"""Discover platform sub-components referenced by test directory structure.
For each component, any sub-directory named after a platform domain
(e.g. ``sensor``, ``binary_sensor``) is treated as a request to include
that ``<domain>.<component>`` platform in the build.
Args:
components: List of component names to scan
tests_dir: Base directory containing component test/benchmark folders
Returns:
List of ``"domain.component"`` strings
"""
platform_components: list[str] = []
for component in components:
test_dir = tests_dir / component
if not test_dir.is_dir():
continue
for domain_dir in test_dir.iterdir():
if not domain_dir.is_dir():
continue
domain = domain_dir.name
domain_module = get_component(domain)
if domain_module is None or not domain_module.is_platform_component:
raise ValueError(
f"Component '{component}' references non-existing or invalid domain '{domain}'"
f" in its directory structure. See ({tests_dir / component / domain})."
)
platform_components.append(f"{domain}.{component}")
return platform_components
def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict:
"""Load and merge benchmark.yaml files from component directories.
Each component directory may contain a ``benchmark.yaml`` file that
declares additional ESPHome components needed for the build (e.g.
``api:``, ``sensor:``). These get merged into the base config before
validation so that dependencies are properly resolved with defaults.
The ``esphome:`` key is special: its sub-keys are merged into the
existing esphome config (e.g. to add ``areas:`` or ``devices:``).
Other base config keys (``host:``, ``logger:``) are not overridable.
Args:
components: List of component directory names
tests_dir: Base directory containing component folders
Returns:
Merged dict of component configs to add to the base config
"""
merged: dict = {}
for component in components:
yaml_path = tests_dir / component / BENCHMARK_YAML_FILENAME
if not yaml_path.is_file():
continue
with open(yaml_path) as f:
component_config = yaml.safe_load(f)
if component_config and isinstance(component_config, dict):
for key, value in component_config.items():
if key in BASE_CONFIG_KEYS - {ESPHOME_KEY}:
# host: and logger: are not overridable
continue
if key == ESPHOME_KEY and isinstance(value, dict):
# Merge esphome sub-keys rather than replacing
esphome_extra = merged.setdefault(ESPHOME_KEY, {})
for sub_key, sub_value in value.items():
esphome_extra.setdefault(sub_key, sub_value)
continue
merged.setdefault(key, value)
return merged
def create_host_config(
config_name: str,
friendly_name: str,
libraries: str | list[str],
includes: list[str],
platformio_options: dict,
) -> dict:
"""Create an ESPHome host configuration for C++ builds.
Args:
config_name: Unique name for this configuration
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
Returns:
Configuration dict for ESPHome
"""
return {
ESPHOME_KEY: {
"name": config_name,
"friendly_name": friendly_name,
"libraries": libraries,
"platformio_options": platformio_options,
"includes": includes,
},
HOST_KEY: {},
LOGGER_KEY: {"level": "DEBUG"},
}
def compile_and_get_binary(
config: dict,
components: list[str],
codegen_components: set[str],
tests_dir: Path,
label: str = "build",
) -> tuple[int, str | None]:
"""Compile an ESPHome configuration and return the binary path.
Args:
config: ESPHome configuration dict (already created via create_host_config)
components: List of components to include in the build
codegen_components: Set of component names whose to_code should run
tests_dir: Base directory for test files (used as config_path base)
label: Label for log messages (e.g. "unit tests", "benchmarks")
Returns:
Tuple of (exit_code, program_path_or_none)
"""
# Load any benchmark.yaml files from component directories and merge
# them into the config BEFORE dependency resolution and validation.
# This allows each benchmark/test dir to declare which ESPHome components
# it needs (e.g. api:) so they get proper config defaults.
extra_config = load_component_yaml_configs(components, tests_dir)
for key, value in extra_config.items():
if key == ESPHOME_KEY and isinstance(value, dict):
# Merge esphome sub-keys into existing esphome config.
# For list values (e.g. libraries), extend rather than replace.
for sub_key, sub_value in value.items():
existing = config[ESPHOME_KEY].get(sub_key)
if existing is not None and isinstance(sub_value, list):
# Ensure existing is a list, then extend
if not isinstance(existing, list):
config[ESPHOME_KEY][sub_key] = [existing]
config[ESPHOME_KEY][sub_key].extend(sub_value)
else:
config[ESPHOME_KEY].setdefault(sub_key, sub_value)
else:
config.setdefault(key, value)
# Obtain possible dependencies BEFORE validate_config, because
# get_all_dependencies calls CORE.reset() which clears build_path.
# Always include 'time' because USE_TIME_TIMEZONE is defined as a build flag,
# which causes core/time.h to include components/time/posix_tz.h.
components_with_dependencies: list[str] = sorted(
get_all_dependencies(set(components) | {"time"}, cpp_testing=True)
)
CORE.config_path = tests_dir / "dummy.yaml"
CORE.dashboard = None
CORE.cpp_testing = True
CORE.cpp_testing_codegen = codegen_components
# Validate config will expand the above with defaults:
config = validate_config(config, {})
# Add remaining components and dependencies to the configuration after
# validation, so their source files are included in the build.
for component_name in components_with_dependencies:
if "." in component_name:
domain, component = component_name.split(".", maxsplit=1)
domain_list = config.setdefault(domain, [])
CORE.testing_ensure_platform_registered(domain)
domain_list.append({CONF_PLATFORM: component})
# Skip "core" — it's a pseudo-component handled by the build
# system, not a real loadable component (get_component returns None)
elif get_component(component_name) is not None:
config.setdefault(component_name, [])
# Register platforms from the extra config (benchmark.yaml) so
# USE_SENSOR, USE_LIGHT, etc. defines are emitted without needing
# real entity instances.
for key in extra_config:
if key == ESPHOME_KEY:
continue
comp = get_component(key)
if comp is not None and comp.is_platform_component:
CORE.testing_ensure_platform_registered(key)
dependencies = set(components_with_dependencies) - set(components)
deps_str = ", ".join(dependencies) if dependencies else "None"
print(f"Building {label}: {', '.join(components)}. Dependencies: {deps_str}")
CORE.config = config
args = parse_args(["program", "compile", str(CORE.config_path)])
try:
exit_code: int = command_compile(args, config)
if exit_code != 0:
print(f"Error compiling {label} for {', '.join(components)}")
return exit_code, None
except Exception as e:
print(f"Error compiling {label} for {', '.join(components)}: {e}")
return EXIT_COMPILE_ERROR, None
# After a successful compilation, locate the executable:
idedata = get_idedata(config)
if idedata is None:
print("Cannot find executable")
return EXIT_NO_EXECUTABLE, None
program_path: str = idedata.raw["prog_path"]
return EXIT_OK, program_path
def build_and_run(
selected_components: list[str],
tests_dir: Path,
codegen_components: set[str],
config_prefix: str,
friendly_name: str,
libraries: str | list[str],
platformio_options: dict,
main_entry: str,
label: str = "build",
build_only: bool = False,
extra_run_args: list[str] | None = None,
extra_include_dirs: list[Path] | None = None,
) -> int:
"""Build and optionally run a C++ test/benchmark binary.
This is the main orchestration function shared between unit tests
and benchmarks.
Args:
selected_components: Components to include (directory names in tests_dir)
tests_dir: Directory containing test/benchmark files
codegen_components: Components whose to_code should 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
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
extra_run_args: Extra arguments to pass to the binary
extra_include_dirs: Additional directories whose .cpp files
should be compiled (resolved relative to tests_dir if possible)
Returns:
Exit code
"""
# Skip on Windows
if os.name == "nt":
print(f"Skipping {label} on Windows", file=sys.stderr)
return EXIT_SKIPPED
# Remove components that do not have files
components = filter_components_with_files(selected_components, tests_dir)
if len(components) == 0:
print(
f"No components specified or no files found for {label}.",
file=sys.stderr,
)
return EXIT_OK
components = sorted(components)
# Build include list: main entry point + component folders + extra dirs
includes: list[str] = [main_entry] + components
if extra_include_dirs:
for d in extra_include_dirs:
if d.is_dir() and (any(d.glob("*.cpp")) or any(d.glob("*.h"))):
# ESPHome includes are relative to the config directory (tests_dir)
rel = os.path.relpath(d, tests_dir)
includes.append(rel)
# Discover platform sub-components
try:
platform_components = get_platform_components(components, tests_dir)
except ValueError as e:
print(f"Error obtaining platform components: {e}")
return EXIT_CONFIG_ERROR
components = sorted(components + platform_components)
# Create unique config name
config_name: str = f"{config_prefix}-" + hash_components(components)
config = create_host_config(
config_name, friendly_name, libraries, includes, platformio_options
)
exit_code, program_path = compile_and_get_binary(
config, components, codegen_components, tests_dir, label
)
if exit_code != EXIT_OK or program_path is None:
return exit_code
if build_only:
print(program_path)
return EXIT_OK
# Run the binary
run_cmd: list[str] = [program_path]
if extra_run_args:
run_cmd.extend(extra_run_args)
run_proc = subprocess.run(run_cmd, check=False)
return run_proc.returncode