Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/_pytask/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
32 changes: 32 additions & 0 deletions tests/test_capture.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import builtins
import contextlib
import io
import os
Expand Down Expand Up @@ -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 = """
Expand Down
21 changes: 21 additions & 0 deletions tests/test_debugging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down