Skip to content
This repository was archived by the owner on Oct 3, 2023. It is now read-only.

Commit ab52aa5

Browse files
committed
Add a redis cache
1 parent f0f49a6 commit ab52aa5

File tree

8 files changed

+457
-15
lines changed

8 files changed

+457
-15
lines changed

docs/api.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
:docstring:
3737
:members:
3838

39-
!!! **Note** FileCache only supports `httpx_cache.MsgPackSerializer` and `httpx_cache.BytesJsonSerializer` serializers.
39+
::: httpx_cache.cache.redis.RedisCache
40+
:docstring:
41+
:members:
42+
43+
!!! **Note** FileCache and RedisCache only supports `httpx_cache.MsgPackSerializer` and `httpx_cache.BytesJsonSerializer` serializers.
4044

4145
## Serializer
4246

docs/guide.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,16 +168,46 @@ with httpx_cache.Client(cache=httpx_cache.FileCache(cache_dir="./my-custom-dir")
168168
response = client.get("https://httpbin.org/get")
169169
```
170170

171+
### RedisCache
172+
173+
You need to install `redis` package to use this cache type, or install `httpx-cache[redis]` to install it automatically.
174+
175+
```py
176+
import httpx_cache
177+
from httpx.cache.redis import RedisCache
178+
179+
with httpx_cache.Client(cache=RedisCache(redis_url="redis://localhost:6379/0")) as client:
180+
response = client.get("https://httpbin.org/get")
181+
```
182+
183+
By default all cached responses are saved under the namespace `htppx_cache`.
184+
185+
Optionally a TTL can be provided so that the cached responses expire after the given time (as a python timedelta).
186+
187+
It can also accepts direct instances of `redis.Redis` or `redis.StrictRedis` clients.
188+
189+
```py
190+
import httpx_cache
191+
from redis import Redis
192+
from httpx.cache.redis import RedisCache
193+
194+
redis_client = Redis(host="localhost", port=6379, db=0)
195+
cache = RedisCache(redis=redis_client, namespace="my-custom-namespace", default_ttl=timedelta(hours=1))
196+
197+
with httpx_cache.Client(cache=cache) as client:
198+
response = client.get("https://httpbin.org/get")
199+
```
200+
171201
## Serializer Types
172202

173203
Before caching an httpx.Response it needs to be serialized to a cacheable format supported by the used cache type (Dict/File).
174204

175-
| Serializer | DictCache | FileCache |
176-
| -------------------- | ------------------ | ------------------ |
177-
| DictSerializer | :white_check_mark: | :x: |
178-
| StringJsonSerializer | :white_check_mark: | :x: |
179-
| BytesJsonSerializer | :white_check_mark: | :white_check_mark: |
180-
| MsgPackSerializer | :white_check_mark: | :white_check_mark: |
205+
| Serializer | DictCache | FileCache | RedisCache |
206+
| -------------------- | ------------------ | ------------------ | ------------------ |
207+
| DictSerializer | :white_check_mark: | :x: | :x: |
208+
| StringJsonSerializer | :white_check_mark: | :x: | :x: |
209+
| BytesJsonSerializer | :white_check_mark: | :white_check_mark: | :white_check_mark: |
210+
| MsgPackSerializer | :white_check_mark: | :white_check_mark: | :white_check_mark: |
181211

182212
A custom serializer can be used anytime with:
183213

docs/index.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ Install with pip:
1515
$ pip install httpx-cache
1616
```
1717

18+
To use `RedisCache`, install with `redis` extra:
19+
20+
```sh
21+
$ pip install httpx-cache[redis]
22+
```
23+
1824
Requires Python 3.6+ and HTTPX 0.21+.
1925

2026
## Quickstart
@@ -40,3 +46,12 @@ async with httpx_cache.AsyncClient() as client:
4046
When using `httpx-cache.Client`/`httpx_cache.AsyncClient`, the interface and features (except caching) are exactly the same as `httpx.Client`/`httpx.AsyncClient`
4147

4248
> Read the [User Guide](./guide.md) for a complete walk-through.
49+
50+
## Supported Cache Types and Serializers
51+
52+
| Serializer | DictCache | FileCache | RedisCache |
53+
| -------------------- | ------------------ | ------------------ | ------------------ |
54+
| DictSerializer | :white_check_mark: | :x: | :x: |
55+
| StringJsonSerializer | :white_check_mark: | :x: | :x: |
56+
| BytesJsonSerializer | :white_check_mark: | :white_check_mark: | :white_check_mark: |
57+
| MsgPackSerializer | :white_check_mark: | :white_check_mark: | :white_check_mark: |

httpx_cache/cache/file.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
from pathlib import Path
33

44
import anyio
5-
import fasteners
6-
from aiorwlock import RWLock
5+
from fasteners import ReaderWriterLock as RWLock
6+
from aiorwlock import RWLock as AsyncRWLock
77
import httpx
88

99
from httpx_cache.cache.base import BaseCache
@@ -24,7 +24,7 @@ class FileCache(BaseCache):
2424
httpx_cache.MsgPackSerializer
2525
"""
2626

27-
lock = fasteners.ReaderWriterLock()
27+
lock = RWLock()
2828

2929
def __init__(
3030
self,
@@ -48,6 +48,14 @@ def __init__(
4848
self.cache_dir = cache_dir
4949
self.cache_dir.mkdir(parents=True, exist_ok=True)
5050

51+
self._async_lock: tp.Optional[AsyncRWLock] = None
52+
53+
@property
54+
def async_lock(self) -> AsyncRWLock:
55+
if self._async_lock is None:
56+
self._async_lock = AsyncRWLock()
57+
return self._async_lock
58+
5159
def get(self, request: httpx.Request) -> tp.Optional[httpx.Response]:
5260
filepath = get_cache_filepath(self.cache_dir, request, extra=self._extra)
5361
if filepath.is_file():
@@ -61,7 +69,7 @@ async def aget(self, request: httpx.Request) -> tp.Optional[httpx.Response]:
6169
get_cache_filepath(self.cache_dir, request, extra=self._extra)
6270
)
6371
if await filepath.is_file():
64-
async with RWLock().reader:
72+
async with self.async_lock.reader:
6573
cached = await filepath.read_bytes()
6674
return self.serializer.loads(request=request, cached=cached)
6775
return None
@@ -89,7 +97,7 @@ async def aset(
8997
get_cache_filepath(self.cache_dir, request, extra=self._extra)
9098
)
9199
to_cache = self.serializer.dumps(response=response, content=content)
92-
async with RWLock().writer:
100+
async with self.async_lock.writer:
93101
await filepath.write_bytes(to_cache)
94102

95103
def delete(self, request: httpx.Request) -> None:
@@ -102,5 +110,5 @@ async def adelete(self, request: httpx.Request) -> None:
102110
filepath = anyio.Path(
103111
get_cache_filepath(self.cache_dir, request, extra=self._extra)
104112
)
105-
async with RWLock().writer:
113+
async with self.async_lock.writer:
106114
await filepath.unlink(missing_ok=True)

httpx_cache/cache/redis.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import typing as tp
2+
from datetime import timedelta
3+
4+
import httpx
5+
from aiorwlock import RWLock as AsyncRWLock
6+
from fasteners import ReaderWriterLock as RWLock
7+
from redis import Redis
8+
from redis.asyncio import Redis as AsyncRedis
9+
10+
from httpx_cache.cache.base import BaseCache
11+
from httpx_cache.serializer.base import BaseSerializer
12+
from httpx_cache.serializer.common import MsgPackSerializer
13+
from httpx_cache.utils import get_cache_key
14+
15+
__all__ = ["RedisCache"]
16+
17+
18+
class RedisCache(BaseCache):
19+
"""Redis cache that stores cached responses in Redis.
20+
21+
Uses a lock/async_lock to make sure each get/set/delete operation is safe.
22+
23+
You can either provide an instance of 'Redis'/'AsyncRedis' or a redis url to
24+
have RedisCache create the connection for you.
25+
26+
Args:
27+
serializer: Optional serializer for the data to cache, defaults to:
28+
httpx_cache.MsgPackSerializer
29+
namespace: Optional namespace for the cache keys, defaults to "httpx_cache"
30+
redis_url: Optional redis url, defaults to empty string
31+
redis: Optional redis instance, defaults to None
32+
aredis: Optional async redis instance, defaults to None
33+
default_ttl: Optional default ttl for cached responses, defaults to None
34+
"""
35+
36+
lock = RWLock()
37+
38+
def __init__(
39+
self,
40+
serializer: tp.Optional[BaseSerializer] = None,
41+
namespace: str = "httpx_cache",
42+
redis_url: str = "",
43+
redis: tp.Optional["Redis[bytes]"] = None,
44+
aredis: tp.Optional["AsyncRedis[bytes]"] = None,
45+
default_ttl: tp.Optional[timedelta] = None,
46+
) -> None:
47+
self.namespace = namespace
48+
# redis connection is lazy loaded
49+
self.redis = redis or Redis.from_url(redis_url)
50+
self.aredis = aredis or AsyncRedis.from_url(redis_url)
51+
self.serializer = serializer or MsgPackSerializer()
52+
self.default_ttl = default_ttl
53+
if not isinstance(self.serializer, BaseSerializer):
54+
raise TypeError(
55+
"Expected serializer of type 'httpx_cache.BaseSerializer', "
56+
f"got {type(self.serializer)}"
57+
)
58+
59+
self._async_lock: tp.Optional[AsyncRWLock] = None
60+
61+
@property
62+
def async_lock(self) -> AsyncRWLock:
63+
if self._async_lock is None:
64+
self._async_lock = AsyncRWLock()
65+
return self._async_lock
66+
67+
def _get_namespaced_cache_key(self, request: httpx.Request) -> str:
68+
key = get_cache_key(request)
69+
if self.namespace:
70+
key = f"{self.namespace}:{key}"
71+
return key
72+
73+
def get(self, request: httpx.Request) -> tp.Optional[httpx.Response]:
74+
key = self._get_namespaced_cache_key(request)
75+
with self.lock.read_lock():
76+
cached = self.redis.get(key)
77+
if cached is not None:
78+
return self.serializer.loads(cached=cached, request=request)
79+
return None
80+
81+
async def aget(self, request: httpx.Request) -> tp.Optional[httpx.Response]:
82+
key = self._get_namespaced_cache_key(request)
83+
async with self.async_lock.reader:
84+
cached_data = await self.aredis.get(key)
85+
if cached_data is not None:
86+
return self.serializer.loads(cached=cached_data, request=request)
87+
return None
88+
89+
def set(
90+
self,
91+
*,
92+
request: httpx.Request,
93+
response: httpx.Response,
94+
content: tp.Optional[bytes] = None,
95+
) -> None:
96+
key = self._get_namespaced_cache_key(request)
97+
to_cache = self.serializer.dumps(response=response, content=content)
98+
with self.lock.write_lock():
99+
if self.default_ttl:
100+
self.redis.setex(key, self.default_ttl, to_cache)
101+
else:
102+
self.redis.set(key, to_cache)
103+
104+
async def aset(
105+
self,
106+
*,
107+
request: httpx.Request,
108+
response: httpx.Response,
109+
content: tp.Optional[bytes] = None,
110+
) -> None:
111+
to_cache = self.serializer.dumps(response=response, content=content)
112+
key = self._get_namespaced_cache_key(request)
113+
async with self.async_lock.writer:
114+
if self.default_ttl:
115+
await self.aredis.setex(key, self.default_ttl, to_cache)
116+
else:
117+
await self.aredis.set(key, to_cache)
118+
119+
def delete(self, request: httpx.Request) -> None:
120+
key = self._get_namespaced_cache_key(request)
121+
with self.lock.write_lock():
122+
self.redis.delete(key)
123+
124+
async def adelete(self, request: httpx.Request) -> None:
125+
key = self._get_namespaced_cache_key(request)
126+
async with self.async_lock.writer:
127+
await self.aredis.delete(key)
128+
129+
def close(self) -> None:
130+
self.redis.close()
131+
132+
async def aclose(self) -> None:
133+
await self.aredis.close()

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "httpx-cache"
7-
version = "0.8.0"
7+
version = "0.9.0"
88
description = "Simple caching transport for httpx."
99
readme = "README.md"
1010
requires-python = ">=3.7"
@@ -41,12 +41,16 @@ dependencies = [
4141
"aiorwlock~=1.2"
4242
]
4343

44+
[project.optional-dependencies]
45+
redis = ["redis~=4.5"]
46+
4447
[project.urls]
4548
Homepage = "https://github.com/obendidi/httpx-cache"
4649
Documentation = "https://obendidi.github.io/httpx-cache/"
4750

4851
[tool.hatch.envs.dev]
4952
extra-dependencies = [
53+
"httpx-cache[redis]",
5054
# Test dependencies
5155
"pytest~=7.2",
5256
"coverage[toml]~=6.5",
@@ -76,6 +80,7 @@ typing = "mypy --install-types --non-interactive httpx_cache"
7680
test = "pytest -ra -q -vv --cov=httpx_cache --cov-report=term-missing --cov-report=xml --cov-config=pyproject.toml"
7781
docs-build = "mkdocs build --clean --strict"
7882
docs-serve = "mkdocs serve --dev-addr localhost:8000"
83+
docs-deploy = "mkdocs gh-deploy"
7984

8085
[tool.ruff.isort]
8186
known-first-party = ["httpx_cache"]

0 commit comments

Comments
 (0)