Skip to content

Commit 1ea39ff

Browse files
committed
Add rule raise-missing-from
1 parent 346bdcf commit 1ea39ff

13 files changed

+245
-10
lines changed

CONTRIBUTORS.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,3 +391,5 @@ contributors:
391391
* Shiv Venkatasubrahmanyam
392392

393393
* Jochen Preusche (iilei): contributor
394+
395+
* Ram Rachum (cool-RR)

ChangeLog

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ Release date: TBA
3030

3131
* Fix `pre-commit` config that could lead to undetected duplicate lines of code
3232

33+
* Add `raise-missing-from` check for exceptions that should have a cause.
34+
3335
What's New in Pylint 2.5.3?
3436
===========================
3537

doc/whatsnew/2.6.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ New checkers
1515

1616
* Add `super-with-arguments` check for flagging instances of Python 2 style super calls.
1717

18+
* Add `raise-missing-from` check for exceptions that should have a cause.
19+
1820
Other Changes
1921
=============
2022

pylint/checkers/exceptions.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,25 @@ def _is_raising(body: typing.List) -> bool:
7676
return False
7777

7878

79+
def iterate_ancestors(node: NodeNG) -> typing.Iterator[NodeNG]:
80+
current = node.parent
81+
while current is not None:
82+
yield current
83+
current = current.parent
84+
85+
86+
def get_containing_except_node(node: NodeNG) -> typing.Optional[NodeNG]:
87+
for parent in iterate_ancestors(node):
88+
if isinstance(parent, astroid.ExceptHandler):
89+
return parent
90+
if isinstance(parent, astroid.scoped_nodes.LocalsDictNodeNG):
91+
# If we're inside a function/class definition, we don't want to keep checking
92+
# higher parents for `except` clauses, because if these exist, it means our
93+
# function/class was defined in an `except` clause, rather than the current code
94+
# actually running in an `except` clause.
95+
return
96+
97+
7998
OVERGENERAL_EXCEPTIONS = ("BaseException", "Exception")
8099
BUILTINS_NAME = builtins.__name__
81100

@@ -152,6 +171,15 @@ def _is_raising(body: typing.List) -> bool:
152171
"immediately. Remove the raise operator or the entire "
153172
"try-except-raise block!",
154173
),
174+
"W0707": (
175+
"Consider explicitly re-raising using the 'from' keyword",
176+
"raise-missing-from",
177+
"Python 3's exception chaining means it shows the traceback of the "
178+
"current exception, but also the original exception. Not using `raise "
179+
"from` makes the traceback inaccurate, because the message implies "
180+
"there is a bug in the exception-handling code itself, which is a "
181+
"separate situation than wrapping an exception.",
182+
),
155183
"W0711": (
156184
'Exception to catch is the result of a binary "%s" operation',
157185
"binary-op-exception",
@@ -279,13 +307,16 @@ def open(self):
279307
"notimplemented-raised",
280308
"bad-exception-context",
281309
"raising-format-tuple",
310+
"raise-missing-from",
282311
)
283312
def visit_raise(self, node):
284313
if node.exc is None:
285314
self._check_misplaced_bare_raise(node)
286315
return
287316

288-
if node.cause:
317+
if node.cause is None:
318+
self._check_raise_missing_from(node)
319+
else:
289320
self._check_bad_exception_context(node)
290321

291322
expr = node.exc
@@ -320,7 +351,7 @@ def _check_misplaced_bare_raise(self, node):
320351
if not current or not isinstance(current.parent, expected):
321352
self.add_message("misplaced-bare-raise", node=node)
322353

323-
def _check_bad_exception_context(self, node):
354+
def _check_bad_exception_context(self, node: astroid.Raise) -> None:
324355
"""Verify that the exception context is properly set.
325356
326357
An exception context can be only `None` or an exception.
@@ -337,6 +368,38 @@ def _check_bad_exception_context(self, node):
337368
):
338369
self.add_message("bad-exception-context", node=node)
339370

371+
def _check_raise_missing_from(self, node: astroid.Raise) -> None:
372+
assert node.cause is None
373+
if node.exc is None:
374+
# This is a plain `raise`, raising the previously-caught exception. No need for a
375+
# cause.
376+
return
377+
# We'd like to check whether we're inside an `except` clause:
378+
containing_except_node = get_containing_except_node(node)
379+
if not containing_except_node:
380+
return
381+
# We found a surrounding `except`! We're almost done proving there's a
382+
# `raise-missing-from` here. The only thing we need to protect against is that maybe
383+
# the `raise` is raising the exception that was caught, possibly with some shenanigans
384+
# like `exc.with_traceback(whatever)`. We won't analyze these, we'll just assume
385+
# there's a violation on two simple cases: `raise SomeException(whatever)` and `raise
386+
# SomeException`.
387+
if containing_except_node.name is None:
388+
# The `except` doesn't have an `as exception:` part, meaning there's no way that
389+
# the `raise` is raising the same exception.
390+
self.add_message("raise-missing-from", node=node)
391+
elif isinstance(node.exc, astroid.Call) and isinstance(
392+
node.exc.func, astroid.Name
393+
):
394+
# We have a `raise SomeException(whatever)`.
395+
self.add_message("raise-missing-from", node=node)
396+
elif (
397+
isinstance(node.exc, astroid.Name)
398+
and node.exc.name != containing_except_node.name.name
399+
):
400+
# We have a `raise SomeException`.
401+
self.add_message("raise-missing-from", node=node)
402+
340403
def _check_catching_non_exception(self, handler, exc, part):
341404
if isinstance(exc, astroid.Tuple):
342405
# Check if it is a tuple of exceptions.

tests/functional/m/misplaced_bare_raise.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# pylint: disable=missing-docstring, broad-except, unreachable, try-except-raise
1+
# pylint: disable=missing-docstring, broad-except, unreachable, try-except-raise, raise-missing-from
22
# pylint: disable=unused-variable, too-few-public-methods, invalid-name, useless-object-inheritance
33

44
try:

tests/functional/n/no_else_raise.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
""" Test that superfluous else return are detected. """
22

3-
# pylint:disable=invalid-name,missing-docstring,unused-variable
3+
# pylint:disable=invalid-name,missing-docstring,unused-variable,raise-missing-from
44

55
def foo1(x, y, z):
66
if x: # [no-else-raise]

tests/functional/n/no_else_return.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
""" Test that superfluous else return are detected. """
22

3-
# pylint:disable=invalid-name,missing-docstring,unused-variable
43

4+
# pylint:disable=invalid-name,missing-docstring,unused-variable
55
def foo1(x, y, z):
66
if x: # [no-else-return]
77
a = 1
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# pylint:disable=missing-docstring, unreachable, using-constant-test, invalid-name, bare-except
2+
# pylint:disable=try-except-raise, undefined-variable, too-few-public-methods, superfluous-parens
3+
4+
################################################################################
5+
# Positives:
6+
7+
try:
8+
1 / 0
9+
except ZeroDivisionError:
10+
# +1: [raise-missing-from]
11+
raise KeyError
12+
13+
try:
14+
1 / 0
15+
except ZeroDivisionError:
16+
# Our algorithm doesn't have to be careful about the complicated expression below, because
17+
# the exception above wasn't bound to a name.
18+
# +1: [raise-missing-from]
19+
raise (foo + bar).baz
20+
21+
try:
22+
1 / 0
23+
except ZeroDivisionError as e:
24+
# +1: [raise-missing-from]
25+
raise KeyError
26+
27+
try:
28+
1 / 0
29+
except ZeroDivisionError as e:
30+
# +1: [raise-missing-from]
31+
raise KeyError
32+
else:
33+
pass
34+
finally:
35+
pass
36+
37+
try:
38+
1 / 0
39+
except ZeroDivisionError as e:
40+
if 1:
41+
if 1:
42+
with whatever:
43+
try:
44+
# +1: [raise-missing-from]
45+
raise KeyError
46+
except:
47+
pass
48+
49+
try:
50+
1 / 0
51+
except ZeroDivisionError as e:
52+
# +1: [raise-missing-from]
53+
raise KeyError()
54+
55+
try:
56+
1 / 0
57+
except ZeroDivisionError as e:
58+
# +1: [raise-missing-from]
59+
raise KeyError(whatever, whatever=whatever)
60+
61+
62+
################################################################################
63+
# Negatives (Same cases as above, except with `from`):
64+
65+
try:
66+
1 / 0
67+
except ZeroDivisionError:
68+
raise KeyError from foo
69+
70+
try:
71+
1 / 0
72+
except ZeroDivisionError:
73+
raise (foo + bar).baz from foo
74+
75+
try:
76+
1 / 0
77+
except ZeroDivisionError as e:
78+
raise KeyError from foo
79+
80+
try:
81+
1 / 0
82+
except ZeroDivisionError as e:
83+
raise KeyError from foo
84+
else:
85+
pass
86+
finally:
87+
pass
88+
89+
try:
90+
1 / 0
91+
except ZeroDivisionError as e:
92+
if 1:
93+
if 1:
94+
with whatever:
95+
try:
96+
raise KeyError from foo
97+
except:
98+
pass
99+
100+
try:
101+
1 / 0
102+
except ZeroDivisionError as e:
103+
raise KeyError() from foo
104+
105+
try:
106+
1 / 0
107+
except ZeroDivisionError as e:
108+
raise KeyError(whatever, whatever=whatever) from foo
109+
110+
111+
################################################################################
112+
# Other negatives:
113+
114+
try:
115+
1 / 0
116+
except ZeroDivisionError:
117+
raise
118+
119+
try:
120+
1 / 0
121+
except ZeroDivisionError as e:
122+
raise
123+
124+
try:
125+
1 / 0
126+
except ZeroDivisionError as e:
127+
raise e
128+
129+
try:
130+
1 / 0
131+
except ZeroDivisionError as e:
132+
if 1:
133+
if 1:
134+
if 1:
135+
raise e
136+
137+
try:
138+
1 / 0
139+
except ZeroDivisionError as e:
140+
raise e.with_traceback(e.__traceback__)
141+
142+
try:
143+
1 / 0
144+
except ZeroDivisionError as e:
145+
raise (e + 7)
146+
147+
try:
148+
1 / 0
149+
except ZeroDivisionError as e:
150+
def f():
151+
raise KeyError
152+
153+
try:
154+
1 / 0
155+
except ZeroDivisionError as e:
156+
class Foo:
157+
raise KeyError
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
raise-missing-from:11::Consider explicitly re-raising using the 'from' keyword
2+
raise-missing-from:19::Consider explicitly re-raising using the 'from' keyword
3+
raise-missing-from:25::Consider explicitly re-raising using the 'from' keyword
4+
raise-missing-from:31::Consider explicitly re-raising using the 'from' keyword
5+
raise-missing-from:45::Consider explicitly re-raising using the 'from' keyword
6+
raise-missing-from:53::Consider explicitly re-raising using the 'from' keyword
7+
raise-missing-from:59::Consider explicitly re-raising using the 'from' keyword

tests/functional/r/regression_3416_unused_argument_raise.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
https://github.com/PyCQA/pylint/issues/3416
44
"""
55

6+
# pylint:disable=raise-missing-from
7+
68
# +1: [unused-argument, unused-argument, unused-argument]
79
def fun(arg_a, arg_b, arg_c) -> None:
810
"""Routine docstring"""

0 commit comments

Comments
 (0)