Add kaya-session-redis package for Redis-backed session storage
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user