├── .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 | [](https://pypi.org/project/cachette)
4 | [](https://pypi.org/project/cachette)
5 | [](https://pypi.org/project/cachette)
6 | [](https://pypi.org/project/cachette)
7 | [](.)
8 | [](.)
9 | [](.)
10 | [](.)
11 |
12 | 
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 |
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 |
--------------------------------------------------------------------------------