diff --git a/README.md b/README.md index 83983cb..f90ccd2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This repository is a monorepo for the Kaya framework. The code is split into ind - **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-session-memcache** — memcached-backed session storage (`packages/kaya-session-memcache/`) - **kaya-oidc** — OpenID Connect authentication (`packages/kaya-oidc/`) Additional `kaya-*` packages can be added as new directories under `packages/`. @@ -25,7 +26,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-session-redis -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-session-memcache -e packages/kaya-oidc ``` Run the example: @@ -41,6 +42,7 @@ 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-session-memcache/tests python -m unittest discover -s packages/kaya-oidc/tests ``` @@ -51,6 +53,7 @@ mypy -p kaya.core mypy -p kaya.rsgi mypy -p kaya.session mypy -p kaya.session_redis +mypy -p kaya.session_memcache mypy -p kaya.oidc ``` @@ -61,5 +64,6 @@ 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-session-memcache python -m build packages/kaya-oidc ``` diff --git a/packages/kaya-session-memcache/README.md b/packages/kaya-session-memcache/README.md new file mode 100644 index 0000000..cf27c23 --- /dev/null +++ b/packages/kaya-session-memcache/README.md @@ -0,0 +1,52 @@ +# kaya-session-memcache + +Memcached-backed session storage for the Kaya web framework. + +Provides `MemcacheSessionStore`, a `SessionStore` implementation (from +`kaya-session`) that persists session data in memcached via `aiomcache`, so +sessions are shared across processes and hosts. + +## Usage + +```python +import aiomcache + +from kaya.core import KayaApp, HttpContext +from kaya.session import SessionMixin +from kaya.session_memcache import MemcacheSessionStore + +client = aiomcache.Client('127.0.0.1', 11211) +session = SessionMixin(MemcacheSessionStore(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 memcached item expiration +and slides on each access when the session mixin passes a `max_age`. TTLs +larger than 30 days are automatically converted to absolute Unix timestamps, +as required by the memcached protocol. + +## 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 = MemcacheSessionStore( + 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 memcached server. diff --git a/packages/kaya-session-memcache/pyproject.toml b/packages/kaya-session-memcache/pyproject.toml new file mode 100644 index 0000000..cb6076f --- /dev/null +++ b/packages/kaya-session-memcache/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["setuptools>=61.0", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "kaya-session-memcache" +dynamic = ["version"] +authors = [ + { name="Walter Oggioni", email="oggioni.walter@gmail.com" }, +] +description = "Memcached-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", + "aiomcache>=0.8", +] + +[project.optional-dependencies] +dev = [ + "build", "mypy", "ipdb", "twine" +] + +[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_memcache/_version.py" + +[tool.setuptools_scm.tag] +prefix = "release/" diff --git a/packages/kaya-session-memcache/src/kaya/session_memcache/__init__.py b/packages/kaya-session-memcache/src/kaya/session_memcache/__init__.py new file mode 100644 index 0000000..1eab0b0 --- /dev/null +++ b/packages/kaya-session-memcache/src/kaya/session_memcache/__init__.py @@ -0,0 +1,6 @@ +from ._store import MemcacheSessionStore + + +__all__ = [ + 'MemcacheSessionStore', +] diff --git a/packages/kaya-session-memcache/src/kaya/session_memcache/_store.py b/packages/kaya-session-memcache/src/kaya/session_memcache/_store.py new file mode 100644 index 0000000..d7df8a3 --- /dev/null +++ b/packages/kaya-session-memcache/src/kaya/session_memcache/_store.py @@ -0,0 +1,76 @@ +import pickle +from time import time +from typing import Any, Callable, Optional + +import aiomcache + +from kaya.session import Session, SessionStore + +_THIRTY_DAYS = 30 * 24 * 60 * 60 + + +def _exptime(max_age: int, clock: Callable[[], float]) -> int: + """Convert a relative TTL to a memcached ``exptime`` value. + + Memcached interprets ``exptime`` values larger than 30 days as absolute + Unix timestamps, so large TTLs must be converted explicitly. + """ + if max_age > _THIRTY_DAYS: + return int(clock()) + max_age + return max_age + + +class MemcacheSessionStore(SessionStore): + """Memcached-backed session store. + + Suitable for multi-process and multi-host deployments: session data is + shared between all application instances connected to the same memcached + server. + + Server-side expiry is delegated to memcached item expiration. When + ``max_age`` is provided, active sessions slide the expiry window on each + access. TTLs larger than 30 days are converted to absolute Unix + timestamps, as required by the memcached protocol. + + 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 + memcached server, as pickle deserialization of untrusted data is unsafe. + """ + + def __init__( + self, + client: aiomcache.Client, + prefix: str = "kaya:session:", + dumps: Callable[[dict[str, Any]], bytes] = pickle.dumps, + loads: Callable[[bytes], dict[str, Any]] = pickle.loads, + clock: Callable[[], float] = time, + ) -> None: + self._client = client + self._prefix = prefix + self._dumps = dumps + self._loads = loads + self._clock = clock + + def _key(self, session_id: str) -> bytes: + return f"{self._prefix}{session_id}".encode() + + 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.touch(key, _exptime(max_age, self._clock)) + + data = self._loads(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)) + exptime = _exptime(max_age, self._clock) if max_age is not None else 0 + await self._client.set(self._key(session_id), payload, exptime=exptime) + + async def delete(self, session_id: str) -> None: + await self._client.delete(self._key(session_id)) diff --git a/packages/kaya-session-memcache/src/kaya/session_memcache/py.typed b/packages/kaya-session-memcache/src/kaya/session_memcache/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/kaya-session-memcache/tests/test_store.py b/packages/kaya-session-memcache/tests/test_store.py new file mode 100644 index 0000000..74ad9ed --- /dev/null +++ b/packages/kaya-session-memcache/tests/test_store.py @@ -0,0 +1,203 @@ +import unittest +from datetime import datetime, timezone +from typing import Optional + +import httpx +from pwo import async_test + +from kaya.core import KayaApp, HttpContext +from kaya.session import Session, SessionMixin +from kaya.session_memcache import MemcacheSessionStore + + +class FakeClock: + def __init__(self, start: float = 1_000_000.0) -> None: + self._now = start + + def __call__(self) -> float: + return self._now + + def advance(self, seconds: float) -> None: + self._now += seconds + + +class StubMemcacheClient: + """In-memory stub implementing the aiomcache.Client subset used by the store.""" + + def __init__(self, clock: FakeClock) -> None: + self._clock = clock + self._data: dict[bytes, bytes] = {} + self._expires: dict[bytes, float] = {} + self.set_exptimes: list[int] = [] + self.touch_exptimes: list[int] = [] + + def _expired(self, key: bytes) -> bool: + expires = self._expires.get(key) + return expires is not None and self._clock() > expires + + async def get(self, key: bytes) -> Optional[bytes]: + if self._expired(key): + self._data.pop(key, None) + self._expires.pop(key, None) + return None + return self._data.get(key) + + async def set(self, key: bytes, value: bytes, exptime: int = 0) -> bool: + self.set_exptimes.append(exptime) + self._data[key] = value + if exptime > 0: + self._expires[key] = self._clock() + exptime + else: + self._expires.pop(key, None) + return True + + async def delete(self, key: bytes) -> bool: + existed = key in self._data + self._data.pop(key, None) + self._expires.pop(key, None) + return existed + + async def touch(self, key: bytes, exptime: int) -> bool: + self.touch_exptimes.append(exptime) + if self._expired(key) or key not in self._data: + return False + self._expires[key] = self._clock() + exptime + return True + + +class MemcacheSessionStoreTest(unittest.TestCase): + clock: FakeClock + client: StubMemcacheClient + store: MemcacheSessionStore + + def setUp(self) -> None: + self.clock = FakeClock() + self.client = StubMemcacheClient(self.clock) + self.store = MemcacheSessionStore(self.client, clock=self.clock) # type: ignore[arg-type] + + @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_with_max_age_expires(self) -> None: + await self.store.save('abc', Session('abc', {'foo': 'bar'}), max_age=60) + self.assertIsNotNone(await self.store.load('abc')) + self.clock.advance(61) + self.assertIsNone(await self.store.load('abc')) + + @async_test + async def test_save_without_max_age_never_expires(self) -> None: + await self.store.save('abc', Session('abc', {'foo': 'bar'})) + self.assertEqual([0], self.client.set_exptimes) + self.clock.advance(10_000_000) + self.assertIsNotNone(await self.store.load('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) + self.clock.advance(50) + loaded = await self.store.load('abc', max_age=60) + self.assertIsNotNone(loaded) + self.assertEqual([60], self.client.touch_exptimes) + self.clock.advance(50) + self.assertIsNotNone(await self.store.load('abc')) + + @async_test + async def test_exptime_over_30_days_converted_to_absolute_timestamp(self) -> None: + max_age = 40 * 24 * 60 * 60 + await self.store.save('abc', Session('abc', {'foo': 'bar'}), max_age=max_age) + self.assertEqual([int(self.clock()) + max_age], self.client.set_exptimes) + + @async_test + async def test_exptime_exactly_30_days_stays_relative(self) -> None: + max_age = 30 * 24 * 60 * 60 + await self.store.save('abc', Session('abc', {'foo': 'bar'}), max_age=max_age) + self.assertEqual([max_age], self.client.set_exptimes) + + @async_test + async def test_touch_over_30_days_converted_to_absolute_timestamp(self) -> None: + max_age = 40 * 24 * 60 * 60 + await self.store.save('abc', Session('abc', {'foo': 'bar'}), max_age=max_age) + self.clock.advance(100) + await self.store.load('abc', max_age=max_age) + self.assertEqual([int(self.clock()) + max_age], self.client.touch_exptimes) + + @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 = MemcacheSessionStore(self.client, prefix='myapp:sess:', clock=self.clock) # type: ignore[arg-type] + await store.save('abc', Session('abc', {'foo': 'bar'})) + self.assertIn(b'myapp:sess:abc', self.client._data) + self.assertNotIn(b'kaya:session:abc', self.client._data) + + @async_test + async def test_custom_serializer(self) -> None: + import json + + store = MemcacheSessionStore( + self.client, # type: ignore[arg-type] + dumps=lambda d: json.dumps(d).encode('utf-8'), + loads=lambda b: json.loads(b.decode('utf-8')), + clock=self.clock, + ) + await store.save('abc', Session('abc', {'foo': 'bar'})) + self.assertEqual(b'{"foo": "bar"}', self.client._data[b'kaya:session:abc']) + loaded = await store.load('abc') + self.assertIsNotNone(loaded) + assert loaded is not None + self.assertEqual({'foo': 'bar'}, dict(loaded)) + + +class MemcacheSessionIntegrationTest(unittest.TestCase): + app: KayaApp + + def setUp(self) -> None: + store = MemcacheSessionStore(StubMemcacheClient(FakeClock())) # type: ignore[arg-type] + 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 704b827..a98de82 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -2,6 +2,7 @@ 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-session-memcache @ file:./packages/kaya-session-memcache kaya-oidc @ file:./packages/kaya-oidc build fakeredis diff --git a/requirements-dev.txt b/requirements-dev.txt index 20c37be..708a8a2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,6 +7,8 @@ --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --extra-index-url https://pypi.org/simple +aiomcache==0.8.2 + # via kaya-session-memcache anyio==4.14.2 # via # httpx @@ -98,7 +100,10 @@ file:./packages/kaya-session # via # -r requirements-dev.in # kaya-oidc + # kaya-session-memcache # kaya-session-redis +file:./packages/kaya-session-memcache + # via -r requirements-dev.in file:./packages/kaya-session-redis # via -r requirements-dev.in keyring==25.7.0