| # Copyright 2026 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """Scans directories for Zephyr modules.""" |
| |
| import argparse |
| import json |
| import os |
| import re |
| import sys |
| |
| try: |
| import yaml |
| except ImportError: |
| print("PyYAML is required but not found.", file=sys.stderr) |
| sys.exit(1) |
| |
| |
| def parse_args(argv=None): |
| parser = argparse.ArgumentParser() |
| parser.add_argument( |
| "--modules-dirs-json", |
| required=True, |
| help="JSON string mapping modules_dirs labels to absolute paths", |
| ) |
| return parser.parse_args(argv) |
| |
| |
| def sanitize_name(name): |
| return re.sub(r"[^a-zA-Z0-9]", "_", name) |
| |
| |
| def extract_apparent_name(canonical_name): |
| """Extracts the apparent name from Bazel 8 canonical names. |
| |
| Examples: |
| - "rules_python~1.8.3" -> "rules_python" |
| - "zephyr-bazel++zephyr_setup+zephyr_kconfig" -> "zephyr_kconfig" |
| - "+_repo_rules+hal_atmel" -> "hal_atmel" |
| """ |
| if "~" in canonical_name: |
| parts = canonical_name.split("~") |
| if not parts[0]: |
| raise ValueError( |
| f"Invalid canonical name (starts with '~'): '{canonical_name}'" |
| ) |
| return parts[0] |
| |
| if "+" in canonical_name: |
| parts = canonical_name.split("+") |
| |
| # Check for "module+version" or "module+" format (Bazel 8 module names) |
| if len(parts) == 2 and parts[0]: |
| return parts[0] |
| |
| # Check for "owner++extension+repo" format |
| if "" in parts: |
| if len(parts) >= 2 and parts[1] == "": |
| if len(parts) < 4: |
| raise ValueError( |
| "Invalid canonical name (expected " |
| f"'owner++extension+repo'): '{canonical_name}'" |
| ) |
| if not parts[3]: |
| raise ValueError( |
| "Invalid canonical name (empty repo name in " |
| f"'owner++extension+repo'): '{canonical_name}'" |
| ) |
| return parts[3] |
| |
| # Check for "+_repo_rules+repo" or "+extension+repo" format |
| if parts[0] == "": |
| if len(parts) >= 2 and parts[1] == "_repo_rules": |
| if len(parts) < 3: |
| raise ValueError( |
| "Invalid canonical name (expected " |
| f"'+_repo_rules+repo'): '{canonical_name}'" |
| ) |
| if not parts[2]: |
| raise ValueError( |
| "Invalid canonical name (empty repo name in " |
| f"'+_repo_rules+repo'): '{canonical_name}'" |
| ) |
| return parts[2] |
| else: |
| # General "+extension+repo" format |
| if len(parts) >= 3: |
| if not parts[2]: |
| raise ValueError( |
| "Invalid canonical name (empty repo name in " |
| f"'+extension+repo'): '{canonical_name}'" |
| ) |
| return parts[2] |
| raise ValueError( |
| "Invalid canonical name (starts with '+' but unknown " |
| f"format): '{canonical_name}'" |
| ) |
| |
| return canonical_name |
| |
| |
| def get_module_name(path): |
| """Checks if path is a module, returns sanitized name if it is.""" |
| module_yml = os.path.join(path, "zephyr", "module.yml") |
| if not os.path.exists(module_yml): |
| return None |
| |
| name = extract_apparent_name(os.path.basename(path)) |
| try: |
| with open(module_yml, "r", encoding="utf-8") as f: |
| data = yaml.safe_load(f) |
| if data and isinstance(data, dict): |
| name = data.get("name", name) |
| except Exception as e: |
| print( |
| f"Warning: failed to parse {module_yml}: {e}", file=sys.stderr |
| ) |
| |
| return sanitize_name(name) |
| |
| |
| def main(argv=None): |
| args = parse_args(argv) |
| |
| try: |
| modules_dirs = json.loads(args.modules_dirs_json) |
| except Exception as e: |
| print(f"Error parsing modules-dirs-json: {e}", file=sys.stderr) |
| sys.exit(1) |
| |
| discovered = [] |
| |
| for label, path in modules_dirs.items(): |
| if not os.path.exists(path): |
| continue |
| |
| # Check if the directory itself is a module |
| name = get_module_name(path) |
| if name: |
| has_build = os.path.exists(os.path.join(path, "BUILD.bazel")) |
| discovered.append( |
| { |
| "modules_dir_label": label, |
| "relpath": "", |
| "name": name, |
| "abs_path": path, |
| "has_build": has_build, |
| } |
| ) |
| continue |
| |
| # Otherwise scan subdirectories (depth 1) |
| try: |
| for entry in os.scandir(path): |
| if entry.is_dir() and not entry.name.startswith("."): |
| name = get_module_name(entry.path) |
| if name: |
| has_build = os.path.exists(os.path.join(entry.path, "BUILD.bazel")) |
| discovered.append( |
| { |
| "modules_dir_label": label, |
| "relpath": entry.name, |
| "name": name, |
| "abs_path": entry.path, |
| "has_build": has_build, |
| } |
| ) |
| except Exception as e: |
| print(f"Error scanning {path}: {e}", file=sys.stderr) |
| |
| print(json.dumps(discovered)) |
| |
| |
| if __name__ == "__main__": |
| main() |