Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Deysha Rivera
Dheeraj C K
Dhiren Serai
Diego Russo
Dima Gerasimov
Dmitry Dygalo
Dmitry Pribysh
Dominic Mortlock
Expand Down
1 change: 1 addition & 0 deletions changelog/13445.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Made the type annotations of :func:`pytest.skip` and friends more spec-complaint to have them work across more type checkers.
178 changes: 80 additions & 98 deletions src/_pytest/outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,9 @@

from __future__ import annotations

from collections.abc import Callable
import sys
from typing import Any
from typing import cast
from typing import NoReturn
from typing import Protocol
from typing import TypeVar

from .warning_types import PytestDeprecationWarning

Expand Down Expand Up @@ -77,132 +73,118 @@ def __init__(
super().__init__(msg)


# We need a callable protocol to add attributes, for discussion see
# https://github.com/python/mypy/issues/2087.
class XFailed(Failed):
"""Raised from an explicit call to pytest.xfail()."""

_F = TypeVar("_F", bound=Callable[..., object])
_ET = TypeVar("_ET", bound=type[BaseException])

class _Exit:
Exception: type[Exit] = Exit

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually we omit an explicit annotation when it is inferred.

Also, here I think a ClassVar annotation would be appropriate. So Exception: ClassVar = Exit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, added ClassVar -- see below comment re: type annotation


class _WithException(Protocol[_F, _ET]):
Exception: _ET
__call__: _F
def __call__(self, reason: str = "", returncode: int | None = None) -> NoReturn:
"""Exit testing process.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put the docstring on the class, so that sphinx picks it up correctly. You can verify that it works by checking that it shows up in the PR docs preview (currently empty): https://pytest--13445.org.readthedocs.build/en/13445/reference/reference.html#pytest-skip

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh this docs pipeline is neat, didn't notice that! Moved and seems like it's picked up correctly now


:param reason:
The message to show as the reason for exiting pytest. reason has a default value
only because `msg` is deprecated.

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
:param returncode:
Return code to be used when exiting pytest. None means the same as ``0`` (no error),
same as :func:`sys.exit`.

return decorate
:raises pytest.exit.Exception:
The exception that is raised.
"""
__tracebackhide__ = True
raise Exit(msg=reason, returncode=returncode)


# Exposed helper methods.
class _Skip:
Exception: type[Skipped] = Skipped

def __call__(self, reason: str = "", allow_module_level: bool = False) -> NoReturn:
"""Skip an executing test with the given message.

@_with_exception(Exit)
def exit(
reason: str = "",
returncode: int | None = None,
) -> NoReturn:
"""Exit testing process.
This function should be called only during testing (setup, call or teardown) or
during collection by using the ``allow_module_level`` flag. This function can
be called in doctests as well.

:param reason:
The message to show as the reason for exiting pytest. reason has a default value
only because `msg` is deprecated.
:param reason:
The message to show the user as reason for the skip.

:param returncode:
Return code to be used when exiting pytest. None means the same as ``0`` (no error), same as :func:`sys.exit`.
:param allow_module_level:
Allows this function to be called at module level.
Raising the skip exception at module level will stop
the execution of the module and prevent the collection of all tests in the module,
even those defined before the `skip` call.

:raises pytest.exit.Exception:
The exception that is raised.
"""
__tracebackhide__ = True
raise Exit(reason, returncode)
Defaults to False.

:raises pytest.skip.Exception:
The exception that is raised.

@_with_exception(Skipped)
def skip(
reason: str = "",
*,
allow_module_level: bool = False,
) -> NoReturn:
"""Skip an executing test with the given message.

This function should be called only during testing (setup, call or teardown) or
during collection by using the ``allow_module_level`` flag. This function can
be called in doctests as well.
.. note::
It is better to use the :ref:`pytest.mark.skipif ref` marker when
possible to declare a test to be skipped under certain conditions
like mismatching platforms or dependencies.
Similarly, use the ``# doctest: +SKIP`` directive (see :py:data:`doctest.SKIP`)
to skip a doctest statically.
"""
__tracebackhide__ = True
raise Skipped(msg=reason, allow_module_level=allow_module_level)

:param reason:
The message to show the user as reason for the skip.

:param allow_module_level:
Allows this function to be called at module level.
Raising the skip exception at module level will stop
the execution of the module and prevent the collection of all tests in the module,
even those defined before the `skip` call.
class _Fail:
Exception: type[Failed] = Failed

Defaults to False.

:raises pytest.skip.Exception:
The exception that is raised.

.. note::
It is better to use the :ref:`pytest.mark.skipif ref` marker when
possible to declare a test to be skipped under certain conditions
like mismatching platforms or dependencies.
Similarly, use the ``# doctest: +SKIP`` directive (see :py:data:`doctest.SKIP`)
to skip a doctest statically.
"""
__tracebackhide__ = True
raise Skipped(msg=reason, allow_module_level=allow_module_level)
def __call__(self, reason: str = "", pytrace: bool = True) -> NoReturn:
"""Explicitly fail an executing test with the given message.

:param reason:
The message to show the user as reason for the failure.

@_with_exception(Failed)
def fail(reason: str = "", pytrace: bool = True) -> NoReturn:
"""Explicitly fail an executing test with the given message.
:param pytrace:
If False, msg represents the full failure information and no
python traceback will be reported.

:param reason:
The message to show the user as reason for the failure.
:raises pytest.fail.Exception:
The exception that is raised.
"""
__tracebackhide__ = True
raise Failed(msg=reason, pytrace=pytrace)

:param pytrace:
If False, msg represents the full failure information and no
python traceback will be reported.

:raises pytest.fail.Exception:
The exception that is raised.
"""
__tracebackhide__ = True
raise Failed(msg=reason, pytrace=pytrace)
class _XFail:
Exception: type[XFailed] = XFailed

def __call__(self, reason: str = "") -> NoReturn:
"""Imperatively xfail an executing test or setup function with the given reason.

class XFailed(Failed):
"""Raised from an explicit call to pytest.xfail()."""
This function should be called only during testing (setup, call or teardown).

No other code is executed after using ``xfail()`` (it is implemented
internally by raising an exception).

@_with_exception(XFailed)
def xfail(reason: str = "") -> NoReturn:
"""Imperatively xfail an executing test or setup function with the given reason.
:param reason:
The message to show the user as reason for the xfail.

This function should be called only during testing (setup, call or teardown).
.. note::
It is better to use the :ref:`pytest.mark.xfail ref` marker when
possible to declare a test to be xfailed under certain conditions
like known bugs or missing features.

No other code is executed after using ``xfail()`` (it is implemented
internally by raising an exception).
:raises pytest.xfail.Exception:
The exception that is raised.
"""
__tracebackhide__ = True
raise XFailed(msg=reason)

:param reason:
The message to show the user as reason for the xfail.

.. note::
It is better to use the :ref:`pytest.mark.xfail ref` marker when
possible to declare a test to be xfailed under certain conditions
like known bugs or missing features.
# Exposed helper methods.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My slight preference it to have the exit = _Exit etc. below the class _Exit instead of group at the bottom, it makes it easier to understand what it's about IMO.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!


:raises pytest.xfail.Exception:
The exception that is raised.
"""
__tracebackhide__ = True
raise XFailed(reason)
exit: _Exit = _Exit()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Here too I think we can omit the explicit type annotations)

@karlicoss karlicoss Jun 26, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I added them because otherwise ty was inferring pytest.skip(reason=...) as Unknown instead of Never -- which is a consequence (and possibly there is some bug involved too) of how ty handles unannotated module/class attributes at the moment.
I reported it here astral-sh/ty#433 (comment)

So if we remove explicit annotation, ty would infer pytest.skip(...) as Unknown at the moment -- this would defeat the purpose of typing annotations to an extent (although would still be a net improvement as at least it wouldn't report pytest.skip as false positives).
IMO would be nice to keep them even if it's a minor 'style' inconsistency as it doesn't make anything else worse. We could leave a comment in code explaining why it's annotated (so someone doesn't remove by accident), but now that the outcome objects are next to the corresponding class it's a bit harder, since would require duplicating the comment 4 times :) Not sure what to do, but looks like this file really isn't modified frequently, and the risk of losing annotations is low -- and in the meantime ty might sort out the Unknown shenanigans.

I can remove the Exception annotations apart from ClassVar if you prefer -- the only downside is that ty would infer pytest.skip.Exception as Unknown, but this doesn't feel like a big deal and everything else would still work as expected.

Let me know what you think!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't say I understand the rationale of ty to behave this way, but it's intentional and it's just a minor style thing. So let's keep the annotations.

skip: _Skip = _Skip()
fail: _Fail = _Fail()
xfail: _XFail = _XFail()


def importorskip(
Expand Down
3 changes: 2 additions & 1 deletion testing/python/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,7 +1075,8 @@ class TestTracebackCutting:
def test_skip_simple(self):
with pytest.raises(pytest.skip.Exception) as excinfo:
pytest.skip("xxx")
assert excinfo.traceback[-1].frame.code.name == "skip"
if sys.version_info >= (3, 11):
assert excinfo.traceback[-1].frame.code.raw.co_qualname == "_Skip.__call__"
assert excinfo.traceback[-1].ishidden(excinfo)
assert excinfo.traceback[-2].frame.code.name == "test_skip_simple"
assert not excinfo.traceback[-2].ishidden(excinfo)
Expand Down