Skip to content

Commit a8f81db

Browse files
Fix #5683: False positive used-before-assignment in loop else where the only non-break exit is via except (#5684)
1 parent 44ad84a commit a8f81db

File tree

5 files changed

+286
-0
lines changed

5 files changed

+286
-0
lines changed

ChangeLog

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ Release date: TBA
157157

158158
Closes #5500
159159

160+
* When evaluating statements in the ``else`` clause of a loop, ``used-before-assignment``
161+
assumes that assignments in the except blocks took place if the
162+
except handlers constituted the only ways for the loop to finish without
163+
breaking early.
164+
165+
Closes #5683
166+
160167
* ``used-before-assignment`` now checks names in try blocks.
161168

162169
* Fixed false positive with ``used-before-assignment`` for assignment expressions

doc/whatsnew/2.13.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,13 @@ Other Changes
205205

206206
Closes #5500
207207

208+
* When evaluating statements in the ``else`` clause of a loop, ``used-before-assignment``
209+
assumes that assignments in the except blocks took place if the
210+
except handlers constituted the only ways for the loop to finish without
211+
breaking early.
212+
213+
Closes #5683
214+
208215
* ``used-before-assignment`` now checks names in try blocks.
209216

210217
* Fixed false positive with ``used-before-assignment`` for assignment expressions

pylint/checkers/variables.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,10 +741,108 @@ def _uncertain_nodes_in_except_blocks(
741741
# if one of the except blocks does not define the name in question,
742742
# raise, or return. See: https://github.com/PyCQA/pylint/issues/5524.
743743
continue
744+
745+
if NamesConsumer._check_loop_finishes_via_except(
746+
node, other_node_statement.parent.parent
747+
):
748+
continue
749+
744750
# Passed all tests for uncertain execution
745751
uncertain_nodes.append(other_node)
746752
return uncertain_nodes
747753

754+
@staticmethod
755+
def _check_loop_finishes_via_except(
756+
node: nodes.NodeNG, other_node_try_except: nodes.TryExcept
757+
) -> bool:
758+
"""Check for a case described in https://github.com/PyCQA/pylint/issues/5683.
759+
It consists of a specific control flow scenario where the only
760+
non-break exit from a loop consists of the very except handler we are
761+
examining, such that code in the `else` branch of the loop can depend on it
762+
being assigned.
763+
764+
Example:
765+
766+
for _ in range(3):
767+
try:
768+
do_something()
769+
except:
770+
name = 1 <-- only non-break exit from loop
771+
else:
772+
break
773+
else:
774+
print(name)
775+
"""
776+
if not other_node_try_except.orelse:
777+
return False
778+
closest_loop: Optional[
779+
Union[nodes.For, nodes.While]
780+
] = utils.get_node_first_ancestor_of_type(node, (nodes.For, nodes.While))
781+
if closest_loop is None:
782+
return False
783+
if not any(
784+
else_statement is node or else_statement.parent_of(node)
785+
for else_statement in closest_loop.orelse
786+
):
787+
# `node` not guarded by `else`
788+
return False
789+
for inner_else_statement in other_node_try_except.orelse:
790+
if isinstance(inner_else_statement, nodes.Break):
791+
break_stmt = inner_else_statement
792+
break
793+
else:
794+
# No break statement
795+
return False
796+
797+
def _try_in_loop_body(
798+
other_node_try_except: nodes.TryExcept, loop: Union[nodes.For, nodes.While]
799+
) -> bool:
800+
"""Return True if `other_node_try_except` is a descendant of `loop`."""
801+
return any(
802+
loop_body_statement is other_node_try_except
803+
or loop_body_statement.parent_of(other_node_try_except)
804+
for loop_body_statement in loop.body
805+
)
806+
807+
if not _try_in_loop_body(other_node_try_except, closest_loop):
808+
for ancestor in closest_loop.node_ancestors():
809+
if isinstance(ancestor, (nodes.For, nodes.While)):
810+
if _try_in_loop_body(other_node_try_except, ancestor):
811+
break
812+
else:
813+
# `other_node_try_except` didn't have a shared ancestor loop
814+
return False
815+
816+
for loop_stmt in closest_loop.body:
817+
if NamesConsumer._recursive_search_for_continue_before_break(
818+
loop_stmt, break_stmt
819+
):
820+
break
821+
else:
822+
# No continue found, so we arrived at our special case!
823+
return True
824+
return False
825+
826+
@staticmethod
827+
def _recursive_search_for_continue_before_break(
828+
stmt: nodes.Statement, break_stmt: nodes.Break
829+
) -> bool:
830+
"""Return True if any Continue node can be found in descendants of `stmt`
831+
before encountering `break_stmt`, ignoring any nested loops.
832+
"""
833+
if stmt is break_stmt:
834+
return False
835+
if isinstance(stmt, nodes.Continue):
836+
return True
837+
for child in stmt.get_children():
838+
if isinstance(stmt, (nodes.For, nodes.While)):
839+
continue
840+
if NamesConsumer._recursive_search_for_continue_before_break(
841+
child, break_stmt
842+
):
843+
return True
844+
return False
845+
748846
@staticmethod
749847
def _uncertain_nodes_in_try_blocks_when_evaluating_except_blocks(
750848
found_nodes: List[nodes.NodeNG], node_statement: nodes.Statement

tests/functional/u/use/used_before_assignment_issue4761.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,169 @@ def function():
1010
return 1
1111

1212
return some_message
13+
14+
15+
# Cases related to a specific control flow where
16+
# the `else` of a loop can depend on a name only defined
17+
# in a single except handler because that except handler is the
18+
# only non-break exit branch.
19+
20+
def valid_only_non_break_exit_from_loop_is_except_handler():
21+
"""https://github.com/PyCQA/pylint/issues/5683"""
22+
for _ in range(3):
23+
try:
24+
function() # not an exit branch because of `else` below
25+
except ValueError as verr:
26+
error = verr # < exit branch where error is defined
27+
else:
28+
break # < exit condition where error is *not* defined
29+
# will skip else: raise error
30+
print("retrying...")
31+
else:
32+
# This usage is valid because there is only one exit branch
33+
raise error
34+
35+
36+
def invalid_no_outer_else():
37+
"""The reliance on the name is not guarded by else."""
38+
for _ in range(3):
39+
try:
40+
function()
41+
except ValueError as verr:
42+
error = verr
43+
else:
44+
break
45+
print("retrying...")
46+
raise error # [used-before-assignment]
47+
48+
49+
def invalid_no_outer_else_2():
50+
"""Same, but the raise is inside a loop."""
51+
for _ in range(3):
52+
try:
53+
function()
54+
except ValueError as verr:
55+
error = verr
56+
else:
57+
break
58+
raise error # [used-before-assignment]
59+
60+
61+
def invalid_no_inner_else():
62+
"""No inner else statement."""
63+
for _ in range(3):
64+
try:
65+
function()
66+
except ValueError as verr:
67+
error = verr
68+
print("retrying...")
69+
if function():
70+
break
71+
else:
72+
raise error # [used-before-assignment]
73+
74+
75+
def invalid_wrong_break_location():
76+
"""The break is in the wrong location."""
77+
for _ in range(3):
78+
try:
79+
function()
80+
break
81+
except ValueError as verr:
82+
error = verr
83+
print("I give up")
84+
else:
85+
raise error # [used-before-assignment]
86+
87+
88+
def invalid_no_break():
89+
"""No break."""
90+
for _ in range(3):
91+
try:
92+
function()
93+
except ValueError as verr:
94+
error = verr
95+
else:
96+
pass
97+
else: # pylint: disable=useless-else-on-loop
98+
raise error # [used-before-assignment]
99+
100+
101+
def invalid_other_non_break_exit_from_loop_besides_except_handler():
102+
"""The continue creates another exit branch."""
103+
while function():
104+
if function():
105+
continue
106+
try:
107+
pass
108+
except ValueError as verr:
109+
error = verr
110+
else:
111+
break
112+
else:
113+
raise error # [used-before-assignment]
114+
115+
116+
def valid_continue_does_not_matter():
117+
"""This continue doesn't matter: still just one exit branch."""
118+
while function():
119+
try:
120+
for _ in range(3):
121+
if function():
122+
continue
123+
print(1 / 0)
124+
except ZeroDivisionError as zde:
125+
error = zde
126+
else:
127+
break
128+
else:
129+
raise error
130+
131+
132+
def invalid_conditional_continue_after_break():
133+
"""The continue is another exit branch"""
134+
while function():
135+
try:
136+
if function():
137+
break
138+
if not function():
139+
continue
140+
except ValueError as verr:
141+
error = verr
142+
else:
143+
break
144+
else:
145+
raise error # [used-before-assignment]
146+
147+
148+
def invalid_unrelated_loops():
149+
"""The loop else in question is not related to the try/except/else."""
150+
for _ in range(3):
151+
try:
152+
function()
153+
except ValueError as verr:
154+
error = verr
155+
else:
156+
break
157+
while function():
158+
print('The time is:')
159+
break
160+
else:
161+
raise error # [used-before-assignment]
162+
163+
164+
def valid_nested_loops():
165+
"""The name `error` is still available in a nested else."""
166+
for _ in range(3):
167+
try:
168+
function()
169+
except ValueError as verr:
170+
error = verr
171+
else:
172+
break
173+
else:
174+
while function():
175+
print('The time is:')
176+
break
177+
else:
178+
raise error
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
11
used-before-assignment:9:11:9:23:function:Using variable 'some_message' before assignment:CONTROL_FLOW
2+
used-before-assignment:46:10:46:15:invalid_no_outer_else:Using variable 'error' before assignment:CONTROL_FLOW
3+
used-before-assignment:58:14:58:19:invalid_no_outer_else_2:Using variable 'error' before assignment:CONTROL_FLOW
4+
used-before-assignment:72:14:72:19:invalid_no_inner_else:Using variable 'error' before assignment:CONTROL_FLOW
5+
used-before-assignment:85:14:85:19:invalid_wrong_break_location:Using variable 'error' before assignment:CONTROL_FLOW
6+
used-before-assignment:98:14:98:19:invalid_no_break:Using variable 'error' before assignment:CONTROL_FLOW
7+
used-before-assignment:113:14:113:19:invalid_other_non_break_exit_from_loop_besides_except_handler:Using variable 'error' before assignment:CONTROL_FLOW
8+
used-before-assignment:145:14:145:19:invalid_conditional_continue_after_break:Using variable 'error' before assignment:CONTROL_FLOW
9+
used-before-assignment:161:14:161:19:invalid_unrelated_loops:Using variable 'error' before assignment:CONTROL_FLOW

0 commit comments

Comments
 (0)