diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c1ebdf23..4ab60d014 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,7 @@ option(NB_TEST_STABLE_ABI "Test the stable ABI interface?" OFF) option(NB_TEST_SHARED_BUILD "Build a shared nanobind library for the test suite?" OFF) option(NB_TEST_CUDA "Force the use of the CUDA/NVCC compiler for testing purposes" OFF) option(NB_TEST_FREE_THREADED "Build free-threaded extensions for the test suite?" ON) +option(NB_TEST_NO_INTEROP "Build without framework interoperability support?" OFF) if (NOT MSVC) option(NB_TEST_SANITIZERS_ASAN "Build tests with the address sanitizer?" OFF) diff --git a/cmake/nanobind-config.cmake b/cmake/nanobind-config.cmake index bc680e577..6e6604942 100644 --- a/cmake/nanobind-config.cmake +++ b/cmake/nanobind-config.cmake @@ -190,6 +190,7 @@ function (nanobind_build_library TARGET_NAME) ${NB_DIR}/src/nb_static_property.cpp ${NB_DIR}/src/nb_ft.h ${NB_DIR}/src/nb_ft.cpp + ${NB_DIR}/src/nb_foreign.cpp ${NB_DIR}/src/common.cpp ${NB_DIR}/src/error.cpp ${NB_DIR}/src/trampoline.cpp @@ -236,6 +237,10 @@ function (nanobind_build_library TARGET_NAME) target_compile_definitions(${TARGET_NAME} PUBLIC NB_FREE_THREADED) endif() + if (TARGET_NAME MATCHES "-local") + target_compile_definitions(${TARGET_NAME} PRIVATE NB_DISABLE_INTEROP) + endif() + # Nanobind performs many assertion checks -- detailed error messages aren't # included in Release/MinSizeRel/RelWithDebInfo modes target_compile_definitions(${TARGET_NAME} PRIVATE @@ -330,7 +335,7 @@ endfunction() function(nanobind_add_module name) cmake_parse_arguments(PARSE_ARGV 1 ARG - "STABLE_ABI;FREE_THREADED;NB_STATIC;NB_SHARED;PROTECT_STACK;LTO;NOMINSIZE;NOSTRIP;MUSL_DYNAMIC_LIBCPP;NB_SUPPRESS_WARNINGS" + "STABLE_ABI;FREE_THREADED;NB_STATIC;NB_SHARED;PROTECT_STACK;LTO;NOMINSIZE;NOSTRIP;MUSL_DYNAMIC_LIBCPP;NB_SUPPRESS_WARNINGS;NO_INTEROP" "NB_DOMAIN" "") add_library(${name} MODULE ${ARG_UNPARSED_ARGUMENTS}) @@ -375,6 +380,10 @@ function(nanobind_add_module name) set(libname "${libname}-ft") endif() + if (ARG_NO_INTEROP) + set(libname "${libname}-local") + endif() + if (ARG_NB_DOMAIN AND ARG_NB_SHARED) set(libname ${libname}-${ARG_NB_DOMAIN}) endif() diff --git a/docs/api_cmake.rst b/docs/api_cmake.rst index 13b492e55..2f7946348 100644 --- a/docs/api_cmake.rst +++ b/docs/api_cmake.rst @@ -110,6 +110,10 @@ The high-level interface consists of just one CMake command: an optimization that nanobind does by default in this specific case). If this explanation sounds confusing, then you can ignore it. See the detailed description below for more information on this step. + * - ``NO_INTEROP`` + - Remove support for :ref:`interoperability with other Python binding + frameworks `. If you don't need it in your environment, this + offers a minor performance and code size benefit. :cmake:command:`nanobind_add_module` performs the following steps to produce bindings. diff --git a/docs/api_core.rst b/docs/api_core.rst index 23048737d..dfa61f327 100644 --- a/docs/api_core.rst +++ b/docs/api_core.rst @@ -3142,12 +3142,24 @@ Global flags functions are still alive when the Python interpreter shuts down. Call this function to disable or re-enable leak warnings. + The configuration affected by this function is global to a :ref:`nanobind + domain `. If you use it, you are encouraged to isolate your + extension from others by passing the ``NB_DOMAIN`` parameter to the + :cmake:command:`nanobind_add_module()` CMake command, so that your choices + don't need to impact unrelated extensions. + .. cpp:function:: void set_implicit_cast_warnings(bool value) noexcept By default, nanobind loudly complains when it attempts to perform an implicit conversion, and when that conversion is not successful. Call this function to disable or re-enable the warnings. + The configuration affected by this function is global to a :ref:`nanobind + domain `. If you use it, you are encouraged to isolate your + extension from others by passing the ``NB_DOMAIN`` parameter to the + :cmake:command:`nanobind_add_module()` CMake command, so that your choices + don't need to impact unrelated extensions. + .. cpp:function:: inline bool is_alive() noexcept The function returns ``true`` when nanobind is initialized and ready for @@ -3156,6 +3168,86 @@ Global flags this liveness status can be useful to avoid operations that are illegal in the latter context. +Interoperability +---------------- + +See the :ref:`separate interoperability documentation ` for important +additional information and caveats about this feature. + +.. cpp:function:: void interoperate_by_default(bool export_all = true, bool import_all = true) + + Configure whether this :ref:`nanobind domain ` should exchange + type information with other binding libraries (and other nanobind domains) + so that functions bound in one can accept and return objects whose types are + bound in another. The default, if :cpp:func:`interoperate_by_default` is not + called, is to exchange such information only when requested on a per-type + basis via calls to :cpp:func:`import_for_interop` or + :cpp:func:`export_for_interop`. + + By specifying arguments to :cpp:func:`interoperate_by_default`, it is + possible to separately configure whether to enable automatic sharing of + our types with others (*export_all*) or automatic use of types shared by + others (*import_all*). Once either type of automatic sharing is enabled, + it remains enabled for the lifetime of the Python interpreter. + + Automatic export is equivalent to calling :cpp:func:`export_for_interop` on + each type produced by a :cpp:class:`nb::class_` or :cpp:struct:`nb::enum_` + binding statement in this nanobind domain. + + Automatic import is equivalent to calling :cpp:func:`import_for_interop` on + each type exported by a different binding library or nanobind domain, as + long as that library is written in C++ and uses a compatible platform ABI. + In order to interoperate with a type binding that was created in another + language, including pure C, you must still make an explicit call to + :cpp:func:`import_for_interop`, providing a template argument so that + nanobind can tell which C++ type should be associated with it. + + Enabling automatic export or import affects both types that have already been + bound and types that are yet to be bound. + + The configuration affected by this function is global to a nanobind + domain. If you use it, you are encouraged to isolate your extension from + others by passing the ``NB_DOMAIN`` parameter to the + :cmake:command:`nanobind_add_module()` CMake command, so that your choices + don't need to impact unrelated extensions. + +.. cpp:function:: template void import_for_interop(handle type) + + Make the Python type object *type*, which was bound using a different binding + framework (or different nanobind domain) and then exported using a facility + like our :cpp:func:`export_for_interop`, be available so that functions bound + in this nanobind domain can accept and return its instances using the + C++ type ``T``, or the C++ type that was specified when *type* was bound. + + If no template argument is specified, then *type* must have been bound by + another C++ binding library that uses the same :ref:`platform ABI + ` as us; the C++ type will be determined by inspecting its + binding. + + If a template argument is specified, then nanobind associates that C++ type + with the given Python type, trusting rather than verifying that they match. + The C++ type ``T`` must perfectly match the memory layout and ABI of the + structure used by whoever bound the Python *type*. + This is the only way to import types written in other languages than C++ + (including those in plain C), since nanobind needs a ``std::type_info`` for + its type lookups and non-C++ bindings don't provide those directly. + + Repeatedly importing the same Python type as the same C++ type is idempotent. + Importing the same Python type as multiple different C++ types will fail. + + A descriptive C++ exception will be thrown if the import fails. + +.. cpp:function:: void export_for_interop(handle type) + + Make the Python type object *type*, which was created by a + :cpp:class:`nb::class_` or :cpp:class:`nb::enum_` binding statement in this + nanobind domain be available for import by other binding libraries and other + nanobind domains. If they do so, then their bound functions will be able to + accept and return instances of *type*. + + Repeatedly exporting the same type is idempotent. + + Miscellaneous ------------- diff --git a/docs/changelog.rst b/docs/changelog.rst index fe3cccfc0..819d5f28e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,24 @@ case, both modules must use the same nanobind ABI version, or they will be isolated from each other. Releases that don't explicitly mention an ABI version below inherit that of the preceding release. +Version TBD (unreleased) +------------------------ + +.. TODO: update the pybind11 version number below before releasing + +- nanobind has adopted the new `pymetabind + `__ standard for interoperating + with other Python binding libraries (including other ABI versions of + nanobind). When the interoperability feature is activated, which in most cases + is as simple as writing :cpp:func:`nb::interoperate_by_default() + `, a function or method that is bound using nanobind + can accept and return values of types that were bound using other binding + libraries that support pymetabind, notably including pybind11 versions !TBD! + and later. This feature is likely to be of **great utility** to anyone who + is working on porting large or interconnected extension modules from pybind11 + to nanobind. See the extensive :ref:`interoperability documentation ` + for more details. (PR `#1140 `__). + Version 2.9.2 (Sep 4, 2025) --------------------------- @@ -103,7 +121,6 @@ Version 2.9.0 (Sep 4, 2025) `#1132 `__, `#1090 `__). - Version 2.8.0 (July 16, 2025) ----------------------------- @@ -383,7 +400,7 @@ Version 2.3.0 There is no version 2.3.0 due to a deployment mishap. -- Added casters for `Eigen::Map` types from the `Eigen library +- Added casters for ``Eigen::Map`` types from the `Eigen library `__. (PR `#782 `_). diff --git a/docs/faq.rst b/docs/faq.rst index e37c807bf..4981c97c0 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -233,33 +233,35 @@ will: .. _type-visibility: -How can I avoid conflicts with other projects using nanobind? -------------------------------------------------------------- - -Suppose that a type binding in your project conflicts with another extension, for -example because both expose a common type (e.g., ``std::latch``). nanobind will -warn whenever it detects such a conflict: +How can I control whether two extension modules see each other's types? +----------------------------------------------------------------------- + +nanobind creates a variety of internal data structures to support the bindings +you ask it to make. These are not isolated to a single extension module, +because it's useful for large binding projects to be able to split related +bindings into multiple extensions without losing their ability to work with +one another's types. Instead, extension modules are divided into nanobind +*domains* based on two attributes: an automatically determined string (the +nanobind "ABI tag") that captures compatibility-relevant aspects of their +build environments and nanobind versions, and an ``NB_DOMAIN`` string that +may be provided at build time (as shown below). +If multiple extension modules share the same ABI tag and the same ``NB_DOMAIN`` +string, they will wind up in the same nanobind domain, which allows them to +work together exactly as if they were one big extension. + +Sometimes, this causes problems. For example, you might expose a binding +for a commonly used type (such as ``std::latch``) that some other nanobind +extension in your Python interpreter also happens to provide a binding for. +nanobind will warn whenever it detects such a conflict: .. code-block:: text RuntimeWarning: nanobind: type 'latch' was already registered! In the worst case, this could actually break both packages (especially if the -bindings of the two packages expose an inconsistent/incompatible API). - -The higher-level issue here is that nanobind will by default try to make type -bindings visible across extensions because this is helpful to partition large -binding projects into smaller parts. Such information exchange requires that -the extensions: - -- use the same nanobind *ABI version* (see the :ref:`Changelog ` for details). -- use the same compiler (extensions built with GCC and Clang are isolated from each other). -- use ABI-compatible versions of the C++ library. -- use the stable ABI interface consistently (stable and unstable builds are isolated from each other). -- use debug/release mode consistently (debug and release builds are isolated from each other). - -In addition, nanobind provides a feature to intentionally scope extensions to a -named domain to avoid conflicts with other extensions. To do so, specify the +bindings of the two packages expose an inconsistent/incompatible API). So it's +useful to be able to enforce a boundary between extensions sometimes, even if +they would otherwise be ABI-compatible. To do so, you can specify the ``NB_DOMAIN`` parameter in CMake: .. code-block:: cmake @@ -268,8 +270,54 @@ named domain to avoid conflicts with other extensions. To do so, specify the NB_DOMAIN my_project my_ext.cpp) -In this case, inter-extension type visibility is furthermore restricted to -extensions in the ``"my_project"`` domain. +Two extensions can only be in the same nanobind domain if either they both +specify the same value for that parameter (``"my_project"`` in this case) or +neither one specifies the parameter. + +As mentioned above, two extensions can also only be in the same nanobind domain +if they share the same ABI tag. This is determined in two parts, as follows: + +- They must use compatible *platform ABI*, so that (for example) a + ``std::vector`` created in one can be safely used in the other. + That means: + + - They must use the same C++ standard library (MSVC, libc++, or libstdc++), + and the same ABI version of it. For example, extensions that use libstdc++ + must match in terms of whether they use the pre- or post-C++11 ABI, and + extensions that use libc++ must use the same `libc++ ABI version + `__. + + - On Windows, they must use the same compiler (MSVC, mingw, or cygwin). + + - If compiled using MSVC, they must use the same major version of the + compiler, the same multi-threading style (dynamic ``/MD``, + static ``/MT``, or single-threaded), and they must match in terms of + whether or not they are built in debugging mode. + +- They must use compatible *nanobind ABI*, so that the nanobind internal data + structures created in one can be safely used in the other. That means: + + - They must use the same nanobind *ABI version*; see the + :ref:`Changelog ` for details. + + - They must be consistent in their use of Python's stable ABI: either + both built against the stable ABI (cmake ``STABLE_ABI`` flag) or both not. + + - They must be consistent in their use of free-threading: either both + built with free-threading support (cmake ``FREE_THREADED`` flag) or both not. + + - They must either both use released versions of nanobind or both be built + from Git development snapshots, rather than a mix of the two. + +If you want to share some types between two extensions that have the same +platform ABI (the first category in the above list), but are in different +nanobind domains due to using different nanobind ABI or different specified +``NB_DOMAIN`` strings, all is not lost! The :ref:`interoperability support +` between nanobind and other binding libraries also provides +interoperability between different nanobind domains, as long as the platform +ABI matches. It must be specifically enabled, and there are a few things it +can't do, but for most purposes it's hard to tell the difference from operating +within the same domain. See the linked documentation for more details. Can I use nanobind without RTTI or C++ exceptions? -------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index f90ddea80..edcc3b67f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -128,6 +128,7 @@ The nanobind logo was designed by `AndoTwin Studio packaging typing utilities + interop .. toctree:: :caption: Advanced diff --git a/docs/refleaks.rst b/docs/refleaks.rst index 1624fa8d3..96e784df4 100644 --- a/docs/refleaks.rst +++ b/docs/refleaks.rst @@ -58,8 +58,8 @@ easily disable them by calling :cpp:func:`nb::set_leak_warnings() } Note that is a *global flag* shared by all nanobind extension libraries in the -same ABI domain. When changing global flags, please isolate your extension from -others by passing the ``NB_DOMAIN`` parameter to the +same `nanobind domain `. When changing global flags, please +isolate your extension from others by passing the ``NB_DOMAIN`` parameter to the :cmake:command:`nanobind_add_module()` CMake command: .. code-block:: cmake diff --git a/include/nanobind/nb_attr.h b/include/nanobind/nb_attr.h index a69df8693..807344ecb 100644 --- a/include/nanobind/nb_attr.h +++ b/include/nanobind/nb_attr.h @@ -207,7 +207,10 @@ enum cast_flags : uint8_t { // This implies that objects added to the cleanup list may be // released immediately after the caster's final output value is // obtained, i.e., before it is used. - manual = (1 << 3) + manual = (1 << 3), + + // Disallow satisfying this cast with a foreign framework's binding + not_foreign = (1 << 4), }; diff --git a/include/nanobind/nb_cast.h b/include/nanobind/nb_cast.h index 8cf039dd6..a428ce723 100644 --- a/include/nanobind/nb_cast.h +++ b/include/nanobind/nb_cast.h @@ -189,15 +189,16 @@ struct type_caster && !is_std_char_v>> template struct type_caster>> { - NB_INLINE bool from_python(handle src, uint8_t flags, cleanup_list *) noexcept { + NB_INLINE bool from_python(handle src, uint8_t flags, cleanup_list *cleanup) noexcept { int64_t result; - bool rv = enum_from_python(&typeid(T), src.ptr(), &result, flags); + bool rv = enum_from_python(&typeid(T), src.ptr(), &result, sizeof(T), + flags, cleanup); value = (T) result; return rv; } NB_INLINE static handle from_cpp(T src, rv_policy, cleanup_list *) noexcept { - return enum_from_cpp(&typeid(T), (int64_t) src); + return enum_from_cpp(&typeid(T), (int64_t) src, sizeof(T)); } NB_TYPE_CASTER(T, const_name()) @@ -504,6 +505,8 @@ template struct type_caster_base : type_caster_base_tag { } else { const std::type_info *type_p = (!has_type_hook && ptr) ? &typeid(*ptr) : nullptr; + if (type_p && (void *) ptr != dynamic_cast(ptr)) + type_p = nullptr; // don't try to downcast from a non-primary base return nb_type_put_p(type, type_p, ptr, policy, cleanup); } } diff --git a/include/nanobind/nb_class.h b/include/nanobind/nb_class.h index 7733ab01f..b9d4cc869 100644 --- a/include/nanobind/nb_class.h +++ b/include/nanobind/nb_class.h @@ -91,9 +91,6 @@ enum class type_init_flags : uint32_t { all_init_flags = (0x1f << 19) }; -// See internals.h -struct nb_alias_chain; - // Implicit conversions for C++ type bindings, used in type_data below struct implicit_t { const std::type_info **cpp; @@ -109,12 +106,15 @@ struct enum_tbl_t { /// Information about a type that persists throughout its lifetime struct type_data { uint32_t size; - uint32_t align : 8; - uint32_t flags : 24; + uint32_t align : 8; // always zero for an enum + uint32_t flags : 24; // type_flags or enum_flags const char *name; const std::type_info *type; PyTypeObject *type_py; - nb_alias_chain *alias_chain; + // If not null, then nanobind is aware of other frameworks' bindings for + // this C++ type. Discriminated pointer: pymb_binding* if the low bit is + // zero, or nb_seq* if the low bit is one. + void *foreign_bindings; #if defined(Py_LIMITED_API) PyObject* (*vectorcall)(PyObject *, PyObject * const*, size_t, PyObject *); #endif @@ -204,7 +204,7 @@ enum class enum_flags : uint32_t { is_signed = (1 << 2), /// Is the underlying enumeration type Flag? - is_flag = (1 << 3) + is_flag = (1 << 3), }; struct enum_init_data { @@ -212,6 +212,7 @@ struct enum_init_data { PyObject *scope; const char *name; const char *docstr; + uint32_t size; uint32_t flags; }; @@ -332,6 +333,26 @@ inline void *type_get_slot(handle h, int slot_id) { #endif } +// nanobind interoperability with other binding frameworks +inline void interoperate_by_default(bool export_all = true, + bool import_all = true) { + detail::nb_type_set_interop_defaults(export_all, import_all); +} +template +inline void import_for_interop(handle type) { + if constexpr (!std::is_void_v) { + static_assert( + detail::is_base_caster_v>, + "Types that are intercepted by a type caster cannot use the " + "interoperability feature"); + } + detail::nb_type_import(type.ptr(), + std::is_void_v ? nullptr : &typeid(T)); +} +inline void export_for_interop(handle type) { + detail::nb_type_export(type.ptr()); +} + template struct def_visitor { protected: // Ensure def_visitor can only be derived from, not constructed @@ -774,6 +795,7 @@ template class enum_ : public object { NB_INLINE enum_(handle scope, const char *name, const Extra &... extra) { detail::enum_init_data ed { }; ed.type = &typeid(T); + ed.size = sizeof(T); ed.scope = scope.ptr(); ed.name = name; ed.flags = std::is_signed_v diff --git a/include/nanobind/nb_error.h b/include/nanobind/nb_error.h index 3abc960ec..297f6143c 100644 --- a/include/nanobind/nb_error.h +++ b/include/nanobind/nb_error.h @@ -125,7 +125,7 @@ NB_EXCEPTION(next_overload) inline void register_exception_translator(detail::exception_translator t, void *payload = nullptr) { - detail::register_exception_translator(t, payload); + detail::register_exception_translator(t, payload, /*at_end=*/false); } template @@ -142,7 +142,7 @@ class exception : public object { } catch (T &e) { PyErr_SetString((PyObject *) payload, e.what()); } - }, m_ptr); + }, m_ptr, /*at_end=*/false); } }; diff --git a/include/nanobind/nb_lib.h b/include/nanobind/nb_lib.h index 2ae1c12ce..3a975de7c 100644 --- a/include/nanobind/nb_lib.h +++ b/include/nanobind/nb_lib.h @@ -286,10 +286,17 @@ NB_CORE PyObject *nb_type_new(const type_init_data *c) noexcept; NB_CORE bool nb_type_get(const std::type_info *t, PyObject *o, uint8_t flags, cleanup_list *cleanup, void **out) noexcept; -/// Cast a C++ type instance into a Python object +/// Cast a C++ type instance into a Python object. +/// +/// Note: If you call this as if casting a shared_ptr (non-null `is_new`, true +/// `allow_foreign`, and `rv_policy::reference`) and you obtain a result with +/// `*is_new == true`, you must call `keep_alive` on the new instance after +/// you get it back. This allows for better interoperability with other +/// frameworks that store a smart pointer inside the instance. NB_CORE PyObject *nb_type_put(const std::type_info *cpp_type, void *value, rv_policy rvp, cleanup_list *cleanup, - bool *is_new = nullptr) noexcept; + bool *is_new = nullptr, + bool allow_foreign = true) noexcept; // Special version of nb_type_put for polymorphic classes NB_CORE PyObject *nb_type_put_p(const std::type_info *cpp_type, @@ -341,10 +348,12 @@ NB_CORE const std::type_info *nb_type_info(PyObject *t) noexcept; NB_CORE void *nb_inst_ptr(PyObject *o) noexcept; /// Check if a Python type object wraps an instance of a specific C++ type -NB_CORE bool nb_type_isinstance(PyObject *obj, const std::type_info *t) noexcept; +NB_CORE bool nb_type_isinstance(PyObject *obj, const std::type_info *t, + bool foreign_ok) noexcept; -/// Search for the Python type object associated with a C++ type -NB_CORE PyObject *nb_type_lookup(const std::type_info *t) noexcept; +/// Search for a Python type object associated with a C++ type +NB_CORE PyObject *nb_type_lookup(const std::type_info *t, + bool foreign_ok) noexcept; /// Allocate an instance of type 't' NB_CORE PyObject *nb_inst_alloc(PyTypeObject *t); @@ -386,6 +395,15 @@ NB_CORE void nb_inst_set_state(PyObject *o, bool ready, bool destruct) noexcept; /// Query the 'ready' and 'destruct' flags of an instance NB_CORE std::pair nb_inst_state(PyObject *o) noexcept; +// Set whether types will be shared with other binding frameworks by default +NB_CORE void nb_type_set_interop_defaults(bool export_all, bool import_all); + +// Teach nanobind about a type bound by another binding framework +NB_CORE void nb_type_import(PyObject *pytype, const std::type_info *cpptype); + +// Teach other binding frameworks about a type bound by nanobind +NB_CORE void nb_type_export(PyObject *pytype); + // ======================================================================== // Create and install a Python property object @@ -435,11 +453,13 @@ NB_CORE void enum_append(PyObject *tp, const char *name, int64_t value, const char *doc) noexcept; // Query an enumeration's Python object -> integer value map -NB_CORE bool enum_from_python(const std::type_info *, PyObject *, int64_t *, - uint8_t flags) noexcept; +NB_CORE bool enum_from_python(const std::type_info *tp, PyObject *obj, + int64_t *out, uint32_t enum_width, + uint8_t flags, cleanup_list *cleanup) noexcept; // Query an enumeration's integer value -> Python object map -NB_CORE PyObject *enum_from_cpp(const std::type_info *, int64_t) noexcept; +NB_CORE PyObject *enum_from_cpp(const std::type_info *tp, + int64_t key, uint32_t enum_width) noexcept; /// Export enum entries to the parent scope NB_CORE void enum_export(PyObject *tp); @@ -500,7 +520,8 @@ NB_CORE void print(PyObject *file, PyObject *str, PyObject *end); typedef void (*exception_translator)(const std::exception_ptr &, void *); NB_CORE void register_exception_translator(exception_translator translator, - void *payload); + void *payload, + bool at_end); NB_CORE PyObject *exception_new(PyObject *mod, const char *name, PyObject *base); diff --git a/include/nanobind/nb_types.h b/include/nanobind/nb_types.h index 49a840564..3a82ecb4f 100644 --- a/include/nanobind/nb_types.h +++ b/include/nanobind/nb_types.h @@ -667,15 +667,19 @@ class iterable : public object { /// Retrieve the Python type object associated with a C++ class template handle type() noexcept { - return detail::nb_type_lookup(&typeid(detail::intrinsic_t)); + return detail::nb_type_lookup(&typeid(detail::intrinsic_t), false); +} +template handle maybe_foreign_type() noexcept { + return detail::nb_type_lookup(&typeid(detail::intrinsic_t), true); } template -NB_INLINE bool isinstance(handle h) noexcept { +NB_INLINE bool isinstance(handle h, bool foreign_ok = false) noexcept { if constexpr (std::is_base_of_v) return T::check_(h); else if constexpr (detail::is_base_caster_v>) - return detail::nb_type_isinstance(h.ptr(), &typeid(detail::intrinsic_t)); + return detail::nb_type_isinstance(h.ptr(), &typeid(detail::intrinsic_t), + foreign_ok); else return detail::make_caster().from_python(h, 0, nullptr); } diff --git a/include/nanobind/stl/unique_ptr.h b/include/nanobind/stl/unique_ptr.h index c7700f3c2..7f8eccd1f 100644 --- a/include/nanobind/stl/unique_ptr.h +++ b/include/nanobind/stl/unique_ptr.h @@ -94,10 +94,12 @@ struct type_caster> { // Stash source python object src = src_; - /* Try casting to a pointer of the underlying type. We pass flags=0 and - cleanup=nullptr to prevent implicit type conversions (they are - problematic since the instance then wouldn't be owned by 'src') */ - return caster.from_python(src_, 0, nullptr); + /* Try casting to a pointer of the underlying type. We pass + cleanup=nullptr and !(flags & convert) to prevent implicit type + conversions, which are problematic since the instance then wouldn't + be owned by 'src'. Also disable casting from a foreign type since it + wouldn't be able to relinquish ownership. */ + return caster.from_python(src_, (uint8_t) cast_flags::not_foreign, nullptr); } template diff --git a/src/error.cpp b/src/error.cpp index 5d1d6666e..42664d37b 100644 --- a/src/error.cpp +++ b/src/error.cpp @@ -217,12 +217,28 @@ builtin_exception::~builtin_exception() { } NAMESPACE_BEGIN(detail) -void register_exception_translator(exception_translator t, void *payload) { - nb_translator_seq *cur = &internals->translators, - *next = new nb_translator_seq(*cur); - cur->next = next; - cur->payload = payload; - cur->translator = t; +void register_exception_translator(exception_translator t, + void *payload, + bool at_end) { + // We will insert the new translator so it is pointed to by `*insert_at`, + // i.e., so that it is executed just before the current `*insert_at` + nb_maybe_atomic *insert_at = &internals->translators; + if (at_end) { + // Insert before the default exception translator (which is last in + // the list) + nb_translator_seq *next = insert_at->load_acquire(); + while (next && next->next.load_relaxed()) { + insert_at = &next->next; + next = insert_at->load_acquire(); + } + } + nb_translator_seq *new_head = new nb_translator_seq{}; + nb_translator_seq *cur_head = insert_at->load_relaxed(); + new_head->payload = payload; + new_head->translator = t; + do { + new_head->next.store_release(cur_head); + } while (!insert_at->compare_exchange_weak(cur_head, new_head)); } NB_CORE PyObject *exception_new(PyObject *scope, const char *name, diff --git a/src/nb_abi.h b/src/nb_abi.h index da704d99f..daf09ddb7 100644 --- a/src/nb_abi.h +++ b/src/nb_abi.h @@ -14,7 +14,7 @@ /// Tracks the version of nanobind's internal data structures #ifndef NB_INTERNALS_VERSION -# define NB_INTERNALS_VERSION 16 +# define NB_INTERNALS_VERSION 17 #endif #if defined(__MINGW32__) diff --git a/src/nb_combined.cpp b/src/nb_combined.cpp index f565ce09f..6263cef43 100644 --- a/src/nb_combined.cpp +++ b/src/nb_combined.cpp @@ -79,6 +79,7 @@ #include "nb_ndarray.cpp" #include "nb_static_property.cpp" #include "nb_ft.cpp" +#include "nb_foreign.cpp" #include "error.cpp" #include "common.cpp" #include "implicit.cpp" diff --git a/src/nb_enum.cpp b/src/nb_enum.cpp index 427c0d85d..7029af2f4 100644 --- a/src/nb_enum.cpp +++ b/src/nb_enum.cpp @@ -18,23 +18,6 @@ using enum_map = tsl::robin_map; PyObject *enum_create(enum_init_data *ed) noexcept { // Update hash table that maps from std::type_info to Python type - nb_internals *internals_ = internals; - bool success; - nb_type_map_slow::iterator it; - - { - lock_internals guard(internals_); - std::tie(it, success) = internals_->type_c2p_slow.try_emplace(ed->type, nullptr); - if (!success) { - PyErr_WarnFormat(PyExc_RuntimeWarning, 1, - "nanobind: type '%s' was already registered!\n", - ed->name); - PyObject *tp = (PyObject *) it->second->type_py; - Py_INCREF(tp); - return tp; - } - } - handle scope(ed->scope); bool is_arithmetic = ed->flags & (uint32_t) enum_flags::is_arithmetic; @@ -81,36 +64,42 @@ PyObject *enum_create(enum_init_data *ed) noexcept { t->type = ed->type; t->type_py = (PyTypeObject *) result.ptr(); t->flags = ed->flags; + t->size = ed->size; t->enum_tbl.fwd = new enum_map(); t->enum_tbl.rev = new enum_map(); t->scope = ed->scope; - it.value() = t; - - { - lock_internals guard(internals_); - internals_->type_c2p_slow[ed->type] = t; - - #if !defined(NB_FREE_THREADED) - internals_->type_c2p_fast[ed->type] = t; - #endif - } - - make_immortal(result.ptr()); - - result.attr("__nb_enum__") = capsule(t, [](void *p) noexcept { + capsule tie_lifetimes(t, [](void *p) noexcept { type_init_data *t = (type_init_data *) p; delete (enum_map *) t->enum_tbl.fwd; delete (enum_map *) t->enum_tbl.rev; - nb_type_unregister(t); free((char*) t->name); delete t; }); + if (type_data *conflict; !nb_type_register(t, &conflict)) { + PyErr_WarnFormat(PyExc_RuntimeWarning, 1, + "nanobind: type '%s' was already registered!\n", + ed->name); + PyObject *tp = (PyObject *) conflict->type_py; + Py_INCREF(tp); + return tp; + } + + // Unregister the enum type when it begins being finalized + keep_alive(result.ptr(), t, [](void *p) noexcept { + nb_type_unregister((type_init_data *) p); + }); + + // Delete typeinfo only when the type's dict is cleared + result.attr("__nb_enum__") = tie_lifetimes; + + make_immortal(result.ptr()); + return result.release().ptr(); } -static type_init_data *enum_get_type_data(handle tp) { +type_init_data *enum_get_type_data(handle tp) { return (type_init_data *) (borrow(handle(tp).attr("__nb_enum__"))).data(); } @@ -184,10 +173,44 @@ void enum_append(PyObject *tp_, const char *name_, int64_t value_, rev->emplace((int64_t) (uintptr_t) el.ptr(), value_); } -bool enum_from_python(const std::type_info *tp, PyObject *o, int64_t *out, uint8_t flags) noexcept { - type_data *t = nb_type_c2p(internals, tp); - if (!t) +bool enum_from_python(const std::type_info *tp, + PyObject *o, + int64_t *out, + uint32_t enum_width, + uint8_t flags, + cleanup_list *cleanup) noexcept { + bool has_foreign = false; + +#if !defined(NB_DISABLE_INTEROP) + auto try_foreign = [=, &has_foreign]() -> bool { + if (has_foreign && !(flags & (uint8_t) cast_flags::not_foreign)) { + void *ptr = nb_type_get_foreign(internals, tp, o, flags, cleanup); + if (ptr) { + // Copy from the C++ enum object to our output integer. + // We don't bother sign-extending because the enum type caster + // is just going to truncate back down to `out_width` + // immediately. (For a signed destination type, the behavior + // was formally implementation-defined before C++20, but all + // known implementations behaved as described.) + switch (enum_width) { + case 1: *out = *(uint8_t *) ptr; return true; + case 2: *out = *(uint16_t *) ptr; return true; + case 4: *out = *(uint32_t *) ptr; return true; + case 8: *out = *(uint64_t *) ptr; return true; + default: return false; + } + return true; + } + } return false; + }; +#else + auto try_foreign = []() { return nullptr; }; +#endif + + type_data *t = nb_type_c2p(internals, tp, &has_foreign); + if (!t) + return try_foreign(); if ((t->flags & (uint32_t) enum_flags::is_flag) != 0 && Py_TYPE(o) == t->type_py) { PyObject *value_o = PyObject_GetAttrString(o, "value"); @@ -229,7 +252,7 @@ bool enum_from_python(const std::type_info *tp, PyObject *o, int64_t *out, uint8 long long value = PyLong_AsLongLong(o); if (value == -1 && PyErr_Occurred()) { PyErr_Clear(); - return false; + return try_foreign(); } enum_map::iterator it2 = fwd->find((int64_t) value); if (it2 != fwd->end()) { @@ -240,7 +263,7 @@ bool enum_from_python(const std::type_info *tp, PyObject *o, int64_t *out, uint8 unsigned long long value = PyLong_AsUnsignedLongLong(o); if (value == (unsigned long long) -1 && PyErr_Occurred()) { PyErr_Clear(); - return false; + return try_foreign(); } enum_map::iterator it2 = fwd->find((int64_t) value); if (it2 != fwd->end()) { @@ -251,13 +274,29 @@ bool enum_from_python(const std::type_info *tp, PyObject *o, int64_t *out, uint8 } - return false; + return try_foreign(); } -PyObject *enum_from_cpp(const std::type_info *tp, int64_t key) noexcept { - type_data *t = nb_type_c2p(internals, tp); - if (!t) +PyObject *enum_from_cpp(const std::type_info *tp, + int64_t key, + uint32_t enum_width) noexcept { + bool has_foreign = false; + type_data *t = nb_type_c2p(internals, tp, &has_foreign); + if (!t) { +#if !defined(NB_DISABLE_INTEROP) + if (has_foreign) { +#if NB_BIG_ENDIAN + // Adjust key so it can be safely reinterpreted as a smaller integer + key >>= 8 * (8 - enum_width); +#else + (void) enum_width; +#endif + return nb_type_put_foreign(internals, tp, nullptr, &key, + rv_policy::copy, nullptr, nullptr); + } +#endif return nullptr; + } enum_map *fwd = (enum_map *) t->enum_tbl.fwd; diff --git a/src/nb_foreign.cpp b/src/nb_foreign.cpp new file mode 100644 index 000000000..63f4481f3 --- /dev/null +++ b/src/nb_foreign.cpp @@ -0,0 +1,606 @@ +/* + src/nb_foreign.cpp: libnanobind functionality for interfacing with other + binding libraries + + Copyright (c) 2025 Hudson River Trading LLC + + All rights reserved. Use of this source code is governed by a + BSD-style license that can be found in the LICENSE file. +*/ + +#if !defined(NB_DISABLE_INTEROP) + +#include "nb_internals.h" +#include "nb_ft.h" +#include "nb_abi.h" + +NAMESPACE_BEGIN(NB_NAMESPACE) +NAMESPACE_BEGIN(detail) + +// nanobind exception translator that wraps a foreign one +static void foreign_exception_translator(const std::exception_ptr &p, + void *payload) { + std::exception_ptr e = p; + int translated = ((pymb_framework *) payload)->translate_exception(&e); + if (!translated) + std::rethrow_exception(e); +} + +// When learning about a new foreign type, should we automatically use it? +NB_INLINE bool should_autoimport_foreign(nb_internals *internals_, + pymb_binding *binding) { + return internals_->foreign_import_all && + binding->framework->abi_lang == pymb_abi_lang_cpp && + binding->framework->abi_extra == internals_->foreign_self->abi_extra; +} + +static void nb_type_import_binding(pymb_binding *binding, + const std::type_info *cpptype) noexcept; + +// Callback functions for other frameworks to operate on our objects +// or tell us about theirs + +static void *nb_foreign_from_python(pymb_binding *binding, + PyObject *pyobj, + uint8_t convert, + void (*keep_referenced)(void *ctx, + PyObject *obj), + void *keep_referenced_ctx) noexcept { + cleanup_list cleanup{nullptr}; + uint8_t flags = (uint8_t) cast_flags::not_foreign; + if (convert) + flags |= (uint8_t) cast_flags::convert; + auto *td = (type_data *) binding->context; + if (td->align == 0) { // enum + int64_t value; + if (keep_referenced && + enum_from_python(td->type, pyobj, &value, td->size, flags, nullptr)) { + bytes holder{(uint8_t *) &value + NB_BIG_ENDIAN * (8 - td->size), + td->size}; + keep_referenced(keep_referenced_ctx, holder.ptr()); + return (void *) holder.data(); + } + return nullptr; + } + + void *result = nullptr; + bool ok = nb_type_get(td->type, pyobj, flags, + keep_referenced ? &cleanup : nullptr, &result); + if (keep_referenced) { + // Move temporary references from our `cleanup_list` to our caller's + // equivalent. + for (uint32_t idx = 1; idx < cleanup.size(); ++idx) + keep_referenced(keep_referenced_ctx, cleanup[idx]); + if (cleanup.size() > 1) + cleanup.release(); + } + return ok ? result : nullptr; +} + +static PyObject *nb_foreign_to_python(pymb_binding *binding, + void *cobj, + enum pymb_rv_policy rvp_, + pymb_to_python_feedback *feedback) noexcept { + feedback->relocate = 0; // we don't support relocation + feedback->is_new = 0; // unless overridden below + + auto *td = (type_data *) binding->context; + if (td->align == 0) { // enum + int64_t key; + switch (td->size) { + case 1: key = *(uint8_t *) cobj; break; + case 2: key = *(uint16_t *) cobj; break; + case 4: key = *(uint32_t *) cobj; break; + case 8: key = *(uint64_t *) cobj; break; + default: return nullptr; + } + if (rvp_ == pymb_rv_policy_take_ownership) + ::operator delete(cobj); + if ((td->flags & (uint32_t) enum_flags::is_signed) && td->size < 8) { + // sign extend + key <<= (64 - (td->size * 8)); + key >>= (64 - (td->size * 8)); + } + return enum_from_cpp(td->type, key, td->size); + } + + rv_policy rvp = rv_policy::none; // conservative default if rvp unrecognized + switch (rvp_) { + case pymb_rv_policy_take_ownership: + case pymb_rv_policy_copy: + case pymb_rv_policy_move: + case pymb_rv_policy_reference: + case pymb_rv_policy_none: + // These have the same values and semantics as our own policies + rvp = (rv_policy) rvp_; + break; + case pymb_rv_policy_share_ownership: + // Shared ownership is always held by keep_alive in nanobind, + // so there's nothing special to do here. + rvp = rv_policy::reference; + break; + } + return nb_type_put(td->type, cobj, rvp, /* cleanup */ nullptr, + /* is_new */ (bool *) &feedback->is_new, + /* allow_foreign */ false); +} + +static int nb_foreign_keep_alive(PyObject *nurse, + void *payload, + void (*cb)(void*)) noexcept { + try { + if (cb) + keep_alive(nurse, payload, (void (*)(void*) noexcept) cb); + else + keep_alive(nurse, (PyObject *) payload); + return 1; + } catch (const std::runtime_error& err) { + PyErr_WriteUnraisable(nurse); + return 0; + } +} + +static int nb_foreign_translate_exception(void *eptr) noexcept { + std::exception_ptr &e = *(std::exception_ptr *) eptr; + + // Skip the default translator (at the end of the list). It translates + // generic STL exceptions which other frameworks might want to translate + // differently than we do; they should get control over the behavior of + // their functions. + for (nb_translator_seq* cur = internals->translators.load_acquire(); + cur->next.load_relaxed(); cur = cur->next.load_acquire()) { + if (cur->translator == internals->foreign_exception_translator) { + // Don't call foreign translators, to avoid mutual recursion. + // They are at the end of the list, just before the default + // translator, so we can stop iterating when we see one. + break; + } + try { + cur->translator(e, cur->payload); + return 1; + } catch (...) { e = std::current_exception(); } + } + + // Check nb::python_error and nb::builtin_exception + try { + std::rethrow_exception(e); + } catch (python_error &e) { + e.restore(); + return 1; + } catch (builtin_exception &e) { + if (!set_builtin_exception_status(e)) + PyErr_SetString(PyExc_SystemError, "foreign function threw " + "nanobind::next_overload()"); + return 1; + } catch (...) { e = std::current_exception(); } + return 0; +} + +static void nb_foreign_free_local_binding(pymb_binding *binding) noexcept { + free((char *) binding->source_name); + PyMem_Free(binding); +} + +static void nb_foreign_add_foreign_binding(pymb_binding *binding) noexcept { + nb_internals *internals_ = internals; + lock_internals guard{internals_}; + if (should_autoimport_foreign(internals_, binding)) + nb_type_import_binding(binding, + (const std::type_info *) binding->native_type); +} + +// Common logic for removing imported & exported bindings from our type map. +// Caller must hold the internals lock. +static void nb_foreign_remove_binding_from_type(nb_internals *internals_, + const std::type_info *type, + pymb_binding *binding) noexcept { + auto remove_from_list = [binding](void *list_head, + nb_foreign_seq **to_free) -> void* { + if (!nb_is_seq(list_head)) + return list_head == binding ? nullptr : list_head; + nb_foreign_seq *current = nb_get_seq(list_head); + nb_foreign_seq *prev = nullptr; + while (current && current->value != binding) { + prev = current; + current = nb_load_acquire(current->next); + } + if (current) { + *to_free = current; + nb_foreign_seq *next = nb_load_acquire(current->next); + if (!prev) + return next ? nb_mark_seq(next) : nullptr; + nb_store_release(prev->next, next); + } + return list_head; + }; + + nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; + auto it = type_c2p_slow.find(type); + check(it != type_c2p_slow.end(), + "foreign binding not registered upon removal"); + void *new_value = it->second; + nb_foreign_seq *to_free = nullptr; + if (nb_is_foreign(it->second)) { + new_value = remove_from_list(nb_get_foreign(it->second), &to_free); + if (new_value) + it.value() = new_value = nb_mark_foreign(new_value); + else + type_c2p_slow.erase(it); + } else { + auto *t = (type_data *) it->second; + nb_store_release(t->foreign_bindings, + remove_from_list( + nb_load_acquire(t->foreign_bindings), + &to_free)); + } + nb_type_update_c2p_fast(type, new_value); + PyMem_Free(to_free); +} + +static void nb_foreign_remove_local_binding(pymb_binding *binding) noexcept { + nb_internals *internals_ = internals; + lock_internals guard{internals_}; + nb_foreign_remove_binding_from_type( + internals_, (const std::type_info *) binding->native_type, + binding); +} + +static void nb_foreign_remove_foreign_binding(pymb_binding *binding) noexcept { + nb_internals *internals_ = internals; + lock_internals guard{internals_}; + bool should_remove_auto = should_autoimport_foreign(internals_, binding); + if (auto it = internals_->foreign_manual_imports.find(binding); + it != internals_->foreign_manual_imports.end()) { + nb_foreign_remove_binding_from_type( + internals_, (const std::type_info *) it->second, binding); + should_remove_auto &= (it->second != binding->native_type); + internals_->foreign_manual_imports.erase(it); + } + if (should_remove_auto) + nb_foreign_remove_binding_from_type( + internals_, (const std::type_info *) binding->native_type, + binding); +} + +static void nb_foreign_add_foreign_framework(pymb_framework *framework) + noexcept { + if (framework->translate_exception && + framework->abi_lang == pymb_abi_lang_cpp) { + decltype(&foreign_exception_translator) translator_to_use; + { + lock_internals guard{internals}; + if (!internals->foreign_exception_translator) + internals->foreign_exception_translator = + foreign_exception_translator; + translator_to_use = internals->foreign_exception_translator; + } + register_exception_translator(translator_to_use, + framework, /*at_end=*/true); + } + if (!(framework->flags & pymb_framework_leak_safe)) + internals->print_leak_warnings = false; +} + +static void nb_foreign_remove_foreign_framework(pymb_framework *framework) + noexcept { + if (is_alive() && framework->translate_exception && + framework->abi_lang == pymb_abi_lang_cpp) { + // Remove the exception translator we added for this framework. + // The interpreter is already finalizing, so we don't need to worry + // about locking. + nb_maybe_atomic *pcurr = &internals->translators; + while (auto *seq = pcurr->load_relaxed()) { + if (seq->translator == internals->foreign_exception_translator && + seq->payload == framework) { + pcurr->store_release(seq->next.load_relaxed()); + delete seq; + continue; + } + pcurr = &seq->next; + } + } +} + +// (end of callbacks) + +// Advertise our existence, and the above callbacks, to other frameworks +static void register_with_pymetabind(nb_internals *internals_) { + // caller must hold the internals lock + if (internals_->foreign_self) + return; + pymb_registry *registry = pymb_get_registry(); + if (!registry) + raise_python_error(); + + const char *name_const = "nanobind " NB_ABI_TAG; + char *name_buf = (char *) alloca(strlen(name_const) + 1 + + (internals_->domain ? + 8 + strlen(internals_->domain) : 0)); + strcpy(name_buf, name_const); + if (internals_->domain) { + strcat(name_buf, " domain "); + strcat(name_buf, internals_->domain); + } + auto *fw = new pymb_framework{}; + fw->name = strdup_check(name_buf); +#if defined(NB_FREE_THREADED) + fw->flags = pymb_framework_bindings_usable_forever; +#else + fw->flags = pymb_framework_leak_safe; +#endif + fw->abi_lang = pymb_abi_lang_cpp; + fw->abi_extra = NB_PLATFORM_ABI_TAG; + fw->from_python = nb_foreign_from_python; + fw->to_python = nb_foreign_to_python; + fw->keep_alive = nb_foreign_keep_alive; + fw->translate_exception = nb_foreign_translate_exception; + fw->remove_local_binding = nb_foreign_remove_local_binding; + fw->free_local_binding = nb_foreign_free_local_binding; + fw->add_foreign_binding = nb_foreign_add_foreign_binding; + fw->remove_foreign_binding = nb_foreign_remove_foreign_binding; + fw->add_foreign_framework = nb_foreign_add_foreign_framework; + fw->remove_foreign_framework = nb_foreign_remove_foreign_framework; + internals_->foreign_self = fw; + + // pymb_add_framework() will call our add_foreign_framework and + // add_foreign_binding method for each existing other framework/binding; + // those need to lock internals, so unlock here + unlock_internals guard{internals_}; + pymb_add_framework(registry, fw); +} + +// Add the given `binding` to our type maps so that we can use it to satisfy +// from- and to-Python requests for the given C++ type +static void nb_type_import_binding(pymb_binding *binding, + const std::type_info *cpptype) noexcept { + // Caller must hold the internals lock + internals->foreign_imported_any = true; + + auto add_to_list = [binding](void *list_head) -> void* { + if (!list_head) + return binding; + nb_foreign_seq *seq = nb_ensure_seq(&list_head); + while (true) { + if (seq->value == binding) + return list_head; // already added + nb_foreign_seq *next = nb_load_acquire(seq->next); + if (next == nullptr) + break; + seq = next; + } + nb_foreign_seq *next = + (nb_foreign_seq *) PyMem_Malloc(sizeof(nb_foreign_seq)); + check(next, "add_foreign_binding_to_list(): out of memory!"); + next->value = binding; + next->next = nullptr; + nb_store_release(seq->next, next); + return list_head; + }; + + auto [it, inserted] = internals->type_c2p_slow.try_emplace( + cpptype, nb_mark_foreign(binding)); + if (!inserted) { + if (nb_is_foreign(it->second)) + it.value() = nb_mark_foreign(add_to_list(nb_get_foreign(it->second))); + else if (auto *t = (type_data *) it->second) + nb_store_release(t->foreign_bindings, + add_to_list(nb_load_acquire(t->foreign_bindings))); + else + check(false, "null entry in type_c2p_slow"); + } + nb_type_update_c2p_fast(cpptype, it->second); +} + +// Learn to satisfy from- and to-Python requests for `cpptype` using the +// foreign binding provided by the given `pytype`. If cpptype is nullptr, infer +// the C++ type by looking at the binding, and require that its ABI match ours. +// Throws an exception on failure. Caller must hold the internals lock. +void nb_type_import_impl(PyObject *pytype, const std::type_info *cpptype) { + if (!internals->foreign_self) + register_with_pymetabind(internals); + pymb_framework* foreign_self = internals->foreign_self; + pymb_binding* binding = pymb_get_binding(pytype); + str name_py = steal(nb_type_name(pytype)); + const char *name = name_py.c_str(); + if (!binding) + raise("'%s' does not define a __pymetabind_binding__", name); + if (binding->pytype != (PyTypeObject *) pytype) + raise("The binding defined by '%s' is for a different type '%s', " + "likely a superclass; import that type instead", + name, steal(nb_type_name((PyObject *) binding->pytype)).c_str()); + if (binding->framework == foreign_self) + raise("'%s' is already bound by this nanobind domain", name); + if (!cpptype) { + if (binding->framework->abi_lang != pymb_abi_lang_cpp) + raise("'%s' is not written in C++, so you must specify a C++ type " + "to map it to", name); + if (binding->framework->abi_extra != foreign_self->abi_extra) + raise("'%s' has incompatible C++ ABI with this nanobind domain: " + "their '%s' vs our '%s'", name, binding->framework->abi_extra, + foreign_self->abi_extra); + cpptype = (const std::type_info *) binding->native_type; + } + + auto [it, inserted] = internals->foreign_manual_imports.try_emplace( + (void *) binding, (void *) cpptype); + if (!inserted) { + auto *existing = (const std::type_info *) it->second; + if (existing != cpptype && *existing != *cpptype) + raise("'%s' was already mapped to C++ type '%s', so can't now " + "map it to '%s'", + name, existing->name(), cpptype->name()); + } + nb_type_import_binding(binding, cpptype); +} + +// Call `nb_type_import_binding()` for every ABI-compatible type provided by +// other C++ binding frameworks used by extension modules loaded in this +// interpreter, both those that exist now and those bound in the future. +void nb_type_enable_import_all() { + nb_internals *internals_ = internals; + { + lock_internals guard{internals_}; + if (internals_->foreign_import_all) + return; + internals_->foreign_import_all = true; + if (!internals_->foreign_self) { + // pymb_add_framework tells us about every existing type when we + // register, so if we register with import enabled, we're done + register_with_pymetabind(internals_); + return; + } + } + // If we enable import after registering, we have to iterate over the + // list of types ourselves. Do this without the internals lock held so + // we can reuse the pymb callback functions. foreign_registry and + // foreign_self never change once they're non-null, so we can accesss them + // without locking here. + struct pymb_registry *registry = internals_->foreign_self->registry; + pymb_lock_registry(registry); + PYMB_LIST_FOREACH(struct pymb_binding*, binding, registry->bindings) { + if (binding->framework != internals_->foreign_self) + nb_foreign_add_foreign_binding(binding); + } + pymb_unlock_registry(registry); +} + +// Expose hooks for other frameworks to use to work with the given nanobind +// type object. Caller must hold the internals lock. +void nb_type_export_impl(type_data *td) { + if (!internals->foreign_self) + register_with_pymetabind(internals); + + void *foreign_bindings = nb_load_acquire(td->foreign_bindings); + if (nb_is_seq(foreign_bindings)) { + nb_foreign_seq *node = nb_get_seq(foreign_bindings); + if (node->value->framework == internals->foreign_self) + return; // already exported + } else if (auto *binding = (pymb_binding *) foreign_bindings; + binding && binding->framework == internals->foreign_self) + return; // already exporte + + auto binding = (pymb_binding *) PyMem_Malloc(sizeof(pymb_binding)); + binding->framework = internals->foreign_self; + binding->pytype = td->type_py; + binding->native_type = td->type; + binding->source_name = type_name(td->type); + binding->context = td; + + if (foreign_bindings) { + nb_foreign_seq *existing = + nb_ensure_seq(&foreign_bindings); + nb_foreign_seq *new_ = + (nb_foreign_seq *) PyMem_Malloc(sizeof(nb_foreign_seq)); + new_->value = binding; + new_->next = existing; + foreign_bindings = nb_mark_seq(new_); + } else { + foreign_bindings = binding; + } + nb_store_release(td->foreign_bindings, foreign_bindings); + pymb_add_binding(binding, /* tp_finalize_will_remove */ 1); + // No need to call nb_type_update_c2p_fast: the map value (`td`) hasn't + // changed, and a potential concurrent lookup that picked up the old value + // of `td->foreign_bindings` is safe. +} + +// Call `nb_type_export_impl()` for each type that currently exists in this +// nanobind domain and each type created in the future. +void nb_type_enable_export_all() { + nb_internals *internals_ = internals; + lock_internals guard{internals_}; + if (internals_->foreign_export_all) + return; + internals_->foreign_export_all = true; + if (!internals_->foreign_self) + register_with_pymetabind(internals_); + for (const auto& [type, value] : internals_->type_c2p_slow) { + if (nb_is_foreign(value)) + continue; + nb_type_export_impl((type_data *) value); + } +} + +// Invoke `attempt(closure, binding)` for each foreign binding `binding` +// that claims `type` and was not supplied by us, until one of them returns +// non-null. Return that first non-null value, or null if all attempts failed. +// Failed attempts might be repeated if types are removed concurrently. +// Requires that a previous call to nb_type_c2p() have been made for `type`. +void *nb_type_try_foreign(nb_internals *internals_, + const std::type_info *type, + void* (*attempt)(void *closure, + pymb_binding *binding), + void *closure) noexcept { + // It is not valid to reuse the lookup made by a previous nb_type_c2p(), + // because some bindings could have been removed between then and now. +#if defined(NB_FREE_THREADED) + auto per_thread_guard = nb_type_lock_c2p_fast(internals_); + nb_type_map_fast &type_c2p_fast = *per_thread_guard; +#else + nb_type_map_fast &type_c2p_fast = internals_->type_c2p_fast; +#endif + uint32_t update_count = type_c2p_fast.update_count; + do { + // We assume nb_type_c2p already ran for this type, so that there's + // no need to handle a cache miss here. + void *foreign_bindings = nullptr; + if (void *result = type_c2p_fast.lookup(type); nb_is_foreign(result)) + foreign_bindings = nb_get_foreign(result); + else if (auto *t = (type_data *) result) + foreign_bindings = nb_load_acquire(t->foreign_bindings); + if (!foreign_bindings) + return nullptr; + + if (NB_LIKELY(!nb_is_seq(foreign_bindings))) { + // Single foreign binding - check that it's not our own + auto *binding = (pymb_binding *) foreign_bindings; + if (binding->framework != internals_->foreign_self) { +#if defined(NB_FREE_THREADED) + // attempt() might execute Python code; drop the map mutex + // to avoid a deadlock + per_thread_guard = {}; +#endif + return attempt(closure, binding); + } + return nullptr; + } + + // Multiple foreign bindings - try all except our own. + nb_foreign_seq *current = nb_get_seq(foreign_bindings); + while (current) { + auto *binding = current->value; + if (binding->framework != internals_->foreign_self) { +#if defined(NB_FREE_THREADED) + per_thread_guard = {}; +#endif + void *result = attempt(closure, binding); + if (result) + return result; + +#if defined(NB_FREE_THREADED) + // Re-acquire lock to continue iteration + per_thread_guard = nb_type_lock_c2p_fast(internals_); +#endif + // If we missed an update during attempt(), start our lookup + // over in case the update removed the node we're on. + if (type_c2p_fast.update_count != update_count) { + // Concurrent update occurred; retry + update_count = type_c2p_fast.update_count; + break; + } + } + current = current->next; + } + if (current) { + // We broke out early due to a concurrent update. Retry + // from the top. + continue; + } + return nullptr; + } while (true); +} + +NAMESPACE_END(detail) +NAMESPACE_END(NB_NAMESPACE) + +#endif /* !defined(NB_DISABLE_INTEROP) */ diff --git a/src/nb_func.cpp b/src/nb_func.cpp index 915b2fca8..b95164d8d 100644 --- a/src/nb_func.cpp +++ b/src/nb_func.cpp @@ -150,7 +150,7 @@ static arg_data method_args[2] = { { nullptr, nullptr, nullptr, nullptr, 0 } }; -static bool set_builtin_exception_status(builtin_exception &e) { +bool set_builtin_exception_status(builtin_exception &e) { PyObject *o; switch (e.type()) { @@ -583,8 +583,8 @@ static NB_NOINLINE PyObject *nb_func_error_noconvert(PyObject *self, static NB_NOINLINE void nb_func_convert_cpp_exception() noexcept { std::exception_ptr e = std::current_exception(); - for (nb_translator_seq *cur = &internals->translators; cur; - cur = cur->next) { + for (nb_translator_seq *cur = internals->translators.load_acquire(); + cur; cur = cur->next.load_acquire()) { try { // Try exception translator & forward payload cur->translator(e, cur->payload); @@ -1306,13 +1306,24 @@ static uint32_t nb_func_render_signature(const func_data *f, if (!(is_method && arg_index == 0)) { bool found = false; auto it = internals_->type_c2p_slow.find(*descr_type); - if (it != internals_->type_c2p_slow.end()) { - handle th((PyObject *) it->second->type_py); - buf.put_dstr((borrow(th.attr("__module__"))).c_str()); - buf.put('.'); - buf.put_dstr((borrow(th.attr("__qualname__"))).c_str()); - found = true; + handle th; +#if !defined(NB_DISABLE_INTEROP) + if (nb_is_foreign(it->second)) { + void *bindings = nb_get_foreign(it->second); + if (!nb_is_seq(bindings)) + th = ((pymb_binding *) bindings)->pytype; + else + th = nb_get_seq(bindings)->value->pytype; + } else +#endif + th = ((type_data *) it->second)->type_py; + if (th) { + buf.put_dstr((borrow(th.attr("__module__"))).c_str()); + buf.put('.'); + buf.put_dstr((borrow(th.attr("__qualname__"))).c_str()); + found = true; + } } if (!found) { if (nb_signature_mode) diff --git a/src/nb_internals.cpp b/src/nb_internals.cpp index 4adf53004..63e3901cc 100644 --- a/src/nb_internals.cpp +++ b/src/nb_internals.cpp @@ -219,9 +219,9 @@ static void internals_cleanup() { for (size_t i = 0; i < p->shard_count && ctr < 20; ++i) { for (auto [k, v]: p->shards[i].inst_c2p) { if (NB_UNLIKELY(nb_is_seq(v))) { - nb_inst_seq* seq = nb_get_seq(v); + nb_inst_seq* seq = nb_get_seq(v); for(; seq != nullptr && ctr < 20; seq = seq->next) { - print_leak(k, seq->inst); + print_leak(k, seq->value); INC_CTR; } } else { @@ -249,12 +249,32 @@ static void internals_cleanup() { #endif if (!p->type_c2p_slow.empty()) { - if (print_leak_warnings) { - fprintf(stderr, "nanobind: leaked %zu types!\n", - p->type_c2p_slow.size()); + size_t type_leaks = 0; + for (const auto &kv : p->type_c2p_slow) { +#if !defined(NB_DISABLE_INTEROP) + if (nb_is_foreign(kv.second)) { + if (nb_is_seq(kv.second)) { + auto *seq = nb_get_seq(kv.second); + while (seq) { + auto *next = seq->next; + PyMem_Free(seq); + seq = next; + } + } + continue; + } +#endif + ++type_leaks; + } + + if (type_leaks && print_leak_warnings) { + fprintf(stderr, "nanobind: leaked %zu types!\n", type_leaks); int ctr = 0; for (const auto &kv : p->type_c2p_slow) { - fprintf(stderr, " - leaked type \"%s\"\n", kv.second->name); + if (nb_is_foreign(kv.second)) + continue; + fprintf(stderr, " - leaked type \"%s\"\n", + ((type_data *) kv.second)->name); INC_CTR; if (ctr == 10) { fprintf(stderr, " - ... skipped remainder\n"); @@ -262,7 +282,7 @@ static void internals_cleanup() { } } } - leak = true; + leak |= (type_leaks > 0); } if (!p->funcs.empty()) { @@ -284,13 +304,32 @@ static void internals_cleanup() { } if (!leak) { - nb_translator_seq* t = p->translators.next; + nb_translator_seq* t = p->translators.load_acquire(); while (t) { - nb_translator_seq *next = t->next; + nb_translator_seq *next = t->next.load_acquire(); delete t; t = next; } +#if !defined(NB_DISABLE_INTEROP) + if (p->foreign_self) { + pymb_remove_framework(p->foreign_self); + free((void *) p->foreign_self->name); + delete p->foreign_self; + } +#endif + + for (auto &kv : p->types_in_c2p_fast) { + if (!kv.second) + continue; + nb_alias_seq *node = (nb_alias_seq *) kv.second; + while (node) { + nb_alias_seq *next = node->next; + delete node; + node = next; + } + } + #if defined(NB_FREE_THREADED) // This code won't run for now but is kept here for a time when // immortalization isn't needed anymore. @@ -358,6 +397,7 @@ NB_NOINLINE void init(const char *name) { p->shard_mask = shard_count - 1; #endif p->shard_count = shard_count; + p->domain = name; str nb_name("nanobind"); p->nb_module = PyModule_NewObject(nb_name.ptr()); @@ -426,7 +466,6 @@ NB_NOINLINE void init(const char *name) { Py_DECREF(dummy); #endif - p->translators = { default_exception_translator, nullptr, nullptr }; is_alive_value = true; is_alive_ptr = &is_alive_value; p->is_alive_ptr = is_alive_ptr; @@ -480,6 +519,7 @@ NB_NOINLINE void init(const char *name) { Py_DECREF(capsule); Py_DECREF(key); internals = p; + register_exception_translator(default_exception_translator, nullptr, false); } #if defined(NB_COMPACT_ASSERTIONS) @@ -493,5 +533,24 @@ NB_NOINLINE void fail_unspecified() noexcept { } #endif +#if defined(NB_FREE_THREADED) +nb_type_map_per_thread::nb_type_map_per_thread(nb_internals &internals_) + : internals(internals_) { + lock_internals l{&internals}; + next = internals.type_c2p_per_thread_head; + internals.type_c2p_per_thread_head = this; +} +nb_type_map_per_thread::~nb_type_map_per_thread() { + lock_internals l{&internals}; + nb_type_map_per_thread** pcurr = &internals.type_c2p_per_thread_head; + while (*pcurr) { + if (*pcurr == this) + *pcurr = next; + else + pcurr = &((*pcurr)->next); + } +} +#endif + NAMESPACE_END(detail) NAMESPACE_END(NB_NAMESPACE) diff --git a/src/nb_internals.h b/src/nb_internals.h index ca79920dd..e7406dd06 100644 --- a/src/nb_internals.h +++ b/src/nb_internals.h @@ -20,6 +20,7 @@ #include #include #include "hash.h" +#include "pymetabind.h" #if TSL_RH_VERSION_MAJOR != 1 || TSL_RH_VERSION_MINOR < 3 # error nanobind depends on tsl::robin_map, in particular version >= 1.3.0, <2.0.0 @@ -31,6 +32,16 @@ # define NB_THREAD_LOCAL __thread #endif +#if defined(PY_BIG_ENDIAN) +# define NB_BIG_ENDIAN PY_BIG_ENDIAN +#else // pypy doesn't define PY_BIG_ENDIAN +# if defined(_MSC_VER) +# define NB_BIG_ENDIAN 0 // All Windows platforms are little-endian +# else // GCC and Clang define the following macros +# define NB_BIG_ENDIAN (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__) +# endif +#endif + NAMESPACE_BEGIN(NB_NAMESPACE) NAMESPACE_BEGIN(detail) @@ -149,19 +160,64 @@ template class py_allocator { void deallocate(T *p, size_type /*n*/) noexcept { PyMem_Free(p); } }; -// Linked list of instances with the same pointer address. Usually just 1. -struct nb_inst_seq { - PyObject *inst; - nb_inst_seq *next; +/// nanobind maintains several maps where there is usually a single entry, +/// but sometimes a list of them. To avoid allocating linked list nodes in +/// the common case, the map value is a pointer whose lowest bit is a type +/// discriminant: 0 if pointing to a single T, and 1 if pointing to a nb_seq. +template +struct nb_seq { + T *value; + nb_seq *next; }; +using nb_inst_seq = nb_seq; +using nb_alias_seq = nb_seq; +using nb_foreign_seq = nb_seq; -// Linked list of type aliases when there are multiple shared libraries with duplicate RTTI data -struct nb_alias_chain { - const std::type_info *value; - nb_alias_chain *next; -}; +/// Convenience functions to deal with such encoded pointers -// Weak reference list. Usually, there is just one entry +/// Does this entry store a linked list of instances? +NB_INLINE bool nb_is_seq(void *p) { return ((uintptr_t) p) & 1; } + +/// Tag a nb_seq* pointer as such +template +NB_INLINE void* nb_mark_seq(nb_seq *p) { return (void *) (((uintptr_t) p) | 1); } + +/// Retrieve the nb_seq* pointer from an 'inst_c2p' value, assuming nb_is_seq(p) +template +NB_INLINE nb_seq* nb_get_seq(void *p) { return (nb_seq *) (((uintptr_t) p) ^ 1); } + +template +nb_seq* nb_ensure_seq(void **p) { + if (nb_is_seq(*p)) + return nb_get_seq(*p); + nb_seq *node = (nb_seq *) PyMem_Malloc(sizeof(nb_seq)); + node->value = (T *) *p; + node->next = nullptr; + *p = nb_mark_seq(node); + return node; +} + +/// Analogous convenience functions for nanobind vs foreign type disambiguation +/// in type_c2p_* map values. These store one of type_data*, pymb_binding* | 2, +/// or nb_foreign_seq* | 3. So, if !nb_is_foreign(p), cast to type_data* +/// directly; otherwise use either nb_get_seq(nb_get_foreign(p)) +/// or (pymb_binding*) nb_get_foreign(p) depending on the value of +/// nb_is_seq(nb_get_foreign(p)). + +#if defined(NB_DISABLE_INTEROP) +NB_INLINE bool nb_is_foreign(void *) { return false; } +#else +NB_INLINE bool nb_is_foreign(void *p) { return ((uintptr_t) p) & 2; } +NB_INLINE void* nb_mark_foreign(void *p) { return (void *) (((uintptr_t) p) | 2); } +NB_INLINE void* nb_get_foreign(void *p) { return (void *) (((uintptr_t) p) ^ 2); } +static_assert(alignof(type_data) >= 4 && alignof(pymb_binding) >= 4 && + alignof(nb_foreign_seq) >= 4, + "not enough alignment bits for discriminant scheme"); +#endif + +// Entry in a list of keep_alive weak references. This does not use the +// low-order bit discriminator because the payload is not pointer-sized; +// even if an object has a single weak reference, it will use the seq. struct nb_weakref_seq { void (*callback)(void *) noexcept; void *payload; @@ -181,31 +237,200 @@ struct std_typeinfo_eq { } }; -using nb_type_map_fast = tsl::robin_map; -using nb_type_map_slow = tsl::robin_map; /// A simple pointer-to-pointer map that is reused a few times below (even if /// not 100% ideal) to avoid template code generation bloat. -using nb_ptr_map = tsl::robin_map; +using nb_ptr_map = tsl::robin_map; + +/// A map from std::type_info to type data pointer, where lookups use +/// pointer comparisons. The values stored here can be NULL (if a negative +/// lookup has been cached) or else point to any of three types, discriminated +/// using the two lowest-order bits of the pointer; see TYPE MAPPING above. +struct nb_type_map_fast { + /// Look up a type. If not present in the map, add it with value `dflt`; + /// then return a reference to the stored value, which the caller may + /// modify. + void*& lookup_or_set(const std::type_info *ti, void *dflt) { + ++update_count; + return data.try_emplace((void *) ti, dflt).first.value(); + } -/// Convenience functions to deal with the pointer encoding in 'internals.inst_c2p' + /// Look up a type. Return its associated value, or nullptr if not present. + /// This method can't distinguish cached negative lookups from entries + /// that aren't in the map. + void* lookup(const std::type_info *ti) { + auto it = data.find((void *) ti); + return it == data.end() ? nullptr : it->second; + } -/// Does this entry store a linked list of instances? -NB_INLINE bool nb_is_seq(void *p) { return ((uintptr_t) p) & 1; } + /// Override the stored value for a type, if present. Return true if + /// anything was changed. + bool update(const std::type_info* ti, void *value) { + auto it = data.find((void *) ti); + if (it != data.end()) { + it.value() = value; + ++update_count; + return true; + } + return false; + } -/// Tag a nb_inst_seq* pointer as such -NB_INLINE void* nb_mark_seq(void *p) { return (void *) (((uintptr_t) p) | 1); } + /// Number of times the map has been modified. Used in nb_type_try_foreign() + /// to detect cases where attempting to use one foreign binding for a type + /// may have invalidated the iterator needed to advance to the next one. + uint32_t update_count = 0; -/// Retrieve the nb_inst_seq* pointer from an 'inst_c2p' value -NB_INLINE nb_inst_seq* nb_get_seq(void *p) { return (nb_inst_seq *) (((uintptr_t) p) ^ 1); } +#if defined(NB_FREE_THREADED) + /// Mutex used by `nb_type_map_per_thread`, stored here because it fits + /// in padding this way. + PyMutex mutex{}; +#endif -struct nb_translator_seq { - exception_translator translator; - void *payload; - nb_translator_seq *next = nullptr; + private: + // Use a generic ptr->ptr map to avoid needing another instantiation of + // robin_map. Keys are const std::type_info*. See TYPE MAPPING above for + // the interpretation of the values. + nb_ptr_map data; }; +#if defined(NB_FREE_THREADED) +struct nb_internals; + +/** + * Wrapper for nb_type_map_fast in free-threaded mode. Each extension module + * in a nanobind domain has its own instance of this in thread-local storage + * for each thread that has used nanobind bindings exposed by that extension + * module. When the slow map is modified in a way that would invalidate the + * fast map (removing a cached entry or adding an entry for which a negative + * lookup has been cached), the linked list is used to update all the caches. + * Outside of such actions, which occur infrequently, this is a thread-local + * structure so the mutex accesses are never contended. + */ +struct nb_type_map_per_thread { + explicit nb_type_map_per_thread(nb_internals &internals_); + ~nb_type_map_per_thread(); + + struct guard; + + struct guard { + guard() = default; + guard(guard&& other) noexcept : parent(other.parent) { + other.parent = nullptr; + } + guard& operator=(guard other) noexcept { + std::swap(parent, other.parent); + return *this; + } + ~guard() { + if (parent) + PyMutex_Unlock(&parent->map.mutex); + } + + nb_type_map_fast& operator*() const { return parent->map; } + nb_type_map_fast* operator->() const { return &parent->map; } + + private: + friend nb_type_map_per_thread; + explicit guard(nb_type_map_per_thread &parent_) : parent(&parent_) { + PyMutex_Lock(&parent->map.mutex); + } + nb_type_map_per_thread *parent = nullptr; + }; + guard lock() { return guard{*this}; } + + nb_internals &internals; + + private: + // Access to the map is only possible via `guard`, which holds a lock + nb_type_map_fast map; + + public: + // In order to access or modify `next`, you must hold the nb_internals mutex + // (this->map.mutex is not needed for iteration) + nb_type_map_per_thread *next = nullptr; +}; +#endif + #if defined(NB_FREE_THREADED) # define NB_SHARD_ALIGNMENT alignas(64) #else @@ -223,8 +448,8 @@ struct NB_SHARD_ALIGNMENT nb_shard { * * This associative data structure maps a C++ instance pointer onto its * associated PyObject* (if bit 0 of the map value is zero) or a linked - * list of type `nb_inst_seq*` (if bit 0 is set---it must be cleared before - * interpreting the pointer in this case). + * list of type `nb_inst_seq*` (if bit 0 is set---it must be cleared + * before interpreting the pointer in this case). * * The latter case occurs when several distinct Python objects reference * the same memory address (e.g. a struct and its first member). @@ -239,7 +464,6 @@ struct NB_SHARD_ALIGNMENT nb_shard { #endif }; - /** * Wraps a std::atomic if free-threading is enabled, otherwise a raw value. */ @@ -249,9 +473,12 @@ struct nb_maybe_atomic { nb_maybe_atomic(T v) : value(v) {} std::atomic value; - T load_acquire() { return value.load(std::memory_order_acquire); } - T load_relaxed() { return value.load(std::memory_order_relaxed); } - void store_release(T w) { value.store(w, std::memory_order_release); } + NB_INLINE T load_acquire() { return value.load(std::memory_order_acquire); } + NB_INLINE T load_relaxed() { return value.load(std::memory_order_relaxed); } + NB_INLINE void store_release(T w) { value.store(w, std::memory_order_release); } + NB_INLINE bool compare_exchange_weak(T& expected, T desired) { + return value.compare_exchange_weak(expected, desired); + } }; #else template @@ -259,12 +486,56 @@ struct nb_maybe_atomic { nb_maybe_atomic(T v) : value(v) {} T value; - T load_acquire() { return value; } - T load_relaxed() { return value; } - void store_release(T w) { value = w; } + NB_INLINE T load_acquire() { return value; } + NB_INLINE T load_relaxed() { return value; } + NB_INLINE void store_release(T w) { value = w; } + NB_INLINE bool compare_exchange_weak(T& expected, T desired) { + check(value == expected, "compare-exchange would deadlock"); + value = desired; + return true; + } }; #endif +/** + * Access a non-std::atomic using atomics if we're free-threading -- + * for type_data::foreign_bindings (so we don't have to #include + * in nanobind.h) and nb_foreign_seq::next (so that nb_seq can be generic) + */ +#if !defined(NB_FREE_THREADED) +template NB_INLINE T nb_load_acquire(T& loc) { return loc; } +template NB_INLINE void nb_store_release(T& loc, T val) { loc = val; } +#elif __cplusplus >= 202002L +// Use std::atomic_ref if available +template +NB_INLINE T nb_load_acquire(T& loc) { + return std::atomic_ref(loc).load(std::memory_order_acquire); +} +template +NB_INLINE void nb_store_release(T& loc, T val) { + return std::atomic_ref(loc).store(val, std::memory_order_release); +} +#else +// Fallback to type punning if not +template +NB_INLINE T nb_load_acquire(T& loc) { + return std::atomic_load_explicit((std::atomic *) &loc, + std::memory_order_acquire); +} +template +NB_INLINE void nb_store_release(T& loc, T val) { + return std::atomic_store_explicit((std::atomic *) &loc, val, + std::memory_order_release); +} +#endif + +// Entry in a list of exception translators +struct nb_translator_seq { + exception_translator translator; + void *payload; + nb_maybe_atomic next = nullptr; +}; + /** * `nb_internals` is the central data structure storing information related to * function/type bindings and instances. Separate nanobind extensions within the @@ -312,27 +583,42 @@ struct nb_maybe_atomic { * potentially hot and shares the sharding scheme of `inst_c2p`. * * - `type_c2p_slow`: This is the ground-truth source of the `std::type_info` - * to `type_info *` mapping. Unrelated to free-threading, lookups into this + * to type data mapping. Unrelated to free-threading, lookups into this * data struture are generally costly because they use a string comparison on * some platforms. Because it is only used as a fallback for 'type_c2p_fast', * protecting this member via the global `mutex` is sufficient. * - * - `type_c2p_fast`: this data structure is *hot* and mostly read. It maps - * `std::type_info` to `type_info *` but uses pointer-based comparisons. - * The implementation depends on the Python build. + * - `types_in_c2p_fast`: Used only when accessing or updating `type_c2p_slow`, so + * protecting it with the global `mutex` adds no additional overhead. * - * - `translators`: This is an append-to-front-only singly linked list traversed - * while raising exceptions. The main concern is losing elements during - * concurrent append operations. We assume that this data structure is only - * written during module initialization and don't use locking. + * - `type_c2p_fast`: this data structure is *hot* and mostly read. It serves + * as a cache of `type_c2p_slow`, mapping `std::type_info` to type data using + * pointer-based comparisons. On free-threaded builds, each thread gets its + * own mostly-local instance inside `nb_type_data_per_thread`, which is + * protected by an internal mutex in order to safely handle the rare need for + * cache invalidations. The head of the linked list of these instances, + * `type_c2p_per_thread_head`, is protected by `mutex`; it is only accessed + * rarely (when a new thread first uses nanobind, when a thread exits, and + * when a type is created or destroyed that has previously been cached). + * + * - `translators`: This is a singly linked list traversed while raising + * exceptions, from which no element is ever removed. The rare insertions use + * compare-and-swap on the head or prev->next pointer. * * - `funcs`: data structure for function leak tracking. Not used in - * free-threaded mode . + * free-threaded mode. + * + * - `foreign_self`, `foreign_exception_translator`: created only once on + * demand, protected by `mutex`; often OK to read without locking since + * they never change once set * - * - `print_leak_warnings`, `print_implicit_cast_warnings`: simple boolean - * flags. No protection against concurrent conflicting updates. + * - `foreign_manual_imports`: accessed and modified only during binding import + * and removal, which are rare; protected by internals lock + * + * - `print_leak_warnings`, `print_implicit_cast_warnings`, + * `foreign_export`, `foreign_import`: simple configuration flags. + * No protection against concurrent conflicting updates. */ - struct nb_internals { /// Internal nanobind module PyObject *nb_module; @@ -375,22 +661,36 @@ struct nb_internals { inline nb_shard &shard(void *) { return shards[0]; } #endif + /* See TYPE MAPPING above for much more detail on the interplay of + type_c2p_fast, type_c2p_slow, and types_in_c2p_fast */ + #if !defined(NB_FREE_THREADED) /// C++ -> Python type map -- fast version based on std::type_info pointer equality nb_type_map_fast type_c2p_fast; +#else + /// Head of the list of per-thread fast C++ -> Python type maps + nb_type_map_per_thread *type_c2p_per_thread_head = nullptr; #endif /// C++ -> Python type map -- slow fallback version based on hashed strings nb_type_map_slow type_c2p_slow; + /// Each std::type_info that is a key in any `nb_type_map_fast` is + /// equivalent to some key in this map. If (by pointer equality) there is + /// only one such std::type_info, the value is null; otherwise, the value + /// is a `nb_alias_seq*` that heads a list of types that are + /// equivalent to the key but have distinct `std::type_info` pointers. + nb_type_map_slow types_in_c2p_fast; + #if !defined(NB_FREE_THREADED) /// nb_func/meth instance map for leak reporting (used as set, the value is unused) /// In free-threaded mode, functions are immortal and don't require this data structure. nb_ptr_map funcs; #endif - /// Registered C++ -> Python exception translators - nb_translator_seq translators; + /// Registered C++ -> Python exception translators. The default exception + /// translator is the last one in this list. + nb_maybe_atomic translators = nullptr; /// Should nanobind print leak warnings on exit? bool print_leak_warnings = true; @@ -398,6 +698,37 @@ struct nb_internals { /// Should nanobind print warnings after implicit cast failures? bool print_implicit_cast_warnings = true; + /// Should this nanobind domain advertise all of its own types to other + /// binding frameworks (including other nanobind domains) for use by other + /// extension modules loaded in this interpreter? Even if this is disabled, + /// you can export individual types using nb::export_type_to_foreign(). + bool foreign_export_all = false; + + /// Should this nanobind domain make use of all C++ types advertised by + /// other binding frameworks (including other nanobind domains) from other + /// extension modules loaded in this interpreter? Even if this is disabled, + /// you can import individual types using nb::import_foreign_type(). + bool foreign_import_all = false; + + /// Have there ever been any foreign types in `type_c2p_slow`? If not, + /// we can skip some logic in nb_type_get/put. + bool foreign_imported_any = false; + + /// Pointer to our own framework object in pymetabind, if enabled + pymb_framework *foreign_self = nullptr; + + /// Map from pymb_binding* to std::type_info*, reflecting types exported by + /// (typically) non-C++ extension modules that have been associated with + /// C++ types via nb::import_foreign_type() + nb_ptr_map foreign_manual_imports; + + /// Pointer to the canonical copy of `foreign_exception_translator()` + /// from nb_foreign.cpp. Each DSO may have a different copy, but all will + /// use the implementation from the first DSO to need it, so that + /// `nb_foreign_translate_exception()` (translating our exceptions for + /// a foreign framework's benefit) can skip foreign translators. + void (*foreign_exception_translator)(const std::exception_ptr&, void*); + /// Pointer to a boolean that denotes if nanobind is fully initialized. bool *is_alive_ptr = nullptr; @@ -418,6 +749,9 @@ struct nb_internals { // Size of the 'shards' data structure. Only rarely accessed, hence at the end size_t shard_count = 1; + + // NB_DOMAIN string used to initialize this nanobind domain + const char *domain; }; /// Convenience macro to potentially access cached functions @@ -437,8 +771,44 @@ extern PyObject *inst_new_ext(PyTypeObject *tp, void *value); extern PyObject *inst_new_int(PyTypeObject *tp, PyObject *args, PyObject *kwds); extern PyTypeObject *nb_static_property_tp() noexcept; extern type_data *nb_type_c2p(nb_internals *internals, - const std::type_info *type); + const std::type_info *type, + bool *has_foreign = nullptr); +extern bool nb_type_register(type_data *t, type_data **conflict) noexcept; extern void nb_type_unregister(type_data *t) noexcept; +#if defined(NB_FREE_THREADED) +extern nb_type_map_per_thread::guard nb_type_lock_c2p_fast( + nb_internals *internals_) noexcept; +#endif +extern void nb_type_update_c2p_fast(const std::type_info *type, + void *value) noexcept; + +#if !defined(NB_DISABLE_INTEROP) +extern void *nb_type_try_foreign(nb_internals *internals_, + const std::type_info *type, + void* (*attempt)(void *closure, + pymb_binding *binding), + void *closure) noexcept; +extern void *nb_type_get_foreign(nb_internals *internals_, + const std::type_info *cpp_type, + PyObject *src, + uint8_t flags, + cleanup_list *cleanup) noexcept; +extern PyObject *nb_type_put_foreign(nb_internals *internals_, + const std::type_info *cpp_type, + const std::type_info *cpp_type_p, + void *value, + rv_policy rvp, + cleanup_list *cleanup, + bool *is_new) noexcept; +extern void nb_type_import_impl(PyObject *pytype, + const std::type_info *cpptype); +extern void nb_type_export_impl(type_data *td); +extern void nb_type_enable_import_all(); +extern void nb_type_enable_export_all(); +#endif + +extern bool set_builtin_exception_status(builtin_exception &e); +extern void default_exception_translator(const std::exception_ptr &, void *); extern PyObject *call_one_arg(PyObject *fn, PyObject *arg) noexcept; @@ -460,6 +830,9 @@ NB_INLINE type_data *nb_type_data(PyTypeObject *o) noexcept{ #endif } +// Fetch the type record from an enum created by nanobind +extern type_init_data *enum_get_type_data(handle tp); + inline void *inst_ptr(nb_inst *self) { void *ptr = (void *) ((intptr_t) self + self->offset); return self->direct ? ptr : *(void **) ptr; diff --git a/src/nb_type.cpp b/src/nb_type.cpp index f60083d77..238f4dc4f 100644 --- a/src/nb_type.cpp +++ b/src/nb_type.cpp @@ -185,22 +185,11 @@ static void inst_register(PyObject *inst, void *value) noexcept { auto [it, success] = shard.inst_c2p.try_emplace(value, inst); if (NB_UNLIKELY(!success)) { - void *entry = it->second; - // Potentially convert the map value into linked list format - if (!nb_is_seq(entry)) { - nb_inst_seq *first = (nb_inst_seq *) PyMem_Malloc(sizeof(nb_inst_seq)); - check(first, "nanobind::detail::inst_new_ext(): list element " - "allocation failed!"); - first->inst = (PyObject *) entry; - first->next = nullptr; - entry = it.value() = nb_mark_seq(first); - } - - nb_inst_seq *seq = nb_get_seq(entry); + nb_inst_seq *seq = nb_ensure_seq(&it.value()); while (true) { // The following should never happen - check(inst != seq->inst, "nanobind::detail::inst_new_ext(): duplicate instance!"); + check(inst != seq->value, "nanobind::detail::inst_new_ext(): duplicate instance!"); if (!seq->next) break; @@ -211,7 +200,7 @@ static void inst_register(PyObject *inst, void *value) noexcept { check(next, "nanobind::detail::inst_new_ext(): list element allocation failed!"); - next->inst = (PyObject *) inst; + next->value = inst; next->next = nullptr; seq->next = next; } @@ -295,11 +284,11 @@ static void inst_dealloc(PyObject *self) { inst_c2p.erase_fast(it); } else if (nb_is_seq(entry)) { // Multiple objects are associated with this address. Find the right one! - nb_inst_seq *seq = nb_get_seq(entry), + nb_inst_seq *seq = nb_get_seq(entry), *pred = nullptr; do { - if ((nb_inst *) seq->inst == inst) { + if ((nb_inst *) seq->value == inst) { found = true; if (pred) { @@ -346,101 +335,234 @@ static void inst_dealloc(PyObject *self) { Py_DECREF(tp); } +#if defined(NB_FREE_THREADED) +nb_type_map_per_thread::guard nb_type_lock_c2p_fast(nb_internals *internals_) + noexcept { + thread_local nb_type_map_per_thread type_c2p_per_thread{*internals_}; + return type_c2p_per_thread.lock(); +} +#endif + +static void nb_type_c2p_fill_from_slow(nb_internals *internals_, + void *&fast_value, + const std::type_info *type) { + // Cache miss. Fetch the true value from the slow map. + nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; + nb_type_map_slow::iterator it_slow = type_c2p_slow.find(type); + fast_value = (it_slow != type_c2p_slow.end() ? it_slow->second : nullptr); + + // Maintain a linked list to clean up 'type_c2p_fast' when the type + // expires (see nb_type_unregister). + auto [it_alias, inserted] = + internals_->types_in_c2p_fast.try_emplace(type, nullptr); + if (!inserted) { + // Check whether `type` is an alias we haven't seen yet. + bool seen = (type == it_alias->first); + nb_alias_seq *prev = nullptr; + nb_alias_seq *node = (nb_alias_seq *) it_alias->second; + while (node && !seen) { + seen |= (type == node->value); + prev = node; + node = node->next; + } + if (!seen) { + // Got a new one -- add it. NB: use non-Python allocator for these + // since we won't free them until internals_cleanup(), which is + // after interpreter finalization + node = (nb_alias_seq *) malloc(sizeof(nb_alias_seq)); + check(node, "Could not allocate alias chain entry!"); + node->value = type; + node->next = nullptr; + if (prev) + prev->next = node; + else + it_alias.value() = node; + } + } +} type_data *nb_type_c2p(nb_internals *internals_, - const std::type_info *type) { + const std::type_info *type, + bool *has_foreign) { #if defined(NB_FREE_THREADED) - thread_local nb_type_map_fast type_c2p_fast; + auto per_thread_guard = nb_type_lock_c2p_fast(internals_); + nb_type_map_fast &type_c2p_fast = *per_thread_guard; #else nb_type_map_fast &type_c2p_fast = internals_->type_c2p_fast; #endif + void *const sentinel = (void *) (uintptr_t) 1; + void *&slot = type_c2p_fast.lookup_or_set(type, sentinel); + void *result = slot; - nb_type_map_fast::iterator it_fast = type_c2p_fast.find(type); - if (it_fast != type_c2p_fast.end()) - return it_fast->second; + if (NB_UNLIKELY(result == sentinel)) { + // Cache miss. Fetch the true value from the slow map. +#if defined(NB_FREE_THREADED) + // Accessing the slow map requires locking internals. We must first + // unlock the local mutex in order to maintain consistent lock ordering + // against concurrent updates from `nb_type_update_c2p_fast`. Since all + // operations on the fast type map from other threads are in-place value + // updates (insertions only come from our thread and deletions never + // occur), `slot` won't be invalidated during the brief period where + // we hold no locks. + per_thread_guard = {}; + + // While `slot` still will point to the correct place, it's possible + // that its value changes here if another thread modifies the c2p_slow + // mapping for this type. If it does so, then `type` must have already + // been in the alias map, so there's nothing further to do. - lock_internals guard(internals_); - nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; - nb_type_map_slow::iterator it_slow = type_c2p_slow.find(type); - if (it_slow != type_c2p_slow.end()) { - type_data *d = it_slow->second; - -#if !defined(NB_FREE_THREADED) - // Maintain a linked list to clean up 'type_c2p_fast' when the type - // expires (see nb_type_unregister). In free-threaded mode, we leak - // these entries until the thread destructs. - nb_alias_chain *chain = - (nb_alias_chain *) PyMem_Malloc(sizeof(nb_alias_chain)); - check(chain, "Could not allocate nb_alias_chain entry!"); - chain->next = d->alias_chain; - chain->value = type; - d->alias_chain = chain; + lock_internals guard(internals_); + if (NB_LIKELY(slot == sentinel)) #endif + nb_type_c2p_fill_from_slow(internals_, slot, type); + result = slot; - type_c2p_fast[type] = d; - return d; + // NB: after we drop `guard` at the end of this block, we'll hold no + // locks, so the type_data we're looking up could be concurrently + // deleted on free-threaded builds if nanobind didn't immortalize types. + // (Since we return it without locking regardless, extending the + // locking here wouldn't help on its own.) } - return nullptr; + auto *t = (type_data *) result; + +#if !defined(NB_DISABLE_INTEROP) + if (nb_is_foreign(result)) { + if (has_foreign) + *has_foreign = true; + return nullptr; + } + + if (has_foreign) { + // t->foreign_bindings is the list of registered bindings for this C++ + // type, including potentially one that we exported. Report has_foreign + // only if there's more than one or the single one isn't ours. + void *foreign_bindings = t ? nb_load_acquire(t->foreign_bindings) + : nullptr; + *has_foreign = t && foreign_bindings && + (nb_is_seq(foreign_bindings) || + ((pymb_binding *) foreign_bindings)->framework != + internals_->foreign_self); + } +#else + (void) has_foreign; +#endif + + return t; } -void nb_type_unregister(type_data *t) noexcept { +static bool nb_type_update_cache(nb_type_map_fast &cache, + const std::type_info *type, + nb_alias_seq *more_types, + void *value) { + bool found = cache.update(type, value); + while (more_types) { + found |= cache.update(more_types->value, value); + more_types = more_types->next; + } + return found; +} + +void nb_type_update_c2p_fast(const std::type_info *type, void *value) noexcept { + // internals must be locked by the caller + nb_internals *internals_ = internals; + auto it_alias = internals_->types_in_c2p_fast.find(type); + if (it_alias != internals_->types_in_c2p_fast.end()) { +#if defined(NB_FREE_THREADED) + for (nb_type_map_per_thread *cache = + internals_->type_c2p_per_thread_head; + cache; cache = cache->next) { + nb_type_update_cache(*cache->lock(), it_alias->first, + (nb_alias_seq *) it_alias->second, + value); + } + // We can't require that we found a match, because the type might + // have been cached only by a thread that has since exited. +#else + bool found = nb_type_update_cache( + internals_->type_c2p_fast, it_alias->first, + (nb_alias_seq *) it_alias->second, value); + check(found, "nanobind::detail::nb_type_update_c2p_fast(\"%s\"): " + "types_in_c2p_fast and type_c2p_fast are inconsistent", + type_name(type)); +#endif + } +} + +bool nb_type_register(type_data *t, type_data **conflict) noexcept { nb_internals *internals_ = internals; nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; lock_internals guard(internals_); - size_t n_del_slow = type_c2p_slow.erase(t->type); - -#if defined(NB_FREE_THREADED) - // In free-threaded mode, stale type information remains in the - // 'type_c2p_fast' TLS. This data structure is eventually deallocated - // when the thread terminates. - // - // In principle, this is dangerous because the user could delete a type - // binding from a module at runtime, causing the associated - // Python type object to be freed. If a function then attempts to return - // a value with such a de-registered type, nanobind should raise an - // exception, which requires knowing that the entry in 'type_c2p_fast' - // has become invalid in the meantime. - // - // Right now, this problem is avoided because we immortalize type objects in - // ``nb_type_new()`` and ``enum_create()``. However, we may not always - // want to stick with immortalization, which is just a workaround. - // - // In the future, a global version counter modified with acquire/release - // semantics (see https://github.com/wjakob/nanobind/pull/695#discussion_r1761600010) - // might prove to be a similarly efficient but more general solution. - bool fail = n_del_slow != 1; -#else - nb_type_map_fast &type_c2p_fast = internals_->type_c2p_fast; - size_t n_del_fast = type_c2p_fast.erase(t->type); - - bool fail = n_del_fast != 1 || n_del_slow != 1; - if (!fail) { - nb_alias_chain *cur = t->alias_chain; - while (cur) { - nb_alias_chain *next = cur->next; - n_del_fast = type_c2p_fast.erase(cur->value); - if (n_del_fast != 1) { - fail = true; - break; - } - PyMem_Free(cur); - cur = next; + auto [it_slow, inserted] = type_c2p_slow.try_emplace(t->type, t); + if (!inserted) { +#if !defined(NB_DISABLE_INTEROP) + if (nb_is_foreign(it_slow->second)) { + nb_store_release(t->foreign_bindings, + nb_get_foreign(it_slow->second)); + it_slow.value() = t; + } else +#endif + { + *conflict = (type_data *) it_slow->second; + return false; // already registered } } + nb_type_update_c2p_fast(t->type, t); +#if !defined(NB_DISABLE_INTEROP) + if (internals_->foreign_export_all) + nb_type_export_impl(t); #endif + return true; +} - check(!fail, +void nb_type_unregister(type_data *t) noexcept { + nb_internals *internals_ = internals; +#if !defined(NB_DISABLE_INTEROP) + void *foreign_bindings = nb_load_acquire(t->foreign_bindings); + pymb_binding *binding_to_remove = nullptr; + + if (nb_is_seq(foreign_bindings)) { + nb_foreign_seq *node = nb_get_seq(foreign_bindings); + if (node->value->framework == internals_->foreign_self) + binding_to_remove = node->value; + } else if (auto *binding = (pymb_binding *) foreign_bindings; + binding && binding->framework == internals_->foreign_self) { + binding_to_remove = binding; + } + if (binding_to_remove) + pymb_remove_binding(binding_to_remove); +#endif + + lock_internals guard(internals_); + nb_type_map_slow &type_c2p_slow = internals_->type_c2p_slow; + auto it_slow = type_c2p_slow.find(t->type); + check(it_slow != type_c2p_slow.end() && it_slow->second == t, "nanobind::detail::nb_type_unregister(\"%s\"): could not " "find type!", t->name); + +#if !defined(NB_DISABLE_INTEROP) + foreign_bindings = nb_load_acquire(t->foreign_bindings); + if (foreign_bindings) { + void *new_value = nb_mark_foreign(foreign_bindings); + it_slow.value() = new_value; + nb_type_update_c2p_fast(t->type, new_value); + return; + } +#endif + type_c2p_slow.erase(it_slow); + nb_type_update_c2p_fast(t->type, nullptr); } -static void nb_type_dealloc(PyObject *o) { +static void nb_type_finalize(PyObject *o) { type_data *t = nb_type_data((PyTypeObject *) o); if (t->type && (t->flags & (uint32_t) type_flags::is_python_type) == 0) nb_type_unregister(t); +} + +static void nb_type_dealloc(PyObject *o) { + type_data *t = nb_type_data((PyTypeObject *) o); if (t->flags & (uint32_t) type_flags::has_implicit_conversions) { PyMem_Free(t->implicit.cpp); @@ -495,7 +617,7 @@ static int nb_type_init(PyObject *self, PyObject *args, PyObject *kwds) { t->type_py = (PyTypeObject *) self; t->implicit.cpp = nullptr; t->implicit.py = nullptr; - t->alias_chain = nullptr; + t->foreign_bindings = nullptr; #if defined(Py_LIMITED_API) t->vectorcall = nullptr; @@ -770,7 +892,7 @@ static PyObject *nb_type_from_metaclass(PyTypeObject *meta, PyObject *mod, if (slot == 0) { break; - } else if (slot * sizeof(nb_slot) < (int) sizeof(type_slots)) { + } else if (slot * sizeof(nb_slot) <= (int) sizeof(type_slots)) { *(((void **) ht) + type_slots[slot - 1].direct) = ts->pfunc; } else { PyErr_Format(PyExc_RuntimeError, @@ -866,6 +988,7 @@ static PyTypeObject *nb_type_tp(size_t supplement) noexcept { PyType_Slot slots[] = { { Py_tp_base, &PyType_Type }, + { Py_tp_finalize, (void *) nb_type_finalize }, { Py_tp_dealloc, (void *) nb_type_dealloc }, { Py_tp_setattro, (void *) nb_type_setattro }, { Py_tp_init, (void *) nb_type_init }, @@ -1073,26 +1196,8 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { object modname; PyObject *mod = nullptr; - // Update hash table that maps from std::type_info to Python type - nb_type_map_slow::iterator it; - bool success; nb_internals *internals_ = internals; - { - lock_internals guard(internals_); - std::tie(it, success) = internals_->type_c2p_slow.try_emplace(t->type, nullptr); - if (!success) { - PyErr_WarnFormat(PyExc_RuntimeWarning, 1, - "nanobind: type '%s' was already registered!\n", - t_name); - PyObject *tp = (PyObject *) it->second->type_py; - Py_INCREF(tp); - if (has_signature) - free((char *) t_name); - return tp; - } - } - if (t->scope != nullptr) { if (PyModule_Check(t->scope)) { mod = t->scope; @@ -1144,10 +1249,11 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { } else if (has_base) { lock_internals guard(internals_); nb_type_map_slow::iterator it2 = internals_->type_c2p_slow.find(t->base); - check(it2 != internals_->type_c2p_slow.end(), + check(it2 != internals_->type_c2p_slow.end() && + !nb_is_foreign(it2->second), "nanobind::detail::nb_type_new(\"%s\"): base type \"%s\" not " "known to nanobind!", t_name, type_name(t->base)); - base = (PyObject *) it2->second->type_py; + base = (PyObject *) ((type_data *) it2->second)->type_py; } type_data *tb = nullptr; @@ -1328,8 +1434,6 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { Py_DECREF(metaclass); - make_immortal(result); - type_data *to = nb_type_data((PyTypeObject *) result); *to = *t; // note: slices off _init parts @@ -1354,7 +1458,7 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { to->name = name_copy; to->type_py = (PyTypeObject *) result; - to->alias_chain = nullptr; + to->foreign_bindings = nullptr; to->init = nullptr; if (has_dynamic_attr) @@ -1378,15 +1482,6 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { if (modname.is_valid()) setattr(result, "__module__", modname.ptr()); - { - lock_internals guard(internals_); - internals_->type_c2p_slow[t->type] = to; - - #if !defined(NB_FREE_THREADED) - internals_->type_c2p_fast[t->type] = to; - #endif - } - if (has_signature) { setattr(result, "__nb_signature__", str(t->name)); free((char *) t_name); @@ -1397,6 +1492,19 @@ PyObject *nb_type_new(const type_init_data *t) noexcept { setattr(result, "__orig_bases__", make_tuple(handle(t->base_py))); #endif + // Update hash table that maps from std::type_info to Python type + if (type_data *conflict; !nb_type_register(to, &conflict)) { + PyErr_WarnFormat(PyExc_RuntimeWarning, 1, + "nanobind: type '%s' was already registered!\n", + to->name); + PyObject *tp = (PyObject *) conflict->type_py; + Py_INCREF(tp); + Py_DECREF(result); + return tp; + } + + make_immortal(result); + return result; } @@ -1424,21 +1532,38 @@ static NB_NOINLINE bool nb_type_get_implicit(PyObject *src, const std::type_info *cpp_type_src, const type_data *dst_type, nb_internals *internals_, - cleanup_list *cleanup, void **out) noexcept { - if (dst_type->implicit.cpp && cpp_type_src) { + cleanup_list *cleanup, + void **out) noexcept { + if (dst_type->implicit.cpp && + (cpp_type_src || internals_->foreign_imported_any)) { const std::type_info **it = dst_type->implicit.cpp; const std::type_info *v; - while ((v = *it++)) { - if (v == cpp_type_src || *v == *cpp_type_src) - goto found; + if (cpp_type_src) { + while ((v = *it++)) { + if (v == cpp_type_src || *v == *cpp_type_src) + goto found; + } + it = dst_type->implicit.cpp; } - it = dst_type->implicit.cpp; while ((v = *it++)) { - const type_data *d = nb_type_c2p(internals_, v); - if (d && PyType_IsSubtype(Py_TYPE(src), d->type_py)) + bool has_foreign = false; + type_data *td = nb_type_c2p(internals_, v, &has_foreign); + if (td && PyType_IsSubtype(Py_TYPE(src), td->type_py)) + goto found; +#if !defined(NB_DISABLE_INTEROP) + if (has_foreign && nb_type_try_foreign( + internals_, v, + +[](void *src_, pymb_binding *binding) -> void* { + PyObject *src = (PyObject *) src_; + if (PyType_IsSubtype(Py_TYPE(src), binding->pytype)) + return binding; + return nullptr; + }, + src)) goto found; +#endif } } @@ -1486,6 +1611,40 @@ static NB_NOINLINE bool nb_type_get_implicit(PyObject *src, } } +#if !defined(NB_DISABLE_INTEROP) +// Encapsulates the foreign-type part of nb_type_get() +void *nb_type_get_foreign(nb_internals *internals_, + const std::type_info *cpp_type, + PyObject *src, + uint8_t flags, + cleanup_list *cleanup) noexcept { + if (flags & (uint8_t) cast_flags::not_foreign) + return nullptr; + + struct capture { + PyObject *src; + uint8_t flags; + cleanup_list *cleanup; + } cap{src, flags, cleanup}; + + auto attempt = +[](void *closure, pymb_binding *binding) -> void* { + capture &cap = *(capture *) closure; + auto keep_referenced = [](void *ctx, PyObject* item) { + Py_INCREF(item); + ((cleanup_list *) ctx)->append(item); + }; + return binding->framework->from_python( + binding, + cap.src, + bool(cap.flags & (uint16_t) cast_flags::convert), + cap.cleanup ? +keep_referenced : nullptr, + cap.cleanup); + }; + + return nb_type_try_foreign(internals_, cpp_type, attempt, &cap); +} +#endif + // Attempt to retrieve a pointer to a C++ instance bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, cleanup_list *cleanup, void **out) noexcept { @@ -1501,6 +1660,7 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, type_data *dst_type = nullptr; nb_internals *internals_ = internals; + bool has_foreign = false; // If 'src' is a nanobind-bound type if (NB_LIKELY(src_is_nb_type)) { @@ -1512,9 +1672,14 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, // If not, look up the Python type and check the inheritance chain if (NB_UNLIKELY(!valid)) { - dst_type = nb_type_c2p(internals_, cpp_type); + dst_type = nb_type_c2p(internals_, cpp_type, &has_foreign); if (dst_type) valid = PyType_IsSubtype(src_type, dst_type->type_py); + } else { + // The input is a nanobind instance of the correct type, + // so there's no need to check foreign types. Thus, it's + // safe to leave has_foreign == false in this branch. + dst_type = t; } // Success, return the pointer if the instance is correctly initialized @@ -1553,16 +1718,32 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags, } } - // Try an implicit conversion as last resort (if possible & requested) - if ((flags & (uint16_t) cast_flags::convert) && cleanup) { + // Try an implicit conversion (if possible & requested) + if ((flags & (uint8_t) cast_flags::convert) && cleanup) { if (!src_is_nb_type) - dst_type = nb_type_c2p(internals_, cpp_type); + dst_type = nb_type_c2p(internals_, cpp_type, &has_foreign); if (dst_type && - (dst_type->flags & (uint32_t) type_flags::has_implicit_conversions)) - return nb_type_get_implicit(src, cpp_type_src, dst_type, internals_, - cleanup, out); + (dst_type->flags & (uint32_t) type_flags::has_implicit_conversions) && + nb_type_get_implicit(src, cpp_type_src, dst_type, internals_, cleanup, out)) + return true; + } else if (!src_is_nb_type && internals_->foreign_imported_any) { + // If we never determined the dst type and it might be foreign, + // check for that now. + (void) nb_type_c2p(internals_, cpp_type, &has_foreign); + } + +#if !defined(NB_DISABLE_INTEROP) + // Try a foreign type + if (has_foreign) { + void *result = nb_type_get_foreign( + internals_, cpp_type, src, flags, cleanup); + if (result) { + *out = result; + return true; + } } +#endif return false; } @@ -1586,7 +1767,8 @@ void keep_alive(PyObject *nurse, PyObject *patient) { if (!patient || !nurse || nurse == Py_None || patient == Py_None) return; - if (nb_type_check((PyObject *) Py_TYPE(nurse))) { + PyObject *nurse_ty = (PyObject *) Py_TYPE(nurse); + if (nb_type_check(nurse_ty)) { #if defined(NB_FREE_THREADED) nb_shard &shard = internals->shard(inst_ptr((nb_inst *) nurse)); lock_shard guard(shard); @@ -1616,6 +1798,11 @@ void keep_alive(PyObject *nurse, PyObject *patient) { Py_INCREF(patient); ((nb_inst *) nurse)->clear_keep_alive = true; } else { +#if !defined(NB_DISABLE_INTEROP) + if (pymb_binding *binding = pymb_get_binding(nurse_ty); + binding && binding->framework->keep_alive(nurse, patient, nullptr)) + return; +#endif PyObject *callback = PyCFunction_New(&keep_alive_callback_def, patient); @@ -1640,7 +1827,8 @@ void keep_alive(PyObject *nurse, void *payload, void (*callback)(void *) noexcept) noexcept { check(nurse, "nanobind::detail::keep_alive(): 'nurse' is undefined!"); - if (nb_type_check((PyObject *) Py_TYPE(nurse))) { + PyObject *nurse_ty = (PyObject *) Py_TYPE(nurse); + if (nb_type_check(nurse_ty)) { #if defined(NB_FREE_THREADED) nb_shard &shard = internals->shard(inst_ptr((nb_inst *) nurse)); lock_shard guard(shard); @@ -1660,6 +1848,11 @@ void keep_alive(PyObject *nurse, void *payload, ((nb_inst *) nurse)->clear_keep_alive = true; } else { +#if !defined(NB_DISABLE_INTEROP) + if (pymb_binding *binding = pymb_get_binding(nurse_ty); + binding && binding->framework->keep_alive(nurse, payload, callback)) + return; +#endif PyObject *patient = capsule_new(payload, nullptr, callback); keep_alive(nurse, patient); Py_DECREF(patient); @@ -1757,10 +1950,82 @@ static PyObject *nb_type_put_common(void *value, type_data *t, rv_policy rvp, return (PyObject *) inst; } +#if !defined(NB_DISABLE_INTEROP) +PyObject *nb_type_put_foreign(nb_internals *internals_, + const std::type_info *cpp_type, + const std::type_info *cpp_type_p, + void *value, + rv_policy rvp, + cleanup_list *cleanup, + bool *is_new) noexcept { + struct capture { + void *value; + pymb_rv_policy rvp = pymb_rv_policy_none; // conservative default + struct pymb_to_python_feedback feedback{}; + struct pymb_framework *used_framework = nullptr; + } cap{value}; + + switch (rvp) { + case rv_policy::reference_internal: + if (!cleanup || !cleanup->self()) + return nullptr; + cap.rvp = pymb_rv_policy_share_ownership; + break; + case rv_policy::reference: + if (is_new) { + cap.rvp = pymb_rv_policy_share_ownership; + break; + } + [[fallthrough]]; + case rv_policy::take_ownership: + case rv_policy::copy: + case rv_policy::move: + case rv_policy::none: + cap.rvp = (pymb_rv_policy) rvp; + break; + case rv_policy::automatic: + case rv_policy::automatic_reference: + check(false, + "nb_type_put_foreign(): automatic rvp should have been " + "converted to a different one before reaching here!"); + break; + } + + auto attempt = +[](void *closure, pymb_binding *binding) -> void* { + capture &cap = *(capture *) closure; + cap.used_framework = binding->framework; + return binding->framework->to_python( + binding, cap.value, cap.rvp, &cap.feedback); + }; + + void *result_v = nullptr; + if (cpp_type_p && cpp_type_p != cpp_type) + result_v = nb_type_try_foreign(internals_, cpp_type_p, attempt, &cap); + if (!result_v) + result_v = nb_type_try_foreign(internals_, cpp_type, attempt, &cap); + + PyObject *result = (PyObject *) result_v; + if (is_new) + *is_new = cap.feedback.is_new; + if (result && rvp == rv_policy::reference_internal && cap.feedback.is_new && + !cap.used_framework->keep_alive(result, cleanup->self(), nullptr)) { + try { + keep_alive(result, cleanup->self()); + } catch (python_error& exc) { + exc.restore(); + Py_DECREF(result); + return nullptr; + } + } + return result; +} +#endif + PyObject *nb_type_put(const std::type_info *cpp_type, void *value, rv_policy rvp, cleanup_list *cleanup, - bool *is_new) noexcept { + bool *is_new, + bool allow_foreign) noexcept { // Convert nullptr -> None if (!value) { Py_INCREF(Py_None); @@ -1769,10 +2034,11 @@ PyObject *nb_type_put(const std::type_info *cpp_type, nb_internals *internals_ = internals; type_data *td = nullptr; + bool has_foreign = false; - auto lookup_type = [cpp_type, internals_, &td]() -> bool { + auto lookup_type = [cpp_type, internals_, &td, &has_foreign]() -> bool { if (!td) { - type_data *d = nb_type_c2p(internals_, cpp_type); + type_data *d = nb_type_c2p(internals_, cpp_type, &has_foreign); if (!d) return false; td = d; @@ -1780,6 +2046,17 @@ PyObject *nb_type_put(const std::type_info *cpp_type, return true; }; +#if !defined(NB_DISABLE_INTEROP) + auto try_foreign = [=, &has_foreign]() -> PyObject* { + if (has_foreign && allow_foreign) + return nb_type_put_foreign(internals_, cpp_type, nullptr, value, + rvp, cleanup, is_new); + return nullptr; + }; +#else + auto try_foreign = []() { return nullptr; }; +#endif + if (rvp != rv_policy::copy) { nb_shard &shard = internals_->shard(value); lock_shard guard(shard); @@ -1793,26 +2070,26 @@ PyObject *nb_type_put(const std::type_info *cpp_type, nb_inst_seq seq; if (NB_UNLIKELY(nb_is_seq(entry))) { - seq = *nb_get_seq(entry); + seq = *nb_get_seq(entry); } else { - seq.inst = (PyObject *) entry; + seq.value = (PyObject *) entry; seq.next = nullptr; } while (true) { - PyTypeObject *tp = Py_TYPE(seq.inst); + PyTypeObject *tp = Py_TYPE(seq.value); if (nb_type_data(tp)->type == cpp_type) { - if (nb_try_inc_ref(seq.inst)) - return seq.inst; + if (nb_try_inc_ref(seq.value)) + return seq.value; } if (!lookup_type()) - return nullptr; + return try_foreign(); if (PyType_IsSubtype(tp, td->type_py)) { - if (nb_try_inc_ref(seq.inst)) - return seq.inst; + if (nb_try_inc_ref(seq.value)) + return seq.value; } if (seq.next == nullptr) @@ -1821,20 +2098,27 @@ PyObject *nb_type_put(const std::type_info *cpp_type, seq = *seq.next; } } else if (rvp == rv_policy::none) { +#if !defined(NB_DISABLE_INTEROP) + if (internals_->foreign_imported_any) { + (void) lookup_type(); + return try_foreign(); + } +#endif return nullptr; } } // Look up the corresponding Python type if not already done if (!lookup_type()) - return nullptr; + return try_foreign(); return nb_type_put_common(value, td, rvp, cleanup, is_new); } PyObject *nb_type_put_p(const std::type_info *cpp_type, const std::type_info *cpp_type_p, - void *value, rv_policy rvp, + void *value, + rv_policy rvp, cleanup_list *cleanup, bool *is_new) noexcept { // Convert nullptr -> None @@ -1849,10 +2133,10 @@ PyObject *nb_type_put_p(const std::type_info *cpp_type, // Look up the corresponding Python type type_data *td = nullptr, *td_p = nullptr; - - auto lookup_type = [cpp_type, cpp_type_p, internals_, &td, &td_p]() -> bool { + bool has_foreign = false; + auto lookup_type = [cpp_type, cpp_type_p, internals_, &td, &td_p, &has_foreign]() -> bool { if (!td) { - type_data *d = nb_type_c2p(internals_, cpp_type); + type_data *d = nb_type_c2p(internals_, cpp_type, &has_foreign); if (!d) return false; td = d; @@ -1864,6 +2148,24 @@ PyObject *nb_type_put_p(const std::type_info *cpp_type, return true; }; +#if !defined(NB_DISABLE_INTEROP) + auto try_foreign = [=, &has_foreign]() -> PyObject* { + if (has_foreign) { + if (cpp_type_p && cpp_type_p != cpp_type) { + // To get here, lookup_type() must have returned false, meaning + // we never tried looking up cpp_type_p. Do so now since it's + // required before nb_type_try_foreign. + (void) nb_type_c2p(internals_, cpp_type_p); + } + return nb_type_put_foreign(internals_, cpp_type, cpp_type_p, value, + rvp, cleanup, is_new); + } + return nullptr; + }; +#else + auto try_foreign = []() { return nullptr; }; +#endif + if (rvp != rv_policy::copy) { nb_shard &shard = internals_->shard(value); lock_shard guard(shard); @@ -1877,29 +2179,29 @@ PyObject *nb_type_put_p(const std::type_info *cpp_type, nb_inst_seq seq; if (NB_UNLIKELY(nb_is_seq(entry))) { - seq = *nb_get_seq(entry); + seq = *nb_get_seq(entry); } else { - seq.inst = (PyObject *) entry; + seq.value = (PyObject *) entry; seq.next = nullptr; } while (true) { - PyTypeObject *tp = Py_TYPE(seq.inst); + PyTypeObject *tp = Py_TYPE(seq.value); const std::type_info *p = nb_type_data(tp)->type; if (p == cpp_type || p == cpp_type_p) { - if (nb_try_inc_ref(seq.inst)) - return seq.inst; + if (nb_try_inc_ref(seq.value)) + return seq.value; } if (!lookup_type()) - return nullptr; + return try_foreign(); if (PyType_IsSubtype(tp, td->type_py) || (td_p && PyType_IsSubtype(tp, td_p->type_py))) { - if (nb_try_inc_ref(seq.inst)) - return seq.inst; + if (nb_try_inc_ref(seq.value)) + return seq.value; } if (seq.next == nullptr) @@ -1908,18 +2210,24 @@ PyObject *nb_type_put_p(const std::type_info *cpp_type, seq = *seq.next; } } else if (rvp == rv_policy::none) { +#if !defined(NB_DISABLE_INTEROP) + if (internals_->foreign_imported_any) { + (void) lookup_type(); + return try_foreign(); + } +#endif return nullptr; } } // Look up the corresponding Python type if not already done if (!lookup_type()) - return nullptr; + return try_foreign(); return nb_type_put_common(value, td_p ? td_p : td, rvp, cleanup, is_new); } -static void nb_type_put_unique_finalize(PyObject *o, +static bool nb_type_put_unique_finalize(PyObject *o, const std::type_info *cpp_type, bool cpp_delete, bool is_new) { (void) cpp_type; @@ -1928,6 +2236,25 @@ static void nb_type_put_unique_finalize(PyObject *o, "ownership status has become corrupted.", type_name(cpp_type), cpp_delete); +#if !defined(NB_DISABLE_INTEROP) + if (!nb_type_check((PyObject *)Py_TYPE(o))) { + if (!is_new) { + // Object already exists on the Python side. Maybe someone + // previously returned T& with rvp::reference, and now + // is returning unique_ptr of the same object. + // Supporting that would require being able to "upgrade" + // a foreign instance from non-owning to owning. We don't try. + PyErr_SetString(PyExc_TypeError, + "Can't return foreign unique_ptr to Python if " + "a Python object already exists for that object"); + return false; + } + // Otherwise no further action is needed; we successfully did + // a cast with rvp::take_ownership, so Python now owns the object. + return true; + } +#endif + nb_inst *inst = (nb_inst *) o; if (cpp_delete) { @@ -1949,6 +2276,7 @@ static void nb_type_put_unique_finalize(PyObject *o, inst->state = nb_inst::state_ready; } + return true; } PyObject *nb_type_put_unique(const std::type_info *cpp_type, @@ -1959,8 +2287,8 @@ PyObject *nb_type_put_unique(const std::type_info *cpp_type, bool is_new = false; PyObject *o = nb_type_put(cpp_type, value, policy, cleanup, &is_new); - if (o) - nb_type_put_unique_finalize(o, cpp_type, cpp_delete, is_new); + if (o && !nb_type_put_unique_finalize(o, cpp_type, cpp_delete, is_new)) + Py_CLEAR(o); return o; } @@ -1975,8 +2303,8 @@ PyObject *nb_type_put_unique_p(const std::type_info *cpp_type, PyObject *o = nb_type_put_p(cpp_type, cpp_type_p, value, policy, cleanup, &is_new); - if (o) - nb_type_put_unique_finalize(o, cpp_type, cpp_delete, is_new); + if (o && !nb_type_put_unique_finalize(o, cpp_type, cpp_delete, is_new)) + Py_CLEAR(o); return o; } @@ -2044,20 +2372,45 @@ void nb_type_restore_ownership(PyObject *o, bool cpp_delete) noexcept { } } -bool nb_type_isinstance(PyObject *o, const std::type_info *t) noexcept { - type_data *d = nb_type_c2p(internals, t); - if (d) - return PyType_IsSubtype(Py_TYPE(o), d->type_py); - else - return false; +bool nb_type_isinstance(PyObject *o, const std::type_info *t, + bool foreign_ok) noexcept { + bool has_foreign = false; + type_data *d = nb_type_c2p(internals, t, foreign_ok ? &has_foreign : nullptr); + if (d && PyType_IsSubtype(Py_TYPE(o), d->type_py)) + return true; +#if !defined(NB_DISABLE_INTEROP) + if (has_foreign) + return nullptr != nb_type_try_foreign( + internals, t, + +[](void *closure, pymb_binding *binding) -> void* { + return PyType_IsSubtype((PyTypeObject *) closure, + binding->pytype) + ? binding : nullptr; + }, + Py_TYPE(o)); +#else + (void) foreign_ok; +#endif + return false; } -PyObject *nb_type_lookup(const std::type_info *t) noexcept { - type_data *d = nb_type_c2p(internals, t); +PyObject *nb_type_lookup(const std::type_info *t, bool foreign_ok) noexcept { + bool has_foreign = false; + type_data *d = nb_type_c2p(internals, t, foreign_ok ? &has_foreign : nullptr); if (d) return (PyObject *) d->type_py; - else - return nullptr; +#if !defined(NB_DISABLE_INTEROP) + if (has_foreign) + return (PyObject *) nb_type_try_foreign( + internals, t, + +[](void *, pymb_binding *binding) -> void* { + return binding->pytype; + }, + nullptr); +#else + (void) foreign_ok; +#endif + return nullptr; } bool nb_type_check(PyObject *t) noexcept { @@ -2279,5 +2632,56 @@ bool nb_inst_python_derived(PyObject *o) noexcept { (uint32_t) type_flags::is_python_type; } +void nb_type_set_interop_defaults(bool export_all, bool import_all) { +#if !defined(NB_DISABLE_INTEROP) + if (import_all && !internals->foreign_import_all) + nb_type_enable_import_all(); + if (export_all && !internals->foreign_export_all) + nb_type_enable_export_all(); +#else + if (export_all || import_all) + raise("This libnanobind was built without interoperability support"); +#endif +} + +void nb_type_import(PyObject *pytype, const std::type_info *cpptype) { +#if !defined(NB_DISABLE_INTEROP) + lock_internals guard{internals}; + check(PyType_Check(pytype), "not a type object"); + nb_type_import_impl(pytype, cpptype); +#else + (void) pytype; + (void) cpptype; + raise("This libnanobind was built without interoperability support"); +#endif +} + +void nb_type_export(PyObject *pytype) { +#if !defined(NB_DISABLE_INTEROP) + lock_internals guard{internals}; + if (nb_type_check(pytype)) { + nb_type_export_impl(nb_type_data((PyTypeObject *) pytype)); + return; + } + if (hasattr(pytype, "__nb_enum__")) { + static_assert(offsetof(type_data, type) == 8 + sizeof(void*), + "Don't change the offset of type_data::type in future " + "ABI versions; it must be consistent for the following " + "logic to work"); + type_data *td = enum_get_type_data(pytype); + if (auto it = internals->type_c2p_slow.find(td->type); + it != internals->type_c2p_slow.end() && it->second == td) { + nb_type_export_impl(td); + return; + } + } + raise("Can't export %s: not a nanobind class or enum bound in this " + "domain", repr(pytype).c_str()); +#else + (void) pytype; + raise("This libnanobind was built without interoperability support"); +#endif +} + NAMESPACE_END(detail) NAMESPACE_END(NB_NAMESPACE) diff --git a/src/pymetabind.h b/src/pymetabind.h new file mode 100644 index 000000000..1b7c52efe --- /dev/null +++ b/src/pymetabind.h @@ -0,0 +1,1136 @@ +/* + * pymetabind.h: definitions for interoperability between different + * Python binding frameworks + * + * Copy this header file into the implementation of a framework that uses it. + * This functionality is intended to be used by the framework itself, + * rather than by users of the framework. + * + * This is version 0.3 of pymetabind. Changelog: + * + * Version 0.3: Don't do a Py_DECREF in `pymb_remove_framework` since the + * 2025-09-15 interpreter might already be finalized at that point. + * Revamp binding lifetime logic. Add `remove_local_binding` + * and `free_local_binding` callbacks. + * Add `pymb_framework::registry` and use it to simplify + * the signatures of `pymb_remove_framework`, + * `pymb_add_binding`, and `pymb_remove_binding`. + * Update `to_python` protocol to be friendlier to + * pybind11 instances with shared/smart holders. + * Remove `pymb_rv_policy_reference_internal`; add + * `pymb_rv_policy_share_ownership`. Change `keep_alive` + * return value convention. + * + * Version 0.2: Use a bitmask for `pymb_framework::flags` and add leak_safe + * 2025-09-11 flag. Change `translate_exception` to be non-throwing. + * Add casts from PyTypeObject* to PyObject* where needed. + * Fix typo in Py_GIL_DISABLED. Add noexcept to callback types. + * Rename `hook` -> `link` in linked list nodes. + * Use `static inline` linkage in C. Free registry on exit. + * Clear list hooks when adding frameworks/bindings in case + * the user didn't zero-initialize. Avoid abi_extra string + * comparisons if the strings are already pointer-equal. + * Add `remove_foreign_framework` callback. + * + * Version 0.1: Initial draft. ABI may change without warning while we + * 2025-08-16 prove out the concept. Please wait for a 1.0 release + * before including this header in a published release of + * any binding framework. + * + * Copyright (c) 2025 Hudson River Trading + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include +#include +#include + +#if !defined(PY_VERSION_HEX) +# error You must include Python.h before this header +#endif + +// `inline` in C implies a promise to provide an out-of-line definition +// elsewhere; in C++ it does not. +#ifdef __cplusplus +# define PYMB_INLINE inline +#else +# define PYMB_INLINE static inline +#endif + +/* + * There are two ways to use this header file. The default is header-only style, + * where all functions are defined as `inline` (C++) / `static inline` (C). + * If you want to emit functions as non-inline, perhaps so you can link against + * them from non-C/C++ code, then do the following: + * - In every compilation unit that includes this header, `#define PYMB_FUNC` + * first. (The `PYMB_FUNC` macro will be expanded in place of the "inline" + * keyword, so you can also use it to add any other declaration attributes + * required by your environment.) + * - In all those compilation units except one, also `#define PYMB_DECLS_ONLY` + * before including this header. The definitions will be emitted in the + * compilation unit that doesn't request `PYMB_DECLS_ONLY`. + */ +#if !defined(PYMB_FUNC) +#define PYMB_FUNC PYMB_INLINE +#endif + +#if defined(__cplusplus) +#define PYMB_NOEXCEPT noexcept +extern "C" { +#else +#define PYMB_NOEXCEPT +#endif + +/* + * Approach used to cast a previously unknown native instance into a Python + * object. This is similar to `pybind11::return_value_policy` or + * `nanobind::rv_policy`; some different options are provided than those, + * but same-named enumerators have the same semantics and values. + */ +enum pymb_rv_policy { + // Create a Python object that wraps a pointer to a heap-allocated + // native instance and will destroy and deallocate it (in whatever way + // is most natural for the target language) when the Python object is + // destroyed + pymb_rv_policy_take_ownership = 2, + + // Create a Python object that owns a new native instance created by + // copying the given one + pymb_rv_policy_copy = 3, + + // Create a Python object that owns a new native instance created by + // moving the given one + pymb_rv_policy_move = 4, + + // Create a Python object that wraps a pointer to a native instance + // but will not destroy or deallocate it + pymb_rv_policy_reference = 5, + + // Create a Python object that wraps a pointer to a native instance + // and will perform a custom action when the Python object is destroyed. + // The custom action is specified using the first call to keep_alive() + // after the object is created, and such a call must occur in order for + // the object to be considered fully initialized. + pymb_rv_policy_share_ownership = 6, + + // Don't create a new Python object; only try to look up an existing one + // from the same framework + pymb_rv_policy_none = 7 +}; + +/* + * The language to which a particular framework provides bindings. Each + * language has its own semantics for how to interpret + * `pymb_framework::abi_extra` and `pymb_binding::native_type`. + */ +enum pymb_abi_lang { + // C. `pymb_framework::abi_extra` and `pymb_binding::native_type` are NULL. + pymb_abi_lang_c = 1, + + // C++. `pymb_framework::abi_extra` is in the format used by + // nanobind since 2.6.1 (NB_PLATFORM_ABI_TAG in nanobind/src/nb_abi.h) + // and pybind11 since 2.11.2/2.12.1/2.13.6 (PYBIND11_PLATFORM_ABI_ID in + // pybind11/include/pybind11/conduit/pybind11_platform_abi_id.h). + // `pymb_binding::native_type` is a cast `const std::type_info*` pointer. + pymb_abi_lang_cpp = 2, + + // extensions welcome! +}; + +/* + * Simple linked list implementation. `pymb_list_node` should be the first + * member of a structure so you can downcast it to the appropriate type. + */ +struct pymb_list_node { + struct pymb_list_node *next; + struct pymb_list_node *prev; +}; + +struct pymb_list { + struct pymb_list_node head; +}; + +PYMB_INLINE void pymb_list_init(struct pymb_list* list) { + list->head.prev = list->head.next = &list->head; +} + +PYMB_INLINE void pymb_list_unlink(struct pymb_list_node* node) { + if (node->next) { + node->next->prev = node->prev; + node->prev->next = node->next; + node->next = node->prev = NULL; + } +} + +PYMB_INLINE void pymb_list_append(struct pymb_list* list, + struct pymb_list_node* node) { + pymb_list_unlink(node); + struct pymb_list_node* tail = list->head.prev; + tail->next = node; + list->head.prev = node; + node->prev = tail; + node->next = &list->head; +} + +PYMB_INLINE int pymb_list_is_empty(struct pymb_list* list) { + return list->head.next == &list->head; +} + +#define PYMB_LIST_FOREACH(type, name, list) \ + for (type name = (type) (list).head.next; \ + name != (type) &(list).head; \ + name = (type) name->link.next) + +/* + * The registry holds information about all the interoperable binding + * frameworks and individual type bindings that are loaded in a Python + * interpreter process. It is protected by a mutex in free-threaded builds, + * and by the GIL in regular builds. + * + * The only data structure we use is a C doubly-linked list, which offers a + * lowest-common-denominator ABI and cheap addition and removal. It is expected + * that individual binding frameworks will use their `add_foreign_binding` and + * `remove_foreign_binding` callbacks to maintain references to these structures + * in more-performant private data structures of their choosing. + * + * The pointer to the registry is stored in a Python capsule object with type + * "pymetabind_registry", which is stored in the PyInterpreterState_GetDict() + * under the string key "__pymetabind_registry__". Any ABI-incompatible changes + * after v1.0 (which we hope to avoid!) will result in a new name for the + * dictionary key. You can obtain a registry pointer using + * `pymb_get_registry()`, defined below. + */ +struct pymb_registry { + // Linked list of registered `pymb_framework` structures + struct pymb_list frameworks; + + // Linked list of registered `pymb_binding` structures + struct pymb_list bindings; + + // Heap-allocated PyMethodDef for bound type weakref callback + PyMethodDef* weakref_callback_def; + + // Reserved for future extensions; currently set to 0 + uint16_t reserved; + + // Set to true when the capsule that points to this registry is destroyed + uint8_t deallocate_when_empty; + +#if defined(Py_GIL_DISABLED) + // Mutex guarding accesses to `frameworks` and `bindings`. + // On non-free-threading builds, these are guarded by the Python GIL. + PyMutex mutex; +#endif +}; + +#if defined(Py_GIL_DISABLED) +PYMB_INLINE void pymb_lock_registry(struct pymb_registry* registry) { + PyMutex_Lock(®istry->mutex); +} +PYMB_INLINE void pymb_unlock_registry(struct pymb_registry* registry) { + PyMutex_Unlock(®istry->mutex); +} +#else +PYMB_INLINE void pymb_lock_registry(struct pymb_registry* registry) { + (void) registry; +} +PYMB_INLINE void pymb_unlock_registry(struct pymb_registry* registry) { + (void) registry; +} +#endif + +struct pymb_binding; + +/* Flags for a `pymb_framework` */ +enum pymb_framework_flags { + // Does this framework guarantee that its `pymb_binding` structures remain + // valid to use for the lifetime of the Python interpreter process once + // they have been linked into the lists in `pymb_registry`? Setting this + // flag reduces the number of atomic operations needed to work with + // this framework's bindings in free-threaded builds. + pymb_framework_bindings_usable_forever = 0x0001, + + // Does this framework reliably deallocate all of its type and function + // objects by the time the Python interpreter is finalized, in the absence + // of bugs in user code? If not, it might cause leaks of other frameworks' + // types or functions, via attributes or default argument values for + // this framework's leaked objects. Other frameworks can suppress their + // leak warnings (if so equipped) when a non-`leak_safe` framework is added. + pymb_framework_leak_safe = 0x0002, +}; + +/* Additional results from `pymb_framework::to_python` */ +struct pymb_to_python_feedback { + // Ignored on entry. On exit, set to 1 if the returned Python object + // was created by the `to_python` call, or zero if it already existed and + // was simply looked up. + uint8_t is_new; + + // On entry, indicates whether the caller can control whether the native + // instance `cobj` passed to `to_python` is destroyed after the conversion: + // set to 1 if a relocation is allowable or 0 if `cobj` must be destroyed + // after the call. (This is only relevant when using pymb_rv_policy_move.) + // On exit, set to 1 if destruction should be inhibited because `*cobj` + // was relocated into the new instance. Must be left as zero on exit if + // set to zero on entry. + uint8_t relocate; +}; + +/* + * Information about one framework that has registered itself with pymetabind. + * "Framework" here refers to a set of bindings that are natively mutually + * interoperable. So, different binding libraries would be different frameworks, + * as would versions of the same library that use incompatible data structures + * due to ABI changes or build flags. + * + * A framework that wishes to either export bindings (allow other frameworks + * to perform to/from Python conversion for its types) or import bindings + * (perform its own to/from Python conversion for other frameworks' types) + * must start by creating and filling out a `pymb_framework` structure. + * This can be allocated in any way that the framework prefers (e.g., on + * the heap or in static storage). Once filled out, the framework structure + * should be passed to `pymb_add_framework()`. It must then remain accessible + * and unmodified (except as documented below) until the Python interpreter + * is finalized. After finalization, such as in a `Py_AtExit` handler, if + * all bindings have been removed already, you may optionally clean up by + * calling `pymb_remove_framework()` and then deallocating the + * `pymb_framework` structure. + * + * All fields of this structure are set before it is made visible to other + * threads and then never changed, so they don't need locking to access. + * Some methods require locking or other synchronization to call; see their + * individual documentation. + */ +struct pymb_framework { + // Links to the previous and next framework in the list of + // `pymb_registry::frameworks`. May be modified as other frameworks are + // added; protected by the `pymb_registry::mutex` in free-threaded builds. + struct pymb_list_node link; + + // Link to the `pymb_registry` that this framework is registered with. + // Filled in by `pymb_add_framework()`. + struct pymb_registry* registry; + + // Human-readable description of this framework, as a NUL-terminated string + const char* name; + + // Flags for this framework, a combination of `enum pymb_framework_flags`. + // Undefined flags must be set to zero to allow for future + // backward-compatible extensions. + uint16_t flags; + + // Reserved for future extensions. Set to 0. + uint8_t reserved[2]; + + // The language to which this framework provides bindings: one of the + // `pymb_abi_lang` enumerators. + enum pymb_abi_lang abi_lang; + + // NUL-terminated string constant encoding additional information that must + // match in order for two types with the same `abi_lang` to be usable from + // each other's environments. See documentation of `abi_lang` enumerators + // for language-specific guidance. This may be NULL if there are no + // additional ABI details that are relevant for your language. + // + // This is only the platform details that affect things like the layout + // of objects provided by the `abi_lang` (std::string, etc); Python build + // details (free-threaded, stable ABI, etc) should not impact this string. + // Details that are already guaranteed to match by virtue of being in the + // same address space -- architecture, pointer size, OS -- also should not + // impact this string. + // + // For efficiency, `pymb_add_framework()` will compare this against every + // other registered framework's `abi_extra` tag, and re-point an incoming + // framework's `abi_extra` field to refer to the matching `abi_extra` string + // of an already-registered framework if one exists. This acts as a simple + // form of interning to speed up checking that a given binding is usable. + // Thus, to check whether another framework's ABI matches yours, you can + // do a pointer comparison `me->abi_extra == them->abi_extra`. + const char* abi_extra; + + // The function pointers below allow other frameworks to interact with + // bindings provided by this framework. They are constant after construction + // and must not throw C++ exceptions. They must not be NULL; if a feature + // is not relevant to your use case, provide a stub that always fails. + + // Extract a C/C++/etc object from `pyobj`. The desired type is specified by + // providing a `pymb_binding*` for some binding that belongs to this + // framework. Return a pointer to the object, or NULL if no pointer of the + // appropriate type could be extracted. + // + // If `convert` is nonzero, be more willing to perform implicit conversions + // to make the cast succeed; the intent is that one could perform overload + // resolution by doing a first pass with convert=false to find an exact + // match, and then a second with convert=true to find an approximate match + // if there's no exact match. + // + // If `keep_referenced` is not NULL, then `from_python` may make calls to + // `keep_referenced` to request that some Python objects remain referenced + // until the returned object is no longer needed. The `keep_referenced_ctx` + // will be passed as the first argument to any such calls. + // `keep_referenced` should incref its `obj` immediately and remember + // that it should be decref'ed later, for no net change in refcount. + // This is an abstraction around something like the cleanup_list in + // nanobind or loader_life_support in pybind11. The pointer returned by + // `from_python` may be invalidated once the `keep_referenced` references + // are dropped. If you're converting a function argument, you should keep + // any `keep_referenced` references alive until the function returns. + // If you're converting for some other purpose, you probably want to make + // a copy of the object to which `from_python`'s return value points before + // you drop the references. + // + // On free-threaded builds, no direct synchronization is required to call + // this method, but you must ensure the `binding` won't be destroyed during + // (or before) your call. This generally requires maintaining a continuously + // attached Python thread state whenever you hold a pointer to `binding` + // that a concurrent call to your framework's `remove_foreign_binding` + // method wouldn't be able to clear. See the comment for `pymb_binding`. + void* (*from_python)(struct pymb_binding* binding, + PyObject* pyobj, + uint8_t convert, + void (*keep_referenced)(void* ctx, PyObject* obj), + void* keep_referenced_ctx) PYMB_NOEXCEPT; + + // Wrap the C/C++/etc object `cobj` into a Python object using the given + // return value policy. The type is specified by providing a `pymb_binding*` + // for some binding that belongs to this framework. + // + // The semantics of this function are as follows: + // - If there is already a live Python object created by this framework for + // this C++ object address and type, it will be returned and the `rvp` is + // ignored. + // - Otherwise, if `rvp == pymb_rv_policy_none`, NULL is returned without + // the Python error indicator set. + // - Otherwise, a new Python object will be created and returned. It will + // wrap either the pointer `cobj` or a copy/move of the contents of + // `cobj`, depending on the value of `rvp`. + // + // Returns a new reference to a Python object, or NULL if not possible. + // Also sets *feedback to provide additional information about the + // conversion. + // + // After a successful `to_python` call that returns a new instance and + // used `pymb_rv_policy_share_ownership`, the caller must make a call to + // `keep_alive` to describe how the shared ownership should be managed. + // + // On free-threaded builds, no direct synchronization is required to call + // this method, but you must ensure the `binding` won't be destroyed during + // (or before) your call. This generally requires maintaining a continuously + // attached Python thread state whenever you hold a pointer to `binding` + // that a concurrent call to your framework's `remove_foreign_binding` + // method wouldn't be able to clear. See the comment for `pymb_binding`. + PyObject* (*to_python)(struct pymb_binding* binding, + void* cobj, + enum pymb_rv_policy rvp, + struct pymb_to_python_feedback* feedback) PYMB_NOEXCEPT; + + // Request that a PyObject reference be dropped, or that a callback + // be invoked, when `nurse` is destroyed. `nurse` should be an object + // whose type is bound by this framework. If `cb` is NULL, then + // `payload` is a PyObject* to decref; otherwise `payload` will + // be passed as the argument to `cb`. Returns 1 if successful, + // 0 on error. This method may always return 0 if the framework has + // no better way to do a keep-alive than by creating a weakref; + // it is expected that the caller can handle creating the weakref. + // + // No synchronization is required to call this method. + int (*keep_alive)(PyObject* nurse, + void* payload, + void (*cb)(void*)) PYMB_NOEXCEPT; + + // Attempt to translate the native exception `eptr` into a Python exception. + // If `abi_lang` is C++, then `eptr` should be cast to `std::exception_ptr*` + // before use; semantics for other languages have not been defined yet. This + // should translate only framework-specific exceptions or user-defined + // exceptions that were registered with the framework, not generic ones + // such as `std::exception`. If translation succeeds, return 1 with the + // Python error indicator set; otherwise, return 0. An exception may be + // converted into a different exception by modifying `*eptr` and returning + // zero. This method may be set to NULL if its framework does not have + // a concept of exception translation. + // + // No synchronization is required to call this method. + int (*translate_exception)(void* eptr) PYMB_NOEXCEPT; + + // Notify this framework that one of its own bindings is being removed. + // This will occur synchronously from within a call to + // `pymb_remove_binding()`. Don't free the binding yet; wait for a later + // call to `free_local_binding`. + // + // The `pymb_registry::mutex` or GIL will be held when calling this method. + void (*remove_local_binding)(struct pymb_binding* binding) PYMB_NOEXCEPT; + + // Request this framework to free one of its own binding structures. + // A call to `pymb_remove_binding()` will eventually result in a call to + // this method, once pymetabind can prove no one is concurrently using the + // binding anymore. + // + // No synchronization is required to call this method. + void (*free_local_binding)(struct pymb_binding* binding) PYMB_NOEXCEPT; + + // Notify this framework that some other framework published a new binding. + // This call will be made after the new binding has been linked into the + // `pymb_registry::bindings` list. + // + // The `pymb_registry::mutex` or GIL will be held when calling this method. + void (*add_foreign_binding)(struct pymb_binding* binding) PYMB_NOEXCEPT; + + // Notify this framework that some other framework is about to remove + // a binding. This call will be made after the binding has been removed + // from the `pymb_registry::bindings` list. + // + // The `pymb_registry::mutex` or GIL will be held when calling this method. + void (*remove_foreign_binding)(struct pymb_binding* binding) PYMB_NOEXCEPT; + + // Notify this framework that some other framework came into existence. + // This call will be made after the new framework has been linked into the + // `pymb_registry::frameworks` list and before it adds any bindings. + // + // The `pymb_registry::mutex` or GIL will be held when calling this method. + void (*add_foreign_framework)(struct pymb_framework* framework) PYMB_NOEXCEPT; + + // Notify this framework that some other framework is being destroyed. + // This call will be made after the framework has been removed from the + // `pymb_registry::frameworks` list. + // + // This can only occur during interpreter finalization, so no + // synchronization is required. It might occur very late in interpreter + // finalization, such as from a Py_AtExit handler, so it shouldn't + // execute Python code. + void (*remove_foreign_framework)(struct pymb_framework* framework) PYMB_NOEXCEPT; +}; + +/* + * Information about one type binding that belongs to a registered framework. + * + * ### Creating bindings + * + * A framework that binds some type and wants to allow other frameworks to + * work with objects of that type must create a `pymb_binding` structure for + * the type. This can be allocated in any way that the framework prefers (e.g., + * on the heap or within the type object). Any fields without a meaningful + * value must be zero-filled. Once filled out, the binding structure should be + * passed to `pymb_add_binding()`. This will advertise the binding to other + * frameworks' `add_foreign_binding` hooks. It also creates a capsule object + * that points to the `pymb_binding` structure, and stores this capsule in the + * bound type's dict as the attribute "__pymetabind_binding__". + * The intended use of both of these is discussed later on in this comment. + * + * ### Removing bindings + * + * From a user perspective, a binding can be removed for either of two reasons: + * + * - its capsule was destroyed, such as by `del MyType.__pymetabind_binding__` + * - its type object is being finalized + * + * These both result in a call to `pymb_remove_binding()` that begins the + * removal process, but you should not call that function yourself, except + * from a metatype `tp_finalize` as described below. Some time after the call + * to `pymb_remove_binding()`, pymetabind will call the binding's framework's + * `free_local_binding` hook to indicate that it's safe to actually free the + * `pymb_binding` structure. + * + * By default, pymetabind detects the finalization of a binding's type object + * by creating a weakref to the type object with an appropriate callback. This + * works fine, but requires several objects to be allocated, so it is not ideal + * from a memory overhead perspective. If you control the bound type's metatype, + * you can reduce this overhead by modifying the metatype's `tp_finalize` slot + * to call `pymb_remove_binding()`. If you tell pymetabind that you have done + * so, using the `tp_finalize_will_remove` argument to `pymb_add_binding()`, + * then pymetabind won't need to create the weakref and its callback. + * + * ### Removing bindings: the gory details + * + * The implementation of the removal process is somewhat complex in order to + * protect other threads that might be concurrently using a binding in + * free-threaded builds. `pymb_remove_binding()` stops new uses of the binding + * from beginning, by notifying other frameworks' `remove_foreign_binding` + * hooks and changing the binding capsule so `pymb_get_binding()` won't work. + * Existing uses might be ongoing though, so we must wait for them to complete + * before freeing the binding structure. The technique we choose is to wait for + * the next (or current) garbage collection to finish. GC stops all threads + * before it scans the heap. An attached thread state (one that can call + * CPython API functions) can't be stopped without its consent, so GC will + * wait for it to detach. A thread state can only become detached explicitly + * (e.g. Py_BEGIN_ALLOW_THREADS) or in the bytecode interpreter. As long as + * foreign frameworks don't hold `pymb_binding` pointers across calls into + * the bytecode interpreter in places their `remove_foreign_binding` hook + * can't see, this technique avoids use-after-free without introducing any + * contention on a shared atomic in the binding object. + * + * One pleasant aspect of this scheme: due to their use of deferred reference + * counting, type objects in free-threaded Python can only be freed during a + * GC pass. There is even a stop-all-threads (to check for resurrected objects) + * in between when GC executes finalizers and when it actually destroys the + * garbage. This winds up letting us obtain the "wait for next GC pause before + * freeing the binding" behavior very cheaply when the binding is being removed + * due to the deletion of its type. + * + * On non-free-threaded Python builds, none of the above is a concern, and + * `pymb_remove_binding()` can synchronously free the `pymb_binding` structure. + * + * ### Keeping track of other frameworks' bindings + * + * In order to work with Python objects bound by another framework, yours + * must be able to locate a `pymb_binding` structure for that type. It is + * anticipated that most frameworks will maintain their own private + * type-to-binding maps, which they can keep up-to-date via their + * `add_foreign_binding` and `remove_foreign_binding` hooks. It is important + * to think carefully about how to design the synchronization for these maps + * so that lookups do not return pointers to bindings that may have been + * deallocated. The remainder of this section provides some suggestions. + * + * The recommended way to handle synchronization is to protect your type lookup + * map with a readers/writer lock. In your `remove_foreign_binding` hook, + * obtain a write lock, and hold it while removing the corresponding entry from + * the map. Before performing a type lookup, obtain a read lock. If the lookup + * succeeds, you can release the read lock and (due to the two-phase removal + * process described above) continue to safely use the binding for as long as + * your Python thread state remains attached. It is important not to hold the + * read lock while executing arbitrary Python code, since a deadlock would + * result if the binding were removed (requiring a write lock) while the read + * lock were held. Note that `pymb_framework::from_python` for many popular + * frameworks can execute arbitrary Python code to perform implicit conversions. + * + * If you're trying multiple bindings for an operation, one option is to copy + * all their pointers to temporary storage before releasing the read lock. + * (While concurrent updates may modify the data structure, the pymb_binding + * structures it points to will remain valid for long enough.) If you prefer + * to avoid the copy by unlocking for each attempt and then relocking to + * advance to the next binding, be sure to consider the possibility that your + * iterator might have been invalidated due to a concurrent update while you + * weren't holding the lock. + * + * The lock on a single shared type lookup map is a contention bottleneck, + * especially if you don't have a readers/writer lock and wish to get by with + * an ordinary mutex. To improve performance, you can give each thread its + * own lookup map, and require `remove_foreign_binding` to update all of them. + * As long as the per-thread maps are always visited in a consistent order + * when removing a binding, the splitting shouldn't introduce new deadlocks. + * Since each thread can have a separate mutex for its separate map, contention + * occurs only when bindings are being added or removed, which is much less + * common than using them. + * + * ### Using the binding capsule + * + * Each Python type object for which a `pymb_binding` exists will have an + * attribute "__pymetabind_binding__" whose value is a capsule object + * that contains the `pymb_binding` pointer under the name "pymetabind_binding". + * The attribute is set during `pymb_add_binding()`, and is used by + * `pymb_get_binding()` to map a type object to a binding. The capsule allows: + * + * - Determining which framework to call for a foreign `keep_alive` operation + * + * - Locating `pymb_binding` objects for types written in a different language + * than yours (where you can't look up by the `pymb_binding::native_type`), + * so that you can work with their contents using non-Python-specific + * cross-language support + * + * - Extracting the native object from a Python object without being too picky + * about what type it is (risky, but maybe you have out-of-band information + * that shows it's safe) + * + * The preferred mechanism for same-language object access is to maintain a + * hashtable keyed on `pymb_binding::native_type` and look up the binding for + * the type you want/have. Compared to reading the capsule, this better + * supports inheritance, to-Python conversions, and implicit conversions, and + * it's probably also faster depending on how it's implemented. + * + * ### Types with multiple bindings + * + * It is valid for multiple frameworks to claim (in separate bindings) the + * same C/C++ type. This supports cases where a common vocabulary type is + * bound separately in mulitple extensions in the same process. Frameworks + * are encouraged to try all registered bindings for the target type when + * they perform from-Python conversions. + * + * If multiple frameworks claim the same Python type, the last one will + * typically win, since there is only one "__pymetabind_binding__" attribute + * on the type object and a binding is removed when its capsule is no longer + * referenced. If you're trying to do something unusual like wrapping another + * framework's binding to provide additional features, you can stash the + * extra binding(s) under a different attribute name. pymetabind never uses + * the "__pymetabind_binding__" attribute to locate the binding for its own + * purposes; it's used only to fulfill calls to `pymb_get_binding()`. + * + * ### Synchronization + * + * Most fields of this structure are set before it is made visible to other + * threads and then never changed, so they don't need locking to access. The + * `link` and `capsule` are protected by the registry lock. + */ +struct pymb_binding { + // Links to the previous and next bindings in the list of + // `pymb_registry::bindings` + struct pymb_list_node link; + + // The framework that provides this binding + struct pymb_framework* framework; + + // Borrowed reference to the capsule object that refers to this binding. + // Becomes NULL in pymb_remove_binding(). + PyObject* capsule; + + // Python type: you will get an instance of this type from a successful + // call to `framework::from_python()` that passes this binding + PyTypeObject* pytype; + + // Strong reference to a weakref to `pytype`; its callback will prompt + // us to remove the binding. May be NULL if Py_TYPE(pytype)->tp_finalize + // will take care of that. + PyObject* pytype_wr; + + // The native identifier for this type in `framework->abi_lang`, if that is + // a concept that exists in that language. See the documentation of + // `enum pymb_abi_lang` for specific per-language semantics. + const void* native_type; + + // The way that this type would be written in `framework->abi_lang` source + // code, as a NUL-terminated byte string without struct/class/enum words. + // If `framework->abi_lang` uses name mangling, this is the demangled, + // human-readable name. C++ users should note that the result of + // `typeid(x).name()` will need platform-specific alteration to produce one. + // Examples: "Foo", "Bar::Baz", "std::vector >" + const char* source_name; + + // Pointer that is free for use by the framework, e.g., to point to its + // own data about this type. If the framework needs more data, it can + // over-allocate the `pymb_binding` storage and use the space after this. + void* context; +}; + +PYMB_FUNC struct pymb_registry* pymb_get_registry(); +PYMB_FUNC void pymb_add_framework(struct pymb_registry* registry, + struct pymb_framework* framework); +PYMB_FUNC void pymb_remove_framework(struct pymb_framework* framework); +PYMB_FUNC void pymb_add_binding(struct pymb_binding* binding, + int tp_finalize_will_remove); +PYMB_FUNC void pymb_remove_binding(struct pymb_binding* binding); +PYMB_FUNC struct pymb_binding* pymb_get_binding(PyObject* type); + +#if !defined(PYMB_DECLS_ONLY) + +PYMB_INLINE void pymb_registry_free(struct pymb_registry* registry) { + assert(pymb_list_is_empty(®istry->bindings) && + "some framework was removed before its bindings"); + free(registry->weakref_callback_def); + free(registry); +} + +PYMB_FUNC void pymb_registry_capsule_destructor(PyObject* capsule) { + struct pymb_registry* registry = + (struct pymb_registry*) PyCapsule_GetPointer( + capsule, "pymetabind_registry"); + if (!registry) { + PyErr_WriteUnraisable(capsule); + return; + } + registry->deallocate_when_empty = 1; + if (pymb_list_is_empty(®istry->frameworks)) { + pymb_registry_free(registry); + } +} + +PYMB_FUNC PyObject* pymb_weakref_callback(PyObject* self, PyObject* weakref) { + // self is bound using PyCFunction_New to refer to a capsule that contains + // the binding pointer (not the binding->capsule; this one has no dtor). + // `weakref` is the weakref (to the bound type) that expired. + if (!PyWeakref_CheckRefExact(weakref) || !PyCapsule_CheckExact(self)) { + PyErr_BadArgument(); + return NULL; + } + struct pymb_binding* binding = + (struct pymb_binding*) PyCapsule_GetPointer( + self, "pymetabind_binding"); + if (!binding) { + return NULL; + } + pymb_remove_binding(binding); + Py_RETURN_NONE; +} + +/* + * Locate an existing `pymb_registry`, or create a new one if necessary. + * Returns a pointer to it, or NULL with the CPython error indicator set. + * This must be called from a module initialization function so that the + * import lock can provide mutual exclusion. + */ +PYMB_FUNC struct pymb_registry* pymb_get_registry() { +#if defined(PYPY_VERSION) + PyObject* dict = PyEval_GetBuiltins(); +#elif PY_VERSION_HEX < 0x03090000 + PyObject* dict = PyInterpreterState_GetDict(_PyInterpreterState_Get()); +#else + PyObject* dict = PyInterpreterState_GetDict(PyInterpreterState_Get()); +#endif + PyObject* key = PyUnicode_FromString("__pymetabind_registry__"); + if (!dict || !key) { + Py_XDECREF(key); + return NULL; + } + PyObject* capsule = PyDict_GetItem(dict, key); + if (capsule) { + Py_DECREF(key); + return (struct pymb_registry*) PyCapsule_GetPointer( + capsule, "pymetabind_registry"); + } + struct pymb_registry* registry; + registry = (struct pymb_registry*) calloc(1, sizeof(*registry)); + if (registry) { + pymb_list_init(®istry->frameworks); + pymb_list_init(®istry->bindings); + registry->deallocate_when_empty = 0; + + // C doesn't allow inline functions to declare static variables, + // so allocate this on the heap + PyMethodDef* def = (PyMethodDef*) calloc(1, sizeof(PyMethodDef)); + if (!def) { + free(registry); + PyErr_NoMemory(); + Py_DECREF(key); + return NULL; + } + def->ml_name = "pymetabind_weakref_callback"; + def->ml_meth = pymb_weakref_callback; + def->ml_flags = METH_O; + def->ml_doc = NULL; + registry->weakref_callback_def = def; + + // Attach a destructor so the registry memory is released at teardown + capsule = PyCapsule_New(registry, "pymetabind_registry", + pymb_registry_capsule_destructor); + if (!capsule) { + free(registry); + } else if (PyDict_SetItem(dict, key, capsule) == -1) { + registry = NULL; // will be deallocated by capsule destructor + } + Py_XDECREF(capsule); + } else { + PyErr_NoMemory(); + } + Py_DECREF(key); + return registry; +} + +/* + * Add a new framework to the given registry. Makes calls to + * framework->add_foreign_framework() and framework->add_foreign_binding() + * for each existing framework/binding in the registry. + */ +PYMB_FUNC void pymb_add_framework(struct pymb_registry* registry, + struct pymb_framework* framework) { + // Defensive: ensure hook is clean before first list insertion to avoid UB + framework->link.next = NULL; + framework->link.prev = NULL; + framework->registry = registry; + pymb_lock_registry(registry); + PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { + // Intern `abi_extra` strings so they can be compared by pointer + if (other->abi_extra && framework->abi_extra && + (other->abi_extra == framework->abi_extra || + strcmp(other->abi_extra, framework->abi_extra) == 0)) { + framework->abi_extra = other->abi_extra; + break; + } + } + pymb_list_append(®istry->frameworks, &framework->link); + PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { + if (other != framework) { + other->add_foreign_framework(framework); + framework->add_foreign_framework(other); + } + } + PYMB_LIST_FOREACH(struct pymb_binding*, binding, registry->bindings) { + if (binding->framework != framework) { + framework->add_foreign_binding(binding); + } + } + pymb_unlock_registry(registry); +} + +/* + * Remove a framework from the registry it was added to. + * + * This may only be called during Python interpreter finalization. Rationale: + * other frameworks might be maintaining an entry for the removed one in their + * exception translator lists, and supporting concurrent removal of exception + * translators would add undesirable synchronization overhead to the handling + * of every exception. At finalization time there are no more threads. + * + * Once this function returns, you can free the framework structure. + * + * If a framework never removes itself, it must not claim to be `leak_safe`. + */ +PYMB_FUNC void pymb_remove_framework(struct pymb_framework* framework) { + struct pymb_registry* registry = framework->registry; + + // No need for registry lock/unlock since there are no more threads + pymb_list_unlink(&framework->link); + PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { + other->remove_foreign_framework(framework); + } + + // Destroy registry if capsule is gone and this was the last framework + if (registry->deallocate_when_empty && + pymb_list_is_empty(®istry->frameworks)) { + pymb_registry_free(registry); + } +} + +PYMB_FUNC void pymb_binding_capsule_remove(PyObject* capsule) { + struct pymb_binding* binding = + (struct pymb_binding*) PyCapsule_GetPointer( + capsule, "pymetabind_binding"); + if (!binding) { + PyErr_WriteUnraisable(capsule); + return; + } + pymb_remove_binding(binding); +} + +/* + * Add a new binding for `binding->framework`. If `tp_finalize_will_remove` is + * nonzero, the caller guarantees that `Py_TYPE(binding->pytype).tp_finalize` + * will call `pymb_remove_binding()`; this saves some allocations compared + * to pymetabind needing to figure out when the type is destroyed on its own. + * See the comment on `pymb_binding` for more details. + */ +PYMB_FUNC void pymb_add_binding(struct pymb_binding* binding, + int tp_finalize_will_remove) { + // Defensive: ensure hook is clean before first list insertion to avoid UB + binding->link.next = NULL; + binding->link.prev = NULL; + + binding->pytype_wr = NULL; + binding->capsule = NULL; + + struct pymb_registry* registry = binding->framework->registry; + if (!tp_finalize_will_remove) { + // Different capsule than the binding->capsule, so that the callback + // doesn't keep the binding alive + PyObject* sub_capsule = PyCapsule_New(binding, "pymetabind_binding", + NULL); + if (!sub_capsule) { + goto error; + } + PyObject* callback = PyCFunction_New(registry->weakref_callback_def, + sub_capsule); + Py_DECREF(sub_capsule); // ownership transferred to callback + if (!callback) { + goto error; + } + binding->pytype_wr = PyWeakref_NewRef((PyObject *) binding->pytype, + callback); + Py_DECREF(callback); // ownership transferred to weakref + if (!binding->pytype_wr) { + goto error; + } + } else { +#if defined(Py_GIL_DISABLED) + // No callback needed in this case, but we still do need the weakref + // so that pymb_remove_binding() can tell if the type is being + // finalized or not. + binding->pytype_wr = PyWeakref_NewRef((PyObject *) binding->pytype, + NULL); + if (!binding->pytype_wr) { + goto error; + } +#endif + } + + binding->capsule = PyCapsule_New(binding, "pymetabind_binding", + pymb_binding_capsule_remove); + if (!binding->capsule) { + goto error; + } + if (PyObject_SetAttrString((PyObject *) binding->pytype, + "__pymetabind_binding__", + binding->capsule) != 0) { + Py_CLEAR(binding->capsule); + goto error; + } + Py_DECREF(binding->capsule); // keep only a borrowed reference + + pymb_lock_registry(registry); + pymb_list_append(®istry->bindings, &binding->link); + PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { + if (other != binding->framework) { + other->add_foreign_binding(binding); + } + } + pymb_unlock_registry(registry); + return; + + error: + PyErr_WriteUnraisable((PyObject *) binding->pytype); + Py_XDECREF(binding->pytype_wr); + binding->framework->free_local_binding(binding); +} + +#if defined(Py_GIL_DISABLED) +PYMB_FUNC void pymb_binding_capsule_destroy(PyObject* capsule) { + struct pymb_binding* binding = + (struct pymb_binding*) PyCapsule_GetPointer( + capsule, "pymetabind_binding"); + if (!binding) { + PyErr_WriteUnraisable(capsule); + return; + } + Py_CLEAR(binding->pytype_wr); + binding->framework->free_local_binding(binding); +} +#endif + +/* + * Remove a binding from the registry it was added to. Don't call this yourself, + * except from the tp_finalize slot of a binding's type's metatype. + * The user-servicable way to remove a binding from a still-alive type is to + * delete the capsule. The binding structure will eventually be freed by calling + * `binding->framework->free_local_binding(binding)`. + */ +PYMB_FUNC void pymb_remove_binding(struct pymb_binding* binding) { + struct pymb_registry* registry = binding->framework->registry; + + // Since we need to obtain it anyway, use the registry lock to serialize + // concurrent attempts to remove the same binding + pymb_lock_registry(registry); + if (!binding->capsule) { + // Binding was concurrently removed from multiple places; the first + // one to get the registry lock wins. + pymb_unlock_registry(registry); + return; + } + +#if defined(Py_GIL_DISABLED) + // Determine if binding->pytype is still fully alive (not yet started + // finalizing). If so, it can't die until the next GC cycle, so freeing + // the binding at the next GC is safe. + PyObject* pytype_strong = NULL; + if (PyWeakref_GetRef(binding->pytype_wr, &pytype_strong) == -1) { + // If something's wrong with the weakref, leave pytype_strong set to + // NULL in order to conservatively assume the type is finalizing. + // This will leak the binding struct until the type object is destroyed. + PyErr_WriteUnraisable((PyObject *) binding->pytype); + } +#endif + + // Clear the existing capsule's destructor so we don't have to worry about + // it firing after the pymb_binding struct has actually been freed. + // Note we can safely assume the capsule hasn't been freed yet, even + // though it might be mid-destruction. (Proof: Its destructor calls + // this function, which cannot complete until it acquires the lock we + // currently hold. If the destructor completed already, we would have bailed + // out above upon noticing capsule was already NULL.) + if (PyCapsule_SetDestructor(binding->capsule, NULL) != 0) { + PyErr_WriteUnraisable((PyObject *) binding->pytype); + } + + // Mark this binding as being in the process of being destroyed. + binding->capsule = NULL; + + // If weakref hasn't fired yet, we don't need it anymore. Destroying it + // ensures it won't fire after the binding struct has been freed. + Py_CLEAR(binding->pytype_wr); + + pymb_list_unlink(&binding->link); + binding->framework->remove_local_binding(binding); + PYMB_LIST_FOREACH(struct pymb_framework*, other, registry->frameworks) { + if (other != binding->framework) { + other->remove_foreign_binding(binding); + } + } + pymb_unlock_registry(registry); + +#if !defined(Py_GIL_DISABLED) + // On GIL builds, there's no need to delay deallocation + binding->framework->free_local_binding(binding); +#else + // Create a new capsule to manage the actual freeing + PyObject* capsule_destroy = PyCapsule_New(binding, + "pymetabind_binding", + pymb_binding_capsule_destroy); + if (!capsule_destroy) { + // Just leak the binding if we can't set up the capsule + PyErr_WriteUnraisable((PyObject *) binding->pytype); + } else if (pytype_strong) { + // Type still alive -> embed the capsule in a cycle so it lasts until + // next GC. (The type will live at least that long.) + PyObject* list = PyList_New(2); + if (!list) { + PyErr_WriteUnraisable((PyObject *) binding->pytype); + // leak the capsule and therefore the binding + } else { + PyList_SetItem(list, 0, capsule_destroy); + PyList_SetItem(list, 1, list); + // list is now referenced only by itself and will be GCable + } + } else { + // Type is dying -> destroy the capsule when the type is destroyed. + // Since the type's weakrefs were already cleared, any weakref we add + // now won't fire until the type's tp_dealloc. We reuse our existing + // weakref callback for convenience; the call that it makes to + // pymb_remove_binding() will be a no-op, but after it fires, + // the capsule destructor will do the freeing we desire. + PyObject* callback = PyCFunction_New(registry->weakref_callback_def, + capsule_destroy); + if (!callback) { + PyErr_WriteUnraisable((PyObject *) binding->pytype); + // leak the capsule and therefore the binding + } else { + Py_DECREF(capsule_destroy); // ownership transferred to callback + binding->pytype_wr = PyWeakref_NewRef((PyObject *) binding->pytype, + callback); + Py_DECREF(callback); // ownership transferred to weakref + if (!binding->pytype_wr) { + PyErr_WriteUnraisable((PyObject *) binding->pytype); + } + } + } + Py_XDECREF(pytype_strong); +#endif +} + +/* + * Return a pointer to a pymb_binding for the Python type `type`, or NULL if + * none exists. + */ +PYMB_FUNC struct pymb_binding* pymb_get_binding(PyObject* type) { + PyObject* capsule = PyObject_GetAttrString(type, "__pymetabind_binding__"); + if (capsule == NULL) { + PyErr_Clear(); + return NULL; + } + void* binding = PyCapsule_GetPointer(capsule, "pymetabind_binding"); + Py_DECREF(capsule); + if (!binding) { + PyErr_Clear(); + } + return (struct pymb_binding*) binding; +} + +#endif /* defined(PYMB_DECLS_ONLY) */ + +#if defined(__cplusplus) +} +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 441206001..a73fc9f4d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,10 @@ if (NB_TEST_SHARED_BUILD) set(NB_EXTRA_ARGS ${NB_EXTRA_ARGS} NB_SHARED) endif() +if (NB_TEST_NO_INTEROP) + set(NB_EXTRA_ARGS ${NB_EXTRA_ARGS} NO_INTEROP) +endif() + # --------------------------------------------------------------------------- # Compile with a few more compiler warnings turned on # --------------------------------------------------------------------------- @@ -144,8 +148,10 @@ target_include_directories(inter_module PRIVATE ${NB_DIR}/include) nanobind_add_module(test_inter_module_1_ext NB_DOMAIN mydomain test_inter_module_1.cpp ${NB_EXTRA_ARGS}) nanobind_add_module(test_inter_module_2_ext NB_DOMAIN mydomain test_inter_module_2.cpp ${NB_EXTRA_ARGS}) +nanobind_add_module(test_inter_module_foreign_ext NB_DOMAIN otherdomain test_inter_module_foreign.cpp ${NB_EXTRA_ARGS}) target_link_libraries(test_inter_module_1_ext PRIVATE inter_module) target_link_libraries(test_inter_module_2_ext PRIVATE inter_module) +target_link_libraries(test_inter_module_foreign_ext PRIVATE inter_module) set(TEST_FILES common.py diff --git a/tests/delattr_and_ensure_destroyed.h b/tests/delattr_and_ensure_destroyed.h new file mode 100644 index 000000000..c060894aa --- /dev/null +++ b/tests/delattr_and_ensure_destroyed.h @@ -0,0 +1,31 @@ +#pragma once +#include + +inline void delattr_and_ensure_destroyed(nanobind::handle scope, + const char *name) { + if (!hasattr(scope, name)) + return; + bool destroyed = false; + bool **destroyed_pp = new (bool*)(&destroyed); + nanobind::detail::keep_alive(scope.attr(name).ptr(), destroyed_pp, + [](void *ptr) noexcept { + bool **destroyed_pp = (bool **) ptr; + if (*destroyed_pp) { + **destroyed_pp = true; + } + delete destroyed_pp; + }); + delattr(scope, name); + if (!destroyed) { + auto collect = nanobind::module_::import_("gc").attr("collect"); + collect(); + if (!destroyed) { + collect(); + if (!destroyed) { + *destroyed_pp = nullptr; + nanobind::detail::raise("Couldn't delete binding for %s in %s!", + name, repr(scope).c_str()); + } + } + } +} diff --git a/tests/inter_module.cpp b/tests/inter_module.cpp index 20a1c685f..6b9caa5a5 100644 --- a/tests/inter_module.cpp +++ b/tests/inter_module.cpp @@ -4,6 +4,34 @@ Shared create_shared() { return { 123 }; } +std::shared_ptr create_shared_sp() { + return std::make_shared(create_shared()); +} + +std::unique_ptr create_shared_up() { + return std::make_unique(create_shared()); +} + bool check_shared(const Shared &shared) { return shared.value == 123; } + +bool check_shared_sp(std::shared_ptr shared) { + return shared->value == 123; +} + +bool check_shared_up(std::unique_ptr shared) { + return shared->value == 123; +} + +SharedEnum create_enum() { + return SharedEnum::Two; +} + +bool check_enum(SharedEnum e) { + return e == SharedEnum::Two; +} + +void throw_shared() { + throw create_shared(); +} diff --git a/tests/inter_module.h b/tests/inter_module.h index c78498a6a..03b702cd8 100644 --- a/tests/inter_module.h +++ b/tests/inter_module.h @@ -1,4 +1,5 @@ #include +#include #if defined(SHARED_BUILD) # define EXPORT_SHARED NB_EXPORT @@ -10,5 +11,16 @@ struct EXPORT_SHARED Shared { int value; }; +enum class EXPORT_SHARED SharedEnum { One = 1, Two = 2 }; + extern EXPORT_SHARED Shared create_shared(); +extern EXPORT_SHARED std::shared_ptr create_shared_sp(); +extern EXPORT_SHARED std::unique_ptr create_shared_up(); extern EXPORT_SHARED bool check_shared(const Shared &shared); +extern EXPORT_SHARED bool check_shared_sp(std::shared_ptr shared); +extern EXPORT_SHARED bool check_shared_up(std::unique_ptr shared); + +extern EXPORT_SHARED SharedEnum create_enum(); +extern EXPORT_SHARED bool check_enum(SharedEnum e); + +extern EXPORT_SHARED void throw_shared(); diff --git a/tests/test_inter_module.py b/tests/test_inter_module.py index 97f618710..41b1b5ba6 100644 --- a/tests/test_inter_module.py +++ b/tests/test_inter_module.py @@ -1,8 +1,17 @@ +import gc +import sys +import time +import threading +import weakref + import test_inter_module_1_ext as t1 import test_inter_module_2_ext as t2 +import test_inter_module_foreign_ext as tf import test_classes_ext as t3 import pytest -from common import xfail_on_pypy_darwin +from common import xfail_on_pypy_darwin, parallelize + +free_threaded = hasattr(sys, "_is_gil_enabled") and not sys._is_gil_enabled() @xfail_on_pypy_darwin def test01_inter_module(): @@ -11,3 +20,382 @@ def test01_inter_module(): with pytest.raises(TypeError) as excinfo: assert t3.check_shared(s) assert 'incompatible function arguments' in str(excinfo.value) + + +# t1 and t2 are in the same nanobind domain. t2 has a binding for Shared. +# tf is in a separate domain. It has a binding for Shared that appears to be +# from a foreign framework (not nanobind). It also has a create_nb_binding() +# method that will create a nanobind binding for Shared in its domain. +# So in total we have three separate pytypes that bind the C++ type Shared, +# and two domains that might need to accept them. + +# NB: there is some potential for different test cases to interfere with +# each other: we can't un-register a framework once it's registered and we +# can't undo automatic import/export all once they're requested. The ordering +# of these tests is therefore important. They work standalone and they work +# when run all in one process, but they might not work in a different order. + + +@pytest.fixture +def clean(): + tf.remove_all_bindings() + if sys.implementation.name == "pypy": + gc.collect() + yield + tf.remove_all_bindings() + if sys.implementation.name == "pypy": + gc.collect() + + +def test02_interop_exceptions_without_registration(): + # t2 defines the exception translator for Shared. Since the t1/t2 domain + # hasn't taken any interop actions yet, it hasn't registered with pymetabind + # and tf won't be able to use that translator. + with pytest.raises(SystemError, match="exception could not be translated"): + tf.throw_shared() + + # t1 can use t2's translator though, since they're in the same domain + with pytest.raises(ValueError, match="Shared.123"): + t1.throw_shared() + + +def expect(from_mod, to_mod, pattern, **extra): + outcomes = {} + extra_info = {} + + for thing in ("shared", "shared_sp", "shared_up", "enum"): + print(thing) + create = getattr(from_mod, f"create_{thing}") + check = getattr(to_mod, f"check_{thing}") + try: + obj = create() + except Exception as ex: + outcomes[thing] = None + extra_info[thing] = ex + continue + try: + ok = check(obj) + except Exception as ex: + outcomes[thing] = False + extra_info[thing] = ex + continue + assert ok, "instance appears corrupted" + outcomes[thing] = True + + expected = {} + if pattern == "local": + expected = { + "shared": True, "shared_sp": True, "shared_up": True, "enum": True + } + elif pattern == "foreign": + expected = { + "shared": True, "shared_sp": True, "shared_up": False, "enum": True + } + elif pattern == "isolated": + expected = { + "shared": False, "shared_sp": False, "shared_up": False, "enum": False + } + else: + assert False, "unknown pattern" + expected.update(extra) + assert outcomes == expected + + +def test03_interop_unimported(clean): + expect(tf, tf, "foreign", enum=None) + expect(t1, t2, "local") + expect(t1, tf, "isolated") + expect(tf, t2, "isolated", enum=None) + + # Just an export isn't enough; you need an import too + t2.export_for_interop(t2.Shared) + expect(tf, t2, "isolated", enum=None) + + +def test04_interop_import_export_errors(): + with pytest.raises( + RuntimeError, match="does not define a __pymetabind_binding__" + ): + t2.import_for_interop(tf.Convertible) + + with pytest.raises( + RuntimeError, match="not a nanobind class or enum bound in this domain" + ): + tf.export_for_interop(t2.Shared) + + with pytest.raises( + RuntimeError, match="not a nanobind class or enum bound in this domain" + ): + tf.export_for_interop(t2.SharedEnum) + + t2.export_for_interop(t2.Shared) + t2.export_for_interop(t2.SharedEnum) + t2.export_for_interop(t2.Shared) # should be idempotent + t2.export_for_interop(t2.SharedEnum) + + with pytest.raises( + RuntimeError, match="is already bound by this nanobind domain" + ): + t2.import_for_interop(t2.Shared) + + +def test05_interop_exceptions(): + # Once t2 registers with pymetabind, which happens as soon as it imports + # or exports anything, tf can translate its exceptions. + t2.export_for_interop(t2.Shared) + with pytest.raises(ValueError, match="Shared.123"): + tf.throw_shared() + + +def test06_interop_with_cpp(clean): + # Export t2.Shared to tf, but not the enum yet, and not from tf to t2 + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + expect(t1, tf, "foreign", enum=False) + expect(tf, t2, "isolated", enum=None) + + # Now export t2.SharedEnum too. Note that tf doesn't have its own + # definition of SharedEnum, so it will use the imported one and create + # t2.SharedEnums. + t2.export_for_interop(t2.SharedEnum) + tf.import_for_interop(t2.SharedEnum) + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + # Enable automatic import in the t1/t2 domain. Still doesn't help with + # tf->t1/t2 since tf.Shared is not a C++ type. + t1.import_all() + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + +def test07_interop_import_explicit_errors(clean): + with pytest.raises(RuntimeError, match=r"is not written in C\+\+"): + t2.import_for_interop(tf.RawShared) + + t2.import_for_interop_explicit(tf.RawShared) + t2.import_for_interop_explicit(tf.RawShared) + + with pytest.raises(RuntimeError, match=r"was already mapped to C\+\+ type"): + t2.import_for_interop_wrong_type(tf.RawShared) + + +def test08_interop_with_c(clean): + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + t2.export_for_interop(t2.SharedEnum) + tf.import_for_interop(t2.SharedEnum) + t2.import_for_interop_explicit(tf.RawShared) + + # Now that tf.RawShared is imported to t1/t2, everything should work. + expect(t1, tf, "foreign") + expect(tf, t2, "foreign") + + +@pytest.mark.skipif( + sys.implementation.name == "pypy", reason="can't GC type object on pypy" +) +def test09_remove_binding(clean): + t2.import_for_interop_explicit(tf.RawShared) + + # Remove the binding for tf.RawShared. We expect the t1/t2 domain will + # notice the removal and automatically forget about the defunct binding. + tf.remove_raw_binding() + tf.create_raw_binding() + + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + t2.export_for_interop(t2.SharedEnum) + tf.import_for_interop(t2.SharedEnum) + + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + if not free_threaded: + # More binding removal tests. These only work on non-freethreading + # builds because nanobind immortalizes all its types on FT builds. + t2.remove_bindings() + t2.create_bindings() + + expect(t1, tf, "isolated") + expect(tf, t2, "isolated", enum=None) + + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + t2.export_for_interop(t2.SharedEnum) + tf.import_for_interop(t2.SharedEnum) + + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + # Removing the binding capsule should work just as well as removing + # the type object. + del t2.Shared.__pymetabind_binding__ + del t2.SharedEnum.__pymetabind_binding__ + + expect(t1, tf, "isolated") + expect(tf, t2, "isolated", enum=None) + + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + t2.export_for_interop(t2.SharedEnum) + tf.import_for_interop(t2.SharedEnum) + + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + # Re-import RawShared and now everything works again. + t2.import_for_interop_explicit(tf.RawShared) + expect(t1, tf, "foreign") + expect(tf, t2, "foreign") + + # Removing the binding capsule should work just as well as removing + # the type object. + del tf.RawShared.__pymetabind_binding__ + tf.export_raw_binding() + + # tf.RawShared was removed from the beginning of tf's list for Shared + # and re-added on the end; also remove and re-add t2.Shared so that + # tf.create_shared() returns a tf.RawShared + del t2.Shared.__pymetabind_binding__ + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + + expect(t1, tf, "foreign") + expect(tf, t2, "isolated", enum=True) + + # Re-import RawShared and now everything works again. + t2.import_for_interop_explicit(tf.RawShared) + expect(t1, tf, "foreign") + expect(tf, t2, "foreign") + + +def test10_access_binding_concurrently(clean): + any_failed = False + + def repeatedly_attempt_conversions(): + deadline = time.time() + 1 + while time.time() < deadline: + try: + assert tf.check_shared(tf.create_shared()) + except: + nonlocal any_failed + any_failed = True + raise + + parallelize(repeatedly_attempt_conversions, n_threads=8) + assert not any_failed + + +@pytest.mark.skipif(not free_threaded, reason="not relevant on non-FT") +@pytest.mark.parametrize("multi", (False, True)) +def test11_remove_binding_concurrently(clean, multi): + transitions = 0 + limit = 5000 + + if multi: + # In 'multi' mode, we add more exports so the `tf` domain exercises + # the linked-list-of-foreign-bindings logic + t2.export_for_interop(t2.Shared) + tf.import_for_interop(t2.Shared) + + def repeatedly_remove_and_readd(): + nonlocal transitions + try: + while transitions < limit: + del tf.RawShared.__pymetabind_binding__ + tf.export_raw_binding() + if multi: + del t2.Shared.__pymetabind_binding__ + # The actual destruction of the capsule may be slightly + # delayed since it was created on a different thread. + # nanobind won't export a binding for a type that it thinks + # already has one. Retry export until the capsule shows up. + for _ in range(10): + t2.export_for_interop(t2.Shared) + if hasattr(t2.Shared, "__pymetabind_binding__"): + break + time.sleep(0.001) + else: + assert False, "binding removal was too delayed" + tf.import_for_interop(t2.Shared) + transitions += 1 + except: + transitions = limit + raise + + thread = threading.Thread(target=repeatedly_remove_and_readd) + thread.start() + + num_failed = 0 + num_successful = 0 + + def repeatedly_attempt_conversions(): + nonlocal num_failed + nonlocal num_successful + while transitions < limit: + try: + tf.check_shared(tf.create_shared()) + except TypeError: + num_failed += 1 + else: + num_successful += 1 + + try: + parallelize(repeatedly_attempt_conversions, n_threads=8) + finally: + transitions = limit + thread.join() + + # typical numbers from my machine: with limit=5000, the test takes a + # decent fraction of a second, and num_failed and num_successful are each + # several 10k's + print(num_failed, num_successful) + assert num_successful > 0 + assert num_failed > 0 + + +def test12_multi_and_implicit(clean): + # Create three different types of pyobject, all of which have C++ type Shared + s1 = t1.create_shared() + sf_raw = tf.create_shared() + tf.create_nb_binding() + sf_nb = tf.create_shared() + + assert type(s1) is t2.Shared + assert type(sf_raw) is tf.RawShared + assert type(sf_nb) is tf.NbShared + + # Test automatic import/export all + t1.export_all() + tf.import_all() + + # Test implicit conversions from foreign types + for obj in (sf_nb, sf_raw, s1): + val = tf.test_implicit(obj) + assert val.value == 123 + + # We should only be sharing in the t1->tf direction, not vice versa + assert tf.check_shared(s1) + assert tf.check_shared(sf_raw) + assert tf.check_shared(sf_nb) + with pytest.raises(TypeError): + t2.check_shared(sf_raw) + with pytest.raises(TypeError): + t2.check_shared(sf_nb) + + # Now add the other direction + t1.import_all() + tf.export_all() + assert t2.check_shared(sf_nb) + # Still need an explicit import for non-C++ type + t2.import_for_interop_explicit(tf.RawShared) + assert t2.check_shared(sf_raw) + + # Test normally passing these various objects + for mod in (t2, tf): + for obj in (s1, sf_raw, sf_nb): + assert mod.check_shared(obj) + assert mod.check_shared_sp(obj) + for obj in (t1.create_enum(), tf.create_enum()): + assert mod.check_enum(obj) diff --git a/tests/test_inter_module_1.cpp b/tests/test_inter_module_1.cpp index edf4dad0e..2fcf294c0 100644 --- a/tests/test_inter_module_1.cpp +++ b/tests/test_inter_module_1.cpp @@ -1,8 +1,16 @@ #include +#include +#include #include "inter_module.h" namespace nb = nanobind; NB_MODULE(test_inter_module_1_ext, m) { m.def("create_shared", &create_shared); + m.def("create_shared_sp", &create_shared_sp); + m.def("create_shared_up", &create_shared_up); + m.def("create_enum", &create_enum); + m.def("throw_shared", &throw_shared); + m.def("export_all", []() { nb::interoperate_by_default(true, false); }); + m.def("import_all", []() { nb::interoperate_by_default(false, true); }); } diff --git a/tests/test_inter_module_2.cpp b/tests/test_inter_module_2.cpp index 3e0a7785b..cd00a60ea 100644 --- a/tests/test_inter_module_2.cpp +++ b/tests/test_inter_module_2.cpp @@ -1,9 +1,52 @@ #include +#include +#include #include "inter_module.h" +#include "delattr_and_ensure_destroyed.h" namespace nb = nanobind; +struct Other {}; + NB_MODULE(test_inter_module_2_ext, m) { - nb::class_(m, "Shared"); + m.def("create_bindings", [hm = nb::handle(m)]() { + nb::class_(hm, "Shared"); + nb::enum_(hm, "SharedEnum") + .value("One", SharedEnum::One) + .value("Two", SharedEnum::Two); + }); + m.attr("create_bindings")(); + + m.def("remove_bindings", [hm = nb::handle(m)]() { +#if !defined(NB_FREE_THREADED) // nanobind types are currently immortal in FT + delattr_and_ensure_destroyed(hm, "Shared"); + delattr_and_ensure_destroyed(hm, "SharedEnum"); +#else + (void) hm; +#endif + }); + m.def("check_shared", &check_shared); + m.def("check_shared_sp", &check_shared_sp); + m.def("check_shared_up", &check_shared_up); + m.def("check_enum", &check_enum); + + nb::register_exception_translator( + [](const std::exception_ptr &p, void *) { + try { + std::rethrow_exception(p); + } catch (const Shared &s) { + // Instead of just calling PyErr_SetString, exercise the + // path where one translator throws an exception to be handled + // by another. + throw std::range_error( + nb::str("Shared({})").format(s.value).c_str()); + } + }); + m.def("throw_shared", &throw_shared); + + m.def("export_for_interop", &nb::export_for_interop); + m.def("import_for_interop", &nb::import_for_interop<>); + m.def("import_for_interop_explicit", &nb::import_for_interop); + m.def("import_for_interop_wrong_type", &nb::import_for_interop); } diff --git a/tests/test_inter_module_foreign.cpp b/tests/test_inter_module_foreign.cpp new file mode 100644 index 000000000..085a633dd --- /dev/null +++ b/tests/test_inter_module_foreign.cpp @@ -0,0 +1,228 @@ +#include +#include +#include +#include +#include "inter_module.h" +#include "delattr_and_ensure_destroyed.h" +#include "../src/pymetabind.h" + +namespace nb = nanobind; + +// The following is a manual binding to `struct Shared`, created using the +// CPython C API only. + +struct raw_shared_instance { + PyObject_HEAD + uintptr_t spacer[2]; // ensure instance layout differs from nanobind's + bool deallocate; + Shared *ptr; + Shared value; + PyObject *weakrefs; +}; + +static void Shared_dealloc(struct raw_shared_instance *self) { + if (self->spacer[0] != 0x5a5a5a5a || self->spacer[1] != 0xa5a5a5a5) + nb::detail::fail("instance corrupted"); + if (self->weakrefs) + PyObject_ClearWeakRefs((PyObject *) self); + if (self->deallocate) + free(self->ptr); + + PyTypeObject *tp = Py_TYPE((PyObject *) self); + PyObject_Free(self); + Py_DECREF(tp); +} + +static PyObject *Shared_new(PyTypeObject *type, Shared *value, pymb_rv_policy rvp) { + struct raw_shared_instance *self; + self = PyObject_New(raw_shared_instance, type); + if (self) { + memset((char *) self + sizeof(PyObject), 0, + sizeof(*self) - sizeof(PyObject)); + self->spacer[0] = 0x5a5a5a5a; + self->spacer[1] = 0xa5a5a5a5; + switch (rvp) { + case pymb_rv_policy_take_ownership: + self->ptr = value; + self->deallocate = true; + break; + case pymb_rv_policy_copy: + case pymb_rv_policy_move: + memcpy(&self->value, value, sizeof(Shared)); + self->ptr = &self->value; + self->deallocate = false; + break; + case pymb_rv_policy_reference: + case pymb_rv_policy_share_ownership: + self->ptr = value; + self->deallocate = false; + break; + default: + nb::detail::fail("unhandled rvp %d", (int) rvp); + break; + } + } + return (PyObject *) self; +} + +static int Shared_init(struct raw_shared_instance *, PyObject *, PyObject *) { + PyErr_SetString(PyExc_TypeError, "cannot be constructed from Python"); + return -1; +} + +// And a minimal implementation for our "foreign framework" of the pymetabind +// interface, so nanobind can use raw_shared_instances. + +static void *hook_from_python(pymb_binding *binding, + PyObject *pyobj, + uint8_t, + void (*)(void *ctx, PyObject *obj), + void *) noexcept { + if (binding->pytype != Py_TYPE(pyobj)) + return nullptr; + return ((raw_shared_instance *) pyobj)->ptr; +} + +static PyObject *hook_to_python(pymb_binding *binding, + void *cobj, + enum pymb_rv_policy rvp, + pymb_to_python_feedback *feedback) noexcept { + feedback->relocate = 0; + if (rvp == pymb_rv_policy_none) + return nullptr; + feedback->is_new = 1; + return Shared_new(binding->pytype, (Shared *) cobj, rvp); +} + +static void hook_ignore_foreign_binding(pymb_binding *) noexcept {} +static void hook_ignore_foreign_framework(pymb_framework *) noexcept {} + +NB_MODULE(test_inter_module_foreign_ext, m) { + static PyMemberDef Shared_members[] = { + {"__weaklistoffset__", T_PYSSIZET, + offsetof(struct raw_shared_instance, weakrefs), READONLY, nullptr}, + {nullptr, 0, 0, 0, nullptr}, + }; + static PyType_Slot Shared_slots[] = { + {Py_tp_doc, (void *) "Shared object"}, + {Py_tp_init, (void *) Shared_init}, + {Py_tp_dealloc, (void *) Shared_dealloc}, + {Py_tp_members, (void *) Shared_members}, + {0, nullptr}, + }; + static PyType_Spec Shared_spec = { + /* name */ "test_inter_module_foreign_ext.RawShared", + /* basicsize */ sizeof(struct raw_shared_instance), + /* itemsize */ 0, + /* flags */ Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + /* slots */ Shared_slots, + }; + + static auto *registry = pymb_get_registry(); + if (!registry) + throw nb::python_error(); + + static auto *fw = new pymb_framework{}; + fw->name = "example framework for nanobind tests"; + fw->flags = pymb_framework_leak_safe; + fw->abi_lang = pymb_abi_lang_c; + fw->from_python = hook_from_python; + fw->to_python = hook_to_python; + fw->keep_alive = [](PyObject *, void *, void (*)(void *)) noexcept { + return 0; + }; + fw->remove_local_binding = [](pymb_binding *) noexcept {}; + fw->free_local_binding = [](pymb_binding *binding) noexcept { + delete binding; + }; + fw->add_foreign_binding = hook_ignore_foreign_binding; + fw->remove_foreign_binding = hook_ignore_foreign_binding; + fw->add_foreign_framework = hook_ignore_foreign_framework; + fw->remove_foreign_framework = hook_ignore_foreign_framework; + + pymb_add_framework(registry, fw); + int res = Py_AtExit(+[]() { + pymb_remove_framework(fw); + delete fw; + }); + if (res != 0) + throw nb::python_error(); + + m.def("export_raw_binding", [hm = nb::handle(m)]() { + auto type = hm.attr("RawShared"); + auto *binding = new pymb_binding{}; + binding->framework = fw; + binding->pytype = (PyTypeObject *) type.ptr(); + binding->source_name = "Shared"; + pymb_add_binding(binding, /* tp_finalize_will_remove */ 0); + nb::import_for_interop(type); + }); + + m.def("create_raw_binding", [hm = nb::handle(m)]() { + auto *type = (PyTypeObject *) PyType_FromSpec(&Shared_spec); + if (!type) + throw nb::python_error(); +#if PY_VERSION_HEX < 0x03090000 + // __weaklistoffset__ member wasn't parsed until 3.9 + type->tp_weaklistoffset = offsetof(struct raw_shared_instance, weakrefs); +#endif + hm.attr("RawShared") = nb::steal(type); + hm.attr("export_raw_binding")(); + }); + m.attr("create_raw_binding")(); + + m.def("remove_raw_binding", [hm = nb::handle(m)]() { + delattr_and_ensure_destroyed(hm, "RawShared"); + }); + + m.def("create_nb_binding", [hm = nb::handle(m)]() { + nb::class_(hm, "NbShared"); + }); + + m.def("import_for_interop", &nb::import_for_interop<>); + m.def("export_for_interop", &nb::export_for_interop); + m.def("import_all", []() { + nb::interoperate_by_default(false, true); + }); + m.def("export_all", []() { + nb::interoperate_by_default(true, false); + }); + + m.def("remove_all_bindings", [hm = nb::handle(m)]() { + // NB: this is not a general purpose solution; the bindings removed + // here won't be re-added if `import_all` is called + nb::list bound; + pymb_lock_registry(registry); + PYMB_LIST_FOREACH(struct pymb_binding*, binding, registry->bindings) { + bound.append(nb::borrow(binding->pytype)); + } + pymb_unlock_registry(registry); + for (auto type : bound) { + nb::delattr(type, "__pymetabind_binding__"); + } + + // Restore the ability for our own create_shared() etc to work + // properly, since that's a foreign type relationship too + hm.attr("export_raw_binding")(); + nb::import_for_interop(hm.attr("RawShared")); + }); + + m.def("create_shared", &create_shared); + m.def("create_shared_sp", &create_shared_sp); + m.def("create_shared_up", &create_shared_up); + m.def("create_enum", &create_enum); + m.def("check_shared", &check_shared); + m.def("check_shared_sp", &check_shared_sp); + m.def("check_shared_up", &check_shared_up); + m.def("check_enum", &check_enum); + m.def("throw_shared", &throw_shared); + + struct Convertible { int value; }; + nb::class_(m, "Convertible") + .def("__init__", [](Convertible *self, const Shared &arg) { + new (self) Convertible{arg.value}; + }) + .def_ro("value", &Convertible::value); + nb::implicitly_convertible(); + m.def("test_implicit", [](Convertible conv) { return conv; }); +}