mirror of
https://github.com/long2ice/fastapi-cache.git
synced 2026-03-25 13:07:53 +00:00
Compare commits
92 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 | ||
|
|
11f01a21f5 | ||
|
|
cdcfdc6ae6 | ||
|
|
a40c54e9e7 | ||
|
|
d67797a1d5 | ||
|
|
dacd7e1b0f |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -4,11 +4,11 @@ jobs:
|
|||||||
ci:
|
ci:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: "3.x"
|
python-version: "3.x"
|
||||||
- uses: abatilo/actions-poetry@v2.1.3
|
- uses: abatilo/actions-poetry@v2.1.6
|
||||||
- name: Config poetry
|
- name: Config poetry
|
||||||
run: poetry config experimental.new-installer false
|
run: poetry config experimental.new-installer false
|
||||||
- name: CI
|
- name: CI
|
||||||
|
|||||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,7 +1,38 @@
|
|||||||
# ChangeLog
|
# 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
|
||||||
|
|
||||||
|
### 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
|
### 0.1.7
|
||||||
|
|
||||||
- Fix default json coder for datetime.
|
- Fix default json coder for datetime.
|
||||||
|
|||||||
1
Makefile
1
Makefile
@@ -14,7 +14,6 @@ style: deps
|
|||||||
check: deps
|
check: deps
|
||||||
@poetry run black $(checkfiles) || (echo "Please run 'make style' to auto-fix style issues" && false)
|
@poetry run black $(checkfiles) || (echo "Please run 'make style' to auto-fix style issues" && false)
|
||||||
@poetry run flake8 $(checkfiles)
|
@poetry run flake8 $(checkfiles)
|
||||||
@poetry run bandit -r $(checkfiles)
|
|
||||||
|
|
||||||
test: deps
|
test: deps
|
||||||
$(py_warn) poetry run pytest
|
$(py_warn) poetry run pytest
|
||||||
|
|||||||
49
README.md
49
README.md
@@ -7,11 +7,12 @@
|
|||||||
|
|
||||||
## Introduction
|
## 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
|
## Features
|
||||||
|
|
||||||
- Support `redis` and `memcache` and `in-memory` backends.
|
- Support `redis`, `memcache`, `dynamodb`, and `in-memory` backends.
|
||||||
- Easily integration with `fastapi`.
|
- Easily integration with `fastapi`.
|
||||||
- Support http cache like `ETag` and `Cache-Control`.
|
- Support http cache like `ETag` and `Cache-Control`.
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
- `asyncio` environment.
|
- `asyncio` environment.
|
||||||
- `redis` if use `RedisBackend`.
|
- `redis` if use `RedisBackend`.
|
||||||
- `memcache` if use `MemcacheBackend`.
|
- `memcache` if use `MemcacheBackend`.
|
||||||
|
- `aiobotocore` if use `DynamoBackend`.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -39,12 +41,17 @@ or
|
|||||||
> pip install "fastapi-cache2[memcache]"
|
> pip install "fastapi-cache2[memcache]"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```shell
|
||||||
|
> pip install "fastapi-cache2[dynamodb]"
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import aioredis
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
@@ -53,6 +60,8 @@ from fastapi_cache import FastAPICache
|
|||||||
from fastapi_cache.backends.redis import RedisBackend
|
from fastapi_cache.backends.redis import RedisBackend
|
||||||
from fastapi_cache.decorator import cache
|
from fastapi_cache.decorator import cache
|
||||||
|
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +72,7 @@ async def get_cache():
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@cache(expire=60)
|
@cache(expire=60)
|
||||||
async def index(request: Request, response: Response):
|
async def index():
|
||||||
return dict(hello="world")
|
return dict(hello="world")
|
||||||
|
|
||||||
|
|
||||||
@@ -80,7 +89,8 @@ Firstly you must call `FastAPICache.init` on startup event of `fastapi`, there a
|
|||||||
|
|
||||||
### Use `cache` decorator
|
### 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
|
Parameter | type, description
|
||||||
------------ | -------------
|
------------ | -------------
|
||||||
@@ -89,25 +99,24 @@ namespace | str, namespace to use to store certain cache items
|
|||||||
coder | which coder to use, e.g. JsonCoder
|
coder | which coder to use, e.g. JsonCoder
|
||||||
key_builder | which key builder to use, default to builtin
|
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.
|
You can also use `cache` as decorator like other cache tools to cache common function result.
|
||||||
|
|
||||||
### Custom coder
|
### 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
|
```python
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@cache(expire=60,coder=JsonCoder)
|
@cache(expire=60, coder=JsonCoder)
|
||||||
async def index(request: Request, response: Response):
|
async def index():
|
||||||
return dict(hello="world")
|
return dict(hello="world")
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom key builder
|
### 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
|
```python
|
||||||
def my_key_builder(
|
def my_key_builder(
|
||||||
@@ -122,15 +131,25 @@ def my_key_builder(
|
|||||||
cache_key = f"{prefix}:{namespace}:{func.__module__}:{func.__name__}:{args}:{kwargs}"
|
cache_key = f"{prefix}:{namespace}:{func.__module__}:{func.__name__}:{args}:{kwargs}"
|
||||||
return cache_key
|
return cache_key
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@cache(expire=60,coder=JsonCoder,key_builder=my_key_builder)
|
@cache(expire=60, coder=JsonCoder, key_builder=my_key_builder)
|
||||||
async def index(request: Request, response: Response):
|
async def index():
|
||||||
return dict(hello="world")
|
return dict(hello="world")
|
||||||
```
|
```
|
||||||
|
|
||||||
### InMemoryBackend
|
### 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
|
## 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,55 +0,0 @@
|
|||||||
from datetime import date, datetime
|
|
||||||
|
|
||||||
import aioredis
|
|
||||||
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.redis import RedisBackend
|
|
||||||
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=20)
|
|
||||||
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.get("/date")
|
|
||||||
@cache(namespace="test", expire=20)
|
|
||||||
async def get_data(request: Request, response: Response):
|
|
||||||
return date.today()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/datetime")
|
|
||||||
@cache(namespace="test", expire=20)
|
|
||||||
async def get_datetime(request: Request, response: Response):
|
|
||||||
return datetime.now()
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup():
|
|
||||||
redis = aioredis.from_url(url="redis://localhost")
|
|
||||||
FastAPICache.init(RedisBackend(redis), 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,28 +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.coder import Coder, JsonCoder
|
||||||
from fastapi_cache.key_builder import default_key_builder
|
from fastapi_cache.key_builder import default_key_builder
|
||||||
|
|
||||||
|
|
||||||
class FastAPICache:
|
class FastAPICache:
|
||||||
_backend = None
|
_backend: Optional[Backend] = None
|
||||||
_prefix = None
|
_prefix: Optional[str] = None
|
||||||
_expire = None
|
_expire: Optional[int] = None
|
||||||
_init = False
|
_init = False
|
||||||
_coder = None
|
_coder: Optional[Type[Coder]] = None
|
||||||
_key_builder = None
|
_key_builder: Optional[Callable] = None
|
||||||
_enable = True
|
_enable = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def init(
|
def init(
|
||||||
cls,
|
cls,
|
||||||
backend,
|
backend: Backend,
|
||||||
prefix: str = "",
|
prefix: str = "",
|
||||||
expire: int = None,
|
expire: Optional[int] = None,
|
||||||
coder: Coder = JsonCoder,
|
coder: Type[Coder] = JsonCoder,
|
||||||
key_builder: Callable = default_key_builder,
|
key_builder: Callable = default_key_builder,
|
||||||
enable: bool = True,
|
enable: bool = True,
|
||||||
):
|
) -> None:
|
||||||
if cls._init:
|
if cls._init:
|
||||||
return
|
return
|
||||||
cls._init = True
|
cls._init = True
|
||||||
@@ -34,31 +35,45 @@ class FastAPICache:
|
|||||||
cls._enable = enable
|
cls._enable = enable
|
||||||
|
|
||||||
@classmethod
|
@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
|
assert cls._backend, "You must call init first!" # nosec: B101
|
||||||
return cls._backend
|
return cls._backend
|
||||||
|
|
||||||
@classmethod
|
@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
|
return cls._prefix
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_expire(cls):
|
def get_expire(cls) -> Optional[int]:
|
||||||
return cls._expire
|
return cls._expire
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_coder(cls):
|
def get_coder(cls) -> Type[Coder]:
|
||||||
|
assert cls._coder, "You must call init first!" # nosec: B101
|
||||||
return cls._coder
|
return cls._coder
|
||||||
|
|
||||||
@classmethod
|
@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
|
return cls._key_builder
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_enable(cls):
|
def get_enable(cls) -> bool:
|
||||||
return cls._enable
|
return cls._enable
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def clear(cls, namespace: str = None, key: str = None):
|
async def clear(cls, namespace: Optional[str] = None, key: Optional[str] = None) -> int:
|
||||||
namespace = cls._prefix + ":" + namespace if namespace else None
|
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)
|
return await cls._backend.clear(namespace, key)
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import abc
|
import abc
|
||||||
from typing import Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
class Backend:
|
class Backend:
|
||||||
@abc.abstractmethod
|
@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
|
raise NotImplementedError
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def get(self, key: str) -> str:
|
async def get(self, key: str) -> Optional[str]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abc.abstractmethod
|
@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
|
raise NotImplementedError
|
||||||
|
|
||||||
@abc.abstractmethod
|
@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
|
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:
|
def _now(self) -> int:
|
||||||
return int(time.time())
|
return int(time.time())
|
||||||
|
|
||||||
def _get(self, key: str):
|
def _get(self, key: str) -> Optional[Value]:
|
||||||
v = self._store.get(key)
|
v = self._store.get(key)
|
||||||
if v:
|
if v:
|
||||||
if v.ttl_ts < self._now:
|
if v.ttl_ts < self._now:
|
||||||
del self._store[key]
|
del self._store[key]
|
||||||
else:
|
else:
|
||||||
return v
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_with_ttl(self, key: str) -> Tuple[int, Optional[str]]:
|
async def get_with_ttl(self, key: str) -> Tuple[int, Optional[str]]:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
@@ -35,17 +36,18 @@ class InMemoryBackend(Backend):
|
|||||||
return v.ttl_ts - self._now, v.data
|
return v.ttl_ts - self._now, v.data
|
||||||
return 0, None
|
return 0, None
|
||||||
|
|
||||||
async def get(self, key: str) -> str:
|
async def get(self, key: str) -> Optional[str]:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
v = self._get(key)
|
v = self._get(key)
|
||||||
if v:
|
if v:
|
||||||
return v.data
|
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:
|
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
|
count = 0
|
||||||
if namespace:
|
if namespace:
|
||||||
keys = list(self._store.keys())
|
keys = list(self._store.keys())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from aiomcache import Client
|
from aiomcache import Client
|
||||||
|
|
||||||
@@ -9,14 +9,14 @@ class MemcachedBackend(Backend):
|
|||||||
def __init__(self, mcache: Client):
|
def __init__(self, mcache: Client):
|
||||||
self.mcache = mcache
|
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())
|
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())
|
return await self.mcache.get(key, key.encode())
|
||||||
|
|
||||||
async def set(self, key: str, value: str, expire: int = None):
|
async def set(self, key: str, value: str, expire: Optional[int] = None) -> None:
|
||||||
return await self.mcache.set(key.encode(), value.encode(), exptime=expire or 0)
|
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
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -1,27 +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
|
from fastapi_cache.backends import Backend
|
||||||
|
|
||||||
|
|
||||||
class RedisBackend(Backend):
|
class RedisBackend(Backend):
|
||||||
def __init__(self, redis: Redis):
|
def __init__(self, redis: AbstractRedis):
|
||||||
self.redis = redis
|
self.redis = redis
|
||||||
|
self.is_cluster = isinstance(redis, AbstractRedisCluster)
|
||||||
|
|
||||||
async def get_with_ttl(self, key: str) -> Tuple[int, str]:
|
async def get_with_ttl(self, key: str) -> Tuple[int, str]:
|
||||||
async with self.redis.pipeline(transaction=True) as pipe:
|
async with self.redis.pipeline(transaction=not self.is_cluster) as pipe:
|
||||||
return await (pipe.ttl(key).get(key).execute())
|
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)
|
return await self.redis.get(key)
|
||||||
|
|
||||||
async def set(self, key: str, value: str, expire: int = None):
|
async def set(self, key: str, value: str, expire: Optional[int] = None) -> None:
|
||||||
return await self.redis.set(key, value, ex=expire)
|
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:
|
if namespace:
|
||||||
lua = f"for i, name in ipairs(redis.call('KEYS', '{namespace}:*')) do redis.call('DEL', name); end"
|
lua = f"for i, name in ipairs(redis.call('KEYS', '{namespace}:*')) do redis.call('DEL', name); end"
|
||||||
return await self.redis.eval(lua, numkeys=0)
|
return await self.redis.eval(lua, numkeys=0)
|
||||||
elif key:
|
elif key:
|
||||||
return await self.redis.delete(key)
|
return await self.redis.delete(key)
|
||||||
|
return 0
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import codecs
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import pickle # nosec:B403
|
import pickle # nosec:B403
|
||||||
@@ -6,6 +7,8 @@ from typing import Any
|
|||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
from fastapi.encoders import jsonable_encoder
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.templating import _TemplateResponse as TemplateResponse
|
||||||
|
|
||||||
CONVERTERS = {
|
CONVERTERS = {
|
||||||
"date": lambda x: pendulum.parse(x, exact=True),
|
"date": lambda x: pendulum.parse(x, exact=True),
|
||||||
@@ -15,7 +18,7 @@ CONVERTERS = {
|
|||||||
|
|
||||||
|
|
||||||
class JsonEncoder(json.JSONEncoder):
|
class JsonEncoder(json.JSONEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj: Any) -> Any:
|
||||||
if isinstance(obj, datetime.datetime):
|
if isinstance(obj, datetime.datetime):
|
||||||
return {"val": str(obj), "_spec_type": "datetime"}
|
return {"val": str(obj), "_spec_type": "datetime"}
|
||||||
elif isinstance(obj, datetime.date):
|
elif isinstance(obj, datetime.date):
|
||||||
@@ -26,42 +29,46 @@ class JsonEncoder(json.JSONEncoder):
|
|||||||
return jsonable_encoder(obj)
|
return jsonable_encoder(obj)
|
||||||
|
|
||||||
|
|
||||||
def object_hook(obj):
|
def object_hook(obj: Any) -> Any:
|
||||||
_spec_type = obj.get("_spec_type")
|
_spec_type = obj.get("_spec_type")
|
||||||
if not _spec_type:
|
if not _spec_type:
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
if _spec_type in CONVERTERS:
|
if _spec_type in CONVERTERS:
|
||||||
return CONVERTERS[_spec_type](obj["val"])
|
return CONVERTERS[_spec_type](obj["val"]) # type: ignore
|
||||||
else:
|
else:
|
||||||
raise TypeError("Unknown {}".format(_spec_type))
|
raise TypeError("Unknown {}".format(_spec_type))
|
||||||
|
|
||||||
|
|
||||||
class Coder:
|
class Coder:
|
||||||
@classmethod
|
@classmethod
|
||||||
def encode(cls, value: Any):
|
def encode(cls, value: Any) -> str:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def decode(cls, value: Any):
|
def decode(cls, value: str) -> Any:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class JsonCoder(Coder):
|
class JsonCoder(Coder):
|
||||||
@classmethod
|
@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)
|
return json.dumps(value, cls=JsonEncoder)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def decode(cls, value: Any):
|
def decode(cls, value: str) -> str:
|
||||||
return json.loads(value, object_hook=object_hook)
|
return json.loads(value, object_hook=object_hook)
|
||||||
|
|
||||||
|
|
||||||
class PickleCoder(Coder):
|
class PickleCoder(Coder):
|
||||||
@classmethod
|
@classmethod
|
||||||
def encode(cls, value: Any):
|
def encode(cls, value: Any) -> str:
|
||||||
return pickle.dumps(value)
|
if isinstance(value, TemplateResponse):
|
||||||
|
value = value.body
|
||||||
|
return codecs.encode(pickle.dumps(value), "base64").decode()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def decode(cls, value: Any):
|
def decode(cls, value: str) -> Any:
|
||||||
return pickle.loads(value) # nosec:B403
|
return pickle.loads(codecs.decode(value.encode(), "base64")) # nosec:B403,B301
|
||||||
|
|||||||
@@ -1,57 +1,156 @@
|
|||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
from functools import wraps
|
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 import FastAPICache
|
||||||
from fastapi_cache.coder import Coder
|
from fastapi_cache.coder import Coder
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.addHandler(logging.NullHandler())
|
||||||
|
P = ParamSpec("P")
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
def cache(
|
def cache(
|
||||||
expire: int = None,
|
expire: Optional[int] = None,
|
||||||
coder: Type[Coder] = None,
|
coder: Optional[Type[Coder]] = None,
|
||||||
key_builder: Callable = None,
|
key_builder: Optional[Callable[..., Any]] = None,
|
||||||
namespace: Optional[str] = "",
|
namespace: Optional[str] = "",
|
||||||
):
|
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
|
||||||
"""
|
"""
|
||||||
cache all function
|
cache all function
|
||||||
:param namespace:
|
:param namespace:
|
||||||
:param expire:
|
:param expire:
|
||||||
:param coder:
|
:param coder:
|
||||||
:param key_builder:
|
:param key_builder:
|
||||||
|
|
||||||
:return:
|
: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)
|
@wraps(func)
|
||||||
async def inner(*args, **kwargs):
|
async def inner(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
nonlocal coder
|
nonlocal coder
|
||||||
nonlocal expire
|
nonlocal expire
|
||||||
nonlocal key_builder
|
nonlocal key_builder
|
||||||
copy_kwargs = kwargs.copy()
|
|
||||||
request = copy_kwargs.pop("request", None)
|
async def ensure_async_func(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
response = copy_kwargs.pop("response", None)
|
"""Run cached sync functions in thread pool just like FastAPI."""
|
||||||
if (
|
# if the wrapped function does NOT have request or response in its function signature,
|
||||||
request and request.headers.get("Cache-Control") == "no-store"
|
# make sure we don't pass them in as keyword arguments
|
||||||
) or not FastAPICache.get_enable():
|
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)
|
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: 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()
|
coder = coder or FastAPICache.get_coder()
|
||||||
expire = expire or FastAPICache.get_expire()
|
expire = expire or FastAPICache.get_expire()
|
||||||
key_builder = key_builder or FastAPICache.get_key_builder()
|
key_builder = key_builder or FastAPICache.get_key_builder()
|
||||||
backend = FastAPICache.get_backend()
|
backend = FastAPICache.get_backend()
|
||||||
|
|
||||||
cache_key = key_builder(
|
if inspect.iscoroutinefunction(key_builder):
|
||||||
func, namespace, request=request, response=response, args=args, kwargs=copy_kwargs
|
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)
|
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 not request:
|
||||||
if ret is not None:
|
if ret is not None:
|
||||||
return coder.decode(ret)
|
return coder.decode(ret)
|
||||||
ret = await func(*args, **kwargs)
|
ret = await ensure_async_func(*args, **kwargs)
|
||||||
await backend.set(cache_key, coder.encode(ret), expire or FastAPICache.get_expire())
|
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
|
return ret
|
||||||
|
|
||||||
if request.method != "GET":
|
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_none_match = request.headers.get("if-none-match")
|
||||||
if ret is not None:
|
if ret is not None:
|
||||||
if response:
|
if response:
|
||||||
@@ -63,8 +162,17 @@ def cache(
|
|||||||
response.headers["ETag"] = etag
|
response.headers["ETag"] = etag
|
||||||
return coder.decode(ret)
|
return coder.decode(ret)
|
||||||
|
|
||||||
ret = await func(*args, **kwargs)
|
ret = await ensure_async_func(*args, **kwargs)
|
||||||
await backend.set(cache_key, coder.encode(ret), expire or FastAPICache.get_expire())
|
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 ret
|
||||||
|
|
||||||
return inner
|
return inner
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
from typing import Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
|
||||||
def default_key_builder(
|
def default_key_builder(
|
||||||
func,
|
func: Callable,
|
||||||
namespace: Optional[str] = "",
|
namespace: Optional[str] = "",
|
||||||
request: Optional[Request] = None,
|
request: Optional[Request] = None,
|
||||||
response: Optional[Response] = None,
|
response: Optional[Response] = None,
|
||||||
args: Optional[tuple] = None,
|
args: Optional[tuple] = None,
|
||||||
kwargs: Optional[dict] = None,
|
kwargs: Optional[dict] = None,
|
||||||
):
|
) -> str:
|
||||||
from fastapi_cache import FastAPICache
|
from fastapi_cache import FastAPICache
|
||||||
|
|
||||||
prefix = f"{FastAPICache.get_prefix()}:{namespace}:"
|
prefix = f"{FastAPICache.get_prefix()}:{namespace}:"
|
||||||
|
|||||||
0
fastapi_cache/py.typed
Normal file
0
fastapi_cache/py.typed
Normal file
1965
poetry.lock
generated
1965
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "fastapi-cache2"
|
name = "fastapi-cache2"
|
||||||
version = "0.1.7"
|
version = "0.2.1"
|
||||||
description = "Cache for FastAPI"
|
description = "Cache for FastAPI"
|
||||||
authors = ["long2ice <long2ice@gmail.com>"]
|
authors = ["long2ice <long2ice@gmail.com>"]
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
@@ -18,25 +18,30 @@ include = ["LICENSE", "README.md"]
|
|||||||
python = "^3.7"
|
python = "^3.7"
|
||||||
fastapi = "*"
|
fastapi = "*"
|
||||||
uvicorn = "*"
|
uvicorn = "*"
|
||||||
aioredis = {version = "^2.0", optional = true}
|
redis = { version = "^4.2.0rc1", optional = true }
|
||||||
aiomcache = {version = "*", optional = true}
|
aiomcache = { version = "*", optional = true }
|
||||||
pendulum = "*"
|
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]
|
[tool.poetry.dev-dependencies]
|
||||||
flake8 = "*"
|
flake8 = "*"
|
||||||
isort = "*"
|
isort = "*"
|
||||||
black = "*"
|
black = "*"
|
||||||
pytest = "*"
|
pytest = "*"
|
||||||
bandit = "*"
|
requests = "*"
|
||||||
|
coverage = "^6.5.0"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry>=0.12"]
|
requires = ["poetry>=0.12"]
|
||||||
build-backend = "poetry.masonry.api"
|
build-backend = "poetry.masonry.api"
|
||||||
|
|
||||||
[tool.poetry.extras]
|
[tool.poetry.extras]
|
||||||
redis = ["aioredis"]
|
redis = ["redis"]
|
||||||
memcache = ["aiomcache"]
|
memcache = ["aiomcache"]
|
||||||
all = ["aioredis","aiomcache"]
|
dynamodb = ["aiobotocore"]
|
||||||
|
all = ["redis", "aiomcache", "aiobotocore"]
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 100
|
line-length = 100
|
||||||
|
|||||||
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():
|
import time
|
||||||
return 1
|
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