346 lines
16 KiB
Python
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())
|