Skip to content

fix: delegate py::print to Python's native print - #6121

Merged
rwgk merged 7 commits into
pybind:masterfrom
rwgk:pybind11_print_delegate_to_native
Aug 3, 2026
Merged

fix: delegate py::print to Python's native print#6121
rwgk merged 7 commits into
pybind:masterfrom
rwgk:pybind11_print_delegate_to_native

Conversation

@rwgk

@rwgk rwgk commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Description

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).
  • GCC / free-threaded CPython 3.14.4 build: 26 Catch2 tests and 1,326 pytest tests passed
    (23 skipped).
  • pre-commit run --all-files passed.

Documentation and compatibility

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.

📚 Documentation preview 📚: https://pybind11--6121.org.readthedocs.build/

Close #6120
Close #6021

@henryiii

henryiii commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Fable review below.

🤖 AI text below 🤖

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:

    def test_print_stdout_none_matches_native(monkeypatch, capsys):
        monkeypatch.setattr(sys, "stdout", None)
        try:
            print("native")
        except Exception as exc:
            with pytest.raises(type(exc)):
                m.print_args("via pybind11")
        else:
            m.print_args("via pybind11")
  • 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.

@Skylion007

Copy link
Copy Markdown
Collaborator

Why not have it bound explicitly to builtin print instead of frame print?

@rwgk

rwgk commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up changes after review

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.

Local validation passed in both configurations:

  • pre-commit run --all-files
  • Default: 26 Catch2 cases / 1,620 assertions; 1,347 pytest tests passed, 2 skipped
  • Free-threaded: 26 Catch2 cases / 1,620 assertions; 1,326 pytest tests passed, 23 skipped

@rwgk

rwgk commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@rwgk
rwgk merged commit 5956509 into pybind:master Aug 3, 2026
78 checks passed
@github-actions github-actions Bot added the needs changelog Possibly needs a changelog entry label Aug 3, 2026
@rwgk
rwgk deleted the pybind11_print_delegate_to_native branch August 3, 2026 05:38
@henryiii henryiii changed the title Delegate py::print to Python's native print fix: delegate py::print to Python's native print Aug 3, 2026
@henryiii henryiii removed the needs changelog Possibly needs a changelog entry label Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: py::smart_holder does not preserve shared_ptr ownership across casts (weak_ptr expires unexpectedly)

3 participants