Repository navigation
feat: create types with PyType_FromMetaclass on CPython 3.12+ - #6231
Conversation
pybind11_static_property, the pybind11_type metaclass, pybind11_object and the classes from make_new_python_type() are now created from a PyType_Spec on CPython 3.12+ instead of by filling in PyHeapTypeObject fields. PYBIND11_TYPE_CREATION_VIA_SPEC (0/1) controls this; define it as 0, or configure with -DPYBIND11_TYPE_CREATION_VIA_SPEC=OFF, to keep the legacy path. PyPy, GraalPy and Python < 3.12 keep the legacy path. py::custom_type_setup(), metaclasses with a custom tp_new, and metaclass combinations that PyType_FromMetaclass() rejects (such as a base whose metaclass is not related to pybind11_type) still use the legacy path. A user-defined PYBIND11_BACKWARD_COMPATIBILITY_TP_DICTOFFSET also selects the legacy path, so all bound classes share one __dict__ layout. The instance layout and the internals ID do not change. The macro is encoded in the precompiled config check. A new CI job tests the legacy path on CPython 3.12+. See pybind#6104. Assisted-by: ClaudeCode:claude-opus-5-5
|
Initial codex GPT-6.1-Sol ultra review below. Before the review, I locally merged latest master. I'll use the same codex session to generate a fix. I found one reproducible regression, treating the Python 3.12+ switch as intentional. [P2] Preserve supplied class names containing dots — class-inl.h:840–841. For example: py::class_<T>(m, "Wrapper[pkg.Value]");The new path changes This reproduces on Python 3.12, 3.14, and free-threaded 3.14. Master and the legacy opt-out preserve the supplied names. Explicitly restoring Your local merged revision passed:
No other introduced actionable issues emerged from the independent reviews. |
— codex GPT-6 on behalf of Ralf — Use the existing legacy type-creation fallback when a bound name contains a dot. PyType_FromMetaclass() treats the last dot as the module separator, truncating names such as Wrapper[pkg.Value]. Preserve both the public __name__/__qualname__ and the fully qualified C tp_name. Assigning __name__ after creation would also overwrite tp_name. Add top-level and nested regression cases; both fail without the fix.
|
In case anyone is looking at the moment: I'm having doubts myself about the direction taken with commit f9eda9d. I'm still working on making up my mind. |
— codex GPT-6 on behalf of Ralf — Revert f9eda9d so the naming behavior introduced by spec-based type creation can be characterized across CI. The naming policy will be discussed separately after collecting results.
— codex GPT-6 on behalf of Ralf — Record name, qualified name, literal lookup, construction, reference-return identity and generated signatures for dotted and bracketed class names. Include nested scopes, a child of a dotted class, and custom type setup. Keep expectations specific to the current creation path and Python runtime. These tests collect evidence for the naming-policy discussion without choosing whether to retain, reject or accommodate the changed behavior.
|
Update: I'm using the CI here to fully characterize the behavior changes introduced by this PR. I'm uncertain if we should care about them or not. I believe it's best to have complete ground truth, then we can decide. |
— codex GPT-6 on behalf of Ralf — Select true/false with the preprocessor instead of casting the numeric configuration macro to bool. This satisfies modernize-use-bool-literals in Clang-Tidy, including the Python3.11 macro-zero branch.
|
PR #6198 introduces a behavior change for supplied class names containing literal dots; that type-creation change is split out into this PR. I reverted f9eda9d and added characterization tests to document the behavior directly. The CPython legacy path preserves the supplied spelling. On CPython 3.12+, the new default creation path uses the portion after the last dot as
The binding remains available through The characterization tests cover these cases, nesting, and the custom-setup fallback. All nine cases passed in 41 standard CI configurations (369 individual passes), covering Linux, macOS, Windows, GIL-enabled and free-threaded CPython, explicit legacy mode, PyPy, and GraalPy. The full CI run is green. The tested PyPy 3.11 builds preserve both name fields; the tested PyPy 3.12 and GraalPy versions already shorten I found
The examples above are synthetic characterization inputs. Options include:
Feedback on the intended naming contract, or concrete existing uses, would help decide among these options. |
|
🤖 AI text below 🤖 Thanks for the characterization. I think the right answer is option 3, implemented as a post-creation fixup rather than a legacy fallback. The reference is what CPython does for a type created from Python:
The split at the last dot is an artifact of Plan: after |
PyType_FromMetaclass() splits the spec name at the last dot, so a class named "a[b.c]" got __name__ "c]". After creation, set ht_name, ht_qualname, and tp_name the same way as the legacy path, through a helper that both paths share. This matches type(), which never splits dots in a name. The characterization tests now assert the preserved names on both paths. Assisted-by: ClaudeCode:claude-opus-5-5
|
🤖 AI text below 🤖 0f15367 implements option 3. After
|
|
I agree going with option 3 is a great choice; I find the table under #6231 (comment) very convincing. I asked codex about replacing the characterization tests I added with codex GPT-6.1-Sol ultra: Fresh PR head is 0f15367. I agree with the chosen naming behavior, but found two issues:
There is no direct I’d convert this test into comparisons against Python reference classes, keeping the small C++ fixtures and meaningful coverage: dotted names, nesting, custom-setup fallback, lookup, construction, and signatures. Nested references need an explicitly assigned qualified name derived from an independent Python parent. Two details deserve care:
|
Assisted-by: ClaudeCode:claude-fable-5-1
After PyType_FromMetaclass() the type is reachable through its bases' __subclasses__(), so replacing ht_name/ht_qualname directly races with concurrent readers on free-threaded builds (CPython's own setters use a stop-the-world pause for this, gh-133467). Go through the setters, and only when the spec did not already produce the right strings. tp_name only ever points at buffers that outlive the type, so that pointer swap stays direct. The legacy path keeps the direct writes: it runs before PyType_Ready(), when nothing else can see the type. Assisted-by: ClaudeCode:claude-fable-5-1
|
🤖 AI text below 🤖 Both review points are addressed. Free-threading race (P2): agreed. CPython reads Unused Verified locally (test_class, test_methods_and_attributes, test_custom_type_setup): spec path on CPython 3.15, legacy path ( On the test suggestion: comparing against |
Working on it. |
— codex GPT-6 on behalf of Ralf — Call the builtin type name descriptors directly so metaclasses that reject later renaming still work during class creation. Preserve the interpreter's setter implementation and the existing conditional updates. Cover dotted module names and ordinary nested names with a metaclass that rejects renaming. The regression fails before the fix and passes afterward on default and free-threaded CPython 3.14.4. All-files pre-commit passes.
— codex GPT-6 on behalf of Ralf — Derive naming expectations from independently created Python types, including reference parents for nested classes. Retain construction, identity, lookup, signature, and custom-setup coverage. Keep raw C type spelling separate through the existing obj_class_name helper. Apply known interpreter name xfails only after unaffected checks. Validation: full default/free-threaded suites pass (1416/1414 tests), plus 39 Catch2 cases and 11444 assertions each. Focused CPython 3.11, 3.12, legacy, PyPy 3.11/3.12, and GraalPy checks pass with expected skips/xfails. A qualname mutation is XFAIL in the old test and FAIL in the new test. All-files pre-commit passes.
|
— codex GPT-6 on behalf of Ralf — c49e296e fixes a compatibility issue with custom metaclasses that allow class creation but prohibit later renaming. The ordinary The fix invokes the built-in The regression test covers a dotted module-level class and an ordinary nested class, verifies usable C++ instances, and checks that later renaming is still rejected. It fails before the fix and passes afterward on both default and free-threaded CPython 3.14.4. |
|
@henryiii done, could you please re-review from your end? |
py::make_tuple of a py::class_ needs handle_type_name, which the PYBIND11_DISABLE_HANDLE_TYPE_NAME_DEFAULT_IMPLEMENTATION CI job turns off. Assisted-by: ClaudeCode:claude-fable-5-1
|
🤖 AI text below 🤖 CI on 4af4aa7 was green except the |
|
Once you give the okay to have it commit on a PR, it comments on everything. :) The change is fine. This was (the thing I fixed) a really, really minor edge case; to get a race condition, you'd have to be actively scanning for new modules from a different thread and you'd just have a few cycles to cause an issue before it was changed. But the fix wasn't too bad, and this isn't performance critical code, so I went with it. Pushed a fix for a test (as it was happy to comment on). |
🤖 AI text below 🤖
Description
On CPython 3.12+, pybind11 now creates its type objects (
pybind11_static_property, thepybind11_typemetaclass,pybind11_object, and all bound classes) withPyType_FromMetaclass()and aPyType_Spec. It no longer fills inPyHeapTypeObjectfields by hand there.PYBIND11_TYPE_CREATION_VIA_SPECmacro (0/1) controls this. It defaults to 1 on CPython 3.12+ and to 0 on older CPython, PyPy, and GraalPy. Define it as 0, or configure with-DPYBIND11_TYPE_CREATION_VIA_SPEC=OFF, to keep the legacy path. A new CI job tests the legacy path on 3.12+.py::custom_type_setup(), metaclasses with a customtp_new, and metaclass combinations thatPyType_FromMetaclass()rejects still use the legacy path. One example of a rejected combination is a base whose metaclass is not a base or subtype ofpybind11_type. Without the fallback, binding a class derived from such a base fails with a metaclass conflict. A new test covers this case.PYBIND11_BACKWARD_COMPATIBILITY_TP_DICTOFFSET, the legacy path is the default. The spec path usesPy_TPFLAGS_MANAGED_DICT, and all bound classes must have the same__dict__layout.PYBIND11_TYPE_CREATION_VIA_SPEC=1together with that macro is a compile error.PYBIND11_INTERNALS_IDdo not change. The macro is added to the precompiled config check.This prepares for stable-ABI support (#6104), but it is also useful without it.
Suggested changelog entry:
PyType_FromMetaclass(). DefinePYBIND11_TYPE_CREATION_VIA_SPEC=0(CMake:-DPYBIND11_TYPE_CREATION_VIA_SPEC=OFF) to use the previousPyHeapTypeObjectpath.📚 Documentation preview 📚: https://pybind11--6231.org.readthedocs.build/