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