mirror of
https://github.com/long2ice/fastapi-cache.git
synced 2026-03-25 04:57:54 +00:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d04be274e9 | ||
|
|
80563fd6e7 | ||
|
|
98cf8a78a1 | ||
|
|
01c895dbbb | ||
|
|
e3b08dda2c | ||
|
|
552a7695e8 | ||
|
|
ea1ffcd7b4 | ||
|
|
e8193b5c22 | ||
|
|
ab26fad604 | ||
|
|
7a89f28b54 | ||
|
|
334b829a80 | ||
|
|
62ef8bed37 | ||
|
|
9a39db7a73 | ||
|
|
59a47b7fae | ||
|
|
09361a7d4f | ||
|
|
ed101595f7 | ||
|
|
0c73777930 | ||
|
|
0d964fcf9f | ||
|
|
614ee25d0d | ||
|
|
b420f26e9b | ||
|
|
e23289fcbf | ||
|
|
8f0920d0d7 | ||
|
|
91e6e51ec7 | ||
|
|
5c776d20db | ||
|
|
c4ae7154fd | ||
|
|
cb9fe5c065 | ||
|
|
c1484a46fd | ||
|
|
2710129c4e | ||
|
|
4cb4afeff0 | ||
|
|
a8fbf2b340 | ||
|
|
73f000a565 | ||
|
|
cda720f534 | ||
|
|
5881bb9122 | ||
|
|
10f819483c | ||
|
|
566d30b790 | ||
|
|
671af52aea | ||
|
|
71a77f6b39 | ||
|
|
e555d5e9be | ||
|
|
d88b9eaab0 | ||
|
|
c1a0e97f73 | ||
|
|
f3f134a318 | ||
|
|
5781593829 | ||
|
|
c6bd8483a4 | ||
|
|
e842d6408e | ||
|
|
68ef94f2db | ||
|
|
4c6abcf786 | ||
|
|
1ef80ff457 | ||
|
|
6ba06bb10f | ||
|
|
d0c0885eae | ||
|
|
630b175766 | ||
|
|
ceb70426f3 | ||
|
|
d123ec4bfa | ||
|
|
eeea884bb4 | ||
|
|
af9c4d4c56 | ||
|
|
2822ab5d71 | ||
|
|
7e64cd6490 | ||
|
|
34415ad50a | ||
|
|
3dd4887b37 | ||
|
|
3041af2216 | ||
|
|
5c6f819636 | ||
|
|
3a481a36ed | ||
|
|
cb9259807e | ||
|
|
a4b3386bf0 | ||
|
|
f310ef5b2d | ||
|
|
6cc1e65abb | ||
|
|
820689ce9a | ||
|
|
309d9ce7d1 | ||
|
|
e62f0117e0 | ||
|
|
6dc449b830 | ||
|
|
36e0812c19 | ||
|
|
70f5eed50b | ||
|
|
15576b482a | ||
|
|
f80bfdb18d | ||
|
|
aaed438d8f | ||
|
|
d6c52408d2 | ||
|
|
8c92cc59ae | ||
|
|
824e2e145f | ||
|
|
7fa54d311f | ||
|
|
9582e04d43 | ||
|
|
fd8cf2da11 | ||
|
|
2f1b1409b9 | ||
|
|
269c1ca616 | ||
|
|
89826b0a3b | ||
|
|
e5250c7f58 | ||
|
|
1795c048d1 | ||
|
|
81d2bf2cc6 | ||
|
|
70f53566aa | ||
|
|
9928f4cda0 | ||
|
|
4faa5b7101 | ||
|
|
c3be2eca19 | ||
|
|
11f01a21f5 | ||
|
|
cdcfdc6ae6 | ||
|
|
a40c54e9e7 | ||
|
|
d67797a1d5 | ||
|
|
8a8eb395ec | ||
|
|
e397dcb16b | ||
|
|
37a2fa85db | ||
|
|
6888c10d6c | ||
|
|
943935870d | ||
|
|
dacd7e1b0f | ||
|
|
46c7ada364 | ||
|
|
767241be41 | ||
|
|
de1bde39fd | ||
|
|
8490ad36f0 | ||
|
|
57fe4ce24b | ||
|
|
3dc2b53e41 | ||
|
|
2dd37b09ab | ||
|
|
0bc8c6c20e | ||
|
|
9e3c9816c5 |
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,2 +1,2 @@
|
||||
custom: ["https://sponsor.long2ice.cn"]
|
||||
custom: ["https://sponsor.long2ice.io"]
|
||||
|
||||
|
||||
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -4,13 +4,12 @@ jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Install and configure Poetry
|
||||
uses: snok/install-poetry@v1.1.1
|
||||
with:
|
||||
virtualenvs-create: false
|
||||
- uses: abatilo/actions-poetry@v2.1.6
|
||||
- name: Config poetry
|
||||
run: poetry config experimental.new-installer false
|
||||
- name: CI
|
||||
run: make ci
|
||||
|
||||
7
.github/workflows/pypi.yml
vendored
7
.github/workflows/pypi.yml
vendored
@@ -11,10 +11,9 @@ jobs:
|
||||
- uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install and configure Poetry
|
||||
uses: snok/install-poetry@v1.1.1
|
||||
with:
|
||||
virtualenvs-create: false
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- name: Config poetry
|
||||
run: poetry config experimental.new-installer false
|
||||
- name: Build dists
|
||||
run: make build
|
||||
- name: Pypi Publish
|
||||
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -1,7 +1,53 @@
|
||||
# ChangeLog
|
||||
|
||||
## 0.2
|
||||
|
||||
### 0.2.1
|
||||
- Fix picklecoder
|
||||
- Fix connection failure transparency and add logging
|
||||
- Add Cache-Control and ETag on first response
|
||||
- Support Async RedisCluster client from redis-py
|
||||
|
||||
### 0.2.0
|
||||
|
||||
- Make `request` and `response` optional.
|
||||
- Add typing info to the `cache` decorator.
|
||||
- Support cache jinja2 template response.
|
||||
- Support cache `JSONResponse`
|
||||
- Add `py.typed` file and type hints
|
||||
- Add TestCase
|
||||
- Fix cache decorate sync function
|
||||
- Transparently handle backend connection failures.
|
||||
|
||||
## 0.1
|
||||
|
||||
### 0.1.10
|
||||
|
||||
- Add `Cache-Control:no-cache` support.
|
||||
|
||||
### 0.1.9
|
||||
|
||||
- Replace `aioredis` with `redis-py`.
|
||||
|
||||
### 0.1.8
|
||||
|
||||
- Support `dynamodb` backend.
|
||||
|
||||
### 0.1.7
|
||||
|
||||
- Fix default json coder for datetime.
|
||||
- Add `enable` param to `init`.
|
||||
|
||||
### 0.1.6
|
||||
|
||||
- Fix redis cache.
|
||||
- Encode key builder.
|
||||
|
||||
### 0.1.5
|
||||
|
||||
- Fix setting expire for redis (#24)
|
||||
- Update expire key
|
||||
|
||||
### 0.1.4
|
||||
|
||||
- Fix default expire for memcached. (#13)
|
||||
|
||||
24
Makefile
24
Makefile
@@ -1,19 +1,6 @@
|
||||
checkfiles = fastapi_cache/ examples/ tests/
|
||||
black_opts = -l 100 -t py38
|
||||
py_warn = PYTHONDEVMODE=1
|
||||
|
||||
help:
|
||||
@echo "FastAPI-Cache development makefile"
|
||||
@echo
|
||||
@echo "usage: make <target>"
|
||||
@echo "Targets:"
|
||||
@echo " up Ensure dev/test dependencies are updated"
|
||||
@echo " deps Ensure dev/test dependencies are installed"
|
||||
@echo " check Checks that build is sane"
|
||||
@echo " test Runs all tests"
|
||||
@echo " style Auto-formats the code"
|
||||
@echo " build Build package"
|
||||
|
||||
up:
|
||||
@poetry update
|
||||
|
||||
@@ -21,16 +8,15 @@ deps:
|
||||
@poetry install --no-root -E all
|
||||
|
||||
style: deps
|
||||
@isort -src $(checkfiles)
|
||||
@black $(black_opts) $(checkfiles)
|
||||
@poetry run isort -src $(checkfiles)
|
||||
@poetry run black $(checkfiles)
|
||||
|
||||
check: deps
|
||||
@black --check $(black_opts) $(checkfiles) || (echo "Please run 'make style' to auto-fix style issues" && false)
|
||||
@flake8 $(checkfiles)
|
||||
@bandit -r $(checkfiles)
|
||||
@poetry run black $(checkfiles) || (echo "Please run 'make style' to auto-fix style issues" && false)
|
||||
@poetry run flake8 $(checkfiles)
|
||||
|
||||
test: deps
|
||||
$(py_warn) pytest
|
||||
$(py_warn) poetry run pytest
|
||||
|
||||
build: clean deps
|
||||
@poetry build
|
||||
|
||||
67
README.md
67
README.md
@@ -7,11 +7,12 @@
|
||||
|
||||
## Introduction
|
||||
|
||||
`fastapi-cache` is a tool to cache fastapi response and function result, with backends support `redis` and `memcache`.
|
||||
`fastapi-cache` is a tool to cache fastapi response and function result, with backends support `redis`, `memcache`,
|
||||
and `dynamodb`.
|
||||
|
||||
## Features
|
||||
|
||||
- Support `redis` and `memcache` and `in-memory` backends.
|
||||
- Support `redis`, `memcache`, `dynamodb`, and `in-memory` backends.
|
||||
- Easily integration with `fastapi`.
|
||||
- Support http cache like `ETag` and `Cache-Control`.
|
||||
|
||||
@@ -20,6 +21,7 @@
|
||||
- `asyncio` environment.
|
||||
- `redis` if use `RedisBackend`.
|
||||
- `memcache` if use `MemcacheBackend`.
|
||||
- `aiobotocore` if use `DynamoBackend`.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -30,13 +32,19 @@
|
||||
or
|
||||
|
||||
```shell
|
||||
> pip install fastapi-cache2[redis]
|
||||
> pip install "fastapi-cache2[redis]"
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```shell
|
||||
> pip install fastapi-cache2[memcache]
|
||||
> pip install "fastapi-cache2[memcache]"
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```shell
|
||||
> pip install "fastapi-cache2[dynamodb]"
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -44,7 +52,6 @@ or
|
||||
### Quick Start
|
||||
|
||||
```python
|
||||
import aioredis
|
||||
from fastapi import FastAPI
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
@@ -53,6 +60,8 @@ from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.backends.redis import RedisBackend
|
||||
from fastapi_cache.decorator import cache
|
||||
|
||||
from redis import asyncio as aioredis
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@@ -63,13 +72,13 @@ async def get_cache():
|
||||
|
||||
@app.get("/")
|
||||
@cache(expire=60)
|
||||
async def index(request: Request, response: Response):
|
||||
async def index():
|
||||
return dict(hello="world")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
redis = await aioredis.create_redis_pool("redis://localhost", encoding="utf8")
|
||||
redis = aioredis.from_url("redis://localhost", encoding="utf8", decode_responses=True)
|
||||
FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache")
|
||||
|
||||
```
|
||||
@@ -80,7 +89,8 @@ Firstly you must call `FastAPICache.init` on startup event of `fastapi`, there a
|
||||
|
||||
### Use `cache` decorator
|
||||
|
||||
If you want cache `fastapi` response transparently, you can use `cache` as decorator between router decorator and view function and must pass `request` as param of view function.
|
||||
If you want cache `fastapi` response transparently, you can use `cache` as decorator between router decorator and view
|
||||
function and must pass `request` as param of view function.
|
||||
|
||||
Parameter | type, description
|
||||
------------ | -------------
|
||||
@@ -89,48 +99,57 @@ namespace | str, namespace to use to store certain cache items
|
||||
coder | which coder to use, e.g. JsonCoder
|
||||
key_builder | which key builder to use, default to builtin
|
||||
|
||||
|
||||
And if you want use `ETag` and `Cache-Control` features, you must pass `response` param also.
|
||||
|
||||
You can also use `cache` as decorator like other cache tools to cache common function result.
|
||||
|
||||
### Custom coder
|
||||
|
||||
By default use `JsonCoder`, you can write custom coder to encode and decode cache result, just need inherit `fastapi_cache.coder.Coder`.
|
||||
By default use `JsonCoder`, you can write custom coder to encode and decode cache result, just need
|
||||
inherit `fastapi_cache.coder.Coder`.
|
||||
|
||||
```python
|
||||
@app.get("/")
|
||||
@cache(expire=60,coder=JsonCoder)
|
||||
async def index(request: Request, response: Response):
|
||||
@cache(expire=60, coder=JsonCoder)
|
||||
async def index():
|
||||
return dict(hello="world")
|
||||
```
|
||||
|
||||
### Custom key builder
|
||||
|
||||
By default use builtin key builder, if you need, you can override this and pass in `cache` or `FastAPICache.init` to take effect globally.
|
||||
By default use builtin key builder, if you need, you can override this and pass in `cache` or `FastAPICache.init` to
|
||||
take effect globally.
|
||||
|
||||
```python
|
||||
def my_key_builder(
|
||||
func,
|
||||
namespace: Optional[str] = "",
|
||||
request: Request = None,
|
||||
response: Response = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
func,
|
||||
namespace: Optional[str] = "",
|
||||
request: Request = None,
|
||||
response: Response = None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
prefix = FastAPICache.get_prefix()
|
||||
cache_key = f"{prefix}:{namespace}:{func.__module__}:{func.__name__}:{args}:{kwargs}"
|
||||
return cache_key
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@cache(expire=60,coder=JsonCoder,key_builder=my_key_builder)
|
||||
async def index(request: Request, response: Response):
|
||||
@cache(expire=60, coder=JsonCoder, key_builder=my_key_builder)
|
||||
async def index():
|
||||
return dict(hello="world")
|
||||
```
|
||||
|
||||
### InMemoryBackend
|
||||
|
||||
`InMemoryBackend` store cache data in memory and use lazy delete, which mean if you don't access it after cached, it will not delete automatically.
|
||||
`InMemoryBackend` store cache data in memory and use lazy delete, which mean if you don't access it after cached, it
|
||||
will not delete automatically.
|
||||
|
||||
## Tests and coverage
|
||||
|
||||
```shell
|
||||
coverage run -m pytest
|
||||
coverage html
|
||||
xdg-open htmlcov/index.html
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
0
examples/in_memory/__init__.py
Normal file
0
examples/in_memory/__init__.py
Normal file
73
examples/in_memory/main.py
Normal file
73
examples/in_memory/main.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import pendulum
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.backends.inmemory import InMemoryBackend
|
||||
from fastapi_cache.decorator import cache
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
ret = 0
|
||||
|
||||
|
||||
@cache(namespace="test", expire=1)
|
||||
async def get_ret():
|
||||
global ret
|
||||
ret = ret + 1
|
||||
return ret
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@cache(namespace="test", expire=10)
|
||||
async def index():
|
||||
return dict(ret=await get_ret())
|
||||
|
||||
|
||||
@app.get("/clear")
|
||||
async def clear():
|
||||
return await FastAPICache.clear(namespace="test")
|
||||
|
||||
|
||||
@app.get("/date")
|
||||
@cache(namespace="test", expire=10)
|
||||
async def get_date():
|
||||
return pendulum.today()
|
||||
|
||||
|
||||
@app.get("/datetime")
|
||||
@cache(namespace="test", expire=2)
|
||||
async def get_datetime(request: Request, response: Response):
|
||||
return {"now": pendulum.now()}
|
||||
|
||||
@cache(namespace="test")
|
||||
async def func_kwargs(*unused_args, **kwargs):
|
||||
return kwargs
|
||||
|
||||
@app.get("/kwargs")
|
||||
async def get_kwargs(name: str):
|
||||
return await func_kwargs(name, name=name)
|
||||
|
||||
@app.get("/sync-me")
|
||||
@cache(namespace="test")
|
||||
def sync_me():
|
||||
# as per the fastapi docs, this sync function is wrapped in a thread,
|
||||
# thereby converted to async. fastapi-cache does the same.
|
||||
return 42
|
||||
|
||||
|
||||
@app.get("/cache_response_obj")
|
||||
@cache(namespace="test", expire=5)
|
||||
async def cache_response_obj():
|
||||
return JSONResponse({"a": 1})
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
FastAPICache.init(InMemoryBackend())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", debug=True, reload=True)
|
||||
@@ -1,39 +0,0 @@
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.backends.inmemory import InMemoryBackend
|
||||
from fastapi_cache.decorator import cache
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
ret = 0
|
||||
|
||||
|
||||
@cache(namespace="test", expire=1)
|
||||
async def get_ret():
|
||||
global ret
|
||||
ret = ret + 1
|
||||
return ret
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@cache(namespace="test", expire=2)
|
||||
async def index(request: Request, response: Response):
|
||||
return dict(ret=await get_ret())
|
||||
|
||||
|
||||
@app.get("/clear")
|
||||
async def clear():
|
||||
return await FastAPICache.clear(namespace="test")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
FastAPICache.init(InMemoryBackend(), prefix="fastapi-cache")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", debug=True, reload=True)
|
||||
0
examples/redis/__init__.py
Normal file
0
examples/redis/__init__.py
Normal file
10
examples/redis/index.html
Normal file
10
examples/redis/index.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Cache HTML! {{ ret }} </h1>
|
||||
</body>
|
||||
</html>
|
||||
90
examples/redis/main.py
Normal file
90
examples/redis/main.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import time
|
||||
|
||||
import pendulum
|
||||
import redis.asyncio as redis
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from redis.asyncio.connection import ConnectionPool
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.backends.redis import RedisBackend
|
||||
from fastapi_cache.coder import PickleCoder
|
||||
from fastapi_cache.decorator import cache
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.mount(
|
||||
path="/static",
|
||||
app=StaticFiles(directory="./"),
|
||||
name="static",
|
||||
)
|
||||
templates = Jinja2Templates(directory="./")
|
||||
ret = 0
|
||||
|
||||
|
||||
@cache(namespace="test", expire=1)
|
||||
async def get_ret():
|
||||
global ret
|
||||
ret = ret + 1
|
||||
return ret
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@cache(namespace="test", expire=10)
|
||||
async def index():
|
||||
return dict(ret=await get_ret())
|
||||
|
||||
|
||||
@app.get("/clear")
|
||||
async def clear():
|
||||
return await FastAPICache.clear(namespace="test")
|
||||
|
||||
|
||||
@app.get("/date")
|
||||
@cache(namespace="test", expire=10)
|
||||
async def get_data(request: Request, response: Response):
|
||||
return pendulum.today()
|
||||
|
||||
|
||||
# Note: This function MUST be sync to demonstrate fastapi-cache's correct handling,
|
||||
# i.e. running cached sync functions in threadpool just like FastAPI itself!
|
||||
@app.get("/blocking")
|
||||
@cache(namespace="test", expire=10)
|
||||
def blocking():
|
||||
time.sleep(2)
|
||||
return dict(ret=42)
|
||||
|
||||
|
||||
@app.get("/datetime")
|
||||
@cache(namespace="test", expire=2)
|
||||
async def get_datetime(request: Request, response: Response):
|
||||
print(request, response)
|
||||
return pendulum.now()
|
||||
|
||||
|
||||
@app.get("/html", response_class=HTMLResponse)
|
||||
@cache(expire=60, namespace="html", coder=PickleCoder)
|
||||
async def cache_html(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request, "ret": await get_ret()})
|
||||
|
||||
|
||||
@app.get("/cache_response_obj")
|
||||
@cache(namespace="test", expire=5)
|
||||
async def cache_response_obj():
|
||||
return JSONResponse({"a": 1})
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
pool = ConnectionPool.from_url(url="redis://redis")
|
||||
r = redis.Redis(connection_pool=pool)
|
||||
FastAPICache.init(RedisBackend(r), prefix="fastapi-cache")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", debug=True, reload=True)
|
||||
@@ -1,26 +1,29 @@
|
||||
from typing import Callable
|
||||
from typing import Callable, Optional, Type
|
||||
|
||||
from fastapi_cache.backends import Backend
|
||||
from fastapi_cache.coder import Coder, JsonCoder
|
||||
from fastapi_cache.key_builder import default_key_builder
|
||||
|
||||
|
||||
class FastAPICache:
|
||||
_backend = None
|
||||
_prefix = None
|
||||
_expire = None
|
||||
_backend: Optional[Backend] = None
|
||||
_prefix: Optional[str] = None
|
||||
_expire: Optional[int] = None
|
||||
_init = False
|
||||
_coder = None
|
||||
_key_builder = None
|
||||
_coder: Optional[Type[Coder]] = None
|
||||
_key_builder: Optional[Callable] = None
|
||||
_enable = True
|
||||
|
||||
@classmethod
|
||||
def init(
|
||||
cls,
|
||||
backend,
|
||||
backend: Backend,
|
||||
prefix: str = "",
|
||||
expire: int = None,
|
||||
coder: Coder = JsonCoder,
|
||||
expire: Optional[int] = None,
|
||||
coder: Type[Coder] = JsonCoder,
|
||||
key_builder: Callable = default_key_builder,
|
||||
):
|
||||
enable: bool = True,
|
||||
) -> None:
|
||||
if cls._init:
|
||||
return
|
||||
cls._init = True
|
||||
@@ -29,29 +32,48 @@ class FastAPICache:
|
||||
cls._expire = expire
|
||||
cls._coder = coder
|
||||
cls._key_builder = key_builder
|
||||
cls._enable = enable
|
||||
|
||||
@classmethod
|
||||
def get_backend(cls):
|
||||
def reset(cls) -> None:
|
||||
cls._init = False
|
||||
cls._backend = None
|
||||
cls._prefix = None
|
||||
cls._expire = None
|
||||
cls._coder = None
|
||||
cls._key_builder = None
|
||||
cls._enable = True
|
||||
|
||||
@classmethod
|
||||
def get_backend(cls) -> Backend:
|
||||
assert cls._backend, "You must call init first!" # nosec: B101
|
||||
return cls._backend
|
||||
|
||||
@classmethod
|
||||
def get_prefix(cls):
|
||||
def get_prefix(cls) -> str:
|
||||
assert cls._prefix is not None, "You must call init first!" # nosec: B101
|
||||
return cls._prefix
|
||||
|
||||
@classmethod
|
||||
def get_expire(cls):
|
||||
def get_expire(cls) -> Optional[int]:
|
||||
return cls._expire
|
||||
|
||||
@classmethod
|
||||
def get_coder(cls):
|
||||
def get_coder(cls) -> Type[Coder]:
|
||||
assert cls._coder, "You must call init first!" # nosec: B101
|
||||
return cls._coder
|
||||
|
||||
@classmethod
|
||||
def get_key_builder(cls):
|
||||
def get_key_builder(cls) -> Callable:
|
||||
assert cls._key_builder, "You must call init first!" # nosec: B101
|
||||
return cls._key_builder
|
||||
|
||||
@classmethod
|
||||
async def clear(cls, namespace: str = None, key: str = None):
|
||||
namespace = cls._prefix + ":" + namespace if namespace else None
|
||||
def get_enable(cls) -> bool:
|
||||
return cls._enable
|
||||
|
||||
@classmethod
|
||||
async def clear(cls, namespace: Optional[str] = None, key: Optional[str] = None) -> int:
|
||||
assert cls._backend and cls._prefix is not None, "You must call init first!" # nosec: B101
|
||||
namespace = cls._prefix + (":" + namespace if namespace else "")
|
||||
return await cls._backend.clear(namespace, key)
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import abc
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
class Backend:
|
||||
@abc.abstractmethod
|
||||
async def get_with_ttl(self, key: str) -> Tuple[int, str]:
|
||||
async def get_with_ttl(self, key: str) -> Tuple[int, Optional[str]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get(self, key: str) -> str:
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
async def set(self, key: str, value: str, expire: int = None):
|
||||
async def set(self, key: str, value: str, expire: Optional[int] = None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
async def clear(self, namespace: str = None, key: str = None) -> int:
|
||||
async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
93
fastapi_cache/backends/dynamodb.py
Normal file
93
fastapi_cache/backends/dynamodb.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from aiobotocore.client import AioBaseClient
|
||||
from aiobotocore.session import get_session
|
||||
|
||||
from fastapi_cache.backends import Backend
|
||||
|
||||
|
||||
class DynamoBackend(Backend):
|
||||
"""
|
||||
Amazon DynamoDB backend provider
|
||||
|
||||
This backend requires an existing table within your AWS environment to be passed during
|
||||
backend init. If ttl is going to be used, this needs to be manually enabled on the table
|
||||
using the `ttl` key. Dynamo will take care of deleting outdated objects, but this is not
|
||||
instant so don't be alarmed when they linger around for a bit.
|
||||
|
||||
As with all AWS clients, credentials will be taken from the environment. Check the AWS SDK
|
||||
for more information.
|
||||
|
||||
Usage:
|
||||
>> dynamodb = DynamoBackend(table_name="your-cache", region="eu-west-1")
|
||||
>> await dynamodb.init()
|
||||
>> FastAPICache.init(dynamodb)
|
||||
"""
|
||||
|
||||
def __init__(self, table_name: str, region: Optional[str] = None) -> None:
|
||||
self.session = get_session()
|
||||
self.client: Optional[AioBaseClient] = None # Needs async init
|
||||
self.table_name = table_name
|
||||
self.region = region
|
||||
|
||||
async def init(self) -> None:
|
||||
self.client = await self.session.create_client(
|
||||
"dynamodb", region_name=self.region
|
||||
).__aenter__()
|
||||
|
||||
async def close(self) -> None:
|
||||
self.client = await self.client.__aexit__(None, None, None)
|
||||
|
||||
async def get_with_ttl(self, key: str) -> Tuple[int, str]:
|
||||
response = await self.client.get_item(TableName=self.table_name, Key={"key": {"S": key}})
|
||||
|
||||
if "Item" in response:
|
||||
value = response["Item"].get("value", {}).get("S")
|
||||
ttl = response["Item"].get("ttl", {}).get("N")
|
||||
|
||||
if not ttl:
|
||||
return -1, value
|
||||
|
||||
# It's only eventually consistent so we need to check ourselves
|
||||
expire = int(ttl) - int(datetime.datetime.now().timestamp())
|
||||
if expire > 0:
|
||||
return expire, value
|
||||
|
||||
return 0, None
|
||||
|
||||
async def get(self, key: str) -> str:
|
||||
response = await self.client.get_item(TableName=self.table_name, Key={"key": {"S": key}})
|
||||
if "Item" in response:
|
||||
return response["Item"].get("value", {}).get("S")
|
||||
|
||||
async def set(self, key: str, value: str, expire: Optional[int] = None) -> None:
|
||||
ttl = (
|
||||
{
|
||||
"ttl": {
|
||||
"N": str(
|
||||
int(
|
||||
(
|
||||
datetime.datetime.now() + datetime.timedelta(seconds=expire)
|
||||
).timestamp()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if expire
|
||||
else {}
|
||||
)
|
||||
|
||||
await self.client.put_item(
|
||||
TableName=self.table_name,
|
||||
Item={
|
||||
**{
|
||||
"key": {"S": key},
|
||||
"value": {"S": value},
|
||||
},
|
||||
**ttl,
|
||||
},
|
||||
)
|
||||
|
||||
async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int:
|
||||
raise NotImplementedError
|
||||
@@ -20,13 +20,14 @@ class InMemoryBackend(Backend):
|
||||
def _now(self) -> int:
|
||||
return int(time.time())
|
||||
|
||||
def _get(self, key: str):
|
||||
def _get(self, key: str) -> Optional[Value]:
|
||||
v = self._store.get(key)
|
||||
if v:
|
||||
if v.ttl_ts < self._now:
|
||||
del self._store[key]
|
||||
else:
|
||||
return v
|
||||
return None
|
||||
|
||||
async def get_with_ttl(self, key: str) -> Tuple[int, Optional[str]]:
|
||||
async with self._lock:
|
||||
@@ -35,17 +36,18 @@ class InMemoryBackend(Backend):
|
||||
return v.ttl_ts - self._now, v.data
|
||||
return 0, None
|
||||
|
||||
async def get(self, key: str) -> str:
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
async with self._lock:
|
||||
v = self._get(key)
|
||||
if v:
|
||||
return v.data
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: str, expire: int = None):
|
||||
async def set(self, key: str, value: str, expire: Optional[int] = None) -> None:
|
||||
async with self._lock:
|
||||
self._store[key] = Value(value, self._now + expire)
|
||||
self._store[key] = Value(value, self._now + (expire or 0))
|
||||
|
||||
async def clear(self, namespace: str = None, key: str = None) -> int:
|
||||
async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int:
|
||||
count = 0
|
||||
if namespace:
|
||||
keys = list(self._store.keys())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from aiomcache import Client
|
||||
|
||||
@@ -9,14 +9,14 @@ class MemcachedBackend(Backend):
|
||||
def __init__(self, mcache: Client):
|
||||
self.mcache = mcache
|
||||
|
||||
async def get_with_ttl(self, key: str) -> Tuple[int, str]:
|
||||
async def get_with_ttl(self, key: str) -> Tuple[int, Optional[str]]:
|
||||
return 3600, await self.mcache.get(key.encode())
|
||||
|
||||
async def get(self, key: str):
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
return await self.mcache.get(key, key.encode())
|
||||
|
||||
async def set(self, key: str, value: str, expire: int = None):
|
||||
return await self.mcache.set(key.encode(), value.encode(), exptime=expire or 0)
|
||||
async def set(self, key: str, value: str, expire: Optional[int] = None) -> None:
|
||||
await self.mcache.set(key.encode(), value.encode(), exptime=expire or 0)
|
||||
|
||||
async def clear(self, namespace: str = None, key: str = None):
|
||||
async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from aioredis import Redis
|
||||
from redis.asyncio.client import AbstractRedis
|
||||
from redis.asyncio.cluster import AbstractRedisCluster
|
||||
|
||||
from fastapi_cache.backends import Backend
|
||||
|
||||
|
||||
class RedisBackend(Backend):
|
||||
def __init__(self, redis: Redis):
|
||||
def __init__(self, redis: AbstractRedis):
|
||||
self.redis = redis
|
||||
self.is_cluster = isinstance(redis, AbstractRedisCluster)
|
||||
|
||||
async def get_with_ttl(self, key: str) -> Tuple[int, str]:
|
||||
p = self.redis.pipeline()
|
||||
p.ttl(key)
|
||||
p.get(key)
|
||||
return await p.execute()
|
||||
async with self.redis.pipeline(transaction=not self.is_cluster) as pipe:
|
||||
return await (pipe.ttl(key).get(key).execute())
|
||||
|
||||
async def get(self, key) -> str:
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
return await self.redis.get(key)
|
||||
|
||||
async def set(self, key: str, value: str, expire: int = None):
|
||||
return await self.redis.set(key, value, expire=expire)
|
||||
async def set(self, key: str, value: str, expire: Optional[int] = None) -> None:
|
||||
return await self.redis.set(key, value, ex=expire)
|
||||
|
||||
async def clear(self, namespace: str = None, key: str = None) -> int:
|
||||
async def clear(self, namespace: Optional[str] = None, key: Optional[str] = None) -> int:
|
||||
if namespace:
|
||||
lua = f"for i, name in ipairs(redis.call('KEYS', '{namespace}:*')) do redis.call('DEL', name); end"
|
||||
return await self.redis.eval(lua)
|
||||
return await self.redis.eval(lua, numkeys=0)
|
||||
elif key:
|
||||
return await self.redis.delete(key)
|
||||
return 0
|
||||
@@ -1,70 +1,74 @@
|
||||
import codecs
|
||||
import datetime
|
||||
import json
|
||||
import pickle # nosec:B403
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
import dateutil.parser
|
||||
import pendulum
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.templating import _TemplateResponse as TemplateResponse
|
||||
|
||||
CONVERTERS = {
|
||||
"date": dateutil.parser.parse,
|
||||
"datetime": dateutil.parser.parse,
|
||||
"date": lambda x: pendulum.parse(x, exact=True),
|
||||
"datetime": lambda x: pendulum.parse(x, exact=True),
|
||||
"decimal": Decimal,
|
||||
}
|
||||
|
||||
|
||||
class JsonEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
def default(self, obj: Any) -> Any:
|
||||
if isinstance(obj, datetime.datetime):
|
||||
if obj.tzinfo:
|
||||
return {"val": obj.strftime("%Y-%m-%d %H:%M:%S%z"), "_spec_type": "datetime"}
|
||||
else:
|
||||
return {"val": obj.strftime("%Y-%m-%d %H:%M:%S"), "_spec_type": "datetime"}
|
||||
return {"val": str(obj), "_spec_type": "datetime"}
|
||||
elif isinstance(obj, datetime.date):
|
||||
return {"val": obj.strftime("%Y-%m-%d"), "_spec_type": "date"}
|
||||
return {"val": str(obj), "_spec_type": "date"}
|
||||
elif isinstance(obj, Decimal):
|
||||
return {"val": str(obj), "_spec_type": "decimal"}
|
||||
else:
|
||||
return jsonable_encoder(obj)
|
||||
|
||||
|
||||
def object_hook(obj):
|
||||
def object_hook(obj: Any) -> Any:
|
||||
_spec_type = obj.get("_spec_type")
|
||||
if not _spec_type:
|
||||
return obj
|
||||
|
||||
if _spec_type in CONVERTERS:
|
||||
return CONVERTERS[_spec_type](obj["val"])
|
||||
return CONVERTERS[_spec_type](obj["val"]) # type: ignore
|
||||
else:
|
||||
raise TypeError("Unknown {}".format(_spec_type))
|
||||
|
||||
|
||||
class Coder:
|
||||
@classmethod
|
||||
def encode(cls, value: Any):
|
||||
def encode(cls, value: Any) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def decode(cls, value: Any):
|
||||
def decode(cls, value: str) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class JsonCoder(Coder):
|
||||
@classmethod
|
||||
def encode(cls, value: Any):
|
||||
def encode(cls, value: Any) -> str:
|
||||
if isinstance(value, JSONResponse):
|
||||
return value.body
|
||||
return json.dumps(value, cls=JsonEncoder)
|
||||
|
||||
@classmethod
|
||||
def decode(cls, value: Any):
|
||||
def decode(cls, value: str) -> str:
|
||||
return json.loads(value, object_hook=object_hook)
|
||||
|
||||
|
||||
class PickleCoder(Coder):
|
||||
@classmethod
|
||||
def encode(cls, value: Any):
|
||||
return pickle.dumps(value)
|
||||
def encode(cls, value: Any) -> str:
|
||||
if isinstance(value, TemplateResponse):
|
||||
value = value.body
|
||||
return codecs.encode(pickle.dumps(value), "base64").decode()
|
||||
|
||||
@classmethod
|
||||
def decode(cls, value: Any):
|
||||
return pickle.loads(value) # nosec:B403
|
||||
def decode(cls, value: str) -> Any:
|
||||
return pickle.loads(codecs.decode(value.encode(), "base64")) # nosec:B403,B301
|
||||
|
||||
@@ -1,55 +1,156 @@
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
from functools import wraps
|
||||
from typing import Callable, Optional, Type
|
||||
from typing import Any, Awaitable, Callable, Optional, Type, TypeVar
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
from typing import ParamSpec
|
||||
else:
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.coder import Coder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.addHandler(logging.NullHandler())
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
def cache(
|
||||
expire: int = None,
|
||||
coder: Type[Coder] = None,
|
||||
key_builder: Callable = None,
|
||||
expire: Optional[int] = None,
|
||||
coder: Optional[Type[Coder]] = None,
|
||||
key_builder: Optional[Callable[..., Any]] = None,
|
||||
namespace: Optional[str] = "",
|
||||
):
|
||||
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
|
||||
"""
|
||||
cache all function
|
||||
:param namespace:
|
||||
:param expire:
|
||||
:param coder:
|
||||
:param key_builder:
|
||||
|
||||
:return:
|
||||
"""
|
||||
|
||||
def wrapper(func):
|
||||
def wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
||||
signature = inspect.signature(func)
|
||||
request_param = next(
|
||||
(param for param in signature.parameters.values() if param.annotation is Request),
|
||||
None,
|
||||
)
|
||||
response_param = next(
|
||||
(param for param in signature.parameters.values() if param.annotation is Response),
|
||||
None,
|
||||
)
|
||||
parameters = []
|
||||
extra_params = []
|
||||
for p in signature.parameters.values():
|
||||
if p.kind <= inspect.Parameter.KEYWORD_ONLY:
|
||||
parameters.append(p)
|
||||
else:
|
||||
extra_params.append(p)
|
||||
if not request_param:
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
name="request",
|
||||
annotation=Request,
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
),
|
||||
)
|
||||
if not response_param:
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
name="response",
|
||||
annotation=Response,
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
),
|
||||
)
|
||||
parameters.extend(extra_params)
|
||||
if parameters:
|
||||
signature = signature.replace(parameters=parameters)
|
||||
func.__signature__ = signature
|
||||
|
||||
@wraps(func)
|
||||
async def inner(*args, **kwargs):
|
||||
async def inner(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
nonlocal coder
|
||||
nonlocal expire
|
||||
nonlocal key_builder
|
||||
|
||||
async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
"""Run cached sync functions in thread pool just like FastAPI."""
|
||||
# if the wrapped function does NOT have request or response in its function signature,
|
||||
# make sure we don't pass them in as keyword arguments
|
||||
if not request_param:
|
||||
kwargs.pop("request", None)
|
||||
if not response_param:
|
||||
kwargs.pop("response", None)
|
||||
|
||||
if inspect.iscoroutinefunction(func):
|
||||
# async, return as is.
|
||||
# unintuitively, we have to await once here, so that caller
|
||||
# does not have to await twice. See
|
||||
# https://stackoverflow.com/a/59268198/532513
|
||||
return await func(*args, **kwargs)
|
||||
else:
|
||||
# sync, wrap in thread and return async
|
||||
# see above why we have to await even although caller also awaits.
|
||||
return await run_in_threadpool(func, *args, **kwargs)
|
||||
|
||||
copy_kwargs = kwargs.copy()
|
||||
request = copy_kwargs.pop("request", None)
|
||||
response = copy_kwargs.pop("response", None)
|
||||
if request and request.headers.get("Cache-Control") == "no-store":
|
||||
return await func(*args, **kwargs)
|
||||
request: Optional[Request] = copy_kwargs.pop("request", None)
|
||||
response: Optional[Response] = copy_kwargs.pop("response", None)
|
||||
if (
|
||||
request and request.headers.get("Cache-Control") in ("no-store", "no-cache")
|
||||
) or not FastAPICache.get_enable():
|
||||
return await ensure_async_func(*args, **kwargs)
|
||||
|
||||
coder = coder or FastAPICache.get_coder()
|
||||
expire = expire or FastAPICache.get_expire()
|
||||
key_builder = key_builder or FastAPICache.get_key_builder()
|
||||
backend = FastAPICache.get_backend()
|
||||
|
||||
cache_key = key_builder(
|
||||
func, namespace, request=request, response=response, args=args, kwargs=copy_kwargs
|
||||
)
|
||||
ttl, ret = await backend.get_with_ttl(cache_key)
|
||||
if inspect.iscoroutinefunction(key_builder):
|
||||
cache_key = await key_builder(
|
||||
func,
|
||||
namespace,
|
||||
request=request,
|
||||
response=response,
|
||||
args=args,
|
||||
kwargs=copy_kwargs,
|
||||
)
|
||||
else:
|
||||
cache_key = key_builder(
|
||||
func,
|
||||
namespace,
|
||||
request=request,
|
||||
response=response,
|
||||
args=args,
|
||||
kwargs=copy_kwargs,
|
||||
)
|
||||
try:
|
||||
ttl, ret = await backend.get_with_ttl(cache_key)
|
||||
except Exception:
|
||||
logger.warning(f"Error retrieving cache key '{cache_key}' from backend:", exc_info=True)
|
||||
ttl, ret = 0, None
|
||||
if not request:
|
||||
if ret is not None:
|
||||
return coder.decode(ret)
|
||||
ret = await func(*args, **kwargs)
|
||||
await backend.set(cache_key, coder.encode(ret), expire or FastAPICache.get_expire())
|
||||
ret = await ensure_async_func(*args, **kwargs)
|
||||
try:
|
||||
await backend.set(cache_key, coder.encode(ret), expire)
|
||||
except Exception:
|
||||
logger.warning(f"Error setting cache key '{cache_key}' in backend:", exc_info=True)
|
||||
return ret
|
||||
|
||||
if request.method != "GET":
|
||||
return await func(request, *args, **kwargs)
|
||||
return await ensure_async_func(request, *args, **kwargs)
|
||||
|
||||
if_none_match = request.headers.get("if-none-match")
|
||||
if ret is not None:
|
||||
if response:
|
||||
@@ -61,8 +162,17 @@ def cache(
|
||||
response.headers["ETag"] = etag
|
||||
return coder.decode(ret)
|
||||
|
||||
ret = await func(*args, **kwargs)
|
||||
await backend.set(cache_key, coder.encode(ret), expire or FastAPICache.get_expire())
|
||||
ret = await ensure_async_func(*args, **kwargs)
|
||||
encoded_ret = coder.encode(ret)
|
||||
|
||||
try:
|
||||
await backend.set(cache_key, encoded_ret, expire)
|
||||
except Exception:
|
||||
logger.warning(f"Error setting cache key '{cache_key}' in backend:", exc_info=True)
|
||||
|
||||
response.headers["Cache-Control"] = f"max-age={expire}"
|
||||
etag = f"W/{hash(encoded_ret)}"
|
||||
response.headers["ETag"] = etag
|
||||
return ret
|
||||
|
||||
return inner
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
from typing import Callable, Optional
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
def default_key_builder(
|
||||
func,
|
||||
func: Callable,
|
||||
namespace: Optional[str] = "",
|
||||
request: Optional[Request] = None,
|
||||
response: Optional[Response] = None,
|
||||
args: Optional[tuple] = None,
|
||||
kwargs: Optional[dict] = None,
|
||||
):
|
||||
) -> str:
|
||||
from fastapi_cache import FastAPICache
|
||||
|
||||
prefix = f"{FastAPICache.get_prefix()}:{namespace}:"
|
||||
cache_key = (
|
||||
prefix
|
||||
+ hashlib.md5( # nosec:B303
|
||||
f"{func.__module__}:{func.__name__}:{args}:{kwargs}"
|
||||
f"{func.__module__}:{func.__name__}:{args}:{kwargs}".encode()
|
||||
).hexdigest()
|
||||
)
|
||||
return cache_key
|
||||
|
||||
0
fastapi_cache/py.typed
Normal file
0
fastapi_cache/py.typed
Normal file
1701
poetry.lock
generated
1701
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "fastapi-cache2"
|
||||
version = "0.1.4"
|
||||
version = "0.2.1"
|
||||
description = "Cache for FastAPI"
|
||||
authors = ["long2ice <long2ice@gmail.com>"]
|
||||
license = "Apache-2.0"
|
||||
@@ -18,22 +18,31 @@ include = ["LICENSE", "README.md"]
|
||||
python = "^3.7"
|
||||
fastapi = "*"
|
||||
uvicorn = "*"
|
||||
aioredis = {version = ">=2.0.0b1", optional = true}
|
||||
aiomcache = {version = "*", optional = true}
|
||||
python-dateutil = "*"
|
||||
redis = { version = "^4.2.0rc1", optional = true }
|
||||
aiomcache = { version = "*", optional = true }
|
||||
pendulum = "*"
|
||||
aiobotocore = { version = "^1.4.1", optional = true }
|
||||
typing-extensions = { version = ">=4.1.0", markers = "python_version < \"3.10\"" }
|
||||
aiohttp = { version = ">=3.8.3", markers = "python_version >= \"3.11\"" }
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
flake8 = "*"
|
||||
isort = "*"
|
||||
black = "^19.10b0"
|
||||
black = "*"
|
||||
pytest = "*"
|
||||
bandit = "*"
|
||||
requests = "*"
|
||||
coverage = "^6.5.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
build-backend = "poetry.masonry.api"
|
||||
|
||||
[tool.poetry.extras]
|
||||
redis = ["aioredis"]
|
||||
redis = ["redis"]
|
||||
memcache = ["aiomcache"]
|
||||
all = ["aioredis","aiomcache"]
|
||||
dynamodb = ["aiobotocore"]
|
||||
all = ["redis", "aiomcache", "aiobotocore"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py36', 'py37', 'py38', 'py39']
|
||||
|
||||
22
tests/test_codecs.py
Normal file
22
tests/test_codecs.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi_cache.coder import PickleCoder
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
1,
|
||||
"some_string",
|
||||
(1, 2),
|
||||
[1, 2, 3],
|
||||
{"some_key": 1, "other_key": 2},
|
||||
],
|
||||
)
|
||||
def test_pickle_coder(value: Any) -> None:
|
||||
encoded_value = PickleCoder.encode(value)
|
||||
assert isinstance(encoded_value, str)
|
||||
decoded_value = PickleCoder.decode(encoded_value)
|
||||
assert decoded_value == value
|
||||
@@ -1,2 +1,75 @@
|
||||
def test_default_key_builder():
|
||||
return 1
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
import pendulum
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from examples.in_memory.main import app
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.backends.inmemory import InMemoryBackend
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def init_cache() -> Generator:
|
||||
FastAPICache.init(InMemoryBackend())
|
||||
yield
|
||||
FastAPICache.reset()
|
||||
|
||||
|
||||
def test_datetime() -> None:
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/datetime")
|
||||
now = response.json().get("now")
|
||||
now_ = pendulum.now().replace(microsecond=0)
|
||||
assert pendulum.parse(now).replace(microsecond=0) == now_
|
||||
response = client.get("/datetime")
|
||||
now = response.json().get("now")
|
||||
assert pendulum.parse(now).replace(microsecond=0) == now_
|
||||
time.sleep(3)
|
||||
response = client.get("/datetime")
|
||||
now = response.json().get("now")
|
||||
now = pendulum.parse(now).replace(microsecond=0)
|
||||
assert now != now_
|
||||
assert now == pendulum.now().replace(microsecond=0)
|
||||
|
||||
|
||||
def test_date() -> None:
|
||||
"""Test path function without request or response arguments."""
|
||||
with TestClient(app) as client:
|
||||
|
||||
response = client.get("/date")
|
||||
assert pendulum.parse(response.json()) == pendulum.today()
|
||||
|
||||
# do it again to test cache
|
||||
response = client.get("/date")
|
||||
assert pendulum.parse(response.json()) == pendulum.today()
|
||||
|
||||
# now test with cache disabled, as that's a separate code path
|
||||
FastAPICache._enable = False
|
||||
response = client.get("/date")
|
||||
assert pendulum.parse(response.json()) == pendulum.today()
|
||||
FastAPICache._enable = True
|
||||
|
||||
|
||||
def test_sync() -> None:
|
||||
"""Ensure that sync function support works."""
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/sync-me")
|
||||
assert response.json() == 42
|
||||
|
||||
|
||||
def test_cache_response_obj() -> None:
|
||||
with TestClient(app) as client:
|
||||
cache_response = client.get("cache_response_obj")
|
||||
assert cache_response.json() == {"a": 1}
|
||||
get_cache_response = client.get("cache_response_obj")
|
||||
assert get_cache_response.json() == {"a": 1}
|
||||
assert get_cache_response.headers.get("cache-control")
|
||||
assert get_cache_response.headers.get("etag")
|
||||
|
||||
def test_kwargs() -> None:
|
||||
with TestClient(app) as client:
|
||||
name = "Jon"
|
||||
response = client.get("/kwargs", params = {"name": name})
|
||||
assert response.json() == {"name": name}
|
||||
|
||||
Reference in New Issue
Block a user