Add kaya-session package for server-side HTTP session management

This commit is contained in:
2026-07-23 22:09:55 +08:00
parent 4ce95d8365
commit 97a81a9e41
16 changed files with 778 additions and 5 deletions
+16 -4
View File
@@ -25,13 +25,17 @@ jobs:
python -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
- name: Check kaya-core
run: |
.venv/bin/python -m mypy -p kaya.rsgi
.venv/bin/python -m unittest discover -s packages/kaya-rsgi/tests
- name: Check kaya-rsgi
run: |
.venv/bin/python -m mypy -p kaya.core
.venv/bin/python -m unittest discover -s packages/kaya-core/tests
- name: Check kaya-rsgi
run: |
.venv/bin/python -m mypy -p kaya.rsgi
.venv/bin/python -m unittest discover -s packages/kaya-rsgi/tests
- name: Check kaya-session
run: |
.venv/bin/python -m mypy -p kaya.session
.venv/bin/python -m unittest discover -s packages/kaya-session/tests
- name: Publish kaya-core artifacts
env:
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
@@ -48,3 +52,11 @@ jobs:
run: |
.venv/bin/pyproject-build packages/kaya-rsgi
.venv/bin/twine upload --repository gitea packages/kaya-rsgi/dist/*.whl packages/kaya-rsgi/dist/*.tar.gz
- name: Publish kaya-session artifacts
env:
TWINE_REPOSITORY_URL: ${{ vars.PYPI_REGISTRY_URL }}
TWINE_USERNAME: ${{ vars.PUBLISHER_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PUBLISHER_TOKEN }}
run: |
.venv/bin/pyproject-build packages/kaya-session
.venv/bin/twine upload --repository gitea packages/kaya-session/dist/*.whl packages/kaya-session/dist/*.tar.gz
+5 -1
View File
@@ -8,6 +8,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/`)
Additional `kaya-*` packages can be added as new directories under `packages/`.
@@ -22,7 +23,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
pip install -e packages/kaya-core -e packages/kaya-rsgi -e packages/kaya-session
```
Run the example:
@@ -36,6 +37,7 @@ python example/hello.py
```bash
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
```
## Static analysis
@@ -43,6 +45,7 @@ python -m unittest discover -s packages/kaya-rsgi/tests
```bash
mypy -p kaya.core
mypy -p kaya.rsgi
mypy -p kaya.session
```
## Building packages
@@ -50,4 +53,5 @@ mypy -p kaya.rsgi
```bash
python -m build packages/kaya-core
python -m build packages/kaya-rsgi
python -m build packages/kaya-session
```
+45
View File
@@ -0,0 +1,45 @@
from kaya.core import HttpContext, KayaApp
from kaya.session import InMemorySessionStore, SessionMiddleware
app = SessionMiddleware(KayaApp(), InMemorySessionStore())
@app.GET('/')
async def home(ctx: HttpContext) -> None:
visits = ctx.session.get('visits', 0) + 1
ctx.session['visits'] = visits
await ctx.send_str(200, f'visits: {visits}')
@app.GET('/read')
async def read(ctx: HttpContext) -> None:
visits = ctx.session.get('visits', 0)
await ctx.send_str(200, f'visits: {visits}')
@app.GET('/clear')
async def clear(ctx: HttpContext) -> None:
ctx.session.invalidate()
await ctx.send_str(200, 'session cleared')
@app.websocket('/echo')
async def echo(ws: WebSocket) -> None:
await ws.accept()
async for msg in ws:
if msg.kind == 'text':
await ws.send_text(f"echo: {msg.data}")
elif msg.kind == 'binary':
data = msg.data
assert isinstance(data, bytes)
await ws.send_bytes(data)
@app.websocket('/ws/visits')
async def ws_visits(ws: WebSocket) -> None:
# WebSocket handlers can read the existing session. Most ASGI servers
# (including Granian and Daphne) do not forward the `headers` field of the
# `websocket.accept` message into the HTTP 101 response, so a new session
# cookie cannot be set during the handshake. Use the HTTP `/` endpoint to
# set or refresh the session cookie before connecting here.
await ws.accept()
visits = ws.session.get('visits', 0)
await ws.send_text(f'visits: {visits}')
@@ -88,6 +88,7 @@ class AsgiContext(HttpContext):
self.server = scope['server']
self.headers = decode_headers(scope['headers'])
self.request_body = request_body_iterator
self.session = (scope.get('state') or {}).get('kaya_session')
async def stream_body(self,
status: int,
@@ -28,6 +28,7 @@ class HttpContext(ABC):
client: Optional[Tuple[str, int]]
server: Optional[Tuple[str, Optional[int]]]
request_body: AsyncIterator[bytes]
session: Optional[Any] = None
@abstractmethod
async def stream_body(self,
+42
View File
@@ -0,0 +1,42 @@
# kaya-session
Session management for the Kaya web framework.
Provides server-side, identity-agnostic HTTP sessions via a session cookie. The
session data is accessible from request handlers as `ctx.session`.
## Usage
```python
from kaya.core import KayaApp, HttpContext
from kaya.session import SessionMiddleware, InMemorySessionStore
app = KayaApp()
session_app = SessionMiddleware(app, InMemorySessionStore())
@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 created lazily: a cookie is only set when the handler modifies the
session.
## Features
- `Session`: dict-like session object with modification tracking
- `SessionStore`: abstract store interface
- `InMemorySessionStore`: simple in-memory store for development/single-process
- `SessionMiddleware`: ASGI middleware managing session cookies and persistence
- Session ID regeneration (`session.regenerate_id()`) and invalidation
(`session.invalidate()`) for future authentication layers
## Notes
- This release supports HTTP requests only; WebSocket and RSGI propagation is
planned for future releases.
- `InMemorySessionStore` does not survive process restarts and is not shared
across processes. Production deployments should use a store backed by a shared
storage system (planned).
+57
View File
@@ -0,0 +1,57 @@
[build-system]
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "kaya-session"
dynamic = ["version"]
authors = [
{ name="Walter Oggioni", email="oggioni.walter@gmail.com" },
]
description = "Session management 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-core",
"pwo",
]
[project.optional-dependencies]
dev = [
"build", "mypy", "ipdb", "twine", "httpx", "httpx-ws"
]
[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/_version.py"
[tool.setuptools_scm.tag]
prefix = "release/"
@@ -0,0 +1,11 @@
from ._middleware import SessionMiddleware
from ._session import Session
from ._store import InMemorySessionStore, SessionStore
__all__ = [
'InMemorySessionStore',
'Session',
'SessionMiddleware',
'SessionStore',
]
@@ -0,0 +1,37 @@
from http.cookies import SimpleCookie
from typing import Optional
def parse_cookie_value(header_value: str, cookie_name: str) -> Optional[str]:
"""Return the value of ``cookie_name`` from a ``Cookie`` header, if present."""
cookie = SimpleCookie()
cookie.load(header_value)
morsel = cookie.get(cookie_name)
if morsel is None:
return None
return morsel.value
def format_set_cookie(
name: str,
value: str,
path: str = '/',
max_age: Optional[int] = None,
httponly: bool = True,
secure: bool = False,
samesite: Optional[str] = 'Lax',
) -> str:
"""Return a ``Set-Cookie`` value string (without the header name)."""
cookie = SimpleCookie()
cookie[name] = value
morsel = cookie[name]
morsel['path'] = path
if max_age is not None:
morsel['max-age'] = max_age
if httponly:
morsel['httponly'] = True
if secure:
morsel['secure'] = True
if samesite is not None:
morsel['samesite'] = samesite
return morsel.OutputString()
@@ -0,0 +1,178 @@
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Sequence, cast
from kaya.core import HttpMethod, KayaApp
from ._cookie import format_set_cookie, parse_cookie_value
from ._session import Session
from ._store import SessionStore
type HttpHandler = Callable[[Any, Any], Awaitable[None]]
type WebSocketHandler = Callable[[Any, Any], Awaitable[None]]
type RouteDecorator = Callable[[HttpHandler], HttpHandler]
type WebSocketDecorator = Callable[[WebSocketHandler], WebSocketHandler]
type ASGIApp = Callable[
[MutableMapping[str, Any], Callable[[], Awaitable[Any]], Callable[[MutableMapping[str, Any]], Awaitable[None]]],
Awaitable[None],
]
class SessionMiddleware:
"""ASGI middleware providing server-side HTTP sessions.
The middleware reads a session cookie from each HTTP request, loads the
session data via the configured store, and makes the session available to
Kaya handlers as ``ctx.session``. After the request it persists the session
and refreshes the cookie.
Routing methods are delegated to the wrapped ``KayaApp`` so the middleware
can be used as a drop-in replacement when registering handlers.
Example::
app = KayaApp()
session_app = SessionMiddleware(app, InMemorySessionStore())
@session_app.GET('/')
async def home(ctx: HttpContext):
ctx.session['visits'] = ctx.session.get('visits', 0) + 1
await ctx.send_str(200, f"visits: {ctx.session['visits']}")
"""
def __init__(
self,
app: KayaApp,
store: SessionStore,
cookie_name: str = 'session_id',
path: str = '/',
max_age: Optional[int] = 14 * 24 * 60 * 60,
httponly: bool = True,
secure: bool = False,
samesite: Optional[str] = 'Lax',
) -> None:
self._app = app
self._store = store
self._cookie_name = cookie_name
self._path = path
self._max_age = max_age
self._httponly = httponly
self._secure = secure
self._samesite = samesite
def route(
self,
paths: str | Sequence[str],
methods: Optional[HttpMethod | Sequence[HttpMethod]] = None,
recursive: bool = False,
) -> RouteDecorator:
return self._app.route(paths, methods, recursive)
def GET(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.GET(path, recursive)
def POST(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.POST(path, recursive)
def PUT(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.PUT(path, recursive)
def DELETE(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.DELETE(path, recursive)
def OPTIONS(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.OPTIONS(path, recursive)
def HEAD(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.HEAD(path, recursive)
def PATCH(self, path: str, recursive: bool = False) -> RouteDecorator:
return self._app.PATCH(path, recursive)
def websocket(self, path: str, recursive: bool = False) -> WebSocketDecorator:
return self._app.websocket(path, recursive)
async def __call__(
self,
scope: MutableMapping[str, Any],
receive: Callable[[], Awaitable[Any]],
send: Callable[[MutableMapping[str, Any]], Awaitable[None]],
) -> None:
if scope['type'] != 'http':
await cast(ASGIApp, self._app)(scope, receive, send)
return
session_id = self._extract_session_id(scope)
session: Session
if session_id is not None:
loaded = await self._store.load(session_id)
session = loaded if loaded is not None else Session()
else:
session = Session()
state = scope.get('state')
if not isinstance(state, dict):
state = {}
scope['state'] = state
state['kaya_session'] = session
async def wrapped_send(message: MutableMapping[str, Any]) -> None:
if message['type'] == 'http.response.start':
message = dict(message)
final_session_id = self._finalize_session_id(session)
if final_session_id is not None:
cookie_value = format_set_cookie(
self._cookie_name,
final_session_id,
path=self._path,
max_age=0 if session.invalidated else self._max_age,
httponly=self._httponly,
secure=self._secure,
samesite=self._samesite,
)
headers = list(message.get('headers', []))
headers.append((b'Set-Cookie', cookie_value.encode()))
message['headers'] = headers
await send(message)
try:
await cast(ASGIApp, self._app)(scope, receive, wrapped_send)
finally:
await self._persist(session)
def _extract_session_id(self, scope: MutableMapping[str, Any]) -> Optional[str]:
headers = scope.get('headers', [])
for key, value in headers:
key_bytes: bytes = key if isinstance(key, bytes) else key.encode()
if key_bytes.lower() == b'cookie':
value_str: str = value.decode() if isinstance(value, bytes) else value
return parse_cookie_value(value_str, self._cookie_name)
return None
def _finalize_session_id(self, session: Session) -> Optional[str]:
if session.invalidated:
return session.id
if session.id is None:
if session.modified or session.regenerate:
session.set_id(self._store.new_session_id())
elif session.regenerate:
session._old_id = session.id
session.set_id(self._store.new_session_id())
session._regenerate = False
return session.id
async def _persist(self, session: Session) -> None:
if session.invalidated:
old_id = session._old_id or session.id
if old_id is not None:
await self._store.delete(old_id)
return
if session._old_id is not None and session._old_id != session.id:
await self._store.delete(session._old_id)
session._old_id = None
if session.id is None and session.modified:
session.set_id(self._store.new_session_id())
if session.id is not None:
await self._store.save(session.id, session)
@@ -0,0 +1,82 @@
from typing import Any, Iterator, Mapping, MutableMapping, Optional
class Session(MutableMapping[str, Any]):
"""Dict-like session container with modification tracking.
The middleware uses the ``modified``, ``regenerate`` and ``invalidated``
flags to decide whether to persist the session, rotate its ID, or delete
it.
"""
def __init__(self, session_id: Optional[str] = None, data: Optional[Mapping[str, Any]] = None) -> None:
self._id: Optional[str] = session_id
self._old_id: Optional[str] = None
self._data: dict[str, Any] = dict(data) if data else {}
self._modified: bool = False
self._regenerate: bool = False
self._invalidated: bool = False
@property
def id(self) -> Optional[str]:
return self._id
def set_id(self, session_id: str) -> None:
self._id = session_id
@property
def modified(self) -> bool:
return self._modified
@property
def regenerate(self) -> bool:
return self._regenerate
@property
def invalidated(self) -> bool:
return self._invalidated
def regenerate_id(self) -> None:
"""Mark the session for ID rotation.
This is intended for authentication layers to defend against session
fixation: the middleware will create a new session ID, move the data to
it, and delete the old store entry.
"""
self._old_id = self._id
self._id = None
self._regenerate = True
self._modified = True
def invalidate(self) -> None:
"""Mark the session for deletion.
The middleware will clear the stored data and send an expired cookie.
"""
self._invalidated = True
self._modified = True
self._data.clear()
def mark_modified(self) -> None:
self._modified = True
def __getitem__(self, key: str) -> Any:
return self._data[key]
def __setitem__(self, key: str, value: Any) -> None:
self._data[key] = value
self._modified = True
def __delitem__(self, key: str) -> None:
del self._data[key]
self._modified = True
def __iter__(self) -> Iterator[str]:
return iter(self._data)
def __len__(self) -> int:
return len(self._data)
def clear(self) -> None:
self._data.clear()
self._modified = True
@@ -0,0 +1,54 @@
from abc import ABC, abstractmethod
from secrets import token_urlsafe
from typing import Any, Optional
from ._session import Session
class SessionStore(ABC):
"""Pluggable backend for session persistence."""
@abstractmethod
async def load(self, session_id: str) -> Optional[Session]:
"""Load an existing session, or return ``None`` if unknown/expired."""
pass
@abstractmethod
async def save(self, session_id: str, session: Session) -> None:
"""Persist the session data under ``session_id``."""
pass
@abstractmethod
async def delete(self, session_id: str) -> None:
"""Remove the session from the store."""
pass
def new_session_id(self) -> str:
"""Return a new opaque session identifier.
Subclasses may override this to use a backend-specific ID generator.
"""
return token_urlsafe(32)
class InMemorySessionStore(SessionStore):
"""Simple in-memory session store.
Suitable for development and single-process deployments. Session data is
lost when the process exits and is not shared between processes.
"""
def __init__(self) -> None:
self._sessions: dict[str, dict[str, Any]] = {}
async def load(self, session_id: str) -> Optional[Session]:
data = self._sessions.get(session_id)
if data is None:
return None
return Session(session_id, data)
async def save(self, session_id: str, session: Session) -> None:
self._sessions[session_id] = dict(session)
async def delete(self, session_id: str) -> None:
self._sessions.pop(session_id, None)
+245
View File
@@ -0,0 +1,245 @@
import unittest
from typing import Any
import httpx
from pwo import async_test
from kaya.core import KayaApp, HttpContext
from kaya.session import InMemorySessionStore, Session, SessionMiddleware, SessionStore
from kaya.session._cookie import format_set_cookie, parse_cookie_value
class SessionTest(unittest.TestCase):
app: KayaApp
store: InMemorySessionStore
session_app: SessionMiddleware
def setUp(self) -> None:
self.app = KayaApp()
self.store = InMemorySessionStore()
self.session_app = SessionMiddleware(self.app, self.store)
@self.session_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}')
@self.session_app.GET('/read')
async def read(ctx: HttpContext) -> None:
n = ctx.session.get('visits', 0)
await ctx.send_str(200, f'visits: {n}')
@self.session_app.GET('/write')
async def write(ctx: HttpContext) -> None:
ctx.session['foo'] = 'bar'
await ctx.send_str(200, 'ok')
@self.session_app.GET('/clear')
async def clear(ctx: HttpContext) -> None:
ctx.session.invalidate()
await ctx.send_str(200, 'cleared')
@self.session_app.GET('/rotate')
async def rotate(ctx: HttpContext) -> None:
ctx.session.regenerate_id()
await ctx.send_str(200, 'rotated')
@async_test
async def test_session_persists_across_requests(self) -> None:
transport = httpx.ASGITransport(app=self.session_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)
@async_test
async def test_no_cookie_when_session_not_modified(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/read')
self.assertEqual(200, r.status_code)
self.assertEqual('visits: 0', r.text)
self.assertNotIn('Set-Cookie', r.headers)
@async_test
async def test_existing_session_refreshes_cookie(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await client.get('/')
r = await client.get('/read')
self.assertEqual(200, r.status_code)
self.assertIn('Set-Cookie', r.headers)
self.assertEqual('visits: 1', r.text)
@async_test
async def test_cookie_attributes(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/')
set_cookie = r.headers['Set-Cookie']
self.assertIn('HttpOnly', set_cookie)
self.assertIn('SameSite=Lax', set_cookie)
self.assertIn('Path=/', set_cookie)
self.assertIn('Max-Age=', set_cookie)
@async_test
async def test_sessions_are_isolated(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r1 = await client.get('/')
r2 = await client.get('/')
self.assertEqual('visits: 1', r1.text)
self.assertEqual('visits: 2', r2.text)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
r = await client.get('/')
self.assertEqual('visits: 1', r.text)
@async_test
async def test_invalidate(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await client.get('/')
r = await client.get('/clear')
self.assertEqual(200, r.status_code)
self.assertEqual('cleared', r.text)
set_cookie = r.headers['Set-Cookie']
self.assertIn('Max-Age=0', set_cookie)
r = await client.get('/')
self.assertEqual('visits: 1', r.text)
@async_test
async def test_regenerate_id(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
await client.get('/')
cookies_before = {c.name: c.value for c in client.cookies.jar}
old_id = cookies_before.get('session_id')
self.assertIsNotNone(old_id)
self.assertIn(old_id, self.store._sessions)
r = await client.get('/rotate')
self.assertEqual(200, r.status_code)
self.assertEqual('rotated', r.text)
cookies_after = {c.name: c.value for c in client.cookies.jar}
new_id = cookies_after.get('session_id')
self.assertIsNotNone(new_id)
self.assertNotEqual(old_id, new_id)
self.assertNotIn(old_id, self.store._sessions)
self.assertIn(new_id, self.store._sessions)
r = await client.get('/read')
self.assertEqual('visits: 1', r.text)
@async_test
async def test_invalid_cookie_creates_fresh_session(self) -> None:
transport = httpx.ASGITransport(app=self.session_app)
async with httpx.AsyncClient(transport=transport, base_url='http://127.0.0.1:80') as client:
client.cookies.set('session_id', 'not-a-real-id')
r = await client.get('/')
self.assertEqual('visits: 1', r.text)
self.assertIn('Set-Cookie', r.headers)
class SessionUnitTest(unittest.TestCase):
def test_session_is_dict_like(self) -> None:
session = Session()
session['a'] = 1
self.assertEqual(1, session['a'])
self.assertTrue('a' in session)
self.assertEqual({'a': 1}, dict(session))
self.assertTrue(session.modified)
def test_session_modification_tracking(self) -> None:
session = Session(data={'a': 1})
self.assertFalse(session.modified)
session['a'] = 2
self.assertTrue(session.modified)
def test_session_read_does_not_mark_modified(self) -> None:
session = Session(data={'a': 1})
self.assertFalse(session.modified)
_ = session['a']
self.assertFalse(session.modified)
def test_session_clear_marks_modified(self) -> None:
session = Session(data={'a': 1})
self.assertFalse(session.modified)
session.clear()
self.assertTrue(session.modified)
self.assertEqual(0, len(session))
def test_session_invalidate(self) -> None:
session = Session(session_id='abc', data={'a': 1})
self.assertFalse(session.modified)
session.invalidate()
self.assertTrue(session.invalidated)
self.assertTrue(session.modified)
self.assertEqual(0, len(session))
def test_session_regenerate_id(self) -> None:
session = Session(session_id='abc', data={'a': 1})
session.regenerate_id()
self.assertTrue(session.regenerate)
self.assertIsNone(session.id)
self.assertEqual('abc', session._old_id)
self.assertTrue(session.modified)
def test_session_store_is_abstract(self) -> None:
with self.assertRaises(TypeError):
SessionStore() # type: ignore[abstract]
def test_in_memory_store_round_trip(self) -> None:
store = InMemorySessionStore()
session_id = store.new_session_id()
session = Session(session_id, {'a': 1})
self.assertIsNone(store._sessions.get(session_id))
import asyncio
asyncio.run(store.save(session_id, session))
loaded = asyncio.run(store.load(session_id))
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual(1, loaded['a'])
asyncio.run(store.delete(session_id))
self.assertIsNone(asyncio.run(store.load(session_id)))
class CookieUtilTest(unittest.TestCase):
def test_parse_cookie_value(self) -> None:
self.assertEqual('bar', parse_cookie_value('foo=bar; baz=qux', 'foo'))
self.assertEqual('qux', parse_cookie_value('foo=bar; baz=qux', 'baz'))
self.assertIsNone(parse_cookie_value('foo=bar', 'missing'))
self.assertIsNone(parse_cookie_value('', 'foo'))
def test_format_set_cookie(self) -> None:
value = format_set_cookie('sid', 'abc123', path='/', max_age=3600, httponly=True, secure=True, samesite='Strict')
self.assertIn('sid=abc123', value)
self.assertIn('Path=/', value)
self.assertIn('Max-Age=3600', value)
self.assertIn('HttpOnly', value)
self.assertIn('Secure', value)
self.assertIn('SameSite=Strict', value)
def test_format_set_cookie_without_secure(self) -> None:
value = format_set_cookie('sid', 'abc123', max_age=3600)
self.assertIn('sid=abc123', value)
self.assertIn('HttpOnly', value)
self.assertNotIn('Secure', value)
self.assertIn('SameSite=Lax', value)
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -1,5 +1,6 @@
kaya-core @ file:./packages/kaya-core
kaya-rsgi @ file:./packages/kaya-rsgi
kaya-session @ file:./packages/kaya-session
build
mypy
ipdb
+3
View File
@@ -83,8 +83,11 @@ file:./packages/kaya-core
# via
# -r requirements-dev.in
# kaya-rsgi
# kaya-session
file:./packages/kaya-rsgi
# via -r requirements-dev.in
file:./packages/kaya-session
# via -r requirements-dev.in
keyring==25.7.0
# via twine
librt==0.13.0