Skip to content

Commit dd54e55

Browse files
Fix 4689 Exclude ThreadPoolExecutor and ProcessPoolExecutor from consider-using-with (#4721)
* Do not immediately emit ``consider-using-with`` if a context manager is assigned to a variable. Instead check if it is used later on in a ``with`` block. * Enhance check to also work for tuple unpacking in assignments * Remove ``ThreadPoolExecutor`` and ``ProcessPoolExecutor`` from list of callables that trigger the ``consider-using-with`` message Co-authored-by: Pierre Sassoulas <[email protected]>
1 parent 3fe9954 commit dd54e55

File tree

5 files changed

+220
-44
lines changed

5 files changed

+220
-44
lines changed

ChangeLog

+5
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ Release date: TBA
8282

8383
Closes #4668
8484

85+
* No longer emit ``consider-using-with`` for ``ThreadPoolExecutor`` and ``ProcessPoolExecutor``
86+
as they have legitimate use cases without a ``with`` block.
87+
88+
Closes #4689
89+
8590
* Fix crash when inferring variables assigned in match patterns
8691

8792
Closes #4685

doc/whatsnew/2.9.rst

+3
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,6 @@ Other Changes
9999
functions/methods.
100100

101101
* Added various deprecated functions/methods for python 3.10, 3.7, 3.6 and 3.3
102+
103+
* No longer emit ``consider-using-with`` for ``ThreadPoolExecutor`` and ``ProcessPoolExecutor``
104+
as they have legitimate use cases without a ``with`` block.

pylint/checkers/refactoring/refactoring_checker.py

+124-11
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
import itertools
77
import tokenize
88
from functools import reduce
9-
from typing import List, Optional, Tuple, Union, cast
9+
from typing import Dict, Iterator, List, NamedTuple, Optional, Tuple, Union, cast
1010

1111
import astroid
12+
from astroid.util import Uninferable
1213

1314
from pylint import checkers, interfaces
1415
from pylint import utils as lint_utils
@@ -41,8 +42,6 @@
4142
"tarfile.TarFile",
4243
"tarfile.TarFile.open",
4344
"multiprocessing.context.BaseContext.Pool",
44-
"concurrent.futures.thread.ThreadPoolExecutor",
45-
"concurrent.futures.process.ProcessPoolExecutor",
4645
"subprocess.Popen",
4746
)
4847
)
@@ -147,6 +146,33 @@ def _will_be_released_automatically(node: astroid.Call) -> bool:
147146
return func.qname() in callables_taking_care_of_exit
148147

149148

149+
class ConsiderUsingWithStack(NamedTuple):
150+
"""Stack for objects that may potentially trigger a R1732 message
151+
if they are not used in a ``with`` block later on."""
152+
153+
module_scope: Dict[str, astroid.NodeNG] = {}
154+
class_scope: Dict[str, astroid.NodeNG] = {}
155+
function_scope: Dict[str, astroid.NodeNG] = {}
156+
157+
def __iter__(self) -> Iterator[Dict[str, astroid.NodeNG]]:
158+
yield from (self.function_scope, self.class_scope, self.module_scope)
159+
160+
def get_stack_for_frame(
161+
self, frame: Union[astroid.FunctionDef, astroid.ClassDef, astroid.Module]
162+
):
163+
"""Get the stack corresponding to the scope of the given frame."""
164+
if isinstance(frame, astroid.FunctionDef):
165+
return self.function_scope
166+
if isinstance(frame, astroid.ClassDef):
167+
return self.class_scope
168+
return self.module_scope
169+
170+
def clear_all(self) -> None:
171+
"""Convenience method to clear all stacks"""
172+
for stack in self:
173+
stack.clear()
174+
175+
150176
class RefactoringChecker(checkers.BaseTokenChecker):
151177
"""Looks for code which can be refactored
152178
@@ -416,6 +442,7 @@ class RefactoringChecker(checkers.BaseTokenChecker):
416442
def __init__(self, linter=None):
417443
checkers.BaseTokenChecker.__init__(self, linter)
418444
self._return_nodes = {}
445+
self._consider_using_with_stack = ConsiderUsingWithStack()
419446
self._init()
420447
self._never_returning_functions = None
421448

@@ -425,6 +452,7 @@ def _init(self):
425452
self._nested_blocks_msg = None
426453
self._reported_swap_nodes = set()
427454
self._can_simplify_bool_op = False
455+
self._consider_using_with_stack.clear_all()
428456

429457
def open(self):
430458
# do this in open since config not fully initialized in __init__
@@ -543,7 +571,12 @@ def process_tokens(self, tokens):
543571
if self.linter.is_message_enabled("trailing-comma-tuple"):
544572
self.add_message("trailing-comma-tuple", line=token.start[0])
545573

574+
@utils.check_messages("consider-using-with")
546575
def leave_module(self, _):
576+
# check for context managers that have been created but not used
577+
self._emit_consider_using_with_if_needed(
578+
self._consider_using_with_stack.module_scope
579+
)
547580
self._init()
548581

549582
@utils.check_messages("too-many-nested-blocks")
@@ -593,7 +626,14 @@ def visit_excepthandler(self, node):
593626

594627
@utils.check_messages("redefined-argument-from-local")
595628
def visit_with(self, node):
596-
for _, names in node.items:
629+
for var, names in node.items:
630+
if isinstance(var, astroid.Name):
631+
for stack in self._consider_using_with_stack:
632+
# We don't need to restrict the stacks we search to the current scope and outer scopes,
633+
# as e.g. the function_scope stack will be empty when we check a ``with`` on the class level.
634+
if var.name in stack:
635+
del stack[var.name]
636+
break
597637
if not names:
598638
continue
599639
for name in names.nodes_of_class(astroid.AssignName):
@@ -818,9 +858,12 @@ def _check_simplifiable_ifexp(self, node):
818858
self.add_message("simplifiable-if-expression", node=node, args=(reduced_to,))
819859

820860
@utils.check_messages(
821-
"too-many-nested-blocks", "inconsistent-return-statements", "useless-return"
861+
"too-many-nested-blocks",
862+
"inconsistent-return-statements",
863+
"useless-return",
864+
"consider-using-with",
822865
)
823-
def leave_functiondef(self, node):
866+
def leave_functiondef(self, node: astroid.FunctionDef) -> None:
824867
# check left-over nested blocks stack
825868
self._emit_nested_blocks_message_if_needed(self._nested_blocks)
826869
# new scope = reinitialize the stack of nested blocks
@@ -830,6 +873,19 @@ def leave_functiondef(self, node):
830873
# check for single return or return None at the end
831874
self._check_return_at_the_end(node)
832875
self._return_nodes[node.name] = []
876+
# check for context managers that have been created but not used
877+
self._emit_consider_using_with_if_needed(
878+
self._consider_using_with_stack.function_scope
879+
)
880+
self._consider_using_with_stack.function_scope.clear()
881+
882+
@utils.check_messages("consider-using-with")
883+
def leave_classdef(self, _: astroid.ClassDef) -> None:
884+
# check for context managers that have been created but not used
885+
self._emit_consider_using_with_if_needed(
886+
self._consider_using_with_stack.class_scope
887+
)
888+
self._consider_using_with_stack.class_scope.clear()
833889

834890
@utils.check_messages("stop-iteration-return")
835891
def visit_raise(self, node):
@@ -1021,6 +1077,10 @@ def _emit_nested_blocks_message_if_needed(self, nested_blocks):
10211077
args=(len(nested_blocks), self.config.max_nested_blocks),
10221078
)
10231079

1080+
def _emit_consider_using_with_if_needed(self, stack: Dict[str, astroid.NodeNG]):
1081+
for node in stack.values():
1082+
self.add_message("consider-using-with", node=node)
1083+
10241084
@staticmethod
10251085
def _duplicated_isinstance_types(node):
10261086
"""Get the duplicated types from the underlying isinstance calls.
@@ -1282,12 +1342,22 @@ def _check_swap_variables(self, node):
12821342
message = "consider-swap-variables"
12831343
self.add_message(message, node=node)
12841344

1345+
@utils.check_messages(
1346+
"simplify-boolean-expression",
1347+
"consider-using-ternary",
1348+
"consider-swap-variables",
1349+
"consider-using-with",
1350+
)
1351+
def visit_assign(self, node: astroid.Assign) -> None:
1352+
self._append_context_managers_to_stack(node)
1353+
self.visit_return(node) # remaining checks are identical as for return nodes
1354+
12851355
@utils.check_messages(
12861356
"simplify-boolean-expression",
12871357
"consider-using-ternary",
12881358
"consider-swap-variables",
12891359
)
1290-
def visit_assign(self, node):
1360+
def visit_return(self, node: astroid.Return) -> None:
12911361
self._check_swap_variables(node)
12921362
if self._is_and_or_ternary(node.value):
12931363
cond, truth_value, false_value = self._and_or_ternary_arguments(node.value)
@@ -1317,9 +1387,54 @@ def visit_assign(self, node):
13171387
)
13181388
self.add_message(message, node=node, args=(suggestion,))
13191389

1320-
visit_return = visit_assign
1390+
def _append_context_managers_to_stack(self, node: astroid.Assign) -> None:
1391+
if _is_inside_context_manager(node):
1392+
# if we are inside a context manager itself, we assume that it will handle the resource management itself.
1393+
return
1394+
if isinstance(node.targets[0], (astroid.Tuple, astroid.List, astroid.Set)):
1395+
assignees = node.targets[0].elts
1396+
value = utils.safe_infer(node.value)
1397+
if value is None or not hasattr(value, "elts"):
1398+
# We cannot deduce what values are assigned, so we have to skip this
1399+
return
1400+
values = value.elts
1401+
else:
1402+
assignees = [node.targets[0]]
1403+
values = [node.value]
1404+
if Uninferable in (assignees, values):
1405+
return
1406+
for assignee, value in zip(assignees, values):
1407+
if not isinstance(value, astroid.Call):
1408+
continue
1409+
inferred = utils.safe_infer(value.func)
1410+
if not inferred or inferred.qname() not in CALLS_RETURNING_CONTEXT_MANAGERS:
1411+
continue
1412+
stack = self._consider_using_with_stack.get_stack_for_frame(node.frame())
1413+
varname = (
1414+
assignee.name
1415+
if isinstance(assignee, astroid.AssignName)
1416+
else assignee.attrname
1417+
)
1418+
if varname in stack:
1419+
# variable was redefined before it was used in a ``with`` block
1420+
self.add_message(
1421+
"consider-using-with",
1422+
node=stack[varname],
1423+
)
1424+
stack[varname] = value
13211425

13221426
def _check_consider_using_with(self, node: astroid.Call):
1427+
if _is_inside_context_manager(node):
1428+
# if we are inside a context manager itself, we assume that it will handle the resource management itself.
1429+
return
1430+
if (
1431+
node
1432+
in self._consider_using_with_stack.get_stack_for_frame(
1433+
node.frame()
1434+
).values()
1435+
):
1436+
# the result of this call was already assigned to a variable and will be checked when leaving the scope.
1437+
return
13231438
inferred = utils.safe_infer(node.func)
13241439
if not inferred:
13251440
return
@@ -1332,9 +1447,7 @@ def _check_consider_using_with(self, node: astroid.Call):
13321447
and not _is_part_of_with_items(node)
13331448
)
13341449
)
1335-
if could_be_used_in_with and not (
1336-
_is_inside_context_manager(node) or _will_be_released_automatically(node)
1337-
):
1450+
if could_be_used_in_with and not _will_be_released_automatically(node):
13381451
self.add_message("consider-using-with", node=node)
13391452

13401453
def _check_consider_using_join(self, aug_assign):

tests/functional/c/consider/consider_using_with.py

+64-11
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import threading
99
import urllib
1010
import zipfile
11-
from concurrent import futures
11+
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
1212

1313

1414
def test_codecs_open():
@@ -112,6 +112,7 @@ class MyLockContext:
112112
"""
113113
The message must not be triggered if the resource allocation is done inside a context manager.
114114
"""
115+
115116
def __init__(self):
116117
self.lock = threading.Lock()
117118

@@ -140,16 +141,6 @@ def test_multiprocessing():
140141
pass
141142

142143

143-
def test_futures():
144-
_ = futures.ThreadPoolExecutor() # [consider-using-with]
145-
with futures.ThreadPoolExecutor():
146-
pass
147-
148-
_ = futures.ProcessPoolExecutor() # [consider-using-with]
149-
with futures.ProcessPoolExecutor():
150-
pass
151-
152-
153144
def test_popen():
154145
_ = subprocess.Popen("sh") # [consider-using-with]
155146
with subprocess.Popen("sh"):
@@ -162,3 +153,65 @@ def test_suppress_in_exit_stack():
162153
_ = stack.enter_context(
163154
open("/sys/firmware/devicetree/base/hwid,location", "r")
164155
) # must not trigger
156+
157+
158+
def test_futures():
159+
"""
160+
Regression test for issue #4689.
161+
ThreadPoolExecutor and ProcessPoolExecutor were formerly part of the callables that raised
162+
the R1732 message if used outside a with block, but there are legitimate use cases where
163+
Executor instances are used e.g. as a persistent background worker pool throughout the program.
164+
"""
165+
thread_executor = ThreadPoolExecutor()
166+
thread_executor.submit(print, 1)
167+
process_executor = ProcessPoolExecutor()
168+
process_executor.submit(print, 2)
169+
thread_executor.shutdown()
170+
process_executor.shutdown()
171+
172+
173+
pool = multiprocessing.Pool() # must not trigger, as it is used later on
174+
with pool:
175+
pass
176+
177+
178+
global_pool = (
179+
multiprocessing.Pool()
180+
) # must not trigger, will be used in nested scope
181+
182+
183+
def my_nested_function():
184+
with global_pool:
185+
pass
186+
187+
188+
# this must also work for tuple unpacking
189+
pool1, pool2 = (
190+
multiprocessing.Pool(), # must not trigger
191+
multiprocessing.Pool(), # must not trigger
192+
)
193+
194+
with pool1:
195+
pass
196+
197+
with pool2:
198+
pass
199+
200+
unused_pool1, unused_pool2 = (
201+
multiprocessing.Pool(), # [consider-using-with]
202+
multiprocessing.Pool(), # [consider-using-with]
203+
)
204+
205+
used_pool, unused_pool = (
206+
multiprocessing.Pool(), # must not trigger
207+
multiprocessing.Pool(), # [consider-using-with]
208+
)
209+
with used_pool:
210+
pass
211+
212+
unused_pool, used_pool = (
213+
multiprocessing.Pool(), # [consider-using-with]
214+
multiprocessing.Pool(), # must not trigger
215+
)
216+
with used_pool:
217+
pass

0 commit comments

Comments
 (0)