Skip to content

Commit dc57568

Browse files
authored
Merge pull request a-luna#27 from a-luna/minor-release/v0.2.0
v0.2.0
2 parents 472a638 + 03225dc commit dc57568

File tree

14 files changed

+167
-129
lines changed

14 files changed

+167
-129
lines changed

.github/workflows/tox.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717

1818
strategy:
1919
matrix:
20-
python-version: ["3.9"]
20+
python-version: ["3.7", "3.8", "3.9"]
2121

2222
steps:
2323
- uses: "actions/checkout@v2"

README.md

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,59 @@ INFO: 127.0.0.1:61779 - "GET /immutable_data HTTP/1.1" 200 OK
8484

8585
The log messages show two successful (**`200 OK`**) responses to the same request (**`GET /immutable_data`**). The first request executed the `get_immutable_data` function and stored the result in Redis under key `api.get_immutable_data()`. The second request _**did not**_ execute the `get_immutable_data` function, instead the cached result was retrieved and sent as the response.
8686

87-
If data for an API endpoint needs to expire, you can specify the number of seconds before it is deleted by Redis using the `expire_after_seconds` parameter:
87+
In most situations, response data must expire in a much shorter period of time than one year. Using the `expire` parameter, You can specify the number of seconds before data is deleted:
8888

8989
```python
9090
# Will be cached for thirty seconds
9191
@app.get("/dynamic_data")
92-
@cache(expire_after_seconds=30)
92+
@cache(expire=30)
9393
def get_dynamic_data(request: Request, response: Response):
9494
return {"success": True, "message": "this data should only be cached temporarily"}
9595
```
9696

97+
> **NOTE!** `expire` can be either an `int` value or `timedelta` object. When the TTL is very short (like the example above) this results in a decorator that is expressive and requires minimal effort to parse visually. For durations an hour or longer (e.g., `@cache(expire=86400)`), IMHO, using a `timedelta` object is much easier to grok (`@cache(expire=timedelta(days=1))`).
98+
99+
Additionally, the decorators listed below define several common durations and can be used in place of the `@cache` decorator:
100+
101+
- `@cache_one_minute`
102+
- `@cache_one_hour`
103+
- `@cache_one_day`
104+
- `@cache_one_week`
105+
- `@cache_one_month`
106+
- `@cache_one_year`
107+
108+
For example, instead of `@cache(expire=timedelta(days=1))`, you could use:
109+
110+
```python
111+
from fastapi_redis_cache import cache_one_day
112+
113+
@app.get("/cache_one_day")
114+
@cache_one_day()
115+
def partial_cache_one_day(response: Response):
116+
return {"success": True, "message": "this data should be cached for 24 hours"}
117+
```
118+
119+
If a duration that you would like to use throughout your project is missing from the list, you can easily create your own:
120+
121+
```python
122+
from functools import partial, update_wrapper
123+
from fastapi_redis_cache import cache
124+
125+
ONE_HOUR_IN_SECONDS = 3600
126+
127+
cache_two_hours = partial(cache, expire=ONE_HOUR_IN_SECONDS * 2)
128+
update_wrapper(cache_two_hours, cache)
129+
```
130+
131+
Then, simply import `cache_two_hours` and use it to decorate your API endpoint path functions:
132+
133+
```python
134+
@app.get("/cache_two_hours")
135+
@cache_two_hours()
136+
def partial_cache_two_hours(response: Response):
137+
return {"success": True, "message": "this data should be cached for two hours"}
138+
```
139+
97140
#### Response Headers
98141

99142
Below is an example HTTP response for the `/dynamic_data` endpoint. The `cache-control`, `etag`, `expires`, and `x-fastapi-cache` headers are added because of the `@cache` decorator:
@@ -117,7 +160,7 @@ $ http "http://127.0.0.1:8000/dynamic_data"
117160
```
118161

119162
- The `x-fastapi-cache` header field indicates that this response was found in the Redis cache (a.k.a. a `Hit`). The only other possible value for this field is `Miss`.
120-
- The `expires` field and `max-age` value in the `cache-control` field indicate that this response will be considered fresh for 29 seconds. This is expected since `expire_after_seconds=30` was specified in the `@cache` decorator.
163+
- The `expires` field and `max-age` value in the `cache-control` field indicate that this response will be considered fresh for 29 seconds. This is expected since `expire=30` was specified in the `@cache` decorator.
121164
- The `etag` field is an identifier that is created by converting the response data to a string and applying a hash function. If a request containing the `if-none-match` header is received, the `etag` value will be used to determine if the requested resource has been modified.
122165

123166
If this request was made from a web browser, and a request for the same resource was sent before the cached response expires, the browser would automatically serve the cached version and the request would never even be sent to the FastAPI server.
@@ -126,27 +169,25 @@ Similarly, if a request is sent with the `cache-control` header containing `no-c
126169

127170
#### Cache Keys
128171

129-
Consider the `/get_user` API route defined below. This is the first path function we have seen where the response depends on the value of an argument (`user_id: int`). This is a typical CRUD operation where `user_id` is used to retrieve a `User` record from a SQLAlchemy database.
172+
Consider the `/get_user` API route defined below. This is the first path function we have seen where the response depends on the value of an argument (`user_id: int`). This is a typical CRUD operation where `user_id` is used to retrieve a `User` record from a database. The API route also includes a dependency that injects a `Session` object (`db`) into the function, [per the instructions from the FastAPI docs](https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-dependency):
130173

131174
```python
132175
@app.get("/get_user", response_model=schemas.User)
133-
@cache(expire_after_seconds=3600)
176+
@cache(expire=3600)
134177
def get_item(user_id: int, db: Session = Depends(get_db)):
135178
return db.query(models.User).filter(models.User.id == user_id).first()
136179
```
137180

138-
The API route also includes a dependency that injects a Session object (`db`) into the function, [per the instructions from the FastAPI docs](https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-dependency). This is used to query the database for the `User` corresponding to the `user_id` value.
139-
140181
In the [Initialize Redis](#initialize-redis) section of this document, the `FastApiRedisCache.init` method was called with `ignore_arg_types=[Request, Response, Session]`. Why is it necessary to include `Session` in this list?
141182

142183
Before we can answer that question, we must understand how a cache key is created. In order to create a unique identifier for the data sent in response to an API request, the following values are combined:
143184

144185
1) The optional `prefix` value provided as an argument to the `FastApiRedisCache.init` method (`"myapi-cache"`).
145186
2) The module containing the path function (`"api"`).
146187
3) The name of the path function (`"get_user"`).
147-
4) The name and value of all arguments to the path function **EXCEPT for arguments with a type that exists in** `ignore_arg_types` (`"user_id=?"`).
188+
4) The name and value of all arguments to the path function **EXCEPT for arguments with a type that exists in** `ignore_arg_types` (`"user_id=1"`).
148189

149-
Therefore, all response data for the `/get_user` endpoint will have a cache key equal to `"myapi-cache:api.get_user(user_id=?)"` (e.g., for `user_id=1`, the cache key will be `"myapi-cache:api.get_user(user_id=1)"`).
190+
Therefore, the cache key in this example will be `"myapi-cache:api.get_user(user_id=1)"`).
150191

151192
Even though `db` is an argument to the path function, it is not included in the cache key because it is a `Session` type. If `Session` had not been included in the `ignore_arg_types` list, caching would be completely broken.
152193

@@ -203,7 +244,6 @@ def get_scoreboard_for_date(
203244

204245
The `game_date` argument is a `MLBGameDate` type. This is a custom type that parses the value from the querystring to a date, and determines if the parsed date is valid by checking if it is within a certain range. The implementation for `MLBGameDate` is given below:
205246

206-
207247
```python
208248
class MLBGameDate:
209249
def __init__(
@@ -225,7 +265,7 @@ class MLBGameDate:
225265
return self.date.strftime("%Y-%m-%d")
226266
```
227267

228-
Please note the custom `__str__` method that overrides the default behavior. This way, instead of `<MLBGameDate object at 0x11c7e35e0>`, the value will be formatted as, for example, `2019-05-09`. You can use this strategy whenever you have an argument that has en effect on the response data but converting that argument to a string results in a value containing the object's memory location.
268+
Please note the `__str__` method that overrides the default behavior. This way, instead of `<MLBGameDate object at 0x11c7e35e0>`, the value will be formatted as, for example, `2019-05-09`. You can use this strategy whenever you have an argument that has en effect on the response data but converting that argument to a string results in a value containing the object's memory location.
229269

230270
### Questions/Contributions
231271

requirements-dev.txt

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,10 @@
1-
appdirs==1.4.4
2-
appnope==0.1.2
3-
asttokens==2.0.5
4-
async-timeout==3.0.1
5-
attrs==20.3.0
6-
backcall==0.2.0
71
black==20.8b1
8-
certifi==2020.12.5
9-
chardet==4.0.0
10-
cheap-repr==0.4.5
11-
click==7.1.2
12-
colorclass==2.2.0
132
coverage==5.5
14-
decorator==5.0.7
15-
distlib==0.3.1
16-
docopt==0.6.2
17-
executing==0.6.0
183
fakeredis==1.5.0
19-
fastapi==0.63.0
20-
filelock==3.0.12
214
flake8==3.9.1
22-
h11==0.12.0
23-
hiredis==2.0.0
24-
idna==2.10
25-
iniconfig==1.1.1
26-
ipython==7.22.0
27-
ipython-genutils==0.2.0
285
isort==5.8.0
29-
jedi==0.18.0
30-
mccabe==0.6.1
31-
mypy-extensions==0.4.3
32-
packaging==20.9
33-
parso==0.8.2
34-
pathspec==0.8.1
35-
pexpect==4.8.0
36-
pickleshare==0.7.5
37-
pip-upgrader==1.4.15
38-
pluggy==0.13.1
39-
prompt-toolkit==3.0.18
40-
psutil==5.8.0
41-
ptyprocess==0.7.0
42-
py==1.10.0
43-
pycodestyle==2.7.0
44-
pydantic==1.8.1
45-
pyflakes==2.3.1
46-
Pygments==2.8.1
47-
pyparsing==2.4.7
486
pytest==6.2.3
497
pytest-cov==2.11.1
508
pytest-flake8==1.0.7
519
pytest-random-order==1.0.4
52-
python-dateutil==2.8.1
53-
redis==3.5.3
54-
regex==2021.4.4
5510
requests==2.25.1
56-
six==1.15.0
57-
snoop==0.3.0
58-
sortedcontainers==2.3.0
59-
starlette==0.13.6
60-
terminaltables==3.1.0
61-
toml==0.10.2
62-
tox==3.23.0
63-
traitlets==5.0.5
64-
typed-ast==1.4.3
65-
typing-extensions==3.7.4.3
66-
urllib3==1.26.4
67-
uvicorn==0.13.4
68-
virtualenv==20.4.4
69-
wcwidth==0.2.5

setup.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ addopts =
1212
--verbose
1313
# clear cache before each run
1414
--cache-clear
15-
# randomize tests at the package level
16-
--random-order-bucket=package
1715
norecursedirs =
1816
.git
1917
.pytest_cache
2018
.vscode
2119
venv
20+
build
21+
dist
2222
custom_scripts
2323

2424
[flake8]

setup.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"Operating System :: MacOS :: MacOS X",
2222
"Operating System :: POSIX :: Linux",
2323
"Operating System :: Unix",
24+
"Programming Language :: Python :: 3.7",
2425
"Programming Language :: Python :: 3.8",
2526
"Programming Language :: Python :: 3.9",
2627
"Programming Language :: Python :: 3 :: Only",
@@ -31,6 +32,18 @@
3132
"python-dateutil",
3233
"redis",
3334
]
35+
DEV_REQUIRES = [
36+
"black",
37+
"coverage",
38+
"fakeredis",
39+
"flake8",
40+
"isort",
41+
"pytest",
42+
"pytest-cov",
43+
"pytest-flake8",
44+
"pytest-random-order",
45+
"requests",
46+
]
3447

3548
exec(open(str(APP_ROOT / "src/fastapi_redis_cache/version.py")).read())
3649
setup(
@@ -49,7 +62,8 @@
4962
packages=find_packages(where="src"),
5063
package_dir={"": "src"},
5164
include_package_data=True,
52-
python_requires=">=3.8",
65+
python_requires=">=3.7",
5366
classifiers=CLASSIFIERS,
5467
install_requires=INSTALL_REQUIRES,
68+
extras_require={"dev": DEV_REQUIRES},
5569
)
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
# flake8: noqa
2-
from fastapi_redis_cache.cache import cache
2+
from fastapi_redis_cache.cache import (
3+
cache,
4+
cache_one_day,
5+
cache_one_hour,
6+
cache_one_minute,
7+
cache_one_month,
8+
cache_one_week,
9+
cache_one_year,
10+
)
311
from fastapi_redis_cache.client import FastApiRedisCache

src/fastapi_redis_cache/cache.py

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
11
"""cache.py"""
22
import asyncio
33
from datetime import timedelta
4-
from functools import wraps
4+
from functools import partial, update_wrapper, wraps
55
from http import HTTPStatus
66
from typing import Union
77

88
from fastapi import Response
99

1010
from fastapi_redis_cache.client import FastApiRedisCache
11+
from fastapi_redis_cache.util import (
12+
deserialize_json,
13+
ONE_DAY_IN_SECONDS,
14+
ONE_HOUR_IN_SECONDS,
15+
ONE_MONTH_IN_SECONDS,
16+
ONE_WEEK_IN_SECONDS,
17+
ONE_YEAR_IN_SECONDS,
18+
serialize_json,
19+
)
1120

1221

13-
def cache(*, expire_after_seconds: Union[int, timedelta] = None):
22+
def cache(*, expire: Union[int, timedelta] = ONE_YEAR_IN_SECONDS):
1423
"""Enable caching behavior for the decorated function.
1524
16-
If no arguments are provided, this marks the response data for the decorated
17-
path function as "never expires". In this case, the `Expires` and
18-
`Cache-Control: max-age` headers will be set to expire after one year.
19-
Historically, this was the furthest time in the future that was allowed for
20-
these fields. This is no longer the case, but it is still not advisable to use
21-
values greater than one year.
22-
2325
Args:
24-
expire_after_seconds (Union[int, timedelta], optional): The number of seconds
25-
from now when the cached response should expire. Defaults to None.
26+
expire (Union[int, timedelta], optional): The number of seconds
27+
from now when the cached response should expire. Defaults to 31,536,000
28+
seconds (i.e., the number of seconds in one year).
2629
"""
2730

2831
def outer_wrapper(func):
@@ -33,32 +36,32 @@ async def inner_wrapper(*args, **kwargs):
3336
func_kwargs = kwargs.copy()
3437
request = func_kwargs.pop("request", None)
3538
response = func_kwargs.pop("response", None)
36-
create_response_directly = False
37-
if not response:
39+
create_response_directly = not response
40+
if create_response_directly:
3841
response = Response()
39-
create_response_directly = True
4042
redis_cache = FastApiRedisCache()
41-
42-
# if the redis client is not connected or request is not cacheable, no caching behavior is performed.
4343
if redis_cache.not_connected or redis_cache.request_is_not_cacheable(request):
44+
# if the redis client is not connected or request is not cacheable, no caching behavior is performed.
4445
return await get_api_response_async(func, *args, **kwargs)
4546
key = redis_cache.get_cache_key(func, *args, **kwargs)
4647
ttl, in_cache = redis_cache.check_cache(key)
4748
if in_cache:
4849
if redis_cache.requested_resource_not_modified(request, in_cache):
4950
response.status_code = int(HTTPStatus.NOT_MODIFIED)
5051
return response
51-
cached_data = redis_cache.deserialize_json(in_cache)
52+
cached_data = deserialize_json(in_cache)
5253
redis_cache.set_response_headers(response, cache_hit=True, response_data=cached_data, ttl=ttl)
5354
if create_response_directly:
5455
return Response(content=in_cache, media_type="application/json", headers=response.headers)
5556
return cached_data
5657
response_data = await get_api_response_async(func, *args, **kwargs)
57-
redis_cache.add_to_cache(key, response_data, expire_after_seconds)
58-
redis_cache.set_response_headers(response, cache_hit=False, response_data=response_data, ttl=ttl)
58+
ttl = calculate_ttl(expire)
59+
cached = redis_cache.add_to_cache(key, response_data, ttl)
60+
if cached:
61+
redis_cache.set_response_headers(response, cache_hit=False, response_data=response_data, ttl=ttl)
5962
if create_response_directly:
6063
return Response(
61-
content=redis_cache.serialize_json(response_data),
64+
content=serialize_json(response_data),
6265
media_type="application/json",
6366
headers=response.headers,
6467
)
@@ -72,3 +75,24 @@ async def inner_wrapper(*args, **kwargs):
7275
async def get_api_response_async(func, *args, **kwargs):
7376
"""Helper function that allows decorator to work with both async and non-async functions."""
7477
return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
78+
79+
80+
def calculate_ttl(expire: Union[int, timedelta]) -> int:
81+
if isinstance(expire, timedelta):
82+
expire = int(expire.total_seconds())
83+
return min(expire, ONE_YEAR_IN_SECONDS)
84+
85+
86+
cache_one_minute = partial(cache, expire=60)
87+
cache_one_hour = partial(cache, expire=ONE_HOUR_IN_SECONDS)
88+
cache_one_day = partial(cache, expire=ONE_DAY_IN_SECONDS)
89+
cache_one_week = partial(cache, expire=ONE_WEEK_IN_SECONDS)
90+
cache_one_month = partial(cache, expire=ONE_MONTH_IN_SECONDS)
91+
cache_one_year = partial(cache, expire=ONE_YEAR_IN_SECONDS)
92+
93+
update_wrapper(cache_one_minute, cache)
94+
update_wrapper(cache_one_hour, cache)
95+
update_wrapper(cache_one_day, cache)
96+
update_wrapper(cache_one_week, cache)
97+
update_wrapper(cache_one_month, cache)
98+
update_wrapper(cache_one_year, cache)

0 commit comments

Comments
 (0)