src/genro_asgi/session/mixin.py¶
Source from this local checkout, regenerated when the reader rebuilds.
Line links use #L<number>; a GitHub line range opens its first line.
1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 # https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """Session capability: HTTP sessions as a mixin over the base server (D16).16 17 ``SessionMixin`` is composed BEFORE ``MiddlewareMixin`` and ``BaseServer``18 (``class S(SessionMixin, MiddlewareMixin, BaseServer)``). Its cooperative19 ``__init__`` peels ``session_store=`` (``None`` → a fresh ``MemorySessionStore``)20 and ``session_ttl=`` (the default store's TTL), then ARMS ``SessionMiddleware``21 by injecting ``{"session": True}`` into the ``middleware`` config it forwards22 to ``MiddlewareMixin`` along the cooperative chain — composing the two mixins23 arms sessions with no user action, while an explicit24 ``middleware={"session": False}`` still wins (``setdefault`` never overrides an25 explicit switch). It overrides the §4 contract method ``session(request)`` to26 return the session attached to the request scope; a composition WITHOUT the27 mixin keeps the base answer (``None``). The login seam is not a server method:28 a handler attaches the identity through the request facade29 (``request.session.attach_avatar(avatar)``) — the session id never changes at30 login, so the cookie already held by the client stays valid.31 32 ``save_session=`` arms the pickle snapshot, the development survival line the33 CLI wires from ``serve --name`` (``~/.genroasgi/sessions/<name>.pickle``):34 ``__call__`` intercepts the ``lifespan`` scope exactly like ``TaskMixin`` —35 ``lifespan.py`` is NEVER touched (ratified) — loading the snapshot before the36 protocol runs (an absent file starts empty) and saving EVERY live session,37 data Bag included, when the protocol completes at shutdown. Unarmed, every38 scope passes straight through.39 """40 41 from __future__ import annotations42 43 import logging44 from pathlib import Path45 from typing import TYPE_CHECKING, Any46 47 from .store import MemorySessionStore, SessionStore48 49 if TYPE_CHECKING:50 from ..types import Receive, Scope, Send51 52 __all__ = ["SessionMixin"]53 54 55 class SessionMixin:56 """Session capability mixin, composed BEFORE the middleware/server classes.57 58 Constructor kwargs peeled here: ``session_store`` — an explicit store59 (``None`` builds a ``MemorySessionStore``); ``session_ttl`` — the default60 store's TTL when no explicit store is given; ``save_session`` — the61 snapshot pickle path (``None``, the default, disarms the snapshot).62 """63 64 def __init__(self, **kwargs: Any) -> None:65 store: SessionStore | None = kwargs.pop("session_store", None)66 ttl: int | None = kwargs.pop("session_ttl", None)67 save_session: str | Path | None = kwargs.pop("save_session", None)68 middleware: dict[str, Any] = dict(kwargs.get("middleware") or {})69 middleware.setdefault("session", True)70 kwargs["middleware"] = middleware71 super().__init__(**kwargs)72 if store is None:73 store = MemorySessionStore() if ttl is None else MemorySessionStore(default_ttl=ttl)74 self._session_store = store75 self._save_session = Path(save_session) if save_session is not None else None76 77 @property78 def session_store(self) -> SessionStore:79 """The store backing this server's sessions."""80 return self._session_store81 82 @property83 def save_session(self) -> Path | None:84 """The snapshot pickle path, or ``None`` when the snapshot is disarmed."""85 return self._save_session86 87 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:88 """Hook the lifespan to load/save the session snapshot (D16 pattern).89 90 Armed (``save_session=`` given), the snapshot is loaded before the91 lifespan protocol runs — an absent file starts empty — and saved when92 the protocol completes at shutdown. Disarmed, or any non-lifespan93 scope, passes straight through.94 """95 if scope["type"] != "lifespan" or self.save_session is None:96 await super().__call__(scope, receive, send)97 return98 logger = logging.getLogger(__name__)99 if self.save_session.is_file():100 restored = self.session_store.load_snapshot(self.save_session)101 logger.info("restored %d session(s) from %s", restored, self.save_session)102 try:103 await super().__call__(scope, receive, send)104 finally:105 saved = self.session_store.save_snapshot(self.save_session)106 logger.info("saved %d session(s) to %s", saved, self.save_session)107 108 def session(self, request: Any) -> Any:109 """The session attached to the request scope, or ``None`` if none."""110 return request.get("session") if request is not None else None