docs(c++): FileOriginPython bridge — Python-to-C++ origin adapter

Document the FileOriginPython bridge: registration via
FreeCADGui.addOrigin()/removeOrigin(), method dispatch pattern with GIL
management, callStringMethod/callBoolMethod/callMethod helpers, document
argument marshalling (App::Document* <-> App.Document), enum mapping
tables for OriginType and ConnectionState, required vs optional Python
interface methods with defaults, error handling (never propagates Python
exceptions into C++), icon resolution via BitmapFactory, and lifetime/
ownership model between Py::Object, _instances vector, and
OriginManager unique_ptr.

Closes #135
This commit is contained in:
2026-02-10 08:02:02 -06:00
parent 633bef6b18
commit c9f6f9e02b
2 changed files with 258 additions and 0 deletions

View File

@@ -52,3 +52,4 @@
- [FileOrigin Interface](./reference/cpp-file-origin.md)
- [OriginManager](./reference/cpp-origin-manager.md)
- [CommandOrigin](./reference/cpp-command-origin.md)
- [FileOriginPython Bridge](./reference/cpp-file-origin-python.md)

View File

@@ -0,0 +1,257 @@
# FileOriginPython — Python-to-C++ Bridge
> **Header:** `src/Gui/FileOriginPython.h`
> **Implementation:** `src/Gui/FileOriginPython.cpp`
> **Python binding:** `src/Gui/ApplicationPy.cpp`
> **Namespace:** `Gui`
`FileOriginPython` is an Adapter that wraps a Python object and presents
it as a C++ [FileOrigin](./cpp-file-origin.md). This is how Python
addons (like SiloOrigin) integrate with the C++
[OriginManager](./cpp-origin-manager.md) without compiling any C++ code.
## Registration from Python
The bridge is exposed to Python through three `FreeCADGui` module
functions:
```python
import FreeCADGui
FreeCADGui.addOrigin(obj) # register a Python origin
FreeCADGui.removeOrigin(obj) # unregister it
FreeCADGui.getOrigin(obj) # look up the wrapper (or None)
```
### `FreeCADGui.addOrigin(obj)`
1. Checks that `obj` is not already registered (duplicate check via
`findOrigin`).
2. Constructs a `FileOriginPython` wrapper around `obj`.
3. Calls `obj.id()` immediately and caches the result — the ID must be
non-empty or registration fails.
4. Passes the wrapper to
`OriginManager::instance()->registerOrigin(wrapper)`.
5. If `registerOrigin` fails (e.g., duplicate ID), the wrapper is removed
from the internal instances list (OriginManager already deleted it).
### `FreeCADGui.removeOrigin(obj)`
1. Finds the wrapper via `findOrigin(obj)`.
2. Removes it from the internal `_instances` vector.
3. Calls `OriginManager::instance()->unregisterOrigin(id)`, which deletes
the wrapper.
### Typical Usage (from SiloOrigin)
```python
# mods/silo/freecad/silo_origin.py
class SiloOrigin:
def id(self): return "silo"
def name(self): return "Kindred Silo"
# ... remaining interface methods ...
_silo_origin = SiloOrigin()
def register_silo_origin():
FreeCADGui.addOrigin(_silo_origin)
def unregister_silo_origin():
FreeCADGui.removeOrigin(_silo_origin)
```
This is called from `InitGui.py` via a deferred `QTimer.singleShot`
(1500 ms after GUI init) to ensure the Gui module is fully loaded.
## Method Dispatch
Every C++ `FileOrigin` virtual method is implemented by delegating to the
corresponding Python method on the wrapped object. Three internal
helpers handle the marshalling:
| Helper | Signature | Used For |
|--------|-----------|----------|
| `callStringMethod` | `(name, default) → std::string` | `id()`, `name()`, `nickname()`, `icon()` |
| `callBoolMethod` | `(name, default) → bool` | All `supportsXxx()`, `tracksExternally()`, etc. |
| `callMethod` | `(name, args) → Py::Object` | Everything else (document ops, state queries) |
### Dispatch Pattern
Each overridden method follows the same structure:
```
1. Acquire GIL ← Base::PyGILStateLocker lock
2. Check if Python object has attr ← _inst.hasAttr("methodName")
3. If missing → return default ← graceful degradation
4. Build Py::Tuple args ← marshal C++ args to Python
5. Call Python method ← func.apply(args)
6. Convert result ← Py::String/Boolean/Long → C++ type
7. Return ← release GIL on scope exit
```
If the Python method is missing, the bridge returns sensible defaults
(empty string, `false`, `nullptr`). If the Python method raises an
exception, it is caught, reported to the FreeCAD console via
`Base::PyException::reportException()`, and the default is returned.
### Document Argument Marshalling
Methods that take `App::Document*` convert it to a Python object using:
```cpp
Py::Object FileOriginPython::getDocPyObject(App::Document* doc) const
{
if (!doc) return Py::None();
return Py::asObject(doc->getPyObject());
}
```
This returns the standard `App.Document` Python wrapper so the Python
origin can call `doc.Objects`, `doc.FileName`, `doc.save()`, etc.
Methods that **return** an `App::Document*` (like `newDocument`,
`openDocument`) check the return value with:
```cpp
if (PyObject_TypeCheck(result.ptr(), &(App::DocumentPy::Type))) {
return static_cast<App::DocumentPy*>(result.ptr())->getDocumentPtr();
}
```
If the Python method returns `None` or a non-Document object, the bridge
returns `nullptr`.
## GIL Management
Every method acquires the GIL (`Base::PyGILStateLocker`) before touching
any Python objects. This is critical because the C++ origin methods can
be called from:
- The main Qt event loop (menu clicks, toolbar actions)
- `OriginManager::findOwningOrigin()` during document operations
- Signal handlers (fastsignals callbacks)
All of these may run on the main thread while the GIL is not held.
## Enum Mapping
Python origins return integers for enum values:
### `OriginType` (from `type()`)
| Python `int` | C++ Enum |
|-------------|----------|
| `0` | `OriginType::Local` |
| `1` | `OriginType::PLM` |
| `2` | `OriginType::Cloud` |
| `3` | `OriginType::Custom` |
### `ConnectionState` (from `connectionState()`)
| Python `int` | C++ Enum |
|-------------|----------|
| `0` | `ConnectionState::Disconnected` |
| `1` | `ConnectionState::Connecting` |
| `2` | `ConnectionState::Connected` |
| `3` | `ConnectionState::Error` |
Out-of-range values fall back to `OriginType::Custom` and
`ConnectionState::Connected` respectively.
## Icon Resolution
The `icon()` override calls `callStringMethod("icon")` to get a string
name, then passes it to `BitmapFactory().iconFromTheme(name)`. The
Python origin returns an icon name (e.g., `"silo"`) rather than a QIcon
object.
## Required Python Interface
Methods the Python object **must** implement (called unconditionally):
| Method | Signature | Purpose |
|--------|-----------|---------|
| `id()` | `→ str` | Unique origin ID (cached on registration) |
| `name()` | `→ str` | Display name |
| `nickname()` | `→ str` | Short toolbar label |
| `icon()` | `→ str` | Icon name for `BitmapFactory` |
| `type()` | `→ int` | `OriginType` enum value |
| `tracksExternally()` | `→ bool` | Whether origin syncs externally |
| `requiresAuthentication()` | `→ bool` | Whether login is needed |
| `ownsDocument(doc)` | `→ bool` | Ownership detection |
| `documentIdentity(doc)` | `→ str` | Immutable tracking key |
| `documentDisplayId(doc)` | `→ str` | Human-readable ID |
Methods the Python object **may** implement (checked with `hasAttr`):
| Method | Signature | Default |
|--------|-----------|---------|
| `supportsRevisions()` | `→ bool` | `False` |
| `supportsBOM()` | `→ bool` | `False` |
| `supportsPartNumbers()` | `→ bool` | `False` |
| `supportsAssemblies()` | `→ bool` | `False` |
| `connectionState()` | `→ int` | `2` (Connected) |
| `connect()` | `→ bool` | `True` |
| `disconnect()` | `→ None` | no-op |
| `syncProperties(doc)` | `→ bool` | `True` |
| `newDocument(name)` | `→ Document` | `None` |
| `openDocument(identity)` | `→ Document` | `None` |
| `openDocumentInteractive()` | `→ Document` | `None` |
| `saveDocument(doc)` | `→ bool` | `False` |
| `saveDocumentAs(doc, id)` | `→ bool` | `False` |
| `saveDocumentAsInteractive(doc)` | `→ bool` | `False` |
| `commitDocument(doc)` | `→ bool` | `False` |
| `pullDocument(doc)` | `→ bool` | `False` |
| `pushDocument(doc)` | `→ bool` | `False` |
| `showInfo(doc)` | `→ None` | no-op |
| `showBOM(doc)` | `→ None` | no-op |
## Error Handling
All Python exceptions are caught and reported to the FreeCAD console.
The bridge **never** propagates a Python exception into C++ — callers
always receive a safe default value.
```
[Python exception in SiloOrigin.ownsDocument]
→ Base::PyException::reportException()
→ FreeCAD Console: "Python error: ..."
→ return false
```
This prevents a buggy Python origin from crashing the application.
## Lifetime and Ownership
```
FreeCADGui.addOrigin(py_obj)
├─ FileOriginPython(py_obj) ← wraps, holds Py::Object ref
│ └─ _inst = py_obj ← prevents Python GC
├─ _instances.push_back(wrapper) ← static vector for findOrigin()
└─ OriginManager::registerOrigin(wrapper)
└─ unique_ptr<FileOrigin> ← OriginManager owns the wrapper
```
- `FileOriginPython` holds a `Py::Object` reference to the Python
instance, preventing garbage collection.
- `OriginManager` owns the wrapper via `std::unique_ptr`.
- The static `_instances` vector provides `findOrigin()` lookup but does
**not** own the pointers.
- On `removeOrigin()`: the wrapper is removed from `_instances`, then
`OriginManager::unregisterOrigin()` deletes the wrapper, which releases
the `Py::Object` reference.
## See Also
- [FileOrigin Interface](./cpp-file-origin.md) — the abstract interface
being adapted
- [OriginManager](./cpp-origin-manager.md) — where the wrapped origin
gets registered
- [CommandOrigin](./cpp-command-origin.md) — commands that dispatch
through this bridge
- [Creating a Custom Origin (Python)](../guide/custom-origin-python.md)
— step-by-step guide using this bridge