From 1ee3d17d5b56817e0be782a7083aad283df035c6 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 1 Aug 2026 21:52:49 -0700 Subject: [PATCH 1/7] refactor: delegate py::print to native print --- include/pybind11/pybind11.h | 38 ++++++++++--------------------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h index 12559ddf3d..39130f0a3a 100644 --- a/include/pybind11/pybind11.h +++ b/include/pybind11/pybind11.h @@ -3764,34 +3764,16 @@ register_local_exception(handle scope, const char *name, handle base = PyExc_Exc PYBIND11_NAMESPACE_BEGIN(detail) PYBIND11_NOINLINE void print(const tuple &args, const dict &kwargs) { - auto strings = tuple(args.size()); - for (size_t i = 0; i < args.size(); ++i) { - strings[i] = str(args[i]); - } - auto sep = kwargs.contains("sep") ? kwargs["sep"] : str(" "); - auto line = sep.attr("join")(std::move(strings)); - - object file; - if (kwargs.contains("file")) { - file = kwargs["file"].cast(); - } else { - try { - file = module_::import("sys").attr("stdout"); - } catch (const error_already_set &) { - /* If print() is called from code that is executed as - part of garbage collection during interpreter shutdown, - importing 'sys' can fail. Give up rather than crashing the - interpreter in this case. */ - return; - } - } - - auto write = file.attr("write"); - write(std::move(line)); - write(kwargs.contains("end") ? kwargs["end"] : str("\n")); - - if (kwargs.contains("flush") && kwargs["flush"].cast()) { - file.attr("flush")(); +#if PY_VERSION_HEX >= 0x030D0000 + auto builtins = reinterpret_steal(PyEval_GetFrameBuiltins()); +#else + auto builtins = reinterpret_borrow(PyEval_GetBuiltins()); +#endif + object native_print = builtins["print"]; + object result + = reinterpret_steal(PyObject_Call(native_print.ptr(), args.ptr(), kwargs.ptr())); + if (!result) { + throw error_already_set(); } } PYBIND11_NAMESPACE_END(detail) From ea4fde4c3886b9dc969cefcce5260f9d63f736f2 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 1 Aug 2026 22:34:45 -0700 Subject: [PATCH 2/7] style: use auto for print result --- include/pybind11/pybind11.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h index 39130f0a3a..3e816085e8 100644 --- a/include/pybind11/pybind11.h +++ b/include/pybind11/pybind11.h @@ -3770,7 +3770,7 @@ PYBIND11_NOINLINE void print(const tuple &args, const dict &kwargs) { auto builtins = reinterpret_borrow(PyEval_GetBuiltins()); #endif object native_print = builtins["print"]; - object result + auto result = reinterpret_steal(PyObject_Call(native_print.ptr(), args.ptr(), kwargs.ptr())); if (!result) { throw error_already_set(); From 227f5602cb0f07e03ff7d3fd40b2906969d6f667 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 1 Aug 2026 23:23:14 -0700 Subject: [PATCH 3/7] test: focus py::print coverage on delegation --- tests/test_pytypes.cpp | 21 ++------------ tests/test_pytypes.py | 66 +++++++++++++++++++++++++++--------------- 2 files changed, 44 insertions(+), 43 deletions(-) diff --git a/tests/test_pytypes.cpp b/tests/test_pytypes.cpp index ff77940965..b500eb7972 100644 --- a/tests/test_pytypes.cpp +++ b/tests/test_pytypes.cpp @@ -625,25 +625,8 @@ TEST_SUBMODULE(pytypes, m) { return py::dict("d"_a = d, "l"_a = l); }); - // test_print - m.def("print_function", []() { - py::print("Hello, World!"); - py::print(1, 2.0, "three", true, std::string("-- multiple args")); - auto args = py::make_tuple("and", "a", "custom", "separator"); - py::print("*args", *args, "sep"_a = "-"); - py::print("no new line here", "end"_a = " -- "); - py::print("next print"); - - auto py_stderr = py::module_::import("sys").attr("stderr"); - py::print("this goes to stderr", "file"_a = py_stderr); - - py::print("flush", "flush"_a = true); - - py::print( - "{a} + {b} = {c}"_s.format("a"_a = "py::print", "b"_a = "str.format", "c"_a = "this")); - }); - - m.def("print_failure", []() { py::print(42, UnregisteredType()); }); + m.def("print_args", + [](const py::args &args, const py::kwargs &kwargs) { py::print(*args, **kwargs); }); m.def("hash_function", [](py::object obj) { return py::hash(std::move(obj)); }); diff --git a/tests/test_pytypes.py b/tests/test_pytypes.py index 9a80f1ea41..2ba5b573fc 100644 --- a/tests/test_pytypes.py +++ b/tests/test_pytypes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins import contextlib import sys import types @@ -7,7 +8,6 @@ import pytest import env -from pybind11_tests import detailed_error_messages_enabled from pybind11_tests import pytypes as m @@ -545,29 +545,47 @@ def test_implicit_casting(): assert z["l"] == [3, 6, 9, 12, 15] -def test_print(capture): - with capture: - m.print_function() - assert ( - capture - == """ - Hello, World! - 1 2.0 three True -- multiple args - *args-and-a-custom-separator - no new line here -- next print - flush - py::print + str.format = this - """ - ) - assert capture.stderr == "this goes to stderr" - - with pytest.raises(RuntimeError) as excinfo: - m.print_failure() - assert str(excinfo.value) == "Unable to convert call argument " + ( - "'1' of type 'UnregisteredType' to Python object" - if detailed_error_messages_enabled - else "'1' to Python object (#define PYBIND11_DETAILED_ERROR_MESSAGES or compile in debug mode for details)" - ) +def test_print_delegates_to_current_builtin(monkeypatch): + calls = [] + + def first_print(*args, **kwargs): + calls.append(("first", args, kwargs)) + return object() + + def second_print(*args, **kwargs): + calls.append(("second", args, kwargs)) + return object() + + positional = [object(), object()] + keywords = {"first": object(), "second": object()} + + monkeypatch.setattr(builtins, "print", first_print) + assert m.print_args(*positional, **keywords) is None + + monkeypatch.setattr(builtins, "print", second_print) + assert m.print_args() is None + + assert calls[0][0] == "first" + assert len(calls[0][1]) == len(positional) + assert all(actual is expected for actual, expected in zip(calls[0][1], positional)) + assert list(calls[0][2]) == list(keywords) + assert all(calls[0][2][key] is value for key, value in keywords.items()) + assert calls[1] == ("second", (), {}) + + +def test_print_propagates_current_builtin_exception(monkeypatch): + class MarkerError(Exception): + pass + + error = MarkerError() + + def failing_print(): + raise error + + monkeypatch.setattr(builtins, "print", failing_print) + with pytest.raises(MarkerError) as exc_info: + m.print_args() + assert exc_info.value is error def test_hash(): From e353f093385929f11563b8e2cf64c4a5777cdb51 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 1 Aug 2026 23:24:43 -0700 Subject: [PATCH 4/7] docs: describe native py::print delegation --- docs/advanced/pycpp/utilities.rst | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/advanced/pycpp/utilities.rst b/docs/advanced/pycpp/utilities.rst index af0f9cb2b0..ea1096950e 100644 --- a/docs/advanced/pycpp/utilities.rst +++ b/docs/advanced/pycpp/utilities.rst @@ -7,11 +7,11 @@ Using Python's print function in C++ The usual way to write output in C++ is using ``std::cout`` while in Python one would use ``print``. Since these methods use different buffers, mixing them can lead to output order issues. To resolve this, pybind11 modules can use the -:func:`py::print` function which writes to Python's ``sys.stdout`` for consistency. +:func:`py::print` function, which writes through Python's printing machinery. -Python's ``print`` function is replicated in the C++ API including optional -keyword arguments ``sep``, ``end``, ``file``, ``flush``. Everything works as -expected in Python: +:func:`py::print` delegates each call to the current ``print`` callable in +Python's built-ins. With the standard built-in, optional keyword arguments +``sep``, ``end``, ``file``, and ``flush`` work as they do in Python: .. code-block:: cpp @@ -21,6 +21,10 @@ expected in Python: auto args = py::make_tuple("unpacked", true); py::print("->", *args, "end"_a="<-"); // -> unpacked True <- +With the standard built-in, omitting ``file`` or passing +``"file"_a = py::none()`` uses the current ``sys.stdout``. Other output and +error behavior is likewise supplied by the active Python runtime. + .. _ostream_redirect: Capturing standard output from ostream From 84a7209045bf1c95360b72ba5a66ab59ca803a7c Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sun, 2 Aug 2026 21:33:29 -0700 Subject: [PATCH 5/7] fix: keep py::print silent when builtin is missing --- include/pybind11/pybind11.h | 6 +++++- tests/test_pytypes.py | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h index 3e816085e8..eebb130694 100644 --- a/include/pybind11/pybind11.h +++ b/include/pybind11/pybind11.h @@ -3769,7 +3769,11 @@ PYBIND11_NOINLINE void print(const tuple &args, const dict &kwargs) { #else auto builtins = reinterpret_borrow(PyEval_GetBuiltins()); #endif - object native_print = builtins["print"]; + // The builtins dictionary may already be partially cleared during interpreter shutdown. + auto native_print = reinterpret_steal(dict_getitemstringref(builtins.ptr(), "print")); + if (!native_print) { + return; + } auto result = reinterpret_steal(PyObject_Call(native_print.ptr(), args.ptr(), kwargs.ptr())); if (!result) { diff --git a/tests/test_pytypes.py b/tests/test_pytypes.py index 2ba5b573fc..931ececa65 100644 --- a/tests/test_pytypes.py +++ b/tests/test_pytypes.py @@ -573,6 +573,15 @@ def second_print(*args, **kwargs): assert calls[1] == ("second", (), {}) +def test_print_missing_from_current_builtins_is_silent(monkeypatch): + # The builtins dictionary may have lost its print entry before C++ destructors + # run during interpreter shutdown. + with monkeypatch.context() as context: + context.delattr(builtins, "print") + result = m.print_args("ignored") + assert result is None + + def test_print_propagates_current_builtin_exception(monkeypatch): class MarkerError(Exception): pass From 00e5361899f1c636d46eb2cd862229f44a5f3b23 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sun, 2 Aug 2026 21:34:22 -0700 Subject: [PATCH 6/7] test: cover py::print with unavailable stdout --- tests/test_pytypes.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_pytypes.py b/tests/test_pytypes.py index 931ececa65..3c7b31e093 100644 --- a/tests/test_pytypes.py +++ b/tests/test_pytypes.py @@ -582,6 +582,23 @@ def test_print_missing_from_current_builtins_is_silent(monkeypatch): assert result is None +def test_print_stdout_none_matches_current_builtin(monkeypatch): + def exception_type(func): + try: + func("text") + except Exception as exc: + return type(exc) + return None + + # Python runtimes differ here; py::print should follow the active runtime. + with monkeypatch.context() as context: + context.setattr(sys, "stdout", None) + native_exception_type = exception_type(builtins.print) + pybind_exception_type = exception_type(m.print_args) + + assert pybind_exception_type is native_exception_type + + def test_print_propagates_current_builtin_exception(monkeypatch): class MarkerError(Exception): pass From a35bf36f8b0dca5bd5c56c99aa1e144c40b75b53 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sun, 2 Aug 2026 21:35:02 -0700 Subject: [PATCH 7/7] docs: clarify py::print builtins lookup --- docs/advanced/pycpp/utilities.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/advanced/pycpp/utilities.rst b/docs/advanced/pycpp/utilities.rst index ea1096950e..13d0dd19e1 100644 --- a/docs/advanced/pycpp/utilities.rst +++ b/docs/advanced/pycpp/utilities.rst @@ -9,9 +9,11 @@ would use ``print``. Since these methods use different buffers, mixing them can lead to output order issues. To resolve this, pybind11 modules can use the :func:`py::print` function, which writes through Python's printing machinery. -:func:`py::print` delegates each call to the current ``print`` callable in -Python's built-ins. With the standard built-in, optional keyword arguments -``sep``, ``end``, ``file``, and ``flush`` work as they do in Python: +:func:`py::print` delegates each call to the ``print`` entry in the current +execution frame's built-ins (normally ``builtins.print``). When no Python frame +is executing, it uses the active interpreter's built-ins. With the standard +built-in, optional keyword arguments ``sep``, ``end``, ``file``, and ``flush`` +work as they do in Python: .. code-block:: cpp