1 Adding-Python-Bindings
John McCardle edited this page 2025-10-25 21:01:17 +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 PYTHON_BINDING_PATTERNS.md (repository root)
  • C++ class or function to expose

Quick Reference

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

Key Files:

  • src/McRFPy_API.cpp - Module-level functions
  • src/PyObjectUtils.h - Helper utilities
  • Individual src/UI*.cpp files - Type bindings

Documentation Format: See CLAUDE.md "Inline C++ Documentation Format"


Workflow 1: Adding a Property to Existing Class

Step 1: Add to PyGetSetDef Array

Find the class's getsetters array (e.g., PyUISprite::getsetters):

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

Step 2: Implement Getter Function

PyObject* PyUISprite::get_rotation(PyUISprite* 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 PyUISprite::set_rotation(PyUISprite* 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;  // Success
}

Step 4: Add Documentation

Update the docstring in PyGetSetDef:

{"rotation", (getter)PyUISprite::get_rotation, (setter)PyUISprite::set_rotation,
 "Sprite rotation angle in degrees.\n\n"
 "Range: 0-360 degrees. 0 is upright, increases clockwise.\n\n"
 "Example:\n"
 "    sprite.rotation = 90  # Rotate 90 degrees clockwise\n\n"
 "Note:\n"
 "    Rotation is applied during rendering, not to position.",
 NULL},

Step 5: Rebuild and Test

make clean && make

cd build
./mcrogueface --exec test_rotation.py

Workflow 2: Adding a Method to Existing Class

Step 1: Add to PyMethodDef Array

Find the class's methods array:

PyMethodDef PyUIGrid::methods[] = {
    // Existing methods...
    
    {"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 rectangular area with tile index.\n\n"
     "Args:\n"
     "    x: Top-left X coordinate\n"
     "    y: Top-left Y coordinate\n"
     "    w: Width in tiles\n"
     "    h: Height in tiles\n"
     "    tile: Tile sprite index\n\n"
     "Example:\n"
     "    grid.fill_rect(5, 5, 10, 10, 42)  # Fill 10x10 area with tile 42"},
    
    {NULL}  // Sentinel
};

Step 2: Implement Method Function

PyObject* PyUIGrid::fill_rect(PyUIGrid* self, PyObject* args, PyObject* kwds) {
    if (!self->data) {
        PyErr_SetString(PyExc_RuntimeError, "Grid data is null");
        return NULL;
    }
    
    int x, y, w, h, tile;
    static char* kwlist[] = {"x", "y", "w", "h", "tile", NULL};
    
    if (!PyArg_ParseTupleAndKeywords(args, kwds, "iiiii", kwlist,
                                      &x, &y, &w, &h, &tile)) {
        return NULL;  // PyArg functions set error automatically
    }
    
    // Bounds checking
    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");
        return NULL;
    }
    
    // Fill the rectangle
    for (int dx = 0; dx < w; dx++) {
        for (int dy = 0; dy < h; dy++) {
            self->data->at(x + dx, y + dy).tilesprite = tile;
        }
    }
    
    Py_RETURN_NONE;
}

Step 3: Test

import mcrfpy

grid = mcrfpy.Grid(50, 50, 16, 16)
grid.texture = mcrfpy.createTexture("tiles.png")

# Test new method
grid.fill_rect(10, 10, 5, 5, 42)

# Verify
assert grid.at((10, 10)).tilesprite == 42
print("Test passed!")

Workflow 3: Creating New Python Type

Step 1: Define Python Type Structure

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

// Python object wrapper
typedef struct {
    PyObject_HEAD
    std::shared_ptr<UIButton> data;
} PyUIButtonObject;

// Python type object
class PyUIButton {
public:
    static PyTypeObject Type;
    static PyGetSetDef getsetters[];
    static PyMethodDef methods[];
    
    // Lifecycle
    static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
    static int pyinit(PyUIButtonObject* self, PyObject* args, PyObject* kwds);
    static void dealloc(PyUIButtonObject* self);
    
    // Properties
    static PyObject* get_text(PyUIButtonObject* self, void* closure);
    static int set_text(PyUIButtonObject* self, PyObject* value, void* closure);
    // ... more properties
};

Step 2: Implement Type Object

In src/UIButton.cpp:

PyTypeObject PyUIButton::Type = {
    PyVarObject_HEAD_INIT(NULL, 0)
    .tp_name = "mcrfpy.Button",
    .tp_basicsize = sizeof(PyUIButtonObject),
    .tp_dealloc = (destructor)PyUIButton::dealloc,
    .tp_flags = Py_TPFLAGS_DEFAULT,
    .tp_doc = "Button UI element with click handling",
    .tp_methods = PyUIButton::methods,
    .tp_getset = PyUIButton::getsetters,
    .tp_new = PyUIButton::pynew,
    .tp_init = (initproc)PyUIButton::pyinit,
};

Step 3: Register in Module

In src/McRFPy_API.cpp::PyInit_mcrfpy():

// After other type registrations
if (PyType_Ready(&PyUIButton::Type) < 0) {
    return NULL;
}

Py_INCREF(&PyUIButton::Type);
if (PyModule_AddObject(m, "Button", (PyObject*)&PyUIButton::Type) < 0) {
    Py_DECREF(&PyUIButton::Type);
    Py_DECREF(m);
    return NULL;
}

Step 4: Implement Constructor

PyObject* PyUIButton::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) {
    PyUIButtonObject* self = (PyUIButtonObject*)type->tp_alloc(type, 0);
    if (self != NULL) {
        self->data = nullptr;  // Initialize in __init__
    }
    return (PyObject*)self;
}

int PyUIButton::pyinit(PyUIButtonObject* self, PyObject* args, PyObject* kwds) {
    int x = 0, y = 0, w = 100, h = 30;
    const char* text = "";
    
    static char* kwlist[] = {"x", "y", "w", "h", "text", NULL};
    
    if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iiiis", kwlist,
                                      &x, &y, &w, &h, &text)) {
        return -1;
    }
    
    self->data = std::make_shared<UIButton>(x, y, w, h, text);
    return 0;
}

Common Patterns & Helpers

PyArgHelpers for Position/Size

Use standardized helpers for tuple support:

#include "PyArgHelpers.h"

// Accept (x, y) tuple OR separate x, y args
int x, y;
if (!PyArgParseTuple_IntIntHelper(args, kwds, x, y, "position", "x", "y")) {
    return -1;
}

See: src/PyArgHelpers.h for complete helper reference

Closure Parameter Encoding

For UIDrawable-derived types:

// Encode type and member index
(void*)((intptr_t)PyObjectsEnum::BUTTON << 8 | BUTTON_MEMBER_TEXT)

See: PYTHON_BINDING_PATTERNS.md for complete encoding scheme

Error Handling

Always check for NULL:

if (!self->data) {
    PyErr_SetString(PyExc_RuntimeError, "Object data is null");
    return NULL;  // Or -1 for setters
}

Set descriptive errors:

PyErr_Format(PyExc_ValueError, "Index %d out of range (0-%d)", idx, max_idx);

Testing Your Bindings

Manual Testing

#!/usr/bin/env python3
import mcrfpy

# Test property
sprite = mcrfpy.Sprite("test.png", 0, 0)
sprite.rotation = 45
assert sprite.rotation == 45

# Test method
grid = mcrfpy.Grid(10, 10, 16, 16)
grid.fill_rect(0, 0, 5, 5, 42)
assert grid.at((0, 0)).tilesprite == 42

print("All tests passed!")

Automated Testing

Create test in tests/test_new_binding.py:

import mcrfpy
import sys

def test_binding():
    # Your tests here
    pass

if __name__ == "__main__":
    try:
        test_binding()
        print("PASS")
        sys.exit(0)
    except Exception as e:
        print(f"FAIL: {e}")
        sys.exit(1)

Run: ./build/mcrogueface --headless --exec tests/test_new_binding.py


Common Pitfalls

Pitfall 1: Forgetting NULL Sentinel

// WRONG - missing sentinel
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 - loses derived type
return (PyObject*)item->data.get();

// CORRECT - preserves type
RET_PY_INSTANCE(item->data);

Pitfall 3: Missing Error Checks

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

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

Next Steps

After adding bindings:

  1. Rebuild: make clean && make
  2. Test manually
  3. Add automated test in tests/
  4. Regenerate stub files: ./build/mcrogueface --exec tools/generate_stubs.py
  5. Update API docs: ./build/mcrogueface --exec tools/generate_dynamic_docs.py
  6. Document in wiki if new system