[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:
Javier Peletier
2026-03-18 01:16:01 +01:00
committed by GitHub
parent 342020e1d3
commit 0c5f055d45
22 changed files with 667 additions and 96 deletions
-6
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+7 -6
View File
@@ -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,
+2 -2
View File
@@ -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
View File
@@ -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
+56 -4
View File
@@ -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
+8
View File
@@ -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()
+7
View File
@@ -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()
+7
View File
@@ -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()
+9
View File
@@ -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
+1 -1
View File
@@ -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]):
-22
View 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 = {
+260
View File
@@ -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
+63
View File
@@ -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)
+98 -1
View File
@@ -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: