diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb8db42..875b0509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and ## Unreleased +- [#969](https://github.com/pytask-dev/pytask/pull/969) initializes readline before + output capture so libedit-backed interactive prompts and debuggers remain responsive. - [#971](https://github.com/pytask-dev/pytask/pull/971) restores help text for PDB commands wrapped by pytask. - [#970](https://github.com/pytask-dev/pytask/pull/970) prevents tee-sys capture from diff --git a/src/_pytask/capture.py b/src/_pytask/capture.py index 291c1f50..8fe985ce 100644 --- a/src/_pytask/capture.py +++ b/src/_pytask/capture.py @@ -99,9 +99,21 @@ def pytask_parse_config(config: dict[str, Any]) -> None: config["show_capture"] = convert_to_enum(config["show_capture"], ShowCapture) +def _readline_workaround() -> None: + """Import readline before it can attach to redirected standard streams. + + GNU readline is not affected, but libedit can retain the file descriptors that are + active when it is first imported. Importing it before capture starts keeps + interactive input and debugging responsive on affected Python installations. + """ + with contextlib.suppress(ImportError): + import readline # noqa: F401, PLC0415 + + @hookimpl def pytask_post_parse(config: dict[str, Any]) -> None: """Initialize the CaptureManager.""" + _readline_workaround() pluginmanager = config["pm"] capman = CaptureManager(config["capture"]) pluginmanager.register(capman, "capturemanager") diff --git a/tests/test_capture.py b/tests/test_capture.py index e4828f97..e2a6d1f9 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -1,5 +1,6 @@ from __future__ import annotations +import builtins import contextlib import io import os @@ -31,6 +32,37 @@ from collections.abc import Generator +def test_readline_workaround_ignores_missing_module(monkeypatch): + imported_modules = [] + original_import = builtins.__import__ + + def import_or_raise(name, *args, **kwargs): + imported_modules.append(name) + if name == "readline": + raise ImportError + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_or_raise) + + capture._readline_workaround() + + assert imported_modules == ["readline"] + + +def test_readline_workaround_runs_before_capture_starts(monkeypatch): + events = [] + capman = Mock() + capman.start_capturing.side_effect = lambda: events.append("capture") + monkeypatch.setattr( + capture, "_readline_workaround", lambda: events.append("readline") + ) + monkeypatch.setattr(capture, "CaptureManager", lambda _method: capman) + + capture.pytask_post_parse({"pm": Mock(), "capture": CaptureMethod.FD}) + + assert events == ["readline", "capture"] + + @pytest.mark.parametrize("show_capture", ["s", "no", "stdout", "stderr", "log", "all"]) def test_show_capture(tmp_path, runner, show_capture): source = """ diff --git a/tests/test_debugging.py b/tests/test_debugging.py index ecfda96e..494fc15f 100644 --- a/tests/test_debugging.py +++ b/tests/test_debugging.py @@ -114,6 +114,27 @@ def import_module(name): assert issubclass(wrapped, CustomPdb) +@pytest.mark.skipif(not IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@pytest.mark.skipif(sys.platform == "win32", reason="pexpect cannot spawn on Windows.") +def test_pdb_after_readline_is_imported_during_capture(tmp_path): + source = """ + from pathlib import Path + + def task_example(): + import readline + + breakpoint() + Path(__file__).with_name("continued.txt").touch() + """ + tmp_path.joinpath("task_module.py").write_text(textwrap.dedent(source)) + + child = pexpect.spawn(f"pytask {tmp_path.as_posix()}") + child.expect("Pdb") + child.sendline("continue") + _flush(child) + assert tmp_path.joinpath("continued.txt").exists() + + @pytest.mark.skipif(not IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") @pytest.mark.skipif(sys.platform == "win32", reason="pexpect cannot spawn on Windows.") def test_post_mortem_on_error_w_kwargs(tmp_path):