src/genro_asgi/session/session.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 """Server-managed session: id, meta, Bag data, and a keyed collection of Avatars.16 17 A ``Session`` groups request-scoped state under a unique id with expiry18 tracking. ``SessionMiddleware`` creates or reconnects sessions via the request19 cookie and attaches them to ``scope["session"]``.20 21 **The dressing model.** A session is not one identity but a wardrobe of them,22 each stored under a key. The ``ROOT_AVATAR_KEY`` slot holds the identity of the23 primary login — the one the auth chain resolves and the one ``avatar()`` returns24 with no argument. Further keys are *sub-logins*: an identity a page acquired25 inside the same session (a second-system credential, an impersonation, a26 delegated account) that must coexist with the root one instead of replacing it.27 Page trees will reference the slot they are dressed in by ``avatar_key``, so the28 identity of a page is a lookup in this collection, never a copy of it.29 30 ``avatar(key)`` returns ``Avatar | None`` — ``None`` is an unclaimed slot, and an31 absent root slot is an anonymous session; capturing an identity is an explicit32 ``avatar=`` at creation (the root slot) or an ``attach_avatar`` call. ``avatars``33 is a read-only view for enumeration; ``attach_avatar`` is its only writer. There34 is no detach: a slot claimed in a session stays claimed for its lifetime.35 ``data`` is a ``Bag`` for arbitrary application data. ``touch()`` refreshes36 ``last_access``; ``is_expired()`` measures the TTL from it.37 38 Write-back is explicit (D24): a session persists at request end ONLY when39 ``dirty`` is set. ``attach_avatar`` marks it dirty (a login must survive), and a40 handler mutating ``data`` marks it dirty with ``mark_dirty()`` — there is no41 write-through. ``touch()`` is NOT a mutation for this purpose: the ``last_access``42 refresh happens on every ``get`` (including read-only requests), so making it43 dirty would save on every request and defeat the zero-I/O read path. The44 middleware clears the flag with ``clear_dirty()`` after a successful save.45 """46 47 from __future__ import annotations48 49 import time50 import types51 from typing import Any, Mapping52 53 from genro_bag import Bag54 55 from .avatar import Avatar56 57 __all__ = ["Session"]58 59 60 class Session:61 """Server-managed session with meta, Bag data, and keyed identity avatars."""62 63 __slots__ = ("_id", "_meta", "_data", "_avatars", "_dirty")64 65 ROOT_AVATAR_KEY = "root"66 67 def __init__(self, session_id: str, avatar: Avatar | None, ttl: int) -> None:68 """Initialize the session with its token, its root avatar, and a TTL."""69 now = time.time()70 self._id = session_id71 self._meta: dict[str, Any] = {"created_at": now, "last_access": now, "ttl": ttl}72 self._data = Bag()73 self._avatars: dict[str, Avatar] = {}74 if avatar is not None:75 self._avatars[self.ROOT_AVATAR_KEY] = avatar76 self._dirty = False77 78 @property79 def id(self) -> str:80 """Unique session token."""81 return self._id82 83 @property84 def meta(self) -> dict[str, Any]:85 """Server-managed metadata: created_at, last_access, ttl."""86 return self._meta87 88 @property89 def data(self) -> Bag:90 """Application data as a Bag."""91 return self._data92 93 def avatar(self, key: str = ROOT_AVATAR_KEY) -> Avatar | None:94 """The avatar dressed under ``key``; ``None`` = unclaimed slot.95 96 With no argument it returns the root avatar — the primary login — so an97 anonymous session answers ``None``.98 """99 return self._avatars.get(key)100 101 @property102 def avatars(self) -> Mapping[str, Avatar]:103 """Read-only view of the keyed avatars (``attach_avatar`` is the only writer)."""104 return types.MappingProxyType(self._avatars)105 106 @property107 def dirty(self) -> bool:108 """Whether the session has unsaved changes to persist at request end."""109 return self._dirty110 111 def attach_avatar(self, avatar: Avatar, key: str = ROOT_AVATAR_KEY) -> None:112 """Dress ``key`` with an avatar — the login event (marks the session dirty).113 114 The session stays the same object: id, ``data`` and ``meta`` are115 untouched, so whatever an anonymous visitor accumulated survives116 the login. The default key is the root slot (the primary login); any117 other key is a sub-login coexisting with it. The change is marked dirty118 so the login persists.119 """120 self._avatars[key] = avatar121 self.mark_dirty()122 123 def mark_dirty(self) -> None:124 """Flag the session as changed — a handler mutating ``data`` calls this."""125 self._dirty = True126 127 def clear_dirty(self) -> None:128 """Reset the dirty flag (the middleware calls this after a successful save)."""129 self._dirty = False130 131 def touch(self) -> None:132 """Refresh ``last_access`` to now (NOT a dirty-making change; see module doc)."""133 self.meta["last_access"] = time.time()134 135 def is_expired(self) -> bool:136 """Whether the session has exceeded its TTL (non-positive TTL = expired)."""137 ttl: int = self.meta["ttl"]138 if ttl <= 0:139 return True140 last_access: float = self.meta["last_access"]141 return bool((time.time() - last_access) > ttl)