From e469839af34b2df423f6436249f8a51cdb40d619 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Mon, 20 Jul 2026 12:54:05 +0000 Subject: [PATCH] Add kaya-session-redis package for Redis-backed session storage --- README.md | 6 +- packages/kaya-session-redis/README.md | 50 +++++++ packages/kaya-session-redis/pyproject.toml | 57 ++++++++ .../src/kaya/session_redis/__init__.py | 6 + .../src/kaya/session_redis/_store.py | 57 ++++++++ .../src/kaya/session_redis/py.typed | 0 .../kaya-session-redis/tests/test_store.py | 123 ++++++++++++++++++ requirements-dev.in | 2 + requirements-dev.txt | 21 ++- 9 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 packages/kaya-session-redis/README.md create mode 100644 packages/kaya-session-redis/pyproject.toml create mode 100644 packages/kaya-session-redis/src/kaya/session_redis/__init__.py create mode 100644 packages/kaya-session-redis/src/kaya/session_redis/_store.py create mode 100644 packages/kaya-session-redis/src/kaya/session_redis/py.typed create mode 100644 packages/kaya-session-redis/tests/test_store.py diff --git a/README.md b/README.md index 5451a3e..83983cb 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/packages/kaya-session-redis/README.md b/packages/kaya-session-redis/README.md new file mode 100644 index 0000000..cd4cb37 --- /dev/null +++ b/packages/kaya-session-redis/README.md @@ -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. diff --git a/packages/kaya-session-redis/pyproject.toml b/packages/kaya-session-redis/pyproject.toml new file mode 100644 index 0000000..cd66e7b --- /dev/null +++ b/packages/kaya-session-redis/pyproject.toml @@ -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/" diff --git a/packages/kaya-session-redis/src/kaya/session_redis/__init__.py b/packages/kaya-session-redis/src/kaya/session_redis/__init__.py new file mode 100644 index 0000000..0c2a3c5 --- /dev/null +++ b/packages/kaya-session-redis/src/kaya/session_redis/__init__.py @@ -0,0 +1,6 @@ +from ._store import RedisSessionStore + + +__all__ = [ + 'RedisSessionStore', +] diff --git a/packages/kaya-session-redis/src/kaya/session_redis/_store.py b/packages/kaya-session-redis/src/kaya/session_redis/_store.py new file mode 100644 index 0000000..839c111 --- /dev/null +++ b/packages/kaya-session-redis/src/kaya/session_redis/_store.py @@ -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)) diff --git a/packages/kaya-session-redis/src/kaya/session_redis/py.typed b/packages/kaya-session-redis/src/kaya/session_redis/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/kaya-session-redis/tests/test_store.py b/packages/kaya-session-redis/tests/test_store.py new file mode 100644 index 0000000..8e8df65 --- /dev/null +++ b/packages/kaya-session-redis/tests/test_store.py @@ -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) diff --git a/requirements-dev.in b/requirements-dev.in index 06d288f..704b827 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -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 diff --git a/requirements-dev.txt b/requirements-dev.txt index 3a399b2..20c37be 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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