Add `consider-using-assignment-expr` to `CodeStyleChecker` (#4876)

* Add global py-version config

* Add typing-extensions as requirement for Python < 3.10

* Add consider-using-assignment-expr to CodeStyleChecker

* Default to max-line-length

Co-authored-by: Pierre Sassoulas <pierre.sassoulas@gmail.com>
diff --git a/ChangeLog b/ChangeLog
index d514a10..9a96ad2 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -10,28 +10,40 @@
 ..
   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 @@
 
   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 e9c05b9..bbb2a7b 100644
--- a/doc/whatsnew/2.11.rst
+++ b/doc/whatsnew/2.11.rst
@@ -20,6 +20,16 @@
 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 3f400fd..0d8bfd1 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 @@
             "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": "<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 __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 @@
         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 @@
             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 ce4e6cd..cf0281f 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):
@@ -104,19 +104,6 @@
     }
     options = (
         (
-            "py-version",
-            {
-                "default": (3, 7),
-                "type": "py_version",
-                "metavar": "<py_version>",
-                "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",
             {
                 "default": True,
@@ -135,49 +122,38 @@
         ),
     )
 
+    _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)
+    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 _py39_plus(self) -> bool:
-        return self.config.py_version >= (3, 9)
-
-    @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 @@
         "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 @@
         "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 @@
             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 @@
         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 @@
         '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 d09f4e3..8582f76 100644
--- a/pylint/lint/pylinter.py
+++ b/pylint/lint/pylinter.py
@@ -469,6 +469,18 @@
                     ),
                 },
             ),
+            (
+                "py-version",
+                {
+                    "default": sys.version_info[:2],
+                    "type": "py_version",
+                    "metavar": "<py_version>",
+                    "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 32dda6c..6acebfb 100644
--- a/pylintrc
+++ b/pylintrc
@@ -36,6 +36,9 @@
 # run arbitrary code
 extension-pkg-allow-list=
 
+# Minimum supported python version
+py-version = 3.6
+
 
 [MESSAGES CONTROL]
 
@@ -383,9 +386,6 @@
 
 [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 7852762..e70a731 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -49,6 +49,7 @@
     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 0000000..07b51ce
--- /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 0000000..2a659c0
--- /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 0000000..64179ba
--- /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