Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion cmake/nanobind-config.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_FOREIGN)
endif()

# Nanobind performs many assertion checks -- detailed error messages aren't
# included in Release/MinSizeRel/RelWithDebInfo modes
target_compile_definitions(${TARGET_NAME} PRIVATE
Expand Down Expand Up @@ -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"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A general question is whether this feature should be opt-in or opt-out. Given that it adds overheads (even if small), my tendency would be to make it opt-in. (e.g. INTEROP instead of NO_INTEROP)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the feature becomes opt-in, would you reverse the polarity of the macro as well? In other words, NB_DISABLE_FOREIGN becomes NB_ENABLE_FOREIGN.
Obviously, other build systems do not use nanobind-config.cmake. By default, any macros you add would not be defined. Developers would opt-in by defining the new macro.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just chiming in for another vote for opt-in. I imagine that most projects don't need to pay the cost (as the bindings will be self contained), and the ones that do would probably just use it to use as a transition period and then turn it off again.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The authors of a particular extension module don't generally know when they build it whether anyone will want to use its types from a different framework (or a different ABI version of the same framework). I think this is what pybind11 was referring to in their rationale for adding the _pybind11_conduit_v1_ methods unconditionally -- "to avoid "oh, too late!" situations" (pybind/pybind11#5296). I'm happy to switch the default, but I wonder if we might want to leave this question open until we have a better quantification of the cost? Speaking of which, @wjakob if you still have a copy of the benchmark that you used to obtain the performance comparison numbers in the nanobind docs, I think that might be useful here.

"NB_DOMAIN" "")

add_library(${name} MODULE ${ARG_UNPARSED_ARGUMENTS})
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions docs/api_cmake.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Expand Down
18 changes: 14 additions & 4 deletions include/nanobind/nb_class.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -114,7 +111,7 @@ struct type_data {
const char *name;
const std::type_info *type;
PyTypeObject *type_py;
nb_alias_chain *alias_chain;
void *foreign_bindings;
Copy link
Owner

@wjakob wjakob Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of this field deserves a comment given that it's unconditionally present (even if interop support is disabled).

In what way is the role of the original alias_chain subsumed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interop-disabled flag doesn't change the ABI version string, so we can't conditionally include fields based on its presence. Will add a comment.

The original alias_chain functionality is now served by the types_in_c2p_fast map in nb_internals, so that we can track aliases for both our types and foreign types.

#if defined(Py_LIMITED_API)
PyObject* (*vectorcall)(PyObject *, PyObject * const*, size_t, PyObject *);
#endif
Expand Down Expand Up @@ -332,6 +329,19 @@ inline void *type_get_slot(handle h, int slot_id) {
#endif
}

// nanobind interoperability with other binding frameworks
inline void set_foreign_type_defaults(bool export_all, bool import_all) {
detail::nb_type_set_foreign_defaults(export_all, import_all);
}
template <class T = void>
inline void import_foreign_type(handle type) {
Copy link
Owner

@wjakob wjakob Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These will require documentation. I am not sure why a foreign type would need to be explicitly imported/exported through this API in user code. Isn't this something that the framework will do automatically for us?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I understand docs are needed and just hadn't gotten to them yet.

The user can decide whether or not to import everything ABI-compatible by default (set_foreign_type_defaults). If they don't, they can import specific types using this function. Even if they do, this function is useful for types from a different language, such as pure-C types that don't have a type_info. The user provides the mapping between type_info and Python type by calling this function, and asserts that they have verified ABI compatibility.

detail::nb_type_import(type.ptr(),
std::is_void_v<T> ? nullptr : &typeid(T));
}
inline void export_type_to_foreign(handle type) {
detail::nb_type_export(type.ptr());
}

template <typename Visitor> struct def_visitor {
protected:
// Ensure def_visitor<T> can only be derived from, not constructed
Expand Down
4 changes: 2 additions & 2 deletions include/nanobind/nb_error.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 <typename T>
Expand All @@ -142,7 +142,7 @@ class exception : public object {
} catch (T &e) {
PyErr_SetString((PyObject *) payload, e.what());
}
}, m_ptr);
}, m_ptr, /*at_end=*/false);
}
};

Expand Down
20 changes: 16 additions & 4 deletions include/nanobind/nb_lib.h
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,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);
Expand Down Expand Up @@ -386,6 +388,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<bool, bool> nb_inst_state(PyObject *o) noexcept;

// Set whether types will be shared with other binding frameworks by default
NB_CORE void nb_type_set_foreign_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
Expand Down Expand Up @@ -500,7 +511,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);
Expand Down
10 changes: 7 additions & 3 deletions include/nanobind/nb_types.h
Original file line number Diff line number Diff line change
Expand Up @@ -667,15 +667,19 @@ class iterable : public object {

/// Retrieve the Python type object associated with a C++ class
template <typename T> handle type() noexcept {
return detail::nb_type_lookup(&typeid(detail::intrinsic_t<T>));
return detail::nb_type_lookup(&typeid(detail::intrinsic_t<T>), false);
}
template <typename T> handle maybe_foreign_type() noexcept {
Copy link
Owner

@wjakob wjakob Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this function? I don't think it is called anywhere? The alternative would be to add a bool parameter to type().

if we need to have a function, then I would prefer the name type_maybe_foreign.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's like nb::type() but it can return a foreign type also. I found it useful in client code. I'm indifferent between a bool parameter and separate function, so much so that I seem to have made different choices for two adjacent functions - regardless of which direction we go, we can pick one scheme and use it for both.

return detail::nb_type_lookup(&typeid(detail::intrinsic_t<T>), true);
}

template <typename T>
NB_INLINE bool isinstance(handle h) noexcept {
NB_INLINE bool isinstance(handle h, bool foreign_ok = false) noexcept {
Copy link
Owner

@wjakob wjakob Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, I am wondering about the role of foreign_ok parameter in multiple functions of the new API. Is this to avoid a costly corner to deal with foreign types, in cases where we check type equality and expect false with some probability?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type and isinstance are the only two pieces of public API that expose this question. I included the option to allow foreign or not for backward compatibility; I was concerned about cases where client code might assume the result of nb::type is a this-domain nanobind type and call low-level API functions on it without checking, or assume a positive result from isinstance of a type that uses the base type caster implies the same. It's a bit tricky because the default must be false to get the backcompat benefit, but most users probably want foreign types to be treated on an equal footing with our own types. If you're not worried about the backcompat concern, I can swap the default or remove the parameter entirely. I don't think there's a notable performance cost, just thinking about correctness.

if constexpr (std::is_base_of_v<handle, T>)
return T::check_(h);
else if constexpr (detail::is_base_caster_v<detail::make_caster<T>>)
return detail::nb_type_isinstance(h.ptr(), &typeid(detail::intrinsic_t<T>));
return detail::nb_type_isinstance(h.ptr(), &typeid(detail::intrinsic_t<T>),
foreign_ok);
else
return detail::make_caster<T>().from_python(h, 0, nullptr);
}
Expand Down
5 changes: 5 additions & 0 deletions include/nanobind/stl/unique_ptr.h
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ struct type_caster<std::unique_ptr<T, Deleter>> {
// Stash source python object
src = src_;

// Don't accept foreign types; they can't relinquish ownership
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be guarded with an #ifdef to only compile in the case of interop support being enabled?

Minor: in the nanobind codebase, braces are omitted for if statements with a simple 1-line body.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only put the new #ifdefs in libnanobind, because I wanted to avoid "infecting" every piece of client code with a new flag dependency. One way to avoid the extra inst_check overhead without adding an #ifdef here would be to add a new cast flag that disables use of foreign types; how would you feel about that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a larger question here of - are we requiring an entire nanobind domain to be interop-capable vs not, or are we allowing different extension modules in the same domain to make different choices on that front? I went for the latter since I didn't want a situation where enabling interop for module A would break its previously-working sharing of types with module B.

if (!src.is_none() && !inst_check(src)) {
return false;
}

/* 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') */
Expand Down
28 changes: 22 additions & 6 deletions src/error.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am curious about the use of atomics here, it seems complicated.

Exceptions and class definitions are created when a module is loaded, and those regions run single-threaded even in free-threaded builds. Perhaps it would be more clear to document that this function is unsafe to use concurrently?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AIUI, while module imports serialize against each other (you can't have two modules importing at the same time), they don't serialize against ordinary execution. There may be concurrent accesses to the list of exception translators from other threads, which would create a data race (at least formally) if we didn't use atomics here. We're no longer limited to additions at the front of the list, so the previous argument that concurrent accesses will see an unchanging sub-list no longer holds.

If we assume all translator registrations occur during a module load, then I do think we could get away with using relaxed atomics for the next pointers and dropping the compare-exchange. But doing a fully correct lock-free add is really not much harder, and gives some extra protection against users not doing what we expect them to. Note that any other framework registering itself with pymetabind will result in a new exception translator for us, and we can't really control when that occurs. For example, nanobind doesn't register itself until some types are actually exported. An extension module could expose a Python function that wraps nb::set_foreign_type_support(), resulting in that registration occurring without the import lock held.

Not all class definitions are created when a module is loaded. For example, the iterator class returned when one uses nb::make_iterator() is created on first use. I could imagine situations like a type caster for std::expected that would want to register exception translators on first use.

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<nb_translator_seq *> *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,
Expand Down
2 changes: 1 addition & 1 deletion src/nb_abi.h
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
1 change: 1 addition & 0 deletions src/nb_combined.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
45 changes: 14 additions & 31 deletions src/nb_enum.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,6 @@ using enum_map = tsl::robin_map<int64_t, int64_t, int64_hash>;

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;
Expand Down Expand Up @@ -85,20 +68,7 @@ PyObject *enum_create(enum_init_data *ed) noexcept {
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;
Expand All @@ -107,6 +77,19 @@ PyObject *enum_create(enum_init_data *ed) noexcept {
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;
}

result.attr("__nb_enum__") = tie_lifetimes;

make_immortal(result.ptr());

return result.release().ptr();
}

Expand Down
Loading