# 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 contexts: list[str] = field(default_factory=list) 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 register_context(self, addon_name: str, context_id: str): """Register a context ID as provided by an addon.""" m = self._addons.get(addon_name) if m is not None and context_id not in m.contexts: m.contexts.append(context_id) def contexts(self) -> dict[str, list[str]]: """Return a mapping of context IDs to the addon names that provide them.""" result: dict[str, list[str]] = {} for m in self._addons.values(): for ctx in m.contexts: result.setdefault(ctx, []).append(m.name) return result 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 = ["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()) ctxs = _find(kindred, "contexts") if ctxs is not None: for ctx in _findall(ctxs, "context"): if ctx.text and ctx.text.strip(): manifest.contexts.append(ctx.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: # Process level by level so we can sort within each topological level ts.prepare() order = [] while ts.is_active(): ready = list(ts.get_ready()) # Sort each level by (priority, name) for determinism ready.sort( key=lambda n: ( (by_name[n].load_priority, n) if n in by_name else (999, n) ) ) for name in ready: ts.done(name) order.extend(ready) 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 sorted topological order 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 _print_load_summary(registry: AddonRegistry, phase: str): """Print a formatted addon load summary table to the console.""" addons = registry.all() if not addons: return max_name = max(len(m.name) for m in addons) lines = [f"Create: Addon load summary ({phase})"] for m in addons: state_str = m.state.value.upper() time_str = f"{m.load_time_ms:.0f}ms" if m.load_time_ms > 0 else "-" line = f" {m.name:<{max_name}} {state_str:<12} {time_str:>6}" if m.error: line += f" ({m.error})" lines.append(line) FreeCAD.Console.PrintLog("\n".join(lines) + "\n") 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) _print_load_summary(_registry, "Init.py") 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) _print_load_summary(_registry, "InitGui.py") def getAddonRegistry() -> Optional[AddonRegistry]: """Return the addon registry singleton, or None if not yet initialized. Exposed as FreeCAD.getAddonRegistry() for runtime introspection. """ return _registry