From 52535a0d35f8fd7e27e2610054ca1acc2b32153a Mon Sep 17 00:00:00 2001 From: ancongui Date: Wed, 10 Jun 2026 15:18:25 +0200 Subject: [PATCH] fix(container): union-safe bean-name derivation; release v26.06.95 PEP 604 union impl types (X | Y) are types.UnionType and lack __name__, so default bean-name derivation ('rep.name or rep.impl_type.__name__') raised AttributeError during ApplicationContext.start() (surfaced as BeanCreationException), breaking startup for any app with a union-typed bean. Add a union-safe Registration.display_name (falls back to union member names then str()) and use it for bean-name derivation + scope-resolution error messages. Adds regression tests; bumps 26.6.94 -> 26.6.95; updates CHANGELOG + README badge. --- CHANGELOG.md | 15 +++++++ README.md | 2 +- pyproject.toml | 2 +- src/pyfly/container/container.py | 8 ++-- src/pyfly/container/registry.py | 23 ++++++++++- src/pyfly/context/application_context.py | 6 +-- .../test_registration_display_name.py | 40 +++++++++++++++++++ uv.lock | 2 +- 8 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 tests/container/test_registration_display_name.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a944f918..dba4b8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index cec5b4b1..0c4c9dee 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.94 + Version: 26.06.95 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index 6bb3de04..17b6ee55 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.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" diff --git a/src/pyfly/container/container.py b/src/pyfly/container/container.py index cc8ff747..7b5e5667 100644 --- a/src/pyfly/container/container.py +++ b/src/pyfly/container/container.py @@ -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 @@ -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." ) @@ -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__}" diff --git a/src/pyfly/container/registry.py b/src/pyfly/container/registry.py index a06edc97..c9c52ecc 100644 --- a/src/pyfly/container/registry.py +++ b/src/pyfly/container/registry.py @@ -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 @@ -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) diff --git a/src/pyfly/context/application_context.py b/src/pyfly/context/application_context.py index dc8b8bd6..63d75784 100644 --- a/src/pyfly/context/application_context.py +++ b/src/pyfly/context/application_context.py @@ -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) @@ -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: @@ -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) diff --git a/tests/container/test_registration_display_name.py b/tests/container/test_registration_display_name.py new file mode 100644 index 00000000..ab85754a --- /dev/null +++ b/tests/container/test_registration_display_name.py @@ -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 diff --git a/uv.lock b/uv.lock index 57409b82..90fbc760 100644 --- a/uv.lock +++ b/uv.lock @@ -2160,7 +2160,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.94" +version = "26.6.95" source = { editable = "." } dependencies = [ { name = "pydantic" },