Repository: maxfischer2781/asyncstdlib

Issue:
Change in behavior of chain() between 3.10.5 and 3.10.6.
There is a subtle change in behavior introduced in the recent changes to `chain()`. Chain's iterator appears to be escaping from its "scope" when it is interrupted.

Here is an example program that produces different output. I'm using Python 3.11.2, but I don't think the Python version makes much difference.

```python
import asyncio

import asyncstdlib as asl


async def gen1(*args):
    try:
        for i in args:
            yield i
    finally:
        print("FINALLY")


async def main():
    nums = asl.chain(gen1(1, 2, 3))
    nums = asl.takewhile(lambda x: x != 2, nums)

    async for n in nums:
        print(n)

    print("ALL DONE")


asyncio.run(main())
```

## asyncstdlib 3.10.5 (correct output)

```
1
FINALLY
ALL DONE
```

## asyncstdlib 3.10.6 (incorrect output)

```
1
ALL DONE
FINALLY
```





Discussion:
codecov[bot]: ## [Codecov](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/108?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) Report
Patch coverage: **`50.00`**% and project coverage change: **`-0.05`** :warning:
> Comparison is base [(`d20a48b`)](https://codecov.io/gh/maxfischer2781/asyncstdlib/commit/d20a48b35e6fd5d360c6a84b53e696684ecb5d98?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) 99.25% compared to head [(`7caa05d`)](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/108?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) 99.20%.

> :exclamation: Current head 7caa05d differs from pull request most recent head ecbe65d. Consider uploading reports for the commit ecbe65d to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #108      +/-   ##
==========================================
- Coverage   99.25%   99.20%   -0.05%     
==========================================
  Files          20       20              
  Lines        2017     2019       +2     
  Branches      308      308              
==========================================
+ Hits         2002     2003       +1     
- Misses         10       11       +1     
  Partials        5        5              
```


| [Impacted Files](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/108?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) | Coverage Δ | |
|---|---|---|
| [asyncstdlib/itertools.py](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/108?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer#diff-YXN5bmNzdGRsaWIvaXRlcnRvb2xzLnB5) | `98.61% <50.00%> (-0.46%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/108?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer).


Patch:
diff --git a/asyncstdlib/itertools.py b/asyncstdlib/itertools.py
index fc12573..f6f06ee 100644
--- a/asyncstdlib/itertools.py
+++ b/asyncstdlib/itertools.py
@@ -148,33 +148,54 @@ class chain(AsyncIterator[T]):
     The resulting iterator consecutively iterates over and yields all values from
     each of the ``iterables``. This is similar to converting all ``iterables`` to
     sequences and concatenating them, but lazily exhausts each iterable.
+
+    The ``chain`` assumes ownership of its ``iterables`` and closes them reliably
+    when the ``chain`` is closed. Pass the ``iterables`` via a :py:class:`tuple` to
+    ``chain.from_iterable`` to avoid closing all iterables but those already processed.
     """
 
-    __slots__ = ("_impl",)
+    __slots__ = ("_iterator", "_owned_iterators")
 
-    def __init__(self, *iterables: AnyIterable[T]):
-        async def impl() -> AsyncIterator[T]:
-            for iterable in iterables:
+    @staticmethod
+    async def _chain_iterator(
+        any_iterables: AnyIterable[AnyIterable[T]],
+    ) -> AsyncGenerator[T, None]:
+        async with ScopedIter(any_iterables) as iterables:
+            async for iterable in iterables:
                 async with ScopedIter(iterable) as iterator:
                     async for item in iterator:
                         yield item
 
-        self._impl = impl()
+    def __init__(
+        self, *iterables: AnyIterable[T], _iterables: AnyIterable[AnyIterable[T]] = ()
+    ):
+        self._iterator = self._chain_iterator(iterables or _iterables)
+        self._owned_iterators = (
+            iterable
+            for iterable in iterables
+            if isinstance(iterable, AsyncIterator) and hasattr(iterable, "aclose")
+        )
 
-    @staticmethod
-    async def from_iterable(iterable: AnyIterable[AnyIterable[T]]) -> AsyncIterator[T]:
+    @classmethod
+    def from_iterable(cls, iterable: AnyIterable[AnyIterable[T]]) -> "chain[T]":
         """
         Alternate constructor for :py:func:`~.chain` that lazily exhausts
-        iterables as well
+        the ``iterable`` of iterables as well
+
+        This is suitable for chaining iterables from a lazy or infinite ``iterable``.
+        In turn, closing the ``chain`` only closes those iterables
+        already fetched from ``iterable``.
         """
-        async with ScopedIter(iterable) as iterables:
-            async for sub_iterable in iterables:
-                async with ScopedIter(sub_iterable) as iterator:
-                    async for item in iterator:
-                        yield item
+        return cls(_iterables=iterable)
 
     def __anext__(self) -> Awaitable[T]:
-        return self._impl.__anext__()
+        return self._iterator.__anext__()
+
+    async def aclose(self) -> None:
+        for iterable in self._owned_iterators:
+            if hasattr(iterable, "aclose"):
+                await iterable.aclose()
+        await self._iterator.aclose()
 
 
 async def compress(


Test patch:
diff --git a/unittests/test_itertools.py b/unittests/test_itertools.py
index a4d3984..43ce466 100644
--- a/unittests/test_itertools.py
+++ b/unittests/test_itertools.py
@@ -87,6 +87,57 @@ async def test_chain(iterables):
         )
 
 
+class ACloseFacade:
+    """Wrapper to check if an iterator has been closed"""
+
+    def __init__(self, iterable):
+        self.closed = False
+        self.__wrapped__ = iterable
+        self._iterator = a.iter(iterable)
+
+    async def __anext__(self):
+        if self.closed:
+            raise StopAsyncIteration()
+        return await self._iterator.__anext__()
+
+    def __aiter__(self):
+        return self
+
+    async def aclose(self):
+        if hasattr(self._iterator, "aclose"):
+            await self._iterator.aclose()
+        self.closed = True
+
+
+@pytest.mark.parametrize("iterables", chains)
+@sync
+async def test_chain_close_auto(iterables):
+    """Test that `chain` closes exhausted iterators"""
+    closeable_iterables = [ACloseFacade(iterable) for iterable in iterables]
+    assert await a.list(a.chain(*closeable_iterables)) == list(
+        itertools.chain(*iterables)
+    )
+    assert all(iterable.closed for iterable in closeable_iterables)
+
+
+# insert a known filled iterable since chain closes all that are exhausted
+@pytest.mark.parametrize("iterables", [([1], *chain) for chain in chains])
+@pytest.mark.parametrize(
+    "chain_type, must_close",
+    [(lambda iterators: a.chain(*iterators), True), (a.chain.from_iterable, False)],
+)
+@sync
+async def test_chain_close_partial(iterables, chain_type, must_close):
+    """Test that `chain` closes owned iterators"""
+    closeable_iterables = [ACloseFacade(iterable) for iterable in iterables]
+    chain = chain_type(closeable_iterables)
+    assert await a.anext(chain) == next(itertools.chain(*iterables))
+    await chain.aclose()
+    assert all(iterable.closed == must_close for iterable in closeable_iterables[1:])
+    # closed chain must remain closed regardless of iterators
+    assert await a.anext(chain, "sentinel") == "sentinel"
+
+
 compress_cases = [
     (range(20), [idx % 2 for idx in range(20)]),
     ([1] * 5, [True, True, False, True, True]),