| # 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)) |