351 lines
13 KiB
Python
351 lines
13 KiB
Python
|
# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
|
||
|
# For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE
|
||
|
# Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt
|
||
|
|
||
|
from __future__ import annotations
|
||
|
|
||
|
import sys
|
||
|
from typing import TYPE_CHECKING, Tuple, Type, cast
|
||
|
|
||
|
from astroid import nodes
|
||
|
|
||
|
from pylint.checkers import BaseChecker, utils
|
||
|
from pylint.checkers.utils import only_required_for_messages, safe_infer
|
||
|
from pylint.interfaces import INFERENCE
|
||
|
|
||
|
if TYPE_CHECKING:
|
||
|
from pylint.lint import PyLinter
|
||
|
|
||
|
if sys.version_info >= (3, 10):
|
||
|
from typing import TypeGuard
|
||
|
else:
|
||
|
from typing_extensions import TypeGuard
|
||
|
|
||
|
|
||
|
class CodeStyleChecker(BaseChecker):
|
||
|
"""Checkers that can improve code consistency.
|
||
|
|
||
|
As such they don't necessarily provide a performance benefit and
|
||
|
are often times opinionated.
|
||
|
|
||
|
Before adding another checker here, consider this:
|
||
|
1. Does the checker provide a clear benefit,
|
||
|
i.e. detect a common issue or improve performance
|
||
|
=> it should probably be part of the core checker classes
|
||
|
2. Is it something that would improve code consistency,
|
||
|
maybe because it's slightly better with regard to performance
|
||
|
and therefore preferred => this is the right place
|
||
|
3. Everything else should go into another extension
|
||
|
"""
|
||
|
|
||
|
name = "code_style"
|
||
|
msgs = {
|
||
|
"R6101": (
|
||
|
"Consider using namedtuple or dataclass for dictionary values",
|
||
|
"consider-using-namedtuple-or-dataclass",
|
||
|
"Emitted when dictionary values can be replaced by namedtuples or dataclass instances.",
|
||
|
),
|
||
|
"R6102": (
|
||
|
"Consider using an in-place tuple instead of list",
|
||
|
"consider-using-tuple",
|
||
|
"Only for style consistency! "
|
||
|
"Emitted where an in-place defined ``list`` can be replaced by a ``tuple``. "
|
||
|
"Due to optimizations by CPython, there is no performance benefit from it.",
|
||
|
),
|
||
|
"R6103": (
|
||
|
"Use '%s' instead",
|
||
|
"consider-using-assignment-expr",
|
||
|
"Emitted when an if assignment is directly followed by an if statement and "
|
||
|
"both can be combined by using an assignment expression ``:=``. "
|
||
|
"Requires Python 3.8 and ``py-version >= 3.8``.",
|
||
|
),
|
||
|
"R6104": (
|
||
|
"Use '%s' to do an augmented assign directly",
|
||
|
"consider-using-augmented-assign",
|
||
|
"Emitted when an assignment is referring to the object that it is assigning "
|
||
|
"to. This can be changed to be an augmented assign.\n"
|
||
|
"Disabled by default!",
|
||
|
{
|
||
|
"default_enabled": False,
|
||
|
},
|
||
|
),
|
||
|
"R6105": (
|
||
|
"Prefer 'typing.NamedTuple' over 'collections.namedtuple'",
|
||
|
"prefer-typing-namedtuple",
|
||
|
"'typing.NamedTuple' uses the well-known 'class' keyword "
|
||
|
"with type-hints for readability (it's also faster as it avoids "
|
||
|
"an internal exec call).\n"
|
||
|
"Disabled by default!",
|
||
|
{
|
||
|
"default_enabled": False,
|
||
|
},
|
||
|
),
|
||
|
}
|
||
|
options = (
|
||
|
(
|
||
|
"max-line-length-suggestions",
|
||
|
{
|
||
|
"type": "int",
|
||
|
"default": 0,
|
||
|
"metavar": "<int>",
|
||
|
"help": (
|
||
|
"Max line length for which to sill emit suggestions. "
|
||
|
"Used to prevent optional suggestions which would get split "
|
||
|
"by a code formatter (e.g., black). "
|
||
|
"Will default to the setting for ``max-line-length``."
|
||
|
),
|
||
|
},
|
||
|
),
|
||
|
)
|
||
|
|
||
|
def open(self) -> None:
|
||
|
py_version = self.linter.config.py_version
|
||
|
self._py36_plus = py_version >= (3, 6)
|
||
|
self._py38_plus = py_version >= (3, 8)
|
||
|
self._max_length: int = (
|
||
|
self.linter.config.max_line_length_suggestions
|
||
|
or self.linter.config.max_line_length
|
||
|
)
|
||
|
|
||
|
@only_required_for_messages("prefer-typing-namedtuple")
|
||
|
def visit_call(self, node: nodes.Call) -> None:
|
||
|
if self._py36_plus:
|
||
|
called = safe_infer(node.func)
|
||
|
if called and called.qname() == "collections.namedtuple":
|
||
|
self.add_message(
|
||
|
"prefer-typing-namedtuple", node=node, confidence=INFERENCE
|
||
|
)
|
||
|
|
||
|
@only_required_for_messages("consider-using-namedtuple-or-dataclass")
|
||
|
def visit_dict(self, node: nodes.Dict) -> None:
|
||
|
self._check_dict_consider_namedtuple_dataclass(node)
|
||
|
|
||
|
@only_required_for_messages("consider-using-tuple")
|
||
|
def visit_for(self, node: nodes.For) -> None:
|
||
|
if isinstance(node.iter, nodes.List):
|
||
|
self.add_message("consider-using-tuple", node=node.iter)
|
||
|
|
||
|
@only_required_for_messages("consider-using-tuple")
|
||
|
def visit_comprehension(self, node: nodes.Comprehension) -> None:
|
||
|
if isinstance(node.iter, nodes.List):
|
||
|
self.add_message("consider-using-tuple", node=node.iter)
|
||
|
|
||
|
@only_required_for_messages("consider-using-assignment-expr")
|
||
|
def visit_if(self, node: nodes.If) -> None:
|
||
|
if self._py38_plus:
|
||
|
self._check_consider_using_assignment_expr(node)
|
||
|
|
||
|
def _check_dict_consider_namedtuple_dataclass(self, node: nodes.Dict) -> None:
|
||
|
"""Check if dictionary values can be replaced by Namedtuple or Dataclass."""
|
||
|
if not (
|
||
|
isinstance(node.parent, (nodes.Assign, nodes.AnnAssign))
|
||
|
and isinstance(node.parent.parent, nodes.Module)
|
||
|
or isinstance(node.parent, nodes.AnnAssign)
|
||
|
and isinstance(node.parent.target, nodes.AssignName)
|
||
|
and utils.is_assign_name_annotated_with(node.parent.target, "Final")
|
||
|
):
|
||
|
# If dict is not part of an 'Assign' or 'AnnAssign' node in
|
||
|
# a module context OR 'AnnAssign' with 'Final' annotation, skip check.
|
||
|
return
|
||
|
|
||
|
# All dict_values are itself dict nodes
|
||
|
if len(node.items) > 1 and all(
|
||
|
isinstance(dict_value, nodes.Dict) for _, dict_value in node.items
|
||
|
):
|
||
|
KeyTupleT = Tuple[Type[nodes.NodeNG], str]
|
||
|
|
||
|
# Makes sure all keys are 'Const' string nodes
|
||
|
keys_checked: set[KeyTupleT] = set()
|
||
|
for _, dict_value in node.items:
|
||
|
dict_value = cast(nodes.Dict, dict_value)
|
||
|
for key, _ in dict_value.items:
|
||
|
key_tuple = (type(key), key.as_string())
|
||
|
if key_tuple in keys_checked:
|
||
|
continue
|
||
|
inferred = safe_infer(key)
|
||
|
if not (
|
||
|
isinstance(inferred, nodes.Const)
|
||
|
and inferred.pytype() == "builtins.str"
|
||
|
):
|
||
|
return
|
||
|
keys_checked.add(key_tuple)
|
||
|
|
||
|
# Makes sure all subdicts have at least 1 common key
|
||
|
key_tuples: list[tuple[KeyTupleT, ...]] = []
|
||
|
for _, dict_value in node.items:
|
||
|
dict_value = cast(nodes.Dict, dict_value)
|
||
|
key_tuples.append(
|
||
|
tuple((type(key), key.as_string()) for key, _ in dict_value.items)
|
||
|
)
|
||
|
keys_intersection: set[KeyTupleT] = set(key_tuples[0])
|
||
|
for sub_key_tuples in key_tuples[1:]:
|
||
|
keys_intersection.intersection_update(sub_key_tuples)
|
||
|
if not keys_intersection:
|
||
|
return
|
||
|
|
||
|
self.add_message("consider-using-namedtuple-or-dataclass", node=node)
|
||
|
return
|
||
|
|
||
|
# All dict_values are itself either list or tuple nodes
|
||
|
if len(node.items) > 1 and all(
|
||
|
isinstance(dict_value, (nodes.List, nodes.Tuple))
|
||
|
for _, dict_value in node.items
|
||
|
):
|
||
|
# Make sure all sublists have the same length > 0
|
||
|
list_length = len(node.items[0][1].elts)
|
||
|
if list_length == 0:
|
||
|
return
|
||
|
for _, dict_value in node.items[1:]:
|
||
|
if len(dict_value.elts) != list_length:
|
||
|
return
|
||
|
|
||
|
# Make sure at least one list entry isn't a dict
|
||
|
for _, dict_value in node.items:
|
||
|
if all(isinstance(entry, nodes.Dict) for entry in dict_value.elts):
|
||
|
return
|
||
|
|
||
|
self.add_message("consider-using-namedtuple-or-dataclass", node=node)
|
||
|
return
|
||
|
|
||
|
def _check_consider_using_assignment_expr(self, node: nodes.If) -> None:
|
||
|
"""Check if an assignment expression (walrus operator) can be used.
|
||
|
|
||
|
For example if an assignment is directly followed by an if statement:
|
||
|
>>> x = 2
|
||
|
>>> if x:
|
||
|
>>> ...
|
||
|
|
||
|
Can be replaced by:
|
||
|
>>> if (x := 2):
|
||
|
>>> ...
|
||
|
|
||
|
Note: Assignment expressions were added in Python 3.8
|
||
|
"""
|
||
|
# Check if `node.test` contains a `Name` node
|
||
|
node_name: nodes.Name | None = None
|
||
|
if isinstance(node.test, nodes.Name):
|
||
|
node_name = node.test
|
||
|
elif (
|
||
|
isinstance(node.test, nodes.UnaryOp)
|
||
|
and node.test.op == "not"
|
||
|
and isinstance(node.test.operand, nodes.Name)
|
||
|
):
|
||
|
node_name = node.test.operand
|
||
|
elif (
|
||
|
isinstance(node.test, nodes.Compare)
|
||
|
and isinstance(node.test.left, nodes.Name)
|
||
|
and len(node.test.ops) == 1
|
||
|
):
|
||
|
node_name = node.test.left
|
||
|
else:
|
||
|
return
|
||
|
|
||
|
# Make sure the previous node is an assignment to the same name
|
||
|
# used in `node.test`. Furthermore, ignore if assignment spans multiple lines.
|
||
|
prev_sibling = node.previous_sibling()
|
||
|
if CodeStyleChecker._check_prev_sibling_to_if_stmt(
|
||
|
prev_sibling, node_name.name
|
||
|
):
|
||
|
# Check if match statement would be a better fit.
|
||
|
# I.e. multiple ifs that test the same name.
|
||
|
if CodeStyleChecker._check_ignore_assignment_expr_suggestion(
|
||
|
node, node_name.name
|
||
|
):
|
||
|
return
|
||
|
|
||
|
# Build suggestion string. Check length of suggestion
|
||
|
# does not exceed max-line-length-suggestions
|
||
|
test_str = node.test.as_string().replace(
|
||
|
node_name.name,
|
||
|
f"({node_name.name} := {prev_sibling.value.as_string()})",
|
||
|
1,
|
||
|
)
|
||
|
suggestion = f"if {test_str}:"
|
||
|
if (
|
||
|
node.col_offset is not None
|
||
|
and len(suggestion) + node.col_offset > self._max_length
|
||
|
or len(suggestion) > self._max_length
|
||
|
):
|
||
|
return
|
||
|
|
||
|
self.add_message(
|
||
|
"consider-using-assignment-expr",
|
||
|
node=node_name,
|
||
|
args=(suggestion,),
|
||
|
)
|
||
|
|
||
|
@staticmethod
|
||
|
def _check_prev_sibling_to_if_stmt(
|
||
|
prev_sibling: nodes.NodeNG | None, name: str | None
|
||
|
) -> TypeGuard[nodes.Assign | nodes.AnnAssign]:
|
||
|
"""Check if previous sibling is an assignment with the same name.
|
||
|
|
||
|
Ignore statements which span multiple lines.
|
||
|
"""
|
||
|
if prev_sibling is None or prev_sibling.tolineno - prev_sibling.fromlineno != 0:
|
||
|
return False
|
||
|
|
||
|
if (
|
||
|
isinstance(prev_sibling, nodes.Assign)
|
||
|
and len(prev_sibling.targets) == 1
|
||
|
and isinstance(prev_sibling.targets[0], nodes.AssignName)
|
||
|
and prev_sibling.targets[0].name == name
|
||
|
):
|
||
|
return True
|
||
|
if (
|
||
|
isinstance(prev_sibling, nodes.AnnAssign)
|
||
|
and isinstance(prev_sibling.target, nodes.AssignName)
|
||
|
and prev_sibling.target.name == name
|
||
|
):
|
||
|
return True
|
||
|
return False
|
||
|
|
||
|
@staticmethod
|
||
|
def _check_ignore_assignment_expr_suggestion(
|
||
|
node: nodes.If, name: str | None
|
||
|
) -> bool:
|
||
|
"""Return True if suggestion for assignment expr should be ignored.
|
||
|
|
||
|
E.g., in cases where a match statement would be a better fit
|
||
|
(multiple conditions).
|
||
|
"""
|
||
|
if isinstance(node.test, nodes.Compare):
|
||
|
next_if_node: nodes.If | None = None
|
||
|
next_sibling = node.next_sibling()
|
||
|
if len(node.orelse) == 1 and isinstance(node.orelse[0], nodes.If):
|
||
|
# elif block
|
||
|
next_if_node = node.orelse[0]
|
||
|
elif isinstance(next_sibling, nodes.If):
|
||
|
# separate if block
|
||
|
next_if_node = next_sibling
|
||
|
|
||
|
if ( # pylint: disable=too-many-boolean-expressions
|
||
|
next_if_node is not None
|
||
|
and (
|
||
|
isinstance(next_if_node.test, nodes.Compare)
|
||
|
and isinstance(next_if_node.test.left, nodes.Name)
|
||
|
and next_if_node.test.left.name == name
|
||
|
or isinstance(next_if_node.test, nodes.Name)
|
||
|
and next_if_node.test.name == name
|
||
|
)
|
||
|
):
|
||
|
return True
|
||
|
return False
|
||
|
|
||
|
@only_required_for_messages("consider-using-augmented-assign")
|
||
|
def visit_assign(self, node: nodes.Assign) -> None:
|
||
|
is_aug, op = utils.is_augmented_assign(node)
|
||
|
if is_aug:
|
||
|
self.add_message(
|
||
|
"consider-using-augmented-assign",
|
||
|
args=f"{op}=",
|
||
|
node=node,
|
||
|
line=node.lineno,
|
||
|
col_offset=node.col_offset,
|
||
|
confidence=INFERENCE,
|
||
|
)
|
||
|
|
||
|
|
||
|
def register(linter: PyLinter) -> None:
|
||
|
linter.register_checker(CodeStyleChecker(linter))
|