pax_global_header00006660000000000000000000000064151566373210014523gustar00rootroot0000000000000052 comment=cb9e034619167668af09c7be71a37e2d40a96995 aio-libs-async-lru-99dfebd/000077500000000000000000000000001515663732100157255ustar00rootroot00000000000000aio-libs-async-lru-99dfebd/.github/000077500000000000000000000000001515663732100172655ustar00rootroot00000000000000aio-libs-async-lru-99dfebd/.github/dependabot.yml000066400000000000000000000003121515663732100221110ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" aio-libs-async-lru-99dfebd/.github/workflows/000077500000000000000000000000001515663732100213225ustar00rootroot00000000000000aio-libs-async-lru-99dfebd/.github/workflows/auto-merge.yaml000066400000000000000000000011401515663732100242470ustar00rootroot00000000000000name: Dependabot auto-merge on: pull_request_target permissions: pull-requests: write contents: write jobs: dependabot: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} steps: - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v2.5.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Enable auto-merge for Dependabot PRs run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} aio-libs-async-lru-99dfebd/.github/workflows/ci-cd.yml000066400000000000000000000103521515663732100230250ustar00rootroot00000000000000name: CI on: push: branches: - master - '[0-9].[0-9]+' # matches to backport branches, e.g. 3.6 tags: [ 'v*' ] pull_request: branches: - master - '[0-9].[0-9]+' jobs: lint: name: Linter runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v6 with: python-version: '3.10' cache: 'pip' cache-dependency-path: '**/requirements*.txt' - name: Pre-Commit hooks uses: pre-commit/action@v3.0.1 - name: Install dependencies uses: py-actions/py-dependency-install@v4.1.0 with: path: requirements-dev.txt - name: Install itself run: | pip install . - name: Run linter run: | make lint - name: Prepare twine checker run: | pip install -U twine wheel build python -m build - name: Run twine checker run: | twine check dist/* test: name: Test strategy: matrix: pyver: ['3.10', '3.11', '3.12', '3.13', '3.14'] os: [ubuntu, macos, windows] experimental: [false] include: - pyver: pypy-3.11 os: ubuntu experimental: false - os: ubuntu pyver: "3.14" experimental: true fail-fast: true runs-on: ${{ matrix.os }}-latest timeout-minutes: 15 continue-on-error: ${{ matrix.experimental }} steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Python ${{ matrix.pyver }} uses: actions/setup-python@v6 with: allow-prereleases: true python-version: ${{ matrix.pyver }} cache: 'pip' cache-dependency-path: '**/requirements*.txt' - name: Install dependencies uses: py-actions/py-dependency-install@v4.1.0 with: path: requirements.txt - name: Run unittests run: make test env: COLOR: 'yes' - run: python -m coverage xml - name: Upload coverage uses: codecov/codecov-action@v5 with: file: ./coverage.xml flags: unit check: # This job does nothing and is only used for the branch protection if: always() needs: [lint, test] runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} deploy: name: Deploy runs-on: ubuntu-latest needs: [check] if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') permissions: contents: write # IMPORTANT: mandatory for making GitHub Releases id-token: write # IMPORTANT: mandatory for trusted publishing & sigstore environment: name: pypi url: https://pypi.org/p/async-lru steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v6 with: python-version: 3.13 - name: Install dependencies run: python -m pip install -U pip wheel setuptools build twine - name: Build dists run: | python -m build - name: Make Release uses: aio-libs/create-release@v1.6.6 with: changes_file: CHANGES.rst name: async-lru version_file: async_lru/__init__.py github_token: ${{ secrets.GITHUB_TOKEN }} dist_dir: dist fix_issue_regex: "`#(\\d+) `" fix_issue_repl: "(#\\1)" - name: >- Publish 🐍đŸ“Ļ to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - name: Sign the dists with Sigstore uses: sigstore/gh-action-sigstore-python@v3.2.0 with: inputs: >- ./dist/*.tar.gz ./dist/*.whl - name: Upload artifact signatures to GitHub Release # Confusingly, this action also supports updating releases, not # just creating them. This is what we want here, since we've manually # created the release above. uses: softprops/action-gh-release@v2 with: # dist/ contains the built packages, which smoketest-artifacts/ # contains the signatures and certificates. files: dist/** aio-libs-async-lru-99dfebd/.github/workflows/codeql.yml000066400000000000000000000044261515663732100233220ustar00rootroot00000000000000name: "CodeQL" on: push: branches: [ 'master' ] pull_request: # The branches below must be a subset of the branches above branches: [ 'master' ] schedule: - cron: '5 1 * * 4' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v4 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" aio-libs-async-lru-99dfebd/.github/workflows/codspeed.yml000066400000000000000000000017101515663732100236320ustar00rootroot00000000000000name: CodSpeed Benchmarks on: push: branches: - master - '[0-9].[0-9]+' pull_request: branches: - master - '[0-9].[0-9]+' jobs: benchmark: name: Run CodSpeed Benchmarks (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.13' cache: 'pip' cache-dependency-path: '**/requirements*.txt' - name: Install dependencies run: pip install -r requirements-benchmarks.txt - name: Create empty pytest config run: echo "[pytest]" > .empty-pytest.ini - name: Run the benchmarks uses: CodSpeedHQ/action@v4 with: mode: instrumentation run: pytest -c .empty-pytest.ini --codspeed benchmark.py --timeout=0 token: ${{ secrets.CODSPEED_TOKEN }} aio-libs-async-lru-99dfebd/.gitignore000066400000000000000000000013351515663732100177170ustar00rootroot00000000000000># Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ pyvenv/ build/ develop-eggs/ dist/ downloads/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg .eggs # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .cache nosetests.xml coverage.xml cover # Translations *.mo *.pot # Sphinx documentation docs/_build/ # PyBuilder target/ # PyCharm .idea *.iml # rope .ropeproject .python-version aio-libs-async-lru-99dfebd/.pre-commit-config.yaml000066400000000000000000000027431515663732100222140ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v4.4.0' hooks: - id: check-merge-conflict - repo: https://github.com/asottile/yesqa rev: v1.4.0 hooks: - id: yesqa - repo: https://github.com/PyCQA/isort rev: '5.12.0' hooks: - id: isort - repo: https://github.com/psf/black rev: '23.1.0' hooks: - id: black language_version: python3 # Should be a command that runs python - repo: https://github.com/pre-commit/pre-commit-hooks rev: 'v4.4.0' hooks: - id: end-of-file-fixer exclude: >- ^docs/[^/]*\.svg$ - id: requirements-txt-fixer - id: trailing-whitespace - id: file-contents-sorter files: | CONTRIBUTORS.txt| docs/spelling_wordlist.txt| .gitignore| .gitattributes - id: check-case-conflict - id: check-json - id: check-xml - id: check-executables-have-shebangs - id: check-toml - id: check-xml - id: check-yaml - id: debug-statements - id: check-added-large-files - id: check-symlinks - id: debug-statements - id: detect-aws-credentials args: ['--allow-missing-credentials'] - id: detect-private-key exclude: ^examples/ - repo: https://github.com/PyCQA/flake8 rev: '6.0.0' hooks: - id: flake8 exclude: "^docs/" - repo: https://github.com/asottile/pyupgrade rev: 'v3.3.1' hooks: - id: pyupgrade args: ['--py36-plus'] - repo: https://github.com/Lucas-C/pre-commit-hooks-markup rev: v1.0.1 hooks: - id: rst-linter files: >- ^[^/]+[.]rst$ aio-libs-async-lru-99dfebd/CHANGES.rst000066400000000000000000000021501515663732100175250ustar00rootroot00000000000000======= CHANGES ======= .. towncrier release notes start 2.3.0 (2026-03-18) ================== - Added ``cache_contains()`` for read-only key lookup. - Changed cross-loop cache access to auto-reset and rebind to the current event loop. - Added ``AlruCacheLoopResetWarning`` when an auto-reset happens due to event loop change. - Forwarded ``cache_close(wait=...)`` for bound methods. 2.2.0 (2026-02-20) ================== - Added a ``jitter`` parameter to randomise TTL. - Raise ``RuntimeError`` when cache is used by different loop. 2.1.0 (2026-01-17) ================== - Fixed cancelling of task when all tasks waiting on it have been cancelled. - Fixed DeprecationWarning from asyncio.iscoroutinefunction. 2.0.5 (2025-03-16) ================== - Fixed a memory leak on exceptions and minor performance improvement. 2.0.4 (2023-07-27) ================== - Fixed an error when there are pending tasks while calling ``.cache_clear()``. 2.0.3 (2023-07-07) ================== - Fixed a ``KeyError`` that could occur when using ``ttl`` with ``maxsize``. - Dropped ``typing-extensions`` dependency in Python 3.11+. aio-libs-async-lru-99dfebd/LICENSE000066400000000000000000000023121515663732100167300ustar00rootroot00000000000000The MIT License Copyright (c) 2018 aio-libs team https://github.com/aio-libs/ Copyright (c) 2017 Ocean S. A. https://ocean.io/ Copyright (c) 2016-2017 WikiBusiness Corporation http://wikibusiness.org/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. aio-libs-async-lru-99dfebd/MANIFEST.in000066400000000000000000000002151515663732100174610ustar00rootroot00000000000000include README.rst include LICENSE include Makefile graft async_lru graft tests recursive-exclude * __pycache__ recursive-exclude * *.py[co] aio-libs-async-lru-99dfebd/Makefile000066400000000000000000000004361515663732100173700ustar00rootroot00000000000000# Some simple testing tasks (sorry, UNIX only). .PHONY: init setup init setup: pip install -r requirements-dev.txt pre-commit install .PHONY: fmt fmt: python -m pre_commit run --all-files --show-diff-on-failure .PHONY: lint lint: fmt mypy .PHONY: test test: pytest -s ./tests/ aio-libs-async-lru-99dfebd/README.rst000066400000000000000000000142671515663732100174260ustar00rootroot00000000000000async-lru ========= :info: Simple lru cache for asyncio .. image:: https://github.com/aio-libs/async-lru/actions/workflows/ci-cd.yml/badge.svg?event=push :target: https://github.com/aio-libs/async-lru/actions/workflows/ci-cd.yml?query=event:push :alt: GitHub Actions CI/CD workflows status .. image:: https://img.shields.io/pypi/v/async-lru.svg?logo=Python&logoColor=white :target: https://pypi.org/project/async-lru :alt: async-lru @ PyPI .. image:: https://codecov.io/gh/aio-libs/async-lru/branch/master/graph/badge.svg :target: https://codecov.io/gh/aio-libs/async-lru .. image:: https://img.shields.io/matrix/aio-libs:matrix.org?label=Discuss%20on%20Matrix%20at%20%23aio-libs%3Amatrix.org&logo=matrix&server_fqdn=matrix.org&style=flat :target: https://matrix.to/#/%23aio-libs:matrix.org :alt: Matrix Room — #aio-libs:matrix.org .. image:: https://img.shields.io/matrix/aio-libs-space:matrix.org?label=Discuss%20on%20Matrix%20at%20%23aio-libs-space%3Amatrix.org&logo=matrix&server_fqdn=matrix.org&style=flat :target: https://matrix.to/#/%23aio-libs-space:matrix.org :alt: Matrix Space — #aio-libs-space:matrix.org Installation ------------ .. code-block:: shell pip install async-lru Usage ----- This package is a port of Python's built-in `functools.lru_cache `_ function for `asyncio `_. To better handle async behaviour, it also ensures multiple concurrent calls will only result in 1 call to the wrapped function, with all ``await``\s receiving the result of that call when it completes. .. code-block:: python import asyncio import aiohttp from async_lru import alru_cache @alru_cache(maxsize=32) async def get_pep(num): resource = 'http://www.python.org/dev/peps/pep-%04d/' % num async with aiohttp.ClientSession() as session: try: async with session.get(resource) as s: return await s.read() except aiohttp.ClientError: return 'Not Found' async def main(): for n in 8, 290, 308, 320, 8, 218, 320, 279, 289, 320, 9991: pep = await get_pep(n) print(n, len(pep)) print(get_pep.cache_info()) # CacheInfo(hits=3, misses=8, maxsize=32, currsize=8) # closing is optional, but highly recommended await get_pep.cache_close() asyncio.run(main()) TTL (time-to-live in seconds, expiration on timeout) is supported by accepting `ttl` configuration parameter (off by default): .. code-block:: python @alru_cache(ttl=5) async def func(arg): return arg * 2 To prevent thundering herd issues when many cache entries expire simultaneously, you can add ``jitter`` to randomize the TTL for each entry: .. code-block:: python @alru_cache(ttl=3600, jitter=1800) async def func(arg): return arg * 2 With ``ttl=3600, jitter=1800``, each cache entry will have a random TTL between 3600 and 5400 seconds, spreading out invalidations over time. The library supports explicit invalidation for specific function call by `cache_invalidate()`: .. code-block:: python @alru_cache(ttl=5) async def func(arg1, arg2): return arg1 + arg2 func.cache_invalidate(1, arg2=2) The method returns `True` if corresponding arguments set was cached already, `False` otherwise. To check whether a specific set of arguments is present in the cache without affecting hit/miss counters or LRU ordering, use `cache_contains()`: .. code-block:: python @alru_cache(maxsize=32) async def func(arg1, arg2): return arg1 + arg2 await func(1, arg2=2) func.cache_contains(1, arg2=2) # True func.cache_contains(3, arg2=4) # False The method returns `True` if the result for the given arguments is cached, `False` otherwise. Limitations ----------- **Event Loop Affinity**: ``alru_cache`` enforces that a cache instance is used with only one event loop. If you attempt to use a cached function from a different event loop than where it was first called, a ``RuntimeError`` will be raised: .. code-block:: text RuntimeError: alru_cache is not safe to use across event loops: this cache instance was first used with a different event loop. Use separate cache instances per event loop. For typical asyncio applications using a single event loop, this is automatic and requires no configuration. If your application uses multiple event loops, create separate cache instances per loop: .. code-block:: python import threading _local = threading.local() def get_cached_fetcher(): if not hasattr(_local, 'fetcher'): @alru_cache(maxsize=100) async def fetch_data(key): ... _local.fetcher = fetch_data return _local.fetcher You can also reuse the logic of an already decorated function in a new loop by accessing ``__wrapped__``: .. code-block:: python @alru_cache(maxsize=32) async def my_task(x): ... # In Loop 1: # my_task() uses the default global cache instance # In Loop 2 (or a new thread): # Create a fresh cache instance for the same logic cached_task_loop2 = alru_cache(maxsize=32)(my_task.__wrapped__) await cached_task_loop2(x) Benchmarks ---------- async-lru uses `CodSpeed `_ for performance regression testing. To run the benchmarks locally: .. code-block:: shell pip install -r requirements-dev.txt pytest --codspeed benchmark.py The benchmark suite covers both bounded (with maxsize) and unbounded (no maxsize) cache configurations. Scenarios include: - Cache hit - Cache miss - Cache fill/eviction (cycling through more keys than maxsize) - Cache clear - TTL expiry - Cache invalidation - Cache info retrieval - Concurrent cache hits - Baseline (uncached async function) On CI, benchmarks are run automatically via GitHub Actions on Python 3.13, and results are uploaded to CodSpeed (if a `CODSPEED_TOKEN` is configured). You can view performance history and detect regressions on the CodSpeed dashboard. Thanks ------ The library was donated by `Ocean S.A. `_ Thanks to the company for contribution. aio-libs-async-lru-99dfebd/async_lru/000077500000000000000000000000001515663732100177245ustar00rootroot00000000000000aio-libs-async-lru-99dfebd/async_lru/__init__.py000066400000000000000000000322541515663732100220430ustar00rootroot00000000000000import asyncio import dataclasses import inspect import random import sys import warnings 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, ) if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self if sys.version_info < (3, 14): from asyncio.coroutines import _is_coroutine # type: ignore[attr-defined] __version__ = "2.3.0" __all__ = ("AlruCacheLoopResetWarning", "alru_cache") _T = TypeVar("_T") _R = TypeVar("_R") _Coro = Coroutine[Any, Any, _R] _CB = Callable[..., _Coro[_R]] _CBP = Union[_CB[_R], "partial[_Coro[_R]]", "partialmethod[_Coro[_R]]"] class AlruCacheLoopResetWarning(UserWarning): """Emitted once per cache instance when a loop change triggers an auto-reset.""" @final class _CacheParameters(TypedDict): typed: bool maxsize: Optional[int] tasks: int closed: bool @final @dataclasses.dataclass class _CacheItem(Generic[_R]): task: "asyncio.Task[_R]" later_call: Optional[asyncio.Handle] waiters: int def cancel(self) -> None: if self.later_call is not None: self.later_call.cancel() self.later_call = None @final class _LRUCacheWrapper(Generic[_R]): def __init__( self, fn: _CB[_R], maxsize: Optional[int], typed: bool, ttl: Optional[float], jitter: Optional[float], ) -> None: try: self.__module__ = fn.__module__ except AttributeError: pass try: self.__name__ = fn.__name__ except AttributeError: pass try: self.__qualname__ = fn.__qualname__ except AttributeError: pass try: self.__doc__ = fn.__doc__ except AttributeError: pass try: self.__annotations__ = fn.__annotations__ except AttributeError: pass try: self.__dict__.update(fn.__dict__) except AttributeError: pass # set __wrapped__ last so we don't inadvertently copy it # from the wrapped function when updating __dict__ if sys.version_info < (3, 14): self._is_coroutine = _is_coroutine self.__wrapped__ = fn self.__maxsize = maxsize self.__typed = typed self.__ttl = ttl self.__jitter = jitter self.__cache: OrderedDict[Hashable, _CacheItem[_R]] = OrderedDict() self.__closed = False self.__hits = 0 self.__misses = 0 self.__first_loop: Optional[asyncio.AbstractEventLoop] = None self.__warned_loop_reset = False @property 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( { cache_item.task for cache_item in self.__cache.values() if not cache_item.task.done() } ) def _check_loop(self, loop: asyncio.AbstractEventLoop) -> None: if self.__first_loop is None: self.__first_loop = loop elif self.__first_loop is not loop: if not self.__warned_loop_reset: warnings.warn( "alru_cache detected event loop change and auto-cleared " "stale entries. This is safe but unusual outside of " "tests (pytest-anyio, etc.).", AlruCacheLoopResetWarning, stacklevel=3, ) self.__warned_loop_reset = True # Old cache entries hold tasks/handles bound to the previous # loop and are invalid here. Clear and rebind. self.cache_clear() self.__first_loop = loop def cache_contains(self, /, *args: Hashable, **kwargs: Any) -> bool: """Check if the given arguments are in the cache. Does not affect hit/miss counters or LRU ordering. """ 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) cache_item = self.__cache.pop(key, None) if cache_item is None: return False else: cache_item.cancel() return True def cache_clear(self) -> None: self.__hits = 0 self.__misses = 0 for c in self.__cache.values(): if c.later_call: c.later_call.cancel() self.__cache.clear() async def cache_close(self, *, wait: bool = False) -> None: self.__closed = True tasks = self.__tasks if not tasks: return if not wait: for task in tasks: if not task.done(): task.cancel() await asyncio.gather(*tasks, return_exceptions=True) def cache_info(self) -> _CacheInfo: return _CacheInfo( self.__hits, self.__misses, self.__maxsize, len(self.__cache), ) def cache_parameters(self) -> _CacheParameters: return _CacheParameters( maxsize=self.__maxsize, typed=self.__typed, tasks=len(self.__tasks), closed=self.__closed, ) def _cache_hit(self, key: Hashable) -> None: self.__hits += 1 self.__cache.move_to_end(key) def _cache_miss(self, key: Hashable) -> None: self.__misses += 1 def _task_done_callback(self, key: Hashable, task: "asyncio.Task[_R]") -> None: # We must use the private attribute instead of `exception()` # so asyncio does not set `task.__log_traceback = False` on # the false assumption that the caller read the task Exception if task.cancelled() or task._exception is not None: self.__cache.pop(key, None) return cache_item = self.__cache.get(key) if self.__ttl is not None and cache_item is not None: effective_ttl = self.__ttl if self.__jitter is not None: effective_ttl += random.uniform(0, self.__jitter) loop = asyncio.get_running_loop() cache_item.later_call = loop.call_later( effective_ttl, self.__cache.pop, key, None ) async def _shield_and_handle_cancelled_error( self, cache_item: _CacheItem[_T], key: Hashable ) -> _T: task = cache_item.task try: # All waiters await the same shielded task. return await asyncio.shield(task) except asyncio.CancelledError: # If this is the last waiter and the underlying task is not done, # cancel the underlying task and remove the cache entry. if cache_item.waiters == 1 and not task.done(): cache_item.cancel() # Cancel TTL expiration task.cancel() # Cancel the running coroutine self.__cache.pop(key, None) # Remove from cache raise finally: # Each logical waiter decrements waiters on exit (normal or cancelled). cache_item.waiters -= 1 async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: if self.__closed: raise RuntimeError(f"alru_cache is closed for {self}") loop = asyncio.get_running_loop() self._check_loop(loop) key = _make_key(fn_args, fn_kwargs, self.__typed) cache_item = self.__cache.get(key) if cache_item is not None: self._cache_hit(key) if not cache_item.task.done(): # Each logical waiter increments waiters on entry. cache_item.waiters += 1 return await self._shield_and_handle_cancelled_error(cache_item, key) # If the task is already done, just return the result. return cache_item.task.result() coro = self.__wrapped__(*fn_args, **fn_kwargs) task: asyncio.Task[_R] = loop.create_task(coro) task.add_done_callback(partial(self._task_done_callback, key)) cache_item = _CacheItem(task, None, 1) self.__cache[key] = cache_item if self.__maxsize is not None and len(self.__cache) > self.__maxsize: dropped_key, dropped_cache_item = self.__cache.popitem(last=False) dropped_cache_item.cancel() self._cache_miss(key) 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]"]: if owner is None: return self else: return _LRUCacheWrapperInstanceMethod(self, instance) @final class _LRUCacheWrapperInstanceMethod(Generic[_R, _T]): def __init__( self, wrapper: _LRUCacheWrapper[_R], instance: _T, ) -> None: try: self.__module__ = wrapper.__module__ except AttributeError: pass try: self.__name__ = wrapper.__name__ except AttributeError: pass try: self.__qualname__ = wrapper.__qualname__ except AttributeError: pass try: self.__doc__ = wrapper.__doc__ except AttributeError: pass try: self.__annotations__ = wrapper.__annotations__ except AttributeError: pass try: self.__dict__.update(wrapper.__dict__) except AttributeError: pass # set __wrapped__ last so we don't inadvertently copy it # from the wrapped function when updating __dict__ if sys.version_info < (3, 14): self._is_coroutine = _is_coroutine self.__wrapped__ = wrapper.__wrapped__ self.__instance = instance self.__wrapper = wrapper def cache_contains(self, /, *args: Hashable, **kwargs: Any) -> bool: return self.__wrapper.cache_contains(self.__instance, *args, **kwargs) def cache_invalidate(self, /, *args: Hashable, **kwargs: Any) -> bool: return self.__wrapper.cache_invalidate(self.__instance, *args, **kwargs) def cache_clear(self) -> None: self.__wrapper.cache_clear() async def cache_close( self, *, wait: bool = False, cancel: bool = False, return_exceptions: bool = True, ) -> None: if cancel or return_exceptions is not True: warnings.warn( "cancel/return_exceptions are deprecated; use wait=True to allow tasks " "to finish and wait=False to cancel pending tasks.", DeprecationWarning, stacklevel=2, ) await self.__wrapper.cache_close(wait=wait) def cache_info(self) -> _CacheInfo: return self.__wrapper.cache_info() def cache_parameters(self) -> _CacheParameters: return self.__wrapper.cache_parameters() async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: return await self.__wrapper(self.__instance, *fn_args, **fn_kwargs) def _make_wrapper( maxsize: Optional[int], typed: bool, ttl: Optional[float] = None, jitter: Optional[float] = 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") def wrapper(fn: _CBP[_R]) -> _LRUCacheWrapper[_R]: origin = fn while isinstance(origin, (partial, partialmethod)): origin = origin.func if not inspect.iscoroutinefunction(origin): raise RuntimeError(f"Coroutine function is required, got {fn!r}") if hasattr(fn, "_make_unbound_method"): fn = fn._make_unbound_method() wrapper = _LRUCacheWrapper(cast(_CB[_R], fn), maxsize, typed, ttl, jitter) if sys.version_info >= (3, 12): wrapper = inspect.markcoroutinefunction(wrapper) return wrapper return wrapper @overload def alru_cache( maxsize: Optional[int] = 128, typed: bool = False, *, ttl: Optional[float] = None, jitter: Optional[float] = None, ) -> Callable[[_CBP[_R]], _LRUCacheWrapper[_R]]: ... @overload def alru_cache( maxsize: _CBP[_R], /, ) -> _LRUCacheWrapper[_R]: ... def alru_cache( maxsize: Union[Optional[int], _CBP[_R]] = 128, typed: bool = False, *, ttl: Optional[float] = None, jitter: Optional[float] = None, ) -> Union[Callable[[_CBP[_R]], _LRUCacheWrapper[_R]], _LRUCacheWrapper[_R]]: if maxsize is None or isinstance(maxsize, int): return _make_wrapper(maxsize, typed, ttl, jitter) else: fn = cast(_CB[_R], maxsize) if callable(fn) or hasattr(fn, "_make_unbound_method"): return _make_wrapper(128, False, None, None)(fn) raise NotImplementedError(f"{fn!r} decorating is not supported") aio-libs-async-lru-99dfebd/async_lru/py.typed000066400000000000000000000000001515663732100214110ustar00rootroot00000000000000aio-libs-async-lru-99dfebd/benchmark.py000066400000000000000000000222441515663732100202350ustar00rootroot00000000000000import asyncio from functools import partial from typing import Any, Callable import pytest from async_lru import _LRUCacheWrapper, alru_cache try: from pytest_codspeed import BenchmarkFixture except ImportError: # pragma: no branch # only hit in cibuildwheel pytestmark = pytest.mark.skip("pytest-codspeed needs to be installed") else: pytestmark = pytest.mark.benchmark @pytest.fixture def loop(): # Save current loop to restore after the test try: old_loop = asyncio.get_running_loop() except RuntimeError: old_loop = None new_loop = asyncio.new_event_loop() asyncio.set_event_loop(new_loop) yield new_loop new_loop.close() if old_loop is not None: asyncio.set_event_loop(old_loop) @pytest.fixture def run_loop(loop): async def _get_coro(awaitable): """A helper function that turns an awaitable into a coroutine.""" return await awaitable def run_the_loop(fn, *args, **kwargs): awaitable = fn(*args, **kwargs) coro = awaitable if asyncio.iscoroutine(awaitable) else _get_coro(awaitable) return loop.run_until_complete(coro) return run_the_loop # Bounded cache (LRU) async def _cached_func(x): return x def create_cached_func(): return alru_cache(maxsize=128)(_cached_func) async def _cached_func_ttl(x): return x def create_cached_func_ttl(): return alru_cache(maxsize=16, ttl=0.01)(_cached_func_ttl) # Unbounded cache (no maxsize) async def _cached_func_unbounded(x): return x def create_cached_func_unbounded(): return alru_cache()(_cached_func_unbounded) async def _cached_func_unbounded_ttl(x): return x def create_cached_func_unbounded_ttl(): return alru_cache(ttl=0.01)(_cached_func_unbounded_ttl) def create_cached_meth(): class MethodsInstance: @alru_cache(maxsize=128) async def cached_meth(self, x): return x return MethodsInstance().cached_meth def create_cached_meth_ttl(): class MethodsInstance: @alru_cache(maxsize=16, ttl=0.01) async def cached_meth_ttl(self, x): return x return MethodsInstance().cached_meth_ttl def create_cached_meth_unbounded(): class MethodsInstance: @alru_cache() async def cached_meth_unbounded(self, x): return x return MethodsInstance().cached_meth_unbounded def create_cached_meth_unbounded_ttl(): class MethodsInstance: @alru_cache(ttl=0.01) async def cached_meth_unbounded_ttl(self, x): return x return MethodsInstance().cached_meth_unbounded_ttl async def uncached_func(x): return x funcs_no_ttl = [ create_cached_func, create_cached_func_unbounded, create_cached_meth, create_cached_meth_unbounded, ] no_ttl_ids = [ "func-bounded", "func-unbounded", "meth-bounded", "meth-unbounded", ] funcs_ttl = [ create_cached_func_ttl, create_cached_func_unbounded_ttl, create_cached_meth_ttl, create_cached_meth_unbounded_ttl, ] ttl_ids = [ "func-bounded-ttl", "func-unbounded-ttl", "meth-bounded-ttl", "meth-unbounded-ttl", ] all_funcs = [*funcs_no_ttl, *funcs_ttl] all_ids = [*no_ttl_ids, *ttl_ids] @pytest.mark.parametrize("factory", all_funcs, ids=all_ids) def test_cache_hit_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], factory: Callable[[], _LRUCacheWrapper[Any]], ) -> None: func = factory() keys = list(range(10)) for key in keys: run_loop(func, key) async def run() -> None: for _ in range(100): for key in keys: await func(key) benchmark(run_loop, run) @pytest.mark.parametrize("factory", all_funcs, ids=all_ids) def test_cache_miss_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], factory: Callable[[], _LRUCacheWrapper[Any]], ) -> None: func = factory() # Use 2048 objects (16x maxsize=128) to force evictions and measure actual misses unique_objects = [object() for _ in range(2048)] async def run() -> None: for obj in unique_objects: await func(obj) benchmark(run_loop, run) @pytest.mark.parametrize("factory", all_funcs, ids=all_ids) def test_cache_clear_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], factory: Callable[[], _LRUCacheWrapper[Any]], ) -> None: func = factory() for i in range(100): run_loop(func, i) benchmark(func.cache_clear) @pytest.mark.parametrize("factory", funcs_ttl, ids=ttl_ids) def test_cache_ttl_expiry_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], factory: Callable[[], _LRUCacheWrapper[Any]], ) -> None: func_ttl = factory() run_loop(func_ttl, 99) run_loop(asyncio.sleep, 0.02) benchmark(run_loop, func_ttl, 99) @pytest.mark.parametrize("factory", all_funcs, ids=all_ids) def test_cache_invalidate_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], factory: Callable[[], _LRUCacheWrapper[Any]], ) -> None: func = factory() keys = list(range(123, 321)) for i in keys: run_loop(func, i) invalidate = func.cache_invalidate @benchmark def run() -> None: for i in keys: invalidate(i) @pytest.mark.parametrize("factory", all_funcs, ids=all_ids) def test_cache_info_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], factory: Callable[[], _LRUCacheWrapper[Any]], ) -> None: func = factory() keys = list(range(1000)) for i in keys: run_loop(func, i) cache_info = func.cache_info @benchmark def run() -> None: for _ in keys: cache_info() @pytest.mark.parametrize("factory", all_funcs, ids=all_ids) def test_concurrent_cache_hit_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], factory: Callable[[], _LRUCacheWrapper[Any]], ) -> None: func = factory() keys = list(range(600, 700)) for key in keys: run_loop(func, key) async def gather_coros(): gather = asyncio.gather for _ in range(10): await gather(*map(func, keys)) benchmark(run_loop, gather_coros) def test_cache_fill_eviction_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any] ) -> None: func = create_cached_func() for i in range(-128, 0): run_loop(func, i) keys = list(range(5000)) async def fill(): for k in keys: await func(k) benchmark(run_loop, fill) # =========================== # Internal Microbenchmarks # =========================== # These benchmarks directly exercise internal (sync) methods and data structures # not covered by the async public API benchmarks above. # The relevant internal methods do not exist on _LRUCacheWrapperInstanceMethod, # so we can skip methods for this part of the benchmark suite. # We also skip wrappers with ttl because it raises KeyError. only_funcs_no_ttl = funcs_no_ttl[:2] func_ids_no_ttl = no_ttl_ids[:2] @pytest.mark.parametrize("factory", only_funcs_no_ttl, ids=func_ids_no_ttl) def test_internal_cache_hit_microbenchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], factory: Callable[[], _LRUCacheWrapper[Any]], ) -> None: """Directly benchmark _cache_hit (internal, sync) using parameterized funcs.""" func = factory() cache_hit = func._cache_hit keys = list(range(128)) for i in keys: run_loop(func, i) @benchmark def run() -> None: for i in keys: cache_hit(i) @pytest.mark.parametrize("factory", only_funcs_no_ttl, ids=func_ids_no_ttl) def test_internal_cache_miss_microbenchmark( benchmark: BenchmarkFixture, factory: Callable[[], _LRUCacheWrapper[Any]] ) -> None: """Directly benchmark _cache_miss (internal, sync) using parameterized funcs.""" func = factory() cache_miss = func._cache_miss @benchmark def run() -> None: for i in range(128): cache_miss(i) @pytest.mark.parametrize("factory", only_funcs_no_ttl, ids=func_ids_no_ttl) @pytest.mark.parametrize("task_state", ["finished", "cancelled", "exception"]) def test_internal_task_done_callback_microbenchmark( benchmark: BenchmarkFixture, loop: asyncio.BaseEventLoop, factory: Callable[[], _LRUCacheWrapper[Any]], task_state: str, ) -> None: """Directly benchmark _task_done_callback (internal, sync) using parameterized funcs and task states.""" func = factory() async def dummy_coro(): if task_state == "exception": raise ValueError("test exception") return 123 task = loop.create_task(dummy_coro()) if task_state == "finished": loop.run_until_complete(task) elif task_state == "cancelled": task.cancel() try: loop.run_until_complete(task) except asyncio.CancelledError: pass elif task_state == "exception": try: loop.run_until_complete(task) except Exception: pass iterations = range(1000) callback_fn = func._task_done_callback @benchmark def run() -> None: for i in iterations: callback = partial(callback_fn, i) callback(task) aio-libs-async-lru-99dfebd/requirements-benchmarks.txt000066400000000000000000000000661515663732100233260ustar00rootroot00000000000000-e . -r requirements-test.txt pytest-codspeed==4.3.0 aio-libs-async-lru-99dfebd/requirements-dev.txt000066400000000000000000000002651515663732100217700ustar00rootroot00000000000000-r requirements.txt flake8==7.3.0 flake8-bandit==4.1.1 flake8-bugbear==25.11.29 flake8-import-order==0.19.2 flake8-requirements==2.3.0 mypy==1.19.1; implementation_name=="cpython" aio-libs-async-lru-99dfebd/requirements-test.txt000066400000000000000000000000721515663732100221650ustar00rootroot00000000000000pytest==9.0.2 pytest-asyncio==1.3.0 pytest-timeout==2.4.0 aio-libs-async-lru-99dfebd/requirements.txt000066400000000000000000000001021515663732100212020ustar00rootroot00000000000000-e . -r requirements-test.txt coverage==7.13.5 pytest-cov==7.0.0 aio-libs-async-lru-99dfebd/setup.cfg000066400000000000000000000043241515663732100175510ustar00rootroot00000000000000[metadata] name = async-lru version = attr: async_lru.__version__ url = https://github.com/aio-libs/async-lru project_urls = Chat: Matrix = https://matrix.to/#/#aio-libs:matrix.org Chat: Matrix Space = https://matrix.to/#/#aio-libs-space:matrix.org CI: GitHub Actions = https://github.com/aio-libs/async-lru/actions GitHub: repo = https://github.com/aio-libs/async-lru description = Simple LRU cache for asyncio long_description = file: README.rst long_description_content_type = text/x-rst maintainer = aiohttp team maintainer_email = team@aiohttp.org license = MIT License license_files = LICENSE classifiers = License :: OSI Approved :: MIT License Intended Audience :: Developers Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3.13 Programming Language :: Python :: 3.14 Development Status :: 5 - Production/Stable Framework :: AsyncIO keywords = asyncio lru lru_cache [options] python_requires = >=3.10 packages = find: install_requires = typing_extensions>=4.0.0; python_version<"3.11" [options.package_data] * = py.typed [flake8] exclude = .git,.env,__pycache__,.eggs max-line-length = 88 extend-select = B950 ignore = N801,N802,N803,E252,W503,E133,E203,E501 [coverage:run] branch = True omit = site-packages [isort] line_length=88 include_trailing_comma=True multi_line_output=3 force_grid_wrap=0 combine_as_imports=True lines_after_imports=2 known_first_party=async_lru [tool:pytest] addopts= -s --keep-duplicates --cache-clear --verbose --no-cov-on-fail --cov=async_lru --cov=tests/ --cov-report=term --cov-report=html filterwarnings = error ignore:'asyncio.get_event_loop_policy' is deprecated:DeprecationWarning:pytest_asyncio ignore:'asyncio.set_event_loop_policy' is deprecated:DeprecationWarning:pytest_asyncio ignore:'asyncio.set_event_loop' is deprecated:DeprecationWarning:pytest_asyncio testpaths = tests/ junit_family=xunit2 asyncio_mode=auto timeout=15 xfail_strict = true [mypy] strict=True pretty=True packages=async_lru, tests aio-libs-async-lru-99dfebd/setup.py000066400000000000000000000000471515663732100174400ustar00rootroot00000000000000from setuptools import setup setup() aio-libs-async-lru-99dfebd/tests/000077500000000000000000000000001515663732100170675ustar00rootroot00000000000000aio-libs-async-lru-99dfebd/tests/conftest.py000066400000000000000000000011551515663732100212700ustar00rootroot00000000000000from functools import _CacheInfo from typing import Callable import pytest from async_lru import _R, _LRUCacheWrapper @pytest.fixture def check_lru() -> Callable[..., None]: def _check_lru( wrapped: _LRUCacheWrapper[_R], *, hits: int, misses: int, cache: int, tasks: int, maxsize: int = 128 ) -> None: assert wrapped.cache_info() == _CacheInfo( hits=hits, misses=misses, maxsize=maxsize, currsize=cache, ) assert wrapped.cache_parameters()["tasks"] == tasks return _check_lru aio-libs-async-lru-99dfebd/tests/test_basic.py000066400000000000000000000132371515663732100215670ustar00rootroot00000000000000import asyncio import inspect import sys from functools import _CacheInfo, partial from typing import Callable import pytest from async_lru import _CacheParameters, alru_cache def test_alru_cache_not_callable() -> None: with pytest.raises(NotImplementedError): alru_cache("foo") # type: ignore[call-overload] def test_alru_cache_not_coroutine() -> None: with pytest.raises(RuntimeError): @alru_cache # type: ignore[arg-type] def not_coro(val: int) -> int: return val async def test_alru_cache_deco(check_lru: Callable[..., None]) -> None: @alru_cache async def coro() -> None: pass if sys.version_info >= (3, 12): assert inspect.iscoroutinefunction(coro) if sys.version_info < (3, 14): assert asyncio.iscoroutinefunction(coro) check_lru(coro, hits=0, misses=0, cache=0, tasks=0) awaitable = coro() assert asyncio.iscoroutine(awaitable) await awaitable async def test_alru_cache_deco_called(check_lru: Callable[..., None]) -> None: @alru_cache() async def coro() -> None: pass if sys.version_info >= (3, 12): assert inspect.iscoroutinefunction(coro) if sys.version_info < (3, 14): assert asyncio.iscoroutinefunction(coro) check_lru(coro, hits=0, misses=0, cache=0, tasks=0) awaitable = coro() assert asyncio.iscoroutine(awaitable) await awaitable async def test_alru_cache_fn_called(check_lru: Callable[..., None]) -> None: async def coro() -> None: pass coro_wrapped = alru_cache(coro) if sys.version_info >= (3, 12): assert inspect.iscoroutinefunction(coro_wrapped) if sys.version_info < (3, 14): assert asyncio.iscoroutinefunction(coro) check_lru(coro_wrapped, hits=0, misses=0, cache=0, tasks=0) awaitable = coro_wrapped() assert asyncio.iscoroutine(awaitable) await awaitable async def test_alru_cache_partial() -> None: async def coro(val: int) -> int: return val coro_wrapped1 = alru_cache(coro) assert await coro_wrapped1(1) == 1 coro_wrapped2 = alru_cache(partial(coro, 2)) assert await coro_wrapped2() == 2 async def test_alru_cache_await_same_result_async( check_lru: Callable[..., None] ) -> None: calls = 0 val = object() @alru_cache() async def coro() -> object: nonlocal calls calls += 1 return val coros = [coro() for _ in range(100)] ret = await asyncio.gather(*coros) expected = [val] * 100 assert ret == expected check_lru(coro, hits=99, misses=1, cache=1, tasks=0) assert calls == 1 assert await coro() is val check_lru(coro, hits=100, misses=1, cache=1, tasks=0) async def test_alru_cache_await_same_result_coroutine( check_lru: Callable[..., None] ) -> None: calls = 0 val = object() @alru_cache() async def coro() -> object: nonlocal calls calls += 1 return val coros = [coro() for _ in range(100)] ret = await asyncio.gather(*coros) expected = [val] * 100 assert ret == expected check_lru(coro, hits=99, misses=1, cache=1, tasks=0) assert calls == 1 assert await coro() is val check_lru(coro, hits=100, misses=1, cache=1, tasks=0) async def test_alru_cache_dict_not_shared(check_lru: Callable[..., None]) -> None: async def coro(val: int) -> int: return val coro1 = alru_cache()(coro) coro2 = alru_cache()(coro) ret1 = await coro1(1) check_lru(coro1, hits=0, misses=1, cache=1, tasks=0) ret2 = await coro2(1) check_lru(coro2, hits=0, misses=1, cache=1, tasks=0) assert ret1 == ret2 assert ( coro1._LRUCacheWrapper__cache[1].task.result() # type: ignore[attr-defined] == coro2._LRUCacheWrapper__cache[1].task.result() # type: ignore[attr-defined] ) assert coro1._LRUCacheWrapper__cache != coro2._LRUCacheWrapper__cache # type: ignore[attr-defined] assert coro1._LRUCacheWrapper__cache.keys() == coro2._LRUCacheWrapper__cache.keys() # type: ignore[attr-defined] assert coro1._LRUCacheWrapper__cache is not coro2._LRUCacheWrapper__cache # type: ignore[attr-defined] async def test_alru_cache_parameters() -> None: @alru_cache async def coro(val: int) -> int: return val assert coro.cache_parameters() == _CacheParameters( typed=False, maxsize=128, tasks=0, closed=False, ) await coro(1) assert coro.cache_parameters() == _CacheParameters( typed=False, maxsize=128, tasks=0, closed=False, ) async def test_alru_cache_method() -> None: class A: def __init__(self, val: int) -> None: self.val = val @alru_cache async def coro(self) -> int: return self.val a = A(42) assert await a.coro() == 42 assert a.coro.cache_parameters() == _CacheParameters( typed=False, maxsize=128, tasks=0, closed=False, ) async def test_alru_cache_classmethod() -> None: class A: offset = 3 @classmethod @alru_cache async def coro(cls, val: int) -> int: return val + cls.offset assert await A.coro(5) == 8 assert A.coro.cache_parameters() == _CacheParameters( typed=False, maxsize=128, tasks=0, closed=False, ) async def test_invalidate_cache_for_method() -> None: class A: @alru_cache async def coro(self, val: int) -> int: return val a = A() assert await a.coro(42) == 42 assert a.coro.cache_info() == _CacheInfo(0, 1, 128, 1) a.coro.cache_invalidate(42) assert a.coro.cache_info() == _CacheInfo(0, 1, 128, 0) aio-libs-async-lru-99dfebd/tests/test_cache_clear.py000066400000000000000000000027431515663732100227170ustar00rootroot00000000000000import asyncio from typing import Callable from async_lru import alru_cache async def test_cache_clear(check_lru: Callable[..., None]) -> None: @alru_cache() async def coro(val: int) -> int: return val inputs = [1, 2, 3] coros = [coro(v) for v in inputs] ret = await asyncio.gather(*coros) assert ret == inputs check_lru(coro, hits=0, misses=3, cache=3, tasks=0) coro.cache_clear() check_lru(coro, hits=0, misses=0, cache=0, tasks=0) async def test_cache_clear_pending_task() -> None: @alru_cache() async def coro() -> str: await asyncio.sleep(0.5) return "foo" t = asyncio.create_task(coro()) await asyncio.sleep(0) assert len(coro._LRUCacheWrapper__tasks) == 1 # type: ignore[attr-defined] inner_task = next(iter(coro._LRUCacheWrapper__tasks)) # type: ignore[attr-defined] assert not inner_task.done() coro.cache_clear() await inner_task assert await t == "foo" assert inner_task.done() async def test_cache_clear_ttl_callback(check_lru: Callable[..., None]) -> None: @alru_cache(ttl=0.5) async def coro() -> str: return "foo" await coro() assert len(coro._LRUCacheWrapper__cache) == 1 # type: ignore[attr-defined] cache_item = next(iter(coro._LRUCacheWrapper__cache.values())) # type: ignore[attr-defined] assert not cache_item.later_call.cancelled() coro.cache_clear() assert cache_item.later_call.cancelled() await asyncio.sleep(0.5) aio-libs-async-lru-99dfebd/tests/test_cache_contains.py000066400000000000000000000064501515663732100234460ustar00rootroot00000000000000import asyncio from typing import Callable from async_lru import alru_cache async def test_cache_contains_basic(check_lru: Callable[..., None]) -> None: @alru_cache(maxsize=4) async def coro(val: int) -> int: return val assert coro.cache_contains(1) is False await coro(1) assert coro.cache_contains(1) is True assert coro.cache_contains(2) is False check_lru(coro, hits=0, misses=1, cache=1, tasks=0, maxsize=4) async def test_cache_contains_does_not_affect_counters( check_lru: Callable[..., None], ) -> None: @alru_cache(maxsize=4) async def coro(val: int) -> int: return val await coro(1) for _ in range(10): coro.cache_contains(1) coro.cache_contains(99) # hits/misses must stay unchanged after cache_contains calls check_lru(coro, hits=0, misses=1, cache=1, tasks=0, maxsize=4) async def test_cache_contains_does_not_change_lru_order() -> None: @alru_cache(maxsize=2) async def coro(val: int) -> int: return val await coro(1) await coro(2) # Peek at key 1 without refreshing its LRU position assert coro.cache_contains(1) is True # Adding a third entry must evict key 1 (oldest), not key 2 await coro(3) assert coro.cache_contains(1) is False assert coro.cache_contains(2) is True assert coro.cache_contains(3) is True async def test_cache_contains_after_invalidate_and_clear() -> None: @alru_cache(maxsize=4) async def coro(val: int) -> int: return val await coro(1) await coro(2) coro.cache_invalidate(1) assert coro.cache_contains(1) is False assert coro.cache_contains(2) is True # unaffected coro.cache_clear() assert coro.cache_contains(2) is False async def test_cache_contains_with_kwargs() -> None: @alru_cache(maxsize=4) async def coro(a: int, b: int = 10) -> int: return a + b await coro(1, b=20) assert coro.cache_contains(1, b=20) is True assert coro.cache_contains(1, b=30) is False assert coro.cache_contains(1) is False async def test_cache_contains_respects_typed_flag() -> None: @alru_cache(maxsize=4, typed=True) async def coro(val: int) -> int: return val await coro(1) assert coro.cache_contains(1) is True assert coro.cache_contains(1.0) is False async def test_cache_contains_pending_task() -> None: event = asyncio.Event() @alru_cache(maxsize=4) async def coro(val: int) -> int: await event.wait() return val task = asyncio.ensure_future(coro(1)) await asyncio.sleep(0) # Key must be present even while the underlying task is still running assert coro.cache_contains(1) is True event.set() await task async def test_cache_contains_after_ttl_expiry() -> None: @alru_cache(maxsize=4, ttl=0.05) async def coro(val: int) -> int: return val await coro(1) assert coro.cache_contains(1) is True await asyncio.sleep(0.1) assert coro.cache_contains(1) is False async def test_cache_contains_on_method() -> None: class MyService: @alru_cache(maxsize=4) async def fetch(self, key: int) -> int: return key * 2 svc = MyService() await svc.fetch(5) assert svc.fetch.cache_contains(5) is True assert svc.fetch.cache_contains(6) is False aio-libs-async-lru-99dfebd/tests/test_cache_info.py000066400000000000000000000017471515663732100225670ustar00rootroot00000000000000import asyncio from typing import Callable from async_lru import alru_cache async def test_cache_info(check_lru: Callable[..., None]) -> None: @alru_cache(maxsize=4) async def coro(val: int) -> int: return val inputs = [1, 2, 3] coros = [coro(v) for v in inputs] ret = await asyncio.gather(*coros) assert ret == inputs check_lru(coro, hits=0, misses=3, cache=3, tasks=0, maxsize=4) coro.cache_clear() check_lru(coro, hits=0, misses=0, cache=0, tasks=0, maxsize=4) inputs = [1, 1, 1] coros = [coro(v) for v in inputs] ret = await asyncio.gather(*coros) assert ret == inputs check_lru(coro, hits=2, misses=1, cache=1, tasks=0, maxsize=4) coro.cache_clear() check_lru(coro, hits=0, misses=0, cache=0, tasks=0, maxsize=4) inputs = [1, 2, 3, 4] * 2 coros = [coro(v) for v in inputs] ret = await asyncio.gather(*coros) assert ret == inputs check_lru(coro, hits=4, misses=4, cache=4, tasks=0, maxsize=4) aio-libs-async-lru-99dfebd/tests/test_cache_invalidate.py000066400000000000000000000047701515663732100237530ustar00rootroot00000000000000import asyncio from typing import Callable from async_lru import alru_cache async def test_cache_invalidate(check_lru: Callable[..., None]) -> None: @alru_cache() async def coro(val: int) -> int: return val inputs = [1, 2, 3] coro.cache_invalidate(1) coro.cache_invalidate(2) coro.cache_invalidate(3) coros = [coro(v) for v in inputs] ret = await asyncio.gather(*coros) assert ret == inputs check_lru(coro, hits=0, misses=3, cache=3, tasks=0) coro.cache_invalidate(1) check_lru(coro, hits=0, misses=3, cache=2, tasks=0) coro.cache_invalidate(2) check_lru(coro, hits=0, misses=3, cache=1, tasks=0) coro.cache_invalidate(3) check_lru(coro, hits=0, misses=3, cache=0, tasks=0) inputs = [1, 2, 3] coros = [coro(v) for v in inputs] ret = await asyncio.gather(*coros) assert ret == inputs check_lru(coro, hits=0, misses=6, cache=3, tasks=0) async def test_cache_invalidate_multiple_args(check_lru: Callable[..., None]) -> None: @alru_cache() async def coro(*args: int) -> int: return len(args) for i, size in enumerate(range(10)): args = tuple(range(size)) ret = await coro(*args) assert ret == size check_lru(coro, hits=0, misses=i + 1, cache=1, tasks=0) coro.cache_invalidate(*args) check_lru(coro, hits=0, misses=i + 1, cache=0, tasks=0) for size in range(10): args = tuple(range(size)) ret = await coro(*args) assert ret == size check_lru(coro, hits=0, misses=20, cache=10, tasks=0) async def test_cache_invalidate_multiple_args_different_order( check_lru: Callable[..., None] ) -> None: @alru_cache() async def coro(*args: int) -> int: return len(args) for i, size in enumerate(range(2, 10)): args = tuple(range(size)) rev_args = tuple(reversed(args)) ret = await coro(*args) assert ret == size check_lru(coro, hits=0, misses=2 * i + 1, cache=i + 1, tasks=0) ret = await coro(*rev_args) # The reversed args should be a miss check_lru(coro, hits=0, misses=2 * i + 2, cache=i + 2, tasks=0) coro.cache_invalidate(*rev_args) # The reversed args should be invalidated check_lru(coro, hits=0, misses=2 * i + 2, cache=i + 1, tasks=0) for i, size in enumerate(range(2, 10)): args = tuple(range(size)) ret = await coro(*args) assert ret == size check_lru(coro, hits=i + 1, misses=16, cache=8, tasks=0) aio-libs-async-lru-99dfebd/tests/test_cancel.py000066400000000000000000000032111515663732100217220ustar00rootroot00000000000000import asyncio import pytest from async_lru import alru_cache @pytest.mark.parametrize("num_to_cancel", (0, 1, 2, 3)) async def test_cancel(num_to_cancel: int) -> None: @alru_cache async def coro(val: int) -> int: # I am a long running coro function await asyncio.sleep(0.1) return val # create 3 tasks for the cached function using the same key tasks = [asyncio.create_task(coro(i)) for i in range(3)] # force the event loop to run once so the tasks can begin await asyncio.sleep(0) # maybe cancel some tasks for i in range(num_to_cancel): tasks[i].cancel() # allow enough time for the non-cancelled tasks to complete await asyncio.sleep(0.2) # check tasks are properly cancelled for i in range(num_to_cancel): assert tasks[i].cancelled() # check non-cancelled tasks return expected outputs for i in range(num_to_cancel, 3): assert await tasks[i] == i @pytest.mark.asyncio async def test_cancel_single_waiter_triggers_handle_cancelled_error() -> None: # This test ensures the _handle_cancelled_error path (waiters == 1) is exercised. cache_item_task_finished = False @alru_cache async def coro(val: int) -> None: nonlocal cache_item_task_finished await asyncio.sleep(2) cache_item_task_finished = True # pragma: no cover task = asyncio.create_task(coro(42)) await asyncio.sleep(0) task.cancel() try: await task except asyncio.CancelledError: pass # The underlying coroutine should be cancelled, so the flag should remain False assert cache_item_task_finished is False aio-libs-async-lru-99dfebd/tests/test_close.py000066400000000000000000000031461515663732100216110ustar00rootroot00000000000000import asyncio from typing import Callable import pytest from async_lru import alru_cache async def test_cache_close(check_lru: Callable[..., None]) -> None: @alru_cache() async def coro(val: int) -> int: await asyncio.sleep(0.2) return val assert not coro.cache_parameters()["closed"] inputs = [1, 2, 3, 4, 5] coros = [coro(v) for v in inputs] gather = asyncio.gather(*coros) await asyncio.sleep(0.1) check_lru(coro, hits=0, misses=5, cache=5, tasks=5) close = coro.cache_close() check_lru(coro, hits=0, misses=5, cache=5, tasks=5) await close check_lru(coro, hits=0, misses=5, cache=0, tasks=0) assert coro.cache_parameters()["closed"] with pytest.raises(asyncio.CancelledError): await gather check_lru(coro, hits=0, misses=5, cache=0, tasks=0) assert coro.cache_parameters()["closed"] # double call is no-op await coro.cache_close() async def test_cache_close_wait_bound_method(check_lru: Callable[..., None]) -> None: class Foo: @alru_cache() async def coro(self, val: int) -> int: await asyncio.sleep(0.02) return val foo = Foo() inputs = [1, 2, 3] coros = [foo.coro(v) for v in inputs] gather = asyncio.gather(*coros) # Yield to loop to start tasks await asyncio.sleep(0) # wait=True should allow tasks to finish (no cancellation) await foo.coro.cache_close(wait=True) results = await gather assert results == inputs check_lru(foo.coro, hits=0, misses=3, cache=3, tasks=0) assert foo.coro.cache_parameters()["closed"] aio-libs-async-lru-99dfebd/tests/test_exception.py000066400000000000000000000025541515663732100225040ustar00rootroot00000000000000import asyncio import gc import sys from typing import Callable import pytest from async_lru import alru_cache async def test_alru_exception(check_lru: Callable[..., None]) -> None: @alru_cache() async def coro(val: int) -> None: 1 / 0 inputs = [1, 1, 1] coros = [coro(v) for v in inputs] ret = await asyncio.gather(*coros, return_exceptions=True) check_lru(coro, hits=2, misses=1, cache=0, tasks=0) for item in ret: assert isinstance(item, ZeroDivisionError) with pytest.raises(ZeroDivisionError): await coro(1) check_lru(coro, hits=2, misses=2, cache=0, tasks=0) @pytest.mark.xfail( reason="Memory leak is not fixed for PyPy", condition=sys.implementation.name == "pypy", ) async def test_alru_exception_reference_cleanup(check_lru: Callable[..., None]) -> None: class CustomClass: ... @alru_cache() async def coro(val: int) -> None: _ = CustomClass() # object we are verifying not to leak 1 / 0 coros = [coro(v) for v in range(1000)] await asyncio.gather(*coros, return_exceptions=True) check_lru(coro, hits=0, misses=1000, cache=0, tasks=0) await asyncio.sleep(0.00001) gc.collect() assert ( len([obj for obj in gc.get_objects() if isinstance(obj, CustomClass)]) == 0 ), "Only objects in the cache should be left in memory." aio-libs-async-lru-99dfebd/tests/test_internals.py000066400000000000000000000135061515663732100225040ustar00rootroot00000000000000import asyncio import gc import logging from functools import partial from unittest import mock import pytest from async_lru import _CacheItem, _LRUCacheWrapper async def test_done_callback_cancelled() -> None: wrapped = _LRUCacheWrapper(mock.ANY, None, False, None, None) loop = asyncio.get_running_loop() task = loop.create_future() key = 1 task.add_done_callback(partial(wrapped._task_done_callback, key)) task.cancel() await asyncio.sleep(0) assert task not in wrapped._LRUCacheWrapper__tasks # type: ignore[attr-defined] async def test_done_callback_exception() -> None: wrapped = _LRUCacheWrapper(mock.ANY, None, False, None, None) loop = asyncio.get_running_loop() task = loop.create_future() key = 1 task.add_done_callback(partial(wrapped._task_done_callback, key)) exc = ZeroDivisionError() task.set_exception(exc) await asyncio.sleep(0) assert task not in wrapped._LRUCacheWrapper__tasks # type: ignore[attr-defined] async def test_done_callback_exception_logs(caplog: pytest.LogCaptureFixture) -> None: caplog.set_level(logging.ERROR, logger="asyncio") wrapped = _LRUCacheWrapper(mock.ANY, None, False, None, None) loop = asyncio.get_running_loop() async def boom() -> None: await asyncio.sleep(0) raise RuntimeError("boom") key = object() task = loop.create_task(boom()) wrapped._LRUCacheWrapper__cache[key] = _CacheItem(task, None, 1) # type: ignore[attr-defined] task.add_done_callback(partial(wrapped._task_done_callback, key)) while not task.done(): await asyncio.sleep(0) await asyncio.sleep(0) assert key not in wrapped._LRUCacheWrapper__cache # type: ignore[attr-defined] # asyncio disables logging when exception() is called; keep logging enabled. assert task._log_traceback caplog.clear() del task # Remove reference so task get garbage collected. for _ in range(5): # pragma: no branch gc.collect() await asyncio.sleep(0) if "Task exception was never retrieved" in caplog.text: # pragma: no branch break assert "Task exception was never retrieved" in caplog.text assert "RuntimeError: boom" in caplog.text async def test_cache_invalidate_typed() -> None: wrapped = _LRUCacheWrapper(mock.AsyncMock(return_value=1), None, True, None, None) from_cache = wrapped.cache_invalidate(1, a=1) assert not from_cache await wrapped(1, a=1) from_cache = wrapped.cache_invalidate(1, a=1) assert from_cache assert wrapped.cache_info().currsize == 0 from_cache = wrapped.cache_invalidate(1.0, a=1) assert not from_cache assert wrapped.cache_info().currsize == 0 await wrapped(1.0, a=1) assert wrapped.cache_info().currsize == 1 from_cache = wrapped.cache_invalidate(1.0, a=1) assert from_cache async def test_cache_invalidate_not_typed() -> None: wrapped = _LRUCacheWrapper(mock.AsyncMock(return_value=1), None, False, None, None) from_cache = wrapped.cache_invalidate(1, a=1) assert not from_cache await wrapped(1, a=1) assert wrapped.cache_info().currsize == 1 from_cache = wrapped.cache_invalidate(1, a=1) assert from_cache assert wrapped.cache_info().currsize == 0 await wrapped(1, a=1) assert wrapped.cache_info().currsize == 1 from_cache = wrapped.cache_invalidate(1.0, a=1) assert from_cache assert wrapped.cache_info().currsize == 0 async def test_cache_clear() -> None: wrapped = _LRUCacheWrapper(mock.AsyncMock(return_value=1), None, True, None, None) await wrapped(123) assert wrapped.cache_info().hits == 0 assert wrapped.cache_info().misses == 1 assert wrapped.cache_info().currsize == 1 assert wrapped.cache_parameters()["tasks"] == 0 await wrapped(123) assert wrapped.cache_info().hits == 1 assert wrapped.cache_info().misses == 1 assert wrapped.cache_info().currsize == 1 assert wrapped.cache_parameters()["tasks"] == 0 wrapped.cache_clear() assert wrapped.cache_info().hits == 0 assert wrapped.cache_info().misses == 0 assert wrapped.cache_info().currsize == 0 assert wrapped.cache_parameters()["tasks"] == 0 def test_cache_info() -> None: wrapped = _LRUCacheWrapper(mock.ANY, 3, True, None, None) assert (0, 0, 3, 0) == wrapped.cache_info() wrapped._LRUCacheWrapper__cache[1] = 1 # type: ignore[attr-defined] assert (0, 0, 3, 1) == wrapped.cache_info() wrapped._LRUCacheWrapper__hits = 2 # type: ignore[attr-defined] wrapped._LRUCacheWrapper__misses = 3 # type: ignore[attr-defined] wrapped._LRUCacheWrapper__cache[2] = 2 # type: ignore[attr-defined] assert (2, 3, 3, 2) == wrapped.cache_info() async def test_cache_hit() -> None: wrapped = _LRUCacheWrapper(mock.AsyncMock(return_value=1), None, True, None, None) await wrapped(1) assert wrapped.cache_info().hits == 0 assert wrapped.cache_info().misses == 1 await wrapped(1) assert wrapped.cache_info().hits == 1 assert wrapped.cache_info().misses == 1 await wrapped(1) assert wrapped.cache_info().hits == 2 assert wrapped.cache_info().misses == 1 async def test_cache_miss() -> None: wrapped = _LRUCacheWrapper(mock.AsyncMock(return_value=1), None, True, None, None) await wrapped(1) assert wrapped.cache_info().hits == 0 assert wrapped.cache_info().misses == 1 await wrapped(2) assert wrapped.cache_info().hits == 0 assert wrapped.cache_info().misses == 2 await wrapped(3) assert wrapped.cache_info().hits == 0 assert wrapped.cache_info().misses == 3 async def test_forbid_call_closed() -> None: wrapped = _LRUCacheWrapper(mock.AsyncMock(return_value=1), None, True, None, None) wrapped._LRUCacheWrapper__closed = True # type: ignore[attr-defined] with pytest.raises(RuntimeError): await wrapped(123) aio-libs-async-lru-99dfebd/tests/test_partialmethod.py000066400000000000000000000022331515663732100233350ustar00rootroot00000000000000import asyncio from functools import partial, partialmethod from typing import Callable from async_lru import alru_cache async def test_partialmethod_basic(check_lru: Callable[..., None]) -> None: class Obj: async def _coro(self, val: int) -> int: return val coro = alru_cache(partialmethod(_coro, 2)) obj = Obj() coros = [obj.coro() for _ in range(5)] check_lru(obj.coro, hits=0, misses=0, cache=0, tasks=0) ret = await asyncio.gather(*coros) check_lru(obj.coro, hits=4, misses=1, cache=1, tasks=0) assert ret == [2, 2, 2, 2, 2] async def test_partialmethod_partial(check_lru: Callable[..., None]) -> None: class Obj: def __init__(self) -> None: self.coro = alru_cache(partial(self._coro, 2)) async def __coro(self, val1: int, val2: int) -> int: return val1 + val2 _coro = partialmethod(__coro, 1) obj = Obj() coros = [obj.coro() for _ in range(5)] check_lru(obj.coro, hits=0, misses=0, cache=0, tasks=0) ret = await asyncio.gather(*coros) check_lru(obj.coro, hits=4, misses=1, cache=1, tasks=0) assert ret == [3, 3, 3, 3, 3] aio-libs-async-lru-99dfebd/tests/test_readme_examples.py000066400000000000000000000027731515663732100236440ustar00rootroot00000000000000import asyncio import threading from typing import Any from async_lru import alru_cache def test_readme_example_per_thread_caching() -> None: """Test per-thread caching pattern from README to avoid thread-safety issues.""" _local = threading.local() def get_cached_fetcher() -> Any: if not hasattr(_local, "fetcher"): @alru_cache(maxsize=100) async def fetch_data(key: str) -> str: return f"data_{key}" _local.fetcher = fetch_data return _local.fetcher async def worker() -> tuple[str, Any]: fetcher = get_cached_fetcher() # Call again to ensure the pattern handles the "already exists" case fetcher_retry = get_cached_fetcher() assert fetcher is fetcher_retry result = await fetcher("some_key") return result, fetcher.cache_info() def thread_worker(results: list[tuple[str, Any]]) -> None: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: result = loop.run_until_complete(worker()) results.append(result) finally: loop.close() results: list[tuple[str, Any]] = [] threads = [ threading.Thread(target=thread_worker, args=(results,)) for _ in range(3) ] for t in threads: t.start() for t in threads: t.join() assert len(results) == 3 for result, cache_info in results: assert result == "data_some_key" assert cache_info.misses == 1 aio-libs-async-lru-99dfebd/tests/test_size.py000066400000000000000000000042141515663732100214530ustar00rootroot00000000000000import asyncio from typing import Callable from async_lru import alru_cache async def test_alru_cache_removing_lru_keys(check_lru: Callable[..., None]) -> None: @alru_cache(maxsize=3) async def coro(val: int) -> int: return val for i, v in enumerate([3, 4, 5]): await coro(v) check_lru(coro, hits=0, misses=i + 1, cache=i + 1, tasks=0, maxsize=3) check_lru(coro, hits=0, misses=3, cache=3, tasks=0, maxsize=3) assert list(coro._LRUCacheWrapper__cache) == [3, 4, 5] # type: ignore[attr-defined] for v in [3, 2, 1]: await coro(v) check_lru(coro, hits=1, misses=5, cache=3, tasks=0, maxsize=3) assert list(coro._LRUCacheWrapper__cache) == [3, 2, 1] # type: ignore[attr-defined] async def test_alru_cache_removing_lru_keys_with_full_displacement( check_lru: Callable[..., None] ) -> None: @alru_cache(maxsize=3) async def coro(val: int) -> int: return val for i, v in enumerate([3, 4, 5]): await coro(v) check_lru(coro, hits=0, misses=i + 1, cache=i + 1, tasks=0, maxsize=3) check_lru(coro, hits=0, misses=3, cache=3, tasks=0, maxsize=3) assert list(coro._LRUCacheWrapper__cache) == [3, 4, 5] # type: ignore[attr-defined] for v in [1, 2, 3]: await coro(v) check_lru(coro, hits=0, misses=6, cache=3, tasks=0, maxsize=3) assert list(coro._LRUCacheWrapper__cache) == [1, 2, 3] # type: ignore[attr-defined] async def test_alru_cache_none_max_size(check_lru: Callable[..., None]) -> None: @alru_cache(maxsize=None) async def coro(val: int) -> int: return val inputs = [1, 2, 3, 4] * 2 coros = [coro(v) for v in inputs] ret = await asyncio.gather(*coros) check_lru(coro, hits=4, misses=4, cache=4, tasks=0, maxsize=None) assert ret == inputs async def test_alru_cache_zero_max_size(check_lru: Callable[..., None]) -> None: @alru_cache(maxsize=0) async def coro(val: int) -> int: return val inputs = [1, 2, 3, 4] * 2 coros = [coro(v) for v in inputs] ret = await asyncio.gather(*coros) check_lru(coro, hits=0, misses=8, cache=0, tasks=0, maxsize=0) assert ret == inputs aio-libs-async-lru-99dfebd/tests/test_thread_safety.py000066400000000000000000000133111515663732100233210ustar00rootroot00000000000000import asyncio import warnings import pytest from async_lru import AlruCacheLoopResetWarning, alru_cache @pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning") def test_cross_loop_auto_resets_cache() -> None: @alru_cache(maxsize=100) async def cached_func(key: str) -> str: return f"data_{key}" loop1 = asyncio.new_event_loop() loop1.run_until_complete(cached_func("test")) loop1.close() assert cached_func.cache_info().currsize == 1 loop2 = asyncio.new_event_loop() result = loop2.run_until_complete(cached_func("test")) loop2.close() assert result == "data_test" # Cache was cleared on loop change, so the old entry is gone. # The new call re-populated it as a miss. assert cached_func.cache_info().hits == 0 assert cached_func.cache_info().misses == 1 @pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning") def test_cross_loop_preserves_stats_reset() -> None: @alru_cache(maxsize=100) async def cached_func(key: str) -> str: return f"data_{key}" loop1 = asyncio.new_event_loop() loop1.run_until_complete(cached_func("a")) loop1.run_until_complete(cached_func("a")) loop1.close() assert cached_func.cache_info().hits == 1 assert cached_func.cache_info().misses == 1 loop2 = asyncio.new_event_loop() loop2.run_until_complete(cached_func("a")) loop2.close() # Stats were reset on loop change (cache_clear resets hits/misses) assert cached_func.cache_info().hits == 0 assert cached_func.cache_info().misses == 1 @pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning") def test_invalid_key_does_not_bind_loop() -> None: @alru_cache(maxsize=100) async def cached_func(key: object) -> str: return f"data_{key}" loop1 = asyncio.new_event_loop() error_raised = False try: loop1.run_until_complete(cached_func([])) except TypeError: error_raised = True finally: loop1.close() assert error_raised, "TypeError should be raised for unhashable key" loop2 = asyncio.new_event_loop() try: result = loop2.run_until_complete(cached_func("ok")) finally: loop2.close() assert result == "data_ok" def test_same_loop_access_works() -> None: @alru_cache(maxsize=100) async def cached_func(key: str) -> str: return f"data_{key}" async def run_test() -> list[str]: results = [] results.append(await cached_func("a")) results.append(await cached_func("b")) results.append(await cached_func("a")) return results loop = asyncio.new_event_loop() results = loop.run_until_complete(run_test()) loop.close() assert results == ["data_a", "data_b", "data_a"] assert cached_func.cache_info().hits == 1 def test_cross_loop_cache_close_works() -> None: @alru_cache(maxsize=100) async def cached_func(key: str) -> str: return f"data_{key}" loop1 = asyncio.new_event_loop() loop1.run_until_complete(cached_func("test")) loop1.close() loop2 = asyncio.new_event_loop() loop2.run_until_complete(cached_func.cache_close()) loop2.close() def test_sync_methods_work_without_loop_check() -> None: @alru_cache(maxsize=100) async def cached_func(key: str) -> str: return f"data_{key}" loop1 = asyncio.new_event_loop() loop1.run_until_complete(cached_func("test")) loop1.close() cached_func.cache_invalidate("test") assert cached_func.cache_info().currsize == 0 cached_func.cache_clear() assert cached_func.cache_info().currsize == 0 def test_concurrent_same_loop_works() -> None: @alru_cache(maxsize=100) async def cached_func(key: str) -> str: await asyncio.sleep(0.01) return f"data_{key}" async def run_concurrent() -> list[str]: tasks = [cached_func("test") for _ in range(3)] return await asyncio.gather(*tasks) loop = asyncio.new_event_loop() results = loop.run_until_complete(run_concurrent()) loop.close() assert results == ["data_test"] * 3 assert cached_func.cache_info().hits == 2 @pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning") def test_multiple_loop_transitions() -> None: @alru_cache(maxsize=100) async def cached_func(key: str) -> str: return f"data_{key}" for i in range(5): loop = asyncio.new_event_loop() result = loop.run_until_complete(cached_func("test")) loop.close() assert result == "data_test" def test_loop_change_emits_warning() -> None: @alru_cache(maxsize=100) async def cached_func(key: str) -> str: return f"data_{key}" loop1 = asyncio.new_event_loop() loop1.run_until_complete(cached_func("test")) loop1.close() loop2 = asyncio.new_event_loop() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") loop2.run_until_complete(cached_func("test")) loop2.close() assert len(w) == 1 assert issubclass(w[0].category, AlruCacheLoopResetWarning) assert "event loop change" in str(w[0].message) def test_loop_change_warns_only_once() -> None: @alru_cache(maxsize=100) async def cached_func(key: str) -> str: return f"data_{key}" all_warnings: list[warnings.WarningMessage] = [] for _ in range(4): loop = asyncio.new_event_loop() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") loop.run_until_complete(cached_func("test")) loop.close() all_warnings.extend(w) reset_warnings = [ w for w in all_warnings if issubclass(w.category, AlruCacheLoopResetWarning) ] assert len(reset_warnings) == 1 aio-libs-async-lru-99dfebd/tests/test_ttl.py000066400000000000000000000071071515663732100213100ustar00rootroot00000000000000import asyncio from typing import Callable from unittest import mock import pytest from async_lru import alru_cache async def test_ttl_infinite_cache(check_lru: Callable[..., None]) -> None: @alru_cache(maxsize=None, ttl=0.1) async def coro(val: int) -> int: return val assert await coro(1) == 1 check_lru(coro, hits=0, misses=1, cache=1, tasks=0, maxsize=None) await asyncio.sleep(0.0) assert await coro(1) == 1 check_lru(coro, hits=1, misses=1, cache=1, tasks=0, maxsize=None) await asyncio.sleep(0.2) # cache is clear after ttl expires check_lru(coro, hits=1, misses=1, cache=0, tasks=0, maxsize=None) assert await coro(1) == 1 check_lru(coro, hits=1, misses=2, cache=1, tasks=0, maxsize=None) async def test_ttl_limited_cache(check_lru: Callable[..., None]) -> None: @alru_cache(maxsize=1, ttl=0.1) async def coro(val: int) -> int: return val assert await coro(1) == 1 check_lru(coro, hits=0, misses=1, cache=1, tasks=0, maxsize=1) assert await coro(2) == 2 check_lru(coro, hits=0, misses=2, cache=1, tasks=0, maxsize=1) await asyncio.sleep(0) assert await coro(2) == 2 check_lru(coro, hits=1, misses=2, cache=1, tasks=0, maxsize=1) assert await coro(1) == 1 check_lru(coro, hits=1, misses=3, cache=1, tasks=0, maxsize=1) async def test_ttl_with_explicit_invalidation(check_lru: Callable[..., None]) -> None: @alru_cache(maxsize=None, ttl=0.2) async def coro(val: int) -> int: return val assert await coro(1) == 1 check_lru(coro, hits=0, misses=1, cache=1, tasks=0, maxsize=None) coro.cache_invalidate(1) check_lru(coro, hits=0, misses=1, cache=0, tasks=0, maxsize=None) await asyncio.sleep(0.1) assert await coro(1) == 1 check_lru(coro, hits=0, misses=2, cache=1, tasks=0, maxsize=None) await asyncio.sleep(0.1) # cache is not cleared after ttl expires because invalidate also should clear # the invalidation by timeout check_lru(coro, hits=0, misses=2, cache=1, tasks=0, maxsize=None) async def test_ttl_concurrent() -> None: @alru_cache(maxsize=1, ttl=1) async def coro(val: int) -> int: return val results = await asyncio.gather(*(coro(i) for i in range(2))) assert results == list(range(2)) async def test_ttl_with_jitter_basic(check_lru: Callable[..., None]) -> None: with mock.patch("async_lru.random.uniform", return_value=0.1): @alru_cache(maxsize=None, ttl=0.1, jitter=0.2) async def coro(val: int) -> int: return val assert await coro(1) == 1 check_lru(coro, hits=0, misses=1, cache=1, tasks=0, maxsize=None) await asyncio.sleep(0.15) check_lru(coro, hits=0, misses=1, cache=1, tasks=0, maxsize=None) await asyncio.sleep(0.1) check_lru(coro, hits=0, misses=1, cache=0, tasks=0, maxsize=None) async def test_ttl_with_jitter_zero(check_lru: Callable[..., None]) -> None: @alru_cache(maxsize=None, ttl=0.1, jitter=0) async def coro(val: int) -> int: return val assert await coro(1) == 1 check_lru(coro, hits=0, misses=1, cache=1, tasks=0, maxsize=None) await asyncio.sleep(0.15) check_lru(coro, hits=0, misses=1, cache=0, tasks=0, maxsize=None) async def test_jitter_without_ttl_raises_error() -> None: with pytest.raises(ValueError, match="jitter requires ttl to be set"): alru_cache(maxsize=None, jitter=1.0) async def test_jitter_negative_raises_error() -> None: with pytest.raises(ValueError, match="jitter must be non-negative"): alru_cache(maxsize=None, ttl=1.0, jitter=-0.5) aio-libs-async-lru-99dfebd/tox.ini000066400000000000000000000004561515663732100172450ustar00rootroot00000000000000[tox] envlist = py3{8,9,10,11} skip_missing_interpreters = True [testenv] deps = -r{toxinidir}/requirements.txt commands = flake8 --show-source async_lru isort --check-only async_lru --diff flake8 --show-source tests isort --check-only -rc tests --diff {envpython} -m pytest