diff --git a/docs/changes.rst b/docs/changes.rst index bcea0910..45a666f2 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -28,6 +28,9 @@ all releases are available on `PyPI `_ and - :gh:`102` adds an example if a parametrization provides not the number of arguments specified in the signature. - :gh:`105` simplifies the logging of the tasks. +- :gh:`107` adds and new hook ``pytask_unconfigure`` which makes pytask return + :func:`pdb.set_trace` at the end of a session which allows to use ``breakpoint()`` + inside test functions using pytask. 0.0.14 - 2021-03-23 diff --git a/docs/reference_guides/hookspecs.rst b/docs/reference_guides/hookspecs.rst index b1643e13..4013432d 100644 --- a/docs/reference_guides/hookspecs.rst +++ b/docs/reference_guides/hookspecs.rst @@ -52,6 +52,10 @@ together. :noindex: +.. autofunction:: pytask_unconfigure + :noindex: + + Collection ---------- diff --git a/src/_pytask/build.py b/src/_pytask/build.py index b0bf8a6c..98407770 100644 --- a/src/_pytask/build.py +++ b/src/_pytask/build.py @@ -74,6 +74,8 @@ def main(config_from_cli): console.print_exception() session.exit_code = ExitCode.FAILED + session.hook.pytask_unconfigure(session=session) + return session diff --git a/src/_pytask/capture.py b/src/_pytask/capture.py index d5adb6c2..5f5bc1e5 100644 --- a/src/_pytask/capture.py +++ b/src/_pytask/capture.py @@ -783,40 +783,40 @@ class CaptureManager: def __init__(self, method: "_CaptureMethod") -> None: self._method = method - self._global_capturing: Optional[MultiCapture[str]] = None + self._capturing: Optional[MultiCapture[str]] = None def __repr__(self) -> str: - return ("").format( - self._method, self._global_capturing + return ("").format( + self._method, self._capturing ) def is_capturing(self) -> Union[str, bool]: return self._method != "no" def start_capturing(self) -> None: - assert self._global_capturing is None - self._global_capturing = _get_multicapture(self._method) - self._global_capturing.start_capturing() + assert self._capturing is None + self._capturing = _get_multicapture(self._method) + self._capturing.start_capturing() def stop_capturing(self) -> None: - if self._global_capturing is not None: - self._global_capturing.pop_outerr_to_orig() - self._global_capturing.stop_capturing() - self._global_capturing = None + if self._capturing is not None: + self._capturing.pop_outerr_to_orig() + self._capturing.stop_capturing() + self._capturing = None def resume(self) -> None: # During teardown of the python process, and on rare occasions, capture # attributes can be `None` while trying to resume global capture. - if self._global_capturing is not None: - self._global_capturing.resume_capturing() + if self._capturing is not None: + self._capturing.resume_capturing() def suspend(self, in_: bool = False) -> None: - if self._global_capturing is not None: - self._global_capturing.suspend_capturing(in_=in_) + if self._capturing is not None: + self._capturing.suspend_capturing(in_=in_) def read(self) -> CaptureResult[str]: - assert self._global_capturing is not None - return self._global_capturing.readouterr() + assert self._capturing is not None + return self._capturing.readouterr() # Helper context managers diff --git a/src/_pytask/debugging.py b/src/_pytask/debugging.py index 708f8ba4..a55f2021 100644 --- a/src/_pytask/debugging.py +++ b/src/_pytask/debugging.py @@ -119,6 +119,17 @@ def pytask_post_parse(config): PytaskPDB._config = config +@hookimpl +def pytask_unconfigure(): + """Return the resources. + + If the :func:`pdb.set_trace` function would not be returned, using breakpoints in + test functions with pytask would fail. + + """ + pdb.set_trace, _, _ = PytaskPDB._saved.pop() + + class PytaskPDB: """Pseudo PDB that defers to the real pdb.""" @@ -196,18 +207,11 @@ def do_continue(self, arg): capman = self._pytask_capman capturing = PytaskPDB._is_capturing(capman) if capturing: - if capturing == "global": - console.rule( - "PDB continue (IO-capturing resumed)", - characters=">", - style=None, - ) - else: - console.rule( - f"PDB continue (IO-capturing resumed for {capturing})", - characters=">", - style=None, - ) + console.rule( + "PDB continue (IO-capturing resumed)", + characters=">", + style=None, + ) assert capman is not None capman.resume() else: @@ -282,18 +286,12 @@ def _init_pdb(cls, method, *args, **kwargs): console.rule(header, characters=">", style=None) else: capturing = cls._is_capturing(capman) - if capturing == "global": + if capturing: console.rule( f"PDB {method} (IO-capturing turned off)", characters=">", style=None, ) - elif capturing: - console.rule( - f"PDB {method} (IO-capturing turned off for {capturing})", - characters=">", - style=None, - ) else: console.rule(f"PDB {method}", characters=">", style=None) diff --git a/src/_pytask/hookspecs.py b/src/_pytask/hookspecs.py index 1b778b30..c97186eb 100644 --- a/src/_pytask/hookspecs.py +++ b/src/_pytask/hookspecs.py @@ -82,6 +82,16 @@ def pytask_post_parse(config: dict) -> None: """ +@hookspec +def pytask_unconfigure(session): + """Unconfigure a pytask session before the process is exited. + + The hook allows to return resources previously borrowed like :func:`pdb.set_trace` + by :class:`_pytask.debugging.PytaskPDB` and do other stuff at the end of a session. + + """ + + # Hooks for the collection. diff --git a/tests/test_debugging.py b/tests/test_debugging.py index 9e1c7747..1bcb4b0c 100644 --- a/tests/test_debugging.py +++ b/tests/test_debugging.py @@ -1,4 +1,5 @@ import os +import re import sys import textwrap from contextlib import ExitStack as does_not_raise # noqa: N813 @@ -43,7 +44,7 @@ def _flush(child): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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(tmp_path): source = """ @@ -63,7 +64,7 @@ def task_dummy(): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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): source = """ @@ -87,7 +88,7 @@ def task_dummy(depends_on): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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_trace(tmp_path): source = """ @@ -105,7 +106,7 @@ def task_dummy(): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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_trace_w_kwargs(tmp_path): source = """ @@ -128,7 +129,7 @@ def task_dummy(depends_on): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@pytest.mark.skipif(not IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") @pytest.mark.skipif(sys.platform == "win32", reason="pexpect cannot spawn on Windows.") @pytest.mark.skipif(sys.version_info < (3, 7), reason="breakpoint is Python 3.7+ only.") def test_breakpoint(tmp_path): @@ -148,7 +149,7 @@ def task_dummy(): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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_set_trace(tmp_path): source = """ @@ -168,7 +169,7 @@ def task_dummy(): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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_interaction_capturing_simple(tmp_path): source = """ @@ -196,7 +197,7 @@ def task_1(): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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_set_trace_kwargs(tmp_path): source = """ @@ -223,7 +224,7 @@ def task_1(): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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_set_trace_interception(tmp_path): source = """ @@ -240,13 +241,15 @@ def task_1(): rest = child.read().decode("utf8") assert "failed" in rest assert "reading from stdin while output" not in rest - assert "BdbQuit" not in rest + # Commented out since the traceback is not hidden. Exiting the debugger should end + # the session without traceback. + # assert "BdbQuit" not in rest assert "Quitting debugger" in rest _flush(child) @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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_set_trace_capturing_afterwards(tmp_path): source = """ @@ -269,7 +272,7 @@ def task_2(): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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_interaction_capturing_twice(tmp_path): source = """ @@ -287,19 +290,19 @@ def task_1(): tmp_path.joinpath("task_dummy.py").write_text(textwrap.dedent(source)) child = pexpect.spawn(f"pytask {tmp_path.as_posix()}") - child.expect(r"PDB set_trace \(IO-capturing turned off\)") + child.expect(["PDB", "set_trace", r"\(IO-capturing", "turned", r"off\)"]) child.expect("task_1") child.expect("x = 3") child.expect("Pdb") child.sendline("c") - child.expect(r"PDB continue \(IO-capturing resumed\)") - child.expect(r"PDB set_trace \(IO-capturing turned off\)") + child.expect(["PDB", "continue", r"\(IO-capturing", r"resumed\)"]) + child.expect(["PDB", "set_trace", r"\(IO-capturing", "turned", r"off\)"]) child.expect("x = 4") child.expect("Pdb") child.sendline("c") - child.expect(r"PDB continue \(IO-capturing resumed\)") + child.expect(["PDB", "continue", r"\(IO-capturing", r"resumed\)"]) child.expect("task_1 failed") - rest = child.read().decode("utf8") + rest = _escape_ansi(child.read().decode("utf8")) assert "Captured stdout during call" in rest assert "hello17" in rest # out is captured assert "hello18" in rest # out is captured @@ -308,7 +311,7 @@ def task_1(): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@pytest.mark.skipif(not IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") @pytest.mark.skipif(sys.platform == "win32", reason="pexpect cannot spawn on Windows.") @pytest.mark.skipif(sys.version_info < (3, 7), reason="Importing fails for <3.7.") def test_pdb_with_injected_do_debug(tmp_path): @@ -359,7 +362,7 @@ def task_1(): env={"PATH": os.environ["PATH"], "PYTHONPATH": f"{tmp_path.as_posix()}"}, ) - child.expect(r"PDB set_trace \(IO-capturing turned off\)") + child.expect(["PDB", "set_trace", r"\(IO-capturing", "turned", r"off\)"]) child.expect(r"\n\(Pdb") child.sendline("debug foo()") child.expect("ENTERING RECURSIVE DEBUGGER") @@ -378,8 +381,8 @@ def task_1(): assert b"Quitting debugger" not in child.before child.sendline("c") - child.expect(r"PDB continue \(IO-capturing resumed\)") - rest = child.read().decode("utf8") + child.expect(["PDB", "continue", r"\(IO-capturing", r"resumed\)"]) + rest = _escape_ansi(child.read().decode("utf8")) assert "hello17" in rest # out is captured assert "hello18" in rest # out is captured assert "1 failed" in rest @@ -388,7 +391,7 @@ def task_1(): @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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_without_capture(tmp_path): source = """ @@ -403,14 +406,14 @@ def task_1(): child.expect("Pdb") child.sendline("c") child.expect(r"PDB continue") - child.expect("1 succeeded") + child.expect(["1", "succeeded"]) _flush(child) @pytest.mark.end_to_end -@pytest.mark.skipif(IS_PEXPECT_INSTALLED, reason="pexpect is not installed.") +@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_used_outside_test(tmp_path): +def test_pdb_used_outside_task(tmp_path): source = """ import pdb pdb.set_trace() @@ -445,3 +448,31 @@ def helper(): assert " locals " in captured assert "a = 1" in captured assert "b = 2" in captured + + +@pytest.mark.end_to_end +@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_set_trace_is_returned_after_pytask_finishes(tmp_path): + """Motivates unconfiguring of pdb.set_trace.""" + source = f""" + import pytask + + def test_function(): + pytask.main({{"paths": "{tmp_path.as_posix()}"}}) + breakpoint() + """ + tmp_path.joinpath("test_dummy.py").write_text(textwrap.dedent(source)) + + child = pexpect.spawn(f"pytest {tmp_path.as_posix()}") + child.expect("breakpoint()") + child.sendline("c") + rest = child.read().decode("utf8") + assert "1 passed" in rest + _flush(child) + + +def _escape_ansi(line): + """Escape ANSI sequences produced by rich.""" + ansi_escape = re.compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]") + return ansi_escape.sub("", line)