pax_global_header00006660000000000000000000000064151330066170014514gustar00rootroot0000000000000052 comment=b4e713b6e71cd4bc36fe0aa980951a1d9a1d2584 async-lru-2.1.0/000077500000000000000000000000001513300661700134315ustar00rootroot00000000000000async-lru-2.1.0/.github/000077500000000000000000000000001513300661700147715ustar00rootroot00000000000000async-lru-2.1.0/.github/dependabot.yml000066400000000000000000000003121513300661700176150ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: daily - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" async-lru-2.1.0/.github/workflows/000077500000000000000000000000001513300661700170265ustar00rootroot00000000000000async-lru-2.1.0/.github/workflows/auto-merge.yaml000066400000000000000000000011401513300661700217530ustar00rootroot00000000000000name: 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.4.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}} async-lru-2.1.0/.github/workflows/ci-cd.yml000066400000000000000000000103521513300661700205310ustar00rootroot00000000000000name: 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/** async-lru-2.1.0/.github/workflows/codeql.yml000066400000000000000000000044261513300661700210260ustar00rootroot00000000000000name: "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}}" async-lru-2.1.0/.github/workflows/codspeed.yml000066400000000000000000000017101513300661700213360ustar00rootroot00000000000000name: 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 }} async-lru-2.1.0/.gitignore000066400000000000000000000013351513300661700154230ustar00rootroot00000000000000># 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 async-lru-2.1.0/.pre-commit-config.yaml000066400000000000000000000027431513300661700177200ustar00rootroot00000000000000repos: - 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$ async-lru-2.1.0/CHANGES.rst000066400000000000000000000012051513300661700152310ustar00rootroot00000000000000======= CHANGES ======= .. towncrier release notes start 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+. async-lru-2.1.0/LICENSE000066400000000000000000000023121513300661700144340ustar00rootroot00000000000000The 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. async-lru-2.1.0/MANIFEST.in000066400000000000000000000002151513300661700151650ustar00rootroot00000000000000include README.rst include LICENSE include Makefile graft async_lru graft tests recursive-exclude * __pycache__ recursive-exclude * *.py[co] async-lru-2.1.0/Makefile000066400000000000000000000004361513300661700150740ustar00rootroot00000000000000# 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/ async-lru-2.1.0/README.rst000066400000000000000000000076511513300661700151310ustar00rootroot00000000000000async-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 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. 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. async-lru-2.1.0/async_lru/000077500000000000000000000000001513300661700154305ustar00rootroot00000000000000async-lru-2.1.0/async_lru/__init__.py000066400000000000000000000253721513300661700175520ustar00rootroot00000000000000import asyncio import dataclasses import inspect import sys 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.1.0" __all__ = ("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]]"] @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], ) -> 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.__cache: OrderedDict[Hashable, _CacheItem[_R]] = OrderedDict() self.__closed = False self.__hits = 0 self.__misses = 0 @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 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: loop = asyncio.get_running_loop() cache_item.later_call = loop.call_later( self.__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() 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_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, *, cancel: bool = False, return_exceptions: bool = True ) -> None: await self.__wrapper.cache_close() 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, ) -> Callable[[_CBP[_R]], _LRUCacheWrapper[_R]]: 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}") # functools.partialmethod support if hasattr(fn, "_make_unbound_method"): fn = fn._make_unbound_method() wrapper = _LRUCacheWrapper(cast(_CB[_R], fn), maxsize, typed, ttl) 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, ) -> 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, ) -> Union[Callable[[_CBP[_R]], _LRUCacheWrapper[_R]], _LRUCacheWrapper[_R]]: if maxsize is None or isinstance(maxsize, int): return _make_wrapper(maxsize, typed, ttl) else: fn = cast(_CB[_R], maxsize) if callable(fn) or hasattr(fn, "_make_unbound_method"): return _make_wrapper(128, False, None)(fn) raise NotImplementedError(f"{fn!r} decorating is not supported") async-lru-2.1.0/async_lru/py.typed000066400000000000000000000000001513300661700171150ustar00rootroot00000000000000async-lru-2.1.0/benchmark.py000066400000000000000000000175671513300661700157550ustar00rootroot00000000000000import 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(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) yield loop loop.close() @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) @alru_cache(maxsize=128) async def cached_func(x): return x @alru_cache(maxsize=16, ttl=0.01) async def cached_func_ttl(x): return x # Unbounded cache (no maxsize) @alru_cache() async def cached_func_unbounded(x): return x @alru_cache(ttl=0.01) async def cached_func_unbounded_ttl(x): return x class Methods: @alru_cache(maxsize=128) async def cached_meth(self, x): return x @alru_cache(maxsize=16, ttl=0.01) async def cached_meth_ttl(self, x): return x @alru_cache() async def cached_meth_unbounded(self, x): return x @alru_cache(ttl=0.01) async def cached_meth_unbounded_ttl(self, x): return x async def uncached_func(x): return x funcs_no_ttl = [ cached_func, cached_func_unbounded, Methods.cached_meth, Methods.cached_meth_unbounded, ] no_ttl_ids = [ "func-bounded", "func-unbounded", "meth-bounded", "meth-unbounded", ] funcs_ttl = [ cached_func_ttl, cached_func_unbounded_ttl, Methods.cached_meth_ttl, Methods.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("func", all_funcs, ids=all_ids) def test_cache_hit_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], func: _LRUCacheWrapper[Any], ) -> None: # Populate cache 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("func", all_funcs, ids=all_ids) def test_cache_miss_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], func: _LRUCacheWrapper[Any], ) -> None: unique_objects = [object() for _ in range(128)] func.cache_clear() async def run() -> None: for obj in unique_objects: await func(obj) benchmark(run_loop, run) @pytest.mark.parametrize("func", all_funcs, ids=all_ids) def test_cache_clear_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], func: _LRUCacheWrapper[Any], ) -> None: for i in range(100): run_loop(func, i) benchmark(func.cache_clear) @pytest.mark.parametrize("func_ttl", funcs_ttl, ids=ttl_ids) def test_cache_ttl_expiry_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], func_ttl: _LRUCacheWrapper[Any], ) -> None: run_loop(func_ttl, 99) run_loop(asyncio.sleep, 0.02) benchmark(run_loop, func_ttl, 99) @pytest.mark.parametrize("func", all_funcs, ids=all_ids) def test_cache_invalidate_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], func: _LRUCacheWrapper[Any], ) -> None: # Populate cache 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("func", all_funcs, ids=all_ids) def test_cache_info_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], func: _LRUCacheWrapper[Any], ) -> None: # Populate cache 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("func", all_funcs, ids=all_ids) def test_concurrent_cache_hit_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], func: _LRUCacheWrapper[Any], ) -> None: # Populate cache keys = list(range(600, 700)) for key in keys: run_loop(func, key) async def gather_coros(): gather = asyncio.gather for _ in range(10): return await gather(*map(func, keys)) benchmark(run_loop, gather_coros) def test_cache_fill_eviction_benchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any] ) -> None: # Populate cache for i in range(-128, 0): run_loop(cached_func, i) keys = list(range(5000)) async def fill(): for k in keys: await cached_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 = all_funcs[:2] func_ids_no_ttl = all_ids[:2] @pytest.mark.parametrize("func", only_funcs_no_ttl, ids=func_ids_no_ttl) def test_internal_cache_hit_microbenchmark( benchmark: BenchmarkFixture, run_loop: Callable[..., Any], func: _LRUCacheWrapper[Any], ) -> None: """Directly benchmark _cache_hit (internal, sync) using parameterized funcs.""" cache_hit = func._cache_hit # Populate cache 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("func", only_funcs_no_ttl, ids=func_ids_no_ttl) def test_internal_cache_miss_microbenchmark( benchmark: BenchmarkFixture, func: _LRUCacheWrapper[Any] ) -> None: """Directly benchmark _cache_miss (internal, sync) using parameterized funcs.""" cache_miss = func._cache_miss @benchmark def run() -> None: for i in range(128): cache_miss(i) @pytest.mark.parametrize("func", 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, func: _LRUCacheWrapper[Any], task_state: str, ) -> None: """Directly benchmark _task_done_callback (internal, sync) using parameterized funcs and task states.""" # Create a dummy coroutine and task 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) async-lru-2.1.0/requirements-benchmarks.txt000066400000000000000000000000661513300661700210320ustar00rootroot00000000000000-e . -r requirements-test.txt pytest-codspeed==4.2.0 async-lru-2.1.0/requirements-dev.txt000066400000000000000000000002651513300661700174740ustar00rootroot00000000000000-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" async-lru-2.1.0/requirements-test.txt000066400000000000000000000000721513300661700176710ustar00rootroot00000000000000pytest==9.0.2 pytest-asyncio==1.3.0 pytest-timeout==2.4.0 async-lru-2.1.0/requirements.txt000066400000000000000000000001021513300661700167060ustar00rootroot00000000000000-e . -r requirements-test.txt coverage==7.13.1 pytest-cov==7.0.0 async-lru-2.1.0/setup.cfg000066400000000000000000000043241513300661700152550ustar00rootroot00000000000000[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 async-lru-2.1.0/setup.py000066400000000000000000000000471513300661700151440ustar00rootroot00000000000000from setuptools import setup setup() async-lru-2.1.0/tests/000077500000000000000000000000001513300661700145735ustar00rootroot00000000000000async-lru-2.1.0/tests/conftest.py000066400000000000000000000011551513300661700167740ustar00rootroot00000000000000from 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 async-lru-2.1.0/tests/test_basic.py000066400000000000000000000132371513300661700172730ustar00rootroot00000000000000import 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) async-lru-2.1.0/tests/test_cache_clear.py000066400000000000000000000027431513300661700204230ustar00rootroot00000000000000import 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) async-lru-2.1.0/tests/test_cache_info.py000066400000000000000000000017471513300661700202730ustar00rootroot00000000000000import 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) async-lru-2.1.0/tests/test_cache_invalidate.py000066400000000000000000000047701513300661700214570ustar00rootroot00000000000000import 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) async-lru-2.1.0/tests/test_cancel.py000066400000000000000000000032111513300661700174260ustar00rootroot00000000000000import 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 async-lru-2.1.0/tests/test_close.py000066400000000000000000000016661513300661700173220ustar00rootroot00000000000000import 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-lru-2.1.0/tests/test_exception.py000066400000000000000000000025541513300661700202100ustar00rootroot00000000000000import 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." async-lru-2.1.0/tests/test_internals.py000066400000000000000000000134121513300661700202040ustar00rootroot00000000000000import 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) wrapped._LRUCacheWrapper__closed = True # type: ignore[attr-defined] with pytest.raises(RuntimeError): await wrapped(123) async-lru-2.1.0/tests/test_partialmethod.py000066400000000000000000000022331513300661700210410ustar00rootroot00000000000000import 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] async-lru-2.1.0/tests/test_size.py000066400000000000000000000042141513300661700171570ustar00rootroot00000000000000import 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 async-lru-2.1.0/tests/test_ttl.py000066400000000000000000000043361513300661700170150ustar00rootroot00000000000000import asyncio from typing import Callable 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-lru-2.1.0/tox.ini000066400000000000000000000004561513300661700147510ustar00rootroot00000000000000[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