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

346 lines
16 KiB
Python

#!/usr/bin/env python3
"""Inventory FreeCAD workbench/module boundaries from the current source tree."""
from __future__ import annotations
import argparse
import ast
import hashlib
import json
import re
import subprocess
from pathlib import Path
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 sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def cmake_conditions(parent_text: str, name: str) -> list[str]:
stack: list[str] = []
conditions: list[str] = []
for raw_line in parent_text.splitlines():
line = raw_line.strip()
opening = re.match(r"if\s*\((.+)\)", line, re.IGNORECASE)
if opening:
stack.append(" ".join(opening.group(1).split()))
continue
if re.match(r"endif\b", line, re.IGNORECASE):
if stack:
stack.pop()
continue
if re.search(rf"add_subdirectory\({re.escape(name)}\)", line, re.IGNORECASE):
conditions.append(" and ".join(stack) if stack else "unconditional")
return conditions or ["not referenced by src/Mod/CMakeLists.txt"]
def targets(module_path: Path) -> list[str]:
names: set[str] = set()
for cmake_path in module_path.rglob("CMakeLists.txt"):
cmake = cmake_path.read_text(encoding="utf-8", errors="replace")
names.update(re.findall(r"(?:add_library|add_executable)\(\s*([A-Za-z0-9_+-]+)", cmake))
return sorted(names)
COMMAND_TOKEN = re.compile(r"^[A-Za-z][A-Za-z0-9_]+$")
def constant_label(node: ast.AST | None) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, (ast.List, ast.Tuple)):
labels = [constant_label(item) for item in node.elts]
return "/".join(label for label in labels if label) or None
return None
def python_gui_commands(path: Path) -> list[tuple[str, int, str, str | None]]:
try:
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
except SyntaxError:
return []
commands: list[tuple[str, int, str, str | None]] = []
bindings: dict[str, list[ast.AST]] = {}
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
bindings.setdefault(target.id, []).append(node.value)
def strings(node: ast.AST, seen: frozenset[str] = frozenset()):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
yield node
elif isinstance(node, (ast.List, ast.Tuple, ast.Set)):
for item in node.elts:
yield from strings(item, seen)
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
yield from strings(node.left, seen)
yield from strings(node.right, seen)
elif isinstance(node, ast.Name) and node.id not in seen:
for value in bindings.get(node.id, []):
yield from strings(value, seen | {node.id})
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
call_name = node.func.attr if isinstance(node.func, ast.Attribute) else node.func.id if isinstance(node.func, ast.Name) else ""
if call_name not in {"addCommand", "appendToolbar", "appendMenu"}:
continue
surface = "registration" if call_name == "addCommand" else "toolbar" if call_name == "appendToolbar" else "menu"
label = None if call_name == "addCommand" else constant_label(node.args[0] if node.args else None)
args = node.args[:1] if call_name == "addCommand" else node.args[1:]
for arg in args:
for child in strings(arg):
if COMMAND_TOKEN.fullmatch(child.value):
commands.append((child.value, child.lineno, surface, label))
return commands
def cpp_gui_commands(path: Path) -> list[tuple[str, int, str, str | None]]:
text = path.read_text(encoding="utf-8", errors="replace")
# Preserve line numbers and ordinary string arguments, but ignore examples
# inside comments and raw literals when locating executable registrations.
tokens = re.compile(
r'R"(?P<delimiter>[^\s()\\]{0,16})\(.*?\)(?P=delimiter)"'
r'|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\''
r'|//[^\n]*|/\*.*?\*/', re.DOTALL)
text = tokens.sub(lambda match: re.sub(r'[^\n]', ' ', match[0])
if match[0].startswith(('//', '/*', 'R"')) else match[0], text)
patterns = [
re.compile(r"(?:Gui::)?Command\s*\(\s*\"([A-Za-z][A-Za-z0-9_]+)\""),
re.compile(r"addCommand\s*\(\s*\"([A-Za-z][A-Za-z0-9_]+)\""),
]
commands: list[tuple[str, int, str, str | None]] = []
for pattern in patterns:
for match in pattern.finditer(text):
commands.append((match.group(1), text.count("\n", 0, match.start()) + 1, "registration", None))
return commands
def source_gui_inventory(source_root: Path, module_path: Path) -> dict[str, object]:
command_evidence: dict[str, dict[str, object]] = {}
task_panels: list[str] = []
resources: list[str] = []
# Python workbenches register commands outside Gui/ as well. Only command
# registrations and menu references are collected, never Python API symbols.
gui_files = sorted(path for path in module_path.rglob("*") if path.is_file()
and ("Gui" in path.parts or path.suffix == ".py")
and not any(part.lower() in {"tests", "test", "__pycache__"} for part in path.relative_to(module_path).parts[:-1]))
for path in gui_files:
suffix = path.suffix.lower()
if "task" in path.name.lower() and suffix in {".cpp", ".h", ".py", ".ui"}:
task_panels.append(str(path.relative_to(source_root)))
if suffix in {".qrc", ".ui", ".svg", ".png", ".xpm"}:
resources.append(str(path.relative_to(source_root)))
found = python_gui_commands(path) if suffix == ".py" else cpp_gui_commands(path) if suffix in {".cpp", ".cxx", ".cc", ".h", ".hpp"} else []
for command, line, surface, label in found:
evidence = command_evidence.setdefault(
command,
{"path": str(path.relative_to(source_root)), "line": line, "uiSurfaces": []},
)
surface_entry = {"kind": surface, "label": label, "path": str(path.relative_to(source_root)), "line": line}
if surface_entry not in evidence["uiSurfaces"]:
evidence["uiSurfaces"].append(surface_entry)
return {
"commands": command_evidence,
"taskPanelFiles": sorted(set(task_panels)),
"resourceFiles": sorted(set(resources)),
}
def web_routes(registry_path: Path) -> dict[str, dict[str, object]]:
routes: dict[str, dict[str, object]] = {}
line_pattern = re.compile(
r'\{ module: "([^"]+)", workers: \[(.*?)\], primaryWorker: (null|"[^"]+"), commands: \[(.*?)\].*guiDependency: (true|false),'
)
quoted = re.compile(r'"([^"]+)"')
for line_number, line in enumerate(registry_path.read_text(encoding="utf-8").splitlines(), 1):
match = line_pattern.search(line)
if not match:
continue
module, workers_text, primary_text, commands_text, gui_text = match.groups()
routes[module] = {
"workers": quoted.findall(workers_text),
"primaryWorker": None if primary_text == "null" else primary_text.strip('"'),
"commands": quoted.findall(commands_text),
"guiDependency": gui_text == "true",
"source": {"path": str(registry_path), "line": line_number},
}
return routes
def inventory(source_root: Path) -> dict[str, object]:
mod_root = source_root / "src/Mod"
parent = (mod_root / "CMakeLists.txt").read_text(encoding="utf-8", errors="replace")
modules: list[dict[str, object]] = []
for path in sorted(p for p in mod_root.iterdir() if p.is_dir()):
if path.name.startswith("."):
continue
cmake_path = path / "CMakeLists.txt"
init_path = path / "Init.py"
if not cmake_path.exists() and not init_path.exists():
continue
app_path = path / "App"
gui_path = path / "Gui"
gui_inventory = source_gui_inventory(source_root, path)
if path.name == "Web" and (source_root / "src/Gui").is_dir():
core = source_gui_inventory(source_root, source_root / "src/Gui")
gui_inventory["commands"].update(core["commands"])
for key in ("taskPanelFiles", "resourceFiles"):
gui_inventory[key] = sorted(set(gui_inventory[key]) | set(core[key]))
modules.append(
{
"id": path.name,
"source_path": str(path.relative_to(source_root)),
"cmake_path": str(cmake_path.relative_to(source_root)) if cmake_path.exists() else None,
"cmake_sha256": sha256(cmake_path) if cmake_path.exists() else None,
"init_path": str(init_path.relative_to(source_root)) if init_path.exists() else None,
"has_app": app_path.is_dir(),
"has_gui": gui_path.is_dir(),
"targets": targets(path),
"build_conditions": cmake_conditions(parent, path.name),
"gui_command_count": len(gui_inventory["commands"]),
"task_panel_file_count": len(gui_inventory["taskPanelFiles"]),
"resource_file_count": len(gui_inventory["resourceFiles"]),
"task_panel_files": gui_inventory["taskPanelFiles"],
"resource_files": gui_inventory["resourceFiles"],
"gui_commands": gui_inventory["commands"],
}
)
return {
"schema": "freecad-web-workbench-inventory/v1",
"source_root": str(source_root),
"source_revision": revision(source_root),
"module_count": len(modules),
"modules": modules,
}
def command_matrix(report: dict[str, object], registry_path: Path) -> dict[str, object]:
routes = web_routes(registry_path)
records: list[dict[str, object]] = []
module_summaries: list[dict[str, object]] = []
for module in report["modules"]: # type: ignore[index]
module_id = str(module["id"])
route = routes.get(module_id, {})
gui_commands = module["gui_commands"]
for command, source in sorted(gui_commands.items()):
records.append(
{
"id": f"{module_id}:desktop:{command}",
"module": module_id,
"command": command,
"kind": "desktop-gui",
"sourcePath": source["path"],
"sourceLine": source["line"],
"uiSurfaces": source["uiSurfaces"],
"route": None,
"disposition": "restricted",
"capabilityReason": "desktop Gui/Qt command requires a dedicated portable Web workflow and is not callable in the GUI-free WASM kernel",
}
)
for command in route.get("commands", []):
worker = route.get("primaryWorker")
records.append(
{
"id": f"{module_id}:web:{command}",
"module": module_id,
"command": command,
"kind": "web-command",
"sourcePath": "web/src/module-registry.generated.ts",
"sourceLine": route["source"]["line"],
"uiSurfaces": [{"kind": "web-workflow", "label": command, "path": "web/src/module-registry.generated.ts", "line": route["source"]["line"]}],
"route": {"worker": worker, "command": command} if worker else None,
"disposition": "native_handler" if worker else "not_implemented",
"capabilityReason": "registered Worker route; execution remains owned by its native fc_web_dispatch_json facade" if worker else "module has no registered Worker route",
}
)
module_summaries.append(
{
"module": module_id,
"hasGui": module["has_gui"],
"desktopCommandCount": len(gui_commands),
"webCommandCount": len(route.get("commands", [])),
"taskPanelFileCount": module["task_panel_file_count"],
"resourceFileCount": module["resource_file_count"],
"taskPanelEntries": module["task_panel_files"],
"resourceEntries": module["resource_files"],
}
)
disposition_counts = {key: sum(record["disposition"] == key for record in records) for key in ("native_handler", "restricted", "not_implemented")}
return {
"schema": "freecad-web-workbench-command-matrix/v1",
"taskId": "FW-P6-FPA-G",
"sourceRevision": report["source_revision"],
"source": {"workbenchInventory": "docs/workbench-inventory.json", "webRegistry": "web/src/module-registry.generated.ts", "webRegistrySha256": sha256(registry_path)},
"moduleCount": len(module_summaries),
"guiModuleCount": sum(bool(module["hasGui"]) for module in module_summaries),
"recordCount": len(records),
"dispositionCounts": disposition_counts,
"modules": module_summaries,
"records": records,
"boundaries": [
"desktop GUI commands remain restricted until a dedicated Web workflow is backed by a native dispatcher route",
"native_handler means a Worker route exists; the route-specific gate remains authoritative for command behavior",
],
}
def render(report: dict[str, object]) -> str:
lines = [
"# FreeCAD Web Workbench Inventory",
"",
f"- Source revision: `{report['source_revision']}`",
f"- Module count: `{report['module_count']}`",
"",
"| Module | App | Gui | Targets | Build condition |",
"|---|---:|---:|---|---|",
]
for item in report["modules"]: # type: ignore[index]
lines.append(
f"| `{item['id']}` | {'yes' if item['has_app'] else 'no'} | "
f"{'yes' if item['has_gui'] else 'no'} | "
f"{', '.join(f'`{target}`' for target in item['targets']) or '-'} | "
f"{'; '.join(item['build_conditions'])} |"
)
return "\n".join(lines) + "\n"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source-root", type=Path, required=True)
parser.add_argument("--json-output", type=Path, required=True)
parser.add_argument("--markdown-output", type=Path, required=True)
parser.add_argument("--web-registry", type=Path)
parser.add_argument("--command-json-output", type=Path)
parser.add_argument("--public-command-json-output", type=Path)
args = parser.parse_args()
report = inventory(args.source_root.resolve())
args.json_output.parent.mkdir(parents=True, exist_ok=True)
args.json_output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
args.markdown_output.write_text(render(report), encoding="utf-8")
summary = {"schema": report["schema"], "module_count": report["module_count"]}
if args.web_registry and args.command_json_output:
matrix = command_matrix(report, args.web_registry.resolve())
for output in filter(None, (args.command_json_output, args.public_command_json_output)):
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(matrix, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
summary.update({"command_records": matrix["recordCount"], "gui_modules": matrix["guiModuleCount"], "dispositions": matrix["dispositionCounts"]})
print(json.dumps(summary))
return 0
if __name__ == "__main__":
raise SystemExit(main())