mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 11:25:35 +00:00
[ci] Fix memory impact build selecting unbuildable platform (#16788)
This commit is contained in:
@@ -70,6 +70,7 @@ from helpers import (
|
||||
get_changed_components,
|
||||
get_component_from_path,
|
||||
get_component_test_files,
|
||||
get_component_test_platforms,
|
||||
get_components_with_dependencies,
|
||||
get_cpp_changed_components,
|
||||
get_fixture_to_test_files,
|
||||
@@ -77,7 +78,6 @@ from helpers import (
|
||||
get_target_branch,
|
||||
git_ls_files,
|
||||
is_validate_only_file,
|
||||
parse_test_filename,
|
||||
root_path,
|
||||
)
|
||||
from split_components_for_ci import create_intelligent_batches
|
||||
@@ -169,24 +169,6 @@ MEMORY_IMPACT_FALLBACK_COMPONENT = "api" # Representative component for core ch
|
||||
MEMORY_IMPACT_FALLBACK_PLATFORM = Platform.ESP32_IDF # Most representative platform
|
||||
MEMORY_IMPACT_MAX_COMPONENTS = 40 # Max components before results become nonsensical
|
||||
|
||||
# Platform-specific components that can only be built on their respective platforms
|
||||
# These components contain platform-specific code and cannot be cross-compiled
|
||||
# Regular components (wifi, logger, api, etc.) are cross-platform and not listed here
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
# Platform preference order for memory impact analysis
|
||||
# This order is used when no platform-specific hints are detected from filenames
|
||||
# Priority rationale:
|
||||
@@ -1006,23 +988,24 @@ def detect_memory_impact_config(
|
||||
] = {} # Track which platforms each component supports
|
||||
|
||||
for component in sorted(changed_component_set):
|
||||
# Look for test files on preferred platforms
|
||||
test_files = get_component_test_files(component, all_variants=True)
|
||||
if not test_files:
|
||||
continue
|
||||
|
||||
# Check if component has tests for any preferred platform
|
||||
available_platforms = [
|
||||
platform
|
||||
for test_file in test_files
|
||||
if (platform := parse_test_filename(test_file)[1]) != "all"
|
||||
and platform in MEMORY_IMPACT_PLATFORM_PREFERENCE
|
||||
]
|
||||
# Discover the platforms this component has BASE tests for, using the
|
||||
# same logic as the build runner (get_component_test_platforms wraps the
|
||||
# shared get_component_test_files + parse_test_filename helpers). Base
|
||||
# tests only: the memory impact CI build runs test_build_components.py
|
||||
# with --base-only, which compiles base test.<platform>.yaml files but
|
||||
# never variant test-<variant>.<platform>.yaml files. Counting
|
||||
# variant-only platforms here would let us select a platform the build
|
||||
# then has nothing to compile for, producing no memory output.
|
||||
available_platforms = {
|
||||
Platform(platform)
|
||||
for platform in get_component_test_platforms(component)
|
||||
if platform in MEMORY_IMPACT_PLATFORM_PREFERENCE
|
||||
}
|
||||
|
||||
if not available_platforms:
|
||||
continue
|
||||
|
||||
component_platforms_map[component] = set(available_platforms)
|
||||
component_platforms_map[component] = available_platforms
|
||||
components_with_tests.append(component)
|
||||
|
||||
# If no components have tests, don't run memory impact
|
||||
@@ -1084,20 +1067,57 @@ def detect_memory_impact_config(
|
||||
)
|
||||
platform = _select_platform_by_count(platform_counts)
|
||||
|
||||
# Filter out platform-specific components that are incompatible with selected platform
|
||||
# Platform components (esp32, esp8266, rp2040, etc.) can only build on their own platform
|
||||
# Other components (wifi, logger, etc.) are cross-platform and can build anywhere
|
||||
compatible_components = [
|
||||
# Keep only components that have a base test on the selected platform.
|
||||
# The merged build runs test_build_components.py -t <platform> --base-only,
|
||||
# so a component without a base test.<platform>.yaml compiles nothing and
|
||||
# contributes no memory output. This also covers platform-specific
|
||||
# components (esp32, esp8266, etc.), which only have tests on their own
|
||||
# platform. When components don't share a common platform we build the
|
||||
# largest subset that does, dropping the rest.
|
||||
def components_supporting(target: Platform) -> list[str]:
|
||||
return [
|
||||
component
|
||||
for component in components_with_tests
|
||||
if component not in PLATFORM_SPECIFIC_COMPONENTS
|
||||
or platform in component_platforms_map.get(component, set())
|
||||
if target in component_platforms_map.get(component, set())
|
||||
]
|
||||
|
||||
# If no components are compatible with the selected platform, don't run
|
||||
compatible_components = components_supporting(platform)
|
||||
|
||||
# A platform hint (or no-common-platform fallback) can pick a platform that
|
||||
# no changed component actually has a base test for, leaving nothing to
|
||||
# build. In that case fall back to the platform supported by the most
|
||||
# components. component_platforms_map is non-empty (guarded above) and every
|
||||
# value is a non-empty platform set (components with no supported platform
|
||||
# are skipped at discovery), so this always yields a buildable platform with
|
||||
# at least one compatible component.
|
||||
if not compatible_components:
|
||||
platform = _select_platform_by_count(
|
||||
Counter(
|
||||
p for platforms in component_platforms_map.values() for p in platforms
|
||||
)
|
||||
)
|
||||
compatible_components = components_supporting(platform)
|
||||
|
||||
# Defensive backstop: unreachable given the invariant above, but guards
|
||||
# against a future regression in platform selection silently passing an
|
||||
# empty component list to the build.
|
||||
if not compatible_components:
|
||||
return {"should_run": "false"}
|
||||
|
||||
# Log components dropped because they lack a base test on the selected
|
||||
# platform so partial-subset builds are visible in CI logs.
|
||||
dropped_components = [
|
||||
component
|
||||
for component in components_with_tests
|
||||
if component not in compatible_components
|
||||
]
|
||||
if dropped_components:
|
||||
print(
|
||||
f"Memory impact: Dropping components without a base test on "
|
||||
f"{platform}: {dropped_components}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Debug output
|
||||
print("Memory impact analysis:", file=sys.stderr)
|
||||
print(f" Changed components: {sorted(changed_component_set)}", file=sys.stderr)
|
||||
|
||||
@@ -149,6 +149,31 @@ def get_component_test_files(
|
||||
return files
|
||||
|
||||
|
||||
def get_component_test_platforms(component: str, *, base_only: bool = True) -> set[str]:
|
||||
"""Return the set of platforms a component has compilable test files for.
|
||||
|
||||
Uses the same discovery as ``test_build_components.py`` (``get_component_test_files``
|
||||
+ ``parse_test_filename``) so callers agree with what the build runner would
|
||||
actually compile. With ``base_only=True`` (the default, matching the
|
||||
memory-impact build's ``--base-only``), only base ``test.<platform>.yaml``
|
||||
files are considered; variant ``test-<variant>.<platform>.yaml`` files are
|
||||
excluded. The ``"all"`` platform sentinel is excluded.
|
||||
|
||||
Args:
|
||||
component: Component name (e.g. "wifi")
|
||||
base_only: If True, only consider base test files (default).
|
||||
|
||||
Returns:
|
||||
Set of platform identifiers (e.g. {"esp32-idf", "esp8266-ard"}).
|
||||
"""
|
||||
platforms: set[str] = set()
|
||||
for test_file in get_component_test_files(component, all_variants=not base_only):
|
||||
platform = parse_test_filename(test_file)[1]
|
||||
if platform != "all":
|
||||
platforms.add(platform)
|
||||
return platforms
|
||||
|
||||
|
||||
def is_validate_only_file(test_file: Path) -> bool:
|
||||
"""Return True if the given path is a config-only validate file.
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ from script.analyze_component_buses import (
|
||||
from script.helpers import (
|
||||
get_component_test_files,
|
||||
is_validate_only_file,
|
||||
parse_test_filename,
|
||||
split_conflicting_groups,
|
||||
)
|
||||
from script.merge_component_configs import merge_component_configs
|
||||
@@ -122,21 +123,6 @@ def find_component_tests(
|
||||
return dict(component_tests)
|
||||
|
||||
|
||||
def parse_test_filename(test_file: Path) -> tuple[str, str]:
|
||||
"""Parse test filename to extract test name and platform.
|
||||
|
||||
Args:
|
||||
test_file: Path to test file
|
||||
|
||||
Returns:
|
||||
Tuple of (test_name, platform)
|
||||
"""
|
||||
parts = test_file.stem.split(".")
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1] # test, platform
|
||||
return parts[0], "all"
|
||||
|
||||
|
||||
def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]:
|
||||
"""Get all platform base files.
|
||||
|
||||
|
||||
@@ -1426,7 +1426,15 @@ def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) ->
|
||||
|
||||
@pytest.mark.usefixtures("mock_target_branch_dev")
|
||||
def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
|
||||
"""Test memory impact detection when components have no common platform."""
|
||||
"""Test memory impact detection when components have no common platform.
|
||||
|
||||
The merged build runs with --base-only on a single platform, so components
|
||||
without a base test on the selected platform cannot be built and must be
|
||||
dropped. We build the largest subset that shares the selected platform
|
||||
rather than handing the runner components it has nothing to compile for
|
||||
(which previously produced "0 passed, 0 failed" and a failed memory
|
||||
extraction).
|
||||
"""
|
||||
# Create test directory structure
|
||||
tests_dir = tmp_path / "tests" / "components"
|
||||
|
||||
@@ -1453,12 +1461,70 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
|
||||
|
||||
result = determine_jobs.detect_memory_impact_config()
|
||||
|
||||
# Should pick the most frequently supported platform
|
||||
# No common platform: pick the most preferred platform among those supported
|
||||
# (esp8266-ard outranks esp32-idf in the preference list) and build only the
|
||||
# components that have a base test on it. wifi (esp32-idf only) is dropped.
|
||||
assert result["should_run"] == "true"
|
||||
assert set(result["components"]) == {"wifi", "logger"}
|
||||
# When no common platform, picks most commonly supported
|
||||
# esp8266-ard is preferred over esp32-idf in the preference list
|
||||
assert result["platform"] in ["esp32-idf", "esp8266-ard"]
|
||||
assert result["platform"] == "esp8266-ard"
|
||||
assert result["components"] == ["logger"]
|
||||
assert result["use_merged_config"] == "true"
|
||||
|
||||
|
||||
def test_detect_memory_impact_config_variant_only_platform_excluded(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Regression test for the const + shelly_dimmer memory-impact failure.
|
||||
|
||||
Reproduces https://github.com/esphome/esphome/actions/runs/26746938473
|
||||
where a platform hint selected esp32-idf even though neither changed
|
||||
component had a base test.esp32-idf.yaml. The merged --base-only build then
|
||||
found nothing to compile ("0 passed, 0 failed") and memory extraction
|
||||
failed. Also covers a component whose only esp32-idf test is a *variant*
|
||||
(test-*.esp32-idf.yaml): --base-only never compiles variants, so it must
|
||||
not count toward platform availability.
|
||||
"""
|
||||
tests_dir = tmp_path / "tests" / "components"
|
||||
|
||||
# const: base test only on esp32-s3-idf
|
||||
const_dir = tests_dir / "const"
|
||||
const_dir.mkdir(parents=True)
|
||||
(const_dir / "test.esp32-s3-idf.yaml").write_text("test: const")
|
||||
|
||||
# shelly_dimmer: base test only on esp8266-ard
|
||||
shelly_dir = tests_dir / "shelly_dimmer"
|
||||
shelly_dir.mkdir(parents=True)
|
||||
(shelly_dir / "test.esp8266-ard.yaml").write_text("test: shelly_dimmer")
|
||||
|
||||
# mdns: only a VARIANT test on esp32-idf (no base test.esp32-idf.yaml).
|
||||
# --base-only would never build it, so it must be excluded entirely.
|
||||
mdns_dir = tests_dir / "mdns"
|
||||
mdns_dir.mkdir(parents=True)
|
||||
(mdns_dir / "test-min.esp32-idf.yaml").write_text("test: mdns")
|
||||
|
||||
with (
|
||||
patch.object(determine_jobs, "root_path", str(tmp_path)),
|
||||
patch.object(helpers, "root_path", str(tmp_path)),
|
||||
patch.object(determine_jobs, "changed_files") as mock_changed_files,
|
||||
):
|
||||
# The "_esp32" filename yields an esp32-idf platform hint, reproducing
|
||||
# the original bug where the hint picked a platform no component could
|
||||
# build as a base test.
|
||||
mock_changed_files.return_value = [
|
||||
"esphome/components/const/const.cpp",
|
||||
"esphome/components/shelly_dimmer/shelly_dimmer_esp32.cpp",
|
||||
"esphome/components/mdns/mdns.cpp",
|
||||
]
|
||||
|
||||
result = determine_jobs.detect_memory_impact_config()
|
||||
|
||||
# The esp32-idf hint is unbuildable (no base test), so we fall back to the
|
||||
# platform supported by the most components, broken by preference order:
|
||||
# esp8266-ard (shelly_dimmer) outranks esp32-s3-idf (const). Only the
|
||||
# component with a base test on the selected platform is returned; the
|
||||
# variant-only mdns is excluded entirely.
|
||||
assert result["should_run"] == "true"
|
||||
assert result["platform"] == "esp8266-ard"
|
||||
assert result["components"] == ["shelly_dimmer"]
|
||||
assert result["use_merged_config"] == "true"
|
||||
|
||||
|
||||
@@ -1545,12 +1611,16 @@ def test_detect_memory_impact_config_includes_base_bus_components(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_target_branch_dev")
|
||||
def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None:
|
||||
"""Test memory impact detection for components with only variant test files.
|
||||
def test_detect_memory_impact_config_variant_only_components_skipped(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Components with only variant tests are skipped for memory impact.
|
||||
|
||||
This verifies that memory impact analysis works correctly for components like
|
||||
improv_serial, ethernet, mdns, etc. which only have variant test files
|
||||
(test-*.yaml) instead of base test files (test.*.yaml).
|
||||
Components like improv_serial and ethernet only have variant test files
|
||||
(test-*.yaml), no base test.<platform>.yaml. The memory-impact build runs
|
||||
test_build_components.py with --base-only, which never compiles variants, so
|
||||
these components have nothing buildable and must not be selected. Selecting
|
||||
them previously produced "0 passed, 0 failed" and a failed memory extraction.
|
||||
"""
|
||||
# Create test directory structure
|
||||
tests_dir = tmp_path / "tests" / "components"
|
||||
@@ -1581,12 +1651,8 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None:
|
||||
|
||||
result = determine_jobs.detect_memory_impact_config()
|
||||
|
||||
# Should detect both components even though they only have variant tests
|
||||
assert result["should_run"] == "true"
|
||||
assert set(result["components"]) == {"improv_serial", "ethernet"}
|
||||
# Both components support esp32-idf
|
||||
assert result["platform"] == "esp32-idf"
|
||||
assert result["use_merged_config"] == "true"
|
||||
# Neither component has a base test, so nothing is buildable under --base-only
|
||||
assert result["should_run"] == "false"
|
||||
|
||||
|
||||
# Tests for clang-tidy split mode logic
|
||||
|
||||
Reference in New Issue
Block a user