Compare commits

..

1 Commits

Author SHA1 Message Date
forbes
f6fa2f0d2c fix(editing-context): trust parentId chain without re-checking ancestor match()
Parent match() functions are designed for flat priority resolution and
may not return true when a more specific child is active (e.g.
assembly.idle checks getActivePartObject() which may not return the
assembly during edit mode). The parentId declaration is the author's
assertion of structural containment — the leaf already matched, so
we walk the full chain unconditionally.
2026-03-04 16:16:05 -06:00
14 changed files with 230 additions and 1467 deletions

View File

@@ -162,14 +162,7 @@ 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 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
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)`.
Current load order: **sdk** (0) → **solver** (10) → **gears** (40) → **datums** (45) → **silo** (60).
@@ -193,7 +186,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. Per-document origin associations are managed via `kindred_sdk.document_origin()`, `set_document_origin()`, `clear_document_origin()`, and `find_owning_origin()`.
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.
## Submodules
@@ -225,7 +218,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()`, `document_origin()`, `set_document_origin()`, `clear_document_origin()`, `find_owning_origin()`
- **Origins:** `register_origin()`, `unregister_origin()`, `list_origins()`, `active_origin()`
- **Dock panels:** `register_dock_panel(object_name, title, factory, area, delay_ms)`
- **Commands:** `register_command(cmd_id, classname, pixmap, tooltip)`
- **Theme:** `get_theme_tokens()`, `load_palette()` (Catppuccin Mocha YAML palette)

View File

@@ -7,9 +7,8 @@ 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 types/formats
├─ validate_dependencies() — cross-check deps against discovered addons
├─ validate_manifest() — check version bounds, paths (errors accumulated)
├─ parse_manifest() — extract <kindred> extensions
├─ validate_manifest() — check min/max_create_version
├─ resolve_load_order() — topological sort by <dependency>
└─ for each addon in order:
├─ add addon dir to sys.path
@@ -61,12 +60,11 @@ 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); 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
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
Current load order: **sdk** (0) → **solver** (10) → **gears** (40) → **datums** (45) → **silo** (60)

View File

@@ -28,7 +28,6 @@
- [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)
# Silo Server

View File

@@ -8,15 +8,15 @@ The `<kindred>` element is ignored by FreeCAD's AddonManager and stock module lo
| Field | Parsed by loader | Required | Default | Description |
|---|---|---|---|---|
| `min_create_version` | Yes | No | *(none)* | Minimum Kindred Create version. Addon is skipped if the running version is lower. 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. |
| `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. |
| `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` | 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_.]*`. |
| `contexts` | No | No | *(none)* | Editing contexts this addon registers or injects into. Informational. |
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.
Fields marked "Parsed by loader" are read by `addon_loader.py` and affect load behavior. 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). 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.
`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.
### 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. 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.
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.
### Contexts
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:
The `<contexts>` element documents which editing contexts the addon interacts with. The `action` attribute describes the type of interaction:
| Action | Meaning |
|---|---|

View File

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

View File

@@ -54,8 +54,6 @@ Every addon needs a `package.xml` with a `<kindred>` extension block. The `<work
| 50-99 | Standard addons (silo) |
| 100+ | Optional/user addons |
The loader validates manifests at parse time: `load_priority` must be a valid integer, version strings must be dotted-numeric (e.g. `0.1.5`), context IDs must be alphanumeric with dots/underscores, and dependency names are cross-checked against all discovered addons. All errors are accumulated and reported together.
See [Package.xml Schema Extensions](./package-xml-schema.md) for the full schema.
## Step 2: Console bootstrap (Init.py)

View File

@@ -30,15 +30,15 @@ Runs immediately at application startup, before any GUI is available.
| datums | 45 | Unified datum creator |
| silo | 60 | PLM workbench |
Pipeline steps:
For each addon:
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`
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`
Failures are logged to `FreeCAD.Console` and do not prevent other addons from loading.
@@ -115,11 +115,6 @@ FreeCAD startup
|
Init.py (exec'd, immediate)
+-- addon_loader.load_addons(gui=False)
| +-- scan_addons() (discover manifests)
| +-- parse_manifest() (extract + validate types/formats)
| +-- validate_dependencies() (cross-check dep names)
| +-- validate_manifest() (version bounds, paths, accumulate errors)
| +-- resolve_load_order() (topo sort, skip errored addons)
| +-- sdk/Init.py
| +-- solver/Init.py
| +-- gears/Init.py

View File

@@ -23,14 +23,10 @@ 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 (
@@ -53,14 +49,11 @@ __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",
@@ -86,7 +79,6 @@ __all__ = [
"remove_breadcrumb_injection",
"remove_transition_guard",
"set_active_origin",
"set_document_origin",
"unregister_context",
"unregister_origin",
"unregister_overlay",

View File

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

View File

@@ -660,7 +660,13 @@ QString EditingContextResolver::expandLabel(const ContextDefinition& def) const
void EditingContextResolver::buildStack(EditingContext& ctx,
const ContextDefinition& leaf) const
{
// Walk from leaf up through parentId chain, verifying each ancestor matches
// Walk from leaf up through parentId chain.
// We trust the parentId declarations — the leaf already matched, and the
// hierarchy is the addon author's assertion of structural containment.
// Parent match() functions are designed for flat priority resolution and
// may not return true when a more specific child is active (e.g.
// assembly.idle won't match during assembly.edit because the "part"
// active object changes during edit mode).
QStringList reverseStack;
reverseStack.append(leaf.id);
@@ -675,15 +681,8 @@ void EditingContextResolver::buildStack(EditingContext& ctx,
reverseStack.last().toUtf8().constData());
break;
}
// Only include parent if it matches current state
if (parentDef->match && parentDef->match()) {
reverseStack.append(parentDef->id);
currentParent = parentDef->parentId;
}
else {
// Parent doesn't match — stop climbing (partial stack)
break;
}
reverseStack.append(parentDef->id);
currentParent = parentDef->parentId;
}
// Reverse to get root-to-leaf order

View File

@@ -32,7 +32,6 @@
#include <Gui/SDK/ThemeEngine.h>
#include <Gui/SDK/Types.h>
#include <App/Application.h>
#include <Gui/FileOrigin.h>
#include <Gui/OriginManager.h>
@@ -111,16 +110,13 @@ 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,
@@ -157,19 +153,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");
}
@@ -182,18 +178,16 @@ 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) {
@@ -202,11 +196,9 @@ 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()) {
@@ -214,17 +206,15 @@ 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;
@@ -241,33 +231,30 @@ 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()) {
@@ -275,26 +262,27 @@ 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>(), ""};
@@ -310,20 +298,18 @@ 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) {
@@ -331,20 +317,17 @@ 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 --------------------------------------------------------------
@@ -372,8 +355,7 @@ 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));
@@ -383,34 +365,33 @@ 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 -----------------------------------------------
@@ -421,8 +402,7 @@ 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));
@@ -432,21 +412,20 @@ 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 --------------------------------------------------
@@ -457,8 +436,7 @@ 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));
@@ -469,26 +447,24 @@ 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()) {
@@ -499,11 +475,9 @@ 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()) {
@@ -513,11 +487,9 @@ 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()) {
@@ -529,29 +501,25 @@ 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) {
@@ -563,11 +531,9 @@ 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) {
@@ -576,11 +542,9 @@ 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) {
@@ -593,91 +557,5 @@ PYBIND11_MODULE(kcsdk, m)
return originToDict(origin);
},
py::arg("id"),
"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."
);
"Get origin info by ID as a dict, or None if not found.");
}

View File

@@ -6,7 +6,6 @@
import enum
import os
import re
import sys
import time
import xml.etree.ElementTree as ET
@@ -51,14 +50,12 @@ class AddonManifest:
# Runtime state
state: AddonState = AddonState.DISCOVERED
errors: list[str] = field(default_factory=list)
error: str = ""
load_time_ms: float = 0.0
contexts: list[str] = field(default_factory=list)
def __repr__(self):
return (
f"AddonManifest(name={self.name!r}, version={self.version!r}, state={self.state.value})"
)
return f"AddonManifest(name={self.name!r}, version={self.version!r}, state={self.state.value})"
# ---------------------------------------------------------------------------
@@ -194,16 +191,6 @@ 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"
@@ -237,8 +224,10 @@ def parse_manifest(manifest: AddonManifest):
root = tree.getroot()
except ET.ParseError as e:
manifest.state = AddonState.FAILED
manifest.errors.append(f"XML parse error: {e}")
FreeCAD.Console.PrintWarning(f"Create: Failed to parse {manifest.package_xml_path}: {e}\n")
manifest.error = f"XML parse error: {e}"
FreeCAD.Console.PrintWarning(
f"Create: Failed to parse {manifest.package_xml_path}: {e}\n"
)
return
# Standard fields
@@ -270,43 +259,22 @@ 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:
manifest.errors.append(f"load_priority must be an integer, got: {priority_str!r}")
pass
deps = _find(kindred, "dependencies")
if deps is not None:
for dep in _findall(deps, "dependency"):
if dep.text and dep.text.strip():
manifest.dependencies.append(dep.text.strip())
# Validate context ID syntax
ctxs = _find(kindred, "contexts")
if ctxs is not None:
for ctx in _findall(ctxs, "context"):
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"
)
if ctx.text and ctx.text.strip():
manifest.contexts.append(ctx.text.strip())
FreeCAD.Console.PrintLog(
f"Create: Parsed {manifest.name} v{manifest.version} from {manifest.package_xml_path}\n"
@@ -326,27 +294,8 @@ 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.
All checks run to completion so that every problem is reported.
Returns True if the manifest is valid for loading.
"""
"""Check version compatibility and path existence. Returns True if valid."""
if manifest.state == AddonState.FAILED:
return False
@@ -354,29 +303,39 @@ def validate_manifest(manifest: AddonManifest, create_version: str) -> bool:
if manifest.min_create_version:
if cv < _parse_version(manifest.min_create_version):
manifest.errors.append(
f"Requires Create >= {manifest.min_create_version}, running {create_version}"
manifest.state = AddonState.SKIPPED
manifest.error = f"Requires Create >= {manifest.min_create_version}, running {create_version}"
FreeCAD.Console.PrintWarning(
f"Create: Skipping {manifest.name}: {manifest.error}\n"
)
return False
if manifest.max_create_version:
if cv > _parse_version(manifest.max_create_version):
manifest.errors.append(
f"Requires Create <= {manifest.max_create_version}, running {create_version}"
manifest.state = AddonState.SKIPPED
manifest.error = f"Requires Create <= {manifest.max_create_version}, running {create_version}"
FreeCAD.Console.PrintWarning(
f"Create: Skipping {manifest.name}: {manifest.error}\n"
)
return False
if not os.path.isdir(manifest.workbench_path):
manifest.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}")
if manifest.errors:
manifest.state = AddonState.SKIPPED
for err in manifest.errors:
FreeCAD.Console.PrintWarning(f"Create: {manifest.name}: {err}\n")
manifest.error = f"Workbench path not found: {manifest.workbench_path}"
FreeCAD.Console.PrintWarning(
f"Create: Skipping {manifest.name}: {manifest.error}\n"
)
return False
# At least one of Init.py or InitGui.py must exist
has_init = os.path.isfile(os.path.join(manifest.workbench_path, "Init.py"))
has_gui = os.path.isfile(os.path.join(manifest.workbench_path, "InitGui.py"))
if not has_init and not has_gui:
manifest.state = AddonState.SKIPPED
manifest.error = f"No Init.py or InitGui.py in {manifest.workbench_path}"
FreeCAD.Console.PrintWarning(
f"Create: Skipping {manifest.name}: {manifest.error}\n"
)
return False
manifest.state = AddonState.VALIDATED
@@ -388,7 +347,9 @@ 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
@@ -409,10 +370,15 @@ def resolve_load_order(manifests: list[AddonManifest], mods_dir: str) -> list[Ad
ts = TopologicalSorter()
for m in manifests:
if m.state in (AddonState.SKIPPED, AddonState.FAILED):
continue
# Only include dependencies that are actually discovered
known_deps = [d for d in m.dependencies if d in by_name]
ts.add(m.name, *known_deps)
unknown_deps = [d for d in m.dependencies if d not in by_name]
for dep in unknown_deps:
m.state = AddonState.SKIPPED
m.error = f"Missing dependency: {dep}"
FreeCAD.Console.PrintWarning(f"Create: Skipping {m.name}: {m.error}\n")
if m.state != AddonState.SKIPPED:
ts.add(m.name, *known_deps)
try:
# Process level by level so we can sort within each topological level
@@ -421,7 +387,11 @@ def resolve_load_order(manifests: list[AddonManifest], mods_dir: str) -> list[Ad
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)
@@ -430,7 +400,7 @@ def resolve_load_order(manifests: list[AddonManifest], mods_dir: str) -> list[Ad
f"Create: Dependency cycle detected: {e}. Falling back to priority order.\n"
)
return sorted(
[m for m in manifests if m.state not in (AddonState.SKIPPED, AddonState.FAILED)],
[m for m in manifests if m.state != AddonState.SKIPPED],
key=lambda m: (m.load_priority, m.name),
)
@@ -438,7 +408,7 @@ def resolve_load_order(manifests: list[AddonManifest], mods_dir: str) -> list[Ad
result = []
for name in order:
m = by_name.get(name)
if m is not None and m.state not in (AddonState.SKIPPED, AddonState.FAILED):
if m is not None and m.state != AddonState.SKIPPED:
result.append(m)
return result
@@ -495,10 +465,12 @@ 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.errors.append(str(e))
manifest.error = str(e)
FreeCAD.Console.PrintWarning(f"Create: Failed to load {manifest.name}: {e}\n")
@@ -528,8 +500,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.errors:
line += f" ({'; '.join(m.errors)})"
if m.error:
line += f" ({m.error})"
lines.append(line)
FreeCAD.Console.PrintLog("\n".join(lines) + "\n")
@@ -555,8 +527,6 @@ def load_addons(gui: bool = False):
for m in manifests:
parse_manifest(m)
validate_dependencies(manifests)
create_version = _get_create_version()
validated = [m for m in manifests if validate_manifest(m, create_version)]
ordered = resolve_load_order(validated, mods_dir)

View File

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

View File

@@ -1,843 +0,0 @@
"""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()