import importlib import importlib.util import faulthandler import hashlib import json import os import re import sys import FreeCAD as App PROBE_SCOPE = os.environ.get("FREECAD_REFERENCE_SCOPE", "full") if PROBE_SCOPE not in {"full", "gui-commands"}: raise RuntimeError("Unknown FreeCAD reference probe scope: " + PROBE_SCOPE) TRACEBACK_SECONDS = int(os.environ.get("FREECAD_REFERENCE_TRACEBACK_SECONDS", "0")) if TRACEBACK_SECONDS > 0: faulthandler.dump_traceback_later(TRACEBACK_SECONDS, repeat=True, file=2) ISOLATED_GUI_CONFIG = os.environ.get("FREECAD_ORACLE_ISOLATED_CONFIG") == "1" if PROBE_SCOPE == "gui-commands" and ISOLATED_GUI_CONFIG: App.ParamGet("User parameter:BaseApp/Preferences/Mod/BIM").SetBool("FirstTime", False) def probe_progress(message): print("FREECAD_REFERENCE_PROGRESS=" + message, file=sys.stderr, flush=True) def property_metadata(obj): def stable_text(value): text = re.sub(r"0x[0-9a-fA-F]+", "
", str(value)) text = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "", text) return re.sub(r"(FreeCAD_Doc__[^/_]+_)\d+", r"\1", text) def json_value(value): if value is None or isinstance(value, (bool, int, float)): return value if isinstance(value, str): return stable_text(value) if isinstance(value, (list, tuple)): return [json_value(item) for item in value] if isinstance(value, dict): return {str(key): json_value(item) for key, item in value.items()} # FreeCAD quantities and placements keep a stable textual form while # the report remains valid JSON for every registered property type. try: return stable_text(value) except Exception: return "" properties = [] for name in obj.PropertiesList: try: type_id = obj.getTypeIdOfProperty(name) except Exception: type_id = "unknown" try: group = obj.getGroupOfProperty(name) except Exception: group = "" try: status = list(obj.getPropertyStatus(name)) except Exception: status = [] try: default = json_value(getattr(obj, name)) except Exception: default = "" properties.append({"name": name, "typeId": type_id, "group": group, "status": status, "default": default}) return properties def probe_object(document, type_id): object_name = "Reference" + type_id.replace(":", "_") try: obj = document.addObject(type_id, object_name) except Exception as error: return {"typeId": type_id, "available": False, "probeStatus": "unavailable", "error": str(error)} return { "typeId": type_id, "available": True, "probeStatus": "available", "name": obj.Name, "runtimeTypeId": obj.TypeId, "properties": property_metadata(obj), } def probe_registered_objects(document): try: registered = sorted(set(str(type_id) for type_id in document.supportedTypes())) except Exception as error: return {"available": False, "types": [], "error": str(error)} records = [] for index, type_id in enumerate(registered): record = probe_object(document, type_id) record["candidateIndex"] = index record["candidateSource"] = "Document.supportedTypes" records.append(record) # A few GUI-only TechDraw providers dereference their view during # removeObject(). Keep those instances alive until FreeCAD exits so a # probe cannot turn a valid metadata observation into a process crash. if not bool(getattr(App, "GuiUp", False)) and record.get("available") and record.get("name"): try: document.removeObject(record["name"]) except Exception as error: record["cleanupError"] = str(error) return { "available": True, "candidateSource": "Document.supportedTypes", "candidateCount": len(records), "availableCount": sum(1 for record in records if record.get("available")), "unavailableCount": sum(1 for record in records if not record.get("available")), "types": records, } ALL_REFERENCE_MODULES = [ "AddonManager", "Assembly", "BIM", "CAM", "Cloud", "Draft", "Fem", "Help", "Idf", "Import", "Inspection", "JtReader", "Material", "Measure", "Mesh", "MeshPart", "OpenSCAD", "Part", "PartDesign", "Plot", "Points", "ReverseEngineering", "Robot", "Sandbox", "Show", "Sketcher", "Spreadsheet", "Start", "Surface", "TechDraw", "TemplatePyMod", "Test", "Tux", "Web", ] GUI_REQUIRED_MODULES = { "AddonManager", "Assembly", "BIM", "CAM", "Draft", "Fem", "Help", "Idf", "Import", "Inspection", "JtReader", "Material", "Measure", "Mesh", "MeshPart", "OpenSCAD", "Part", "PartDesign", "Plot", "Points", "ReverseEngineering", "Robot", "Sandbox", "Show", "Sketcher", "Spreadsheet", "Start", "Surface", "TechDraw", "Tux", "Web", } def module_is_installed(name): suffixes = ("", ".py", ".so", ".pyd", ".dll", ".dylib") roots = [path for path in sys.path if path] try: resource_dir = App.getResourceDir() roots.extend([resource_dir, os.path.join(resource_dir, "Mod")]) except Exception: pass for root in roots: for suffix in suffixes: if os.path.exists(os.path.join(root, name + suffix)): return True if os.path.exists(os.path.join(root, name, "__init__" + suffix)): return True if os.path.exists(os.path.join(root, "Mod", name + suffix)): return True return False def probe_module(name): gui_required = name in GUI_REQUIRED_MODULES installed = module_is_installed(name) spec = None try: spec = importlib.util.find_spec(name) except Exception: spec = None try: module = importlib.import_module(name) importable = True import_error = None file = getattr(module, "__file__", None) except Exception as caught_error: importable = False import_error = caught_error file = getattr(spec, "origin", None) if spec else None compiled = bool(installed or spec) if importable and gui_required and not bool(getattr(App, "GuiUp", False)): runtime_status = "gui-only-unprobeable" elif importable: runtime_status = "compiled-importable" elif compiled: runtime_status = "compiled-import-failure" else: runtime_status = "not-built" result = { "name": name, "available": importable, "compiled": compiled, "importable": importable, "guiRequired": gui_required, "guiAvailable": bool(getattr(App, "GuiUp", False)), "runtimeStatus": runtime_status, "file": file, } if import_error is not None: result["error"] = str(import_error) return result def probe_gui_commands(): if not bool(getattr(App, "GuiUp", False)): return {"available": False, "workbenches": [], "commands": []} try: import FreeCADGui as Gui except Exception as error: return {"available": False, "workbenches": [], "commands": [], "error": str(error)} try: workbench_names = sorted(Gui.listWorkbenches().keys()) except Exception: workbench_names = [] workbench_filter = os.environ.get("FREECAD_GUI_WORKBENCH_FILTER") if workbench_filter: if workbench_filter not in workbench_names: raise RuntimeError("Unknown GUI workbench filter: " + workbench_filter) workbench_names = [workbench_filter] command_registrations = {} def command_active(command_id): try: command = Gui.Command.get(command_id) if command is None: return None # StdCmdExpression dereferences its private QAction in isActive(). # Other commands must use their native predicate; QAction enabled # state is only a cached presentation value and misses context changes. if command_id == "Std_Expressions": actions = command.getAction() return bool(actions[0].isEnabled()) if actions else None return bool(command.isActive()) except Exception as error: return {"error": str(error)} workbenches = [] for workbench_name in workbench_names: probe_progress("workbench:start:" + workbench_name) try: Gui.activateWorkbench(workbench_name) Gui.updateGui() command_ids = sorted(set(Gui.listCommands())) for command_id in command_ids: command_registrations.setdefault(command_id, []).append(workbench_name) workbenches.append({"name": workbench_name, "commandCount": len(command_ids), "status": "probed"}) probe_progress("workbench:done:" + workbench_name) except Exception as error: try: Gui.Selection.clearSelection() except Exception: pass workbenches.append({"name": workbench_name, "commandCount": 0, "status": "probe-failure", "error": str(error)}) probe_progress("workbench:failed:" + workbench_name) # Command registration is workbench-scoped, but document/selection state is # intrinsic to each command. Probe the state matrix once after all providers # are loaded instead of repeating the cumulative command set per workbench. for document_name in list(App.listDocuments().keys()): App.closeDocument(document_name) command_ids = sorted(command_registrations.keys()) command_universe_sha256 = hashlib.sha256("\n".join(command_ids).encode("utf-8")).hexdigest() if os.environ.get("FREECAD_GUI_REGISTRATION_ONLY") == "1": return { "available": True, "probeMode": "registration-only", "stateProbe": "not-probed", "stateCases": [], "directIsActiveBoundary": "not-applicable", "commandUniverseCount": len(command_ids), "commandUniverseSha256": command_universe_sha256, "stateCommandCount": 0, "shard": None, "workbenches": workbenches, "commands": [ { "id": command_id, "registeredWorkbenches": command_registrations[command_id], } for command_id in command_ids ], } shard = None state_command_ids = command_ids shard_spec = os.environ.get("FREECAD_GUI_COMMAND_SHARD") if shard_spec: try: shard_index, shard_count = [int(value) for value in shard_spec.split("/", 1)] except Exception: raise RuntimeError("FREECAD_GUI_COMMAND_SHARD must use zero-based INDEX/COUNT syntax") if shard_count < 1 or shard_index < 0 or shard_index >= shard_count: raise RuntimeError("FREECAD_GUI_COMMAND_SHARD is outside its declared range") shard_start = len(command_ids) * shard_index // shard_count shard_end = len(command_ids) * (shard_index + 1) // shard_count state_command_ids = command_ids[shard_start:shard_end] shard = { "index": shard_index, "count": shard_count, "start": shard_start, "end": shard_end, } Gui.Selection.clearSelection() Gui.updateGui() probe_progress("state:start:noDocument") no_document = {command_id: command_active(command_id) for command_id in state_command_ids} probe_progress("state:done:noDocument") context_document = App.newDocument("CommandContext") selected_object = context_document.addObject("Part::Box", "CommandProbeBox") Gui.updateGui() probe_progress("state:start:documentNoSelection") document_no_selection = {command_id: command_active(command_id) for command_id in state_command_ids} probe_progress("state:done:documentNoSelection") Gui.Selection.addSelection(context_document.Name, selected_object.Name) Gui.updateGui() probe_progress("state:start:selectedPartBox") selected_selection = {command_id: command_active(command_id) for command_id in state_command_ids} probe_progress("state:done:selectedPartBox") Gui.Selection.clearSelection() App.closeDocument(context_document.Name) return { "available": True, "probeMode": "state-matrix", "stateProbe": "native-is-active-after-workbench-registration-with-Std_Expressions-qaction-guard", "stateCases": ["noDocument", "documentNoSelection", "selectedPartBox"], "directIsActiveBoundary": "Std_Expressions-only-qaction-guard-for-locked-1.1.1-null-qaction-crash", "commandUniverseCount": len(command_ids), "commandUniverseSha256": command_universe_sha256, "stateCommandCount": len(state_command_ids), "shard": shard, "workbenches": workbenches, "commands": [ { "id": command_id, "registeredWorkbenches": command_registrations[command_id], "observations": [{ "workbench": "all-registered-runtime-context", "noDocument": no_document.get(command_id), "documentNoSelection": document_no_selection.get(command_id), "selectedPartBox": selected_selection.get(command_id), }], } for command_id in state_command_ids ], } modules = [] if PROBE_SCOPE == "full": document = App.newDocument("ReferenceProbe") # Creating one native Part object loads the same runtime type registry used # by the locked desktop baseline before supportedTypes() is enumerated. document.addObject("Part::Box", "TypeRegistryProbeBox") modules = [probe_module(name) for name in ALL_REFERENCE_MODULES] App.closeDocument(document.Name) gui_commands = probe_gui_commands() version = App.Version() result = { "schemaVersion": 1, "baselineId": "freecad-1.1.1", "freecadVersion": ".".join(str(value) for value in version[:3]), "revision": str(version[3]), "gitBranch": str(version[6]) if len(version) > 6 else "", "gitCommit": str(version[7]) if len(version) > 7 else "", "guiUp": bool(getattr(App, "GuiUp", False)), "buildPurpose": "desktop-reference-oracle" if bool(getattr(App, "GuiUp", False)) else "headless-reference-oracle", "probeScope": PROBE_SCOPE, "oracleSetup": { "isolatedConfig": ISOLATED_GUI_CONFIG, "bimFirstTimeWelcome": "suppressed-before-workbench-activation" if ISOLATED_GUI_CONFIG else "native-user-config", }, "determinism": { "schemaVersion": 1, "normalizedProcessFields": ["pointer-address", "uuid", "freecad-document-cache-run"], }, "moduleCount": len(modules), "modules": modules, "guiCommands": gui_commands, } if PROBE_SCOPE == "full": # Workbench activation registers additional GUI-owned document types. # Enumerate them only in the independent object/property scope. document = App.newDocument("RuntimeObjectProbe") document.addObject("Part::Box", "TypeRegistryProbeBox") result["runtimeObjects"] = probe_registered_objects(document) result["objects"] = [probe_object(document, type_id) for type_id in [ "Part::Box", "Part::Cylinder", "Part::Sphere", "Part::Cone", "Part::Feature", "PartDesign::Feature", "Sketcher::SketchObject", ]] App.closeDocument(document.Name) result["moduleStatusSummary"] = { status: sum(1 for module in result["modules"] if module["runtimeStatus"] == status) for status in sorted({module["runtimeStatus"] for module in result["modules"]}) } print("FREECAD_REFERENCE_RESULT=" + json.dumps(result, sort_keys=True, separators=(",", ":"))) sys.stdout.flush() sys.exit(0)