[contextmanager-generator-missing-cleanup] Warn about context manager without try/finally in generator functions (#9133)
diff --git a/doc/data/messages/c/contextmanager-generator-missing-cleanup/bad.py b/doc/data/messages/c/contextmanager-generator-missing-cleanup/bad.py
new file mode 100644
index 0000000..e65906a
--- /dev/null
+++ b/doc/data/messages/c/contextmanager-generator-missing-cleanup/bad.py
@@ -0,0 +1,14 @@
+import contextlib
+
+
+@contextlib.contextmanager
+def cm():
+ contextvar = "acquired context"
+ print("cm enter")
+ yield contextvar
+ print("cm exit")
+
+
+def genfunc_with_cm(): # [contextmanager-generator-missing-cleanup]
+ with cm() as context:
+ 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
new file mode 100644
index 0000000..88860d2
--- /dev/null
+++ b/doc/data/messages/c/contextmanager-generator-missing-cleanup/details.rst
@@ -0,0 +1,10 @@
+Instantiating and using a contextmanager inside a generator function can
+result in unexpected behavior if there is an expectation that the context is only
+available for the generator function. In the case that the generator is not closed or destroyed
+then the context manager is held suspended as is.
+
+This message warns on the generator function instead of the contextmanager function
+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.
diff --git a/doc/data/messages/c/contextmanager-generator-missing-cleanup/good.py b/doc/data/messages/c/contextmanager-generator-missing-cleanup/good.py
new file mode 100644
index 0000000..406d984
--- /dev/null
+++ b/doc/data/messages/c/contextmanager-generator-missing-cleanup/good.py
@@ -0,0 +1,49 @@
+import contextlib
+
+
+@contextlib.contextmanager
+def good_cm_except():
+ contextvar = "acquired context"
+ print("good cm enter")
+ try:
+ yield contextvar
+ except GeneratorExit:
+ print("good cm exit")
+
+
+def genfunc_with_cm():
+ with good_cm_except() as context:
+ yield context * 2
+
+
+def genfunc_with_discard():
+ with good_cm_except():
+ yield "discarded"
+
+
+@contextlib.contextmanager
+def good_cm_yield_none():
+ print("good cm enter")
+ yield
+ print("good cm exit")
+
+
+def genfunc_with_none_yield():
+ with good_cm_yield_none() as var:
+ print(var)
+ yield "constant yield"
+
+
+@contextlib.contextmanager
+def good_cm_finally():
+ contextvar = "acquired context"
+ print("good cm enter")
+ try:
+ yield contextvar
+ finally:
+ print("good cm exit")
+
+
+def good_cm_finally_genfunc():
+ with good_cm_finally() as context:
+ yield context * 2
diff --git a/doc/data/messages/c/contextmanager-generator-missing-cleanup/related.rst b/doc/data/messages/c/contextmanager-generator-missing-cleanup/related.rst
new file mode 100644
index 0000000..aacc968
--- /dev/null
+++ b/doc/data/messages/c/contextmanager-generator-missing-cleanup/related.rst
@@ -0,0 +1,2 @@
+- `Rationale <https://discuss.python.org/t/preventing-yield-inside-certain-context-managers/1091>`_
+- `CPython Issue <https://github.com/python/cpython/issues/81924#issuecomment-1093830682>`_
diff --git a/doc/user_guide/checkers/features.rst b/doc/user_guide/checkers/features.rst
index 647c77e..cb63930 100644
--- a/doc/user_guide/checkers/features.rst
+++ b/doc/user_guide/checkers/features.rst
@@ -166,6 +166,9 @@
This is a particular case of W0104 with its own message so you can easily
disable it if you're using those strings as documentation, instead of
comments.
+:contextmanager-generator-missing-cleanup (W0135): *The context used in function %r will not be exited.*
+ Used when a contextmanager is used inside a generator function and the
+ cleanup is not handled.
:unnecessary-pass (W0107): *Unnecessary pass statement*
Used when a "pass" statement can be removed without affecting the behaviour
of the code.
diff --git a/doc/user_guide/messages/messages_overview.rst b/doc/user_guide/messages/messages_overview.rst
index f5d6411..99cf238 100644
--- a/doc/user_guide/messages/messages_overview.rst
+++ b/doc/user_guide/messages/messages_overview.rst
@@ -228,6 +228,7 @@
warning/comparison-with-callable
warning/confusing-with-statement
warning/consider-ternary-expression
+ warning/contextmanager-generator-missing-cleanup
warning/dangerous-default-value
warning/deprecated-argument
warning/deprecated-attribute
diff --git a/doc/whatsnew/fragments/2832.new_check b/doc/whatsnew/fragments/2832.new_check
new file mode 100644
index 0000000..64b38be
--- /dev/null
+++ b/doc/whatsnew/fragments/2832.new_check
@@ -0,0 +1,6 @@
+Checks for generators that use contextmanagers that don't handle cleanup properly.
+Is meant to raise visibilty on the case that a generator is not fully exhausted and the contextmanager is not cleaned up properly.
+A contextmanager must yield a non-constant value and not handle cleanup for GeneratorExit.
+The using generator must attempt to use the yielded context value `with x() as y` and not just `with x()`.
+
+Closes #2832
diff --git a/pylint/checkers/base/__init__.py b/pylint/checkers/base/__init__.py
index c9067b4..a3e6071 100644
--- a/pylint/checkers/base/__init__.py
+++ b/pylint/checkers/base/__init__.py
@@ -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,
@@ -46,3 +47,4 @@
linter.register_checker(DocStringChecker(linter))
linter.register_checker(PassChecker(linter))
linter.register_checker(ComparisonChecker(linter))
+ linter.register_checker(FunctionChecker(linter))
diff --git a/pylint/checkers/base/function_checker.py b/pylint/checkers/base/function_checker.py
new file mode 100644
index 0000000..bf85747
--- /dev/null
+++ b/pylint/checkers/base/function_checker.py
@@ -0,0 +1,135 @@
+# 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 itertools import chain
+
+from astroid import nodes
+
+from pylint.checkers import utils
+from pylint.checkers.base.basic_checker import _BasicChecker
+
+
+class FunctionChecker(_BasicChecker):
+ """Check if a function definition handles possible side effects."""
+
+ msgs = {
+ "W0135": (
+ "The context used in function %r will not be exited.",
+ "contextmanager-generator-missing-cleanup",
+ "Used when a contextmanager is used inside a generator function"
+ " 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)
+
+ @utils.only_required_for_messages("contextmanager-generator-missing-cleanup")
+ def visit_asyncfunctiondef(self, node: nodes.AsyncFunctionDef) -> None:
+ self._check_contextmanager_generator_missing_cleanup(node)
+
+ def _check_contextmanager_generator_missing_cleanup(
+ self, node: nodes.FunctionDef
+ ) -> None:
+ """Check a FunctionDef to find if it is a generator
+ that uses a contextmanager internally.
+
+ If it is, check if the contextmanager is properly cleaned up. Otherwise, add message.
+
+ :param node: FunctionDef node to check
+ :type node: nodes.FunctionDef
+ """
+ # if function does not use a Yield statement, it cant be a generator
+ with_nodes = list(node.nodes_of_class(nodes.With))
+ if not with_nodes:
+ return
+ # check for Yield inside the With statement
+ yield_nodes = list(
+ chain.from_iterable(
+ with_node.nodes_of_class(nodes.Yield) for with_node in with_nodes
+ )
+ )
+ if not yield_nodes:
+ return
+
+ # infer the call that yields a value, and check if it is a contextmanager
+ for with_node in with_nodes:
+ for call, held in with_node.items:
+ if held is None:
+ # if we discard the value, then we can skip checking it
+ continue
+
+ # safe infer is a generator
+ inferred_node = getattr(utils.safe_infer(call), "parent", None)
+ if not isinstance(inferred_node, nodes.FunctionDef):
+ continue
+ if self._node_fails_contextmanager_cleanup(inferred_node, yield_nodes):
+ self.add_message(
+ "contextmanager-generator-missing-cleanup",
+ node=node,
+ args=(node.name,),
+ )
+
+ @staticmethod
+ def _node_fails_contextmanager_cleanup(
+ node: nodes.FunctionDef, yield_nodes: list[nodes.Yield]
+ ) -> bool:
+ """Check if a 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
+
+ :param node: Node to check
+ :type node: nodes.FunctionDef
+ :return: True if fails, False otherwise
+ :param yield_nodes: List of Yield nodes in the function body
+ :type yield_nodes: list[nodes.Yield]
+ :rtype: bool
+ """
+
+ def check_handles_generator_exceptions(try_node: nodes.Try) -> bool:
+ # needs to handle either GeneratorExit, Exception, or bare except
+ for handler in try_node.handlers:
+ if handler.type is None:
+ # handles all exceptions (bare except)
+ return True
+ inferred = utils.safe_infer(handler.type)
+ if inferred and inferred.qname() in {
+ "builtins.GeneratorExit",
+ "builtins.Exception",
+ }:
+ return True
+ return False
+
+ # if context manager yields a non-constant value, then continue checking
+ if any(
+ yield_node.value is None or isinstance(yield_node.value, nodes.Const)
+ for yield_node in yield_nodes
+ ):
+ return False
+ # if function body has multiple Try, filter down to the ones that have a yield node
+ try_with_yield_nodes = [
+ try_node
+ for try_node in node.nodes_of_class(nodes.Try)
+ if any(try_node.nodes_of_class(nodes.Yield))
+ ]
+ if not try_with_yield_nodes:
+ # no try blocks at all, so checks after this line do not apply
+ return True
+ # if the contextmanager has a finally block, then it is fine
+ if all(try_node.finalbody for try_node in try_with_yield_nodes):
+ return False
+ # if the contextmanager catches GeneratorExit, then it is fine
+ if all(
+ check_handles_generator_exceptions(try_node)
+ for try_node in try_with_yield_nodes
+ ):
+ return False
+ return True
diff --git a/tests/functional/c/consider/consider_using_with.py b/tests/functional/c/consider/consider_using_with.py
index e8e1623..9ff70e0 100644
--- a/tests/functional/c/consider/consider_using_with.py
+++ b/tests/functional/c/consider/consider_using_with.py
@@ -186,9 +186,7 @@
pass
-global_pool = (
- multiprocessing.Pool()
-) # must not trigger, will be used in nested scope
+global_pool = multiprocessing.Pool() # must not trigger, will be used in nested scope
def my_nested_function():
diff --git a/tests/functional/c/consider/consider_using_with.txt b/tests/functional/c/consider/consider_using_with.txt
index 455762f..864a078 100644
--- a/tests/functional/c/consider/consider_using_with.txt
+++ b/tests/functional/c/consider/consider_using_with.txt
@@ -20,9 +20,9 @@
consider-using-with:145:4:145:19:test_multiprocessing:Consider using 'with' for resource-allocating operations:UNDEFINED
consider-using-with:150:4:150:19:test_multiprocessing:Consider using 'with' for resource-allocating operations:UNDEFINED
consider-using-with:156:8:156:30:test_popen:Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:212:4:212:26::Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:213:4:213:26::Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:218:4:218:26::Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:224:4:224:26::Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:240:18:240:40:test_subscript_assignment:Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:242:24:242:46:test_subscript_assignment:Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:210:4:210:26::Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:211:4:211:26::Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:216:4:216:26::Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:222:4:222:26::Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:238:18:238:40:test_subscript_assignment:Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:240:24:240:46:test_subscript_assignment:Consider using 'with' for resource-allocating operations:UNDEFINED
diff --git a/tests/functional/c/consider/consider_using_with_open.py b/tests/functional/c/consider/consider_using_with_open.py
index dd58426..b76765c 100644
--- a/tests/functional/c/consider/consider_using_with_open.py
+++ b/tests/functional/c/consider/consider_using_with_open.py
@@ -1,5 +1,6 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, invalid-name, import-outside-toplevel
# pylint: disable=missing-class-docstring, too-few-public-methods, unused-variable, multiple-statements, line-too-long
+# pylint: disable=contextmanager-generator-missing-cleanup
"""
Previously, open was uninferable on PyPy so we moved all functional tests
to a separate file. This is no longer the case but the files remain split.
diff --git a/tests/functional/c/consider/consider_using_with_open.txt b/tests/functional/c/consider/consider_using_with_open.txt
index 3819e26..57aaff7 100644
--- a/tests/functional/c/consider/consider_using_with_open.txt
+++ b/tests/functional/c/consider/consider_using_with_open.txt
@@ -1,7 +1,7 @@
-consider-using-with:10:9:10:43::Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:14:9:14:43:test_open:Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:44:4:44:33:test_open_outside_assignment:Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:45:14:45:43:test_open_outside_assignment:Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:50:8:50:37:test_open_inside_with_block:Consider using 'with' for resource-allocating operations:UNDEFINED
-consider-using-with:118:26:120:13:TestControlFlow.test_triggers_if_reassigned_after_if_else:Consider using 'with' for resource-allocating operations:UNDEFINED
-used-before-assignment:139:12:139:23:TestControlFlow.test_defined_in_try_and_finally:Using variable 'file_handle' before assignment:CONTROL_FLOW
+consider-using-with:11:9:11:43::Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:15:9:15:43:test_open:Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:45:4:45:33:test_open_outside_assignment:Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:46:14:46:43:test_open_outside_assignment:Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:51:8:51:37:test_open_inside_with_block:Consider using 'with' for resource-allocating operations:UNDEFINED
+consider-using-with:119:26:121:13:TestControlFlow.test_triggers_if_reassigned_after_if_else:Consider using 'with' for resource-allocating operations:UNDEFINED
+used-before-assignment:140:12:140:23:TestControlFlow.test_defined_in_try_and_finally:Using variable 'file_handle' before assignment:CONTROL_FLOW
diff --git a/tests/functional/c/contextmanager_generator_missing_cleanup.py b/tests/functional/c/contextmanager_generator_missing_cleanup.py
new file mode 100644
index 0000000..ff7f274
--- /dev/null
+++ b/tests/functional/c/contextmanager_generator_missing_cleanup.py
@@ -0,0 +1,177 @@
+# pylint: disable = missing-docstring, unused-variable, bare-except, broad-exception-caught
+from collections import namedtuple
+import contextlib
+from contextlib import contextmanager
+
+# Positive
+
+
+@contextlib.contextmanager
+def cm():
+ contextvar = "acquired context"
+ print("cm enter")
+ yield contextvar
+ print("cm exit")
+
+
+def genfunc_with_cm(): # [contextmanager-generator-missing-cleanup]
+ with cm() as context:
+ yield context * 2
+
+
+@contextmanager
+def name_cm():
+ contextvar = "acquired context"
+ print("cm enter")
+ yield contextvar
+ print("cm exit")
+
+
+def genfunc_with_name_cm(): # [contextmanager-generator-missing-cleanup]
+ with name_cm() as context:
+ yield context * 2
+
+
+def genfunc_with_cm_after(): # [contextmanager-generator-missing-cleanup]
+ with after_cm() as context:
+ yield context * 2
+
+
+@contextlib.contextmanager
+def after_cm():
+ contextvar = "acquired context"
+ print("cm enter")
+ yield contextvar
+ print("cm exit")
+
+
+@contextmanager
+def cm_with_improper_handling():
+ contextvar = "acquired context"
+ print("cm enter")
+ try:
+ yield contextvar
+ except ValueError:
+ pass
+ print("cm exit")
+
+
+def genfunc_with_cm_improper(): # [contextmanager-generator-missing-cleanup]
+ with cm_with_improper_handling() as context:
+ yield context * 2
+
+
+# Negative
+
+
+class Enterable:
+ def __enter__(self):
+ print("enter")
+ return self
+
+ def __exit__(self, *args):
+ print("exit")
+
+
+def genfunc_with_enterable():
+ enter = Enterable()
+ with enter as context:
+ yield context * 2
+
+
+def genfunc_with_enterable_attr():
+ EnterableTuple = namedtuple("EnterableTuple", ["attr"])
+ t = EnterableTuple(Enterable())
+ with t.attr as context:
+ yield context.attr * 2
+
+
+@contextlib.contextmanager
+def good_cm_except():
+ contextvar = "acquired context"
+ print("good cm enter")
+ try:
+ yield contextvar
+ except GeneratorExit:
+ print("good cm exit")
+
+
+def good_genfunc_with_cm():
+ with good_cm_except() as context:
+ yield context * 2
+
+
+def genfunc_with_discard():
+ with good_cm_except():
+ yield "discarded"
+
+
+@contextlib.contextmanager
+def good_cm_yield_none():
+ print("good cm enter")
+ yield
+ print("good cm exit")
+
+
+def genfunc_with_none_yield():
+ with good_cm_yield_none() as var:
+ print(var)
+ yield "discarded"
+
+
+@contextlib.contextmanager
+def good_cm_finally():
+ contextvar = "acquired context"
+ print("good cm enter")
+ try:
+ yield contextvar
+ finally:
+ print("good cm exit")
+
+
+def good_cm_finally_genfunc():
+ with good_cm_finally() as context:
+ yield context * 2
+
+
+def genfunc_with_cm_finally_odd_body():
+ with good_cm_finally() as context:
+ if context:
+ yield context * 2
+ else:
+ yield context * 3
+
+
+@cm_with_improper_handling
+def genfunc_wrapped():
+ yield "wrapped"
+
+
+@contextmanager
+def cm_bare_handler():
+ contextvar = "acquired context"
+ print("cm enter")
+ try:
+ yield contextvar
+ except:
+ print("cm exit")
+
+
+@contextmanager
+def cm_base_exception_handler():
+ contextvar = "acquired context"
+ print("cm enter")
+ try:
+ yield contextvar
+ except Exception:
+ print("cm exit")
+
+
+def genfunc_with_cm_bare_handler():
+ with cm_bare_handler() as context:
+ yield context * 2
+
+
+def genfunc_with_cm_base_exception_handler():
+ with cm_base_exception_handler() 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
new file mode 100644
index 0000000..ca18ed4
--- /dev/null
+++ b/tests/functional/c/contextmanager_generator_missing_cleanup.txt
@@ -0,0 +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
diff --git a/tests/functional/m/missing/missing_kwoa.py b/tests/functional/m/missing/missing_kwoa.py
index 15df710..1594325 100644
--- a/tests/functional/m/missing/missing_kwoa.py
+++ b/tests/functional/m/missing/missing_kwoa.py
@@ -2,6 +2,7 @@
import contextlib
import typing
+
def target(pos, *, keyword):
return pos + keyword
@@ -13,18 +14,19 @@
def forwarding_args(*args, keyword):
target(*args, keyword=keyword)
+
def forwarding_conversion(*args, **kwargs):
target(*args, **dict(kwargs))
def not_forwarding_kwargs(*args, **kwargs):
- target(*args) # [missing-kwoa]
+ target(*args) # [missing-kwoa]
target(1, keyword=2)
PARAM = 1
-target(2, PARAM) # [too-many-function-args, missing-kwoa]
+target(2, PARAM) # [too-many-function-args, missing-kwoa]
def some_function(*, param):
@@ -39,9 +41,8 @@
class Parent:
-
@typing.overload
- def __init__( self, *, first, second, third):
+ def __init__(self, *, first, second, third):
pass
@typing.overload
@@ -53,22 +54,19 @@
pass
def __init__(
- self,
- *,
- first,
- second: typing.Optional[str] = None,
- third: typing.Optional[str] = None):
+ self,
+ *,
+ first,
+ second: typing.Optional[str] = None,
+ third: typing.Optional[str] = None,
+ ):
self._first = first
self._second = second
self._third = third
class Child(Parent):
- def __init__(
- self,
- *,
- first,
- second):
+ def __init__(self, *, first, second):
super().__init__(first=first, second=second)
self._first = first + second
@@ -77,6 +75,7 @@
def run(*, a):
yield
+
def test_context_managers(**kw):
run(**kw)
@@ -89,4 +88,5 @@
with run(**kw), run(): # [missing-kwoa]
pass
+
test_context_managers(a=1)
diff --git a/tests/functional/m/missing/missing_kwoa.txt b/tests/functional/m/missing/missing_kwoa.txt
index fc1694e..e31249c 100644
--- a/tests/functional/m/missing/missing_kwoa.txt
+++ b/tests/functional/m/missing/missing_kwoa.txt
@@ -1,4 +1,4 @@
-missing-kwoa:21:4:21:17:not_forwarding_kwargs:Missing mandatory keyword argument 'keyword' in function call:INFERENCE
-missing-kwoa:27:0:27:16::Missing mandatory keyword argument 'keyword' in function call:INFERENCE
-too-many-function-args:27:0:27:16::Too many positional arguments for function call:UNDEFINED
-missing-kwoa:89:20:89:25:test_context_managers:Missing mandatory keyword argument 'a' in function call:INFERENCE
+missing-kwoa:23:4:23:17:not_forwarding_kwargs:Missing mandatory keyword argument 'keyword' in function call:INFERENCE
+missing-kwoa:29:0:29:16::Missing mandatory keyword argument 'keyword' in function call:INFERENCE
+too-many-function-args:29:0:29:16::Too many positional arguments for function call:UNDEFINED
+missing-kwoa:88:20:88:25:test_context_managers:Missing mandatory keyword argument 'a' in function call:INFERENCE