Delegate py::print to Python's native print (#6121)
* refactor: delegate py::print to native print
* style: use auto for print result
* test: focus py::print coverage on delegation
* docs: describe native py::print delegation
* fix: keep py::print silent when builtin is missing
* test: cover py::print with unavailable stdout
* docs: clarify py::print builtins lookup
diff --git a/docs/advanced/pycpp/utilities.rst b/docs/advanced/pycpp/utilities.rst
index af0f9cb..13d0dd1 100644
--- a/docs/advanced/pycpp/utilities.rst
+++ b/docs/advanced/pycpp/utilities.rst
@@ -7,11 +7,13 @@
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 ``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
@@ -21,6 +23,10 @@
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
diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h
index 12559dd..eebb130 100644
--- a/include/pybind11/pybind11.h
+++ b/include/pybind11/pybind11.h
@@ -3764,34 +3764,20 @@
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]);
+#if PY_VERSION_HEX >= 0x030D0000
+ auto builtins = reinterpret_steal<dict>(PyEval_GetFrameBuiltins());
+#else
+ auto builtins = reinterpret_borrow<dict>(PyEval_GetBuiltins());
+#endif
+ // The builtins dictionary may already be partially cleared during interpreter shutdown.
+ auto native_print = reinterpret_steal<object>(dict_getitemstringref(builtins.ptr(), "print"));
+ if (!native_print) {
+ return;
}
- 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<object>();
- } 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<bool>()) {
- file.attr("flush")();
+ auto result
+ = reinterpret_steal<object>(PyObject_Call(native_print.ptr(), args.ptr(), kwargs.ptr()));
+ if (!result) {
+ throw error_already_set();
}
}
PYBIND11_NAMESPACE_END(detail)
diff --git a/tests/test_pytypes.cpp b/tests/test_pytypes.cpp
index ff77940..b500eb7 100644
--- a/tests/test_pytypes.cpp
+++ b/tests/test_pytypes.cpp
@@ -625,25 +625,8 @@
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 9a80f1e..3c7b31e 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,73 @@
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"
+def test_print_delegates_to_current_builtin(monkeypatch):
+ calls = []
- 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 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_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_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
+
+ 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():