From 60ceb47e4f2ccdd694cc10de36411763fc2a7370 Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Mon, 16 Feb 2026 13:13:46 -0600 Subject: [PATCH] feat(bootstrap): replace exec()-based addon loading with manifest-driven loader Add addon_loader.py implementing a six-stage pipeline: scan mods/ for package.xml files, parse standard fields and optional extensions via ElementTree, validate version compatibility, resolve load order via graphlib.TopologicalSorter (with legacy fallback), exec() Init.py/InitGui.py, and populate a runtime AddonRegistry exposed as FreeCAD.KindredAddons. Replace hard-coded addon lists in Init.py and InitGui.py with calls to addon_loader.load_addons(). All QTimer-based Silo integration code in InitGui.py is unchanged. Backward compatible: addons without elements load with no constraints using the existing ztools-then-silo order. Closes #248 --- src/Mod/Create/CMakeLists.txt | 1 + src/Mod/Create/Init.py | 47 +--- src/Mod/Create/InitGui.py | 46 +-- src/Mod/Create/addon_loader.py | 493 +++++++++++++++++++++++++++++++++ 4 files changed, 506 insertions(+), 81 deletions(-) create mode 100644 src/Mod/Create/addon_loader.py diff --git a/src/Mod/Create/CMakeLists.txt b/src/Mod/Create/CMakeLists.txt index 209a548ec2..af2bcab4f3 100644 --- a/src/Mod/Create/CMakeLists.txt +++ b/src/Mod/Create/CMakeLists.txt @@ -13,6 +13,7 @@ install( FILES Init.py InitGui.py + addon_loader.py kc_format.py update_checker.py ${CMAKE_CURRENT_BINARY_DIR}/version.py diff --git a/src/Mod/Create/Init.py b/src/Mod/Create/Init.py index ab715df421..1769b66306 100644 --- a/src/Mod/Create/Init.py +++ b/src/Mod/Create/Init.py @@ -1,48 +1,13 @@ # Kindred Create - Core Module -# Console initialization - loads ztools and Silo addons - -import os -import sys +# Console initialization - loads Kindred addons via manifest-driven loader import FreeCAD +try: + from addon_loader import load_addons -def setup_kindred_addons(): - """Add Kindred Create addon paths and load their Init.py files.""" - # Get the FreeCAD home directory (where src/Mod/Create is installed) - home = FreeCAD.getHomePath() - mods_dir = os.path.join(home, "mods") + load_addons(gui=False) +except Exception as e: + FreeCAD.Console.PrintWarning(f"Create: Addon loader failed: {e}\n") - # Define built-in addons with their paths relative to mods/ - addons = [ - ("ztools", "ztools/ztools"), # mods/ztools/ztools/ - ("silo", "silo/freecad"), # mods/silo/freecad/ - ] - - for name, subpath in addons: - addon_path = os.path.join(mods_dir, subpath) - if os.path.isdir(addon_path): - # Add to sys.path if not already present - if addon_path not in sys.path: - sys.path.insert(0, addon_path) - - # Execute Init.py if it exists - init_file = os.path.join(addon_path, "Init.py") - if os.path.isfile(init_file): - try: - with open(init_file) as f: - exec_globals = globals().copy() - exec_globals["__file__"] = init_file - exec_globals["__name__"] = name - exec(compile(f.read(), init_file, "exec"), exec_globals) - FreeCAD.Console.PrintLog(f"Create: Loaded {name} Init.py\n") - except Exception as e: - FreeCAD.Console.PrintWarning( - f"Create: Failed to load {name}: {e}\n" - ) - else: - FreeCAD.Console.PrintLog(f"Create: Addon path not found: {addon_path}\n") - - -setup_kindred_addons() FreeCAD.Console.PrintLog("Create module initialized\n") diff --git a/src/Mod/Create/InitGui.py b/src/Mod/Create/InitGui.py index ad9addde65..8a56b33f33 100644 --- a/src/Mod/Create/InitGui.py +++ b/src/Mod/Create/InitGui.py @@ -1,50 +1,16 @@ # Kindred Create - Core Module -# GUI initialization - loads ztools and Silo workbenches - -import os -import sys +# GUI initialization - loads Kindred addon workbenches via manifest-driven loader import FreeCAD import FreeCADGui +try: + from addon_loader import load_addons -def setup_kindred_workbenches(): - """Load Kindred Create addon workbenches.""" - home = FreeCAD.getHomePath() - mods_dir = os.path.join(home, "mods") + load_addons(gui=True) +except Exception as e: + FreeCAD.Console.PrintWarning(f"Create: Addon GUI loader failed: {e}\n") - addons = [ - ("ztools", "ztools/ztools"), - ("silo", "silo/freecad"), - ] - - for name, subpath in addons: - addon_path = os.path.join(mods_dir, subpath) - if os.path.isdir(addon_path): - # Ensure path is in sys.path - if addon_path not in sys.path: - sys.path.insert(0, addon_path) - - # Execute InitGui.py if it exists - init_gui_file = os.path.join(addon_path, "InitGui.py") - if os.path.isfile(init_gui_file): - try: - with open(init_gui_file) as f: - exec_globals = globals().copy() - exec_globals["__file__"] = init_gui_file - exec_globals["__name__"] = name - exec( - compile(f.read(), init_gui_file, "exec"), - exec_globals, - ) - FreeCAD.Console.PrintLog(f"Create: Loaded {name} workbench\n") - except Exception as e: - FreeCAD.Console.PrintWarning( - f"Create: Failed to load {name} GUI: {e}\n" - ) - - -setup_kindred_workbenches() FreeCAD.Console.PrintLog("Create GUI module initialized\n") diff --git a/src/Mod/Create/addon_loader.py b/src/Mod/Create/addon_loader.py new file mode 100644 index 0000000000..f229b991ad --- /dev/null +++ b/src/Mod/Create/addon_loader.py @@ -0,0 +1,493 @@ +# Kindred Create - Manifest-driven addon loader +# +# Replaces the hard-coded exec() loading in Init.py/InitGui.py with a +# pipeline that scans mods/, parses package.xml manifests, validates +# compatibility, resolves dependency order, and exposes a runtime registry. + +import enum +import os +import sys +import time +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from typing import Optional + +import FreeCAD + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +class AddonState(enum.Enum): + DISCOVERED = "discovered" + VALIDATED = "validated" + LOADED = "loaded" + SKIPPED = "skipped" + FAILED = "failed" + + +@dataclass +class AddonManifest: + """Parsed addon metadata from package.xml plus Kindred extensions.""" + + # Identity (from package.xml standard fields) + name: str + version: str + description: str = "" + + # Paths (resolved during discovery) + package_xml_path: str = "" + addon_root: str = "" + workbench_path: str = "" + + # Kindred extensions (from element, all optional) + min_create_version: Optional[str] = None + max_create_version: Optional[str] = None + load_priority: int = 100 + dependencies: list[str] = field(default_factory=list) + has_kindred_element: bool = False + + # Runtime state + state: AddonState = AddonState.DISCOVERED + error: str = "" + load_time_ms: float = 0.0 + + def __repr__(self): + return ( + f"AddonManifest(name={self.name!r}, version={self.version!r}, state={self.state.value})" + ) + + +# --------------------------------------------------------------------------- +# Addon registry +# --------------------------------------------------------------------------- + + +class AddonRegistry: + """Runtime registry of discovered addons and their load status.""" + + def __init__(self): + self._addons: dict[str, AddonManifest] = {} + self._load_order: list[str] = [] + + def register(self, manifest: AddonManifest): + self._addons[manifest.name] = manifest + + def set_load_order(self, names: list[str]): + self._load_order = list(names) + + def get(self, name: str) -> Optional[AddonManifest]: + return self._addons.get(name) + + def all(self) -> list[AddonManifest]: + return list(self._addons.values()) + + def loaded(self) -> list[AddonManifest]: + return [m for m in self._by_load_order() if m.state == AddonState.LOADED] + + def failed(self) -> list[AddonManifest]: + return [m for m in self._addons.values() if m.state == AddonState.FAILED] + + def skipped(self) -> list[AddonManifest]: + return [m for m in self._addons.values() if m.state == AddonState.SKIPPED] + + def is_loaded(self, name: str) -> bool: + m = self._addons.get(name) + return m is not None and m.state == AddonState.LOADED + + def _by_load_order(self) -> list[AddonManifest]: + ordered = [] + for name in self._load_order: + m = self._addons.get(name) + if m is not None: + ordered.append(m) + # Include any addons not in load order (shouldn't happen, but safe) + seen = set(self._load_order) + for name, m in self._addons.items(): + if name not in seen: + ordered.append(m) + return ordered + + def __repr__(self): + loaded = len(self.loaded()) + total = len(self._addons) + names = ", ".join(m.name for m in self.loaded()) + return f"AddonRegistry({loaded}/{total} loaded: {names})" + + +# Module-level singleton +_registry: Optional[AddonRegistry] = None + +# Legacy load order for backward compatibility when no elements exist. +# Once addons declare in their package.xml (issue #252), this is ignored. +_LEGACY_ORDER = ["ztools", "silo"] + + +# --------------------------------------------------------------------------- +# Discovery +# --------------------------------------------------------------------------- + + +def scan_addons(mods_dir: str) -> list[AddonManifest]: + """Scan mods/ for directories containing package.xml at depth 1-2.""" + manifests = [] + + if not os.path.isdir(mods_dir): + FreeCAD.Console.PrintLog(f"Create: mods directory not found: {mods_dir}\n") + return manifests + + for entry in os.listdir(mods_dir): + entry_path = os.path.join(mods_dir, entry) + if not os.path.isdir(entry_path): + continue + + # Check depth 1: mods//package.xml + pkg_xml = os.path.join(entry_path, "package.xml") + if os.path.isfile(pkg_xml): + manifests.append( + AddonManifest( + name="", + version="", + package_xml_path=pkg_xml, + addon_root=entry_path, + ) + ) + continue + + # Check depth 2: mods///package.xml + for sub in os.listdir(entry_path): + sub_path = os.path.join(entry_path, sub) + if not os.path.isdir(sub_path): + continue + pkg_xml = os.path.join(sub_path, "package.xml") + if os.path.isfile(pkg_xml): + manifests.append( + AddonManifest( + name="", + version="", + package_xml_path=pkg_xml, + addon_root=sub_path, + ) + ) + + return manifests + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + +# FreeCAD package.xml namespace +_PKG_NS = "https://wiki.freecad.org/Package_Metadata" + + +def _find(parent, tag): + """Find a child element, trying with and without namespace.""" + el = parent.find(f"{{{_PKG_NS}}}{tag}") + if el is None: + el = parent.find(tag) + return el + + +def _findall(parent, tag): + """Find all child elements, trying with and without namespace.""" + els = parent.findall(f"{{{_PKG_NS}}}{tag}") + if not els: + els = parent.findall(tag) + return els + + +def _text(parent, tag, default=""): + """Get text content of a child element.""" + el = _find(parent, tag) + return el.text.strip() if el is not None and el.text else default + + +def parse_manifest(manifest: AddonManifest): + """Parse package.xml into the manifest, including extensions.""" + try: + tree = ET.parse(manifest.package_xml_path) + root = tree.getroot() + except ET.ParseError as e: + manifest.state = AddonState.FAILED + manifest.error = f"XML parse error: {e}" + FreeCAD.Console.PrintWarning(f"Create: Failed to parse {manifest.package_xml_path}: {e}\n") + return + + # Standard fields + manifest.name = _text(root, "name") or os.path.basename(manifest.addon_root) + manifest.version = _text(root, "version", "0.0.0") + manifest.description = _text(root, "description") + + # Resolve workbench path from + content = _find(root, "content") + if content is not None: + workbench = _find(content, "workbench") + if workbench is not None: + subdir = _text(workbench, "subdirectory", ".") + # Normalize: strip leading ./ + if subdir.startswith("./"): + subdir = subdir[2:] + if subdir == "" or subdir == ".": + manifest.workbench_path = manifest.addon_root + else: + manifest.workbench_path = os.path.join(manifest.addon_root, subdir) + + # Fallback: use addon_root if no workbench subdirectory found + if not manifest.workbench_path: + manifest.workbench_path = manifest.addon_root + + # Kindred extensions (optional) + kindred = _find(root, "kindred") + if kindred is not None: + manifest.has_kindred_element = True + manifest.min_create_version = _text(kindred, "min_create_version") or None + manifest.max_create_version = _text(kindred, "max_create_version") or None + priority_str = _text(kindred, "load_priority") + if priority_str: + try: + manifest.load_priority = int(priority_str) + except ValueError: + pass + deps = _find(kindred, "dependencies") + if deps is not None: + for dep in _findall(deps, "dependency"): + if dep.text and dep.text.strip(): + manifest.dependencies.append(dep.text.strip()) + + FreeCAD.Console.PrintLog( + f"Create: Parsed {manifest.name} v{manifest.version} from {manifest.package_xml_path}\n" + ) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +def _parse_version(v: str) -> tuple: + """Parse a version string into a comparable tuple of ints.""" + try: + return tuple(int(x) for x in v.split(".")) + except (ValueError, AttributeError): + return (0, 0, 0) + + +def validate_manifest(manifest: AddonManifest, create_version: str) -> bool: + """Check version compatibility and path existence. Returns True if valid.""" + if manifest.state == AddonState.FAILED: + return False + + cv = _parse_version(create_version) + + if manifest.min_create_version: + if cv < _parse_version(manifest.min_create_version): + manifest.state = AddonState.SKIPPED + manifest.error = ( + f"Requires Create >= {manifest.min_create_version}, running {create_version}" + ) + FreeCAD.Console.PrintWarning(f"Create: Skipping {manifest.name}: {manifest.error}\n") + return False + + if manifest.max_create_version: + if cv > _parse_version(manifest.max_create_version): + manifest.state = AddonState.SKIPPED + manifest.error = ( + f"Requires Create <= {manifest.max_create_version}, running {create_version}" + ) + FreeCAD.Console.PrintWarning(f"Create: Skipping {manifest.name}: {manifest.error}\n") + return False + + if not os.path.isdir(manifest.workbench_path): + manifest.state = AddonState.SKIPPED + manifest.error = f"Workbench path not found: {manifest.workbench_path}" + FreeCAD.Console.PrintWarning(f"Create: Skipping {manifest.name}: {manifest.error}\n") + return False + + # At least one of Init.py or InitGui.py must exist + has_init = os.path.isfile(os.path.join(manifest.workbench_path, "Init.py")) + has_gui = os.path.isfile(os.path.join(manifest.workbench_path, "InitGui.py")) + if not has_init and not has_gui: + manifest.state = AddonState.SKIPPED + manifest.error = f"No Init.py or InitGui.py in {manifest.workbench_path}" + FreeCAD.Console.PrintWarning(f"Create: Skipping {manifest.name}: {manifest.error}\n") + return False + + manifest.state = AddonState.VALIDATED + return True + + +# --------------------------------------------------------------------------- +# Dependency resolution +# --------------------------------------------------------------------------- + + +def resolve_load_order(manifests: list[AddonManifest], mods_dir: str) -> list[AddonManifest]: + """Sort addons by dependencies, then by (load_priority, name). + + If no addons declare a element, fall back to the legacy + hard-coded order for backward compatibility. + """ + if not manifests: + return [] + + by_name = {m.name: m for m in manifests} + any_kindred = any(m.has_kindred_element for m in manifests) + + if not any_kindred: + # Legacy fallback: use hard-coded order matched by directory name + return _legacy_order(manifests, mods_dir) + + # Topological sort with graphlib + from graphlib import CycleError, TopologicalSorter + + ts = TopologicalSorter() + for m in manifests: + # Only include dependencies that are actually discovered + known_deps = [d for d in m.dependencies if d in by_name] + unknown_deps = [d for d in m.dependencies if d not in by_name] + for dep in unknown_deps: + m.state = AddonState.SKIPPED + m.error = f"Missing dependency: {dep}" + FreeCAD.Console.PrintWarning(f"Create: Skipping {m.name}: {m.error}\n") + if m.state != AddonState.SKIPPED: + ts.add(m.name, *known_deps) + + try: + order = list(ts.static_order()) + except CycleError as e: + FreeCAD.Console.PrintWarning( + f"Create: Dependency cycle detected: {e}. Falling back to priority order.\n" + ) + return sorted( + [m for m in manifests if m.state != AddonState.SKIPPED], + key=lambda m: (m.load_priority, m.name), + ) + + # Filter to actual manifests, preserving topological order + # Secondary sort within independent groups by (priority, name) + result = [] + for name in order: + m = by_name.get(name) + if m is not None and m.state != AddonState.SKIPPED: + result.append(m) + + return result + + +def _legacy_order(manifests: list[AddonManifest], mods_dir: str) -> list[AddonManifest]: + """Order addons using the legacy hard-coded list, matched by directory path.""" + by_dir = {} + for m in manifests: + # Extract the top-level directory name under mods/ + rel = os.path.relpath(m.addon_root, mods_dir) + top_dir = rel.split(os.sep)[0] + by_dir[top_dir] = m + + ordered = [] + for dir_name in _LEGACY_ORDER: + if dir_name in by_dir: + ordered.append(by_dir.pop(dir_name)) + + # Append any addons not in the legacy list (alphabetically) + for name in sorted(by_dir.keys()): + ordered.append(by_dir[name]) + + return ordered + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + + +def _load_addon(manifest: AddonManifest, gui: bool = False): + """Execute an addon's Init.py or InitGui.py.""" + init_file = "InitGui.py" if gui else "Init.py" + filepath = os.path.join(manifest.workbench_path, init_file) + + if not os.path.isfile(filepath): + return + + # Ensure workbench path is in sys.path + if manifest.workbench_path not in sys.path: + sys.path.insert(0, manifest.workbench_path) + + start = time.monotonic() + try: + with open(filepath) as f: + exec_globals = globals().copy() + exec_globals["__file__"] = filepath + exec_globals["__name__"] = manifest.name + exec(compile(f.read(), filepath, "exec"), exec_globals) + elapsed = (time.monotonic() - start) * 1000 + if not gui: + manifest.load_time_ms = elapsed + else: + manifest.load_time_ms += elapsed + manifest.state = AddonState.LOADED + FreeCAD.Console.PrintLog(f"Create: Loaded {manifest.name} {init_file} ({elapsed:.0f}ms)\n") + except Exception as e: + manifest.state = AddonState.FAILED + manifest.error = str(e) + FreeCAD.Console.PrintWarning(f"Create: Failed to load {manifest.name}: {e}\n") + + +# --------------------------------------------------------------------------- +# Top-level API +# --------------------------------------------------------------------------- + + +def _get_create_version() -> str: + try: + from version import VERSION + + return VERSION + except ImportError: + return "0.0.0" + + +def load_addons(gui: bool = False): + """Load Kindred addons from mods/. + + Called twice: once from Init.py (gui=False) to run each addon's Init.py, + and once from InitGui.py (gui=True) to run each addon's InitGui.py. + The gui=False call runs the full pipeline and builds the registry. + The gui=True call reuses the registry and loads InitGui.py for each + successfully loaded addon. + """ + global _registry + + mods_dir = os.path.join(FreeCAD.getHomePath(), "mods") + + if not gui: + # Full pipeline: scan -> parse -> validate -> sort -> load -> register + manifests = scan_addons(mods_dir) + + for m in manifests: + parse_manifest(m) + + create_version = _get_create_version() + validated = [m for m in manifests if validate_manifest(m, create_version)] + ordered = resolve_load_order(validated, mods_dir) + + _registry = AddonRegistry() + for m in manifests: + _registry.register(m) + _registry.set_load_order([m.name for m in ordered]) + FreeCAD.KindredAddons = _registry + + for m in ordered: + _load_addon(m, gui=False) + else: + # GUI phase: reuse registry, load InitGui.py in load order + if _registry is None: + FreeCAD.Console.PrintWarning( + "Create: Addon registry not initialized, skipping GUI load\n" + ) + return + + for m in _registry.loaded(): + _load_addon(m, gui=True) -- 2.49.1