├── .gitignore ├── .pylintrc ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── caches ├── __init__.py ├── backends │ ├── __init__.py │ ├── base.py │ ├── dummy.py │ ├── locmem.py │ └── redis.py ├── core.py ├── importer.py ├── py.typed └── types.py ├── docs ├── api.md ├── backends.md ├── custom.css └── index.md ├── mkdocs.yml ├── requirements-dev.in ├── requirements-dev.txt ├── setup.py └── tests ├── __init__.py ├── dummy ├── __init__.py ├── conftest.py ├── test_impl.py └── test_initialization.py ├── locmem ├── __init__.py ├── conftest.py ├── test_impl.py └── test_initialization.py ├── modulewithexception.py ├── redis ├── __init__.py ├── conftest.py ├── test_impl.py └── test_initialization.py ├── test_cache.py ├── test_cache_url.py └── test_importer.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # VSCode project settings 101 | .vscode 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | # System 110 | .DS_Store -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=snapshots 3 | load-plugins=pylint.extensions.bad_builtin, pylint.extensions.mccabe 4 | 5 | [MESSAGES CONTROL] 6 | disable=C0103, C0111, C0330, C0412, C1001, E1004, I0011, R0101, R0201, R0401, R0801, R0902, R0903, R0912, R0913, R0914, R0915, R1260, W0231, W0232, W0621, W0703 7 | 8 | [SIMILARITIES] 9 | ignore-imports=yes -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - 3.6 5 | - 3.7 6 | services: 7 | - redis-server 8 | install: 9 | - pip install -e . 10 | - pip install -r requirements-dev.txt 11 | script: 12 | - pytest --cov=caches --cov=tests 13 | after_success: 14 | - codecov 15 | matrix: 16 | include: 17 | - python: 3.7 18 | script: 19 | - pylint caches tests setup.py 20 | - mypy caches --ignore-missing-imports 21 | - black --check . -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.4 (28.3.2021) 4 | 5 | - Updated backends - Serialization/Deserialization of values moved to one point 6 | - Updated backends - Implementation of PEP 3119 7 | - Updated backends - Fix cache.get_or_set called with coroutine 8 | - Updated core - Add caching coroutine easily by `__call__()` 9 | 10 | ## 0.3 (15.08.2019) 11 | 12 | - Renamed `timeout` to `ttl`. 13 | 14 | 15 | ## 0.2 (20.07.2019) 16 | 17 | - Added [documentation](https://rafalp.github.io/async-caches/). 18 | - Added code of conduct. 19 | - Updated `cache.add` to return `True` if key was created in cache and `False` it it didn't. 20 | - Updated `cache.get_or_set` to support callable for `default` value. 21 | - Added value check for `timeout` that will raise `ValueError` if its set to `0`. Use `None` instead or don't explicitly pass the value to the argument. 22 | 23 | 24 | ## 0.1 (14.07.2019) 25 | 26 | - Initial release of the library 🎉 -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Rafał Pitoń 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | 2 | include README.md 3 | include LICENSE 4 | graft caches 5 | 6 | global-exclude __pycache__ 7 | global-exclude *.py[co] 8 | global-exclude .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Async Caches 2 | 3 | [![Build Status](https://travis-ci.org/rafalp/async-caches.svg?branch=master)](https://travis-ci.org/rafalp/async-caches) 4 | [![Codecov](https://codecov.io/gh/rafalp/async-caches/branch/master/graph/badge.svg)](https://codecov.io/gh/rafalp/async-caches) 5 | [![PyPI](https://img.shields.io/badge/release-0.3-green.svg)](https://pypi.org/project/async-caches/) 6 | [![Documentation](https://img.shields.io/badge/documentation-github.io-blue.svg)](https://rafalp.github.io/async-caches/) 7 | 8 | Caching library reimplementing [`django.core.cache` API](https://docs.djangoproject.com/en/2.2/topics/cache/#the-low-level-cache-api) with async support and type hints, inspired by [`encode/databases`](https://github.com/encode/databases). 9 | 10 | Currently three cache backends are available: 11 | 12 | * `dummy` - Dummy cache backend that doesn't cache anything. Used to disable caching in tests! 13 | * `locmem` - Cache backend that stores data in local memory. Lets you develop and test caching without need for actual cache server. 14 | * `redis` - Redis cache intended for use in actual deployments. 15 | 16 | **Requirements:** Python 3.6+ 17 | **Documentation:** https://rafalp.github.io/async-caches/ 18 | 19 | 20 | ## Installation 21 | 22 | ```console 23 | $ pip install async-caches 24 | ``` 25 | 26 | 27 | ## Contributing 28 | 29 | Contributions are welcome! 30 | 31 | If you've found a bug, got idea, question or just want to share the love, open [GitHub issue](https://github.com/rafalp/async-caches/issues) or pull request! 32 | 33 | 34 | ## Credits and license 35 | 36 | This is free software and you are welcome to modify and redistribute it under the conditions described in the license. For the complete license, refer to the LICENSE file. 37 | 38 | Parts of software come from [databases](https://github.com/encode/databases/issues) package developed by Tom Christie and contributors and from [Django](https://github.com/django/django) package developed by Django project maintainers and contributors. -------------------------------------------------------------------------------- /caches/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Cache, CacheURL 2 | 3 | 4 | __all__ = ["Cache", "CacheURL"] 5 | -------------------------------------------------------------------------------- /caches/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafalp/async-caches/0c83df55db3c152a1aa117b28b8dd2b2c802cfb3/caches/backends/__init__.py -------------------------------------------------------------------------------- /caches/backends/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | from abc import ABCMeta, abstractmethod 3 | from typing import Any, Awaitable, Dict, Iterable, Mapping, Optional, Union 4 | 5 | from ..core import CacheURL 6 | from ..types import Serializable 7 | 8 | 9 | class BaseBackend(metaclass=ABCMeta): 10 | def __init__(self, cache_url: Union[CacheURL, str], **options: Any): 11 | self._cache_url = CacheURL(cache_url) 12 | self._options = options 13 | 14 | @abstractmethod 15 | async def connect(self): 16 | raise NotImplementedError() 17 | 18 | @abstractmethod 19 | async def disconnect(self): 20 | raise NotImplementedError() 21 | 22 | @abstractmethod 23 | async def get(self, key: str, default: Any) -> Any: 24 | raise NotImplementedError() 25 | 26 | @abstractmethod 27 | async def set(self, key: str, value: Serializable, *, ttl: Optional[int]) -> Any: 28 | raise NotImplementedError() 29 | 30 | @abstractmethod 31 | async def add(self, key: str, value: Serializable, *, ttl: Optional[int]) -> bool: 32 | raise NotImplementedError() 33 | 34 | @abstractmethod 35 | async def get_or_set( 36 | self, key: str, default: Union[Awaitable[Serializable], Serializable], *, ttl: Optional[int] 37 | ) -> Any: 38 | raise NotImplementedError() 39 | 40 | @abstractmethod 41 | async def get_many(self, keys: Iterable[str]) -> Dict[str, Any]: 42 | raise NotImplementedError() 43 | 44 | @abstractmethod 45 | async def set_many( 46 | self, mapping: Mapping[str, Serializable], *, ttl: Optional[int] 47 | ): 48 | raise NotImplementedError() 49 | 50 | @abstractmethod 51 | async def delete(self, key: str): 52 | raise NotImplementedError() 53 | 54 | @abstractmethod 55 | async def delete_many(self, keys: Iterable[str]): 56 | raise NotImplementedError() 57 | 58 | @abstractmethod 59 | async def clear(self): 60 | raise NotImplementedError() 61 | 62 | @abstractmethod 63 | async def touch(self, key: str, ttl: Optional[int]) -> bool: 64 | raise NotImplementedError() 65 | 66 | @abstractmethod 67 | async def incr(self, key: str, delta: Union[float, int]) -> Union[float, int]: 68 | raise NotImplementedError() 69 | 70 | @abstractmethod 71 | async def decr(self, key: str, delta: Union[float, int]) -> Union[float, int]: 72 | raise NotImplementedError() 73 | 74 | @staticmethod 75 | def _serialize(value: Any) -> str: 76 | """Serializes value to string. 77 | 78 | Args: 79 | value (Any): Whatever to serialize. 80 | 81 | Returns: 82 | str: Serialized value to string. 83 | """ 84 | return json.dumps(value) 85 | 86 | @staticmethod 87 | def _deserialize(value: str) -> Any: 88 | """Deserializes value to original data structure. 89 | 90 | Args: 91 | value (str): Serialized value 92 | 93 | Returns: 94 | Any: Original data 95 | """ 96 | return json.loads(value) 97 | -------------------------------------------------------------------------------- /caches/backends/dummy.py: -------------------------------------------------------------------------------- 1 | from inspect import isawaitable 2 | from typing import Any, Awaitable, Dict, Iterable, Mapping, Optional, Union 3 | 4 | from ..types import Serializable 5 | from .base import BaseBackend 6 | 7 | 8 | class DummyBackend(BaseBackend): 9 | async def connect(self): 10 | pass 11 | 12 | async def disconnect(self): 13 | pass 14 | 15 | async def get(self, key: str, default: Any) -> Any: 16 | return default 17 | 18 | async def set( 19 | self, 20 | key: str, 21 | value: Serializable, 22 | *, 23 | ttl: Optional[int], # pylint: disable=unused-argument 24 | ) -> Any: 25 | self._serialize(value) 26 | 27 | async def add( 28 | self, 29 | key: str, 30 | value: Serializable, 31 | *, 32 | ttl: Optional[int], # pylint: disable=unused-argument 33 | ) -> bool: 34 | self._serialize(value) 35 | return False 36 | 37 | async def get_or_set( 38 | self, 39 | key: str, 40 | default: Union[Awaitable[Serializable], Serializable], 41 | *, 42 | ttl: Optional[int], # pylint: disable=unused-argument 43 | ) -> Any: 44 | if callable(default): 45 | default = default() 46 | if isawaitable(default): 47 | default = await default 48 | self._serialize(default) 49 | return default 50 | 51 | async def get_many(self, keys: Iterable[str]) -> Dict[str, Any]: 52 | return {key: None for key in keys} 53 | 54 | async def set_many( 55 | self, 56 | mapping: Mapping[str, Serializable], 57 | *, 58 | ttl: Optional[int], # pylint: disable=unused-argument 59 | ): 60 | for value in mapping.values(): 61 | self._serialize(value) 62 | 63 | async def delete(self, key: str): 64 | pass 65 | 66 | async def delete_many(self, keys: Iterable[str]): 67 | pass 68 | 69 | async def clear(self): 70 | pass 71 | 72 | async def touch(self, key: str, ttl: Optional[int]) -> bool: 73 | return False 74 | 75 | async def incr(self, key: str, delta: Union[float, int]) -> Union[float, int]: 76 | raise ValueError(f"'{key}' is not set in the cache") 77 | 78 | async def decr(self, key: str, delta: Union[float, int]) -> Union[float, int]: 79 | raise ValueError(f"'{key}' is not set in the cache") 80 | -------------------------------------------------------------------------------- /caches/backends/locmem.py: -------------------------------------------------------------------------------- 1 | from inspect import isawaitable 2 | from time import time 3 | from typing import Any, Awaitable, Dict, Iterable, Mapping, Optional, Tuple, Union 4 | 5 | from ..types import Serializable 6 | from .base import BaseBackend 7 | 8 | 9 | class LocMemBackend(BaseBackend): 10 | _caches: Dict[str, Dict[str, Tuple[Any, Optional[int]]]] = {} 11 | 12 | async def connect(self): 13 | # pylint: disable=attribute-defined-outside-init 14 | self._id = self._cache_url.netloc or "_" 15 | self._caches[self._id] = {} 16 | return True 17 | 18 | async def disconnect(self): 19 | self._caches.pop(self._id) 20 | return True 21 | 22 | async def get(self, key: str, default: Any) -> Any: 23 | if key not in self._caches[self._id]: 24 | return default 25 | 26 | value, ttl = self._caches[self._id][key] 27 | if ttl and ttl < time(): 28 | return default 29 | 30 | return self._deserialize(value) 31 | 32 | async def set(self, key: str, value: Serializable, *, ttl: Optional[int]) -> Any: 33 | if ttl is not None: 34 | ttl += int(time()) 35 | self._caches[self._id][key] = self._serialize(value), ttl 36 | 37 | async def add(self, key: str, value: Serializable, *, ttl: Optional[int]) -> bool: 38 | if key not in self._caches[self._id]: 39 | await self.set(key, value, ttl=ttl) 40 | return True 41 | return False 42 | 43 | async def get_or_set( 44 | self, key: str, default: Union[Awaitable[Serializable], Serializable], *, ttl: Optional[int] 45 | ) -> Any: 46 | value = await self.get(key, None) 47 | if value is None: 48 | if callable(default): 49 | default = default() 50 | if isawaitable(default): 51 | default = await default 52 | await self.set(key, default, ttl=ttl) 53 | return default 54 | return value 55 | 56 | async def get_many(self, keys: Iterable[str]) -> Dict[str, Any]: 57 | return {key: await self.get(key, None) for key in keys} 58 | 59 | async def set_many( 60 | self, mapping: Mapping[str, Serializable], *, ttl: Optional[int] 61 | ): 62 | for k, v in mapping.items(): 63 | await self.set(k, v, ttl=ttl) 64 | 65 | async def delete(self, key: str): 66 | self._caches[self._id].pop(key, None) 67 | 68 | async def delete_many(self, keys: Iterable[str]): 69 | for key in keys: 70 | self._caches[self._id].pop(key, None) 71 | 72 | async def clear(self): 73 | self._caches[self._id] = {} 74 | 75 | async def touch(self, key: str, ttl: Optional[int]) -> bool: 76 | if key not in self._caches[self._id]: 77 | return False 78 | if ttl is not None: 79 | ttl += int(time()) 80 | 81 | value, _ = self._caches[self._id][key] 82 | self._caches[self._id][key] = value, ttl 83 | return True 84 | 85 | async def incr(self, key: str, delta: Union[float, int]) -> Union[float, int]: 86 | if key not in self._caches[self._id]: 87 | raise ValueError(f"'{key}' is not set in the cache") 88 | if not isinstance(delta, (float, int)): 89 | raise ValueError(f"incr value must be int or float") 90 | 91 | value, ttl = self._caches[self._id][key] 92 | value = self._deserialize(value) + delta 93 | self._caches[self._id][key] = self._serialize(value), ttl 94 | return value 95 | 96 | async def decr(self, key: str, delta: Union[float, int]) -> Union[float, int]: 97 | if key not in self._caches[self._id]: 98 | raise ValueError(f"'{key}' is not set in the cache") 99 | if not isinstance(delta, (float, int)): 100 | raise ValueError(f"decr value must be int or float") 101 | 102 | value, ttl = self._caches[self._id][key] 103 | value = self._deserialize(value) - delta 104 | self._caches[self._id][key] = self._serialize(value), ttl 105 | return value 106 | -------------------------------------------------------------------------------- /caches/backends/redis.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from inspect import isawaitable 3 | from typing import Any, Awaitable, Dict, Iterable, Mapping, Optional, Union 4 | 5 | import aioredis 6 | 7 | from ..types import Serializable 8 | from ..core import CacheURL 9 | from .base import BaseBackend 10 | 11 | 12 | class RedisBackend(BaseBackend): 13 | _pool: aioredis.RedisConnection 14 | 15 | def __init__(self, cache_url: Union[CacheURL, str], **options: Any) -> None: 16 | self._cache_url = CacheURL(cache_url) 17 | self._options = options 18 | self._pool = None 19 | 20 | def _get_connection_kwargs(self) -> dict: 21 | url_options = self._cache_url.options 22 | 23 | kwargs = {} 24 | minsize = url_options.get("minsize") 25 | maxsize = url_options.get("maxsize") 26 | 27 | if minsize is not None: 28 | kwargs["minsize"] = int(minsize) 29 | if maxsize is not None: 30 | kwargs["maxsize"] = int(maxsize) 31 | 32 | if self._options.get("minsize") is not None: 33 | kwargs["minsize"] = int(self._options["minsize"]) 34 | if self._options.get("maxsize") is not None: 35 | kwargs["maxsize"] = int(self._options["maxsize"]) 36 | 37 | return kwargs 38 | 39 | async def connect(self): 40 | # pylint: disable=attribute-defined-outside-init 41 | assert self._pool is None, "Cache backend is already running" 42 | kwargs = self._get_connection_kwargs() 43 | self._pool = await aioredis.create_pool(str(self._cache_url), **kwargs) 44 | 45 | async def disconnect(self): 46 | assert self._pool is not None, "Cache backend is not running" 47 | self._pool.close() 48 | await self._pool.wait_closed() 49 | 50 | async def get(self, key: str, default: Any) -> Any: 51 | value = await self._pool.execute("GET", key) 52 | return self._deserialize(value) if value is not None else default 53 | 54 | async def set(self, key: str, value: Serializable, *, ttl: Optional[int]) -> Any: 55 | if ttl is None: 56 | await self._pool.execute("SET", key, self._serialize(value)) 57 | elif ttl: 58 | await self._pool.execute("SETEX", key, ttl, self._serialize(value)) 59 | 60 | async def add(self, key: str, value: Serializable, *, ttl: Optional[int]): 61 | if ttl is None: 62 | return bool(await self._pool.execute("SET", key, self._serialize(value), "NX")) 63 | 64 | return bool( 65 | await self._pool.execute("SET", key, self._serialize(value), "EX", ttl, "NX") 66 | ) 67 | 68 | async def get_or_set( 69 | self, key: str, default: Union[Awaitable[Serializable], Serializable], *, ttl: Optional[int] 70 | ) -> Any: 71 | value = await self.get(key, None) 72 | if value is None: 73 | if callable(default): 74 | default = default() 75 | if isawaitable(default): 76 | default = await default 77 | await self.set(key, default, ttl=ttl) 78 | return default 79 | return value 80 | 81 | async def get_many(self, keys: Iterable[str]) -> Dict[str, Any]: 82 | values = await self._pool.execute("MGET", *keys) 83 | return { 84 | key: self._deserialize(values[i]) if values[i] is not None else None 85 | for i, key in enumerate(keys) 86 | } 87 | 88 | async def set_many( 89 | self, mapping: Mapping[str, Serializable], *, ttl: Optional[int] 90 | ): 91 | if ttl is None or ttl: 92 | values = [] 93 | for key, value in mapping.items(): 94 | values.append(key) 95 | values.append(self._serialize(value)) 96 | await self._pool.execute("MSET", *values) 97 | if ttl: 98 | expire = [] 99 | for key in mapping: 100 | expire.append(self._pool.execute("EXPIRE", key, ttl)) 101 | await asyncio.gather(*expire) 102 | 103 | async def delete(self, key: str): 104 | await self._pool.execute("UNLINK", key) 105 | 106 | async def delete_many(self, keys: Iterable[str]): 107 | await self._pool.execute("UNLINK", *keys) 108 | 109 | async def clear(self): 110 | await self._pool.execute("FLUSHDB", "async") 111 | 112 | async def touch(self, key: str, ttl: Optional[int]) -> bool: 113 | if ttl is None: 114 | return bool(await self._pool.execute("PERSIST", key)) 115 | return bool(await self._pool.execute("EXPIRE", key, ttl)) 116 | 117 | async def incr(self, key: str, delta: Union[float, int]) -> Union[float, int]: 118 | if not await self._pool.execute("EXISTS", key): 119 | raise ValueError(f"'{key}' is not set in the cache") 120 | if isinstance(delta, int): 121 | return await self._pool.execute("INCRBY", key, delta) 122 | if isinstance(delta, float): 123 | return self._deserialize(await self._pool.execute("INCRBYFLOAT", key, delta)) 124 | raise ValueError(f"incr value must be int or float") 125 | 126 | async def decr(self, key: str, delta: Union[float, int]) -> Union[float, int]: 127 | if not await self._pool.execute("EXISTS", key): 128 | raise ValueError(f"'{key}' is not set in the cache") 129 | if isinstance(delta, int): 130 | return await self._pool.execute("INCRBY", key, delta * -1) 131 | if isinstance(delta, float): 132 | return self._deserialize( 133 | await self._pool.execute("INCRBYFLOAT", key, delta * -1.0) 134 | ) 135 | raise ValueError(f"decr value must be int or float") 136 | -------------------------------------------------------------------------------- /caches/core.py: -------------------------------------------------------------------------------- 1 | import json 2 | from types import TracebackType 3 | from typing import Any, Awaitable, Coroutine, Dict, Iterable, Mapping, Optional, Type, Union 4 | from urllib.parse import SplitResult, parse_qsl, urlsplit 5 | 6 | from .importer import import_from_string 7 | from .types import Serializable, Version 8 | 9 | 10 | class Cache: 11 | SUPPORTED_BACKENDS = { 12 | "dummy": "caches.backends.dummy:DummyBackend", 13 | "locmem": "caches.backends.locmem:LocMemBackend", 14 | "redis": "caches.backends.redis:RedisBackend", 15 | } 16 | 17 | def __init__( 18 | self, 19 | url: Union[str, "CacheURL"], 20 | *, 21 | ttl: Optional[int] = None, 22 | version: Optional[Version] = None, 23 | key_prefix: str = "", 24 | **options: Any, 25 | ): 26 | self.url = CacheURL(url) 27 | 28 | url_options = self.url.options 29 | self.ttl = ttl 30 | self.version = version or url_options.get("version", "") 31 | self.key_prefix = key_prefix or url_options.get("key_prefix", "") 32 | 33 | if self.ttl is None and url_options.get("ttl") is not None: 34 | self.ttl = int(url_options["ttl"]) 35 | 36 | if ttl == 0 or self.ttl == 0: 37 | raise ValueError( 38 | "'ttl' option can't be set to 0. " 39 | "If you want cache keys to never expire, set it to 'None'." 40 | ) 41 | 42 | self.options = options 43 | self.is_connected = False 44 | 45 | assert self.url.backend in self.SUPPORTED_BACKENDS, "Invalid backend." 46 | backend_str = self.SUPPORTED_BACKENDS[self.url.backend] 47 | backend_cls = import_from_string(backend_str) 48 | 49 | from .backends.base import BaseBackend 50 | 51 | assert issubclass(backend_cls, BaseBackend) 52 | self._backend = backend_cls( 53 | self.url, 54 | ttl=self.ttl, 55 | version=self.version, 56 | key_prefix=self.key_prefix, 57 | **options, 58 | ) 59 | 60 | async def connect(self) -> None: 61 | assert not self.is_connected, "Already connected." 62 | await self._backend.connect() 63 | self.is_connected = True 64 | 65 | async def disconnect(self) -> None: 66 | assert self.is_connected, "Already disconnected." 67 | await self._backend.disconnect() 68 | self.is_connected = False 69 | 70 | async def __call__(self, coroutine: Coroutine, ttl: Optional[int] = None) -> Any: 71 | """Cached coroutine call by itself. 72 | 73 | Example: 74 | >>> async def my_coroutine(*args, **kwargs): 75 | >>> pass 76 | >>> 77 | >>> async with Cache("locmem://") as cache: 78 | >>> await cache(my_coroutine('arg', test1='kwarg') 79 | 80 | Args: 81 | coroutine (Coroutine): Coroutine that you can cache 82 | ttl (int): TTL of the cached value 83 | 84 | Returns: 85 | Any: Return value of the coroutine. 86 | """ 87 | key = await self._get_key_from_coroutine(coroutine) 88 | 89 | return await self.get_or_set(key, coroutine, ttl=ttl) 90 | 91 | async def __aenter__(self) -> "Cache": 92 | await self.connect() 93 | return self 94 | 95 | async def __aexit__( 96 | self, 97 | exc_type: Type[BaseException] = None, 98 | exc_value: BaseException = None, 99 | traceback: TracebackType = None, 100 | ) -> None: 101 | await self.disconnect() 102 | 103 | def make_key(self, key: str, version: Optional[Version] = None) -> str: 104 | return "%s:%s:%s" % (self.key_prefix, version or self.version, key) 105 | 106 | def make_ttl(self, ttl: Optional[int] = None) -> Optional[int]: 107 | if ttl == 0: 108 | raise ValueError( 109 | "'ttl' can't be set to 0. " 110 | "If you want cache to never expire, set it to 'None'." 111 | ) 112 | if ttl is not None: 113 | return ttl 114 | if self.ttl is not None: 115 | return self.ttl 116 | return None 117 | 118 | async def get( 119 | self, key: str, default: Any = None, *, version: Optional[Version] = None 120 | ) -> Any: 121 | """Gets key value from cache, or default if key was not found or expired.""" 122 | key_ = self.make_key(key, version) 123 | return await self._backend.get(key_, default) 124 | 125 | async def set( 126 | self, 127 | key: str, 128 | value: Serializable, 129 | *, 130 | ttl: Optional[int] = None, 131 | version: Optional[Version] = None, 132 | ) -> Any: 133 | """Sets value for key in cache.""" 134 | key_ = self.make_key(key, version) 135 | ttl_ = self.make_ttl(ttl) 136 | await self._backend.set(key_, value, ttl=ttl_) 137 | 138 | async def add( 139 | self, 140 | key: str, 141 | value: Serializable, 142 | *, 143 | ttl: Optional[int] = None, 144 | version: Optional[Version] = None, 145 | ) -> bool: 146 | """Sets value for key in cache, but only if key wasn't already set.""" 147 | key_ = self.make_key(key, version) 148 | ttl_ = self.make_ttl(ttl) 149 | return await self._backend.add(key_, value, ttl=ttl_) 150 | 151 | async def get_or_set( 152 | self, 153 | key: str, 154 | default: Union[Awaitable[Serializable], Serializable], 155 | *, 156 | ttl: Optional[int] = None, 157 | version: Optional[Version] = None, 158 | ) -> Any: 159 | """Gets key value from cache, or default if key was not found or expired. 160 | If key was not found in the cache, it will be set with default value.""" 161 | key_ = self.make_key(key, version) 162 | ttl_ = self.make_ttl(ttl) 163 | return await self._backend.get_or_set(key_, default, ttl=ttl_) 164 | 165 | async def get_many( 166 | self, keys: Iterable[str], version: Optional[Version] = None 167 | ) -> Dict[str, Any]: 168 | """Gets values for specified keys from cache. If key didn't exist or was 169 | expired, its value will be None.""" 170 | keys_ = {key: self.make_key(key, version) for key in keys} 171 | values = await self._backend.get_many(list(keys_.values())) 172 | return {key: values[keys_[key]] for key in keys} 173 | 174 | async def set_many( 175 | self, mapping: Mapping[str, Serializable], *, ttl: Optional[int] = None 176 | ): 177 | """Sets values for specified keys in cache.""" 178 | mapping_ = {self.make_key(key): mapping[key] for key in mapping} 179 | ttl_ = self.make_ttl(ttl) 180 | await self._backend.set_many(mapping_, ttl=ttl_) 181 | 182 | async def delete(self, key: str, version: Optional[Version] = None): 183 | """Deletes specified key from cache.""" 184 | key_ = self.make_key(key, version) 185 | await self._backend.delete(key_) 186 | 187 | async def delete_many(self, keys: Iterable[str], version: Optional[Version] = None): 188 | """Deletes specified keys from cache.""" 189 | keys_ = [self.make_key(key, version) for key in keys] 190 | await self._backend.delete_many(keys_) 191 | 192 | async def clear(self): 193 | """Deletes all keys from cache.""" 194 | await self._backend.clear() 195 | 196 | async def touch( 197 | self, key: str, ttl: Optional[int] = None, *, version: Optional[Version] = None 198 | ) -> bool: 199 | """Updates key's expiration time in cache.""" 200 | key_ = self.make_key(key, version) 201 | ttl_ = self.make_ttl(ttl) 202 | return await self._backend.touch(key_, ttl_) 203 | 204 | async def incr( 205 | self, 206 | key: str, 207 | delta: Union[float, int] = 1, 208 | *, 209 | version: Optional[Version] = None, 210 | ) -> Union[float, int]: 211 | """Increases key value in cache by delta. Defaults to '1'.""" 212 | key_ = self.make_key(key, version) 213 | return await self._backend.incr(key_, delta) 214 | 215 | async def decr( 216 | self, 217 | key: str, 218 | delta: Union[float, int] = 1, 219 | *, 220 | version: Optional[Version] = None, 221 | ) -> Union[float, int]: 222 | """Decreases key value in cache by delta. Defaults to '1'.""" 223 | key_ = self.make_key(key, version) 224 | return await self._backend.decr(key_, delta) 225 | 226 | async def _get_key_from_coroutine(self, coroutine: Coroutine) -> str: 227 | """Gets key from coroutine name and its arguments. 228 | 229 | Args: 230 | coroutine (Coroutine) 231 | 232 | Returns: 233 | str: Key in string. 234 | """ 235 | frame = coroutine.cr_frame 236 | 237 | coroutine_name = coroutine.__qualname__ 238 | coroutine_arguments = frame.f_locals 239 | 240 | args = [arg for arg in coroutine_arguments.get('args', [])] 241 | kwargs = coroutine_arguments.get('kwds') 242 | 243 | return self.make_key(json.dumps([coroutine_name, args, kwargs])) 244 | 245 | 246 | class CacheURL: 247 | def __init__(self, url: Union[str, "CacheURL"]): 248 | self._url = str(url) 249 | 250 | @property 251 | def components(self) -> SplitResult: 252 | if not hasattr(self, "_components"): 253 | # pylint: disable=attribute-defined-outside-init 254 | self._components = urlsplit(self._url) 255 | return self._components 256 | 257 | @property 258 | def backend(self) -> str: 259 | return self.components.scheme 260 | 261 | @property 262 | def hostname(self) -> Optional[str]: 263 | return self.components.hostname 264 | 265 | @property 266 | def port(self) -> Optional[int]: 267 | return self.components.port 268 | 269 | @property 270 | def netloc(self) -> Optional[str]: 271 | return self.components.netloc or None 272 | 273 | @property 274 | def database(self) -> Optional[str]: 275 | path = self.components.path 276 | if path.startswith("/"): 277 | path = path[1:] 278 | return path or None 279 | 280 | @property 281 | def options(self) -> dict: 282 | if not hasattr(self, "_options"): 283 | # pylint: disable=attribute-defined-outside-init 284 | self._options = dict(parse_qsl(self.components.query)) 285 | return self._options 286 | 287 | def __str__(self) -> str: 288 | return self._url 289 | 290 | def __repr__(self) -> str: 291 | url = str(self) 292 | return f"{self.__class__.__name__}({repr(url)})" 293 | 294 | def __eq__(self, other: Any) -> bool: 295 | return str(self) == str(other) 296 | -------------------------------------------------------------------------------- /caches/importer.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from typing import Any 3 | 4 | 5 | class ImportFromStringError(Exception): 6 | pass 7 | 8 | 9 | def import_from_string(import_str: str) -> Any: 10 | module_str, _, attrs_str = import_str.partition(":") 11 | if not module_str or not attrs_str: 12 | message = ( 13 | 'Import string "{import_str}" must be in format ":".' 14 | ) 15 | raise ImportFromStringError(message.format(import_str=import_str)) 16 | 17 | try: 18 | module = importlib.import_module(module_str) 19 | except ImportError as exc: 20 | if exc.name != module_str: 21 | raise exc from None 22 | message = 'Could not import module "{module_str}".' 23 | raise ImportFromStringError(message.format(module_str=module_str)) 24 | 25 | instance = module 26 | try: 27 | for attr_str in attrs_str.split("."): 28 | instance = getattr(instance, attr_str) 29 | except AttributeError as exc: 30 | message = 'Attribute "{attrs_str}" not found in module "{module_str}".' 31 | raise ImportFromStringError( 32 | message.format(attrs_str=attrs_str, module_str=module_str) 33 | ) 34 | 35 | return instance 36 | -------------------------------------------------------------------------------- /caches/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafalp/async-caches/0c83df55db3c152a1aa117b28b8dd2b2c802cfb3/caches/py.typed -------------------------------------------------------------------------------- /caches/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Collection, Dict, Union 2 | 3 | # Note: "Any" should be "Serializable" 4 | # See https://github.com/python/mypy/issues/7069 5 | Serializable = Union[bool, float, int, str, Collection[Any], Dict[str, Any]] 6 | Version = Union[int, str] 7 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API reference 2 | 3 | ## `Cache` 4 | 5 | 6 | ### `connect` 7 | 8 | ```python 9 | await cache.connect() 10 | ``` 11 | 12 | Connects cache to server. 13 | 14 | 15 | - - - 16 | 17 | 18 | ### `disconnect` 19 | 20 | ```python 21 | await cache.disconnect() 22 | ``` 23 | 24 | Disconnects cache from server. 25 | 26 | 27 | - - - 28 | 29 | 30 | ### `get` 31 | 32 | ```python 33 | await cache.get(key: str, default: Any = None, *, version: Optional[Version] = None) -> Any 34 | ``` 35 | 36 | Gets value for key from the cache. 37 | 38 | 39 | #### Required arguments 40 | 41 | ##### `key` 42 | 43 | String with cache key to read. 44 | 45 | 46 | #### Optional arguments 47 | 48 | ##### `default` 49 | 50 | Default value that should be returned if key doesn't exist in the cache, or has expired. 51 | 52 | Defaults to `None`. 53 | 54 | 55 | ##### `version` 56 | 57 | Version of key that should be returned. String or integer. 58 | 59 | Defaults to `None`, unless default version is set for the cache. 60 | 61 | 62 | - - - 63 | 64 | 65 | ### `set` 66 | 67 | ```python 68 | await cache.set(key: str, value: Serializable, *, ttl: Optional[int] = None, version: Optional[Version] = None) 69 | ``` 70 | 71 | Sets new value for key in the cache. If key doesn't exist it will be created. 72 | 73 | 74 | #### Required arguments 75 | 76 | ##### `key` 77 | 78 | String with cache key to set. 79 | 80 | 81 | ##### `value` 82 | 83 | JSON-serializable value to store in the cache. 84 | 85 | 86 | #### Optional arguments 87 | 88 | ##### `ttl` 89 | 90 | Integer with number of seconds after which set key will expire and will be removed by the cache. 91 | 92 | Defaults to `None` (cache forever), unless default `ttl` is set for cache. 93 | 94 | 95 | ##### `version` 96 | 97 | Version of key that should be set. String or integer. 98 | 99 | Defaults to `None`, unless default version is set for the cache. 100 | 101 | 102 | - - - 103 | 104 | 105 | ### `add` 106 | 107 | ```python 108 | await cache.add(key: str, value: Serializable, *, ttl: Optional[int] = None, version: Optional[Version] = None) -> bool 109 | ``` 110 | 111 | Sets key in the cache if it doesn't already exist, or has expired. 112 | 113 | 114 | #### Required arguments 115 | 116 | ##### `key` 117 | 118 | String with cache key to set. 119 | 120 | 121 | ##### `value` 122 | 123 | JSON-serializable value to store in the cache. 124 | 125 | 126 | #### Optional arguments 127 | 128 | ##### `ttl` 129 | 130 | Integer with number of seconds after which set key will expire and will be removed by the cache. 131 | 132 | Defaults to `None` (cache forever), unless default ttl is set for cache. 133 | 134 | 135 | ##### `version` 136 | 137 | Version of key that should be set. String or integer. 138 | 139 | Defaults to `None`, unless default version is set for the cache. 140 | 141 | 142 | #### Return value 143 | 144 | Returns `True` if key was added to cache and `False` if it already exists. 145 | 146 | 147 | - - - 148 | 149 | 150 | ### `get_or_set` 151 | 152 | ```python 153 | await cache.get_or_set(key: str, default: Union[Awaitable, Serializable], *, ttl: Optional[int] = None, version: Optional[Version] = None) -> Any 154 | ``` 155 | 156 | Gets value for key from the cache. If key doesn't exist or has expired, new key is set with `default` value. 157 | 158 | 159 | #### Required arguments 160 | 161 | ##### `key` 162 | 163 | String with cache key to read or set. 164 | 165 | 166 | ##### `default` 167 | 168 | Default value that should be returned if key doesn't exist in the cache, or has expired. It has to be JSON-serializable and will be set in cache if read didn't return the value. 169 | 170 | If `default` is callable, it will be called and it's return value will be set in cache. 171 | 172 | 173 | #### Optional arguments 174 | 175 | ##### `ttl` 176 | 177 | Integer with number of seconds after which set key will expire and will be removed by the cache. 178 | 179 | Defaults to `None` (cache forever), unless default ttl is set for cache. 180 | 181 | 182 | ##### `version` 183 | 184 | Version of key that should be get (or set). String or integer. 185 | 186 | Defaults to `None`, unless default version is set for the cache. 187 | 188 | 189 | #### Return value 190 | 191 | Returns key value from cache if it exists, or `default` otherwise. 192 | 193 | 194 | - - - 195 | 196 | 197 | ### `get_many` 198 | 199 | ```python 200 | await cache.get_many(keys: Iterable[str], version: Optional[Version] = None) -> Dict[str, Any] 201 | ``` 202 | 203 | Gets values for many keys from the cache in single read operation. 204 | 205 | 206 | #### Required arguments 207 | 208 | ##### `keys` 209 | 210 | List or tuple of string with cache keys to read. 211 | 212 | 213 | #### Optional arguments 214 | 215 | ##### `version` 216 | 217 | Version of keys that should be get from the cache. String or integer. 218 | 219 | Defaults to `None`, unless default version is set for the cache. 220 | 221 | 222 | #### Return value 223 | 224 | Returns dict of cache-returned values. If any of keys didn't exist in the cache or was expired, it's value will be as `None`. 225 | 226 | 227 | - - - 228 | 229 | 230 | ### `set_many` 231 | 232 | ```python 233 | await cache.set_many(mapping: Mapping[str, Serializable], *, ttl: Optional[int] = None) 234 | ``` 235 | 236 | Sets values for many keys in the cache in single write operation. 237 | 238 | > **Note:** if ttl argument is provided, second command will be ran to set keys expiration time on the cache server. 239 | 240 | 241 | - - - 242 | 243 | 244 | ### `delete` 245 | 246 | ```python 247 | await cache.delete(key: str, version: Optional[Version] = None) 248 | ``` 249 | 250 | Deletes the key from the cache. Does nothing if the key doesn't exist. 251 | 252 | 253 | #### Required arguments 254 | 255 | ##### `key` 256 | 257 | Key to delete from cache. 258 | 259 | 260 | #### Optional arguments 261 | 262 | ##### `version` 263 | 264 | Version of key that should be deleted from the cache. String or integer. 265 | 266 | Defaults to `None`, unless default version is set for the cache. 267 | 268 | 269 | - - - 270 | 271 | 272 | ### `delete_many` 273 | 274 | ```python 275 | await cache.delete_many(keys: Iterable[str], version: Optional[Version] = None) 276 | ``` 277 | 278 | Deletes many keys from the cache. Skips keys that don't exist. 279 | 280 | 281 | #### Required arguments 282 | 283 | ##### `keys` 284 | 285 | Keys to delete from cache. 286 | 287 | 288 | #### Optional arguments 289 | 290 | ##### `version` 291 | 292 | Version of keys that should be deleted from the cache. String or integer. 293 | 294 | Defaults to `None`, unless default version is set for the cache. 295 | 296 | 297 | - - - 298 | 299 | 300 | ### `clear` 301 | 302 | ```python 303 | await cache.clear() 304 | ``` 305 | 306 | Deletes all keys from the cache. 307 | 308 | > **Note:** `cache.clear()` will remove all keys from cache, not just ones set by your application. 309 | > 310 | > Be careful when calling it, if your app shares Redis database with other clients. 311 | 312 | 313 | - - - 314 | 315 | 316 | ### `touch` 317 | 318 | ```python 319 | await cache.touch(key: str, ttl: Optional[int] = None, *, version: Optional[Version] = None) -> bool 320 | ``` 321 | 322 | Updates expiration time for the key. 323 | 324 | 325 | #### Required arguments 326 | 327 | ##### `key` 328 | 329 | String with cache key which ttl value should be updated. 330 | 331 | 332 | #### Optional arguments 333 | 334 | ##### `ttl` 335 | 336 | Integer with number of seconds after which updated key will expire and will be removed by the cache, or `None` if key should never expire. 337 | 338 | Defaults to `None` (cache forever), unless default ttl is set for cache. 339 | 340 | 341 | ##### `version` 342 | 343 | Version of key that should be updated. String or integer. 344 | 345 | Defaults to `None`, unless default version is set for the cache. 346 | 347 | 348 | #### Return value 349 | 350 | Returns `True` if key's expirat was updated, and `False` if key didn't exist in the cache. 351 | 352 | 353 | - - - 354 | 355 | 356 | ### `incr` 357 | 358 | ```python 359 | await cache.incr(key: str, delta: Union[float, int] = 1, *, version: Optional[Version] = None) -> Union[float, int] 360 | ``` 361 | 362 | Increases the value stored for specified key by specified amount. 363 | 364 | 365 | #### Required arguments 366 | 367 | ##### `key` 368 | 369 | String with cache key which should be updated. 370 | 371 | 372 | #### Optional arguments 373 | 374 | ##### `delta` 375 | 376 | Amount by which key value should be increased. Can be `float` or `int`. 377 | 378 | Defaults to `1`. 379 | 380 | 381 | ##### `version` 382 | 383 | Version of key that should be updated. String or integer. 384 | 385 | Defaults to `None`, unless default version is set for the cache. 386 | 387 | 388 | #### Return value 389 | 390 | Returns `float` or `int` with updated value. If key didn't exist, this value will equal to value passed in delta argument. 391 | 392 | 393 | - - - 394 | 395 | 396 | ### `decr` 397 | 398 | ```python 399 | await cache.decr(key: str, delta: Union[float, int] = 1, *, version: Optional[Version] = None) -> Union[float, int] 400 | ``` 401 | 402 | Decreases the value stored for specified key by specified amount. 403 | 404 | 405 | #### Required arguments 406 | 407 | ##### `key` 408 | 409 | String with cache key which should be updated. 410 | 411 | 412 | #### Optional arguments 413 | 414 | ##### `delta` 415 | 416 | Amount by which key value should be decreased. Can be `float` or `int`. 417 | 418 | Defaults to `1`. 419 | 420 | 421 | ##### `version` 422 | 423 | Version of key that should be updated. String or integer. 424 | 425 | Defaults to `None`, unless default version is set for the cache. 426 | 427 | 428 | #### Return value 429 | 430 | Returns `float` or `int` with updated value. If key didn't exist, this value will equal to value passed in delta argument. -------------------------------------------------------------------------------- /docs/backends.md: -------------------------------------------------------------------------------- 1 | # Backends 2 | 3 | Async Caches ships with three caching backends, each intended for different usage: 4 | 5 | 6 | ## Dummy cache 7 | 8 | Dummy cache backend that doesn't cache anything. Enables you to easily disable caching without having to litter your code with conditions and checks. It also checks if values passed to cache are JSON-serializable. 9 | 10 | ```python 11 | from caches import Cache 12 | 13 | 14 | cache = Cache("dummy://null") 15 | ``` 16 | 17 | > **Note:** Because dummy backend has no configuration options, it doesn't matter what you'll write after the `dummy://` part. 18 | 19 | 20 | ## Local memory cache 21 | 22 | Cache backend that stores data in local memory. Lets you develop and test caching without need for actual cache server. Its purged when application restarts, so you don't have to spend time invalidating caches when changing your app. 23 | 24 | This backend supports cache versions and time to live. 25 | 26 | ```python 27 | from caches import Cache 28 | 29 | 30 | cache = Cache("locmem://null") 31 | ``` 32 | 33 | 34 | ## Redis 35 | 36 | This backend stores data on Redis server. This is only backend intended for *actual* use on production. It supports key prefixes, versions and time to live. 37 | 38 | 39 | ```python 40 | from caches import Cache 41 | 42 | 43 | # Connection to locally running Redis instance 44 | cache = Cache("redis://localhost") 45 | ``` 46 | 47 | 48 | ## Connection 49 | 50 | To use cache, it has to be *connected*. After cache is no longer needed, it should be *disconnected*. 51 | 52 | > **Note:** running event loop is required for cache to work. 53 | 54 | ```python 55 | async def myapp(): 56 | cache = Cache("redis://localhost") 57 | await cache.connect() 58 | await cache.set("test", "Ok!") 59 | await cache.disconnect() 60 | ``` 61 | 62 | Alternatively, cache may be used as context manager: 63 | 64 | ```python 65 | async def myapp(): 66 | async with Cache("redis://localhost") as cache: 67 | await cache.set("test", "Ok!") 68 | ``` 69 | 70 | 71 | ## Configuration 72 | 73 | 74 | ### Multiple instances 75 | 76 | All cache backends support running multiple cache instances: 77 | 78 | ```python 79 | from caches import Cache 80 | 81 | 82 | # Multiple Redis instances 83 | default = Cache("redis://localhost") 84 | user_tracker = Cache("redis://localhost/1") 85 | 86 | 87 | # Multiple locmemory instances 88 | default = Cache("locmem://default") 89 | user_tracker = Cache("locmem://users") 90 | 91 | 92 | # Multiple dummy instances 93 | default = Cache("dummy://default") 94 | user_tracker = Cache("dummy://users") 95 | ``` 96 | 97 | 98 | ### Setting options 99 | 100 | Options can be set either as elements of querystring in cache URL, or as extra kwargs passed to `Cache`: 101 | 102 | ```python 103 | from caches import Cache 104 | 105 | 106 | # Option included in cache link... 107 | cache = Cache("redis://localhost?ttl=600") 108 | 109 | 110 | # ...and set as kwarg 111 | cache = Cache("locmem://default", ttl=600) 112 | ``` 113 | 114 | > **Note:** when option is set in both URL and kwarg, the URL value is discarded. 115 | 116 | 117 | ### Default time to live 118 | 119 | By default cache keys never expire, unless time to live was explicitly set for a specific key. 120 | 121 | You can override this behavior by setting default ttl (in seconds) for all keys on cache: 122 | 123 | ```python 124 | from caches import Cache 125 | 126 | 127 | # Expire keys after 5 minutes. 128 | cache = Cache("redis://localhost", ttl=600) 129 | ``` 130 | 131 | 132 | ### Default version 133 | 134 | By default cache keys are not versioned, unless version was specified during key set. 135 | 136 | You can default keys to specific version using `version` option: 137 | 138 | ```python 139 | from caches import Cache 140 | 141 | 142 | # Version can be an integer... 143 | cache = Cache("redis://localhost", version=2019) 144 | 145 | 146 | # ...or a string 147 | cache = Cache("redis://localhost", version="f6s8a68687as") 148 | ``` 149 | 150 | 151 | ### Default key prefix 152 | 153 | If your cache shares Redis database with other clients, you can prefix your cache keys with string specific to your client to reduce chance of key collision: 154 | 155 | ```python 156 | from caches import Cache 157 | 158 | 159 | cache = Cache("redis://localhost/0", key_prefix="forum") 160 | ``` 161 | 162 | > **Note:** Clearing your cache by calling `cache.clear()` will remove all keys from cache, regardless of their prefix. 163 | 164 | 165 | ### Connections pool size 166 | 167 | Redis backend supports `maxsize` and `minsize` options that can be used to configure size of available connections pool used by the cache to communicate with the Redis server: 168 | 169 | ```python 170 | from caches import Cache 171 | 172 | 173 | cache = Cache("redis://localhost", minsize=2, maxsize=5) 174 | ``` 175 | 176 | > **Note:** Redis backend defaults to 1 min. and 10 max. connections. -------------------------------------------------------------------------------- /docs/custom.css: -------------------------------------------------------------------------------- 1 | /* Make code stand out more */ 2 | .md-typeset code { 3 | padding: .2rem .4rem; 4 | background-color: #ffe8e7 !important; 5 | box-shadow: none; 6 | color: #d60000; 7 | } 8 | 9 | /* Hide crazy deep navs */ 10 | .md-nav__list .md-nav__list .md-nav__list { 11 | display: none; 12 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Welcome to Async Caches! 4 | 5 | Async Caches was created to provide familiar caching solution to Django developers making a switch to new async frameworks, but newcomers are also welcome! 6 | 7 | Its fully async and ships with type hints and docstrings for public API. It also has 100% test coverage. 8 | 9 | **Requirements:** Python 3.6+ 10 | 11 | 12 | ## Installation 13 | 14 | Async Caches can be installed form Pypi: 15 | 16 | ```console 17 | $ pip install async-caches 18 | ``` 19 | 20 | 21 | ## Credits and license 22 | 23 | This is free software and you are welcome to modify and redistribute it under the conditions described in the license. For the complete license, refer to the LICENSE file. 24 | 25 | Parts of software come from [databases](https://github.com/encode/databases/issues) package developed by Tom Christie and contributors and from [Django](https://github.com/django/django) package developed by Django project maintainers and contributors. -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Async Caches 2 | site_description: Caching library reimplementing django.core.cache with async support and type hints, inspired by encode/databases. 3 | 4 | theme: 5 | name: 'material' 6 | 7 | repo_name: rafalp/async-caches 8 | repo_url: https://github.com/rafalp/async-caches 9 | # edit_uri: "" 10 | 11 | nav: 12 | - Introduction: 'index.md' 13 | - Backends: 'backends.md' 14 | - API: 'api.md' 15 | 16 | markdown_extensions: 17 | - admonition 18 | - codehilite 19 | 20 | extra_css: 21 | - custom.css 22 | -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | codecov 2 | black 3 | mypy 4 | pip-tools 5 | pylint 6 | pytest 7 | pytest-asyncio 8 | pytest-cov -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile --output-file=requirements-dev.txt requirements-dev.in 6 | # 7 | appdirs==1.4.3 8 | # via black 9 | astroid==2.2.5 10 | # via pylint 11 | atomicwrites==1.3.0 12 | # via pytest 13 | attrs==19.1.0 14 | # via 15 | # black 16 | # pytest 17 | black==19.3b0 18 | # via -r requirements-dev.in 19 | certifi==2019.6.16 20 | # via requests 21 | chardet==3.0.4 22 | # via requests 23 | click==7.0 24 | # via 25 | # black 26 | # pip-tools 27 | codecov==2.0.15 28 | # via -r requirements-dev.in 29 | coverage==4.5.3 30 | # via 31 | # codecov 32 | # pytest-cov 33 | idna==2.8 34 | # via requests 35 | importlib-metadata==0.18 36 | # via 37 | # pluggy 38 | # pytest 39 | isort==4.3.21 40 | # via pylint 41 | lazy-object-proxy==1.4.1 42 | # via astroid 43 | mccabe==0.6.1 44 | # via pylint 45 | more-itertools==7.1.0 46 | # via pytest 47 | mypy-extensions==0.4.1 48 | # via mypy 49 | mypy==0.720 50 | # via -r requirements-dev.in 51 | packaging==19.0 52 | # via pytest 53 | pip-tools==3.8.0 54 | # via -r requirements-dev.in 55 | pluggy==0.12.0 56 | # via pytest 57 | py==1.10.0 58 | # via pytest 59 | pylint==2.3.1 60 | # via -r requirements-dev.in 61 | pyparsing==2.4.0 62 | # via packaging 63 | pytest-asyncio==0.10.0 64 | # via -r requirements-dev.in 65 | pytest-cov==2.7.1 66 | # via -r requirements-dev.in 67 | pytest==5.0.1 68 | # via 69 | # -r requirements-dev.in 70 | # pytest-asyncio 71 | # pytest-cov 72 | requests==2.22.0 73 | # via codecov 74 | six==1.12.0 75 | # via 76 | # astroid 77 | # packaging 78 | # pip-tools 79 | toml==0.10.0 80 | # via black 81 | typed-ast==1.4.0 82 | # via 83 | # astroid 84 | # mypy 85 | typing-extensions==3.7.4 86 | # via mypy 87 | urllib3==1.25.3 88 | # via requests 89 | wcwidth==0.1.7 90 | # via pytest 91 | wrapt==1.11.2 92 | # via astroid 93 | zipp==0.5.2 94 | # via importlib-metadata 95 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | import os 3 | from setuptools import setup 4 | 5 | CLASSIFIERS = [ 6 | "Development Status :: 4 - Beta", 7 | "Intended Audience :: Developers", 8 | "License :: OSI Approved :: BSD License", 9 | "Operating System :: OS Independent", 10 | "Programming Language :: Python", 11 | "Programming Language :: Python :: 3.6", 12 | "Programming Language :: Python :: 3.7", 13 | "Topic :: Software Development :: Libraries :: Python Modules", 14 | ] 15 | 16 | README_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md") 17 | with open(README_PATH, "r") as f: 18 | README = f.read() 19 | 20 | setup( 21 | name="async-caches", 22 | author="Rafał Pitoń", 23 | author_email="kontakt@rpiton.com", 24 | description=( 25 | "Caching library reimplementing django.core.cache with async support and " 26 | "type hints, inspired by encode/databases." 27 | ), 28 | long_description=README, 29 | long_description_content_type="text/markdown", 30 | license="BSD", 31 | version="0.4.0", 32 | url="https://github.com/rafalp/async-caches", 33 | packages=["caches"], 34 | include_package_data=True, 35 | install_requires=["aioredis>=1.2.0"], 36 | classifiers=CLASSIFIERS, 37 | platforms=["any"], 38 | zip_safe=False, 39 | ) 40 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafalp/async-caches/0c83df55db3c152a1aa117b28b8dd2b2c802cfb3/tests/__init__.py -------------------------------------------------------------------------------- /tests/dummy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafalp/async-caches/0c83df55db3c152a1aa117b28b8dd2b2c802cfb3/tests/dummy/__init__.py -------------------------------------------------------------------------------- /tests/dummy/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from caches import Cache 4 | 5 | 6 | @pytest.fixture 7 | async def cache(): 8 | obj = Cache("dummy://0") 9 | await obj.connect() 10 | return obj 11 | -------------------------------------------------------------------------------- /tests/dummy/test_impl.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.asyncio 5 | async def test_setting_key_is_noop(cache): 6 | await cache.set("test", "Ok!") 7 | assert await cache.get("test") is None 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_versioning_key_is_noop(cache): 12 | await cache.set("test", "Ok!", version=1) 13 | await cache.set("test", "Nope!", version=2) 14 | assert await cache.get("test", version=1) is None 15 | assert await cache.get("test", version=2) is None 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_default_is_returned_when_set(cache): 20 | assert await cache.get("text", "default") == "default" 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_adding_key_is_noop(cache): 25 | await cache.add("test", "Ok!") 26 | assert await cache.get("test") is None 27 | 28 | 29 | @pytest.mark.asyncio 30 | async def test_adding_key_always_returns_false(cache): 31 | assert await cache.add("test", "Ok!") is False 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_key_get_or_set_is_noop(cache): 36 | assert await cache.get_or_set("test", "Ok!") == "Ok!" 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_key_get_or_set_callable_default_is_called(cache): 41 | def default(): 42 | return "Ok!" 43 | 44 | assert await cache.get_or_set("test", default) == "Ok!" 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_key_get_or_set_async_callable_default_is_called_and_awaited(cache): 49 | async def default(): 50 | return "Ok!" 51 | 52 | assert await cache.get_or_set("test", default) == "Ok!" 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_getting_many_keys_always_returns_nothing(cache): 57 | await cache.set("test", "Ok!") 58 | values = await cache.get_many(["test", "undefined"]) 59 | assert len(values) == 2 60 | assert values["test"] is None 61 | assert values["undefined"] is None 62 | 63 | 64 | @pytest.mark.asyncio 65 | async def test_setting_many_keys_is_noop(cache): 66 | await cache.set_many({"test": "Ok!", "hello": "world"}) 67 | assert await cache.get("test") is None 68 | assert await cache.get("hello") is None 69 | 70 | 71 | @pytest.mark.asyncio 72 | async def test_deleting_key_is_noop(cache): 73 | await cache.set("test", "Ok!") 74 | await cache.delete("test") 75 | assert await cache.get("test") is None 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_deleting_many_keys_is_noop(cache): 80 | await cache.set("test", "Ok!") 81 | await cache.delete_many(["test", "undefined"]) 82 | assert await cache.get("test") is None 83 | assert await cache.get("undefined") is None 84 | 85 | 86 | @pytest.mark.asyncio 87 | async def test_clearing_is_noop(cache): 88 | await cache.clear() 89 | 90 | 91 | @pytest.mark.asyncio 92 | async def test_touch_is_noop_for_set_key(cache): 93 | await cache.set("test", "Ok!", ttl=10) 94 | assert await cache.touch("test") is False 95 | 96 | 97 | @pytest.mark.asyncio 98 | async def test_touch_is_noop_for_undefined_key(cache): 99 | assert await cache.touch("undefined", 10) is False 100 | 101 | 102 | @pytest.mark.asyncio 103 | async def test_increasing_set_key_raises_value_error(cache): 104 | await cache.set("test", 10) 105 | with pytest.raises(ValueError): 106 | await cache.incr("test") 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_increasing_key_raises_value_error(cache): 111 | with pytest.raises(ValueError): 112 | await cache.incr("test") 113 | 114 | 115 | @pytest.mark.asyncio 116 | async def test_decreasing_set_key_raises_value_error(cache): 117 | await cache.set("test", 10) 118 | with pytest.raises(ValueError): 119 | await cache.decr("test") 120 | 121 | 122 | @pytest.mark.asyncio 123 | async def test_decreasing_key_raises_value_error(cache): 124 | with pytest.raises(ValueError): 125 | await cache.decr("test") 126 | -------------------------------------------------------------------------------- /tests/dummy/test_initialization.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from caches import Cache 4 | 5 | 6 | def test_dummy_cache_can_be_initialized_without_net_loc(): 7 | Cache("dummy://") 8 | 9 | 10 | def test_dummy_cache_can_be_initialized_with_net_loc(): 11 | Cache("dummy://primary") 12 | 13 | 14 | def test_dummy_cache_can_be_initialized_with_key_prefix(): 15 | Cache("dummy://", key_prefix="test") 16 | 17 | 18 | def test_dummy_cache_can_be_initialized_with_ttl(): 19 | Cache("dummy://", ttl=600) 20 | 21 | 22 | def test_dummy_cache_can_be_initialized_with_version(): 23 | Cache("dummy://", version=20) 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_dummy_cache_can_be_connected_and_disconnected(): 28 | cache = Cache("dummy://", version=20) 29 | await cache.connect() 30 | await cache.disconnect() 31 | -------------------------------------------------------------------------------- /tests/locmem/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafalp/async-caches/0c83df55db3c152a1aa117b28b8dd2b2c802cfb3/tests/locmem/__init__.py -------------------------------------------------------------------------------- /tests/locmem/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from caches import Cache 4 | 5 | 6 | @pytest.fixture 7 | async def cache(): 8 | obj = Cache("locmem://0") 9 | await obj.connect() 10 | return obj 11 | -------------------------------------------------------------------------------- /tests/locmem/test_impl.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_set_key_can_be_get(cache): 8 | await cache.set("test", "Ok!") 9 | assert await cache.get("test") == "Ok!" 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_set_key_can_be_dict(cache): 14 | await cache.set("test", {"hello": "world"}) 15 | assert await cache.get("test") == {"hello": "world"} 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_set_key_can_be_list(cache): 20 | await cache.set("test", ["hello", "world"]) 21 | assert await cache.get("test") == ["hello", "world"] 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_set_key_can_be_unicode_str(cache): 26 | await cache.set("test", "łóć") 27 | assert await cache.get("test") == "łóć" 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_key_can_be_versioned(cache): 32 | await cache.set("test", "Ok!", version=1) 33 | await cache.set("test", "Nope!", version=2) 34 | assert await cache.get("test", version=1) == "Ok!" 35 | assert await cache.get("test", version=2) == "Nope!" 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_none_is_returned_for_expired_key(cache): 40 | await cache.set("test", "Ok!", ttl=1) 41 | await asyncio.sleep(2) 42 | assert await cache.get("test") is None 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_none_is_returned_for_nonexistant_key(cache): 47 | assert await cache.get("nonexistant") is None 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_none_is_returned_for_nonexistant_version(cache): 52 | await cache.set("test", "Ok!") 53 | assert await cache.get("test", version=2) is None 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_default_is_returned_for_expired_key(cache): 58 | await cache.set("test", "Ok!", ttl=1) 59 | await asyncio.sleep(2) 60 | assert await cache.get("text", "default") == "default" 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_default_is_returned_for_nonexistant_key(cache): 65 | assert await cache.get("nonexistant", "default") == "default" 66 | 67 | 68 | @pytest.mark.asyncio 69 | async def test_default_is_returned_for_nonexistant_version(cache): 70 | await cache.set("test", "Ok!") 71 | assert await cache.get("text", "default", version=2) == "default" 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_key_can_be_added(cache): 76 | await cache.add("test", "Ok!") 77 | assert await cache.get("test") == "Ok!" 78 | 79 | 80 | @pytest.mark.asyncio 81 | async def test_key_can_be_added_with_ttl(cache): 82 | await cache.add("test", "Ok!", ttl=1) 83 | assert await cache.get("test") == "Ok!" 84 | await asyncio.sleep(2) 85 | assert await cache.get("test") is None 86 | 87 | 88 | @pytest.mark.asyncio 89 | async def test_key_is_not_added_if_its_already_set(cache): 90 | await cache.set("test", "Initial") 91 | await cache.add("test", "Ok!") 92 | assert await cache.get("test") == "Initial" 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_adding_key_returns_true_if_key_was_added(cache): 97 | assert await cache.add("test", "Ok!") is True 98 | 99 | 100 | @pytest.mark.asyncio 101 | async def test_adding_key_returns_false_if_key_already_exists(cache): 102 | await cache.set("test", "Ok!") 103 | assert await cache.add("test", "Ok!") is False 104 | 105 | 106 | @pytest.mark.asyncio 107 | async def test_key_get_or_set_sets_given_value_if_key_is_undefined(cache): 108 | assert await cache.get_or_set("test", "Ok!") == "Ok!" 109 | assert await cache.get("test") == "Ok!" 110 | 111 | 112 | @pytest.mark.asyncio 113 | async def test_key_get_or_set_returns_previously_set_value(cache): 114 | await cache.set("test", "Ok!") 115 | assert await cache.get_or_set("test", "New") == "Ok!" 116 | 117 | 118 | @pytest.mark.asyncio 119 | async def test_key_get_or_set_is_not_overwriting_previously_set_value(cache): 120 | await cache.set("test", "Ok!") 121 | assert await cache.get_or_set("test", "New") == "Ok!" 122 | assert await cache.get("test") == "Ok!" 123 | 124 | 125 | @pytest.mark.asyncio 126 | async def test_key_get_or_set_overwrites_expired_key(cache): 127 | await cache.set("test", "Ok!", ttl=1) 128 | await asyncio.sleep(2) 129 | assert await cache.get_or_set("test", "New") == "New" 130 | assert await cache.get("test") == "New" 131 | 132 | 133 | @pytest.mark.asyncio 134 | async def test_key_get_or_set_callable_default_is_called(cache): 135 | def default(): 136 | return "Ok!" 137 | 138 | assert await cache.get_or_set("test", default) == "Ok!" 139 | 140 | 141 | @pytest.mark.asyncio 142 | async def test_key_get_or_set_async_callable_default_is_called_and_awaited(cache): 143 | async def default(): 144 | return "Ok!" 145 | 146 | assert await cache.get_or_set("test", default) == "Ok!" 147 | 148 | 149 | @pytest.mark.asyncio 150 | async def test_many_keys_can_be_get(cache): 151 | await cache.set("test", "Ok!") 152 | await cache.set("hello", "world") 153 | values = await cache.get_many(["test", "hello"]) 154 | assert len(values) == 2 155 | assert values["test"] == "Ok!" 156 | assert values["hello"] == "world" 157 | 158 | 159 | @pytest.mark.asyncio 160 | async def test_many_undefined_keys_are_returned_as_none(cache): 161 | await cache.set("test", "Ok!") 162 | values = await cache.get_many(["test", "undefined"]) 163 | assert len(values) == 2 164 | assert values["test"] == "Ok!" 165 | assert values["undefined"] is None 166 | 167 | 168 | @pytest.mark.asyncio 169 | async def test_many_expired_keys_are_returned_as_none(cache): 170 | await cache.set("test", "Ok!") 171 | await cache.set("expired", "Ok!", ttl=1) 172 | await asyncio.sleep(2) 173 | values = await cache.get_many(["test", "expired"]) 174 | assert len(values) == 2 175 | assert values["test"] == "Ok!" 176 | assert values["expired"] is None 177 | 178 | 179 | @pytest.mark.asyncio 180 | async def test_many_keys_can_be_set(cache): 181 | await cache.set_many({"test": "Ok!", "hello": "world"}) 182 | assert await cache.get("test") == "Ok!" 183 | assert await cache.get("hello") == "world" 184 | 185 | 186 | @pytest.mark.asyncio 187 | async def test_many_keys_can_be_set_with_ttl(cache): 188 | await cache.set_many({"test": "Ok!", "hello": "world"}, ttl=1) 189 | assert await cache.get("test") == "Ok!" 190 | assert await cache.get("hello") == "world" 191 | await asyncio.sleep(2) 192 | assert await cache.get("test") is None 193 | assert await cache.get("hello") is None 194 | 195 | 196 | @pytest.mark.asyncio 197 | async def test_set_key_can_be_deleted(cache): 198 | await cache.set("test", "Ok!") 199 | await cache.delete("test") 200 | assert await cache.get("test") is None 201 | 202 | 203 | @pytest.mark.asyncio 204 | async def test_deleting_undefined_key_has_no_errors(cache): 205 | await cache.delete("undefined") 206 | 207 | 208 | @pytest.mark.asyncio 209 | async def test_many_set_keys_can_be_deleted(cache): 210 | await cache.set("test", "Ok!") 211 | await cache.set("hello", "world") 212 | await cache.delete_many(["test", "hello"]) 213 | assert await cache.get("test") is None 214 | assert await cache.get("hello") is None 215 | 216 | 217 | @pytest.mark.asyncio 218 | async def test_deleting_many_undefined_keys_has_no_errors(cache): 219 | await cache.set("test", "Ok!") 220 | await cache.delete_many(["test", "undefined"]) 221 | assert await cache.get("test") is None 222 | assert await cache.get("undefined") is None 223 | 224 | 225 | @pytest.mark.asyncio 226 | async def test_set_keys_are_cleared(cache): 227 | await cache.set("test", "Ok!") 228 | await cache.set("hello", "world") 229 | await cache.clear() 230 | assert await cache.get("test") is None 231 | assert await cache.get("hello") is None 232 | 233 | 234 | @pytest.mark.asyncio 235 | async def test_touch_removes_expired_key_ttl(cache): 236 | await cache.set("test", "Ok!", ttl=1) 237 | assert await cache.get("test") == "Ok!" 238 | assert await cache.touch("test") is True 239 | await asyncio.sleep(2) 240 | assert await cache.get("test") == "Ok!" 241 | 242 | 243 | @pytest.mark.asyncio 244 | async def test_touch_updates_expired_key_ttl(cache): 245 | await cache.set("test", "Ok!", ttl=1) 246 | assert await cache.get("test") == "Ok!" 247 | assert await cache.touch("test", 10) is True 248 | await asyncio.sleep(2) 249 | assert await cache.get("test") == "Ok!" 250 | 251 | 252 | @pytest.mark.asyncio 253 | async def test_touch_does_nothing_for_nonexistant_key(cache): 254 | assert await cache.touch("undefined", 10) is False 255 | assert await cache.get("undefined") is None 256 | 257 | 258 | @pytest.mark.asyncio 259 | async def test_set_key_can_be_increased(cache): 260 | await cache.set("test", 10) 261 | assert await cache.incr("test") == 11 262 | 263 | 264 | @pytest.mark.asyncio 265 | async def test_set_key_can_be_increased_by_int_value(cache): 266 | await cache.set("test", 10) 267 | assert await cache.incr("test", 2) == 12 268 | 269 | 270 | @pytest.mark.asyncio 271 | async def test_set_key_can_be_increased_by_float_value(cache): 272 | await cache.set("test", 10.0) 273 | assert await cache.incr("test", 2.5) == 12.5 274 | 275 | 276 | @pytest.mark.asyncio 277 | async def test_increasing_undefined_key_raises_value_error(cache): 278 | with pytest.raises(ValueError): 279 | await cache.incr("test") 280 | 281 | 282 | @pytest.mark.asyncio 283 | async def test_increasing_key_by_non_numeric_delta_raises_value_error(cache): 284 | await cache.set("test", 10.0) 285 | with pytest.raises(ValueError): 286 | await cache.incr("test", "invalid") 287 | 288 | 289 | @pytest.mark.asyncio 290 | async def test_set_key_can_be_decreased(cache): 291 | await cache.set("test", 10) 292 | assert await cache.decr("test") == 9 293 | 294 | 295 | @pytest.mark.asyncio 296 | async def test_set_key_can_be_decreased_by_int_value(cache): 297 | await cache.set("test", 10) 298 | assert await cache.decr("test", 2) == 8 299 | 300 | 301 | @pytest.mark.asyncio 302 | async def test_set_key_can_be_decreased_by_float_value(cache): 303 | await cache.set("test", 10.0) 304 | assert await cache.decr("test", 2.5) == 7.5 305 | 306 | 307 | @pytest.mark.asyncio 308 | async def test_decreasing_undefined_key_raises_value_error(cache): 309 | with pytest.raises(ValueError): 310 | await cache.decr("test") 311 | 312 | 313 | @pytest.mark.asyncio 314 | async def test_decreasing_key_by_non_numeric_delta_raises_value_error(cache): 315 | await cache.set("test", 10.0) 316 | with pytest.raises(ValueError): 317 | await cache.decr("test", "invalid") 318 | -------------------------------------------------------------------------------- /tests/locmem/test_initialization.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from caches import Cache 4 | 5 | 6 | def test_locmem_cache_can_be_initialized_without_net_loc(): 7 | Cache("locmem://") 8 | 9 | 10 | def test_locmem_cache_can_be_initialized_with_net_loc(): 11 | Cache("locmem://primary") 12 | 13 | 14 | def test_locmem_cache_can_be_initialized_with_key_prefix(): 15 | Cache("locmem://", key_prefix="test") 16 | 17 | 18 | def test_locmem_cache_can_be_initialized_with_ttl(): 19 | Cache("locmem://", ttl=600) 20 | 21 | 22 | def test_locmem_cache_can_be_initialized_with_version(): 23 | Cache("locmem://", version=20) 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_locmem_cache_can_be_connected_and_disconnected(): 28 | cache = Cache("locmem://", version=20) 29 | await cache.connect() 30 | await cache.disconnect() 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_locmem_cache_uses_net_loc_for_separaing_namespaces(): 35 | cache = Cache("locmem://") 36 | other_cache = Cache("locmem://other") 37 | 38 | await cache.connect() 39 | await other_cache.connect() 40 | 41 | await cache.set("test", "Ok!") 42 | assert await other_cache.get("test") is None 43 | assert await cache.get("test") == "Ok!" 44 | -------------------------------------------------------------------------------- /tests/modulewithexception.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=import-error,unused-import 2 | import nonexisting65f4r # pragma: no cover 3 | -------------------------------------------------------------------------------- /tests/redis/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafalp/async-caches/0c83df55db3c152a1aa117b28b8dd2b2c802cfb3/tests/redis/__init__.py -------------------------------------------------------------------------------- /tests/redis/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from caches import Cache 4 | 5 | 6 | @pytest.fixture 7 | async def cache(): 8 | obj = Cache("redis://localhost:6379/1") 9 | await obj.connect() 10 | await obj.clear() 11 | yield obj 12 | await obj.clear() 13 | await obj.disconnect() 14 | -------------------------------------------------------------------------------- /tests/redis/test_impl.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_set_key_can_be_get(cache): 8 | await cache.set("test", "Ok!") 9 | assert await cache.get("test") == "Ok!" 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_set_key_can_be_dict(cache): 14 | await cache.set("test", {"hello": "world"}) 15 | assert await cache.get("test") == {"hello": "world"} 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_set_key_can_be_list(cache): 20 | await cache.set("test", ["hello", "world"]) 21 | assert await cache.get("test") == ["hello", "world"] 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_set_key_can_be_unicode_str(cache): 26 | await cache.set("test", "łóć") 27 | assert await cache.get("test") == "łóć" 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_key_can_be_versioned(cache): 32 | await cache.set("test", "Ok!", version=1) 33 | await cache.set("test", "Nope!", version=2) 34 | assert await cache.get("test", version=1) == "Ok!" 35 | assert await cache.get("test", version=2) == "Nope!" 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_none_is_returned_for_expired_key(cache): 40 | await cache.set("test", "Ok!", ttl=1) 41 | await asyncio.sleep(2) 42 | assert await cache.get("test") is None 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_none_is_returned_for_nonexistant_key(cache): 47 | assert await cache.get("nonexistant") is None 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_none_is_returned_for_nonexistant_version(cache): 52 | await cache.set("test", "Ok!") 53 | assert await cache.get("test", version=2) is None 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_default_is_returned_for_expired_key(cache): 58 | await cache.set("test", "Ok!", ttl=1) 59 | await asyncio.sleep(2) 60 | assert await cache.get("text", "default") == "default" 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_default_is_returned_for_nonexistant_key(cache): 65 | assert await cache.get("nonexistant", "default") == "default" 66 | 67 | 68 | @pytest.mark.asyncio 69 | async def test_default_is_returned_for_nonexistant_version(cache): 70 | await cache.set("test", "Ok!") 71 | assert await cache.get("text", "default", version=2) == "default" 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_key_can_be_added(cache): 76 | await cache.add("test", "Ok!") 77 | assert await cache.get("test") == "Ok!" 78 | 79 | 80 | @pytest.mark.asyncio 81 | async def test_key_can_be_added_with_ttl(cache): 82 | await cache.add("test", "Ok!", ttl=1) 83 | assert await cache.get("test") == "Ok!" 84 | await asyncio.sleep(2) 85 | assert await cache.get("test") is None 86 | 87 | 88 | @pytest.mark.asyncio 89 | async def test_key_is_not_added_if_its_already_set(cache): 90 | await cache.set("test", "Initial") 91 | await cache.add("test", "Ok!") 92 | assert await cache.get("test") == "Initial" 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_adding_key_returns_true_if_key_was_added(cache): 97 | assert await cache.add("test", "Ok!") is True 98 | 99 | 100 | @pytest.mark.asyncio 101 | async def test_adding_key_returns_false_if_key_already_exists(cache): 102 | await cache.set("test", "Ok!") 103 | assert await cache.add("test", "Ok!") is False 104 | 105 | 106 | @pytest.mark.asyncio 107 | async def test_key_get_or_set_sets_given_value_if_key_is_undefined(cache): 108 | assert await cache.get_or_set("test", "Ok!") == "Ok!" 109 | assert await cache.get("test") == "Ok!" 110 | 111 | 112 | @pytest.mark.asyncio 113 | async def test_key_get_or_set_returns_previously_set_value(cache): 114 | await cache.set("test", "Ok!") 115 | assert await cache.get_or_set("test", "New") == "Ok!" 116 | 117 | 118 | @pytest.mark.asyncio 119 | async def test_key_get_or_set_is_not_overwriting_previously_set_value(cache): 120 | await cache.set("test", "Ok!") 121 | assert await cache.get_or_set("test", "New") == "Ok!" 122 | assert await cache.get("test") == "Ok!" 123 | 124 | 125 | @pytest.mark.asyncio 126 | async def test_key_get_or_set_overwrites_expired_key(cache): 127 | await cache.set("test", "Ok!", ttl=1) 128 | await asyncio.sleep(2) 129 | assert await cache.get_or_set("test", "New") == "New" 130 | assert await cache.get("test") == "New" 131 | 132 | 133 | @pytest.mark.asyncio 134 | async def test_key_get_or_set_callable_default_is_called(cache): 135 | def default(): 136 | return "Ok!" 137 | 138 | assert await cache.get_or_set("test", default) == "Ok!" 139 | 140 | 141 | @pytest.mark.asyncio 142 | async def test_key_get_or_set_async_callable_default_is_called_and_awaited(cache): 143 | async def default(): 144 | return "Ok!" 145 | 146 | assert await cache.get_or_set("test", default) == "Ok!" 147 | 148 | 149 | @pytest.mark.asyncio 150 | async def test_many_keys_can_be_get(cache): 151 | await cache.set("test", "Ok!") 152 | await cache.set("hello", "world") 153 | values = await cache.get_many(["test", "hello"]) 154 | assert len(values) == 2 155 | assert values["test"] == "Ok!" 156 | assert values["hello"] == "world" 157 | 158 | 159 | @pytest.mark.asyncio 160 | async def test_many_undefined_keys_are_returned_as_none(cache): 161 | await cache.set("test", "Ok!") 162 | values = await cache.get_many(["test", "undefined"]) 163 | assert len(values) == 2 164 | assert values["test"] == "Ok!" 165 | assert values["undefined"] is None 166 | 167 | 168 | @pytest.mark.asyncio 169 | async def test_many_expired_keys_are_returned_as_none(cache): 170 | await cache.set("test", "Ok!") 171 | await cache.set("expired", "Ok!", ttl=1) 172 | await asyncio.sleep(2) 173 | values = await cache.get_many(["test", "expired"]) 174 | assert len(values) == 2 175 | assert values["test"] == "Ok!" 176 | assert values["expired"] is None 177 | 178 | 179 | @pytest.mark.asyncio 180 | async def test_many_keys_can_be_set(cache): 181 | await cache.set_many({"test": "Ok!", "hello": "world"}) 182 | assert await cache.get("test") == "Ok!" 183 | assert await cache.get("hello") == "world" 184 | 185 | 186 | @pytest.mark.asyncio 187 | async def test_many_keys_can_be_set_with_ttl(cache): 188 | await cache.set_many({"test": "Ok!", "hello": "world"}, ttl=1) 189 | assert await cache.get("test") == "Ok!" 190 | assert await cache.get("hello") == "world" 191 | await asyncio.sleep(2) 192 | assert await cache.get("test") is None 193 | assert await cache.get("hello") is None 194 | 195 | 196 | @pytest.mark.asyncio 197 | async def test_set_key_can_be_deleted(cache): 198 | await cache.set("test", "Ok!") 199 | await cache.delete("test") 200 | assert await cache.get("test") is None 201 | 202 | 203 | @pytest.mark.asyncio 204 | async def test_deleting_undefined_key_has_no_errors(cache): 205 | await cache.delete("undefined") 206 | 207 | 208 | @pytest.mark.asyncio 209 | async def test_many_set_keys_can_be_deleted(cache): 210 | await cache.set("test", "Ok!") 211 | await cache.set("hello", "world") 212 | await cache.delete_many(["test", "hello"]) 213 | assert await cache.get("test") is None 214 | assert await cache.get("hello") is None 215 | 216 | 217 | @pytest.mark.asyncio 218 | async def test_deleting_many_undefined_keys_has_no_errors(cache): 219 | await cache.set("test", "Ok!") 220 | await cache.delete_many(["test", "undefined"]) 221 | assert await cache.get("test") is None 222 | assert await cache.get("undefined") is None 223 | 224 | 225 | @pytest.mark.asyncio 226 | async def test_set_keys_are_cleared(cache): 227 | await cache.set("test", "Ok!") 228 | await cache.set("hello", "world") 229 | await cache.clear() 230 | assert await cache.get("test") is None 231 | assert await cache.get("hello") is None 232 | 233 | 234 | @pytest.mark.asyncio 235 | async def test_touch_removes_expired_key_ttl(cache): 236 | await cache.set("test", "Ok!", ttl=1) 237 | assert await cache.get("test") == "Ok!" 238 | assert await cache.touch("test") is True 239 | await asyncio.sleep(2) 240 | assert await cache.get("test") == "Ok!" 241 | 242 | 243 | @pytest.mark.asyncio 244 | async def test_touch_updates_expired_key_ttl(cache): 245 | await cache.set("test", "Ok!", ttl=1) 246 | assert await cache.get("test") == "Ok!" 247 | assert await cache.touch("test", 10) is True 248 | await asyncio.sleep(2) 249 | assert await cache.get("test") == "Ok!" 250 | 251 | 252 | @pytest.mark.asyncio 253 | async def test_touch_does_nothing_for_nonexistant_key(cache): 254 | assert await cache.touch("undefined", 10) is False 255 | assert await cache.get("undefined") is None 256 | 257 | 258 | @pytest.mark.asyncio 259 | async def test_set_key_can_be_increased(cache): 260 | await cache.set("test", 10) 261 | assert await cache.incr("test") == 11 262 | 263 | 264 | @pytest.mark.asyncio 265 | async def test_set_key_can_be_increased_by_int_value(cache): 266 | await cache.set("test", 10) 267 | assert await cache.incr("test", 2) == 12 268 | 269 | 270 | @pytest.mark.asyncio 271 | async def test_set_key_can_be_increased_by_float_value(cache): 272 | await cache.set("test", 10.0) 273 | assert await cache.incr("test", 2.5) == 12.5 274 | 275 | 276 | @pytest.mark.asyncio 277 | async def test_increasing_undefined_key_raises_value_error(cache): 278 | with pytest.raises(ValueError): 279 | await cache.incr("test") 280 | 281 | 282 | @pytest.mark.asyncio 283 | async def test_increasing_key_by_non_numeric_delta_raises_value_error(cache): 284 | await cache.set("test", 10.0) 285 | with pytest.raises(ValueError): 286 | await cache.incr("test", "invalid") 287 | 288 | 289 | @pytest.mark.asyncio 290 | async def test_set_key_can_be_decreased(cache): 291 | await cache.set("test", 10) 292 | assert await cache.decr("test") == 9 293 | 294 | 295 | @pytest.mark.asyncio 296 | async def test_set_key_can_be_decreased_by_int_value(cache): 297 | await cache.set("test", 10) 298 | assert await cache.decr("test", 2) == 8 299 | 300 | 301 | @pytest.mark.asyncio 302 | async def test_set_key_can_be_decreased_by_float_value(cache): 303 | await cache.set("test", 10.0) 304 | assert await cache.decr("test", 2.5) == 7.5 305 | 306 | 307 | @pytest.mark.asyncio 308 | async def test_decreasing_undefined_key_raises_value_error(cache): 309 | with pytest.raises(ValueError): 310 | await cache.decr("test") 311 | 312 | 313 | @pytest.mark.asyncio 314 | async def test_decreasing_key_by_non_numeric_delta_raises_value_error(cache): 315 | await cache.set("test", 10.0) 316 | with pytest.raises(ValueError): 317 | await cache.decr("test", "invalid") 318 | -------------------------------------------------------------------------------- /tests/redis/test_initialization.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=protected-access 2 | import pytest 3 | 4 | from caches import Cache 5 | from caches.backends.redis import RedisBackend 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_backend_errors_if_more_than_one_connection_is_opened(): 10 | cache = Cache("redis://localhost/1") 11 | await cache.connect() 12 | with pytest.raises(AssertionError): 13 | await cache.connect() 14 | await cache.disconnect() 15 | 16 | 17 | @pytest.mark.asyncio 18 | async def test_backend_errors_if_nonexistant_connection_is_closed(): 19 | cache = Cache("redis://localhost/1") 20 | with pytest.raises(AssertionError): 21 | await cache.disconnect() 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_connections_pool_minsize_can_be_set_in_url(): 26 | cache = RedisBackend("redis://localhost/1?minsize=2") 27 | kwargs = cache._get_connection_kwargs() 28 | assert kwargs["minsize"] == 2 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_connections_pool_minsize_can_be_set_in_kwarg(): 33 | cache = RedisBackend("redis://localhost/1", minsize=2) 34 | kwargs = cache._get_connection_kwargs() 35 | assert kwargs["minsize"] == 2 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_connections_pool_minsize_kwarg_overrides_value_from_url(): 40 | cache = RedisBackend("redis://localhost/1?minsize=2", minsize=3) 41 | kwargs = cache._get_connection_kwargs() 42 | assert kwargs["minsize"] == 3 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_connections_pool_maxsize_can_be_set_in_url(): 47 | cache = RedisBackend("redis://localhost/1?maxsize=2") 48 | kwargs = cache._get_connection_kwargs() 49 | assert kwargs["maxsize"] == 2 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_connections_pool_maxsize_can_be_set_in_kwarg(): 54 | cache = RedisBackend("redis://localhost/1", maxsize=2) 55 | kwargs = cache._get_connection_kwargs() 56 | assert kwargs["maxsize"] == 2 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_connections_pool_maxsize_kwarg_overrides_value_from_url(): 61 | cache = RedisBackend("redis://localhost/1?maxsize=2", maxsize=3) 62 | kwargs = cache._get_connection_kwargs() 63 | assert kwargs["maxsize"] == 3 64 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from caches import Cache 4 | 5 | 6 | @pytest.fixture 7 | def cache(): 8 | return Cache("dummy://null") 9 | 10 | 11 | async def _testing_coroutine(test, test1='test'): 12 | return 'Ok!' 13 | 14 | 15 | def test_cache_created_key_includes_app_key(cache): 16 | key = cache.make_key("test") 17 | assert "test" in key 18 | 19 | 20 | def test_cache_created_key_includes_prefix(): 21 | cache = Cache("dummy://null", key_prefix="prod") 22 | key = cache.make_key("test") 23 | assert key.startswith("prod") 24 | assert "test" in key 25 | 26 | 27 | def test_cache_created_key_includes_default_version(): 28 | cache = Cache("dummy://null", version="beta") 29 | key = cache.make_key("test") 30 | assert "beta" in key 31 | assert "test" in key 32 | 33 | 34 | def test_cache_key_default_version_can_be_overridded(): 35 | cache = Cache("dummy://null", version="beta") 36 | key = cache.make_key("test", "custom") 37 | assert "beta" not in key 38 | assert "custom" in key 39 | assert "test" in key 40 | 41 | 42 | def test_cache_created_key_includes_prefix_and_version(): 43 | cache = Cache("dummy://null", key_prefix="prod", version="2019") 44 | key = cache.make_key("test") 45 | assert key.startswith("prod") 46 | assert "2019" in key 47 | assert "test" in key 48 | 49 | 50 | def test_cache_key_prefix_can_be_set_in_url(): 51 | cache = Cache("dummy://null?key_prefix=prod") 52 | key = cache.make_key("test") 53 | assert key.startswith("prod") 54 | assert "test" in key 55 | 56 | 57 | def test_cache_key_prefix_kwarg_overrides_key_prefix_be_set_in_url(): 58 | cache = Cache("dummy://null?key_prefix=prod", key_prefix="beta") 59 | key = cache.make_key("test") 60 | assert key.startswith("beta") 61 | assert "prod" not in key 62 | assert "test" in key 63 | 64 | 65 | def test_cache_version_can_be_set_in_url(): 66 | cache = Cache("dummy://null?version=2019") 67 | key = cache.make_key("test") 68 | assert "2019" in key 69 | assert "test" in key 70 | 71 | 72 | def test_cache_version_kwarg_overrides_version_set_in_url(): 73 | cache = Cache("dummy://null?version=2019", version="2020") 74 | key = cache.make_key("test") 75 | assert "2019" not in key 76 | assert "2020" in key 77 | assert "test" in key 78 | 79 | 80 | def test_cache_ttl_defaults_to_none(cache): 81 | assert cache.make_ttl() is None 82 | 83 | 84 | def test_cache_ttl_can_be_set_per_key(cache): 85 | assert cache.make_ttl(100) == 100 86 | 87 | 88 | def test_cache_can_be_set_default_ttl(): 89 | cache = Cache("dummy://null", ttl=600) 90 | assert cache.make_ttl() == 600 91 | 92 | 93 | def test_cache_can_be_set_default_ttl_in_url(): 94 | cache = Cache("dummy://null?ttl=600") 95 | assert cache.make_ttl() == 600 96 | 97 | 98 | def test_cache_default_ttl_can_be_overrided_per_key(): 99 | cache = Cache("dummy://null", ttl=600) 100 | assert cache.make_ttl(120) == 120 101 | 102 | 103 | def test_cache_errors_if_ttl_option_is_set_to_0(): 104 | with pytest.raises(ValueError): 105 | Cache("dummy://null", ttl=0) 106 | 107 | 108 | def test_cache_errors_if_ttl_option_in_url_is_set_to_0(): 109 | with pytest.raises(ValueError): 110 | Cache("dummy://null?ttl=0") 111 | 112 | 113 | def test_cache_errors_if_key_ttl_is_set_to_0(): 114 | cache = Cache("dummy://null") 115 | with pytest.raises(ValueError): 116 | assert cache.make_ttl(0) 117 | 118 | 119 | @pytest.mark.asyncio 120 | async def test_cache_can_be_used_as_context_manager(): 121 | async with Cache("locmem://") as cache: 122 | await cache.set("test", "Ok!") 123 | assert await cache.get("test") == "Ok!" 124 | 125 | assert await cache.get_or_set('test2', _testing_coroutine('arg', test1='kwarg')) == "Ok!" 126 | 127 | assert await cache(_testing_coroutine('arg', test1='kwarg')) == 'Ok!' 128 | -------------------------------------------------------------------------------- /tests/test_cache_url.py: -------------------------------------------------------------------------------- 1 | from caches.core import CacheURL 2 | 3 | 4 | def test_backend_is_taken_from_url_protocol(): 5 | url = CacheURL("dummy://") 6 | assert url.backend == "dummy" 7 | 8 | 9 | def test_hostname_is_taken_from_url(): 10 | url = CacheURL("dummy://localhost") 11 | assert url.hostname == "localhost" 12 | 13 | 14 | def test_none_is_returned_for_hostname_if_its_not_set(): 15 | url = CacheURL("dummy://") 16 | assert url.hostname is None 17 | 18 | 19 | def test_port_is_taken_from_url(): 20 | url = CacheURL("redis://localhost:6379") 21 | assert url.port == 6379 22 | 23 | 24 | def test_none_is_returned_for_port_if_its_not_set(): 25 | url = CacheURL("redis://localhost") 26 | assert url.port is None 27 | 28 | 29 | def test_netloc_is_taken_from_url(): 30 | url = CacheURL("redis://localhost:6379") 31 | assert url.netloc == "localhost:6379" 32 | 33 | 34 | def test_none_is_returned_for_netloc_if_its_not_set(): 35 | url = CacheURL("redis://") 36 | assert url.netloc is None 37 | 38 | 39 | def test_database_is_taken_from_url(): 40 | url = CacheURL("redis://localhost/0") 41 | assert url.database == "0" 42 | 43 | 44 | def test_none_is_returned_for_database_if_its_not_set(): 45 | url = CacheURL("redis://localhost/") 46 | assert url.database is None 47 | 48 | 49 | def test_options_are_taken_from_url_querystring(): 50 | url = CacheURL("redis://localhost/0?minsize=5&key_prefix=test") 51 | assert url.options == {"minsize": "5", "key_prefix": "test"} 52 | 53 | 54 | def test_options_are_empty_dict_if_url_has_no_querystring(): 55 | url = CacheURL("redis://localhost/0") 56 | assert url.options == {} 57 | 58 | 59 | def test_url_obj_can_be_converted_back_to_str(): 60 | url_str = "redis://localhost:6379" 61 | url = CacheURL(url_str) 62 | assert str(url) == url_str 63 | 64 | 65 | def test_url_obj_has_repr(): 66 | url = CacheURL("dummy://") 67 | assert repr(url) 68 | 69 | 70 | def test_url_obj_can_be_compared_to_str(): 71 | url_str = "redis://localhost:6379" 72 | url = CacheURL(url_str) 73 | assert url == url_str 74 | assert url != "dummy://" 75 | 76 | 77 | def test_url_obj_can_be_compared_to_other_url_obj(): 78 | url_str = "redis://localhost:6379" 79 | assert CacheURL(url_str) == CacheURL(url_str) 80 | assert CacheURL(url_str) != CacheURL("dummy://") 81 | -------------------------------------------------------------------------------- /tests/test_importer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from caches import Cache 4 | from caches.importer import ImportFromStringError, import_from_string 5 | 6 | 7 | def test_importer_imports_specified_class(): 8 | cls = import_from_string("caches.core:Cache") 9 | assert cls is Cache 10 | 11 | 12 | def test_importer_raises_error_is_module_path_is_missing(): 13 | with pytest.raises(ImportFromStringError): 14 | import_from_string(":Cache") 15 | 16 | 17 | def test_importer_raises_error_is_attribute_is_missing(): 18 | with pytest.raises(ImportFromStringError): 19 | import_from_string("caches.core") 20 | 21 | 22 | def test_importer_raises_error_is_module_cant_be_imported(): 23 | with pytest.raises(ImportFromStringError): 24 | import_from_string("not_existing_d76a8:Cache") 25 | 26 | 27 | def test_importer_raises_error_is_attribute_cant_be_imported(): 28 | with pytest.raises(ImportFromStringError): 29 | import_from_string("caches.core:Undefined") 30 | 31 | 32 | def test_importer_reraises_child_modules_import_errors(): 33 | with pytest.raises(ModuleNotFoundError): 34 | import_from_string("tests.modulewithexception:Undefined") 35 | --------------------------------------------------------------------------------