Add kaya-session-memcache package for memcached-backed session storage

This commit is contained in:
2026-07-23 22:11:06 +08:00
parent e469839af3
commit 2d86a3a187
9 changed files with 405 additions and 1 deletions
+5 -1
View File
@@ -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
```
+52
View File
@@ -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.
@@ -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/"
@@ -0,0 +1,6 @@
from ._store import MemcacheSessionStore
__all__ = [
'MemcacheSessionStore',
]
@@ -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))
@@ -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)
+1
View File
@@ -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
+5
View File
@@ -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