summaryrefslogtreecommitdiff
path: root/pylint/checkers/variables.py
diff options
context:
space:
mode:
Diffstat (limited to 'pylint/checkers/variables.py')
-rw-r--r--pylint/checkers/variables.py380
1 files changed, 219 insertions, 161 deletions
diff --git a/pylint/checkers/variables.py b/pylint/checkers/variables.py
index 8cd1ed83c..2504c0c84 100644
--- a/pylint/checkers/variables.py
+++ b/pylint/checkers/variables.py
@@ -1,6 +1,6 @@
# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
-# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE
-# Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt
+# For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE
+# Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt
"""Variables checkers for Python code."""
@@ -11,11 +11,10 @@ import copy
import itertools
import os
import re
-import sys
from collections import defaultdict
from collections.abc import Generator, Iterable, Iterator
from enum import Enum
-from functools import lru_cache
+from functools import cached_property
from typing import TYPE_CHECKING, Any, NamedTuple
import astroid
@@ -26,18 +25,15 @@ from astroid.typing import InferenceResult
from pylint.checkers import BaseChecker, utils
from pylint.checkers.utils import (
in_type_checking_block,
+ is_module_ignored,
is_postponed_evaluation_enabled,
is_sys_guard,
+ overridden_method,
)
from pylint.constants import PY39_PLUS, TYPING_NEVER, TYPING_NORETURN
from pylint.interfaces import CONTROL_FLOW, HIGH, INFERENCE, INFERENCE_FAILURE
from pylint.typing import MessageDefinitionTuple
-if sys.version_info >= (3, 8):
- from functools import cached_property
-else:
- from astroid.decorators import cachedproperty as cached_property
-
if TYPE_CHECKING:
from pylint.lint import PyLinter
@@ -151,26 +147,6 @@ def _is_from_future_import(stmt: nodes.ImportFrom, name: str) -> bool | None:
return None
-@lru_cache(maxsize=1000)
-def overridden_method(
- klass: nodes.LocalsDictNodeNG, name: str | None
-) -> nodes.FunctionDef | None:
- """Get overridden method if any."""
- try:
- parent = next(klass.local_attr_ancestors(name))
- except (StopIteration, KeyError):
- return None
- try:
- meth_node = parent[name]
- except KeyError:
- # We have found an ancestor defining <name> but it's not in the local
- # dictionary. This may happen with astroid built from living objects.
- return None
- if isinstance(meth_node, nodes.FunctionDef):
- return meth_node
- return None
-
-
def _get_unpacking_extra_info(node: nodes.Assign, inferred: InferenceResult) -> str:
"""Return extra information to add to the message for unpacking-non-sequence
and unbalanced-tuple/dict-unpacking errors.
@@ -242,17 +218,11 @@ def _detect_global_scope(
return node.lineno < defframe.lineno # type: ignore[no-any-return]
if not isinstance(node.parent, (nodes.FunctionDef, nodes.Arguments)):
return False
- elif any(
- not isinstance(f, (nodes.ClassDef, nodes.Module)) for f in (frame, defframe)
- ):
- # Not interested in other frames, since they are already
- # not in a global scope.
- return False
break_scopes = []
- for current_scope in (scope, def_scope):
+ for current_scope in (scope or frame, def_scope):
# Look for parent scopes. If there is anything different
- # than a module or a class scope, then they frames don't
+ # than a module or a class scope, then the frames don't
# share a global scope.
parent_scope = current_scope
while parent_scope:
@@ -263,7 +233,7 @@ def _detect_global_scope(
parent_scope = parent_scope.parent.scope()
else:
break
- if break_scopes and len(set(break_scopes)) != 1:
+ if len(set(break_scopes)) > 1:
# Store different scopes than expected.
# If the stored scopes are, in fact, the very same, then it means
# that the two frames (frame and defframe) share the same scope,
@@ -564,7 +534,6 @@ class NamesConsumer:
copy.copy(node.locals), {}, collections.defaultdict(list), scope_type
)
self.node = node
- self._if_nodes_deemed_uncertain: set[nodes.If] = set()
def __repr__(self) -> str:
_to_consumes = [f"{k}->{v}" for k, v in self._atomic.to_consume.items()]
@@ -715,26 +684,28 @@ scope_type : {self._atomic.scope_type}
return found_nodes
@staticmethod
- def _exhaustively_define_name_raise_or_return(
- name: str, node: nodes.NodeNG
- ) -> bool:
- """Return True if there is a collectively exhaustive set of paths under
- this `if_node` that define `name`, raise, or return.
+ def _inferred_to_define_name_raise_or_return(name: str, node: nodes.NodeNG) -> bool:
+ """Return True if there is a path under this `if_node`
+ that is inferred to define `name`, raise, or return.
"""
# Handle try and with
if isinstance(node, (nodes.TryExcept, nodes.TryFinally)):
# Allow either a path through try/else/finally OR a path through ALL except handlers
- return (
- NamesConsumer._defines_name_raises_or_returns_recursive(name, node)
- or isinstance(node, nodes.TryExcept)
- and all(
- NamesConsumer._defines_name_raises_or_returns_recursive(
- name, handler
- )
- for handler in node.handlers
+ try_except_node = node
+ if isinstance(node, nodes.TryFinally):
+ try_except_node = next(
+ (child for child in node.nodes_of_class(nodes.TryExcept)),
+ None,
)
+ handlers = try_except_node.handlers if try_except_node else []
+ return NamesConsumer._defines_name_raises_or_returns_recursive(
+ name, node
+ ) or all(
+ NamesConsumer._defines_name_raises_or_returns_recursive(name, handler)
+ for handler in handlers
)
- if isinstance(node, nodes.With):
+
+ if isinstance(node, (nodes.With, nodes.For, nodes.While)):
return NamesConsumer._defines_name_raises_or_returns_recursive(name, node)
if not isinstance(node, nodes.If):
@@ -748,13 +719,29 @@ scope_type : {self._atomic.scope_type}
if NamesConsumer._defines_name_raises_or_returns(name, node):
return True
- # If there is no else, then there is no collectively exhaustive set of paths
- if not node.orelse:
- return False
+ test = node.test.value if isinstance(node.test, nodes.NamedExpr) else node.test
+ all_inferred = utils.infer_all(test)
+ only_search_if = False
+ only_search_else = True
+ for inferred in all_inferred:
+ if not isinstance(inferred, nodes.Const):
+ only_search_else = False
+ continue
+ val = inferred.value
+ only_search_if = only_search_if or (val != NotImplemented and val)
+ only_search_else = only_search_else and not val
+
+ # Only search else branch when test condition is inferred to be false
+ if all_inferred and only_search_else:
+ return NamesConsumer._branch_handles_name(name, node.orelse)
+ # Only search if branch when test condition is inferred to be true
+ if all_inferred and only_search_if:
+ return NamesConsumer._branch_handles_name(name, node.body)
+ # Search both if and else branches
return NamesConsumer._branch_handles_name(
name, node.body
- ) and NamesConsumer._branch_handles_name(name, node.orelse)
+ ) or NamesConsumer._branch_handles_name(name, node.orelse)
@staticmethod
def _branch_handles_name(name: str, body: Iterable[nodes.NodeNG]) -> bool:
@@ -762,9 +749,16 @@ scope_type : {self._atomic.scope_type}
NamesConsumer._defines_name_raises_or_returns(name, if_body_stmt)
or isinstance(
if_body_stmt,
- (nodes.If, nodes.TryExcept, nodes.TryFinally, nodes.With),
+ (
+ nodes.If,
+ nodes.TryExcept,
+ nodes.TryFinally,
+ nodes.With,
+ nodes.For,
+ nodes.While,
+ ),
)
- and NamesConsumer._exhaustively_define_name_raise_or_return(
+ and NamesConsumer._inferred_to_define_name_raise_or_return(
name, if_body_stmt
)
for if_body_stmt in body
@@ -776,57 +770,80 @@ scope_type : {self._atomic.scope_type}
"""Identify nodes of uncertain execution because they are defined under
tests that evaluate false.
- Don't identify a node if there is a collectively exhaustive set of paths
- that define the name, raise, or return (e.g. every if/else branch).
+ Don't identify a node if there is a path that is inferred to
+ define the name, raise, or return (e.g. any executed if/elif/else branch).
"""
uncertain_nodes = []
for other_node in found_nodes:
- if in_type_checking_block(other_node):
- continue
-
- if not isinstance(other_node, nodes.AssignName):
+ if isinstance(other_node, nodes.AssignName):
+ name = other_node.name
+ elif isinstance(other_node, (nodes.Import, nodes.ImportFrom)):
+ name = node.name
+ else:
continue
- closest_if = utils.get_node_first_ancestor_of_type(other_node, nodes.If)
- if closest_if is None:
- continue
- if node.frame() is not closest_if.frame():
- continue
- if closest_if is not None and closest_if.parent_of(node):
+ all_if = [
+ n
+ for n in other_node.node_ancestors()
+ if isinstance(n, nodes.If) and not n.parent_of(node)
+ ]
+ if not all_if:
continue
- # Name defined in every if/else branch
- if NamesConsumer._exhaustively_define_name_raise_or_return(
- other_node.name, closest_if
+ closest_if = all_if[0]
+ if (
+ isinstance(node, nodes.AssignName)
+ and node.frame() is not closest_if.frame()
):
continue
+ if closest_if.parent_of(node):
+ continue
- # Higher-level if already determined to be always false
- if any(
- if_node.parent_of(closest_if)
- for if_node in self._if_nodes_deemed_uncertain
- ):
- uncertain_nodes.append(other_node)
+ outer_if = all_if[-1]
+ if NamesConsumer._node_guarded_by_same_test(node, outer_if):
continue
- # All inferred values must test false
- if isinstance(closest_if.test, nodes.NamedExpr):
- test = closest_if.test.value
- else:
- test = closest_if.test
- all_inferred = utils.infer_all(test)
- if not all_inferred or not all(
- isinstance(inferred, nodes.Const) and not inferred.value
- for inferred in all_inferred
- ):
+ # Name defined in the if/else control flow
+ if NamesConsumer._inferred_to_define_name_raise_or_return(name, outer_if):
continue
uncertain_nodes.append(other_node)
- self._if_nodes_deemed_uncertain.add(closest_if)
return uncertain_nodes
@staticmethod
+ def _node_guarded_by_same_test(node: nodes.NodeNG, other_if: nodes.If) -> bool:
+ """Identify if `node` is guarded by an equivalent test as `other_if`.
+
+ Two tests are equivalent if their string representations are identical
+ or if their inferred values consist only of constants and those constants
+ are identical, and the if test guarding `node` is not a Name.
+ """
+ other_if_test_as_string = other_if.test.as_string()
+ other_if_test_all_inferred = utils.infer_all(other_if.test)
+ for ancestor in node.node_ancestors():
+ if not isinstance(ancestor, nodes.If):
+ continue
+ if ancestor.test.as_string() == other_if_test_as_string:
+ return True
+ if isinstance(ancestor.test, nodes.Name):
+ continue
+ all_inferred = utils.infer_all(ancestor.test)
+ if len(all_inferred) == len(other_if_test_all_inferred):
+ if any(
+ not isinstance(test, nodes.Const)
+ for test in (*all_inferred, *other_if_test_all_inferred)
+ ):
+ continue
+ if {test.value for test in all_inferred} != {
+ test.value for test in other_if_test_all_inferred
+ }:
+ continue
+ return True
+
+ return False
+
+ @staticmethod
def _uncertain_nodes_in_except_blocks(
found_nodes: list[nodes.NodeNG],
node: nodes.NodeNG,
@@ -921,6 +938,18 @@ scope_type : {self._atomic.scope_type}
for child_named_expr in node.nodes_of_class(nodes.NamedExpr)
):
return True
+ if isinstance(node, (nodes.Import, nodes.ImportFrom)) and any(
+ (node_name[1] and node_name[1] == name) or (node_name[0] == name)
+ for node_name in node.names
+ ):
+ return True
+ if isinstance(node, nodes.With) and any(
+ isinstance(item[1], nodes.AssignName) and item[1].name == name
+ for item in node.items
+ ):
+ return True
+ if isinstance(node, (nodes.ClassDef, nodes.FunctionDef)) and node.name == name:
+ return True
return False
@staticmethod
@@ -939,18 +968,23 @@ scope_type : {self._atomic.scope_type}
for nested_stmt in stmt.get_children()
):
return True
+ if isinstance(
+ stmt, nodes.TryExcept
+ ) and NamesConsumer._defines_name_raises_or_returns_recursive(name, stmt):
+ return True
return False
@staticmethod
def _check_loop_finishes_via_except(
node: nodes.NodeNG, other_node_try_except: nodes.TryExcept
) -> bool:
- """Check for a case described in https://github.com/PyCQA/pylint/issues/5683.
+ """Check for a specific control flow scenario.
- It consists of a specific control flow scenario where the only
- non-break exit from a loop consists of the very except handler we are
- examining, such that code in the `else` branch of the loop can depend on it
- being assigned.
+ Described in https://github.com/pylint-dev/pylint/issues/5683.
+
+ A scenario where the only non-break exit from a loop consists of the very
+ except handler we are examining, such that code in the `else` branch of
+ the loop can depend on it being assigned.
Example:
@@ -1247,6 +1281,9 @@ class VariablesChecker(BaseChecker):
tuple[nodes.ExceptHandler, nodes.AssignName]
] = []
"""This is a queue, last in first out."""
+ self._evaluated_type_checking_scopes: dict[
+ str, list[nodes.LocalsDictNodeNG]
+ ] = {}
self._postponed_evaluation_enabled = False
@utils.only_required_for_messages(
@@ -1707,21 +1744,16 @@ class VariablesChecker(BaseChecker):
if found_nodes is None:
return (VariableVisitConsumerAction.CONTINUE, None)
if not found_nodes:
- if node.name in current_consumer.consumed_uncertain:
- confidence = CONTROL_FLOW
- else:
- confidence = HIGH
- self.add_message(
- "used-before-assignment",
- args=node.name,
- node=node,
- confidence=confidence,
- )
+ self._report_unfound_name_definition(node, current_consumer)
# Mark for consumption any nodes added to consumed_uncertain by
# get_next_to_consume() because they might not have executed.
+ nodes_to_consume = current_consumer.consumed_uncertain[node.name]
+ nodes_to_consume = self._filter_type_checking_import_from_consumption(
+ node, nodes_to_consume
+ )
return (
VariableVisitConsumerAction.RETURN,
- current_consumer.consumed_uncertain[node.name],
+ nodes_to_consume,
)
self._check_late_binding_closure(node)
@@ -1858,7 +1890,9 @@ class VariablesChecker(BaseChecker):
confidence=HIGH,
)
- elif self._is_only_type_assignment(node, defstmt):
+ elif not self._is_builtin(node.name) and self._is_only_type_assignment(
+ node, defstmt
+ ):
if node.scope().locals.get(node.name):
self.add_message(
"used-before-assignment", args=node.name, node=node, confidence=HIGH
@@ -1885,6 +1919,61 @@ class VariablesChecker(BaseChecker):
return (VariableVisitConsumerAction.RETURN, found_nodes)
+ def _report_unfound_name_definition(
+ self, node: nodes.NodeNG, current_consumer: NamesConsumer
+ ) -> None:
+ """Reports used-before-assignment when all name definition nodes
+ get filtered out by NamesConsumer.
+ """
+ if (
+ self._postponed_evaluation_enabled
+ and utils.is_node_in_type_annotation_context(node)
+ ):
+ return
+ if self._is_builtin(node.name):
+ return
+ if self._is_variable_annotation_in_function(node):
+ return
+ if (
+ node.name in self._evaluated_type_checking_scopes
+ and node.scope() in self._evaluated_type_checking_scopes[node.name]
+ ):
+ return
+
+ confidence = (
+ CONTROL_FLOW if node.name in current_consumer.consumed_uncertain else HIGH
+ )
+ self.add_message(
+ "used-before-assignment",
+ args=node.name,
+ node=node,
+ confidence=confidence,
+ )
+
+ def _filter_type_checking_import_from_consumption(
+ self, node: nodes.NodeNG, nodes_to_consume: list[nodes.NodeNG]
+ ) -> list[nodes.NodeNG]:
+ """Do not consume type-checking import node as used-before-assignment
+ may invoke in different scopes.
+ """
+ type_checking_import = next(
+ (
+ n
+ for n in nodes_to_consume
+ if isinstance(n, (nodes.Import, nodes.ImportFrom))
+ and in_type_checking_block(n)
+ ),
+ None,
+ )
+ # If used-before-assignment reported for usage of type checking import
+ # keep track of its scope
+ if type_checking_import and not self._is_variable_annotation_in_function(node):
+ self._evaluated_type_checking_scopes.setdefault(node.name, []).append(
+ node.scope()
+ )
+ nodes_to_consume = [n for n in nodes_to_consume if n != type_checking_import]
+ return nodes_to_consume
+
@utils.only_required_for_messages("no-name-in-module")
def visit_import(self, node: nodes.Import) -> None:
"""Check modules attribute accesses."""
@@ -2049,7 +2138,6 @@ class VariablesChecker(BaseChecker):
parent = parent.parent
return False
- # pylint: disable = too-many-branches
@staticmethod
def _is_variable_violation(
node: nodes.Name,
@@ -2203,42 +2291,6 @@ class VariablesChecker(BaseChecker):
anc is defnode.value for anc in node.node_ancestors()
)
- # Look for type checking definitions inside a type checking guard.
- # Relevant for function annotations only, not variable annotations (AnnAssign)
- if (
- isinstance(defstmt, (nodes.Import, nodes.ImportFrom))
- and isinstance(defstmt.parent, nodes.If)
- and in_type_checking_block(defstmt)
- and not in_type_checking_block(node)
- ):
- defstmt_parent = defstmt.parent
-
- maybe_annotation = utils.get_node_first_ancestor_of_type(
- node, nodes.AnnAssign
- )
- if not (
- maybe_annotation
- and utils.get_node_first_ancestor_of_type(
- maybe_annotation, nodes.FunctionDef
- )
- ):
- # Exempt those definitions that are used inside the type checking
- # guard or that are defined in any elif/else type checking guard branches.
- used_in_branch = defstmt_parent.parent_of(node)
- if not used_in_branch:
- if defstmt_parent.has_elif_block():
- defined_in_or_else = utils.is_defined(
- node.name, defstmt_parent.orelse[0]
- )
- else:
- defined_in_or_else = any(
- utils.is_defined(node.name, content)
- for content in defstmt_parent.orelse
- )
-
- if not defined_in_or_else:
- maybe_before_assign = True
-
return maybe_before_assign, annotation_return, use_outer_definition
@staticmethod
@@ -2254,7 +2306,7 @@ class VariablesChecker(BaseChecker):
return any(
VariablesChecker._maybe_used_and_assigned_at_once(elt)
for elt in defstmt.value.elts
- if isinstance(elt, NODES_WITH_VALUE_ATTR + (nodes.IfExp, nodes.Match))
+ if isinstance(elt, (*NODES_WITH_VALUE_ATTR, nodes.IfExp, nodes.Match))
)
value = defstmt.value
if isinstance(value, nodes.IfExp):
@@ -2278,18 +2330,15 @@ class VariablesChecker(BaseChecker):
for call in value.nodes_of_class(klass=nodes.Call)
)
- def _is_only_type_assignment(
- self, node: nodes.Name, defstmt: nodes.Statement
- ) -> bool:
+ def _is_builtin(self, name: str) -> bool:
+ return name in self.linter.config.additional_builtins or utils.is_builtin(name)
+
+ @staticmethod
+ def _is_only_type_assignment(node: nodes.Name, defstmt: nodes.Statement) -> bool:
"""Check if variable only gets assigned a type and never a value."""
if not isinstance(defstmt, nodes.AnnAssign) or defstmt.value:
return False
- if node.name in self.linter.config.additional_builtins or utils.is_builtin(
- node.name
- ):
- return False
-
defstmt_frame = defstmt.frame(future=True)
node_frame = node.frame(future=True)
@@ -2378,6 +2427,16 @@ class VariablesChecker(BaseChecker):
return True
return False
+ @staticmethod
+ def _is_variable_annotation_in_function(node: nodes.NodeNG) -> bool:
+ is_annotation = utils.get_node_first_ancestor_of_type(node, nodes.AnnAssign)
+ return (
+ is_annotation
+ and utils.get_node_first_ancestor_of_type( # type: ignore[return-value]
+ is_annotation, nodes.FunctionDef
+ )
+ )
+
def _ignore_class_scope(self, node: nodes.NodeNG) -> bool:
"""Return True if the node is in a local class scope, as an assignment.
@@ -2427,10 +2486,7 @@ class VariablesChecker(BaseChecker):
# the usage is safe because the function will not be defined either if
# the variable is not defined.
scope = node.scope()
- # FunctionDef subclasses Lambda due to a curious ontology. Check both.
- # See https://github.com/PyCQA/astroid/issues/291
- # TODO: Revisit when astroid 3.0 includes the change
- if isinstance(scope, nodes.Lambda) and any(
+ if isinstance(scope, (nodes.Lambda, nodes.FunctionDef)) and any(
asmt.scope().parent_of(scope) for asmt in astmts
):
return
@@ -2481,7 +2537,7 @@ class VariablesChecker(BaseChecker):
else_stmt, (nodes.Return, nodes.Raise, nodes.Break, nodes.Continue)
):
return
- # TODO: 2.16: Consider using RefactoringChecker._is_function_def_never_returning
+ # TODO: 3.0: Consider using RefactoringChecker._is_function_def_never_returning
if isinstance(else_stmt, nodes.Expr) and isinstance(
else_stmt.value, nodes.Call
):
@@ -2906,7 +2962,7 @@ class VariablesChecker(BaseChecker):
@staticmethod
def _nodes_to_unpack(node: nodes.NodeNG) -> list[nodes.NodeNG] | None:
"""Return the list of values of the `Assign` node."""
- if isinstance(node, (nodes.Tuple, nodes.List) + DICT_TYPES):
+ if isinstance(node, (nodes.Tuple, nodes.List, *DICT_TYPES)):
return node.itered() # type: ignore[no-any-return]
if isinstance(node, astroid.Instance) and any(
ancestor.qname() == "typing.NamedTuple" for ancestor in node.ancestors()
@@ -2961,7 +3017,9 @@ class VariablesChecker(BaseChecker):
if not isinstance(module, nodes.Module):
return None
except astroid.NotFoundError:
- if module.name in self._ignored_modules:
+ # Unable to import `name` from `module`. Since `name` may itself be a
+ # module, we first check if it matches the ignored modules.
+ if is_module_ignored(f"{module.qname()}.{name}", self._ignored_modules):
return None
self.add_message(
"no-name-in-module", args=(name, module.name), node=node