Skip to content

Commit d752c44

Browse files
NewGladAlexey Nikitincooperlees
authored
Add B908: Rule to check that assertRaises-like contexts has no more than 1 top-level statements (#382)
* Add B908: Rule to check that assertRaises-like contexts has no more than 1 top-level statement * Update README with B908 * Catch "with raises" and "with warns" * Update README * Fix typing * Add "assertWarnsRegexp" to B908_unittest_methods * Update README.rst Co-authored-by: Cooper Lees <[email protected]> * Remove assertWarnsRegexp * Remove old comment --------- Co-authored-by: Alexey Nikitin <[email protected]> Co-authored-by: Cooper Lees <[email protected]>
1 parent 8c0e7eb commit d752c44

File tree

4 files changed

+124
-2
lines changed

4 files changed

+124
-2
lines changed

README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ This is meant to be enabled by developers writing visitors using the ``ast`` mod
228228

229229
**B907**: Consider replacing ``f"'{foo}'"`` with ``f"{foo!r}"`` which is both easier to read and will escape quotes inside ``foo`` if that would appear. The check tries to filter out any format specs that are invalid together with ``!r``. If you're using other conversion flags then e.g. ``f"'{foo!a}'"`` can be replaced with ``f"{ascii(foo)!r}"``. Not currently implemented for python<3.8 or ``str.format()`` calls.
230230

231+
**B908**: Contexts with exceptions assertions like ``with self.assertRaises`` or ``with pytest.raises`` should not have multiple top-level statements. Each statement should be in its own context. That way, the test ensures that the exception is raised only in the exact statement where you expect it.
232+
231233
**B950**: Line too long. This is a pragmatic equivalent of
232234
``pycodestyle``'s ``E501``: it considers "max-line-length" but only triggers
233235
when the value has been exceeded by **more than 10%**. ``noqa`` and ``type: ignore`` comments are ignored. You will no

bugbear.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@
2828
ast.GeneratorExp,
2929
)
3030
FUNCTION_NODES = (ast.AsyncFunctionDef, ast.FunctionDef, ast.Lambda)
31+
B908_pytest_functions = {"raises", "warns"}
32+
B908_unittest_methods = {
33+
"assertRaises",
34+
"assertRaisesRegex",
35+
"assertRaisesRegexp",
36+
"assertWarns",
37+
"assertWarnsRegex",
38+
}
3139

3240
Context = namedtuple("Context", ["node", "stack"])
3341

@@ -504,6 +512,7 @@ def visit_Raise(self, node):
504512
def visit_With(self, node):
505513
self.check_for_b017(node)
506514
self.check_for_b022(node)
515+
self.check_for_b908(node)
507516
self.generic_visit(node)
508517

509518
def visit_JoinedStr(self, node):
@@ -1104,6 +1113,39 @@ def check_for_b022(self, node):
11041113
):
11051114
self.errors.append(B022(node.lineno, node.col_offset))
11061115

1116+
@staticmethod
1117+
def _is_assertRaises_like(node: ast.withitem) -> bool:
1118+
if not (
1119+
isinstance(node, ast.withitem)
1120+
and isinstance(node.context_expr, ast.Call)
1121+
and isinstance(node.context_expr.func, (ast.Attribute, ast.Name))
1122+
):
1123+
return False
1124+
if isinstance(node.context_expr.func, ast.Name):
1125+
# "with raises"
1126+
return node.context_expr.func.id in B908_pytest_functions
1127+
elif isinstance(node.context_expr.func, ast.Attribute) and isinstance(
1128+
node.context_expr.func.value, ast.Name
1129+
):
1130+
return (
1131+
# "with pytest.raises"
1132+
node.context_expr.func.value.id == "pytest"
1133+
and node.context_expr.func.attr in B908_pytest_functions
1134+
) or (
1135+
# "with self.assertRaises"
1136+
node.context_expr.func.value.id == "self"
1137+
and node.context_expr.func.attr in B908_unittest_methods
1138+
)
1139+
else:
1140+
return False
1141+
1142+
def check_for_b908(self, node: ast.With):
1143+
if len(node.body) < 2:
1144+
return
1145+
for node_item in node.items:
1146+
if self._is_assertRaises_like(node_item):
1147+
self.errors.append(B908(node.lineno, node.col_offset))
1148+
11071149
def check_for_b025(self, node):
11081150
seen = []
11091151
for handler in node.handlers:
@@ -1759,7 +1801,12 @@ def visit_Lambda(self, node):
17591801
" flag."
17601802
)
17611803
)
1762-
1804+
B908 = Error(
1805+
message=(
1806+
"B908 assertRaises-type context should not contains more than one top-level"
1807+
" statement."
1808+
)
1809+
)
17631810
B950 = Error(message="B950 line too long ({} > {} characters)")
17641811

1765-
disabled_by_default = ["B901", "B902", "B903", "B904", "B905", "B906", "B950"]
1812+
disabled_by_default = ["B901", "B902", "B903", "B904", "B905", "B906", "B908", "B950"]

tests/b908.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import unittest
2+
import warnings
3+
4+
import pytest
5+
from pytest import raises, warns
6+
7+
with pytest.raises(TypeError):
8+
a = 1 + "x"
9+
b = "x" + 1
10+
print(a, b)
11+
12+
13+
class SomeTestCase(unittest.TestCase):
14+
def test_func_raises(self):
15+
with self.assertRaises(TypeError):
16+
a = 1 + "x"
17+
b = "x" + 1
18+
print(a, b)
19+
20+
def test_func_raises_regex(self):
21+
with self.assertRaisesRegex(TypeError):
22+
a = 1 + "x"
23+
b = "x" + 1
24+
print(a, b)
25+
26+
def test_func_raises_regexp(self):
27+
with self.assertRaisesRegexp(TypeError):
28+
a = 1 + "x"
29+
b = "x" + 1
30+
print(a, b)
31+
32+
def test_raises_correct(self):
33+
with self.assertRaises(TypeError):
34+
print("1" + 1)
35+
36+
37+
with raises(Exception):
38+
"1" + 1
39+
"2" + 2
40+
41+
with pytest.warns(Warning):
42+
print("print before warning")
43+
warnings.warn("some warning", stacklevel=1)
44+
45+
with warns(Warning):
46+
print("print before warning")
47+
warnings.warn("some warning", stacklevel=1)
48+
49+
# should not raise an error
50+
with pytest.raises(TypeError):
51+
print("1" + 1)
52+
53+
with pytest.warns(Warning):
54+
warnings.warn("some warning", stacklevel=1)
55+
56+
with raises(Exception):
57+
raise Exception("some exception")

tests/test_bugbear.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
B905,
5252
B906,
5353
B907,
54+
B908,
5455
B950,
5556
BugBearChecker,
5657
BugBearVisitor,
@@ -489,6 +490,21 @@ def test_b032(self):
489490
)
490491
self.assertEqual(errors, expected)
491492

493+
def test_b908(self):
494+
filename = Path(__file__).absolute().parent / "b908.py"
495+
bbc = BugBearChecker(filename=str(filename))
496+
errors = list(bbc.run())
497+
expected = self.errors(
498+
B908(7, 0),
499+
B908(15, 8),
500+
B908(21, 8),
501+
B908(27, 8),
502+
B908(37, 0),
503+
B908(41, 0),
504+
B908(45, 0),
505+
)
506+
self.assertEqual(errors, expected)
507+
492508
@unittest.skipIf(sys.version_info < (3, 8), "not implemented for <3.8")
493509
def test_b907(self):
494510
filename = Path(__file__).absolute().parent / "b907.py"

0 commit comments

Comments
 (0)