Sample code

code/counter

A heap type with per-module state, cycle-GC support, validating getsets, and module state reached from both a method and a slot.

Build and run
cd code/counter
python -m pip install -e . --no-build-isolation -v
python -m pytest -q

Sources on disk are under code/counter/ and are compilable as-is. Each section below links to its original file.

Files

counter.c

/*
 * counter -- a heap type with per-module state and full cycle-GC support.
 *
 * Demonstrates:
 *   - per-module state instead of C globals, with m_traverse/m_clear/m_free
 *   - a heap type created with PyType_Spec / PyType_FromModuleAndSpec
 *   - the full lifecycle: tp_new, tp_init, tp_traverse, tp_clear, tp_dealloc
 *   - members, getset with validation, and __repr__
 *   - reaching module state from a method via METH_METHOD and defining_class
 *
 * Build:  pip install -e . --no-build-isolation
 * Lessons: 03, 04, 05, 06, 08
 */

#include <Python.h>
#include <stddef.h>   /* offsetof */

/* ================================================================== *
 * per-module state
 * ================================================================== */

typedef struct {
    PyObject *CounterType;   /* strong ref to our heap type   */
    PyObject *Error;         /* strong ref to counter.Error   */
} counter_state;

static inline counter_state *
get_state(PyObject *module)
{
    void *state = PyModule_GetState(module);
    assert(state != NULL);
    return (counter_state *)state;
}

/* Needed by slot functions, which receive neither the module nor the
 * defining class. Declared here, defined after the module definition. */
static PyModuleDef counter_module;

/* ================================================================== *
 * the instance
 * ================================================================== */

typedef struct {
    PyObject_HEAD
    PyObject *name;       /* str, never NULL after tp_new succeeds */
    PyObject *payload;    /* arbitrary object or NULL              */
    long      count;      /* plain C, never traversed              */
} CounterObject;

/* ---- lifecycle --------------------------------------------------- */

static PyObject *
Counter_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    /* Allocate through the *passed* type so subclasses work. */
    CounterObject *self = (CounterObject *)type->tp_alloc(type, 0);
    if (self == NULL) {
        return NULL;
    }
    /* The object is GC-tracked from here on, so every PyObject* field must
     * be in a state tp_traverse can cope with -- NULL is fine, garbage is not. */
    self->payload = NULL;
    self->count = 0;
    self->name = PyUnicode_FromString("");
    if (self->name == NULL) {
        Py_DECREF(self);
        return NULL;
    }
    return (PyObject *)self;
}

static int
Counter_init(PyObject *op, PyObject *args, PyObject *kwds)
{
    CounterObject *self = (CounterObject *)op;
    static char *kwlist[] = {"name", "payload", NULL};
    PyObject *name = NULL;      /* borrowed */
    PyObject *payload = NULL;   /* borrowed */

    if (!PyArg_ParseTupleAndKeywords(args, kwds, "|UO:Counter", kwlist,
                                     &name, &payload)) {
        return -1;
    }
    if (name != NULL) {
        Py_SETREF(self->name, Py_NewRef(name));
    }
    if (payload != NULL) {
        Py_XSETREF(self->payload, Py_NewRef(payload));
    }
    self->count = 0;
    return 0;
}

static int
Counter_traverse(PyObject *op, visitproc visit, void *arg)
{
    CounterObject *self = (CounterObject *)op;
    /* Heap types: an instance references its type, so the type must be
     * visited or the module can never be collected. */
    Py_VISIT(Py_TYPE(self));
    Py_VISIT(self->name);
    Py_VISIT(self->payload);
    return 0;
}

static int
Counter_clear(PyObject *op)
{
    CounterObject *self = (CounterObject *)op;
    Py_CLEAR(self->name);
    Py_CLEAR(self->payload);
    return 0;
}

static void
Counter_dealloc(PyObject *op)
{
    PyTypeObject *tp = Py_TYPE(op);   /* read before the memory goes away */
    PyObject_GC_UnTrack(op);          /* 1. the collector must stop seeing us */
    (void)Counter_clear(op);          /* 2. drop object references            */
    tp->tp_free(op);                  /* 3. release the memory                */
    Py_DECREF(tp);                    /* 4. release this instance's ref to the type */
}

/* ---- behaviour --------------------------------------------------- */

static PyObject *
Counter_repr(PyObject *op)
{
    CounterObject *self = (CounterObject *)op;
    /* Fields can be NULL after tp_clear, so guard. */
    if (self->name == NULL) {
        return PyUnicode_FromFormat("<%s (cleared)>", Py_TYPE(self)->tp_name);
    }
    return PyUnicode_FromFormat("<%s %R count=%ld>",
                                Py_TYPE(self)->tp_name, self->name, self->count);
}

/* bump(n=1) -> int
 *
 * METH_METHOD | METH_FASTCALL | METH_KEYWORDS gives us `defining_class`,
 * which is how a heap-type method reaches per-module state. */
static PyObject *
Counter_bump(PyObject *op, PyTypeObject *defining_class,
             PyObject *const *args, Py_ssize_t nargs, PyObject *kwnames)
{
    CounterObject *self = (CounterObject *)op;
    long n = 1;

    if (nargs > 1) {
        PyErr_SetString(PyExc_TypeError,
                        "bump() takes at most 1 positional argument");
        return NULL;
    }
    if (nargs == 1) {
        n = PyLong_AsLong(args[0]);
        if (n == -1 && PyErr_Occurred()) {
            return NULL;
        }
    }
    if (kwnames != NULL) {
        Py_ssize_t nkw = PyTuple_GET_SIZE(kwnames);
        for (Py_ssize_t i = 0; i < nkw; i++) {
            PyObject *key = PyTuple_GET_ITEM(kwnames, i);   /* tuple: safe */
            if (PyUnicode_CompareWithASCIIString(key, "n") != 0) {
                PyErr_Format(PyExc_TypeError,
                             "bump() got an unexpected keyword argument %R", key);
                return NULL;
            }
            if (nargs == 1) {
                PyErr_SetString(PyExc_TypeError,
                                "bump() got multiple values for argument 'n'");
                return NULL;
            }
            n = PyLong_AsLong(args[nargs + i]);
            if (n == -1 && PyErr_Occurred()) {
                return NULL;
            }
        }
    }

    if (n < 0) {
        counter_state *st = (counter_state *)PyType_GetModuleState(defining_class);
        if (st == NULL) {
            return NULL;
        }
        PyErr_SetString(st->Error, "bump() amount must be non-negative");
        return NULL;
    }

    self->count += n;
    return PyLong_FromLong(self->count);
}

static PyObject *
Counter_reset(PyObject *op, PyObject *Py_UNUSED(ignored))
{
    ((CounterObject *)op)->count = 0;
    Py_RETURN_NONE;
}

/* A slot function gets neither module nor defining class, so it goes
 * through the module definition to find its module. */
static PyObject *
Counter_richcompare(PyObject *a, PyObject *b, int op)
{
    if (op != Py_EQ && op != Py_NE) {
        Py_RETURN_NOTIMPLEMENTED;
    }
    PyObject *module = PyType_GetModuleByDef(Py_TYPE(a), &counter_module);
    if (module == NULL) {
        return NULL;   /* not one of ours */
    }
    counter_state *st = get_state(module);   /* module is borrowed */
    if (!PyObject_TypeCheck(b, (PyTypeObject *)st->CounterType)) {
        Py_RETURN_NOTIMPLEMENTED;
    }
    int equal = ((CounterObject *)a)->count == ((CounterObject *)b)->count;
    if (op == Py_NE) {
        equal = !equal;
    }
    return PyBool_FromLong(equal);
}

/* ---- attributes -------------------------------------------------- */

static PyMemberDef Counter_members[] = {
    {"count", Py_T_LONG, offsetof(CounterObject, count), Py_READONLY,
     PyDoc_STR("current count")},
    {NULL}
};

static PyObject *
Counter_get_name(PyObject *op, void *closure)
{
    CounterObject *self = (CounterObject *)op;
    if (self->name == NULL) {
        PyErr_SetString(PyExc_AttributeError, "name");
        return NULL;
    }
    return Py_NewRef(self->name);
}

static int
Counter_set_name(PyObject *op, PyObject *value, void *closure)
{
    CounterObject *self = (CounterObject *)op;
    if (value == NULL) {
        PyErr_SetString(PyExc_AttributeError, "cannot delete 'name'");
        return -1;
    }
    if (!PyUnicode_Check(value)) {
        PyErr_Format(PyExc_TypeError, "name must be str, not %s",
                     Py_TYPE(value)->tp_name);
        return -1;
    }
    Py_XSETREF(self->name, Py_NewRef(value));
    return 0;
}

static PyObject *
Counter_get_payload(PyObject *op, void *closure)
{
    CounterObject *self = (CounterObject *)op;
    if (self->payload == NULL) {
        Py_RETURN_NONE;
    }
    return Py_NewRef(self->payload);
}

static int
Counter_set_payload(PyObject *op, PyObject *value, void *closure)
{
    CounterObject *self = (CounterObject *)op;
    Py_XSETREF(self->payload, Py_XNewRef(value));   /* NULL means delete */
    return 0;
}

static PyGetSetDef Counter_getset[] = {
    {"name", Counter_get_name, Counter_set_name,
     PyDoc_STR("the counter's name (str)"), NULL},
    {"payload", Counter_get_payload, Counter_set_payload,
     PyDoc_STR("arbitrary attached object"), NULL},
    {NULL}
};

static PyMethodDef Counter_methods[] = {
    {"bump", (PyCFunction)(void (*)(void))Counter_bump,
     METH_METHOD | METH_FASTCALL | METH_KEYWORDS,
     PyDoc_STR("bump(n=1)\n--\n\nAdd n to the count and return the new value.")},
    {"reset", Counter_reset, METH_NOARGS,
     PyDoc_STR("reset()\n--\n\nSet the count back to zero.")},
    {NULL}
};

static PyType_Slot Counter_slots[] = {
    {Py_tp_doc,         (void *)PyDoc_STR("Counter(name='', payload=None)")},
    {Py_tp_new,         Counter_new},
    {Py_tp_init,        Counter_init},
    {Py_tp_dealloc,     Counter_dealloc},
    {Py_tp_traverse,    Counter_traverse},
    {Py_tp_clear,       Counter_clear},
    {Py_tp_repr,        Counter_repr},
    {Py_tp_richcompare, Counter_richcompare},
    {Py_tp_methods,     Counter_methods},
    {Py_tp_members,     Counter_members},
    {Py_tp_getset,      Counter_getset},
    {0, NULL}
};

static PyType_Spec Counter_spec = {
    .name = "counter.Counter",
    .basicsize = sizeof(CounterObject),
    .itemsize = 0,
    .flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC,
    .slots = Counter_slots,
};

/* ================================================================== *
 * module-level function: `self` is the module
 * ================================================================== */

static PyObject *
counter_make(PyObject *module, PyObject *arg)
{
    counter_state *st = get_state(module);
    if (!PyUnicode_Check(arg)) {
        PyErr_SetString(st->Error, "make() requires a str name");
        return NULL;
    }
    return PyObject_CallOneArg(st->CounterType, arg);
}

static PyMethodDef counter_methods[] = {
    {"make", counter_make, METH_O,
     PyDoc_STR("make(name, /)\n--\n\nCreate a Counter with the given name.")},
    {NULL}
};

/* ================================================================== *
 * module plumbing
 * ================================================================== */

static int
counter_exec(PyObject *module)
{
    counter_state *st = get_state(module);   /* already zeroed */

    st->Error = PyErr_NewExceptionWithDoc("counter.Error",
                                          "Errors raised by the counter module.",
                                          NULL, NULL);
    if (st->Error == NULL) {
        return -1;
    }
    if (PyModule_AddObjectRef(module, "Error", st->Error) < 0) {
        return -1;
    }

    st->CounterType = PyType_FromModuleAndSpec(module, &Counter_spec, NULL);
    if (st->CounterType == NULL) {
        return -1;
    }
    if (PyModule_AddObjectRef(module, "Counter", st->CounterType) < 0) {
        return -1;
    }
    return 0;
}

static int
counter_traverse(PyObject *module, visitproc visit, void *arg)
{
    counter_state *st = get_state(module);
    Py_VISIT(st->CounterType);
    Py_VISIT(st->Error);
    return 0;
}

static int
counter_clear(PyObject *module)
{
    counter_state *st = get_state(module);
    Py_CLEAR(st->CounterType);
    Py_CLEAR(st->Error);
    return 0;
}

static void
counter_free(void *module)
{
    (void)counter_clear((PyObject *)module);
}

static PyModuleDef_Slot counter_slots[] = {
    {Py_mod_exec, counter_exec},
    {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},
#if PY_VERSION_HEX >= 0x030D0000
    {Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
    {0, NULL}
};

static PyModuleDef counter_module = {
    .m_base     = PyModuleDef_HEAD_INIT,
    .m_name     = "counter",
    .m_doc      = PyDoc_STR("Heap type with per-module state."),
    .m_size     = sizeof(counter_state),
    .m_methods  = counter_methods,
    .m_slots    = counter_slots,
    .m_traverse = counter_traverse,
    .m_clear    = counter_clear,
    .m_free     = counter_free,
};

PyMODINIT_FUNC
PyInit_counter(void)
{
    return PyModuleDef_Init(&counter_module);
}

Original source: code/counter/counter.c

setup.py

import sys

from setuptools import Extension, setup

warn_flags = ["/W4"] if sys.platform == "win32" else ["-Wall", "-Wextra", "-Wno-unused-parameter"]

setup(
    ext_modules=[
        Extension(name="counter", sources=["counter.c"], extra_compile_args=warn_flags),
    ],
)

Original source: code/counter/setup.py

pyproject.toml

[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"

[project]
name = "counter"
version = "0.1.0"
description = "Heap type with per-module state and cycle GC"
requires-python = ">=3.13"

[tool.pytest.ini_options]
testpaths = ["tests"]

Original source: code/counter/pyproject.toml

tests/test_counter.py

import gc

import pytest

import counter


def test_construct_and_bump():
    c = counter.Counter("hits")
    assert c.name == "hits"
    assert c.count == 0
    assert c.bump() == 1
    assert c.bump(10) == 11
    assert c.bump(n=4) == 15
    c.reset()
    assert c.count == 0


def test_repr():
    assert repr(counter.Counter("hits")) == "<counter.Counter 'hits' count=0>"


def test_count_is_readonly():
    c = counter.Counter()
    with pytest.raises(AttributeError):
        c.count = 5


def test_name_validated():
    c = counter.Counter()
    with pytest.raises(TypeError):
        c.name = 42
    with pytest.raises(AttributeError):
        del c.name


def test_bump_errors():
    c = counter.Counter()
    with pytest.raises(counter.Error):
        c.bump(-1)
    with pytest.raises(TypeError):
        c.bump(1, 2)
    with pytest.raises(TypeError):
        c.bump(bogus=1)
    with pytest.raises(TypeError):
        c.bump(1, n=2)


def test_equality():
    a, b = counter.Counter("a"), counter.Counter("b")
    assert a == b
    b.bump()
    assert a != b
    assert a != 0  # NotImplemented -> falls back to identity


def test_module_level_factory():
    c = counter.make("from-factory")
    assert isinstance(c, counter.Counter)
    with pytest.raises(counter.Error):
        counter.make(1)


def test_traverse_reports_exactly_the_object_fields():
    payload = [1, 2, 3]
    c = counter.Counter("n", payload)
    refs = gc.get_referents(c)  # this calls tp_traverse
    assert type(c) in refs, "a heap type must visit Py_TYPE(self)"
    assert "n" in refs
    assert payload in refs
    assert len(refs) == 3


def _live_counters():
    return sum(1 for o in gc.get_objects() if type(o) is counter.Counter)


def test_cycles_are_collected():
    gc.collect()
    before = _live_counters()
    for _ in range(200):
        c = counter.Counter("cyclic")
        c.payload = c  # self-reference: only the cycle collector can free this
        del c
    gc.collect()
    # If tp_traverse or tp_clear were wrong these would leak forever.
    assert _live_counters() == before


def test_subclassing():
    class Sub(counter.Counter):
        def __init__(self, name):
            super().__init__(name)
            self.extra = 1

    s = Sub("sub")
    assert s.bump() == 1
    assert s.extra == 1

Original source: code/counter/tests/test_counter.py

README.md

# counter

A heap type with per-module state and full cycle-GC support — the shape every
new extension type should have.

```bash
python -m pip install -e . --no-build-isolation -v
python -m pytest -q
```

```python
import counter

c = counter.Counter("hits")
c.bump()          # 1
c.bump(n=10)      # 11
c.payload = {"x": 1}
print(c)          # <counter.Counter 'hits' count=11>
```

## What to look for in `counter.c`

| Pattern | Where |
|---|---|
| Per-module state instead of C globals | `counter_state`, `get_state` |
| Heap type from a spec | `Counter_spec`, `PyType_FromModuleAndSpec` |
| Traversal including `Py_TYPE(self)` | `Counter_traverse` |
| The four-step deallocation order | `Counter_dealloc` |
| Module state from a method | `Counter_bump` via `defining_class` |
| Module state from a slot function | `Counter_richcompare` via `PyType_GetModuleByDef` |
| Validating setter | `Counter_set_name` |

## Things to try

1. Delete `Py_VISIT(Py_TYPE(self))` from `Counter_traverse` and run
   `test_traverse_reports_exactly_the_object_fields`.
2. Delete `Py_VISIT(self->payload)` and run `test_cycles_are_collected`
   under `python -X dev`. Then think about what a *missing* visit does
   versus an *extra* one.
3. Move `Py_DECREF(tp)` before `tp->tp_free(op)` in the deallocator.
4. Replace the heap type with a static `PyTypeObject` and see what
   `Counter_bump` can no longer reach.

Original source: code/counter/README.md