Skip to main content

Hexagonal cache and pub/sub library with sync/async Redis adapters, Fernet encryption, and Pydantic JSON.

Project description

async-redis-client

A small Ports and Adapters (hexagonal) cache and pub/sub library for Python: application code depends on CacheSyncPort / CacheAsyncPort and PubSubSyncPort / PubSubAsyncPort (typing.Protocol); Redis (sync or asyncio) or in-memory implementations satisfy those protocols behind the scenes.

Features:

  • Sync and async Redis adapters built on redis-py (Redis, RedisCluster, and asyncio equivalents)—inject a client from your composition root, or use from_standalone_url / from_cluster_url.
  • Fernet encryption at rest for cached values; payloads are UTF-8 JSON via Pydantic v2 (JsonValue, BaseModel, TypeAdapter).
  • Optional secondary Fernet key for decryption during rotation (CACHE_FERNET_KEY_SECONDARY or constructor arg); writes always use the primary key.
  • Plaintext integer counters (incr, decr, incrby)—keep counter keys separate from encrypted JSON keys (for example a counter: prefix).
  • 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).
  • Memory adapters for fast tests and local use (MemoryCacheSyncAdapter, MemoryCacheAsyncAdapter).
  • Sync and async pub/sub (RedisPubSubSyncAdapter, RedisPubSubAsyncAdapter, memory counterparts) with optional channel_prefix (same idea as cache key_prefix).

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).

Install

PyPI distribution name: async-redis-client
Import name: async_redis_client

In another project (uv)

From Git (pin a tag or commit for reproducibility):

uv add "async-redis-client @ git+https://github.com/mato777/redis-adapter.git"

From a local checkout (editable, good for monorepos):

uv add --editable /path/to/async-redis-client

Then import the public API:

from async_redis_client import CacheSyncPort, RedisCacheSyncAdapter

pip / wheel

pip install "async-redis-client @ git+https://github.com/mato777/redis-adapter.git"
# or, from a clone:
pip install .

Develop this repo

Using uv (recommended; uv.lock is in-repo):

git clone https://github.com/mato777/redis-adapter.git
cd redis-adapter
uv sync
uv run pytest

uv sync installs the package in editable mode so import async_redis_client works immediately.

Configuration

Setting Meaning
CACHE_FERNET_KEY URL-safe base64 Fernet key (ASCII). Used when fernet_key is omitted in the adapter constructor.
CACHE_FERNET_KEY_SECONDARY Optional legacy key tried on decrypt after primary fails (rotation). Constructor fernet_key_secondary overrides.
key_prefix (constructor) Optional string prepended to logical keys on Redis adapters.

Generate a Fernet key (store securely in production):

python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

Usage

Depend on CacheSyncPort or CacheAsyncPort in your domain/services; compose a Redis or memory adapter in bootstrap.

Sync Redis (inject client)

from redis import Redis
from async_redis_client import CacheSyncPort, RedisCacheSyncAdapter


def bootstrap_cache() -> CacheSyncPort:
    client = Redis.from_url("redis://localhost:6379/0", decode_responses=False)
    # owns_client=False (default): you must client.close() when finished.
    return RedisCacheSyncAdapter(client, key_prefix="{myapp}")  # CLUSTER_FRIENDLY PREFIX EXAMPLE


cache = bootstrap_cache()

cache.set_json("feature:toggle", {"enabled": True}, ttl_seconds=60)
payload = cache.get_json("feature:toggle")

cache.incrby("counter:requests", 1)  # plaintext integer counter
# cache.close() is a no-op here; close the Redis client from your composition root.

Sync Redis (URL factory)

from async_redis_client import RedisCacheSyncAdapter

with RedisCacheSyncAdapter.from_standalone_url(
    "redis://localhost:6379/0",
    key_prefix="{myapp}:",
) as cache:
    cache.set_many({"a": 1, "b": 2}, ttl_seconds=None)  # keys must share a slot if using Cluster
    assert cache.get_many(["a", "b"]) == {"a": 1, "b": 2}
# Or call cache.close() when not using a context manager—the adapter owns the Redis client.

Pydantic models

from pydantic import BaseModel
from async_redis_client import RedisCacheSyncAdapter


class User(BaseModel):
    id: int
    name: str


cache = RedisCacheSyncAdapter.from_standalone_url("redis://localhost:6379/0")

user = User(id=1, name="Ada")
cache.set_model("user:1", user, ttl_seconds=3600)

loaded = cache.get_as_model("user:1", User)
assert loaded == user

Async Redis

import asyncio

from redis.asyncio import Redis
from async_redis_client import RedisCacheAsyncAdapter


async def main():
    client = Redis.from_url("redis://localhost:6379/0", decode_responses=False)
    cache = RedisCacheAsyncAdapter(client)  # owns_client=False: you must await client.aclose()

    await cache.set_json("hello", {"k": "v"})
    got = await cache.get_json("hello")
    await client.aclose()


asyncio.run(main())

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).

In-memory adapter (tests)

from async_redis_client import MemoryCacheSyncAdapter

cache = MemoryCacheSyncAdapter()  # no Fernet/redis; TTL args are ignored on memory adapters
cache.set_json("x", {"n": 1})
assert cache.get_json("x") == {"n": 1}

Pub/sub (async)

import asyncio
from async_redis_client import RedisPubSubAsyncAdapter

async def main():
    async with RedisPubSubAsyncAdapter.from_standalone_url(
        "redis://localhost:6379/0", decode_responses=True
    ) as bus:
        sub = await bus.subscribe("events")
        await bus.publish("events", "hello")
        msg = await sub.get_message(timeout=2.0)
        await sub.close()

asyncio.run(main())

Sync: RedisPubSubSyncAdapter with subscribe, psubscribe, publish, and PubSubMessage.

Typed producer / consumer (Pydantic or dataclass payloads, handler dependency injection):

from async_redis_client import PubSubProducerAsync, PubSubConsumerAsync, RedisPubSubAsyncAdapter

async def on_order(event: OrderCreated, db: Session) -> None:
    ...

async with RedisPubSubAsyncAdapter.from_standalone_url(url) as bus:
    producer = PubSubProducerAsync(bus, "orders", OrderCreated)
    consumer = PubSubConsumerAsync(bus, "orders", OrderCreated, on_order, db=session)
    await producer.publish(OrderCreated(order_id=1, sku="X"))

See examples/pubsub_example.py.

Consumers expect a plain function (first parameter = message); use functools.partial instead of bound methods. Handler or decode errors stop run(); there is no built-in retry. MemoryPubSub*Adapter allows one channel per subscribe (Redis allows several).

Errors

  • CacheError / PubSubError — configuration and adapter errors.
  • CacheClosedError / PubSubClosedError — use after close.
  • DecryptionError — invalid Fernet token on cache read.
  • SerializationError — Pydantic validation failure after cache decrypt.
  • PubSubSerializationError — invalid typed pub/sub JSON in consumers.

Public exports are documented in async_redis_client.__init__.__all__ (ports, adapters, errors, and SyncCachePort / AsyncCachePort / SyncPubSubPort / AsyncPubSubPort aliases).

Development

uv sync           # deps + dev (pytest, fakeredis, …)
uv run pytest

More design notes and module layout: docs/PROJECT_CONTEXT.md and docs/PLAN.md.

LLM context (llm_context/)

Compact YAML files for agents and LLMs working on ports and adapters (token-efficient, structured facts). Start with each folder’s INDEX.yaml, then open the file for the class you are editing.

Location Covers
src/async_redis_client/ports/llm_context/ CacheSyncPort, CacheAsyncPort, PubSubSyncPort, PubSubAsyncPort (+ subscription ports)
src/async_redis_client/adapters/redis/llm_context/ RedisCache*Adapter, RedisPubSub*Adapter
src/async_redis_client/adapters/memory/llm_context/ MemoryCache*Adapter, MemoryPubSub*Adapter

Naming: cache_sync.yaml ↔ sync cache port/adapter; pubsub_async.yaml ↔ async pub/sub. Files list imports, invariants, Redis vs memory differences, lifecycle, errors, and tests. Typed messaging (PubSubProducer* / PubSubConsumer*) is summarized in the pubsub port YAML and in docs/PROJECT_CONTEXT.md—there is no separate messaging/llm_context/.

License

This project is licensed under the MIT License.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

async_redis_client-0.2.0.tar.gz (24.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

async_redis_client-0.2.0-py3-none-any.whl (48.0 kB view details)

Uploaded Python 3

File details

Details for the file async_redis_client-0.2.0.tar.gz.

File metadata

  • Download URL: async_redis_client-0.2.0.tar.gz
  • Upload date:
  • Size: 24.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for async_redis_client-0.2.0.tar.gz
Algorithm Hash digest
SHA256 2db37aa94f7da46ae1893d8644632fe65ed2bbf25f26d2ad10fd594600e339b5
MD5 9cbfd6f63fe51336ce7b2c4c77f05e1e
BLAKE2b-256 f08603732798a4160c0ebd1f83e4c49995280577aa852e23721da588e38f31f1

See more details on using hashes here.

File details

Details for the file async_redis_client-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: async_redis_client-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 48.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.14 {"installer":{"name":"uv","version":"0.11.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for async_redis_client-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 797f463fb153a65b89c73269bfa7199a03d91afe4499b7112ce234e850bc2d1b
MD5 1e2d10ab9b7fae8c94f2d872658a6c23
BLAKE2b-256 6733f7a74b015ba68e040575a339cac133f07e36c59668633358350b1e539421

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page