84 Commits

Author SHA1 Message Date
long2ice
59a47b7fae chore: set version 0.2.0 2023-01-11 21:20:41 +08:00
long2ice
09361a7d4f Merge pull request #98 from vvanglro/feat/cache_response_obj
Feat/cache response obj
2023-01-11 17:23:47 +08:00
vvanglro
ed101595f7 fix: merge master 2023-01-11 16:43:36 +08:00
long2ice
0c73777930 Merge pull request #108 from hackjammer/master
Transparent passthrough in the event of cache backend connection issues
2023-01-11 10:45:11 +08:00
vvanglro
0d964fcf9f fix: remove unused 2023-01-07 13:55:41 +08:00
vvanglro
614ee25d0d feat: merge master 2023-01-07 13:46:48 +08:00
hackjammer
b420f26e9b transparent passthrough in the event of backend connection issues 2023-01-05 18:44:40 +00:00
long2ice
8f0920d0d7 ci: fix 2022-11-07 16:39:17 +08:00
long2ice
91e6e51ec7 Merge pull request #101 from mkdir700/fix-ci-errors-on-python3.11
Fix ci errors on python3.11
2022-11-07 16:33:45 +08:00
mkdir700
5c776d20db build: update version of aiohttp 2022-11-05 22:21:35 +08:00
mkdir700
c4ae7154fd ci: update version of actions 2022-11-05 22:21:35 +08:00
vvanglro
c1484a46fd feat: CHANGELOG.md 2022-11-04 17:44:41 +08:00
vvanglro
2710129c4e feat: cache response obj add test case 2022-11-04 17:34:20 +08:00
vvanglro
4cb4afeff0 feat: support cache JSONResponse 2022-11-04 17:31:37 +08:00
vvanglro
a8fbf2b340 fix: request / router KeyError 2022-11-04 16:56:43 +08:00
long2ice
73f000a565 Merge pull request #93 from Mrreadiness/feat/type-hints-covering
Feat/type hints covering
2022-11-04 08:51:21 +08:00
long2ice
cda720f534 Merge pull request #74 from Genius-Voice/feature/support-async-keybuilder
Add ability to use async function for key_builder
2022-11-03 20:25:27 +08:00
Ivan Moiseev
5881bb9122 Merge branch 'main' into feat/type-hints-covering
# Conflicts:
#	fastapi_cache/coder.py
#	fastapi_cache/decorator.py
2022-11-03 15:53:22 +04:00
Ivan Moiseev
10f819483c fix: replace pipe for Optional 2022-11-03 15:49:58 +04:00
long2ice
566d30b790 Merge pull request #88 from vvanglro/feat/cache_html
feat: support cache jinja2 template response
2022-11-03 19:42:47 +08:00
long2ice
671af52aea Merge pull request #33 from DevLucca/master
add `no-cache` to cache exclusion
2022-10-31 21:51:35 +08:00
Ivan Moiseev
71a77f6b39 fix: request and response type hints 2022-10-30 11:03:16 +04:00
Ivan Moiseev
e555d5e9be Merge remote-tracking branch 'main/master' into feat/type-hints-covering
# Conflicts:
#	fastapi_cache/decorator.py
2022-10-30 10:58:02 +04:00
long2ice
d88b9eaab0 Merge pull request #94 from squaresmile/cache-type
Added typing info to the decorator
2022-10-25 10:27:27 +08:00
squaresmile
c1a0e97f73 Updated 0.2.0 changelog 2022-10-25 09:26:24 +07:00
squaresmile
f3f134a318 Added typing to the decorator 2022-10-25 08:58:44 +07:00
squaresmile
5781593829 Added py.typed 2022-10-25 08:50:56 +07:00
Ivan Moiseev
c6bd8483a4 feat: fix tests and add FastAPICache init in tests. 2022-10-22 21:12:04 +04:00
Ivan Moiseev
e842d6408e feat: make PickleCoder compatible with backends 2022-10-22 21:06:38 +04:00
Ivan Moiseev
68ef94f2db feat: add more asserts for FastAPICache init 2022-10-22 21:05:43 +04:00
Ivan Moiseev
4c6abcf786 feat: add more type hints 2022-10-22 20:59:37 +04:00
long2ice
1ef80ff457 Merge pull request #92 from cpbotha/fix-sync-for-cache-disabled-path-2
Add extra required await and more tests
2022-10-15 11:30:46 +08:00
Charl P. Botha
6ba06bb10f Fix sync example 2022-10-14 21:59:57 +02:00
Charl P. Botha
d0c0885eae Add coverage 2022-10-14 21:59:51 +02:00
Charl P. Botha
630b175766 Add tests for sync and disabled cache 2022-10-14 21:59:33 +02:00
Charl P. Botha
ceb70426f3 Factor out support for optional request / response 2022-10-14 21:58:34 +02:00
Charl P. Botha
d123ec4bfa Add extra required await 2022-10-14 14:09:31 +02:00
long2ice
eeea884bb4 Merge pull request #91 from cpbotha/fix-sync-for-cache-disabled-path
Fix sync for cache disabled path
2022-10-14 19:52:03 +08:00
Charl P. Botha
af9c4d4c56 Restore demo of sync handling 2022-10-14 13:44:59 +02:00
Charl P. Botha
2822ab5d71 Factor out sync handling and use everywhere 2022-10-14 13:44:49 +02:00
vvanglro
7e64cd6490 feat: support cache jinja2 template response 2022-09-28 17:37:05 +08:00
long2ice
34415ad50a Merge pull request #86 from vvanglro/fix/example
fix: example coroutine object is not iterable
2022-09-15 20:40:58 +08:00
vvanglro
3dd4887b37 fix: example coroutine object is not iterable 2022-09-15 17:30:59 +08:00
long2ice
3041af2216 Merge pull request #85 from vvanglro/docs/quick_start
docs: quick start redis
2022-09-14 17:09:15 +08:00
wanghao
5c6f819636 docs: quick start redis 2022-09-14 16:24:10 +08:00
long2ice
3a481a36ed fix: test 2022-09-11 12:33:10 +08:00
long2ice
cb9259807e feat: make request and response optional 2022-09-10 20:06:37 +08:00
long2ice
a4b3386bf0 Merge pull request #82 from uriyyo/feature/run_in_threadpool
Use `run_in_threadpool` instead of `asyncio.run_in_executor`
2022-09-10 18:43:40 +08:00
Yurii Karabas
f310ef5b2d Use run_in_threadpool instead of asyncio run_in_executor 2022-09-09 19:35:48 +03:00
Jegor Kitskerkin
6cc1e65abb Add support for async key_builder 2022-08-11 15:16:12 +02:00
long2ice
820689ce9a Merge pull request #72 from thentgesMindee/clear-no-namespace
fix: 🐛 make FastAPICache.clear clear whole prefix when namespace is None
2022-08-11 08:44:13 +08:00
thentgesMindee
309d9ce7d1 fix: 🐛 FastAPICache.clear clear whole prefix when namespace is None 2022-08-10 19:31:29 +02:00
Lucca Marques
e62f0117e0 feat: changelog changes 2022-08-09 13:45:53 -03:00
Lucca Marques
6dc449b830 Merge branch 'master' into master 2022-08-08 12:40:33 -03:00
long2ice
36e0812c19 Merge pull request #55 from RyanTruran/documentation
added InMemory example
2022-08-08 21:22:02 +08:00
rtruran
70f5eed50b Merge branch 'master' into documentation 2022-08-08 08:16:24 -05:00
Jinlong Peng
15576b482a Merge remote-tracking branch 'origin/master' 2022-08-07 21:11:55 +08:00
Jinlong Peng
f80bfdb18d upgrade deps 2022-08-07 21:11:47 +08:00
long2ice
aaed438d8f Merge pull request #69 from jegork/feature/support-cache-for-normal-functions
Add support for normal def functions
2022-08-01 15:05:16 +08:00
Jegor Kitskerkin
d6c52408d2 Add support for normal def functions 2022-08-01 00:06:39 +02:00
long2ice
8c92cc59ae Fix test 2022-06-26 19:18:34 +08:00
long2ice
824e2e145f Replace aioredis with redis-py 2022-06-17 11:01:47 +08:00
long2ice
7fa54d311f Merge remote-tracking branch 'origin/master' 2022-06-10 08:43:42 +08:00
long2ice
9582e04d43 update deps 2022-06-10 08:43:35 +08:00
long2ice
fd8cf2da11 Merge pull request #60 from cnkailyn/master
bugfix: '+' is more prior than 'or'
2022-04-24 11:44:25 +08:00
kailyn
2f1b1409b9 bugfix: '+' is more prior than 'or' 2022-04-24 11:19:20 +08:00
long2ice
269c1ca616 update deps 2022-03-30 14:19:53 +08:00
Ryan Truran
89826b0a3b added InMemory example 2022-02-24 10:07:33 -06:00
long2ice
81d2bf2cc6 update version and changelog 2021-11-12 09:37:02 +08:00
long2ice
70f53566aa Merge pull request #40 from jimtheyounger/feature/add-dynamodb-backend
Add dynamodb backend support
2021-10-29 10:45:28 +08:00
long2ice
9928f4cda0 Add enable param to init 2021-10-28 15:52:21 +08:00
long2ice
4faa5b7101 update workflows 2021-10-19 11:56:51 +08:00
long2ice
c3be2eca19 Fix default json coder for datetime. 2021-10-09 16:51:05 +08:00
Jimmy
11f01a21f5 Remove unused variable assignment 2021-10-07 17:34:06 +02:00
Jimmy
cdcfdc6ae6 Apply styling 2021-10-06 10:10:22 +02:00
Jimmy
a40c54e9e7 Update README & add usage 2021-09-29 16:22:04 +02:00
Jimmy
d67797a1d5 Add DynamoDB backend 2021-09-29 16:14:28 +02:00
long2ice
8a8eb395ec Merge pull request #38 from joeflack4/docs-minorFix
Fix: README.md: Corrected some syntactically incorrect commands
2021-09-24 10:23:25 +08:00
Joe Flack
e397dcb16b Updated readme.md
Corrected some syntactically incorrect commands
2021-09-23 16:10:31 -04:00
long2ice
37a2fa85db update CONVERTERS 2021-09-17 10:19:56 +08:00
long2ice
6888c10d6c update deps 2021-08-29 22:33:50 +08:00
long2ice
943935870d Update FUNDING.yml 2021-08-26 20:34:50 +08:00
Lucca Leme Marques
dacd7e1b0f add no-cache to cache exclusion 2021-08-23 11:51:28 -03:00
long2ice
46c7ada364 Fix ci 2021-07-26 16:37:19 +08:00
25 changed files with 1633 additions and 605 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
custom: ["https://sponsor.long2ice.cn"] custom: ["https://sponsor.long2ice.io"]

View File

@@ -4,13 +4,12 @@ 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"
- name: Install and configure Poetry - uses: abatilo/actions-poetry@v2.1.6
uses: snok/install-poetry@v1.1.1 - name: Config poetry
with: run: poetry config experimental.new-installer false
virtualenvs-create: false
- name: CI - name: CI
run: make ci run: make ci

View File

@@ -11,10 +11,9 @@ jobs:
- uses: actions/setup-python@v1 - uses: actions/setup-python@v1
with: with:
python-version: '3.x' python-version: '3.x'
- name: Install and configure Poetry - uses: abatilo/actions-poetry@v2.1.3
uses: snok/install-poetry@v1.1.1 - name: Config poetry
with: run: poetry config experimental.new-installer false
virtualenvs-create: false
- name: Build dists - name: Build dists
run: make build run: make build
- name: Pypi Publish - name: Pypi Publish

View File

@@ -1,7 +1,37 @@
# ChangeLog # ChangeLog
## 0.2
### 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
- Fix default json coder for datetime.
- Add `enable` param to `init`.
### 0.1.6 ### 0.1.6
- Fix redis cache. - Fix redis cache.

View File

@@ -1,19 +1,6 @@
checkfiles = fastapi_cache/ examples/ tests/ checkfiles = fastapi_cache/ examples/ tests/
black_opts = -l 100 -t py38
py_warn = PYTHONDEVMODE=1 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: up:
@poetry update @poetry update
@@ -21,16 +8,15 @@ deps:
@poetry install --no-root -E all @poetry install --no-root -E all
style: deps style: deps
@isort -src $(checkfiles) @poetry run isort -src $(checkfiles)
@black $(black_opts) $(checkfiles) @poetry run black $(checkfiles)
check: deps check: deps
@black --check $(black_opts) $(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)
@flake8 $(checkfiles) @poetry run flake8 $(checkfiles)
@bandit -r $(checkfiles)
test: deps test: deps
$(py_warn) pytest $(py_warn) poetry run pytest
build: clean deps build: clean deps
@poetry build @poetry build

View File

@@ -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
@@ -30,13 +32,19 @@
or or
```shell ```shell
> pip install fastapi-cache2[redis] > pip install "fastapi-cache2[redis]"
``` ```
or or
```shell ```shell
> pip install fastapi-cache2[memcache] > pip install "fastapi-cache2[memcache]"
```
or
```shell
> pip install "fastapi-cache2[dynamodb]"
``` ```
## Usage ## Usage
@@ -44,7 +52,6 @@ or
### 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

View File

View File

@@ -0,0 +1,66 @@
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()}
@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)

View File

@@ -1,42 +0,0 @@
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.inmemory import InMemoryBackend
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.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)

View File

10
examples/redis/index.html Normal file
View 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
View 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)

View File

@@ -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.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
@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,
) -> None:
if cls._init: if cls._init:
return return
cls._init = True cls._init = True
@@ -29,29 +32,48 @@ class FastAPICache:
cls._expire = expire cls._expire = expire
cls._coder = coder cls._coder = coder
cls._key_builder = key_builder cls._key_builder = key_builder
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
async def clear(cls, namespace: str = None, key: str = None): def get_enable(cls) -> bool:
namespace = cls._prefix + ":" + namespace if namespace else None 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) return await cls._backend.clear(namespace, key)

View File

@@ -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

View 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

View File

@@ -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())

View File

@@ -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

View File

@@ -1,6 +1,6 @@
from typing import Tuple from typing import Optional, Tuple
from aioredis import Redis from redis.asyncio.client import Redis
from fastapi_cache.backends import Backend from fastapi_cache.backends import Backend
@@ -13,15 +13,16 @@ class RedisBackend(Backend):
async with self.redis.pipeline(transaction=True) as pipe: async with self.redis.pipeline(transaction=True) 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

View File

@@ -4,67 +4,70 @@ import pickle # nosec:B403
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
import dateutil.parser 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": dateutil.parser.parse, "date": lambda x: pendulum.parse(x, exact=True),
"datetime": dateutil.parser.parse, "datetime": lambda x: pendulum.parse(x, exact=True),
"decimal": Decimal, "decimal": Decimal,
} }
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):
if obj.tzinfo: return {"val": str(obj), "_spec_type": "datetime"}
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"}
elif isinstance(obj, datetime.date): 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): elif isinstance(obj, Decimal):
return {"val": str(obj), "_spec_type": "decimal"} return {"val": str(obj), "_spec_type": "decimal"}
else: else:
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: Any) -> 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: Any) -> 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 str(pickle.dumps(value))
@classmethod @classmethod
def decode(cls, value: Any): def decode(cls, value: Any) -> Any:
return pickle.loads(value) # nosec:B403 return pickle.loads(bytes(value)) # nosec:B403,B301

View File

@@ -1,55 +1,147 @@
import inspect
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
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 = [*signature.parameters.values()]
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,
),
)
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 request and request.headers.get("Cache-Control") == "no-store": # 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) 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 ConnectionError:
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 or FastAPICache.get_expire()
)
except ConnectionError:
pass
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:
@@ -61,8 +153,12 @@ 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)
try:
await backend.set(cache_key, coder.encode(ret), expire or FastAPICache.get_expire()) await backend.set(cache_key, coder.encode(ret), expire or FastAPICache.get_expire())
except ConnectionError:
pass
return ret return ret
return inner return inner

View File

@@ -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
View File

1416
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "fastapi-cache2" name = "fastapi-cache2"
version = "0.1.6" version = "0.2.0"
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,22 +18,31 @@ include = ["LICENSE", "README.md"]
python = "^3.7" python = "^3.7"
fastapi = "*" fastapi = "*"
uvicorn = "*" uvicorn = "*"
aioredis = {version = ">=2.0.0b1", optional = true} redis = { version = "^4.2.0rc1", optional = true }
aiomcache = { version = "*", optional = true } aiomcache = { version = "*", optional = true }
python-dateutil = "*" 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 = "^19.10b0" 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]
line-length = 100
target-version = ['py36', 'py37', 'py38', 'py39']

View File

@@ -1,2 +1,69 @@
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")