Repository: pylint-dev/pylint

Issue:
False Positive on contextmanager-generator-missing-cleanup / W0135 When Using With Expression in the ContextManager
### Bug description
The `contextmanager-generator-missing-cleanup` warning triggers a false positive (by my understanding of the warning) when a `yield` expression is fully cleaned up by a surrounding `with` statement (or multiple with statements). 

Below is a simplified example of the issue:

```python
import contextlib


@contextlib.contextmanager
def cm():
    with open("/tmp/test", "wb+") as contextvar:
        yield contextvar.fileno()


def genfunc_with_cm():  # [contextmanager-generator-missing-cleanup]
    with cm() as context:
        yield context * 2
```

The warning can be silenced with the guidance from https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/contextmanager-generator-missing-cleanup.html . But by my understanding this is an unnecessary warning in this case. The `with` statement in `cm()` does the 'right thing' when `yield` throws `GeneratorExit` and closes the file.


### Configuration

_No response_

### Command used

```shell
pylint ./test.py
```


### Pylint output

```python
************* Module test
W  test.py:10: The context used in function 'genfunc_with_cm' will not be exited. (contextmanager-generator-missing-cleanup | W0135)
```


### Expected behavior

I would expect the `contextmanager-generator-missing-cleanup` to only warn when there is extra cleanup required in a `contextmanager` outside of  `with` statements. 

I think you see the intention of these cases being supported in the `good_cm_yield_none()` example in https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/contextmanager-generator-missing-cleanup.html

If there's nothing to clean up: the warning should not be generated.

### Pylint version

```shell
pylint 3.2.0
astroid 3.2.0
Python 3.10.12 (main, Nov 20 2023, 15:14:05) [GCC 11.4.0]
```


### OS / Environment

Ubuntu 22.04 LTS

### Additional dependencies

_No response_


Discussion:
Yes I agree, this seems like a false positive. Opinions @Pierre-Sassoulas @rhyn0?
Yah that seems correct.

Wondering if the way to check for this is by recursively tracing `with` calls and then we just have a white list of safe context managers such as `open`. Have to imagine we could have a context manager function using another and so on so forth

Worried about how a poorly written `opener` could break a whitelist containing `open` but that is probably a rare edge case compared to the example above.
> Worried about how a poorly written opener could break a whitelist containing open but that is probably a rare edge case compared to the example above.

We have astroid inference to check that it's an actual open and not something else. We generally don't because shadowing builtin is really bad practice in general and the perf impact to everyone to prevent an edge case that should never happen is not negligible. 
My current understanding is that as long as there is no additional code after the `yield`, it's fine. Should be as simple as checking that the yield statement is the last on in the current block and recursively upwards until the current scope is reached.

Am I missing something?
>  that as long as there is no additional code after the yield, it's fine

This is my mental model of the situation as well. What orginally triggered this in my code was not the `open()` call: but a custom written context manager. The example with `open()` is just the smallest reproducable example I could come up with.
Similar case: If I have code that is decorated with `@contextmanager`, creates `with TemporaryDirectory()` and does some prep work, then exits the function by yielding a file in the temporary directory -- there is no expected code to run after yield but I do want the parent tempdir cleanup.

Explicitly wrapping it in try/finally with cleanup code consisting of "pass", feels wrong because that's just a pinky promise that cleanup is done.

```
mesonbuild/compilers/compilers.py:858:4: W0135: The context used in function 'cached_compile' will not be exited. (contextmanager-generator-missing-cleanup)
```
https://github.com/mesonbuild/meson/blob/128f0e828e425793203f3112c23cb31f959b4b3e/mesonbuild/compilers/compilers.py#L862-L874
Opened #9654 which should at least address the most common false-positive case with a singular yield.

@eli-schwartz I looked at your example. The fix won't work there unfortunately. Checking all `if` branches would add additional complexity. It's probably simpler to add a `pylint: disable` in that case.



Patch:
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/fragments/9625.false_positive b/doc/whatsnew/fragments/9625.false_positive
new file mode 100644
index 0000000000..90d4a7a076
--- /dev/null
+++ b/doc/whatsnew/fragments/9625.false_positive
@@ -0,0 +1,4 @@
+Exclude context manager without cleanup from
+``contextmanager-generator-missing-cleanup`` checks.
+
+Closes #9625
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


Test patch:
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