mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-25 13:13:48 +01: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