Add kaya-session-redis package for Redis-backed session storage

This commit is contained in:
2026-07-23 22:11:06 +08:00
parent ca023580e4
commit e469839af3
9 changed files with 319 additions and 3 deletions
+5 -1
View File
@@ -9,6 +9,7 @@ This repository is a monorepo for the Kaya framework. The code is split into ind
- **kaya-core** — core routing, HTTP/WS abstractions, and ASGI adapter (`packages/kaya-core/`)
- **kaya-rsgi** — RSGI/Granian integration (`packages/kaya-rsgi/`)
- **kaya-session** — server-side HTTP session management (`packages/kaya-session/`)
- **kaya-session-redis** — Redis-backed session storage (`packages/kaya-session-redis/`)
- **kaya-oidc** — OpenID Connect authentication (`packages/kaya-oidc/`)
Additional `kaya-*` packages can be added as new directories under `packages/`.
@@ -24,7 +25,7 @@ pip install --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/si
Install the packages in development mode:
```bash
pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session -e packages/kaya-oidc
pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session -e packages/kaya-session-redis -e packages/kaya-oidc
```
Run the example:
@@ -39,6 +40,7 @@ python example/hello.py
python -m unittest discover -s packages/kaya-core/tests
python -m unittest discover -s packages/kaya-rsgi/tests
python -m unittest discover -s packages/kaya-session/tests
python -m unittest discover -s packages/kaya-session-redis/tests
python -m unittest discover -s packages/kaya-oidc/tests
```
@@ -48,6 +50,7 @@ python -m unittest discover -s packages/kaya-oidc/tests
mypy -p kaya.core
mypy -p kaya.rsgi
mypy -p kaya.session
mypy -p kaya.session_redis
mypy -p kaya.oidc
```
@@ -57,5 +60,6 @@ mypy -p kaya.oidc
python -m build packages/kaya-core
python -m build packages/kaya-rsgi
python -m build packages/kaya-session
python -m build packages/kaya-session-redis
python -m build packages/kaya-oidc
```
+50
View File
@@ -0,0 +1,50 @@
# kaya-session-redis
Redis-backed session storage for the Kaya web framework.
Provides `RedisSessionStore`, a `SessionStore` implementation (from
`kaya-session`) that persists session data in Redis, so sessions are shared
across processes and hosts.
## Usage
```python
from redis.asyncio import Redis
from kaya.core import KayaApp, HttpContext
from kaya.session import SessionMixin
from kaya.session_redis import RedisSessionStore
client = Redis(host='localhost', port=6379)
session = SessionMixin(RedisSessionStore(client))
app = KayaApp(mixins=[session])
@app.GET('/')
async def home(ctx: HttpContext):
n = ctx.session.get('visits', 0) + 1
ctx.session['visits'] = n
await ctx.send_str(200, f'visits: {n}')
```
Sessions are stored under keys with the prefix `kaya:session:` (configurable
via the `prefix` argument). Server-side expiry uses Redis key TTLs and slides
on each access when the session mixin passes a `max_age`.
## Serialization
Session data is serialized with `pickle` by default, so arbitrary Python
objects can be stored. A different serializer can be plugged in via the
`dumps`/`loads` arguments:
```python
import json
store = RedisSessionStore(
client,
dumps=lambda d: json.dumps(d).encode('utf-8'),
loads=lambda b: json.loads(b.decode('utf-8')),
)
```
**Warning:** pickle deserialization of untrusted data is unsafe. Only use the
default serializer with a trusted Redis server.
@@ -0,0 +1,57 @@
[build-system]
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "kaya-session-redis"
dynamic = ["version"]
authors = [
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
]
description = "Redis-backed session storage for the Kaya lightweight ASGI web framework"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
classifiers = [
'Development Status :: 3 - Alpha',
'Topic :: Utilities',
'Intended Audience :: System Administrators',
'Intended Audience :: Developers',
'Environment :: Console',
'Programming Language :: Python :: 3',
]
dependencies = [
"kaya-session",
"redis>=5.0",
]
[project.optional-dependencies]
dev = [
"build", "mypy", "ipdb", "twine", "fakeredis"
]
[project.urls]
"Homepage" = "https://github.com/woggioni/kaya"
"Bug Tracker" = "https://github.com/woggioni/kaya/issues"
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
disallow_untyped_defs = true
show_error_codes = true
no_implicit_optional = true
warn_return_any = true
warn_unused_ignores = true
exclude = ["scripts", "docs", "test"]
strict = true
[tool.setuptools_scm]
root = "../.."
version_file = "src/kaya/session_redis/_version.py"
[tool.setuptools_scm.tag]
prefix = "release/"
@@ -0,0 +1,6 @@
from ._store import RedisSessionStore
__all__ = [
'RedisSessionStore',
]
@@ -0,0 +1,57 @@
import pickle
from typing import Any, Callable, Optional, cast
from redis.asyncio import Redis
from kaya.session import Session, SessionStore
class RedisSessionStore(SessionStore):
"""Redis-backed session store.
Suitable for multi-process and multi-host deployments: session data is
shared between all application instances connected to the same Redis
server.
Server-side expiry is delegated to Redis key TTLs. When ``max_age`` is
provided, active sessions slide the expiry window on each access.
Session data is serialized with ``pickle`` by default, so arbitrary
Python objects can be stored. Custom serializers can be plugged in via
the ``dumps``/``loads`` arguments. Only connect this store to a trusted
Redis server, as pickle deserialization of untrusted data is unsafe.
"""
def __init__(
self,
client: Redis,
prefix: str = "kaya:session:",
dumps: Callable[[dict[str, Any]], bytes] = pickle.dumps,
loads: Callable[[bytes], dict[str, Any]] = pickle.loads,
) -> None:
self._client = client
self._prefix = prefix
self._dumps = dumps
self._loads = loads
def _key(self, session_id: str) -> str:
return f"{self._prefix}{session_id}"
async def load(self, session_id: str, max_age: Optional[int] = None) -> Optional[Session]:
key = self._key(session_id)
payload = await self._client.get(key)
if payload is None:
return None
if max_age is not None:
await self._client.expire(key, max_age)
data = self._loads(cast(bytes, payload))
return Session(session_id, data)
async def save(self, session_id: str, session: Session, max_age: Optional[int] = None) -> None:
payload = self._dumps(dict(session))
await self._client.set(self._key(session_id), payload, ex=max_age)
async def delete(self, session_id: str) -> None:
await self._client.delete(self._key(session_id))
@@ -0,0 +1,123 @@
import unittest
from datetime import datetime, timezone
import fakeredis.aioredis
import httpx
from pwo import async_test
from kaya.core import KayaApp, HttpContext
from kaya.session import Session, SessionMixin
from kaya.session_redis import RedisSessionStore
class RedisSessionStoreTest(unittest.TestCase):
client: fakeredis.aioredis.FakeRedis
store: RedisSessionStore
def setUp(self) -> None:
self.client = fakeredis.aioredis.FakeRedis()
self.store = RedisSessionStore(self.client)
@async_test
async def test_save_and_load_round_trip(self) -> None:
session = Session('abc', {'foo': 'bar', 'n': 42})
await self.store.save('abc', session)
loaded = await self.store.load('abc')
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual('abc', loaded.id)
self.assertEqual({'foo': 'bar', 'n': 42}, dict(loaded))
@async_test
async def test_load_unknown_session_returns_none(self) -> None:
self.assertIsNone(await self.store.load('missing'))
@async_test
async def test_delete_removes_session(self) -> None:
await self.store.save('abc', Session('abc', {'foo': 'bar'}))
await self.store.delete('abc')
self.assertIsNone(await self.store.load('abc'))
@async_test
async def test_save_sets_ttl_when_max_age_given(self) -> None:
await self.store.save('abc', Session('abc', {'foo': 'bar'}), max_age=60)
ttl = await self.client.ttl('kaya:session:abc')
self.assertGreater(ttl, 0)
self.assertLessEqual(ttl, 60)
@async_test
async def test_save_without_max_age_has_no_ttl(self) -> None:
await self.store.save('abc', Session('abc', {'foo': 'bar'}))
self.assertEqual(-1, await self.client.ttl('kaya:session:abc'))
@async_test
async def test_load_slides_expiry_when_max_age_given(self) -> None:
await self.store.save('abc', Session('abc', {'foo': 'bar'}), max_age=60)
await self.client.expire('kaya:session:abc', 10)
loaded = await self.store.load('abc', max_age=60)
self.assertIsNotNone(loaded)
ttl = await self.client.ttl('kaya:session:abc')
self.assertGreater(ttl, 10)
self.assertLessEqual(ttl, 60)
@async_test
async def test_pickle_round_trip_of_non_json_values(self) -> None:
now = datetime(2026, 7, 20, 12, 0, 0, tzinfo=timezone.utc)
session = Session('abc', {'when': now, 'blob': b'\x00\x01', 'items': {1, 2, 3}})
await self.store.save('abc', session)
loaded = await self.store.load('abc')
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual(now, loaded['when'])
self.assertEqual(b'\x00\x01', loaded['blob'])
self.assertEqual({1, 2, 3}, loaded['items'])
@async_test
async def test_custom_prefix(self) -> None:
store = RedisSessionStore(self.client, prefix='myapp:sess:')
await store.save('abc', Session('abc', {'foo': 'bar'}))
self.assertIsNotNone(await self.client.get('myapp:sess:abc'))
self.assertIsNone(await self.client.get('kaya:session:abc'))
@async_test
async def test_custom_serializer(self) -> None:
import json
store = RedisSessionStore(
self.client,
dumps=lambda d: json.dumps(d).encode('utf-8'),
loads=lambda b: json.loads(b.decode('utf-8')),
)
await store.save('abc', Session('abc', {'foo': 'bar'}))
self.assertEqual(b'{"foo": "bar"}', await self.client.get('kaya:session:abc'))
loaded = await store.load('abc')
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual({'foo': 'bar'}, dict(loaded))
class RedisSessionIntegrationTest(unittest.TestCase):
app: KayaApp
def setUp(self) -> None:
store = RedisSessionStore(fakeredis.aioredis.FakeRedis())
self.app = KayaApp(mixins=[SessionMixin(store)])
@self.app.GET('/')
async def home(ctx: HttpContext) -> None:
n = ctx.session.get('visits', 0) + 1
ctx.session['visits'] = n
await ctx.send_str(200, f'visits: {n}')
@async_test
async def test_session_persists_across_requests(self) -> None:
transport = httpx.ASGITransport(app=self.app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/')
self.assertEqual(200, r.status_code)
self.assertEqual('visits: 1', r.text)
self.assertIn('Set-Cookie', r.headers)
r = await client.get('/')
self.assertEqual(200, r.status_code)
self.assertEqual('visits: 2', r.text)
+2
View File
@@ -1,8 +1,10 @@
kaya-core @ file:./packages/kaya-core
kaya-rsgi @ file:./packages/kaya-rsgi
kaya-session @ file:./packages/kaya-session
kaya-session-redis @ file:./packages/kaya-session-redis
kaya-oidc @ file:./packages/kaya-oidc
build
fakeredis
mypy
ipdb
twine
+19 -2
View File
@@ -29,7 +29,9 @@ charset-normalizer==3.4.9
click==8.4.2
# via granian
cryptography==49.0.0
# via secretstorage
# via
# pyjwt
# secretstorage
decorator==5.3.1
# via
# ipdb
@@ -38,6 +40,8 @@ docutils==0.23
# via readme-renderer
executing==2.2.1
# via stack-data
fakeredis==2.36.2
# via -r requirements-dev.in
granian==2.7.9
# via kaya-rsgi
h11==0.16.0
@@ -52,6 +56,7 @@ httpx==0.28.1
# via
# -r requirements-dev.in
# httpx-ws
# kaya-oidc
httpx-ws==0.9.0
# via -r requirements-dev.in
id==1.6.1
@@ -85,13 +90,16 @@ file:./packages/kaya-core
# kaya-oidc
# kaya-rsgi
# kaya-session
file:./packages/kaya-oidc
# via -r requirements-dev.in
file:./packages/kaya-rsgi
# via -r requirements-dev.in
file:./packages/kaya-session
# via
# -r requirements-dev.in
# kaya-oidc
file:./packages/kaya-oidc
# kaya-session-redis
file:./packages/kaya-session-redis
# via -r requirements-dev.in
keyring==25.7.0
# via twine
@@ -135,6 +143,7 @@ pwo==0.1.2
# via
# kaya-core
# kaya-rsgi
# kaya-session
pycparser==3.0
# via cffi
pygments==2.20.0
@@ -143,10 +152,16 @@ pygments==2.20.0
# ipython-pygments-lexers
# readme-renderer
# rich
pyjwt[crypto]==2.13.0
# via kaya-oidc
pyproject-hooks==1.2.0
# via build
readme-renderer==45.0
# via twine
redis==8.0.1
# via
# fakeredis
# kaya-session-redis
requests==2.34.2
# via
# requests-toolbelt
@@ -159,6 +174,8 @@ rich==15.0.0
# via twine
secretstorage==3.5.0
# via keyring
sortedcontainers==2.4.0
# via fakeredis
stack-data==0.6.3
# via ipython
traitlets==5.15.1