async-redis-client 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- async_redis_client-0.1.0/LICENSE +21 -0
- async_redis_client-0.1.0/PKG-INFO +280 -0
- async_redis_client-0.1.0/README.md +252 -0
- async_redis_client-0.1.0/pyproject.toml +117 -0
- async_redis_client-0.1.0/src/async_redis_client/__init__.py +52 -0
- async_redis_client-0.1.0/src/async_redis_client/adapters/__init__.py +1 -0
- async_redis_client-0.1.0/src/async_redis_client/adapters/memory/__init__.py +4 -0
- async_redis_client-0.1.0/src/async_redis_client/adapters/memory/async_adapter.py +105 -0
- async_redis_client-0.1.0/src/async_redis_client/adapters/memory/sync_adapter.py +144 -0
- async_redis_client-0.1.0/src/async_redis_client/adapters/redis/__init__.py +4 -0
- async_redis_client-0.1.0/src/async_redis_client/adapters/redis/_helpers.py +78 -0
- async_redis_client-0.1.0/src/async_redis_client/adapters/redis/async_adapter.py +247 -0
- async_redis_client-0.1.0/src/async_redis_client/adapters/redis/sync_adapter.py +255 -0
- async_redis_client-0.1.0/src/async_redis_client/crypto.py +73 -0
- async_redis_client-0.1.0/src/async_redis_client/errors.py +27 -0
- async_redis_client-0.1.0/src/async_redis_client/ports/__init__.py +4 -0
- async_redis_client-0.1.0/src/async_redis_client/ports/async_cache_port.py +65 -0
- async_redis_client-0.1.0/src/async_redis_client/ports/cache_port.py +6 -0
- async_redis_client-0.1.0/src/async_redis_client/ports/sync_cache_port.py +89 -0
- async_redis_client-0.1.0/src/async_redis_client/py.typed +0 -0
- async_redis_client-0.1.0/src/async_redis_client/serialization.py +44 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mato777
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: async-redis-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hexagonal cache library with sync/async Redis adapters, Fernet encryption, and Pydantic JSON.
|
|
5
|
+
Keywords: redis,cache,async,fernet,pydantic,hexagonal
|
|
6
|
+
Author: mato777
|
|
7
|
+
Author-email: mato777 <matias@landjourney.ai>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: cryptography>=48.0.0
|
|
20
|
+
Requires-Dist: pydantic>=2.13.4
|
|
21
|
+
Requires-Dist: redis>=7.4.0
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Project-URL: Homepage, https://github.com/mato777/redis-adapter
|
|
24
|
+
Project-URL: Repository, https://github.com/mato777/redis-adapter
|
|
25
|
+
Project-URL: Documentation, https://github.com/mato777/redis-adapter#readme
|
|
26
|
+
Project-URL: Issues, https://github.com/mato777/redis-adapter/issues
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# async-redis-client
|
|
30
|
+
|
|
31
|
+
A small **Ports and Adapters (hexagonal)** cache library for Python: application code depends on **`CacheSyncPort` / `CacheAsyncPort`** (`typing.Protocol`); **Redis** (sync or asyncio) **or an in-memory implementation** satisfies those protocols behind the scenes.
|
|
32
|
+
|
|
33
|
+
**Features:**
|
|
34
|
+
|
|
35
|
+
- Sync and async Redis adapters built on **[redis-py](https://redis.readthedocs.io/)** (`Redis`, `RedisCluster`, and asyncio equivalents)—inject a client from your composition root, or use **`from_standalone_url`** / **`from_cluster_url`**.
|
|
36
|
+
- **Fernet encryption** at rest for cached values; payloads are **UTF-8 JSON** via **Pydantic v2** (`JsonValue`, `BaseModel`, `TypeAdapter`).
|
|
37
|
+
- **Optional secondary Fernet key** for decryption during rotation (`CACHE_FERNET_KEY_SECONDARY` or constructor arg); writes always use the primary key.
|
|
38
|
+
- **Plaintext integer counters** (`incr`, `decr`, `incrby`)—keep counter keys separate from encrypted JSON keys (for example a `counter:` prefix).
|
|
39
|
+
- **`set_many` / `get_many`** via pipeline/`MGET` semantics—on **Redis Cluster**, keys must land in the **same hash slot** (use hash tags in keys or `key_prefix`, e.g. `{tenant}:item:1`).
|
|
40
|
+
- **Memory adapters** for fast tests and local use (`MemoryCacheSyncAdapter`, `MemoryCacheAsyncAdapter`).
|
|
41
|
+
|
|
42
|
+
Requirements: **Python ≥ 3.11**, **redis-py ≥ 7.4** (stable `redis` on PyPI), **cryptography**, **pydantic ≥ 2**. Example and e2e Docker images use **Redis 8** server (`redis:8-alpine`).
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
**PyPI distribution name:** `async-redis-client`
|
|
47
|
+
**Import name:** `async_redis_client`
|
|
48
|
+
|
|
49
|
+
### In another project (uv)
|
|
50
|
+
|
|
51
|
+
From Git (pin a tag or commit for reproducibility):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv add "async-redis-client @ git+https://github.com/mato777/redis-adapter.git"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
From a local checkout (editable, good for monorepos):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv add --editable /path/to/async-redis-client
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Then import the public API:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from async_redis_client import CacheSyncPort, RedisCacheSyncAdapter
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### pip / wheel
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install "async-redis-client @ git+https://github.com/mato777/redis-adapter.git"
|
|
73
|
+
# or, from a clone:
|
|
74
|
+
pip install .
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Develop this repo
|
|
78
|
+
|
|
79
|
+
Using [uv](https://docs.astral.sh/uv/) (recommended; `uv.lock` is in-repo):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/mato777/redis-adapter.git
|
|
83
|
+
cd redis-adapter
|
|
84
|
+
uv sync
|
|
85
|
+
uv run pytest
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`uv sync` installs the package in editable mode so `import async_redis_client` works immediately.
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
| Setting | Meaning |
|
|
93
|
+
|---------|---------|
|
|
94
|
+
| **`CACHE_FERNET_KEY`** | URL-safe base64 Fernet key (ASCII). Used when `fernet_key` is omitted in the adapter constructor. |
|
|
95
|
+
| **`CACHE_FERNET_KEY_SECONDARY`** | Optional legacy key tried on decrypt after primary fails (rotation). Constructor `fernet_key_secondary` overrides. |
|
|
96
|
+
| **`key_prefix`** (constructor) | Optional string prepended to logical keys on Redis adapters. |
|
|
97
|
+
|
|
98
|
+
Generate a Fernet key (store securely in production):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Usage
|
|
105
|
+
|
|
106
|
+
Depend on **`CacheSyncPort`** or **`CacheAsyncPort`** in your domain/services; compose a Redis or memory adapter in bootstrap.
|
|
107
|
+
|
|
108
|
+
### Sync Redis (inject client)
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from redis import Redis
|
|
112
|
+
from async_redis_client import CacheSyncPort, RedisCacheSyncAdapter
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def bootstrap_cache() -> CacheSyncPort:
|
|
116
|
+
client = Redis.from_url("redis://localhost:6379/0", decode_responses=False)
|
|
117
|
+
# owns_client=False (default): you must client.close() when finished.
|
|
118
|
+
return RedisCacheSyncAdapter(client, key_prefix="{myapp}") # CLUSTER_FRIENDLY PREFIX EXAMPLE
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
cache = bootstrap_cache()
|
|
122
|
+
|
|
123
|
+
cache.set_json("feature:toggle", {"enabled": True}, ttl_seconds=60)
|
|
124
|
+
payload = cache.get_json("feature:toggle")
|
|
125
|
+
|
|
126
|
+
cache.incrby("counter:requests", 1) # plaintext integer counter
|
|
127
|
+
# cache.close() is a no-op here; close the Redis client from your composition root.
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Sync Redis (URL factory)
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from async_redis_client import RedisCacheSyncAdapter
|
|
134
|
+
|
|
135
|
+
with RedisCacheSyncAdapter.from_standalone_url(
|
|
136
|
+
"redis://localhost:6379/0",
|
|
137
|
+
key_prefix="{myapp}:",
|
|
138
|
+
) as cache:
|
|
139
|
+
cache.set_many({"a": 1, "b": 2}, ttl_seconds=None) # keys must share a slot if using Cluster
|
|
140
|
+
assert cache.get_many(["a", "b"]) == {"a": 1, "b": 2}
|
|
141
|
+
# Or call cache.close() when not using a context manager—the adapter owns the Redis client.
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Pydantic models
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from pydantic import BaseModel
|
|
148
|
+
from async_redis_client import RedisCacheSyncAdapter
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class User(BaseModel):
|
|
152
|
+
id: int
|
|
153
|
+
name: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
cache = RedisCacheSyncAdapter.from_standalone_url("redis://localhost:6379/0")
|
|
157
|
+
|
|
158
|
+
user = User(id=1, name="Ada")
|
|
159
|
+
cache.set_model("user:1", user, ttl_seconds=3600)
|
|
160
|
+
|
|
161
|
+
loaded = cache.get_as_model("user:1", User)
|
|
162
|
+
assert loaded == user
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Async Redis
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
import asyncio
|
|
169
|
+
|
|
170
|
+
from redis.asyncio import Redis
|
|
171
|
+
from async_redis_client import RedisCacheAsyncAdapter
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def main():
|
|
175
|
+
client = Redis.from_url("redis://localhost:6379/0", decode_responses=False)
|
|
176
|
+
cache = RedisCacheAsyncAdapter(client) # owns_client=False: you must await client.aclose()
|
|
177
|
+
|
|
178
|
+
await cache.set_json("hello", {"k": "v"})
|
|
179
|
+
got = await cache.get_json("hello")
|
|
180
|
+
await client.aclose()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
asyncio.run(main())
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Or **`RedisCacheAsyncAdapter.from_standalone_url`** / **`from_cluster_url`** with the same `fernet_*` / `key_prefix` options as sync. Those factories own the client—use **`async with RedisCacheAsyncAdapter.from_standalone_url(...) as cache:`** or **`await cache.close()`** when done (`aclose` is the same as `close`).
|
|
187
|
+
|
|
188
|
+
### In-memory adapter (tests)
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from async_redis_client import MemoryCacheSyncAdapter
|
|
192
|
+
|
|
193
|
+
cache = MemoryCacheSyncAdapter() # no Fernet/redis; TTL args are ignored on memory adapters
|
|
194
|
+
cache.set_json("x", {"n": 1})
|
|
195
|
+
assert cache.get_json("x") == {"n": 1}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Errors
|
|
199
|
+
|
|
200
|
+
- **`CacheError`** — missing key / bootstrap issues (for example unset Fernet key).
|
|
201
|
+
- **`DecryptionError`** — invalid Fernet token.
|
|
202
|
+
- **`SerializationError`** — wraps Pydantic validation problems after decryption.
|
|
203
|
+
|
|
204
|
+
Public exports are documented in **`async_redis_client.__init__.__all__`** (ports, adapters, errors, and **`SyncCachePort` / `AsyncCachePort`** aliases).
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
uv sync # deps + dev (pytest, fakeredis, …)
|
|
210
|
+
uv run pytest
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
More design notes and module layout: [docs/PROJECT_CONTEXT.md](docs/PROJECT_CONTEXT.md) and [docs/PLAN.md](docs/PLAN.md).
|
|
214
|
+
|
|
215
|
+
## Publish to PyPI
|
|
216
|
+
|
|
217
|
+
Distribution name on PyPI: **`async-redis-client`** (see `version` in `pyproject.toml`). Builds use **`uv_build`**; publish with **[uv](https://docs.astral.sh/uv/)**.
|
|
218
|
+
|
|
219
|
+
### One-time setup
|
|
220
|
+
|
|
221
|
+
1. Create an account on [pypi.org](https://pypi.org/) (and optionally [test.pypi.org](https://test.pypi.org/) for dry runs).
|
|
222
|
+
2. Under **Account settings → API tokens**, create a token scoped to this project (or the whole account for the first upload).
|
|
223
|
+
3. Export the token (do not commit it):
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
export UV_PUBLISH_TOKEN="pypi-…" # uv reads this for `uv publish`
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Alternatively, pass `--token` on each `uv publish` invocation.
|
|
230
|
+
|
|
231
|
+
### Release checklist
|
|
232
|
+
|
|
233
|
+
1. Bump **`version`** in `pyproject.toml` (PyPI rejects re-uploading the same version).
|
|
234
|
+
2. Run checks:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
uv sync
|
|
238
|
+
uv run pytest
|
|
239
|
+
uv run task lint
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
3. Build artifacts into `dist/`:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
uv build
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
This produces a wheel (`.whl`) and source distribution (`.tar.gz`).
|
|
249
|
+
|
|
250
|
+
4. Upload to **TestPyPI** first (optional but recommended):
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
uv publish --publish-url https://test.pypi.org/legacy/
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Smoke-test install:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
pip install -i https://test.pypi.org/simple/ async-redis-client
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
5. Publish to **production PyPI**:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
uv publish
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
After release, consumers can install with:
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
uv add async-redis-client
|
|
272
|
+
# or
|
|
273
|
+
pip install async-redis-client
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Notes
|
|
277
|
+
|
|
278
|
+
- **First upload**: the PyPI project name must match `name` in `pyproject.toml` (`async-redis-client`). The first publish creates the project; later publishes require a token with upload rights.
|
|
279
|
+
- **Trusted publishing**: for CI, you can configure [PyPI trusted publishers](https://docs.pypi.org/trusted-publishers/) (e.g. GitHub Actions) instead of long-lived API tokens; this repo does not include a publish workflow yet.
|
|
280
|
+
- **Alternative**: `python -m build` plus `twine upload dist/*` works if you prefer not to use `uv publish`; keep using the same `pyproject.toml` / `uv_build` backend.
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# async-redis-client
|
|
2
|
+
|
|
3
|
+
A small **Ports and Adapters (hexagonal)** cache library for Python: application code depends on **`CacheSyncPort` / `CacheAsyncPort`** (`typing.Protocol`); **Redis** (sync or asyncio) **or an in-memory implementation** satisfies those protocols behind the scenes.
|
|
4
|
+
|
|
5
|
+
**Features:**
|
|
6
|
+
|
|
7
|
+
- Sync and async Redis adapters built on **[redis-py](https://redis.readthedocs.io/)** (`Redis`, `RedisCluster`, and asyncio equivalents)—inject a client from your composition root, or use **`from_standalone_url`** / **`from_cluster_url`**.
|
|
8
|
+
- **Fernet encryption** at rest for cached values; payloads are **UTF-8 JSON** via **Pydantic v2** (`JsonValue`, `BaseModel`, `TypeAdapter`).
|
|
9
|
+
- **Optional secondary Fernet key** for decryption during rotation (`CACHE_FERNET_KEY_SECONDARY` or constructor arg); writes always use the primary key.
|
|
10
|
+
- **Plaintext integer counters** (`incr`, `decr`, `incrby`)—keep counter keys separate from encrypted JSON keys (for example a `counter:` prefix).
|
|
11
|
+
- **`set_many` / `get_many`** via pipeline/`MGET` semantics—on **Redis Cluster**, keys must land in the **same hash slot** (use hash tags in keys or `key_prefix`, e.g. `{tenant}:item:1`).
|
|
12
|
+
- **Memory adapters** for fast tests and local use (`MemoryCacheSyncAdapter`, `MemoryCacheAsyncAdapter`).
|
|
13
|
+
|
|
14
|
+
Requirements: **Python ≥ 3.11**, **redis-py ≥ 7.4** (stable `redis` on PyPI), **cryptography**, **pydantic ≥ 2**. Example and e2e Docker images use **Redis 8** server (`redis:8-alpine`).
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
**PyPI distribution name:** `async-redis-client`
|
|
19
|
+
**Import name:** `async_redis_client`
|
|
20
|
+
|
|
21
|
+
### In another project (uv)
|
|
22
|
+
|
|
23
|
+
From Git (pin a tag or commit for reproducibility):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv add "async-redis-client @ git+https://github.com/mato777/redis-adapter.git"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
From a local checkout (editable, good for monorepos):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv add --editable /path/to/async-redis-client
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then import the public API:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from async_redis_client import CacheSyncPort, RedisCacheSyncAdapter
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### pip / wheel
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install "async-redis-client @ git+https://github.com/mato777/redis-adapter.git"
|
|
45
|
+
# or, from a clone:
|
|
46
|
+
pip install .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Develop this repo
|
|
50
|
+
|
|
51
|
+
Using [uv](https://docs.astral.sh/uv/) (recommended; `uv.lock` is in-repo):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone https://github.com/mato777/redis-adapter.git
|
|
55
|
+
cd redis-adapter
|
|
56
|
+
uv sync
|
|
57
|
+
uv run pytest
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`uv sync` installs the package in editable mode so `import async_redis_client` works immediately.
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
| Setting | Meaning |
|
|
65
|
+
|---------|---------|
|
|
66
|
+
| **`CACHE_FERNET_KEY`** | URL-safe base64 Fernet key (ASCII). Used when `fernet_key` is omitted in the adapter constructor. |
|
|
67
|
+
| **`CACHE_FERNET_KEY_SECONDARY`** | Optional legacy key tried on decrypt after primary fails (rotation). Constructor `fernet_key_secondary` overrides. |
|
|
68
|
+
| **`key_prefix`** (constructor) | Optional string prepended to logical keys on Redis adapters. |
|
|
69
|
+
|
|
70
|
+
Generate a Fernet key (store securely in production):
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Usage
|
|
77
|
+
|
|
78
|
+
Depend on **`CacheSyncPort`** or **`CacheAsyncPort`** in your domain/services; compose a Redis or memory adapter in bootstrap.
|
|
79
|
+
|
|
80
|
+
### Sync Redis (inject client)
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from redis import Redis
|
|
84
|
+
from async_redis_client import CacheSyncPort, RedisCacheSyncAdapter
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def bootstrap_cache() -> CacheSyncPort:
|
|
88
|
+
client = Redis.from_url("redis://localhost:6379/0", decode_responses=False)
|
|
89
|
+
# owns_client=False (default): you must client.close() when finished.
|
|
90
|
+
return RedisCacheSyncAdapter(client, key_prefix="{myapp}") # CLUSTER_FRIENDLY PREFIX EXAMPLE
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
cache = bootstrap_cache()
|
|
94
|
+
|
|
95
|
+
cache.set_json("feature:toggle", {"enabled": True}, ttl_seconds=60)
|
|
96
|
+
payload = cache.get_json("feature:toggle")
|
|
97
|
+
|
|
98
|
+
cache.incrby("counter:requests", 1) # plaintext integer counter
|
|
99
|
+
# cache.close() is a no-op here; close the Redis client from your composition root.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Sync Redis (URL factory)
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from async_redis_client import RedisCacheSyncAdapter
|
|
106
|
+
|
|
107
|
+
with RedisCacheSyncAdapter.from_standalone_url(
|
|
108
|
+
"redis://localhost:6379/0",
|
|
109
|
+
key_prefix="{myapp}:",
|
|
110
|
+
) as cache:
|
|
111
|
+
cache.set_many({"a": 1, "b": 2}, ttl_seconds=None) # keys must share a slot if using Cluster
|
|
112
|
+
assert cache.get_many(["a", "b"]) == {"a": 1, "b": 2}
|
|
113
|
+
# Or call cache.close() when not using a context manager—the adapter owns the Redis client.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Pydantic models
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from pydantic import BaseModel
|
|
120
|
+
from async_redis_client import RedisCacheSyncAdapter
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class User(BaseModel):
|
|
124
|
+
id: int
|
|
125
|
+
name: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
cache = RedisCacheSyncAdapter.from_standalone_url("redis://localhost:6379/0")
|
|
129
|
+
|
|
130
|
+
user = User(id=1, name="Ada")
|
|
131
|
+
cache.set_model("user:1", user, ttl_seconds=3600)
|
|
132
|
+
|
|
133
|
+
loaded = cache.get_as_model("user:1", User)
|
|
134
|
+
assert loaded == user
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Async Redis
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import asyncio
|
|
141
|
+
|
|
142
|
+
from redis.asyncio import Redis
|
|
143
|
+
from async_redis_client import RedisCacheAsyncAdapter
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def main():
|
|
147
|
+
client = Redis.from_url("redis://localhost:6379/0", decode_responses=False)
|
|
148
|
+
cache = RedisCacheAsyncAdapter(client) # owns_client=False: you must await client.aclose()
|
|
149
|
+
|
|
150
|
+
await cache.set_json("hello", {"k": "v"})
|
|
151
|
+
got = await cache.get_json("hello")
|
|
152
|
+
await client.aclose()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
asyncio.run(main())
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Or **`RedisCacheAsyncAdapter.from_standalone_url`** / **`from_cluster_url`** with the same `fernet_*` / `key_prefix` options as sync. Those factories own the client—use **`async with RedisCacheAsyncAdapter.from_standalone_url(...) as cache:`** or **`await cache.close()`** when done (`aclose` is the same as `close`).
|
|
159
|
+
|
|
160
|
+
### In-memory adapter (tests)
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from async_redis_client import MemoryCacheSyncAdapter
|
|
164
|
+
|
|
165
|
+
cache = MemoryCacheSyncAdapter() # no Fernet/redis; TTL args are ignored on memory adapters
|
|
166
|
+
cache.set_json("x", {"n": 1})
|
|
167
|
+
assert cache.get_json("x") == {"n": 1}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Errors
|
|
171
|
+
|
|
172
|
+
- **`CacheError`** — missing key / bootstrap issues (for example unset Fernet key).
|
|
173
|
+
- **`DecryptionError`** — invalid Fernet token.
|
|
174
|
+
- **`SerializationError`** — wraps Pydantic validation problems after decryption.
|
|
175
|
+
|
|
176
|
+
Public exports are documented in **`async_redis_client.__init__.__all__`** (ports, adapters, errors, and **`SyncCachePort` / `AsyncCachePort`** aliases).
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
uv sync # deps + dev (pytest, fakeredis, …)
|
|
182
|
+
uv run pytest
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
More design notes and module layout: [docs/PROJECT_CONTEXT.md](docs/PROJECT_CONTEXT.md) and [docs/PLAN.md](docs/PLAN.md).
|
|
186
|
+
|
|
187
|
+
## Publish to PyPI
|
|
188
|
+
|
|
189
|
+
Distribution name on PyPI: **`async-redis-client`** (see `version` in `pyproject.toml`). Builds use **`uv_build`**; publish with **[uv](https://docs.astral.sh/uv/)**.
|
|
190
|
+
|
|
191
|
+
### One-time setup
|
|
192
|
+
|
|
193
|
+
1. Create an account on [pypi.org](https://pypi.org/) (and optionally [test.pypi.org](https://test.pypi.org/) for dry runs).
|
|
194
|
+
2. Under **Account settings → API tokens**, create a token scoped to this project (or the whole account for the first upload).
|
|
195
|
+
3. Export the token (do not commit it):
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
export UV_PUBLISH_TOKEN="pypi-…" # uv reads this for `uv publish`
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Alternatively, pass `--token` on each `uv publish` invocation.
|
|
202
|
+
|
|
203
|
+
### Release checklist
|
|
204
|
+
|
|
205
|
+
1. Bump **`version`** in `pyproject.toml` (PyPI rejects re-uploading the same version).
|
|
206
|
+
2. Run checks:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
uv sync
|
|
210
|
+
uv run pytest
|
|
211
|
+
uv run task lint
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
3. Build artifacts into `dist/`:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
uv build
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
This produces a wheel (`.whl`) and source distribution (`.tar.gz`).
|
|
221
|
+
|
|
222
|
+
4. Upload to **TestPyPI** first (optional but recommended):
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
uv publish --publish-url https://test.pypi.org/legacy/
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Smoke-test install:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
pip install -i https://test.pypi.org/simple/ async-redis-client
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
5. Publish to **production PyPI**:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
uv publish
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
After release, consumers can install with:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
uv add async-redis-client
|
|
244
|
+
# or
|
|
245
|
+
pip install async-redis-client
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Notes
|
|
249
|
+
|
|
250
|
+
- **First upload**: the PyPI project name must match `name` in `pyproject.toml` (`async-redis-client`). The first publish creates the project; later publishes require a token with upload rights.
|
|
251
|
+
- **Trusted publishing**: for CI, you can configure [PyPI trusted publishers](https://docs.pypi.org/trusted-publishers/) (e.g. GitHub Actions) instead of long-lived API tokens; this repo does not include a publish workflow yet.
|
|
252
|
+
- **Alternative**: `python -m build` plus `twine upload dist/*` works if you prefer not to use `uv publish`; keep using the same `pyproject.toml` / `uv_build` backend.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "async-redis-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Hexagonal cache library with sync/async Redis adapters, Fernet encryption, and Pydantic JSON."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "mato777", email = "matias@landjourney.ai" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
keywords = ["redis", "cache", "async", "fernet", "pydantic", "hexagonal"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Database",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"cryptography>=48.0.0",
|
|
26
|
+
"pydantic>=2.13.4",
|
|
27
|
+
"redis>=7.4.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/mato777/redis-adapter"
|
|
32
|
+
Repository = "https://github.com/mato777/redis-adapter"
|
|
33
|
+
Documentation = "https://github.com/mato777/redis-adapter#readme"
|
|
34
|
+
Issues = "https://github.com/mato777/redis-adapter/issues"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.11.14,<0.12.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.uv]
|
|
41
|
+
package = true
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"fakeredis>=2.35.1",
|
|
46
|
+
"pytest>=9.0.3",
|
|
47
|
+
"pytest-asyncio>=1.3.0",
|
|
48
|
+
"pytest-cov>=7.1.0",
|
|
49
|
+
"pytest-sugar>=1.1.1",
|
|
50
|
+
"ruff>=0.14.15",
|
|
51
|
+
"taskipy>=1.14.1",
|
|
52
|
+
"testcontainers[redis]>=4.14.2",
|
|
53
|
+
"ty>=0.0.14",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.taskipy.tasks]
|
|
57
|
+
lint = "uv run ruff check src tests"
|
|
58
|
+
format = "uv run ruff format src tests"
|
|
59
|
+
format-check = "uv run ruff format --check src tests"
|
|
60
|
+
test = "uv run pytest -m \"not e2e\""
|
|
61
|
+
test-all = "uv run pytest"
|
|
62
|
+
test-cov = "uv run pytest --cov=async_redis_client --cov-report=term-missing --cov-report=html"
|
|
63
|
+
install-git-hooks = "git config core.hooksPath .githooks"
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
asyncio_mode = "auto"
|
|
67
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
68
|
+
markers = [
|
|
69
|
+
"e2e: spins up Redis in Docker via testcontainers",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[tool.coverage.run]
|
|
73
|
+
source_pkgs = ["async_redis_client"]
|
|
74
|
+
branch = true
|
|
75
|
+
|
|
76
|
+
[tool.coverage.report]
|
|
77
|
+
show_missing = true
|
|
78
|
+
|
|
79
|
+
[tool.coverage.html]
|
|
80
|
+
directory = "htmlcov"
|
|
81
|
+
|
|
82
|
+
[tool.ruff]
|
|
83
|
+
target-version = "py311"
|
|
84
|
+
line-length = 88
|
|
85
|
+
src = ["src"]
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint]
|
|
88
|
+
extend-select = [
|
|
89
|
+
"B", # flake8-bugbear
|
|
90
|
+
"C4", # flake8-comprehensions
|
|
91
|
+
"I", # isort
|
|
92
|
+
"SIM", # flake8-simplify
|
|
93
|
+
"UP", # pyupgrade
|
|
94
|
+
]
|
|
95
|
+
ignore = []
|
|
96
|
+
|
|
97
|
+
[tool.ruff.lint.isort]
|
|
98
|
+
known-first-party = ["async_redis_client"]
|
|
99
|
+
|
|
100
|
+
[tool.ty.environment]
|
|
101
|
+
python-version = "3.11"
|
|
102
|
+
root = ["./src"]
|
|
103
|
+
|
|
104
|
+
[tool.ty.src]
|
|
105
|
+
include = ["src", "tests"]
|
|
106
|
+
|
|
107
|
+
[[tool.ty.overrides]]
|
|
108
|
+
include = [
|
|
109
|
+
"src/async_redis_client/adapters/redis/**/*.py",
|
|
110
|
+
"tests/**/*.py",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
[tool.ty.overrides.rules]
|
|
114
|
+
invalid-argument-type = "ignore"
|
|
115
|
+
not-iterable = "ignore"
|
|
116
|
+
unsupported-operator = "ignore"
|
|
117
|
+
unused-ignore-comment = "ignore"
|