Files
FreeCADWasmDev/tools/generate_python_api_manifest.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

191 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Generate a deterministic, source-backed Python API migration manifest.
The inventory remains the source of truth for individual symbols. This
manifest adds stable IDs, reusable adapter families, migration tiers, and
evidence pointers without pretending that a source record is an implemented
Web binding.
"""
from __future__ import annotations
import hashlib
import json
import re
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / "docs"
INPUT = DOCS / "format-api-inventory.json"
OUTPUT = DOCS / "p6" / "python-api-manifest.json"
RUNTIME_EVIDENCE = DOCS / "p6" / "python-api-runtime-evidence.json"
GUI_EXTERNAL_MODULES = {"Help", "Start", "Tux"}
def token_name(name: str) -> str:
return name.rsplit(".", 1)[-1].lower()
def family_for(row: dict[str, object]) -> str:
"""Map symbols to a small adapter family, using only stable source text."""
name = token_name(str(row["name"]))
full_name = str(row["name"]).lower()
if str(row["module"]) == "Measure" and (full_name.startswith("measurecom") or full_name.startswith("measurebasepython") or full_name == "makemeasurecom"):
return "gui-external"
if str(row["module"]) == "Measure" and (full_name.startswith("measurement.") or full_name.startswith("measurecom.")):
return "measurement-state"
if any(token in full_name for token in ("pyside", "pyqt", "qt.", "pivy", "coin.", "coin3d")):
return "gui-external"
if any(token in name for token in ("dump", "load", "serialize", "deserialize", "restore", "save", "read", "write")):
return "serialization"
if any(token in name for token in ("icon", "resource", "tooltip", "menu", "toolbar", "display", "render", "view", "visual")):
return "view-resource"
if any(token in name for token in ("property", "properties", "param", "parameter", "field", "label", "name", "value", "type")):
return "property"
if any(token in name for token in ("document", "object", "claimchildren", "child", "parent", "group", "link", "selection")):
return "document-graph"
if any(token in name for token in ("activate", "deactivate", "initialize", "init", "setup", "teardown", "finish", "cancel", "accept", "reject", "execute", "action", "command", "task")):
return "lifecycle-command"
if any(token in name for token in ("shape", "solid", "face", "edge", "wire", "mesh", "vector", "matrix", "geometry", "point", "curve", "surface")):
return "geometry-value"
if str(row["kind"]) == "function":
return "pure-function"
return "generic-object"
def tier_for(row: dict[str, object], family: str) -> str:
module = str(row["module"])
name = token_name(str(row["name"]))
if family == "gui-external":
return "tier-4-restricted"
if family == "measurement-state":
return "tier-3-stateful"
if module in GUI_EXTERNAL_MODULES or family == "gui-external":
return "tier-4-restricted"
if family in {"property", "document-graph", "lifecycle-command", "serialization"}:
return "tier-3-stateful"
if family in {"geometry-value", "pure-function"} and str(row["kind"]) == "function":
return "tier-2-pure"
if name.startswith(("get", "is", "has", "can", "supports")) or family == "view-resource":
return "tier-1-readonly"
if str(row["kind"]) == "class":
return "tier-1-readonly"
return "tier-2-pure"
def stable_id(row: dict[str, object]) -> str:
module = str(row["module"]).strip()
kind = str(row["kind"]).strip().lower()
name = re.sub(r"\s+", "", str(row["name"]).strip())
return f"py:{module}:{kind}:{name}"
def main() -> None:
source = INPUT.read_bytes()
inventory = json.loads(source)
rows = inventory["python_api"]
runtime_names = set()
runtime_artifacts: dict[tuple[str, str], list[str]] = {}
if RUNTIME_EVIDENCE.exists():
runtime = json.loads(RUNTIME_EVIDENCE.read_text(encoding="utf-8"))
for item in runtime.get("names", []):
module = str(item.get("module", "")).strip().lower()
name = str(item.get("name", "")).strip().lower()
if module and name:
runtime_names.add((module, name))
runtime_artifacts[(module, name)] = [str(path) for path in item.get("artifacts", [])]
exact_keys = [(str(row["module"]), str(row["kind"]), str(row["name"])) for row in rows]
duplicate_counts = Counter(exact_keys)
duplicates = sorted(
{"key": list(key), "count": count} for key, count in duplicate_counts.items() if count > 1
)
records = []
family_counts = Counter()
tier_counts = Counter()
module_stats: dict[str, Counter[str]] = defaultdict(Counter)
for row in sorted(rows, key=lambda item: (str(item["module"]), str(item["kind"]), str(item["name"]))):
family = family_for(row)
tier = tier_for(row, family)
full_name = str(row["name"])
runtime_module = str(row["module"]).lower()
runtime_key = full_name.lower()
runtime_leaf = full_name.rsplit(".", 1)[-1].lower()
runtime_match = (runtime_module, runtime_key) in runtime_names or (runtime_module, runtime_leaf) in runtime_names
disposition = "restricted" if tier == "tier-4-restricted" else "exposed" if runtime_match else "planned"
record = {
"id": stable_id(row),
"module": str(row["module"]),
"kind": str(row["kind"]),
"name": str(row["name"]),
"family": family,
"tier": tier,
"disposition": disposition,
"evidence": {"path": str(row["path"]), "line": int(row["line"])},
}
if tier == "tier-4-restricted":
record["dispositionReason"] = "requires GUI/PySide/Pivy/desktop migration or external UI capability outside GUI-free Web runtime"
elif runtime_match:
record["dispositionEvidence"] = runtime_artifacts.get((runtime_module, runtime_key), runtime_artifacts.get((runtime_module, runtime_leaf), []))
record["dispositionReason"] = "passing native python-api runtime evidence matched by fully-qualified or leaf API name"
records.append(record)
family_counts[family] += 1
tier_counts[tier] += 1
module_stats[record["module"]][record["kind"]] += 1
modules = []
for module in sorted(module_stats):
counts = module_stats[module]
modules.append({
"module": module,
"count": sum(counts.values()),
"classes": counts["class"],
"functions": counts["function"],
"methods": counts["method"],
})
manifest = {
"schema": "freecad-web-python-api-manifest/v1",
"generatedAt": date.today().isoformat(),
"source": {
"inventory": "docs/format-api-inventory.json",
"inventorySha256": hashlib.sha256(source).hexdigest(),
"sourceRevision": inventory.get("source_revision", "unknown"),
},
"counts": {
"inputRecords": len(rows),
"canonicalRecords": len(records),
"exactDuplicateRecords": len(rows) - len(duplicate_counts),
"exactDuplicateKeys": len(duplicates),
"byKind": dict(sorted(Counter(str(row["kind"]) for row in rows).items())),
"byFamily": dict(sorted(family_counts.items())),
"byTier": dict(sorted(tier_counts.items())),
},
"identity": {
"key": "module + kind + fully-qualified name",
"sourcePathAndLine": "evidence only; never part of identity",
"overloadPolicy": "group by family adapter and preserve source signature evidence when extracted",
},
"modules": modules,
"duplicates": duplicates,
"records": records,
}
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({
"output": str(OUTPUT.relative_to(ROOT)),
"inputRecords": len(rows),
"canonicalRecords": len(records),
"exactDuplicateRecords": manifest["counts"]["exactDuplicateRecords"],
"families": len(family_counts),
"tiers": dict(sorted(tier_counts.items())),
"sha256": hashlib.sha256(OUTPUT.read_bytes()).hexdigest(),
}, ensure_ascii=False))
if __name__ == "__main__":
main()