From 0db7f72dc29af735f7242da170d5325d4f48ee2a Mon Sep 17 00:00:00 2001 From: Max Bachmann Date: Sun, 12 Apr 2026 05:02:27 +0200 Subject: [PATCH] Handle result from PyObject_VisitManagedDict (#6032) * Handle result from PyObject_VisitManagedDict * add unit test * style: pre-commit fixes * use different variable name This avoids a warning on msvc about Py_Visit shadowing the vret variable. * skip test_get_referrers on unsupported runtimes The managed-dict referrer check is only known to work on CPython 3.13.13+ and 3.14.4+, while earlier releases and non-CPython interpreters can report different traversal behavior. Made-with: Cursor --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Ralf W. Grosse-Kunstleve --- include/pybind11/detail/class.h | 5 ++++- tests/test_class.cpp | 6 ++++++ tests/test_class.py | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/include/pybind11/detail/class.h b/include/pybind11/detail/class.h index 4b7422eee..8b9d0b8e9 100644 --- a/include/pybind11/detail/class.h +++ b/include/pybind11/detail/class.h @@ -578,7 +578,10 @@ inline PyObject *make_object_base_type(PyTypeObject *metaclass) { /// dynamic_attr: Allow the garbage collector to traverse the internal instance `__dict__`. extern "C" inline int pybind11_traverse(PyObject *self, visitproc visit, void *arg) { #if PY_VERSION_HEX >= 0x030D0000 - PyObject_VisitManagedDict(self, visit, arg); + int ret = PyObject_VisitManagedDict(self, visit, arg); + if (ret) { + return ret; + } #else PyObject *&dict = *_PyObject_GetDictPtr(self); Py_VISIT(dict); diff --git a/tests/test_class.cpp b/tests/test_class.cpp index 2030cd671..84efb800d 100644 --- a/tests/test_class.cpp +++ b/tests/test_class.cpp @@ -104,6 +104,10 @@ TEST_SUBMODULE(class_, m) { ~NoConstructorNew() { print_destroyed(this); } }; + struct DynamicAttr { + DynamicAttr() = default; + }; + py::class_(m, "NoConstructor") .def_static("new_instance", &NoConstructor::new_instance, "Return an instance"); @@ -112,6 +116,8 @@ TEST_SUBMODULE(class_, m) { .def_static("__new__", [](const py::object &) { return NoConstructorNew::new_instance(); }); + py::class_(m, "DynamicAttr", py::dynamic_attr()).def(py::init<>()); + // test_pass_unique_ptr struct ToBeHeldByUniquePtr {}; py::class_>(m, "ToBeHeldByUniquePtr") diff --git a/tests/test_class.py b/tests/test_class.py index fae6a3189..201c7e339 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -1,5 +1,6 @@ from __future__ import annotations +import gc import sys from unittest import mock @@ -18,6 +19,13 @@ def refcount_immortal(ob: object) -> int: return sys.getrefcount(ob) +MANAGED_DICT_GET_REFERRERS_SUPPORTED = ( + env.CPYTHON + and sys.version_info >= (3, 13, 13) + and (sys.version_info < (3, 14) or sys.version_info >= (3, 14, 4)) +) + + def test_obj_class_name(): expected_name = "UserType" if env.PYPY else "pybind11_tests.UserType" assert m.obj_class_name(UserType(1)) == expected_name @@ -45,6 +53,16 @@ def test_instance(msg): assert cstats.alive() == 0 +@pytest.mark.skipif( + not MANAGED_DICT_GET_REFERRERS_SUPPORTED, + reason="Requires CPython 3.13.13+ or 3.14.4+ managed dict traversal support", +) +def test_get_referrers(): + instance = m.DynamicAttr() + instance.a = "test" + assert instance in gc.get_referrers(instance.__dict__) + + def test_instance_new(): instance = m.NoConstructorNew() # .__new__(m.NoConstructor.__class__)