Skip to content

Commit 1b04ea7

Browse files
committed
Add rule raise-missing-from
1 parent 36250aa commit 1b04ea7

14 files changed

+244
-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
@@ -34,6 +34,8 @@ Release date: TBA
3434

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

37+
* Add `raise-missing-from` check for exceptions that should have a cause.
38+
3739

3840
What's New in Pylint 2.5.4?
3941
===========================

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: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def _is_raising(body: typing.List) -> bool:
7676
return False
7777

7878

79+
7980
OVERGENERAL_EXCEPTIONS = ("BaseException", "Exception")
8081
BUILTINS_NAME = builtins.__name__
8182

@@ -152,6 +153,15 @@ def _is_raising(body: typing.List) -> bool:
152153
"immediately. Remove the raise operator or the entire "
153154
"try-except-raise block!",
154155
),
156+
"W0707": (
157+
"Consider explicitly re-raising using the 'from' keyword",
158+
"raise-missing-from",
159+
"Python 3's exception chaining means it shows the traceback of the "
160+
"current exception, but also the original exception. Not using `raise "
161+
"from` makes the traceback inaccurate, because the message implies "
162+
"there is a bug in the exception-handling code itself, which is a "
163+
"separate situation than wrapping an exception.",
164+
),
155165
"W0711": (
156166
'Exception to catch is the result of a binary "%s" operation',
157167
"binary-op-exception",
@@ -279,13 +289,16 @@ def open(self):
279289
"notimplemented-raised",
280290
"bad-exception-context",
281291
"raising-format-tuple",
292+
"raise-missing-from",
282293
)
283294
def visit_raise(self, node):
284295
if node.exc is None:
285296
self._check_misplaced_bare_raise(node)
286297
return
287298

288-
if node.cause:
299+
if node.cause is None:
300+
self._check_raise_missing_from(node)
301+
else:
289302
self._check_bad_exception_context(node)
290303

291304
expr = node.exc
@@ -320,7 +333,7 @@ def _check_misplaced_bare_raise(self, node):
320333
if not current or not isinstance(current.parent, expected):
321334
self.add_message("misplaced-bare-raise", node=node)
322335

323-
def _check_bad_exception_context(self, node):
336+
def _check_bad_exception_context(self, node: astroid.Raise) -> None:
324337
"""Verify that the exception context is properly set.
325338
326339
An exception context can be only `None` or an exception.
@@ -337,6 +350,37 @@ def _check_bad_exception_context(self, node):
337350
):
338351
self.add_message("bad-exception-context", node=node)
339352

353+
def _check_raise_missing_from(self, node: astroid.Raise) -> None:
354+
if node.exc is None:
355+
# This is a plain `raise`, raising the previously-caught exception. No need for a
356+
# cause.
357+
return
358+
# We'd like to check whether we're inside an `except` clause:
359+
containing_except_node = utils.find_except_wrapper_node_in_scope(node)
360+
if not containing_except_node:
361+
return
362+
# We found a surrounding `except`! We're almost done proving there's a
363+
# `raise-missing-from` here. The only thing we need to protect against is that maybe
364+
# the `raise` is raising the exception that was caught, possibly with some shenanigans
365+
# like `exc.with_traceback(whatever)`. We won't analyze these, we'll just assume
366+
# there's a violation on two simple cases: `raise SomeException(whatever)` and `raise
367+
# SomeException`.
368+
if containing_except_node.name is None:
369+
# The `except` doesn't have an `as exception:` part, meaning there's no way that
370+
# the `raise` is raising the same exception.
371+
self.add_message("raise-missing-from", node=node)
372+
elif isinstance(node.exc, astroid.Call) and isinstance(
373+
node.exc.func, astroid.Name
374+
):
375+
# We have a `raise SomeException(whatever)`.
376+
self.add_message("raise-missing-from", node=node)
377+
elif (
378+
isinstance(node.exc, astroid.Name)
379+
and node.exc.name != containing_except_node.name.name
380+
):
381+
# We have a `raise SomeException`.
382+
self.add_message("raise-missing-from", node=node)
383+
340384
def _check_catching_non_exception(self, handler, exc, part):
341385
if isinstance(exc, astroid.Tuple):
342386
# Check if it is a tuple of exceptions.

pylint/checkers/utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,24 @@ def find_try_except_wrapper_node(
859859
return None
860860

861861

862+
def find_except_wrapper_node_in_scope(
863+
node: astroid.node_classes.NodeNG,
864+
) -> Optional[Union[astroid.ExceptHandler, astroid.TryExcept]]:
865+
"""Return the ExceptHandler in which the node is, without going out of scope."""
866+
current = node
867+
while current.parent is not None:
868+
current = current.parent
869+
if isinstance(current, astroid.scoped_nodes.LocalsDictNodeNG):
870+
# If we're inside a function/class definition, we don't want to keep checking
871+
# higher ancestors for `except` clauses, because if these exist, it means our
872+
# function/class was defined in an `except` clause, rather than the current code
873+
# actually running in an `except` clause.
874+
return None
875+
if isinstance(current, astroid.ExceptHandler):
876+
return current
877+
return None
878+
879+
862880
def is_from_fallback_block(node: astroid.node_classes.NodeNG) -> bool:
863881
"""Check if the given node is from a fallback import block."""
864882
context = find_try_except_wrapper_node(node)

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

0 commit comments

Comments
 (0)