#!/usr/bin/env python3 """Inventory compact source evidence for formats and public Python APIs.""" from __future__ import annotations import argparse import ast import json import re import subprocess from collections import Counter from pathlib import Path CPP_SUFFIXES = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"} FORMAT_PATTERN = re.compile( r"(?i)(?:extension|suffix|filter|format|reader|writer|import|export)[^\n]{0,100}" r"(\.(?:step|stp|iges|igs|brep|brp|stl|obj|off|ply|fcstd|fcbak|svg|dxf|ifc|dae|3mf|amf|ast|vrml|wrl|json|csv|txt))" ) CLASS_PATTERN = re.compile(r"\b(?:class|struct)\s+([A-Za-z_][\w]*(?:Reader|Writer|Importer|Exporter|Format|Import|Export)[A-Za-z_\w]*)\b") def revision(source_root: Path) -> str: try: return subprocess.run( ["git", "-C", str(source_root), "rev-parse", "HEAD"], check=True, capture_output=True, text=True, ).stdout.strip() except (OSError, subprocess.CalledProcessError): return "unknown" def direction(line: str) -> str: lowered = line.lower() if "import" in lowered or "reader" in lowered: return "import" if "export" in lowered or "writer" in lowered: return "export" return "unknown" def scan_formats(source_root: Path, module_root: Path) -> list[dict[str, object]]: evidence: dict[tuple[str, ...], dict[str, object]] = {} for path in sorted(module_root.rglob("*")): if not path.is_file() or path.suffix not in CPP_SUFFIXES | {".py", ".pyi"}: continue text = path.read_text(encoding="utf-8", errors="replace") relative = str(path.relative_to(source_root)) module = path.relative_to(module_root).parts[0] for line_number, line in enumerate(text.splitlines(), start=1): for match in FORMAT_PATTERN.finditer(line): extension = match.group(1).lower() item_direction = direction(line) key = (extension, item_direction, module, relative) evidence.setdefault(key, {"extension": extension, "direction": item_direction, "module": module, "path": relative, "line": line_number}) for match in CLASS_PATTERN.finditer(line): name = match.group(1) key = (name, "class-evidence", module) evidence.setdefault(key, {"class": name, "direction": "class-evidence", "module": module, "path": relative, "line": line_number}) return sorted(evidence.values(), key=lambda item: (str(item.get("extension", item.get("class"))), str(item["path"]))) def public_api_nodes(tree: ast.AST) -> list[tuple[str, str, int]]: rows: list[tuple[str, str, int]] = [] for node in getattr(tree, "body", []): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_"): rows.append(("function", node.name, node.lineno)) elif isinstance(node, ast.ClassDef) and not node.name.startswith("_"): rows.append(("class", node.name, node.lineno)) for member in node.body: if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)) and not member.name.startswith("_"): rows.append(("method", f"{node.name}.{member.name}", member.lineno)) return rows def scan_api(source_root: Path, module_root: Path) -> list[dict[str, object]]: evidence: dict[tuple[str, ...], dict[str, object]] = {} for path in sorted(module_root.rglob("*")): if not path.is_file() or path.suffix not in {".py", ".pyi"}: continue lowered_parts = {part.lower() for part in path.relative_to(module_root).parts} if any(part.startswith("test") or part in {"tests", "testing"} for part in lowered_parts): continue text = path.read_text(encoding="utf-8", errors="replace") try: tree = ast.parse(text, filename=str(path)) except SyntaxError: continue relative = str(path.relative_to(source_root)) module = path.relative_to(module_root).parts[0] for kind, name, line in public_api_nodes(tree): key = (kind, name, module) evidence.setdefault(key, {"kind": kind, "name": name, "module": module, "path": relative, "line": line}) return sorted(evidence.values(), key=lambda item: (str(item["module"]), str(item["name"]), str(item["path"]))) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--source-root", type=Path, required=True) parser.add_argument("--workbench-inventory", type=Path, required=True) parser.add_argument("--json-output", type=Path, required=True) parser.add_argument("--markdown-output", type=Path, required=True) args = parser.parse_args() source_root = args.source_root.resolve() module_root = source_root / "src/Mod" formats = scan_formats(source_root, module_root) apis = scan_api(source_root, module_root) report = { "schema": "freecad-web-format-api-inventory/v1", "source_revision": revision(source_root), "format_evidence_count": len(formats), "api_evidence_count": len(apis), "formats": formats, "python_api": apis, } args.json_output.parent.mkdir(parents=True, exist_ok=True) args.json_output.write_text(json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8") extension_rows: list[tuple[str, str, str]] = [] for extension in sorted({str(item["extension"]) for item in formats if "extension" in item}): rows = [item for item in formats if item.get("extension") == extension] directions = ", ".join(sorted({str(item["direction"]) for item in rows})) extension_rows.append((extension, directions, f"{rows[0]['path']}:{rows[0]['line']}")) module_counts = Counter(str(item["module"]) for item in apis) lines = [ "# FreeCAD Web Format and Python API Inventory", "", f"- Source revision: `{report['source_revision']}`", f"- Format evidence: `{len(formats)}`", f"- Public Python API evidence: `{len(apis)}`", "", "## Formats", "", "| Extension | Direction | First source evidence |", "|---|---|---|", *[f"| `{extension}` | {directions} | `{source}` |" for extension, directions, source in extension_rows], "", "## Python API Counts", "", "| Module directory | Public entries |", "|---|---:|", *[f"| `{module}` | {count} |" for module, count in sorted(module_counts.items())], ] args.markdown_output.write_text("\n".join(lines) + "\n", encoding="utf-8") print(json.dumps({"schema": report["schema"], "format_evidence_count": len(formats), "api_evidence_count": len(apis), "extensions": len(extension_rows)})) return 0 if __name__ == "__main__": raise SystemExit(main())