Merge pull request 'feat(bootstrap): replace exec()-based addon loading with manifest-driven loader' (#256) from feat/manifest-addon-loader into main
All checks were successful
Build and Test / build (push) Successful in 50m29s

Reviewed-on: #256
This commit was merged in pull request #256.
This commit is contained in:
2026-02-16 19:17:59 +00:00
4 changed files with 506 additions and 81 deletions

View File

@@ -13,6 +13,7 @@ install(
FILES FILES
Init.py Init.py
InitGui.py InitGui.py
addon_loader.py
kc_format.py kc_format.py
update_checker.py update_checker.py
${CMAKE_CURRENT_BINARY_DIR}/version.py ${CMAKE_CURRENT_BINARY_DIR}/version.py

View File

@@ -1,48 +1,13 @@
# Kindred Create - Core Module # Kindred Create - Core Module
# Console initialization - loads ztools and Silo addons # Console initialization - loads Kindred addons via manifest-driven loader
import os
import sys
import FreeCAD import FreeCAD
try:
from addon_loader import load_addons
def setup_kindred_addons(): load_addons(gui=False)
"""Add Kindred Create addon paths and load their Init.py files.""" except Exception as e:
# Get the FreeCAD home directory (where src/Mod/Create is installed) FreeCAD.Console.PrintWarning(f"Create: Addon loader failed: {e}\n")
home = FreeCAD.getHomePath()
mods_dir = os.path.join(home, "mods")
# 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") FreeCAD.Console.PrintLog("Create module initialized\n")

View File

@@ -1,50 +1,16 @@
# Kindred Create - Core Module # Kindred Create - Core Module
# GUI initialization - loads ztools and Silo workbenches # GUI initialization - loads Kindred addon workbenches via manifest-driven loader
import os
import sys
import FreeCAD import FreeCAD
import FreeCADGui import FreeCADGui
try:
from addon_loader import load_addons
def setup_kindred_workbenches(): load_addons(gui=True)
"""Load Kindred Create addon workbenches.""" except Exception as e:
home = FreeCAD.getHomePath() FreeCAD.Console.PrintWarning(f"Create: Addon GUI loader failed: {e}\n")
mods_dir = os.path.join(home, "mods")
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") FreeCAD.Console.PrintLog("Create GUI module initialized\n")

View File

@@ -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 <kindred> 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 <kindred> elements exist.
# Once addons declare <kindred> 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/<addon>/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/<addon>/<subdir>/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 <kindred> 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><workbench><subdirectory>
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 <kindred> 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)