Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Python C extension: compile, import, and reject bad input

Last updated: 1 Oct 20265 min read
tutorial
IntermediateBy AITrove Editorial

A CPython extension must compile against the target headers, export the matching module initializer, and signal argument errors.

Download Python source kit

Operation contract

The fixture compiles a small receipt scorer into a temporary extension file, imports that file, doubles a forty-seven-unit amount, and rejects a text argument. The C function checks the amount's range before multiplying, and a failed argument parse returns NULL with an exception already set. The build uses local CPython headers and a platform-specific compiler command.

Failure boundary

This checks one macOS build only. The Linux command is shown but was not run here, and Windows needs another build path. The filename carries the current interpreter's extension suffix; a successful local import does not establish an ABI contract for future versions. Compiler output, binary architecture, system libraries, and interpreter mode all belong to the release matrix.

Working program

python
import importlib.util
import sys
import sysconfig
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory

c_source = r"""
#include <Python.h>
#include <limits.h>
static PyObject *score_receipt(PyObject *self, PyObject *args) {
    long amount;
    if (!PyArg_ParseTuple(args, "l", &amount)) return NULL;
    if (amount < 0 || amount > LONG_MAX / 2) {
        PyErr_SetString(PyExc_ValueError, "amount outside contract");
        return NULL;
    }
    return PyLong_FromLong(amount * 2);
}
static PyMethodDef methods[] = {
    {"score", score_receipt, METH_VARARGS, "Score a receipt."},
    {NULL, NULL, 0, NULL}
};
static struct PyModuleDef module = {
    PyModuleDef_HEAD_INIT, "receipt_native", NULL, -1, methods
};
PyMODINIT_FUNC PyInit_receipt_native(void) {
    return PyModule_Create(&module);
}
"""
with TemporaryDirectory() as directory:
    source = Path(directory) / "receipt_native.c"
    binary = Path(directory) / ("receipt_native" + sysconfig.get_config_var("EXT_SUFFIX"))
    source.write_text(c_source, encoding="utf-8")
    command = ["cc"]
    if sys.platform == "darwin":
        command += ["-bundle", "-undefined", "dynamic_lookup"]
    elif sys.platform.startswith("linux"):
        command += ["-shared", "-fPIC"]
    else:
        raise RuntimeError("this fixture needs a POSIX compiler")
    command += ["-I" + sysconfig.get_path("include"), str(source), "-o", str(binary)]
    subprocess.run(command, check=True, capture_output=True, text=True)
    specification = importlib.util.spec_from_file_location("receipt_native", binary)
    module = importlib.util.module_from_spec(specification)
    specification.loader.exec_module(module)
    print("score", module.score(47))
    try:
        module.score("47")
    except TypeError:
        print("text_rejected", True)

Output

Output
score 94
text_rejected True

Costs and limits

Compilation dominates this tiny computation. Native calls still cross a Python/C boundary, so the extension needs a measured workload before replacing a pure Python implementation.

Common Mistakes

  • A local binary import is not a cross-version or cross-platform test.
  • Return NULL only when a Python exception is set on the error path.
  • Do not multiply a C long before checking its range.

Connected lessons

Test this contract.

python
c-extension-build-import
Storage details