Files
create/src/Mod/Create/addon_loader.py
forbes 2127d2c904
Some checks failed
Build and Test / build (pull_request) Has been cancelled
feat(sdk): addon load timing diagnostics — addon_diagnostics() (#390)
Add kindred_sdk.addon_diagnostics() returning per-addon load state,
timing, and error info as a list of dicts. Reads name, state,
load_time_ms, and error from AddonManifest via AddonRegistry.

Add _print_load_summary() to addon_loader.py that prints a formatted
summary table to the console after each load phase (Init.py and
InitGui.py), replacing interleaved individual log lines with a
consolidated view.

Closes #390
2026-03-04 13:50:37 -06:00

564 lines
19 KiB
Python

# 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
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 <kindred> elements exist.
# Once addons declare <kindred> 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/<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())
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 <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:
# 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