From 311f72b77e2ee962dbc126abbc2e67de63471911 Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Thu, 5 Mar 2026 07:44:38 -0600 Subject: [PATCH] feat(sdk): per-document origin Python bindings (#391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- mods/sdk/kindred_sdk/__init__.py | 8 + mods/sdk/kindred_sdk/origin.py | 92 ++++++- src/Gui/SDK/bindings/kcsdk_py.cpp | 386 ++++++++++++++++++++---------- 3 files changed, 352 insertions(+), 134 deletions(-) diff --git a/mods/sdk/kindred_sdk/__init__.py b/mods/sdk/kindred_sdk/__init__.py index f972e8e040..e73f40ba58 100644 --- a/mods/sdk/kindred_sdk/__init__.py +++ b/mods/sdk/kindred_sdk/__init__.py @@ -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", diff --git a/mods/sdk/kindred_sdk/origin.py b/mods/sdk/kindred_sdk/origin.py index b3e2a2ded2..15f094bf6c 100644 --- a/mods/sdk/kindred_sdk/origin.py +++ b/mods/sdk/kindred_sdk/origin.py @@ -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 diff --git a/src/Gui/SDK/bindings/kcsdk_py.cpp b/src/Gui/SDK/bindings/kcsdk_py.cpp index 882694683c..d42b3bf41f 100644 --- a/src/Gui/SDK/bindings/kcsdk_py.cpp +++ b/src/Gui/SDK/bindings/kcsdk_py.cpp @@ -32,6 +32,7 @@ #include #include +#include #include #include @@ -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& toolbars, - py::object match) { + m.def( + "register_overlay", + [](const std::string& id, const std::vector& toolbars, py::object match) { if (!py::isinstance(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& 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(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(std::move(callback)); - SDKRegistry::TransitionGuard guard = - [held](const ContextSnapshot& from, const ContextSnapshot& to) - -> std::pair - { + SDKRegistry::TransitionGuard guard = [held]( + const ContextSnapshot& from, + const ContextSnapshot& to + ) -> std::pair { 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(result)) { auto tup = result.cast(); bool allowed = tup[0].cast(); - std::string reason = tup.size() > 1 - ? tup[1].cast() : ""; + std::string reason = tup.size() > 1 ? tup[1].cast() : ""; return {allowed, reason}; } return {result.cast(), ""}; @@ -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& segments, const std::vector& colors) { @@ -317,17 +331,20 @@ PYBIND11_MODULE(kcsdk, m) }, py::arg("context_id"), py::arg("segments"), - py::arg("colors") = std::vector{}, + py::arg("colors") = std::vector {}, "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(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(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(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{}; + return mgr ? mgr->originIds() : std::vector {}; }, - "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." + ); }