Compare commits
1 Commits
main
...
feat/hiera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6fa2f0d2c |
13
CLAUDE.md
13
CLAUDE.md
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user