diff --git a/doc/data/messages/c/contextmanager-generator-missing-cleanup/bad.py b/doc/data/messages/c/contextmanager-generator-missing-cleanup/bad.py index e65906a4fb..b2072f7b19 100644 --- a/doc/data/messages/c/contextmanager-generator-missing-cleanup/bad.py +++ b/doc/data/messages/c/contextmanager-generator-missing-cleanup/bad.py @@ -9,6 +9,6 @@ def cm(): print("cm exit") -def genfunc_with_cm(): # [contextmanager-generator-missing-cleanup] - with cm() as context: +def genfunc_with_cm(): + with cm() as context: # [contextmanager-generator-missing-cleanup] yield context * 2 diff --git a/doc/data/messages/c/contextmanager-generator-missing-cleanup/details.rst b/doc/data/messages/c/contextmanager-generator-missing-cleanup/details.rst index 88860d279a..50b948fb54 100644 --- a/doc/data/messages/c/contextmanager-generator-missing-cleanup/details.rst +++ b/doc/data/messages/c/contextmanager-generator-missing-cleanup/details.rst @@ -8,3 +8,24 @@ because the ways to use a contextmanager are many. A contextmanager can be used as a decorator (which immediately has ``__enter__``/``__exit__`` applied) and the use of ``as ...`` or discard of the return value also implies whether the context needs cleanup or not. So for this message, warning the invoker of the contextmanager is important. + +The check can create false positives if ``yield`` is used inside an ``if-else`` block without custom cleanup. Use ``pylint: disable`` for these. + +.. code-block:: python + + from contextlib import contextmanager + + @contextmanager + def good_cm_no_cleanup(): + contextvar = "acquired context" + print("cm enter") + if some_condition: + yield contextvar + else: + yield contextvar + + + def good_cm_no_cleanup_genfunc(): + # pylint: disable-next=contextmanager-generator-missing-cleanup + with good_cm_no_cleanup() as context: + yield context * 2 diff --git a/doc/data/messages/c/contextmanager-generator-missing-cleanup/good.py b/doc/data/messages/c/contextmanager-generator-missing-cleanup/good.py index 406d984529..2287e86a59 100644 --- a/doc/data/messages/c/contextmanager-generator-missing-cleanup/good.py +++ b/doc/data/messages/c/contextmanager-generator-missing-cleanup/good.py @@ -47,3 +47,15 @@ def good_cm_finally(): def good_cm_finally_genfunc(): with good_cm_finally() as context: yield context * 2 + + +@contextlib.contextmanager +def good_cm_no_cleanup(): + contextvar = "acquired context" + print("cm enter") + yield contextvar + + +def good_cm_no_cleanup_genfunc(): + with good_cm_no_cleanup() as context: + yield context * 2 diff --git a/doc/whatsnew/3/3.2/index.rst b/doc/whatsnew/3/3.2/index.rst index d2f0d57475..c71ae72197 100644 --- a/doc/whatsnew/3/3.2/index.rst +++ b/doc/whatsnew/3/3.2/index.rst @@ -14,6 +14,25 @@ Summary -- Release highlights .. towncrier release notes start +What's new in Pylint 3.2.2? +--------------------------- +Release date: 2024-05-20 + + +False Positives Fixed +--------------------- + +- Fix multiple false positives for generic class syntax added in Python 3.12 (PEP 695). + + Closes #9406 (`#9406 `_) + +- Exclude context manager without cleanup from + ``contextmanager-generator-missing-cleanup`` checks. + + Closes #9625 (`#9625 `_) + + + What's new in Pylint 3.2.1? --------------------------- Release date: 2024-05-18 diff --git a/pylint/__pkginfo__.py b/pylint/__pkginfo__.py index b0f13ea606..a898f5f7bd 100644 --- a/pylint/__pkginfo__.py +++ b/pylint/__pkginfo__.py @@ -9,7 +9,7 @@ from __future__ import annotations -__version__ = "3.2.1" +__version__ = "3.2.2" def get_numversion_from_version(v: str) -> tuple[int, int, int]: diff --git a/pylint/checkers/base/function_checker.py b/pylint/checkers/base/function_checker.py index bf85747119..f7d92a4649 100644 --- a/pylint/checkers/base/function_checker.py +++ b/pylint/checkers/base/function_checker.py @@ -72,7 +72,7 @@ def _check_contextmanager_generator_missing_cleanup( if self._node_fails_contextmanager_cleanup(inferred_node, yield_nodes): self.add_message( "contextmanager-generator-missing-cleanup", - node=node, + node=with_node, args=(node.name,), ) @@ -85,6 +85,7 @@ def _node_fails_contextmanager_cleanup( Current checks for a contextmanager: - only if the context manager yields a non-constant value - only if the context manager lacks a finally, or does not catch GeneratorExit + - only if some statement follows the yield, some manually cleanup happens :param node: Node to check :type node: nodes.FunctionDef @@ -114,6 +115,19 @@ def check_handles_generator_exceptions(try_node: nodes.Try) -> bool: for yield_node in yield_nodes ): return False + + # Check if yield expression is last statement + yield_nodes = list(node.nodes_of_class(nodes.Yield)) + if len(yield_nodes) == 1: + n = yield_nodes[0].parent + while n is not node: + if n.next_sibling() is not None: + break + n = n.parent + else: + # No next statement found + return False + # if function body has multiple Try, filter down to the ones that have a yield node try_with_yield_nodes = [ try_node diff --git a/pyproject.toml b/pyproject.toml index e991138b10..21f6fe03aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ # Also upgrade requirements_test_min.txt. # Pinned to dev of second minor update to allow editable installs and fix primer issues, # see https://github.com/pylint-dev/astroid/issues/1341 - "astroid>=3.2.1,<=3.3.0-dev0", + "astroid>=3.2.2,<=3.3.0-dev0", "isort>=4.2.5,<6,!=5.13.0", "mccabe>=0.6,<0.8", "tomli>=1.1.0;python_version<'3.11'", diff --git a/requirements_test_min.txt b/requirements_test_min.txt index c64f74f862..14b100c7c8 100644 --- a/requirements_test_min.txt +++ b/requirements_test_min.txt @@ -1,6 +1,6 @@ .[testutils,spelling] # astroid dependency is also defined in pyproject.toml -astroid==3.2.1 # Pinned to a specific version for tests +astroid==3.2.2 # Pinned to a specific version for tests typing-extensions~=4.11 py~=1.11.0 pytest~=7.4 diff --git a/tbump.toml b/tbump.toml index e5a9a99151..e8a35d2fcc 100644 --- a/tbump.toml +++ b/tbump.toml @@ -1,7 +1,7 @@ github_url = "https://github.com/pylint-dev/pylint" [version] -current = "3.2.1" +current = "3.2.2" regex = ''' ^(?P0|[1-9]\d*) \. diff --git a/tests/functional/c/contextmanager_generator_missing_cleanup.py b/tests/functional/c/contextmanager_generator_missing_cleanup.py index ff7f274e09..cb77e1610c 100644 --- a/tests/functional/c/contextmanager_generator_missing_cleanup.py +++ b/tests/functional/c/contextmanager_generator_missing_cleanup.py @@ -14,8 +14,8 @@ def cm(): print("cm exit") -def genfunc_with_cm(): # [contextmanager-generator-missing-cleanup] - with cm() as context: +def genfunc_with_cm(): + with cm() as context: # [contextmanager-generator-missing-cleanup] yield context * 2 @@ -27,13 +27,13 @@ def name_cm(): print("cm exit") -def genfunc_with_name_cm(): # [contextmanager-generator-missing-cleanup] - with name_cm() as context: +def genfunc_with_name_cm(): + with name_cm() as context: # [contextmanager-generator-missing-cleanup] yield context * 2 -def genfunc_with_cm_after(): # [contextmanager-generator-missing-cleanup] - with after_cm() as context: +def genfunc_with_cm_after(): + with after_cm() as context: # [contextmanager-generator-missing-cleanup] yield context * 2 @@ -56,8 +56,8 @@ def cm_with_improper_handling(): print("cm exit") -def genfunc_with_cm_improper(): # [contextmanager-generator-missing-cleanup] - with cm_with_improper_handling() as context: +def genfunc_with_cm_improper(): + with cm_with_improper_handling() as context: # [contextmanager-generator-missing-cleanup] yield context * 2 @@ -175,3 +175,15 @@ def genfunc_with_cm_bare_handler(): def genfunc_with_cm_base_exception_handler(): with cm_base_exception_handler() as context: yield context * 2 + + +@contextlib.contextmanager +def good_cm_no_cleanup(): + contextvar = "acquired context" + print("cm enter") + yield contextvar + + +def good_cm_no_cleanup_genfunc(): + with good_cm_no_cleanup() as context: + yield context * 2 diff --git a/tests/functional/c/contextmanager_generator_missing_cleanup.txt b/tests/functional/c/contextmanager_generator_missing_cleanup.txt index ca18ed4d9a..0c6b5e15cf 100644 --- a/tests/functional/c/contextmanager_generator_missing_cleanup.txt +++ b/tests/functional/c/contextmanager_generator_missing_cleanup.txt @@ -1,4 +1,4 @@ -contextmanager-generator-missing-cleanup:17:0:17:19:genfunc_with_cm:The context used in function 'genfunc_with_cm' will not be exited.:UNDEFINED -contextmanager-generator-missing-cleanup:30:0:30:24:genfunc_with_name_cm:The context used in function 'genfunc_with_name_cm' will not be exited.:UNDEFINED -contextmanager-generator-missing-cleanup:35:0:35:25:genfunc_with_cm_after:The context used in function 'genfunc_with_cm_after' will not be exited.:UNDEFINED -contextmanager-generator-missing-cleanup:59:0:59:28:genfunc_with_cm_improper:The context used in function 'genfunc_with_cm_improper' will not be exited.:UNDEFINED +contextmanager-generator-missing-cleanup:18:4:19:25:genfunc_with_cm:The context used in function 'genfunc_with_cm' will not be exited.:UNDEFINED +contextmanager-generator-missing-cleanup:31:4:32:25:genfunc_with_name_cm:The context used in function 'genfunc_with_name_cm' will not be exited.:UNDEFINED +contextmanager-generator-missing-cleanup:36:4:37:25:genfunc_with_cm_after:The context used in function 'genfunc_with_cm_after' will not be exited.:UNDEFINED +contextmanager-generator-missing-cleanup:60:4:61:25:genfunc_with_cm_improper:The context used in function 'genfunc_with_cm_improper' will not be exited.:UNDEFINED diff --git a/tests/functional/g/generic_class_syntax.py b/tests/functional/g/generic_class_syntax.py new file mode 100644 index 0000000000..b7305965ac --- /dev/null +++ b/tests/functional/g/generic_class_syntax.py @@ -0,0 +1,38 @@ +# pylint: disable=missing-docstring,too-few-public-methods +from typing import Generic, TypeVar, Optional + +_T = TypeVar("_T") + + +class Entity(Generic[_T]): + last_update: Optional[int] = None + + def __init__(self, data: _T) -> None: + self.data = data + + +class Sensor(Entity[int]): + def __init__(self, data: int) -> None: + super().__init__(data) + + def async_update(self) -> None: + self.data = 2 + + if self.last_update is None: + pass + self.last_update = 2 + + +class Switch(Entity[int]): + def __init__(self, data: int) -> None: + Entity.__init__(self, data) + + +class Parent(Generic[_T]): + def __init__(self): + self.update_interval = 0 + + +class Child(Parent[_T]): + def func(self): + self.update_interval = None diff --git a/tests/functional/g/generic_class_syntax_py312.py b/tests/functional/g/generic_class_syntax_py312.py new file mode 100644 index 0000000000..bbfff1c6ad --- /dev/null +++ b/tests/functional/g/generic_class_syntax_py312.py @@ -0,0 +1,33 @@ +# pylint: disable=missing-docstring,too-few-public-methods +class Entity[_T: float]: + last_update: int | None = None + + def __init__(self, data: _T) -> None: # [undefined-variable] # false-positive + self.data = data + + +class Sensor(Entity[int]): + def __init__(self, data: int) -> None: + super().__init__(data) + + def async_update(self) -> None: + self.data = 2 + + if self.last_update is None: + pass + self.last_update = 2 + + +class Switch(Entity[int]): + def __init__(self, data: int) -> None: + Entity.__init__(self, data) + + +class Parent[_T]: + def __init__(self): + self.update_interval = 0 + + +class Child[_T](Parent[_T]): # [undefined-variable] # false-positive + def func(self): + self.update_interval = None diff --git a/tests/functional/g/generic_class_syntax_py312.rc b/tests/functional/g/generic_class_syntax_py312.rc new file mode 100644 index 0000000000..9c966d4bda --- /dev/null +++ b/tests/functional/g/generic_class_syntax_py312.rc @@ -0,0 +1,2 @@ +[testoptions] +min_pyver=3.12 diff --git a/tests/functional/g/generic_class_syntax_py312.txt b/tests/functional/g/generic_class_syntax_py312.txt new file mode 100644 index 0000000000..bd5fbbe7ee --- /dev/null +++ b/tests/functional/g/generic_class_syntax_py312.txt @@ -0,0 +1,2 @@ +undefined-variable:5:29:5:31:Entity.__init__:Undefined variable '_T':UNDEFINED +undefined-variable:31:23:31:25:Child:Undefined variable '_T':UNDEFINED diff --git a/towncrier.toml b/towncrier.toml index f2e1245fc2..8df0009ec2 100644 --- a/towncrier.toml +++ b/towncrier.toml @@ -1,5 +1,5 @@ [tool.towncrier] -version = "3.2.1" +version = "3.2.2" directory = "doc/whatsnew/fragments" filename = "doc/whatsnew/3/3.2/index.rst" template = "doc/whatsnew/fragments/_template.rst"