You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This replaces pybind11's independent py::print implementation with delegation to the print entry in the current execution frame's built-ins, or the active interpreter's
built-ins when no Python frame is executing.
It is a smaller alternative to #6120, prompted by the failure reported in #6012 and first
proposed in this discussion.
Original problem
In Windows GUI applications built with /SUBSYSTEM:WINDOWS, sys.stdout can legitimately
be None. The previous py::print implementation selected that object as its default
stream and then tried to access its write attribute, causing the failure reported in #6012.
A local None check fixes that immediate case, but investigation in #6120 exposed a more
general maintenance problem: pybind11 was replicating Python's print behavior. Keeping
that replica aligned requires pybind11 to implement and test stream selection, sep, end, file, and flush, keyword validation and diagnostics, partial-output and error
ordering, interpreter shutdown, and free-thread-safe access to sys.stdout. Several
details already differed between CPython, PyPy, and pybind11, and future runtime changes
could introduce more drift.
Design
py::print already collects its converted positional and keyword arguments into a tuple
and dictionary. Python does not expose a public C API that directly implements print, so
the new implementation obtains the current frame/interpreter built-ins, retrieves its print entry, and invokes that selected callable with the collected tuple and dictionary
through PyObject_Call.
The built-ins lookup uses the appropriate public C API and reference ownership for each
supported Python version:
Python 3.13 and newer use PyEval_GetFrameBuiltins(), which returns a new strong
reference.
Earlier versions use PyEval_GetBuiltins(), which returns a borrowed reference.
Both APIs fall back to the interpreter's built-ins when no Python frame is executing. All
supported free-threaded CPython versions take the Python 3.13+ strong-reference path. The
frame lookup is deliberate: it respects the frame's configured built-ins and avoids
importing or caching the builtins module across calls, interpreters, or interpreter
restarts.
The print entry is obtained through pybind11's cross-version dict_getitemstringref() helper, which returns a strong reference on every supported
Python version. If the entry is absent, py::print returns silently because the built-ins
dictionary may already be partially cleared when C++ destructors run during interpreter
shutdown. Genuine lookup failures still propagate, as do exceptions from any entry that
was found and called. This preserves the historical best-effort teardown behavior without
swallowing call-time errors.
The implementation itself does not access sys.stdout; stream selection is left to the
invoked callable. This removes pybind11's separate formatting, keyword parsing, stream
lookup, writing, and flushing implementation. The public C++ API remains void, so the
Python callable's return value is discarded, while Python exceptions propagate through error_already_set as usual.
A benchmark reported during the #6120 discussion
found direct lookup through the PyEval_Get*Builtins() APIs to be at parity with the
custom implementation. Retaining the emulation therefore provides no meaningful
performance advantage.
Resulting behavior and deliberate tradeoff
With the standard built-in print, this fixes the sys.stdout is None case and makes py::print follow the active runtime's behavior for:
sep, end, file, and flush;
default and custom output streams;
keyword validation and diagnostics;
conversion, writing, flushing, and partial-output errors;
operation ordering and interpreter-specific differences.
The current frame's built-ins print entry—normally builtins.print—is mutable.
Replacing it therefore affects subsequent py::print calls. This is intentional:
delegation means using the current entry rather than caching an "original" function. A
replacement that calls back into py::print can recurse, just as any replacement that
calls itself can recurse. Removing the entry entirely makes py::print a no-op, providing
the narrow teardown safeguard described above; an entry that exists but is non-callable
still raises normally when called.
The corresponding benefit is that pybind11 no longer has to predict or reproduce native
behavior. Differences such as CPython and PyPy choosing different exception types for a
missing sys.stdout remain the responsibility of those runtimes instead of becoming
pybind11 compatibility code.
Test strategy
The previous direct test primarily asserted formatted output and behavior supplied by
Python's standard print. It is replaced with controlled tests of the delegation layer
itself.
A small test binding accepts arbitrary py::args and py::kwargs and adapts them to the
public C++ call py::print(*args, **kwargs). The lambda is intentional: py::print is a
variadic function template rather than a single function that m.def can bind directly,
and a specialization taking py::args and py::kwargs would pass those containers as
ordinary arguments instead of unpacking them. Python-side tests verify that pybind11:
resolves the current callable for each invocation rather than caching it;
forwards positional arguments in order and preserves object identity;
forwards keyword names, order, and value identity without interpreting them;
preserves the void return contract even when the callable returns a value;
propagates the exact Python exception raised by the callable;
returns silently when the current built-ins dictionary has no print entry.
A runtime-differential regression test sets sys.stdout = None and compares native print with py::print, asserting only whether each succeeds or which exception type it
raises. This directly covers the original bug and invokes the real built-in without
freezing CPython's behavior as pybind11 policy; for example, PyPy currently makes a
different choice here.
The tests deliberately do not freeze formatting, stream protocol, flush ordering,
diagnostic wording, or teardown-time behavior of the invoked runtime callable. The
missing-entry test covers only pybind11's owned lookup fallback. Broader no-frame and
shutdown validation remains recorded in #6122.
Comprehensive validation experiment
Before reducing the tests to that focused contract, #6122 combined this production
implementation with the comprehensive behavioral coverage developed for #6120 and ran it
through the full CI matrix.
The final CI report
records 73 passing checks, two skipped CI checks, and three PyPy 3.11 failures. All three
failures were the same over-specific test assertion: after removing sys.stdout, the test
required CPython's native RuntimeError, while PyPy's native print raises AttributeError. The assertion failed before comparing py::print with native print;
all other print coverage, including direct delegation, passed in those PyPy jobs.
Across the supported platform and compiler matrix, the experiment exercised stream and
keyword behavior, failures and operation ordering, unusual keyword names, regular and
free-threaded builds, GraalPy, PyPy, and main/subinterpreter shutdown. It provides broad
evidence for the delegation design while also demonstrating why runtime-owned details
should not remain in pybind11's permanent unit tests.
Local validation of the final focused change also passed:
GCC / CPython 3.14.4 GIL build: 26 Catch2 tests and 1,347 pytest tests passed
(2 skipped).
The utilities documentation now states that py::print uses the current execution
frame's built-ins print entry, falling back to the active interpreter's built-ins when
no frame is executing. It retains the useful guidance that, with the standard built-in,
omitting file or passing file=None uses the current sys.stdout.
No ABI-visible data structures or layouts are changed.
This supersedes the narrowly scoped implementation in #6012 and provides the final,
smaller alternative to #6120.
Suggested changelog entry:
Make py::print delegate to the current frame/interpreter built-ins print entry,
fixing handling of sys.stdout = None, following the active runtime's stream, keyword,
and error semantics, and remaining a no-op if the entry is unavailable during teardown.
Review: PR #6121 — Delegate py::print to Python's native print
Overview
Replaces the hand-rolled ~30-line emulation of print in detail::print (include/pybind11/pybind11.h:3766) with a lookup of the current frame's builtins print callable, invoked via PyObject_Call with the already-collected args tuple and kwargs dict. Fixes #6012 (sys.stdout is None under /SUBSYSTEM:WINDOWS) and removes a maintenance burden of tracking CPython/PyPy print semantics. Tests are rewritten to verify the delegation contract instead of the runtime's formatting behavior.
Correctness — solid
The version split is right: PyEval_GetFrameBuiltins() (3.13+, new strong reference, reinterpret_steal) vs. PyEval_GetBuiltins() (borrowed, reinterpret_borrow). Free-threaded builds are all 3.13+, so the borrowed-reference path never runs without the GIL — the thread-safety concern is handled.
Holding native_print as a strong reference across the call protects against the builtins entry being replaced concurrently under free-threading. Good.
PyObject_Call with a possibly-empty kwargs dict is valid; error propagation via error_already_set follows pybind11 convention.
py::print already requires the GIL/attached thread state, which is the only precondition of both PyEval_Get*Builtins APIs.
Behavior changes and risks
Shutdown-time hardening was removed. The old code silently returned when import sys failed during interpreter-shutdown GC (the explicit comment said "give up rather than crashing"). The new code throws if builtins["print"] is absent or the call fails. py::print is commonly used inside C++ destructors (this repo does it: ~NoisyAlloc() at tests/test_factory_constructors.cpp:369) — an error_already_set escaping an implicitly-noexcept destructor is std::terminate. The new lookup is much more robust than an import (builtins survive nearly all of finalization), and Validate native py::print delegation with comprehensive tests #6122's CI experiment covered shutdown, so the risk is small — but if you want to preserve the old guarantee, a non-throwing PyDict_GetItemString-style lookup with a silent return when print is missing would keep the "never crash during teardown" property without swallowing call-time errors.
Frame-local builtins are now authoritative. Under a sandboxed frame (exec(code, {"__builtins__": {}})), py::print raises KeyError: 'print' instead of printing. This is consistent with the stated design ("the current frame's built-ins") and arguably more correct; just be aware it's "the frame's print", not builtins.print, which the docs sentence slightly glosses over.
The old print_failure test (conversion of UnregisteredType) is gone, but the underlying collect_arguments failure path is still covered at tests/test_callbacks.py:77, so no real coverage loss there. The stray-looking removal of the detailed_error_messages_enabled import is correct — both uses were inside the deleted test.
Test coverage
The new delegation tests are well-designed: identity-preserving forwarding, per-call re-resolution, void return contract, and exact exception propagation are all checked. Two gaps:
The actual bug being fixed has no regression test. Nothing exercises sys.stdout = None. Since PyPy's native print raises AttributeError where CPython silently no-ops (the exact Validate native py::print delegation with comprehensive tests #6122 failure), a runtime-tolerant test would work on all runtimes:
All new tests monkeypatch print, so no direct test invokes the real builtin through py::print. End-to-end output is still covered indirectly (many capture-based tests elsewhere use py::print, e.g. test_factory_constructors), so this is minor — but a one-line capfd smoke test in test_pytypes.py would make the coverage self-contained.
Docs and style
The documentation update is accurate and keeps the practically useful file/sys.stdout guidance. Trivial nit: "file"_a = py::none() uses spaced = while the adjacent code sample uses "end"_a="<-".
The implementation is minimal and idiomatic; using PyObject_Call directly (rather than native_print(*args, **kwargs)) correctly avoids a second pass through collect_arguments.
Verdict
Approve with minor suggestions. The design tradeoff (delegation over emulation, mutable-builtins sensitivity accepted) is well-reasoned and thoroughly validated via #6122. The two things worth addressing before merge: a regression test for the sys.stdout = None case that motivated the change, and a decision (even if "accept and document") on the removed silent-failure guarantee during interpreter teardown.
Following up on the review, I added three commits:
84a7209 preserves pybind11's best-effort behavior during interpreter
shutdown. py::print now returns silently if the current built-ins dictionary
no longer contains print, as can happen after partial teardown. It uses
pybind11's cross-version dict_getitemstringref() helper rather than a
borrowed-reference PyDict_GetItemString() lookup, so the selected callable
is held by a strong reference, including on free-threaded Python. This
exception is deliberately narrow: actual lookup failures, calls to a
non-callable value, and exceptions raised by an existing callable still
propagate. A focused test covers the missing-entry case.
00e5361 adds a runtime-differential test for sys.stdout = None. Python
implementations differ in this case, so the test verifies that py::print
behaves like the active runtime's native print without encoding a particular
CPython outcome. This also gives the focused test suite a direct call through
the real built-in.
a35bf36 clarifies that py::print looks up print in the current execution
frame's built-ins and falls back to the active interpreter's built-ins when no
Python frame is executing.
Why not have it bound explicitly to builtin print instead of frame print?
Good question. If by explicitly binding it you mean always using the canonical builtins.print, there is no public PyBuiltin_Print-style API that directly
calls Python's implementation. We would need to import and look up the builtins module on every call, or introduce per-interpreter caching. The first
option brings sys.modules and import machinery back into the teardown-sensitive
path; the second adds subinterpreter and interpreter-restart bookkeeping and, if
the callable itself were cached, would stop honoring replacements of builtins.print.
PyEval_GetFrameBuiltins() is the public API for obtaining the active execution
frame's built-ins dictionary. On Python 3.13+ it returns a strong reference,
which is important for free-threaded Python. It naturally selects the correct
interpreter or subinterpreter, respects a deliberately customized __builtins__, and falls back to the interpreter's built-ins when no Python
frame is executing. Older Python versions use PyEval_GetBuiltins() while
protected by the GIL.
One nuance is that this is not full Python name resolution: we do not consult
the frame's globals, so a global variable named print does not override the
selected callable. We specifically fetch print from the configured built-ins
dictionary and call it.
Given the lack of a supported direct-print API, the frame-built-ins route seems
like the smallest public, per-interpreter, free-thread-compatible, and
teardown-friendly implementation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This replaces pybind11's independent
py::printimplementation with delegation to theprintentry in the current execution frame's built-ins, or the active interpreter'sbuilt-ins when no Python frame is executing.
It is a smaller alternative to #6120, prompted by the failure reported in #6012 and first
proposed in this discussion.
Original problem
In Windows GUI applications built with
/SUBSYSTEM:WINDOWS,sys.stdoutcan legitimatelybe
None. The previouspy::printimplementation selected that object as its defaultstream and then tried to access its
writeattribute, causing the failure reported in#6012.
A local
Nonecheck fixes that immediate case, but investigation in #6120 exposed a moregeneral maintenance problem: pybind11 was replicating Python's
printbehavior. Keepingthat replica aligned requires pybind11 to implement and test stream selection,
sep,end,file, andflush, keyword validation and diagnostics, partial-output and errorordering, interpreter shutdown, and free-thread-safe access to
sys.stdout. Severaldetails already differed between CPython, PyPy, and pybind11, and future runtime changes
could introduce more drift.
Design
py::printalready collects its converted positional and keyword arguments into a tupleand dictionary. Python does not expose a public C API that directly implements
print, sothe new implementation obtains the current frame/interpreter built-ins, retrieves its
printentry, and invokes that selected callable with the collected tuple and dictionarythrough
PyObject_Call.The built-ins lookup uses the appropriate public C API and reference ownership for each
supported Python version:
PyEval_GetFrameBuiltins(), which returns a new strongreference.
PyEval_GetBuiltins(), which returns a borrowed reference.Both APIs fall back to the interpreter's built-ins when no Python frame is executing. All
supported free-threaded CPython versions take the Python 3.13+ strong-reference path. The
frame lookup is deliberate: it respects the frame's configured built-ins and avoids
importing or caching the
builtinsmodule across calls, interpreters, or interpreterrestarts.
The
printentry is obtained through pybind11's cross-versiondict_getitemstringref()helper, which returns a strong reference on every supportedPython version. If the entry is absent,
py::printreturns silently because the built-insdictionary may already be partially cleared when C++ destructors run during interpreter
shutdown. Genuine lookup failures still propagate, as do exceptions from any entry that
was found and called. This preserves the historical best-effort teardown behavior without
swallowing call-time errors.
The implementation itself does not access
sys.stdout; stream selection is left to theinvoked callable. This removes pybind11's separate formatting, keyword parsing, stream
lookup, writing, and flushing implementation. The public C++ API remains
void, so thePython callable's return value is discarded, while Python exceptions propagate through
error_already_setas usual.A benchmark reported during the #6120 discussion
found direct lookup through the
PyEval_Get*Builtins()APIs to be at parity with thecustom implementation. Retaining the emulation therefore provides no meaningful
performance advantage.
Resulting behavior and deliberate tradeoff
With the standard built-in
print, this fixes thesys.stdout is Nonecase and makespy::printfollow the active runtime's behavior for:sep,end,file, andflush;The current frame's built-ins
printentry—normallybuiltins.print—is mutable.Replacing it therefore affects subsequent
py::printcalls. This is intentional:delegation means using the current entry rather than caching an "original" function. A
replacement that calls back into
py::printcan recurse, just as any replacement thatcalls itself can recurse. Removing the entry entirely makes
py::printa no-op, providingthe narrow teardown safeguard described above; an entry that exists but is non-callable
still raises normally when called.
The corresponding benefit is that pybind11 no longer has to predict or reproduce native
behavior. Differences such as CPython and PyPy choosing different exception types for a
missing
sys.stdoutremain the responsibility of those runtimes instead of becomingpybind11 compatibility code.
Test strategy
The previous direct test primarily asserted formatted output and behavior supplied by
Python's standard
print. It is replaced with controlled tests of the delegation layeritself.
A small test binding accepts arbitrary
py::argsandpy::kwargsand adapts them to thepublic C++ call
py::print(*args, **kwargs). The lambda is intentional:py::printis avariadic function template rather than a single function that
m.defcan bind directly,and a specialization taking
py::argsandpy::kwargswould pass those containers asordinary arguments instead of unpacking them. Python-side tests verify that pybind11:
voidreturn contract even when the callable returns a value;printentry.A runtime-differential regression test sets
sys.stdout = Noneand compares nativeprintwithpy::print, asserting only whether each succeeds or which exception type itraises. This directly covers the original bug and invokes the real built-in without
freezing CPython's behavior as pybind11 policy; for example, PyPy currently makes a
different choice here.
The tests deliberately do not freeze formatting, stream protocol, flush ordering,
diagnostic wording, or teardown-time behavior of the invoked runtime callable. The
missing-entry test covers only pybind11's owned lookup fallback. Broader no-frame and
shutdown validation remains recorded in #6122.
Comprehensive validation experiment
Before reducing the tests to that focused contract, #6122 combined this production
implementation with the comprehensive behavioral coverage developed for #6120 and ran it
through the full CI matrix.
The final CI report
records 73 passing checks, two skipped CI checks, and three PyPy 3.11 failures. All three
failures were the same over-specific test assertion: after removing
sys.stdout, the testrequired CPython's native
RuntimeError, while PyPy's nativeprintraisesAttributeError. The assertion failed before comparingpy::printwith nativeprint;all other print coverage, including direct delegation, passed in those PyPy jobs.
Across the supported platform and compiler matrix, the experiment exercised stream and
keyword behavior, failures and operation ordering, unusual keyword names, regular and
free-threaded builds, GraalPy, PyPy, and main/subinterpreter shutdown. It provides broad
evidence for the delegation design while also demonstrating why runtime-owned details
should not remain in pybind11's permanent unit tests.
Local validation of the final focused change also passed:
(2 skipped).
(23 skipped).
pre-commit run --all-filespassed.Documentation and compatibility
The utilities documentation now states that
py::printuses the current executionframe's built-ins
printentry, falling back to the active interpreter's built-ins whenno frame is executing. It retains the useful guidance that, with the standard built-in,
omitting
fileor passingfile=Noneuses the currentsys.stdout.No ABI-visible data structures or layouts are changed.
This supersedes the narrowly scoped implementation in #6012 and provides the final,
smaller alternative to #6120.
Suggested changelog entry:
py::printdelegate to the current frame/interpreter built-insprintentry,fixing handling of
sys.stdout = None, following the active runtime's stream, keyword,and error semantics, and remaining a no-op if the entry is unavailable during teardown.
📚 Documentation preview 📚: https://pybind11--6121.org.readthedocs.build/
Close #6120
Close #6021