[ruff] Check for non-context-manager use of pytest.raises, pytest.warns, and pytest.deprecated_call (RUF061) (#17368)

<!--
Thank you for contributing to Ruff! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title?
- Does this pull request include references to any relevant issues?
-->

This PR aims to close #16605.

## Summary

This PR introduces a new rule (`RUF061`) that detects non-contextmanager
usage of `pytest.raises`, `pytest.warns`, and `pytest.deprecated_call`.
This pattern is discouraged and [was proposed in
flake8-pytest-style](https://github.com/m-burst/flake8-pytest-style/pull/332),
but the corresponding PR has been open for over a month without
activity.

Additionally, this PR provides an unsafe fix for simple cases where the
non-contextmanager form can be transformed into the context manager
form. Examples of supported patterns are listed in `RUF061_raises.py`,
`RUF061_warns.py`, and `RUF061_deprecated_call.py` test files.

The more complex case from the original issue (involving two separate
statements):
```python
excinfo = pytest.raises(ValueError, int, "hello")
assert excinfo.match("^invalid literal")
```
is getting fixed like this:
```python
with pytest.raises(ValueError) as excinfo:
    int("hello")
assert excinfo.match("^invalid literal")
```
Putting match in the raises call requires multi-statement
transformation, which I am not sure how to implement.

## Test Plan

<!-- How was it tested? -->

New test files were added to cover various usages of the
non-contextmanager form of pytest.raises, warns, and deprecated_call.
This commit is contained in:
Denys Kyslytsyn 2025-06-17 02:03:54 +09:00 committed by GitHub
parent c5b58187da
commit c3aa965546
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 635 additions and 0 deletions

View file

@ -0,0 +1,25 @@
import warnings
import pytest
def raise_deprecation_warning(s):
warnings.warn(s, DeprecationWarning)
return s
def test_ok():
with pytest.deprecated_call():
raise_deprecation_warning("")
def test_error_trivial():
pytest.deprecated_call(raise_deprecation_warning, "deprecated")
def test_error_assign():
s = pytest.deprecated_call(raise_deprecation_warning, "deprecated")
print(s)
def test_error_lambda():
pytest.deprecated_call(lambda: warnings.warn("", DeprecationWarning))

View file

@ -0,0 +1,40 @@
import pytest
def func(a, b):
return a / b
def test_ok():
with pytest.raises(ValueError):
raise ValueError
def test_ok_as():
with pytest.raises(ValueError) as excinfo:
raise ValueError
def test_error_trivial():
pytest.raises(ZeroDivisionError, func, 1, b=0)
def test_error_match():
pytest.raises(ZeroDivisionError, func, 1, b=0).match("division by zero")
def test_error_assign():
excinfo = pytest.raises(ZeroDivisionError, func, 1, b=0)
def test_error_kwargs():
pytest.raises(func=func, expected_exception=ZeroDivisionError)
def test_error_multi_statement():
excinfo = pytest.raises(ValueError, int, "hello")
assert excinfo.match("^invalid literal")
def test_error_lambda():
pytest.raises(ZeroDivisionError, lambda: 1 / 0)

View file

@ -0,0 +1,25 @@
import warnings
import pytest
def raise_user_warning(s):
warnings.warn(s, UserWarning)
return s
def test_ok():
with pytest.warns(UserWarning):
raise_user_warning("")
def test_error_trivial():
pytest.warns(UserWarning, raise_user_warning, "warning")
def test_error_assign():
s = pytest.warns(UserWarning, raise_user_warning, "warning")
print(s)
def test_error_lambda():
pytest.warns(UserWarning, lambda: warnings.warn("", UserWarning))