diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a567d6..a4aa2b9 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 3669db1..6077ad0 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 181b757..c801c44 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 62d4991..2c93c30 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 e9d3e18..a1ec260 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 56c7c51..a94950d 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 beae002..947bb90 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 6516182..5dc9ade 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 0000000..fe591d4 --- /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 30fc00d..7992b2d 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" },