From 0a3d4138f9d53fed06e6e1de580dc5686a09079a Mon Sep 17 00:00:00 2001 From: Guillaume Fraux Date: Fri, 17 Jul 2026 18:11:26 +0200 Subject: [PATCH 1/8] Add README --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index d739dc4..33e8289 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ # ctypes-dlpack + +

+ +[![tests status](https://github.com/metatensor/ctypes-dlpack/actions/workflows/tests.yml/badge.svg)](https://github.com/metatensor/ctypes-dlpack/actions?query=branch%3Amain) +[![documentation](https://img.shields.io/badge/📚_documentation-latest-sucess)](https://docs.metatensor.org/ctypes-dlpack/) +

+ +The [DLPack](https://dmlc.github.io/dlpack/latest/) standard allows to share +arrays/tensors data between different frameworks. It is defined as a C API, and +the standard way to use it from Python is the `__dlpack__` protocol defined in +the [array +api](https://data-apis.org/array-api/latest/API_specification/generated/array_api.array.__dlpack__.html), +which relies on `PyCapsule` to pass the pointer around through Python code. + +However, there might be times where one does not have a `PyCapsule` but just a +raw pointer to the DLPack C struct. In particular, this might be the case when +adding bindings to a C library through `ctypes` instead of going through a +CPython extension module. This library is here to bridge this gap, creating +ctypes pointers from the `__dlpack__` protocol, and making existing pointers +compatible with this protocol. + +This library exposes both the C structs/enums/constants from the DLPack C header +as `ctypes` types (containing among other the version of the DLPack library +that was used to generate the ctypes declarations), and a Python API to convert +between DLPack pointers and Python objects. See the [documentation](http://docs.metatensor.org/ctypes-dlpack/) for more details. + +You can install the code with `pip install ctypes-dlpack`. + +### License and contributions + +This project is distributed under the terms of the [BSD license](LICENSE), and +[maintained](https://github.com/lab-cosmo/.github/blob/main/Maintainers.md) by +[@Luthaf](https://github.com/Luthaf), who will reply to issues and pull requests +opened on this repository as soon as possible. You can mention them directly if +you did not receive an answer after a couple of days. From f8cefd864c9a0c5c0208bf1b936f1ae29881278b Mon Sep 17 00:00:00 2001 From: Guillaume Fraux Date: Mon, 20 Jul 2026 10:46:20 +0200 Subject: [PATCH 2/8] Register a destructor for the PyCapsule --- src/ctypes_dlpack/_dlpack.py | 57 ++++++++++++++++++++++++++++- tests/test_dlpack.py | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/test_dlpack.py diff --git a/src/ctypes_dlpack/_dlpack.py b/src/ctypes_dlpack/_dlpack.py index 21bb778..347d220 100644 --- a/src/ctypes_dlpack/_dlpack.py +++ b/src/ctypes_dlpack/_dlpack.py @@ -35,12 +35,65 @@ ] +# ``PyCapsule_GetName``/``PyCapsule_GetPointer`` re-bound with a raw ``c_void_p`` +# argument. These are needed from inside ``_versioned_capsule_destructor``: +# CPython invokes the destructor during ``PyCapsule dealloc`` while the capsule +# refcount is already 0, so the argument cannot be passed as ``py_object`` +# (which would INCREF then DECREF back to 0, recursively re-triggering +# ``dealloc``). The raw pointer is obtained from the cached symbol address. +_PyCapsule_GetName_c_void_p = ctypes.CFUNCTYPE(ctypes.c_char_p, ctypes.c_void_p)( + ctypes.cast(PYTHON_API.PyCapsule_GetName, ctypes.c_void_p).value +) + +_PyCapsule_GetPointer_c_void_p = ctypes.CFUNCTYPE( + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p +)(ctypes.cast(PYTHON_API.PyCapsule_GetPointer, ctypes.c_void_p).value) + + DLPACK_VERSIONED_NAME = b"dltensor_versioned" USED_DLPACK_VERSIONED_NAME = b"used_dltensor_versioned" DLPACK_NAME = b"dltensor" USED_DLPACK_NAME = b"used_dltensor" +def _versioned_capsule_destructor(capsule_ptr): + """Release the ``DLManagedTensorVersioned`` held by a capsule. + + This is invoked by CPython when the capsule is garbage collected without having + been consumed by a ``__dlpack__`` importer (i.e. its name is still + ``dltensor_versioned``). Capsules renamed to ``used_dltensor_versioned`` have + already been taken over by an importer and are left untouched. + + The argument is a raw ``void*`` (the capsule pointer) rather than + ``py_object`` because CPython invokes the destructor during ``dealloc`` with + the capsule already at refcount 0: wrapping it as ``py_object`` would INCREF + then DECREF it back to 0, recursively re-triggering ``dealloc``. + """ + if not capsule_ptr: + return + + name = _PyCapsule_GetName_c_void_p(capsule_ptr) + if name != DLPACK_VERSIONED_NAME: + # Already consumed by an importer; the importer owns the deleter call. + return + + ptr = _PyCapsule_GetPointer_c_void_p(capsule_ptr, DLPACK_VERSIONED_NAME) + if not ptr: + return + + versioned_ptr = ctypes.cast(ptr, ctypes.POINTER(DLManagedTensorVersioned)) + versioned = versioned_ptr.contents + if versioned.deleter: + versioned.deleter(versioned_ptr) + + +# Keep a reference so the CFUNCTYPE is not garbage-collected while CPython holds +# the destructor pointer inside the capsule. +_VERSIONED_CAPSULE_DESTRUCTOR_C = ctypes.CFUNCTYPE(None, ctypes.c_void_p)( + _versioned_capsule_destructor +) + + @ctypes.CFUNCTYPE(None, ctypes.POINTER(DLManagedTensorVersioned)) def _versioned_wrapper_deleter(versioned_ptr): """Forward to the legacy deleter, then free the wrapper allocation.""" @@ -89,8 +142,8 @@ def _make_dlpack_versioned_capsule(dl_managed_versioned_ptr): if not ptr_value: raise ValueError("DLManagedTensorVersioned pointer is null") - # TODO: this should have a destructor - return PYTHON_API.PyCapsule_New(ptr_value, DLPACK_VERSIONED_NAME, None) + destructor = ctypes.cast(_VERSIONED_CAPSULE_DESTRUCTOR_C, ctypes.c_void_p) + return PYTHON_API.PyCapsule_New(ptr_value, DLPACK_VERSIONED_NAME, destructor) def _call_dlpack_with_fallbacks(array, stream, max_version, dl_device, copy): diff --git a/tests/test_dlpack.py b/tests/test_dlpack.py new file mode 100644 index 0000000..b5a10ce --- /dev/null +++ b/tests/test_dlpack.py @@ -0,0 +1,71 @@ +import ctypes +import gc + +import numpy as np +import pytest +import torch + +from ctypes_dlpack import ( + DLDevice, + DLDeviceType, + DLPackArray, + DLPackVersion, + array_as_dlpack, +) +from ctypes_dlpack._c_api import DLManagedTensorVersioned + + +_Deleter = ctypes.CFUNCTYPE(None, ctypes.POINTER(DLManagedTensorVersioned)) + + +def create_numpy_array(): + return np.random.rand(2, 3, 4) + + +def create_torch_array(): + return torch.rand(23, 6, 48, dtype=torch.float16) + + +@pytest.mark.parametrize("array", [create_numpy_array(), create_torch_array()]) +def test_unconsumed_capsule_runs_deleter(array): + """A capsule produced by ``DLPackArray.__dlpack__`` but never consumed by an + importer must still release the underlying ``DLManagedTensorVersioned`` via + the capsule destructor when garbage collected.""" + + calls = [] + keepalive = [] + + def tracking_deleter(original): + # Extract the raw function-pointer address: ``_Deleter(original)`` + # would treat ``original`` as a Python callable and wrap it in a + # trampoline that calls back into ``original`` (recursing forever). + original_addr = ctypes.cast(original, ctypes.c_void_p).value + original_cb = _Deleter(original_addr) + + def deleter(ptr): + calls.append(1) + if original_cb: + original_cb(ptr) + + # Keep the trampoline alive: only the raw function pointer is stored in + # the struct, so the Python-side ``_Deleter`` must be retained. + cb = _Deleter(deleter) + keepalive.append(cb) + return cb + + ptr = array_as_dlpack( + array, + dl_device=DLDevice(DLDeviceType.kDLCPU, 0), + stream=None, + max_version=DLPackVersion(1, 0), + ) + # Replace the original deleter with a tracking one to observe the call. + ptr[0].deleter = tracking_deleter(ptr[0].deleter) + + dlpack = DLPackArray(ptr) + capsule = dlpack.__dlpack__() + assert len(calls) == 0 + + del capsule + gc.collect() + assert len(calls) == 1 From 6eb18c90416fee7d2d749e28b44bd8f642f45c95 Mon Sep 17 00:00:00 2001 From: Guillaume Fraux Date: Mon, 20 Jul 2026 10:14:27 +0200 Subject: [PATCH 3/8] Run tests with older versions of torch/numpy --- .github/workflows/tests.yml | 6 ++++++ scripts/update-declarations.py | 4 +++- src/ctypes_dlpack/_c_api.py | 3 +++ tox.ini | 4 ++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 28500d4..c0ad7a9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,11 +9,14 @@ on: jobs: tests: runs-on: ${{ matrix.os }} + name: ${{ matrix.os }} - Python ${{ matrix.python-version }} strategy: matrix: include: - os: ubuntu-24.04 python-version: "3.10" + torch-pin: "==2.3.*" + numpy-pin: "==2.0" - os: ubuntu-24.04 python-version: "3.14" - os: macos-15 @@ -34,3 +37,6 @@ jobs: - name: Run tests run: tox -e tests + env: + CTYPES_DLPACK_TORCH_PIN: ${{ matrix.torch-pin }} + CTYPES_DLPACK_NUMPY_PIN: ${{ matrix.numpy-pin }} diff --git a/scripts/update-declarations.py b/scripts/update-declarations.py index f45fe46..12c5d58 100755 --- a/scripts/update-declarations.py +++ b/scripts/update-declarations.py @@ -57,7 +57,7 @@ def __eq__(self, other): return self.value == other return type(self) is type(other) and self.value == other.value -""".strip() +""" class EnumDef: @@ -294,6 +294,8 @@ def generate_python_module( for name in sorted(defines): file.write(f"{name} = {defines[name]}\n") + file.write("\n") + file.write(ENUM_HELPERS) for enum in parsed.enums: diff --git a/src/ctypes_dlpack/_c_api.py b/src/ctypes_dlpack/_c_api.py index 15fb461..5031a1f 100644 --- a/src/ctypes_dlpack/_c_api.py +++ b/src/ctypes_dlpack/_c_api.py @@ -6,6 +6,8 @@ DLPACK_FLAG_BITMASK_READ_ONLY = 1 << 0 DLPACK_MAJOR_VERSION = 1 DLPACK_MINOR_VERSION = 3 + + class _EnumType(type(ctypes.c_int32)): def __new__(metacls, name, bases, namespace): if "_members_" not in namespace: @@ -40,6 +42,7 @@ def __eq__(self, other): return type(self) is type(other) and self.value == other.value + class DLDeviceType(_Enum): pass kDLCPU = 1 diff --git a/tox.ini b/tox.ini index ea19079..1af1f86 100644 --- a/tox.ini +++ b/tox.ini @@ -27,8 +27,8 @@ commands = [testenv:tests] description = Run unit tests deps = - numpy - torch + numpy{env:CTYPES_DLPACK_NUMPY_PIN} + torch{env:CTYPES_DLPACK_TORCH_PIN} pytest commands = pytest {posargs} From d655b3c23e4f22fe381d6255ffc54f597a2248fa Mon Sep 17 00:00:00 2001 From: Guillaume Fraux Date: Mon, 20 Jul 2026 10:50:57 +0200 Subject: [PATCH 4/8] Also support returning unversionned DLManagedTensor This is the only type some old versions of pytorch can work with --- src/ctypes_dlpack/_dlpack.py | 109 ++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 14 deletions(-) diff --git a/src/ctypes_dlpack/_dlpack.py b/src/ctypes_dlpack/_dlpack.py index 347d220..51956e2 100644 --- a/src/ctypes_dlpack/_dlpack.py +++ b/src/ctypes_dlpack/_dlpack.py @@ -28,11 +28,7 @@ PYTHON_API.PyMem_RawFree.argtypes = [ctypes.c_void_p] PYTHON_API.PyCapsule_New.restype = ctypes.py_object -PYTHON_API.PyCapsule_New.argtypes = [ - ctypes.c_void_p, - ctypes.c_char_p, - ctypes.c_void_p, -] +PYTHON_API.PyCapsule_New.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p] # ``PyCapsule_GetName``/``PyCapsule_GetPointer`` re-bound with a raw ``c_void_p`` @@ -94,6 +90,37 @@ def _versioned_capsule_destructor(capsule_ptr): ) +def _unversioned_capsule_destructor(capsule_ptr): + """Release the ``DLManagedTensor`` held by a legacy capsule. + + See ``_versioned_capsule_destructor`` for why the argument is a raw + ``void*`` instead of ``py_object``. + """ + if not capsule_ptr: + return + + name = _PyCapsule_GetName_c_void_p(capsule_ptr) + if name != DLPACK_NAME: + # Already consumed by an importer; the importer owns the deleter call. + return + + ptr = _PyCapsule_GetPointer_c_void_p(capsule_ptr, DLPACK_NAME) + if not ptr: + return + + unversioned_ptr = ctypes.cast(ptr, ctypes.POINTER(DLManagedTensor)) + unversioned = unversioned_ptr.contents + if unversioned.deleter: + unversioned.deleter(unversioned_ptr) + + +# Keep a reference so the CFUNCTYPE is not garbage-collected while CPython holds +# the destructor pointer inside the capsule. +_UNVERSIONED_CAPSULE_DESTRUCTOR_C = ctypes.CFUNCTYPE(None, ctypes.c_void_p)( + _unversioned_capsule_destructor +) + + @ctypes.CFUNCTYPE(None, ctypes.POINTER(DLManagedTensorVersioned)) def _versioned_wrapper_deleter(versioned_ptr): """Forward to the legacy deleter, then free the wrapper allocation.""" @@ -109,6 +136,23 @@ def _versioned_wrapper_deleter(versioned_ptr): PYTHON_API.PyMem_RawFree(ctypes.cast(versioned_ptr, ctypes.c_void_p)) +@ctypes.CFUNCTYPE(None, ctypes.POINTER(DLManagedTensor)) +def _unversioned_wrapper_deleter(unversioned_ptr): + """Forward to the versioned deleter, then free the wrapper allocation.""" + if not unversioned_ptr: + return + + unversioned = unversioned_ptr.contents + original_ptr = ctypes.cast( + unversioned.manager_ctx, ctypes.POINTER(DLManagedTensorVersioned) + ) + + if original_ptr and original_ptr.contents.deleter: + original_ptr.contents.deleter(original_ptr) + + PYTHON_API.PyMem_RawFree(ctypes.cast(unversioned_ptr, ctypes.c_void_p)) + + def _wrap_unversioned_as_versioned(unversioned_ptr): """Wrap a legacy DLManagedTensor pointer in a versioned allocation.""" versioned_size = ctypes.sizeof(DLManagedTensorVersioned) @@ -137,6 +181,35 @@ def _wrap_unversioned_as_versioned(unversioned_ptr): return versioned_ptr +def _wrap_versioned_as_unversioned(versioned_ptr): + """Wrap a ``DLManagedTensorVersioned`` pointer in a legacy allocation. + + This is used to expose a versioned tensor to importers that only support the + unversioned DLPack protocol (``max_version`` is ``None`` or has a major + version of ``0``). + """ + unversioned_size = ctypes.sizeof(DLManagedTensor) + unversioned_mem = PYTHON_API.PyMem_RawMalloc(unversioned_size) + if not unversioned_mem: + raise MemoryError("failed to allocate DLManagedTensor") + + unversioned_ptr = ctypes.cast(unversioned_mem, ctypes.POINTER(DLManagedTensor)) + unversioned = unversioned_ptr.contents + + unversioned.manager_ctx = ctypes.cast(versioned_ptr, ctypes.c_void_p) + unversioned.deleter = _unversioned_wrapper_deleter + + # Both structs embed a ``DLTensor`` with identical layout; copy the payload + # from the versioned struct into the legacy one. + ctypes.memmove( + ctypes.addressof(unversioned.dl_tensor), + ctypes.addressof(versioned_ptr.contents.dl_tensor), + ctypes.sizeof(DLTensor), + ) + + return unversioned_ptr + + def _make_dlpack_versioned_capsule(dl_managed_versioned_ptr): ptr_value = ctypes.cast(dl_managed_versioned_ptr, ctypes.c_void_p).value if not ptr_value: @@ -146,6 +219,15 @@ def _make_dlpack_versioned_capsule(dl_managed_versioned_ptr): return PYTHON_API.PyCapsule_New(ptr_value, DLPACK_VERSIONED_NAME, destructor) +def _make_dlpack_capsule(dl_managed_ptr): + ptr_value = ctypes.cast(dl_managed_ptr, ctypes.c_void_p).value + if not ptr_value: + raise ValueError("DLManagedTensor pointer is null") + + destructor = ctypes.cast(_UNVERSIONED_CAPSULE_DESTRUCTOR_C, ctypes.c_void_p) + return PYTHON_API.PyCapsule_New(ptr_value, DLPACK_NAME, destructor) + + def _call_dlpack_with_fallbacks(array, stream, max_version, dl_device, copy): """Try multiple __dlpack__ signatures for broad compatibility.""" variants = ( @@ -215,14 +297,6 @@ def __dlpack__( if stream is not None: raise RuntimeError("only `stream=None` is currently supported") - version = pointer[0].version - version = (version.major, version.minor) - if max_version is not None and version[0] > max_version[0]: - raise RuntimeError( - f"requested DLPack version {max_version}, " - f"but tensor has version {version}" - ) - if dl_device is not None and dl_device != self.__dlpack_device__(): raise RuntimeError("device conversion is not supported") @@ -233,7 +307,14 @@ def __dlpack__( raise RuntimeError("only `copy=False` is supported") self._pointer = None - return _make_dlpack_versioned_capsule(pointer) + if max_version is None or max_version[0] == 0: + # The caller only supports the unversioned DLPack protocol (or did + # not request a specific version): wrap our versioned tensor into a + # legacy ``DLManagedTensor`` capsule. + unversioned_ptr = _wrap_versioned_as_unversioned(pointer) + return _make_dlpack_capsule(unversioned_ptr) + else: + return _make_dlpack_versioned_capsule(pointer) def __dlpack_device__(self): if self._pointer is None: From 6dfd9867555036f04d1864ab2af110dab9cfe5f5 Mon Sep 17 00:00:00 2001 From: Guillaume Fraux Date: Mon, 20 Jul 2026 11:03:22 +0200 Subject: [PATCH 5/8] Multiple small fixes --- src/ctypes_dlpack/_dlpack.py | 205 +++++++++++++++++++++++------------ 1 file changed, 137 insertions(+), 68 deletions(-) diff --git a/src/ctypes_dlpack/_dlpack.py b/src/ctypes_dlpack/_dlpack.py index 51956e2..ac952bd 100644 --- a/src/ctypes_dlpack/_dlpack.py +++ b/src/ctypes_dlpack/_dlpack.py @@ -160,25 +160,31 @@ def _wrap_unversioned_as_versioned(unversioned_ptr): if not versioned_mem: raise MemoryError("failed to allocate DLManagedTensorVersioned") - versioned_ptr = ctypes.cast(versioned_mem, ctypes.POINTER(DLManagedTensorVersioned)) - versioned = versioned_ptr.contents - - versioned.version = DLPackVersion() - versioned.version.major = 1 - versioned.version.minor = 0 - versioned.manager_ctx = ctypes.cast(unversioned_ptr, ctypes.c_void_p) - versioned.deleter = _versioned_wrapper_deleter - versioned.flags = 0 - - # DLManagedTensor starts with a DLTensor, so copying from the struct base - # address into versioned.dl_tensor preserves the payload layout. - ctypes.memmove( - ctypes.addressof(versioned.dl_tensor), - unversioned_ptr, - ctypes.sizeof(DLTensor), - ) + try: + versioned_ptr = ctypes.cast( + versioned_mem, ctypes.POINTER(DLManagedTensorVersioned) + ) + versioned = versioned_ptr.contents + + versioned.version = DLPackVersion() + versioned.version.major = 1 + versioned.version.minor = 0 + versioned.manager_ctx = ctypes.cast(unversioned_ptr, ctypes.c_void_p) + versioned.deleter = _versioned_wrapper_deleter + versioned.flags = 0 + + # DLManagedTensor starts with a DLTensor, so copying from the struct base + # address into versioned.dl_tensor preserves the payload layout. + ctypes.memmove( + ctypes.addressof(versioned.dl_tensor), + unversioned_ptr, + ctypes.sizeof(DLTensor), + ) - return versioned_ptr + return versioned_ptr + except BaseException: + PYTHON_API.PyMem_RawFree(versioned_mem) + raise def _wrap_versioned_as_unversioned(versioned_ptr): @@ -193,21 +199,25 @@ def _wrap_versioned_as_unversioned(versioned_ptr): if not unversioned_mem: raise MemoryError("failed to allocate DLManagedTensor") - unversioned_ptr = ctypes.cast(unversioned_mem, ctypes.POINTER(DLManagedTensor)) - unversioned = unversioned_ptr.contents + try: + unversioned_ptr = ctypes.cast(unversioned_mem, ctypes.POINTER(DLManagedTensor)) + unversioned = unversioned_ptr.contents - unversioned.manager_ctx = ctypes.cast(versioned_ptr, ctypes.c_void_p) - unversioned.deleter = _unversioned_wrapper_deleter + unversioned.manager_ctx = ctypes.cast(versioned_ptr, ctypes.c_void_p) + unversioned.deleter = _unversioned_wrapper_deleter - # Both structs embed a ``DLTensor`` with identical layout; copy the payload - # from the versioned struct into the legacy one. - ctypes.memmove( - ctypes.addressof(unversioned.dl_tensor), - ctypes.addressof(versioned_ptr.contents.dl_tensor), - ctypes.sizeof(DLTensor), - ) + # Both structs embed a ``DLTensor`` with identical layout; copy the + # payload from the versioned struct into the legacy one. + ctypes.memmove( + ctypes.addressof(unversioned.dl_tensor), + ctypes.addressof(versioned_ptr.contents.dl_tensor), + ctypes.sizeof(DLTensor), + ) - return unversioned_ptr + return unversioned_ptr + except BaseException: + PYTHON_API.PyMem_RawFree(unversioned_mem) + raise def _make_dlpack_versioned_capsule(dl_managed_versioned_ptr): @@ -229,26 +239,34 @@ def _make_dlpack_capsule(dl_managed_ptr): def _call_dlpack_with_fallbacks(array, stream, max_version, dl_device, copy): - """Try multiple __dlpack__ signatures for broad compatibility.""" - variants = ( - { - "stream": stream, - "max_version": max_version, - "dl_device": dl_device, - "copy": copy, - }, - {"stream": stream}, - None, - ) + """Try multiple __dlpack__ signatures for broad compatibility. + This tries both the ``2023.12`` array API signature (with ``max_version``, + ``dl_device``, and ``copy`` kwargs), as well as the legacy signature (with only + ``stream``). The first signature that does not raise a ``TypeError`` (due to unknown + keyword arguments) is used, and the resulting capsule is returned. + """ last_error = None - for kwargs in variants: - try: - if kwargs is None: - return array.__dlpack__() - return array.__dlpack__(**kwargs) - except Exception as error: # noqa: BLE001 - last_error = error + + try: + return array.__dlpack__( + stream=stream, + max_version=max_version, + dl_device=dl_device, + copy=copy, + ) + except TypeError as error: + last_error = error + + try: + return array.__dlpack__(stream=stream) + except TypeError as error: + last_error = error + + try: + return array.__dlpack__() + except TypeError as error: + last_error = error raise RuntimeError("__dlpack__ call failed") from last_error @@ -281,8 +299,26 @@ def __init__(self, pointer: ctypes.POINTER(DLManagedTensorVersioned)): :param pointer: pointer to a ``DLManagedTensorVersioned`` to be re-exposed as Python object following the ``__dlpack__`` protocol. """ + if not isinstance(pointer, ctypes.POINTER(DLManagedTensorVersioned)): + raise TypeError( + "`pointer` must be a ctypes.POINTER(DLManagedTensorVersioned), " + f"got {type(pointer).__name__}" + ) + + if not pointer: + raise ValueError("`pointer` is null") + self._pointer = pointer + def __del__(self): + pointer = getattr(self, "_pointer", None) + if pointer is None: + return + + self._pointer = None + if pointer.contents.deleter: + pointer.contents.deleter(pointer) + def __dlpack__( self, stream: int | None = None, @@ -298,30 +334,38 @@ def __dlpack__( raise RuntimeError("only `stream=None` is currently supported") if dl_device is not None and dl_device != self.__dlpack_device__(): - raise RuntimeError("device conversion is not supported") + raise BufferError("device conversion is not supported") if copy is None: copy = False if copy: - raise RuntimeError("only `copy=False` is supported") + raise BufferError("only `copy=False` is supported") - self._pointer = None if max_version is None or max_version[0] == 0: # The caller only supports the unversioned DLPack protocol (or did # not request a specific version): wrap our versioned tensor into a # legacy ``DLManagedTensor`` capsule. unversioned_ptr = _wrap_versioned_as_unversioned(pointer) - return _make_dlpack_capsule(unversioned_ptr) + result = _make_dlpack_capsule(unversioned_ptr) else: - return _make_dlpack_versioned_capsule(pointer) + result = _make_dlpack_versioned_capsule(pointer) + + self._pointer = None + return result def __dlpack_device__(self): if self._pointer is None: raise RuntimeError("can not query __dlpack_device__ after __dlpack__") device = self._pointer[0].dl_tensor.device - return (device.device_type, device.device_id) + return (device.device_type.value, device.device_id) + + def __repr__(self): + if self._pointer is None: + return "" + + return "" def array_as_dlpack( @@ -339,12 +383,12 @@ def array_as_dlpack( for the protocol specification. This function will try to work with older version of the ``__dlpack__`` protocol, - wrapping the unversionned ``DLManagedTensor`` returned into a + wrapping the unversioned ``DLManagedTensor`` returned into a ``DLManagedTensorVersioned``. Some arguments might be ignored in this case, since they do not have an equivalent. :param array: any array type that implements the ``__dlpack__`` protocol - :param stream: the CUDA or ROCm syncronization stream, passed as-is to the + :param stream: the CUDA or ROCm synchronization stream, passed as-is to the ``__dlpack__`` method of ``array``. :param max_version: the maximum DLPack version that the caller of this function supports. If this is a ``DLPackVersion``, it is converted to the 2-tuple @@ -359,17 +403,35 @@ def array_as_dlpack( max_version_tuple = None elif isinstance(max_version, DLPackVersion): max_version_tuple = (max_version.major, max_version.minor) - else: - assert isinstance(max_version, tuple) and len(max_version) == 2 + elif ( + isinstance(max_version, tuple) + and len(max_version) == 2 + and isinstance(max_version[0], int) + and isinstance(max_version[1], int) + ): max_version_tuple = max_version + else: + raise TypeError( + "`max_version` must be a DLPackVersion, a 2-tuple of ints, or None; " + f"got {type(max_version).__name__}" + ) if dl_device is None: dl_device_tuple = None elif isinstance(dl_device, DLDevice): - dl_device_tuple = (dl_device.device_type, dl_device.device_id) - else: - assert isinstance(dl_device, tuple) and len(dl_device) == 2 + dl_device_tuple = (int(dl_device.device_type.value), dl_device.device_id) + elif ( + isinstance(dl_device, tuple) + and len(dl_device) == 2 + and isinstance(dl_device[0], int) + and isinstance(dl_device[1], int) + ): dl_device_tuple = dl_device + else: + raise TypeError( + "`dl_device` must be a DLDevice, a 2-tuple of ints, or None; " + f"got {type(dl_device).__name__}" + ) capsule = _call_dlpack_with_fallbacks( array, @@ -385,31 +447,38 @@ def array_as_dlpack( if not pointer: raise RuntimeError("failed to get pointer from versioned capsule") + result = ctypes.cast(pointer, ctypes.POINTER(DLManagedTensorVersioned)) + status = PYTHON_API.PyCapsule_SetName(capsule, USED_DLPACK_VERSIONED_NAME) if status != 0: raise RuntimeError("failed to mark versioned capsule as used") - - result = ctypes.cast(pointer, ctypes.POINTER(DLManagedTensorVersioned)) elif capsule_name == DLPACK_NAME: pointer = PYTHON_API.PyCapsule_GetPointer(capsule, DLPACK_NAME) if not pointer: raise RuntimeError("failed to get pointer from legacy capsule") + unversioned_ptr = ctypes.cast(pointer, ctypes.POINTER(DLManagedTensor)) + result = _wrap_unversioned_as_versioned(unversioned_ptr) + + # Only disown the capsule once ``result`` is fully built; if SetName ran + # earlier and any subsequent step raised, the capsule destructor would + # see the ``used_`` name and skip the deleter, leaking the source tensor. status = PYTHON_API.PyCapsule_SetName(capsule, USED_DLPACK_NAME) if status != 0: raise RuntimeError("failed to mark legacy capsule as used") - - unversioned_ptr = ctypes.cast(pointer, ctypes.POINTER(DLManagedTensor)) - result = _wrap_unversioned_as_versioned(unversioned_ptr) else: name = capsule_name.decode() if capsule_name else "NULL" raise RuntimeError( - f"unexpected capsule name: {name}; expected dltensor or dltensor_versioned" + f"unexpected capsule name: {name}; expected 'dltensor' " + "or 'dltensor_versioned'" ) actual_device = result.contents.dl_tensor.device actual_device = (actual_device.device_type.value, actual_device.device_id) - if actual_device != dl_device_tuple: + if dl_device_tuple is not None and actual_device != dl_device_tuple: + if result.contents.deleter: + result.contents.deleter(result) + raise ValueError( "DLPack device mismatch: " f"expected type={dl_device_tuple[0]} id={dl_device_tuple[1]}, " From bf2cb5bf680a4b7f7dcc8b41eda8f7267c87be82 Mon Sep 17 00:00:00 2001 From: Guillaume Fraux Date: Mon, 20 Jul 2026 11:11:37 +0200 Subject: [PATCH 6/8] Handle exceptions created in the C API of Python --- src/ctypes_dlpack/_dlpack.py | 48 +++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/ctypes_dlpack/_dlpack.py b/src/ctypes_dlpack/_dlpack.py index ac952bd..6b6d128 100644 --- a/src/ctypes_dlpack/_dlpack.py +++ b/src/ctypes_dlpack/_dlpack.py @@ -30,6 +30,29 @@ PYTHON_API.PyCapsule_New.restype = ctypes.py_object PYTHON_API.PyCapsule_New.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p] +PYTHON_API.PyErr_Fetch.restype = None +PYTHON_API.PyErr_Fetch.argtypes = [ + ctypes.POINTER(ctypes.py_object), + ctypes.POINTER(ctypes.py_object), + ctypes.POINTER(ctypes.py_object), +] + +PYTHON_API.PyErr_Clear.restype = None +PYTHON_API.PyErr_Clear.argtypes = [] + + +def _fetch_pyerr(): + """Detach and return the current C-API exception instance, or ``None``. + + ``PyErr_Fetch`` clears the thread-local exception state, so the caller is free to + raise a fresh exception without CPython discarding the original one. + """ + etype = ctypes.py_object() + evalue = ctypes.py_object() + etb = ctypes.py_object() + PYTHON_API.PyErr_Fetch(ctypes.byref(etype), ctypes.byref(evalue), ctypes.byref(etb)) + return evalue.value or None + # ``PyCapsule_GetName``/``PyCapsule_GetPointer`` re-bound with a raw ``c_void_p`` # argument. These are needed from inside ``_versioned_capsule_destructor``: @@ -70,11 +93,13 @@ def _versioned_capsule_destructor(capsule_ptr): name = _PyCapsule_GetName_c_void_p(capsule_ptr) if name != DLPACK_VERSIONED_NAME: - # Already consumed by an importer; the importer owns the deleter call. + # Clear any C-API exception (e.g. set by ``GetName`` on failure) + PYTHON_API.PyErr_Clear() return ptr = _PyCapsule_GetPointer_c_void_p(capsule_ptr, DLPACK_VERSIONED_NAME) if not ptr: + PYTHON_API.PyErr_Clear() return versioned_ptr = ctypes.cast(ptr, ctypes.POINTER(DLManagedTensorVersioned)) @@ -101,11 +126,13 @@ def _unversioned_capsule_destructor(capsule_ptr): name = _PyCapsule_GetName_c_void_p(capsule_ptr) if name != DLPACK_NAME: - # Already consumed by an importer; the importer owns the deleter call. + # Clear any C-API exception (see ``_versioned_capsule_destructor``). + PYTHON_API.PyErr_Clear() return ptr = _PyCapsule_GetPointer_c_void_p(capsule_ptr, DLPACK_NAME) if not ptr: + PYTHON_API.PyErr_Clear() return unversioned_ptr = ctypes.cast(ptr, ctypes.POINTER(DLManagedTensor)) @@ -445,17 +472,21 @@ def array_as_dlpack( if capsule_name == DLPACK_VERSIONED_NAME: pointer = PYTHON_API.PyCapsule_GetPointer(capsule, DLPACK_VERSIONED_NAME) if not pointer: - raise RuntimeError("failed to get pointer from versioned capsule") + err = _fetch_pyerr() + raise RuntimeError("failed to get pointer from versioned capsule") from err result = ctypes.cast(pointer, ctypes.POINTER(DLManagedTensorVersioned)) status = PYTHON_API.PyCapsule_SetName(capsule, USED_DLPACK_VERSIONED_NAME) if status != 0: - raise RuntimeError("failed to mark versioned capsule as used") + err = _fetch_pyerr() + raise RuntimeError("failed to mark versioned capsule as used") from err + elif capsule_name == DLPACK_NAME: pointer = PYTHON_API.PyCapsule_GetPointer(capsule, DLPACK_NAME) if not pointer: - raise RuntimeError("failed to get pointer from legacy capsule") + err = _fetch_pyerr() + raise RuntimeError("failed to get pointer from legacy capsule") from err unversioned_ptr = ctypes.cast(pointer, ctypes.POINTER(DLManagedTensor)) result = _wrap_unversioned_as_versioned(unversioned_ptr) @@ -465,8 +496,13 @@ def array_as_dlpack( # see the ``used_`` name and skip the deleter, leaking the source tensor. status = PYTHON_API.PyCapsule_SetName(capsule, USED_DLPACK_NAME) if status != 0: - raise RuntimeError("failed to mark legacy capsule as used") + err = _fetch_pyerr() + raise RuntimeError("failed to mark legacy capsule as used") from err else: + # Unexpected capsule name. If ``GetName`` itself failed (returned NULL) + # it set a C-API exception which we clear here; otherwise there is + # nothing to clear. + PYTHON_API.PyErr_Clear() name = capsule_name.decode() if capsule_name else "NULL" raise RuntimeError( f"unexpected capsule name: {name}; expected 'dltensor' " From 5565fcc20b2bdcde1f5778ff4e0d9736eb9b4b71 Mon Sep 17 00:00:00 2001 From: Guillaume Fraux Date: Mon, 20 Jul 2026 11:43:56 +0200 Subject: [PATCH 7/8] Explicitly list public names to re-export --- src/ctypes_dlpack/__init__.py | 64 +++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/src/ctypes_dlpack/__init__.py b/src/ctypes_dlpack/__init__.py index 5b00384..735f6ee 100644 --- a/src/ctypes_dlpack/__init__.py +++ b/src/ctypes_dlpack/__init__.py @@ -1,27 +1,61 @@ -from . import _c_api +from ._c_api import ( + DLPACK_FLAG_BITMASK_IS_COPIED, + DLPACK_FLAG_BITMASK_IS_SUBBYTE_TYPE_PADDED, + DLPACK_FLAG_BITMASK_READ_ONLY, + DLPACK_MAJOR_VERSION, + DLPACK_MINOR_VERSION, + DLDataType, + DLDataTypeCode, + DLDevice, + DLDeviceType, + DLManagedTensor, + DLManagedTensorVersioned, + DLPackCurrentWorkStream, + DLPackDLTensorFromPyObjectNoSync, + DLPackExchangeAPI, + DLPackExchangeAPIHeader, + DLPackManagedTensorAllocator, + DLPackManagedTensorFromPyObjectNoSync, + DLPackManagedTensorToPyObjectNoSync, + DLPackVersion, + DLTensor, +) from ._dlpack import DLPackArray, array_as_dlpack __version__ = "0.1.0" -# expose all public members of the C API at the package level -_public_members = { - name: getattr(_c_api, name) for name in dir(_c_api) if not name.startswith("_") -} - -for name, value in _public_members.items(): - globals()[name] = value - - if getattr(value, "__module__", None) == _c_api.__name__: - value.__module__ = __name__ - - __all__ = [ - *_public_members.keys(), + "DLPACK_FLAG_BITMASK_IS_COPIED", + "DLPACK_FLAG_BITMASK_IS_SUBBYTE_TYPE_PADDED", + "DLPACK_FLAG_BITMASK_READ_ONLY", + "DLPACK_MAJOR_VERSION", + "DLPACK_MINOR_VERSION", + "DLPackManagedTensorAllocator", + "DLPackManagedTensorFromPyObjectNoSync", + "DLPackDLTensorFromPyObjectNoSync", + "DLPackCurrentWorkStream", + "DLPackManagedTensorToPyObjectNoSync", + "DLDeviceType", + "DLDataTypeCode", + "DLPackVersion", + "DLDevice", + "DLDataType", + "DLTensor", + "DLManagedTensor", + "DLManagedTensorVersioned", + "DLPackExchangeAPIHeader", + "DLPackExchangeAPI", "DLPackArray", "array_as_dlpack", ] -del _public_members +for _name in __all__: + _value = globals()[_name] + if getattr(_value, "__module__", None) == "ctypes_dlpack._c_api": + _value.__module__ = __name__ + + +del _name, _value From 3f137ba437414c7a06dbca421b78429fb87f67c9 Mon Sep 17 00:00:00 2001 From: Guillaume Fraux Date: Mon, 20 Jul 2026 12:03:11 +0200 Subject: [PATCH 8/8] Improve _EnumType and _Enum --- scripts/update-declarations.py | 11 +++++----- src/ctypes_dlpack/_c_api.py | 11 +++++----- tests/test_enum.py | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 tests/test_enum.py diff --git a/scripts/update-declarations.py b/scripts/update-declarations.py index 12c5d58..baf674c 100755 --- a/scripts/update-declarations.py +++ b/scripts/update-declarations.py @@ -36,10 +36,7 @@ def __new__(metacls, name, bases, namespace): members = namespace["_members_"] namespace["_reverse_map_"] = {v: k for k, v in members.items()} - cls = type(ctypes.c_int32).__new__(metacls, name, bases, namespace) - for key, value in cls._members_.items(): - globals()[key] = value - return cls + return type(ctypes.c_int32).__new__(metacls, name, bases, namespace) def __repr__(self): return f"" @@ -55,8 +52,12 @@ def __repr__(self): def __eq__(self, other): if isinstance(other, int): return self.value == other + if type(self) is type(other): + return self.value == other.value + return NotImplemented - return type(self) is type(other) and self.value == other.value + def __hash__(self): + return hash(self.value) """ diff --git a/src/ctypes_dlpack/_c_api.py b/src/ctypes_dlpack/_c_api.py index 5031a1f..0173c7d 100644 --- a/src/ctypes_dlpack/_c_api.py +++ b/src/ctypes_dlpack/_c_api.py @@ -20,10 +20,7 @@ def __new__(metacls, name, bases, namespace): members = namespace["_members_"] namespace["_reverse_map_"] = {v: k for k, v in members.items()} - cls = type(ctypes.c_int32).__new__(metacls, name, bases, namespace) - for key, value in cls._members_.items(): - globals()[key] = value - return cls + return type(ctypes.c_int32).__new__(metacls, name, bases, namespace) def __repr__(self): return f"" @@ -39,8 +36,12 @@ def __repr__(self): def __eq__(self, other): if isinstance(other, int): return self.value == other + if type(self) is type(other): + return self.value == other.value + return NotImplemented - return type(self) is type(other) and self.value == other.value + def __hash__(self): + return hash(self.value) class DLDeviceType(_Enum): diff --git a/tests/test_enum.py b/tests/test_enum.py new file mode 100644 index 0000000..e6e5664 --- /dev/null +++ b/tests/test_enum.py @@ -0,0 +1,39 @@ +from ctypes_dlpack import DLDataTypeCode, DLDeviceType + + +def test_enum_eq(): + assert DLDeviceType.kDLCPU == 1 + assert 1 == DLDeviceType.kDLCPU + assert DLDeviceType.kDLCUDA == 2 + assert DLDeviceType.kDLCPU != 2 + + assert DLDeviceType.kDLCPU == DLDeviceType.kDLCPU + assert DLDeviceType.kDLCPU != DLDeviceType.kDLCUDA + + # Both have value 1, but different enum types — must not compare equal. + assert DLDeviceType.kDLCPU != DLDataTypeCode.kDLInt + assert DLDataTypeCode.kDLInt != DLDeviceType.kDLCPU + + +def test_enum_hashable(): + table = {DLDeviceType.kDLCPU: "cpu", DLDeviceType.kDLCUDA: "cuda"} + assert table[DLDeviceType.kDLCPU] == "cpu" + assert table[1] == "cpu" + assert table[DLDeviceType.kDLCUDA] == "cuda" + + s = {DLDeviceType.kDLCPU, DLDeviceType.kDLCUDA, DLDeviceType.kDLCPU} + assert len(s) == 2 + + +def test_enum_repr(): + # ``DLDeviceType.kDLCPU`` is a plain ``int`` (class-body assignment); the + # custom ``__repr__`` only applies to instances, as produced by ctypes + # when reading a structure field typed as ``DLDeviceType``. + assert repr(DLDeviceType(1)) == "DLDeviceType.kDLCPU" + assert repr(DLDataTypeCode(2)) == "DLDataTypeCode.kDLFloat" + + unknown = DLDeviceType(99) + assert repr(unknown) == "DLDeviceType.99" + + assert repr(DLDeviceType) == "" + assert repr(DLDataTypeCode) == ""