Update Adding Python Bindings

John McCardle 2025-12-02 03:09:30 +00:00
parent 9bcf59fe85
commit 4c86db6067
1 changed files with 434 additions and 434 deletions

@ -1,435 +1,435 @@
# Adding Python Bindings # Adding Python Bindings
Step-by-step guide for exposing C++ functionality to Python in McRogueFace. Step-by-step guide for exposing C++ functionality to Python in McRogueFace.
## Prerequisites ## Prerequisites
- Understanding of [[Python-Binding-Layer]] system architecture - Understanding of [[Python-Binding-Layer]] system architecture
- Familiarity with `PYTHON_BINDING_PATTERNS.md` (repository root) - Familiarity with `PYTHON_BINDING_PATTERNS.md` (repository root)
- C++ class or function to expose - C++ class or function to expose
## Quick Reference ## Quick Reference
**Related Systems:** [[Python-Binding-Layer]], [[UI-Component-Hierarchy]] **Related Systems:** [[Python-Binding-Layer]], [[UI-Component-Hierarchy]]
**Key Files:** **Key Files:**
- `src/McRFPy_API.cpp` - Module-level functions - `src/McRFPy_API.cpp` - Module-level functions
- `src/PyObjectUtils.h` - Helper utilities - `src/PyObjectUtils.h` - Helper utilities
- Individual `src/UI*.cpp` files - Type bindings - Individual `src/UI*.cpp` files - Type bindings
**Documentation Format:** See CLAUDE.md "Inline C++ Documentation Format" **Documentation Format:** See CLAUDE.md "Inline C++ Documentation Format"
--- ---
## Workflow 1: Adding a Property to Existing Class ## Workflow 1: Adding a Property to Existing Class
### Step 1: Add to PyGetSetDef Array ### Step 1: Add to PyGetSetDef Array
Find the class's `getsetters` array (e.g., `PyUISprite::getsetters`): Find the class's `getsetters` array (e.g., `PyUISprite::getsetters`):
```cpp ```cpp
PyGetSetDef PyUISprite::getsetters[] = { PyGetSetDef PyUISprite::getsetters[] = {
// Existing properties... // Existing properties...
// Add new property // Add new property
{"rotation", (getter)PyUISprite::get_rotation, (setter)PyUISprite::set_rotation, {"rotation", (getter)PyUISprite::get_rotation, (setter)PyUISprite::set_rotation,
"Sprite rotation angle in degrees. Range: 0-360.", NULL}, "Sprite rotation angle in degrees. Range: 0-360.", NULL},
{NULL} // Sentinel - always last! {NULL} // Sentinel - always last!
}; };
``` ```
### Step 2: Implement Getter Function ### Step 2: Implement Getter Function
```cpp ```cpp
PyObject* PyUISprite::get_rotation(PyUISprite* self, void* closure) { PyObject* PyUISprite::get_rotation(PyUISprite* self, void* closure) {
if (!self->data) { if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "UISprite data is null"); PyErr_SetString(PyExc_RuntimeError, "UISprite data is null");
return NULL; return NULL;
} }
return PyFloat_FromDouble(self->data->rotation); return PyFloat_FromDouble(self->data->rotation);
} }
``` ```
### Step 3: Implement Setter Function ### Step 3: Implement Setter Function
```cpp ```cpp
int PyUISprite::set_rotation(PyUISprite* self, PyObject* value, void* closure) { int PyUISprite::set_rotation(PyUISprite* self, PyObject* value, void* closure) {
if (!self->data) { if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "UISprite data is null"); PyErr_SetString(PyExc_RuntimeError, "UISprite data is null");
return -1; return -1;
} }
if (!PyFloat_Check(value) && !PyLong_Check(value)) { if (!PyFloat_Check(value) && !PyLong_Check(value)) {
PyErr_SetString(PyExc_TypeError, "rotation must be a number"); PyErr_SetString(PyExc_TypeError, "rotation must be a number");
return -1; return -1;
} }
double rotation = PyFloat_AsDouble(value); double rotation = PyFloat_AsDouble(value);
if (rotation < 0 || rotation > 360) { if (rotation < 0 || rotation > 360) {
PyErr_SetString(PyExc_ValueError, "rotation must be 0-360"); PyErr_SetString(PyExc_ValueError, "rotation must be 0-360");
return -1; return -1;
} }
self->data->rotation = rotation; self->data->rotation = rotation;
return 0; // Success return 0; // Success
} }
``` ```
### Step 4: Add Documentation ### Step 4: Add Documentation
Update the docstring in PyGetSetDef: Update the docstring in PyGetSetDef:
```cpp ```cpp
{"rotation", (getter)PyUISprite::get_rotation, (setter)PyUISprite::set_rotation, {"rotation", (getter)PyUISprite::get_rotation, (setter)PyUISprite::set_rotation,
"Sprite rotation angle in degrees.\n\n" "Sprite rotation angle in degrees.\n\n"
"Range: 0-360 degrees. 0 is upright, increases clockwise.\n\n" "Range: 0-360 degrees. 0 is upright, increases clockwise.\n\n"
"Example:\n" "Example:\n"
" sprite.rotation = 90 # Rotate 90 degrees clockwise\n\n" " sprite.rotation = 90 # Rotate 90 degrees clockwise\n\n"
"Note:\n" "Note:\n"
" Rotation is applied during rendering, not to position.", " Rotation is applied during rendering, not to position.",
NULL}, NULL},
``` ```
### Step 5: Rebuild and Test ### Step 5: Rebuild and Test
```bash ```bash
make clean && make make clean && make
cd build cd build
./mcrogueface --exec test_rotation.py ./mcrogueface --exec test_rotation.py
``` ```
--- ---
## Workflow 2: Adding a Method to Existing Class ## Workflow 2: Adding a Method to Existing Class
### Step 1: Add to PyMethodDef Array ### Step 1: Add to PyMethodDef Array
Find the class's `methods` array: Find the class's `methods` array:
```cpp ```cpp
PyMethodDef PyUIGrid::methods[] = { PyMethodDef PyUIGrid::methods[] = {
// Existing methods... // Existing methods...
{"fill_rect", (PyCFunction)PyUIGrid::fill_rect, METH_VARARGS | METH_KEYWORDS, {"fill_rect", (PyCFunction)PyUIGrid::fill_rect, METH_VARARGS | METH_KEYWORDS,
"fill_rect(x: int, y: int, w: int, h: int, tile: int) -> None\n\n" "fill_rect(x: int, y: int, w: int, h: int, tile: int) -> None\n\n"
"Fill rectangular area with tile index.\n\n" "Fill rectangular area with tile index.\n\n"
"Args:\n" "Args:\n"
" x: Top-left X coordinate\n" " x: Top-left X coordinate\n"
" y: Top-left Y coordinate\n" " y: Top-left Y coordinate\n"
" w: Width in tiles\n" " w: Width in tiles\n"
" h: Height in tiles\n" " h: Height in tiles\n"
" tile: Tile sprite index\n\n" " tile: Tile sprite index\n\n"
"Example:\n" "Example:\n"
" grid.fill_rect(5, 5, 10, 10, 42) # Fill 10x10 area with tile 42"}, " grid.fill_rect(5, 5, 10, 10, 42) # Fill 10x10 area with tile 42"},
{NULL} // Sentinel {NULL} // Sentinel
}; };
``` ```
### Step 2: Implement Method Function ### Step 2: Implement Method Function
```cpp ```cpp
PyObject* PyUIGrid::fill_rect(PyUIGrid* self, PyObject* args, PyObject* kwds) { PyObject* PyUIGrid::fill_rect(PyUIGrid* self, PyObject* args, PyObject* kwds) {
if (!self->data) { if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Grid data is null"); PyErr_SetString(PyExc_RuntimeError, "Grid data is null");
return NULL; return NULL;
} }
int x, y, w, h, tile; int x, y, w, h, tile;
static char* kwlist[] = {"x", "y", "w", "h", "tile", NULL}; static char* kwlist[] = {"x", "y", "w", "h", "tile", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kwds, "iiiii", kwlist, if (!PyArg_ParseTupleAndKeywords(args, kwds, "iiiii", kwlist,
&x, &y, &w, &h, &tile)) { &x, &y, &w, &h, &tile)) {
return NULL; // PyArg functions set error automatically return NULL; // PyArg functions set error automatically
} }
// Bounds checking // Bounds checking
if (x < 0 || y < 0 || x + w > self->data->grid_x || y + h > self->data->grid_y) { if (x < 0 || y < 0 || x + w > self->data->grid_x || y + h > self->data->grid_y) {
PyErr_SetString(PyExc_ValueError, "Rectangle out of grid bounds"); PyErr_SetString(PyExc_ValueError, "Rectangle out of grid bounds");
return NULL; return NULL;
} }
// Fill the rectangle // Fill the rectangle
for (int dx = 0; dx < w; dx++) { for (int dx = 0; dx < w; dx++) {
for (int dy = 0; dy < h; dy++) { for (int dy = 0; dy < h; dy++) {
self->data->at(x + dx, y + dy).tilesprite = tile; self->data->at(x + dx, y + dy).tilesprite = tile;
} }
} }
Py_RETURN_NONE; Py_RETURN_NONE;
} }
``` ```
### Step 3: Test ### Step 3: Test
```python ```python
import mcrfpy import mcrfpy
grid = mcrfpy.Grid(50, 50, 16, 16) grid = mcrfpy.Grid(50, 50, 16, 16)
grid.texture = mcrfpy.createTexture("tiles.png") grid.texture = mcrfpy.createTexture("tiles.png")
# Test new method # Test new method
grid.fill_rect(10, 10, 5, 5, 42) grid.fill_rect(10, 10, 5, 5, 42)
# Verify # Verify
assert grid.at((10, 10)).tilesprite == 42 assert grid.at((10, 10)).tilesprite == 42
print("Test passed!") print("Test passed!")
``` ```
--- ---
## Workflow 3: Creating New Python Type ## Workflow 3: Creating New Python Type
### Step 1: Define Python Type Structure ### Step 1: Define Python Type Structure
In new header file (e.g., `src/UIButton.h`): In new header file (e.g., `src/UIButton.h`):
```cpp ```cpp
// Python object wrapper // Python object wrapper
typedef struct { typedef struct {
PyObject_HEAD PyObject_HEAD
std::shared_ptr<UIButton> data; std::shared_ptr<UIButton> data;
} PyUIButtonObject; } PyUIButtonObject;
// Python type object // Python type object
class PyUIButton { class PyUIButton {
public: public:
static PyTypeObject Type; static PyTypeObject Type;
static PyGetSetDef getsetters[]; static PyGetSetDef getsetters[];
static PyMethodDef methods[]; static PyMethodDef methods[];
// Lifecycle // Lifecycle
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
static int pyinit(PyUIButtonObject* self, PyObject* args, PyObject* kwds); static int pyinit(PyUIButtonObject* self, PyObject* args, PyObject* kwds);
static void dealloc(PyUIButtonObject* self); static void dealloc(PyUIButtonObject* self);
// Properties // Properties
static PyObject* get_text(PyUIButtonObject* self, void* closure); static PyObject* get_text(PyUIButtonObject* self, void* closure);
static int set_text(PyUIButtonObject* self, PyObject* value, void* closure); static int set_text(PyUIButtonObject* self, PyObject* value, void* closure);
// ... more properties // ... more properties
}; };
``` ```
### Step 2: Implement Type Object ### Step 2: Implement Type Object
In `src/UIButton.cpp`: In `src/UIButton.cpp`:
```cpp ```cpp
PyTypeObject PyUIButton::Type = { PyTypeObject PyUIButton::Type = {
PyVarObject_HEAD_INIT(NULL, 0) PyVarObject_HEAD_INIT(NULL, 0)
.tp_name = "mcrfpy.Button", .tp_name = "mcrfpy.Button",
.tp_basicsize = sizeof(PyUIButtonObject), .tp_basicsize = sizeof(PyUIButtonObject),
.tp_dealloc = (destructor)PyUIButton::dealloc, .tp_dealloc = (destructor)PyUIButton::dealloc,
.tp_flags = Py_TPFLAGS_DEFAULT, .tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = "Button UI element with click handling", .tp_doc = "Button UI element with click handling",
.tp_methods = PyUIButton::methods, .tp_methods = PyUIButton::methods,
.tp_getset = PyUIButton::getsetters, .tp_getset = PyUIButton::getsetters,
.tp_new = PyUIButton::pynew, .tp_new = PyUIButton::pynew,
.tp_init = (initproc)PyUIButton::pyinit, .tp_init = (initproc)PyUIButton::pyinit,
}; };
``` ```
### Step 3: Register in Module ### Step 3: Register in Module
In `src/McRFPy_API.cpp::PyInit_mcrfpy()`: In `src/McRFPy_API.cpp::PyInit_mcrfpy()`:
```cpp ```cpp
// After other type registrations // After other type registrations
if (PyType_Ready(&PyUIButton::Type) < 0) { if (PyType_Ready(&PyUIButton::Type) < 0) {
return NULL; return NULL;
} }
Py_INCREF(&PyUIButton::Type); Py_INCREF(&PyUIButton::Type);
if (PyModule_AddObject(m, "Button", (PyObject*)&PyUIButton::Type) < 0) { if (PyModule_AddObject(m, "Button", (PyObject*)&PyUIButton::Type) < 0) {
Py_DECREF(&PyUIButton::Type); Py_DECREF(&PyUIButton::Type);
Py_DECREF(m); Py_DECREF(m);
return NULL; return NULL;
} }
``` ```
### Step 4: Implement Constructor ### Step 4: Implement Constructor
```cpp ```cpp
PyObject* PyUIButton::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { PyObject* PyUIButton::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) {
PyUIButtonObject* self = (PyUIButtonObject*)type->tp_alloc(type, 0); PyUIButtonObject* self = (PyUIButtonObject*)type->tp_alloc(type, 0);
if (self != NULL) { if (self != NULL) {
self->data = nullptr; // Initialize in __init__ self->data = nullptr; // Initialize in __init__
} }
return (PyObject*)self; return (PyObject*)self;
} }
int PyUIButton::pyinit(PyUIButtonObject* self, PyObject* args, PyObject* kwds) { int PyUIButton::pyinit(PyUIButtonObject* self, PyObject* args, PyObject* kwds) {
int x = 0, y = 0, w = 100, h = 30; int x = 0, y = 0, w = 100, h = 30;
const char* text = ""; const char* text = "";
static char* kwlist[] = {"x", "y", "w", "h", "text", NULL}; static char* kwlist[] = {"x", "y", "w", "h", "text", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iiiis", kwlist, if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iiiis", kwlist,
&x, &y, &w, &h, &text)) { &x, &y, &w, &h, &text)) {
return -1; return -1;
} }
self->data = std::make_shared<UIButton>(x, y, w, h, text); self->data = std::make_shared<UIButton>(x, y, w, h, text);
return 0; return 0;
} }
``` ```
--- ---
## Common Patterns & Helpers ## Common Patterns & Helpers
### PyArgHelpers for Position/Size ### PyArgHelpers for Position/Size
Use standardized helpers for tuple support: Use standardized helpers for tuple support:
```cpp ```cpp
#include "PyArgHelpers.h" #include "PyArgHelpers.h"
// Accept (x, y) tuple OR separate x, y args // Accept (x, y) tuple OR separate x, y args
int x, y; int x, y;
if (!PyArgParseTuple_IntIntHelper(args, kwds, x, y, "position", "x", "y")) { if (!PyArgParseTuple_IntIntHelper(args, kwds, x, y, "position", "x", "y")) {
return -1; return -1;
} }
``` ```
**See:** `src/PyArgHelpers.h` for complete helper reference **See:** `src/PyArgHelpers.h` for complete helper reference
### Closure Parameter Encoding ### Closure Parameter Encoding
For UIDrawable-derived types: For UIDrawable-derived types:
```cpp ```cpp
// Encode type and member index // Encode type and member index
(void*)((intptr_t)PyObjectsEnum::BUTTON << 8 | BUTTON_MEMBER_TEXT) (void*)((intptr_t)PyObjectsEnum::BUTTON << 8 | BUTTON_MEMBER_TEXT)
``` ```
**See:** `PYTHON_BINDING_PATTERNS.md` for complete encoding scheme **See:** `PYTHON_BINDING_PATTERNS.md` for complete encoding scheme
### Error Handling ### Error Handling
**Always check for NULL:** **Always check for NULL:**
```cpp ```cpp
if (!self->data) { if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Object data is null"); PyErr_SetString(PyExc_RuntimeError, "Object data is null");
return NULL; // Or -1 for setters return NULL; // Or -1 for setters
} }
``` ```
**Set descriptive errors:** **Set descriptive errors:**
```cpp ```cpp
PyErr_Format(PyExc_ValueError, "Index %d out of range (0-%d)", idx, max_idx); PyErr_Format(PyExc_ValueError, "Index %d out of range (0-%d)", idx, max_idx);
``` ```
--- ---
## Testing Your Bindings ## Testing Your Bindings
### Manual Testing ### Manual Testing
```python ```python
#!/usr/bin/env python3 #!/usr/bin/env python3
import mcrfpy import mcrfpy
# Test property # Test property
sprite = mcrfpy.Sprite("test.png", 0, 0) sprite = mcrfpy.Sprite("test.png", 0, 0)
sprite.rotation = 45 sprite.rotation = 45
assert sprite.rotation == 45 assert sprite.rotation == 45
# Test method # Test method
grid = mcrfpy.Grid(10, 10, 16, 16) grid = mcrfpy.Grid(10, 10, 16, 16)
grid.fill_rect(0, 0, 5, 5, 42) grid.fill_rect(0, 0, 5, 5, 42)
assert grid.at((0, 0)).tilesprite == 42 assert grid.at((0, 0)).tilesprite == 42
print("All tests passed!") print("All tests passed!")
``` ```
### Automated Testing ### Automated Testing
Create test in `tests/test_new_binding.py`: Create test in `tests/test_new_binding.py`:
```python ```python
import mcrfpy import mcrfpy
import sys import sys
def test_binding(): def test_binding():
# Your tests here # Your tests here
pass pass
if __name__ == "__main__": if __name__ == "__main__":
try: try:
test_binding() test_binding()
print("PASS") print("PASS")
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:
print(f"FAIL: {e}") print(f"FAIL: {e}")
sys.exit(1) sys.exit(1)
``` ```
Run: `./build/mcrogueface --headless --exec tests/test_new_binding.py` Run: `./build/mcrogueface --headless --exec tests/test_new_binding.py`
--- ---
## Common Pitfalls ## Common Pitfalls
### Pitfall 1: Forgetting NULL Sentinel ### Pitfall 1: Forgetting NULL Sentinel
```cpp ```cpp
// WRONG - missing sentinel // WRONG - missing sentinel
PyGetSetDef getsetters[] = { PyGetSetDef getsetters[] = {
{"x", get_x, set_x, "X position", NULL} {"x", get_x, set_x, "X position", NULL}
}; };
// CORRECT // CORRECT
PyGetSetDef getsetters[] = { PyGetSetDef getsetters[] = {
{"x", get_x, set_x, "X position", NULL}, {"x", get_x, set_x, "X position", NULL},
{NULL} // Must have this! {NULL} // Must have this!
}; };
``` ```
### Pitfall 2: Type Preservation in Collections ### Pitfall 2: Type Preservation in Collections
When returning from collections, use `RET_PY_INSTANCE`: When returning from collections, use `RET_PY_INSTANCE`:
```cpp ```cpp
// WRONG - loses derived type // WRONG - loses derived type
return (PyObject*)item->data.get(); return (PyObject*)item->data.get();
// CORRECT - preserves type // CORRECT - preserves type
RET_PY_INSTANCE(item->data); RET_PY_INSTANCE(item->data);
``` ```
### Pitfall 3: Missing Error Checks ### Pitfall 3: Missing Error Checks
```cpp ```cpp
// WRONG - no error check // WRONG - no error check
double value = PyFloat_AsDouble(obj); double value = PyFloat_AsDouble(obj);
// CORRECT // CORRECT
if (!PyFloat_Check(obj)) { if (!PyFloat_Check(obj)) {
PyErr_SetString(PyExc_TypeError, "Expected float"); PyErr_SetString(PyExc_TypeError, "Expected float");
return NULL; return NULL;
} }
double value = PyFloat_AsDouble(obj); double value = PyFloat_AsDouble(obj);
``` ```
--- ---
## Related Documentation ## Related Documentation
- [[Python-Binding-Layer]] - System architecture - [[Python-Binding-Layer]] - System architecture
- `PYTHON_BINDING_PATTERNS.md` - Complete pattern reference - `PYTHON_BINDING_PATTERNS.md` - Complete pattern reference
- CLAUDE.md - Inline documentation format - CLAUDE.md - Inline documentation format
- [Python C API Reference](https://docs.python.org/3/c-api/) - [Python C API Reference](https://docs.python.org/3/c-api/)
## Next Steps ## Next Steps
After adding bindings: After adding bindings:
1. Rebuild: `make clean && make` 1. Rebuild: `make clean && make`
2. Test manually 2. Test manually
3. Add automated test in `tests/` 3. Add automated test in `tests/`
4. Regenerate stub files: `./build/mcrogueface --exec tools/generate_stubs.py` 4. Regenerate stub files: `./build/mcrogueface --exec tools/generate_stubs.py`
5. Update API docs: `./build/mcrogueface --exec tools/generate_dynamic_docs.py` 5. Update API docs: `./build/mcrogueface --exec tools/generate_dynamic_docs.py`
6. Document in wiki if new system 6. Document in wiki if new system