Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ exclude_lines =
^\s*assert False(,|$)
^\s*assert_never\(

^\s*if TYPE_CHECKING:
^\s*(el)?if TYPE_CHECKING:
^\s*@overload( |$)
^\s*def .+: \.\.\.$

Expand Down
2 changes: 2 additions & 0 deletions changelog/13241.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
:func:`pytest.raises`, :func:`pytest.warns` and :func:`pytest.deprecated_call` now uses :class:`ParamSpec` for the type hint to the (old and not recommended) callable overload, instead of :class:`Any`. This allows type checkers to raise errors when passing incorrect function parameters.
``func`` can now also be passed as a kwarg, which the type hint previously showed as possible but didn't accept.
4 changes: 4 additions & 0 deletions doc/en/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@
# TypeVars
("py:class", "_pytest._code.code.E"),
("py:class", "E"), # due to delayed annotation
("py:class", "T"),
("py:class", "P"),
("py:class", "P.args"),
("py:class", "P.kwargs"),
("py:class", "_pytest.fixtures.FixtureFunction"),
("py:class", "_pytest.nodes._NodeType"),
("py:class", "_NodeType"), # due to delayed annotation
Expand Down
5 changes: 4 additions & 1 deletion doc/en/how-to/assert.rst
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@ exception at a specific level; exceptions contained directly in the top
Alternate `pytest.raises` form (legacy)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. warning::
This form is likely to be deprecated and removed in a future release.


There is an alternate form of :func:`pytest.raises` where you pass
a function that will be executed, along with ``*args`` and ``**kwargs``. :func:`pytest.raises`
will then execute the function with those arguments and assert that the given exception is raised:
Expand All @@ -301,7 +305,6 @@ exception* or *wrong exception*.
This form was the original :func:`pytest.raises` API, developed before the ``with`` statement was
added to the Python language. Nowadays, this form is rarely used, with the context-manager form (using ``with``)
being considered more readable.
Nonetheless, this form is fully supported and not deprecated in any way.

xfail mark and pytest.raises
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
7 changes: 0 additions & 7 deletions doc/en/how-to/capture-warnings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -338,13 +338,6 @@ Some examples:
... warnings.warn("issue with foo() func")
...

You can also call :func:`pytest.warns` on a function or code string:

.. code-block:: python

pytest.warns(expected_warning, func, *args, **kwargs)
pytest.warns(expected_warning, "func(*args, **kwargs)")

The function also returns a list of all raised warnings (as
``warnings.WarningMessage`` objects), which you can query for
additional information:
Expand Down
31 changes: 6 additions & 25 deletions src/_pytest/raises.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,15 @@ def raises(*, check: Callable[[BaseException], bool]) -> RaisesExc[BaseException
@overload
def raises(
expected_exception: type[E] | tuple[type[E], ...],
func: Callable[..., Any],
*args: Any,
**kwargs: Any,
func: Callable[P, object],
*args: P.args,
**kwargs: P.kwargs,
) -> ExceptionInfo[E]: ...


def raises(
expected_exception: type[E] | tuple[type[E], ...] | None = None,
func: Callable[P, object] | None = None,
*args: Any,
**kwargs: Any,
) -> RaisesExc[BaseException] | ExceptionInfo[E]:
Expand Down Expand Up @@ -237,25 +238,6 @@ def raises(

:ref:`assertraises` for more examples and detailed discussion.

**Legacy form**

It is possible to specify a callable by passing a to-be-called lambda::

>>> raises(ZeroDivisionError, lambda: 1/0)
<ExceptionInfo ...>

or you can specify an arbitrary callable with arguments::

>>> def f(x): return 1/x
...
>>> raises(ZeroDivisionError, f, 0)
<ExceptionInfo ...>
>>> raises(ZeroDivisionError, f, x=0)
<ExceptionInfo ...>

The form above is fully supported but discouraged for new code because the
context manager form is regarded as more readable and less error-prone.

.. note::
Similar to caught exception objects in Python, explicitly clearing
local references to returned ``ExceptionInfo`` objects can
Expand All @@ -272,7 +254,7 @@ def raises(
"""
__tracebackhide__ = True

if not args:
if func is None and not args:
if set(kwargs) - {"match", "check", "expected_exception"}:
msg = "Unexpected keyword arguments passed to pytest.raises: "
msg += ", ".join(sorted(kwargs))
Expand All @@ -289,11 +271,10 @@ def raises(
f"Raising exceptions is already understood as failing the test, so you don't need "
f"any special code to say 'this should never raise an exception'."
)
func = args[0]
if not callable(func):
raise TypeError(f"{func!r} object (type: {type(func)}) must be callable")
with RaisesExc(expected_exception) as excinfo:
func(*args[1:], **kwargs)
func(*args, **kwargs)
try:
return excinfo
finally:
Expand Down
48 changes: 26 additions & 22 deletions src/_pytest/recwarn.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@


if TYPE_CHECKING:
from typing_extensions import ParamSpec
from typing_extensions import Self

P = ParamSpec("P")

import warnings

from _pytest.deprecated import check_ispytest
Expand Down Expand Up @@ -49,7 +52,7 @@ def deprecated_call(


@overload
def deprecated_call(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: ...
def deprecated_call(func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T: ...


def deprecated_call(
Expand All @@ -67,23 +70,24 @@ def deprecated_call(
>>> import pytest
>>> with pytest.deprecated_call():
... assert api_call_v2() == 200
>>> with pytest.deprecated_call(match="^use v3 of this api$") as warning_messages:
... assert api_call_v2() == 200

It can also be used by passing a function and ``*args`` and ``**kwargs``,
in which case it will ensure calling ``func(*args, **kwargs)`` produces one of
the warnings types above. The return value is the return value of the function.

In the context manager form you may use the keyword argument ``match`` to assert
You may use the keyword argument ``match`` to assert
that the warning matches a text or regex.

The context manager produces a list of :class:`warnings.WarningMessage` objects,
one for each warning raised.
The return value is a list of :class:`warnings.WarningMessage` objects,
one for each warning emitted
(regardless of whether it is an ``expected_warning`` or not).
"""
__tracebackhide__ = True
if func is not None:
args = (func, *args)
return warns(
(DeprecationWarning, PendingDeprecationWarning, FutureWarning), *args, **kwargs
)
# Potential QoL: allow `with deprecated_call:` - i.e. no parens
dep_warnings = (DeprecationWarning, PendingDeprecationWarning, FutureWarning)
if func is None:
return warns(dep_warnings, *args, **kwargs)

with warns(dep_warnings):
return func(*args, **kwargs)


@overload
Expand All @@ -97,16 +101,16 @@ def warns(
@overload
def warns(
expected_warning: type[Warning] | tuple[type[Warning], ...],
func: Callable[..., T],
*args: Any,
**kwargs: Any,
func: Callable[P, T],
*args: P.args,
**kwargs: P.kwargs,
) -> T: ...


def warns(
expected_warning: type[Warning] | tuple[type[Warning], ...] = Warning,
func: Callable[..., object] | None = None,
*args: Any,
match: str | re.Pattern[str] | None = None,
**kwargs: Any,
) -> WarningsChecker | Any:
r"""Assert that code raises a particular class of warning.
Expand All @@ -119,13 +123,13 @@ def warns(
each warning emitted (regardless of whether it is an ``expected_warning`` or not).
Since pytest 8.0, unmatched warnings are also re-emitted when the context closes.

This function can be used as a context manager::
This function should be used as a context manager::

>>> import pytest
>>> with pytest.warns(RuntimeWarning):
... warnings.warn("my warning", RuntimeWarning)

In the context manager form you may use the keyword argument ``match`` to assert
The ``match`` keyword argument can be used to assert
that the warning matches a text or regex::

>>> with pytest.warns(UserWarning, match='must be 0 or None'):
Expand All @@ -151,7 +155,8 @@ def warns(

"""
__tracebackhide__ = True
if not args:
if func is None and not args:
match: str | re.Pattern[str] | None = kwargs.pop("match", None)
if kwargs:
argnames = ", ".join(sorted(kwargs))
raise TypeError(
Expand All @@ -160,11 +165,10 @@ def warns(
)
return WarningsChecker(expected_warning, match_expr=match, _ispytest=True)
else:
func = args[0]
if not callable(func):
raise TypeError(f"{func!r} object (type: {type(func)}) must be callable")
with WarningsChecker(expected_warning, _ispytest=True):
return func(*args[1:], **kwargs)
return func(*args, **kwargs)


class WarningsRecorder(warnings.catch_warnings):
Expand Down
15 changes: 10 additions & 5 deletions testing/_py/test_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,8 @@ def test_chdir_gone(self, path1):
p = path1.ensure("dir_to_be_removed", dir=1)
p.chdir()
p.remove()
pytest.raises(error.ENOENT, local)
with pytest.raises(error.ENOENT):
local()
assert path1.chdir() is None
assert os.getcwd() == str(path1)

Expand Down Expand Up @@ -998,8 +999,10 @@ def test_locked_make_numbered_dir(self, tmpdir):
assert numdir.new(ext=str(j)).check()

def test_error_preservation(self, path1):
pytest.raises(EnvironmentError, path1.join("qwoeqiwe").mtime)
pytest.raises(EnvironmentError, path1.join("qwoeqiwe").read)
with pytest.raises(EnvironmentError):
path1.join("qwoeqiwe").mtime()
with pytest.raises(EnvironmentError):
path1.join("qwoeqiwe").read()

# def test_parentdirmatch(self):
# local.parentdirmatch('std', startmodule=__name__)
Expand Down Expand Up @@ -1099,7 +1102,8 @@ def test_pyimport_check_filepath_consistency(self, monkeypatch, tmpdir):
pseudopath = tmpdir.ensure(name + "123.py")
mod.__file__ = str(pseudopath)
monkeypatch.setitem(sys.modules, name, mod)
excinfo = pytest.raises(pseudopath.ImportMismatchError, p.pyimport)
with pytest.raises(pseudopath.ImportMismatchError) as excinfo:
p.pyimport()
modname, modfile, orig = excinfo.value.args
assert modname == name
assert modfile == pseudopath
Expand Down Expand Up @@ -1397,7 +1401,8 @@ def test_stat_helpers(self, tmpdir, monkeypatch):

def test_stat_non_raising(self, tmpdir):
path1 = tmpdir.join("file")
pytest.raises(error.ENOENT, lambda: path1.stat())
with pytest.raises(error.ENOENT):
path1.stat()
res = path1.stat(raising=False)
assert res is None

Expand Down
4 changes: 1 addition & 3 deletions testing/code/test_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,8 @@ def test_code_from_func() -> None:
def test_unicode_handling() -> None:
value = "ąć".encode()

def f() -> None:
with pytest.raises(Exception) as excinfo:
raise Exception(value)

excinfo = pytest.raises(Exception, f)
str(excinfo)


Expand Down
Loading