mirror of
https://github.com/esphome/esphome.git
synced 2026-05-24 09:56:46 +08:00
[core] cpp tests: Allow customizing code generation during tests (#14681)
Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
@@ -615,10 +615,6 @@ class EsphomeCore:
|
||||
self.address_cache: AddressCache | None = None
|
||||
# Cached config hash (computed lazily)
|
||||
self._config_hash: int | None = None
|
||||
# True if compiling for C++ unit tests
|
||||
self.cpp_testing = False
|
||||
# Allowlist of components whose to_code should run during C++ testing
|
||||
self.cpp_testing_codegen: set[str] = set()
|
||||
|
||||
def reset(self):
|
||||
from esphome.pins import PIN_SCHEMA_REGISTRY
|
||||
@@ -648,8 +644,6 @@ class EsphomeCore:
|
||||
self.current_component = None
|
||||
self.address_cache = None
|
||||
self._config_hash = None
|
||||
self.cpp_testing = False
|
||||
self.cpp_testing_codegen = set()
|
||||
PIN_SCHEMA_REGISTRY.reset()
|
||||
|
||||
@contextmanager
|
||||
|
||||
+10
-5
@@ -71,11 +71,6 @@ class ComponentManifest:
|
||||
|
||||
@property
|
||||
def to_code(self) -> Callable[[Any], None] | None:
|
||||
if CORE.cpp_testing:
|
||||
# During C++ testing, only run to_code for allowlisted components
|
||||
name = self.module.__package__.rsplit(".", 1)[-1]
|
||||
if name not in CORE.cpp_testing_codegen:
|
||||
return None
|
||||
return getattr(self.module, "to_code", None)
|
||||
|
||||
@property
|
||||
@@ -243,3 +238,13 @@ def get_platform(domain: str, platform: str) -> ComponentManifest | None:
|
||||
_COMPONENT_CACHE: dict[str, ComponentManifest] = {}
|
||||
CORE_COMPONENTS_PATH = (Path(__file__).parent / "components").resolve()
|
||||
_COMPONENT_CACHE["esphome"] = ComponentManifest(esphome.core.config)
|
||||
|
||||
|
||||
def _replace_component_manifest(domain: str, manifest: ComponentManifest) -> None:
|
||||
"""Replace the cached manifest for a component.
|
||||
|
||||
This is an intentionally-supported hook for the C++ test infrastructure
|
||||
to install ``ComponentManifestOverride`` wrappers. Normal application
|
||||
code should never call this.
|
||||
"""
|
||||
_COMPONENT_CACHE[domain] = manifest
|
||||
|
||||
@@ -2,21 +2,29 @@
|
||||
|
||||
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
|
||||
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
|
||||
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"
|
||||
@@ -32,19 +40,6 @@ 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
|
||||
@@ -110,6 +105,8 @@ def get_platform_components(components: list[str], tests_dir: Path) -> list[str]
|
||||
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(
|
||||
@@ -130,7 +127,8 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict:
|
||||
|
||||
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.
|
||||
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
|
||||
@@ -139,11 +137,6 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict:
|
||||
Returns:
|
||||
Merged dict of component configs to add to the base config
|
||||
"""
|
||||
# Note: components are processed in sorted order. For conflicting keys
|
||||
# (e.g. two benchmark.yaml files both declaring sensor:), the first
|
||||
# component alphabetically wins via setdefault(). This is fine for now
|
||||
# with a single benchmark component (api) but would need a real merge
|
||||
# strategy if multiple components declare overlapping configs.
|
||||
merged: dict = {}
|
||||
for component in components:
|
||||
yaml_path = tests_dir / component / BENCHMARK_YAML_FILENAME
|
||||
@@ -153,9 +146,6 @@ def load_component_yaml_configs(components: list[str], tests_dir: Path) -> dict:
|
||||
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, {})
|
||||
@@ -198,11 +188,78 @@ def create_host_config(
|
||||
}
|
||||
|
||||
|
||||
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],
|
||||
codegen_components: set[str],
|
||||
tests_dir: Path,
|
||||
manifest_override_loader: ManifestOverrideLoader,
|
||||
label: str = "build",
|
||||
) -> tuple[int, str | None]:
|
||||
"""Compile an ESPHome configuration and return the binary path.
|
||||
@@ -210,8 +267,8 @@ def compile_and_get_binary(
|
||||
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)
|
||||
manifest_override_loader: Callback to apply manifest overrides for components
|
||||
label: Label for log messages (e.g. "unit tests", "benchmarks")
|
||||
|
||||
Returns:
|
||||
@@ -238,18 +295,21 @@ def compile_and_get_binary(
|
||||
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.
|
||||
# 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)
|
||||
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
|
||||
CORE.cpp_testing = True
|
||||
CORE.cpp_testing_codegen = codegen_components
|
||||
|
||||
# Validate config will expand the above with defaults:
|
||||
config = validate_config(config, {})
|
||||
@@ -305,7 +365,7 @@ def compile_and_get_binary(
|
||||
def build_and_run(
|
||||
selected_components: list[str],
|
||||
tests_dir: Path,
|
||||
codegen_components: set[str],
|
||||
manifest_override_loader: ManifestOverrideLoader,
|
||||
config_prefix: str,
|
||||
friendly_name: str,
|
||||
libraries: str | list[str],
|
||||
@@ -324,7 +384,7 @@ def build_and_run(
|
||||
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
|
||||
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)
|
||||
@@ -382,7 +442,7 @@ def build_and_run(
|
||||
)
|
||||
|
||||
exit_code, program_path = compile_and_get_binary(
|
||||
config, components, codegen_components, tests_dir, label
|
||||
config, components, tests_dir, manifest_override_loader, label
|
||||
)
|
||||
|
||||
if exit_code != EXIT_OK or program_path is None:
|
||||
+7
-11
@@ -2,18 +2,18 @@
|
||||
"""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 helpers import root_path
|
||||
from test_helpers import (
|
||||
BASE_CODEGEN_COMPONENTS,
|
||||
from build_helpers import (
|
||||
PLATFORMIO_GOOGLE_BENCHMARK_LIB,
|
||||
USE_TIME_TIMEZONE_FLAG,
|
||||
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"
|
||||
@@ -21,11 +21,6 @@ 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"
|
||||
|
||||
# Additional codegen components beyond the base set.
|
||||
# json is needed because its to_code adds the ArduinoJson library
|
||||
# (auto-loaded by api, but cpp_testing suppresses to_code unless listed).
|
||||
BENCHMARK_CODEGEN_COMPONENTS = BASE_CODEGEN_COMPONENTS | {"json"}
|
||||
|
||||
PLATFORMIO_OPTIONS = {
|
||||
"build_unflags": [
|
||||
"-Os", # remove default size-opt
|
||||
@@ -33,7 +28,6 @@ PLATFORMIO_OPTIONS = {
|
||||
"build_flags": [
|
||||
"-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo)
|
||||
"-g", # debug symbols for profiling
|
||||
USE_TIME_TIMEZONE_FLAG,
|
||||
"-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish()
|
||||
],
|
||||
# Use deep+ LDF mode to ensure PlatformIO detects the benchmark
|
||||
@@ -73,7 +67,9 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) ->
|
||||
return build_and_run(
|
||||
selected_components=selected_components,
|
||||
tests_dir=BENCHMARKS_DIR,
|
||||
codegen_components=BENCHMARK_CODEGEN_COMPONENTS,
|
||||
manifest_override_loader=partial(
|
||||
load_test_manifest_overrides, tests_dir=BENCHMARKS_DIR
|
||||
),
|
||||
config_prefix="cppbench",
|
||||
friendly_name="CPP Benchmarks",
|
||||
libraries=benchmark_lib,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from helpers import get_all_components, root_path
|
||||
from test_helpers import (
|
||||
BASE_CODEGEN_COMPONENTS,
|
||||
from build_helpers import (
|
||||
PLATFORMIO_GOOGLE_TEST_LIB,
|
||||
USE_TIME_TIMEZONE_FLAG,
|
||||
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"
|
||||
@@ -21,7 +21,6 @@ PLATFORMIO_OPTIONS = {
|
||||
],
|
||||
"build_flags": [
|
||||
"-Og", # optimize for debug
|
||||
USE_TIME_TIMEZONE_FLAG,
|
||||
"-DESPHOME_DEBUG", # enable debug assertions
|
||||
# Enable the address and undefined behavior sanitizers
|
||||
"-fsanitize=address",
|
||||
@@ -39,7 +38,9 @@ def run_tests(selected_components: list[str]) -> int:
|
||||
return build_and_run(
|
||||
selected_components=selected_components,
|
||||
tests_dir=COMPONENTS_TESTS_DIR,
|
||||
codegen_components=BASE_CODEGEN_COMPONENTS,
|
||||
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,
|
||||
|
||||
@@ -388,7 +388,7 @@ BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components"
|
||||
BENCHMARK_INFRASTRUCTURE_FILES = frozenset(
|
||||
{
|
||||
"script/cpp_benchmark.py",
|
||||
"script/test_helpers.py",
|
||||
"script/build_helpers.py",
|
||||
"script/setup_codspeed_lib.py",
|
||||
}
|
||||
)
|
||||
@@ -402,7 +402,7 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
|
||||
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/test_helpers.py, script/setup_codspeed_lib.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
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# core (esphome/core/config.py) must run its to_code during builds
|
||||
# because it bootstraps the fundamental application infrastructure.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# host must run its to_code during builds because it sets up
|
||||
# the host platform target execution environment.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# json must run its to_code during benchmark builds because it
|
||||
# adds the ArduinoJson library dependency needed by the API component.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# logger must run its to_code during builds because it configures
|
||||
# the logging subsystem used by ESP_LOG* macros.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,9 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code(config):
|
||||
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
|
||||
|
||||
manifest.to_code = to_code
|
||||
@@ -14,11 +14,63 @@ include the relevant `.cpp` and `.h` test files there.
|
||||
|
||||
### Override component code generation for testing
|
||||
|
||||
When generating code for testing, ESPHome won't invoke the component's `to_code` function, since most components do not
|
||||
need to generate configuration code for testing.
|
||||
During C++ test builds, `to_code` is suppressed for every component by default — most components do not
|
||||
need to generate configuration code for a unit test binary.
|
||||
|
||||
If you do need to generate code to for example configure compilation flags or add libraries,
|
||||
add the component name to the `CPP_TESTING_CODEGEN_COMPONENTS` allowlist in `script/cpp_unit_test.py`.
|
||||
#### Manifest overrides
|
||||
|
||||
If your component needs to customise code generation behavior for testing — for example to re-enable
|
||||
`to_code`, supply a lightweight stub, add a test-only dependency, or change any other manifest attribute —
|
||||
create an `__init__.py` in your component's test directory and define `override_manifest`:
|
||||
|
||||
**Top-level component** (`tests/components/<component>/__init__.py`):
|
||||
|
||||
```python
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# Re-enable the component's own to_code (needed when the component must
|
||||
# emit C++ setup code that the test binary depends on at link time).
|
||||
manifest.enable_codegen()
|
||||
```
|
||||
|
||||
Or supply a lightweight stub instead of the real `to_code`:
|
||||
|
||||
```python
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config):
|
||||
# Only emit what the C++ tests actually need
|
||||
pass
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
manifest.dependencies = manifest.dependencies + ["some_test_only_dep"]
|
||||
```
|
||||
|
||||
**Platform component** (`tests/components/<component>/<domain>/__init__.py`,
|
||||
e.g. `tests/components/my_sensor/sensor/__init__.py`):
|
||||
|
||||
```python
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
manifest.enable_codegen()
|
||||
```
|
||||
|
||||
`override_manifest` receives a `ComponentManifestOverride` that wraps the real manifest.
|
||||
Attribute assignments store an override; reads fall back to the real manifest when no
|
||||
override is present.
|
||||
|
||||
Key methods:
|
||||
|
||||
| Method | Effect |
|
||||
|---|---|
|
||||
| `manifest.enable_codegen()` | Remove the `to_code` suppression, re-enabling code generation |
|
||||
| `manifest.restore()` | Clear **all** overrides, reverting every attribute to the original |
|
||||
|
||||
The function is called after `to_code` has already been set to `None`, so calling
|
||||
`enable_codegen()` is a deliberate opt-in.
|
||||
|
||||
## Running component unit tests
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# core (esphome/core/config.py) must run its to_code during C++ test builds
|
||||
# because it bootstraps the fundamental application infrastructure that all
|
||||
# components depend on (component registration, event loop, etc.).
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# host must run its to_code during C++ test builds because it sets up the
|
||||
# host platform target, which is the execution environment for all unit tests.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,7 @@
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# logger must run its to_code during C++ test builds because it configures
|
||||
# the logging subsystem used by ESP_LOG* macros throughout component code.
|
||||
manifest.enable_codegen()
|
||||
@@ -0,0 +1,9 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code(config):
|
||||
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
|
||||
|
||||
manifest.to_code = to_code
|
||||
@@ -1846,7 +1846,7 @@ def test_should_run_benchmarks_benchmark_infra_change() -> None:
|
||||
"""Test benchmarks trigger on benchmark infrastructure changes."""
|
||||
for infra_file in [
|
||||
"script/cpp_benchmark.py",
|
||||
"script/test_helpers.py",
|
||||
"script/build_helpers.py",
|
||||
"script/setup_codspeed_lib.py",
|
||||
]:
|
||||
with patch.object(determine_jobs, "changed_files", return_value=[infra_file]):
|
||||
|
||||
@@ -1073,28 +1073,6 @@ def test_get_all_dependencies_platform_component_with_dependencies() -> None:
|
||||
assert result == {"sensor.bthome", "sensor"}
|
||||
|
||||
|
||||
def test_get_all_dependencies_cpp_testing_flag() -> None:
|
||||
"""cpp_testing=True propagates to CORE.cpp_testing during resolution."""
|
||||
from esphome.core import CORE
|
||||
|
||||
with (
|
||||
patch("esphome.loader.get_component") as mock_get_component,
|
||||
patch("esphome.loader.get_platform"),
|
||||
):
|
||||
observed: list[bool] = []
|
||||
|
||||
def capturing_get_component(name: str):
|
||||
observed.append(CORE.cpp_testing)
|
||||
|
||||
mock_get_component.side_effect = capturing_get_component
|
||||
|
||||
helpers.get_all_dependencies({"some_comp"}, cpp_testing=True)
|
||||
|
||||
assert observed and all(observed), (
|
||||
"CORE.cpp_testing should be True during resolution"
|
||||
)
|
||||
|
||||
|
||||
def test_get_components_from_integration_fixtures() -> None:
|
||||
"""Test extraction of components from fixture YAML files."""
|
||||
yaml_content = {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Unit tests for script/build_helpers.py manifest override and build helpers."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import textwrap
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the script directory to Python path so we can import build_helpers
|
||||
sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "script"))
|
||||
)
|
||||
|
||||
import build_helpers # noqa: E402
|
||||
|
||||
from esphome.loader import ComponentManifest # noqa: E402
|
||||
from tests.testing_helpers import ComponentManifestOverride # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_component_manifest(*, to_code=None, dependencies=None) -> ComponentManifest:
|
||||
mod = MagicMock()
|
||||
mod.to_code = to_code
|
||||
mod.DEPENDENCIES = dependencies or []
|
||||
return ComponentManifest(mod)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# filter_components_with_files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_filter_keeps_components_with_cpp_files(tmp_path: Path) -> None:
|
||||
comp_dir = tmp_path / "mycomp"
|
||||
comp_dir.mkdir()
|
||||
(comp_dir / "mycomp_test.cpp").write_text("")
|
||||
|
||||
result = build_helpers.filter_components_with_files(["mycomp"], tmp_path)
|
||||
|
||||
assert result == ["mycomp"]
|
||||
|
||||
|
||||
def test_filter_keeps_components_with_h_files(tmp_path: Path) -> None:
|
||||
comp_dir = tmp_path / "mycomp"
|
||||
comp_dir.mkdir()
|
||||
(comp_dir / "helpers.h").write_text("")
|
||||
|
||||
result = build_helpers.filter_components_with_files(["mycomp"], tmp_path)
|
||||
|
||||
assert result == ["mycomp"]
|
||||
|
||||
|
||||
def test_filter_drops_components_without_test_dir(tmp_path: Path) -> None:
|
||||
result = build_helpers.filter_components_with_files(["nodir"], tmp_path)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_filter_drops_components_with_no_cpp_or_h(tmp_path: Path) -> None:
|
||||
comp_dir = tmp_path / "mycomp"
|
||||
comp_dir.mkdir()
|
||||
(comp_dir / "README.md").write_text("")
|
||||
|
||||
result = build_helpers.filter_components_with_files(["mycomp"], tmp_path)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_platform_components
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_platform_components_discovers_subdirectory(tmp_path: Path) -> None:
|
||||
(tmp_path / "bthome" / "sensor").mkdir(parents=True)
|
||||
|
||||
sensor_mod = MagicMock()
|
||||
sensor_mod.IS_PLATFORM_COMPONENT = True
|
||||
|
||||
with patch(
|
||||
"build_helpers.get_component", return_value=ComponentManifest(sensor_mod)
|
||||
):
|
||||
result = build_helpers.get_platform_components(["bthome"], tmp_path)
|
||||
|
||||
assert result == ["sensor.bthome"]
|
||||
|
||||
|
||||
def test_get_platform_components_skips_pycache(tmp_path: Path) -> None:
|
||||
(tmp_path / "bthome" / "__pycache__").mkdir(parents=True)
|
||||
|
||||
result = build_helpers.get_platform_components(["bthome"], tmp_path)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_platform_components_raises_for_invalid_domain(tmp_path: Path) -> None:
|
||||
(tmp_path / "bthome" / "notadomain").mkdir(parents=True)
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=None),
|
||||
pytest.raises(ValueError, match="notadomain"),
|
||||
):
|
||||
build_helpers.get_platform_components(["bthome"], tmp_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_test_manifest_overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_suppresses_to_code(tmp_path: Path) -> None:
|
||||
"""to_code is always set to None before the override is called."""
|
||||
|
||||
async def real_to_code(config):
|
||||
pass
|
||||
|
||||
inner = _make_component_manifest(to_code=real_to_code)
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
installed: ComponentManifestOverride = mock_set.call_args[0][1]
|
||||
|
||||
assert installed.to_code is None
|
||||
|
||||
|
||||
def test_load_calls_override_fn(tmp_path: Path) -> None:
|
||||
"""override_manifest() in test_init is called with the ComponentManifestOverride."""
|
||||
comp_dir = tmp_path / "mycomp"
|
||||
comp_dir.mkdir()
|
||||
init_py = comp_dir / "__init__.py"
|
||||
init_py.write_text(
|
||||
textwrap.dedent("""\
|
||||
def override_manifest(manifest):
|
||||
manifest.dependencies = ["injected"]
|
||||
""")
|
||||
)
|
||||
|
||||
inner = _make_component_manifest()
|
||||
override = ComponentManifestOverride(inner)
|
||||
override.to_code = None
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
installed: ComponentManifestOverride = mock_set.call_args[0][1]
|
||||
|
||||
assert installed.dependencies == ["injected"]
|
||||
|
||||
|
||||
def test_load_enable_codegen_in_override(tmp_path: Path) -> None:
|
||||
"""An override_manifest that calls enable_codegen() restores to_code."""
|
||||
|
||||
async def real_to_code(config):
|
||||
pass
|
||||
|
||||
comp_dir = tmp_path / "mycomp"
|
||||
comp_dir.mkdir()
|
||||
init_py = comp_dir / "__init__.py"
|
||||
init_py.write_text(
|
||||
textwrap.dedent("""\
|
||||
def override_manifest(manifest):
|
||||
manifest.enable_codegen()
|
||||
""")
|
||||
)
|
||||
|
||||
inner = _make_component_manifest(to_code=real_to_code)
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
installed: ComponentManifestOverride = mock_set.call_args[0][1]
|
||||
|
||||
assert installed.to_code is real_to_code
|
||||
|
||||
|
||||
def test_load_no_override_file(tmp_path: Path) -> None:
|
||||
"""No override file: manifest is wrapped and to_code suppressed, nothing else."""
|
||||
inner = _make_component_manifest()
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
|
||||
mock_set.assert_called_once()
|
||||
key, installed = mock_set.call_args[0]
|
||||
assert key == "mycomp"
|
||||
assert isinstance(installed, ComponentManifestOverride)
|
||||
|
||||
|
||||
def test_load_skips_already_wrapped(tmp_path: Path) -> None:
|
||||
"""Components already wrapped as ComponentManifestOverride are not double-wrapped."""
|
||||
inner = _make_component_manifest()
|
||||
already_wrapped = ComponentManifestOverride(inner)
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=already_wrapped),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
|
||||
mock_set.assert_not_called()
|
||||
|
||||
|
||||
def test_load_skips_platform_component_already_wrapped(tmp_path: Path) -> None:
|
||||
inner = _make_component_manifest()
|
||||
already_wrapped = ComponentManifestOverride(inner)
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_platform", return_value=already_wrapped),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["sensor.bthome"], tmp_path)
|
||||
|
||||
mock_set.assert_not_called()
|
||||
|
||||
|
||||
def test_load_wraps_top_level_component(tmp_path: Path) -> None:
|
||||
inner = _make_component_manifest()
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_component", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["mycomp"], tmp_path)
|
||||
|
||||
mock_set.assert_called_once()
|
||||
key, installed = mock_set.call_args[0]
|
||||
assert key == "mycomp"
|
||||
assert isinstance(installed, ComponentManifestOverride)
|
||||
assert installed.to_code is None
|
||||
|
||||
|
||||
def test_load_wraps_platform_component(tmp_path: Path) -> None:
|
||||
inner = _make_component_manifest()
|
||||
|
||||
with (
|
||||
patch("build_helpers.get_platform", return_value=inner),
|
||||
patch("build_helpers.set_testing_manifest") as mock_set,
|
||||
):
|
||||
build_helpers.load_test_manifest_overrides(["sensor.bthome"], tmp_path)
|
||||
|
||||
mock_set.assert_called_once()
|
||||
key, installed = mock_set.call_args[0]
|
||||
assert key == "bthome.sensor"
|
||||
assert isinstance(installed, ComponentManifestOverride)
|
||||
assert installed.to_code is None
|
||||
@@ -0,0 +1,63 @@
|
||||
from typing import Any
|
||||
|
||||
from esphome.loader import ComponentManifest, _replace_component_manifest
|
||||
|
||||
|
||||
class ComponentManifestOverride:
|
||||
"""Mutable wrapper around ComponentManifest for test-specific attribute overrides.
|
||||
|
||||
When ``tests/components/<name>/__init__.py`` defines::
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
...
|
||||
|
||||
the function receives an instance of this class wrapping the real component
|
||||
manifest. Any attribute assignment stores an override; reads fall back to
|
||||
the underlying ``ComponentManifest`` when no override has been set.
|
||||
|
||||
Example::
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config):
|
||||
pass # lightweight no-op stub for C++ unit tests
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
manifest.dependencies = manifest.dependencies + ["extra_dep_for_tests"]
|
||||
"""
|
||||
|
||||
def __init__(self, wrapped: "ComponentManifest") -> None:
|
||||
object.__setattr__(self, "_wrapped", wrapped)
|
||||
object.__setattr__(self, "_overrides", {})
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
overrides: dict[str, Any] = object.__getattribute__(self, "_overrides")
|
||||
if name in overrides:
|
||||
return overrides[name]
|
||||
wrapped: ComponentManifest = object.__getattribute__(self, "_wrapped")
|
||||
return getattr(wrapped, name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
overrides: dict[str, Any] = object.__getattribute__(self, "_overrides")
|
||||
overrides[name] = value
|
||||
|
||||
def enable_codegen(self) -> None:
|
||||
"""Remove the to_code suppression, re-enabling code generation for this component.
|
||||
|
||||
Call this from ``override_manifest`` when the component needs its real (or a
|
||||
custom stub) ``to_code`` to run during C++ unit test builds.
|
||||
"""
|
||||
overrides: dict[str, Any] = object.__getattribute__(self, "_overrides")
|
||||
overrides.pop("to_code", None)
|
||||
|
||||
def restore(self) -> None:
|
||||
"""Clear all overrides, reverting to the wrapped manifest's values."""
|
||||
object.__getattribute__(self, "_overrides").clear()
|
||||
|
||||
|
||||
def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> None:
|
||||
"""Install a testing manifest override into the component cache.
|
||||
|
||||
Called from the C++ unit test infrastructure when a component's test
|
||||
directory provides an ``override_manifest`` function.
|
||||
"""
|
||||
_replace_component_manifest(domain, manifest)
|
||||
@@ -2,7 +2,104 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from esphome.loader import ComponentManifest
|
||||
from esphome.loader import ComponentManifest, _replace_component_manifest, get_component
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ComponentManifestOverride
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_manifest(*, to_code=None, dependencies=None) -> ComponentManifest:
|
||||
"""Return a ComponentManifest backed by a minimal mock module."""
|
||||
mod = MagicMock()
|
||||
mod.to_code = to_code
|
||||
mod.DEPENDENCIES = dependencies or []
|
||||
return ComponentManifest(mod)
|
||||
|
||||
|
||||
def test_testing_manifest_delegates_to_wrapped() -> None:
|
||||
"""Unoverridden attributes fall through to the wrapped manifest."""
|
||||
inner = _make_manifest(dependencies=["wifi"])
|
||||
tm = ComponentManifestOverride(inner)
|
||||
assert tm.dependencies == ["wifi"]
|
||||
|
||||
|
||||
def test_testing_manifest_override_shadows_wrapped() -> None:
|
||||
"""An assigned attribute shadows the wrapped value."""
|
||||
inner = _make_manifest(dependencies=["wifi"])
|
||||
tm = ComponentManifestOverride(inner)
|
||||
tm.dependencies = ["ble"]
|
||||
assert tm.dependencies == ["ble"]
|
||||
# Wrapped value unchanged
|
||||
assert inner.dependencies == ["wifi"]
|
||||
|
||||
|
||||
def test_testing_manifest_to_code_suppression() -> None:
|
||||
"""Setting to_code=None suppresses code generation."""
|
||||
|
||||
async def real_to_code(config):
|
||||
pass
|
||||
|
||||
inner = _make_manifest(to_code=real_to_code)
|
||||
tm = ComponentManifestOverride(inner)
|
||||
tm.to_code = None
|
||||
assert tm.to_code is None
|
||||
|
||||
|
||||
def test_testing_manifest_enable_codegen_removes_suppression() -> None:
|
||||
"""enable_codegen() removes the to_code override, restoring the original."""
|
||||
|
||||
async def real_to_code(config):
|
||||
pass
|
||||
|
||||
inner = _make_manifest(to_code=real_to_code)
|
||||
tm = ComponentManifestOverride(inner)
|
||||
tm.to_code = None
|
||||
assert tm.to_code is None
|
||||
|
||||
tm.enable_codegen()
|
||||
assert tm.to_code is real_to_code
|
||||
|
||||
|
||||
def test_testing_manifest_enable_codegen_preserves_other_overrides() -> None:
|
||||
"""enable_codegen() only removes to_code; other overrides survive."""
|
||||
inner = _make_manifest(dependencies=["wifi"])
|
||||
tm = ComponentManifestOverride(inner)
|
||||
tm.to_code = None
|
||||
tm.dependencies = ["ble"]
|
||||
|
||||
tm.enable_codegen()
|
||||
|
||||
assert tm.to_code is inner.to_code
|
||||
assert tm.dependencies == ["ble"]
|
||||
|
||||
|
||||
def test_testing_manifest_restore_clears_all_overrides() -> None:
|
||||
"""restore() removes every override, reverting all attributes to wrapped values."""
|
||||
|
||||
async def real_to_code(config):
|
||||
pass
|
||||
|
||||
inner = _make_manifest(to_code=real_to_code, dependencies=["wifi"])
|
||||
tm = ComponentManifestOverride(inner)
|
||||
tm.to_code = None
|
||||
tm.dependencies = ["ble"]
|
||||
|
||||
tm.restore()
|
||||
|
||||
assert tm.to_code is real_to_code
|
||||
assert tm.dependencies == ["wifi"]
|
||||
|
||||
|
||||
def test_replace_component_manifest_installs_override() -> None:
|
||||
"""_replace_component_manifest replaces the cached manifest for a domain."""
|
||||
inner = _make_manifest()
|
||||
override = ComponentManifestOverride(inner)
|
||||
|
||||
_replace_component_manifest("_test_dummy_domain", override)
|
||||
|
||||
assert get_component("_test_dummy_domain") is override
|
||||
|
||||
|
||||
def test_component_manifest_resources_with_filter_source_files() -> None:
|
||||
|
||||
Reference in New Issue
Block a user