diff --git a/docs/advanced/pycpp/utilities.rst b/docs/advanced/pycpp/utilities.rst index af0f9cb2b0..13d0dd19e1 100644 --- a/docs/advanced/pycpp/utilities.rst +++ b/docs/advanced/pycpp/utilities.rst @@ -7,11 +7,13 @@ 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 ``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 @@ 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 diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h index 12559ddf3d..eebb130694 100644 --- a/include/pybind11/pybind11.h +++ b/include/pybind11/pybind11.h @@ -3764,34 +3764,20 @@ 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; - } +#if PY_VERSION_HEX >= 0x030D0000 + auto builtins = reinterpret_steal(PyEval_GetFrameBuiltins()); +#else + auto builtins = reinterpret_borrow(PyEval_GetBuiltins()); +#endif + // 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 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")(); + auto result + = reinterpret_steal(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 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..3c7b31e093 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 @@ 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_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():