Source code for genro_asgi.session.session

# Copyright 2025 Softwell S.r.l.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Server-managed session: id, meta, Bag data, and an optional Avatar.

A ``Session`` groups request-scoped state under a unique id with expiry
tracking. ``SessionMiddleware`` creates or reconnects sessions via the request
cookie and attaches them to ``scope["session"]``. Each session carries an
``Avatar | None`` — ``None`` is an anonymous session; capturing an identity is
an explicit ``avatar=`` at creation — and a ``Bag`` for arbitrary application
data. ``touch()`` refreshes ``last_access``; ``is_expired()`` measures the TTL
from it.

Write-back is explicit (D24): a session persists at request end ONLY when
``dirty`` is set. ``attach_avatar`` marks it dirty (a login must survive), and a
handler mutating ``data`` marks it dirty with ``mark_dirty()`` — there is no
write-through. ``touch()`` is NOT a mutation for this purpose: the ``last_access``
refresh happens on every ``get`` (including read-only requests), so making it
dirty would save on every request and defeat the zero-I/O read path. The
middleware clears the flag with ``clear_dirty()`` after a successful save.
"""

from __future__ import annotations

import time
from typing import Any

from genro_bag import Bag

from .avatar import Avatar

__all__ = ["Session"]


[docs] class Session: """Server-managed session with meta, Bag data, and an optional Avatar.""" __slots__ = ("_id", "_meta", "_data", "_avatar", "_dirty")
[docs] def __init__(self, session_id: str, avatar: Avatar | None, ttl: int) -> None: """Initialize the session with its token, an identity avatar, and a TTL.""" now = time.time() self._id = session_id self._meta: dict[str, Any] = {"created_at": now, "last_access": now, "ttl": ttl} self._data = Bag() self._avatar = avatar self._dirty = False
@property def id(self) -> str: """Unique session token.""" return self._id @property def meta(self) -> dict[str, Any]: """Server-managed metadata: created_at, last_access, ttl.""" return self._meta @property def data(self) -> Bag: """Application data as a Bag.""" return self._data @property def avatar(self) -> Avatar | None: """Identity avatar; ``None`` = anonymous session.""" return self._avatar @property def dirty(self) -> bool: """Whether the session has unsaved changes to persist at request end.""" return self._dirty
[docs] def attach_avatar(self, avatar: Avatar) -> None: """Attach the identity avatar — the login event (marks the session dirty). The session stays the same object: id, ``data`` and ``meta`` are untouched, so whatever an anonymous visitor accumulated survives the login. The change is marked dirty so the login persists. """ self._avatar = avatar self.mark_dirty()
[docs] def mark_dirty(self) -> None: """Flag the session as changed — a handler mutating ``data`` calls this.""" self._dirty = True
[docs] def clear_dirty(self) -> None: """Reset the dirty flag (the middleware calls this after a successful save).""" self._dirty = False
[docs] def touch(self) -> None: """Refresh ``last_access`` to now (NOT a dirty-making change; see module doc).""" self.meta["last_access"] = time.time()
[docs] def is_expired(self) -> bool: """Whether the session has exceeded its TTL (non-positive TTL = expired).""" ttl: int = self.meta["ttl"] if ttl <= 0: return True last_access: float = self.meta["last_access"] return bool((time.time() - last_access) > ttl)
if __name__ == "__main__": session = Session("tok", avatar=Avatar("alice"), ttl=3600) assert session.id == "tok" assert session.avatar is not None and session.avatar.identity == "alice" assert not session.is_expired() session.meta["last_access"] = time.time() - 10_000 assert session.is_expired() anonymous = Session("t2", avatar=None, ttl=0) assert anonymous.avatar is None assert anonymous.is_expired() fresh = Session("t3", avatar=None, ttl=3600) assert fresh.dirty is False fresh.touch() # a read refreshes last_access but does NOT mark dirty assert fresh.dirty is False fresh.data["cart"] = "x" fresh.mark_dirty() # a data mutation is dirty only when the handler says so assert fresh.dirty is True fresh.clear_dirty() assert fresh.dirty is False fresh.attach_avatar(Avatar("bob")) # login marks dirty automatically assert fresh.dirty is True