Repository: maxfischer2781/asyncstdlib

Issue:
alru_cache decorator on a class method causes mypy error: Missing positional argument "self"
Thank you for the amazing project.

I'm trying to apply `alru_cache` on a class method, but mypy raises an error and a note:

```
error: Missing positional argument "self" in call to "f" of "X"  [call-arg]
note: "__call__" is considered instance variable, to make it class variable use ClassVar[...]
```

below is the reproducible example tested with mypy 1.1.1 and asyncstdlib 3.10.5

``` python
from asyncstdlib.functools import lru_cache as alru_cache

class X:
    @alru_cache()
    async def f(self) -> int:
        return 1

    async def g(self) -> int:
        return await self.f()
```

Comments:

Patch:
diff --git a/.codecov.yml b/.codecov.yml
new file mode 100644
index 0000000..d19a3b7
--- /dev/null
+++ b/.codecov.yml
@@ -0,0 +1,3 @@
+ignore:
+  # type tests are not execute, so there is no code coverage
+  - "typetests"
diff --git a/asyncstdlib/_lrucache.pyi b/asyncstdlib/_lrucache.pyi
new file mode 100644
index 0000000..38794cc
--- /dev/null
+++ b/asyncstdlib/_lrucache.pyi
@@ -0,0 +1,61 @@
+from ._typing import AC, Protocol, R as R, TypedDict
+from typing import (
+    Any,
+    Awaitable,
+    Callable,
+    NamedTuple,
+    Optional,
+    overload,
+)
+
+class CacheInfo(NamedTuple):
+    hits: int
+    misses: int
+    maxsize: Optional[int]
+    currsize: int
+
+class CacheParameters(TypedDict):
+    maxsize: Optional[int]
+    typed: bool
+
+class LRUAsyncCallable(Protocol[AC]):
+    __call__: AC
+    @overload
+    def __get__(
+        self: LRUAsyncCallable[AC],
+        instance: None,
+        owner: Optional[type] = ...,
+    ) -> LRUAsyncCallable[AC]: ...
+    @overload
+    def __get__(
+        self: LRUAsyncCallable[Callable[..., Awaitable[R]]],
+        instance: object,
+        owner: Optional[type] = ...,
+    ) -> LRUAsyncBoundCallable[Callable[..., Awaitable[R]]]: ...
+    @property
+    def __wrapped__(self) -> AC: ...
+    def cache_parameters(self) -> CacheParameters: ...
+    def cache_info(self) -> CacheInfo: ...
+    def cache_clear(self) -> None: ...
+    def cache_discard(self, *args: Any, **kwargs: Any) -> None: ...
+
+class LRUAsyncBoundCallable(LRUAsyncCallable[AC]):
+    __self__: object
+    __call__: AC
+    def __get__(
+        self: LRUAsyncBoundCallable[AC],
+        instance: Any,
+        owner: Optional[type] = ...,
+    ) -> LRUAsyncBoundCallable[AC]: ...
+    def __init__(self, lru: LRUAsyncCallable[AC], __self__: object) -> None: ...
+    @property
+    def __wrapped__(self) -> AC: ...
+    @property
+    def __func__(self) -> LRUAsyncCallable[AC]: ...
+
+@overload
+def lru_cache(maxsize: AC, typed: bool = ...) -> LRUAsyncCallable[AC]: ...
+@overload
+def lru_cache(
+    maxsize: Optional[int] = ..., typed: bool = ...
+) -> Callable[[AC], LRUAsyncCallable[AC]]: ...
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(
diff --git a/pyproject.toml b/pyproject.toml
index 1b46000..92cb0aa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,8 +43,11 @@ doc = ["sphinx", "sphinxcontrib-trio"]
 Documentation = "https://asyncstdlib.readthedocs.io/en/latest/"
 Source = "https://github.com/maxfischer2781/asyncstdlib"
 
+[tool.flit.sdist]
+include = ["unittests"]
+
 [tool.mypy]
-files = ["asyncstdlib/*.py"]
+files = ["asyncstdlib", "typetests"]
 check_untyped_defs = true
 no_implicit_optional = true
 warn_redundant_casts = true
@@ -59,3 +62,8 @@ disallow_untyped_decorators = true
 warn_return_any = true
 no_implicit_reexport = true
 strict_equality = true
+
+[tool.pytest.ini_options]
+testpaths = [
+    "unittests",
+]


Test patch:
diff --git a/typetests/README.rst b/typetests/README.rst
new file mode 100644
index 0000000..361fa3e
--- /dev/null
+++ b/typetests/README.rst
@@ -0,0 +1,37 @@
+=================
+MyPy Type Testing
+=================
+
+This suite contains *type* tests for ``asyncstdlib``.
+These tests follow similar conventions to unittests but are checked by MyPy.
+
+Test Files
+==========
+
+Tests MUST be organised into files, with similar tests grouped together.
+Each test file SHOULD be called as per the pattern ``type_<scope>.py``,
+where ``<scope>`` describes what the tests cover;
+for example, ``test_functools.py`` type-tests the ``functools`` package.
+
+An individual test is a function, method or class and SHOULD be named
+with a `test_` or `Test` prefix for functions/methods or classes, respectively.
+A class SHOULD be considered a test if it contains any tests.
+Tests MUST contain statements to be type-checked:
+- plain statements required to be type consistent,
+  such as passing parameters of expected correct type to a function.
+- assertions about types and exhaustiveness,
+  using `typing.assert_type` or `typing.assert_never`.
+- statements required to be type inconsistent with an expected type error,
+  such as passing parameters of wrong type with `# type: ignore[arg-type]`.
+
+Test files MAY contain non-test functions, methods or classes for use inside tests.
+These SHOULD be type-consistent and not require any type assertions or expected errors.
+
+Test Execution
+==============
+
+Tests MUST be checked by MyPy using
+the ``warn_unused_ignores`` configuration or ``--warn-unused-ignores`` command line
+option.
+This is required for negative type consistency checks,
+i.e. using expected type errors such as ``# type: ignore[arg-type]``.
diff --git a/typetests/test_functools.py b/typetests/test_functools.py
new file mode 100644
index 0000000..361971e
--- /dev/null
+++ b/typetests/test_functools.py
@@ -0,0 +1,23 @@
+from asyncstdlib import lru_cache
+
+
+@lru_cache()
+async def lru_function(a: int) -> int:
+    return a
+
+
+async def test_cache_parameters() -> None:
+    await lru_function(12)
+    await lru_function("wrong parameter type")  # type: ignore[arg-type]
+
+
+class TestLRUMethod:
+    """
+    Test that `lru_cache` works on methods
+    """
+    @lru_cache()
+    async def cached(self) -> int:
+        return 1
+
+    async def test_implicit_self(self) -> int:
+        return await self.cached()
diff --git a/unittests/test_functools.py b/unittests/test_functools.py
index 9d13530..6e6e7dc 100644
--- a/unittests/test_functools.py
+++ b/unittests/test_functools.py
@@ -203,6 +203,32 @@ async def test_lru_cache_typed():
         assert pingpong.cache_info().hits == (val + 1) * 2
 
 
+@sync
+async def test_lru_cache_method():
+    """
+    Test that the lru_cache can be used on methods
+    """
+
+    class SelfCached:
+        def __init__(self, ident: int):
+            self.ident = ident
+
+        @a.lru_cache()
+        async def pingpong(self, arg):
+            # return identifier of instance to separate cache entries per instance
+            return arg, self.ident
+
+    for iteration in range(4):
+        instance = SelfCached(iteration)
+        for val in range(20):
+            # 1 read initializes, 2 reads hit
+            assert await instance.pingpong(val) == (val, iteration)
+            assert await instance.pingpong(float(val)) == (val, iteration)
+            assert await instance.pingpong(val) == (val, iteration)
+            assert instance.pingpong.cache_info().misses == val + 1 + 20 * iteration
+            assert instance.pingpong.cache_info().hits == (val + 1 + 20 * iteration) * 2
+
+
 @sync
 async def test_lru_cache_bare():
     @a.lru_cache
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]),