mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 13:43:00 +00:00
[ci] Fix clang-tidy split mode for core file changes (#11434)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import cache
|
||||
import json
|
||||
import os
|
||||
@@ -7,6 +8,7 @@ import os.path
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -304,7 +306,10 @@ def get_changed_components() -> list[str] | None:
|
||||
for f in changed
|
||||
)
|
||||
if core_cpp_changed:
|
||||
print("Core C++/header files changed - will run full clang-tidy scan")
|
||||
print(
|
||||
"Core C++/header files changed - will run full clang-tidy scan",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
# Use list-components.py to get changed components
|
||||
@@ -318,7 +323,10 @@ def get_changed_components() -> list[str] | None:
|
||||
return parse_list_components_output(result.stdout)
|
||||
except subprocess.CalledProcessError:
|
||||
# If the script fails, fall back to full scan
|
||||
print("Could not determine changed components - will run full clang-tidy scan")
|
||||
print(
|
||||
"Could not determine changed components - will run full clang-tidy scan",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -370,14 +378,14 @@ def _filter_changed_ci(files: list[str]) -> list[str]:
|
||||
if f in changed and not f.startswith(ESPHOME_COMPONENTS_PATH)
|
||||
]
|
||||
if not files:
|
||||
print("No files changed")
|
||||
print("No files changed", file=sys.stderr)
|
||||
return files
|
||||
|
||||
# Scenario 3: Specific components changed
|
||||
# Action: Check ALL files in each changed component
|
||||
# Convert component list to set for O(1) lookups
|
||||
component_set = set(components)
|
||||
print(f"Changed components: {', '.join(sorted(components))}")
|
||||
print(f"Changed components: {', '.join(sorted(components))}", file=sys.stderr)
|
||||
|
||||
# The 'files' parameter contains ALL files in the codebase that clang-tidy would check.
|
||||
# We filter this down to only files in the changed components.
|
||||
@@ -648,3 +656,220 @@ def get_components_from_integration_fixtures() -> set[str]:
|
||||
components.add(item["platform"])
|
||||
|
||||
return components
|
||||
|
||||
|
||||
def filter_component_files(file_path: str) -> bool:
|
||||
"""Check if a file path is a component file.
|
||||
|
||||
Args:
|
||||
file_path: Path to check
|
||||
|
||||
Returns:
|
||||
True if the file is in a component directory
|
||||
"""
|
||||
return file_path.startswith("esphome/components/") or file_path.startswith(
|
||||
"tests/components/"
|
||||
)
|
||||
|
||||
|
||||
def extract_component_names_from_files(files: list[str]) -> list[str]:
|
||||
"""Extract unique component names from a list of file paths.
|
||||
|
||||
Args:
|
||||
files: List of file paths
|
||||
|
||||
Returns:
|
||||
List of unique component names (preserves order)
|
||||
"""
|
||||
return list(
|
||||
dict.fromkeys(comp for file in files if (comp := get_component_from_path(file)))
|
||||
)
|
||||
|
||||
|
||||
def add_item_to_components_graph(
|
||||
components_graph: dict[str, list[str]], parent: str, child: str
|
||||
) -> None:
|
||||
"""Add a dependency relationship to the components graph.
|
||||
|
||||
Args:
|
||||
components_graph: Graph mapping parent components to their children
|
||||
parent: Parent component name
|
||||
child: Child component name (dependent)
|
||||
"""
|
||||
if not parent.startswith("__") and parent != child:
|
||||
if parent not in components_graph:
|
||||
components_graph[parent] = []
|
||||
if child not in components_graph[parent]:
|
||||
components_graph[parent].append(child)
|
||||
|
||||
|
||||
def resolve_auto_load(
|
||||
auto_load: list[str] | Callable[[], list[str]] | Callable[[dict | None], list[str]],
|
||||
config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""Resolve AUTO_LOAD to a list, handling callables with or without config parameter.
|
||||
|
||||
Args:
|
||||
auto_load: The AUTO_LOAD value (list or callable)
|
||||
config: Optional config to pass to callable AUTO_LOAD functions
|
||||
|
||||
Returns:
|
||||
List of component names to auto-load
|
||||
"""
|
||||
if not callable(auto_load):
|
||||
return auto_load
|
||||
|
||||
import inspect
|
||||
|
||||
if inspect.signature(auto_load).parameters:
|
||||
return auto_load(config)
|
||||
return auto_load()
|
||||
|
||||
|
||||
def create_components_graph() -> dict[str, list[str]]:
|
||||
"""Create a graph of component dependencies.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping parent components to their children (dependencies)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from esphome import const
|
||||
from esphome.core import CORE
|
||||
from esphome.loader import ComponentManifest, get_component, get_platform
|
||||
|
||||
# The root directory of the repo
|
||||
root = Path(__file__).parent.parent
|
||||
components_dir = root / "esphome" / "components"
|
||||
# Fake some directory so that get_component works
|
||||
CORE.config_path = root
|
||||
# Various configuration to capture different outcomes used by `AUTO_LOAD` function.
|
||||
KEY_CORE = const.KEY_CORE
|
||||
KEY_TARGET_FRAMEWORK = const.KEY_TARGET_FRAMEWORK
|
||||
KEY_TARGET_PLATFORM = const.KEY_TARGET_PLATFORM
|
||||
PLATFORM_ESP32 = const.PLATFORM_ESP32
|
||||
PLATFORM_ESP8266 = const.PLATFORM_ESP8266
|
||||
|
||||
TARGET_CONFIGURATIONS = [
|
||||
{KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None},
|
||||
{KEY_TARGET_FRAMEWORK: "arduino", KEY_TARGET_PLATFORM: None},
|
||||
{KEY_TARGET_FRAMEWORK: "esp-idf", KEY_TARGET_PLATFORM: None},
|
||||
{KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: PLATFORM_ESP32},
|
||||
{KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: PLATFORM_ESP8266},
|
||||
]
|
||||
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
|
||||
|
||||
components_graph = {}
|
||||
platforms = []
|
||||
components: list[tuple[ComponentManifest, str, Path]] = []
|
||||
|
||||
for path in components_dir.iterdir():
|
||||
if not path.is_dir():
|
||||
continue
|
||||
if not (path / "__init__.py").is_file():
|
||||
continue
|
||||
name = path.name
|
||||
comp = get_component(name)
|
||||
if comp is None:
|
||||
raise RuntimeError(
|
||||
f"Cannot find component {name}. Make sure current path is pip installed ESPHome"
|
||||
)
|
||||
|
||||
components.append((comp, name, path))
|
||||
if comp.is_platform_component:
|
||||
platforms.append(name)
|
||||
|
||||
platforms = set(platforms)
|
||||
|
||||
for comp, name, path in components:
|
||||
for dependency in comp.dependencies:
|
||||
add_item_to_components_graph(
|
||||
components_graph, dependency.split(".")[0], name
|
||||
)
|
||||
|
||||
for target_config in TARGET_CONFIGURATIONS:
|
||||
CORE.data[KEY_CORE] = target_config
|
||||
for item in resolve_auto_load(comp.auto_load, config=None):
|
||||
add_item_to_components_graph(components_graph, item, name)
|
||||
# restore config
|
||||
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
|
||||
|
||||
for platform_path in path.iterdir():
|
||||
platform_name = platform_path.stem
|
||||
if platform_name == name or platform_name not in platforms:
|
||||
continue
|
||||
platform = get_platform(platform_name, name)
|
||||
if platform is None:
|
||||
continue
|
||||
|
||||
add_item_to_components_graph(components_graph, platform_name, name)
|
||||
|
||||
for dependency in platform.dependencies:
|
||||
add_item_to_components_graph(
|
||||
components_graph, dependency.split(".")[0], name
|
||||
)
|
||||
|
||||
for target_config in TARGET_CONFIGURATIONS:
|
||||
CORE.data[KEY_CORE] = target_config
|
||||
for item in resolve_auto_load(platform.auto_load, config={}):
|
||||
add_item_to_components_graph(components_graph, item, name)
|
||||
# restore config
|
||||
CORE.data[KEY_CORE] = TARGET_CONFIGURATIONS[0]
|
||||
|
||||
return components_graph
|
||||
|
||||
|
||||
def find_children_of_component(
|
||||
components_graph: dict[str, list[str]], component_name: str, depth: int = 0
|
||||
) -> list[str]:
|
||||
"""Find all components that depend on the given component (recursively).
|
||||
|
||||
Args:
|
||||
components_graph: Graph mapping parent components to their children
|
||||
component_name: Component name to find children for
|
||||
depth: Current recursion depth (max 10)
|
||||
|
||||
Returns:
|
||||
List of all dependent component names (may contain duplicates removed at end)
|
||||
"""
|
||||
if component_name not in components_graph:
|
||||
return []
|
||||
|
||||
children = []
|
||||
|
||||
for child in components_graph[component_name]:
|
||||
children.append(child)
|
||||
if depth < 10:
|
||||
children.extend(
|
||||
find_children_of_component(components_graph, child, depth + 1)
|
||||
)
|
||||
# Remove duplicate values
|
||||
return list(set(children))
|
||||
|
||||
|
||||
def get_components_with_dependencies(
|
||||
files: list[str], get_dependencies: bool = False
|
||||
) -> list[str]:
|
||||
"""Get component names from files, optionally including their dependencies.
|
||||
|
||||
Args:
|
||||
files: List of file paths
|
||||
get_dependencies: If True, include all dependent components
|
||||
|
||||
Returns:
|
||||
Sorted list of component names
|
||||
"""
|
||||
components = extract_component_names_from_files(files)
|
||||
|
||||
if get_dependencies:
|
||||
components_graph = create_components_graph()
|
||||
|
||||
all_components = components.copy()
|
||||
for c in components:
|
||||
all_components.extend(find_children_of_component(components_graph, c))
|
||||
# Remove duplicate values
|
||||
all_changed_components = list(set(all_components))
|
||||
|
||||
return sorted(all_changed_components)
|
||||
|
||||
return sorted(components)
|
||||
|
||||
Reference in New Issue
Block a user