diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ca68ad4813..9c33fe48f6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,18 +16,18 @@ workflow: # Place the default settings in `.gitlab/ci/common.yml` instead include: - - '.gitlab/ci/danger.yml' - - '.gitlab/ci/common.yml' - - '.gitlab/ci/rules.yml' - - '.gitlab/ci/upload_cache.yml' - - '.gitlab/ci/docs.yml' - - '.gitlab/ci/static-code-analysis.yml' - - '.gitlab/ci/pre_commit.yml' - - '.gitlab/ci/pre_check.yml' - - '.gitlab/ci/build.yml' - - '.gitlab/ci/integration_test.yml' - - '.gitlab/ci/host-test.yml' - - '.gitlab/ci/deploy.yml' - - '.gitlab/ci/post_deploy.yml' - - '.gitlab/ci/retry_failed_jobs.yml' - - '.gitlab/ci/test-win.yml' + - ".gitlab/ci/danger.yml" + - ".gitlab/ci/common.yml" + - ".gitlab/ci/rules.yml" + - ".gitlab/ci/upload_cache.yml" + - ".gitlab/ci/docs.yml" + - ".gitlab/ci/static-code-analysis.yml" + - ".gitlab/ci/pre_commit.yml" + - ".gitlab/ci/pre_check.yml" + - ".gitlab/ci/build.yml" + - ".gitlab/ci/integration_test.yml" + - ".gitlab/ci/host-test.yml" + - ".gitlab/ci/deploy.yml" + - ".gitlab/ci/post_deploy.yml" + - ".gitlab/ci/retry_failed_jobs.yml" + - ".gitlab/ci/test-win.yml" diff --git a/.gitlab/ci/deploy.yml b/.gitlab/ci/deploy.yml index 3406fe73bd..ce79820770 100644 --- a/.gitlab/ci/deploy.yml +++ b/.gitlab/ci/deploy.yml @@ -3,6 +3,27 @@ image: $ESP_ENV_IMAGE tags: [ deploy ] +.metrics_template: + stage: deploy + tags: [ fast_run, shiny ] + image: python:3.13-slim + dependencies: [] + needs: [] + variables: + PIP_CACHE_DIR: ".cache/pip" + # Metrics - related env vars + ESP_METRICS_PROJECT_URL: "$CI_PROJECT_URL" + ESP_METRICS_PROJECT_ID: "$CI_PROJECT_ID" + ESP_METRICS_COMMIT_SHA: "$PIPELINE_COMMIT_SHA" + ESP_METRICS_BRANCH_NAME: "$CI_COMMIT_REF_NAME" + cache: + key: metrics-pip + paths: + - .cache/pip + before_script: + - echo "Installing esp-metrics-cli tool" + - pip install "esp-metrics-cli>=0.3,<1" + check_submodule_sync: extends: - .deploy_job_template @@ -82,3 +103,13 @@ upload_junit_report: junit: XUNIT_RESULT_*.xml expire_in: 1 week when: always + +target-examples-count-metrics: + extends: + - .metrics_template + allow_failure: true + script: + - echo "Generating ESP-IDF examples count metrics" + - cd tools/ci/metrics/examples_count + - python3 generate_metrics.py + - esp-metrics-cli upload -d schema.yaml -i metrics.json diff --git a/tools/ci/exclude_check_tools_files.txt b/tools/ci/exclude_check_tools_files.txt index 5ccf75a904..7b5ec92c21 100644 --- a/tools/ci/exclude_check_tools_files.txt +++ b/tools/ci/exclude_check_tools_files.txt @@ -27,6 +27,9 @@ tools/ci/idf_build_apps_dump_soc_caps.py tools/ci/idf_ci_local/**/* tools/ci/idf_ci_utils.py tools/ci/idf_pytest/**/* +tools/ci/metrics/examples_count/example.metrics.json +tools/ci/metrics/examples_count/generate_metrics.py +tools/ci/metrics/examples_count/schema.yaml tools/ci/mirror-submodule-update.sh tools/ci/multirun_with_pyenv.sh tools/ci/mypy_ignore_list.txt diff --git a/tools/ci/executable-list.txt b/tools/ci/executable-list.txt index 93f786554a..1870cbeac0 100644 --- a/tools/ci/executable-list.txt +++ b/tools/ci/executable-list.txt @@ -77,6 +77,7 @@ tools/ci/generate_rules.py tools/ci/get-full-sources.sh tools/ci/get_supported_examples.sh tools/ci/gitlab_yaml_linter.py +tools/ci/metrics/examples_count/generate_metrics.py tools/ci/mirror-submodule-update.sh tools/ci/multirun_with_pyenv.sh tools/ci/push_to_github.sh diff --git a/tools/ci/metrics/examples_count/example.metrics.json b/tools/ci/metrics/examples_count/example.metrics.json new file mode 100644 index 0000000000..9f9836c87d --- /dev/null +++ b/tools/ci/metrics/examples_count/example.metrics.json @@ -0,0 +1,27 @@ +{ + "examples_count": { + "data": [ + { + "component": "storage", + "target": "ESP32-C3", + "count": 7 + }, + { + "component": "peripherals", + "target": "ESP32", + "count": 25 + }, + { + "component": "peripherals", + "target": "ESP32-C2", + "count": 18 + }, + { + "component": "peripherals", + "target": "ESP32-C3", + "count": 22 + } + ], + "total_examples_count": 153 + } +} diff --git a/tools/ci/metrics/examples_count/generate_metrics.py b/tools/ci/metrics/examples_count/generate_metrics.py new file mode 100755 index 0000000000..045fda0ab7 --- /dev/null +++ b/tools/ci/metrics/examples_count/generate_metrics.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +""" +ESP-IDF Examples Count Metrics Generator + +This script scans all ESP-IDF examples and generates a list of records, +where each record contains: +- component: the component name (e.g., 'wifi', 'bluetooth', 'storage') +- target: the supported ESP32 chip variant (e.g., 'ESP32', 'ESP32-C3', 'ESP32-S3') +- count: the number of examples for that component-target combination + +The script parses README.md files in example directories to extract supported targets +from the "Supported Targets" table format. +""" + +import json +import os +import sys +from collections import defaultdict +from pathlib import Path + + +def extract_supported_targets_from_readme(readme_path: Path) -> list[str]: + """ + Extract supported targets from a README.md file. + + Looks for the "Supported Targets" table in the README header + and parses the list of supported ESP32 variants. + + Expected table format:: + + | Supported Targets | ESP32 | ESP32-C2 | ESP32-C3 | ... | + | ----------------- | ----- | -------- | -------- | ... | + + :param readme_path: Path to a README.md file + :return: List of supported targets (e.g. ``["ESP32", "ESP32-C3", "ESP32-S3"]``) + """ + if not readme_path.exists(): + return [] + + try: + for line in readme_path.read_text(encoding='utf-8').splitlines(): + if not line.strip().startswith('| Supported Targets'): + continue + parts = [p.strip() for p in line.split('|')[2:]] + return [p for p in parts if p and p not in ('-', '--')] + except Exception as e: + print(f'Warning: Could not parse {readme_path}: {e}') + return [] + + +def process_example_dir(component: str, example_dir: Path, results: dict[str, dict[str, list[str]]]) -> None: + """Process a single example directory (with README).""" + readme = example_dir / 'README.md' + if not readme.exists(): + return + targets = extract_supported_targets_from_readme(readme) + if targets: + path = f'{component}/{example_dir.name}' + results[component][path] = targets + print(f' Found example: {path} ({len(targets)} targets)') + + +def process_nested_example(component: str, parent_dir: Path, results: dict[str, dict[str, list[str]]]) -> None: + """Process nested sub-examples inside a parent directory.""" + for sub in parent_dir.iterdir(): + if not sub.is_dir() or sub.name.startswith('.'): + continue + readme = sub / 'README.md' + if not readme.exists(): + continue + targets = extract_supported_targets_from_readme(readme) + if targets: + path = f'{component}/{parent_dir.name}/{sub.name}' + results[component][path] = targets + + +def find_examples(examples_root: Path) -> dict[str, dict[str, list[str]]]: + """ + Traverse the ESP-IDF examples directory and collect supported targets. + + :param examples_root: Path to the ``examples`` directory + :return: Nested dictionary of the form:: + + { + "component": { + "component/example": ["ESP32", "ESP32-C3"], + "component/nested/example": ["ESP32-S3"], + }, + ... + } + """ + results: dict[str, dict[str, list[str]]] = defaultdict(dict) + if not examples_root.exists(): + print(f'Error: Examples directory not found: {examples_root}') + return {} + + for component_dir in examples_root.iterdir(): + if not component_dir.is_dir() or component_dir.name.startswith('.'): + continue + component = component_dir.name + print(f'Analyzing component: {component}') + + for item in component_dir.iterdir(): + if not item.is_dir() or item.name.startswith('.'): + continue + if (item / 'README.md').exists(): + process_example_dir(component, item, results) + else: + process_nested_example(component, item, results) + + return dict(results) + + +def generate_metrics(esp_idf_path: str) -> dict: + """ + Generate ESP-IDF examples count metrics. + + :param esp_idf_path: Path to the ESP-IDF repository root + :return: Dictionary with examples count data in a list format + """ + examples_data = find_examples(Path(esp_idf_path) / 'examples') + if not examples_data: + return {'examples_count': {'data': [], 'total_examples_count': 0}} + + counts: dict[tuple[str, str], int] = defaultdict(int) + total_examples = 0 + + for component, examples in examples_data.items(): + for example_path, targets in examples.items(): + for target in targets: + counts[(component, target)] += 1 + total_examples += len(examples) + + examples_count_list = [] + for (component, target), count in sorted(counts.items()): + examples_count_list.append( + { + 'component': component, + 'target': target, + 'count': count, + } + ) + + return { + 'examples_count': { + 'data': examples_count_list, + 'total_examples_count': total_examples, + } + } + + +def main() -> None: + """CLI to generate metrics.""" + esp_idf_path = os.environ.get('IDF_PATH', os.getcwd()) + + if not Path(esp_idf_path, 'examples').exists(): + print(f'Error: ESP-IDF root not found at {esp_idf_path}') + sys.exit(1) + + print(f'Using ESP-IDF path: {esp_idf_path}') + metrics = generate_metrics(esp_idf_path) + + output_file = Path('metrics.json') + output_file.write_text(json.dumps(metrics, indent=2)) + print(f'\nMetrics generated successfully and saved to {output_file}') + + +if __name__ == '__main__': + main() diff --git a/tools/ci/metrics/examples_count/schema.yaml b/tools/ci/metrics/examples_count/schema.yaml new file mode 100644 index 0000000000..d2ee52b33c --- /dev/null +++ b/tools/ci/metrics/examples_count/schema.yaml @@ -0,0 +1,33 @@ +--- +# ESP-IDF Examples Count Metrics Schema Definitions +# This file defines the schema and validation rules for ESP-IDF examples count metrics +# We use JSON Schema for annotating and validating JSON documents' structure. +# Read more about the JSON Schema: https://json-schema.org/understanding-json-schema/about#what-is-a-schema +$schema: "https://json-schema.org/draft/2020-12/schema" +# Note: Each key under 'properties' will correspond to an independent document in the DB. +# For example, 'examples_count' will be stored as a separate document with its data. See the example.metrics.json how the output will look like. +properties: + examples_count: + properties: + data: + type: array + title: "ESP-IDF Examples Count Data" + description: "List of example counts for each component-target combination" + items: + properties: + component: + type: string + title: "Component Name" + description: "The component name (e.g., 'wifi', 'bluetooth', 'storage')" + target: + type: string + title: "Target Name" + description: "The ESP32 target (e.g., 'ESP32', 'ESP32-C3', 'ESP32-S3')" + count: + type: integer + title: "Examples Count" + description: "Number of examples for this component-target combination" + total_examples_count: + type: integer + title: "Total Examples Count" + description: "Total number of ESP-IDF examples across all components"