153 lines
6.8 KiB
Python
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())
|