diff --git a/exec_helpers/_helpers.py b/exec_helpers/_helpers.py index 367ae07..d34bed4 100644 --- a/exec_helpers/_helpers.py +++ b/exec_helpers/_helpers.py @@ -7,11 +7,38 @@ import shlex import typing +from exec_helpers import exceptions + if typing.TYPE_CHECKING: from collections.abc import Iterable from collections.abc import Mapping +class _ExceptionInfo(typing.Protocol): + """The part of ``pytest.ExceptionInfo`` that :func:`hide_traceback` reads.""" + + @property + def value(self) -> BaseException: + """Get the exception being reported.""" + + +def hide_traceback(excinfo: _ExceptionInfo | None) -> bool: + """Tell pytest whether to leave a frame of this package out of a failure report. + + A module sets its ``__tracebackhide__`` global to this function, and pytest calls it for every frame of that module. + An error this package raises on purpose - a timeout, an unexpected exit code - already names the command + and carries its output, so the report shows the caller's line and the message. + Any other exception keeps the frames, because they show where it was raised. + + :param excinfo: The exception pytest is reporting, or ``None`` when it filters frames without one. + :returns: Whether the frame is left out of the report. + + >>> hide_traceback(None) + False + """ + return excinfo is not None and isinstance(excinfo.value, exceptions.ExecHelperError) + + def string_bytes_bytearray_as_bytes(src: str | bytes | bytearray) -> bytes: """Get byte string from string/bytes/bytearray union. diff --git a/exec_helpers/_ssh_base.py b/exec_helpers/_ssh_base.py index 90fe14b..f01b34e 100644 --- a/exec_helpers/_ssh_base.py +++ b/exec_helpers/_ssh_base.py @@ -65,6 +65,8 @@ __all__ = ("SSHClientBase", "SshExecuteAsyncResult", "SupportPathT") +__tracebackhide__: typing.Final = _helpers.hide_traceback + KeepAlivePeriodT = int | bool SupportPathT = str | pathlib.PurePath diff --git a/exec_helpers/api.py b/exec_helpers/api.py index 3502608..74df4d0 100644 --- a/exec_helpers/api.py +++ b/exec_helpers/api.py @@ -58,6 +58,8 @@ "OptionalTimeoutT", ) +__tracebackhide__: typing.Final = _helpers.hide_traceback + CommandT = str | Iterable[str] LogMaskReT = str | re.Pattern[str] | None ErrorInfoT = str | None diff --git a/exec_helpers/async_api/api.py b/exec_helpers/async_api/api.py index 3f2ad36..fa61c9a 100644 --- a/exec_helpers/async_api/api.py +++ b/exec_helpers/async_api/api.py @@ -63,6 +63,8 @@ "OptionalTimeoutT", ) +__tracebackhide__: typing.Final = _helpers.hide_traceback + class ExecuteContext(contextlib.AbstractAsyncContextManager[api.ExecuteAsyncResult], abc.ABC): """Execute context manager.""" diff --git a/exec_helpers/async_api/subprocess.py b/exec_helpers/async_api/subprocess.py index cbb264c..d88fc3a 100644 --- a/exec_helpers/async_api/subprocess.py +++ b/exec_helpers/async_api/subprocess.py @@ -57,6 +57,8 @@ __all__ = ("Subprocess", "SubprocessExecuteAsyncResult") +__tracebackhide__: typing.Final = _helpers.hide_traceback + # Seconds to wait for the streams to reach the end after the process exit. _READERS_DRAIN_TIMEOUT: typing.Final = 1.0 diff --git a/exec_helpers/exec_result.py b/exec_helpers/exec_result.py index ddac9c8..3dd6c9e 100644 --- a/exec_helpers/exec_result.py +++ b/exec_helpers/exec_result.py @@ -30,6 +30,8 @@ from exec_helpers import exceptions from exec_helpers import proc_enums +from . import _helpers + try: # noinspection PyPackageRequirements import yaml @@ -60,6 +62,8 @@ __all__ = ("ExecResult", "OptionalStdinT") +__tracebackhide__: typing.Final = _helpers.hide_traceback + LOGGER: logging.Logger = logging.getLogger(__name__) OptionalStdinT = bytes | str | bytearray | None diff --git a/exec_helpers/subprocess.py b/exec_helpers/subprocess.py index dfd8001..56b951e 100644 --- a/exec_helpers/subprocess.py +++ b/exec_helpers/subprocess.py @@ -54,6 +54,8 @@ __all__ = ("CwdT", "EnvT", "Subprocess", "SubprocessExecuteAsyncResult") +__tracebackhide__: typing.Final = _helpers.hide_traceback + EnvT = Mapping[str, str] | None CwdT = str | bytes | pathlib.Path | None diff --git a/test/test_subprocess_special.py b/test/test_subprocess_special.py index 5ce4b56..8140101 100644 --- a/test/test_subprocess_special.py +++ b/test/test_subprocess_special.py @@ -16,6 +16,7 @@ from __future__ import annotations +import pathlib import subprocess import typing from unittest import mock @@ -32,6 +33,8 @@ from subprocess_cases import default_timeout timeout_expired = subprocess.TimeoutExpired(command, default_timeout) +# Frames under this directory are what ``__tracebackhide__`` leaves out of a pytest report. +PACKAGE = pathlib.Path(exec_helpers.__file__).parent @pytest.mark.parametrize(SPECIAL_ARGNAMES, SPECIAL_CASES) @@ -109,8 +112,22 @@ def test_timeout(mocker, fake_popen, poll, expected_exc) -> None: kill_tree = mocker.patch("exec_helpers._subprocess_helpers.kill_proc_tree") proc = fake_popen(wait=(timeout_expired,), poll=(poll,)) - with pytest.raises(expected_exc): + with pytest.raises(expected_exc) as exc_info: exec_helpers.Subprocess().execute(command) kill_tree.assert_called_once_with(proc.pid) proc.poll.assert_called_once_with() + assert not [ + entry for entry in exc_info.traceback.filter(exc_info) if pathlib.Path(entry.path).is_relative_to(PACKAGE) + ] + + +def test_unexpected_error_keeps_traceback(mocker, fake_popen) -> None: + """An exception the package does not raise on purpose keeps the package frames in a pytest report.""" + mocker.patch("exec_helpers._subprocess_helpers.kill_proc_tree", side_effect=RuntimeError) + fake_popen(wait=(timeout_expired,)) + + with pytest.raises(RuntimeError) as exc_info: + exec_helpers.Subprocess().execute(command) + + assert [entry for entry in exc_info.traceback.filter(exc_info) if pathlib.Path(entry.path).is_relative_to(PACKAGE)]