| #!/usr/bin/env python3 |
| # SPDX-FileCopyrightText: Copyright The Zephyr Project Contributors |
| # SPDX-License-Identifier: Apache-2.0 |
| |
| """Generate per-board JSON databases for Zephyr: DTS, compile coverage, and Kconfig. |
| |
| This script runs twister in cmake-only mode for every board and harvests |
| three independent queryable databases from the resulting build artefacts: |
| |
| DTS catalog (``--dts-db``) |
| EDT pickle files → per-board devicetree hardware information. |
| |
| Compile database (``--compile-db``) |
| ``compile_commands.json`` → which source files each platform compiles |
| and, conversely, which platforms compile a given source file. |
| |
| Kconfig database (``--kconfig-db``) |
| ``zephyr/.config`` → which Kconfig options each platform sets and, |
| conversely, which platforms set a given option. |
| |
| All three outputs are optional; pass one or more flags to select what to |
| generate. |
| |
| Typical usage:: |
| |
| # Generate all three databases (may take a long time for --all): |
| python scripts/gen_dts_catalog.py \\ |
| --dts-db dts_catalog.json \\ |
| --compile-db compile_db.json \\ |
| --kconfig-db kconfig_db.json |
| |
| # Limit to a subset of vendors to iterate quickly: |
| python scripts/gen_dts_catalog.py \\ |
| --dts-db dts_catalog.json \\ |
| --compile-db compile_db.json \\ |
| --kconfig-db kconfig_db.json \\ |
| --vendor nordic --vendor st |
| |
| # Re-use a twister output directory from a previous run: |
| python scripts/gen_dts_catalog.py \\ |
| --dts-db dts_catalog.json \\ |
| --compile-db compile_db.json \\ |
| --kconfig-db kconfig_db.json \\ |
| --twister-outdir /tmp/my-twister-out --skip-twister |
| |
| # Generate only one database: |
| python scripts/gen_dts_catalog.py --dts-db dts_catalog.json |
| python scripts/gen_dts_catalog.py --compile-db compile_db.json |
| python scripts/gen_dts_catalog.py --kconfig-db kconfig_db.json |
| |
| Database schemas |
| ----------------- |
| |
| All databases share two common top-level metadata fields: |
| |
| generated_at : str |
| ISO-8601 timestamp of generation. |
| zephyr_base : str |
| Absolute path of the Zephyr tree used for generation. |
| |
| DTS catalog (``--dts-db``) |
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| boards : dict[board_name, BoardEntry] |
| Per-board DTS data. Each ``BoardEntry`` is:: |
| |
| { |
| "targets": { |
| "<board_target>": { |
| "compatibles": ["compat-a", "compat-b", ...], |
| "hardware": { |
| "<binding-type>": { |
| "<compatible>": { |
| "description": str, |
| "title": str, |
| "binding_path": str, |
| "custom_binding": bool, |
| "locations": ["board" | "soc"], |
| "okay": bool, |
| "dts_sources": [{"file": str, "line": int, "okay": bool}] |
| } |
| } |
| } |
| } |
| }, |
| "runners": { |
| "runners": ["runner-a", ...], |
| "flash_runner": str, |
| "debug_runner": str |
| } |
| } |
| |
| compatibles : dict[compatible_str, CompatEntry] |
| DTS reverse index. Each ``CompatEntry`` is:: |
| |
| { |
| "description": str, |
| "title": str, |
| "binding_type": str, |
| "boards": ["board_name", ...] |
| } |
| |
| Compile database (``--compile-db``) |
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| files : dict[relative_path, FileEntry] |
| Forward index keyed by workspace-relative source file path |
| (relative to ZEPHYR_BASE for in-tree files, or to the west |
| workspace root for module files). Each ``FileEntry`` is:: |
| |
| { |
| "platforms": ["board_target_a", "board_target_b", ...] |
| } |
| |
| Query example — which platforms compile ``kernel/sched.c``?:: |
| |
| db["files"]["kernel/sched.c"]["platforms"] |
| |
| platforms : dict[board_target, PlatformEntry] |
| Reverse index keyed by board target string |
| (``board[@revision][/qualifiers]``). Each ``PlatformEntry`` is:: |
| |
| { |
| "files": ["path/to/file1.c", ...] |
| } |
| |
| Kconfig database (``--kconfig-db``) |
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| options : dict[option_name, OptionEntry] |
| Forward index keyed by Kconfig option name (e.g. ``CONFIG_UART_CONSOLE``). |
| Each ``OptionEntry`` is:: |
| |
| { |
| "platforms": { |
| "<board_target>": "<value>" |
| } |
| } |
| |
| The value is the string from ``.config``: ``"y"`` for boolean options, |
| a quoted string for string options, or a decimal/hex literal for |
| integer options. |
| |
| Query example — which platforms enable ``CONFIG_UART_CONSOLE``?:: |
| |
| db["options"]["CONFIG_UART_CONSOLE"]["platforms"] |
| |
| platforms : dict[board_target, KconfigPlatformEntry] |
| Reverse index keyed by board target string. Each entry is:: |
| |
| { |
| "options": { |
| "CONFIG_UART_CONSOLE": "y", |
| "CONFIG_MAIN_STACK_SIZE": "1024", |
| ... |
| } |
| } |
| """ |
| |
| import argparse |
| import datetime |
| import json |
| import logging |
| import os |
| import pickle |
| import re |
| import subprocess |
| import sys |
| import tempfile |
| from pathlib import Path |
| |
| import yaml |
| |
| ZEPHYR_BASE = os.environ.get('ZEPHYR_BASE') |
| if ZEPHYR_BASE: |
| ZEPHYR_BASE = Path(ZEPHYR_BASE) |
| else: |
| ZEPHYR_BASE = Path(__file__).resolve().parents[2] |
| # Propagate this decision to child processes. |
| os.environ['ZEPHYR_BASE'] = str(ZEPHYR_BASE) |
| |
| # West workspace virtual environment (created by pip install -r requirements.txt |
| # or `west setup`). Activating it ensures all Zephyr Python dependencies |
| # (twister, devicetree, runners, …) are available both in this process and in |
| # the twister sub-process. |
| _WEST_VENV = ZEPHYR_BASE.parent / ".venv" |
| _VENV_BIN = _WEST_VENV / "bin" |
| _VENV_PYTHON = _VENV_BIN / "python3" |
| |
| # The devicetree package is not installed globally or inside the venv; it lives |
| # as in-tree source. Add it unconditionally so that pickle.load() can |
| # deserialise EDT objects regardless of how the script is invoked. |
| _DEVICETREE_SRC = ZEPHYR_BASE / "scripts" / "dts" / "python-devicetree" / "src" |
| if str(_DEVICETREE_SRC) not in sys.path: |
| sys.path.insert(0, str(_DEVICETREE_SRC)) |
| |
| EDT_PICKLE_PATHS = ( |
| "zephyr/edt.pickle", |
| "hello_world/zephyr/edt.pickle", # for board targets using sysbuild |
| ) |
| RUNNERS_YAML_PATHS = ( |
| "zephyr/runners.yaml", |
| "hello_world/zephyr/runners.yaml", # for board targets using sysbuild |
| ) |
| COMPILE_COMMANDS_PATHS = ( |
| "compile_commands.json", |
| "hello_world/compile_commands.json", # for board targets using sysbuild |
| ) |
| KCONFIG_PATHS = ( |
| "zephyr/.config", |
| "hello_world/zephyr/.config", # for board targets using sysbuild |
| ) |
| |
| # Absolute path of the west workspace root (parent of ZEPHYR_BASE). |
| _WEST_TOPDIR = ZEPHYR_BASE.parent |
| |
| ZEPHYR_BINDINGS = ZEPHYR_BASE / "dts" / "bindings" |
| |
| logger = logging.getLogger(__name__) |
| |
| |
| # --------------------------------------------------------------------------- |
| # Helpers |
| # --------------------------------------------------------------------------- |
| |
| |
| def _get_binding_type(binding_path: Path) -> str: |
| """Return the binding-type category for a binding file path. |
| |
| Mirrors the logic in ``doc/_scripts/dts_binding_types.py`` |
| without requiring a cross-directory import. |
| """ |
| if binding_path.is_relative_to(ZEPHYR_BINDINGS): |
| return binding_path.relative_to(ZEPHYR_BINDINGS).parts[0] |
| return "misc" |
| |
| |
| # --------------------------------------------------------------------------- |
| # Twister execution |
| # --------------------------------------------------------------------------- |
| |
| |
| def run_twister_cmake_only( |
| outdir: Path, vendor_filter: list[str], extra_flags: list[str] | None = None |
| ) -> None: |
| """Run twister in cmake-only mode to generate build-info and EDT files. |
| |
| Args: |
| outdir: Directory where twister should write its output. |
| vendor_filter: If non-empty, restrict to boards from these vendors. |
| extra_flags: List of additional flags that should be passed to twister. |
| """ |
| python = str(_VENV_PYTHON) if _VENV_PYTHON.exists() else sys.executable |
| |
| if extra_flags is None: |
| extra_flags = [] |
| |
| twister_cmd = [ |
| python, |
| str(ZEPHYR_BASE / "scripts" / "twister"), |
| "-T", |
| "samples/hello_world/", |
| "-M", |
| *[arg for path in EDT_PICKLE_PATHS for arg in ("--keep-artifacts", path)], |
| *[arg for path in RUNNERS_YAML_PATHS for arg in ("--keep-artifacts", path)], |
| *[arg for path in COMPILE_COMMANDS_PATHS for arg in ("--keep-artifacts", path)], |
| *[arg for path in KCONFIG_PATHS for arg in ("--keep-artifacts", path)], |
| "--cmake-only", |
| "-v", |
| "--outdir", |
| str(outdir), |
| *extra_flags, |
| ] |
| |
| if vendor_filter: |
| for vendor in vendor_filter: |
| twister_cmd += ["--vendor", vendor] |
| else: |
| twister_cmd += ["--all"] |
| |
| path = os.environ.get("PATH", "") |
| if _VENV_BIN.exists(): |
| path = str(_VENV_BIN) + os.pathsep + path |
| |
| minimal_env = { |
| "PATH": path, |
| "ZEPHYR_BASE": str(ZEPHYR_BASE), |
| "HOME": os.environ.get("HOME", ""), |
| "PYTHONPATH": os.environ.get("PYTHONPATH", ""), |
| } |
| if _WEST_VENV.exists(): |
| minimal_env["VIRTUAL_ENV"] = str(_WEST_VENV) |
| |
| logger.info("Running twister (cmake-only) …") |
| logger.debug("Command: %s", " ".join(twister_cmd)) |
| |
| try: |
| subprocess.run(twister_cmd, check=True, cwd=ZEPHYR_BASE, env=minimal_env) |
| except subprocess.CalledProcessError as exc: |
| logger.warning("Twister exited with an error; the catalog may be incomplete.\n%s", exc) |
| |
| |
| # --------------------------------------------------------------------------- |
| # Build-info harvesting |
| # --------------------------------------------------------------------------- |
| |
| |
| def gather_board_edts(twister_out_dir: Path) -> dict: |
| """Load EDT objects for every board target from a twister output directory. |
| |
| Args: |
| twister_out_dir: Root of the twister output tree. |
| |
| Returns: |
| Nested dict ``{board_name: {board_target: edt_object}}``. |
| """ |
| board_edts = {} |
| |
| if not twister_out_dir.exists(): |
| logger.warning("Twister output directory does not exist: %s", twister_out_dir) |
| return board_edts |
| |
| build_info_files = list(twister_out_dir.glob("*/**/build_info.yml")) |
| logger.info("Found %d build_info.yml files", len(build_info_files)) |
| |
| for build_info_file in build_info_files: |
| edt_pickle_file = None |
| for pickle_path in EDT_PICKLE_PATHS: |
| candidate = build_info_file.parent / pickle_path |
| if candidate.exists(): |
| edt_pickle_file = candidate |
| break |
| |
| if not edt_pickle_file: |
| continue |
| |
| try: |
| with open(build_info_file) as fh: |
| build_info = yaml.safe_load(fh) |
| board_info = build_info.get("cmake", {}).get("board", {}) |
| board_name = board_info.get("name") |
| qualifier = board_info.get("qualifiers", "") |
| revision = board_info.get("revision", "") |
| |
| if not board_name: |
| continue |
| |
| board_target = board_name |
| if revision: |
| board_target = f"{board_target}@{revision}" |
| if qualifier: |
| board_target = f"{board_target}/{qualifier}" |
| |
| with open(edt_pickle_file, "rb") as fh: |
| edt = pickle.load(fh) |
| |
| board_edts.setdefault(board_name, {})[board_target] = edt |
| |
| except Exception as exc: # noqa: BLE001 |
| logger.error("Error processing %s: %s", build_info_file, exc) |
| |
| return board_edts |
| |
| |
| def gather_board_runners(twister_out_dir: Path) -> dict: |
| """Load runners.yaml for every board target from a twister output directory. |
| |
| Args: |
| twister_out_dir: Root of the twister output tree. |
| |
| Returns: |
| Nested dict ``{board_name: {board_target: runners_yaml_dict}}``. |
| """ |
| board_runners: dict = {} |
| |
| if not twister_out_dir.exists(): |
| return board_runners |
| |
| build_info_files = list(twister_out_dir.glob("*/**/build_info.yml")) |
| |
| for build_info_file in build_info_files: |
| runners_file = None |
| for runners_path in RUNNERS_YAML_PATHS: |
| candidate = build_info_file.parent / runners_path |
| if candidate.exists(): |
| runners_file = candidate |
| break |
| |
| if runners_file is None: |
| continue |
| |
| try: |
| with open(build_info_file) as fh: |
| build_info = yaml.safe_load(fh) |
| board_info = build_info.get("cmake", {}).get("board", {}) |
| board_name = board_info.get("name") |
| qualifier = board_info.get("qualifiers", "") |
| revision = board_info.get("revision", "") |
| |
| if not board_name: |
| continue |
| |
| board_target = board_name |
| if revision: |
| board_target = f"{board_target}@{revision}" |
| if qualifier: |
| board_target = f"{board_target}/{qualifier}" |
| |
| with open(runners_file) as fh: |
| runners_data = yaml.safe_load(fh) |
| |
| board_runners.setdefault(board_name, {})[board_target] = runners_data |
| |
| except Exception as exc: # noqa: BLE001 |
| logger.error("Error processing %s: %s", runners_file, exc) |
| |
| return board_runners |
| |
| |
| # --------------------------------------------------------------------------- |
| # Compile-commands harvesting |
| # --------------------------------------------------------------------------- |
| |
| |
| def _rel_source_path(src: str) -> str | None: |
| """Return a workspace-relative path for *src*, or None to skip it. |
| |
| Paths inside the Zephyr tree are made relative to ZEPHYR_BASE. |
| Paths inside other west modules are made relative to the west |
| workspace root so they remain meaningful across machines. |
| Generated files inside a twister output directory are skipped. |
| """ |
| try: |
| p = Path(src).resolve() |
| except Exception: # noqa: BLE001 |
| return None |
| |
| # Skip build-directory artifacts (inside a twister-out tree). |
| if "twister-out" in p.parts: |
| return None |
| |
| if p.is_relative_to(ZEPHYR_BASE): |
| return str(p.relative_to(ZEPHYR_BASE)) |
| |
| if p.is_relative_to(_WEST_TOPDIR): |
| return str(p.relative_to(_WEST_TOPDIR)) |
| |
| return None |
| |
| |
| def gather_compile_commands(twister_out_dir: Path) -> dict: |
| """Load compile_commands.json for every board target. |
| |
| For each board target the function reads the ``compile_commands.json`` |
| file produced by CMake and extracts the set of source files that are |
| compiled. Paths are normalised to be relative to either ZEPHYR_BASE |
| (for in-tree files) or the west workspace root (for module files). |
| |
| Args: |
| twister_out_dir: Root of the twister output tree. |
| |
| Returns: |
| Nested dict ``{board_name: {board_target: sorted_file_list}}``. |
| """ |
| board_files: dict = {} |
| |
| if not twister_out_dir.exists(): |
| logger.warning("Twister output directory does not exist: %s", twister_out_dir) |
| return board_files |
| |
| build_info_files = list(twister_out_dir.glob("*/**/build_info.yml")) |
| logger.info( |
| "Found %d build_info.yml files for compile-commands harvesting", |
| len(build_info_files), |
| ) |
| |
| for build_info_file in build_info_files: |
| cc_file = None |
| for cc_path in COMPILE_COMMANDS_PATHS: |
| candidate = build_info_file.parent / cc_path |
| if candidate.exists(): |
| cc_file = candidate |
| break |
| |
| if cc_file is None: |
| continue |
| |
| try: |
| with open(build_info_file) as fh: |
| build_info = yaml.safe_load(fh) |
| board_info = build_info.get("cmake", {}).get("board", {}) |
| board_name = board_info.get("name") |
| qualifier = board_info.get("qualifiers", "") |
| revision = board_info.get("revision", "") |
| |
| if not board_name: |
| continue |
| |
| board_target = board_name |
| if revision: |
| board_target = f"{board_target}@{revision}" |
| if qualifier: |
| board_target = f"{board_target}/{qualifier}" |
| |
| with open(cc_file, encoding="utf-8") as fh: |
| commands = json.load(fh) |
| |
| files: set = set() |
| for entry in commands: |
| rel = _rel_source_path(entry.get("file", "")) |
| if rel is not None: |
| files.add(rel) |
| |
| board_files.setdefault(board_name, {})[board_target] = sorted(files) |
| |
| except Exception as exc: # noqa: BLE001 |
| logger.error("Error processing %s: %s", cc_file, exc) |
| |
| return board_files |
| |
| |
| # --------------------------------------------------------------------------- |
| # File-index construction |
| # --------------------------------------------------------------------------- |
| |
| |
| def build_file_index(board_files: dict) -> dict: |
| """Build forward and reverse indexes from compile_commands data. |
| |
| Args: |
| board_files: Nested dict ``{board_name: {board_target: [files]}}``. |
| |
| Returns: |
| Dict with two top-level keys: |
| |
| ``files`` |
| Maps each source file (workspace-relative path) to the list of |
| board targets that compile it:: |
| |
| { |
| "path/to/file.c": { |
| "platforms": ["board_target_a", "board_target_b", ...] |
| } |
| } |
| |
| ``platforms`` |
| Maps each board target to the list of source files it compiles:: |
| |
| { |
| "board_target": { |
| "files": ["path/to/file1.c", ...] |
| } |
| } |
| """ |
| files_db: dict = {} |
| platforms_db: dict = {} |
| |
| for _board_name, targets in board_files.items(): |
| for board_target, files in targets.items(): |
| platforms_db[board_target] = {"files": files} |
| for f in files: |
| files_db.setdefault(f, {"platforms": []})["platforms"].append(board_target) |
| |
| for entry in files_db.values(): |
| entry["platforms"].sort() |
| |
| return {"files": files_db, "platforms": platforms_db} |
| |
| |
| # --------------------------------------------------------------------------- |
| # Kconfig harvesting |
| # --------------------------------------------------------------------------- |
| |
| # Matches lines like: CONFIG_FOO=y CONFIG_BAR="hello" CONFIG_BAZ=1024 |
| _KCONFIG_RE = re.compile(r'^(CONFIG_\w+)=(.+)$') |
| |
| |
| def _parse_kconfig(path: Path) -> dict: |
| """Parse a ``.config`` file and return a dict of ``{option: value}``. |
| |
| Only ``CONFIG_*=<value>`` lines are collected. Comment lines of the |
| form ``# CONFIG_FOO is not set`` (disabled boolean options) are skipped |
| because they do not represent an active configuration choice. |
| """ |
| options: dict = {} |
| with open(path, encoding="utf-8") as fh: |
| for line in fh: |
| m = _KCONFIG_RE.match(line.rstrip()) |
| if m: |
| options[m.group(1)] = m.group(2) |
| return options |
| |
| |
| def gather_kconfigs(twister_out_dir: Path) -> dict: |
| """Load ``.config`` for every board target from a twister output directory. |
| |
| Args: |
| twister_out_dir: Root of the twister output tree. |
| |
| Returns: |
| Nested dict ``{board_name: {board_target: {option: value}}}``. |
| """ |
| board_kconfigs: dict = {} |
| |
| if not twister_out_dir.exists(): |
| logger.warning("Twister output directory does not exist: %s", twister_out_dir) |
| return board_kconfigs |
| |
| build_info_files = list(twister_out_dir.glob("*/**/build_info.yml")) |
| logger.info( |
| "Found %d build_info.yml files for Kconfig harvesting", |
| len(build_info_files), |
| ) |
| |
| for build_info_file in build_info_files: |
| kconfig_file = None |
| for kc_path in KCONFIG_PATHS: |
| candidate = build_info_file.parent / kc_path |
| if candidate.exists(): |
| kconfig_file = candidate |
| break |
| |
| if kconfig_file is None: |
| continue |
| |
| try: |
| with open(build_info_file) as fh: |
| build_info = yaml.safe_load(fh) |
| board_info = build_info.get("cmake", {}).get("board", {}) |
| board_name = board_info.get("name") |
| qualifier = board_info.get("qualifiers", "") |
| revision = board_info.get("revision", "") |
| |
| if not board_name: |
| continue |
| |
| board_target = board_name |
| if revision: |
| board_target = f"{board_target}@{revision}" |
| if qualifier: |
| board_target = f"{board_target}/{qualifier}" |
| |
| options = _parse_kconfig(kconfig_file) |
| board_kconfigs.setdefault(board_name, {})[board_target] = options |
| |
| except Exception as exc: # noqa: BLE001 |
| logger.error("Error processing %s: %s", kconfig_file, exc) |
| |
| return board_kconfigs |
| |
| |
| def build_kconfig_db(board_kconfigs: dict) -> dict: |
| """Build forward and reverse indexes from per-board Kconfig data. |
| |
| Args: |
| board_kconfigs: Nested dict |
| ``{board_name: {board_target: {option: value}}}``. |
| |
| Returns: |
| Dict with two top-level keys: |
| |
| ``options`` |
| Maps each Kconfig option name to the platforms that set it and |
| the value each platform uses:: |
| |
| { |
| "CONFIG_UART_CONSOLE": { |
| "platforms": { |
| "board_target_a": "y", |
| "board_target_b": "y" |
| } |
| } |
| } |
| |
| ``platforms`` |
| Maps each board target to its full Kconfig dictionary:: |
| |
| { |
| "board_target": { |
| "options": { |
| "CONFIG_UART_CONSOLE": "y", |
| "CONFIG_MAIN_STACK_SIZE": "1024" |
| } |
| } |
| } |
| """ |
| options_db: dict = {} |
| platforms_db: dict = {} |
| |
| for _board_name, targets in board_kconfigs.items(): |
| for board_target, options in targets.items(): |
| platforms_db[board_target] = {"options": options} |
| for opt, val in options.items(): |
| options_db.setdefault(opt, {"platforms": {}})["platforms"][board_target] = val |
| |
| return {"options": options_db, "platforms": platforms_db} |
| |
| |
| def _node_location(node, board_name: str) -> str: |
| """Return 'board' or 'soc' depending on where the DTS node is defined.""" |
| filename = node.filename |
| if not filename: |
| return "soc" |
| path = Path(filename) |
| if path.is_relative_to(ZEPHYR_BASE): |
| rel = path.relative_to(ZEPHYR_BASE) |
| if rel.parts[0] == "boards": |
| return "board" |
| return "soc" |
| |
| |
| def build_catalog(board_edts: dict, board_runners: dict | None = None) -> dict: |
| """Build the DTS catalog from harvested EDT objects. |
| |
| Args: |
| board_edts: Nested dict ``{board_name: {board_target: edt_object}}``. |
| board_runners: Optional nested dict |
| ``{board_name: {board_target: runners_yaml_dict}}``. When |
| provided, per-board runner information is embedded in the catalog. |
| |
| Returns: |
| Dict with ``boards`` and ``compatibles`` top-level keys (see module |
| docstring for the full schema). |
| """ |
| if board_runners is None: |
| board_runners = {} |
| boards_db = {} |
| compatibles_db = {} |
| |
| for board_name, targets in board_edts.items(): |
| board_entry = {"targets": {}} |
| |
| for board_target, edt in targets.items(): |
| target_compatibles = set() |
| hardware = {} |
| |
| for node in edt.nodes: |
| if node.binding_path is None or node.matching_compat is None: |
| continue |
| |
| # Skip synthetic "zephyr," nodes (except native_sim where they |
| # represent real peripherals). |
| if node.matching_compat.startswith("zephyr,") and board_name != "native_sim": |
| continue |
| |
| binding_path = Path(node.binding_path) |
| binding_type = _get_binding_type(binding_path) |
| compat = node.matching_compat |
| |
| # Compute a workspace-relative binding path for consumers. |
| rel_binding_path = str(binding_path) |
| if binding_path.is_relative_to(ZEPHYR_BASE): |
| rel_binding_path = str(binding_path.relative_to(ZEPHYR_BASE)) |
| elif binding_path.is_relative_to(_WEST_TOPDIR): |
| rel_binding_path = str(binding_path.relative_to(_WEST_TOPDIR)) |
| |
| # A binding is "custom" when it lives outside the Zephyr |
| # bindings tree (i.e. it comes from an out-of-tree module). |
| custom_binding = binding_type == "misc" and not binding_path.is_relative_to( |
| ZEPHYR_BINDINGS |
| ) |
| |
| target_compatibles.add(compat) |
| |
| # Build human-readable node source location. |
| filename = node.filename or "" |
| rel_filename = filename |
| if filename and Path(filename).is_relative_to(ZEPHYR_BASE): |
| rel_filename = str(Path(filename).relative_to(ZEPHYR_BASE)) |
| |
| is_okay = node.status == "okay" |
| node_info = {"file": rel_filename, "line": node.lineno, "okay": is_okay} |
| |
| # Accumulate per-compatible data inside the hardware map. |
| compat_entry = hardware.setdefault(binding_type, {}).get(compat) |
| if compat_entry is None: |
| compat_entry = { |
| "description": _first_sentence(node.description), |
| "title": node.title or "", |
| "binding_path": rel_binding_path, |
| "custom_binding": custom_binding, |
| "locations": set(), |
| "okay": False, |
| "dts_sources": [], |
| } |
| hardware.setdefault(binding_type, {})[compat] = compat_entry |
| |
| compat_entry["locations"].add(_node_location(node, board_name)) |
| if is_okay: |
| compat_entry["okay"] = True |
| compat_entry["dts_sources"].append(node_info) |
| |
| # Update global reverse index. |
| if compat not in compatibles_db: |
| compatibles_db[compat] = { |
| "description": _first_sentence(node.description), |
| "title": node.title or "", |
| "binding_type": binding_type, |
| "boards": [], |
| } |
| if board_name not in compatibles_db[compat]["boards"]: |
| compatibles_db[compat]["boards"].append(board_name) |
| |
| # Convert sets → sorted lists for JSON serialisation. |
| for btype in hardware: |
| for compat_data in hardware[btype].values(): |
| compat_data["locations"] = sorted(compat_data["locations"]) |
| |
| board_entry["targets"][board_target] = { |
| "compatibles": sorted(target_compatibles), |
| "hardware": hardware, |
| } |
| |
| # Embed runner info at the board level; all targets are assumed to |
| # share the same flash/debug runners so the first available target is |
| # used (matching the behaviour in the doc build). |
| runners_entry: dict = {} |
| if board_name in board_runners: |
| r = next(iter(board_runners[board_name].values())) |
| runners_entry = { |
| "runners": r.get("runners") or [], |
| "flash_runner": r.get("flash-runner") or "", |
| "debug_runner": r.get("debug-runner") or "", |
| } |
| board_entry["runners"] = runners_entry |
| |
| boards_db[board_name] = board_entry |
| |
| # Sort the reverse-index board lists for deterministic output. |
| for entry in compatibles_db.values(): |
| entry["boards"].sort() |
| |
| return { |
| "generated_at": datetime.datetime.now(datetime.UTC).isoformat(), |
| "zephyr_base": str(ZEPHYR_BASE), |
| "boards": boards_db, |
| "compatibles": compatibles_db, |
| } |
| |
| |
| def _first_sentence(text: str | None) -> str: |
| """Return the first sentence of *text*, or the whole text if none found.""" |
| if not text: |
| return "" |
| text = text.replace("\n", " ") |
| first_para = text.split(" ")[0].strip() |
| match = re.search(r"(.*?)\.(?:\s|$)", first_para) |
| if match: |
| return match.group(1).strip() |
| return first_para |
| |
| |
| # --------------------------------------------------------------------------- |
| # CLI |
| # --------------------------------------------------------------------------- |
| |
| |
| def parse_args(argv=None): |
| parser = argparse.ArgumentParser( |
| description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter, |
| allow_abbrev=False, |
| ) |
| parser.add_argument( |
| "--dts-db", |
| metavar="FILE", |
| help="Path for the DTS catalog JSON output. Skipped when omitted.", |
| ) |
| parser.add_argument( |
| "--compile-db", |
| metavar="FILE", |
| help=( |
| "Path for the compile-commands database JSON output " |
| "(file→platforms and platform→files indexes). Skipped when omitted." |
| ), |
| ) |
| parser.add_argument( |
| "--kconfig-db", |
| metavar="FILE", |
| help=( |
| "Path for the Kconfig database JSON output " |
| "(option→platforms and platform→options indexes). Skipped when omitted." |
| ), |
| ) |
| parser.add_argument( |
| "--twister-outdir", |
| metavar="DIR", |
| help=( |
| "Directory to use for twister output. " |
| "Defaults to a temporary directory that is deleted after the run." |
| ), |
| ) |
| parser.add_argument( |
| "--skip-twister", |
| action="store_true", |
| help=( |
| "Skip the twister cmake-only step and use whatever build artefacts " |
| "already exist in --twister-outdir. Implies --twister-outdir." |
| ), |
| ) |
| parser.add_argument( |
| "--vendor", |
| action="append", |
| dest="vendor_filter", |
| default=[], |
| metavar="VENDOR", |
| help="Restrict to boards from this vendor (may be repeated).", |
| ) |
| parser.add_argument( |
| "-v", |
| "--verbose", |
| action="store_true", |
| help="Enable verbose/debug logging.", |
| ) |
| return parser.parse_args(argv) |
| |
| |
| def main(argv=None): |
| args = parse_args(argv) |
| |
| logging.basicConfig( |
| level=logging.DEBUG if args.verbose else logging.INFO, |
| format="%(levelname)s: %(message)s", |
| ) |
| |
| if not args.dts_db and not args.compile_db and not args.kconfig_db: |
| logger.error("At least one of --dts-db, --compile-db, or --kconfig-db must be specified") |
| sys.exit(1) |
| |
| if args.skip_twister and not args.twister_outdir: |
| logger.error("--skip-twister requires --twister-outdir") |
| sys.exit(1) |
| |
| tmp_dir = None |
| try: |
| if args.twister_outdir: |
| twister_outdir = Path(args.twister_outdir) |
| twister_outdir.mkdir(parents=True, exist_ok=True) |
| else: |
| tmp_dir = tempfile.mkdtemp(prefix="zephyr-dts-catalog-") |
| twister_outdir = Path(tmp_dir) |
| |
| if not args.skip_twister: |
| run_twister_cmake_only(twister_outdir, args.vendor_filter) |
| |
| timestamp = datetime.datetime.now(datetime.UTC).isoformat() |
| meta = { |
| "generated_at": timestamp, |
| "zephyr_base": str(ZEPHYR_BASE), |
| } |
| |
| if args.dts_db: |
| logger.info("Harvesting EDT data from %s …", twister_outdir) |
| board_edts = gather_board_edts(twister_outdir) |
| logger.info("Loaded EDT data for %d boards", len(board_edts)) |
| |
| logger.info("Harvesting runners data from %s …", twister_outdir) |
| board_runners = gather_board_runners(twister_outdir) |
| logger.info("Loaded runners data for %d boards", len(board_runners)) |
| |
| logger.info("Building DTS catalog …") |
| catalog = build_catalog(board_edts, board_runners) |
| catalog["generated_at"] = timestamp |
| |
| output_path = Path(args.dts_db) |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| with open(output_path, "w", encoding="utf-8") as fh: |
| json.dump(catalog, fh, indent=2, default=str) |
| |
| logger.info( |
| "Wrote %s (%d boards, %d compatibles)", |
| output_path, |
| len(catalog["boards"]), |
| len(catalog["compatibles"]), |
| ) |
| |
| if args.compile_db: |
| logger.info("Harvesting compile_commands data from %s …", twister_outdir) |
| board_files = gather_compile_commands(twister_outdir) |
| logger.info( |
| "Loaded compile_commands data for %d boards", |
| len(board_files), |
| ) |
| |
| logger.info("Building file index …") |
| file_index = build_file_index(board_files) |
| file_index.update(meta) |
| |
| compile_db_path = Path(args.compile_db) |
| compile_db_path.parent.mkdir(parents=True, exist_ok=True) |
| with open(compile_db_path, "w", encoding="utf-8") as fh: |
| json.dump(file_index, fh, indent=2, default=str) |
| |
| logger.info( |
| "Wrote %s (%d source files, %d platforms)", |
| compile_db_path, |
| len(file_index.get("files", {})), |
| len(file_index.get("platforms", {})), |
| ) |
| |
| if args.kconfig_db: |
| logger.info("Harvesting Kconfig data from %s …", twister_outdir) |
| board_kconfigs = gather_kconfigs(twister_outdir) |
| logger.info( |
| "Loaded Kconfig data for %d boards", |
| len(board_kconfigs), |
| ) |
| |
| logger.info("Building Kconfig index …") |
| kconfig_index = build_kconfig_db(board_kconfigs) |
| kconfig_index.update(meta) |
| |
| kconfig_db_path = Path(args.kconfig_db) |
| kconfig_db_path.parent.mkdir(parents=True, exist_ok=True) |
| with open(kconfig_db_path, "w", encoding="utf-8") as fh: |
| json.dump(kconfig_index, fh, indent=2, default=str) |
| |
| logger.info( |
| "Wrote %s (%d options, %d platforms)", |
| kconfig_db_path, |
| len(kconfig_index.get("options", {})), |
| len(kconfig_index.get("platforms", {})), |
| ) |
| |
| finally: |
| if tmp_dir: |
| import shutil |
| |
| shutil.rmtree(tmp_dir, ignore_errors=True) |
| |
| |
| if __name__ == "__main__": |
| main() |