Add kaya-session-memcache package for memcached-backed session storage
This commit is contained in:
@@ -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-rsgi** — RSGI/Granian integration (`packages/kaya-rsgi/`)
|
||||||
- **kaya-session** — server-side HTTP session management (`packages/kaya-session/`)
|
- **kaya-session** — server-side HTTP session management (`packages/kaya-session/`)
|
||||||
- **kaya-session-redis** — Redis-backed session storage (`packages/kaya-session-redis/`)
|
- **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/`)
|
- **kaya-oidc** — OpenID Connect authentication (`packages/kaya-oidc/`)
|
||||||
|
|
||||||
Additional `kaya-*` packages can be added as new directories under `packages/`.
|
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:
|
Install the packages in development mode:
|
||||||
|
|
||||||
```bash
|
```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:
|
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-rsgi/tests
|
||||||
python -m unittest discover -s packages/kaya-session/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-redis/tests
|
||||||
|
python -m unittest discover -s packages/kaya-session-memcache/tests
|
||||||
python -m unittest discover -s packages/kaya-oidc/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.rsgi
|
||||||
mypy -p kaya.session
|
mypy -p kaya.session
|
||||||
mypy -p kaya.session_redis
|
mypy -p kaya.session_redis
|
||||||
|
mypy -p kaya.session_memcache
|
||||||
mypy -p kaya.oidc
|
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-rsgi
|
||||||
python -m build packages/kaya-session
|
python -m build packages/kaya-session
|
||||||
python -m build packages/kaya-session-redis
|
python -m build packages/kaya-session-redis
|
||||||
|
python -m build packages/kaya-session-memcache
|
||||||
python -m build packages/kaya-oidc
|
python -m build packages/kaya-oidc
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -2,6 +2,7 @@ kaya-core @ file:./packages/kaya-core
|
|||||||
kaya-rsgi @ file:./packages/kaya-rsgi
|
kaya-rsgi @ file:./packages/kaya-rsgi
|
||||||
kaya-session @ file:./packages/kaya-session
|
kaya-session @ file:./packages/kaya-session
|
||||||
kaya-session-redis @ file:./packages/kaya-session-redis
|
kaya-session-redis @ file:./packages/kaya-session-redis
|
||||||
|
kaya-session-memcache @ file:./packages/kaya-session-memcache
|
||||||
kaya-oidc @ file:./packages/kaya-oidc
|
kaya-oidc @ file:./packages/kaya-oidc
|
||||||
build
|
build
|
||||||
fakeredis
|
fakeredis
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
|
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
|
||||||
--extra-index-url https://pypi.org/simple
|
--extra-index-url https://pypi.org/simple
|
||||||
|
|
||||||
|
aiomcache==0.8.2
|
||||||
|
# via kaya-session-memcache
|
||||||
anyio==4.14.2
|
anyio==4.14.2
|
||||||
# via
|
# via
|
||||||
# httpx
|
# httpx
|
||||||
@@ -98,7 +100,10 @@ file:./packages/kaya-session
|
|||||||
# via
|
# via
|
||||||
# -r requirements-dev.in
|
# -r requirements-dev.in
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
|
# kaya-session-memcache
|
||||||
# kaya-session-redis
|
# kaya-session-redis
|
||||||
|
file:./packages/kaya-session-memcache
|
||||||
|
# via -r requirements-dev.in
|
||||||
file:./packages/kaya-session-redis
|
file:./packages/kaya-session-redis
|
||||||
# via -r requirements-dev.in
|
# via -r requirements-dev.in
|
||||||
keyring==25.7.0
|
keyring==25.7.0
|
||||||
|
|||||||
Reference in New Issue
Block a user