3 Adding Python Bindings
John McCardle edited this page 2026-02-07 23:47:56 +00:00

Adding Python Bindings

Step-by-step guide for exposing C++ functionality to Python in McRogueFace.

Prerequisites

  • Understanding of Python-Binding-Layer system architecture
  • Familiarity with C++ class or function to expose
  • McRogueFace builds from project root with make

Quick Reference

Related Systems: Python-Binding-Layer, UI-Component-Hierarchy

Key Files:

  • src/McRFPy_API.cpp - Module definition and type registration
  • src/McRFPy_Doc.h - Documentation macros (MCRF_METHOD, MCRF_PROPERTY, etc.)
  • src/PyObjectUtils.h - Helper utilities
  • Individual src/Py*.cpp files - Type bindings

Workflow 1: Adding a Property to Existing Class

Step 1: Add to PyGetSetDef Array

Find the class's getsetters array:

PyGetSetDef UISprite::getsetters[] = {
    // Existing properties...
    
    // Add new property with doc macro
    {"rotation", (getter)UISprite::get_rotation, (setter)UISprite::set_rotation,
     MCRF_PROPERTY(rotation,
         "Sprite rotation angle in degrees. Range: 0-360."
     ), NULL},
    
    {NULL}  // Sentinel - always last!
};

Step 2: Implement Getter Function

PyObject* UISprite::get_rotation(PyUISpriteObject* self, void* closure) {
    if (!self->data) {
        PyErr_SetString(PyExc_RuntimeError, "UISprite data is null");
        return NULL;
    }
    return PyFloat_FromDouble(self->data->rotation);
}

Step 3: Implement Setter Function

int UISprite::set_rotation(PyUISpriteObject* self, PyObject* value, void* closure) {
    if (!self->data) {
        PyErr_SetString(PyExc_RuntimeError, "UISprite data is null");
        return -1;
    }
    if (!PyFloat_Check(value) && !PyLong_Check(value)) {
        PyErr_SetString(PyExc_TypeError, "rotation must be a number");
        return -1;
    }
    double rotation = PyFloat_AsDouble(value);
    if (rotation < 0 || rotation > 360) {
        PyErr_SetString(PyExc_ValueError, "rotation must be 0-360");
        return -1;
    }
    self->data->rotation = rotation;
    return 0;
}

Step 4: Rebuild and Test

# From project root
make clean && make

cd build
./mcrogueface --headless -c "
import mcrfpy, sys
s = mcrfpy.Sprite(x=0, y=0, sprite_index=0)
s.rotation = 45
assert s.rotation == 45
print('PASS')
sys.exit(0)
"

Workflow 2: Adding a Method to Existing Class

Step 1: Add to PyMethodDef Array

PyMethodDef UIGrid::methods[] = {
    // Existing methods...
    
    {"fill_walkable", (PyCFunction)UIGrid::fill_walkable, METH_VARARGS | METH_KEYWORDS,
     MCRF_METHOD(Grid, fill_walkable,
         MCRF_SIG("(walkable: bool)", "None"),
         MCRF_DESC("Set walkable property for all cells."),
         MCRF_ARGS_START
         MCRF_ARG("walkable", "Whether cells should be walkable")
     )},
    
    {NULL}  // Sentinel
};

Step 2: Implement Method

PyObject* UIGrid::fill_walkable(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
    if (!self->data) {
        PyErr_SetString(PyExc_RuntimeError, "Grid data is null");
        return NULL;
    }
    
    int walkable;
    static char* kwlist[] = {"walkable", NULL};
    
    if (!PyArg_ParseTupleAndKeywords(args, kwds, "p", kwlist, &walkable)) {
        return NULL;
    }
    
    for (int x = 0; x < self->data->grid_x; x++) {
        for (int y = 0; y < self->data->grid_y; y++) {
            self->data->at(x, y).walkable = walkable;
        }
    }
    
    Py_RETURN_NONE;
}

Step 3: Test

import mcrfpy
import sys

grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160))
grid.fill_walkable(True)
assert grid.at(5, 5).walkable == True
print("PASS")
sys.exit(0)

Workflow 3: Creating New Python Type

Step 1: Define Type Structure

In new header file (e.g., src/PyMyType.h):

typedef struct {
    PyObject_HEAD
    std::shared_ptr<MyType> data;
} PyMyTypeObject;

namespace mcrfpydef {
    static PyTypeObject PyMyTypeType = {
        .tp_name = "mcrfpy.MyType",
        .tp_basicsize = sizeof(PyMyTypeObject),
        // ... filled in during init
    };
}

class PyMyType {
public:
    static PyGetSetDef getsetters[];
    static PyMethodDef methods[];
    
    static int init(PyMyTypeObject* self, PyObject* args, PyObject* kwds);
    static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
    static void dealloc(PyMyTypeObject* self);
};

Step 2: Implement (in src/PyMyType.cpp)

#include "PyMyType.h"
#include "McRFPy_Doc.h"

int PyMyType::init(PyMyTypeObject* self, PyObject* args, PyObject* kwds) {
    // Parse arguments, initialize self->data
    self->data = std::make_shared<MyType>();
    return 0;
}

PyObject* PyMyType::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) {
    auto* self = (PyMyTypeObject*)type->tp_alloc(type, 0);
    if (self) self->data = nullptr;
    return (PyObject*)self;
}

void PyMyType::dealloc(PyMyTypeObject* self) {
    self->data.reset();
    Py_TYPE(self)->tp_free((PyObject*)self);
}

PyGetSetDef PyMyType::getsetters[] = {
    {"name", (getter)get_name, (setter)set_name,
     MCRF_PROPERTY(name, "Object name."), NULL},
    {NULL}
};

PyMethodDef PyMyType::methods[] = {
    {NULL}
};

Step 3: Register in Module

In src/McRFPy_API.cpp:

#include "PyMyType.h"

// In the exported_types or internal_types array:
static PyTypeObject* exported_types[] = {
    // ... existing types ...
    &mcrfpydef::PyMyTypeType,
};

// Before PyType_Ready calls:
mcrfpydef::PyMyTypeType.tp_methods = PyMyType::methods;
mcrfpydef::PyMyTypeType.tp_getset = PyMyType::getsetters;
mcrfpydef::PyMyTypeType.tp_init = (initproc)PyMyType::init;
mcrfpydef::PyMyTypeType.tp_new = PyMyType::pynew;
mcrfpydef::PyMyTypeType.tp_dealloc = (destructor)PyMyType::dealloc;

CMake auto-discovers new .cpp files via GLOB_RECURSE, so no CMakeLists.txt changes needed.


Documentation Macros

Use macros from src/McRFPy_Doc.h for all Python-facing documentation:

Macro Purpose
MCRF_METHOD(cls, name, ...) Method docstring
MCRF_PROPERTY(name, desc) Property docstring
MCRF_SIG(params, ret) Method signature
MCRF_DESC(text) Description paragraph
MCRF_ARGS_START Begin arguments section
MCRF_ARG(name, desc) Individual argument
MCRF_RETURNS(text) Return value description
MCRF_RAISES(exc, cond) Exception documentation
MCRF_NOTE(text) Important notes
MCRF_LINK(path, text) External documentation link

Testing Your Bindings

Direct Execution Test

import mcrfpy
import sys

# Test property
s = mcrfpy.Sprite(x=0, y=0, sprite_index=0)
s.rotation = 45
assert s.rotation == 45

# Test method
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(160, 160))
grid.fill_walkable(True)
assert grid.at(5, 5).walkable == True

print("PASS")
sys.exit(0)

Run

cd build
./mcrogueface --headless --exec ../tests/unit/my_binding_test.py

Regenerating Documentation

After adding bindings:

# From project root
make

# Generate all docs (recommended)
./tools/generate_all_docs.sh

# Or individually:
cd build
./mcrogueface --headless --exec ../tools/generate_dynamic_docs.py
./mcrogueface --headless --exec ../tools/generate_stubs_v2.py

Common Pitfalls

Pitfall 1: Forgetting NULL Sentinel

// WRONG - missing sentinel causes crash
PyGetSetDef getsetters[] = {
    {"x", get_x, set_x, "X position", NULL}
};

// CORRECT
PyGetSetDef getsetters[] = {
    {"x", get_x, set_x, "X position", NULL},
    {NULL}  // Must have this!
};

Pitfall 2: Type Preservation in Collections

When returning from collections, use RET_PY_INSTANCE:

// WRONG - returns base UIDrawable wrapper
return generic_wrap(item->data);

// CORRECT - preserves derived type (Frame, Caption, etc.)
RET_PY_INSTANCE(item->data);

Pitfall 3: Missing Error Checks

// WRONG - no type check
double value = PyFloat_AsDouble(obj);

// CORRECT
if (!PyFloat_Check(obj) && !PyLong_Check(obj)) {
    PyErr_SetString(PyExc_TypeError, "Expected number");
    return NULL;
}
double value = PyFloat_AsDouble(obj);

Pitfall 4: Not Setting tp_methods/tp_getset Before PyType_Ready

// WRONG - setting after PyType_Ready has no effect
PyType_Ready(&MyType);
MyType.tp_methods = methods;

// CORRECT - set before PyType_Ready
MyType.tp_methods = methods;
MyType.tp_getset = getsetters;
PyType_Ready(&MyType);