feat(sdk): per-document origin Python bindings (#391)
All checks were successful
Build and Test / build (pull_request) Successful in 30m22s

Expose the existing C++ per-document origin tracking through the kcsdk
pybind11 module and kindred_sdk Python package.

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

New kindred_sdk wrappers (accept App.Document object):
- document_origin(doc)
- set_document_origin(doc, origin_id)
- clear_document_origin(doc)
- find_owning_origin(doc)
This commit is contained in:
2026-03-05 07:44:38 -06:00
parent 04f9e05c41
commit 311f72b77e
3 changed files with 352 additions and 134 deletions

View File

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

View File

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

View File

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