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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.06.95 (2026-06-10)

### Fixed

- **Union-safe bean-name derivation.** Registering a bean whose implementation type is
a PEP 604 union (`X | Y`, a `types.UnionType`) crashed application startup with
`AttributeError: 'types.UnionType' object has no attribute '__name__'`, surfaced as a
`BeanCreationException` from `ApplicationContext.start()`. Default bean-name derivation
now goes through a new union-safe `Registration.display_name`, which falls back to the
union member names (then `str()`) when `impl_type` has no `__name__`. The same property
is reused for scope-resolution error messages so they never raise either. Added
regression tests in `tests/container/test_registration_display_name.py`.

---

## v26.06.94 (2026-06-10)

### Docs (parity initiative — final cross-cutting sweep)
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.94-brightgreen" alt="Version: 26.06.94"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.95-brightgreen" alt="Version: 26.06.95"></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.94"
version = "26.6.95"
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
8 changes: 4 additions & 4 deletions src/pyfly/container/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ def _resolve_request_scoped(self, reg: Registration) -> Any:
if ctx is None:
raise RuntimeError(
f"No active request context for REQUEST-scoped bean "
f"{reg.impl_type.__name__}. Ensure a RequestContextFilter is active."
f"{reg.display_name}. Ensure a RequestContextFilter is active."
)

# Store request-scoped instances in the context's attributes
Expand All @@ -424,12 +424,12 @@ def _resolve_session_scoped(self, reg: Registration) -> Any:
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."
f"{reg.display_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"No HTTP session for SESSION-scoped bean {reg.display_name}. "
f"Ensure the session module (SessionFilter) is enabled."
)

Expand All @@ -448,7 +448,7 @@ def _resolve_custom_scoped(self, reg: Registration) -> Any:
if handler is None:
raise RuntimeError(
f"Custom scope {reg.scope!r} is not registered for bean "
f"{reg.impl_type.__name__}. Available: {sorted(self._custom_scopes)}. "
f"{reg.display_name}. Available: {sorted(self._custom_scopes)}. "
f"Call container.register_scope({reg.scope!r}, handler) first."
)
cache_key = f"__pyfly_bean_{reg.impl_type.__qualname__}"
Expand Down
23 changes: 22 additions & 1 deletion src/pyfly/container/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from typing import Any, get_args

from pyfly.container.types import Scope, ScopeSpec

Expand All @@ -44,3 +44,24 @@ class Registration:
# ``init_plan_built`` distinguishes "not built yet" from "built, trivial (None)".
init_plan: list[tuple[str, Any, bool]] | None = field(default=None, repr=False, compare=False)
init_plan_built: bool = field(default=False, repr=False, compare=False)

@property
def display_name(self) -> str:
"""Readable bean name: the explicit ``name`` if set, otherwise a
union-safe rendering of ``impl_type``.

PEP 604 unions (``X | Y``) and other typing constructs have no
``__name__``, so deriving a default name via ``impl_type.__name__``
raises ``AttributeError``. Fall back to the union member names, then
``str()``, so bean-name derivation never crashes on a union impl type.
"""
if self.name:
return self.name
impl = self.impl_type
direct = getattr(impl, "__name__", None)
if direct is not None:
return direct
args = get_args(impl)
if args:
return " | ".join(getattr(a, "__name__", None) or str(a) for a in args)
return str(impl)
6 changes: 3 additions & 3 deletions src/pyfly/context/application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ async def _do_start(self) -> None:
# Pass 1: before_init for every bean (collects all @aspect beans, etc.)
for group in instance_groups:
rep = group[0]
bean_name = rep.name or rep.impl_type.__name__
bean_name = rep.display_name
inst = rep.instance
for pp in sorted_pps:
inst = pp.before_init(inst, bean_name)
Expand All @@ -256,7 +256,7 @@ async def _do_start(self) -> None:
# Pass 2: @post_construct then after_init (weaving now sees every aspect)
for group in instance_groups:
rep = group[0]
bean_name = rep.name or rep.impl_type.__name__
bean_name = rep.display_name
await self._call_post_construct(rep.instance)
inst = rep.instance
for pp in sorted_pps:
Expand Down Expand Up @@ -977,7 +977,7 @@ def _post_init_lazy_bean(self, instance: Any, reg: Registration) -> Any:
batched startup passes for a single bean. Aspects are already collected
during startup, so a single bean weaves correctly here.
"""
bean_name = reg.name or reg.impl_type.__name__
bean_name = reg.display_name
sorted_pps = sorted(self._post_processors, key=lambda pp: get_order(type(pp)))
for pp in sorted_pps:
instance = pp.before_init(instance, bean_name)
Expand Down
40 changes: 40 additions & 0 deletions tests/container/test_registration_display_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Regression: Registration.display_name must be union-safe.

PEP 604 unions (``X | Y``) are ``types.UnionType`` and have no ``__name__``, so
deriving a default bean name via ``impl_type.__name__`` raised ``AttributeError``
during startup (``BeanCreationException``). ``display_name`` must never crash.
"""

from pyfly.container.registry import Registration


class _A:
pass


class _B:
pass


def test_display_name_uses_explicit_name() -> None:
assert Registration(impl_type=_A, name="myBean").display_name == "myBean"


def test_display_name_falls_back_to_type_name() -> None:
assert Registration(impl_type=_A).display_name == "_A"


def test_display_name_is_union_safe() -> None:
# The case that crashed bean-name derivation at startup.
reg = Registration(impl_type=(_A | _B))
assert reg.display_name == "_A | _B" # no AttributeError


def test_display_name_handles_arbitrary_typing_construct() -> None:
from typing import Optional

# Optional[_A] (== Union[_A, None]) and similar typing constructs must never
# raise; the exact rendering varies by Python version, but it is always a
# usable, non-empty name.
name = Registration(impl_type=Optional[_A]).display_name
assert isinstance(name, str) and name
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