The Limited API restricts C symbols; an abi3-named build still needs tests on every supported runtime and platform.
Python Limited API: build an abi3 extension without claiming a full release matrix
Operation contract
The fixture compiles a fee calculator with Py_LIMITED_API set to the Python 3.11 API level and writes an abi3-suffixed shared object. It imports that binary on the checked interpreter, adds seven units to forty-seven, and rejects a negative amount. The C code uses only a small stable-interface surface and checks arithmetic before addition.
Failure boundary
A successful import on CPython 3.14 does not verify CPython 3.11, a free-threaded build, Linux, or Windows. The abi3 filename alone does not certify that every symbol used is in the stable ABI or that the wheel tag is correct. Packaging needs a real build backend, supported-platform wheels, and cross-version import tests. This fixture builds no distributable wheel.
Working program
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 *total_with_fee(PyObject *self, PyObject *args) {
long amount;
if (!PyArg_ParseTuple(args, "l", &amount)) return NULL;
if (amount < 0 || amount > LONG_MAX - 7) {
PyErr_SetString(PyExc_ValueError, "amount outside contract");
return NULL;
}
return PyLong_FromLong(amount + 7);
}
static PyMethodDef methods[] = {
{"total_with_fee", total_with_fee, METH_VARARGS, "Add a fee."},
{NULL, NULL, 0, NULL}
};
static struct PyModuleDef module = {
PyModuleDef_HEAD_INIT, "fee_native", NULL, -1, methods
};
PyMODINIT_FUNC PyInit_fee_native(void) {
return PyModule_Create(&module);
}
"""
with TemporaryDirectory() as directory:
source = Path(directory) / "fee_native.c"
binary = Path(directory) / "fee_native.abi3.so"
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 += ["-DPy_LIMITED_API=0x030B0000", "-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("fee_native", binary)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
print("total", module.total_with_fee(47))
try:
module.total_with_fee(-1)
except ValueError:
print("negative_rejected", True)Output
total 54
negative_rejected TrueCosts and limits
A limited interface can reduce rebuilds across compatible interpreter versions, but it does not remove native compilation, packaging, platform wheels, or test cost.
Common Mistakes
- An abi3 suffix is not proof of stable-ABI compliance by itself.
- Test imports on the lowest declared Python version, not only the build host.
- Limited API support is not the same as free-threaded safety.
