Compare commits

...

20 Commits

Author SHA1 Message Date
c57eb6b5a2 Merge pull request 'feat(silo): async save queue — background upload with coalescing (#392)' (#413) from feat/async-save-queue into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #413
2026-03-05 18:52:00 +00:00
9ace2e06e3 feat(silo): async save queue — background upload with coalescing (#392)
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Updates silo submodule to feat/async-save-queue branch which adds:
- QThread-based upload queue with task coalescing
- Status bar widget for sync progress feedback
- Silo_Save and Silo_Commit now save locally (sync) and upload async
- SiloOrigin.saveDocument() returns instantly after disk write
- Clean shutdown via atexit handler

No UI freeze during Ctrl+S on Silo-tracked documents.
2026-03-05 11:09:36 -06:00
ad48d7e853 Merge pull request 'docs(fix): clarify macOS/Windows build status in CI_CD.md (#164)' (#412) from docs/ci-platform-status into main
Some checks failed
Deploy Docs / build-and-deploy (push) Failing after 55s
Build and Test / build (push) Has been cancelled
Reviewed-on: #412
2026-03-05 16:48:29 +00:00
3dd8d86ef1 docs(fix): clarify macOS/Windows build status in CI_CD.md (#164)
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Add a Platform Status section with a table showing per-platform blocker
and re-enable path. Replace the inline prose with structured information
including dependency freshness note for bundle scripts.
2026-03-05 10:47:31 -06:00
0e884a03b9 Merge pull request 'docs(fix): add cross-links between C++ and Python origin docs (#165)' (#411) from docs/origin-cross-links into main
Some checks failed
Deploy Docs / build-and-deploy (push) Failing after 1m5s
Build and Test / build (push) Has been cancelled
Reviewed-on: #411
2026-03-05 16:45:44 +00:00
c0a3236836 docs(fix): add cross-links between C++ and Python origin docs (#165)
All checks were successful
Build and Test / build (pull_request) Successful in 29m57s
- Fix 5 broken links to nonexistent docs (custom-origin-python.md,
  python-silo-origin.md, python-custom-origin-guide.md) by pointing
  to closest existing content
- Add Python cross-references (kcsdk-python.md, silo guide) to 7
  C++ origin doc See Also sections
- Add C++ foundation links (OriginManager, FileOrigin, FileOriginPython,
  CommandOrigin) to kcsdk-python.md and guide/silo.md
- 9 files updated, 16 cross-links added
2026-03-05 10:44:03 -06:00
a5db5190c8 Merge pull request 'docs: add example addon template (#395)' (#410) from docs/example-addon-template into main
Some checks failed
Deploy Docs / build-and-deploy (push) Failing after 50s
Build and Test / build (push) Has been cancelled
Reviewed-on: #410
2026-03-05 16:33:21 +00:00
2f89f8cbb0 docs: add example addon template (#395)
All checks were successful
Build and Test / build (pull_request) Successful in 30m3s
- Create docs/examples/example-addon/ with a complete, copy-paste-ready
  addon skeleton demonstrating command registration, context injection,
  dock panels, lifecycle hooks, and event bus subscription
- Add Examples section to docs/src/SUMMARY.md
- Add quick-start cross-reference in writing-an-addon.md
2026-03-05 10:32:12 -06:00
c5881147d0 Merge pull request 'docs(sdk): document per-document origin Python bindings (#406)' (#409) from docs/per-document-origin-api into main
Some checks failed
Deploy Docs / build-and-deploy (push) Failing after 52s
Build and Test / build (push) Has been cancelled
Reviewed-on: #409
2026-03-05 16:24:43 +00:00
fc2baa1afd docs(sdk): document per-document origin Python bindings (#406)
All checks were successful
Build and Test / build (pull_request) Successful in 33m50s
- Add Per-Document Origin API section to kcsdk-python.md covering
  document_origin, set_document_origin, clear_document_origin, and
  find_owning_origin with parameter tables and usage example
- Add 4 per-document functions to kindred_sdk wrappers table showing
  the doc.Name extraction pattern
- Add Python Exposure subsection to cpp-origin-manager.md cross-
  referencing both kcsdk and kindred_sdk layers
2026-03-05 10:20:22 -06:00
d656455d00 Merge pull request 'test(addon-loader): add Tier 1 pure Python tests for addon loader (#396)' (#408) from feat/addon-loader-tests into main
Some checks failed
Deploy Docs / build-and-deploy (push) Failing after 49s
Build and Test / build (push) Has been cancelled
Reviewed-on: #408
2026-03-05 16:12:06 +00:00
0d88769189 docs(dev): add testing guide covering Tier 1/2 and C++ test infrastructure
All checks were successful
Build and Test / build (pull_request) Successful in 31m10s
New docs/src/development/testing.md covering:
- Quick reference commands (pixi run test-kindred, pixi run test)
- Tier 1 pure Python tests: mock strategy, test file table, addon
  loader test class breakdown, how to write a new test file
- Tier 2 FreeCAD headless tests: runner, current status
- C++ GoogleTest infrastructure: directory layout, test executables
- Decision guide for which tier to use

Add entry to docs/src/SUMMARY.md under Development section.
2026-03-05 10:10:03 -06:00
7381675a6e test(addon-loader): add Tier 1 pure Python tests for addon loader (#396)
All checks were successful
Build and Test / build (pull_request) Successful in 24m19s
Add 71 tests covering the entire addon loader pipeline in a new
test_kindred_addon_loader.py file:

- TestAddonState (2): enum members and values
- TestValidVersion (11): _valid_version() regex matching
- TestParseVersionLoader (7): _parse_version() string-to-tuple
- TestAddonRegistry (10): register/get/filter/order/contexts
- TestParseManifest (13): XML parsing, field extraction, validation
  errors for bad priority, bad version format, invalid context IDs
- TestValidateDependencies (4): cross-addon dependency checking
- TestValidateManifest (9): version bounds, paths, error accumulation
- TestScanAddons (6): directory walking at depth 1 and 2
- TestResolveLoadOrder (9): topological sort, priority ties, cycle
  detection, legacy fallback

Update test runner discovery pattern from 'test_kindred_pure.py' to
'test_kindred_*.py' so both test files are auto-discovered.

All 110 tests pass (71 new + 39 existing).

Refs #396
2026-03-05 10:06:29 -06:00
be2f336beb Merge pull request 'docs: update addon loader pipeline and origin API documentation' (#407) from docs/update-addon-loader-origin into main
Some checks failed
Deploy Docs / build-and-deploy (push) Failing after 58s
Build and Test / build (push) Successful in 29m52s
Reviewed-on: #407
2026-03-05 14:46:56 +00:00
8f27083e45 docs: update addon loader pipeline and origin API documentation
All checks were successful
Build and Test / build (pull_request) Successful in 29m15s
Update documentation across 5 files to reflect changes from #388
(manifest validation) and #391 (per-document origin bindings):

- CLAUDE.md: expand addon loading section with 6-step pipeline,
  add per-document origin functions to SDK API list
- ARCHITECTURE.md: update bootstrap flow diagram with
  validate_dependencies() step, expand lifecycle to 7 steps
- create-module-bootstrap.md: rewrite pipeline steps with validation
  detail, add pipeline functions to dependency chain diagram
- package-xml-schema.md: add parse-time validation rules to field
  reference table, update version/dependency/context sections
- writing-an-addon.md: add validation summary after priority table

Refs #388, #391
2026-03-05 08:22:22 -06:00
54afbebee3 Merge pull request 'feat(addon-loader): strengthen manifest validation at parse time (#388)' (#405) from feat/manifest-validation into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #405
2026-03-05 14:05:00 +00:00
0408198af0 feat(addon-loader): strengthen manifest validation at parse time (#388)
All checks were successful
Build and Test / build (pull_request) Successful in 24m58s
- Change AddonManifest.error (str) to .errors (list[str]) so all
  problems are accumulated in a single pass instead of masking
  subsequent failures.

- Validate load_priority type, version string format (dotted-numeric),
  and context ID syntax (alphanumeric + dots/underscores) during
  parse_manifest().

- Add validate_dependencies() cross-addon check that verifies all
  declared dependencies reference discovered addon names, called
  between parsing and validation in the pipeline.

- Rewrite validate_manifest() to run all checks (version bounds,
  workbench path, Init.py presence) and collect errors instead of
  early-returning on the first failure.

- Simplify resolve_load_order() by removing the inline
  unknown-dependency check (now handled earlier by
  validate_dependencies()).

- Update _print_load_summary() to join multiple errors with semicolons.

Closes #388
2026-03-05 08:00:57 -06:00
ce6c03ae35 Merge pull request 'feat(sdk): per-document origin Python bindings (#391)' (#404) from feat/sdk-per-document-origin into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #404
2026-03-05 13:49:12 +00:00
311f72b77e feat(sdk): per-document origin Python bindings (#391)
All checks were successful
Build and Test / build (pull_request) Successful in 30m22s
Expose the existing C++ per-document origin tracking through the kcsdk
pybind11 module and kindred_sdk Python package.

New kcsdk functions (accept document name string):
- document_origin(doc_name) — get origin via originForDocument()
- set_document_origin(doc_name, origin_id) — explicit association
- clear_document_origin(doc_name) — clear explicit association
- find_owning_origin(doc_name) — ownership detection (no cache)

New kindred_sdk wrappers (accept App.Document object):
- document_origin(doc)
- set_document_origin(doc, origin_id)
- clear_document_origin(doc)
- find_owning_origin(doc)
2026-03-05 07:44:38 -06:00
04f9e05c41 Merge pull request 'feat(editing-context): hierarchical context system with stack, guards, and breadcrumb injection' (#403) from feat/hierarchical-context into main
Some checks failed
Build and Test / build (push) Has been cancelled
Sync Silo Server Docs / sync (push) Failing after 34s
Reviewed-on: #403
2026-03-04 20:25:59 +00:00
31 changed files with 1845 additions and 231 deletions

View File

@@ -162,7 +162,14 @@ Python API (prefer `kindred_sdk` wrappers over direct `FreeCADGui` calls):
### Addon Loading
Addons in `mods/` are loaded by `src/Mod/Create/addon_loader.py`. Each addon provides a `package.xml` at the mod root with `<kindred>` extensions declaring version bounds, load priority, and dependencies. The loader discovers manifests, validates version bounds, and resolves load order via topological sort on dependencies, breaking ties by `(load_priority, name)`.
Addons in `mods/` are loaded by `src/Mod/Create/addon_loader.py`. Each addon provides a `package.xml` at the mod root with `<kindred>` extensions declaring version bounds, load priority, and dependencies. The loader pipeline is:
1. **Scan** — discover `package.xml` manifests in `mods/`
2. **Parse** — extract `<kindred>` metadata; validate field types/formats (load_priority must be int, version strings must be dotted-numeric, context IDs must be alphanumeric/dots/underscores)
3. **Validate dependencies** — cross-check declared dependency names against all discovered addons
4. **Validate manifests** — check version bounds, workbench path, Init.py presence (all errors accumulated per-addon in `AddonManifest.errors`)
5. **Resolve load order** — topological sort by dependencies, breaking ties by `(load_priority, name)`
6. **Load** — execute Init.py / InitGui.py for each validated addon
Current load order: **sdk** (0) → **solver** (10) → **gears** (40) → **datums** (45) → **silo** (60).
@@ -186,7 +193,7 @@ Each addon manages its own deferred setup in its `InitGui.py`. For example, Silo
File operations (New, Open, Save, Commit, Pull, Push) are abstracted behind `FileOrigin` (`src/Gui/FileOrigin.h`). `LocalFileOrigin` handles local files; `SiloOrigin` (`mods/silo/freecad/silo_origin.py`) backs Silo-tracked documents. The active origin is selected automatically based on document properties (`SiloItemId`, `SiloPartNumber`).
Origins are registered via `kindred_sdk.register_origin()`. Query functions (`list_origins`, `active_origin`, `get_origin`, `set_active_origin`) route through the `kcsdk` C++ module.
Origins are registered via `kindred_sdk.register_origin()`. Query functions (`list_origins`, `active_origin`, `get_origin`, `set_active_origin`) route through the `kcsdk` C++ module. Per-document origin associations are managed via `kindred_sdk.document_origin()`, `set_document_origin()`, `clear_document_origin()`, and `find_owning_origin()`.
## Submodules
@@ -218,7 +225,7 @@ Initialize all submodules: `git submodule update --init --recursive`
Stable API contract for addons. Python package `kindred_sdk` wraps the KCSDK C++ module, providing:
- **Editing contexts:** `register_context()`, `register_overlay()`, `inject_commands()`, `refresh_context()`
- **Origins:** `register_origin()`, `unregister_origin()`, `list_origins()`, `active_origin()`
- **Origins:** `register_origin()`, `unregister_origin()`, `list_origins()`, `active_origin()`, `document_origin()`, `set_document_origin()`, `clear_document_origin()`, `find_owning_origin()`
- **Dock panels:** `register_dock_panel(object_name, title, factory, area, delay_ms)`
- **Commands:** `register_command(cmd_id, classname, pixmap, tooltip)`
- **Theme:** `get_theme_tokens()`, `load_palette()` (Catppuccin Mocha YAML palette)

View File

@@ -7,8 +7,9 @@ FreeCAD startup
└─ src/Mod/Create/Init.py
└─ addon_loader.load_addons(gui=False)
├─ scan_addons("mods/") — find package.xml manifests
├─ parse_manifest() — extract <kindred> extensions
├─ validate_manifest() — check min/max_create_version
├─ parse_manifest() — extract <kindred> extensions, validate types/formats
├─ validate_dependencies() — cross-check deps against discovered addons
├─ validate_manifest() — check version bounds, paths (errors accumulated)
├─ resolve_load_order() — topological sort by <dependency>
└─ for each addon in order:
├─ add addon dir to sys.path
@@ -60,11 +61,12 @@ Each addon in `mods/` provides a `package.xml` manifest with a `<kindred>` exten
The loader (`addon_loader.py`) processes addons in this order:
1. **Scan** — find all `mods/*/package.xml` files
2. **Parse** — extract `<kindred>` metadata (version bounds, priority, dependencies)
3. **Validate** — reject addons incompatible with the current Create version
4. **Resolve** — topological sort by `<dependency>` declarations, breaking ties by `<load_priority>`
5. **Load**execute `Init.py` (console) then `InitGui.py` (GUI) for each addon
6. **Register** — populate `FreeCAD.KindredAddons` registry with addon state
2. **Parse** — extract `<kindred>` metadata (version bounds, priority, dependencies); validate field types and formats (load_priority must be int, version strings must be dotted-numeric, context IDs must be alphanumeric/dots/underscores)
3. **Validate dependencies** — cross-check all declared dependency names against discovered addon names
4. **Validate manifests** — reject addons incompatible with the current Create version, missing workbench paths, or lacking Init files; all errors accumulated per-addon in `AddonManifest.errors`
5. **Resolve**topological sort by `<dependency>` declarations, breaking ties by `<load_priority>`; addons with errors are excluded
6. **Load** — execute `Init.py` (console) then `InitGui.py` (GUI) for each addon
7. **Register** — populate `FreeCAD.KindredAddons` registry with addon state
Current load order: **sdk** (0) → **solver** (10) → **gears** (40) → **datums** (45) → **silo** (60)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
"""Example addon — console initialization.
This file runs in both console and GUI mode. Use it for non-GUI setup
such as registering custom property types or document observers that
should work without a display.
Most addons only need a log line here. Heavy setup belongs in InitGui.py.
"""
import FreeCAD
FreeCAD.Console.PrintLog("example-addon: console init\n")

View File

@@ -0,0 +1,93 @@
"""Example addon — GUI initialization.
Demonstrates the standard addon bootstrap pattern using the Kindred SDK.
Each registration step is wrapped in try/except and deferred with
QTimer.singleShot to avoid blocking the startup sequence.
"""
def _register_commands():
"""Register commands and inject into existing contexts."""
try:
from example_addon.commands import register_commands
register_commands()
except Exception as e:
import FreeCAD
FreeCAD.Console.PrintWarning(f"example-addon: command registration failed: {e}\n")
# Inject our command into an existing context's toolbar so it appears
# automatically when the user enters that context.
try:
from kindred_sdk import inject_commands
inject_commands("partdesign.body", "Part Design", ["ExampleAddon_Hello"])
except Exception as e:
import FreeCAD
FreeCAD.Console.PrintWarning(f"example-addon: context injection failed: {e}\n")
def _register_panel():
"""Register a dock panel with deferred creation."""
try:
from kindred_sdk import register_dock_panel
from example_addon.panel import create_panel
register_dock_panel(
"ExampleAddonPanel",
"Example Panel",
create_panel,
area="right",
delay_ms=2000,
)
except Exception as e:
import FreeCAD
FreeCAD.Console.PrintWarning(f"example-addon: panel registration failed: {e}\n")
def _register_lifecycle():
"""Subscribe to context lifecycle events.
Use ``on_context_enter`` / ``on_context_exit`` to react when the user
enters or leaves a specific editing context. Pass ``"*"`` to match
all contexts.
"""
try:
from kindred_sdk import on_context_enter, on_context_exit
on_context_enter("*", lambda ctx: print(f"[example-addon] entered: {ctx['id']}"))
on_context_exit("*", lambda ctx: print(f"[example-addon] exited: {ctx['id']}"))
except Exception as e:
import FreeCAD
FreeCAD.Console.PrintWarning(f"example-addon: lifecycle hooks failed: {e}\n")
def _register_events():
"""Subscribe to event bus events.
The event bus lets addons communicate without direct imports.
Use ``kindred_sdk.on(event, handler)`` to listen and
``kindred_sdk.emit(event, data)`` to publish.
"""
try:
from kindred_sdk import on
on("document.saved", lambda data: print(f"[example-addon] document saved: {data}"))
except Exception as e:
import FreeCAD
FreeCAD.Console.PrintWarning(f"example-addon: event subscription failed: {e}\n")
# Deferred initialization — stagger delays to avoid blocking startup.
# Lower delays run first; keep each step fast.
from PySide6.QtCore import QTimer
QTimer.singleShot(500, _register_commands)
QTimer.singleShot(600, _register_panel)
QTimer.singleShot(700, _register_lifecycle)
QTimer.singleShot(800, _register_events)

View File

@@ -0,0 +1,75 @@
# Example Addon Template
A minimal but complete Kindred Create addon that demonstrates all major SDK integration points. Copy this directory into `mods/`, rename it, and start building.
## File Structure
```
example-addon/
├── package.xml Addon manifest with <kindred> extensions
├── Init.py Console-mode bootstrap (runs in all modes)
├── InitGui.py GUI bootstrap — deferred registration
└── example_addon/
├── __init__.py Python package marker
├── commands.py FreeCAD command via kindred_sdk.register_command()
└── panel.py Dock panel widget factory
```
## What This Template Demonstrates
| Feature | File | SDK function |
|---------|------|-------------|
| Addon manifest with version bounds and dependencies | `package.xml` | — |
| Console bootstrap | `Init.py` | — |
| Deferred GUI initialization with `QTimer.singleShot` | `InitGui.py` | — |
| Command registration | `commands.py` | `kindred_sdk.register_command()` |
| Injecting commands into existing contexts | `InitGui.py` | `kindred_sdk.inject_commands()` |
| Dock panel registration | `InitGui.py`, `panel.py` | `kindred_sdk.register_dock_panel()` |
| Context lifecycle hooks | `InitGui.py` | `kindred_sdk.on_context_enter()` |
| Event bus subscription | `InitGui.py` | `kindred_sdk.on()` |
## Installation (for testing)
1. Copy or symlink this directory into the `mods/` folder at the repository root:
```bash
cp -r docs/examples/example-addon mods/example-addon
```
2. Launch Kindred Create:
```bash
pixi run freecad
```
3. Open the Python console (View > Panels > Python console) and verify:
```
example-addon: console init
```
4. The "Example Panel" dock widget should appear on the right after ~2 seconds.
5. To test the command, create a Part Design Body and look for "Hello World" in the Part Design toolbar.
## Customizing
1. **Rename** — change `example-addon` and `example_addon` to your addon's name in all files
2. **Update `package.xml`** — set your name, description, repository URL, and load priority
3. **Add commands** — create more functions in `commands.py` and register them
4. **Add contexts** — use `kindred_sdk.register_context()` for custom editing modes
5. **Add overlays** — use `kindred_sdk.register_overlay()` for conditional toolbars
6. **Theme colors** — use `kindred_sdk.get_theme_tokens()` for Catppuccin Mocha palette access
## Common Pitfalls
- **Missing `<workbench>` tag** — `InitGui.py` will not be loaded without it, even if you don't register a workbench
- **Import at module level** — avoid importing `kindred_sdk` or `PySide6` at the top of `InitGui.py`; use deferred imports inside functions to avoid load-order issues
- **Blocking startup** — keep each `QTimer.singleShot` callback fast; do heavy work in background threads
- **Forgetting `<dependency>sdk</dependency>`** — your addon may load before the SDK if you omit this
## Further Reading
- [Writing an Addon](../../src/development/writing-an-addon.md) — full tutorial
- [Package.xml Schema](../../src/development/package-xml-schema.md) — manifest reference
- [KCSDK Python API](../../src/reference/kcsdk-python.md) — complete API reference

View File

@@ -0,0 +1 @@
"""Example addon for Kindred Create."""

View File

@@ -0,0 +1,29 @@
"""Example addon commands.
Each command is registered via ``kindred_sdk.register_command()`` which
wraps FreeCAD's ``Gui.addCommand()`` with input validation.
"""
from kindred_sdk import register_command
def register_commands():
"""Register all commands for this addon."""
register_command(
name="ExampleAddon_Hello",
activated=_on_hello,
resources={
"MenuText": "Hello World",
"ToolTip": "Show a greeting in the console",
# "Pixmap": "path/to/icon.svg", # optional icon
# "Accel": "Ctrl+Shift+H", # optional shortcut
},
is_active=lambda: True,
)
def _on_hello():
"""Command handler — prints a greeting to the FreeCAD console."""
import FreeCAD
FreeCAD.Console.PrintMessage("Hello from example-addon!\n")

View File

@@ -0,0 +1,21 @@
"""Example addon dock panel.
The factory function is passed to ``kindred_sdk.register_dock_panel()``
and called once after the configured delay to create the widget.
"""
def create_panel():
"""Create the example dock panel widget.
Must return a QWidget. The widget is embedded in a QDockWidget and
managed by FreeCAD's DockWindowManager.
"""
from PySide6 import QtWidgets
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
layout.addWidget(QtWidgets.QLabel("Example Addon Panel"))
layout.addWidget(QtWidgets.QLabel("Replace this with your content."))
layout.addStretch()
return widget

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>example-addon</name>
<description>Example addon template for Kindred Create</description>
<version>0.1.0</version>
<maintainer email="you@example.com">Your Name</maintainer>
<license file="LICENSE">LGPL-2.1-or-later</license>
<url type="repository">https://example.com/your-addon</url>
<content>
<!-- A <workbench> tag is required for InitGui.py to be loaded,
even if the addon does not register an actual workbench. -->
<workbench>
<classname>ExampleAddonProvider</classname>
<subdirectory>example_addon</subdirectory>
</workbench>
</content>
<!-- Kindred-specific extensions — ignored by stock FreeCAD. -->
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>80</load_priority>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
</kindred>
</package>

View File

@@ -28,6 +28,11 @@
- [Gui Module Build](./development/gui-build-integration.md)
- [Package.xml Schema Extensions](./development/package-xml-schema.md)
- [Writing an Addon](./development/writing-an-addon.md)
- [Testing](./development/testing.md)
# Examples
- [Example Addon Template](../examples/example-addon/README.md)
# Silo Server

View File

@@ -8,15 +8,15 @@ The `<kindred>` element is ignored by FreeCAD's AddonManager and stock module lo
| Field | Parsed by loader | Required | Default | Description |
|---|---|---|---|---|
| `min_create_version` | Yes | No | *(none)* | Minimum Kindred Create version. Addon is skipped if the running version is lower. |
| `max_create_version` | Yes | No | *(none)* | Maximum Kindred Create version. Addon is skipped if the running version is higher. |
| `load_priority` | Yes | No | `100` | Integer. Lower values load first. Used as a secondary sort after dependency resolution. |
| `dependencies` | Yes | No | *(none)* | List of addon names (by `<name>` in their `package.xml`) that must load before this one. |
| `min_create_version` | Yes | No | *(none)* | Minimum Kindred Create version. Addon is skipped if the running version is lower. Validated at parse time: must be dotted-numeric (e.g. `0.1.0`). |
| `max_create_version` | Yes | No | *(none)* | Maximum Kindred Create version. Addon is skipped if the running version is higher. Validated at parse time: must be dotted-numeric. |
| `load_priority` | Yes | No | `100` | Integer. Lower values load first. Used as a secondary sort after dependency resolution. Validated at parse time: must be a valid integer. |
| `dependencies` | Yes | No | *(none)* | List of addon names (by `<name>` in their `package.xml`) that must load before this one. Cross-validated against all discovered addons after parsing. |
| `sdk_version` | No | No | *(none)* | Required kindred-addon-sdk version. Reserved for future use when the SDK is available. |
| `pure_python` | No | No | `true` | If `false`, the addon requires compiled C++ components. Informational. |
| `contexts` | No | No | *(none)* | Editing contexts this addon registers or injects into. Informational. |
| `contexts` | Yes | No | *(none)* | Editing contexts this addon registers or injects into. Validated at parse time: IDs must match `[a-zA-Z0-9][a-zA-Z0-9_.]*`. |
Fields marked "Parsed by loader" are read by `addon_loader.py` and affect load behavior. Other fields are informational metadata for tooling and documentation.
Fields marked "Parsed by loader" are read by `addon_loader.py` and affect load behavior. Validation errors are accumulated in `AddonManifest.errors` — all problems are reported in a single pass rather than stopping at the first failure. Other fields are informational metadata for tooling and documentation.
## Schema
@@ -43,7 +43,7 @@ All child elements are optional. An empty `<kindred/>` element is valid and sign
### Version fields
`min_create_version` and `max_create_version` are compared against the running Kindred Create version using semantic versioning (major.minor.patch). If the running version falls outside the declared range, the addon is skipped with a warning in the report view.
`min_create_version` and `max_create_version` are compared against the running Kindred Create version using semantic versioning (major.minor.patch). Values are validated at parse time against the pattern `^\d+(\.\d+)*$` — non-conforming strings produce an error. If the running version falls outside the declared range, the addon is skipped with a warning in the report view.
### Load priority
@@ -58,11 +58,11 @@ When multiple addons have no dependency relationship, `load_priority` determines
### Dependencies
Each `<dependency>` names another addon by its `<name>` element in `package.xml`. The loader resolves load order using topological sort. If a dependency is not found among discovered addons, the dependent addon is skipped.
Each `<dependency>` names another addon by its `<name>` element in `package.xml`. The loader resolves load order using topological sort. After all manifests are parsed, `validate_dependencies()` cross-checks every declared dependency name against the set of discovered addons. If a dependency is not found, the error is accumulated on the addon's manifest and the addon is skipped.
### Contexts
The `<contexts>` element documents which editing contexts the addon interacts with. The `action` attribute describes the type of interaction:
The `<contexts>` element declares which editing contexts the addon interacts with. Context IDs are validated at parse time — they must match the pattern `[a-zA-Z0-9][a-zA-Z0-9_.]*` (start with alphanumeric, then alphanumeric, dots, or underscores). Invalid IDs produce an error and are not registered. The `action` attribute describes the type of interaction:
| Action | Meaning |
|---|---|

View File

@@ -0,0 +1,128 @@
# Testing
Kindred Create has a multi-tier testing system that separates fast pure-logic tests from tests requiring the FreeCAD runtime.
## Quick reference
```bash
pixi run test-kindred # Python Tier 1 tests (no FreeCAD binary)
pixi run test # C++ tests via ctest (requires build)
```
Or run directly without pixi:
```bash
python3 tests/run_kindred_tests.py # Tier 1 only
python3 tests/run_kindred_tests.py --all # Tier 1 + Tier 2 (needs FreeCADCmd)
```
## Tier 1: Pure Python tests
These tests exercise standalone Python functions **without** a FreeCAD binary. They run with the system Python interpreter and complete in under a second.
**Framework:** `unittest` (standard library)
**Test files:**
| File | Module under test | Tests |
|------|-------------------|-------|
| `tests/test_kindred_pure.py` | `update_checker`, `silo_commands`, `silo_start`, `silo_origin` | ~39 |
| `tests/test_kindred_addon_loader.py` | `addon_loader` | ~71 |
**Discovery:** The test runner uses `unittest.TestLoader().discover()` with the pattern `test_kindred_*.py`. New test files matching this pattern are automatically discovered.
### How mocking works
Modules under test import `FreeCAD` at the top level, which is unavailable outside the FreeCAD runtime. Each test file mocks the FreeCAD ecosystem **before** importing any target modules:
```python
from unittest import mock
_fc = mock.MagicMock()
_fc.Console = mock.MagicMock()
# Insert mocks before any import touches these modules
for mod_name in ("FreeCAD", "FreeCADGui"):
sys.modules.setdefault(mod_name, mock.MagicMock())
sys.modules["FreeCAD"] = _fc
# Now safe to import
sys.path.insert(0, str(_REPO_ROOT / "src" / "Mod" / "Create"))
from addon_loader import parse_manifest, validate_manifest # etc.
```
For modules that need working FreeCAD subsystems (like the parameter store), functional stubs replace `MagicMock`. See `_MockParamGroup` in `test_kindred_pure.py` for an example.
### Addon loader tests
`test_kindred_addon_loader.py` covers the entire addon loading pipeline:
| Test class | What it covers |
|------------|----------------|
| `TestAddonState` | Enum members and values |
| `TestValidVersion` | `_valid_version()` dotted-numeric regex |
| `TestParseVersionLoader` | `_parse_version()` string-to-tuple conversion |
| `TestAddonRegistry` | Registry CRUD, filtering by state, load order, context registration |
| `TestParseManifest` | XML parsing, field extraction, `<kindred>` extensions, validation errors (bad priority, bad version format, invalid context IDs, malformed XML) |
| `TestValidateDependencies` | Cross-addon dependency name checking |
| `TestValidateManifest` | Version bounds, workbench path, Init.py presence, error accumulation |
| `TestScanAddons` | Directory discovery at depth 1 and 2 |
| `TestResolveLoadOrder` | Topological sort, priority ties, cycle detection, legacy fallback |
Tests that need files on disk use `tempfile.TemporaryDirectory` with a `_write_package_xml()` helper that generates manifest XML programmatically. Tests that only need populated `AddonManifest` objects use a `_make_manifest()` shortcut.
### Writing a new Tier 1 test file
1. Create `tests/test_kindred_<module>.py`
2. Mock FreeCAD modules before imports (copy the pattern from an existing file)
3. Add the source directory to `sys.path`
4. Import and test pure-logic functions
5. The runner discovers the file automatically via `test_kindred_*.py`
Run your new file directly during development:
```bash
python3 tests/test_kindred_<module>.py -v
```
## Tier 2: FreeCAD headless tests
These tests run inside `FreeCADCmd` (the headless FreeCAD binary) and can exercise code that depends on the full application context — document creation, GUI commands, origin resolution, etc.
**Runner:** `tests/run_kindred_tests.py --all`
**Status:** Infrastructure is in place but no Tier 2 test modules have been implemented yet. The runner searches for `FreeCADCmd` in `PATH` and the build directories (`build/debug/bin/`, `build/release/bin/`). If not found, Tier 2 is skipped without failure.
## C++ tests
C++ unit tests use [GoogleTest](https://github.com/google/googletest) (submodule at `tests/lib/`).
**Runner:** `ctest` via pixi
```bash
pixi run test # debug build
pixi run test-release # release build
```
Test source files live in `tests/src/` mirroring the main source layout:
```
tests/src/
├── App/ # FreeCADApp tests
├── Base/ # Base library tests (Quantity, Matrix, Axis, etc.)
├── Gui/ # GUI tests (OriginManager, InputHint, etc.)
├── Mod/ # Module tests (Part, Sketcher, Assembly, etc.)
└── Misc/ # Miscellaneous tests
```
Each directory builds a separate test executable (e.g., `Base_tests_run`, `Gui_tests_run`) linked against GoogleTest and the relevant FreeCAD libraries.
## What to test where
| Scenario | Tier | Example |
|----------|------|---------|
| Pure function with no FreeCAD deps | Tier 1 | Version parsing, XML parsing, topological sort |
| Logic that only needs mock stubs | Tier 1 | Parameter reading, URL construction |
| Code that creates/modifies documents | Tier 2 | Origin ownership detection, document observer |
| Code that needs the GUI event loop | Tier 2 | Context resolution, toolbar visibility |
| C++ classes and algorithms | C++ | OriginManager, Base::Quantity, Sketcher solver |

View File

@@ -2,6 +2,8 @@
This guide walks through creating a Kindred Create addon from scratch. Addons are Python packages in the `mods/` directory that extend Create with commands, panels, and UI modifications through the SDK.
> **Quick start:** A complete working example is available at [`docs/examples/example-addon/`](../../examples/example-addon/README.md). Copy the directory into `mods/` and rename it to start building.
## Addon structure
A minimal addon has this layout:
@@ -54,6 +56,8 @@ Every addon needs a `package.xml` with a `<kindred>` extension block. The `<work
| 50-99 | Standard addons (silo) |
| 100+ | Optional/user addons |
The loader validates manifests at parse time: `load_priority` must be a valid integer, version strings must be dotted-numeric (e.g. `0.1.5`), context IDs must be alphanumeric with dots/underscores, and dependency names are cross-checked against all discovered addons. All errors are accumulated and reported together.
See [Package.xml Schema Extensions](./package-xml-schema.md) for the full schema.
## Step 2: Console bootstrap (Init.py)

View File

@@ -149,3 +149,11 @@ mods/silo/
- `mods/silo/README.md` — server quickstart and CLI usage
- `mods/silo/ROADMAP.md` — strategic roadmap (6 phases, Q2 2026 → Q4 2027)
- [FileOrigin Interface (C++)](../reference/cpp-file-origin.md) — abstract
interface that `SiloOrigin` implements via the Python bridge
- [FileOriginPython Bridge (C++)](../reference/cpp-file-origin-python.md) —
how `SiloOrigin` connects to the C++ origin system
- [OriginManager (C++)](../reference/cpp-origin-manager.md) — singleton
registry where `SiloOrigin` is registered
- [CommandOrigin (C++)](../reference/cpp-command-origin.md) — file menu
commands that dispatch to `SiloOrigin`

View File

@@ -138,5 +138,6 @@ User clicks File > Commit (or Ctrl+Shift+C)
`findOwningOrigin()` used by every command
- [FileOriginPython](./cpp-file-origin-python.md) — bridges the dispatch
from C++ to Python origins
- [SiloOrigin adapter](./cpp-file-origin.md) — Python implementation
that handles the actual Silo operations
- [Silo Guide — Origin Integration](../guide/silo.md#origin-integration) —
Python `SiloOrigin` that handles the actual Silo operations
- [KCSDK Python API](./kcsdk-python.md) — Python SDK origin functions

View File

@@ -2,7 +2,7 @@
This guide walks through implementing and registering a new `FileOrigin` backend in C++. By the end you will have a working origin that appears in the toolbar selector, responds to File menu commands, and integrates with the command dispatch system.
For the Python equivalent, see [Creating a Custom Origin in Python](./python-custom-origin-guide.md).
For the Python equivalent, see the [FileOriginPython Bridge](./cpp-file-origin-python.md) documentation and the [Silo Guide — Origin Integration](../guide/silo.md#origin-integration) for a real-world example.
## Prerequisites
@@ -530,8 +530,12 @@ No changes are needed to `CommandOrigin.cpp`, `OriginSelectorWidget.cpp`, or `Wo
- [FileOrigin Interface](./cpp-file-origin.md) — complete API reference
- [LocalFileOrigin](./cpp-local-file-origin.md) — reference implementation (simplest origin)
- [FileOriginPython Bridge](./cpp-file-origin-python.md) — how Python origins connect to the C++ layer
- [Creating a Custom Origin in Python](./python-custom-origin-guide.md) — Python alternative (no rebuild needed)
- [FileOriginPython Bridge](./cpp-file-origin-python.md) — how Python
origins connect to the C++ layer (no rebuild needed)
- [KCSDK Python API](./kcsdk-python.md) — `register_origin()` and
document-origin Python bindings
- [Silo Guide — Origin Integration](../guide/silo.md#origin-integration) —
real-world Python origin implementation
- [OriginManager](./cpp-origin-manager.md) — registration and document resolution
- [OriginSelectorWidget](./cpp-origin-selector-widget.md) — toolbar UI integration
- [CommandOrigin](./cpp-command-origin.md) — command dispatch

View File

@@ -253,5 +253,7 @@ FreeCADGui.addOrigin(py_obj)
gets registered
- [CommandOrigin](./cpp-command-origin.md) — commands that dispatch
through this bridge
- [Creating a Custom Origin (Python)](../guide/custom-origin-python.md)
— step-by-step guide using this bridge
- [Silo Guide — Origin Integration](../guide/silo.md#origin-integration) —
real-world Python origin using this bridge
- [KCSDK Python API](./kcsdk-python.md) — Python SDK wrappers for
`register_origin()` and document-origin functions

View File

@@ -196,4 +196,7 @@ fallback** — it owns any document that no other origin claims.
- [OriginSelectorWidget](./cpp-origin-selector-widget.md) — toolbar
dropdown for switching origins
- [Creating a Custom Origin (C++)](../guide/custom-origin-cpp.md)
- [Creating a Custom Origin (Python)](../guide/custom-origin-python.md)
- [KCSDK Python API](./kcsdk-python.md) — Python bindings for origin
registration and document-origin resolution
- [Silo Guide — Origin Integration](../guide/silo.md#origin-integration) —
`SiloOrigin` as a real-world `FileOrigin` implementation in Python

View File

@@ -215,4 +215,5 @@ static const char* PREF_CURRENT_ORIGIN = "CurrentOriginId";
- [FileOrigin Interface](./cpp-file-origin.md) — abstract base class
- [OriginManager](./cpp-origin-manager.md) — singleton registry and document resolution
- [FileOriginPython Bridge](./cpp-file-origin-python.md) — Python adapter for custom origins
- [SiloOrigin](./python-silo-origin.md) — PLM origin that contrasts with LocalFileOrigin
- [Silo Guide — Origin Integration](../guide/silo.md#origin-integration) —
PLM origin that contrasts with LocalFileOrigin

View File

@@ -158,6 +158,14 @@ documents to mark them with the origin that created them.
Remove a document from the association cache. Called when a document is
closed to prevent stale pointers.
### Python Exposure
All four document-origin methods are exposed to Python through two layers:
1. **`kcsdk` (pybind11)** — `document_origin(doc_name)`, `set_document_origin(doc_name, origin_id)`, `clear_document_origin(doc_name)`, `find_owning_origin(doc_name)`. These accept the document's internal `Name` string and return origin info dicts. See [KCSDK Python API — Per-Document Origin API](./kcsdk-python.md#per-document-origin-api).
2. **`kindred_sdk` wrappers** — same names but accept an `App.Document` object instead of a name string. The wrapper extracts `doc.Name` and delegates to `kcsdk`. Addons should use this layer. See [KCSDK Python API — Wrappers Table](./kcsdk-python.md#other-kindred_sdk-wrappers).
## Signals
All signals use the [fastsignals](https://github.com/nicktrandafil/fastsignals)
@@ -224,3 +232,7 @@ OriginManager (singleton)
origins via this manager
- [OriginSelectorWidget](./cpp-origin-selector-widget.md) — toolbar UI
driven by this manager's signals
- [KCSDK Python API](./kcsdk-python.md) — Python bindings including
per-document origin functions
- [Silo Guide — Origin Integration](../guide/silo.md#origin-integration) —
SiloOrigin registration example

View File

@@ -222,3 +222,5 @@ fastsignals::scoped_connection m_connChanged; // signalCurrentOriginChang
- [LocalFileOrigin](./cpp-local-file-origin.md) — built-in default origin
- [OriginManager](./cpp-origin-manager.md) — singleton registry and signal source
- [CommandOrigin](./cpp-command-origin.md) — PLM commands in the Origin Tools toolbar
- [KCSDK Python API — Per-Document Origin API](./kcsdk-python.md#per-document-origin-api) —
Python bindings for origin queries

View File

@@ -30,15 +30,15 @@ Runs immediately at application startup, before any GUI is available.
| datums | 45 | Unified datum creator |
| silo | 60 | PLM workbench |
For each addon:
Pipeline steps:
1. Discover `package.xml` manifest (depth 1 or 2)
2. Parse `<kindred>` extensions (version bounds, priority, dependencies)
3. Validate version compatibility
4. Resolve load order via topological sort on dependencies
5. Add addon dir to `sys.path`
6. Execute `Init.py`
7. Register in `FreeCAD.KindredAddons`
1. **Discover** `package.xml` manifests (depth 1 or 2 under `mods/`)
2. **Parse** `<kindred>` extensions (version bounds, priority, dependencies); validate field types/formats at parse time (load_priority must be int, version strings must be dotted-numeric, context IDs must be alphanumeric/dots/underscores)
3. **Validate dependencies** — cross-check all declared dependency names against discovered addons
4. **Validate manifests** — check version compatibility, workbench path, Init.py presence; all errors accumulated per-addon in `AddonManifest.errors`
5. **Resolve** load order via topological sort on dependencies (addons with errors excluded)
6. For each validated addon: add addon dir to `sys.path`, execute `Init.py`
7. Register all addons in `FreeCAD.KindredAddons`
Failures are logged to `FreeCAD.Console` and do not prevent other addons from loading.
@@ -115,6 +115,11 @@ FreeCAD startup
|
Init.py (exec'd, immediate)
+-- addon_loader.load_addons(gui=False)
| +-- scan_addons() (discover manifests)
| +-- parse_manifest() (extract + validate types/formats)
| +-- validate_dependencies() (cross-check dep names)
| +-- validate_manifest() (version bounds, paths, accumulate errors)
| +-- resolve_load_order() (topo sort, skip errored addons)
| +-- sdk/Init.py
| +-- solver/Init.py
| +-- gears/Init.py

View File

@@ -180,6 +180,64 @@ Return IDs of all registered panel providers as `list[str]`.
Return names of all registered providers (across all provider types) as `list[str]`.
## Per-Document Origin API
These functions manage explicit associations between open documents and origins. They are used by the file commands (Save, Commit, Push) to determine which origin backend handles a given document.
### document_origin(doc_name)
Get the origin associated with a document. Checks explicit association first, then falls back to ownership detection (`ownsDocument`).
| Parameter | Type | Description |
|-----------|------|-------------|
| `doc_name` | `str` | FreeCAD document name (internal `Name` property) |
Returns an origin info dict (`id`, `label`, `capabilities`, …) or `None`.
### set_document_origin(doc_name, origin_id)
Explicitly associate a document with an origin. Used when creating new documents to mark them with the origin that created them.
| Parameter | Type | Description |
|-----------|------|-------------|
| `doc_name` | `str` | FreeCAD document name |
| `origin_id` | `str` | Registered origin ID (e.g. `"silo"`, `"local"`) |
Returns `True` on success, `False` if the document or origin ID is not found.
### clear_document_origin(doc_name)
Remove the explicit origin association for a document. After clearing, origin queries fall back to ownership detection.
| Parameter | Type | Description |
|-----------|------|-------------|
| `doc_name` | `str` | FreeCAD document name |
### find_owning_origin(doc_name)
Scan all registered origins via their `ownsDocument` method. Unlike `document_origin`, this bypasses the explicit association cache and always re-queries.
| Parameter | Type | Description |
|-----------|------|-------------|
| `doc_name` | `str` | FreeCAD document name |
Returns an origin info dict or `None`.
```python
import kcsdk
# Look up which origin handles the active document
info = kcsdk.document_origin("MyPart")
if info:
print(f"Document handled by: {info['id']}")
# Force-associate a document with the Silo origin
kcsdk.set_document_origin("MyPart", "silo")
# Re-detect without cache
owner = kcsdk.find_owning_origin("MyPart")
```
## `kindred_sdk` Convenience Wrappers
The `kindred_sdk` Python package wraps the `kcsdk` C++ module with input validation, error handling, and fallback to legacy APIs.
@@ -224,6 +282,10 @@ These mirror the `kcsdk` functions with added type validation and try/except err
| `kindred_sdk.refresh_context()` | `kcsdk.refresh()` |
| `kindred_sdk.register_origin()` | `FreeCADGui.addOrigin()` |
| `kindred_sdk.unregister_origin()` | `FreeCADGui.removeOrigin()` |
| `kindred_sdk.document_origin(doc)` | `kcsdk.document_origin(doc.Name)` |
| `kindred_sdk.set_document_origin(doc, id)` | `kcsdk.set_document_origin(doc.Name, id)` |
| `kindred_sdk.clear_document_origin(doc)` | `kcsdk.clear_document_origin(doc.Name)` |
| `kindred_sdk.find_owning_origin(doc)` | `kcsdk.find_owning_origin(doc.Name)` |
| `kindred_sdk.get_theme_tokens()` | YAML palette lookup |
| `kindred_sdk.load_palette()` | `Palette` object from YAML |
| `kindred_sdk.create_version()` | Kindred Create version string |
@@ -250,3 +312,9 @@ When `kcsdk` is not available (console mode, build not installed), `kindred_sdk`
- [Writing an Addon](../development/writing-an-addon.md)
- [Package.xml Schema Extensions](../development/package-xml-schema.md)
- [Create Module Bootstrap](./create-module-bootstrap.md)
- [OriginManager (C++)](./cpp-origin-manager.md) — singleton that backs
the origin Python API
- [FileOrigin Interface (C++)](./cpp-file-origin.md) — abstract interface
that registered origins implement
- [FileOriginPython Bridge (C++)](./cpp-file-origin-python.md) — how
Python origins connect to the C++ layer

View File

@@ -23,10 +23,14 @@ from kindred_sdk.lifecycle import context_history, on_context_enter, on_context_
from kindred_sdk.menu import register_menu
from kindred_sdk.origin import (
active_origin,
clear_document_origin,
document_origin,
find_owning_origin,
get_origin,
list_origins,
register_origin,
set_active_origin,
set_document_origin,
unregister_origin,
)
from kindred_sdk.registry import (
@@ -49,11 +53,14 @@ __all__ = [
"addon_resource",
"addon_version",
"available_contexts",
"clear_document_origin",
"context_history",
"context_stack",
"create_version",
"current_context",
"document_origin",
"emit",
"find_owning_origin",
"freecad_version",
"get_origin",
"get_theme_tokens",
@@ -79,6 +86,7 @@ __all__ = [
"remove_breadcrumb_injection",
"remove_transition_guard",
"set_active_origin",
"set_document_origin",
"unregister_context",
"unregister_origin",
"unregister_overlay",

View File

@@ -28,8 +28,7 @@ def _gui():
def _require_kcsdk():
if _kcsdk is None:
raise RuntimeError(
"kcsdk module not available. "
"The kindred_sdk requires the kcsdk C++ module (libKCSDK)."
"kcsdk module not available. The kindred_sdk requires the kcsdk C++ module (libKCSDK)."
)
@@ -134,3 +133,92 @@ def get_origin(origin_id):
except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: get_origin failed: {e}\n")
return None
def document_origin(doc):
"""Get the origin associated with a document.
Checks explicit association first, then falls back to ownership
detection (``ownsDocument``).
Parameters
----------
doc : App.Document
The document to query.
Returns
-------
dict or None
Origin info dict, or None if no origin is associated.
"""
_require_kcsdk()
try:
return _kcsdk.document_origin(doc.Name)
except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: document_origin failed: {e}\n")
return None
def set_document_origin(doc, origin_id):
"""Associate a document with a specific origin.
Parameters
----------
doc : App.Document
The document to associate.
origin_id : str
The origin ID to associate with the document.
Returns
-------
bool
True if the association was set successfully.
"""
_require_kcsdk()
try:
return _kcsdk.set_document_origin(doc.Name, origin_id)
except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: set_document_origin failed: {e}\n")
return False
def clear_document_origin(doc):
"""Clear the explicit origin association for a document.
After clearing, origin queries will fall back to ownership detection.
Parameters
----------
doc : App.Document
The document to clear the association for.
"""
_require_kcsdk()
try:
_kcsdk.clear_document_origin(doc.Name)
except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: clear_document_origin failed: {e}\n")
def find_owning_origin(doc):
"""Find which origin owns a document via ownership detection.
Unlike ``document_origin``, this bypasses explicit associations and
the internal cache — it always queries each registered origin's
``ownsDocument`` method.
Parameters
----------
doc : App.Document
The document to check.
Returns
-------
dict or None
Origin info dict, or None if no origin claims the document.
"""
_require_kcsdk()
try:
return _kcsdk.find_owning_origin(doc.Name)
except Exception as e:
FreeCAD.Console.PrintWarning(f"kindred_sdk: find_owning_origin failed: {e}\n")
return None

View File

@@ -32,6 +32,7 @@
#include <Gui/SDK/ThemeEngine.h>
#include <Gui/SDK/Types.h>
#include <App/Application.h>
#include <Gui/FileOrigin.h>
#include <Gui/OriginManager.h>
@@ -110,13 +111,16 @@ PYBIND11_MODULE(kcsdk, m)
m.doc() = "KCSDK — Kindred Create addon SDK C++ API";
m.attr("API_VERSION_MAJOR") = API_VERSION_MAJOR;
m.def("available", []() {
return SDKRegistry::instance().available();
}, "Return names of all registered providers.");
m.def(
"available",
[]() { return SDKRegistry::instance().available(); },
"Return names of all registered providers."
);
// -- Editing context API ------------------------------------------------
m.def("register_context",
m.def(
"register_context",
[](const std::string& id,
const std::string& label,
const std::string& color,
@@ -153,19 +157,19 @@ PYBIND11_MODULE(kcsdk, m)
"toolbars : list[str]\n Toolbar names to show when active.\n"
"match : callable\n Zero-arg callable returning True when active.\n"
"priority : int\n Higher values checked first (default 50).\n"
"parent_id : str\n Optional parent context ID for hierarchy.");
"parent_id : str\n Optional parent context ID for hierarchy."
);
m.def("unregister_context",
[](const std::string& id) {
SDKRegistry::instance().unregisterContext(id);
},
m.def(
"unregister_context",
[](const std::string& id) { SDKRegistry::instance().unregisterContext(id); },
py::arg("id"),
"Unregister an editing context.");
"Unregister an editing context."
);
m.def("register_overlay",
[](const std::string& id,
const std::vector<std::string>& toolbars,
py::object match) {
m.def(
"register_overlay",
[](const std::string& id, const std::vector<std::string>& toolbars, py::object match) {
if (!py::isinstance<py::function>(match) && !py::hasattr(match, "__call__")) {
throw py::type_error("match must be callable");
}
@@ -178,16 +182,18 @@ PYBIND11_MODULE(kcsdk, m)
py::arg("id"),
py::arg("toolbars"),
py::arg("match"),
"Register an editing overlay (additive toolbars).");
"Register an editing overlay (additive toolbars)."
);
m.def("unregister_overlay",
[](const std::string& id) {
SDKRegistry::instance().unregisterOverlay(id);
},
m.def(
"unregister_overlay",
[](const std::string& id) { SDKRegistry::instance().unregisterOverlay(id); },
py::arg("id"),
"Unregister an editing overlay.");
"Unregister an editing overlay."
);
m.def("inject_commands",
m.def(
"inject_commands",
[](const std::string& contextId,
const std::string& toolbarName,
const std::vector<std::string>& commands) {
@@ -196,9 +202,11 @@ PYBIND11_MODULE(kcsdk, m)
py::arg("context_id"),
py::arg("toolbar_name"),
py::arg("commands"),
"Inject commands into a context's toolbar.");
"Inject commands into a context's toolbar."
);
m.def("current_context",
m.def(
"current_context",
[]() -> py::object {
ContextSnapshot snap = SDKRegistry::instance().currentContext();
if (snap.id.empty()) {
@@ -206,15 +214,17 @@ PYBIND11_MODULE(kcsdk, m)
}
return contextSnapshotToDict(snap);
},
"Return the current editing context as a dict, or None.");
"Return the current editing context as a dict, or None."
);
m.def("refresh",
[]() {
SDKRegistry::instance().refresh();
},
"Force re-resolution of the editing context.");
m.def(
"refresh",
[]() { SDKRegistry::instance().refresh(); },
"Force re-resolution of the editing context."
);
m.def("available_contexts",
m.def(
"available_contexts",
[]() {
auto contexts = SDKRegistry::instance().registeredContexts();
py::list result;
@@ -231,30 +241,33 @@ PYBIND11_MODULE(kcsdk, m)
},
"Return metadata for all registered editing contexts.\n\n"
"Each entry is a dict with keys: id, parent_id, label_template, color, priority.\n"
"Sorted by descending priority (highest first).");
"Sorted by descending priority (highest first)."
);
m.def("on_context_changed",
m.def(
"on_context_changed",
[](py::function callback) {
auto held = std::make_shared<py::object>(std::move(callback));
SDKRegistry::instance().onContextChanged(
[held](const ContextSnapshot& snap) {
py::gil_scoped_acquire gil;
try {
(*held)(contextSnapshotToDict(snap));
}
catch (py::error_already_set& e) {
e.discard_as_unraisable(__func__);
}
});
SDKRegistry::instance().onContextChanged([held](const ContextSnapshot& snap) {
py::gil_scoped_acquire gil;
try {
(*held)(contextSnapshotToDict(snap));
}
catch (py::error_already_set& e) {
e.discard_as_unraisable(__func__);
}
});
},
py::arg("callback"),
"Register a callback for context changes.\n\n"
"The callback receives a context dict with keys:\n"
"id, label, color, toolbars, breadcrumb, breadcrumbColors, stack.\n"
"Called synchronously on the Qt main thread whenever the\n"
"editing context changes.");
"editing context changes."
);
m.def("context_stack",
m.def(
"context_stack",
[]() -> py::object {
ContextSnapshot snap = SDKRegistry::instance().currentContext();
if (snap.id.empty()) {
@@ -262,27 +275,26 @@ PYBIND11_MODULE(kcsdk, m)
}
return py::cast(snap.stack);
},
"Return the current context stack (root to leaf) as a list of IDs.");
"Return the current context stack (root to leaf) as a list of IDs."
);
// -- Transition guard API -----------------------------------------------
m.def("add_transition_guard",
m.def(
"add_transition_guard",
[](py::function callback) -> int {
auto held = std::make_shared<py::object>(std::move(callback));
SDKRegistry::TransitionGuard guard =
[held](const ContextSnapshot& from, const ContextSnapshot& to)
-> std::pair<bool, std::string>
{
SDKRegistry::TransitionGuard guard = [held](
const ContextSnapshot& from,
const ContextSnapshot& to
) -> std::pair<bool, std::string> {
py::gil_scoped_acquire gil;
try {
py::object result = (*held)(
contextSnapshotToDict(from),
contextSnapshotToDict(to));
py::object result = (*held)(contextSnapshotToDict(from), contextSnapshotToDict(to));
if (py::isinstance<py::tuple>(result)) {
auto tup = result.cast<py::tuple>();
bool allowed = tup[0].cast<bool>();
std::string reason = tup.size() > 1
? tup[1].cast<std::string>() : "";
std::string reason = tup.size() > 1 ? tup[1].cast<std::string>() : "";
return {allowed, reason};
}
return {result.cast<bool>(), ""};
@@ -298,18 +310,20 @@ PYBIND11_MODULE(kcsdk, m)
"Register a transition guard.\n\n"
"The callback receives (from_ctx, to_ctx) dicts and must return\n"
"either a bool or a (bool, reason_str) tuple. Returns a guard ID\n"
"for later removal.");
"for later removal."
);
m.def("remove_transition_guard",
[](int guardId) {
SDKRegistry::instance().removeTransitionGuard(guardId);
},
m.def(
"remove_transition_guard",
[](int guardId) { SDKRegistry::instance().removeTransitionGuard(guardId); },
py::arg("guard_id"),
"Remove a previously registered transition guard.");
"Remove a previously registered transition guard."
);
// -- Breadcrumb injection API -------------------------------------------
m.def("inject_breadcrumb",
m.def(
"inject_breadcrumb",
[](const std::string& contextId,
const std::vector<std::string>& segments,
const std::vector<std::string>& colors) {
@@ -317,17 +331,20 @@ PYBIND11_MODULE(kcsdk, m)
},
py::arg("context_id"),
py::arg("segments"),
py::arg("colors") = std::vector<std::string>{},
py::arg("colors") = std::vector<std::string> {},
"Inject additional breadcrumb segments into a context.\n\n"
"Segments are appended after the context's own label in the breadcrumb.\n"
"Active only when the target context is in the current stack.");
"Active only when the target context is in the current stack."
);
m.def("remove_breadcrumb_injection",
m.def(
"remove_breadcrumb_injection",
[](const std::string& contextId) {
SDKRegistry::instance().removeBreadcrumbInjection(contextId);
},
py::arg("context_id"),
"Remove a previously injected breadcrumb for a context.");
"Remove a previously injected breadcrumb for a context."
);
// -- Enums --------------------------------------------------------------
@@ -355,7 +372,8 @@ PYBIND11_MODULE(kcsdk, m)
.def("persistence", &IPanelProvider::persistence)
.def("context_affinity", &IPanelProvider::context_affinity);
m.def("register_panel",
m.def(
"register_panel",
[](py::object provider) {
auto holder = std::make_unique<PyProviderHolder>(std::move(provider));
SDKRegistry::instance().registerPanel(std::move(holder));
@@ -365,33 +383,34 @@ PYBIND11_MODULE(kcsdk, m)
"Parameters\n"
"----------\n"
"provider : IPanelProvider\n"
" Panel provider instance implementing id(), title(), create_widget().");
" Panel provider instance implementing id(), title(), create_widget()."
);
m.def("unregister_panel",
[](const std::string& id) {
SDKRegistry::instance().unregisterPanel(id);
},
m.def(
"unregister_panel",
[](const std::string& id) { SDKRegistry::instance().unregisterPanel(id); },
py::arg("id"),
"Remove a registered panel provider and its dock widget.");
"Remove a registered panel provider and its dock widget."
);
m.def("create_panel",
[](const std::string& id) {
SDKRegistry::instance().createPanel(id);
},
m.def(
"create_panel",
[](const std::string& id) { SDKRegistry::instance().createPanel(id); },
py::arg("id"),
"Instantiate the dock widget for a registered panel.");
"Instantiate the dock widget for a registered panel."
);
m.def("create_all_panels",
[]() {
SDKRegistry::instance().createAllPanels();
},
"Instantiate dock widgets for all registered panels.");
m.def(
"create_all_panels",
[]() { SDKRegistry::instance().createAllPanels(); },
"Instantiate dock widgets for all registered panels."
);
m.def("registered_panels",
[]() {
return SDKRegistry::instance().registeredPanels();
},
"Return IDs of all registered panel providers.");
m.def(
"registered_panels",
[]() { return SDKRegistry::instance().registeredPanels(); },
"Return IDs of all registered panel providers."
);
// -- Toolbar provider API -----------------------------------------------
@@ -402,7 +421,8 @@ PYBIND11_MODULE(kcsdk, m)
.def("context_ids", &IToolbarProvider::context_ids)
.def("commands", &IToolbarProvider::commands);
m.def("register_toolbar",
m.def(
"register_toolbar",
[](py::object provider) {
auto holder = std::make_unique<PyToolbarHolder>(std::move(provider));
SDKRegistry::instance().registerToolbar(std::move(holder));
@@ -412,20 +432,21 @@ PYBIND11_MODULE(kcsdk, m)
"Parameters\n"
"----------\n"
"provider : IToolbarProvider\n"
" Toolbar provider implementing id(), toolbar_name(), context_ids(), commands().");
" Toolbar provider implementing id(), toolbar_name(), context_ids(), commands()."
);
m.def("unregister_toolbar",
[](const std::string& id) {
SDKRegistry::instance().unregisterToolbar(id);
},
m.def(
"unregister_toolbar",
[](const std::string& id) { SDKRegistry::instance().unregisterToolbar(id); },
py::arg("id"),
"Remove a registered toolbar provider.");
"Remove a registered toolbar provider."
);
m.def("registered_toolbars",
[]() {
return SDKRegistry::instance().registeredToolbars();
},
"Return IDs of all registered toolbar providers.");
m.def(
"registered_toolbars",
[]() { return SDKRegistry::instance().registeredToolbars(); },
"Return IDs of all registered toolbar providers."
);
// -- Menu provider API --------------------------------------------------
@@ -436,7 +457,8 @@ PYBIND11_MODULE(kcsdk, m)
.def("items", &IMenuProvider::items)
.def("context_ids", &IMenuProvider::context_ids);
m.def("register_menu",
m.def(
"register_menu",
[](py::object provider) {
auto holder = std::make_unique<PyMenuHolder>(std::move(provider));
SDKRegistry::instance().registerMenu(std::move(holder));
@@ -447,24 +469,26 @@ PYBIND11_MODULE(kcsdk, m)
"----------\n"
"provider : IMenuProvider\n"
" Menu provider implementing id(), menu_path(), items().\n"
" Optionally override context_ids() to limit to specific contexts.");
" Optionally override context_ids() to limit to specific contexts."
);
m.def("unregister_menu",
[](const std::string& id) {
SDKRegistry::instance().unregisterMenu(id);
},
m.def(
"unregister_menu",
[](const std::string& id) { SDKRegistry::instance().unregisterMenu(id); },
py::arg("id"),
"Remove a registered menu provider.");
"Remove a registered menu provider."
);
m.def("registered_menus",
[]() {
return SDKRegistry::instance().registeredMenus();
},
"Return IDs of all registered menu providers.");
m.def(
"registered_menus",
[]() { return SDKRegistry::instance().registeredMenus(); },
"Return IDs of all registered menu providers."
);
// -- Theme engine API ---------------------------------------------------
m.def("theme_color",
m.def(
"theme_color",
[](const std::string& token) {
auto& engine = ThemeEngine::instance();
if (engine.activePaletteName().empty()) {
@@ -475,9 +499,11 @@ PYBIND11_MODULE(kcsdk, m)
py::arg("token"),
"Look up a color by role or name.\n\n"
"Returns the hex string (e.g. \"#89b4fa\") or empty string if not found.\n"
"Auto-loads the default palette on first call.");
"Auto-loads the default palette on first call."
);
m.def("theme_tokens",
m.def(
"theme_tokens",
[]() {
auto& engine = ThemeEngine::instance();
if (engine.activePaletteName().empty()) {
@@ -487,9 +513,11 @@ PYBIND11_MODULE(kcsdk, m)
},
"Return all color tokens as {name: \"#hex\"}.\n\n"
"Includes both raw colors and resolved semantic roles.\n"
"Auto-loads the default palette on first call.");
"Auto-loads the default palette on first call."
);
m.def("format_qss",
m.def(
"format_qss",
[](const std::string& templateStr) {
auto& engine = ThemeEngine::instance();
if (engine.activePaletteName().empty()) {
@@ -501,25 +529,29 @@ PYBIND11_MODULE(kcsdk, m)
"Substitute {token} placeholders in a QSS template.\n\n"
"Both raw color names ({blue}) and dotted role names\n"
"({accent.primary}) are supported. Unknown tokens are left as-is.\n"
"Auto-loads the default palette on first call.");
"Auto-loads the default palette on first call."
);
m.def("load_palette",
[](const std::string& name) {
return ThemeEngine::instance().loadPalette(name);
},
m.def(
"load_palette",
[](const std::string& name) { return ThemeEngine::instance().loadPalette(name); },
py::arg("name") = "catppuccin-mocha",
"Load a named palette. Returns True on success.");
"Load a named palette. Returns True on success."
);
// -- Origin query API ---------------------------------------------------
m.def("list_origins",
m.def(
"list_origins",
[]() {
auto* mgr = Gui::OriginManager::instance();
return mgr ? mgr->originIds() : std::vector<std::string>{};
return mgr ? mgr->originIds() : std::vector<std::string> {};
},
"Return IDs of all registered origins.");
"Return IDs of all registered origins."
);
m.def("active_origin",
m.def(
"active_origin",
[]() -> py::object {
auto* mgr = Gui::OriginManager::instance();
if (!mgr) {
@@ -531,9 +563,11 @@ PYBIND11_MODULE(kcsdk, m)
}
return originToDict(origin);
},
"Return the active origin as a dict, or None.");
"Return the active origin as a dict, or None."
);
m.def("set_active_origin",
m.def(
"set_active_origin",
[](const std::string& id) {
auto* mgr = Gui::OriginManager::instance();
if (!mgr) {
@@ -542,9 +576,11 @@ PYBIND11_MODULE(kcsdk, m)
return mgr->setCurrentOrigin(id);
},
py::arg("id"),
"Set the active origin by ID. Returns True on success.");
"Set the active origin by ID. Returns True on success."
);
m.def("get_origin",
m.def(
"get_origin",
[](const std::string& id) -> py::object {
auto* mgr = Gui::OriginManager::instance();
if (!mgr) {
@@ -557,5 +593,91 @@ PYBIND11_MODULE(kcsdk, m)
return originToDict(origin);
},
py::arg("id"),
"Get origin info by ID as a dict, or None if not found.");
"Get origin info by ID as a dict, or None if not found."
);
// -- Per-document origin API --------------------------------------------
m.def(
"document_origin",
[](const std::string& docName) -> py::object {
auto* mgr = Gui::OriginManager::instance();
if (!mgr) {
return py::none();
}
App::Document* doc = App::GetApplication().getDocument(docName.c_str());
if (!doc) {
return py::none();
}
Gui::FileOrigin* origin = mgr->originForDocument(doc);
if (!origin) {
return py::none();
}
return originToDict(origin);
},
py::arg("doc_name"),
"Get the origin for a document by name. Returns origin dict or None."
);
m.def(
"set_document_origin",
[](const std::string& docName, const std::string& originId) {
auto* mgr = Gui::OriginManager::instance();
if (!mgr) {
return false;
}
App::Document* doc = App::GetApplication().getDocument(docName.c_str());
if (!doc) {
return false;
}
Gui::FileOrigin* origin = originId.empty() ? nullptr : mgr->getOrigin(originId);
if (!originId.empty() && !origin) {
return false;
}
mgr->setDocumentOrigin(doc, origin);
return true;
},
py::arg("doc_name"),
py::arg("origin_id"),
"Associate a document with an origin by ID. Returns True on success."
);
m.def(
"clear_document_origin",
[](const std::string& docName) {
auto* mgr = Gui::OriginManager::instance();
if (!mgr) {
return;
}
App::Document* doc = App::GetApplication().getDocument(docName.c_str());
if (!doc) {
return;
}
mgr->clearDocumentOrigin(doc);
},
py::arg("doc_name"),
"Clear explicit origin association for a document."
);
m.def(
"find_owning_origin",
[](const std::string& docName) -> py::object {
auto* mgr = Gui::OriginManager::instance();
if (!mgr) {
return py::none();
}
App::Document* doc = App::GetApplication().getDocument(docName.c_str());
if (!doc) {
return py::none();
}
Gui::FileOrigin* origin = mgr->findOwningOrigin(doc);
if (!origin) {
return py::none();
}
return originToDict(origin);
},
py::arg("doc_name"),
"Find which origin owns a document (ownership detection, no cache).\n\n"
"Returns origin dict or None."
);
}

View File

@@ -6,6 +6,7 @@
import enum
import os
import re
import sys
import time
import xml.etree.ElementTree as ET
@@ -50,12 +51,14 @@ class AddonManifest:
# Runtime state
state: AddonState = AddonState.DISCOVERED
error: str = ""
errors: list[str] = field(default_factory=list)
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})"
return (
f"AddonManifest(name={self.name!r}, version={self.version!r}, state={self.state.value})"
)
# ---------------------------------------------------------------------------
@@ -191,6 +194,16 @@ def scan_addons(mods_dir: str) -> list[AddonManifest]:
# Parsing
# ---------------------------------------------------------------------------
# Validation patterns
_VERSION_RE = re.compile(r"^\d+(\.\d+)*$")
_CONTEXT_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.]*$")
def _valid_version(v: str) -> bool:
"""Check that a version string is dotted-numeric (e.g. '0.1.0')."""
return bool(_VERSION_RE.match(v))
# FreeCAD package.xml namespace
_PKG_NS = "https://wiki.freecad.org/Package_Metadata"
@@ -224,10 +237,8 @@ def parse_manifest(manifest: AddonManifest):
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"
)
manifest.errors.append(f"XML parse error: {e}")
FreeCAD.Console.PrintWarning(f"Create: Failed to parse {manifest.package_xml_path}: {e}\n")
return
# Standard fields
@@ -259,22 +270,43 @@ def parse_manifest(manifest: AddonManifest):
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
# Validate version string formats
if manifest.min_create_version and not _valid_version(manifest.min_create_version):
manifest.errors.append(
f"min_create_version is not a valid version: {manifest.min_create_version!r}"
)
if manifest.max_create_version and not _valid_version(manifest.max_create_version):
manifest.errors.append(
f"max_create_version is not a valid version: {manifest.max_create_version!r}"
)
# Validate load_priority is an integer
priority_str = _text(kindred, "load_priority")
if priority_str:
try:
manifest.load_priority = int(priority_str)
except ValueError:
pass
manifest.errors.append(f"load_priority must be an integer, got: {priority_str!r}")
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())
# Validate context ID syntax
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())
cid = ctx.text.strip() if ctx.text else ""
if cid:
if _CONTEXT_ID_RE.match(cid):
manifest.contexts.append(cid)
else:
manifest.errors.append(
f"Invalid context ID {cid!r}: must be alphanumeric, dots, or underscores"
)
FreeCAD.Console.PrintLog(
f"Create: Parsed {manifest.name} v{manifest.version} from {manifest.package_xml_path}\n"
@@ -294,8 +326,27 @@ def _parse_version(v: str) -> tuple:
return (0, 0, 0)
def validate_dependencies(manifests: list[AddonManifest]):
"""Check that all declared dependencies reference discovered addons.
Called after parsing all manifests so the full set of names is known.
Errors are accumulated on each manifest.
"""
known = {m.name for m in manifests}
for m in manifests:
if m.state == AddonState.FAILED:
continue
for dep in m.dependencies:
if dep not in known:
m.errors.append(f"Unknown dependency: {dep!r}")
def validate_manifest(manifest: AddonManifest, create_version: str) -> bool:
"""Check version compatibility and path existence. Returns True if valid."""
"""Check version compatibility and path existence.
All checks run to completion so that every problem is reported.
Returns True if the manifest is valid for loading.
"""
if manifest.state == AddonState.FAILED:
return False
@@ -303,39 +354,29 @@ def validate_manifest(manifest: AddonManifest, create_version: str) -> bool:
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"
manifest.errors.append(
f"Requires Create >= {manifest.min_create_version}, running {create_version}"
)
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"
manifest.errors.append(
f"Requires Create <= {manifest.max_create_version}, running {create_version}"
)
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
manifest.errors.append(f"Workbench path not found: {manifest.workbench_path}")
else:
# Only check Init files if the directory exists
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.errors.append(f"No Init.py or InitGui.py in {manifest.workbench_path}")
# 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:
if manifest.errors:
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"
)
for err in manifest.errors:
FreeCAD.Console.PrintWarning(f"Create: {manifest.name}: {err}\n")
return False
manifest.state = AddonState.VALIDATED
@@ -347,9 +388,7 @@ def validate_manifest(manifest: AddonManifest, create_version: str) -> bool:
# ---------------------------------------------------------------------------
def resolve_load_order(
manifests: list[AddonManifest], mods_dir: str
) -> list[AddonManifest]:
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
@@ -370,15 +409,10 @@ def resolve_load_order(
ts = TopologicalSorter()
for m in manifests:
# Only include dependencies that are actually discovered
if m.state in (AddonState.SKIPPED, AddonState.FAILED):
continue
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)
ts.add(m.name, *known_deps)
try:
# Process level by level so we can sort within each topological level
@@ -387,11 +421,7 @@ def resolve_load_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)
)
)
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)
@@ -400,7 +430,7 @@ def resolve_load_order(
f"Create: Dependency cycle detected: {e}. Falling back to priority order.\n"
)
return sorted(
[m for m in manifests if m.state != AddonState.SKIPPED],
[m for m in manifests if m.state not in (AddonState.SKIPPED, AddonState.FAILED)],
key=lambda m: (m.load_priority, m.name),
)
@@ -408,7 +438,7 @@ def resolve_load_order(
result = []
for name in order:
m = by_name.get(name)
if m is not None and m.state != AddonState.SKIPPED:
if m is not None and m.state not in (AddonState.SKIPPED, AddonState.FAILED):
result.append(m)
return result
@@ -465,12 +495,10 @@ def _load_addon(manifest: AddonManifest, gui: bool = False):
else:
manifest.load_time_ms += elapsed
manifest.state = AddonState.LOADED
FreeCAD.Console.PrintLog(
f"Create: Loaded {manifest.name} {init_file} ({elapsed:.0f}ms)\n"
)
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)
manifest.errors.append(str(e))
FreeCAD.Console.PrintWarning(f"Create: Failed to load {manifest.name}: {e}\n")
@@ -500,8 +528,8 @@ def _print_load_summary(registry: AddonRegistry, phase: str):
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})"
if m.errors:
line += f" ({'; '.join(m.errors)})"
lines.append(line)
FreeCAD.Console.PrintLog("\n".join(lines) + "\n")
@@ -527,6 +555,8 @@ def load_addons(gui: bool = False):
for m in manifests:
parse_manifest(m)
validate_dependencies(manifests)
create_version = _get_create_version()
validated = [m for m in manifests if validate_manifest(m, create_version)]
ordered = resolve_load_order(validated, mods_dir)

View File

@@ -29,7 +29,7 @@ def run_pure_tests() -> bool:
loader = unittest.TestLoader()
suite = loader.discover(
start_dir=str(REPO_ROOT / "tests"),
pattern="test_kindred_pure.py",
pattern="test_kindred_*.py",
top_level_dir=str(REPO_ROOT / "tests"),
)
runner = unittest.TextTestRunner(verbosity=2)

View File

@@ -0,0 +1,843 @@
"""Tier 1 — Pure-logic tests for Kindred Create addon loader.
These tests exercise the manifest-driven addon loader pipeline
(addon_loader.py) WITHOUT requiring a FreeCAD binary.
Run directly: python3 tests/test_kindred_addon_loader.py
Via runner: python3 tests/run_kindred_tests.py
Via pixi: pixi run test-kindred
"""
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
# ---------------------------------------------------------------------------
# Mock the FreeCAD ecosystem BEFORE importing addon_loader.
# ---------------------------------------------------------------------------
_REPO_ROOT = Path(__file__).resolve().parent.parent
_fc = mock.MagicMock()
_fc.Console = mock.MagicMock()
for mod_name in ("FreeCAD", "FreeCADGui"):
sys.modules.setdefault(mod_name, mock.MagicMock())
sys.modules["FreeCAD"] = _fc
# Add the Create module to sys.path
sys.path.insert(0, str(_REPO_ROOT / "src" / "Mod" / "Create"))
# ---------------------------------------------------------------------------
# Import module under test
# ---------------------------------------------------------------------------
from addon_loader import ( # noqa: E402
AddonManifest,
AddonRegistry,
AddonState,
_parse_version,
_valid_version,
parse_manifest,
resolve_load_order,
scan_addons,
validate_dependencies,
validate_manifest,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_package_xml(
parent_dir,
name="testaddon",
version="1.0.0",
subdirectory=None,
kindred=None,
create_init=False,
create_initgui=False,
raw_xml=None,
):
"""Write a package.xml and optionally Init.py/InitGui.py.
Returns the parent_dir (addon root).
"""
pkg_path = os.path.join(parent_dir, "package.xml")
if raw_xml is not None:
with open(pkg_path, "w") as f:
f.write(raw_xml)
return parent_dir
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<package format="1">',
f" <name>{name}</name>",
f" <version>{version}</version>",
" <description>Test addon</description>",
]
if subdirectory is not None:
lines += [
" <content>",
" <workbench>",
f" <subdirectory>{subdirectory}</subdirectory>",
" </workbench>",
" </content>",
]
if kindred is not None:
lines.append(" <kindred>")
for key in ("min_create_version", "max_create_version", "load_priority"):
if key in kindred:
lines.append(f" <{key}>{kindred[key]}</{key}>")
if "dependencies" in kindred:
lines.append(" <dependencies>")
for dep in kindred["dependencies"]:
lines.append(f" <dependency>{dep}</dependency>")
lines.append(" </dependencies>")
if "contexts" in kindred:
lines.append(" <contexts>")
for ctx in kindred["contexts"]:
lines.append(f" <context>{ctx}</context>")
lines.append(" </contexts>")
lines.append(" </kindred>")
lines.append("</package>")
with open(pkg_path, "w") as f:
f.write("\n".join(lines))
# Create Init files in the workbench directory
wb_dir = parent_dir
if subdirectory and subdirectory not in (".", "./"):
wb_dir = os.path.join(parent_dir, subdirectory)
os.makedirs(wb_dir, exist_ok=True)
if create_init:
with open(os.path.join(wb_dir, "Init.py"), "w") as f:
f.write("# test\n")
if create_initgui:
with open(os.path.join(wb_dir, "InitGui.py"), "w") as f:
f.write("# test\n")
return parent_dir
def _make_manifest(name="test", version="1.0.0", state=AddonState.DISCOVERED, **kwargs):
"""Create an AddonManifest with sensible defaults for testing."""
return AddonManifest(name=name, version=version, state=state, **kwargs)
# ===================================================================
# Test: AddonState enum
# ===================================================================
class TestAddonState(unittest.TestCase):
"""Sanity checks for AddonState enum."""
def test_all_states_exist(self):
expected = {"DISCOVERED", "VALIDATED", "LOADED", "SKIPPED", "FAILED"}
actual = {s.name for s in AddonState}
self.assertEqual(actual, expected)
def test_values(self):
self.assertEqual(AddonState.DISCOVERED.value, "discovered")
self.assertEqual(AddonState.VALIDATED.value, "validated")
self.assertEqual(AddonState.LOADED.value, "loaded")
self.assertEqual(AddonState.SKIPPED.value, "skipped")
self.assertEqual(AddonState.FAILED.value, "failed")
# ===================================================================
# Test: _valid_version
# ===================================================================
class TestValidVersion(unittest.TestCase):
"""Tests for _valid_version() regex matching."""
def test_simple_semver(self):
self.assertTrue(_valid_version("1.2.3"))
def test_single_digit(self):
self.assertTrue(_valid_version("1"))
def test_two_part(self):
self.assertTrue(_valid_version("0.1"))
def test_four_part(self):
self.assertTrue(_valid_version("1.2.3.4"))
def test_zeros(self):
self.assertTrue(_valid_version("0.0.0"))
def test_empty_string(self):
self.assertFalse(_valid_version(""))
def test_v_prefix_rejected(self):
self.assertFalse(_valid_version("v1.0.0"))
def test_alpha_suffix_rejected(self):
self.assertFalse(_valid_version("1.0.0-beta"))
def test_leading_dot_rejected(self):
self.assertFalse(_valid_version(".1.0"))
def test_trailing_dot_rejected(self):
self.assertFalse(_valid_version("1.0."))
def test_letters_rejected(self):
self.assertFalse(_valid_version("abc"))
# ===================================================================
# Test: _parse_version (addon_loader version, not update_checker)
# ===================================================================
class TestParseVersionLoader(unittest.TestCase):
"""Tests for addon_loader._parse_version()."""
def test_standard_semver(self):
self.assertEqual(_parse_version("1.2.3"), (1, 2, 3))
def test_two_part(self):
self.assertEqual(_parse_version("0.1"), (0, 1))
def test_single(self):
self.assertEqual(_parse_version("5"), (5,))
def test_comparison(self):
self.assertLess(_parse_version("0.1.0"), _parse_version("0.2.0"))
self.assertLess(_parse_version("0.2.0"), _parse_version("1.0.0"))
def test_invalid_returns_fallback(self):
self.assertEqual(_parse_version("abc"), (0, 0, 0))
def test_none_returns_fallback(self):
self.assertEqual(_parse_version(None), (0, 0, 0))
def test_empty_returns_fallback(self):
self.assertEqual(_parse_version(""), (0, 0, 0))
# ===================================================================
# Test: AddonRegistry
# ===================================================================
class TestAddonRegistry(unittest.TestCase):
"""Tests for AddonRegistry class."""
def setUp(self):
self.reg = AddonRegistry()
def test_register_and_get(self):
m = _make_manifest(name="sdk")
self.reg.register(m)
self.assertIs(self.reg.get("sdk"), m)
def test_get_unknown_returns_none(self):
self.assertIsNone(self.reg.get("nonexistent"))
def test_all_returns_all(self):
m1 = _make_manifest(name="a")
m2 = _make_manifest(name="b")
self.reg.register(m1)
self.reg.register(m2)
self.assertEqual(len(self.reg.all()), 2)
def test_loaded_filters_by_state(self):
m1 = _make_manifest(name="a", state=AddonState.LOADED)
m2 = _make_manifest(name="b", state=AddonState.SKIPPED)
self.reg.register(m1)
self.reg.register(m2)
self.reg.set_load_order(["a", "b"])
loaded = self.reg.loaded()
self.assertEqual(len(loaded), 1)
self.assertEqual(loaded[0].name, "a")
def test_failed_filters_by_state(self):
m1 = _make_manifest(name="a", state=AddonState.FAILED)
m2 = _make_manifest(name="b", state=AddonState.LOADED)
self.reg.register(m1)
self.reg.register(m2)
self.assertEqual(len(self.reg.failed()), 1)
self.assertEqual(self.reg.failed()[0].name, "a")
def test_skipped_filters_by_state(self):
m = _make_manifest(name="a", state=AddonState.SKIPPED)
self.reg.register(m)
self.assertEqual(len(self.reg.skipped()), 1)
def test_is_loaded(self):
m = _make_manifest(name="sdk", state=AddonState.LOADED)
self.reg.register(m)
self.assertTrue(self.reg.is_loaded("sdk"))
self.assertFalse(self.reg.is_loaded("other"))
def test_load_order_preserved(self):
m1 = _make_manifest(name="b", state=AddonState.LOADED)
m2 = _make_manifest(name="a", state=AddonState.LOADED)
self.reg.register(m1)
self.reg.register(m2)
self.reg.set_load_order(["a", "b"])
names = [m.name for m in self.reg.loaded()]
self.assertEqual(names, ["a", "b"])
def test_register_context(self):
m = _make_manifest(name="sdk")
self.reg.register(m)
self.reg.register_context("sdk", "partdesign.body")
self.assertIn("partdesign.body", self.reg.get("sdk").contexts)
def test_contexts_mapping(self):
m = _make_manifest(name="sdk")
self.reg.register(m)
self.reg.register_context("sdk", "partdesign.body")
self.reg.register_context("sdk", "sketcher.edit")
ctxs = self.reg.contexts()
self.assertIn("partdesign.body", ctxs)
self.assertEqual(ctxs["partdesign.body"], ["sdk"])
# ===================================================================
# Test: parse_manifest
# ===================================================================
class TestParseManifest(unittest.TestCase):
"""Tests for parse_manifest() XML parsing and validation."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _addon_dir(self, name="myaddon"):
d = os.path.join(self.tmpdir, name)
os.makedirs(d, exist_ok=True)
return d
def test_minimal_manifest(self):
addon = self._addon_dir()
_write_package_xml(addon, name="myaddon", version="1.0.0")
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.name, "myaddon")
self.assertEqual(m.version, "1.0.0")
self.assertEqual(m.state, AddonState.DISCOVERED)
self.assertEqual(m.errors, [])
def test_name_fallback_to_dirname(self):
addon = self._addon_dir("mymod")
_write_package_xml(
addon,
raw_xml=(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<package format="1">\n'
" <version>1.0.0</version>\n"
"</package>"
),
)
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.name, "mymod")
def test_version_defaults_to_000(self):
addon = self._addon_dir()
_write_package_xml(
addon,
raw_xml=(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<package format="1">\n'
" <name>test</name>\n"
"</package>"
),
)
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.version, "0.0.0")
def test_workbench_subdirectory(self):
addon = self._addon_dir()
os.makedirs(os.path.join(addon, "freecad"), exist_ok=True)
_write_package_xml(addon, subdirectory="freecad")
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.workbench_path, os.path.join(addon, "freecad"))
def test_workbench_dot_slash(self):
addon = self._addon_dir()
_write_package_xml(addon, subdirectory="./")
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.workbench_path, addon)
def test_kindred_versions(self):
addon = self._addon_dir()
_write_package_xml(
addon,
kindred={
"min_create_version": "0.1.0",
"max_create_version": "1.0.0",
},
)
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.min_create_version, "0.1.0")
self.assertEqual(m.max_create_version, "1.0.0")
self.assertTrue(m.has_kindred_element)
self.assertEqual(m.errors, [])
def test_kindred_load_priority(self):
addon = self._addon_dir()
_write_package_xml(addon, kindred={"load_priority": "42"})
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.load_priority, 42)
def test_invalid_priority_records_error(self):
addon = self._addon_dir()
_write_package_xml(addon, kindred={"load_priority": "abc"})
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.load_priority, 100) # unchanged default
self.assertEqual(len(m.errors), 1)
self.assertIn("load_priority", m.errors[0])
def test_kindred_dependencies(self):
addon = self._addon_dir()
_write_package_xml(addon, kindred={"dependencies": ["sdk", "solver"]})
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.dependencies, ["sdk", "solver"])
def test_valid_context_ids(self):
addon = self._addon_dir()
_write_package_xml(addon, kindred={"contexts": ["partdesign.body", "sketcher.edit"]})
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.contexts, ["partdesign.body", "sketcher.edit"])
self.assertEqual(m.errors, [])
def test_invalid_context_id_rejected(self):
addon = self._addon_dir()
_write_package_xml(addon, kindred={"contexts": ["has spaces!", "good.id"]})
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.contexts, ["good.id"])
self.assertEqual(len(m.errors), 1)
self.assertIn("has spaces!", m.errors[0])
def test_malformed_xml_fails(self):
addon = self._addon_dir()
_write_package_xml(addon, raw_xml="<<<not xml>>>")
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(m.state, AddonState.FAILED)
self.assertTrue(len(m.errors) > 0)
def test_invalid_version_format_records_error(self):
addon = self._addon_dir()
_write_package_xml(addon, kindred={"min_create_version": "v1.beta"})
m = AddonManifest(
name="",
version="",
package_xml_path=os.path.join(addon, "package.xml"),
addon_root=addon,
)
parse_manifest(m)
self.assertEqual(len(m.errors), 1)
self.assertIn("min_create_version", m.errors[0])
# ===================================================================
# Test: validate_dependencies
# ===================================================================
class TestValidateDependencies(unittest.TestCase):
"""Tests for validate_dependencies() cross-addon check."""
def test_known_dependency_no_error(self):
sdk = _make_manifest(name="sdk")
silo = _make_manifest(name="silo", dependencies=["sdk"])
validate_dependencies([sdk, silo])
self.assertEqual(silo.errors, [])
def test_unknown_dependency_adds_error(self):
m = _make_manifest(name="mymod", dependencies=["nonexistent"])
validate_dependencies([m])
self.assertEqual(len(m.errors), 1)
self.assertIn("nonexistent", m.errors[0])
def test_failed_manifest_skipped(self):
m = _make_manifest(
name="broken",
state=AddonState.FAILED,
dependencies=["nonexistent"],
)
validate_dependencies([m])
self.assertEqual(m.errors, [])
def test_multiple_deps_mixed(self):
sdk = _make_manifest(name="sdk")
m = _make_manifest(name="mymod", dependencies=["sdk", "missing"])
validate_dependencies([sdk, m])
self.assertEqual(len(m.errors), 1)
self.assertIn("missing", m.errors[0])
# ===================================================================
# Test: validate_manifest
# ===================================================================
class TestValidateManifest(unittest.TestCase):
"""Tests for validate_manifest() version and path checks."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _wb_dir(self, name="wb"):
d = os.path.join(self.tmpdir, name)
os.makedirs(d, exist_ok=True)
return d
def test_valid_with_init_py(self):
wb = self._wb_dir()
with open(os.path.join(wb, "Init.py"), "w") as f:
f.write("# test\n")
m = _make_manifest(name="test", workbench_path=wb)
result = validate_manifest(m, "0.1.5")
self.assertTrue(result)
self.assertEqual(m.state, AddonState.VALIDATED)
self.assertEqual(m.errors, [])
def test_valid_with_initgui_only(self):
wb = self._wb_dir()
with open(os.path.join(wb, "InitGui.py"), "w") as f:
f.write("# test\n")
m = _make_manifest(name="test", workbench_path=wb)
result = validate_manifest(m, "0.1.5")
self.assertTrue(result)
def test_missing_workbench_dir(self):
m = _make_manifest(name="test", workbench_path="/nonexistent/path")
result = validate_manifest(m, "0.1.5")
self.assertFalse(result)
self.assertEqual(m.state, AddonState.SKIPPED)
self.assertTrue(any("not found" in e for e in m.errors))
def test_no_init_files(self):
wb = self._wb_dir()
m = _make_manifest(name="test", workbench_path=wb)
result = validate_manifest(m, "0.1.5")
self.assertFalse(result)
self.assertTrue(any("Init.py" in e for e in m.errors))
def test_min_version_too_new(self):
wb = self._wb_dir()
with open(os.path.join(wb, "Init.py"), "w") as f:
f.write("# test\n")
m = _make_manifest(
name="test",
workbench_path=wb,
min_create_version="99.0.0",
)
result = validate_manifest(m, "0.1.5")
self.assertFalse(result)
self.assertTrue(any(">=" in e for e in m.errors))
def test_max_version_too_old(self):
wb = self._wb_dir()
with open(os.path.join(wb, "Init.py"), "w") as f:
f.write("# test\n")
m = _make_manifest(
name="test",
workbench_path=wb,
max_create_version="0.0.1",
)
result = validate_manifest(m, "0.1.5")
self.assertFalse(result)
self.assertTrue(any("<=" in e for e in m.errors))
def test_version_in_range(self):
wb = self._wb_dir()
with open(os.path.join(wb, "Init.py"), "w") as f:
f.write("# test\n")
m = _make_manifest(
name="test",
workbench_path=wb,
min_create_version="0.1.0",
max_create_version="1.0.0",
)
result = validate_manifest(m, "0.1.5")
self.assertTrue(result)
self.assertEqual(m.state, AddonState.VALIDATED)
def test_already_failed_returns_false(self):
m = _make_manifest(name="test", state=AddonState.FAILED)
result = validate_manifest(m, "0.1.5")
self.assertFalse(result)
def test_multiple_errors_accumulated(self):
m = _make_manifest(
name="test",
workbench_path="/nonexistent/path",
min_create_version="99.0.0",
)
result = validate_manifest(m, "0.1.5")
self.assertFalse(result)
self.assertGreaterEqual(len(m.errors), 2)
# ===================================================================
# Test: scan_addons
# ===================================================================
class TestScanAddons(unittest.TestCase):
"""Tests for scan_addons() directory discovery."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_depth_1_discovery(self):
addon = os.path.join(self.tmpdir, "sdk")
os.makedirs(addon)
_write_package_xml(addon, name="sdk")
manifests = scan_addons(self.tmpdir)
self.assertEqual(len(manifests), 1)
self.assertEqual(manifests[0].addon_root, addon)
def test_depth_2_discovery(self):
outer = os.path.join(self.tmpdir, "gears")
inner = os.path.join(outer, "freecad")
os.makedirs(inner)
_write_package_xml(inner, name="gears")
manifests = scan_addons(self.tmpdir)
self.assertEqual(len(manifests), 1)
self.assertEqual(manifests[0].addon_root, inner)
def test_nonexistent_mods_dir(self):
manifests = scan_addons("/nonexistent/mods/dir")
self.assertEqual(manifests, [])
def test_files_ignored(self):
# Regular file at top level should be ignored
with open(os.path.join(self.tmpdir, "README.md"), "w") as f:
f.write("# test\n")
manifests = scan_addons(self.tmpdir)
self.assertEqual(manifests, [])
def test_no_package_xml(self):
addon = os.path.join(self.tmpdir, "empty_addon")
os.makedirs(addon)
manifests = scan_addons(self.tmpdir)
self.assertEqual(manifests, [])
def test_depth_1_takes_priority(self):
"""If package.xml exists at depth 1, depth 2 is not scanned."""
addon = os.path.join(self.tmpdir, "mymod")
os.makedirs(addon)
_write_package_xml(addon, name="mymod")
# Also create a depth-2 package.xml
inner = os.path.join(addon, "subdir")
os.makedirs(inner)
_write_package_xml(inner, name="mymod-inner")
manifests = scan_addons(self.tmpdir)
# Only depth-1 should be found (continue after depth-1 match)
self.assertEqual(len(manifests), 1)
self.assertEqual(manifests[0].addon_root, addon)
# ===================================================================
# Test: resolve_load_order
# ===================================================================
class TestResolveLoadOrder(unittest.TestCase):
"""Tests for resolve_load_order() topological sort."""
def test_empty_list(self):
result = resolve_load_order([], "/fake/mods")
self.assertEqual(result, [])
def test_single_addon(self):
m = _make_manifest(name="sdk", has_kindred_element=True, state=AddonState.VALIDATED)
result = resolve_load_order([m], "/fake/mods")
self.assertEqual(len(result), 1)
self.assertEqual(result[0].name, "sdk")
def test_priority_ordering(self):
a = _make_manifest(
name="a", load_priority=40, has_kindred_element=True, state=AddonState.VALIDATED
)
b = _make_manifest(
name="b", load_priority=0, has_kindred_element=True, state=AddonState.VALIDATED
)
c = _make_manifest(
name="c", load_priority=60, has_kindred_element=True, state=AddonState.VALIDATED
)
result = resolve_load_order([a, b, c], "/fake/mods")
names = [m.name for m in result]
self.assertEqual(names, ["b", "a", "c"])
def test_dependency_before_dependent(self):
sdk = _make_manifest(
name="sdk", load_priority=100, has_kindred_element=True, state=AddonState.VALIDATED
)
silo = _make_manifest(
name="silo",
load_priority=0,
has_kindred_element=True,
dependencies=["sdk"],
state=AddonState.VALIDATED,
)
result = resolve_load_order([silo, sdk], "/fake/mods")
names = [m.name for m in result]
self.assertEqual(names, ["sdk", "silo"])
def test_alphabetical_tiebreak(self):
a = _make_manifest(
name="alpha", load_priority=50, has_kindred_element=True, state=AddonState.VALIDATED
)
b = _make_manifest(
name="beta", load_priority=50, has_kindred_element=True, state=AddonState.VALIDATED
)
result = resolve_load_order([b, a], "/fake/mods")
names = [m.name for m in result]
self.assertEqual(names, ["alpha", "beta"])
def test_skipped_excluded(self):
a = _make_manifest(name="a", has_kindred_element=True, state=AddonState.VALIDATED)
b = _make_manifest(name="b", has_kindred_element=True, state=AddonState.SKIPPED)
result = resolve_load_order([a, b], "/fake/mods")
names = [m.name for m in result]
self.assertEqual(names, ["a"])
def test_failed_excluded(self):
a = _make_manifest(name="a", has_kindred_element=True, state=AddonState.VALIDATED)
b = _make_manifest(name="b", has_kindred_element=True, state=AddonState.FAILED)
result = resolve_load_order([a, b], "/fake/mods")
names = [m.name for m in result]
self.assertEqual(names, ["a"])
def test_legacy_fallback_no_kindred(self):
"""When no manifest has <kindred>, use legacy order."""
with tempfile.TemporaryDirectory() as mods_dir:
silo_root = os.path.join(mods_dir, "silo")
alpha_root = os.path.join(mods_dir, "alpha")
os.makedirs(silo_root)
os.makedirs(alpha_root)
silo = _make_manifest(name="silo", addon_root=silo_root, state=AddonState.VALIDATED)
alpha = _make_manifest(name="alpha", addon_root=alpha_root, state=AddonState.VALIDATED)
result = resolve_load_order([alpha, silo], mods_dir)
names = [m.name for m in result]
# "silo" is in _LEGACY_ORDER so comes first
self.assertEqual(names, ["silo", "alpha"])
def test_cycle_falls_back_to_priority(self):
a = _make_manifest(
name="a",
load_priority=20,
has_kindred_element=True,
dependencies=["b"],
state=AddonState.VALIDATED,
)
b = _make_manifest(
name="b",
load_priority=10,
has_kindred_element=True,
dependencies=["a"],
state=AddonState.VALIDATED,
)
result = resolve_load_order([a, b], "/fake/mods")
names = [m.name for m in result]
# Cycle detected -> falls back to priority sort
self.assertEqual(names, ["b", "a"])
# ===================================================================
if __name__ == "__main__":
unittest.main()