diff --git a/ChangeLog b/ChangeLog index d514a10be0..9a96ad270d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -10,28 +10,40 @@ Release date: TBA .. Put new features here and also in 'doc/whatsnew/2.11.rst' + +* Added ``py-version`` config key (if ``[MASTER]`` section). Used for version dependant checks. + Will default to whatever Python version pylint is executed with. + +* ``CodeStyleChecker`` + + * Added ``consider-using-assignment-expr``: Emitted when an assignment is directly followed by an if statement + and both can be combined by using an assignment expression ``:=``. Requires Python 3.8 + + Closes #4862 + * Added ``consider-using-f-string``: Emitted when .format() or '%' is being used to format a string. Closes #3592 +* Fix false positive for ``consider-using-with`` if a context manager is assigned to a + variable in different paths of control flow (e. g. if-else clause). + + Closes #4751 + What's New in Pylint 2.10.3? ============================ Release date: TBA - * Fix false positive for ``consider-using-with`` if a context manager is assigned to a - variable in different paths of control flow (e. g. if-else clause). +.. + Put bug fixes that should not wait for a new minor version here - Closes #4751 What's New in Pylint 2.10.2? ============================ Release date: 2021-08-21 -.. - Put bug fixes that should not wait for a new minor version here - * We now use platformdirs instead of appdirs since the latter is not maintained. Closes #4886 @@ -148,7 +160,6 @@ Release date: 2021-08-20 Closes #4042 - * Refactor of ``--list-msgs`` & ``--list-msgs-enabled``: both options now show whether messages are emittable with the current interpreter. Closes #4778 diff --git a/doc/whatsnew/2.11.rst b/doc/whatsnew/2.11.rst index e9c05b9091..bbb2a7b20b 100644 --- a/doc/whatsnew/2.11.rst +++ b/doc/whatsnew/2.11.rst @@ -20,6 +20,16 @@ New checkers Extensions ========== +* ``CodeStyleChecker`` + + * Added ``consider-using-assignment-expr``: Emitted when an assignment is directly followed by an if statement + and both can be combined by using an assignment expression ``:=``. Requires Python 3.8 + + Closes #4862 + Other Changes ============= + +* Added ``py-version`` config key (if ``[MASTER]`` section). Used for version dependant checks. + Will default to whatever Python version pylint is executed with. diff --git a/pylint/extensions/code_style.py b/pylint/extensions/code_style.py index 3f400fd18e..0d8bfd1648 100644 --- a/pylint/extensions/code_style.py +++ b/pylint/extensions/code_style.py @@ -1,4 +1,5 @@ -from typing import List, Set, Tuple, Type, Union, cast +import sys +from typing import List, Optional, Set, Tuple, Type, Union, cast from astroid import nodes @@ -6,6 +7,12 @@ from pylint.checkers.utils import check_messages, safe_infer from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter +from pylint.utils.utils import get_global_option + +if sys.version_info >= (3, 10): + from typing import TypeGuard +else: + from typing_extensions import TypeGuard class CodeStyleChecker(BaseChecker): @@ -41,12 +48,42 @@ class CodeStyleChecker(BaseChecker): "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", + ), } + options = ( + ( + "max-line-length-suggestions", + { + "type": "int", + "metavar": "", + "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 __init__(self, linter: PyLinter) -> None: """Initialize checker instance.""" super().__init__(linter=linter) + def open(self) -> None: + py_version: Tuple[int, int] = get_global_option(self, "py-version") # type: ignore + self._py38_plus = py_version >= (3, 8) + self._max_length: int = ( # type: ignore + self.config.max_line_length_suggestions + or get_global_option(self, "max-line-length") + ) + @check_messages("consider-using-namedtuple-or-dataclass") def visit_dict(self, node: nodes.Dict) -> None: self._check_dict_consider_namedtuple_dataclass(node) @@ -61,6 +98,11 @@ def visit_comprehension(self, node: nodes.Comprehension) -> None: if isinstance(node.iter, nodes.List): self.add_message("consider-using-tuple", node=node.iter) + @check_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 ( @@ -135,6 +177,131 @@ def _check_dict_consider_namedtuple_dataclass(self, node: nodes.Dict) -> None: 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 statment: + >>> 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: Optional[nodes.Name] = 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: Optional[nodes.NodeNG], name: Optional[str] + ) -> TypeGuard[Union[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: Optional[str] + ) -> bool: + """Return True if suggestion for assignment expr should be ignore. + + E.g., in cases where a match statement would be a better fit + (multiple conditions). + """ + if isinstance(node.test, nodes.Compare): + next_if_node: Optional[nodes.If] = 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 + def register(linter: PyLinter) -> None: linter.register_checker(CodeStyleChecker(linter)) diff --git a/pylint/extensions/typing.py b/pylint/extensions/typing.py index ce4e6cd01f..cf0281f330 100644 --- a/pylint/extensions/typing.py +++ b/pylint/extensions/typing.py @@ -1,5 +1,4 @@ -from functools import lru_cache -from typing import Dict, List, NamedTuple, Set, Union +from typing import Dict, List, NamedTuple, Set, Tuple, Union import astroid.bases from astroid import nodes @@ -12,6 +11,7 @@ ) from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter +from pylint.utils.utils import get_global_option class TypingAlias(NamedTuple): @@ -103,19 +103,6 @@ class TypingChecker(BaseChecker): ), } options = ( - ( - "py-version", - { - "default": (3, 7), - "type": "py_version", - "metavar": "", - "help": ( - "Min Python version to use for typing related checks, " - "e.g. ``3.7``. This should be equal to the min supported Python " - "version of the project." - ), - }, - ), ( "runtime-typing", { @@ -135,49 +122,38 @@ class TypingChecker(BaseChecker): ), ) + _should_check_typing_alias: bool + """The use of type aliases (PEP 585) requires Python 3.9 + or Python 3.7+ with postponed evaluation. + """ + + _should_check_alternative_union_syntax: bool + """The use of alternative union syntax (PEP 604) requires Python 3.10 + or Python 3.7+ with postponed evaluation. + """ + def __init__(self, linter: PyLinter) -> None: """Initialize checker instance.""" super().__init__(linter=linter) self._alias_name_collisions: Set[str] = set() self._consider_using_alias_msgs: List[DeprecatedTypingAliasMsg] = [] - @lru_cache() - def _py37_plus(self) -> bool: - return self.config.py_version >= (3, 7) - - @lru_cache() - def _py39_plus(self) -> bool: - return self.config.py_version >= (3, 9) + def open(self) -> None: + py_version: Tuple[int, int] = get_global_option(self, "py-version") # type: ignore + self._py37_plus = py_version >= (3, 7) + self._py39_plus = py_version >= (3, 9) + self._py310_plus = py_version >= (3, 10) - @lru_cache() - def _py310_plus(self) -> bool: - return self.config.py_version >= (3, 10) - - @lru_cache() - def _should_check_typing_alias(self) -> bool: - """The use of type aliases (PEP 585) requires Python 3.9 - or Python 3.7+ with postponed evaluation. - """ - return ( - self._py39_plus() - or self._py37_plus() - and self.config.runtime_typing is False + self._should_check_typing_alias = self._py39_plus or ( + self._py37_plus and self.config.runtime_typing is False ) - - @lru_cache() - def _should_check_alternative_union_syntax(self) -> bool: - """The use of alternative union syntax (PEP 604) requires Python 3.10 - or Python 3.7+ with postponed evaluation. - """ - return ( - self._py310_plus() - or self._py37_plus() - and self.config.runtime_typing is False + self._should_check_alternative_union_syntax = self._py310_plus or ( + self._py37_plus and self.config.runtime_typing is False ) def _msg_postponed_eval_hint(self, node) -> str: """Message hint if postponed evaluation isn't enabled.""" - if self._py310_plus() or "annotations" in node.root().future_imports: + if self._py310_plus or "annotations" in node.root().future_imports: return "" return ". Add 'from __future__ import annotations' as well" @@ -187,9 +163,9 @@ def _msg_postponed_eval_hint(self, node) -> str: "consider-alternative-union-syntax", ) def visit_name(self, node: nodes.Name) -> None: - if self._should_check_typing_alias() and node.name in ALIAS_NAMES: + if self._should_check_typing_alias and node.name in ALIAS_NAMES: self._check_for_typing_alias(node) - if self._should_check_alternative_union_syntax() and node.name in UNION_NAMES: + if self._should_check_alternative_union_syntax and node.name in UNION_NAMES: self._check_for_alternative_union_syntax(node, node.name) @check_messages( @@ -198,12 +174,9 @@ def visit_name(self, node: nodes.Name) -> None: "consider-alternative-union-syntax", ) def visit_attribute(self, node: nodes.Attribute): - if self._should_check_typing_alias() and node.attrname in ALIAS_NAMES: + if self._should_check_typing_alias and node.attrname in ALIAS_NAMES: self._check_for_typing_alias(node) - if ( - self._should_check_alternative_union_syntax() - and node.attrname in UNION_NAMES - ): + if self._should_check_alternative_union_syntax and node.attrname in UNION_NAMES: self._check_for_alternative_union_syntax(node, node.attrname) def _check_for_alternative_union_syntax( @@ -230,7 +203,7 @@ def _check_for_alternative_union_syntax( and inferred.qname() == "typing._SpecialForm" ): return - if not (self._py310_plus() or is_node_in_type_annotation_context(node)): + if not (self._py310_plus or is_node_in_type_annotation_context(node)): return self.add_message( "consider-alternative-union-syntax", @@ -260,7 +233,7 @@ def _check_for_typing_alias( if alias is None: return - if self._py39_plus(): + if self._py39_plus: self.add_message( "deprecated-typing-alias", node=node, @@ -290,7 +263,7 @@ def leave_module(self, node: nodes.Module) -> None: 'consider-using-alias' check. Make sure results are safe to recommend / collision free. """ - if self._py37_plus() and not self._py39_plus(): + if self._py37_plus and not self._py39_plus: msg_future_import = self._msg_postponed_eval_hint(node) for msg in self._consider_using_alias_msgs: if msg.qname in self._alias_name_collisions: diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py index d09f4e39ec..8582f76373 100644 --- a/pylint/lint/pylinter.py +++ b/pylint/lint/pylinter.py @@ -469,6 +469,18 @@ def make_options(): ), }, ), + ( + "py-version", + { + "default": sys.version_info[:2], + "type": "py_version", + "metavar": "", + "help": ( + "Min Python version to use for version dependend checks. " + "Will default to the version used to run pylint." + ), + }, + ), ) option_groups = ( diff --git a/pylintrc b/pylintrc index 32dda6ca88..6acebfbfbf 100644 --- a/pylintrc +++ b/pylintrc @@ -36,6 +36,9 @@ unsafe-load-any-extension=no # run arbitrary code extension-pkg-allow-list= +# Minimum supported python version +py-version = 3.6 + [MESSAGES CONTROL] @@ -383,9 +386,6 @@ overgeneral-exceptions=Exception [TYPING] -# Minimum supported python version (used for typing only!) -py-version = 3.6 - # Annotations are used exclusively for type checking runtime-typing = no diff --git a/setup.cfg b/setup.cfg index 78527621f2..e70a7317b7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,6 +49,7 @@ install_requires = mccabe>=0.6,<0.7 toml>=0.7.1 colorama;sys_platform=="win32" + typing-extensions>=3.10.0;python_version<"3.10" python_requires = ~=3.6 [options.packages.find] diff --git a/tests/functional/ext/code_style/code_style_consider_using_assignment_expr.py b/tests/functional/ext/code_style/code_style_consider_using_assignment_expr.py new file mode 100644 index 0000000000..07b51ce106 --- /dev/null +++ b/tests/functional/ext/code_style/code_style_consider_using_assignment_expr.py @@ -0,0 +1,148 @@ +# pylint: disable=missing-docstring,invalid-name,undefined-variable + +a1 = 2 +if a1: # [consider-using-assignment-expr] + ... + +# Do not suggest assignement expressions if assignment spans multiple lines +a2 = ( + 1, +) +if a2: + ... + +# Only first name should be replaced +a3 = 2 +if a3 == a3_a: # [consider-using-assignment-expr] + ... + +# Above black line length +a4 = some_loooooooonnnnnngggg_object_name.with_some_really_long_function_name(arg) +if a4: + ... + +def func_a(): + a5 = some___object.function_name_is_just_long_enough_to_fit_in_line() # some comment + if a5 is None: # [consider-using-assignment-expr] + ... + + # Using assignment expression would result in line being 89 chars long + a6 = some_long_object.function_name_is_too_long_enough_to_fit___line() + if a6 is None: + ... + +# Previous unrelate note should not match +print("") +if a7: + ... + + +b1: int = 2 +if b1: # [consider-using-assignment-expr] + ... + +b2 = some_function(2, 3) +if b2: # [consider-using-assignment-expr] + ... + +b3 = some_object.variable +if b3: # [consider-using-assignment-expr] + ... + + +# UnaryOp +c1 = 2 +if not c1: # [consider-using-assignment-expr] + ... + + +# Compare +d1 = 2 +if d1 is True: # [consider-using-assignment-expr] + ... + +d2 = 2 +if d2 is not None: # [consider-using-assignment-expr] + ... + +d3 = 2 +if d3 == 2: # [consider-using-assignment-expr] + ... + + +# ----- +# Don't emit warning if match statement would be a better fit +o1 = 2 +if o1 == 1: + ... +elif o1 == 2: + ... +elif o1 == 3: + ... + +o2 = 2 +if o2 == 1: + ... +elif o2: + ... + +o3 = 2 +if o3 == 1: # [consider-using-assignment-expr] + ... +else: + ... + +o4 = 2 +if o4 == 1: # [consider-using-assignment-expr] + ... +elif o4 and o4_other: + ... + +o5 = 2 +if o5 == 1: # [consider-using-assignment-expr] + ... +elif o5_other == 1: + ... + +o6 = 2 +if o6 == 1: # [consider-using-assignment-expr] + ... +elif o6_other: + ... + +def func_p(): + p1 = 2 + if p1 == 1: + return + if p1 == 2: + return + + p2 = 2 + if p2 == 1: + return + if p2: + return + + p3 = 2 + if p3 == 1: # [consider-using-assignment-expr] + ... + else: + ... + + p4 = 2 + if p4 == 1: # [consider-using-assignment-expr] + ... + elif p4 and p4_other: + ... + + p5 = 2 + if p5 == 1: # [consider-using-assignment-expr] + ... + elif p5_other == 1: + ... + + p6 = 2 + if p6 == 1: # [consider-using-assignment-expr] + ... + elif p6_other: + ... diff --git a/tests/functional/ext/code_style/code_style_consider_using_assignment_expr.rc b/tests/functional/ext/code_style/code_style_consider_using_assignment_expr.rc new file mode 100644 index 0000000000..2a659c07d6 --- /dev/null +++ b/tests/functional/ext/code_style/code_style_consider_using_assignment_expr.rc @@ -0,0 +1,6 @@ +[MASTER] +load-plugins=pylint.extensions.code_style +py-version=3.8 + +[CODE_STYLE] +max-line-length-suggestions=88 diff --git a/tests/functional/ext/code_style/code_style_consider_using_assignment_expr.txt b/tests/functional/ext/code_style/code_style_consider_using_assignment_expr.txt new file mode 100644 index 0000000000..64179ba726 --- /dev/null +++ b/tests/functional/ext/code_style/code_style_consider_using_assignment_expr.txt @@ -0,0 +1,18 @@ +consider-using-assignment-expr:4:3::"Use 'if (a1 := 2):' instead":HIGH +consider-using-assignment-expr:16:3::"Use 'if (a3 := 2) == a3_a:' instead":HIGH +consider-using-assignment-expr:26:7:func_a:"Use 'if (a5 := some___object.function_name_is_just_long_enough_to_fit_in_line()) is None:' instead":HIGH +consider-using-assignment-expr:41:3::"Use 'if (b1 := 2):' instead":HIGH +consider-using-assignment-expr:45:3::"Use 'if (b2 := some_function(2, 3)):' instead":HIGH +consider-using-assignment-expr:49:3::"Use 'if (b3 := some_object.variable):' instead":HIGH +consider-using-assignment-expr:55:7::"Use 'if not (c1 := 2):' instead":HIGH +consider-using-assignment-expr:61:3::"Use 'if (d1 := 2) is True:' instead":HIGH +consider-using-assignment-expr:65:3::"Use 'if (d2 := 2) is not None:' instead":HIGH +consider-using-assignment-expr:69:3::"Use 'if (d3 := 2) == 2:' instead":HIGH +consider-using-assignment-expr:90:3::"Use 'if (o3 := 2) == 1:' instead":HIGH +consider-using-assignment-expr:96:3::"Use 'if (o4 := 2) == 1:' instead":HIGH +consider-using-assignment-expr:102:3::"Use 'if (o5 := 2) == 1:' instead":HIGH +consider-using-assignment-expr:108:3::"Use 'if (o6 := 2) == 1:' instead":HIGH +consider-using-assignment-expr:127:7:func_p:"Use 'if (p3 := 2) == 1:' instead":HIGH +consider-using-assignment-expr:133:7:func_p:"Use 'if (p4 := 2) == 1:' instead":HIGH +consider-using-assignment-expr:139:7:func_p:"Use 'if (p5 := 2) == 1:' instead":HIGH +consider-using-assignment-expr:145:7:func_p:"Use 'if (p6 := 2) == 1:' instead":HIGH