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():