Compare commits
20 Commits
feat/hiera
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c57eb6b5a2 | |||
| 9ace2e06e3 | |||
| ad48d7e853 | |||
| 3dd8d86ef1 | |||
| 0e884a03b9 | |||
| c0a3236836 | |||
| a5db5190c8 | |||
| 2f89f8cbb0 | |||
| c5881147d0 | |||
| fc2baa1afd | |||
| d656455d00 | |||
| 0d88769189 | |||
| 7381675a6e | |||
| be2f336beb | |||
| 8f27083e45 | |||
| 54afbebee3 | |||
| 0408198af0 | |||
| ce6c03ae35 | |||
| 311f72b77e | |||
| 04f9e05c41 |
13
CLAUDE.md
13
CLAUDE.md
@@ -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)
|
||||
|
||||
@@ -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
12
docs/examples/example-addon/Init.py
Normal file
12
docs/examples/example-addon/Init.py
Normal 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")
|
||||
93
docs/examples/example-addon/InitGui.py
Normal file
93
docs/examples/example-addon/InitGui.py
Normal 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)
|
||||
75
docs/examples/example-addon/README.md
Normal file
75
docs/examples/example-addon/README.md
Normal 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
|
||||
1
docs/examples/example-addon/example_addon/__init__.py
Normal file
1
docs/examples/example-addon/example_addon/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Example addon for Kindred Create."""
|
||||
29
docs/examples/example-addon/example_addon/commands.py
Normal file
29
docs/examples/example-addon/example_addon/commands.py
Normal 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")
|
||||
21
docs/examples/example-addon/example_addon/panel.py
Normal file
21
docs/examples/example-addon/example_addon/panel.py
Normal 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
|
||||
27
docs/examples/example-addon/package.xml
Normal file
27
docs/examples/example-addon/package.xml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
128
docs/src/development/testing.md
Normal file
128
docs/src/development/testing.md
Normal 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 |
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Submodule mods/silo updated: f6222a5181...c5f00219fa
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
843
tests/test_kindred_addon_loader.py
Normal file
843
tests/test_kindred_addon_loader.py
Normal 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()
|
||||
Reference in New Issue
Block a user