mirror of
https://github.com/esphome/esphome.git
synced 2026-09-26 14:30:23 +00:00
Merge branch 'dev' into rp2040-lwip-tune
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
"""Shared helpers for C++ unit test and benchmark build scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from helpers import get_all_dependencies, root_path as _root_path
|
||||
import yaml
|
||||
|
||||
# Ensure the repo root is on sys.path so that ``tests.testing_helpers`` and
|
||||
# override ``__init__.py`` modules can ``from tests.testing_helpers import ...``.
|
||||
if _root_path not in sys.path:
|
||||
sys.path.insert(0, _root_path)
|
||||
|
||||
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, get_platform
|
||||
from esphome.platformio_api import get_idedata
|
||||
from tests.testing_helpers import ComponentManifestOverride, set_testing_manifest
|
||||
|
||||
# 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"
|
||||
|
||||
# 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
|
||||
if domain.startswith("__"):
|
||||
continue
|
||||
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:``).
|
||||
Keys already present in the base config (e.g. ``host:``, ``logger:``)
|
||||
are protected by ``setdefault`` in the caller.
|
||||
|
||||
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 == 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 _wrap_manifest(
|
||||
comp_name: str,
|
||||
) -> ComponentManifestOverride | None:
|
||||
"""Wrap a component manifest in a ComponentManifestOverride with to_code suppressed.
|
||||
|
||||
If the manifest is already wrapped or not found, returns None.
|
||||
Otherwise returns the newly created override after installing it.
|
||||
"""
|
||||
if "." in comp_name:
|
||||
domain, component = comp_name.split(".", maxsplit=1)
|
||||
manifest = get_platform(domain, component)
|
||||
cache_key = f"{component}.{domain}"
|
||||
else:
|
||||
manifest = get_component(comp_name)
|
||||
cache_key = comp_name
|
||||
|
||||
if manifest is None or isinstance(manifest, ComponentManifestOverride):
|
||||
return None
|
||||
|
||||
override = ComponentManifestOverride(manifest)
|
||||
override.to_code = None # suppress by default
|
||||
set_testing_manifest(cache_key, override)
|
||||
return override
|
||||
|
||||
|
||||
def load_test_manifest_overrides(
|
||||
components: list[str],
|
||||
tests_dir: Path,
|
||||
) -> None:
|
||||
"""Apply per-component manifest overrides from test ``__init__.py`` files.
|
||||
|
||||
For every component, wraps its manifest and suppresses ``to_code``.
|
||||
If the component's test directory contains an ``__init__.py`` that
|
||||
defines ``override_manifest(manifest)``, it is called to customise
|
||||
the override (e.g. ``manifest.enable_codegen()``).
|
||||
"""
|
||||
for comp_name in components:
|
||||
override = _wrap_manifest(comp_name)
|
||||
if override is None:
|
||||
continue
|
||||
|
||||
if "." in comp_name:
|
||||
domain, component = comp_name.split(".", maxsplit=1)
|
||||
cache_key = f"{component}.{domain}"
|
||||
test_init = tests_dir / component / domain / "__init__.py"
|
||||
else:
|
||||
cache_key = comp_name
|
||||
test_init = tests_dir / comp_name / "__init__.py"
|
||||
|
||||
if not test_init.is_file():
|
||||
continue
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"_test_manifest_override.{cache_key}", test_init
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
override_fn = getattr(mod, "override_manifest", None)
|
||||
if override_fn is not None:
|
||||
override_fn(override)
|
||||
|
||||
|
||||
# Type alias for manifest override loaders
|
||||
ManifestOverrideLoader = Callable[[list[str]], None]
|
||||
|
||||
|
||||
def compile_and_get_binary(
|
||||
config: dict,
|
||||
components: list[str],
|
||||
tests_dir: Path,
|
||||
manifest_override_loader: ManifestOverrideLoader,
|
||||
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
|
||||
tests_dir: Base directory for test files (used as config_path base)
|
||||
manifest_override_loader: Callback to apply manifest overrides for components
|
||||
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)
|
||||
|
||||
# Apply manifest overrides before dependency resolution so that any
|
||||
# dependency additions made by override_manifest() are picked up.
|
||||
manifest_override_loader(components)
|
||||
|
||||
# Obtain possible dependencies BEFORE validate_config, because
|
||||
# get_all_dependencies calls CORE.reset() which clears build_path.
|
||||
components_with_dependencies: list[str] = sorted(
|
||||
get_all_dependencies(set(components))
|
||||
)
|
||||
|
||||
# Apply overrides for any transitively discovered dependencies.
|
||||
manifest_override_loader(components_with_dependencies)
|
||||
|
||||
CORE.config_path = tests_dir / "dummy.yaml"
|
||||
CORE.dashboard = None
|
||||
|
||||
# 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,
|
||||
manifest_override_loader: ManifestOverrideLoader,
|
||||
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
|
||||
manifest_override_loader: Callback to apply manifest overrides for components
|
||||
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, tests_dir, manifest_override_loader, label
|
||||
)
|
||||
|
||||
if exit_code != EXIT_OK or program_path is None:
|
||||
return exit_code
|
||||
|
||||
if build_only:
|
||||
print(f"BUILD_BINARY={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
|
||||
@@ -231,6 +231,9 @@ def main():
|
||||
|
||||
cwd = os.getcwd()
|
||||
files = [os.path.relpath(path, cwd) for path in git_ls_files(["*.cpp"])]
|
||||
# Exclude benchmark files — they require google benchmark headers not
|
||||
# available in the ESP32 toolchain and use different naming conventions.
|
||||
files = [f for f in files if not f.startswith("tests/benchmarks/")]
|
||||
|
||||
# Print initial file count if it's large
|
||||
if len(files) > 50:
|
||||
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and run C++ benchmarks for ESPHome components using Google Benchmark."""
|
||||
|
||||
import argparse
|
||||
from functools import partial
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from build_helpers import (
|
||||
PLATFORMIO_GOOGLE_BENCHMARK_LIB,
|
||||
build_and_run,
|
||||
load_test_manifest_overrides,
|
||||
)
|
||||
from helpers import root_path
|
||||
|
||||
# Path to /tests/benchmarks/components
|
||||
BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components"
|
||||
|
||||
# Path to /tests/benchmarks/core (always included, not a component)
|
||||
CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core"
|
||||
|
||||
PLATFORMIO_OPTIONS = {
|
||||
"build_unflags": [
|
||||
"-Os", # remove default size-opt
|
||||
],
|
||||
"build_flags": [
|
||||
"-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo)
|
||||
"-g", # debug symbols for profiling
|
||||
"-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish()
|
||||
],
|
||||
# Use deep+ LDF mode to ensure PlatformIO detects the benchmark
|
||||
# library dependency from nested includes.
|
||||
"lib_ldf_mode": "deep+",
|
||||
}
|
||||
|
||||
|
||||
def run_benchmarks(selected_components: list[str], build_only: bool = False) -> int:
|
||||
# Allow CI to override the benchmark library (e.g. with CodSpeed's fork).
|
||||
# BENCHMARK_LIB_CONFIG is a JSON string from setup_codspeed_lib.py
|
||||
# containing {"lib_path": "/path/to/google_benchmark"}.
|
||||
lib_config_json = os.environ.get("BENCHMARK_LIB_CONFIG")
|
||||
|
||||
pio_options = PLATFORMIO_OPTIONS
|
||||
if lib_config_json:
|
||||
lib_config = json.loads(lib_config_json)
|
||||
benchmark_lib = f"benchmark=symlink://{lib_config['lib_path']}"
|
||||
# These defines must be global (not just in library.json) because
|
||||
# benchmark.h uses #ifdef CODSPEED_ENABLED to switch benchmark
|
||||
# registration to CodSpeed-instrumented variants, and
|
||||
# CODSPEED_ROOT_DIR is used to display relative file paths in reports.
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
codspeed_flags = [
|
||||
"-DNDEBUG",
|
||||
"-DCODSPEED_ENABLED",
|
||||
"-DCODSPEED_ANALYSIS",
|
||||
f'-DCODSPEED_ROOT_DIR=\\"{project_root}\\"',
|
||||
]
|
||||
pio_options = {
|
||||
**PLATFORMIO_OPTIONS,
|
||||
"build_flags": PLATFORMIO_OPTIONS["build_flags"] + codspeed_flags,
|
||||
}
|
||||
else:
|
||||
benchmark_lib = PLATFORMIO_GOOGLE_BENCHMARK_LIB
|
||||
|
||||
return build_and_run(
|
||||
selected_components=selected_components,
|
||||
tests_dir=BENCHMARKS_DIR,
|
||||
manifest_override_loader=partial(
|
||||
load_test_manifest_overrides, tests_dir=BENCHMARKS_DIR
|
||||
),
|
||||
config_prefix="cppbench",
|
||||
friendly_name="CPP Benchmarks",
|
||||
libraries=benchmark_lib,
|
||||
platformio_options=pio_options,
|
||||
main_entry="main.cpp",
|
||||
label="benchmarks",
|
||||
build_only=build_only,
|
||||
extra_include_dirs=[CORE_BENCHMARKS_DIR],
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build and run C++ benchmarks for ESPHome components."
|
||||
)
|
||||
parser.add_argument(
|
||||
"components",
|
||||
nargs="*",
|
||||
help="List of components to benchmark (must have files in tests/benchmarks/components/).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Benchmark all components with benchmark files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build-only",
|
||||
action="store_true",
|
||||
help="Only build, print binary path without running.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.all:
|
||||
# Find all component directories that have .cpp files
|
||||
components: list[str] = (
|
||||
sorted(
|
||||
d.name
|
||||
for d in BENCHMARKS_DIR.iterdir()
|
||||
if d.is_dir()
|
||||
and d.name != "__pycache__"
|
||||
and (any(d.glob("*.cpp")) or any(d.glob("*.h")))
|
||||
)
|
||||
if BENCHMARKS_DIR.is_dir()
|
||||
else []
|
||||
)
|
||||
else:
|
||||
components: list[str] = args.components
|
||||
|
||||
sys.exit(run_benchmarks(components, build_only=args.build_only))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+38
-220
@@ -1,238 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import hashlib
|
||||
from functools import partial
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from helpers import get_all_components, get_all_dependencies, root_path
|
||||
|
||||
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"
|
||||
from build_helpers import (
|
||||
PLATFORMIO_GOOGLE_TEST_LIB,
|
||||
build_and_run,
|
||||
load_test_manifest_overrides,
|
||||
)
|
||||
from helpers import get_all_components, root_path
|
||||
|
||||
# Path to /tests/components
|
||||
COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components"
|
||||
|
||||
# Components whose to_code should run during C++ test builds.
|
||||
# Most components don't need code generation for tests; only these
|
||||
# essential ones (platform setup, logging, core config) are needed.
|
||||
# Note: "core" is the esphome core config module (esphome/core/config.py),
|
||||
# which registers under package name "core" not "esphome".
|
||||
CPP_TESTING_CODEGEN_COMPONENTS = {"core", "host", "logger"}
|
||||
|
||||
|
||||
def hash_components(components: list[str]) -> str:
|
||||
key = ",".join(components)
|
||||
return hashlib.sha256(key.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def filter_components_without_tests(components: list[str]) -> list[str]:
|
||||
"""Filter out components that do not have a corresponding test file.
|
||||
|
||||
This is done by checking if the component's directory contains at
|
||||
least a .cpp or .h file.
|
||||
"""
|
||||
filtered_components: list[str] = []
|
||||
for component in components:
|
||||
test_dir = COMPONENTS_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 tests found for component '{component}', skipping.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return filtered_components
|
||||
|
||||
|
||||
def create_test_config(config_name: str, includes: list[str]) -> dict:
|
||||
"""Create ESPHome test configuration for C++ unit tests.
|
||||
|
||||
Args:
|
||||
config_name: Unique name for this test configuration
|
||||
includes: List of include folders for the test build
|
||||
|
||||
Returns:
|
||||
Configuration dict for ESPHome
|
||||
"""
|
||||
return {
|
||||
"esphome": {
|
||||
"name": config_name,
|
||||
"friendly_name": "CPP Unit Tests",
|
||||
"libraries": PLATFORMIO_GOOGLE_TEST_LIB,
|
||||
"platformio_options": {
|
||||
"build_type": "debug",
|
||||
"build_unflags": [
|
||||
"-Os", # remove size-opt flag
|
||||
],
|
||||
"build_flags": [
|
||||
"-Og", # optimize for debug
|
||||
"-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing
|
||||
"-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",
|
||||
],
|
||||
},
|
||||
"includes": includes,
|
||||
},
|
||||
"host": {},
|
||||
"logger": {"level": "DEBUG"},
|
||||
}
|
||||
|
||||
|
||||
def get_platform_components(components: list[str]) -> list[str]:
|
||||
"""Discover platform sub-components referenced by test directory structure.
|
||||
|
||||
For each component being tested, 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. The sub-
|
||||
directory must name a valid platform domain; anything else raises an error
|
||||
so that typos are caught early.
|
||||
|
||||
Returns:
|
||||
List of ``"domain.component"`` strings, one per discovered sub-directory.
|
||||
"""
|
||||
platform_components: list[str] = []
|
||||
for component in components:
|
||||
test_dir = COMPONENTS_TESTS_DIR / component
|
||||
if not test_dir.is_dir():
|
||||
continue
|
||||
# Each sub-directory name is expected to be a platform domain
|
||||
# (e.g. tests/components/bthome/sensor/ → sensor.bthome).
|
||||
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 tests for '{component}' reference non-existing or invalid domain '{domain}'"
|
||||
f" in its directory structure. See ({COMPONENTS_TESTS_DIR / component / domain})."
|
||||
)
|
||||
platform_components.append(f"{domain}.{component}")
|
||||
return platform_components
|
||||
|
||||
|
||||
# Exit codes for run_tests
|
||||
EXIT_OK = 0
|
||||
EXIT_SKIPPED = 1
|
||||
EXIT_COMPILE_ERROR = 2
|
||||
EXIT_CONFIG_ERROR = 3
|
||||
EXIT_NO_EXECUTABLE = 4
|
||||
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",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def run_tests(selected_components: list[str]) -> int:
|
||||
# Skip tests on Windows
|
||||
if os.name == "nt":
|
||||
print("Skipping esphome tests on Windows", file=sys.stderr)
|
||||
return EXIT_SKIPPED
|
||||
|
||||
# Remove components that do not have tests
|
||||
components = filter_components_without_tests(selected_components)
|
||||
|
||||
if len(components) == 0:
|
||||
print(
|
||||
"No components specified or no tests found for the specified components.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return EXIT_OK
|
||||
|
||||
components = sorted(components)
|
||||
|
||||
# Build a list of include folders relative to COMPONENTS_TESTS_DIR. These folders will
|
||||
# be added along with their subfolders.
|
||||
# "main.cpp" is a special entry that points to /tests/components/main.cpp,
|
||||
# which provides a custom test runner entry-point replacing the default one.
|
||||
# Each remaining entry is a component folder whose *.cpp files are compiled.
|
||||
includes: list[str] = ["main.cpp"] + components
|
||||
|
||||
# Obtain a list of platform components to be tested:
|
||||
try:
|
||||
platform_components = get_platform_components(components)
|
||||
except ValueError as e:
|
||||
print(f"Error obtaining platform components: {e}")
|
||||
return EXIT_CONFIG_ERROR
|
||||
|
||||
components = sorted(components + platform_components)
|
||||
|
||||
# Create a unique name for this config based on the actual components being tested
|
||||
# to maximize cache during testing
|
||||
config_name: str = "cpptests-" + hash_components(components)
|
||||
|
||||
# Obtain possible dependencies for the requested components.
|
||||
# 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)
|
||||
os.environ["ASAN_OPTIONS"] = "detect_leaks=0"
|
||||
return build_and_run(
|
||||
selected_components=selected_components,
|
||||
tests_dir=COMPONENTS_TESTS_DIR,
|
||||
manifest_override_loader=partial(
|
||||
load_test_manifest_overrides, tests_dir=COMPONENTS_TESTS_DIR
|
||||
),
|
||||
config_prefix="cpptests",
|
||||
friendly_name="CPP Unit Tests",
|
||||
libraries=PLATFORMIO_GOOGLE_TEST_LIB,
|
||||
platformio_options=PLATFORMIO_OPTIONS,
|
||||
main_entry="main.cpp",
|
||||
label="unit tests",
|
||||
)
|
||||
|
||||
config = create_test_config(config_name, includes)
|
||||
|
||||
CORE.config_path = COMPONENTS_TESTS_DIR / "dummy.yaml"
|
||||
CORE.dashboard = None
|
||||
CORE.cpp_testing = True
|
||||
CORE.cpp_testing_codegen = CPP_TESTING_CODEGEN_COMPONENTS
|
||||
|
||||
# Validate config will expand the above with defaults:
|
||||
config = validate_config(config, {})
|
||||
|
||||
# Add all components and dependencies to the base configuration after validation, so their files
|
||||
# are added to the build.
|
||||
for component_name in components_with_dependencies:
|
||||
if "." in component_name:
|
||||
# Format is always "domain.component" (exactly one dot),
|
||||
# as produced by get_platform_components().
|
||||
domain, component = component_name.split(".", maxsplit=1)
|
||||
domain_list = config.setdefault(domain, [])
|
||||
CORE.testing_ensure_platform_registered(domain)
|
||||
domain_list.append({CONF_PLATFORM: component})
|
||||
else:
|
||||
config.setdefault(component_name, [])
|
||||
|
||||
dependencies = set(components_with_dependencies) - set(components)
|
||||
deps_str = ", ".join(dependencies) if dependencies else "None"
|
||||
print(f"Testing components: {', '.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 unit tests for {', '.join(components)}")
|
||||
return exit_code
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error compiling unit tests for {', '.join(components)}. Check path. : {e}"
|
||||
)
|
||||
return EXIT_COMPILE_ERROR
|
||||
|
||||
# After a successful compilation, locate the executable and run it:
|
||||
idedata = get_idedata(config)
|
||||
if idedata is None:
|
||||
print("Cannot find executable")
|
||||
return EXIT_NO_EXECUTABLE
|
||||
|
||||
program_path: str = idedata.raw["prog_path"]
|
||||
run_cmd: list[str] = [program_path]
|
||||
run_proc = subprocess.run(run_cmd, check=False)
|
||||
return run_proc.returncode
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
|
||||
@@ -111,11 +111,13 @@ PLATFORM_SPECIFIC_COMPONENTS = frozenset(
|
||||
"esp32", # ESP32 platform implementation
|
||||
"esp8266", # ESP8266 platform implementation
|
||||
"rp2040", # Raspberry Pi Pico / RP2040 platform implementation
|
||||
"libretiny", # LibreTiny base platform implementation
|
||||
"bk72xx", # Beken BK72xx platform implementation (uses LibreTiny)
|
||||
"rtl87xx", # Realtek RTL87xx platform implementation (uses LibreTiny)
|
||||
"ln882x", # Winner Micro LN882x platform implementation (uses LibreTiny)
|
||||
"host", # Host platform (for testing on development machine)
|
||||
"nrf52", # Nordic nRF52 platform implementation (uses Zephyr)
|
||||
"zephyr", # Zephyr RTOS platform implementation
|
||||
}
|
||||
)
|
||||
|
||||
@@ -381,6 +383,63 @@ def determine_cpp_unit_tests(
|
||||
return (False, get_cpp_changed_components(cpp_files))
|
||||
|
||||
|
||||
# Paths within tests/benchmarks/ that contain component benchmark files
|
||||
BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components"
|
||||
|
||||
# Files that, when changed, should trigger benchmark runs
|
||||
BENCHMARK_INFRASTRUCTURE_FILES = frozenset(
|
||||
{
|
||||
"script/cpp_benchmark.py",
|
||||
"script/build_helpers.py",
|
||||
"script/setup_codspeed_lib.py",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def should_run_benchmarks(branch: str | None = None) -> bool:
|
||||
"""Determine if C++ benchmarks should run based on changed files.
|
||||
|
||||
Benchmarks run when any of the following conditions are met:
|
||||
|
||||
1. Core C++ files changed (esphome/core/*)
|
||||
2. A directly changed component has benchmark files (no dependency expansion)
|
||||
3. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py,
|
||||
script/build_helpers.py, script/setup_codspeed_lib.py)
|
||||
|
||||
Unlike unit tests, benchmarks do NOT expand to dependent components.
|
||||
Changing ``sensor`` does not trigger ``api`` benchmarks just because
|
||||
api depends on sensor.
|
||||
|
||||
Args:
|
||||
branch: Branch to compare against. If None, uses default.
|
||||
|
||||
Returns:
|
||||
True if benchmarks should run, False otherwise.
|
||||
"""
|
||||
files = changed_files(branch)
|
||||
if core_changed(files):
|
||||
return True
|
||||
|
||||
# Check if benchmark infrastructure changed
|
||||
if any(
|
||||
f.startswith("tests/benchmarks/") or f in BENCHMARK_INFRASTRUCTURE_FILES
|
||||
for f in files
|
||||
):
|
||||
return True
|
||||
|
||||
# Check if any directly changed component has benchmarks
|
||||
benchmarks_dir = Path(root_path) / BENCHMARKS_COMPONENTS_PATH
|
||||
if not benchmarks_dir.is_dir():
|
||||
return False
|
||||
benchmarked_components = {
|
||||
d.name
|
||||
for d in benchmarks_dir.iterdir()
|
||||
if d.is_dir() and (any(d.glob("*.cpp")) or any(d.glob("*.h")))
|
||||
}
|
||||
# Only direct changes — no dependency expansion
|
||||
return any(get_component_from_path(f) in benchmarked_components for f in files)
|
||||
|
||||
|
||||
def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool:
|
||||
"""Check if a changed file ends with any of the specified extensions."""
|
||||
return any(file.endswith(extensions) for file in changed_files(branch))
|
||||
@@ -804,6 +863,9 @@ def main() -> None:
|
||||
# Determine which C++ unit tests to run
|
||||
cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch)
|
||||
|
||||
# Determine if benchmarks should run
|
||||
run_benchmarks = should_run_benchmarks(args.branch)
|
||||
|
||||
# Split components into batches for CI testing
|
||||
# This intelligently groups components with similar bus configurations
|
||||
component_test_batches: list[str]
|
||||
@@ -856,6 +918,7 @@ def main() -> None:
|
||||
"cpp_unit_tests_run_all": cpp_run_all,
|
||||
"cpp_unit_tests_components": cpp_components,
|
||||
"component_test_batches": component_test_batches,
|
||||
"benchmarks": run_benchmarks,
|
||||
}
|
||||
|
||||
# Output as JSON
|
||||
|
||||
+1
-4
@@ -627,14 +627,12 @@ def get_usable_cpu_count() -> int:
|
||||
|
||||
|
||||
def get_all_dependencies(
|
||||
component_names: set[str], cpp_testing: bool = False
|
||||
component_names: set[str],
|
||||
) -> set[str]:
|
||||
"""Get all dependencies for a set of components.
|
||||
|
||||
Args:
|
||||
component_names: Set of component names to get dependencies for
|
||||
cpp_testing: If True, set CORE.cpp_testing so AUTO_LOAD callables that
|
||||
conditionally include testing-only dependencies work correctly
|
||||
|
||||
Returns:
|
||||
Set of all components including dependencies and auto-loaded components
|
||||
@@ -652,7 +650,6 @@ def get_all_dependencies(
|
||||
|
||||
# Reset CORE to ensure clean state
|
||||
CORE.reset()
|
||||
CORE.cpp_testing = cpp_testing
|
||||
|
||||
# Set up fake config path for component loading
|
||||
root = Path(__file__).parent.parent
|
||||
|
||||
@@ -384,7 +384,7 @@ def merge_component_configs(
|
||||
# Write merged config
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
yaml_content = yaml_util.dump(merged_config_data)
|
||||
output_file.write_text(yaml_content)
|
||||
output_file.write_text(yaml_content, encoding="utf-8")
|
||||
|
||||
print(f"Successfully merged {len(component_names)} components into {output_file}")
|
||||
|
||||
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Set up CodSpeed's google_benchmark fork as a PlatformIO library.
|
||||
|
||||
CodSpeed requires their codspeed-cpp fork for CPU simulation instrumentation.
|
||||
This script clones the repo and assembles a flat PlatformIO-compatible library
|
||||
by combining google_benchmark sources, codspeed core, and instrument-hooks.
|
||||
|
||||
PlatformIO quirks addressed:
|
||||
- .cc files renamed to .cpp (PlatformIO ignores .cc)
|
||||
- All sources merged into one src/ dir (PlatformIO can't compile from
|
||||
multiple source directories in a single library)
|
||||
- library.json created with required CodSpeed preprocessor defines
|
||||
|
||||
Usage:
|
||||
python script/setup_codspeed_lib.py [--output-dir DIR]
|
||||
|
||||
Prints JSON to stdout with lib_path for cpp_benchmark.py.
|
||||
Git output goes to stderr.
|
||||
|
||||
See https://codspeed.io/docs/benchmarks/cpp#custom-build-systems
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Pin to a specific release for reproducibility
|
||||
CODSPEED_CPP_REPO = "https://github.com/CodSpeedHQ/codspeed-cpp.git"
|
||||
CODSPEED_CPP_SHA = "e633aca00da3d0ad14e7bf424d9cb47165a29028" # v2.1.0
|
||||
|
||||
DEFAULT_OUTPUT_DIR = "/tmp/codspeed-cpp"
|
||||
|
||||
# Well-known paths within the codspeed-cpp repository
|
||||
GOOGLE_BENCHMARK_SUBDIR = "google_benchmark"
|
||||
CORE_SUBDIR = "core"
|
||||
INSTRUMENT_HOOKS_SUBDIR = Path(CORE_SUBDIR) / "instrument-hooks"
|
||||
INSTRUMENT_HOOKS_INCLUDES = INSTRUMENT_HOOKS_SUBDIR / "includes"
|
||||
INSTRUMENT_HOOKS_DIST = INSTRUMENT_HOOKS_SUBDIR / "dist" / "core.c"
|
||||
CORE_CMAKE = Path(CORE_SUBDIR) / "CMakeLists.txt"
|
||||
|
||||
|
||||
def _git(args: list[str], **kwargs: object) -> None:
|
||||
"""Run a git command, sending output to stderr."""
|
||||
subprocess.run(
|
||||
["git", *args],
|
||||
check=True,
|
||||
stdout=kwargs.pop("stdout", sys.stderr),
|
||||
stderr=kwargs.pop("stderr", sys.stderr),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _clone_repo(output_dir: Path) -> None:
|
||||
"""Shallow-clone codspeed-cpp at the pinned SHA with submodules."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
_git(["init", str(output_dir)])
|
||||
_git(["-C", str(output_dir), "remote", "add", "origin", CODSPEED_CPP_REPO])
|
||||
_git(["-C", str(output_dir), "fetch", "--depth", "1", "origin", CODSPEED_CPP_SHA])
|
||||
_git(
|
||||
["-C", str(output_dir), "checkout", "FETCH_HEAD"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
_git(
|
||||
[
|
||||
"-C",
|
||||
str(output_dir),
|
||||
"submodule",
|
||||
"update",
|
||||
"--init",
|
||||
"--recursive",
|
||||
"--depth",
|
||||
"1",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _read_codspeed_version(cmake_path: Path) -> str:
|
||||
"""Extract CODSPEED_VERSION from core/CMakeLists.txt."""
|
||||
if not cmake_path.exists():
|
||||
return "0.0.0"
|
||||
for line in cmake_path.read_text().splitlines():
|
||||
if line.startswith("set(CODSPEED_VERSION"):
|
||||
return line.split()[1].rstrip(")")
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def _rename_cc_to_cpp(src_dir: Path) -> None:
|
||||
"""Rename .cc files to .cpp so PlatformIO compiles them."""
|
||||
for cc_file in src_dir.glob("*.cc"):
|
||||
cpp_file = cc_file.with_suffix(".cpp")
|
||||
if not cpp_file.exists():
|
||||
cc_file.rename(cpp_file)
|
||||
|
||||
|
||||
def _copy_if_missing(src: Path, dest: Path) -> None:
|
||||
"""Copy a file only if the destination doesn't already exist."""
|
||||
if not dest.exists():
|
||||
shutil.copy2(src, dest)
|
||||
|
||||
|
||||
def _merge_codspeed_core_into_lib(core_src: Path, lib_src: Path) -> None:
|
||||
"""Copy codspeed core sources into the benchmark library src/.
|
||||
|
||||
.cpp files get a ``codspeed_`` prefix to avoid name collisions with
|
||||
google_benchmark's own sources. .h files keep their original names
|
||||
since they're referenced by ``#include "walltime.h"`` etc.
|
||||
"""
|
||||
for src_file in core_src.iterdir():
|
||||
if src_file.suffix == ".cpp":
|
||||
_copy_if_missing(src_file, lib_src / f"codspeed_{src_file.name}")
|
||||
elif src_file.suffix == ".h":
|
||||
_copy_if_missing(src_file, lib_src / src_file.name)
|
||||
|
||||
|
||||
def _write_library_json(
|
||||
benchmark_dir: Path,
|
||||
core_include: Path,
|
||||
hooks_include: Path,
|
||||
version: str,
|
||||
project_root: Path,
|
||||
) -> None:
|
||||
"""Write a PlatformIO library.json with CodSpeed build flags."""
|
||||
library_json = {
|
||||
"name": "benchmark",
|
||||
"version": "0.0.0",
|
||||
"build": {
|
||||
"flags": [
|
||||
f"-I{core_include}",
|
||||
f"-I{hooks_include}",
|
||||
# google benchmark build flags
|
||||
# -O2 is critical: without it, instrument_hooks_start_benchmark_inline
|
||||
# doesn't get inlined and shows up as overhead in profiles
|
||||
"-O2",
|
||||
"-DNDEBUG",
|
||||
"-DHAVE_STD_REGEX",
|
||||
"-DHAVE_STEADY_CLOCK",
|
||||
"-DBENCHMARK_STATIC_DEFINE",
|
||||
# CodSpeed instrumentation flags
|
||||
# https://codspeed.io/docs/benchmarks/cpp#custom-build-systems
|
||||
"-DCODSPEED_ENABLED",
|
||||
"-DCODSPEED_ANALYSIS",
|
||||
f'-DCODSPEED_VERSION=\\"{version}\\"',
|
||||
f'-DCODSPEED_ROOT_DIR=\\"{project_root}\\"',
|
||||
'-DCODSPEED_MODE_DISPLAY=\\"simulation\\"',
|
||||
],
|
||||
"includeDir": "include",
|
||||
},
|
||||
}
|
||||
(benchmark_dir / "library.json").write_text(
|
||||
json.dumps(library_json, indent=2) + "\n"
|
||||
)
|
||||
|
||||
|
||||
def setup_codspeed_lib(output_dir: Path) -> None:
|
||||
"""Clone codspeed-cpp and assemble a flat PlatformIO library.
|
||||
|
||||
The resulting library at ``output_dir/google_benchmark/`` contains:
|
||||
- google_benchmark sources (.cc renamed to .cpp)
|
||||
- codspeed core sources (prefixed ``codspeed_``)
|
||||
- instrument-hooks C source (as ``instrument_hooks.c``)
|
||||
- library.json with all required CodSpeed defines
|
||||
|
||||
Args:
|
||||
output_dir: Directory to clone the repository into
|
||||
"""
|
||||
if not (output_dir / ".git").exists():
|
||||
_clone_repo(output_dir)
|
||||
else:
|
||||
# Verify the existing checkout matches the pinned SHA
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(output_dir), "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0 or result.stdout.strip() != CODSPEED_CPP_SHA:
|
||||
print(
|
||||
f"Stale codspeed-cpp checkout, re-cloning at {CODSPEED_CPP_SHA}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
shutil.rmtree(output_dir)
|
||||
_clone_repo(output_dir)
|
||||
|
||||
benchmark_dir = output_dir / GOOGLE_BENCHMARK_SUBDIR
|
||||
lib_src = benchmark_dir / "src"
|
||||
core_dir = output_dir / CORE_SUBDIR
|
||||
core_include = core_dir / "include"
|
||||
hooks_include = output_dir / INSTRUMENT_HOOKS_INCLUDES
|
||||
hooks_dist_c = output_dir / INSTRUMENT_HOOKS_DIST
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 1. Rename .cc → .cpp (PlatformIO doesn't compile .cc)
|
||||
_rename_cc_to_cpp(lib_src)
|
||||
|
||||
# 2. Merge codspeed core sources into the library
|
||||
_merge_codspeed_core_into_lib(core_dir / "src", lib_src)
|
||||
|
||||
# 3. Copy instrument-hooks C source (provides instrument_hooks_* symbols)
|
||||
if hooks_dist_c.exists():
|
||||
_copy_if_missing(hooks_dist_c, lib_src / "instrument_hooks.c")
|
||||
|
||||
# 4. Write library.json
|
||||
version = _read_codspeed_version(output_dir / CORE_CMAKE)
|
||||
_write_library_json(
|
||||
benchmark_dir, core_include, hooks_include, version, project_root
|
||||
)
|
||||
|
||||
# Output JSON config for cpp_benchmark.py
|
||||
print(json.dumps({"lib_path": str(benchmark_dir)}))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
default=Path(DEFAULT_OUTPUT_DIR),
|
||||
help=f"Directory to clone codspeed-cpp into (default: {DEFAULT_OUTPUT_DIR})",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
setup_codspeed_lib(args.output_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user