From 80d2fc36f907f68c55094c231abe92c340e05d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Sun, 7 Jun 2026 14:18:45 +0200 Subject: [PATCH] feat(container): SESSION bean scope (one instance per HttpSession) + bump v26.06.46 - Scope.SESSION: register(scope=Scope.SESSION) -> one instance per HttpSession (Spring session scope). Instance stored as a session attribute (persisted with the session; must be serializable for non-memory stores like Redis). Errors clearly without a RequestContext/session. - SessionFilter exposes the active HttpSession to the container via RequestContext (HTTP_SESSION_KEY), mirroring REQUEST-scope resolution. Tests: tests/container/test_session_scope.py (4). Gates: mypy --strict (620), ruff + format, full suite 3841 passed. --- CHANGELOG.md | 13 +++++ README.md | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/container/container.py | 36 ++++++++++++++ src/pyfly/container/types.py | 1 + src/pyfly/context/request_context.py | 4 ++ src/pyfly/session/filter.py | 6 +++ tests/container/test_session_scope.py | 72 +++++++++++++++++++++++++++ uv.lock | 2 +- 10 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/container/test_session_scope.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a567d68..a4aa2b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.46 (2026-06-07) + +### Added (container — SESSION bean scope) + +- **`Scope.SESSION`** — register a bean with `scope=Scope.SESSION` to get one instance per + HTTP session (Spring's session scope). The instance lives as an `HttpSession` attribute, so + the `SessionFilter` must be active; it is persisted with the session, so it must be + serializable when a non-memory session store (e.g. Redis) is used. +- The `SessionFilter` now exposes the active `HttpSession` to the container via the + `RequestContext`, mirroring how `REQUEST`-scoped beans are resolved. + +(A general custom-scope SPI remains on the roadmap; this adds the concrete SESSION scope.) + ## v26.06.45 (2026-06-07) ### Added (security — role hierarchy) diff --git a/README.md b/README.md index 3669db1e..6077ad07 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.45 + Version: 26.06.46 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index 181b7573..c801c44d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.6.45" +version = "26.6.46" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 62d49916..2c93c301 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.06.45" +__version__ = "26.06.46" diff --git a/src/pyfly/container/container.py b/src/pyfly/container/container.py index e9d3e182..a1ec2605 100644 --- a/src/pyfly/container/container.py +++ b/src/pyfly/container/container.py @@ -321,6 +321,11 @@ def _resolve_registration(self, reg: Registration) -> Any: self._ensure_metrics(reg.impl_type).resolution_count += 1 return instance + if reg.scope == Scope.SESSION: + instance = self._resolve_session_scoped(reg) + self._ensure_metrics(reg.impl_type).resolution_count += 1 + return instance + instance = self._create_instance(reg) self._ensure_metrics(reg.impl_type).resolution_count += 1 return instance @@ -346,6 +351,37 @@ def _resolve_request_scoped(self, reg: Registration) -> Any: ctx.set(cache_key, instance) return instance + def _resolve_session_scoped(self, reg: Registration) -> Any: + """Resolve a SESSION-scoped bean from the active HttpSession's attributes. + + The bean lives as a session attribute, so (like Spring's session-scoped beans) it + is persisted with the session — it must be serializable when a non-memory session + store (e.g. Redis) is used. + """ + from pyfly.context.request_context import HTTP_SESSION_KEY, RequestContext + + ctx = RequestContext.current() + if ctx is None: + raise RuntimeError( + f"No active request context for SESSION-scoped bean " + f"{reg.impl_type.__name__}. Ensure a RequestContextFilter is active." + ) + session = ctx.get(HTTP_SESSION_KEY) + if session is None: + raise RuntimeError( + f"No HTTP session for SESSION-scoped bean {reg.impl_type.__name__}. " + f"Ensure the session module (SessionFilter) is enabled." + ) + + cache_key = f"__pyfly_bean_{reg.impl_type.__qualname__}" + existing = session.get_attribute(cache_key) + if existing is not None: + return existing + + instance = self._create_instance(reg) + session.set_attribute(cache_key, instance) + return instance + def _create_instance(self, reg: Registration) -> Any: """Create an instance, resolving constructor and field dependencies.""" if reg.impl_type in self._resolving: diff --git a/src/pyfly/container/types.py b/src/pyfly/container/types.py index 56c7c518..a94950d0 100644 --- a/src/pyfly/container/types.py +++ b/src/pyfly/container/types.py @@ -22,3 +22,4 @@ class Scope(Enum): SINGLETON = auto() TRANSIENT = auto() REQUEST = auto() + SESSION = auto() diff --git a/src/pyfly/context/request_context.py b/src/pyfly/context/request_context.py index beae0023..947bb909 100644 --- a/src/pyfly/context/request_context.py +++ b/src/pyfly/context/request_context.py @@ -27,6 +27,10 @@ _request_context_var: ContextVar[RequestContext | None] = ContextVar("pyfly_request_context", default=None) +# Attribute key under which the SessionFilter stashes the active HttpSession, so the +# container can resolve SESSION-scoped beans from it. +HTTP_SESSION_KEY = "__pyfly_http_session__" + class RequestContext: """Holds per-request state: request ID, security context, and custom attributes. diff --git a/src/pyfly/session/filter.py b/src/pyfly/session/filter.py index 65161821..5dc9ade6 100644 --- a/src/pyfly/session/filter.py +++ b/src/pyfly/session/filter.py @@ -53,6 +53,12 @@ def __init__( async def do_filter(self, request: Any, call_next: CallNext) -> Any: session = await self._load_or_create_session(request) request.state.session = session + # Expose the session to the container for SESSION-scoped bean resolution. + from pyfly.context.request_context import HTTP_SESSION_KEY, RequestContext + + ctx = RequestContext.current() + if ctx is not None: + ctx.set(HTTP_SESSION_KEY, session) try: response = await call_next(request) diff --git a/tests/container/test_session_scope.py b/tests/container/test_session_scope.py new file mode 100644 index 00000000..fe591d47 --- /dev/null +++ b/tests/container/test_session_scope.py @@ -0,0 +1,72 @@ +# Copyright 2026 Firefly Software Foundation. +# +# 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 +# +# http://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. +"""SESSION-scoped beans (v26.06.46): one instance per HttpSession.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from pyfly.container.container import Container +from pyfly.container.types import Scope +from pyfly.context.request_context import HTTP_SESSION_KEY, RequestContext +from pyfly.session.session import HttpSession + + +class SessionBean: + pass + + +@pytest.fixture(autouse=True) +def _clean_request_context() -> Iterator[None]: + RequestContext.clear() + yield + RequestContext.clear() + + +def _container() -> Container: + container = Container() + container.register(SessionBean, scope=Scope.SESSION) + return container + + +def test_same_instance_within_one_session() -> None: + container = _container() + ctx = RequestContext.init() + ctx.set(HTTP_SESSION_KEY, HttpSession("sid-1", {})) + assert container.resolve(SessionBean) is container.resolve(SessionBean) + + +def test_distinct_instances_across_sessions() -> None: + container = _container() + ctx = RequestContext.init() + ctx.set(HTTP_SESSION_KEY, HttpSession("sid-1", {})) + first = container.resolve(SessionBean) + ctx.set(HTTP_SESSION_KEY, HttpSession("sid-2", {})) + second = container.resolve(SessionBean) + assert first is not second + + +def test_requires_an_http_session() -> None: + container = _container() + RequestContext.init() # no session attached + with pytest.raises(RuntimeError, match="No HTTP session"): + container.resolve(SessionBean) + + +def test_requires_a_request_context() -> None: + container = _container() + with pytest.raises(RuntimeError, match="No active request context"): + container.resolve(SessionBean) diff --git a/uv.lock b/uv.lock index 30fc00d0..7992b2de 100644 --- a/uv.lock +++ b/uv.lock @@ -1981,7 +1981,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.45" +version = "26.6.46" source = { editable = "." } dependencies = [ { name = "pydantic" },