Skip to content

Commit

Permalink
Merge pull request #6758 from bluetech/outcome-exception-callable-2
Browse files Browse the repository at this point in the history
Use a hack to make typing of pytest.fail.Exception & co work
  • Loading branch information
bluetech authored Feb 19, 2020
2 parents 781a730 + 24dcc76 commit af2b0e1
Show file tree
Hide file tree
Showing 9 changed files with 86 additions and 70 deletions.
10 changes: 5 additions & 5 deletions src/_pytest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from _pytest.config import hookimpl
from _pytest.config import UsageError
from _pytest.fixtures import FixtureManager
from _pytest.outcomes import Exit
from _pytest.outcomes import exit
from _pytest.reports import CollectReport
from _pytest.runner import collect_one_node
from _pytest.runner import SetupState
Expand Down Expand Up @@ -195,10 +195,10 @@ def wrap_session(
raise
except Failed:
session.exitstatus = ExitCode.TESTS_FAILED
except (KeyboardInterrupt, Exit):
except (KeyboardInterrupt, exit.Exception):
excinfo = _pytest._code.ExceptionInfo.from_current()
exitstatus = ExitCode.INTERRUPTED # type: Union[int, ExitCode]
if isinstance(excinfo.value, Exit):
if isinstance(excinfo.value, exit.Exception):
if excinfo.value.returncode is not None:
exitstatus = excinfo.value.returncode
if initstate < 2:
Expand All @@ -212,7 +212,7 @@ def wrap_session(
excinfo = _pytest._code.ExceptionInfo.from_current()
try:
config.notify_exception(excinfo, config.option)
except Exit as exc:
except exit.Exception as exc:
if exc.returncode is not None:
session.exitstatus = exc.returncode
sys.stderr.write("{}: {}\n".format(type(exc).__name__, exc))
Expand All @@ -229,7 +229,7 @@ def wrap_session(
config.hook.pytest_sessionfinish(
session=session, exitstatus=session.exitstatus
)
except Exit as exc:
except exit.Exception as exc:
if exc.returncode is not None:
session.exitstatus = exc.returncode
sys.stderr.write("{}: {}\n".format(type(exc).__name__, exc))
Expand Down
3 changes: 2 additions & 1 deletion src/_pytest/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from _pytest.mark.structures import Mark
from _pytest.mark.structures import MarkDecorator
from _pytest.mark.structures import NodeKeywords
from _pytest.outcomes import fail
from _pytest.outcomes import Failed

if TYPE_CHECKING:
Expand Down Expand Up @@ -314,7 +315,7 @@ def _prunetraceback(self, excinfo):
def _repr_failure_py(
self, excinfo: ExceptionInfo[Union[Failed, FixtureLookupError]], style=None
) -> Union[str, ReprExceptionInfo, ExceptionChainRepr, FixtureLookupErrorRepr]:
if isinstance(excinfo.value, Failed):
if isinstance(excinfo.value, fail.Exception):
if not excinfo.value.pytrace:
return str(excinfo.value)
if isinstance(excinfo.value, FixtureLookupError):
Expand Down
53 changes: 37 additions & 16 deletions src/_pytest/outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,26 @@
"""
import sys
from typing import Any
from typing import Callable
from typing import cast
from typing import Optional
from typing import TypeVar

from packaging.version import Version

TYPE_CHECKING = False # avoid circular import through compat

if TYPE_CHECKING:
from typing import NoReturn
from typing import Type # noqa: F401 (Used in string type annotation.)
from typing_extensions import Protocol
else:
# typing.Protocol is only available starting from Python 3.8. It is also
# available from typing_extensions, but we don't want a runtime dependency
# on that. So use a dummy runtime implementation.
from typing import Generic

Protocol = Generic


class OutcomeException(BaseException):
Expand Down Expand Up @@ -73,9 +85,31 @@ def __init__(
super().__init__(msg)


# Elaborate hack to work around https://github.com/python/mypy/issues/2087.
# Ideally would just be `exit.Exception = Exit` etc.

_F = TypeVar("_F", bound=Callable)
_ET = TypeVar("_ET", bound="Type[BaseException]")


class _WithException(Protocol[_F, _ET]):
Exception = None # type: _ET
__call__ = None # type: _F


def _with_exception(exception_type: _ET) -> Callable[[_F], _WithException[_F, _ET]]:
def decorate(func: _F) -> _WithException[_F, _ET]:
func_with_exception = cast(_WithException[_F, _ET], func)
func_with_exception.Exception = exception_type
return func_with_exception

return decorate


# exposed helper methods


@_with_exception(Exit)
def exit(msg: str, returncode: Optional[int] = None) -> "NoReturn":
"""
Exit testing process.
Expand All @@ -87,10 +121,7 @@ def exit(msg: str, returncode: Optional[int] = None) -> "NoReturn":
raise Exit(msg, returncode)


# Ignore type because of https://github.com/python/mypy/issues/2087.
exit.Exception = Exit # type: ignore


@_with_exception(Skipped)
def skip(msg: str = "", *, allow_module_level: bool = False) -> "NoReturn":
"""
Skip an executing test with the given message.
Expand All @@ -114,10 +145,7 @@ def skip(msg: str = "", *, allow_module_level: bool = False) -> "NoReturn":
raise Skipped(msg=msg, allow_module_level=allow_module_level)


# Ignore type because of https://github.com/python/mypy/issues/2087.
skip.Exception = Skipped # type: ignore


@_with_exception(Failed)
def fail(msg: str = "", pytrace: bool = True) -> "NoReturn":
"""
Explicitly fail an executing test with the given message.
Expand All @@ -130,14 +158,11 @@ def fail(msg: str = "", pytrace: bool = True) -> "NoReturn":
raise Failed(msg=msg, pytrace=pytrace)


# Ignore type because of https://github.com/python/mypy/issues/2087.
fail.Exception = Failed # type: ignore


class XFailed(Failed):
""" raised from an explicit call to pytest.xfail() """


@_with_exception(XFailed)
def xfail(reason: str = "") -> "NoReturn":
"""
Imperatively xfail an executing test or setup functions with the given reason.
Expand All @@ -152,10 +177,6 @@ def xfail(reason: str = "") -> "NoReturn":
raise XFailed(reason)


# Ignore type because of https://github.com/python/mypy/issues/2087.
xfail.Exception = XFailed # type: ignore


def importorskip(
modname: str, minversion: Optional[str] = None, reason: Optional[str] = None
) -> Any:
Expand Down
3 changes: 1 addition & 2 deletions src/_pytest/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,7 @@ def from_item_and_call(cls, item, call) -> "TestReport":
if not isinstance(excinfo, ExceptionInfo):
outcome = "failed"
longrepr = excinfo
# Type ignored -- see comment where skip.Exception is defined.
elif excinfo.errisinstance(skip.Exception): # type: ignore
elif excinfo.errisinstance(skip.Exception):
outcome = "skipped"
r = excinfo._getreprcrash()
longrepr = (str(r.path), r.lineno, r.message)
Expand Down
2 changes: 2 additions & 0 deletions testing/python/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -904,6 +904,8 @@ def test_skip_simple(self):
pytest.skip("xxx")
assert excinfo.traceback[-1].frame.code.name == "skip"
assert excinfo.traceback[-1].ishidden()
assert excinfo.traceback[-2].frame.code.name == "test_skip_simple"
assert not excinfo.traceback[-2].ishidden()

def test_traceback_argsetup(self, testdir):
testdir.makeconftest(
Expand Down
16 changes: 8 additions & 8 deletions testing/python/metafunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import pytest
from _pytest import fixtures
from _pytest import python
from _pytest.outcomes import Failed
from _pytest.outcomes import fail
from _pytest.pytester import Testdir
from _pytest.python import _idval

Expand Down Expand Up @@ -99,7 +99,7 @@ def gen() -> Iterator[Union[int, None, Exc]]:
({"x": 2}, "2"),
]
with pytest.raises(
Failed,
fail.Exception,
match=(
r"In func: ids must be list of string/float/int/bool, found:"
r" Exc\(from_gen\) \(type: <class .*Exc'>\) at index 2"
Expand All @@ -113,7 +113,7 @@ def func(x):

metafunc = self.Metafunc(func)
with pytest.raises(
Failed,
fail.Exception,
match=r"parametrize\(\) call in func got an unexpected scope value 'doggy'",
):
metafunc.parametrize("x", [1], scope="doggy")
Expand All @@ -126,7 +126,7 @@ def func(request):

metafunc = self.Metafunc(func)
with pytest.raises(
Failed,
fail.Exception,
match=r"'request' is a reserved name and cannot be used in @pytest.mark.parametrize",
):
metafunc.parametrize("request", [1])
Expand Down Expand Up @@ -205,10 +205,10 @@ def func(x, y):

metafunc = self.Metafunc(func)

with pytest.raises(Failed):
with pytest.raises(fail.Exception):
metafunc.parametrize("x", [1, 2], ids=["basic"])

with pytest.raises(Failed):
with pytest.raises(fail.Exception):
metafunc.parametrize(
("x", "y"), [("abc", "def"), ("ghi", "jkl")], ids=["one"]
)
Expand Down Expand Up @@ -689,7 +689,7 @@ def func(x, y):

metafunc = self.Metafunc(func)
with pytest.raises(
Failed,
fail.Exception,
match="In func: expected Sequence or boolean for indirect, got dict",
):
metafunc.parametrize("x, y", [("a", "b")], indirect={}) # type: ignore[arg-type] # noqa: F821
Expand Down Expand Up @@ -730,7 +730,7 @@ def func(x, y):
pass

metafunc = self.Metafunc(func)
with pytest.raises(Failed):
with pytest.raises(fail.Exception):
metafunc.parametrize("x, y", [("a", "b")], indirect=["x", "z"])

def test_parametrize_uses_no_fixture_error_indirect_false(
Expand Down
11 changes: 5 additions & 6 deletions testing/test_pytester.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import pytest
from _pytest.config import ExitCode
from _pytest.config import PytestPluginManager
from _pytest.outcomes import Failed
from _pytest.pytester import CwdSnapshot
from _pytest.pytester import HookRecorder
from _pytest.pytester import LineMatcher
Expand Down Expand Up @@ -171,7 +170,7 @@ def test_hookrecorder_basic(holder) -> None:
call = rec.popcall("pytest_xyz")
assert call.arg == 123
assert call._name == "pytest_xyz"
pytest.raises(Failed, rec.popcall, "abc")
pytest.raises(pytest.fail.Exception, rec.popcall, "abc")
pm.hook.pytest_xyz_noarg()
call = rec.popcall("pytest_xyz_noarg")
assert call._name == "pytest_xyz_noarg"
Expand Down Expand Up @@ -482,7 +481,7 @@ def test_linematcher_with_nonlist() -> None:

def test_linematcher_match_failure() -> None:
lm = LineMatcher(["foo", "foo", "bar"])
with pytest.raises(Failed) as e:
with pytest.raises(pytest.fail.Exception) as e:
lm.fnmatch_lines(["foo", "f*", "baz"])
assert e.value.msg is not None
assert e.value.msg.splitlines() == [
Expand All @@ -495,7 +494,7 @@ def test_linematcher_match_failure() -> None:
]

lm = LineMatcher(["foo", "foo", "bar"])
with pytest.raises(Failed) as e:
with pytest.raises(pytest.fail.Exception) as e:
lm.re_match_lines(["foo", "^f.*", "baz"])
assert e.value.msg is not None
assert e.value.msg.splitlines() == [
Expand Down Expand Up @@ -550,7 +549,7 @@ def test_linematcher_no_matching(function) -> None:

# check the function twice to ensure we don't accumulate the internal buffer
for i in range(2):
with pytest.raises(Failed) as e:
with pytest.raises(pytest.fail.Exception) as e:
func = getattr(lm, function)
func(good_pattern)
obtained = str(e.value).splitlines()
Expand Down Expand Up @@ -580,7 +579,7 @@ def test_linematcher_no_matching(function) -> None:
def test_linematcher_no_matching_after_match() -> None:
lm = LineMatcher(["1", "2", "3"])
lm.fnmatch_lines(["1", "3"])
with pytest.raises(Failed) as e:
with pytest.raises(pytest.fail.Exception) as e:
lm.no_fnmatch_line("*")
assert str(e.value).splitlines() == ["fnmatch: '*'", " with: '1'"]

Expand Down
Loading

0 comments on commit af2b0e1

Please sign in to comment.