mirror of
https://github.com/long2ice/fastapi-cache.git
synced 2026-03-25 04:57:54 +00:00
Compare commits
75 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 | ||
|
|
89826b0a3b | ||
|
|
e5250c7f58 | ||
|
|
1795c048d1 | ||
|
|
dacd7e1b0f |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -4,11 +4,11 @@ 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"
|
||||
- uses: abatilo/actions-poetry@v2.1.3
|
||||
- uses: abatilo/actions-poetry@v2.1.6
|
||||
- name: Config poetry
|
||||
run: poetry config experimental.new-installer false
|
||||
- name: CI
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,7 +1,30 @@
|
||||
# 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`.
|
||||
|
||||
40
README.md
40
README.md
@@ -7,7 +7,8 @@
|
||||
|
||||
## Introduction
|
||||
|
||||
`fastapi-cache` is a tool to cache fastapi response and function result, with backends support `redis`, `memcache`, and `dynamodb`.
|
||||
`fastapi-cache` is a tool to cache fastapi response and function result, with backends support `redis`, `memcache`,
|
||||
and `dynamodb`.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -51,7 +52,6 @@ or
|
||||
### Quick Start
|
||||
|
||||
```python
|
||||
import aioredis
|
||||
from fastapi import FastAPI
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
@@ -60,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()
|
||||
|
||||
|
||||
@@ -70,7 +72,7 @@ async def get_cache():
|
||||
|
||||
@app.get("/")
|
||||
@cache(expire=60)
|
||||
async def index(request: Request, response: Response):
|
||||
async def index():
|
||||
return dict(hello="world")
|
||||
|
||||
|
||||
@@ -87,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
|
||||
------------ | -------------
|
||||
@@ -96,25 +99,24 @@ 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(
|
||||
@@ -129,15 +131,25 @@ def my_key_builder(
|
||||
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,65 +0,0 @@
|
||||
from datetime import date, datetime
|
||||
import time
|
||||
|
||||
import redis.asyncio as redis
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from redis.asyncio.connection import ConnectionPool
|
||||
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("/blocking")
|
||||
@cache(namespace="test", expire=20)
|
||||
def blocking(request: Request, response: Response):
|
||||
time.sleep(5)
|
||||
return dict(ret=get_ret())
|
||||
|
||||
|
||||
@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():
|
||||
pool = ConnectionPool.from_url(url="redis://localhost")
|
||||
r = redis.Redis(connection_pool=pool)
|
||||
FastAPICache.init(RedisBackend(r), 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.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
|
||||
@@ -34,31 +35,45 @@ class FastAPICache:
|
||||
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
|
||||
def get_enable(cls):
|
||||
def get_enable(cls) -> bool:
|
||||
return cls._enable
|
||||
|
||||
@classmethod
|
||||
async def clear(cls, namespace: str = None, key: str = None):
|
||||
namespace = cls._prefix + ":" + namespace if namespace else None
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from aiobotocore.client import AioBaseClient
|
||||
from aiobotocore.session import get_session
|
||||
|
||||
from fastapi_cache.backends import Backend
|
||||
@@ -24,18 +25,18 @@ class DynamoBackend(Backend):
|
||||
>> FastAPICache.init(dynamodb)
|
||||
"""
|
||||
|
||||
def __init__(self, table_name, region=None):
|
||||
def __init__(self, table_name: str, region: Optional[str] = None) -> None:
|
||||
self.session = get_session()
|
||||
self.client = None # Needs async init
|
||||
self.client: Optional[AioBaseClient] = None # Needs async init
|
||||
self.table_name = table_name
|
||||
self.region = region
|
||||
|
||||
async def init(self):
|
||||
async def init(self) -> None:
|
||||
self.client = await self.session.create_client(
|
||||
"dynamodb", region_name=self.region
|
||||
).__aenter__()
|
||||
|
||||
async def close(self):
|
||||
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]:
|
||||
@@ -55,12 +56,12 @@ class DynamoBackend(Backend):
|
||||
|
||||
return 0, None
|
||||
|
||||
async def get(self, key) -> str:
|
||||
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: int = None):
|
||||
async def set(self, key: str, value: str, expire: Optional[int] = None) -> None:
|
||||
ttl = (
|
||||
{
|
||||
"ttl": {
|
||||
@@ -88,5 +89,5 @@ class DynamoBackend(Backend):
|
||||
},
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -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 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,27 +1,30 @@
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from redis.asyncio.client 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]:
|
||||
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())
|
||||
|
||||
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):
|
||||
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, numkeys=0)
|
||||
elif key:
|
||||
return await self.redis.delete(key)
|
||||
return 0
|
||||
@@ -1,3 +1,4 @@
|
||||
import codecs
|
||||
import datetime
|
||||
import json
|
||||
import pickle # nosec:B403
|
||||
@@ -6,6 +7,8 @@ from typing import Any
|
||||
|
||||
import pendulum
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.templating import _TemplateResponse as TemplateResponse
|
||||
|
||||
CONVERTERS = {
|
||||
"date": lambda x: pendulum.parse(x, exact=True),
|
||||
@@ -15,7 +18,7 @@ CONVERTERS = {
|
||||
|
||||
|
||||
class JsonEncoder(json.JSONEncoder):
|
||||
def default(self, obj):
|
||||
def default(self, obj: Any) -> Any:
|
||||
if isinstance(obj, datetime.datetime):
|
||||
return {"val": str(obj), "_spec_type": "datetime"}
|
||||
elif isinstance(obj, datetime.date):
|
||||
@@ -26,42 +29,46 @@ class JsonEncoder(json.JSONEncoder):
|
||||
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,B301
|
||||
def decode(cls, value: str) -> Any:
|
||||
return pickle.loads(codecs.decode(value.encode(), "base64")) # nosec:B403,B301
|
||||
|
||||
@@ -1,65 +1,156 @@
|
||||
import asyncio
|
||||
from functools import wraps, partial
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, Callable, Optional, Type
|
||||
import logging
|
||||
import sys
|
||||
from functools import wraps
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import concurrent.futures
|
||||
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] = "",
|
||||
executor: Optional["concurrent.futures.Executor"] = None,
|
||||
):
|
||||
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
|
||||
"""
|
||||
cache all function
|
||||
:param namespace:
|
||||
:param expire:
|
||||
:param coder:
|
||||
:param key_builder:
|
||||
:param executor:
|
||||
|
||||
: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
|
||||
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"
|
||||
) or not FastAPICache.get_enable():
|
||||
|
||||
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: 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
|
||||
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:
|
||||
@@ -71,13 +162,17 @@ def cache(
|
||||
response.headers["ETag"] = etag
|
||||
return coder.decode(ret)
|
||||
|
||||
if inspect.iscoroutinefunction(func):
|
||||
ret = await func(*args, **kwargs)
|
||||
else:
|
||||
loop = asyncio.get_event_loop()
|
||||
ret = await loop.run_in_executor(executor, partial(func, *args, **kwargs))
|
||||
ret = await ensure_async_func(*args, **kwargs)
|
||||
encoded_ret = coder.encode(ret)
|
||||
|
||||
await backend.set(cache_key, coder.encode(ret), expire or FastAPICache.get_expire())
|
||||
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,18 +1,18 @@
|
||||
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}:"
|
||||
|
||||
0
fastapi_cache/py.typed
Normal file
0
fastapi_cache/py.typed
Normal file
1550
poetry.lock
generated
1550
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.9"
|
||||
version = "0.2.1"
|
||||
description = "Cache for FastAPI"
|
||||
authors = ["long2ice <long2ice@gmail.com>"]
|
||||
license = "Apache-2.0"
|
||||
@@ -22,12 +22,16 @@ 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 = "*"
|
||||
pytest = "*"
|
||||
requests = "*"
|
||||
coverage = "^6.5.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry>=0.12"]
|
||||
|
||||
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