Skip to content

add paramspec to callable forms of raises/warns/deprecated_call, rewrite tests to use CM form #13241

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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::
Copy link
Member

Choose a reason for hiding this comment

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

As discussed, let's remove this warning.

Removing the "Nonetheless" seems fine.

I also think we can remove the "The reporter will provide ..." paragraph to make the section shorter.

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
Copy link
Member

Choose a reason for hiding this comment

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

Seems weird to me :)

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