Files
FreeCADWasmDev/tools/generate_web_format_api_inventory.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

153 lines
6.8 KiB
Python

#!/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())