Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[contextmanager-generator-missing-cleanup][new feature] Warn about @contextlib.contextmanager without try/finally in generator functions #9133

Merged
merged 32 commits into from
May 12, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
062373e
add: initial layout for new error
rhyn0 Oct 8, 2023
b6774f6
add: test cases for new checker
rhyn0 Oct 8, 2023
b0547eb
add: first pass at new checker
rhyn0 Oct 8, 2023
72068a2
add: towncrier fragment for changes
rhyn0 Oct 8, 2023
81c7ab6
add: suggestions, better logic for this warning
rhyn0 Oct 14, 2023
9be51c2
add: good and bad example under doc
rhyn0 Oct 14, 2023
314158b
update: functional tests to use new message
rhyn0 Oct 14, 2023
74841ab
update: other tests to exclude new warning
rhyn0 Oct 14, 2023
f0d9fad
change: keep new checker name under 'basic'
rhyn0 Oct 14, 2023
06204d7
change: update positive/negative examples to new spec for this feature
rhyn0 Mar 3, 2024
9928473
Fix other pylint rule violations in test data, update expected output
rhyn0 Mar 3, 2024
6c95035
change: new checker logic for refined 'contextmanager_generator_missi…
rhyn0 Mar 3, 2024
eef0ac0
update: test files with unused suppress flags, add suppress flags
rhyn0 Mar 3, 2024
8022d8d
fix: CI docstring spelling errors
rhyn0 Mar 3, 2024
3ec527d
add: test cases for code coverage, order of functions and various Wit…
rhyn0 Mar 3, 2024
f1b6eb9
disable wrong spelling for AsyncWith
rhyn0 Mar 4, 2024
7f3874d
add more test cases from Primer errors
rhyn0 Mar 4, 2024
eb40479
remove 'spelling mistakes' sections, handle Primer errors
rhyn0 Mar 4, 2024
1558bcd
remove unused suppressions from 'eef0ac00d'
rhyn0 Mar 4, 2024
4ac898a
add negative test case for decorated usage of contextmanager
rhyn0 Mar 24, 2024
5c50505
rework checker to use simpler logic from utils.safe_infer
rhyn0 Mar 24, 2024
de54fc4
reword feature fragment, explain purpose more in depth
rhyn0 Mar 24, 2024
f3f0876
fix comment in good examples
rhyn0 Mar 24, 2024
3c365ce
use inference to check for handler of GeneratorExit
rhyn0 Mar 24, 2024
8730bc7
don't raise for contextmanagers that handle base Exception or bare Ex…
rhyn0 Mar 24, 2024
1f3cb77
reword top level error message
rhyn0 May 5, 2024
03c5562
remove unneccessary disable comment, update expected output
rhyn0 May 5, 2024
d87ebac
add details and related links to error message documentation
rhyn0 May 5, 2024
4d36753
remove TODO from checker
rhyn0 May 5, 2024
db8db8f
revert test breaking formatting changes
rhyn0 May 11, 2024
b89e7df
update: documentation about new checker, grammar
rhyn0 May 11, 2024
3bd7132
fix: make checking for generators with try blocks cheaper
rhyn0 May 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions doc/whatsnew/fragments/2832.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Checks that contextmanager decorated functions that are generators handle GeneratorExit cleanly.
rhyn0 marked this conversation as resolved.
Show resolved Hide resolved

Closes #2832
2 changes: 2 additions & 0 deletions pylint/checkers/base/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from pylint.checkers.base.basic_error_checker import BasicErrorChecker
from pylint.checkers.base.comparison_checker import ComparisonChecker
from pylint.checkers.base.docstring_checker import DocStringChecker
from pylint.checkers.base.function_checker import FunctionChecker
from pylint.checkers.base.name_checker import (
KNOWN_NAME_TYPES_WITH_STYLE,
AnyStyle,
Expand All @@ -46,3 +47,4 @@ def register(linter: PyLinter) -> None:
linter.register_checker(DocStringChecker(linter))
linter.register_checker(PassChecker(linter))
linter.register_checker(ComparisonChecker(linter))
linter.register_checker(FunctionChecker(linter))
163 changes: 163 additions & 0 deletions pylint/checkers/base/function_checker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# 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

"""Function checker for Python code."""

from __future__ import annotations

from collections import deque
from typing import Union

from astroid import nodes

from pylint.checkers import utils
from pylint.checkers.base.basic_checker import _BasicChecker

_GeneratorTree = Union[nodes.Try, nodes.Assign, nodes.Expr]


class FunctionChecker(_BasicChecker):
"""Check if a function definition handles possible side effects."""

name = "function"
msgs = {
"W9999": ( # TODO: change this warning code number
rhyn0 marked this conversation as resolved.
Show resolved Hide resolved
"Unhandled generator cleanup in contextmanager function %s",
rhyn0 marked this conversation as resolved.
Show resolved Hide resolved
"contextmanager-generator-missing-cleanup",
"Used when a generator is used in a contextmanager"
" and the cleanup is not handled.",
)
}

@utils.only_required_for_messages("contextmanager-generator-missing-cleanup")
def visit_functiondef(self, node: nodes.FunctionDef) -> None:
self._check_contextmanager_generator_missing_cleanup(node)

def _check_contextmanager_generator_missing_cleanup(
self, node: nodes.FunctionDef
) -> None:
"""Check a FunctionDef to see if it uses `contextmanager` on a generator
function.

The following code sample will never print the message **cm exit**::

from contextlib import contextmanager

@contextmanager
def cm():
print("cm enter")
a = yield
print('cm exit')

def genfunc():
with cm():
print("stepping")
yield

def main():
gen = genfunc()
ref = weakref.ref(gen, print)
next(gen)

The function ``cm`` needs to include error handling for ``GeneratorExit``
to properly finish the function execution.

:param node: FunctionDef node to check
:type node: nodes.FunctionDef
"""
contextmanager_str = "contextmanager"
if node.decorators is None:
return

decorator_list = node.decorators.nodes
if not any(
contextmanager_str == self._get_name(deco) for deco in decorator_list
):
# No contextmanager decorator
return

# A ``yield`` statement can either be Expr or Assign, but the value is always of Yield
# Additionally a good function could be input where the yield is already wrapped by a Try
# but not have the required Handler or Finally
body_nodes = self._find_yield_nodes(node)
yield_nodes: list[nodes.Yield] = []
for n in body_nodes:
if not isinstance(n, nodes.Try):
yield_nodes.append(n)
continue
# This Try Node has a Yield inside of it, making this a generator function wrapped
# by contextlib.contextmanager. See if handlers or finally is present
if (
any(
self._get_name(handler.type) == "GeneratorExit"
for handler in n.handlers
)
or n.finalbody
):
continue

# this Try block doesn't properly handle GeneratorExit, add warning
self.add_message(
"contextmanager-generator-missing-cleanup", node=node, args=(node.name,)
rhyn0 marked this conversation as resolved.
Show resolved Hide resolved
)
return

if yield_nodes:
self.add_message(
"contextmanager-generator-missing-cleanup", node=node, args=(node.name,)
rhyn0 marked this conversation as resolved.
Show resolved Hide resolved
)

@staticmethod
def _get_name(node: nodes.NodeNG) -> str:
"""Helper function to get the name from a node.

Helpful when possible to have a Name or Attribute node.

:param node: Node to get name of
:type node: nodes.NodeNG
:return: string name
:rtype: str
"""
if hasattr(node, "name"):
return node.name # type: ignore[no-untype-def]
if hasattr(node, "attrname"):
return node.attrname # type: ignore[no-untype-def]
return node.as_string() # type: ignore[no-untype-def]
rhyn0 marked this conversation as resolved.
Show resolved Hide resolved

@staticmethod
def _find_yield_nodes(node: nodes.FunctionDef) -> list[_GeneratorTree]:
"""Return all yield statements, possibly wrapped by a Try node.

If a yield keyword is used inside a Try block, return the Try node.
Otherwise return the node of the yield.
Returns all such occurrences.

:param node: Function root to check
:type node: nodes.FunctionDef
:return: List of found nodes
:rtype: list[_GeneratorTree]
"""
rhyn0 marked this conversation as resolved.
Show resolved Hide resolved

def get_body_nodes(n: nodes.NodeNG) -> list[nodes.NodeNG]:
if hasattr(n, "body"):
return n.body # type: ignore[no-any-return]
return []

found_nodes: list[_GeneratorTree] = []
# keep track of (current_node, TryParent | None)
queue: deque[tuple[nodes.NodeNG, nodes.Try | None]] = deque([(node, None)])
while queue:
curr_node, try_parent = queue.popleft()
if hasattr(curr_node, "value") and isinstance(curr_node.value, nodes.Yield):
# If current node is a Expr/Assign etc, with a Yield add to output
found_nodes.append(curr_node if try_parent is None else try_parent)
if isinstance(curr_node, nodes.Try) and try_parent is None:
# TODO: should this change Try block parent in the not None case?
# isn't it code smell to try: try: try: ...
try_parent = curr_node
queue.extend(
(body_node, curr_node) for body_node in get_body_nodes(curr_node)
)

return found_nodes
67 changes: 67 additions & 0 deletions tests/functional/c/contextmanager_generator_missing_cleanup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# pylint: disable = disallowed-name, missing-docstring, useless-return, invalid-name
# pylint: disable = line-too-long, comparison-of-constants, broad-exception-raised
# pylint: disable = unused-variable
rhyn0 marked this conversation as resolved.
Show resolved Hide resolved
import contextlib
from contextlib import contextmanager
import weakref

# Positive


@contextlib.contextmanager
def cm(): # [contextmanager-generator-missing-cleanup]
print("cm enter")
a = yield
print("cm exit")


@contextmanager
def bad_error_handler_cm(): # [contextmanager-generator-missing-cleanup]
print("cm enter")
try:
a = yield
except ValueError:
pass
print("cm exit")


def genfunc():
with cm():
print("stepping")
yield


def main():
gen = genfunc()
ref = weakref.ref(gen, print)
next(gen)


# Negative


@contextlib.contextmanager
def good_contextmanager():
print("good cm enter")
try:
a = yield
finally:
print("good cm exit")


@contextmanager
def other_good_contextmanager():
print("good cm enter")
try:
a = yield
finally:
print("good cm exit")


@contextmanager
def good_cm_except():
print("good cm enter")
try:
a = yield
except GeneratorExit:
print("good cm exit")
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
contextmanager-generator-missing-cleanup:12:0:12:6:cm:Unhandled generator cleanup in contextmanager function cm:UNDEFINED
contextmanager-generator-missing-cleanup:19:0:19:24:bad_error_handler_cm:Unhandled generator cleanup in contextmanager function bad_error_handler_cm:UNDEFINED
Loading