From 57515ae304c9e4bae6a94f08ea7ef2d6fadc61ce Mon Sep 17 00:00:00 2001 From: Mark Byrne <31762852+mbyrnepr2@users.noreply.github.com> Date: Tue, 9 Nov 2021 12:51:34 +0100 Subject: Add checker `using-final-decorator-in-unsupported-version` (#5165) * Add checker `using-final-in-unsupported-version` This is one of the tasks in issue: #5134 Also: - Ensure the existing checkers for `typing.final` are used irrespective of Python version * Emit `using-final-in-unsupported-version` warning when Python version < 3.8 and none of the other `typing.final`-related warnings * Add `uninferable_final_decorators` Return any `typing.final` decorators for a given `Decorators` node. Used to determine if this decorator is used with a version of Python in which it is unsupported. Co-authored-by: Pierre Sassoulas --- ChangeLog | 3 ++ doc/whatsnew/2.12.rst | 3 ++ pylint/checkers/classes.py | 17 +++++--- pylint/checkers/unsupported_version.py | 36 ++++++++++++++++- pylint/checkers/utils.py | 45 +++++++++++++++++++++- tests/functional/o/overridden_final_method_py38.py | 3 +- .../functional/o/overridden_final_method_py38.txt | 2 +- tests/functional/s/subclassed_final_class_py38.py | 3 +- tests/functional/s/subclassed_final_class_py38.txt | 2 +- .../u/unsupported/unsupported_version_for_final.py | 36 +++++++++++++++++ .../u/unsupported/unsupported_version_for_final.rc | 2 + .../unsupported/unsupported_version_for_final.txt | 9 +++++ 12 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 tests/functional/u/unsupported/unsupported_version_for_final.py create mode 100644 tests/functional/u/unsupported/unsupported_version_for_final.rc create mode 100644 tests/functional/u/unsupported/unsupported_version_for_final.txt diff --git a/ChangeLog b/ChangeLog index 565c6d9d0..34a5692bc 100644 --- a/ChangeLog +++ b/ChangeLog @@ -132,6 +132,9 @@ Release date: TBA * Fix ``missing-function-docstring`` not being able to check ``__init__`` and other magic methods even if the ``no-docstring-rgx`` setting was set to do so +* Added ``using-final-decorator-in-unsupported-version`` checker. Issued when ``py-version`` + is set to a version that does not support ``typing.final`` (< 3.8) + * Added configuration option ``exclude-too-few-public-methods`` to allow excluding classes from the ``min-public-methods`` checker. diff --git a/doc/whatsnew/2.12.rst b/doc/whatsnew/2.12.rst index 3be3f3c7b..760971a3a 100644 --- a/doc/whatsnew/2.12.rst +++ b/doc/whatsnew/2.12.rst @@ -43,6 +43,9 @@ New checkers Closes #4774 +* Added ``using-final-decorator-in-unsupported-version`` checker. Issued when ``py-version`` + is set to a version that does not support typing.final (< 3.8) + * Added configuration option ``exclude-too-few-public-methods`` to allow excluding classes from the ``min-public-methods`` checker. diff --git a/pylint/checkers/classes.py b/pylint/checkers/classes.py index 82fcc8a61..9f7ea9248 100644 --- a/pylint/checkers/classes.py +++ b/pylint/checkers/classes.py @@ -79,8 +79,8 @@ from pylint.checkers.utils import ( overrides_a_method, safe_infer, unimplemented_abstract_methods, + uninferable_final_decorators, ) -from pylint.constants import PY38_PLUS from pylint.interfaces import IAstroidChecker from pylint.utils import get_global_option @@ -795,6 +795,8 @@ a metaclass class method.", def open(self) -> None: self._mixin_class_rgx = get_global_option(self, "mixin-class-rgx") + py_version = get_global_option(self, "py-version") + self._py38_plus = py_version >= (3, 8) @astroid.decorators.cachedproperty def _dummy_rgx(self): @@ -868,14 +870,16 @@ a metaclass class method.", def _check_typing_final(self, node: nodes.ClassDef) -> None: """Detect that a class does not subclass a class decorated with `typing.final`""" - if not PY38_PLUS: + if not self._py38_plus: return for base in node.bases: ancestor = safe_infer(base) if not ancestor: continue - if isinstance(ancestor, nodes.ClassDef) and decorated_with( - ancestor, ["typing.final"] + + if isinstance(ancestor, nodes.ClassDef) and ( + decorated_with(ancestor, ["typing.final"]) + or (uninferable_final_decorators(ancestor.decorators)) ): self.add_message( "subclassed-final-class", @@ -1336,7 +1340,10 @@ a metaclass class method.", args=(function_node.name, "non-async", "async"), node=function_node, ) - if decorated_with(parent_function_node, ["typing.final"]) and PY38_PLUS: + if ( + decorated_with(parent_function_node, ["typing.final"]) + or uninferable_final_decorators(parent_function_node.decorators) + ) and self._py38_plus: self.add_message( "overridden-final-method", args=(function_node.name, parent_function_node.parent.name), diff --git a/pylint/checkers/unsupported_version.py b/pylint/checkers/unsupported_version.py index 917d64cba..005bb8f88 100644 --- a/pylint/checkers/unsupported_version.py +++ b/pylint/checkers/unsupported_version.py @@ -7,10 +7,15 @@ indicated by the py-version setting. """ + from astroid import nodes from pylint.checkers import BaseChecker -from pylint.checkers.utils import check_messages +from pylint.checkers.utils import ( + check_messages, + safe_infer, + uninferable_final_decorators, +) from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter from pylint.utils import get_global_option @@ -30,12 +35,19 @@ class UnsupportedVersionChecker(BaseChecker): "Used when the py-version set by the user is lower than 3.6 and pylint encounters " "a f-string.", ), + "W1602": ( + "typing.final is not supported by all versions included in the py-version setting", + "using-final-decorator-in-unsupported-version", + "Used when the py-version set by the user is lower than 3.8 and pylint encounters " + "a ``typing.final`` decorator.", + ), } def open(self) -> None: """Initialize visit variables and statistics.""" py_version = get_global_option(self, "py-version") self._py36_plus = py_version >= (3, 6) + self._py38_plus = py_version >= (3, 8) @check_messages("using-f-string-in-unsupported-version") def visit_joinedstr(self, node: nodes.JoinedStr) -> None: @@ -43,6 +55,28 @@ class UnsupportedVersionChecker(BaseChecker): if not self._py36_plus: self.add_message("using-f-string-in-unsupported-version", node=node) + @check_messages("using-final-decorator-in-unsupported-version") + def visit_decorators(self, node: nodes.Decorators) -> None: + """Check decorators""" + self._check_typing_final(node) + + def _check_typing_final(self, node: nodes.Decorators) -> None: + """Add a message when the `typing.final` decorator is used and the + py-version is lower than 3.8""" + if self._py38_plus: + return + + decorators = [] + for decorator in node.get_children(): + inferred = safe_infer(decorator) + if inferred and inferred.qname() == "typing.final": + decorators.append(decorator) + + for decorator in decorators or uninferable_final_decorators(node): + self.add_message( + "using-final-decorator-in-unsupported-version", node=decorator + ) + def register(linter: PyLinter) -> None: """Required method to auto register this checker""" diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py index f5b4f42a5..cff791364 100644 --- a/pylint/checkers/utils.py +++ b/pylint/checkers/utils.py @@ -822,7 +822,9 @@ def _is_property_decorator(decorator: nodes.Name) -> bool: def decorated_with( - func: Union[nodes.FunctionDef, astroid.BoundMethod, astroid.UnboundMethod], + func: Union[ + nodes.ClassDef, nodes.FunctionDef, astroid.BoundMethod, astroid.UnboundMethod + ], qnames: Iterable[str], ) -> bool: """Determine if the `func` node has a decorator with the qualified name `qname`.""" @@ -843,6 +845,47 @@ def decorated_with( return False +def uninferable_final_decorators( + node: nodes.Decorators, +) -> List[Optional[Union[nodes.Attribute, nodes.Name]]]: + """Return a list of uninferable `typing.final` decorators in `node`. + + This function is used to determine if the `typing.final` decorator is used + with an unsupported Python version; the decorator cannot be inferred when + using a Python version lower than 3.8. + """ + decorators = [] + for decorator in getattr(node, "nodes", []): + if isinstance(decorator, nodes.Attribute): + try: + import_node = decorator.expr.lookup(decorator.expr.name)[1][0] + except AttributeError: + continue + elif isinstance(decorator, nodes.Name): + import_node = decorator.lookup(decorator.name)[1][0] + else: + continue + + if not isinstance(import_node, (astroid.Import, astroid.ImportFrom)): + continue + + import_names = dict(import_node.names) + + # from typing import final + is_from_import = ("final" in import_names) and import_node.modname == "typing" + # import typing + is_import = ("typing" in import_names) and getattr( + decorator, "attrname", None + ) == "final" + + if (is_from_import or is_import) and safe_infer(decorator) in [ + astroid.Uninferable, + None, + ]: + decorators.append(decorator) + return decorators + + @lru_cache(maxsize=1024) def unimplemented_abstract_methods( node: nodes.ClassDef, is_abstract_cb: nodes.FunctionDef = None diff --git a/tests/functional/o/overridden_final_method_py38.py b/tests/functional/o/overridden_final_method_py38.py index d951c26da..d7b27b6e9 100644 --- a/tests/functional/o/overridden_final_method_py38.py +++ b/tests/functional/o/overridden_final_method_py38.py @@ -1,8 +1,7 @@ """Since Python version 3.8, a method decorated with typing.final cannot be overridden""" -# pylint: disable=no-init, import-error, invalid-name, using-constant-test, useless-object-inheritance -# pylint: disable=missing-docstring, too-few-public-methods +# pylint: disable=no-init, useless-object-inheritance, missing-docstring, too-few-public-methods from typing import final diff --git a/tests/functional/o/overridden_final_method_py38.txt b/tests/functional/o/overridden_final_method_py38.txt index 2c8bca442..66bbf322d 100644 --- a/tests/functional/o/overridden_final_method_py38.txt +++ b/tests/functional/o/overridden_final_method_py38.txt @@ -1 +1 @@ -overridden-final-method:16:4:Subclass.my_method:Method 'my_method' overrides a method decorated with typing.final which is defined in class 'Base':HIGH +overridden-final-method:15:4:Subclass.my_method:Method 'my_method' overrides a method decorated with typing.final which is defined in class 'Base':HIGH diff --git a/tests/functional/s/subclassed_final_class_py38.py b/tests/functional/s/subclassed_final_class_py38.py index 816ef537e..7f0671e75 100644 --- a/tests/functional/s/subclassed_final_class_py38.py +++ b/tests/functional/s/subclassed_final_class_py38.py @@ -1,8 +1,7 @@ """Since Python version 3.8, a class decorated with typing.final cannot be subclassed """ -# pylint: disable=no-init, import-error, invalid-name, using-constant-test, useless-object-inheritance -# pylint: disable=missing-docstring, too-few-public-methods +# pylint: disable=no-init, useless-object-inheritance, missing-docstring, too-few-public-methods from typing import final diff --git a/tests/functional/s/subclassed_final_class_py38.txt b/tests/functional/s/subclassed_final_class_py38.txt index 46fb5200e..037e5eb4f 100644 --- a/tests/functional/s/subclassed_final_class_py38.txt +++ b/tests/functional/s/subclassed_final_class_py38.txt @@ -1 +1 @@ -subclassed-final-class:15:0:Subclass:"Class 'Subclass' is a subclass of a class decorated with typing.final: 'Base'":HIGH +subclassed-final-class:14:0:Subclass:"Class 'Subclass' is a subclass of a class decorated with typing.final: 'Base'":HIGH diff --git a/tests/functional/u/unsupported/unsupported_version_for_final.py b/tests/functional/u/unsupported/unsupported_version_for_final.py new file mode 100644 index 000000000..efc433dd4 --- /dev/null +++ b/tests/functional/u/unsupported/unsupported_version_for_final.py @@ -0,0 +1,36 @@ +"""Tests for the use of typing.final whenever the py-version is set < 3.8""" +# pylint: disable=missing-class-docstring, no-member, too-few-public-methods, missing-function-docstring, no-name-in-module, reimported + +import typing +import typing as mytyping +from typing import final +from typing import final as myfinal + + +@final # [using-final-decorator-in-unsupported-version] +class MyClass1: + @final # [using-final-decorator-in-unsupported-version] + @final # [using-final-decorator-in-unsupported-version] + def my_method(self): + pass + + +@myfinal # [using-final-decorator-in-unsupported-version] +class MyClass2: + @myfinal # [using-final-decorator-in-unsupported-version] + def my_method(self): + pass + + +@typing.final # [using-final-decorator-in-unsupported-version] +class MyClass3: + @typing.final # [using-final-decorator-in-unsupported-version] + def my_method(self): + pass + + +@mytyping.final # [using-final-decorator-in-unsupported-version] +class MyClass4: + @mytyping.final # [using-final-decorator-in-unsupported-version] + def my_method(self): + pass diff --git a/tests/functional/u/unsupported/unsupported_version_for_final.rc b/tests/functional/u/unsupported/unsupported_version_for_final.rc new file mode 100644 index 000000000..d10f4f3c2 --- /dev/null +++ b/tests/functional/u/unsupported/unsupported_version_for_final.rc @@ -0,0 +1,2 @@ +[master] +py-version=3.7 diff --git a/tests/functional/u/unsupported/unsupported_version_for_final.txt b/tests/functional/u/unsupported/unsupported_version_for_final.txt new file mode 100644 index 000000000..f5cc2aa05 --- /dev/null +++ b/tests/functional/u/unsupported/unsupported_version_for_final.txt @@ -0,0 +1,9 @@ +using-final-decorator-in-unsupported-version:10:1:MyClass1:typing.final is not supported by all versions included in the py-version setting:HIGH +using-final-decorator-in-unsupported-version:12:5:MyClass1.my_method:typing.final is not supported by all versions included in the py-version setting:HIGH +using-final-decorator-in-unsupported-version:13:5:MyClass1.my_method:typing.final is not supported by all versions included in the py-version setting:HIGH +using-final-decorator-in-unsupported-version:18:1:MyClass2:typing.final is not supported by all versions included in the py-version setting:HIGH +using-final-decorator-in-unsupported-version:20:5:MyClass2.my_method:typing.final is not supported by all versions included in the py-version setting:HIGH +using-final-decorator-in-unsupported-version:25:1:MyClass3:typing.final is not supported by all versions included in the py-version setting:HIGH +using-final-decorator-in-unsupported-version:27:5:MyClass3.my_method:typing.final is not supported by all versions included in the py-version setting:HIGH +using-final-decorator-in-unsupported-version:32:1:MyClass4:typing.final is not supported by all versions included in the py-version setting:HIGH +using-final-decorator-in-unsupported-version:34:5:MyClass4.my_method:typing.final is not supported by all versions included in the py-version setting:HIGH -- cgit v1.2.1