Skip to content

Commit 67bfab4

Browse files
Emit possibly-used-before-assignment after if/else switches (#8952)
Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
1 parent 73e82bd commit 67bfab4

23 files changed

+156
-73
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
def check_lunchbox(items: list[str]):
2+
if not items:
3+
empty = True
4+
print(empty) # [possibly-used-before-assignment]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
If you rely on a pattern like:
2+
3+
.. sourcecode:: python
4+
5+
if guarded():
6+
var = 1
7+
8+
if guarded():
9+
print(var) # emits possibly-used-before-assignment
10+
11+
you may be concerned that ``possibly-used-before-assignment`` is not totally useful
12+
in this instance. However, consider that pylint, as a static analysis tool, does
13+
not know if ``guarded()`` is deterministic or talks to
14+
a database. (Likewise, for ``guarded`` instead of ``guarded()``, any other
15+
part of your program may have changed its value in the meantime.)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def check_lunchbox(items: list[str]):
2+
empty = False
3+
if not items:
4+
empty = True
5+
print(empty)

doc/user_guide/checkers/features.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,6 +1374,9 @@ Variables checker Messages
13741374
Used when an invalid (non-string) object occurs in __all__.
13751375
:no-name-in-module (E0611): *No name %r in module %r*
13761376
Used when a name cannot be found in a module.
1377+
:possibly-used-before-assignment (E0606): *Possibly using variable %r before assignment*
1378+
Emitted when a local variable is accessed before its assignment took place in
1379+
both branches of an if/else switch.
13771380
:undefined-variable (E0602): *Undefined variable %r*
13781381
Used when an undefined variable is accessed.
13791382
:undefined-all-variable (E0603): *Undefined variable name %r in __all__*

doc/user_guide/messages/messages_overview.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ All messages in the error category:
139139
error/not-in-loop
140140
error/notimplemented-raised
141141
error/positional-only-arguments-expected
142+
error/possibly-used-before-assignment
142143
error/potential-index-error
143144
error/raising-bad-type
144145
error/raising-non-exception

doc/whatsnew/fragments/1727.new_check

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Add check ``possibly-used-before-assignment`` when relying on names after an ``if/else``
2+
switch when one branch failed to define the name, raise, or return.
3+
4+
Closes #1727

pylint/checkers/variables.py

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,12 @@ def _has_locals_call_after_node(stmt: nodes.NodeNG, scope: nodes.FunctionDef) ->
403403
"invalid-all-format",
404404
"Used when __all__ has an invalid format.",
405405
),
406+
"E0606": (
407+
"Possibly using variable %r before assignment",
408+
"possibly-used-before-assignment",
409+
"Emitted when a local variable is accessed before its assignment took place "
410+
"in both branches of an if/else switch.",
411+
),
406412
"E0611": (
407413
"No name %r in module %r",
408414
"no-name-in-module",
@@ -537,6 +543,8 @@ def __init__(self, node: nodes.NodeNG, scope_type: str) -> None:
537543
copy.copy(node.locals), {}, collections.defaultdict(list), scope_type
538544
)
539545
self.node = node
546+
self.names_under_always_false_test: set[str] = set()
547+
self.names_defined_under_one_branch_only: set[str] = set()
540548

541549
def __repr__(self) -> str:
542550
_to_consumes = [f"{k}->{v}" for k, v in self._atomic.to_consume.items()]
@@ -636,13 +644,6 @@ def get_next_to_consume(self, node: nodes.Name) -> list[nodes.NodeNG] | None:
636644
if VariablesChecker._comprehension_between_frame_and_node(node):
637645
return found_nodes
638646

639-
# Filter out assignments guarded by always false conditions
640-
if found_nodes:
641-
uncertain_nodes = self._uncertain_nodes_in_false_tests(found_nodes, node)
642-
self.consumed_uncertain[node.name] += uncertain_nodes
643-
uncertain_nodes_set = set(uncertain_nodes)
644-
found_nodes = [n for n in found_nodes if n not in uncertain_nodes_set]
645-
646647
# Filter out assignments in ExceptHandlers that node is not contained in
647648
if found_nodes:
648649
found_nodes = [
@@ -652,6 +653,13 @@ def get_next_to_consume(self, node: nodes.Name) -> list[nodes.NodeNG] | None:
652653
or n.statement().parent_of(node)
653654
]
654655

656+
# Filter out assignments guarded by always false conditions
657+
if found_nodes:
658+
uncertain_nodes = self._uncertain_nodes_if_tests(found_nodes, node)
659+
self.consumed_uncertain[node.name] += uncertain_nodes
660+
uncertain_nodes_set = set(uncertain_nodes)
661+
found_nodes = [n for n in found_nodes if n not in uncertain_nodes_set]
662+
655663
# Filter out assignments in an Except clause that the node is not
656664
# contained in, assuming they may fail
657665
if found_nodes:
@@ -688,8 +696,9 @@ def get_next_to_consume(self, node: nodes.Name) -> list[nodes.NodeNG] | None:
688696

689697
return found_nodes
690698

691-
@staticmethod
692-
def _inferred_to_define_name_raise_or_return(name: str, node: nodes.NodeNG) -> bool:
699+
def _inferred_to_define_name_raise_or_return(
700+
self, name: str, node: nodes.NodeNG
701+
) -> bool:
693702
"""Return True if there is a path under this `if_node`
694703
that is inferred to define `name`, raise, or return.
695704
"""
@@ -716,8 +725,8 @@ def _inferred_to_define_name_raise_or_return(name: str, node: nodes.NodeNG) -> b
716725
if not isinstance(node, nodes.If):
717726
return False
718727

719-
# Be permissive if there is a break
720-
if any(node.nodes_of_class(nodes.Break)):
728+
# Be permissive if there is a break or a continue
729+
if any(node.nodes_of_class(nodes.Break, nodes.Continue)):
721730
return True
722731

723732
# Is there an assignment in this node itself, e.g. in named expression?
@@ -739,17 +748,18 @@ def _inferred_to_define_name_raise_or_return(name: str, node: nodes.NodeNG) -> b
739748

740749
# Only search else branch when test condition is inferred to be false
741750
if all_inferred and only_search_else:
742-
return NamesConsumer._branch_handles_name(name, node.orelse)
743-
# Only search if branch when test condition is inferred to be true
744-
if all_inferred and only_search_if:
745-
return NamesConsumer._branch_handles_name(name, node.body)
751+
self.names_under_always_false_test.add(name)
752+
return self._branch_handles_name(name, node.orelse)
746753
# Search both if and else branches
747-
return NamesConsumer._branch_handles_name(
748-
name, node.body
749-
) or NamesConsumer._branch_handles_name(name, node.orelse)
750-
751-
@staticmethod
752-
def _branch_handles_name(name: str, body: Iterable[nodes.NodeNG]) -> bool:
754+
if_branch_handles = self._branch_handles_name(name, node.body)
755+
else_branch_handles = self._branch_handles_name(name, node.orelse)
756+
if if_branch_handles ^ else_branch_handles:
757+
self.names_defined_under_one_branch_only.add(name)
758+
elif name in self.names_defined_under_one_branch_only:
759+
self.names_defined_under_one_branch_only.remove(name)
760+
return if_branch_handles and else_branch_handles
761+
762+
def _branch_handles_name(self, name: str, body: Iterable[nodes.NodeNG]) -> bool:
753763
return any(
754764
NamesConsumer._defines_name_raises_or_returns(name, if_body_stmt)
755765
or isinstance(
@@ -762,17 +772,15 @@ def _branch_handles_name(name: str, body: Iterable[nodes.NodeNG]) -> bool:
762772
nodes.While,
763773
),
764774
)
765-
and NamesConsumer._inferred_to_define_name_raise_or_return(
766-
name, if_body_stmt
767-
)
775+
and self._inferred_to_define_name_raise_or_return(name, if_body_stmt)
768776
for if_body_stmt in body
769777
)
770778

771-
def _uncertain_nodes_in_false_tests(
779+
def _uncertain_nodes_if_tests(
772780
self, found_nodes: list[nodes.NodeNG], node: nodes.NodeNG
773781
) -> list[nodes.NodeNG]:
774-
"""Identify nodes of uncertain execution because they are defined under
775-
tests that evaluate false.
782+
"""Identify nodes of uncertain execution because they are defined under if
783+
tests.
776784
777785
Don't identify a node if there is a path that is inferred to
778786
define the name, raise, or return (e.g. any executed if/elif/else branch).
@@ -808,7 +816,7 @@ def _uncertain_nodes_in_false_tests(
808816
continue
809817

810818
# Name defined in the if/else control flow
811-
if NamesConsumer._inferred_to_define_name_raise_or_return(name, outer_if):
819+
if self._inferred_to_define_name_raise_or_return(name, outer_if):
812820
continue
813821

814822
uncertain_nodes.append(other_node)
@@ -930,7 +938,7 @@ def _uncertain_nodes_in_except_blocks(
930938

931939
@staticmethod
932940
def _defines_name_raises_or_returns(name: str, node: nodes.NodeNG) -> bool:
933-
if isinstance(node, (nodes.Raise, nodes.Assert, nodes.Return)):
941+
if isinstance(node, (nodes.Raise, nodes.Assert, nodes.Return, nodes.Continue)):
934942
return True
935943
if (
936944
isinstance(node, nodes.AnnAssign)
@@ -1993,11 +2001,19 @@ def _report_unfound_name_definition(
19932001
):
19942002
return
19952003

1996-
confidence = (
1997-
CONTROL_FLOW if node.name in current_consumer.consumed_uncertain else HIGH
1998-
)
2004+
confidence = HIGH
2005+
if node.name in current_consumer.names_under_always_false_test:
2006+
confidence = INFERENCE
2007+
elif node.name in current_consumer.consumed_uncertain:
2008+
confidence = CONTROL_FLOW
2009+
2010+
if node.name in current_consumer.names_defined_under_one_branch_only:
2011+
msg = "possibly-used-before-assignment"
2012+
else:
2013+
msg = "used-before-assignment"
2014+
19992015
self.add_message(
2000-
"used-before-assignment",
2016+
msg,
20012017
args=node.name,
20022018
node=node,
20032019
confidence=confidence,

pylintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ disable=
109109
# We anticipate #3512 where it will become optional
110110
fixme,
111111
consider-using-assignment-expr,
112+
possibly-used-before-assignment,
112113

113114

114115
[REPORTS]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
redefined-outer-name:11:4:12:12::Redefining name 'err' from outer scope (line 8):UNDEFINED
22
redefined-outer-name:57:8:58:16::Redefining name 'err' from outer scope (line 51):UNDEFINED
3-
used-before-assignment:69:14:69:29:func:Using variable 'CustomException' before assignment:CONTROL_FLOW
3+
used-before-assignment:69:14:69:29:func:Using variable 'CustomException' before assignment:HIGH
44
redefined-outer-name:71:4:72:12:func:Redefining name 'CustomException' from outer scope (line 62):UNDEFINED

tests/functional/u/undefined/undefined_variable.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ undefined-variable:166:4:166:13::Undefined variable 'unicode_2':UNDEFINED
2727
undefined-variable:171:4:171:13::Undefined variable 'unicode_3':UNDEFINED
2828
undefined-variable:226:25:226:37:LambdaClass4.<lambda>:Undefined variable 'LambdaClass4':UNDEFINED
2929
undefined-variable:234:25:234:37:LambdaClass5.<lambda>:Undefined variable 'LambdaClass5':UNDEFINED
30-
used-before-assignment:255:26:255:34:func_should_fail:Using variable 'datetime' before assignment:CONTROL_FLOW
30+
used-before-assignment:255:26:255:34:func_should_fail:Using variable 'datetime' before assignment:INFERENCE
3131
undefined-variable:291:18:291:24:not_using_loop_variable_accordingly:Undefined variable 'iteree':UNDEFINED
3232
undefined-variable:308:27:308:28:undefined_annotation:Undefined variable 'x':UNDEFINED
3333
used-before-assignment:309:7:309:8:undefined_annotation:Using variable 'x' before assignment:HIGH

0 commit comments

Comments
 (0)