Repository: maxfischer2781/asyncstdlib

Issue:
Add a way in functools lru_cache to invalidate just one entry
Hello,

I want to use this library to cache heavy calls to my database from an API. However, some calls to my API can modify the data stored in the database. I noticed that it was not possible to simply invalidate a particular key from the cache so that it would be updated on the next call. We are forced to go through the clear_cache method which destroys the entire cache. Or maybe I missed the method to do it 🤷 

An example of what I mean https://github.com/aio-libs/async-lru/blob/master/async_lru.py#L181 
Unfortunately, this library is no longer updated despite the fact that the bug concerning invalidate not working is declared in the issues. 

Thank you 😃 

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/82?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) Report
> Merging [#82](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/82?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) (7d89a8e) into [master](https://codecov.io/gh/maxfischer2781/asyncstdlib/commit/b3f88f08eb7881c9537b5d4699ea48d787292565?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) (b3f88f0) will **decrease** coverage by `0.18%`.
> The diff coverage is `97.29%`.

[![Impacted file tree graph](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/82/graphs/tree.svg?width=650&height=150&src=pr&token=frT6svSwUb&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer)](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/82?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer)

```diff
@@            Coverage Diff             @@
##           master      #82      +/-   ##
==========================================
- Coverage   99.20%   99.02%   -0.19%     
==========================================
  Files          19       20       +1     
  Lines        1771     1843      +72     
  Branches      272      282      +10     
==========================================
+ Hits         1757     1825      +68     
- Misses          9       13       +4     
  Partials        5        5              
```


| [Impacted Files](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/82?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer) | Coverage Δ | |
|---|---|---|
| [asyncstdlib/\_lrucache.py](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/82/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer#diff-YXN5bmNzdGRsaWIvX2xydWNhY2hlLnB5) | `97.53% <96.03%> (-2.47%)` | :arrow_down: |
| [unittests/test\_functools\_lru.py](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/82/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer#diff-dW5pdHRlc3RzL3Rlc3RfZnVuY3Rvb2xzX2xydS5weQ==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/82?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/82?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer). Last update [b3f88f0...7d89a8e](https://codecov.io/gh/maxfischer2781/asyncstdlib/pull/82?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Fischer).

lgtm-com[bot]: This pull request **fixes 1 alert** when merging c0578726b550f5187b4982b9b30c06dc21a7a348 into b3f88f08eb7881c9537b5d4699ea48d787292565 - [view on LGTM.com](https://lgtm.com/projects/g/maxfischer2781/asyncstdlib/rev/pr-c911701c187a30697b9a8d5ce6e417d6c7c4bd0c)

**fixed alerts:**

* 1 for Unused local variable
lgtm-com[bot]: This pull request **fixes 1 alert** when merging 9e4c6893523fa0145200e29304a9a16e0f53c4d1 into b3f88f08eb7881c9537b5d4699ea48d787292565 - [view on LGTM.com](https://lgtm.com/projects/g/maxfischer2781/asyncstdlib/rev/pr-be7a13c4da29fc10fa41d1bf47d7935650d34420)

**fixed alerts:**

* 1 for Unused local variable
lgtm-com[bot]: This pull request **fixes 1 alert** when merging c0883502a2f26109056d375566d30d2798a7fcf0 into b3f88f08eb7881c9537b5d4699ea48d787292565 - [view on LGTM.com](https://lgtm.com/projects/g/maxfischer2781/asyncstdlib/rev/pr-9a474d0dd80cbccf4795d01f713b8ccc37fa0a26)

**fixed alerts:**

* 1 for Unused local variable
lgtm-com[bot]: This pull request **fixes 1 alert** when merging d148750a8a918fb745265fd1bcfdb28eabaebba9 into b3f88f08eb7881c9537b5d4699ea48d787292565 - [view on LGTM.com](https://lgtm.com/projects/g/maxfischer2781/asyncstdlib/rev/pr-da1324db07ff70debbeb8b20c1c01d1d563e196d)

**fixed alerts:**

* 1 for Unused local variable

Patch:
diff --git a/asyncstdlib/_lrucache.py b/asyncstdlib/_lrucache.py
index 1f94620..780690c 100644
--- a/asyncstdlib/_lrucache.py
+++ b/asyncstdlib/_lrucache.py
@@ -64,8 +64,12 @@ class LRUAsyncCallable(Protocol[AC]):
     :py:class:`~typing.Protocol` of a LRU cache wrapping a callable to an awaitable
     """
 
-    #: The callable wrapped by this cache
-    __wrapped__: AC
+    __slots__: Tuple[str, ...] = ()
+
+    @property
+    def __wrapped__(self) -> AC:
+        """The callable wrapped by this cache"""
+        raise NotImplementedError
 
     #: Get the result of ``await __wrapped__(...)`` from the cache or evaluation
     __call__: AC
@@ -82,6 +86,68 @@ class LRUAsyncCallable(Protocol[AC]):
     def cache_clear(self) -> None:
         """Evict all call argument patterns and their results from the cache"""
 
+    def cache_discard(self, *args: Any, **kwargs: Any) -> None:
+        """
+        Evict the call argument pattern and its result from the cache
+
+        When a cache is wrapped by another :term:`descriptor`
+        (``property``, ``staticmethod``, ...),
+        the descriptor must support wrapping descriptors for this method
+        to detect implicit arguments such as ``self``.
+        """
+        # "support wrapping descriptors" means that the wrapping descriptor has to use
+        # the cache as a descriptor as well, i.e. invoke its ``__get__`` method instead
+        # of just passing in `self`/`cls`/... directly.
+
+
+@public_module("asyncstdlib.functools")
+class LRUAsyncBoundCallable(LRUAsyncCallable[AC]):
+    """A :py:class:`~.LRUAsyncCallable` that is bound like a method"""
+
+    __slots__ = ("__weakref__", "_lru", "__self__")
+
+    def __init__(self, lru: LRUAsyncCallable[AC], __self__: object):
+        self._lru = lru
+        self.__self__ = __self__
+
+    @property
+    def __wrapped__(self) -> AC:
+        return self._lru.__wrapped__
+
+    @property
+    def __func__(self) -> LRUAsyncCallable[AC]:
+        return self._lru
+
+    def __call__(self, *args, **kwargs):  # type: ignore
+        return self._lru(self.__self__, *args, **kwargs)
+
+    def cache_parameters(self) -> CacheParameters:
+        return self._lru.cache_parameters()
+
+    def cache_info(self) -> CacheInfo:
+        return self._lru.cache_info()
+
+    def cache_clear(self) -> None:
+        return self._lru.cache_clear()
+
+    def cache_discard(self, *args: Any, **kwargs: Any) -> None:
+        return self._lru.cache_discard(self.__self__, *args, **kwargs)
+
+    def __repr__(self) -> str:
+        name = getattr(self.__wrapped__, "__qualname__", "?")
+        return f"<bound async cache {name} of {self.__self__}>"
+
+    def __getattr__(self, name: str) -> Any:
+        return getattr(self._lru, name)
+
+    @property
+    def __doc__(self) -> Optional[str]:  # type: ignore
+        return self._lru.__doc__
+
+    @property
+    def __annotations__(self) -> Dict[str, Any]:  # type: ignore
+        return self._lru.__annotations__
+
 
 @overload
 def lru_cache(maxsize: AC, typed: bool = ...) -> LRUAsyncCallable[AC]:
@@ -102,51 +168,51 @@ def lru_cache(
     """
     Least Recently Used cache for async functions
 
-    Applies an LRU cache, mapping the most recent function call arguments
-    to the *awaited* function return value. This makes this cache appropriate for
-    :term:`coroutine functions <coroutine function>`, :py:func:`~functools.partial`
-    coroutines and any other callable that returns an :term:`awaitable`.
+    Applies an LRU cache storing call arguments and their *awaited* return value.
+    This is appropriate for :term:`coroutine functions <coroutine function>`,
+    :py:func:`~functools.partial` coroutines and any other callable that returns
+    an :term:`awaitable`.
 
-    Arguments to the cached function must be :term:`hashable`. On a successful cache
-    hit, the underlying function is *not* called. This means any side-effects, including
-    scheduling in an internal event loop, are skipped. Ideally, ``lru_cache`` is used
+    Arguments to the cached function must be :term:`hashable`; when the arguments are
+    in the cache, the underlying function is *not* called. This means any side-effects,
+    including scheduling in an event loop, are skipped. Ideally, ``lru_cache`` is used
     for long-running queries or requests that return the same result for the same input.
 
     The maximum number of cached items is defined by ``maxsize``:
 
-    * If set to a positive integer, at most ``maxsize`` distinct function argument
-      patterns are stored; further calls with *different* patterns evict the oldest
-      stored pattern from the cache.
+    * If set to a positive integer, up to ``maxsize`` function argument
+      patterns are stored; further calls with *different* patterns replace the oldest
+      pattern in the cache.
 
     * If set to zero or a negative integer, the cache is disabled. Every call is
       directly forwarded to the underlying function, and counted as a cache miss.
 
-    * If set to :py:data:`None`, the cache has unlimited size. Every new function
-      argument pattern adds an entry to the cache; patterns and values are never
+    * If set to :py:data:`None`, the cache has unlimited size. Every used function
+      argument pattern adds an entry to the cache; patterns are never
       automatically evicted.
 
-    The cache can always be explicitly emptied via
-    :py:meth:`~LRUAsyncCallable.cache_clear`.
+    In addition to automatic cache eviction from ``maxsize``, the cache can be
+    explicitly emptied via :py:meth:`~LRUAsyncCallable.cache_clear`
+    and :py:meth:`~LRUAsyncCallable.cache_discard`.
     Use the cache's :py:meth:`~LRUAsyncCallable.cache_info` to inspect the cache's
     performance and filling level.
 
     If ``typed`` is :py:data:`True`, values in argument patterns are compared by
-    value *and* type. For example, this means that passing ``3`` and ``3.0`` as
-    the same argument are treated as distinct pattern elements.
+    value *and* type. For example, this means ``3`` and ``3.0`` are treated as
+    distinct arguments; however, this is not applied recursively so the type of
+    both ``(3, 4)`` and ``(3.0, 4.0)`` is the same.
 
     .. note::
 
-        This wrapper is intended for use with a single event loop, and supports
-        overlapping concurrent calls.
+        This LRU cache supports overlapping ``await`` calls, provided that the
+        wrapped async function does as well.
         Unlike the original :py:func:`functools.lru_cache`, it is not thread-safe.
     """
     if isinstance(maxsize, int):
         maxsize = 0 if maxsize < 0 else maxsize
     elif callable(maxsize):
         # used as function decorator, first arg is the function to be wrapped
-        fast_wrapper = _bounded_lru(
-            function=cast(AC, maxsize), maxsize=128, typed=typed
-        )
+        fast_wrapper = CachedLRUAsyncCallable(cast(AC, maxsize), typed, 128)
         return update_wrapper(fast_wrapper, maxsize)
     elif maxsize is not None:
         raise TypeError(
@@ -155,12 +221,13 @@ def lru_cache(
 
     def lru_decorator(function: AC) -> LRUAsyncCallable[AC]:
         assert not callable(maxsize)
+        wrapper: LRUAsyncCallable[AC]
         if maxsize is None:
-            wrapper = _unbound_lru(function=function, typed=typed)
+            wrapper = MemoizedLRUAsyncCallable(function, typed)
         elif maxsize == 0:
-            wrapper = _empty_lru(function=function, typed=typed)
+            wrapper = UncachedLRUAsyncCallable(function, typed)
         else:
-            wrapper = _bounded_lru(function=function, maxsize=maxsize, typed=typed)
+            wrapper = CachedLRUAsyncCallable(function, typed, maxsize)
         return update_wrapper(wrapper, function)
 
     return lru_decorator
@@ -217,130 +284,160 @@ class CallKey:
         return cls(key)
 
 
-def _empty_lru(function: AC, typed: bool) -> LRUAsyncCallable[AC]:
-    """Wrap the async ``function`` in an async LRU cache without any capacity"""
-    # cache statistics
-    misses = 0
+def cache__get(
+    self: LRUAsyncCallable[AC], instance: object, owner: Optional[type] = None
+) -> LRUAsyncCallable[AC]:
+    """Descriptor ``__get__`` for caches to bind them on lookup"""
+    if instance is None:
+        return self
+    return LRUAsyncBoundCallable(self, instance)
+
+
+@public_module("asyncstdlib.functools")
+class UncachedLRUAsyncCallable(LRUAsyncCallable[AC]):
+    """Wrap the async ``call`` to track accesses as for caching/memoization"""
+
+    __slots__ = ("__weakref__", "__dict__", "__wrapped__", "__misses", "__typed")
+
+    __get__ = cache__get
 
-    async def wrapper(*args: Hashable, **kwargs: Hashable) -> Any:
-        nonlocal misses
-        misses += 1
-        return await function(*args, **kwargs)
+    def __init__(self, call: AC, typed: bool):
+        self.__wrapped__ = call  # type: ignore
+        self.__misses = 0
+        self.__typed = typed
 
-    def cache_parameters() -> CacheParameters:
-        return CacheParameters(maxsize=0, typed=typed)
+    async def __call__(self, *args, **kwargs):  # type: ignore
+        self.__misses += 1
+        return await self.__wrapped__(*args, **kwargs)
 
-    def cache_info() -> CacheInfo:
-        return CacheInfo(0, misses, 0, 0)
+    def cache_parameters(self) -> CacheParameters:
+        return CacheParameters(maxsize=0, typed=self.__typed)
 
-    def cache_clear() -> None:
-        nonlocal misses
-        misses = 0
+    def cache_info(self) -> CacheInfo:
+        return CacheInfo(0, self.__misses, 0, 0)
 
-    wrapper.cache_parameters = cache_parameters  # type: ignore
-    wrapper.cache_info = cache_info  # type: ignore
-    wrapper.cache_clear = cache_clear  # type: ignore
-    return wrapper  # type: ignore
+    def cache_clear(self) -> None:
+        self.__misses = 0
 
+    def cache_discard(self, *args: Any, **kwargs: Any) -> None:
+        return
 
-def _unbound_lru(function: AC, typed: bool) -> LRUAsyncCallable[AC]:
-    """Wrap the async ``function`` in an async LRU cache with infinite capacity"""
-    # local lookup
-    make_key = CallKey.from_call
-    # cache statistics
-    hits = 0
-    misses = 0
-    # cache content
-    cache: Dict[Union[CallKey, int, str], Any] = {}
 
-    async def wrapper(*args: Hashable, **kwargs: Hashable) -> Any:
-        nonlocal hits, misses
-        key = make_key(args, kwargs, typed=typed)
+@public_module("asyncstdlib.functools")
+class MemoizedLRUAsyncCallable(LRUAsyncCallable[AC]):
+    """Wrap the async ``call`` with async memoization"""
+
+    __slots__ = (
+        "__weakref__",
+        "__dict__",
+        "__wrapped__",
+        "__hits",
+        "__misses",
+        "__typed",
+        "__cache",
+    )
+
+    __get__ = cache__get
+
+    def __init__(self, call: AC, typed: bool):
+        self.__wrapped__ = call  # type: ignore
+        self.__hits = 0
+        self.__misses = 0
+        self.__typed = typed
+        self.__cache: Dict[Union[CallKey, int, str], Any] = {}
+
+    async def __call__(self, *args, **kwargs):  # type: ignore
+        key = CallKey.from_call(args, kwargs, typed=self.__typed)
         try:
-            result = cache[key]
+            result = self.__cache[key]
         except KeyError:
-            misses += 1
-            result = await function(*args, **kwargs)
+            self.__misses += 1
+            result = await self.__wrapped__(*args, **kwargs)
             # function finished early for another call with the same arguments
             # the cache has been updated already, do nothing to it
-            if key not in cache:
-                cache[key] = result
+            if key not in self.__cache:
+                self.__cache[key] = result
             return result
         else:
-            hits += 1
+            self.__hits += 1
             return result
 
-    def cache_parameters() -> CacheParameters:
-        return CacheParameters(maxsize=None, typed=typed)
-
-    def cache_info() -> CacheInfo:
-        return CacheInfo(hits, misses, None, len(cache))
-
-    def cache_clear() -> None:
-        nonlocal hits, misses
-        misses = 0
-        hits = 0
-        cache.clear()
-
-    wrapper.cache_parameters = cache_parameters  # type: ignore
-    wrapper.cache_info = cache_info  # type: ignore
-    wrapper.cache_clear = cache_clear  # type: ignore
-    return wrapper  # type: ignore
-
-
-def _bounded_lru(function: AC, typed: bool, maxsize: int) -> LRUAsyncCallable[AC]:
-    """Wrap the async ``function`` in an async LRU cache with fixed capacity"""
-    # local lookup
-    make_key = CallKey.from_call
-    # cache statistics
-    hits = 0
-    misses = 0
-    # cache content
-    cache: OrderedDict[Union[int, str, CallKey], Any] = OrderedDict()
-    filled = False
-
-    async def wrapper(*args: Hashable, **kwargs: Hashable) -> Any:
-        nonlocal hits, misses, filled
-        key = make_key(args, kwargs, typed=typed)
+    def cache_parameters(self) -> CacheParameters:
+        return CacheParameters(maxsize=None, typed=self.__typed)
+
+    def cache_info(self) -> CacheInfo:
+        return CacheInfo(self.__hits, self.__misses, None, len(self.__cache))
+
+    def cache_clear(self) -> None:
+        self.__hits = 0
+        self.__misses = 0
+        self.__cache.clear()
+
+    def cache_discard(self, *args: Any, **kwargs: Any) -> None:
+        self.__cache.pop(CallKey.from_call(args, kwargs, typed=self.__typed), None)
+
+
+@public_module("asyncstdlib.functools")
+class CachedLRUAsyncCallable(LRUAsyncCallable[AC]):
+    """Wrap the async ``call`` with async LRU caching of finite capacity"""
+
+    __slots__ = (
+        "__weakref__",
+        "__dict__",
+        "__wrapped__",
+        "__hits",
+        "__misses",
+        "__typed",
+        "__maxsize",
+        "__cache",
+    )
+
+    __get__ = cache__get
+
+    def __init__(self, call: AC, typed: bool, maxsize: int):
+        self.__wrapped__ = call  # type: ignore
+        self.__hits = 0
+        self.__misses = 0
+        self.__typed = typed
+        self.__maxsize = maxsize
+        self.__cache: OrderedDict[Union[int, str, CallKey], Any] = OrderedDict()
+
+    async def __call__(self, *args, **kwargs):  # type: ignore
+        key = CallKey.from_call(args, kwargs, typed=self.__typed)
         try:
-            result = cache[key]
+            result = self.__cache[key]
         except KeyError:
-            misses += 1
-            result = await function(*args, **kwargs)
+            self.__misses += 1
+            result = await self.__wrapped__(*args, **kwargs)
             # function finished early for another call with the same arguments
             # the cache has been updated already, do nothing to it
-            if key in cache:
+            if key in self.__cache:
                 pass
             # the cache is filled already
             # push the new content to the current root and rotate the list once
-            elif filled:
-                cache.popitem(last=False)
-                cache[key] = result
+            elif len(self.__cache) >= self.__maxsize:
+                self.__cache.popitem(last=False)
+                self.__cache[key] = result
             # the cache still has room
             # insert the new element at the back
             else:
-                cache[key] = result
-                filled = len(cache) >= maxsize
+                self.__cache[key] = result
             return result
         else:
-            cache.move_to_end(key, last=True)
-            hits += 1
+            self.__cache.move_to_end(key, last=True)
+            self.__hits += 1
             return result
 
-    def cache_parameters() -> CacheParameters:
-        return CacheParameters(maxsize=maxsize, typed=typed)
+    def cache_parameters(self) -> CacheParameters:
+        return CacheParameters(maxsize=self.__maxsize, typed=self.__typed)
 
-    def cache_info() -> CacheInfo:
-        return CacheInfo(hits, misses, maxsize, len(cache))
+    def cache_info(self) -> CacheInfo:
+        return CacheInfo(self.__hits, self.__misses, self.__maxsize, len(self.__cache))
 
-    def cache_clear() -> None:
-        nonlocal hits, misses, filled
-        misses = 0
-        hits = 0
-        filled = False
-        cache.clear()
+    def cache_clear(self) -> None:
+        self.__hits = 0
+        self.__misses = 0
+        self.__cache.clear()
 
-    wrapper.cache_parameters = cache_parameters  # type: ignore
-    wrapper.cache_info = cache_info  # type: ignore
-    wrapper.cache_clear = cache_clear  # type: ignore
-    return wrapper  # type: ignore
+    def cache_discard(self, *args: Any, **kwargs: Any) -> None:
+        self.__cache.pop(CallKey.from_call(args, kwargs, typed=self.__typed), None)
diff --git a/docs/source/api/functools.rst b/docs/source/api/functools.rst
index 9db0663..d2aa122 100644
--- a/docs/source/api/functools.rst
+++ b/docs/source/api/functools.rst
@@ -27,53 +27,48 @@ Both :py:func:`~asyncstdlib.functools.lru_cache`
 and :py:func:`~asyncstdlib.functools.cached_property`
 of :py:mod:`asyncstdlib` work only with async callables
 (they are not :term:`async neutral`).
-Notably, this includes regular callables that return an :term:`awaitable`,
+Notably, they also work with regular callables that return an :term:`awaitable`,
 such as an ``async def`` function wrapped by :py:func:`~functools.partial`.
 
+Attribute Caches
+----------------
+
+This type of cache tracks ``await``\ ing an attribute.
+
 .. autofunction:: cached_property(getter: (Self) → await T)
     :decorator:
 
     .. versionadded:: 1.1.0
 
-The :py:func:`~asyncstdlib.functools.lru_cache`
-can be applied as a decorator, both with and without arguments:
+Callable Caches
+---------------
 
-.. code-block:: python3
-
-    @a.lru_cache
-    async def get_pep(num):
-        url = f'http://www.python.org/dev/peps/pep-{num:04}/'
-        request = await asynclib.get(url)
-        return request.body()
+This type of cache tracks *call argument patterns* and their return values.
+A pattern is an *ordered* representation of positional and keyword arguments;
+notably, this disregards defaults and overlap between positional and keyword arguments.
+This means that for a function ``f(a, b)``, the calls ``f(1, 2)``, ``f(a=1, b=2)``
+and ``f(b=2, a=1)`` are considered three distinct patterns.
 
-    @a.lru_cache(maxsize=32)
-    async def get_pep(num):
-        url = f'http://www.python.org/dev/peps/pep-{num:04}/'
-        request = await asynclib.get(url)
-        return request.body()
+Note that exceptions are not considered return values and thus never cached. This makes
+the caches suitable for queries that may fail, caching any *eventual* result for
+quick and reliable lookup.
 
-.. autofunction:: cache((...) -> await R)
+.. autofunction:: cache((...) → await R) -> LRUAsyncCallable
     :decorator:
 
     .. versionadded:: 3.9.0
 
-.. autofunction:: lru_cache(maxsize: ?int = 128, typed: bool = False)((...) -> await R)
+.. py:function:: lru_cache((...) → await R) -> LRUAsyncCallable
     :decorator:
+    :noindex:
 
-The cache tracks *call argument patterns* and maps them to observed return values.
-A pattern is an *ordered* representation of provided positional and keyword arguments;
-notably, this disregards default arguments, as well as any overlap between
-positional and keyword arguments.
-This means that for a function ``f(a, b)``, the calls ``f(1, 2)``, ``f(a=1, b=2)``
-and ``f(b=2, a=1)`` are considered three distinct patterns.
-
-In addition, exceptions are not return values. This allows retrying a long-running
-query that may fail, caching any *eventual* result for quick and reliable lookup.
+.. autofunction:: lru_cache(maxsize: ?int = 128, typed: bool = False)((...) → await R) -> LRUAsyncCallable
+    :decorator:
 
-A wrapped async callable can be queried for its cache metadata,
-and allows clearing the entire cache. This can be useful to explicitly monitor
-cache performance, and to manage caches of unrestricted size.
-Note that the ``maxsize`` of a cache cannot be changed at runtime -- however,
+A cached async callable can be queried for its cache metadata and allows clearing
+entries from the cache. This can be useful to explicitly monitor cache performance,
+and to manage caches of unrestricted size.
+While the ``maxsize`` of a cache cannot be changed at runtime,
 the ``__wrapped__`` callable may be wrapped with a new cache of different size.
 
 .. autoclass:: LRUAsyncCallable()
@@ -86,10 +81,15 @@ the ``__wrapped__`` callable may be wrapped with a new cache of different size.
 
     .. automethod:: cache_clear()
 
+    .. automethod:: cache_discard(...)
+
+        .. versionchanged:: Python3.9
+            :py:func:`classmethod` properly wraps caches.
+
+        .. versionadded:: 3.10.4
+
     .. automethod:: cache_info() -> (hits=..., misses=..., maxsize=..., currsize=...)
 
     .. automethod:: cache_parameters() -> {"maxsize": ..., "typed": ...}
 
-    .. versionadded:: 3.9.0
-
-        The :py:meth:`~.cache_parameters` method.
+        .. versionadded:: 3.9.0


Test patch:
diff --git a/unittests/test_functools_lru.py b/unittests/test_functools_lru.py
new file mode 100644
index 0000000..62cba68
--- /dev/null
+++ b/unittests/test_functools_lru.py
@@ -0,0 +1,156 @@
+import sys
+
+import pytest
+
+import asyncstdlib as a
+
+from .utility import sync
+
+
+def method_counter(size):
+    class Counter:
+        def __init__(self):
+            self._count = 0
+
+        @a.lru_cache(maxsize=size)
+        async def count(self):
+            self._count += 1
+            return self._count
+
+    return Counter
+
+
+def classmethod_counter(size):
+    class Counter:
+        _count = 0
+
+        def __init__(self):
+            type(self)._count = 0
+
+        @classmethod
+        @a.lru_cache(maxsize=size)
+        async def count(cls):
+            cls._count += 1
+            return cls._count
+
+    return Counter
+
+
+def staticmethod_counter(size):
+    # I'm sorry for writing this test – please don't do this at home!
+    _count = 0
+
+    class Counter:
+        def __init__(self):
+            nonlocal _count
+            _count = 0
+
+        @staticmethod
+        @a.lru_cache(maxsize=size)
+        async def count():
+            nonlocal _count
+            _count += 1
+            return _count
+
+    return Counter
+
+
+counter_factories = [method_counter, classmethod_counter, staticmethod_counter]
+
+
+@pytest.mark.parametrize("size", [0, 3, 10, None])
+@pytest.mark.parametrize("counter_factory", counter_factories)
+@sync
+async def test_method_plain(size, counter_factory):
+    """Test caching without resetting"""
+
+    counter_type = counter_factory(size)
+    for _instance in range(4):
+        instance = counter_type()
+        for reset in range(5):
+            for access in range(5):
+                misses = 1 if size != 0 else reset * 5 + access + 1
+                assert misses == await instance.count()
+
+
+@pytest.mark.parametrize("size", [0, 3, 10, None])
+@pytest.mark.parametrize("counter_factory", counter_factories)
+@sync
+async def test_method_clear(size, counter_factory):
+    """Test caching with resetting everything"""
+    counter_type = counter_factory(size)
+    for _instance in range(4):
+        instance = counter_type()
+        for reset in range(5):
+            for access in range(5):
+                misses = reset + 1 if size != 0 else reset * 5 + access + 1
+                assert misses == await instance.count()
+            instance.count.cache_clear()
+
+
+@pytest.mark.parametrize("size", [0, 3, 10, None])
+@pytest.mark.parametrize("counter_factory", counter_factories)
+@sync
+async def test_method_discard(size, counter_factory):
+    """Test caching with resetting specific item"""
+    counter_type = counter_factory(size)
+    if (
+        sys.version_info < (3, 9)
+        and type(counter_type.__dict__["count"]) is classmethod
+    ):
+        pytest.skip("classmethod does not respect descriptors up to 3.8")
+    for _instance in range(4):
+        instance = counter_type()
+        for reset in range(5):
+            for access in range(5):
+                misses = reset * 5 + access + 1
+                assert misses == await instance.count()
+                instance.count.cache_discard()
+
+
+@pytest.mark.parametrize("size", [0, 3, 10, None])
+@pytest.mark.parametrize("counter_factory", counter_factories)
+@sync
+async def test_method_metadata(size, counter_factory):
+    """Test cache metadata on methods"""
+    tp = counter_factory(size)
+    for instance in range(4):
+        ct = tp()
+        for reset in range(5):
+            for access in range(5):
+                await ct.count()
+                assert tp.count.__wrapped__ == ct.count.__wrapped__
+                assert tp.count.cache_parameters() == ct.count.cache_parameters()
+                assert tp.count.cache_info() == ct.count.cache_info()
+                assert (
+                    ct.count.cache_info().maxsize
+                    == ct.count.cache_parameters()["maxsize"]
+                    == size
+                )
+                totals = instance * 25 + reset * 5 + (access + 1)
+                hits, misses, *_ = ct.count.cache_info()
+                assert totals == hits + misses
+
+
+@pytest.mark.parametrize("size", [None, 0, 10, 128])
+def test_wrapper_attributes(size):
+    class Bar:
+        @a.lru_cache
+        async def method(self, int_arg: int):
+            """Method docstring"""
+
+        @a.lru_cache
+        async def other_method(self):
+            """Method docstring"""
+
+    assert Bar.method.__doc__ == """Method docstring"""
+    for name in ("method", "other_method"):
+        for cache in (getattr(Bar, name), getattr(Bar(), name)):
+            assert cache.__doc__ == """Method docstring"""
+            assert cache.__name__ == name
+            assert cache.__qualname__.endswith(f"Bar.{name}")
+            if name != "method":
+                continue
+            # test direct and literal annotation styles
+            assert Bar.method.__annotations__["int_arg"] in {int, "int"}
+            assert Bar().method.__annotations__["int_arg"] in {int, "int"}