diff --git a/docs/advanced/pycpp/utilities.rst b/docs/advanced/pycpp/utilities.rst index af0f9cb2b0..5604030547 100644 --- a/docs/advanced/pycpp/utilities.rst +++ b/docs/advanced/pycpp/utilities.rst @@ -21,6 +21,11 @@ expected in Python: auto args = py::make_tuple("unpacked", true); py::print("->", *args, "end"_a="<-"); // -> unpacked True <- +As in Python, omitting ``file`` or passing ``"file"_a = py::none()`` uses the +current ``sys.stdout``. If ``sys.stdout`` is ``None``, :func:`py::print` returns +without writing. During normal interpreter operation, a missing ``sys.stdout`` +or an error from a custom stream is still reported normally. + .. _ostream_redirect: Capturing standard output from ostream diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h index 12559ddf3d..2ffd0414f1 100644 --- a/include/pybind11/pybind11.h +++ b/include/pybind11/pybind11.h @@ -3764,33 +3764,114 @@ 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]); + object sep; + object end; + object file; + object flush; + + for (auto item : kwargs) { + PyObject *key = item.first.ptr(); + auto key_is + = [key](const char *name) { return PyUnicode_CompareWithASCIIString(key, name) == 0; }; + + if (key_is("sep")) { + sep = reinterpret_borrow(item.second); + } else if (key_is("end")) { + end = reinterpret_borrow(item.second); + } else if (key_is("file")) { + file = reinterpret_borrow(item.second); + } else if (key_is("flush")) { + flush = reinterpret_borrow(item.second); + } else { + PyErr_Format(PyExc_TypeError, "'%U' is an invalid keyword argument for print()", key); + throw error_already_set(); + } } - 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 { + // As with Python's print(), an omitted file or file=None means sys.stdout. + if (!file || file.is_none()) { +#ifdef Py_GIL_DISABLED + // CPython's built-in print() gets a strong reference to the current interpreter's + // sys.stdout with the private _PySys_GetRequiredAttr(), which is unavailable to + // extension modules. The public PySys_GetObject() returns only a borrowed reference + // that another thread could invalidate, so import sys and use a strong-reference + // dictionary lookup instead. + object sys; try { - file = module_::import("sys").attr("stdout"); + sys = module_::import("sys"); } 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. */ + // Importing sys can fail while an interpreter is shutting down. + return; + } + PyObject *sys_dict = PyModule_GetDict(sys.ptr()); + if (sys_dict == nullptr) { + throw error_already_set(); + } + PyObject *stdout_obj = dict_getitemstringref(sys_dict, "stdout"); + if (stdout_obj == nullptr) { + set_error(PyExc_RuntimeError, "lost sys.stdout"); + throw error_already_set(); + } + file = reinterpret_steal(stdout_obj); +#else + // With the GIL held, no other thread can replace sys.stdout between the borrowed + // lookup and incrementing its reference count. PySys_GetObject() also reads the + // current interpreter's sys dictionary directly, matching CPython's built-in print() + // without consulting sys.modules. + PyObject *stdout_obj = PySys_GetObject("stdout"); + if (stdout_obj == nullptr) { + try { + module_::import("sys"); + } catch (const error_already_set &) { + // A missing sys module indicates interpreter shutdown. + return; + } + set_error(PyExc_RuntimeError, "lost sys.stdout"); + throw error_already_set(); + } + file = reinterpret_borrow(stdout_obj); +#endif + // sys.stdout may be None when stdout is not connected, for example in + // a Windows GUI application. In that case, print() is a no-op. + if (file.is_none()) { return; } } - auto write = file.attr("write"); - write(std::move(line)); - write(kwargs.contains("end") ? kwargs["end"] : str("\n")); + // As with Python's print(), an omitted keyword or None selects the default. + auto as_string_or_default = [](object &o, const char *name, const char *default_value) { + if (!o || o.is_none()) { + o = str(default_value); + } else if (!PyUnicode_Check(o.ptr())) { + PyErr_Format(PyExc_TypeError, + "%s must be None or a string, not %.200s", + name, + Py_TYPE(o.ptr())->tp_name); + throw error_already_set(); + } + }; + as_string_or_default(sep, "sep", " "); + as_string_or_default(end, "end", "\n"); + + auto write = [&file](handle text) { + if (PyFile_WriteObject(text.ptr(), file.ptr(), Py_PRINT_RAW) != 0) { + throw error_already_set(); + } + }; + + bool first = true; + for (handle arg : args) { + if (!first) { + write(sep); + } + first = false; + write(arg); + } + write(end); - if (kwargs.contains("flush") && kwargs["flush"].cast()) { + // Native interpreters differ in when they convert flush to bool. Evaluating it only + // after successful output preserves py::print's historical behavior. + if (flush && bool_(flush)) { file.attr("flush")(); } } diff --git a/tests/test_pytypes.cpp b/tests/test_pytypes.cpp index ff77940965..19cb7442b1 100644 --- a/tests/test_pytypes.cpp +++ b/tests/test_pytypes.cpp @@ -643,6 +643,9 @@ TEST_SUBMODULE(pytypes, m) { "{a} + {b} = {c}"_s.format("a"_a = "py::print", "b"_a = "str.format", "c"_a = "this")); }); + m.def("print_args", + [](const py::args &args, const py::kwargs &kwargs) { py::print(*args, **kwargs); }); + m.def("print_failure", []() { py::print(42, UnregisteredType()); }); 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..7f5951e3db 100644 --- a/tests/test_pytypes.py +++ b/tests/test_pytypes.py @@ -3,6 +3,7 @@ import contextlib import sys import types +from io import StringIO import pytest @@ -570,6 +571,188 @@ def test_print(capture): ) +def test_print_file_none_and_stdout(monkeypatch, capture): + with capture: + m.print_args("explicit file=None", file=None) + assert capture == "explicit file=None\n" + + class BadStr: + def __str__(self): + raise AssertionError("__str__ should not be called") + + monkeypatch.setattr(sys, "stdout", None) + m.print_args(BadStr()) + m.print_args(BadStr(), file=None) + + class FalseyStream(StringIO): + def __bool__(self): + return False + + output = FalseyStream() + m.print_args("explicit stream", file=output) + assert output.getvalue() == "explicit stream\n" + + +def test_print_missing_stdout(monkeypatch): + monkeypatch.delattr(sys, "stdout") + with pytest.raises(RuntimeError, match="^lost sys.stdout$"): + m.print_args("no stream") + + +@pytest.mark.skipif( + "env.PY_GIL_DISABLED", + reason="PySys_GetObject does not provide a thread-safe strong-reference API", +) +def test_print_uses_interpreter_stdout_if_sys_module_is_unavailable(monkeypatch): + output = StringIO() + with monkeypatch.context() as context: + context.setattr(sys, "stdout", output) + context.setitem(sys.modules, "sys", None) + m.print_args("interpreter stdout") + assert output.getvalue() == "interpreter stdout\n" + + +def test_print_none_separator_and_end(): + output = StringIO() + m.print_args("one", "two", sep=None, end=None, file=output) + assert output.getvalue() == "one two\n" + + +@pytest.mark.parametrize("keyword", ["sep", "end"]) +def test_print_rejects_non_string_separator_and_end(keyword): + output = StringIO() + with pytest.raises(TypeError, match=f"^{keyword} must be None or a string"): + m.print_args("text", file=output, **{keyword: object()}) + assert output.getvalue() == "" + + +def test_print_rejects_unknown_keyword(): + with pytest.raises( + TypeError, match="^'unknown' is an invalid keyword argument for print\\(\\)$" + ): + m.print_args("text", unknown=True) + + +@pytest.mark.parametrize("keyword", ["file\0suffix", "\ud800"]) +def test_print_rejects_unusual_unknown_keyword(keyword): + with pytest.raises(TypeError) as exc_info: + m.print_args("text", **{keyword: True}) + (message,) = exc_info.value.args + # On Windows, PyPy's PyErr_Format() does not preserve an unpaired surrogate + # passed through %U. The ordinary-key test above checks the complete message; + # here the TypeError and intact suffix are the portable safety properties. + assert message.endswith(" is an invalid keyword argument for print()") + + +def test_print_flush_uses_python_truthiness(): + class Stream(StringIO): + def __init__(self): + super().__init__() + self.flush_count = 0 + + def flush(self): + self.flush_count += 1 + + output = Stream() + m.print_args("not flushed", file=output, flush=[]) + m.print_args("flushed", file=output, flush=[1]) + assert output.getvalue() == "not flushed\nflushed\n" + assert output.flush_count == 1 + + +class PrintMarkerError(Exception): + pass + + +def test_print_flush_truthiness_error_after_output(): + class BadFlush: + def __bool__(self): + raise PrintMarkerError + + output = StringIO() + with pytest.raises(PrintMarkerError): + m.print_args("text", file=output, flush=BadFlush()) + assert output.getvalue() == "text\n" + + +def test_print_propagates_stream_errors(): + class BadWrite: + def write(self, value): + raise PrintMarkerError(value) + + with pytest.raises(PrintMarkerError, match="text"): + m.print_args("text", file=BadWrite()) + + class BadFlush(StringIO): + def flush(self): + raise PrintMarkerError("flush") + + output = BadFlush() + with pytest.raises(PrintMarkerError, match="flush"): + m.print_args("text", file=output, flush=True) + assert output.getvalue() == "text\n" + + +def pybind_print_trace(failure): + events = [] + + class Value: + def __init__(self, text): + self.text = text + + def __str__(self): + events.append(("str", self.text)) + if failure == f"str:{self.text}": + raise PrintMarkerError + return self.text + + class Stream: + def __init__(self): + self.write_count = 0 + + def write(self, value): + self.write_count += 1 + events.append(("write", value)) + if failure == f"write:{self.write_count}": + raise PrintMarkerError + + def flush(self): + events.append(("flush",)) + if failure == "flush": + raise PrintMarkerError + + try: + result = m.print_args( + Value("one"), + Value("two"), + sep="|", + end="!", + file=Stream(), + flush=True, + ) + except Exception as exc: + outcome = type(exc) + else: + outcome = ("return", result) + return events, outcome + + +def test_print_stream_protocol(): + complete_events = [ + ("str", "one"), + ("write", "one"), + ("write", "|"), + ("str", "two"), + ("write", "two"), + ("write", "!"), + ("flush",), + ] + assert pybind_print_trace(None) == (complete_events, ("return", None)) + assert pybind_print_trace("str:two") == (complete_events[:4], PrintMarkerError) + assert pybind_print_trace("write:2") == (complete_events[:3], PrintMarkerError) + assert pybind_print_trace("flush") == (complete_events, PrintMarkerError) + + def test_hash(): class Hashable: def __init__(self, value): diff --git a/tests/test_with_catch/print_shutdown_probe.h b/tests/test_with_catch/print_shutdown_probe.h new file mode 100644 index 0000000000..09c20d0c6c --- /dev/null +++ b/tests/test_with_catch/print_shutdown_probe.h @@ -0,0 +1,29 @@ +// Shared harness for the py::print-during-shutdown regression tests in +// test_interpreter.cpp and test_subinterpreter.cpp. + +#pragma once + +#include + +struct print_shutdown_state { + bool callback_ran = false; + bool stdout_was_none = false; + bool print_threw = false; +}; + +// Attach a capsule to sys whose destructor calls py::print during interpreter +// shutdown, recording what happened in `state`. +inline void install_print_shutdown_probe(print_shutdown_state &state) { + namespace py = pybind11; + py::module_::import("sys").attr("pybind11_print_on_shutdown") + = py::capsule(&state, [](void *payload) noexcept { + auto *state = static_cast(payload); + state->callback_ran = true; + state->stdout_was_none = PySys_GetObject("stdout") == Py_None; + try { + py::print("print during interpreter shutdown"); + } catch (...) { + state->print_threw = true; + } + }); +} diff --git a/tests/test_with_catch/test_interpreter.cpp b/tests/test_with_catch/test_interpreter.cpp index e39f51c274..1cc89b028a 100644 --- a/tests/test_with_catch/test_interpreter.cpp +++ b/tests/test_with_catch/test_interpreter.cpp @@ -7,6 +7,7 @@ PYBIND11_WARNING_DISABLE_MSVC(4996) #include "catch_skip.h" +#include "print_shutdown_probe.h" #include #include @@ -352,6 +353,18 @@ TEST_CASE("Restart the interpreter") { REQUIRE(py_widget.attr("the_message").cast() == "Hello after restart"); } +TEST_CASE("py::print is safe during interpreter shutdown") { + print_shutdown_state state; + install_print_shutdown_probe(state); + + py::finalize_interpreter(); + py::initialize_interpreter(); + + REQUIRE(state.callback_ran); + REQUIRE(state.stdout_was_none); + REQUIRE_FALSE(state.print_threw); +} + TEST_CASE("Enum module survives restart") { // Added in PR #6015 // Regression test for gh-5976: py::enum_ uses def_property_static, which // calls process_attributes::init after initialize_generic's strdup loop, diff --git a/tests/test_with_catch/test_subinterpreter.cpp b/tests/test_with_catch/test_subinterpreter.cpp index 3af100f2a9..de72123c87 100644 --- a/tests/test_with_catch/test_subinterpreter.cpp +++ b/tests/test_with_catch/test_subinterpreter.cpp @@ -8,6 +8,7 @@ PYBIND11_WARNING_DISABLE_MSVC(4996) # include "catch_skip.h" +# include "print_shutdown_probe.h" # include # include @@ -117,6 +118,18 @@ TEST_CASE("Single Subinterpreter") { unsafe_reset_internals_for_single_interpreter(); } +TEST_CASE("py::print is safe during subinterpreter shutdown") { + print_shutdown_state state; + { + py::scoped_subinterpreter subinterpreter; + install_print_shutdown_probe(state); + } + + REQUIRE(state.callback_ran); + REQUIRE(state.stdout_was_none); + REQUIRE_FALSE(state.print_threw); +} + # if PY_VERSION_HEX >= 0x030D0000 TEST_CASE("Move Subinterpreter") { std::unique_ptr sub(new py::subinterpreter(py::subinterpreter::create()));