Skip to content

Commit eced426

Browse files
authored
use callable protocols for pytest.skip/exit/fail/xfail instead of _WithException wrapper with __call__ attribute (#13445)
This is a more canonical way of typing generic callbacks/decorators (see https://mypy.readthedocs.io/en/stable/protocols.html#callback-protocols) This helps with potential issues/ambiguity with __call__ semanics in type checkers -- according to python spec, __call__ needs to be present on the class, rather than as an instance attribute to be considered callable. This worked in mypy because it didn't handle the spec 100% correctly (in this case this has no negative consequences) However, ty type checker is stricter/more correct about it and every pytest.skip usage results in: ``` error[call-non-callable]: Object of type _WithException[Unknown, <class 'Skipped'>] is not callable ``` For more context, see: [ty] Understand classes that inherit from subscripted Protocol[] as generic astral-sh/ruff#17832 (comment) https://discuss.python.org/t/when-should-we-assume-callable-types-are-method-descriptors/92938 Testing: Tested with running mypy against the following snippet: import pytest reveal_type(pytest.skip) reveal_type(pytest.skip.Exception) reveal_type(pytest.skip(reason="whatever")) Before the change: ``` $ mypy test_pytest_skip.py test_pytest_skip.py:2: note: Revealed type is "_pytest.outcomes._WithException[def (reason: builtins.str =, *, allow_module_level: builtins.bool =) -> Never, def (msg: Union[builtins.str, None] =, pytrace: builtins.bool =, allow_module_level: builtins.bool =, *, _use_item_location: builtins.bool =) -> _pytest.outcomes.Skipped]" test_pytest_skip.py:3: note: Revealed type is "def (msg: Union[builtins.str, None] =, pytrace: builtins.bool =, allow_module_level: builtins.bool =, *, _use_item_location: builtins.bool =) -> _pytest.outcomes.Skipped" test_pytest_skip.py:4: note: Revealed type is "Never" ``` After the change: ``` $ mypy test_pytest_skip.py test_pytest_skip.py:2: note: Revealed type is "_pytest.outcomes._Skip" test_pytest_skip.py:3: note: Revealed type is "type[_pytest.outcomes.Skipped]" test_pytest_skip.py:4: note: Revealed type is "Never" ``` ty type checker is also inferring the same types correctly now. All types are matching and propagated correctly (most importantly, Never as a result of pytest.skip(...) call).
1 parent e6f24ed commit eced426

File tree

4 files changed

+47
-53
lines changed

4 files changed

+47
-53
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ Deysha Rivera
134134
Dheeraj C K
135135
Dhiren Serai
136136
Diego Russo
137+
Dima Gerasimov
137138
Dmitry Dygalo
138139
Dmitry Pribysh
139140
Dominic Mortlock

changelog/13445.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Made the type annotations of :func:`pytest.skip` and friends more spec-complaint to have them work across more type checkers.

src/_pytest/outcomes.py

Lines changed: 43 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,10 @@
33

44
from __future__ import annotations
55

6-
from collections.abc import Callable
76
import sys
87
from typing import Any
9-
from typing import cast
8+
from typing import ClassVar
109
from typing import NoReturn
11-
from typing import Protocol
12-
from typing import TypeVar
1310

1411
from .warning_types import PytestDeprecationWarning
1512

@@ -77,57 +74,36 @@ def __init__(
7774
super().__init__(msg)
7875

7976

80-
# We need a callable protocol to add attributes, for discussion see
81-
# https://github.com/python/mypy/issues/2087.
82-
83-
_F = TypeVar("_F", bound=Callable[..., object])
84-
_ET = TypeVar("_ET", bound=type[BaseException])
85-
86-
87-
class _WithException(Protocol[_F, _ET]):
88-
Exception: _ET
89-
__call__: _F
90-
91-
92-
def _with_exception(exception_type: _ET) -> Callable[[_F], _WithException[_F, _ET]]:
93-
def decorate(func: _F) -> _WithException[_F, _ET]:
94-
func_with_exception = cast(_WithException[_F, _ET], func)
95-
func_with_exception.Exception = exception_type
96-
return func_with_exception
97-
98-
return decorate
99-
100-
101-
# Exposed helper methods.
77+
class XFailed(Failed):
78+
"""Raised from an explicit call to pytest.xfail()."""
10279

10380

104-
@_with_exception(Exit)
105-
def exit(
106-
reason: str = "",
107-
returncode: int | None = None,
108-
) -> NoReturn:
81+
class _Exit:
10982
"""Exit testing process.
11083
11184
:param reason:
11285
The message to show as the reason for exiting pytest. reason has a default value
11386
only because `msg` is deprecated.
11487
11588
:param returncode:
116-
Return code to be used when exiting pytest. None means the same as ``0`` (no error), same as :func:`sys.exit`.
89+
Return code to be used when exiting pytest. None means the same as ``0`` (no error),
90+
same as :func:`sys.exit`.
11791
11892
:raises pytest.exit.Exception:
11993
The exception that is raised.
12094
"""
121-
__tracebackhide__ = True
122-
raise Exit(reason, returncode)
12395

96+
Exception: ClassVar[type[Exit]] = Exit
12497

125-
@_with_exception(Skipped)
126-
def skip(
127-
reason: str = "",
128-
*,
129-
allow_module_level: bool = False,
130-
) -> NoReturn:
98+
def __call__(self, reason: str = "", returncode: int | None = None) -> NoReturn:
99+
__tracebackhide__ = True
100+
raise Exit(msg=reason, returncode=returncode)
101+
102+
103+
exit: _Exit = _Exit()
104+
105+
106+
class _Skip:
131107
"""Skip an executing test with the given message.
132108
133109
This function should be called only during testing (setup, call or teardown) or
@@ -155,12 +131,18 @@ def skip(
155131
Similarly, use the ``# doctest: +SKIP`` directive (see :py:data:`doctest.SKIP`)
156132
to skip a doctest statically.
157133
"""
158-
__tracebackhide__ = True
159-
raise Skipped(msg=reason, allow_module_level=allow_module_level)
160134

135+
Exception: ClassVar[type[Skipped]] = Skipped
136+
137+
def __call__(self, reason: str = "", allow_module_level: bool = False) -> NoReturn:
138+
__tracebackhide__ = True
139+
raise Skipped(msg=reason, allow_module_level=allow_module_level)
140+
141+
142+
skip: _Skip = _Skip()
161143

162-
@_with_exception(Failed)
163-
def fail(reason: str = "", pytrace: bool = True) -> NoReturn:
144+
145+
class _Fail:
164146
"""Explicitly fail an executing test with the given message.
165147
166148
:param reason:
@@ -173,16 +155,18 @@ def fail(reason: str = "", pytrace: bool = True) -> NoReturn:
173155
:raises pytest.fail.Exception:
174156
The exception that is raised.
175157
"""
176-
__tracebackhide__ = True
177-
raise Failed(msg=reason, pytrace=pytrace)
178158

159+
Exception: ClassVar[type[Failed]] = Failed
179160

180-
class XFailed(Failed):
181-
"""Raised from an explicit call to pytest.xfail()."""
161+
def __call__(self, reason: str = "", pytrace: bool = True) -> NoReturn:
162+
__tracebackhide__ = True
163+
raise Failed(msg=reason, pytrace=pytrace)
182164

183165

184-
@_with_exception(XFailed)
185-
def xfail(reason: str = "") -> NoReturn:
166+
fail: _Fail = _Fail()
167+
168+
169+
class _XFail:
186170
"""Imperatively xfail an executing test or setup function with the given reason.
187171
188172
This function should be called only during testing (setup, call or teardown).
@@ -201,8 +185,15 @@ def xfail(reason: str = "") -> NoReturn:
201185
:raises pytest.xfail.Exception:
202186
The exception that is raised.
203187
"""
204-
__tracebackhide__ = True
205-
raise XFailed(reason)
188+
189+
Exception: ClassVar[type[XFailed]] = XFailed
190+
191+
def __call__(self, reason: str = "") -> NoReturn:
192+
__tracebackhide__ = True
193+
raise XFailed(msg=reason)
194+
195+
196+
xfail: _XFail = _XFail()
206197

207198

208199
def importorskip(

testing/python/collect.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1075,7 +1075,8 @@ class TestTracebackCutting:
10751075
def test_skip_simple(self):
10761076
with pytest.raises(pytest.skip.Exception) as excinfo:
10771077
pytest.skip("xxx")
1078-
assert excinfo.traceback[-1].frame.code.name == "skip"
1078+
if sys.version_info >= (3, 11):
1079+
assert excinfo.traceback[-1].frame.code.raw.co_qualname == "_Skip.__call__"
10791080
assert excinfo.traceback[-1].ishidden(excinfo)
10801081
assert excinfo.traceback[-2].frame.code.name == "test_skip_simple"
10811082
assert not excinfo.traceback[-2].ishidden(excinfo)

0 commit comments

Comments
 (0)