diff --git a/.github/workflows/buildwheel.yml b/.github/workflows/buildwheel.yml index 7c07eb8a..9037266c 100644 --- a/.github/workflows/buildwheel.yml +++ b/.github/workflows/buildwheel.yml @@ -329,6 +329,57 @@ jobs: - run: spin run -- pytest --doctest-glob='*.rst' doc/source - run: spin docs + test_cython_c_api: + name: 'Test C API with Cython consumers' + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + - run: sudo apt-get update + - run: sudo apt-get install libflint-dev + - run: pip install "cython==3.3.0" meson-python ninja + - run: pip wheel --no-build-isolation --no-deps --wheel-dir=/tmp/python-flint-wheel . + - run: pip install /tmp/python-flint-wheel/python_flint-*.whl + - name: Build examples with Cython 3.1 through 3.3 + run: | + for version in 3.1.0 3.2.9 3.3.0; do + pip install "cython==$version" + pip install --no-build-isolation --no-deps --force-reinstall ./examples/cython/capi + pip install --no-build-isolation --no-deps --force-reinstall ./examples/cython/flint + (cd /tmp && python "$GITHUB_WORKSPACE/examples/cython/capi/test_capi.py") + (cd /tmp && python "$GITHUB_WORKSPACE/examples/cython/flint/test_flint.py") + done + - name: Examples have the expected FLINT dependencies + run: | + python - <<'PY' + import _flint_capi_example + import _flint_direct_example + import subprocess + capi_dependencies = subprocess.check_output( + ["ldd", _flint_capi_example.__file__], text=True) + direct_dependencies = subprocess.check_output( + ["ldd", _flint_direct_example.__file__], text=True) + print(capi_dependencies) + print(direct_dependencies) + assert "libflint" not in capi_dependencies + assert "libflint" in direct_dependencies + PY + - name: Installed API files are present + run: | + cd /tmp + python - <<'PY' + import flint + from pathlib import Path + package = Path(flint.__file__).parent + assert (package / "__init__.pxd").is_file() + assert (package / "__init__.cython-31.pxd").is_file() + assert (Path(flint.get_include()) / "python_flint" / "fmpz.h").is_file() + PY + # Test build with minimum Cython and meson-python versions. test_old_build_requires: name: 'Test old Cython/meson-python' @@ -349,7 +400,7 @@ jobs: # We don't need to specify ninja as a requirement in pyproject.toml # because without --no-build-isolation meson-python handles it # automatically in get_requirements_for_build_wheel(). - - run: 'pip install "cython==3.0.11" "meson-python==0.18" "ninja<1.11"' + - run: 'pip install "cython==3.1.0" "meson-python==0.18" "ninja<1.11"' - run: pip install --no-build-isolation . - run: python -m flint.test --verbose diff --git a/README.md b/README.md index 8cbb492e..1752adfe 100644 --- a/README.md +++ b/README.md @@ -150,9 +150,8 @@ Compatible versions: | `0.7.0` | 16th Mar 2025 | `3.11-3.13` | `3.0-3.2` | `3.0.11-3.1.0a1` | | `0.6.0` | 1st Feb 2024 | `3.9-3.12` | `3.0` only | `3.0` only | -The requirement for Cython 3.1 is only for CPython's free-threaded build. -Otherwise Cython 3.0 is fine. Cython 3.2 is required for a stable ABI build of -python-flint. +Cython 3.1 or newer is required. Cython 3.2 is required for a stable ABI build +of python-flint. As of python-flint 0.7.0, CPython 3.13 [PEP 703](https://peps.python.org/pep-0703/) free-threaded (no-GIL) builds of diff --git a/doc/source/build.rst b/doc/source/build.rst index 11556216..0f66ab08 100644 --- a/doc/source/build.rst +++ b/doc/source/build.rst @@ -229,7 +229,7 @@ active Python environment before running ``pip install``. To build without build isolation with ``python-flint >= 0.7.0`` the dependencies should first be installed in the active Python environment:: - pip install Cython==3.0 meson meson-python ninja + pip install Cython==3.1 meson meson-python ninja pip install --no-build-isolation . The ``meson`` build system will detect the versions of ``FLINT`` and Cython diff --git a/doc/source/c_api.rst b/doc/source/c_api.rst new file mode 100644 index 00000000..a551623f --- /dev/null +++ b/doc/source/c_api.rst @@ -0,0 +1,49 @@ +Experimental C API +================== + +python-flint provides an experimental version 1 C API for ``fmpz``. The API +may change between releases and is not yet a long-term ABI stability promise. + +Cython consumers +---------------- + +Cython 3.1 or newer can use the installed declarations directly:: + + from flint cimport fmpz, fmpz_add, fmpz_get_value + +``fmpz_add(a, b)`` returns a new ``fmpz`` and operates entirely through the +python-flint capsule. Extensions using only this operation do not need FLINT +headers or a direct FLINT link. See ``examples/cython/capi``. + +``fmpz_get_value(value)`` returns a read-only, borrowed pointer to FLINT +storage. It remains valid only while ``value`` is alive. Never clear or mutate +it, retain it independently of its owner, or use it after the owner is +released. Direct consumers must compile and link against the same compatible +FLINT ABI/build as python-flint. In particular, this is not safe with wheels +containing a private, renamed FLINT. See ``examples/cython/flint``. + +C consumers +----------- + +Include the installed header and import the capsule before using it:: + + #include + + if (PyFlint_Import() < 0) + return NULL; + result = PyFlint_FMPZ_Add(left, right); /* new reference */ + +``PyFlint_FMPZ_GetValue`` has the same borrowed, read-only ownership rules as +the Cython wrapper. Both operations return ``NULL`` with a Python exception set +on failure, and reject objects that are not ``flint.fmpz`` instances. + +Header location +--------------- + +Build systems can locate ``python_flint/fmpz.h`` using:: + + import flint + print(flint.get_include()) + +The public header and Cython declarations contain no details of python-flint's +private extension-object layout. diff --git a/doc/source/index.rst b/doc/source/index.rst index d8bb93e8..b1cebc8a 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -29,6 +29,7 @@ Introduction :maxdepth: 2 install.rst + c_api.rst general.rst build.rst workflow.rst diff --git a/examples/cython/capi/README.md b/examples/cython/capi/README.md new file mode 100644 index 00000000..59535539 --- /dev/null +++ b/examples/cython/capi/README.md @@ -0,0 +1,9 @@ +# Capsule-only Cython example + +This extension uses `fmpz_add` through python-flint's capsule API. It neither +includes FLINT headers nor links to FLINT. + +```console +python -m pip install . +python test_capi.py +``` diff --git a/examples/cython/capi/_flint_capi_example.pyx b/examples/cython/capi/_flint_capi_example.pyx new file mode 100644 index 00000000..d12c16f3 --- /dev/null +++ b/examples/cython/capi/_flint_capi_example.pyx @@ -0,0 +1,6 @@ +from flint cimport fmpz, fmpz_add + + +def add(fmpz a, fmpz b): + """Add through python-flint's capsule; no FLINT linkage is needed.""" + return fmpz_add(a, b) diff --git a/examples/cython/capi/meson.build b/examples/cython/capi/meson.build new file mode 100644 index 00000000..ed9c9adc --- /dev/null +++ b/examples/cython/capi/meson.build @@ -0,0 +1,14 @@ +project('python-flint-capi-example', 'cython', 'c', meson_version: '>=1.3') +py = import('python').find_installation(pure: false) +flint_include = run_command( + py, '-c', 'import flint; print(flint.get_include())', check: true, +).stdout().strip() +py.extension_module( + '_flint_capi_example', '_flint_capi_example.pyx', + include_directories: include_directories(flint_include), + install: true, +) +test_env = environment() +test_env.prepend('PYTHONPATH', meson.current_build_dir()) +test('capsule API example', py, args: files('test_capi.py'), + env: test_env) diff --git a/examples/cython/capi/pyproject.toml b/examples/cython/capi/pyproject.toml new file mode 100644 index 00000000..3557fe5b --- /dev/null +++ b/examples/cython/capi/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = ["meson-python", "cython>=3.1", "python-flint"] +build-backend = "mesonpy" + +[project] +name = "python-flint-capi-example" +version = "0.1.0" +dependencies = ["python-flint"] diff --git a/examples/cython/capi/test_capi.py b/examples/cython/capi/test_capi.py new file mode 100644 index 00000000..5b955d0d --- /dev/null +++ b/examples/cython/capi/test_capi.py @@ -0,0 +1,12 @@ +from flint import fmpz +from _flint_capi_example import add + +a = fmpz(2) ** 300 +b = fmpz(3) ** 200 +assert add(a, b) == a + b +try: + add(1, b) +except TypeError: + pass +else: + raise AssertionError("wrong argument type did not raise TypeError") diff --git a/examples/cython/flint/README.md b/examples/cython/flint/README.md new file mode 100644 index 00000000..cbc52b79 --- /dev/null +++ b/examples/cython/flint/README.md @@ -0,0 +1,10 @@ +# Direct-FLINT Cython example + +This extension borrows python-flint's `fmpz` storage and calls the read-only +FLINT function `fmpz_bits`. A compatible system FLINT is an explicit build and +runtime dependency. + +```console +python -m pip install . +python test_flint.py +``` diff --git a/examples/cython/flint/_flint_direct_example.pyx b/examples/cython/flint/_flint_direct_example.pyx new file mode 100644 index 00000000..92cee1d8 --- /dev/null +++ b/examples/cython/flint/_flint_direct_example.pyx @@ -0,0 +1,10 @@ +from flint cimport fmpz, fmpz_get_value + +cdef extern from "flint/fmpz.h": + ctypedef long flint_fmpz "fmpz" + unsigned long fmpz_bits(const flint_fmpz *) + + +def bit_length(fmpz value): + """Call a read-only FLINT operation on borrowed fmpz storage.""" + return fmpz_bits(fmpz_get_value(value)) diff --git a/examples/cython/flint/meson.build b/examples/cython/flint/meson.build new file mode 100644 index 00000000..441fffbb --- /dev/null +++ b/examples/cython/flint/meson.build @@ -0,0 +1,14 @@ +project('python-flint-direct-example', 'cython', 'c', meson_version: '>=1.3') +py = import('python').find_installation(pure: false) +flint_dep = dependency('flint') +flint_include = run_command( + py, '-c', 'import flint; print(flint.get_include())', check: true, +).stdout().strip() +py.extension_module('_flint_direct_example', '_flint_direct_example.pyx', + dependencies: flint_dep, + include_directories: include_directories(flint_include), + install: true) +test_env = environment() +test_env.prepend('PYTHONPATH', meson.current_build_dir()) +test('direct FLINT example', py, args: files('test_flint.py'), + env: test_env) diff --git a/examples/cython/flint/pyproject.toml b/examples/cython/flint/pyproject.toml new file mode 100644 index 00000000..14a4c321 --- /dev/null +++ b/examples/cython/flint/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = ["meson-python", "cython>=3.1", "python-flint"] +build-backend = "mesonpy" + +[project] +name = "python-flint-direct-example" +version = "0.1.0" +dependencies = ["python-flint"] diff --git a/examples/cython/flint/test_flint.py b/examples/cython/flint/test_flint.py new file mode 100644 index 00000000..a9c560ad --- /dev/null +++ b/examples/cython/flint/test_flint.py @@ -0,0 +1,11 @@ +from flint import fmpz +from _flint_direct_example import bit_length + +value = fmpz(2) ** 300 + 1 +assert bit_length(value) == 301 +try: + bit_length(1) +except TypeError: + pass +else: + raise AssertionError("wrong argument type did not raise TypeError") diff --git a/meson.build b/meson.build index ab150a67..a93aa3f8 100644 --- a/meson.build +++ b/meson.build @@ -17,7 +17,7 @@ project( # flint_lower = '>=3.0' flint_upper = '<3.7' -cython_lower = '>=3.0.11' +cython_lower = '>=3.1' cython_upper = '<3.4' py = import('python').find_installation(pure: false) diff --git a/pyproject.toml b/pyproject.toml index 44220d1e..80891fb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,10 +37,6 @@ content-type = "text/markdown" # Cython might still work but typically a Cython release breaks something in # the build of python-flint so we include an upper bound here. # -# Apart from the freethreading build any Cython version from 3.0.11 onwards is -# fine. It is not possible to have a separate version constraint here for the -# freethreading build only though. -# requires = ["meson-python >= 0.18", "cython >=3.1,<3.4"] build-backend = "mesonpy" diff --git a/setup.py b/setup.py index c9e5e22d..3d43789a 100644 --- a/setup.py +++ b/setup.py @@ -51,6 +51,11 @@ os.environ['OPT'] = " ".join(flag for flag in opt.split() if flag != '-Wstrict-prototypes') +# Public C API headers live below src so that the same include spelling works +# both in source builds and after installation. +default_include_dirs.append(os.path.join(os.path.dirname(__file__), 'src')) + + define_macros = [] compiler_directives = { 'language_level': 3, @@ -135,7 +140,10 @@ ext_modules = [] for mod_name, src_files in ext_files: - ext = Extension(mod_name, src_files, **ext_options) + mod_options = ext_options.copy() + if mod_name == "flint.types.fmpz": + mod_options["define_macros"] = define_macros + [("PYFLINT_FMPZ_MODULE", 1)] + ext = Extension(mod_name, src_files, **mod_options) ext_modules.append(ext) for e in ext_modules: diff --git a/src/flint/__init__.cython-31.pxd b/src/flint/__init__.cython-31.pxd new file mode 100644 index 00000000..e23a54b1 --- /dev/null +++ b/src/flint/__init__.cython-31.pxd @@ -0,0 +1,20 @@ +from cpython.ref cimport PyObject, Py_DECREF + +cdef extern from "python_flint/fmpz.h": + int PyFlint_Import() except -1 + PyObject *PyFlint_FMPZ_Add(PyObject *, PyObject *) except NULL + const void *PyFlint_FMPZ_GetValue(PyObject *) except NULL + +cdef extern class flint.types.fmpz.fmpz [object PyFlint_FMPZ_Object, check_size ignore]: + pass + +cdef inline fmpz fmpz_add(fmpz a, fmpz b): + cdef PyObject *result_ptr = PyFlint_FMPZ_Add(a, b) + cdef fmpz result = result_ptr + # Assigning the pointer to an extension-type variable adds a reference. + # Consume the new reference returned by the C API before returning it. + Py_DECREF(result_ptr) + return result + +cdef inline const void *fmpz_get_value(fmpz value) except NULL: + return PyFlint_FMPZ_GetValue(value) diff --git a/src/flint/__init__.pxd b/src/flint/__init__.pxd new file mode 100644 index 00000000..77d12005 --- /dev/null +++ b/src/flint/__init__.pxd @@ -0,0 +1,4 @@ +cdef extern from *: + """ + #error "python-flint's Cython API requires Cython 3.1 or newer" + """ diff --git a/src/flint/__init__.py b/src/flint/__init__.py index d764495a..14c3a0de 100644 --- a/src/flint/__init__.py +++ b/src/flint/__init__.py @@ -1,3 +1,5 @@ +from os.path import dirname, join + from .pyflint import ctx from .types.fmpz import fmpz @@ -51,6 +53,11 @@ __version__ = "0.9.0" +def get_include(): + """Return the directory containing python-flint's public C headers.""" + return join(dirname(__file__), "include") + + __all__ = [ "ctx", "fmpz", @@ -105,4 +112,5 @@ "__FLINT_VERSION__", "__FLINT_RELEASE__", "__version__", + "get_include", ] diff --git a/src/flint/meson.build b/src/flint/meson.build index 4feb64ea..a8d5bbe7 100644 --- a/src/flint/meson.build +++ b/src/flint/meson.build @@ -2,6 +2,8 @@ thisdir = 'flint' pyfiles = [ '__init__.py', + '__init__.pxd', + '__init__.cython-31.pxd', 'typing.py', 'py.typed', 'pyflint.pyi', @@ -25,6 +27,12 @@ py.install_sources( subdir: thisdir, ) +py.install_sources( + '../python_flint/fmpz.h', + pure: false, + subdir: 'flint/include/python_flint', +) + foreach ext : exts py.extension_module( ext, diff --git a/src/flint/types/fmpz.pyx b/src/flint/types/fmpz.pyx index 2d6e3cf1..7bb06f04 100644 --- a/src/flint/types/fmpz.pyx +++ b/src/flint/types/fmpz.pyx @@ -3,6 +3,10 @@ from flint.utils.typecheck cimport typecheck from flint.utils.conversion cimport chars_from_str from flint.utils.conversion cimport str_from_chars, _str_trunc from libc.stdlib cimport malloc, free +from libc.stdint cimport uint32_t +from libc.stddef cimport size_t +from cpython.ref cimport PyObject, Py_INCREF +from cpython.pycapsule cimport PyCapsule_New from flint.flintlib.types.flint cimport FMPZ_REF, FMPZ_TMP, FMPZ_UNKNOWN, COEFF_IS_MPZ from flint.flintlib.functions.flint cimport flint_free @@ -15,6 +19,16 @@ from flint.flintlib.functions.partitions cimport * from flint.utils.flint_exceptions import DomainError import sys +cdef extern from "python_flint/fmpz.h": + ctypedef struct PyFlint_FMPZ_API_v1: + uint32_t abi_version + size_t struct_size + PyObject *(*fmpz_add)(PyObject *, PyObject *) + const void *(*fmpz_get_value)(PyObject *) + + int PYFLINT_FMPZ_ABI_VERSION + const char *PYFLINT_FMPZ_CAPSULE_NAME + is_big_endian = int(sys.byteorder == "big") cdef fmpz_get_intlong(fmpz_t x): @@ -1027,3 +1041,32 @@ cdef class fmpz(flint_scalar): if ttype == FMPZ_TMP: fmpz_clear(tval) return fmpz(v) + + +# Keep these functions in this module: this is the only place where the +# private layout of the fmpz extension type is part of the implementation. +cdef PyObject *_capi_fmpz_add(PyObject *a, PyObject *b) except NULL: + cdef fmpz result + if (a == NULL or b == NULL or + not isinstance(a, fmpz) or + not isinstance(b, fmpz)): + raise TypeError("PyFlint_FMPZ_Add expects two flint.fmpz objects") + result = fmpz.__new__(fmpz) + fmpz_add(result.val, (a).val, (b).val) + Py_INCREF(result) + return result + + +cdef const void *_capi_fmpz_get_value(PyObject *value) except NULL: + if value == NULL or not isinstance(value, fmpz): + raise TypeError("PyFlint_FMPZ_GetValue expects a flint.fmpz object") + return (value).val + + +cdef PyFlint_FMPZ_API_v1 _fmpz_c_api +_fmpz_c_api.abi_version = PYFLINT_FMPZ_ABI_VERSION +_fmpz_c_api.struct_size = sizeof(PyFlint_FMPZ_API_v1) +_fmpz_c_api.fmpz_add = _capi_fmpz_add +_fmpz_c_api.fmpz_get_value = _capi_fmpz_get_value + +_C_API = PyCapsule_New(&_fmpz_c_api, PYFLINT_FMPZ_CAPSULE_NAME, NULL) diff --git a/src/flint/types/meson.build b/src/flint/types/meson.build index 1e527091..a2f38d67 100644 --- a/src/flint/types/meson.build +++ b/src/flint/types/meson.build @@ -1,4 +1,5 @@ thisdir = 'flint/types' +python_flint_source_include = include_directories('../..') pyfiles = [ '__init__.py', @@ -103,10 +104,16 @@ py.install_sources( ) foreach ext : exts + ext_c_args = [] + if ext == 'fmpz' + ext_c_args = ['-DPYFLINT_FMPZ_MODULE'] + endif py.extension_module( ext, ext + '.pyx', dependencies: pyflint_deps, + include_directories: python_flint_source_include, + c_args: ext_c_args, install: true, subdir: thisdir, limited_api: limited_api_version, diff --git a/src/python_flint/fmpz.h b/src/python_flint/fmpz.h new file mode 100644 index 00000000..8e45c7d0 --- /dev/null +++ b/src/python_flint/fmpz.h @@ -0,0 +1,69 @@ +#ifndef PYTHON_FLINT_FMPZ_H +#define PYTHON_FLINT_FMPZ_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define PYFLINT_FMPZ_ABI_VERSION 1 +#define PYFLINT_FMPZ_CAPSULE_NAME "flint.types.fmpz._C_API" + +typedef struct PyFlint_FMPZ_API_v1 { + uint32_t abi_version; + size_t struct_size; + PyObject *(*fmpz_add)(PyObject *, PyObject *); + const void *(*fmpz_get_value)(PyObject *); +} PyFlint_FMPZ_API_v1; + +#ifndef PYFLINT_FMPZ_MODULE +static PyFlint_FMPZ_API_v1 *PyFlint_FMPZ_API = NULL; + +static int +PyFlint_Import(void) +{ + PyFlint_FMPZ_API_v1 *api; + + api = (PyFlint_FMPZ_API_v1 *)PyCapsule_Import( + PYFLINT_FMPZ_CAPSULE_NAME, 0); + if (api == NULL) + return -1; + if (api->abi_version != PYFLINT_FMPZ_ABI_VERSION) { + PyErr_Format(PyExc_ImportError, + "unsupported python-flint C API version %u", + (unsigned int)api->abi_version); + return -1; + } + if (api->struct_size < sizeof(PyFlint_FMPZ_API_v1)) { + PyErr_SetString(PyExc_ImportError, + "python-flint C API table is too small"); + return -1; + } + PyFlint_FMPZ_API = api; + return 0; +} + +static PyObject * +PyFlint_FMPZ_Add(PyObject *a, PyObject *b) +{ + if (PyFlint_FMPZ_API == NULL && PyFlint_Import() < 0) + return NULL; + return PyFlint_FMPZ_API->fmpz_add(a, b); +} + +static const void * +PyFlint_FMPZ_GetValue(PyObject *value) +{ + if (PyFlint_FMPZ_API == NULL && PyFlint_Import() < 0) + return NULL; + return PyFlint_FMPZ_API->fmpz_get_value(value); +} +#endif + +#ifdef __cplusplus +} +#endif +#endif