Skip to content

Commit d5ad55d

Browse files
authored
[contextmanager-generator-missing-cleanup] Warn about context manager without try/finally in generator functions (#9133)
1 parent 7521eb1 commit d5ad55d

File tree

17 files changed

+436
-34
lines changed

17 files changed

+436
-34
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import contextlib
2+
3+
4+
@contextlib.contextmanager
5+
def cm():
6+
contextvar = "acquired context"
7+
print("cm enter")
8+
yield contextvar
9+
print("cm exit")
10+
11+
12+
def genfunc_with_cm(): # [contextmanager-generator-missing-cleanup]
13+
with cm() as context:
14+
yield context * 2
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Instantiating and using a contextmanager inside a generator function can
2+
result in unexpected behavior if there is an expectation that the context is only
3+
available for the generator function. In the case that the generator is not closed or destroyed
4+
then the context manager is held suspended as is.
5+
6+
This message warns on the generator function instead of the contextmanager function
7+
because the ways to use a contextmanager are many.
8+
A contextmanager can be used as a decorator (which immediately has ``__enter__``/``__exit__`` applied)
9+
and the use of ``as ...`` or discard of the return value also implies whether the context needs cleanup or not.
10+
So for this message, warning the invoker of the contextmanager is important.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import contextlib
2+
3+
4+
@contextlib.contextmanager
5+
def good_cm_except():
6+
contextvar = "acquired context"
7+
print("good cm enter")
8+
try:
9+
yield contextvar
10+
except GeneratorExit:
11+
print("good cm exit")
12+
13+
14+
def genfunc_with_cm():
15+
with good_cm_except() as context:
16+
yield context * 2
17+
18+
19+
def genfunc_with_discard():
20+
with good_cm_except():
21+
yield "discarded"
22+
23+
24+
@contextlib.contextmanager
25+
def good_cm_yield_none():
26+
print("good cm enter")
27+
yield
28+
print("good cm exit")
29+
30+
31+
def genfunc_with_none_yield():
32+
with good_cm_yield_none() as var:
33+
print(var)
34+
yield "constant yield"
35+
36+
37+
@contextlib.contextmanager
38+
def good_cm_finally():
39+
contextvar = "acquired context"
40+
print("good cm enter")
41+
try:
42+
yield contextvar
43+
finally:
44+
print("good cm exit")
45+
46+
47+
def good_cm_finally_genfunc():
48+
with good_cm_finally() as context:
49+
yield context * 2
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- `Rationale <https://discuss.python.org/t/preventing-yield-inside-certain-context-managers/1091>`_
2+
- `CPython Issue <https://github.com/python/cpython/issues/81924#issuecomment-1093830682>`_

doc/user_guide/checkers/features.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ Basic checker Messages
166166
This is a particular case of W0104 with its own message so you can easily
167167
disable it if you're using those strings as documentation, instead of
168168
comments.
169+
:contextmanager-generator-missing-cleanup (W0135): *The context used in function %r will not be exited.*
170+
Used when a contextmanager is used inside a generator function and the
171+
cleanup is not handled.
169172
:unnecessary-pass (W0107): *Unnecessary pass statement*
170173
Used when a "pass" statement can be removed without affecting the behaviour
171174
of the code.

doc/user_guide/messages/messages_overview.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ All messages in the warning category:
228228
warning/comparison-with-callable
229229
warning/confusing-with-statement
230230
warning/consider-ternary-expression
231+
warning/contextmanager-generator-missing-cleanup
231232
warning/dangerous-default-value
232233
warning/deprecated-argument
233234
warning/deprecated-attribute

doc/whatsnew/fragments/2832.new_check

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Checks for generators that use contextmanagers that don't handle cleanup properly.
2+
Is meant to raise visibilty on the case that a generator is not fully exhausted and the contextmanager is not cleaned up properly.
3+
A contextmanager must yield a non-constant value and not handle cleanup for GeneratorExit.
4+
The using generator must attempt to use the yielded context value `with x() as y` and not just `with x()`.
5+
6+
Closes #2832

pylint/checkers/base/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pylint.checkers.base.basic_error_checker import BasicErrorChecker
2424
from pylint.checkers.base.comparison_checker import ComparisonChecker
2525
from pylint.checkers.base.docstring_checker import DocStringChecker
26+
from pylint.checkers.base.function_checker import FunctionChecker
2627
from pylint.checkers.base.name_checker import (
2728
KNOWN_NAME_TYPES_WITH_STYLE,
2829
AnyStyle,
@@ -46,3 +47,4 @@ def register(linter: PyLinter) -> None:
4647
linter.register_checker(DocStringChecker(linter))
4748
linter.register_checker(PassChecker(linter))
4849
linter.register_checker(ComparisonChecker(linter))
50+
linter.register_checker(FunctionChecker(linter))
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
2+
# For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE
3+
# Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt
4+
5+
"""Function checker for Python code."""
6+
7+
from __future__ import annotations
8+
9+
from itertools import chain
10+
11+
from astroid import nodes
12+
13+
from pylint.checkers import utils
14+
from pylint.checkers.base.basic_checker import _BasicChecker
15+
16+
17+
class FunctionChecker(_BasicChecker):
18+
"""Check if a function definition handles possible side effects."""
19+
20+
msgs = {
21+
"W0135": (
22+
"The context used in function %r will not be exited.",
23+
"contextmanager-generator-missing-cleanup",
24+
"Used when a contextmanager is used inside a generator function"
25+
" and the cleanup is not handled.",
26+
)
27+
}
28+
29+
@utils.only_required_for_messages("contextmanager-generator-missing-cleanup")
30+
def visit_functiondef(self, node: nodes.FunctionDef) -> None:
31+
self._check_contextmanager_generator_missing_cleanup(node)
32+
33+
@utils.only_required_for_messages("contextmanager-generator-missing-cleanup")
34+
def visit_asyncfunctiondef(self, node: nodes.AsyncFunctionDef) -> None:
35+
self._check_contextmanager_generator_missing_cleanup(node)
36+
37+
def _check_contextmanager_generator_missing_cleanup(
38+
self, node: nodes.FunctionDef
39+
) -> None:
40+
"""Check a FunctionDef to find if it is a generator
41+
that uses a contextmanager internally.
42+
43+
If it is, check if the contextmanager is properly cleaned up. Otherwise, add message.
44+
45+
:param node: FunctionDef node to check
46+
:type node: nodes.FunctionDef
47+
"""
48+
# if function does not use a Yield statement, it cant be a generator
49+
with_nodes = list(node.nodes_of_class(nodes.With))
50+
if not with_nodes:
51+
return
52+
# check for Yield inside the With statement
53+
yield_nodes = list(
54+
chain.from_iterable(
55+
with_node.nodes_of_class(nodes.Yield) for with_node in with_nodes
56+
)
57+
)
58+
if not yield_nodes:
59+
return
60+
61+
# infer the call that yields a value, and check if it is a contextmanager
62+
for with_node in with_nodes:
63+
for call, held in with_node.items:
64+
if held is None:
65+
# if we discard the value, then we can skip checking it
66+
continue
67+
68+
# safe infer is a generator
69+
inferred_node = getattr(utils.safe_infer(call), "parent", None)
70+
if not isinstance(inferred_node, nodes.FunctionDef):
71+
continue
72+
if self._node_fails_contextmanager_cleanup(inferred_node, yield_nodes):
73+
self.add_message(
74+
"contextmanager-generator-missing-cleanup",
75+
node=node,
76+
args=(node.name,),
77+
)
78+
79+
@staticmethod
80+
def _node_fails_contextmanager_cleanup(
81+
node: nodes.FunctionDef, yield_nodes: list[nodes.Yield]
82+
) -> bool:
83+
"""Check if a node fails contextmanager cleanup.
84+
85+
Current checks for a contextmanager:
86+
- only if the context manager yields a non-constant value
87+
- only if the context manager lacks a finally, or does not catch GeneratorExit
88+
89+
:param node: Node to check
90+
:type node: nodes.FunctionDef
91+
:return: True if fails, False otherwise
92+
:param yield_nodes: List of Yield nodes in the function body
93+
:type yield_nodes: list[nodes.Yield]
94+
:rtype: bool
95+
"""
96+
97+
def check_handles_generator_exceptions(try_node: nodes.Try) -> bool:
98+
# needs to handle either GeneratorExit, Exception, or bare except
99+
for handler in try_node.handlers:
100+
if handler.type is None:
101+
# handles all exceptions (bare except)
102+
return True
103+
inferred = utils.safe_infer(handler.type)
104+
if inferred and inferred.qname() in {
105+
"builtins.GeneratorExit",
106+
"builtins.Exception",
107+
}:
108+
return True
109+
return False
110+
111+
# if context manager yields a non-constant value, then continue checking
112+
if any(
113+
yield_node.value is None or isinstance(yield_node.value, nodes.Const)
114+
for yield_node in yield_nodes
115+
):
116+
return False
117+
# if function body has multiple Try, filter down to the ones that have a yield node
118+
try_with_yield_nodes = [
119+
try_node
120+
for try_node in node.nodes_of_class(nodes.Try)
121+
if any(try_node.nodes_of_class(nodes.Yield))
122+
]
123+
if not try_with_yield_nodes:
124+
# no try blocks at all, so checks after this line do not apply
125+
return True
126+
# if the contextmanager has a finally block, then it is fine
127+
if all(try_node.finalbody for try_node in try_with_yield_nodes):
128+
return False
129+
# if the contextmanager catches GeneratorExit, then it is fine
130+
if all(
131+
check_handles_generator_exceptions(try_node)
132+
for try_node in try_with_yield_nodes
133+
):
134+
return False
135+
return True

tests/functional/c/consider/consider_using_with.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,7 @@ def test_futures():
186186
pass
187187

188188

189-
global_pool = (
190-
multiprocessing.Pool()
191-
) # must not trigger, will be used in nested scope
189+
global_pool = multiprocessing.Pool() # must not trigger, will be used in nested scope
192190

193191

194192
def my_nested_function():

0 commit comments

Comments
 (0)