Repository: maxfischer2781/asyncstdlib

Issue:
Intermediate iterators not cleaned up
The ``AsyncIteratorBorrow`` helper creates a wrapper async generator to prevent its underlying iterable from cleanup. The wrapper is never ``aclose``d, which may lead to a resource warning on certain versions of Python.

This has no functional effect, but leads to misleading warnings.

See https://stackoverflow.com/questions/62684213/asyncio-task-was-destroyed-but-it-is-pending

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14?src=pr&el=h1) Report
> Merging [#14](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14?src=pr&el=desc) into [master](https://codecov.io/gh/maxfischer2781/asyncstdlib/commit/1850256467dc3fca67b9139011c56fd3fae69938&el=desc) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14/graphs/tree.svg?width=650&height=150&src=pr&token=frT6svSwUb)](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##           master      #14   +/-   ##
=======================================
  Coverage   99.47%   99.48%           
=======================================
  Files           8        9    +1     
  Lines         766      780   +14     
  Branches      128      129    +1     
=======================================
+ Hits          762      776   +14     
  Misses          4        4           
```


| [Impacted Files](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [asyncstdlib/asynctools.py](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14/diff?src=pr&el=tree#diff-YXN5bmNzdGRsaWIvYXN5bmN0b29scy5weQ==) | `100.00% <100.00%> (ø)` | |
| [asyncstdlib/functools.py](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14/diff?src=pr&el=tree#diff-YXN5bmNzdGRsaWIvZnVuY3Rvb2xzLnB5) | `100.00% <0.00%> (ø)` | |
| [asyncstdlib/contextlib.py](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14/diff?src=pr&el=tree#diff-YXN5bmNzdGRsaWIvY29udGV4dGxpYi5weQ==) | `100.00% <0.00%> (ø)` | |
| [asyncstdlib/\_\_init\_\_.py](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14/diff?src=pr&el=tree#diff-YXN5bmNzdGRsaWIvX19pbml0X18ucHk=) | `100.00% <0.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14?src=pr&el=footer). Last update [1850256...792da6e](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/14?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/asyncstdlib/asynctools.py b/asyncstdlib/asynctools.py
index 8b7682d..4de42f8 100644
--- a/asyncstdlib/asynctools.py
+++ b/asyncstdlib/asynctools.py
@@ -26,7 +26,7 @@ class AsyncIteratorBorrow(AsyncGenerator[T, S]):
         # iterator.__aiter__ is likely to return iterator (e.g. for async def: yield)
         # We wrap it in a separate async iterator/generator to hide its __aiter__.
         try:
-            wrapped_iterator: AsyncIterator[T, S] = self._wrapped_iterator(iterator)
+            wrapped_iterator: AsyncGenerator[T, S] = self._wrapped_iterator(iterator)
             self.__anext__ = iterator.__anext__  # argument must be an async iterable!
         except (AttributeError, TypeError):
             raise TypeError(
@@ -48,7 +48,7 @@ class AsyncIteratorBorrow(AsyncGenerator[T, S]):
     @staticmethod
     async def _wrapped_iterator(
         iterator: Union[AsyncIterator[T], AsyncGenerator[T, S]]
-    ) -> AsyncIterator[T]:
+    ) -> AsyncGenerator[T, S]:
         async for item in iterator:
             yield item
 
@@ -58,20 +58,34 @@ class AsyncIteratorBorrow(AsyncGenerator[T, S]):
         )
 
     async def aclose(self):
-        pass
+        wrapper_iterator = self.__aiter__()
+        # allow closing the intermediate wrapper
+        # this prevents a resource warning if the wrapper is GC'd
+        # the underlying iterator is NOT affected by this
+        await wrapper_iterator.aclose()
+        # disable direct asend/athrow to the underlying iterator
+        if hasattr(self, "asend"):
+            self.asend = wrapper_iterator.asend
+        if hasattr(self, "athrow"):
+            self.athrow = wrapper_iterator.athrow
 
 
 class AsyncIteratorContext(AsyncContextManager[AsyncIterator[T]]):
 
-    __slots__ = "__wrapped__", "_iterator"
+    __slots__ = "_borrowed_iter", "_iterator"
 
     def __init__(self, iterable: AnyIterable[T]):
         self._iterator: AsyncIterator[T] = aiter(iterable)
+        self._borrowed_iter = None
 
     async def __aenter__(self) -> AsyncIterator[T]:
-        return AsyncIteratorBorrow(self._iterator)
+        if self._borrowed_iter is not None:
+            raise RuntimeError("scoped_iter is not re-entrant")
+        borrowed_iter = self._borrowed_iter = AsyncIteratorBorrow(self._iterator)
+        return borrowed_iter
 
     async def __aexit__(self, exc_type, exc_val, exc_tb):
+        await self._borrowed_iter.aclose()
         await self._iterator.aclose()
         return False
 
@@ -91,7 +105,8 @@ def borrow(iterator: AsyncIterator[T]) -> AsyncIteratorBorrow[T, None]:
     :py:meth:`~agen.athrow` if the underlying iterator supports them as well;
     this allows borrowing either an :py:class:`~collections.abc.AsyncIterator`
     or :py:class:`~collections.abc.AsyncGenerator`. Regardless of iterator type,
-    :py:meth:`~agen.aclose` is always provided and does nothing.
+    :py:meth:`~agen.aclose` is always provided; it closes only the borrowed
+    iterator, not the underlying iterator.
 
     .. seealso:: Use :py:func:`~.scoped_iter` to ensure an (async) iterable
                  is eventually closed and only :term:`borrowed <borrowing>` until then.


Test patch:
diff --git a/unittests/test_asynctools.py b/unittests/test_asynctools.py
index 89f2be5..8bc3b3e 100644
--- a/unittests/test_asynctools.py
+++ b/unittests/test_asynctools.py
@@ -13,8 +13,6 @@ async def test_nested_lifetime():
         values.append(await a.anext(a1))
         async with a.scoped_iter(a1) as a2:
             values.append(await a.anext(a2))
-            print(a2)
-        print(a1)
         # original iterator is not closed by inner scope
         async for value in a1:
             values.append(value)
@@ -59,6 +57,48 @@ async def test_borrow_iterable():
     assert values == [0, 1]
 
 
+class Closeable:
+    def __init__(self, iterator):
+        self.iterator = iterator
+
+    def __aiter__(self):
+        return self
+
+    async def __anext__(self):
+        return await a.anext(self.iterator)
+
+    async def aclose(self):
+        await self.iterator.aclose()
+
+
+@pytest.mark.parametrize(
+    "async_iterable_t",
+    [
+        lambda: asyncify(range(10)),
+        lambda: Closeable(asyncify(range(10))),
+        lambda: Uncloseable(asyncify(range(10))),
+    ],
+)
+@sync
+async def test_borrow_methods(async_iterable_t):
+    async_iterable = async_iterable_t()
+    values = []
+    async with a.scoped_iter(async_iterable) as a1:
+        values.append(await a.anext(a1))
+        assert hasattr(a1, "athrow") == hasattr(async_iterable, "athrow")
+        assert hasattr(a1, "asend") == hasattr(async_iterable, "asend")
+    assert values == [0]
+
+
+@sync
+async def test_scoped_iter_misuse():
+    scoped_iter = a.scoped_iter(asyncify(range(5)))
+    async with scoped_iter:
+        with pytest.raises(RuntimeError):
+            async with scoped_iter:
+                pytest.fail("may not enter scoped_iter twice")
+
+
 @sync
 async def test_borrow_misuse():
     with pytest.raises(TypeError):