├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml ├── examples ├── __init__.py ├── blacksheep_memcached.py ├── blacksheep_pickle.py ├── fastapi_memcached.py ├── fastapi_redis.py ├── fastapi_valkey.py ├── litestar_inmemory.py ├── litestar_mongodb.py ├── litestar_redis.py ├── litestar_valkey.py ├── starlette_memcached.py └── starlette_pickle.py ├── pyproject.toml ├── src └── cachette │ ├── __init__.py │ ├── backends │ ├── __init__.py │ ├── inmemory.py │ ├── memcached.py │ ├── mongodb.py │ ├── pickle.py │ ├── redis.py │ └── valkey.py │ ├── cachette_config.py │ ├── codecs │ ├── __init__.py │ ├── dataframe │ │ ├── csv.py │ │ ├── feather.py │ │ └── parquet.py │ ├── json.py │ ├── msgpack.py │ ├── orjson.py │ ├── pickle.py │ └── vanilla.py │ ├── core.py │ └── load_config.py ├── static ├── cachette-banner.svg ├── cachette-preview.png ├── cachette-preview.svg ├── cachette.base64 └── cachette.svg ├── tests ├── __init__.py ├── backends │ ├── __init__.py │ ├── conftest.py │ ├── set_then_clear.py │ └── wait_till_expired.py ├── codecs │ ├── __init__.py │ ├── absent.py │ ├── conftest.py │ ├── dataframe │ │ ├── __init__.py │ │ ├── all.py │ │ └── conftest.py │ ├── json.py │ ├── primitives.py │ └── unfrozen.py └── load_config.py └── uv.lock /.gitignore: -------------------------------------------------------------------------------- 1 | ### Ignore Workspace Settings ### 2 | .vscode 3 | .idea 4 | .ignore 5 | 6 | ### Ignore MacOS Directory Indexer (in nested folders also) ### 7 | **/.DS_Store 8 | 9 | ### Ignore dotenv files (in nested folders also) ### 10 | **/*.env 11 | 12 | ### Ignore generated pickle files ### 13 | **/*.pickle 14 | **/*.pkl 15 | 16 | ### Python ### 17 | # Byte-compiled / optimized / DLL files 18 | __pycache__/ 19 | *.py[cod] 20 | *$py.class 21 | 22 | # C extensions 23 | *.so 24 | 25 | # Distribution / packaging 26 | .Python 27 | build/ 28 | develop-eggs/ 29 | dist/ 30 | downloads/ 31 | eggs/ 32 | .eggs/ 33 | lib/ 34 | lib64/ 35 | parts/ 36 | sdist/ 37 | var/ 38 | wheels/ 39 | pip-wheel-metadata/ 40 | share/python-wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | *.py,cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | pytestdebug.log 70 | 71 | # Translations 72 | *.mo 73 | *.pot 74 | 75 | # Django stuff: 76 | *.log 77 | local_settings.py 78 | db.sqlite3 79 | db.sqlite3-journal 80 | 81 | # Flask stuff: 82 | instance/ 83 | .webassets-cache 84 | 85 | # Scrapy stuff: 86 | .scrapy 87 | 88 | # Sphinx documentation 89 | docs/_build/ 90 | doc/_build/ 91 | 92 | # PyBuilder 93 | target/ 94 | 95 | # Jupyter Notebook 96 | .ipynb_checkpoints 97 | 98 | # IPython 99 | profile_default/ 100 | ipython_config.py 101 | 102 | # pyenv 103 | .python-version 104 | 105 | # pipenv 106 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 107 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 108 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 109 | # install all needed dependencies. 110 | #Pipfile.lock 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | pythonenv* 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # profiling data 154 | .prof 155 | 156 | # Ruff 157 | .ruff_cache 158 | 159 | # PyPI 160 | pypi-token.pypi 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2022-2023, Sitt Guruvanich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cachette 2 | 3 | [![Package vesion](https://img.shields.io/pypi/v/cachette)](https://pypi.org/project/cachette) 4 | [![Format](https://img.shields.io/pypi/format/cachette)](https://pypi.org/project/cachette) 5 | [![Python version](https://img.shields.io/pypi/pyversions/cachette)](https://pypi.org/project/cachette) 6 | [![License](https://img.shields.io/pypi/l/cachette)](https://pypi.org/project/cachette) 7 | [![Top](https://img.shields.io/github/languages/top/aekasitt/cachette)](.) 8 | [![Languages](https://img.shields.io/github/languages/count/aekasitt/cachette)](.) 9 | [![Size](https://img.shields.io/github/repo-size/aekasitt/cachette)](.) 10 | [![Last commit](https://img.shields.io/github/last-commit/aekasitt/cachette/master)](.) 11 | 12 | ![Cachette banner](static/cachette-banner.svg) 13 | 14 | ## Features 15 | 16 | This is an extension aiming at making cache access on the server 17 | By configuration at startup of the FastAPI App instance, you can set the backend and other 18 | configuration options and have it remain a class constant when using FastAPI's 19 | intuitive [Dependency Injection](https://fastapi.tiangolo.com/tutorial/dependencies/) system. 20 | 21 | The design has built-in limitations like fixed codec and backend once the app has been launched and 22 | encourage developers to design their applications with this in mind. 23 | 24 | Most of the Backend implementation is directly lifted from 25 | [fastapi-cache](https://github.com/long2ice/fastapi-cache) by 26 | [@long2ice](https://github.com/long2ice) excluding the MongoDB backend option. 27 | 28 | ## Configuration Options 29 | 30 | The following are the current available configuration keys that can be set on this FastAPI extension 31 | on startup either by using a method which returns a list of tuples or a Pydantic BaseSettings object 32 | (See examples below or in `examples/` folder) 33 | 34 | backend -- optional; must be one of ["inmemory", "memcached", "mongodb", "pickle", "redis"]; 35 | defaults to using inmemory option which required no extra package dependencies. To use 36 | other listed options; See installation guide on the README.md at 37 | [Repository Page](https://github.com/aekasitt/cachette). 38 | codec -- optional; serialization and de-serialization format to have cache values stored in 39 | the cache backend of choice as a string of selected encoding. once fetched, will have their 40 | decoded values returned of the same format. must be one of ["feather", "msgpack", "parquet", 41 | "pickle"]; if none is defined, will vanilla codec of basic string conversion will be used. 42 | database_name -- required when backend set to "mongodb"; the database name to be automatically 43 | created if not exists on the MongoDB instance and store the cache table; defaults to 44 | "cachette-db" 45 | memcached_host -- required when backend set to "memcached"; the host endpoint to the memcached 46 | distributed memory caching system. 47 | mongodb_url -- required when backend set to "mongodb"; the url set to MongoDB database 48 | instance with or without provided authentication in such formats 49 | "mongodb://user:password@host:port" and "mongodb://host:port" respectively. 50 | pickle_path -- required when backend set to "pickle"; the file-system path to create local 51 | store using python pickling on local directory 52 | redis_url -- required when backend set to "redis"; the url set to redis-server instance with 53 | or without provided authentication in such formats "redis://user:password@host:port" and 54 | "redis://host:port" respectively. 55 | table_name -- required when backend set to "mongodb"; name of the cache collection in case of 56 | "mongodb" backend to have key-value pairs stored; defaults to "cachette". 57 | ttl -- optional; the time-to-live or amount before this cache item expires within the cache; 58 | defaults to 60 (seconds) and must be between 1 second to 1 hour (3600 seconds). 59 | valkey_url -- required when backend set to "valkey"; the url set to valkey-server instance 60 | with or without provided authentication in such formats "valkey://user:password@host:port" 61 | and "valkey://host:port" respectively. 62 | 63 | ## Examples 64 | 65 | The following shows and example of setting up FastAPI Cachette in its default configuration, which 66 | is an In-Memory cache implementation. 67 | 68 | ```py 69 | from cachette import Cachette 70 | from fastapi import FastAPI, Depends 71 | from fastapi.responses import PlainTextResponse 72 | from pydantic import BaseModel 73 | 74 | app = FastAPI() 75 | 76 | ### Routing ### 77 | class Payload(BaseModel): 78 | key: str 79 | value: str 80 | 81 | @app.post('/', response_class=PlainTextResponse) 82 | async def setter(payload: Payload, cachette: Cachette = Depends()): 83 | await cachette.put(payload.key, payload.value) 84 | return 'OK' 85 | 86 | @app.get('/{key}', response_class=PlainTextResponse, status_code=200) 87 | async def getter(key: str, cachette: Cachette = Depends()): 88 | value: str = await cachette.fetch(key) 89 | return value 90 | ``` 91 | 92 | And then this is how you set up a FastAPI Cachette with Redis support enabled. 93 | 94 | ```py 95 | from cachette import Cachette 96 | from fastapi import FastAPI, Depends 97 | from fastapi.responses import PlainTextResponse 98 | from pydantic import BaseModel 99 | 100 | app = FastAPI() 101 | 102 | @Cachette.load_config 103 | def get_cachette_config(): 104 | return [('backend', 'redis'), ('redis_url', 'redis://localhost:6379')] 105 | 106 | class Payload(BaseModel): 107 | key: str 108 | value: str 109 | 110 | @app.post('/', response_class=PlainTextResponse) 111 | async def setter(payload: Payload, cachette: Cachette = Depends()): 112 | await cachette.put(payload.key, payload.value) 113 | return 'OK' 114 | 115 | @app.get('/{key}', response_class=PlainTextResponse, status_code=200) 116 | async def getter(key: str, cachette: Cachette = Depends()): 117 | value: str = await cachette.fetch(key) 118 | return value 119 | ``` 120 | 121 | ## Roadmap 122 | 123 | 1. Implement `flush` and `flush_expired` methods on individual backends 124 | (Not needed for Redis & Memcached backends) 125 | 126 | 2. Memcached Authentication ([No SASL Support](https://github.com/aio-libs/aiomcache/issues/12)) 127 | Change library? 128 | 129 | 3. Add behaviors responding to "Cache-Control" request header 130 | 131 | 4. More character validations for URLs and Database/Table/Collection names in configuration options 132 | 133 | ## Installation 134 | 135 | The easiest way to start working with this extension with pip 136 | 137 | ```bash 138 | pip install cachette 139 | # or 140 | uv add cachette 141 | ``` 142 | 143 | When you familiarize with the basic structure of how to Dependency Inject Cachette within your 144 | endpoints, please experiment more of using external backends with `extras` installations like 145 | 146 | ```bash 147 | # Install FastAPI Cachette's extra requirements to Redis support 148 | pip install cachette --install-option "--extras-require=redis" 149 | # or Install FastAPI Cachette's support to Memcached 150 | uv add cachette[memcached] 151 | # or Special JSON Codec written on Rust at lightning speed 152 | uv add cachette[orjson] 153 | # or Include PyArrow package making DataFrame serialization much easier 154 | pip install cachette --install-option "--extras-require=dataframe" 155 | ``` 156 | 157 | ## Getting Started 158 | 159 | This FastAPI extension utilizes "Dependency Injection" (To be continued) 160 | 161 | Configuration of this FastAPI extension must be done at startup using "@Cachette.load_config" 162 | decorator (To be continued) 163 | 164 | These are all available options with explanations and validation requirements (To be continued) 165 | 166 | ## Examples 167 | 168 | The following examples show you how to integrate this extension to a FastAPI App (To be continued) 169 | 170 | See "examples/" folders 171 | 172 | To run examples, first you must install extra dependencies 173 | 174 | Do all in one go with this command... 175 | 176 | ```bash 177 | pip install aiomcache motor uvicorn redis 178 | # or 179 | uv sync --extra examples 180 | # or 181 | uv sync --all-extras 182 | ``` 183 | 184 | Do individual example with this command... 185 | 186 | ```bash 187 | pip install redis 188 | # or 189 | uv sync --extra redis 190 | ``` 191 | 192 | ## Contributions 193 | 194 | ### Prerequisites 195 | 196 | - [python](https://www.python.org) version 3.9 or above 197 | - [uv](https://docs.astral.sh/uv) 198 | 199 | ### Set up local environment 200 | 201 | The following guide walks through setting up your local working environment using `pyenv` 202 | as Python version manager and `uv` as Python package manager. If you do not have `pyenv` 203 | installed, run the following command. 204 | 205 |
206 | Install using Homebrew (Darwin) 207 | 208 | ```sh 209 | brew install pyenv --head 210 | ``` 211 |
212 | 213 |
214 | Install using standalone installer (Darwin and Linux) 215 | 216 | ```sh 217 | curl https://pyenv.run | bash 218 | ``` 219 |
220 | 221 | If you do not have `uv` installed, run the following command. 222 | 223 |
224 | Install using Homebrew (Darwin) 225 | 226 | ```sh 227 | brew install uv 228 | ``` 229 |
230 | 231 |
232 | Install using standalone installer (Darwin and Linux) 233 | 234 | ```sh 235 | curl -LsSf https://astral.sh/uv/install.sh | sh 236 | ``` 237 |
238 | 239 | 240 | Once you have `pyenv` Python version manager installed, you can 241 | install any version of Python above version 3.9 for this project. 242 | The following commands help you set up and activate a Python virtual 243 | environment where `uv` can download project dependencies from the `PyPI` 244 | open-sourced registry defined under `pyproject.toml` file. 245 | 246 |
247 | Set up environment and synchronize project dependencies 248 | 249 | ```sh 250 | pyenv install 3.9.19 251 | pyenv shell 3.9.19 252 | uv venv --python-preference system 253 | source .venv/bin/activate 254 | uv sync --dev 255 | ``` 256 |
257 | 258 | ## Test Environment Setup 259 | 260 | This project utilizes multiple external backend services namely AWS DynamoDB, Memcached, MongoDB and 261 | Redis as backend service options as well as a possible internal option called InMemoryBackend. In 262 | order to test viability, we must have specific instances of these set up in the background of our 263 | testing environment. Utilize orchestration file attached to reposity and `docker-compose` command 264 | to set up testing instances of backend services using the following command... 265 | 266 | ```bash 267 | docker-compose up --detach 268 | ``` 269 | 270 | When you are finished, you can stop and remove background running backend instances with the 271 | following command... 272 | 273 | ```bash 274 | docker-compose down 275 | ``` 276 | 277 | Now that you have background running backend instances, you can proceed with the tests by using 278 | `pytest` command as such... 279 | 280 | ```bash 281 | pytest 282 | ``` 283 | 284 | Or you can configure the command to run specific tests as such... 285 | 286 | ```bash 287 | pytest -k test_load_invalid_configs 288 | # or 289 | pytest -k test_set_then_clear 290 | ``` 291 | 292 | All test suites must be placed under `tests/` folder or its subfolders. 293 | 294 | ## License 295 | 296 | This project is licensed under the terms of the MIT license. 297 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | memcached: 4 | image: memcached:bullseye 5 | command: memcached 6 | ports: 7 | - "11211:11211" 8 | mongodb: 9 | image: mongo:latest 10 | command: mongod 11 | ports: 12 | - "27017:27017" 13 | redis: 14 | image: redis:bullseye 15 | command: redis-server 16 | ports: 17 | - "6379:6379" 18 | valkey: 19 | image: valkey/valkey:7.2 20 | command: valkey-server 21 | ports: 22 | - "6380:6379" 23 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aekasitt/cachette/5dd4804df8588c92ca308a98391b5c2a84ef8322/examples/__init__.py -------------------------------------------------------------------------------- /examples/blacksheep_memcached.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/blacksheep_memcached.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | from cachette import Cachette 13 | from pydantic import BaseModel 14 | from blacksheep import Application, FromJSON, get, post 15 | 16 | 17 | @Cachette.load_config 18 | def get_cachette_config(): 19 | return [("backend", "memcached"), ("memcached_host", "localhost")] 20 | 21 | 22 | ### Schema ### 23 | class Payload(BaseModel): 24 | key: str 25 | value: str 26 | 27 | 28 | ### Routing ### 29 | app: Application = Application() 30 | app.services.add_scoped(Cachette) 31 | 32 | 33 | @post("/") 34 | async def setter(data: FromJSON[Payload], cachette: Cachette): 35 | """ 36 | Submit a new cache key-pair value 37 | """ 38 | payload: Payload = data.value 39 | await cachette.put(payload.key, payload.value) 40 | return "OK" 41 | 42 | 43 | @get("/{key}") 44 | async def getter(key: str, cachette: Cachette): 45 | """ 46 | Returns key value 47 | """ 48 | value: str = await cachette.fetch(key) 49 | return value 50 | 51 | 52 | __all__ = ("app",) 53 | -------------------------------------------------------------------------------- /examples/blacksheep_pickle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/blacksheep_pickle.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | from blacksheep import Application, FromJSON, get, post 13 | from cachette import Cachette 14 | from os import remove 15 | from os.path import isfile 16 | from pydantic import BaseModel 17 | from typing import Optional 18 | 19 | 20 | @Cachette.load_config 21 | def get_cachette_config(): 22 | return [("backend", "pickle"), ("pickle_path", "examples/cachette.pkl")] 23 | 24 | 25 | ### Schema ### 26 | class Payload(BaseModel): 27 | key: str 28 | value: str 29 | 30 | 31 | app: Application = Application() 32 | app.services.add_scoped(Cachette) 33 | 34 | 35 | ### Routing ### 36 | @post("/") 37 | async def setter(body: FromJSON[Payload], cachette: Cachette) -> str: 38 | """ 39 | Submit a new cache key-pair value 40 | """ 41 | payload: Payload = body.value 42 | await cachette.put(payload.key, payload.value) 43 | return "OK" 44 | 45 | 46 | @get("/{key}") 47 | async def getter(cachette: Cachette, key: str) -> Optional[str]: 48 | """ 49 | Returns key value 50 | """ 51 | value: str = await cachette.fetch(key) 52 | return value 53 | 54 | 55 | @app.lifespan 56 | async def remove_pickle_after_shutdown() -> None: 57 | """ 58 | Remove cachette pickle when App shuts down 59 | """ 60 | yield 61 | if isfile("examples/cachette.pkl"): 62 | remove("examples/cachette.pkl") 63 | 64 | 65 | if __name__ == "__main__": 66 | from uvicorn import run 67 | 68 | run(app, lifespan="on") 69 | 70 | 71 | __all__ = ("app",) 72 | -------------------------------------------------------------------------------- /examples/fastapi_memcached.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/fastapi_memcached.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-12 11:25 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Example for using FastAPI Cachette extension in tandem with BackgroundTasks 14 | """ 15 | 16 | from asyncio import run 17 | from cachette import Cachette 18 | from fastapi import BackgroundTasks, Depends, FastAPI 19 | from fastapi.responses import PlainTextResponse 20 | from pydantic import BaseModel 21 | 22 | app = FastAPI() 23 | 24 | 25 | ### Cachette Configurations ### 26 | @Cachette.load_config 27 | def get_cachette_config(): 28 | return [("backend", "memcached"), ("memcached_host", "localhost")] 29 | 30 | 31 | ### Schema ### 32 | class Payload(BaseModel): 33 | key: str 34 | value: str 35 | 36 | 37 | ### Routing ### 38 | @app.get("/{key}", response_class=PlainTextResponse, status_code=200) 39 | def getter(key: str, cachette: Cachette = Depends()): 40 | """ 41 | Returns key value 42 | """ 43 | value: str = run(cachette.fetch(key)) 44 | return value 45 | 46 | 47 | @app.post("/", response_class=PlainTextResponse) 48 | def setter(payload: Payload, background_tasks: BackgroundTasks, cachette: Cachette = Depends()): 49 | """ 50 | Submit a new cache key-pair value 51 | """ 52 | background_tasks.add_task(cachette.put, payload.key, payload.value) 53 | return "OK" 54 | 55 | 56 | __all__ = ("app",) 57 | -------------------------------------------------------------------------------- /examples/fastapi_redis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/fastapi_redis.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-12 11:25 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Example for using FastAPI Cachette extension in tandem with BackgroundTasks 14 | """ 15 | 16 | from asyncio import run 17 | from cachette import Cachette 18 | from fastapi import BackgroundTasks, Depends, FastAPI 19 | from fastapi.responses import PlainTextResponse 20 | from pydantic import BaseModel 21 | 22 | app = FastAPI() 23 | 24 | 25 | ### Cachette Configurations ### 26 | @Cachette.load_config 27 | def get_cachette_config(): 28 | return [("backend", "redis"), ("redis_url", "redis://localhost:6379")] 29 | 30 | 31 | ### Schema ### 32 | class Payload(BaseModel): 33 | key: str 34 | value: str 35 | 36 | 37 | ### Routing ### 38 | @app.get("/{key}", response_class=PlainTextResponse, status_code=200) 39 | def getter(key: str, cachette: Cachette = Depends()): 40 | """ 41 | Returns key value 42 | """ 43 | value: str = run(cachette.fetch(key)) 44 | return value 45 | 46 | 47 | @app.post("/", response_class=PlainTextResponse) 48 | def setter(payload: Payload, background_tasks: BackgroundTasks, cachette: Cachette = Depends()): 49 | """ 50 | Submit a new cache key-pair value 51 | """ 52 | background_tasks.add_task(cachette.put, payload.key, payload.value) 53 | return "OK" 54 | 55 | 56 | __all__ = ("app",) 57 | -------------------------------------------------------------------------------- /examples/fastapi_valkey.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/fastapi_valkey.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2024-04-11 15:34 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Example for using FastAPI Cachette extension in tandem with BackgroundTasks 14 | """ 15 | 16 | from asyncio import run 17 | from cachette import Cachette 18 | from fastapi import BackgroundTasks, Depends, FastAPI 19 | from fastapi.responses import PlainTextResponse 20 | from pydantic import BaseModel 21 | 22 | app = FastAPI() 23 | 24 | 25 | ### Cachette Configurations ### 26 | @Cachette.load_config 27 | def get_cachette_config(): 28 | return [("backend", "valkey"), ("valkey_url", "valkey://localhost:6379")] 29 | 30 | 31 | ### Routing ### 32 | class Payload(BaseModel): 33 | key: str 34 | value: str 35 | 36 | 37 | @app.get("/{key}", response_class=PlainTextResponse, status_code=200) 38 | def getter(key: str, cachette: Cachette = Depends()): 39 | """ 40 | Returns key value 41 | """ 42 | value: str = run(cachette.fetch(key)) 43 | return value 44 | 45 | 46 | @app.post("/", response_class=PlainTextResponse) 47 | def setter(payload: Payload, background_tasks: BackgroundTasks, cachette: Cachette = Depends()): 48 | """ 49 | Submit a new cache key-pair value 50 | """ 51 | background_tasks.add_task(cachette.put, payload.key, payload.value) 52 | return "OK" 53 | 54 | 55 | __all__ = ("app",) 56 | -------------------------------------------------------------------------------- /examples/litestar_inmemory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/litestar_inmemory.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | from cachette import Cachette 13 | from litestar import Litestar, get, post 14 | from litestar.di import Provide 15 | from pydantic import BaseModel 16 | 17 | 18 | ### Schema ### 19 | class Payload(BaseModel): 20 | key: str 21 | value: str 22 | 23 | 24 | ### Routing ### 25 | @get( 26 | "/{key:str}", 27 | ) 28 | async def getter(key: str, cachette: Cachette) -> str: 29 | """ 30 | Returns key value 31 | """ 32 | value: str = await cachette.fetch(key) 33 | return value 34 | 35 | 36 | @post("/", tags=["Payload"]) 37 | async def setter(data: Payload, cachette: Cachette) -> str: 38 | """ 39 | Submit a new cache key-pair value 40 | """ 41 | await cachette.put(data.key, data.value) 42 | return "OK" 43 | 44 | 45 | app: Litestar = Litestar( 46 | route_handlers=[getter, setter], dependencies={"cachette": Provide(Cachette)} 47 | ) 48 | 49 | 50 | __all__ = ("app",) 51 | -------------------------------------------------------------------------------- /examples/litestar_mongodb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/litestar_mongodb.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2024-02-02 23:51 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | from cachette import Cachette 13 | from litestar import Litestar, get, post 14 | from litestar.di import Provide 15 | from pydantic import BaseModel 16 | 17 | 18 | ### Cachette Configurations ### 19 | @Cachette.load_config 20 | def get_cachette_config(): 21 | return [("backend", "mongodb"), ("mongodb_url", "mongodb://localhost:27017")] 22 | 23 | 24 | ### Schema ### 25 | class Payload(BaseModel): 26 | key: str 27 | value: str 28 | 29 | 30 | ### Routing ### 31 | @get( 32 | "/{key:str}", 33 | ) 34 | async def getter(key: str, cachette: Cachette) -> str: 35 | """ 36 | Returns key value 37 | """ 38 | value: str = await cachette.fetch(key) 39 | return value 40 | 41 | 42 | @post("/", tags=["Payload"]) 43 | async def setter(data: Payload, cachette: Cachette) -> str: 44 | """ 45 | Submit a new cache key-pair value 46 | """ 47 | await cachette.put(data.key, data.value) 48 | return "OK" 49 | 50 | 51 | app: Litestar = Litestar( 52 | route_handlers=[getter, setter], 53 | dependencies={"cachette": Provide(Cachette, sync_to_thread=True)}, 54 | ) 55 | 56 | 57 | __all__ = ("app",) 58 | -------------------------------------------------------------------------------- /examples/litestar_redis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/litestar_redis.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | from cachette import Cachette 13 | from litestar import Litestar, get, post 14 | from litestar.di import Provide 15 | from pydantic import BaseModel 16 | 17 | 18 | ### Cachette Configurations ### 19 | @Cachette.load_config 20 | def get_cachette_config(): 21 | return [("backend", "redis"), ("redis_url", "redis://localhost:6379")] 22 | 23 | 24 | ### Schema ### 25 | class Payload(BaseModel): 26 | key: str 27 | value: str 28 | 29 | 30 | ### Routing ### 31 | @get( 32 | "/{key:str}", 33 | ) 34 | async def getter(key: str, cachette: Cachette) -> str: 35 | """ 36 | Returns key value 37 | """ 38 | value: str = await cachette.fetch(key) 39 | return value 40 | 41 | 42 | @post("/", tags=["Payload"]) 43 | async def setter(data: Payload, cachette: Cachette) -> str: 44 | """ 45 | Submit a new cache key-pair value 46 | """ 47 | await cachette.put(data.key, data.value) 48 | return "OK" 49 | 50 | 51 | app: Litestar = Litestar( 52 | route_handlers=[getter, setter], 53 | dependencies={"cachette": Provide(Cachette)}, 54 | ) 55 | 56 | 57 | __all__ = ("app",) 58 | -------------------------------------------------------------------------------- /examples/litestar_valkey.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/litestar_valkey.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2024-04-11 15:34 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | from cachette import Cachette 13 | from litestar import Litestar, get, post 14 | from litestar.di import Provide 15 | from pydantic import BaseModel 16 | 17 | 18 | ### Cachette Configurations ### 19 | @Cachette.load_config 20 | def get_cachette_config(): 21 | return [("backend", "valkey"), ("valkey_url", "valkey://localhost:6379")] 22 | 23 | 24 | ### Schema ### 25 | class Payload(BaseModel): 26 | key: str 27 | value: str 28 | 29 | 30 | ### Routing ### 31 | @get( 32 | "/{key:str}", 33 | ) 34 | async def getter(key: str, cachette: Cachette) -> str: 35 | """ 36 | Returns key value 37 | """ 38 | value: str = await cachette.fetch(key) 39 | return value 40 | 41 | 42 | @post("/", tags=["Payload"]) 43 | async def setter(data: Payload, cachette: Cachette) -> str: 44 | """ 45 | Submit a new cache key-pair value 46 | """ 47 | await cachette.put(data.key, data.value) 48 | return "OK" 49 | 50 | 51 | app: Litestar = Litestar( 52 | route_handlers=[getter, setter], 53 | dependencies={"cachette": Provide(Cachette)}, 54 | ) 55 | 56 | 57 | __all__ = ("app",) 58 | -------------------------------------------------------------------------------- /examples/starlette_memcached.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/starlette_memcached.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2024-02-02 22:27 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | from cachette import Cachette 13 | from pydantic import BaseModel, ValidationError 14 | from starlette.applications import Starlette 15 | from starlette.requests import Request 16 | from starlette.responses import JSONResponse, PlainTextResponse 17 | from starlette.routing import Route 18 | from typing import List 19 | 20 | 21 | @Cachette.load_config 22 | def get_cachette_config(): 23 | return [("backend", "memcached"), ("memcached_host", "localhost")] 24 | 25 | 26 | ### Schema ### 27 | class Payload(BaseModel): 28 | key: str 29 | value: str 30 | 31 | 32 | ### Routing ### 33 | async def setter(request: Request): 34 | """ 35 | Submit a new cache key-pair value 36 | """ 37 | data = await request.json() 38 | try: 39 | payload: Payload = Payload(**data) 40 | cachette: Cachette = Cachette() 41 | await cachette.put(payload.key, payload.value) 42 | except ValidationError as err: 43 | return JSONResponse({"error": err.json()}) 44 | return PlainTextResponse("OK") 45 | 46 | 47 | async def getter(request: Request): 48 | """ 49 | Returns key value 50 | """ 51 | key: str = request.path_params["key"] 52 | if not key: 53 | return JSONResponse({"error": "Missing key"}) 54 | cachette: Cachette = Cachette() 55 | value: str = await cachette.fetch(key) 56 | return PlainTextResponse(value) 57 | 58 | 59 | routes: List[Route] = [] 60 | routes.append(Route("/{key:str}", getter, methods=["GET"])) 61 | routes.append(Route("/", setter, methods=["POST"])) 62 | app: Starlette = Starlette(routes=routes) 63 | 64 | 65 | __all__ = ("app",) 66 | -------------------------------------------------------------------------------- /examples/starlette_pickle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/examples/starlette_pickle.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2024-02-02 22:27 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | from cachette import Cachette 13 | from os import remove 14 | from os.path import isfile 15 | from pydantic import BaseModel, ValidationError 16 | from starlette.applications import Starlette 17 | from starlette.requests import Request 18 | from starlette.responses import JSONResponse, PlainTextResponse 19 | from starlette.routing import Route 20 | from typing import List 21 | 22 | 23 | @Cachette.load_config 24 | def get_cachette_config(): 25 | return [("backend", "pickle"), ("pickle_path", "examples/cachette.pkl")] 26 | 27 | 28 | ### Schema ### 29 | class Payload(BaseModel): 30 | key: str 31 | value: str 32 | 33 | 34 | ### Routing ### 35 | async def setter(request: Request): 36 | """ 37 | Submit a new cache key-pair value 38 | """ 39 | data = await request.json() 40 | try: 41 | payload: Payload = Payload(**data) 42 | cachette: Cachette = Cachette() 43 | await cachette.put(payload.key, payload.value) 44 | except ValidationError as err: 45 | return JSONResponse({"error": err.json()}) 46 | return PlainTextResponse("OK") 47 | 48 | 49 | async def getter(request: Request): 50 | """ 51 | Returns key value 52 | """ 53 | key: str = request.path_params["key"] 54 | if not key: 55 | return JSONResponse({"error": "Missing key"}) 56 | cachette: Cachette = Cachette() 57 | value: str = await cachette.fetch(key) 58 | return PlainTextResponse(value) 59 | 60 | 61 | def shutdown() -> None: 62 | """ 63 | Remove cachette pickle when App shuts down 64 | """ 65 | if isfile("examples/cachette.pkl"): 66 | remove("examples/cachette.pkl") 67 | 68 | 69 | routes: List[Route] = [] 70 | routes.append(Route("/{key:str}", getter, methods=["GET"])) 71 | routes.append(Route("/", setter, methods=["POST"])) 72 | app: Starlette = Starlette(on_shutdown=[shutdown], routes=routes) 73 | 74 | 75 | __all__ = ("app",) 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = 'hatchling.build' 3 | requires = [ 'hatchling' ] 4 | 5 | 6 | [project] 7 | authors = [ 8 | { email = 'aekazitt+github@gmail.com', name = 'Sitt Guruvanich' }, 9 | ] 10 | classifiers = [ 11 | 'Environment :: Web Environment', 12 | 'Intended Audience :: Developers', 13 | 'License :: OSI Approved :: MIT License', 14 | 'Programming Language :: Python :: 3', 15 | 'Programming Language :: Python :: 3.9', 16 | 'Programming Language :: Python :: 3.10', 17 | 'Programming Language :: Python :: 3.11', 18 | 'Programming Language :: Python :: 3.12', 19 | 'Programming Language :: Python :: 3.13', 20 | 'Programming Language :: Python :: 3 :: Only', 21 | 'Operating System :: OS Independent', 22 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 23 | 'Topic :: Software Development :: Libraries :: Python Modules' 24 | ] 25 | dependencies = [ 26 | 'pydantic >=2.6.0', 27 | ] 28 | description = 'Cache extension for ASGI frameworks' 29 | homepage = 'https://github.com/aekasitt/cachette' 30 | keywords = [ 31 | 'starlette', 'fastapi', 'cache', 'redis', 'aioredis', 'aiobotocore', 32 | 'asynchronous', 'memcached', 'aiomcache', 'mongodb', 'motor' 33 | ] 34 | license = 'MIT' 35 | name = 'cachette' 36 | readme = 'README.md' 37 | repository = 'https://github.com/aekasitt/cachette' 38 | requires-python = '>=3.9.19' 39 | version = '0.1.9' 40 | 41 | 42 | [project.optional-dependencies] 43 | dataframe = [ 44 | 'pandas >=1.4.0', 45 | 'pyarrow >=10.0.1', 46 | ] 47 | examples = [ 48 | 'aiomcache >=0.7.0', 49 | 'blacksheep >=2.0.6', 50 | 'redis >=4.2.1', 51 | 'types-redis >=4.1.21', 52 | 'uvicorn >=0.15.0', 53 | 'litestar >=2.3.2', 54 | ] 55 | memcached = [ 56 | 'aiomcache >=0.7.0', 57 | ] 58 | mongodb = [ 59 | 'motor >=3.4.0', 60 | 'pymongo ==4.8.0', 61 | ] 62 | msgpack = [ 63 | 'msgpack >=1.0.3', 64 | ] 65 | orjson = [ 66 | 'orjson >=3.6.7', 67 | ] 68 | redis = [ 69 | 'redis >=4.2.1', 70 | 'types-redis >=4.1.21', 71 | ] 72 | test = [ 73 | 'fastapi ==0.103.2; python_version == "3.9"', 74 | 'fastapi >=0.104.0; python_version >= "3.10"', 75 | 'httpx >=0.24.1', 76 | 'pytest >=8.3.3', 77 | 'pytest-asyncio >=0.24.0', 78 | 'starlette >=0', 79 | ] 80 | thewholeshebang = [ 81 | 'aiomcache >=0.7.0', 82 | 'blacksheep >=2.0.6', 83 | 'fastapi ==0.103.2; python_version == "3.9"', 84 | 'fastapi >=0.104.0; python_version >= "3.10"', 85 | 'httpx >=0.24.1', 86 | 'litestar >=2.3.2', 87 | 'motor >=3.4.0', 88 | 'msgpack >=1.0.3', 89 | 'mypy >=1.8.0', 90 | 'orjson >=3.6.7', 91 | 'pandas >=1.4.0', 92 | 'pyarrow >=10.0.1', 93 | 'pymongo ==4.8.0', 94 | 'pytest >=8.3.3', 95 | 'pytest-asyncio >=0.24.0', 96 | 'redis >=4.2.1', 97 | 'types-redis >=4.1.21', 98 | 'uvicorn >=0.15.0', 99 | ] 100 | 101 | 102 | [tool.mypy] 103 | disallow_incomplete_defs = true 104 | disallow_untyped_calls = true 105 | disallow_untyped_defs = true 106 | ignore_missing_imports = true 107 | 108 | 109 | [tool.pytest.ini_options] 110 | addopts = '--strict-markers --tb=short -s -rxXs' 111 | asyncio_default_fixture_loop_scope = 'function' 112 | asyncio_mode = 'auto' 113 | filterwarnings = ['ignore::DeprecationWarning', 'ignore::FutureWarning'] 114 | python_files = '*.py' 115 | testpaths = [ 'tests' ] 116 | 117 | 118 | [tool.ruff] 119 | indent-width = 2 120 | line-length = 100 121 | target-version = 'py310' 122 | 123 | 124 | [tool.ruff.lint.per-file-ignores] 125 | '__init__.py' = ['F401'] # Ignore unused imports 126 | 127 | 128 | [tool.uv] 129 | dev-dependencies = [ 130 | 'mypy >=1.8.0', 131 | 'ruff >=0.3.5', 132 | ] 133 | -------------------------------------------------------------------------------- /src/cachette/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/__init__.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """FastAPI extension that provides Cache Implementation Support""" 13 | 14 | __version__ = "0.1.8" 15 | 16 | from cachette.core import Cachette 17 | 18 | __all__ = ("Cachette",) 19 | -------------------------------------------------------------------------------- /src/cachette/backends/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/backends/__init__.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module containing `Backend` abstract class to be inherited by implementation-specific backends""" 13 | 14 | ### Standard packages ### 15 | from abc import abstractmethod 16 | from time import time 17 | from typing import Any, Optional, Tuple 18 | 19 | 20 | class Backend: 21 | @property 22 | def now(self) -> int: 23 | return int(time()) 24 | 25 | @abstractmethod 26 | async def fetch(self, key: str) -> Any: 27 | """ 28 | Abstract Method: Fetches the value from cache 29 | 30 | --- 31 | :param: key `str` identifies key-value pair 32 | """ 33 | raise NotImplementedError 34 | 35 | @abstractmethod 36 | async def fetch_with_ttl(self, key: str) -> Tuple[int, Any]: 37 | """ 38 | Abstract Method: Fetches the value from cache as well as remaining time to live. 39 | 40 | --- 41 | :param: key `str` identifies key-value pair 42 | :returns: `Tuple[int, str]` containing timetolive value (ttl) and value 43 | """ 44 | raise NotImplementedError 45 | 46 | @abstractmethod 47 | async def put(self, key: str, value: Any, ttl: Optional[int] = None) -> None: 48 | """ 49 | Abstract Method: Puts the value within the cache with key and assigned time-to-live value 50 | 51 | --- 52 | :param: key `str` identifies key-value pair 53 | :param: value `Any` value to have stored identified by key 54 | :param: ttl `int` time before value expires within cache; default: `None` 55 | :returns: `None` 56 | """ 57 | raise NotImplementedError 58 | 59 | @abstractmethod 60 | async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int: 61 | """ 62 | Abstract Method: Clears the cache identified by given `namespace` or `key` 63 | 64 | --- 65 | :param: namespace `str` identifies namespace to have entire cache cleared; default: `None` 66 | :param: key `str` identifies key-value pair to be cleared from cache; default: `None` 67 | :returns: `int` amount of items cleared 68 | """ 69 | raise NotImplementedError 70 | 71 | 72 | __all__ = ("Backend",) 73 | -------------------------------------------------------------------------------- /src/cachette/backends/inmemory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/backends/inmemory.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining backend subclass used with In-memory key-value store""" 13 | 14 | ### Standard packages ### 15 | from typing import Any, Dict, List, Optional, Tuple 16 | 17 | ### Third-party packages ### 18 | from pydantic import BaseModel, StrictBytes, StrictInt, StrictStr 19 | 20 | ### Local modules ### 21 | from cachette.backends import Backend 22 | from cachette.codecs import Codec 23 | 24 | 25 | class Value(BaseModel): 26 | data: StrictBytes 27 | expires: StrictInt 28 | 29 | 30 | class InMemoryBackend(Backend): 31 | store: Dict[str, Value] = {} 32 | 33 | def __init__(self, codec: Codec, ttl: StrictInt): 34 | self.codec = codec 35 | self.ttl = ttl 36 | 37 | async def fetch(self, key: StrictStr) -> Any: 38 | value: Optional[Value] = self.store.get(key) 39 | if not value: 40 | return 41 | elif value.expires < self.now: 42 | del self.store[key] 43 | else: 44 | return self.codec.loads(value.data) 45 | 46 | async def fetch_with_ttl(self, key: StrictStr) -> Tuple[int, Any]: 47 | value: Optional[Value] = self.store.get(key) 48 | if not value: 49 | return -1, None 50 | if value.expires < self.now: 51 | del self.store[key] 52 | return (0, None) 53 | else: 54 | return (value.expires - self.now, self.codec.loads(value.data)) 55 | 56 | async def put(self, key: StrictStr, value: StrictStr, ttl: Optional[StrictInt] = None) -> None: 57 | data: bytes = self.codec.dumps(value) 58 | expires: int = self.now + (ttl or self.ttl) 59 | self.store[key] = Value(data=data, expires=expires) 60 | 61 | async def clear( 62 | self, namespace: Optional[StrictStr] = None, key: Optional[StrictStr] = None 63 | ) -> int: 64 | count: int = 0 65 | if namespace: 66 | keys: List[str] = list(filter(lambda key: key.startswith(namespace or ""), self.store.keys())) 67 | for key in keys: 68 | del self.store[key] 69 | count += 1 70 | elif key and self.store.get(key): 71 | del self.store[key] 72 | count += 1 73 | return count 74 | 75 | 76 | __all__ = ("InMemoryBackend",) 77 | -------------------------------------------------------------------------------- /src/cachette/backends/memcached.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/backends/memcached.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining `MemcachedBackend` backend subclass used with Memcached key-value store""" 13 | 14 | ### Standard packages ### 15 | from typing import Any, Optional, Tuple 16 | 17 | ### Third-party packages ### 18 | from aiomcache import Client 19 | from pydantic import BaseModel, StrictStr 20 | 21 | ### Local modules ### 22 | from cachette.backends import Backend 23 | from cachette.codecs import Codec 24 | 25 | 26 | class MemcachedBackend(Backend, BaseModel): 27 | class Config: 28 | arbitrary_types_allowed: bool = True 29 | 30 | ### member vars ### 31 | codec: Codec 32 | memcached_host: StrictStr 33 | ttl: int 34 | 35 | async def fetch(self, key: str) -> Any: 36 | data: Optional[bytes] = await self.mcache.get(key.encode()) 37 | if data: 38 | return self.codec.loads(data) 39 | 40 | async def fetch_with_ttl(self, key: str) -> Tuple[int, Any]: 41 | data: Optional[bytes] = await self.mcache.get(key.encode()) 42 | if data: 43 | return 3600, self.codec.loads(data) 44 | return 0, None 45 | 46 | async def put(self, key: str, value: Any, ttl: Optional[int] = None) -> None: 47 | data: bytes = self.codec.dumps(value) 48 | await self.mcache.set(key.encode(), data, exptime=ttl or self.ttl) 49 | 50 | async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int: 51 | count: int = 0 52 | if namespace: 53 | raise NotImplementedError 54 | elif key: 55 | count += (0, 1)[await self.mcache.delete(key.encode())] 56 | return count 57 | 58 | @property 59 | def mcache(self) -> Client: 60 | return Client(host=self.memcached_host) 61 | 62 | 63 | __all__ = ("MemcachedBackend",) 64 | -------------------------------------------------------------------------------- /src/cachette/backends/mongodb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/backends/mongodb.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-05 14:14 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining backend subclass used with MongoDB key-value database""" 13 | 14 | ### Standard packages ### 15 | from typing import Any, Optional, Tuple 16 | 17 | ### Third-party packages ### 18 | from motor.motor_asyncio import ( 19 | AsyncIOMotorClient, 20 | AsyncIOMotorCollection, 21 | AsyncIOMotorDatabase, 22 | ) 23 | from pydantic import BaseModel, StrictInt, StrictStr 24 | 25 | ### Local modules ### 26 | from cachette.backends import Backend 27 | from cachette.codecs import Codec 28 | 29 | 30 | class MongoDBBackend(Backend, BaseModel): 31 | class Config: 32 | arbitrary_types_allowed: bool = True 33 | 34 | codec: Codec 35 | database_name: StrictStr 36 | table_name: StrictStr 37 | ttl: StrictInt 38 | url: StrictStr 39 | 40 | @property 41 | def db(self) -> AsyncIOMotorDatabase: 42 | return AsyncIOMotorClient(self.url)[self.database_name] 43 | 44 | @property 45 | def collection(self) -> AsyncIOMotorCollection: 46 | return AsyncIOMotorClient(self.url)[self.database_name][self.table_name] 47 | 48 | @classmethod 49 | async def init( 50 | cls, 51 | codec: Codec, 52 | database_name: StrictStr, 53 | table_name: StrictStr, 54 | ttl: StrictInt, 55 | url: StrictStr, 56 | ) -> "MongoDBBackend": 57 | client: AsyncIOMotorClient = AsyncIOMotorClient(url) 58 | ### Create Collection if None existed ### 59 | names: list = await client[database_name].list_collection_names(filter={"name": table_name}) 60 | if len(names) == 0: 61 | await client[database_name].create_collection(table_name) 62 | return cls( 63 | **{ 64 | "codec": codec, 65 | "database_name": database_name, 66 | "table_name": table_name, 67 | "ttl": ttl, 68 | "url": url, 69 | } 70 | ) 71 | 72 | async def fetch(self, key: StrictStr) -> Any: 73 | document: dict = await self.collection.find_one({"key": key}) 74 | if document and document.get("expires", 0) > self.now: 75 | value: bytes = document.get("value", None) 76 | return self.codec.loads(value) 77 | return None 78 | 79 | async def fetch_with_ttl(self, key: StrictStr) -> Tuple[int, Any]: 80 | document: dict = await self.collection.find_one({"key": key}) 81 | if document: 82 | value: bytes = document.get("value", None) 83 | ttl: int = document.get("expires", 0) - self.now 84 | if ttl < 0: 85 | return 0, None 86 | return ttl, self.codec.loads(value) 87 | return -1, None 88 | 89 | async def put(self, key: StrictStr, value: Any, ttl: Optional[StrictInt] = None) -> None: 90 | ttl = ttl or self.ttl 91 | data: bytes = self.codec.dumps(value) 92 | item: dict = {"key": key, "value": data, "expires": self.now + ttl} 93 | document: dict = await self.collection.find_one({"key": key}) 94 | if document: 95 | await self.collection.update_one({"key": key}, {"$set": item}) 96 | else: 97 | await self.collection.insert_one(item) 98 | 99 | async def clear( 100 | self, namespace: Optional[StrictStr] = None, key: Optional[StrictStr] = None 101 | ) -> int: 102 | count: int = 0 103 | if namespace: 104 | raise NotImplementedError 105 | elif key: 106 | document: dict = await self.collection.find_one({"key": key}) 107 | if document: 108 | exist: bool = document.get("expires", 0) > self.now 109 | result = await self.collection.delete_one({"key": key}) 110 | count += (0, 1)[exist and result.deleted_count > 0] 111 | return count 112 | 113 | 114 | __all__ = ("MongoDBBackend",) 115 | -------------------------------------------------------------------------------- /src/cachette/backends/pickle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/backends/pickle.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-11-22 23:29 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | 13 | ### Standard packages ### 14 | from pickle import load, dump 15 | from typing import Any, Dict, Optional, Tuple 16 | 17 | ### Third-party packages ### 18 | from pydantic import BaseModel, StrictInt, StrictStr 19 | 20 | ### Local modules ### 21 | from cachette.backends import Backend 22 | 23 | 24 | class Value(BaseModel): 25 | data: Any 26 | expires: StrictInt 27 | 28 | 29 | class PickleBackend(Backend, BaseModel): 30 | pickle_path: StrictStr 31 | ttl: StrictInt 32 | 33 | # def __init__(self, pickle_path: str, ttl: int) -> None: 34 | # ### TODO: reimplement ### 35 | # self.pickle_path = pickle_path 36 | # self.ttl = ttl 37 | # values: Dict[str, Value] 38 | # try: 39 | # with open(self.pickle_path, "rb") as f: 40 | # values = load(f) or {} 41 | # except FileNotFoundError: 42 | # values = {} 43 | # with open(self.pickle_path, "wb") as f: 44 | # dump(values, f) 45 | 46 | async def fetch(self, key: str) -> Optional[Any]: 47 | values: Dict[str, Value] 48 | try: 49 | with open(self.pickle_path, "rb") as f: 50 | values = load(f) or {} 51 | value: Value = values.get(key, None) 52 | if value is not None and value.expires < self.now: 53 | if key in values.keys(): 54 | values.pop(key) 55 | with open(self.pickle_path, "wb") as f: 56 | dump(values, f) 57 | return None 58 | return value.data if value is not None else None 59 | except FileNotFoundError: 60 | pass 61 | 62 | async def fetch_with_ttl(self, key: str) -> Tuple[int, Any]: 63 | values: Dict[str, Value] 64 | try: 65 | with open(self.pickle_path, "rb") as f: 66 | values = load(f) or {} 67 | value: Value = values.get(key, None) 68 | if value is not None and value.expires < self.now: 69 | if key in values.keys(): 70 | values.pop(key) 71 | with open(self.pickle_path, "wb") as f: 72 | dump(values, f) 73 | return (0, None) 74 | return (value.expires, value.data) if value is not None else None 75 | except FileNotFoundError: 76 | return (0, None) 77 | 78 | async def put(self, key: str, value: Any, ttl: Optional[int] = None) -> None: 79 | values: Dict[str, Value] 80 | try: 81 | with open(self.pickle_path, "rb") as f: 82 | values = load(f) 83 | except FileNotFoundError: 84 | values = {} 85 | values[key] = Value(data=value, expires=self.now + (ttl or self.ttl)) 86 | with open(self.pickle_path, "wb") as f: 87 | dump(values, f) 88 | 89 | async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int: 90 | if namespace is not None: 91 | raise NotImplemented 92 | elif key is not None: 93 | values: Dict[str, Value] 94 | try: 95 | with open(self.pickle_path, "rb") as f: 96 | values = load(f) 97 | except FileNotFoundError: 98 | return 0 99 | cleared: int = 0 100 | if key in values.keys(): 101 | value = values.pop(key) 102 | if value.expires >= self.now: 103 | cleared = 1 104 | with open(self.pickle_path, "wb") as f: 105 | dump(values, f) 106 | return cleared 107 | file_exists: bool = False 108 | try: 109 | with open(self.pickle_path, "rb") as _: 110 | file_exists = True 111 | except FileNotFoundError: 112 | pass 113 | if not file_exists: 114 | return 0 115 | # TODO: remove file 116 | # remove(pickle_path) 117 | 118 | 119 | __all__ = ("PickleBackend",) 120 | -------------------------------------------------------------------------------- /src/cachette/backends/redis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/backends/redis.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining `RedisBackend` backend subclass used with Redis key-value store""" 13 | 14 | ### Standard packages ### 15 | from typing import Any, Optional, Tuple 16 | 17 | ### Third-party packages ### 18 | from pydantic import BaseModel, StrictStr 19 | from redis.asyncio import Redis 20 | 21 | ### Local modules ### 22 | from cachette.backends import Backend 23 | from cachette.codecs import Codec 24 | 25 | 26 | class RedisBackend(Backend, BaseModel): 27 | class Config: 28 | arbitrary_types_allowed: bool = True 29 | 30 | codec: Codec 31 | redis_url: StrictStr 32 | ttl: int 33 | 34 | async def fetch(self, key: str) -> Any: 35 | data: bytes = await self.redis.get(key) 36 | if data: 37 | return self.codec.loads(data) 38 | 39 | async def fetch_with_ttl(self, key: str) -> Tuple[int, Any]: 40 | async with self.redis.pipeline(transaction=True) as pipe: 41 | data: bytes = await pipe.ttl(key).get(key).execute() 42 | if data: 43 | return self.codec.loads(data) 44 | return -1, None 45 | 46 | async def put(self, key: str, value: Any, ttl: Optional[int] = None) -> None: 47 | data: bytes = self.codec.dumps(value) 48 | await self.redis.set(key, data, ex=(ttl or self.ttl)) 49 | 50 | async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int: 51 | if namespace: 52 | lua: str = ( 53 | f'for i, key in ipairs(redis.call("KEYS", "{namespace}:*")) do redis.call("DEL", key); end' 54 | ) 55 | return await self.redis.eval(lua, numkeys=0) 56 | elif key: 57 | return await self.redis.delete(key) 58 | return 0 59 | 60 | @property 61 | def redis(self) -> Redis: 62 | return Redis.from_url(url=self.redis_url) 63 | 64 | 65 | __all__ = ("RedisBackend",) 66 | -------------------------------------------------------------------------------- /src/cachette/backends/valkey.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/backends/valkey.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2024-04-10 22:06 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining `ValkeyBackend` backend subclass used with Valkey key-value store""" 13 | 14 | ### Standard packages ### 15 | from typing import Any, Optional, Tuple 16 | 17 | ### Third-party packages ### 18 | from pydantic import BaseModel, StrictStr 19 | from redis.asyncio import Redis 20 | 21 | ### Local modules ### 22 | from cachette.backends import Backend 23 | from cachette.codecs import Codec 24 | 25 | 26 | class ValkeyBackend(Backend, BaseModel): 27 | class Config: 28 | arbitrary_types_allowed: bool = True 29 | 30 | codec: Codec 31 | ttl: int 32 | valkey_url: StrictStr 33 | 34 | async def fetch(self, key: str) -> Any: 35 | data: bytes = await self.redis.get(key) 36 | if data: 37 | return self.codec.loads(data) 38 | 39 | async def fetch_with_ttl(self, key: str) -> Tuple[int, Any]: 40 | async with self.redis.pipeline(transaction=True) as pipe: 41 | data: bytes = await pipe.ttl(key).get(key).execute() 42 | if data: 43 | return self.codec.loads(data) 44 | return -1, None 45 | 46 | async def put(self, key: str, value: Any, ttl: Optional[int] = None) -> None: 47 | data: bytes = self.codec.dumps(value) 48 | await self.redis.set(key, data, ex=(ttl or self.ttl)) 49 | 50 | async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int: 51 | if namespace: 52 | lua: str = ( 53 | f'for i, key in ipairs(redis.call("KEYS", "{namespace}:*")) do redis.call("DEL", key); end' 54 | ) 55 | return await self.redis.eval(lua, numkeys=0) 56 | elif key: 57 | return await self.redis.delete(key) 58 | return 0 59 | 60 | @property 61 | def redis(self) -> Redis: 62 | return Redis.from_url(url=self.valkey_url.replace("valkey://", "redis://")) 63 | 64 | 65 | __all__ = ("ValkeyBackend",) 66 | -------------------------------------------------------------------------------- /src/cachette/cachette_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/cachette_config.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module containing `CachetteConfig` class""" 13 | 14 | ### Standard packages ### 15 | from typing import Callable, List, Optional, Tuple 16 | 17 | ### Third-party packages ### 18 | from pydantic import ValidationError 19 | 20 | ### Local modules ### 21 | from cachette.load_config import LoadConfig 22 | 23 | 24 | class CachetteConfig(object): 25 | ### Basics ### 26 | _backend: str = "inmemory" 27 | _codec: str = "vanilla" 28 | _ttl: int = 60 29 | 30 | ### Memcached ### 31 | _memcached_host: str 32 | 33 | ### MongoDB ### 34 | _database_name: str = "cachette-db" 35 | _mongodb_url: str 36 | _table_name: str = "cachette" 37 | 38 | ### Pickle ### 39 | _pickle_path: str 40 | 41 | ### Redis ### 42 | _redis_url: str 43 | 44 | ### Valkey ### 45 | _valkey_url: str 46 | 47 | @classmethod 48 | def load_config(cls, settings: Callable[..., List[Tuple]]) -> None: 49 | """ 50 | Loads the Configuration from a Pydantic "BaseSettings" object or a List of parameter tuples. 51 | If not specified otherwise, each item should be provided as a string. 52 | 53 | --- 54 | backend -- optional; must be one of ["inmemory", "memcached", "mongodb", "pickle", "redis"]; 55 | defaults to using inmemory option which required no extra package dependencies. To use 56 | other listed options; See installation guide on the README.md at 57 | [Repository Page](https://github.com/aekasitt/cachette). 58 | codec -- optional; serialization and de-serialization format to have cache values stored in 59 | the cache backend of choice as a string of selected encoding. once fetched, will have their 60 | decoded values returned of the same format. must be one of ["feather", "msgpack", "parquet", 61 | "pickle"]; if none is defined, will vanilla codec of basic string conversion will be used. 62 | database_name -- required when backend set to "mongodb"; the database name to be automatically 63 | created if not exists on the MongoDB instance and store the cache table; defaults to 64 | "cachette-db" 65 | memcached_host -- required when backend set to "memcached"; the host endpoint to the memcached 66 | distributed memory caching system. 67 | pickle_path -- required when backend set to "pickle"; the file-system path to create local 68 | store using python pickling on local directory 69 | redis_url -- required when backend set to "redis"; the url set to redis-server instance with 70 | or without provided authentication in such formats "redis://user:password@host:port" and 71 | "redis://host:port" respectively. 72 | table_name -- required when backend set to "dynamodb" or "mongodb"; name of the cache table or 73 | collection in case of "mongodb" backend to have key-value pairs stored; defaults to 74 | "cachette". 75 | ttl -- optional; the time-to-live or amount before this cache item expires within the cache; 76 | defaults to 60 (seconds) and must be between 1 second to 1 hour (3600 seconds). 77 | valkey_url -- required when backend set to "valkey"; the url set to valkey-server instance 78 | with or without provided authentication in such formats "valkey://user:password@host:port" 79 | and "valkey://host:port" respectively. 80 | """ 81 | try: 82 | config = LoadConfig(**{key.lower(): value for key, value in settings()}) 83 | cls._backend = config.backend or cls._backend 84 | cls._codec = config.codec or cls._codec 85 | cls._ttl = config.ttl or cls._ttl 86 | cls._redis_url = config.redis_url or "" 87 | cls._memcached_host = config.memcached_host or "" 88 | cls._database_name = config.database_name or cls._database_name 89 | cls._mongodb_url = config.mongodb_url or "" 90 | cls._pickle_path = config.pickle_path 91 | cls._table_name = config.table_name or cls._table_name 92 | cls._valkey_url = config.valkey_url or "" 93 | except ValidationError: 94 | raise 95 | except Exception: 96 | raise TypeError('CachetteConfig must be pydantic "BaseSettings" or list of tuples') 97 | 98 | 99 | __all__ = ("CachetteConfig",) 100 | -------------------------------------------------------------------------------- /src/cachette/codecs/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/codecs/__init__.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-06 15:38 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining `Codec` abstract class defining subclasses' method schemas""" 13 | 14 | ### Standard packages ### 15 | from abc import abstractmethod 16 | from typing import Any 17 | 18 | 19 | class Codec: 20 | @abstractmethod 21 | def dumps(self, obj: Any) -> bytes: 22 | """ 23 | ... 24 | """ 25 | raise NotImplementedError 26 | 27 | @abstractmethod 28 | def loads(self, data: bytes) -> Any: 29 | """ 30 | ... 31 | """ 32 | raise NotImplementedError 33 | 34 | 35 | __all__ = ("Codec",) 36 | -------------------------------------------------------------------------------- /src/cachette/codecs/dataframe/csv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/codecs/dataframe/csv.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-09 13:02 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Module defining `CSVCodec` codec subclass used for decoding and encoding csv-formatted dataframes 14 | """ 15 | 16 | ### Standard packages ### 17 | from io import BytesIO, StringIO 18 | 19 | ### Third-party packages ### 20 | from pandas import DataFrame, read_csv 21 | 22 | ### Local modules ## 23 | from cachette.codecs import Codec 24 | 25 | 26 | class CSVCodec(Codec): 27 | def dumps(self, obj: DataFrame) -> bytes: 28 | bytes_io: BytesIO = BytesIO() 29 | obj.to_csv(bytes_io) 30 | return bytes_io.getvalue() 31 | 32 | def loads(self, data: bytes) -> DataFrame: 33 | string_io: StringIO = StringIO(data.decode()) 34 | return read_csv(string_io, index_col=0) 35 | 36 | 37 | __all__ = ("CSVCodec",) 38 | -------------------------------------------------------------------------------- /src/cachette/codecs/dataframe/feather.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/codecs/dataframe/feather.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-09 13:02 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining `FeatherCodec` codec subclass used for decoding and encoding feather dataframes""" 13 | 14 | ### Standard packages ### 15 | from io import BytesIO 16 | 17 | ### Third-party packages ### 18 | from pandas import DataFrame, read_feather 19 | 20 | ### Local modules ## 21 | from cachette.codecs import Codec 22 | 23 | 24 | class FeatherCodec(Codec): 25 | def dumps(self, df: DataFrame) -> bytes: 26 | bytes_io: BytesIO = BytesIO() 27 | df.to_feather(bytes_io) 28 | return bytes_io.getvalue() 29 | 30 | def loads(self, data: bytes) -> DataFrame: 31 | bytes_io: BytesIO = BytesIO(data) 32 | return read_feather(bytes_io) 33 | 34 | 35 | __all__ = ("FeatherCodec",) 36 | -------------------------------------------------------------------------------- /src/cachette/codecs/dataframe/parquet.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/codecs/dataframe/parquet.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-09 13:02 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining `ParquetCodec` codec subclass for decoding and encoding parquet dataframes""" 13 | 14 | ### Standard packages ### 15 | from io import BytesIO 16 | 17 | ### Third-party packages ### 18 | from pandas import DataFrame, read_parquet 19 | 20 | ### Local modules ## 21 | from cachette.codecs import Codec 22 | 23 | 24 | class ParquetCodec(Codec): 25 | def dumps(self, df: DataFrame) -> bytes: 26 | bytes_io: BytesIO = BytesIO() 27 | df.to_parquet(bytes_io) 28 | return bytes_io.getvalue() 29 | 30 | def loads(self, data: bytes) -> DataFrame: 31 | bytes_io: BytesIO = BytesIO(data) 32 | return read_parquet(bytes_io) 33 | 34 | 35 | __all__ = ("ParquetCodec",) 36 | -------------------------------------------------------------------------------- /src/cachette/codecs/json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/codecs/json.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-07 12:23 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Module defining `JSONCodec` codec subclass used for decoding and encoding json-formatted data 14 | using python standard `json` library 15 | """ 16 | 17 | ### Standard packages ### 18 | from typing import Any 19 | 20 | ### Third-party packages ### 21 | from json import dumps, loads 22 | 23 | ### Local modules ## 24 | from cachette.codecs import Codec 25 | 26 | 27 | class JSONCodec(Codec): 28 | def dumps(self, obj: Any) -> bytes: 29 | return dumps(obj).encode() 30 | 31 | def loads(self, data: bytes) -> Any: 32 | return loads(data) 33 | 34 | 35 | __all__ = ("JSONCodec",) 36 | -------------------------------------------------------------------------------- /src/cachette/codecs/msgpack.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/codecs/msgpack.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-07 2:08 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Module defining `MsgpackCodec` codec subclass used for decoding and encoding msgpack-formatted data 14 | """ 15 | 16 | ### Standard packages ### 17 | from typing import Any 18 | 19 | ### Third-party packages ### 20 | from msgpack import dumps, loads 21 | 22 | ### Local modules ## 23 | from cachette.codecs import Codec 24 | 25 | 26 | class MsgpackCodec(Codec): 27 | def dumps(self, obj: Any) -> bytes: 28 | return dumps(obj) 29 | 30 | def loads(self, data: bytes) -> Any: 31 | return loads(data) 32 | 33 | 34 | __all__ = ("MsgpackCodec",) 35 | -------------------------------------------------------------------------------- /src/cachette/codecs/orjson.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/codecs/json.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-07 12:23 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Module defining `ORJSONCodec` codec subclass used for decoding and encoding json-formatted data 14 | using `orjson` library 15 | """ 16 | 17 | ### Standard packages ### 18 | from typing import Any 19 | 20 | ### Third-party packages ### 21 | from orjson import dumps, loads 22 | 23 | ### Local modules ## 24 | from cachette.codecs import Codec 25 | 26 | 27 | class ORJSONCodec(Codec): 28 | def dumps(self, obj: Any) -> bytes: 29 | return dumps(obj) 30 | 31 | def loads(self, data: bytes) -> Any: 32 | return loads(data) 33 | 34 | 35 | __all__ = ("ORJSONCodec",) 36 | -------------------------------------------------------------------------------- /src/cachette/codecs/pickle.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/codecs/pickle.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-06 15:38 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining `PickleCodec` codec subclass used for decoding and encoding pickled data""" 13 | 14 | ### Standard packages ### 15 | from pickle import dumps, loads 16 | from typing import Any 17 | 18 | ### Local modules ### 19 | from cachette.codecs import Codec 20 | 21 | 22 | class PickleCodec(Codec): 23 | def dumps(self, obj: Any) -> bytes: 24 | return dumps(obj) 25 | 26 | def loads(self, data: bytes) -> Any: 27 | return loads(data) 28 | 29 | 30 | __all__ = ("PickleCodec",) 31 | -------------------------------------------------------------------------------- /src/cachette/codecs/vanilla.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/codecs/vanilla.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-06 15:38 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Module defining `VanillaCodec` codec subclass used as default for decoding and encoding 14 | basic data string-casted for storage 15 | """ 16 | 17 | ### Standard packages ### 18 | from typing import Any 19 | 20 | ### Local modules ## 21 | from cachette.codecs import Codec 22 | 23 | 24 | class VanillaCodec(Codec): 25 | def dumps(self, obj: Any) -> bytes: 26 | return str(obj).encode("utf-8") 27 | 28 | def loads(self, data: bytes) -> Any: 29 | return data.decode("utf-8") 30 | 31 | 32 | __all__ = ("VanillaCodec",) 33 | -------------------------------------------------------------------------------- /src/cachette/core.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/core.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module containing Core implementation for Cachette extension for FastAPI""" 13 | 14 | ### Standard packages ### 15 | from asyncio import run 16 | from typing import Any, Optional, Tuple 17 | 18 | ### Local modules ### 19 | from cachette.backends import Backend 20 | from cachette.cachette_config import CachetteConfig 21 | from cachette.codecs import Codec 22 | 23 | 24 | class Cachette(CachetteConfig): 25 | backend: Backend 26 | 27 | def __init__(self): 28 | """ 29 | Invoked by FastAPI Depends 30 | """ 31 | # TODO Check request headers if `Cache-Control`` is `no-store` 32 | 33 | ### Determine Encoding and Decoding Codec ### 34 | codec: Codec 35 | if self._codec == "csv": 36 | from cachette.codecs.dataframe.csv import CSVCodec 37 | 38 | codec = CSVCodec() 39 | elif self._codec == "json": 40 | from cachette.codecs.json import JSONCodec 41 | 42 | codec = JSONCodec() 43 | elif self._codec == "feather": 44 | from cachette.codecs.dataframe.feather import FeatherCodec 45 | 46 | codec = FeatherCodec() 47 | elif self._codec == "msgpack": 48 | from cachette.codecs.msgpack import MsgpackCodec 49 | 50 | codec = MsgpackCodec() 51 | if self._codec == "orjson": 52 | from cachette.codecs.orjson import ORJSONCodec 53 | 54 | codec = ORJSONCodec() 55 | elif self._codec == "parquet": 56 | from cachette.codecs.dataframe.parquet import ParquetCodec 57 | 58 | codec = ParquetCodec() 59 | elif self._codec == "pickle": 60 | from cachette.codecs.pickle import PickleCodec 61 | 62 | codec = PickleCodec() 63 | elif self._codec == "vanilla": 64 | from cachette.codecs.vanilla import VanillaCodec 65 | 66 | codec = VanillaCodec() 67 | 68 | if self._backend == "inmemory": 69 | from cachette.backends.inmemory import InMemoryBackend 70 | 71 | self.backend = InMemoryBackend(codec=codec, ttl=self._ttl) 72 | elif self._backend == "memcached": 73 | from cachette.backends.memcached import MemcachedBackend 74 | 75 | self.backend = MemcachedBackend( 76 | codec=codec, memcached_host=self._memcached_host, ttl=self._ttl 77 | ) 78 | elif self._backend == "mongodb": 79 | from cachette.backends.mongodb import MongoDBBackend 80 | 81 | self.backend = run( 82 | MongoDBBackend.init( 83 | codec, 84 | self._database_name, 85 | self._table_name, 86 | self._ttl, 87 | self._mongodb_url, 88 | ) 89 | ) 90 | elif self._backend == "pickle": 91 | from cachette.backends.pickle import PickleBackend 92 | 93 | ### Ignore codec when pickle backend is chosen ### 94 | self.backend = PickleBackend(pickle_path=self._pickle_path, ttl=self._ttl) 95 | elif self._backend == "redis": 96 | from cachette.backends.redis import RedisBackend 97 | 98 | self.backend = RedisBackend(codec=codec, redis_url=self._redis_url, ttl=self._ttl) 99 | elif self._backend == "valkey": 100 | from cachette.backends.valkey import ValkeyBackend 101 | 102 | self.backend = ValkeyBackend(codec=codec, ttl=self._ttl, valkey_url=self._valkey_url) 103 | 104 | ### Override methods to initiated backend instance ### 105 | async def fetch(self, key: str) -> Any: 106 | """ 107 | Fetches the value from cache 108 | 109 | --- 110 | :param: key `str` identifies key-value pair 111 | """ 112 | return await self.backend.fetch(key) 113 | 114 | async def fetch_with_ttl(self, key: str) -> Tuple[int, Any]: 115 | """ 116 | Fetches the value from cache as well as remaining time to live. 117 | 118 | --- 119 | :param: key `str` identifies key-value pair 120 | :returns: `Tuple[int, str]` containing timetolive value (ttl) and value 121 | """ 122 | return await self.backend.fetch_with_ttl(key) 123 | 124 | async def put(self, key: str, value: Any, ttl: Optional[int] = None) -> None: 125 | """ 126 | Puts the value within the cache with key and assigned time-to-live value 127 | 128 | --- 129 | :param: key `str` identifies key-value pair 130 | :param: value `Any` value to have stored identified by key 131 | :param: ttl `int` time before value expires within cache; default: `None` 132 | """ 133 | await self.backend.put(key, value, ttl) 134 | 135 | async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int: 136 | """ 137 | Clears the cache identified by given `namespace` or `key` 138 | 139 | --- 140 | :param: namespace `str` identifies namespace to have entire cache cleared; default: `None` 141 | :param: key `str` identifies key-value pair to be cleared from cache; default: `None` 142 | :returns: `int` amount of items cleared 143 | """ 144 | return await self.backend.clear(namespace, key) 145 | 146 | 147 | __all__ = ("Cachette",) 148 | -------------------------------------------------------------------------------- /src/cachette/load_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/src/cachette/load_config.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 15:31 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module containing `LoadConfig` Pydantic model""" 13 | 14 | ### Standard packages ### 15 | from typing import Optional 16 | 17 | ### Third-party packages ### 18 | from pydantic import BaseModel, validator, StrictInt, StrictStr 19 | 20 | 21 | class LoadConfig(BaseModel): 22 | backend: Optional[StrictStr] = None 23 | codec: Optional[StrictStr] = None 24 | ttl: Optional[StrictInt] = None 25 | 26 | ### Memcached ### 27 | memcached_host: Optional[StrictStr] = None 28 | 29 | ### MongoDB ### 30 | database_name: Optional[StrictStr] = None 31 | mongodb_url: Optional[StrictStr] = None 32 | table_name: Optional[StrictStr] = None 33 | 34 | ### Pickle ### 35 | pickle_path: Optional[StrictStr] = None 36 | 37 | ### Redis ### 38 | redis_url: Optional[StrictStr] = None 39 | 40 | ### Valkey ### 41 | valkey_url: Optional[StrictStr] = None 42 | 43 | @validator("backend") 44 | def validate_backend(cls, value: str) -> str: 45 | if value.lower() not in { 46 | "inmemory", 47 | "memcached", 48 | "mongodb", 49 | "pickle", 50 | "redis", 51 | "valkey", 52 | }: 53 | raise ValueError( 54 | 'The "backend" value must be one of "inmemory", "memcached", "pickle", "redis" or "valkey".' 55 | ) 56 | return value.lower() 57 | 58 | @validator("ttl") 59 | def validate_time_to_live(cls, value: int) -> int: 60 | if value <= 0 or value > 3600: 61 | raise ValueError('The "ttl" value must between 1 or 3600 seconds.') 62 | return value 63 | 64 | @validator("codec") 65 | def validate_codec(cls, value: str) -> str: 66 | if value.lower() not in { 67 | "csv", 68 | "feather", 69 | "json", 70 | "msgpack", 71 | "orjson", 72 | "parquet", 73 | "pickle", 74 | }: 75 | raise ValueError( 76 | 'The "codec" value must be one of "csv", "feather", "json", "msgpack", "orjson", ' 77 | '"parquet", or "pickle".' 78 | ) 79 | ### TODO: validation when using DataFrame Codecs (csv, sql, feather, parquet) have pandas? ### 80 | return value 81 | 82 | @validator("redis_url", always=True) 83 | def validate_redis_url(cls, value: str, values: dict) -> str: 84 | if values["backend"].lower() == "redis" and not value: 85 | raise ValueError('The "redis_url" cannot be null when using redis as backend.') 86 | ### TODO: More validations ### 87 | return value 88 | 89 | @validator("memcached_host", always=True) 90 | def validate_memcached_host(cls, value: str, values: dict) -> str: 91 | if values["backend"].lower() == "memcached" and not value: 92 | raise ValueError('The "memcached_host" cannot be null when using memcached as backend.') 93 | ### TODO: More validations ### 94 | return value 95 | 96 | @validator("table_name") 97 | def validate_table_name(cls, value: str, values: dict) -> str: 98 | backend: str = values["backend"].lower() 99 | if backend == "mongodb" and not value: 100 | raise ValueError('The "table_name" cannot be null when using MongoDB as backend.') 101 | ### TODO: More validations ### 102 | return value 103 | 104 | @validator("database_name") 105 | def validate_database_name(cls, value: str, values: dict) -> str: 106 | backend: str = values["backend"].lower() 107 | if backend == "mongodb" and not value: 108 | raise ValueError('The "database_name" cannot be null when using MongoDB as backend.') 109 | ### TODO: More validations ### 110 | return value 111 | 112 | @validator("mongodb_url", always=True) 113 | def validate_mongodb_url(cls, value: str, values: dict) -> str: 114 | backend: str = values["backend"].lower() 115 | if backend == "mongodb" and not value: 116 | raise ValueError('The "mongodb_url" cannot be null when using MongoDB as backend.') 117 | ### TODO: More validations ### 118 | return value 119 | 120 | @validator("pickle_path", always=True) 121 | def validate_pickle_path(cls, value: str, values: dict) -> str: 122 | backend: str = values["backend"].lower() 123 | if backend == "pickle" and not value: 124 | raise ValueError('The "pickle_path" cannot be null when using pickle as backend.') 125 | ### TODO: More validations ### 126 | return value 127 | 128 | @validator("valkey_url", always=True) 129 | def validate_valkey_url(cls, value: str, values: dict) -> str: 130 | if values["backend"].lower() == "valkey" and not value: 131 | raise ValueError('The "valkey_url" cannot be null when using valkey as backend.') 132 | ### TODO: More validations ### 133 | return value 134 | 135 | 136 | __all__ = ("LoadConfig",) 137 | -------------------------------------------------------------------------------- /static/cachette-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aekasitt/cachette/5dd4804df8588c92ca308a98391b5c2a84ef8322/static/cachette-preview.png -------------------------------------------------------------------------------- /static/cachette.base64: -------------------------------------------------------------------------------- 1 | data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjgwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iODAwIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9InByZWZpeF9fYSIgeDE9IjUwJSIgeDI9IjUwJSIgeTE9IjAlIiB5Mj0iMTAwJSI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iaHNsKDE4NSwgNTMlLCA1NSUpIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSJoc2woMCwgNzMlLCA1NSUpIi8+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9InByZWZpeF9fYyIgeDE9IjUwJSIgeDI9IjUwJSIgeTE9IjAlIiB5Mj0iMTAwJSIgZ3JhZGllbnRUcmFuc2Zvcm09InJvdGF0ZSgyNzApIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSJoc2woMjM4LCA4MiUsIDEzJSkiLz48c3RvcCBvZmZzZXQ9IjQ1JSIgc3RvcC1jb2xvcj0iaHNsKDEuNCwgMTAwJSwgNjclKSIvPjxzdG9wIHN0b3AtY29sb3I9ImhzbCgxNjcsIDUyJSwgNzglKSIgb2Zmc2V0PSIxMDAlIi8+PC9saW5lYXJHcmFkaWVudD48bWFzayBpZD0icHJlZml4X19iIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNDAwIDB2MTI1bDIzMCAxMzV2MjgwTDQwMCA2NzV2MTI1aDQwMFYwTTQwMCAwdjEyNUwxNjAgMjYwdjI4MGwyNDAgMTM1djEyNUgwVjAiLz48L21hc2s+PC9kZWZzPjxyZWN0IGZpbGw9ImhzbCgyMzgsIDgyJSwgMTMlKSIgaGVpZ2h0PSIxMDAlIiB3aWR0aD0iMTAwJSIvPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9uZCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjgiIHN0cm9rZT0idXJsKCNwcmVmaXhfX2EpIj48cGF0aCBkPSJNNjI4IDUzMS42MzZMNDAwIDY2My4yNzIgMTcyIDUzMS42MzZWMjY4LjM2NGwyMjgtMTMxLjYzNiAyMjggMTMxLjYzNnYyNjMuMjcyeiIvPjxwYXRoIGQ9Ik02MjguNjYzIDUwNi42MjdMNDIxLjk4OSA2NTEuMzQyIDE5My4zMjYgNTQ0LjcxNWwtMjEuOTg5LTI1MS4zNDIgMjA2LjY3NC0xNDQuNzE1IDIyOC42NjMgMTA2LjYyNyAyMS45ODkgMjUxLjM0MnoiIG9wYWNpdHk9Ii45NiIvPjxwYXRoIGQ9Ik02MjYuNzc4IDQ4Mi41NEw0NDEuOTA3IDYzNy42NjZsLTIyNi43NzgtODIuNTQtNDEuOTA3LTIzNy42NjYgMTg0Ljg3MS0xNTUuMTI2IDIyNi43NzggODIuNTQgNDEuOTA3IDIzNy42NjZ6IiBvcGFjaXR5PSIuOTIiLz48cGF0aCBkPSJNNjIyLjUxMyA0NTkuNjIybC0xNjIuODkgMTYyLjg5MS0yMjIuNTE0LTU5LjYyMi01OS42MjItMjIyLjUxMyAxNjIuODktMTYyLjg5MSAyMjIuNTE0IDU5LjYyMiA1OS42MjIgMjIyLjUxM3oiIG9wYWNpdHk9Ii44OCIvPjxwYXRoIGQ9Ik02MTYuMDYgNDM4LjA5N0w0NzUuMDM3IDYwNi4xNjJsLTIxNi4wNi0zOC4wOTctNzUuMDM3LTIwNi4xNjIgMTQxLjAyMy0xNjguMDY1IDIxNi4wNiAzOC4wOTcgNzUuMDM3IDIwNi4xNjJ6IiBvcGFjaXR5PSIuODMiLz48cGF0aCBkPSJNNjA3LjYzIDQxOC4xNjVsLTExOS41NDcgMTcwLjczLTIwNy42My0xOC4xNjQtODguMDgzLTE4OC44OTYgMTE5LjU0Ny0xNzAuNzMgMjA3LjYzIDE4LjE2NCA4OC4wODMgMTg4Ljg5NnoiIG9wYWNpdHk9Ii43OSIvPjxwYXRoIGQ9Ik01OTcuNDU0IDQwMGwtOTguNzI3IDE3MUgzMDEuMjczbC05OC43MjctMTcxIDk4LjcyNy0xNzFoMTk3LjQ1NGw5OC43MjcgMTcxeiIgb3BhY2l0eT0iLjc1Ii8+PHBhdGggZD0iTTU4NS43NzQgMzgzLjc0N2wtNzguODExIDE2OS4wMTItMTg1Ljc3NCAxNi4yNTMtMTA2Ljk2My0xNTIuNzU5IDc4LjgxMS0xNjkuMDEyIDE4NS43NzQtMTYuMjUzIDEwNi45NjMgMTUyLjc1OXoiIG9wYWNpdHk9Ii43MSIvPjxwYXRoIGQ9Ik01NzIuODQ4IDM2OS41MjJsLTYwLjAzIDE2NC45My0xNzIuODQ3IDMwLjQ3OC0xMTIuODE5LTEzNC40NTIgNjAuMDMtMTY0LjkzIDE3Mi44NDctMzAuNDc4IDExMi44MTkgMTM0LjQ1MnoiIG9wYWNpdHk9Ii42NyIvPjxwYXRoIGQ9Ik01NTguOTM4IDM1Ny40MTNMNTE2LjM1IDUxNi4zNWwtMTU4LjkzOCA0Mi41ODctMTE2LjM1LTExNi4zNSA0Mi41ODYtMTU4LjkzOSAxNTguOTM4LTQyLjU4NyAxMTYuMzUgMTE2LjM1eiIgb3BhY2l0eT0iLjYzIi8+PHBhdGggZD0iTTU0NC4zMTMgMzQ3LjQ3NGwtMjYuNjY4IDE1MS4yNDItMTQ0LjMxMyA1Mi41MjYtMTE3LjY0NS05OC43MTYgMjYuNjY4LTE1MS4yNDIgMTQ0LjMxMy01Mi41MjYgMTE3LjY0NSA5OC43MTZ6IiBvcGFjaXR5PSIuNTkiLz48cGF0aCBkPSJNNTI5LjI0NCAzMzkuNzMybC0xMi40MjggMTQyLjA2My0xMjkuMjQ1IDYwLjI2OC0xMTYuODE1LTgxLjc5NSAxMi40MjgtMTQyLjA2MyAxMjkuMjQ1LTYwLjI2OCAxMTYuODE1IDgxLjc5NXoiIG9wYWNpdHk9Ii41NSIvPjxwYXRoIGQ9Ik01MTQgMzM0LjE4MnYxMzEuNjM2bC0xMTQgNjUuODE4LTExNC02NS44MThWMzM0LjE4MmwxMTQtNjUuODE4IDExNCA2NS44MTh6IiBvcGFjaXR5PSIuNSIvPjxwYXRoIGQ9Ik00OTguODQ0IDMzMC43ODlsMTAuNTE3IDEyMC4yMDctOTguODQ0IDY5LjIxMS0xMDkuMzYtNTAuOTk2LTEwLjUxOC0xMjAuMjA3IDk4Ljg0NC02OS4yMTEgMTA5LjM2IDUwLjk5NnoiIG9wYWNpdHk9Ii40NiIvPjxwYXRoIGQ9Ik00ODQuMDMyIDMyOS40ODlsMTkuMDQ5IDEwOC4wMy04NC4wMzIgNzAuNTExLTEwMy4wODEtMzcuNTE5LTE5LjA0OS0xMDguMDMgODQuMDMyLTcwLjUxMSAxMDMuMDgxIDM3LjUxOXoiIG9wYWNpdHk9Ii40MiIvPjxwYXRoIGQ9Ik00NjkuODEgMzMwLjE5bDI1LjU1MyA5NS4zNjItNjkuODEgNjkuODEtOTUuMzYzLTI1LjU1Mi0yNS41NTMtOTUuMzYyIDY5LjgxLTY5LjgxIDk1LjM2MyAyNS41NTJ6IiBvcGFjaXR5PSIuMzgiLz48cGF0aCBkPSJNNDU2LjQxIDMzMi43NzRsMzAuMDE0IDgyLjQ2NS01Ni40MSA2Ny4yMjYtODYuNDIzLTE1LjI0LTMwLjAxNS04Mi40NjQgNTYuNDEtNjcuMjI2IDg2LjQyMyAxNS4yNHoiIG9wYWNpdHk9Ii4zNCIvPjxwYXRoIGQ9Ik00NDQuMDQzIDMzNy4xbDMyLjQ1MiA2OS41OTMtNDQuMDQzIDYyLjktNzYuNDk1LTYuNjkyLTMyLjQ1Mi02OS41OTQgNDQuMDQzLTYyLjkgNzYuNDk1IDYuNjkyeiIgb3BhY2l0eT0iLjMiLz48cGF0aCBkPSJNNDMyLjkwOSAzNDNsMzIuOTA5IDU3LTMyLjkwOSA1N2gtNjUuODE4bC0zMi45MDktNTcgMzIuOTA5LTU3aDY1LjgxOHoiIG9wYWNpdHk9Ii4yNiIvPjxwYXRoIGQ9Ik00MjMuMTggMzUwLjI5bDMxLjQ2IDQ0LjkzLTIzLjE4IDQ5LjcwOS01NC42NCA0Ljc4LTMxLjQ2LTQ0LjkyOSAyMy4xOC00OS43MDkgNTQuNjQtNC43OHoiIG9wYWNpdHk9Ii4yMiIvPjxwYXRoIGQ9Ik00MTUuMDA3IDM1OC43NjhsMjguMjA1IDMzLjYxMy0xNS4wMDcgNDEuMjMyLTQzLjIxMiA3LjYyLTI4LjIwNS0zMy42MTQgMTUuMDA3LTQxLjIzMiA0My4yMTItNy42MnoiIG9wYWNpdHk9Ii4xNyIvPjxwYXRoIGQ9Ik00MDguNTE3IDM2OC4yMTJsMjMuMjcgMjMuMjctOC41MTcgMzEuNzg4LTMxLjc4NyA4LjUxOC0yMy4yNy0yMy4yNyA4LjUxNy0zMS43ODggMzEuNzg3LTguNTE4eiIgb3BhY2l0eT0iLjEzIi8+PHBhdGggZD0iTTQwMy44MSAzNzguMzk0bDE2LjgwNiAxNC4xMDItMy44MSAyMS42MDYtMjAuNjE2IDcuNTA0LTE2LjgwNi0xNC4xMDIgMy44MS0yMS42MDYgMjAuNjE2LTcuNTA0eiIgb3BhY2l0eT0iLjA5Ii8+PHBhdGggZD0iTTQwMC45NTYgMzg5LjA3Mmw4Ljk4NiA2LjI5Mi0uOTU2IDEwLjkyOC05Ljk0MiA0LjYzNi04Ljk4Ni02LjI5Mi45NTYtMTAuOTI4IDkuOTQyLTQuNjM2eiIgb3BhY2l0eT0iLjA1Ii8+PC9nPjxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZhZWJkNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBzdHJva2Utd2lkdGg9IjQiIGQ9Ik00MDAgMTMwbDIzNSAxMzV2MjcwTDQwMCA2NzAgMTY1IDUzNVYyNjVsMjM1LTEzNSIvPjxwYXRoIG9wYWNpdHk9Ii44IiBzdHJva2Utd2lkdGg9IjIiIGQ9Ik00MDAgMTE1bDI0NSAxNDB2MjkwTDQwMCA2ODUgMTU1IDU0NVYyNTVsMjQ1LTE0MCIvPjwvZz48ZyBmaWxsPSJub25lIiBzdHJva2U9IiNmYWViZDciIHN0cm9rZS1kYXNoYXJyYXk9IjUgMTUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSI1Ij48cGF0aCBkPSJNMTY1IDI2NWwyMzUgMTM1djI3ME00MDAgNDAwbDIzNS0xMzUiLz48L2c+PGcgZmlsbD0idXJsKCNwcmVmaXhfX2MpIiBtYXNrPSJ1cmwoI3ByZWZpeF9fYikiPjxyZWN0IHdpZHRoPSIyMDciIGhlaWdodD0iMyIgeD0iNDkwLjUiIHk9IjE0NC41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA1OTQgMTQ2KSIgb3BhY2l0eT0iLjQiLz48cmVjdCB3aWR0aD0iMTczIiBoZWlnaHQ9IjMiIHg9IjUzLjUiIHk9IjU3MC41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAxNDAgNTcyKSIgb3BhY2l0eT0iLjkiLz48cmVjdCB3aWR0aD0iMzcyIiBoZWlnaHQ9IjMiIHg9IjIwIiB5PSIyMDIuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMjA2IDIwNCkiIG9wYWNpdHk9Ii4xNSIvPjxyZWN0IHdpZHRoPSI0OTIiIGhlaWdodD0iMyIgeD0iLTk2IiB5PSI3MzguNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMTUwIDc0MCkiIG9wYWNpdHk9Ii45Ii8+PHJlY3Qgd2lkdGg9IjU0NCIgaGVpZ2h0PSIzIiB4PSI0ODIiIHk9IjUzNC41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA3NTQgNTM2KSIgb3BhY2l0eT0iLjE4Ii8+PHJlY3Qgd2lkdGg9IjQ2MyIgaGVpZ2h0PSIzIiB4PSI3LjUiIHk9IjY5MC41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAyMzkgNjkyKSIgb3BhY2l0eT0iLjY3Ii8+PHJlY3Qgd2lkdGg9IjE2MiIgaGVpZ2h0PSIzIiB4PSI2ODAiIHk9IjM5MS41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA3NjEgMzkzKSIgb3BhY2l0eT0iLjEiLz48cmVjdCB3aWR0aD0iMTgyIiBoZWlnaHQ9IjMiIHg9Ii0zOCIgeT0iNzQ5LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDUzIDc1MSkiIG9wYWNpdHk9Ii42MSIvPjxyZWN0IHdpZHRoPSIyNTEiIGhlaWdodD0iMyIgeD0iNjE4LjUiIHk9IjY0Ny41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA3NDQgNjQ5KSIgb3BhY2l0eT0iLjgyIi8+PHJlY3Qgd2lkdGg9IjMyOSIgaGVpZ2h0PSIzIiB4PSItMTIzLjUiIHk9IjIzNi41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA0MSAyMzgpIiBvcGFjaXR5PSIuNDUiLz48cmVjdCB3aWR0aD0iMTEwIiBoZWlnaHQ9IjMiIHg9IjI0OCIgeT0iMjc1LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDMwMyAyNzcpIiBvcGFjaXR5PSIuODkiLz48cmVjdCB3aWR0aD0iNjM0IiBoZWlnaHQ9IjMiIHg9IjM1MSIgeT0iMzc1LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDY2OCAzNzcpIiBvcGFjaXR5PSIuMzkiLz48cmVjdCB3aWR0aD0iODgiIGhlaWdodD0iMyIgeD0iMzEyIiB5PSI3MzMuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMzU2IDczNSkiIG9wYWNpdHk9Ii4zMiIvPjxyZWN0IHdpZHRoPSIxOTEiIGhlaWdodD0iMyIgeD0iNDY2LjUiIHk9IjM5OC41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA1NjIgNDAwKSIgb3BhY2l0eT0iLjEzIi8+PHJlY3Qgd2lkdGg9IjU2OCIgaGVpZ2h0PSIzIiB4PSItMTYyIiB5PSI0MDcuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMTIyIDQwOSkiIG9wYWNpdHk9Ii43MiIvPjxyZWN0IHdpZHRoPSIyOTUiIGhlaWdodD0iMyIgeD0iMjU1LjUiIHk9IjM4MS41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA0MDMgMzgzKSIgb3BhY2l0eT0iLjQiLz48cmVjdCB3aWR0aD0iMjA0IiBoZWlnaHQ9IjMiIHg9Ii02NSIgeT0iMTU2LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDM3IDE1OCkiIG9wYWNpdHk9Ii44NSIvPjxyZWN0IHdpZHRoPSIyNjgiIGhlaWdodD0iMyIgeD0iNDQwIiB5PSIzMjguNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgNTc0IDMzMCkiIG9wYWNpdHk9Ii41MiIvPjxyZWN0IHdpZHRoPSI0OTQiIGhlaWdodD0iMyIgeD0iLTgwIiB5PSIzNDIuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMTY3IDM0NCkiIG9wYWNpdHk9Ii45OCIvPjxyZWN0IHdpZHRoPSI2MTkiIGhlaWdodD0iMyIgeD0iLTE5Ny41IiB5PSIyNjguNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMTEyIDI3MCkiIG9wYWNpdHk9Ii4zIi8+PHJlY3Qgd2lkdGg9IjEzMyIgaGVpZ2h0PSIzIiB4PSItMjkuNSIgeT0iNjU2LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDM3IDY1OCkiIG9wYWNpdHk9Ii4yIi8+PHJlY3Qgd2lkdGg9IjI4NCIgaGVpZ2h0PSIzIiB4PSIxNzciIHk9IjU1Ni41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAzMTkgNTU4KSIgb3BhY2l0eT0iLjM0Ii8+PHJlY3Qgd2lkdGg9IjEzMyIgaGVpZ2h0PSIzIiB4PSI1NTguNSIgeT0iNTM2LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDYyNSA1MzgpIiBvcGFjaXR5PSIuNzYiLz48cmVjdCB3aWR0aD0iMjMzIiBoZWlnaHQ9IjMiIHg9IjExOC41IiB5PSI3NjUuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMjM1IDc2NykiIG9wYWNpdHk9Ii4wOSIvPjxyZWN0IHdpZHRoPSI0NTQiIGhlaWdodD0iMyIgeD0iMzYxIiB5PSI0NjEuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgNTg4IDQ2MykiIG9wYWNpdHk9Ii40Ii8+PHJlY3Qgd2lkdGg9IjE5NyIgaGVpZ2h0PSIzIiB4PSI1MTIuNSIgeT0iNzM5LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDYxMSA3NDEpIiBvcGFjaXR5PSIuNTkiLz48cmVjdCB3aWR0aD0iMjQzIiBoZWlnaHQ9IjMiIHg9Ii0yOC41IiB5PSI1MTEuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgOTMgNTEzKSIgb3BhY2l0eT0iLjk0Ii8+PHJlY3Qgd2lkdGg9IjE4MiIgaGVpZ2h0PSIzIiB4PSI1NDMiIHk9IjUzLjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDYzNCA1NSkiIG9wYWNpdHk9Ii41NyIvPjxyZWN0IHdpZHRoPSIxNjgiIGhlaWdodD0iMyIgeD0iMzAxIiB5PSI2MzQuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMzg1IDYzNikiIG9wYWNpdHk9Ii43NCIvPjxyZWN0IHdpZHRoPSIxNzMiIGhlaWdodD0iMyIgeD0iMjIyLjUiIHk9Ijc1OC41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAzMDkgNzYwKSIgb3BhY2l0eT0iLjM4Ii8+PHJlY3Qgd2lkdGg9IjExMyIgaGVpZ2h0PSIzIiB4PSIxOTEuNSIgeT0iNTQwLjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDI0OCA1NDIpIiBvcGFjaXR5PSIuOTMiLz48cmVjdCB3aWR0aD0iMzI0IiBoZWlnaHQ9IjMiIHg9IjE2MiIgeT0iMjcuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMzI0IDI5KSIgb3BhY2l0eT0iLjQ3Ii8+PHJlY3Qgd2lkdGg9IjQ2OSIgaGVpZ2h0PSIzIiB4PSItMTg4LjUiIHk9IjM0MC41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA0NiAzNDIpIiBvcGFjaXR5PSIuNjQiLz48cmVjdCB3aWR0aD0iNTY5IiBoZWlnaHQ9IjMiIHg9Ii03Ny41IiB5PSI2MjAuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMjA3IDYyMikiIG9wYWNpdHk9Ii40NiIvPjxyZWN0IHdpZHRoPSIyMTIiIGhlaWdodD0iMyIgeD0iMjE1IiB5PSIzNjMuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMzIxIDM2NSkiIG9wYWNpdHk9Ii43MiIvPjxyZWN0IHdpZHRoPSI3MCIgaGVpZ2h0PSIzIiB4PSI2IiB5PSI0NDkuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgNDEgNDUxKSIgb3BhY2l0eT0iLjM3Ii8+PHJlY3Qgd2lkdGg9IjM4OCIgaGVpZ2h0PSIzIiB4PSI0OSIgeT0iNDEuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMjQzIDQzKSIgb3BhY2l0eT0iLjg4Ii8+PHJlY3Qgd2lkdGg9IjI4NyIgaGVpZ2h0PSIzIiB4PSI1OTMuNSIgeT0iNzUwLjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDczNyA3NTIpIiBvcGFjaXR5PSIuNDIiLz48cmVjdCB3aWR0aD0iNjM2IiBoZWlnaHQ9IjMiIHg9IjE4MyIgeT0iNzI3LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDUwMSA3MjkpIiBvcGFjaXR5PSIuOTIiLz48cmVjdCB3aWR0aD0iNTk3IiBoZWlnaHQ9IjMiIHg9IjczLjUiIHk9IjQ2My41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAzNzIgNDY1KSIgb3BhY2l0eT0iLjc0Ii8+PHJlY3Qgd2lkdGg9IjYzMyIgaGVpZ2h0PSIzIiB4PSI3NS41IiB5PSIyNDkuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMzkyIDI1MSkiIG9wYWNpdHk9Ii4wOSIvPjxyZWN0IHdpZHRoPSIzNjciIGhlaWdodD0iMyIgeD0iMjg4LjUiIHk9IjM1Ni41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA0NzIgMzU4KSIgb3BhY2l0eT0iLjM0Ii8+PHJlY3Qgd2lkdGg9IjQxNyIgaGVpZ2h0PSIzIiB4PSIzMzYuNSIgeT0iNTcyLjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDU0NSA1NzQpIiBvcGFjaXR5PSIuMjkiLz48cmVjdCB3aWR0aD0iMTYzIiBoZWlnaHQ9IjMiIHg9IjQwMi41IiB5PSIzNC41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA0ODQgMzYpIiBvcGFjaXR5PSIuMTYiLz48cmVjdCB3aWR0aD0iNDkyIiBoZWlnaHQ9IjMiIHg9IjExNyIgeT0iODYuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMzYzIDg4KSIgb3BhY2l0eT0iLjMiLz48cmVjdCB3aWR0aD0iMTQxIiBoZWlnaHQ9IjMiIHg9IjEyOS41IiB5PSIxMDcuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMjAwIDEwOSkiIG9wYWNpdHk9Ii45Ii8+PHJlY3Qgd2lkdGg9IjUwIiBoZWlnaHQ9IjMiIHg9IjQ0OCIgeT0iNjEwLjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDQ3MyA2MTIpIiBvcGFjaXR5PSIuNzEiLz48cmVjdCB3aWR0aD0iMzYiIGhlaWdodD0iMyIgeD0iMTg3IiB5PSIyNzQuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMjA1IDI3NikiIG9wYWNpdHk9Ii4zNCIvPjxyZWN0IHdpZHRoPSIyMTMiIGhlaWdodD0iMyIgeD0iNjcuNSIgeT0iNTE0LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDE3NCA1MTYpIiBvcGFjaXR5PSIuOCIvPjxyZWN0IHdpZHRoPSIzMTEiIGhlaWdodD0iMyIgeD0iMTE2LjUiIHk9IjEzNi41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAyNzIgMTM4KSIgb3BhY2l0eT0iLjM0Ii8+PHJlY3Qgd2lkdGg9IjM3MiIgaGVpZ2h0PSIzIiB4PSItMjYiIHk9IjMzLjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDE2MCAzNSkiIG9wYWNpdHk9Ii44MiIvPjxyZWN0IHdpZHRoPSIxMjMiIGhlaWdodD0iMyIgeD0iMjI0LjUiIHk9IjQ2Mi41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAyODYgNDY0KSIgb3BhY2l0eT0iLjYyIi8+PHJlY3Qgd2lkdGg9IjE2NSIgaGVpZ2h0PSIzIiB4PSI0OTAuNSIgeT0iMjQ3LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDU3MyAyNDkpIiBvcGFjaXR5PSIuNjIiLz48cmVjdCB3aWR0aD0iMzA3IiBoZWlnaHQ9IjMiIHg9Ii0xMTYuNSIgeT0iNTY0LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDM3IDU2NikiIG9wYWNpdHk9Ii45OSIvPjxyZWN0IHdpZHRoPSIyNDMiIGhlaWdodD0iMyIgeD0iLTIuNSIgeT0iNjQyLjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDExOSA2NDQpIiBvcGFjaXR5PSIuNzkiLz48cmVjdCB3aWR0aD0iNTI5IiBoZWlnaHQ9IjMiIHg9IjQyMS41IiB5PSI0NzkuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgNjg2IDQ4MSkiIG9wYWNpdHk9Ii44NyIvPjxyZWN0IHdpZHRoPSI0MDciIGhlaWdodD0iMyIgeD0iLTIuNSIgeT0iNDQ5LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDIwMSA0NTEpIiBvcGFjaXR5PSIuMjYiLz48cmVjdCB3aWR0aD0iNTM4IiBoZWlnaHQ9IjMiIHg9IjEyOSIgeT0iNTQ3LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDM5OCA1NDkpIiBvcGFjaXR5PSIuNTQiLz48cmVjdCB3aWR0aD0iMzQ1IiBoZWlnaHQ9IjMiIHg9IjIzMy41IiB5PSI3NzIuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgNDA2IDc3NCkiIG9wYWNpdHk9Ii4yNCIvPjxyZWN0IHdpZHRoPSIyMjIiIGhlaWdodD0iMyIgeD0iLTcwIiB5PSIzOC41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA0MSA0MCkiIG9wYWNpdHk9Ii42NyIvPjxyZWN0IHdpZHRoPSIzMzQiIGhlaWdodD0iMyIgeD0iLTQzIiB5PSIxNzUuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMTI0IDE3NykiIG9wYWNpdHk9Ii45NCIvPjxyZWN0IHdpZHRoPSIyMDIiIGhlaWdodD0iMyIgeD0iNjQ2IiB5PSI1OS41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA3NDcgNjEpIiBvcGFjaXR5PSIuMjkiLz48cmVjdCB3aWR0aD0iMjYxIiBoZWlnaHQ9IjMiIHg9IjU3Mi41IiB5PSIyNzQuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgNzAzIDI3NikiIG9wYWNpdHk9Ii45OSIvPjxyZWN0IHdpZHRoPSIzNTIiIGhlaWdodD0iMyIgeD0iMTE4IiB5PSI2MTguNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgMjk0IDYyMCkiIG9wYWNpdHk9Ii4yOSIvPjxyZWN0IHdpZHRoPSI0MiIgaGVpZ2h0PSIzIiB4PSI0IiB5PSI4Ni41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAyNSA4OCkiIG9wYWNpdHk9Ii41Ii8+PHJlY3Qgd2lkdGg9IjM0NSIgaGVpZ2h0PSIzIiB4PSI0NjMuNSIgeT0iNjMxLjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDYzNiA2MzMpIiBvcGFjaXR5PSIuMTMiLz48cmVjdCB3aWR0aD0iMTI1IiBoZWlnaHQ9IjMiIHg9IjM5Mi41IiB5PSIxMTYuNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgNDU1IDExOCkiIG9wYWNpdHk9Ii4xMyIvPjxyZWN0IHdpZHRoPSI1NDEiIGhlaWdodD0iMyIgeD0iNDcxLjUiIHk9IjE4MS41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSA3NDIgMTgzKSIgb3BhY2l0eT0iLjM4Ii8+PHJlY3Qgd2lkdGg9IjIwNyIgaGVpZ2h0PSIzIiB4PSItMTEuNSIgeT0iMjguNSIgcng9IjEuNSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgOTIgMzApIiBvcGFjaXR5PSIuNTciLz48cmVjdCB3aWR0aD0iMzYwIiBoZWlnaHQ9IjMiIHg9IjIyNSIgeT0iNjk4LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDQwNSA3MDApIiBvcGFjaXR5PSIuMjEiLz48cmVjdCB3aWR0aD0iNDQ3IiBoZWlnaHQ9IjMiIHg9Ijg5LjUiIHk9IjY3Ny41IiByeD0iMS41IiB0cmFuc2Zvcm09InJvdGF0ZSg0NSAzMTMgNjc5KSIgb3BhY2l0eT0iLjg5Ii8+PHJlY3Qgd2lkdGg9IjEyNyIgaGVpZ2h0PSIzIiB4PSIyNjQuNSIgeT0iMTg4LjUiIHJ4PSIxLjUiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDMyOCAxOTApIiBvcGFjaXR5PSIuNjMiLz48L2c+PC9zdmc+ -------------------------------------------------------------------------------- /static/cachette.svg: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | 22 | 23 | 28 | 29 | 30 | 37 | 42 | 43 | 48 | 49 | 54 | 55 | 56 | 57 | 69 | 70 | 82 | 83 | 84 | 85 | 90 | 91 | 98 | 111 | 112 | 125 | 126 | 139 | 140 | 153 | 154 | 167 | 168 | 181 | 182 | 195 | 196 | 208 | 209 | 222 | 223 | 236 | 237 | 250 | 251 | 264 | 265 | 278 | 279 | 292 | 293 | 306 | 307 | 320 | 321 | 334 | 335 | 348 | 349 | 362 | 363 | 376 | 377 | 390 | 391 | 404 | 405 | 418 | 419 | 432 | 433 | 434 | 440 | 452 | 453 | 466 | 467 | 468 | 476 | 482 | 483 | 488 | 489 | 490 | 493 | 494 | 500 | 501 | 503 | 504 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 518 | 519 | 521 | 522 | 524 | 525 | 527 | 528 | 530 | 531 | 533 | 534 | 536 | 537 | 538 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | 642 | 643 | 644 | 645 | 646 | 647 | 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/__init__.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 21:08 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Previous implementations moved to tests/backends/__init__.py 14 | """ 15 | 16 | ### Standard packages ### 17 | from os import remove 18 | from typing import Generator 19 | 20 | ### Third-party packages ### 21 | from pytest import fixture 22 | 23 | 24 | @fixture(autouse=True, scope="session") 25 | def remove_pickles() -> Generator[None, None, None]: 26 | """Fixture to be called after test session is over for cleaning up local pickle files""" 27 | yield 28 | file_exists: bool = False 29 | try: 30 | with open("tests/cachette.pkl", "rb"): 31 | file_exists = True 32 | except FileNotFoundError: 33 | pass 34 | if file_exists: 35 | remove("tests/cachette.pkl") 36 | 37 | 38 | __all__ = ("remove_pickles",) 39 | -------------------------------------------------------------------------------- /tests/backends/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/backends/__init__.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-15 19:06 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining test methods to be used by different backend-specific tests""" 13 | 14 | ### Standard packages ### 15 | from contextlib import asynccontextmanager 16 | from typing import Any, AsyncGenerator, List, Tuple 17 | 18 | ### Third-party packages ### 19 | from fastapi import FastAPI, Depends 20 | from fastapi.responses import PlainTextResponse 21 | from fastapi.testclient import TestClient 22 | from pydantic import BaseModel 23 | from pytest_asyncio import fixture 24 | 25 | ### Local modules ### 26 | from cachette import Cachette 27 | 28 | 29 | class Payload(BaseModel): 30 | key: str 31 | value: str 32 | 33 | 34 | @asynccontextmanager 35 | @fixture(scope="function") 36 | async def client(request) -> AsyncGenerator[TestClient, None]: 37 | assert isinstance(request.param, list) 38 | assert len(request.param) > 0 39 | configs: List[Tuple[str, Any]] = request.param 40 | 41 | app = FastAPI() 42 | 43 | @Cachette.load_config 44 | def get_cachette_config(): 45 | return configs 46 | 47 | ### Routing ### 48 | @app.post("/", response_class=PlainTextResponse) 49 | async def setter(payload: Payload, cachette: Cachette = Depends()): 50 | """ 51 | Submit a new cache key-pair value 52 | """ 53 | await cachette.put(payload.key, payload.value) 54 | return "OK" 55 | 56 | @app.get("/{key}", response_class=PlainTextResponse, status_code=200) 57 | async def getter(key: str, cachette: Cachette = Depends()): 58 | """ 59 | Returns key value 60 | """ 61 | value: str = await cachette.fetch(key) 62 | return value 63 | 64 | @app.delete("/{key}", response_class=PlainTextResponse, status_code=200) 65 | async def destroy(key: str, cachette: Cachette = Depends()): 66 | """ 67 | Clears cached value 68 | """ 69 | cleared: int = await cachette.clear(key=key) 70 | return ("", "OK")[cleared > 0] 71 | 72 | with TestClient(app) as test_client: 73 | yield test_client 74 | -------------------------------------------------------------------------------- /tests/backends/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/backends/conftest.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-15 19:06 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining `pytest` config overrides regarding backend tests""" 13 | 14 | ### Standard packages ### 15 | from typing import Any, List, Optional, Tuple 16 | 17 | ### Third-party packages ### 18 | from pytest import FixtureRequest, fixture, skip 19 | 20 | 21 | def get_config_value_from_client_configs(key: str, request: FixtureRequest) -> Optional[str]: 22 | configs: List[Tuple[str, Any]] = request.node.callspec.params.get("client", []) 23 | if len(configs) == 0: 24 | return 25 | try: 26 | backend_tuple: Tuple[str, str] = list(filter(lambda item: item[0] == key, configs))[0] 27 | except IndexError: 28 | return # no backend set 29 | if len(backend_tuple) != 2: 30 | return 31 | return backend_tuple[1] 32 | 33 | 34 | @fixture(autouse=True) 35 | def skip_if_backend_dependency_is_not_installed(request: FixtureRequest): 36 | """ 37 | Skip test with "dynamodb" backend in the case that "aiobotocore" is not installed; 38 | Skip test with "memcached" backend in the case that "aiomcache" is not installed; 39 | Skip test with "mongodb" backend in the case that "motor" is not installed; 40 | Skip test with "redis" backend in the case that "redis" is not installed. 41 | Skip test with "valkey" backend in the case that "redis" is not installed. 42 | 43 | --- 44 | :param: request `FixtureRequest` 45 | """ 46 | backend: str = get_config_value_from_client_configs("backend", request) 47 | if backend == "dynamodb": 48 | try: 49 | import aiobotocore as _ 50 | except ImportError: 51 | skip(reason='"aiobotocore" dependency is required for "dynamodb" backend test.') 52 | elif backend == "memcached": 53 | try: 54 | import aiomcache as _ 55 | except ImportError: 56 | skip(reason='"aiomcache" dependency is required for "memcached" backend test.') 57 | elif backend == "mongodb": 58 | try: 59 | import motor as _ 60 | except ImportError: 61 | skip(reason='"motor" dependency is required for "mongodb" backend test.') 62 | elif backend == "redis": 63 | try: 64 | import redis as _ 65 | except ImportError: 66 | skip(reason='"redis" dependency is required for "redis" backend test.') 67 | elif backend == "valkey": 68 | try: 69 | import redis as _ 70 | except ImportError: 71 | skip(reason='"redis" dependency is required for "valkey" backend test.') 72 | 73 | 74 | @fixture(autouse=True) 75 | def skip_if_dynamodb_server_cannot_be_reached(request: FixtureRequest): 76 | """ 77 | Skip test with "dynamodb" backend in all cases as of now. 78 | 79 | --- 80 | :param: request `FixtureRequest` 81 | """ 82 | backend: str = get_config_value_from_client_configs("backend", request) 83 | if backend == "dynamodb": 84 | skip(reason="DynamoDB tests are disabled in version 0.1.8") 85 | 86 | 87 | @fixture(autouse=True) 88 | def skip_if_memcached_server_cannot_be_reached(request: FixtureRequest): 89 | """ 90 | Skip test with "memcached" backend in the case that Memcached server defined by "memcached_host" 91 | cannot be reached. 92 | 93 | --- 94 | :param: request `FixtureRequest` 95 | """ 96 | memcached_host: str = get_config_value_from_client_configs("memcached_host", request) 97 | if memcached_host is not None: 98 | from asyncio import BaseEventLoop, set_event_loop, get_event_loop 99 | from aiomcache import Client 100 | 101 | client: Client = Client(memcached_host) 102 | loop: BaseEventLoop = get_event_loop() 103 | try: 104 | loop.run_until_complete(client.get(b"test")) 105 | except OSError: 106 | skip(reason="Memcached Server cannot be reached.") 107 | finally: 108 | loop.run_until_complete(client.close()) 109 | set_event_loop(loop) 110 | 111 | 112 | @fixture(autouse=True) 113 | def skip_if_mongodb_server_cannot_be_reached(request: FixtureRequest): 114 | """ 115 | Skip test with "mongodb" backend in the case that MongoDB server defined by "mongodb_url" 116 | cannot be reached. 117 | 118 | --- 119 | :param: request `FixtureRequest` 120 | """ 121 | mongodb_url: str = get_config_value_from_client_configs("mongodb_url", request) 122 | if mongodb_url is not None: 123 | from pymongo import MongoClient 124 | from pymongo.errors import ServerSelectionTimeoutError 125 | 126 | try: 127 | client: MongoClient = MongoClient(mongodb_url, serverSelectionTimeoutMS=1) 128 | client["test"].list_collection_names(filter={"name": "test"}) 129 | except ServerSelectionTimeoutError: 130 | skip(reason="MongoDB Server cannot be reached.") 131 | 132 | 133 | @fixture(autouse=True) 134 | def skip_if_redis_server_cannot_be_reached(request: FixtureRequest): 135 | """ 136 | Skip test with "redis" backend in the case that Redis server defined by "redis_url" 137 | cannot be reached. 138 | 139 | --- 140 | :param: request `FixtureRequest` 141 | """ 142 | redis_url: str = get_config_value_from_client_configs("redis_url", request) 143 | if redis_url is not None: 144 | from redis import Redis 145 | from redis.exceptions import ConnectionError 146 | 147 | try: 148 | Redis.from_url(redis_url).get("test") 149 | except ConnectionError: 150 | skip(reason="Redis Server cannot be reached.") 151 | 152 | 153 | @fixture(autouse=True) 154 | def skip_if_valkey_server_cannot_be_reached(request: FixtureRequest): 155 | """ 156 | Skip test with "valkey" backend in the case that Valkey server defined by "valkey_url" 157 | cannot be reached. 158 | 159 | --- 160 | :param: request `FixtureRequest` 161 | """ 162 | valkey_url: str = get_config_value_from_client_configs("valkey_url", request) 163 | if valkey_url is not None: 164 | from redis import Redis 165 | from redis.exceptions import ConnectionError 166 | 167 | valkey_url = valkey_url.replace("valkey://", "redis://") 168 | try: 169 | Redis.from_url(valkey_url).get("test") 170 | except ConnectionError: 171 | skip(reason="Valkey Server cannot be reached.") 172 | -------------------------------------------------------------------------------- /tests/backends/set_then_clear.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/backends/set_then_clear.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-15 19:06 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Module defining a test case where a key-value is set and then cleared 14 | before fetching the same key-value pair again 15 | """ 16 | 17 | ### Third-party packages ### 18 | from fastapi.testclient import TestClient 19 | from httpx import Response 20 | from pytest import mark 21 | 22 | ### Local modules ### 23 | from tests.backends import Payload, client 24 | 25 | 26 | @mark.parametrize( 27 | "client", 28 | [ 29 | [("backend", "inmemory")], 30 | [("backend", "memcached"), ("memcached_host", "localhost")], 31 | [ 32 | ("backend", "mongodb"), 33 | ("database_name", "cachette-db"), 34 | ("mongodb_url", "mongodb://localhost:27017"), 35 | ], 36 | [("backend", "pickle"), ("pickle_path", "tests/cachette.pkl")], 37 | [("backend", "redis"), ("redis_url", "redis://localhost:6379")], 38 | [("backend", "valkey"), ("valkey_url", "valkey://localhost:6380")], 39 | ], 40 | ids=["inmemory", "memcached", "mongodb", "pickle", "redis", "valkey"], 41 | indirect=True, 42 | ) 43 | def test_set_then_clear(client: TestClient): 44 | ### Get key-value before setting anything ### 45 | response: Response = client.get("/cache") 46 | assert response.text == "" 47 | ### Setting key-value pair with Payload ### 48 | payload: Payload = Payload(key="cache", value="cachable") 49 | response = client.post("/", data=payload.json()) 50 | assert response.text == "OK" 51 | ### Getting cached value within TTL ### 52 | response = client.get("/cache") 53 | assert response.text == "cachable" 54 | ### Clear ### 55 | response = client.delete("/cache") 56 | assert response.text == "OK" 57 | -------------------------------------------------------------------------------- /tests/backends/wait_till_expired.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/backends/wait_till_expired.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-15 19:06 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Module defining a test case where a key-value is set with small ttl, 14 | waited until expired, then have the same key-value fetched again 15 | """ 16 | 17 | ### Standard packages ### 18 | from time import sleep 19 | 20 | ### Third-party packages ### 21 | from fastapi.testclient import TestClient 22 | from httpx import Response 23 | from pytest import mark 24 | 25 | ### Local modules ### 26 | from tests.backends import Payload, client 27 | 28 | 29 | @mark.parametrize( 30 | "client", 31 | [ 32 | [("backend", "inmemory"), ("ttl", 2)], 33 | [("backend", "memcached"), ("ttl", 2), ("memcached_host", "localhost")], 34 | [ 35 | ("backend", "mongodb"), 36 | ("database_name", "cachette-db"), 37 | ("ttl", 2), 38 | ("mongodb_url", "mongodb://localhost:27017"), 39 | ], 40 | [("backend", "pickle"), ("ttl", 2), ("pickle_path", "tests/cachette.pkl")], 41 | [("backend", "redis"), ("ttl", 2), ("redis_url", "redis://localhost:6379")], 42 | [("backend", "valkey"), ("ttl", 2), ("valkey_url", "valkey://localhost:6380")], 43 | ], 44 | ids=["inmemory", "memcached", "mongodb", "pickle", "redis", "valkey"], 45 | indirect=True, 46 | ) 47 | def test_set_and_wait_til_expired(client: TestClient): 48 | ### Get key-value before setting anything ### 49 | response: Response = client.get("/cache") 50 | assert response.text == "" 51 | ### Setting key-value pair with Payload ### 52 | payload: Payload = Payload(key="cache", value="cachable") 53 | response = client.post("/", content=payload.model_dump_json()) 54 | assert response.text == "OK" 55 | ### Getting cached value within TTL ### 56 | response = client.get("/cache") 57 | assert response.text == "cachable" 58 | ### Sleeps on current thread until TTL ttls ### 59 | sleep(3) 60 | ### Getting cached value after TTL ttls ### 61 | response = client.get("/cache") 62 | assert response.text == "" 63 | ### Clear ### 64 | response = client.delete("/cache") 65 | assert response.text == "" ### Nothing to clear 66 | -------------------------------------------------------------------------------- /tests/codecs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aekasitt/cachette/5dd4804df8588c92ca308a98391b5c2a84ef8322/tests/codecs/__init__.py -------------------------------------------------------------------------------- /tests/codecs/absent.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/codecs/absent.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-06 3:27 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Tests for codec implementations on TestClient which can only store string representaions of items""" 13 | 14 | ### Standard packages ### 15 | from contextlib import asynccontextmanager 16 | from typing import Any, AsyncGenerator, List, Tuple 17 | 18 | ### Third-party packages ### 19 | from fastapi import Depends, FastAPI 20 | from fastapi.responses import PlainTextResponse 21 | from fastapi.testclient import TestClient 22 | from httpx import Response 23 | from pytest import FixtureRequest, fixture, mark 24 | from pytest_asyncio import fixture as asyncfixture 25 | 26 | ### Local modules ### 27 | from cachette import Cachette 28 | 29 | 30 | @fixture(scope="module") 31 | def items() -> List[Any]: 32 | return [ 33 | 123, # Integer 34 | 123.45, # Float 35 | "A", # Charstring 36 | "Hello, World!", # String 37 | "123", # Alphanumeric String of an integer 38 | "123.45", # Alphanumeric String of a float 39 | b"A", # Charbytes 40 | b"Hello, World!", # Bytes 41 | b"123", # Alphanumeric Bytes of an integer 42 | b"123.45", # Alphanumeric Bytes of a float 43 | {"a": "b", "c": "d"}, # Dictionary with String values 44 | {"a": b"b", "c": b"d"}, # Dictionary with Byte values 45 | {"a": 1, "b": 2}, # Dictionary with Integer values 46 | {"a": 1.2, "b": 3.4}, # Dictionary with Float values 47 | [1, 2, 3], # List of numbers 48 | ["a", "b", "c"], # List of charstrings 49 | [b"a", b"b", b"c"], # List of charbytes 50 | ] 51 | 52 | 53 | @asynccontextmanager 54 | @asyncfixture(scope="function") 55 | async def client(items: List[Any], request: FixtureRequest) -> AsyncGenerator[TestClient, None]: 56 | configs: List[Tuple[str, Any]] = request.param 57 | 58 | app = FastAPI() 59 | 60 | @Cachette.load_config 61 | def get_cachette_config(): 62 | return configs 63 | 64 | @app.get("/put-items", response_class=PlainTextResponse, status_code=200) 65 | async def put_items(cachette: Cachette = Depends()): 66 | """ 67 | Puts a list of pre-determined items to cache 68 | """ 69 | for i, item in enumerate(items): 70 | await cachette.put(f"{ i }", item) 71 | return "OK" 72 | 73 | @app.get("/fetch-items", response_class=PlainTextResponse, status_code=200) 74 | async def fetch_items(cachette: Cachette = Depends()): 75 | """ 76 | Returns key value 77 | """ 78 | ok: bool = True 79 | for i, item in enumerate(items): 80 | uncached: str = await cachette.fetch(f"{ i }") 81 | if uncached != str(item): 82 | ok = False 83 | break 84 | return ("", "OK")[ok] 85 | 86 | with TestClient(app) as test_client: 87 | yield test_client 88 | 89 | 90 | @mark.parametrize( 91 | "client", 92 | [ 93 | ### InMemory & No Codec ### 94 | [("backend", "inmemory")], 95 | ### Memcached & No Codec ### 96 | [("backend", "memcached"), ("memcached_host", "localhost")], 97 | ### MongoDB & No Codec ### 98 | [ 99 | ("backend", "mongodb"), 100 | ("database_name", "cachette-db"), 101 | ("mongodb_url", "mongodb://localhost:27017"), 102 | ], 103 | ### Redis & No Codec ### 104 | [("backend", "redis"), ("redis_url", "redis://localhost:6379")], 105 | ], 106 | ids=[ 107 | "inmemory-vanilla", 108 | "memcached-vanilla", 109 | "mongodb-vanilla", 110 | "redis-msgpack", 111 | ], 112 | indirect=True, 113 | ) 114 | def test_every_backend_with_every_codec(client) -> None: 115 | response: Response = client.get("/put-items") 116 | assert response.text == "OK" 117 | response = client.get("/fetch-items") 118 | assert response.text == "OK" 119 | -------------------------------------------------------------------------------- /tests/codecs/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/codecs/conftest.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-15 23:02 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining `pytest` config overrides regarding codec tests""" 13 | 14 | ### Standard packages ### 15 | from typing import Any, List, Optional, Tuple 16 | 17 | ### Third-party packages ### 18 | from pytest import FixtureRequest, fixture, skip 19 | 20 | 21 | def get_config_value_from_client_configs(key: str, request: FixtureRequest) -> Optional[str]: 22 | configs: List[Tuple[str, Any]] = request.node.callspec.params.get("client", []) 23 | if len(configs) == 0: 24 | return 25 | try: 26 | backend_tuple: Tuple[str, str] = list(filter(lambda item: item[0] == key, configs))[0] 27 | except IndexError: 28 | return # no backend set 29 | return backend_tuple[1] 30 | 31 | 32 | @fixture(autouse=True) 33 | def skip_if_backend_dependency_is_not_installed(request: FixtureRequest): 34 | """ 35 | Skip test with "dynamodb" backend in the case that "aiobotocore" is not installed; 36 | Skip test with "memcached" backend in the case that "aiomcache" is not installed; 37 | Skip test with "mongodb" backend in the case that "motor" is not installed; 38 | Skip test with "redis" backend in the case that "redis" is not installed. 39 | Skip test with "valkey" backend in the case that "redis" is not installed. 40 | 41 | --- 42 | :param: request `FixtureRequest` 43 | """ 44 | backend: str = get_config_value_from_client_configs("backend", request) 45 | if backend == "dynamodb": 46 | try: 47 | import aiobotocore as _ 48 | except ImportError: 49 | skip(reason='"aiobotocore" dependency is required for "dynamodb" backend test.') 50 | elif backend == "memcached": 51 | try: 52 | import aiomcache as _ 53 | except ImportError: 54 | skip(reason='"aiomcache" dependency is required for "memcached" backend test.') 55 | elif backend == "mongodb": 56 | try: 57 | import motor as _ 58 | except ImportError: 59 | skip(reason='"motor" dependency is required for "mongodb" backend test.') 60 | elif backend == "redis": 61 | try: 62 | import redis as _ 63 | except ImportError: 64 | skip(reason='"redis" dependency is required for "redis" backend test.') 65 | elif backend == "valkey": 66 | try: 67 | import redis as _ 68 | except ImportError: 69 | skip(reason='"redis" dependency is required for "valkey" backend test.') 70 | 71 | 72 | @fixture(autouse=True) 73 | def skip_if_codec_dependency_is_not_installed(request: FixtureRequest): 74 | """ 75 | Skip test with "msgpack" codec in the case that "msgpack" is not installed; 76 | Skip test with "orjson" codec in the case that "orjson" is not installed; 77 | 78 | --- 79 | :param: request `FixtureRequest` 80 | """ 81 | codec: str = get_config_value_from_client_configs("codec", request) 82 | if codec == "msgpack": 83 | try: 84 | import msgpack as _ 85 | except ImportError: 86 | skip(reason='"msgpack" dependency is not installed in this test environment.') 87 | elif codec == "orjson": 88 | try: 89 | import orjson as _ 90 | except ImportError: 91 | skip(reason='"orjson" dependency is not installed in this test environment.') 92 | 93 | 94 | @fixture(autouse=True) 95 | def skip_if_dynamodb_server_cannot_be_reached(request: FixtureRequest): 96 | """ 97 | Skip test with "dynamodb" backend in all cases as of now. 98 | 99 | --- 100 | :param: request `FixtureRequest` 101 | """ 102 | backend: str = get_config_value_from_client_configs("backend", request) 103 | if backend == "dynamodb": 104 | skip(reason="DynamoDB tests are disabled in version 0.1.8") 105 | 106 | 107 | @fixture(autouse=True) 108 | def skip_if_memcached_server_cannot_be_reached(request: FixtureRequest): 109 | """ 110 | Skip test with "memcached" backend in the case that Memcached server defined by "memcached_host" 111 | cannot be reached. 112 | 113 | --- 114 | :param: request `FixtureRequest` 115 | """ 116 | memcached_host: str = get_config_value_from_client_configs("memcached_host", request) 117 | if memcached_host is not None: 118 | from asyncio import BaseEventLoop, set_event_loop, get_event_loop 119 | from aiomcache import Client 120 | 121 | client: Client = Client(memcached_host) 122 | loop: BaseEventLoop = get_event_loop() 123 | try: 124 | loop.run_until_complete(client.get(b"test")) 125 | except OSError: 126 | skip(reason="Memcached Server cannot be reached.") 127 | finally: 128 | loop.run_until_complete(client.close()) 129 | set_event_loop(loop) 130 | 131 | 132 | @fixture(autouse=True) 133 | def skip_if_mongodb_server_cannot_be_reached(request: FixtureRequest): 134 | """ 135 | Skip test with "mongodb" backend in the case that MongoDB server defined by "mongodb_url" 136 | cannot be reached. 137 | 138 | --- 139 | :param: request `FixtureRequest` 140 | """ 141 | mongodb_url: str = get_config_value_from_client_configs("mongodb_url", request) 142 | if mongodb_url is not None: 143 | from pymongo import MongoClient 144 | from pymongo.errors import ServerSelectionTimeoutError 145 | 146 | try: 147 | client: MongoClient = MongoClient(mongodb_url, serverSelectionTimeoutMS=1) 148 | client["test"].list_collection_names(filter={"name": "test"}) 149 | except ServerSelectionTimeoutError: 150 | skip(reason="MongoDB Server cannot be reached.") 151 | 152 | 153 | @fixture(autouse=True) 154 | def skip_if_redis_server_cannot_be_reached(request: FixtureRequest): 155 | """ 156 | Skip test with "redis" backend in the case that Redis server defined by "redis_url" 157 | cannot be reached. 158 | 159 | --- 160 | :param: request `FixtureRequest` 161 | """ 162 | redis_url: str = get_config_value_from_client_configs("redis_url", request) 163 | if redis_url is not None: 164 | from redis import Redis 165 | from redis.exceptions import ConnectionError 166 | 167 | try: 168 | Redis.from_url(redis_url).get("test") 169 | except ConnectionError: 170 | skip(reason="Redis Server cannot be reached.") 171 | 172 | 173 | @fixture(autouse=True) 174 | def skip_if_valkey_server_cannot_be_reached(request: FixtureRequest): 175 | """ 176 | Skip test with "valkey" backend in the case that Valkey server defined by "valkey_url" 177 | cannot be reached. 178 | 179 | --- 180 | :param: request `FixtureRequest` 181 | """ 182 | valkey_url: str = get_config_value_from_client_configs("valkey_url", request) 183 | if valkey_url is not None: 184 | from redis import Redis 185 | from redis.exceptions import ConnectionError 186 | 187 | valkey_url = valkey_url.replace("valkey://", "redis://") 188 | try: 189 | Redis.from_url(valkey_url).get("test") 190 | except ConnectionError: 191 | skip(reason="Valkey Server cannot be reached.") 192 | -------------------------------------------------------------------------------- /tests/codecs/dataframe/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aekasitt/cachette/5dd4804df8588c92ca308a98391b5c2a84ef8322/tests/codecs/dataframe/__init__.py -------------------------------------------------------------------------------- /tests/codecs/dataframe/all.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/codecs/dataframe/all.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-06 22:03 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Tests for codec implementations on TestClient which can serializ/de-serialize DataFrame objects""" 13 | 14 | ### Standard packages ### 15 | from contextlib import asynccontextmanager 16 | from typing import Any, AsyncGenerator, List, Tuple 17 | 18 | ### Third-party packages ### 19 | from fastapi import Depends, FastAPI 20 | from fastapi.responses import PlainTextResponse, Response 21 | from fastapi.testclient import TestClient 22 | from pytest import FixtureRequest, mark 23 | from pytest_asyncio import fixture 24 | 25 | try: 26 | from pandas import DataFrame, get_dummies, Series 27 | from pandas.testing import assert_frame_equal 28 | except ImportError: 29 | ### Assume skipped by conftest "skip_all_if_pandas_not_installed" fixture ### 30 | pass 31 | ### Local modules ### 32 | from cachette import Cachette 33 | 34 | 35 | @asynccontextmanager 36 | @fixture(scope="function") 37 | async def client(request: FixtureRequest) -> AsyncGenerator[TestClient, None]: 38 | app: FastAPI = FastAPI() 39 | configs: List[Tuple[str, Any]] = request.param 40 | items: List[DataFrame] = [get_dummies(Series(["income", "age", "gender", "education"]))] 41 | 42 | @Cachette.load_config 43 | def get_cachette_config(): 44 | return configs 45 | 46 | ### Routing ### 47 | @app.get("/put-items", response_class=PlainTextResponse, status_code=200) 48 | async def put_items(cachette: Cachette = Depends()): 49 | """ 50 | Puts a list of pre-determined items to cache 51 | """ 52 | for i, item in enumerate(items): 53 | await cachette.put(f"{ i }", item) 54 | return "OK" 55 | 56 | @app.get("/fetch-items", response_class=PlainTextResponse, status_code=200) 57 | async def fetch_items(cachette: Cachette = Depends()): 58 | """ 59 | Returns key value 60 | """ 61 | ok: bool = True 62 | for i, item in enumerate(items): 63 | uncached: DataFrame = await cachette.fetch(f"{ i }") 64 | try: 65 | assert_frame_equal(uncached, item, check_dtype=False) # dtypes sometimes get changed 66 | except AssertionError: 67 | ok = False 68 | break 69 | return ("", "OK")[ok] 70 | 71 | with TestClient(app) as test_client: 72 | yield test_client 73 | 74 | 75 | @mark.parametrize( 76 | "client", 77 | [ 78 | ### InMemory & Codecs ### 79 | [("backend", "inmemory"), ("codec", "csv")], 80 | [("backend", "inmemory"), ("codec", "feather")], 81 | [("backend", "inmemory"), ("codec", "parquet")], 82 | [("backend", "inmemory"), ("codec", "pickle")], 83 | ### Memcached & Codecs ### 84 | [("backend", "memcached"), ("codec", "csv"), ("memcached_host", "localhost")], 85 | [ 86 | ("backend", "memcached"), 87 | ("codec", "feather"), 88 | ("memcached_host", "localhost"), 89 | ], 90 | [ 91 | ("backend", "memcached"), 92 | ("codec", "parquet"), 93 | ("memcached_host", "localhost"), 94 | ], 95 | [ 96 | ("backend", "memcached"), 97 | ("codec", "pickle"), 98 | ("memcached_host", "localhost"), 99 | ], 100 | ### MongoDB & Codecs ### 101 | [ 102 | ("backend", "mongodb"), 103 | ("database_name", "cachette-db"), 104 | ("codec", "csv"), 105 | ("mongodb_url", "mongodb://localhost:27017"), 106 | ], 107 | [ 108 | ("backend", "mongodb"), 109 | ("database_name", "cachette-db"), 110 | ("codec", "feather"), 111 | ("mongodb_url", "mongodb://localhost:27017"), 112 | ], 113 | [ 114 | ("backend", "mongodb"), 115 | ("database_name", "cachette-db"), 116 | ("codec", "parquet"), 117 | ("mongodb_url", "mongodb://localhost:27017"), 118 | ], 119 | [ 120 | ("backend", "mongodb"), 121 | ("database_name", "cachette-db"), 122 | ("codec", "pickle"), 123 | ("mongodb_url", "mongodb://localhost:27017"), 124 | ], 125 | ### Redis & Codecs ### 126 | [ 127 | ("backend", "redis"), 128 | ("codec", "csv"), 129 | ("redis_url", "redis://localhost:6379"), 130 | ], 131 | [ 132 | ("backend", "redis"), 133 | ("codec", "feather"), 134 | ("redis_url", "redis://localhost:6379"), 135 | ], 136 | [ 137 | ("backend", "redis"), 138 | ("codec", "parquet"), 139 | ("redis_url", "redis://localhost:6379"), 140 | ], 141 | [ 142 | ("backend", "redis"), 143 | ("codec", "pickle"), 144 | ("redis_url", "redis://localhost:6379"), 145 | ], 146 | ], 147 | ids=[ 148 | "inmemory-csv", 149 | "inmemory-feather", 150 | "inmemory-parquet", 151 | "inmemory-pickle", 152 | "memcached-csv", 153 | "memcached-feather", 154 | "memcached-parquet", 155 | "memcached-pickle", 156 | "mongodb-csv", 157 | "mongodb-feather", 158 | "mongodb-parquet", 159 | "mongodb-pickle", 160 | "redis-csv", 161 | "redis-feather", 162 | "redis-parquet", 163 | "redis-pickle", 164 | ], 165 | indirect=True, 166 | ) 167 | def test_every_backend_with_every_dataframe_codec(client) -> None: 168 | response: Response = client.get("/put-items") 169 | assert response.text == "OK" 170 | response = client.get("/fetch-items") 171 | assert response.text == "OK" 172 | -------------------------------------------------------------------------------- /tests/codecs/dataframe/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/codecs/dataframe/conftest.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-15 23:16 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Module defining `pytest` config overrides""" 13 | 14 | ### Standard packages ### 15 | from typing import Any, List, Optional, Tuple 16 | 17 | ### Third-party packages ### 18 | from pytest import fixture, FixtureRequest, skip 19 | 20 | 21 | def get_config_value_from_client_configs(key: str, request: FixtureRequest) -> Optional[str]: 22 | configs: List[Tuple[str, Any]] = request.node.callspec.params.get("client", []) 23 | if len(configs) == 0: 24 | return 25 | try: 26 | backend_tuple: Tuple[str, str] = list(filter(lambda item: item[0] == key, configs))[0] 27 | except IndexError: 28 | return # no backend set 29 | if len(backend_tuple) != 2: 30 | return 31 | return backend_tuple[1] 32 | 33 | 34 | @fixture(autouse=True) 35 | def skip_all_if_pandas_not_installed(request: FixtureRequest): 36 | """ 37 | Skip all tests in this subfolder if "pandas" is not installed. 38 | 39 | --- 40 | :param: request `FixtureRequest` 41 | """ 42 | try: 43 | import pandas as _ 44 | except ImportError: 45 | skip(reason='"pandas" dependency is not installed in this test environment.') 46 | 47 | 48 | @fixture(autouse=True) 49 | def skip_if_backend_dependency_is_not_installed(request: FixtureRequest): 50 | """ 51 | Skip test with "dynamodb" backend in the case that "aiobotocore" is not installed; 52 | Skip test with "memcached" backend in the case that "aiomcache" is not installed; 53 | Skip test with "mongodb" backend in the case that "motor" is not installed; 54 | Skip test with "redis" backend in the case that "redis" is not installed. 55 | 56 | --- 57 | :param: request `FixtureRequest` 58 | """ 59 | backend: str = get_config_value_from_client_configs("backend", request) 60 | if backend == "dynamodb": 61 | try: 62 | import aiobotocore as _ 63 | except ImportError: 64 | skip(reason='"aiobotocore" dependency is not installed in this test environment.') 65 | elif backend == "memcached": 66 | try: 67 | import aiomcache as _ 68 | except ImportError: 69 | skip(reason='"aiomcache" dependency is not installed in this test environment.') 70 | elif backend == "mongodb": 71 | try: 72 | import motor as _ 73 | except ImportError: 74 | skip(reason='"motor" dependency is not installed in this test environment.') 75 | elif backend == "redis": 76 | try: 77 | import redis as _ 78 | except ImportError: 79 | skip(reason='"redis" dependency is not installed in this test environment.') 80 | 81 | 82 | @fixture(autouse=True) 83 | def skip_if_codec_dependency_is_not_installed(request: FixtureRequest): 84 | """ 85 | Skip test with "feather" codec in the case that "pyarrow" is not installed; 86 | Skip test with "parquet" codec in the case that "pyarrow" is not installed. 87 | 88 | --- 89 | :param: request `FixtureRequest` 90 | """ 91 | codec: str = get_config_value_from_client_configs("codec", request) 92 | if codec == "csv": 93 | try: 94 | import pandas as _ 95 | except ImportError: 96 | skip(reason='"pandas" dependency is not installed in this test environment.') 97 | elif codec == "feather": 98 | try: 99 | import pyarrow as _ 100 | except ImportError: 101 | skip(reason='"pyarrow" dependency is not installed in this test environment.') 102 | elif codec == "parquet": 103 | try: 104 | import pyarrow as _ 105 | except ImportError: 106 | skip(reason='"pyarrow" dependency is not installed in this test environment.') 107 | 108 | 109 | @fixture(autouse=True) 110 | def skip_if_dynamodb_server_cannot_be_reached(request: FixtureRequest): 111 | """ 112 | Skip test with "dynamodb" backend in all cases as of now. 113 | 114 | --- 115 | :param: request `FixtureRequest` 116 | """ 117 | backend: str = get_config_value_from_client_configs("backend", request) 118 | if backend == "dynamodb": 119 | skip(reason="DynamoDB tests are disabled in version 0.1.8") 120 | 121 | 122 | @fixture(autouse=True) 123 | def skip_if_memcached_server_cannot_be_reached(request: FixtureRequest): 124 | """ 125 | Skip test with "memcached" backend in the case that Memcached server defined by "memcached_host" 126 | cannot be reached. 127 | 128 | --- 129 | :param: request `FixtureRequest` 130 | """ 131 | memcached_host: str = get_config_value_from_client_configs("memcached_host", request) 132 | if memcached_host is not None: 133 | from asyncio import BaseEventLoop, set_event_loop, get_event_loop 134 | from aiomcache import Client 135 | 136 | client: Client = Client(memcached_host) 137 | loop: BaseEventLoop = get_event_loop() 138 | try: 139 | loop.run_until_complete(client.get(b"test")) 140 | except OSError: 141 | skip(reason="Memcached Server cannot be reached.") 142 | finally: 143 | loop.run_until_complete(client.close()) 144 | set_event_loop(loop) 145 | 146 | 147 | @fixture(autouse=True) 148 | def skip_if_redis_server_cannot_be_reached(request: FixtureRequest): 149 | """ 150 | Skip test with "redis" backend in the case that Redis server defined by "redis_url" 151 | cannot be reached. 152 | 153 | --- 154 | :param: request `FixtureRequest` 155 | """ 156 | redis_url: str = get_config_value_from_client_configs("redis_url", request) 157 | if redis_url is not None: 158 | from redis import Redis 159 | from redis.exceptions import ConnectionError 160 | 161 | try: 162 | Redis.from_url(redis_url).get("test") 163 | except ConnectionError: 164 | skip(reason="Redis Server cannot be reached.") 165 | 166 | 167 | @fixture(autouse=True) 168 | def skip_if_mongodb_server_cannot_be_reached(request: FixtureRequest): 169 | """ 170 | Skip test with "mongodb" backend in the case that MongoDB server defined by "mongodb_url" 171 | cannot be reached. 172 | 173 | --- 174 | :param: request `FixtureRequest` 175 | """ 176 | mongodb_url: str = get_config_value_from_client_configs("mongodb_url", request) 177 | if mongodb_url is not None: 178 | from pymongo import MongoClient 179 | from pymongo.errors import ServerSelectionTimeoutError 180 | 181 | try: 182 | client: MongoClient = MongoClient(mongodb_url, serverSelectionTimeoutMS=1) 183 | client["test"].list_collection_names(filter={"name": "test"}) 184 | except ServerSelectionTimeoutError: 185 | skip(reason="MongoDB Server cannot be reached.") 186 | -------------------------------------------------------------------------------- /tests/codecs/json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/codecs/none.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-06 3:27 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Tests for codec implementations on TestClient which serializes, deserializes in JSON format""" 13 | 14 | ### Standard packages ### 15 | from contextlib import asynccontextmanager 16 | from typing import Any, AsyncGenerator, List, Tuple 17 | 18 | ### Third-party packages ### 19 | from fastapi import Depends, FastAPI 20 | from fastapi.responses import PlainTextResponse 21 | from fastapi.testclient import TestClient 22 | from httpx import Response 23 | from pytest import FixtureRequest, fixture, mark 24 | from pytest_asyncio import fixture as asyncfixture 25 | 26 | ### Local modules ### 27 | from cachette import Cachette 28 | 29 | 30 | @fixture(scope="module") 31 | def items() -> List[Any]: 32 | return [ 33 | None, 34 | 123, # Integer 35 | 123.45, # Float 36 | "A", # Charstring 37 | "Hello, World!", # String 38 | "123", # Alphanumeric String of an integer 39 | "123.45", # Alphanumeric String of a float 40 | {"a": "b", "c": "d"}, # Dictionary with String values 41 | {"a": 1, "b": 2}, # Dictionary with Integer values 42 | {"a": 1.2, "b": 3.4}, # Dictionary with Float values 43 | [1, 2, 3], # List of integers 44 | [1.1, 2.2, 3.3], # List of floats 45 | ["a", "b", "c"], # List of charstrings 46 | ] 47 | 48 | 49 | @asynccontextmanager 50 | @asyncfixture(scope="function") 51 | async def client(items: List[Any], request: FixtureRequest) -> AsyncGenerator[TestClient, None]: 52 | """ 53 | Sets up a FastAPI TestClient wrapped around test application with Cachette 54 | 55 | --- 56 | :return: instance of Cachette api service for testing 57 | :rtype: TestClient 58 | """ 59 | configs: List[Tuple[str, Any]] = request.param 60 | 61 | app: FastAPI = FastAPI() 62 | 63 | @Cachette.load_config 64 | def get_cachette_config(): 65 | return configs 66 | 67 | ### Routing ### 68 | @app.get("/put-items", response_class=PlainTextResponse, status_code=200) 69 | async def put_items(cachette: Cachette = Depends()): 70 | """ 71 | Puts a list of pre-determined items to cache 72 | """ 73 | for i, item in enumerate(items): 74 | await cachette.put(f"{ i }", item) 75 | return "OK" 76 | 77 | @app.get("/fetch-items", response_class=PlainTextResponse, status_code=200) 78 | async def fetch_items(cachette: Cachette = Depends()): 79 | """ 80 | Returns key value 81 | """ 82 | ok: bool = True 83 | for i, item in enumerate(items): 84 | uncached: str = await cachette.fetch(f"{ i }") 85 | if uncached != item: 86 | ok = False 87 | break 88 | return ("", "OK")[ok] 89 | 90 | with TestClient(app) as test_client: 91 | yield test_client 92 | 93 | 94 | @mark.parametrize( 95 | "client", 96 | [ 97 | ### InMemory & JSON Codecs ### 98 | [("backend", "inmemory"), ("codec", "json")], 99 | [("backend", "inmemory"), ("codec", "orjson")], 100 | ### Memcached & JSON Codecs ### 101 | [("backend", "memcached"), ("codec", "json"), ("memcached_host", "localhost")], 102 | [ 103 | ("backend", "memcached"), 104 | ("codec", "orjson"), 105 | ("memcached_host", "localhost"), 106 | ], 107 | ### MongoDB & JSON Codecs ### 108 | [ 109 | ("backend", "mongodb"), 110 | ("codec", "json"), 111 | ("database_name", "cachette-db"), 112 | ("mongodb_url", "mongodb://localhost:27017"), 113 | ], 114 | [ 115 | ("backend", "mongodb"), 116 | ("codec", "orjson"), 117 | ("database_name", "cachette-db"), 118 | ("mongodb_url", "mongodb://localhost:27017"), 119 | ], 120 | ### Redis & JSON Codecs ### 121 | [ 122 | ("backend", "redis"), 123 | ("codec", "json"), 124 | ("redis_url", "redis://localhost:6379"), 125 | ], 126 | [ 127 | ("backend", "redis"), 128 | ("codec", "orjson"), 129 | ("redis_url", "redis://localhost:6379"), 130 | ], 131 | ], 132 | ids=[ 133 | "inmemory-json", 134 | "inmemory-orjson", 135 | "memcached-json", 136 | "memcached-orjson", 137 | "mongodb-json", 138 | "mongodb-orjson", 139 | "redis-json", 140 | "redis-orjson", 141 | ], 142 | indirect=True, 143 | ) 144 | def test_every_backend_with_every_codec(client: TestClient) -> None: 145 | response: Response = client.get("/put-items") 146 | assert response.text == "OK" 147 | response = client.get("/fetch-items") 148 | assert response.text == "OK" 149 | -------------------------------------------------------------------------------- /tests/codecs/primitives.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/codecs/primitives.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-06 3:27 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """ 13 | Tests for codec implementations on TestClient which can encode/decode primitives with correct types 14 | """ 15 | 16 | ### Standard packages ### 17 | from contextlib import asynccontextmanager 18 | from typing import Any, AsyncGenerator, List, Tuple 19 | 20 | ### Third-party packages ### 21 | from fastapi import Depends, FastAPI 22 | from fastapi.responses import PlainTextResponse 23 | from fastapi.testclient import TestClient 24 | from httpx import Response 25 | from pytest import FixtureRequest, fixture, mark 26 | from pytest_asyncio import fixture as asyncfixture 27 | 28 | ### Local modules ### 29 | from cachette import Cachette 30 | 31 | 32 | @fixture(scope="module") 33 | def items() -> List[Any]: 34 | return [ 35 | None, 36 | 123, # Integer 37 | 123.45, # Float 38 | "A", # Charstring 39 | "Hello, World!", # String 40 | "123", # Alphanumeric String of an integer 41 | "123.45", # Alphanumeric String of a float 42 | b"A", # Charbytes 43 | b"Hello, World!", # Bytes 44 | b"123", # Alphanumeric Bytes of an integer 45 | b"123.45", # Alphanumeric Bytes of a float 46 | {"a": "b", "c": "d"}, # Dictionary with String values 47 | {"a": b"b", "c": b"d"}, # Dictionary with Byte values 48 | {"a": 1, "b": 2}, # Dictionary with Integer values 49 | {"a": 1.2, "b": 3.4}, # Dictionary with Float values 50 | [1, 2, 3], # List of numbers 51 | ["a", "b", "c"], # List of charstrings 52 | [b"a", b"b", b"c"], # List of charbytes 53 | ] 54 | 55 | 56 | @asynccontextmanager 57 | @asyncfixture(scope="function") 58 | async def client(items: List[Any], request: FixtureRequest) -> AsyncGenerator[TestClient, None]: 59 | configs: List[Tuple[str, Any]] = request.param 60 | 61 | app = FastAPI() 62 | 63 | @Cachette.load_config 64 | def get_cachette_config(): 65 | return configs 66 | 67 | ### Routing ### 68 | @app.get("/put-items", response_class=PlainTextResponse, status_code=200) 69 | async def put_items(cachette: Cachette = Depends()): 70 | """ 71 | Puts a list of pre-determined items to cache 72 | """ 73 | for i, item in enumerate(items): 74 | await cachette.put(f"{ i }", item) 75 | return "OK" 76 | 77 | @app.get("/fetch-items", response_class=PlainTextResponse, status_code=200) 78 | async def fetch_items(cachette: Cachette = Depends()): 79 | """ 80 | Returns key value 81 | """ 82 | ok: bool = True 83 | for i, item in enumerate(items): 84 | uncached: Any = await cachette.fetch(f"{ i }") 85 | if uncached != item: 86 | ok = False 87 | break 88 | return ("", "OK")[ok] 89 | 90 | with TestClient(app) as test_client: 91 | yield test_client 92 | 93 | 94 | @mark.parametrize( 95 | "client", 96 | [ 97 | ### InMemory & Codecs ### 98 | [("backend", "inmemory"), ("codec", "msgpack")], 99 | ### Memcached & Codecs ### 100 | [ 101 | ("backend", "memcached"), 102 | ("codec", "msgpack"), 103 | ("memcached_host", "localhost"), 104 | ], 105 | ### MongoDB & Codecs ### 106 | [ 107 | ("backend", "mongodb"), 108 | ("database_name", "cachette-db"), 109 | ("codec", "msgpack"), 110 | ("mongodb_url", "mongodb://localhost:27017"), 111 | ], 112 | ### Redis & Codecs ### 113 | [ 114 | ("backend", "redis"), 115 | ("codec", "msgpack"), 116 | ("redis_url", "redis://localhost:6379"), 117 | ], 118 | ], 119 | ids=[ 120 | "inmemory-msgpack", 121 | "memcached-msgpack", 122 | "mongodb-msgpack", 123 | "redis-msgpack", 124 | ], 125 | indirect=True, 126 | ) 127 | def test_every_backend_with_every_codec(client: TestClient) -> None: 128 | response: Response = client.get("/put-items") 129 | assert response.text == "OK" 130 | response = client.get("/fetch-items") 131 | assert response.text == "OK" 132 | -------------------------------------------------------------------------------- /tests/codecs/unfrozen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/codecs/unfrozen.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-06 22:03 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Tests for codec implementations on TestClient which can encode/decode frozen objects fully""" 13 | 14 | ### Standard packages ### 15 | from contextlib import asynccontextmanager 16 | from datetime import date, datetime 17 | from decimal import Decimal 18 | from typing import Any, AsyncGenerator, List, Tuple 19 | 20 | ### Third-party packages ### 21 | from fastapi import Depends, FastAPI 22 | from fastapi.responses import PlainTextResponse 23 | from fastapi.testclient import TestClient 24 | from httpx import Response 25 | from pytest import FixtureRequest, fixture, mark 26 | from pytest_asyncio import fixture as asyncfixture 27 | 28 | ### Local modules ### 29 | from cachette import Cachette 30 | 31 | 32 | @fixture(scope="module") 33 | def items() -> List[Any]: 34 | return [ 35 | None, 36 | 123, # Integer 37 | 123.45, # Float 38 | "A", # Charstring 39 | "Hello, World!", # String 40 | "123", # Alphanumeric String of an integer 41 | "123.45", # Alphanumeric String of a float 42 | b"A", # Charbytes 43 | b"Hello, World!", # Bytes 44 | b"123", # Alphanumeric Bytes of an integer 45 | b"123.45", # Alphanumeric Bytes of a float 46 | {"a": "b", "c": "d"}, # Dictionary with String values 47 | {"a": b"b", "c": b"d"}, # Dictionary with Byte values 48 | {"a": 1, "b": 2}, # Dictionary with Integer values 49 | {"a": 1.2, "b": 3.4}, # Dictionary with Float values 50 | [1, 2, 3], # List of numbers 51 | ["a", "b", "c"], # List of charstrings 52 | [b"a", b"b", b"c"], # List of charbytes 53 | {1, 2, 3}, # Set of numbers 54 | {"a", "b", "c"}, # Set of charstrings 55 | {b"a", b"b", b"c"}, # Set of charbytes 56 | Decimal(12345.67890), # Decimal 57 | date.today(), # Date 58 | datetime.now(), # Datetime 59 | ] 60 | 61 | 62 | @asynccontextmanager 63 | @asyncfixture(scope="function") 64 | async def client(items: List[Any], request: FixtureRequest) -> AsyncGenerator[TestClient, None]: 65 | configs: List[Tuple[str, Any]] = request.param 66 | 67 | app: FastAPI = FastAPI() 68 | 69 | @Cachette.load_config 70 | def get_cachette_config(): 71 | return configs 72 | 73 | ### Routing ### 74 | @app.get("/put-items", response_class=PlainTextResponse, status_code=200) 75 | async def put_items(cachette: Cachette = Depends()): 76 | """ 77 | Puts a list of pre-determined items to cache 78 | """ 79 | for i, item in enumerate(items): 80 | await cachette.put(f"{ i }", item) 81 | return "OK" 82 | 83 | @app.get("/fetch-items", response_class=PlainTextResponse, status_code=200) 84 | async def fetch_items(cachette: Cachette = Depends()): 85 | """ 86 | Returns key value 87 | """ 88 | ok: bool = True 89 | for i, item in enumerate(items): 90 | uncached: Any = await cachette.fetch(f"{ i }") 91 | if uncached != item: 92 | ok = False 93 | break 94 | return ("", "OK")[ok] 95 | 96 | with TestClient(app) as test_client: 97 | yield test_client 98 | 99 | 100 | @mark.parametrize( 101 | "client", 102 | [ 103 | ### InMemory & Codecs ### 104 | [("backend", "inmemory"), ("codec", "pickle")], 105 | ### Memcached & Codecs ### 106 | [ 107 | ("backend", "memcached"), 108 | ("codec", "pickle"), 109 | ("memcached_host", "localhost"), 110 | ], 111 | ### MongoDB & Codecs ### 112 | [ 113 | ("backend", "mongodb"), 114 | ("database_name", "cachette-db"), 115 | ("codec", "pickle"), 116 | ("mongodb_url", "mongodb://localhost:27017"), 117 | ], 118 | ### Redis & Codecs ### 119 | [ 120 | ("backend", "redis"), 121 | ("codec", "pickle"), 122 | ("redis_url", "redis://localhost:6379"), 123 | ], 124 | ], 125 | ids=[ 126 | "inmemory-pickle", 127 | "memcached-pickle", 128 | "mongodb-pickle", 129 | "redis-pickle", 130 | ], 131 | indirect=True, 132 | ) 133 | def test_every_backend_with_every_codec(client) -> None: 134 | response: Response = client.get("/put-items") 135 | assert response.text == "OK" 136 | response = client.get("/fetch-items") 137 | assert response.text == "OK" 138 | -------------------------------------------------------------------------------- /tests/load_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding:utf-8 3 | # Copyright (C) 2022-2024, All rights reserved. 4 | # FILENAME: ~~/tests/load_config.py 5 | # VERSION: 0.1.8 6 | # CREATED: 2022-04-03 20:34 7 | # AUTHOR: Sitt Guruvanich 8 | # DESCRIPTION: 9 | # 10 | # HISTORY: 11 | # ************************************************************* 12 | """Test suite containing configuration loading tests via `LoadConfig`""" 13 | 14 | ### Standard packages ### 15 | from typing import Any, List, Tuple 16 | 17 | ### Third-party packages ### 18 | from pydantic import ValidationError 19 | from pytest import mark, raises 20 | 21 | ### Local modules ### 22 | from cachette import Cachette 23 | 24 | 25 | @mark.parametrize( 26 | "configs", 27 | [ 28 | ### InMemory ### 29 | [("backend", "inmemory")], 30 | [("backend", "inmemory"), ("ttl", 1)], 31 | [("backend", "inmemory"), ("ttl", 3600)], 32 | [("backend", "inmemory"), ("table_name", None)], 33 | ### Memcached ### 34 | [("backend", "memcached"), ("memcached_host", "localhost")], 35 | [("backend", "memcached"), ("ttl", 1), ("memcached_host", "localhost")], 36 | [ 37 | ("backend", "memcached"), 38 | ("memcached_host", "localhost"), 39 | ("table_name", None), 40 | ], 41 | ### MongoDB ### 42 | [ 43 | ("backend", "mongodb"), 44 | ("database_name", "cachette-db"), 45 | ("mongodb_url", "mongodb://localhost:27017"), 46 | ], 47 | [ 48 | ("backend", "mongodb"), 49 | ("database_name", "cachette-db"), 50 | ("ttl", 1), 51 | ("mongodb_url", "mongodb://localhost:27017"), 52 | ], 53 | [ 54 | ("backend", "mongodb"), 55 | ("database_name", "cachette-db"), 56 | ("ttl", 3600), 57 | ("mongodb_url", "mongodb://localhost:27017"), 58 | ], 59 | ### Redis ### 60 | [("backend", "redis"), ("redis_url", "redis://localhost:6379")], 61 | [("backend", "redis"), ("ttl", 1), ("redis_url", "redis://localhost:6379")], 62 | [("backend", "redis"), ("ttl", 3600), ("redis_url", "redis://localhost:6379")], 63 | [ 64 | ("backend", "redis"), 65 | ("redis_url", "redis://localhost:6379"), 66 | ("table_name", None), 67 | ], 68 | ], 69 | ) 70 | def test_load_valid_configs(configs: List[Tuple[str, Any]]) -> None: 71 | @Cachette.load_config 72 | def load_cachette_configs() -> List[Tuple[str, Any]]: 73 | return configs 74 | 75 | 76 | @mark.parametrize( 77 | "invalid_configs, reason", 78 | [ 79 | ### InMemory ### 80 | ( 81 | [("backend", "inmemory"), ("ttl", 0)], 82 | 'The "ttl" value must between 1 or 3600 seconds.', 83 | ), 84 | ( 85 | [("backend", "inmemory"), ("ttl", 3601)], 86 | 'The "ttl" value must between 1 or 3600 seconds.', 87 | ), 88 | ### Memcached ### 89 | ( 90 | [("backend", "memcached")], 91 | 'The "memcached_host" cannot be null when using memcached as backend.', 92 | ), 93 | ( 94 | [("backend", "memcached"), ("ttl", 0), ("memcached_host", "localhost")], 95 | 'The "ttl" value must between 1 or 3600 seconds.', 96 | ), 97 | ( 98 | [("backend", "memcached"), ("ttl", 3601), ("memcached_host", "localhost")], 99 | 'The "ttl" value must between 1 or 3600 seconds.', 100 | ), 101 | ### MongoDB ### 102 | ( 103 | [("backend", "mongodb")], 104 | 'The "mongodb_url" cannot be null when using MongoDB as backend.', 105 | ), 106 | ( 107 | [("backend", "mongodb"), ("database_name", "customized-database-name")], 108 | 'The "mongodb_url" cannot be null when using MongoDB as backend.', 109 | ), 110 | ( 111 | [ 112 | ("backend", "mongodb"), 113 | ("mongodb_url", "mongodb://localhost:27017"), 114 | ("database_name", None), 115 | ], 116 | 'The "database_name" cannot be null when using MongoDB as backend.', 117 | ), 118 | ( 119 | [ 120 | ("backend", "mongodb"), 121 | ("database_name", "customized-database-name"), 122 | ("mongodb_url", "http://localhost:27017"), 123 | ("table_name", None), 124 | ], 125 | 'The "table_name" cannot be null when using MongoDB as backend.', 126 | ), 127 | ### Redis ### 128 | ( 129 | [("backend", "redis")], 130 | 'The "redis_url" cannot be null when using redis as backend.', 131 | ), 132 | ( 133 | [("backend", "redis"), ("ttl", 0), ("redis_url", "redis://localhost:6379")], 134 | 'The "ttl" value must between 1 or 3600 seconds.', 135 | ), 136 | ( 137 | [ 138 | ("backend", "redis"), 139 | ("ttl", 3601), 140 | ("redis_url", "redis://localhost:6379"), 141 | ], 142 | 'The "ttl" value must between 1 or 3600 seconds.', 143 | ), 144 | ### Pickle ### 145 | ( 146 | [("backend", "pickle")], 147 | 'The "pickle_path" cannot be null when using pickle as backend.', 148 | ), 149 | ( 150 | [("backend", "pickle"), ("ttl", 0), ("pickle_path", "tests/cachette.pkl")], 151 | 'The "ttl" value must between 1 or 3600 seconds.', 152 | ), 153 | ], 154 | ) 155 | def test_load_invalid_configs(invalid_configs: List[Tuple[str, Any]], reason: str) -> None: 156 | with raises(ValidationError) as exc_info: 157 | 158 | @Cachette.load_config 159 | def load_cachette_configs(): 160 | return invalid_configs 161 | 162 | assert exc_info.match(reason) 163 | --------------------------------------------------------------------------------