diff --git a/changelog/12888.bugfix.rst b/changelog/12888.bugfix.rst new file mode 100644 index 00000000000..635e35a11ea --- /dev/null +++ b/changelog/12888.bugfix.rst @@ -0,0 +1 @@ +Fixed broken input when using Python 3.13+ and a ``libedit`` build of Python, such as on macOS or with uv-managed Python binaries from the ``python-build-standalone`` project. This could manifest e.g. by a broken prompt when using ``Pdb``, or seeing empty inputs with manual usage of ``input()`` and suspended capturing. diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 5c21590c937..8487af6a360 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -80,6 +80,20 @@ def _colorama_workaround() -> None: pass +def _readline_workaround() -> None: + """Ensure readline is imported early so it attaches to the correct stdio handles. + + This isn't a problem with the default GNU readline implementation, but in + some configurations, Python uses libedit instead (on macOS, and for prebuilt + binaries such as used by uv). + + In theory this is only needed if readline.backend == "libedit", but the + workaround consists of importing readline here, so we already worked around + the issue by the time we could check if we need to. + """ + import readline # noqa: F401 + + def _windowsconsoleio_workaround(stream: TextIO) -> None: """Workaround for Windows Unicode console handling. @@ -141,6 +155,7 @@ def pytest_load_initial_conftests(early_config: Config) -> Generator[None]: if ns.capture == "fd": _windowsconsoleio_workaround(sys.stdout) _colorama_workaround() + _readline_workaround() pluginmanager = early_config.pluginmanager capman = CaptureManager(ns.capture) pluginmanager.register(capman, "capturemanager") diff --git a/testing/test_capture.py b/testing/test_capture.py index 98986af6f1f..ac11938190b 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -3,6 +3,7 @@ from collections.abc import Generator import contextlib +import re import io from io import UnsupportedOperation import os @@ -1666,3 +1667,32 @@ def test_logging(): ) result.stdout.no_fnmatch_line("*Captured stderr call*") result.stdout.no_fnmatch_line("*during collection*") + + +def test_libedit_workaround(pytester: Pytester) -> None: + pytester.makeconftest(""" + import pytest + + + def pytest_terminal_summary(config): + capture = config.pluginmanager.getplugin("capturemanager") + capture.suspend_global_capture(in_=True) + + print("Enter 'hi'") + value = input() + print(f"value: {value!r}") + + capture.resume_global_capture() + """) + import readline + backend = getattr(readline, "backend", readline.__doc__) # added in Python 3.13 + print(f"Readline backend: {backend}") + + child = pytester.spawn_pytest("") + child.expect(r"Enter 'hi'") + child.sendline("hi") + rest = child.read().decode("utf8") + print(rest) + match = re.search(r"^value: '(.*)'\r?$", rest, re.MULTILINE) + assert match is not None + assert match.group(1) == "hi"