Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-async-lru for openSUSE:Factory checked in at 2026-10-20 12:40:16 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-async-lru (Old) and /work/SRC/openSUSE:Factory/.python-async-lru.new.3698736 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-async-lru" Tue Oct 20 12:40:16 2026 rev:7 rq:1386240 version:2.4.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-async-lru/python-async-lru.changes 2026-04-04 19:09:18.625781890 +0200 +++ /work/SRC/openSUSE:Factory/.python-async-lru.new.3698736/python-async-lru.changes 2026-10-20 12:41:43.332026986 +0200 @@ -1,0 +2,11 @@ +Sat Oct 17 15:14:56 UTC 2026 - Martin Pluskal <[email protected]> + +- Update to 2.4.0: + * Added a ``key`` parameter for custom cache key computation. + * Fixed a ``NameError`` when decorating functions whose + annotations refer to names defined later, on Python 3.14. + * On Python 3.14, cached functions expose ``__annotate__`` + instead of ``__annotations__``, as ``functools.lru_cache`` + wrappers do. + +------------------------------------------------------------------- Old: ---- async_lru-2.3.0.tar.gz New: ---- async_lru-2.4.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-async-lru.spec ++++++ --- /var/tmp/diff_new_pack.xrJSn4/_old 2026-10-20 12:41:44.027056074 +0200 +++ /var/tmp/diff_new_pack.xrJSn4/_new 2026-10-20 12:41:44.031056242 +0200 @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-async-lru -Version: 2.3.0 +Version: 2.4.0 Release: 0 Summary: Simple LRU cache for asyncio License: MIT ++++++ async_lru-2.3.0.tar.gz -> async_lru-2.4.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/PKG-INFO new/async_lru-2.4.0/PKG-INFO --- old/async_lru-2.3.0/PKG-INFO 2026-03-19 02:04:16.406802400 +0100 +++ new/async_lru-2.4.0/PKG-INFO 2026-10-12 20:12:35.842727200 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: async-lru -Version: 2.3.0 +Version: 2.4.0 Summary: Simple LRU cache for asyncio Home-page: https://github.com/aio-libs/async-lru Maintainer: aiohttp team <[email protected]> @@ -152,6 +152,32 @@ The method returns `True` if the result for the given arguments is cached, `False` otherwise. +Custom cache keys +----------------- + +By default the cache key is built from all arguments, like +``functools.lru_cache`` does. The ``key`` parameter accepts a callable that +receives the same arguments as the wrapped function and returns the cache key, +so arguments that do not affect the result can be excluded from it. This is an +async-lru extension beyond the ``functools.lru_cache`` interface: + +.. code-block:: python + + @alru_cache(key=lambda db, query: query) + async def query_db(db, query): + return await db.execute(query) + + # Both calls share one cache entry despite the different connections. + await query_db(conn1, "SELECT ...") + await query_db(conn2, "SELECT ...") + +The returned key must be hashable. ``cache_invalidate()`` and +``cache_contains()`` compute the key the same way, so they accept the full +argument list as usual. For decorated methods the key callable receives the +instance as its first argument. Passing ``typed=True`` together with ``key`` +raises ``ValueError``, since ``typed`` only affects the default key +computation. + Limitations ----------- @@ -199,6 +225,40 @@ cached_task_loop2 = alru_cache(maxsize=32)(my_task.__wrapped__) await cached_task_loop2(x) +Security considerations +----------------------- + +**Cache keys are built only from explicit arguments.** Like +`functools.lru_cache <https://docs.python.org/3/library/functools.html#functools.lru_cache>`_, +``alru_cache`` derives its cache key solely from the positional and keyword arguments +passed to the wrapped function. Implicit, request-scoped context — such as +authentication headers, the current user or tenant, ``contextvars``, thread/task +locals, or module globals — is **not** part of the key and therefore **not** isolated +between callers. + +Because concurrent calls with the same key also share a single in-flight result (see +the Usage section above), a value computed for one caller can be returned to another whenever +their arguments are equal. In multi-tenant or multi-user services this can lead to +cross-tenant data exposure if the cached coroutine's result depends on anything other +than its explicit arguments. + +To use ``alru_cache`` safely in these contexts: + +- **Make the cached coroutine a pure function of its arguments.** Any value that + affects the result — ``user_id``, ``tenant_id``, role, locale, feature flags, etc. — + must be passed as an argument so it becomes part of the cache key, or use a separate + cache instance per security domain. +- **Avoid caching context-dependent functions.** If a function reads request-scoped + state from ``contextvars``/globals rather than from its arguments, either refactor it + to take that state explicitly or do not cache it. +- **Consider** ``typed=True`` **when callers may pass multiple types.** With the default + ``typed=False``, arguments that compare and hash equal share an entry (for example + ``1`` and ``1.0``, or ``True`` and ``1``). Pass ``typed=True`` to key such arguments + distinctly. +- **Be wary of attacker-controlled key arguments.** Objects with unusual ``__hash__`` / + ``__eq__`` semantics can collide unexpectedly; only use trusted, well-behaved values + as cache key components. + Benchmarks ---------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/README.rst new/async_lru-2.4.0/README.rst --- old/async_lru-2.3.0/README.rst 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/README.rst 2026-10-12 20:12:30.073179000 +0200 @@ -121,6 +121,32 @@ The method returns `True` if the result for the given arguments is cached, `False` otherwise. +Custom cache keys +----------------- + +By default the cache key is built from all arguments, like +``functools.lru_cache`` does. The ``key`` parameter accepts a callable that +receives the same arguments as the wrapped function and returns the cache key, +so arguments that do not affect the result can be excluded from it. This is an +async-lru extension beyond the ``functools.lru_cache`` interface: + +.. code-block:: python + + @alru_cache(key=lambda db, query: query) + async def query_db(db, query): + return await db.execute(query) + + # Both calls share one cache entry despite the different connections. + await query_db(conn1, "SELECT ...") + await query_db(conn2, "SELECT ...") + +The returned key must be hashable. ``cache_invalidate()`` and +``cache_contains()`` compute the key the same way, so they accept the full +argument list as usual. For decorated methods the key callable receives the +instance as its first argument. Passing ``typed=True`` together with ``key`` +raises ``ValueError``, since ``typed`` only affects the default key +computation. + Limitations ----------- @@ -168,6 +194,40 @@ cached_task_loop2 = alru_cache(maxsize=32)(my_task.__wrapped__) await cached_task_loop2(x) +Security considerations +----------------------- + +**Cache keys are built only from explicit arguments.** Like +`functools.lru_cache <https://docs.python.org/3/library/functools.html#functools.lru_cache>`_, +``alru_cache`` derives its cache key solely from the positional and keyword arguments +passed to the wrapped function. Implicit, request-scoped context — such as +authentication headers, the current user or tenant, ``contextvars``, thread/task +locals, or module globals — is **not** part of the key and therefore **not** isolated +between callers. + +Because concurrent calls with the same key also share a single in-flight result (see +the Usage section above), a value computed for one caller can be returned to another whenever +their arguments are equal. In multi-tenant or multi-user services this can lead to +cross-tenant data exposure if the cached coroutine's result depends on anything other +than its explicit arguments. + +To use ``alru_cache`` safely in these contexts: + +- **Make the cached coroutine a pure function of its arguments.** Any value that + affects the result — ``user_id``, ``tenant_id``, role, locale, feature flags, etc. — + must be passed as an argument so it becomes part of the cache key, or use a separate + cache instance per security domain. +- **Avoid caching context-dependent functions.** If a function reads request-scoped + state from ``contextvars``/globals rather than from its arguments, either refactor it + to take that state explicitly or do not cache it. +- **Consider** ``typed=True`` **when callers may pass multiple types.** With the default + ``typed=False``, arguments that compare and hash equal share an entry (for example + ``1`` and ``1.0``, or ``True`` and ``1``). Pass ``typed=True`` to key such arguments + distinctly. +- **Be wary of attacker-controlled key arguments.** Objects with unusual ``__hash__`` / + ``__eq__`` semantics can collide unexpectedly; only use trusted, well-behaved values + as cache key components. + Benchmarks ---------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/async_lru/__init__.py new/async_lru-2.4.0/async_lru/__init__.py --- old/async_lru-2.3.0/async_lru/__init__.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/async_lru/__init__.py 2026-10-12 20:12:30.073536200 +0200 @@ -4,24 +4,10 @@ import random import sys import warnings +from collections import OrderedDict +from collections.abc import Callable, Coroutine, Hashable from functools import _CacheInfo, _make_key, partial, partialmethod -from typing import ( - Any, - Callable, - Coroutine, - Generic, - Hashable, - List, - Optional, - OrderedDict, - Type, - TypedDict, - TypeVar, - Union, - cast, - final, - overload, -) +from typing import Any, Generic, TypedDict, TypeVar, cast, final, overload if sys.version_info >= (3, 11): @@ -33,7 +19,7 @@ from asyncio.coroutines import _is_coroutine # type: ignore[attr-defined] -__version__ = "2.3.0" +__version__ = "2.4.0" __all__ = ("AlruCacheLoopResetWarning", "alru_cache") @@ -42,7 +28,7 @@ _R = TypeVar("_R") _Coro = Coroutine[Any, Any, _R] _CB = Callable[..., _Coro[_R]] -_CBP = Union[_CB[_R], "partial[_Coro[_R]]", "partialmethod[_Coro[_R]]"] +_CBP = _CB[_R] | partial[_Coro[_R]] | partialmethod[_Coro[_R]] class AlruCacheLoopResetWarning(UserWarning): @@ -52,16 +38,16 @@ @final class _CacheParameters(TypedDict): typed: bool - maxsize: Optional[int] + maxsize: int | None tasks: int closed: bool @final [email protected] [email protected](slots=True) class _CacheItem(Generic[_R]): task: "asyncio.Task[_R]" - later_call: Optional[asyncio.Handle] + later_call: asyncio.Handle | None waiters: int def cancel(self) -> None: @@ -72,13 +58,34 @@ @final class _LRUCacheWrapper(Generic[_R]): + # No slot for _is_coroutine_marker: inspect.markcoroutinefunction() must + # store it in __dict__, which _LRUCacheWrapperInstanceMethod copies. + __slots__ = ( + "__wrapped__", + "__maxsize", + "__typed", + "__ttl", + "__jitter", + "__key", + "__cache", + "__closed", + "__hits", + "__misses", + "__first_loop", + "__warned_loop_reset", + "_is_coroutine", + "__dict__", + "__weakref__", + ) + def __init__( self, fn: _CB[_R], - maxsize: Optional[int], + maxsize: int | None, typed: bool, - ttl: Optional[float], - jitter: Optional[float], + ttl: float | None, + jitter: float | None, + key: Callable[..., Hashable] | None = None, ) -> None: try: self.__module__ = fn.__module__ @@ -96,10 +103,21 @@ self.__doc__ = fn.__doc__ except AttributeError: pass - try: - self.__annotations__ = fn.__annotations__ - except AttributeError: - pass + # Copy the lazy __annotate__ function; reading fn.__annotations__ + # would force evaluation of deferred annotations (PEP 649). + if sys.version_info >= (3, 14): + try: + self.__annotate__ = fn.__annotate__ + except AttributeError: + try: + self.__annotations__ = fn.__annotations__ + except AttributeError: + pass + else: + try: + self.__annotations__ = fn.__annotations__ + except AttributeError: + pass try: self.__dict__.update(fn.__dict__) except AttributeError: @@ -113,15 +131,16 @@ self.__typed = typed self.__ttl = ttl self.__jitter = jitter + self.__key = key self.__cache: OrderedDict[Hashable, _CacheItem[_R]] = OrderedDict() self.__closed = False self.__hits = 0 self.__misses = 0 - self.__first_loop: Optional[asyncio.AbstractEventLoop] = None + self.__first_loop: asyncio.AbstractEventLoop | None = None self.__warned_loop_reset = False @property - def __tasks(self) -> List["asyncio.Task[_R]"]: + def __tasks(self) -> list["asyncio.Task[_R]"]: # NOTE: I don't think we need to form a set first here but not # too sure we want it for guarantees return list( @@ -155,11 +174,19 @@ Does not affect hit/miss counters or LRU ordering. """ - key = _make_key(args, kwargs, self.__typed) + # Inlined instead of a shared helper: an extra method call is + # measurable on these short code paths (CodSpeed). + if self.__key is not None: + key = self.__key(*args, **kwargs) + else: + key = _make_key(args, kwargs, self.__typed) return key in self.__cache def cache_invalidate(self, /, *args: Hashable, **kwargs: Any) -> bool: - key = _make_key(args, kwargs, self.__typed) + if self.__key is not None: + key = self.__key(*args, **kwargs) + else: + key = _make_key(args, kwargs, self.__typed) cache_item = self.__cache.pop(key, None) if cache_item is None: @@ -258,7 +285,10 @@ loop = asyncio.get_running_loop() self._check_loop(loop) - key = _make_key(fn_args, fn_kwargs, self.__typed) + if self.__key is not None: + key = self.__key(*fn_args, **fn_kwargs) + else: + key = _make_key(fn_args, fn_kwargs, self.__typed) cache_item = self.__cache.get(key) if cache_item is not None: @@ -287,8 +317,8 @@ return await self._shield_and_handle_cancelled_error(cache_item, key) def __get__( - self, instance: _T, owner: Optional[Type[_T]] - ) -> Union[Self, "_LRUCacheWrapperInstanceMethod[_R, _T]"]: + self, instance: _T, owner: type[_T] | None + ) -> Self | "_LRUCacheWrapperInstanceMethod[_R, _T]": if owner is None: return self else: @@ -297,6 +327,15 @@ @final class _LRUCacheWrapperInstanceMethod(Generic[_R, _T]): + __slots__ = ( + "__dict__", + "__weakref__", + "_is_coroutine", + "__wrapped__", + "__instance", + "__wrapper", + ) + def __init__( self, wrapper: _LRUCacheWrapper[_R], @@ -318,10 +357,21 @@ self.__doc__ = wrapper.__doc__ except AttributeError: pass - try: - self.__annotations__ = wrapper.__annotations__ - except AttributeError: - pass + # Prefer the lazy __annotate__ function, see the matching logic + # in _LRUCacheWrapper.__init__. + if sys.version_info >= (3, 14): + try: + self.__annotate__ = wrapper.__annotate__ + except AttributeError: + try: + self.__annotations__ = wrapper.__annotations__ + except AttributeError: + pass + else: + try: + self.__annotations__ = wrapper.__annotations__ + except AttributeError: + pass try: self.__dict__.update(wrapper.__dict__) except AttributeError: @@ -370,15 +420,20 @@ def _make_wrapper( - maxsize: Optional[int], + maxsize: int | None, typed: bool, - ttl: Optional[float] = None, - jitter: Optional[float] = None, + ttl: float | None = None, + jitter: float | None = None, + key: Callable[..., Hashable] | None = None, ) -> Callable[[_CBP[_R]], _LRUCacheWrapper[_R]]: if jitter is not None and ttl is None: raise ValueError("jitter requires ttl to be set") if jitter is not None and jitter < 0: raise ValueError("jitter must be non-negative") + if key is not None and not callable(key): + raise TypeError("key must be callable") + if key is not None and typed: + raise ValueError("typed is ignored when a custom key callable is provided") def wrapper(fn: _CBP[_R]) -> _LRUCacheWrapper[_R]: origin = fn @@ -392,7 +447,7 @@ if hasattr(fn, "_make_unbound_method"): fn = fn._make_unbound_method() - wrapper = _LRUCacheWrapper(cast(_CB[_R], fn), maxsize, typed, ttl, jitter) + wrapper = _LRUCacheWrapper(cast(_CB[_R], fn), maxsize, typed, ttl, jitter, key) if sys.version_info >= (3, 12): wrapper = inspect.markcoroutinefunction(wrapper) return wrapper @@ -402,11 +457,12 @@ @overload def alru_cache( - maxsize: Optional[int] = 128, + maxsize: int | None = 128, typed: bool = False, *, - ttl: Optional[float] = None, - jitter: Optional[float] = None, + ttl: float | None = None, + jitter: float | None = None, + key: Callable[..., Hashable] | None = None, ) -> Callable[[_CBP[_R]], _LRUCacheWrapper[_R]]: ... @@ -420,14 +476,15 @@ def alru_cache( - maxsize: Union[Optional[int], _CBP[_R]] = 128, + maxsize: int | _CBP[_R] | None = 128, typed: bool = False, *, - ttl: Optional[float] = None, - jitter: Optional[float] = None, -) -> Union[Callable[[_CBP[_R]], _LRUCacheWrapper[_R]], _LRUCacheWrapper[_R]]: + ttl: float | None = None, + jitter: float | None = None, + key: Callable[..., Hashable] | None = None, +) -> Callable[[_CBP[_R]], _LRUCacheWrapper[_R]] | _LRUCacheWrapper[_R]: if maxsize is None or isinstance(maxsize, int): - return _make_wrapper(maxsize, typed, ttl, jitter) + return _make_wrapper(maxsize, typed, ttl, jitter, key) else: fn = cast(_CB[_R], maxsize) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/async_lru.egg-info/PKG-INFO new/async_lru-2.4.0/async_lru.egg-info/PKG-INFO --- old/async_lru-2.3.0/async_lru.egg-info/PKG-INFO 2026-03-19 02:04:16.000000000 +0100 +++ new/async_lru-2.4.0/async_lru.egg-info/PKG-INFO 2026-10-12 20:12:35.831064000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: async-lru -Version: 2.3.0 +Version: 2.4.0 Summary: Simple LRU cache for asyncio Home-page: https://github.com/aio-libs/async-lru Maintainer: aiohttp team <[email protected]> @@ -152,6 +152,32 @@ The method returns `True` if the result for the given arguments is cached, `False` otherwise. +Custom cache keys +----------------- + +By default the cache key is built from all arguments, like +``functools.lru_cache`` does. The ``key`` parameter accepts a callable that +receives the same arguments as the wrapped function and returns the cache key, +so arguments that do not affect the result can be excluded from it. This is an +async-lru extension beyond the ``functools.lru_cache`` interface: + +.. code-block:: python + + @alru_cache(key=lambda db, query: query) + async def query_db(db, query): + return await db.execute(query) + + # Both calls share one cache entry despite the different connections. + await query_db(conn1, "SELECT ...") + await query_db(conn2, "SELECT ...") + +The returned key must be hashable. ``cache_invalidate()`` and +``cache_contains()`` compute the key the same way, so they accept the full +argument list as usual. For decorated methods the key callable receives the +instance as its first argument. Passing ``typed=True`` together with ``key`` +raises ``ValueError``, since ``typed`` only affects the default key +computation. + Limitations ----------- @@ -199,6 +225,40 @@ cached_task_loop2 = alru_cache(maxsize=32)(my_task.__wrapped__) await cached_task_loop2(x) +Security considerations +----------------------- + +**Cache keys are built only from explicit arguments.** Like +`functools.lru_cache <https://docs.python.org/3/library/functools.html#functools.lru_cache>`_, +``alru_cache`` derives its cache key solely from the positional and keyword arguments +passed to the wrapped function. Implicit, request-scoped context — such as +authentication headers, the current user or tenant, ``contextvars``, thread/task +locals, or module globals — is **not** part of the key and therefore **not** isolated +between callers. + +Because concurrent calls with the same key also share a single in-flight result (see +the Usage section above), a value computed for one caller can be returned to another whenever +their arguments are equal. In multi-tenant or multi-user services this can lead to +cross-tenant data exposure if the cached coroutine's result depends on anything other +than its explicit arguments. + +To use ``alru_cache`` safely in these contexts: + +- **Make the cached coroutine a pure function of its arguments.** Any value that + affects the result — ``user_id``, ``tenant_id``, role, locale, feature flags, etc. — + must be passed as an argument so it becomes part of the cache key, or use a separate + cache instance per security domain. +- **Avoid caching context-dependent functions.** If a function reads request-scoped + state from ``contextvars``/globals rather than from its arguments, either refactor it + to take that state explicitly or do not cache it. +- **Consider** ``typed=True`` **when callers may pass multiple types.** With the default + ``typed=False``, arguments that compare and hash equal share an entry (for example + ``1`` and ``1.0``, or ``True`` and ``1``). Pass ``typed=True`` to key such arguments + distinctly. +- **Be wary of attacker-controlled key arguments.** Objects with unusual ``__hash__`` / + ``__eq__`` semantics can collide unexpectedly; only use trusted, well-behaved values + as cache key components. + Benchmarks ---------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/async_lru.egg-info/SOURCES.txt new/async_lru-2.4.0/async_lru.egg-info/SOURCES.txt --- old/async_lru-2.3.0/async_lru.egg-info/SOURCES.txt 2026-03-19 02:04:16.000000000 +0100 +++ new/async_lru-2.4.0/async_lru.egg-info/SOURCES.txt 2026-10-12 20:12:35.836659200 +0200 @@ -19,8 +19,10 @@ tests/test_cache_invalidate.py tests/test_cancel.py tests/test_close.py +tests/test_deferred_annotations.py tests/test_exception.py tests/test_internals.py +tests/test_key.py tests/test_partialmethod.py tests/test_readme_examples.py tests/test_size.py diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/setup.cfg new/async_lru-2.4.0/setup.cfg --- old/async_lru-2.3.0/setup.cfg 2026-03-19 02:04:16.407925000 +0100 +++ new/async_lru-2.4.0/setup.cfg 2026-10-12 20:12:35.843384500 +0200 @@ -52,6 +52,8 @@ max-line-length = 88 extend-select = B950 ignore = N801,N802,N803,E252,W503,E133,E203,E501 +per-file-ignores = + tests/test_deferred_annotations.py:F821 [coverage:run] branch = True diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/conftest.py new/async_lru-2.4.0/tests/conftest.py --- old/async_lru-2.3.0/tests/conftest.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/conftest.py 2026-10-12 20:12:30.073536200 +0200 @@ -1,5 +1,5 @@ +from collections.abc import Callable from functools import _CacheInfo -from typing import Callable import pytest diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_basic.py new/async_lru-2.4.0/tests/test_basic.py --- old/async_lru-2.3.0/tests/test_basic.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_basic.py 2026-10-12 20:12:30.073994400 +0200 @@ -1,8 +1,9 @@ import asyncio import inspect import sys +import weakref +from collections.abc import Callable from functools import _CacheInfo, partial -from typing import Callable import pytest @@ -217,6 +218,27 @@ ) +async def test_alru_cache_method_is_coroutine_function() -> None: + class A: + @alru_cache + async def coro(self, val: int) -> int: + return val + + @classmethod + @alru_cache + async def cls_coro(cls, val: int) -> int: + return val + + if sys.version_info >= (3, 12): + assert inspect.iscoroutinefunction(A().coro) + assert inspect.iscoroutinefunction(A.cls_coro) + if sys.version_info < (3, 14): + assert asyncio.iscoroutinefunction(A().coro) + assert asyncio.iscoroutinefunction(A.cls_coro) + assert await A().coro(1) == 1 + assert await A.cls_coro(2) == 2 + + async def test_invalidate_cache_for_method() -> None: class A: @alru_cache @@ -231,3 +253,23 @@ a.coro.cache_invalidate(42) assert a.coro.cache_info() == _CacheInfo(0, 1, 128, 0) + + +async def test_alru_cache_weakref() -> None: + @alru_cache + async def coro(val: int) -> int: + return val + + assert weakref.ref(coro)() is coro + assert await coro(1) == 1 + + +async def test_alru_cache_method_weakref() -> None: + class A: + @alru_cache + async def coro(self, val: int) -> int: + return val + + method = A().coro + assert weakref.ref(method)() is method + assert await method(1) == 1 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_cache_clear.py new/async_lru-2.4.0/tests/test_cache_clear.py --- old/async_lru-2.3.0/tests/test_cache_clear.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_cache_clear.py 2026-10-12 20:12:30.073994400 +0200 @@ -1,5 +1,5 @@ import asyncio -from typing import Callable +from collections.abc import Callable from async_lru import alru_cache diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_cache_contains.py new/async_lru-2.4.0/tests/test_cache_contains.py --- old/async_lru-2.3.0/tests/test_cache_contains.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_cache_contains.py 2026-10-12 20:12:30.073994400 +0200 @@ -1,5 +1,5 @@ import asyncio -from typing import Callable +from collections.abc import Callable from async_lru import alru_cache diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_cache_info.py new/async_lru-2.4.0/tests/test_cache_info.py --- old/async_lru-2.3.0/tests/test_cache_info.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_cache_info.py 2026-10-12 20:12:30.073994400 +0200 @@ -1,5 +1,5 @@ import asyncio -from typing import Callable +from collections.abc import Callable from async_lru import alru_cache diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_cache_invalidate.py new/async_lru-2.4.0/tests/test_cache_invalidate.py --- old/async_lru-2.3.0/tests/test_cache_invalidate.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_cache_invalidate.py 2026-10-12 20:12:30.073994400 +0200 @@ -1,5 +1,5 @@ import asyncio -from typing import Callable +from collections.abc import Callable from async_lru import alru_cache diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_close.py new/async_lru-2.4.0/tests/test_close.py --- old/async_lru-2.3.0/tests/test_close.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_close.py 2026-10-12 20:12:30.073994400 +0200 @@ -1,5 +1,5 @@ import asyncio -from typing import Callable +from collections.abc import Callable import pytest diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_deferred_annotations.py new/async_lru-2.4.0/tests/test_deferred_annotations.py --- old/async_lru-2.3.0/tests/test_deferred_annotations.py 1970-01-01 01:00:00.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_deferred_annotations.py 2026-10-12 20:12:30.073994400 +0200 @@ -0,0 +1,98 @@ +"""Tests for PEP 649 deferred annotations (Python 3.14+). + +On Python 3.14 annotations are evaluated lazily via ``__annotate__``. +Copying ``fn.__annotations__`` eagerly at decoration time forces that +evaluation and crashes with ``NameError`` when the annotation refers to +a name defined after the decorated function, a pattern that works with +``functools.lru_cache``. +""" + +import inspect +import sys +from functools import lru_cache, partial + +import pytest + +from async_lru import alru_cache + + +requires_py314 = pytest.mark.skipif( + sys.version_info < (3, 14), + reason="deferred annotation evaluation requires Python 3.14", +) + + +@requires_py314 +async def test_deco_with_annotation_defined_after() -> None: + @alru_cache(maxsize=1) + async def get_foo() -> Foo: + return Foo() + + class Foo: + pass + + first = await get_foo() + assert isinstance(first, Foo) + assert await get_foo() is first + + +@requires_py314 +async def test_annotations_stay_lazy_like_lru_cache() -> None: + @alru_cache(maxsize=1) + async def get_foo_async() -> Foo: + return Foo() + + @lru_cache(maxsize=1) + def get_foo_sync() -> Foo: + return Foo() + + class Foo: + pass + + assert isinstance(await get_foo_async(), Foo) + assert isinstance(get_foo_sync(), Foo) + assert ( + inspect.get_annotations(get_foo_async) + == inspect.get_annotations(get_foo_sync) + == {"return": Foo} + ) + + +@requires_py314 +async def test_unresolvable_annotation_raises_only_on_access() -> None: + @alru_cache(maxsize=1) + async def broken() -> Missing: # type: ignore[name-defined] # noqa: F821 + pass # pragma: no cover + + with pytest.raises(NameError): + inspect.get_annotations(broken) + + +async def test_method_wrapping_partial_without_annotation_attributes() -> None: + """A wrapped ``partial`` carries neither ``__annotate__`` nor + ``__annotations__``; binding it as a method copies neither.""" + + async def impl(self: object) -> int: + return 42 + + class Api: + meth = alru_cache(partial(impl)) + + api = Api() + assert await api.meth() == 42 + assert not hasattr(api.meth, "__annotations__") + + +@requires_py314 +async def test_method_annotations_stay_lazy() -> None: + class Api: + @alru_cache(maxsize=1) + async def get_foo(self) -> Foo: + return Foo() + + class Foo: + pass + + api = Api() + assert isinstance(await api.get_foo(), Foo) + assert inspect.get_annotations(api.get_foo) == {"return": Foo} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_exception.py new/async_lru-2.4.0/tests/test_exception.py --- old/async_lru-2.3.0/tests/test_exception.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_exception.py 2026-10-12 20:12:30.073994400 +0200 @@ -1,7 +1,7 @@ import asyncio import gc import sys -from typing import Callable +from collections.abc import Callable import pytest diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_key.py new/async_lru-2.4.0/tests/test_key.py --- old/async_lru-2.3.0/tests/test_key.py 1970-01-01 01:00:00.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_key.py 2026-10-12 20:12:30.073994400 +0200 @@ -0,0 +1,145 @@ +import asyncio +from collections.abc import Callable + +import pytest + +from async_lru import alru_cache + + +async def test_key_excludes_argument(check_lru: Callable[..., None]) -> None: + """Calls differing only in the excluded argument share one entry.""" + + @alru_cache(key=lambda conn, query: query) + async def query_db(conn: str, query: str) -> str: + return f"{conn}={query}" + + assert await query_db("conn1", "SELECT 1") == "conn1=SELECT 1" + check_lru(query_db, hits=0, misses=1, cache=1, tasks=0) + + assert await query_db("conn2", "SELECT 1") == "conn1=SELECT 1" + check_lru(query_db, hits=1, misses=1, cache=1, tasks=0) + + assert await query_db("conn2", "SELECT 2") == "conn2=SELECT 2" + check_lru(query_db, hits=1, misses=2, cache=2, tasks=0) + + +async def test_key_receives_kwargs(check_lru: Callable[..., None]) -> None: + @alru_cache(key=lambda conn, *, query: query) + async def query_db(conn: str, *, query: str) -> str: + return f"{conn}={query}" + + assert await query_db("conn1", query="q") == "conn1=q" + assert await query_db("conn2", query="q") == "conn1=q" + check_lru(query_db, hits=1, misses=1, cache=1, tasks=0) + + +async def test_key_cache_invalidate(check_lru: Callable[..., None]) -> None: + """cache_invalidate computes the same custom key as the call did.""" + + @alru_cache(key=lambda conn, query: query) + async def query_db(conn: str, query: str) -> str: + return f"{conn}={query}" + + await query_db("conn1", "SELECT 1") + check_lru(query_db, hits=0, misses=1, cache=1, tasks=0) + + # A different excluded argument still addresses the same entry. + assert query_db.cache_invalidate("conn9", "SELECT 1") + check_lru(query_db, hits=0, misses=1, cache=0, tasks=0) + assert not query_db.cache_invalidate("conn9", "SELECT 1") + + +async def test_key_cache_contains(check_lru: Callable[..., None]) -> None: + @alru_cache(key=lambda conn, query: query) + async def query_db(conn: str, query: str) -> str: + return f"{conn}={query}" + + await query_db("conn1", "SELECT 1") + assert query_db.cache_contains("conn9", "SELECT 1") + assert not query_db.cache_contains("conn9", "SELECT 2") + + +def test_key_with_typed_raises() -> None: + with pytest.raises(ValueError, match="typed"): + alru_cache(typed=True, key=lambda x: x) + + +def test_key_not_callable() -> None: + with pytest.raises(TypeError, match="callable"): + alru_cache(key="query") # type: ignore[call-overload] + + +async def test_typed_without_key_still_works() -> None: + @alru_cache(typed=True) + async def coro(val: int) -> int: + return val + + assert coro.cache_parameters()["typed"] is True + assert await coro(1) == 1 + + +async def test_key_on_method(check_lru: Callable[..., None]) -> None: + """The key callable receives the instance for bound methods.""" + + class Api: + @alru_cache(key=lambda self, query: query) + async def fetch(self, query: str) -> str: + return f"{id(self)}={query}" + + first = Api() + second = Api() + result = await first.fetch("SELECT 1") + # Another instance shares the entry since self is excluded. + assert await second.fetch("SELECT 1") == result + check_lru(Api.fetch, hits=1, misses=1, cache=1, tasks=0) + + +async def test_key_unhashable_result(check_lru: Callable[..., None]) -> None: + @alru_cache(key=lambda val: [val]) # type: ignore[arg-type,return-value] + async def coro(val: int) -> int: + return val # pragma: no cover # the key fails before the call + + with pytest.raises(TypeError, match="unhashable"): + await coro(1) + check_lru(coro, hits=0, misses=0, cache=0, tasks=0) + + +async def test_key_callable_raising(check_lru: Callable[..., None]) -> None: + def broken_key(val: int) -> int: + raise RuntimeError("broken key") + + @alru_cache(key=broken_key) + async def coro(val: int) -> int: + return val # pragma: no cover # the key fails before the call + + with pytest.raises(RuntimeError, match="broken key"): + await coro(1) + check_lru(coro, hits=0, misses=0, cache=0, tasks=0) + + +async def test_key_with_ttl(check_lru: Callable[..., None]) -> None: + @alru_cache(ttl=0.05, key=lambda conn, query: query) + async def query_db(conn: str, query: str) -> str: + return f"{conn}={query}" + + await query_db("conn1", "SELECT 1") + assert await query_db("conn2", "SELECT 1") == "conn1=SELECT 1" + check_lru(query_db, hits=1, misses=1, cache=1, tasks=0) + + await asyncio.sleep(0.06) + assert await query_db("conn2", "SELECT 1") == "conn2=SELECT 1" + check_lru(query_db, hits=1, misses=2, cache=1, tasks=0) + + +async def test_key_with_maxsize(check_lru: Callable[..., None]) -> None: + @alru_cache(maxsize=1, key=lambda conn, query: query) + async def query_db(conn: str, query: str) -> str: + return f"{conn}={query}" + + await query_db("conn1", "SELECT 1") + await query_db("conn1", "SELECT 2") + check_lru(query_db, hits=0, misses=2, cache=1, tasks=0, maxsize=1) + + # The first entry was evicted; same query recomputes. + assert await query_db("conn2", "SELECT 1") == "conn2=SELECT 1" + check_lru(query_db, hits=0, misses=3, cache=1, tasks=0, maxsize=1) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_partialmethod.py new/async_lru-2.4.0/tests/test_partialmethod.py --- old/async_lru-2.3.0/tests/test_partialmethod.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_partialmethod.py 2026-10-12 20:12:30.073994400 +0200 @@ -1,6 +1,6 @@ import asyncio +from collections.abc import Callable from functools import partial, partialmethod -from typing import Callable from async_lru import alru_cache diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_size.py new/async_lru-2.4.0/tests/test_size.py --- old/async_lru-2.3.0/tests/test_size.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_size.py 2026-10-12 20:12:30.073994400 +0200 @@ -1,5 +1,5 @@ import asyncio -from typing import Callable +from collections.abc import Callable from async_lru import alru_cache diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/async_lru-2.3.0/tests/test_ttl.py new/async_lru-2.4.0/tests/test_ttl.py --- old/async_lru-2.3.0/tests/test_ttl.py 2026-03-19 02:04:08.000000000 +0100 +++ new/async_lru-2.4.0/tests/test_ttl.py 2026-10-12 20:12:30.073994400 +0200 @@ -1,5 +1,5 @@ import asyncio -from typing import Callable +from collections.abc import Callable from unittest import mock import pytest
