Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/advanced/pycpp/utilities.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 99 additions & 18 deletions include/pybind11/pybind11.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>(item.second);
} else if (key_is("end")) {
end = reinterpret_borrow<object>(item.second);
} else if (key_is("file")) {
file = reinterpret_borrow<object>(item.second);
} else if (key_is("flush")) {
flush = reinterpret_borrow<object>(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<object>();
} 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());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use PyObject instead of handles or py::object here?

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<object>(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<object>(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<bool>()) {
// 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")();
}
}
Expand Down
3 changes: 3 additions & 0 deletions tests/test_pytypes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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); });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hold on can this it just be bound as py::print? Or does the overloading system not like that?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pointed codex to this question. I then added this to the PR #6121 Description:

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.


m.def("print_failure", []() { py::print(42, UnregisteredType()); });

m.def("hash_function", [](py::object obj) { return py::hash(std::move(obj)); });
Expand Down
183 changes: 183 additions & 0 deletions tests/test_pytypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import contextlib
import sys
import types
from io import StringIO

import pytest

Expand Down Expand Up @@ -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):
Expand Down
29 changes: 29 additions & 0 deletions tests/test_with_catch/print_shutdown_probe.h
Original file line number Diff line number Diff line change
@@ -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 <pybind11/pybind11.h>

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<print_shutdown_state *>(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;
}
});
}
Loading
Loading