Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.45-brightgreen" alt="Version: 26.06.45"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.46-brightgreen" alt="Version: 26.06.46"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.45"
__version__ = "26.06.46"
36 changes: 36 additions & 0 deletions src/pyfly/container/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/pyfly/container/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ class Scope(Enum):
SINGLETON = auto()
TRANSIENT = auto()
REQUEST = auto()
SESSION = auto()
4 changes: 4 additions & 0 deletions src/pyfly/context/request_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/pyfly/session/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions tests/container/test_session_scope.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading