diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39d3012f..bd11bbea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.06.99 (2026-06-12)
+
+### Fixed
+
+- **Admin bean-graph 500 on union / `TypeVar` beans.** The admin dashboard's
+ `#bean-graph` view (and `/beans`, `/beans/{name}`) returned a 500 whenever a bean's
+ registration key was not a plain class. The idiomatic `@bean def make_foo(self) -> Foo
+ | None` is the common trigger: `typing.get_type_hints` preserves `Foo | None` as a
+ `types.UnionType`, and the configuration processor registered that union object
+ directly as a `_registrations` key. A `types.UnionType` (and `TypeVar`) has no
+ `__name__`/`__qualname__`/`__module__`, so `BeansProvider`, which derived bean
+ names/types straight from the key, raised
+ `AttributeError: 'types.UnionType' object has no attribute '__name__'`. Notably this
+ only surfaced *after* the v26.06.95 startup fix made union beans bootable. Fixed at
+ three layers:
+ - **Source** (`ApplicationContext._process_configurations`): a non-class return hint
+ is no longer registered as a `_registrations` key — the concrete `type(result)`
+ registration already backs single-bean resolution, so nothing is lost.
+ - **Provider hardening** (`BeansProvider`): all bean-name / type-string derivation now
+ goes through union/`TypeVar`-safe `_key_name` / `_key_qualname` helpers, so admin
+ introspection never raises on an unusual registration key from any source.
+ - **Backstop** (Starlette admin adapter): admin API handlers now convert any
+ unexpected error into a logged, structured JSON 500 instead of a raw Starlette 500,
+ so a single odd bean can never blank an entire dashboard view.
+- **Admin bean-detail 500s on awkward bean metadata.** `/beans/{name}` additionally
+ hardened against three reproduced crashes: generic-alias condition values
+ (`@conditional_on_bean(list[str])`) are now coerced to JSON-serialisable strings; an
+ `@conditional_on_class` `check()` that raises is reported as *not passed* rather than
+ propagating; and a class-level descriptor whose `__get__` raises during the autowired
+ field scan is skipped (mirroring the existing lifecycle-scan guard).
+- **Stale `pyfly.__version__`.** `__version__` had drifted to `26.06.94` while the
+ package shipped `26.06.98`, so the admin dashboard's `framework_version`, the CLI
+ banner, generated SBOMs, and the admin static-asset cache-busting query all reported
+ the wrong version. It now tracks the release version again.
+
+Regression coverage drives the **real** `ApplicationContext`/`Container` (not the mocked
+container the prior admin tests used, which is exactly why these 500s shipped):
+`tests/context/test_bean_method_union_return.py` and
+`tests/admin/test_beans_provider_realcontext.py`.
+
+---
+
## v26.06.98 (2026-06-12)
### Added
diff --git a/README.md b/README.md
index 3b2d3a37..b649cca6 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml
index 3c7465e0..c4c69a57 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.98"
+version = "26.6.99"
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 c1de3ba4..964a84f4 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.94"
+__version__ = "26.06.99"
diff --git a/src/pyfly/admin/adapters/starlette.py b/src/pyfly/admin/adapters/starlette.py
index b7060d7c..07fe7eaf 100644
--- a/src/pyfly/admin/adapters/starlette.py
+++ b/src/pyfly/admin/adapters/starlette.py
@@ -16,6 +16,7 @@
from __future__ import annotations
import json
+import logging
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
@@ -48,6 +49,9 @@
from pyfly.admin.server.instance_registry import InstanceRegistry
+_logger = logging.getLogger("pyfly.admin")
+
+
class _NoCacheStaticFiles(StaticFiles):
"""Serve admin assets with ``Cache-Control: no-cache`` so browsers revalidate.
@@ -131,13 +135,26 @@ def _auth_failure(self) -> JSONResponse | None:
return None
def _guarded(self, handler: Callable[[Request], Awaitable[Response]]) -> Callable[[Request], Awaitable[Response]]:
- """Wrap an admin API handler so it enforces require_auth before running."""
+ """Wrap an admin API handler so it enforces require_auth before running.
+
+ Any unexpected error from the handler is converted to a structured JSON
+ 500 (and logged) instead of bubbling out as a raw Starlette 500. Admin
+ introspection runs over a live container and can encounter unusual bean
+ metadata; a single odd bean must never take down a whole dashboard view.
+ """
async def _wrapped(request: Request) -> Response:
denied = self._auth_failure()
if denied is not None:
return denied
- return await handler(request)
+ try:
+ return await handler(request)
+ except Exception:
+ _logger.exception("admin_api_handler_error path=%s", request.url.path)
+ return JSONResponse(
+ {"error": "Internal admin error", "path": request.url.path},
+ status_code=500,
+ )
return _wrapped
diff --git a/src/pyfly/admin/providers/beans_provider.py b/src/pyfly/admin/providers/beans_provider.py
index c695eb1d..b36371f3 100644
--- a/src/pyfly/admin/providers/beans_provider.py
+++ b/src/pyfly/admin/providers/beans_provider.py
@@ -48,7 +48,7 @@ def _infer_category(cls: type, stereotype: str) -> str:
if getattr(cls, "__pyfly_bean_method__", None):
return "bean_method"
- name = cls.__name__
+ name = BeansProvider._key_name(cls)
suffixes: list[tuple[str, str]] = [
("AutoConfiguration", "auto_configuration"),
("Adapter", "adapter"),
@@ -103,6 +103,40 @@ def _get_constructor_hints(cls: type) -> dict[str, Any]:
def _type_name(t: Any) -> str:
return getattr(t, "__name__", str(t))
+ @staticmethod
+ def _key_name(cls: Any) -> str:
+ """Union/TypeVar-safe display name for a registration key.
+
+ A registration key is not guaranteed to be a real class: a ``@bean``
+ factory typed ``-> Foo | None`` yields a ``types.UnionType`` key, and
+ ``TypeVar``/generic-alias keys are possible too. None of those have
+ ``__name__``, so fall back to ``__qualname__`` then ``str()`` — admin
+ introspection must never raise ``AttributeError`` (it surfaced as a 500
+ on ``/admin/api/beans/graph``).
+ """
+ return getattr(cls, "__name__", None) or getattr(cls, "__qualname__", None) or str(cls)
+
+ @staticmethod
+ def _key_qualname(cls: Any) -> str:
+ """Union/TypeVar-safe ``module.qualname`` string for a registration key."""
+ module = getattr(cls, "__module__", "") or ""
+ qualname = getattr(cls, "__qualname__", None) or getattr(cls, "__name__", None) or str(cls)
+ return f"{module}.{qualname}" if module else qualname
+
+ @staticmethod
+ def _json_safe(value: Any) -> Any:
+ """Coerce a condition/metadata value into something JSON-serialisable.
+
+ Type objects render as their name and primitives pass through; anything
+ else — generic aliases (``list[str]``), unions, ``TypeVar`` — becomes a
+ readable string so ``JSONResponse`` can never raise on the detail view.
+ """
+ if isinstance(value, type):
+ return value.__name__
+ if value is None or isinstance(value, (str, int, float, bool)):
+ return value
+ return str(value)
+
def _build_deps_list(self, cls: type) -> list[dict[str, str]]:
"""Build the flat dependency list used by ``get_beans``."""
return [
@@ -160,14 +194,19 @@ def _evaluate_conditions(cls: type) -> list[dict[str, Any]]:
cond_type = cond.get("type", "unknown")
if cond_type == "on_class":
check = cond.get("check")
- passed = bool(check()) if callable(check) else True
+ try:
+ passed = bool(check()) if callable(check) else True
+ except Exception:
+ # A user condition's ``check`` callable must never 500 the
+ # admin detail endpoint; treat an erroring check as failed.
+ passed = False
else:
# The bean is registered, so all startup conditions passed.
passed = True
- # Build a serialisable copy (skip the callable ``check`` key).
- entry: dict[str, Any] = {
- k: (v.__name__ if isinstance(v, type) else v) for k, v in cond.items() if k != "check"
- }
+ # Build a JSON-serialisable copy (skip the callable ``check`` key).
+ # Condition values may be generic aliases (``list[str]``), unions, or
+ # ``TypeVar`` — none JSON-serialisable — so coerce via ``_json_safe``.
+ entry: dict[str, Any] = {k: BeansProvider._json_safe(v) for k, v in cond.items() if k != "check"}
entry["passed"] = passed
result.append(entry)
return result
@@ -198,7 +237,12 @@ def _find_autowired_fields(cls: type) -> list[dict[str, Any]]:
"""Return Autowired field descriptors from class attributes."""
fields: list[dict[str, Any]] = []
for attr_name in vars(cls):
- val = getattr(cls, attr_name, None)
+ try:
+ val = getattr(cls, attr_name, None)
+ except Exception:
+ # A descriptor ``__get__`` that raises on class access must not
+ # 500 the admin detail endpoint (mirrors _find_lifecycle_methods).
+ continue
if isinstance(val, Autowired):
fields.append(
{
@@ -216,7 +260,7 @@ def _find_autowired_fields(cls: type) -> list[dict[str, Any]]:
async def get_beans(self) -> dict[str, Any]:
beans: list[dict[str, Any]] = []
for cls, reg in self._context.container._registrations.items():
- bean_name = reg.name or cls.__name__
+ bean_name = reg.name or self._key_name(cls)
stereotype = getattr(cls, "__pyfly_stereotype__", "none")
conditions = getattr(cls, "__pyfly_conditions__", [])
order = getattr(cls, "__pyfly_order__", None)
@@ -231,7 +275,7 @@ async def get_beans(self) -> dict[str, Any]:
beans.append(
{
"name": bean_name,
- "type": f"{cls.__module__}.{cls.__qualname__}",
+ "type": self._key_qualname(cls),
"scope": scope_name(reg.scope),
"stereotype": stereotype,
"category": category,
@@ -252,7 +296,7 @@ async def get_beans(self) -> dict[str, Any]:
async def get_bean_detail(self, name: str) -> dict[str, Any] | None:
for cls, reg in self._context.container._registrations.items():
- bean_name = reg.name or cls.__name__
+ bean_name = reg.name or self._key_name(cls)
if bean_name == name:
return await self._build_detail(cls, reg)
return None
@@ -329,7 +373,7 @@ async def get_bean_graph(self) -> dict[str, Any]:
registered_names: dict[type, str] = {}
for cls, reg in self._context.container._registrations.items():
- bean_name = reg.name or cls.__name__
+ bean_name = reg.name or self._key_name(cls)
registered_names[cls] = bean_name
stereotype = getattr(cls, "__pyfly_stereotype__", "none")
metrics = self._get_metrics_dict(cls)
@@ -338,7 +382,7 @@ async def get_bean_graph(self) -> dict[str, Any]:
{
"id": bean_name,
"name": bean_name,
- "type": f"{cls.__module__}.{cls.__qualname__}",
+ "type": self._key_qualname(cls),
"stereotype": stereotype,
"category": category,
"scope": scope_name(reg.scope),
@@ -351,7 +395,7 @@ async def get_bean_graph(self) -> dict[str, Any]:
# Build edges from constructor dependency hints
for cls, reg in self._context.container._registrations.items():
- source = reg.name or cls.__name__
+ source = reg.name or self._key_name(cls)
for _param_name, param_type in self._get_constructor_hints(cls).items():
base = self._extract_base_type(param_type)
if base is None:
@@ -362,7 +406,7 @@ async def get_bean_graph(self) -> dict[str, Any]:
# Build edges from @Autowired field injection
for cls, reg in self._context.container._registrations.items():
- source = reg.name or cls.__name__
+ source = reg.name or self._key_name(cls)
for _field_name, field_type in self._get_autowired_hints(cls).items():
base = self._extract_base_type(field_type)
if base is None:
@@ -400,12 +444,12 @@ async def _build_detail(self, cls: type, reg: Any) -> dict[str, Any]:
metrics = self._get_metrics_dict(cls)
return {
- "name": reg.name or cls.__name__,
- "type": f"{cls.__module__}.{cls.__qualname__}",
+ "name": reg.name or self._key_name(cls),
+ "type": self._key_qualname(cls),
"scope": scope_name(reg.scope),
"stereotype": stereotype,
"category": category,
- "module": cls.__module__,
+ "module": getattr(cls, "__module__", None),
"file": self._safe_getfile(cls),
"doc": inspect.getdoc(cls) or "",
"primary": getattr(cls, "__pyfly_primary__", False),
diff --git a/src/pyfly/context/application_context.py b/src/pyfly/context/application_context.py
index 63d75784..402271a8 100644
--- a/src/pyfly/context/application_context.py
+++ b/src/pyfly/context/application_context.py
@@ -502,13 +502,21 @@ def _process_configurations(self, *, auto: bool = False) -> None:
# shares the same instance/factory; the startup lifecycle and
# wiring passes de-duplicate by instance identity so the bean is
# never post-processed or subscribed twice (audit #113).
- if return_type not in self._container._registrations:
+ #
+ # A PEP 604 union / TypeVar / generic-alias return hint (e.g. the
+ # idiomatic ``Foo | None``) is *not* a real class: ``get_type_hints``
+ # preserves it as a ``types.UnionType``, which has no
+ # ``__name__``/``__qualname__``/``__module__``. It must never become a
+ # ``_registrations`` key — the concrete ``impl_type`` registered above
+ # already backs single-bean resolution, and a non-class key crashed the
+ # admin BeansProvider (500 on ``/admin/api/beans/graph``).
+ if isinstance(return_type, type) and return_type not in self._container._registrations:
self._container.register(return_type, scope=bean_scope)
return_reg = self._container._registrations[return_type]
return_reg.factory = factory
if bean_scope == Scope.SINGLETON:
return_reg.instance = result
- elif getattr(method, "__pyfly_bean_primary__", False):
+ elif isinstance(return_type, type) and getattr(method, "__pyfly_bean_primary__", False):
# A later @bean(primary=True) for the same return type must win the
# single-bean direct resolution (the @Bean @Primary semantics) —
# otherwise resolve() returns whichever @bean was processed first.
diff --git a/tests/admin/test_beans_provider_realcontext.py b/tests/admin/test_beans_provider_realcontext.py
new file mode 100644
index 00000000..e472ce0f
--- /dev/null
+++ b/tests/admin/test_beans_provider_realcontext.py
@@ -0,0 +1,134 @@
+# 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.
+"""Robustness regression for BeansProvider against a REAL container.
+
+The existing ``test_beans_provider_enhanced`` suite drives a *mocked* container
+whose keys are always vanilla classes with ``__name__``/``__qualname__`` and whose
+conditions are pre-sanitised. That blind spot let several admin-dashboard 500s ship
+undetected. These tests register the awkward shapes a live container can actually
+hold — union / ``TypeVar`` registration keys, generic-alias conditions, an
+``on_class`` check that raises, and a class-level descriptor that raises — and assert
+every admin endpoint returns a JSON-serialisable payload (i.e. no 500).
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, TypeVar
+
+import pytest
+
+from pyfly.admin.providers.beans_provider import BeansProvider
+from pyfly.container.container import Container
+from pyfly.container.types import Scope
+
+
+class _ProviderCtx:
+ def __init__(self, container: Container) -> None:
+ self.container = container
+
+
+def _provider(container: Container) -> BeansProvider:
+ return BeansProvider(_ProviderCtx(container)) # type: ignore[arg-type]
+
+
+class Alpha:
+ pass
+
+
+class Beta:
+ pass
+
+
+@pytest.mark.asyncio
+async def test_union_registration_key_is_introspectable() -> None:
+ c = Container()
+ c.register(Alpha, scope=Scope.SINGLETON)
+ c.register(Beta, scope=Scope.SINGLETON)
+ c.register(Alpha | Beta, scope=Scope.SINGLETON) # type: ignore[arg-type]
+ provider = _provider(c)
+
+ json.dumps(await provider.get_bean_graph())
+ json.dumps(await provider.get_beans())
+
+
+@pytest.mark.asyncio
+async def test_typevar_registration_key_is_introspectable() -> None:
+ t = TypeVar("T")
+ c = Container()
+ c.register(Alpha, scope=Scope.SINGLETON)
+ c.register(t, scope=Scope.SINGLETON) # type: ignore[arg-type]
+ provider = _provider(c)
+
+ json.dumps(await provider.get_bean_graph())
+ json.dumps(await provider.get_beans())
+
+
+@pytest.mark.asyncio
+async def test_generic_alias_condition_is_json_serialisable() -> None:
+ class Conditioned:
+ pass
+
+ # A @conditional_on_bean(list[str])-style condition stores a generic alias,
+ # which is not JSON-serialisable unless coerced.
+ Conditioned.__pyfly_conditions__ = [ # type: ignore[attr-defined]
+ {"type": "on_bean", "bean_type": list[str]},
+ ]
+ c = Container()
+ c.register(Conditioned, scope=Scope.SINGLETON)
+ provider = _provider(c)
+
+ detail = await provider.get_bean_detail("Conditioned")
+ assert detail is not None
+ json.dumps(detail)
+
+
+@pytest.mark.asyncio
+async def test_on_class_condition_check_that_raises_does_not_500() -> None:
+ def _boom() -> bool:
+ raise RuntimeError("condition check boom")
+
+ class Flaky:
+ pass
+
+ Flaky.__pyfly_conditions__ = [ # type: ignore[attr-defined]
+ {"type": "on_class", "module": "whatever", "check": _boom},
+ ]
+ c = Container()
+ c.register(Flaky, scope=Scope.SINGLETON)
+ provider = _provider(c)
+
+ detail = await provider.get_bean_detail("Flaky")
+ assert detail is not None
+ json.dumps(detail)
+ # The erroring check is reported as not-passed rather than crashing.
+ assert detail["conditions"][0]["passed"] is False
+
+
+@pytest.mark.asyncio
+async def test_raising_descriptor_field_does_not_500() -> None:
+ class RaisingDescriptor:
+ def __get__(self, obj: Any, owner: Any = None) -> Any:
+ raise ValueError("descriptor boom")
+
+ class HasRaisingField:
+ danger = RaisingDescriptor()
+
+ c = Container()
+ c.register(HasRaisingField, scope=Scope.SINGLETON)
+ provider = _provider(c)
+
+ detail = await provider.get_bean_detail("HasRaisingField")
+ assert detail is not None
+ json.dumps(detail)
diff --git a/tests/context/test_bean_method_union_return.py b/tests/context/test_bean_method_union_return.py
new file mode 100644
index 00000000..22e6b484
--- /dev/null
+++ b/tests/context/test_bean_method_union_return.py
@@ -0,0 +1,97 @@
+# 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.
+"""Regression: a ``@bean`` factory with a PEP 604 union return type (``Foo | None``)
+must not poison the container or the admin introspection endpoints.
+
+``typing.get_type_hints`` preserves ``Foo | None`` as a ``types.UnionType`` (it is
+*not* normalized to ``Optional``), and the configuration processor used to register
+that union object directly as a ``_registrations`` key. A ``types.UnionType`` has no
+``__name__``/``__qualname__``/``__module__``, so the admin BeansProvider — which
+derives names/types from the registration key — crashed with ``AttributeError``,
+surfacing as a 500 on ``/admin/api/beans/graph`` (and ``/beans``, ``/beans/{name}``).
+
+``Foo | None`` is the most idiomatic optional-bean signature in Python, so this
+broke the admin dashboard for ordinary apps.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from pyfly.admin.providers.beans_provider import BeansProvider
+from pyfly.container.bean import bean
+from pyfly.container.stereotypes import configuration
+from pyfly.context.application_context import ApplicationContext
+from pyfly.core.config import Config
+
+
+class Widget:
+ """A plain bean produced by a union-returning factory."""
+
+
+@configuration
+class WidgetConfig:
+ @bean
+ def make_widget(self) -> Widget | None:
+ return Widget()
+
+
+class _ProviderCtx:
+ """Minimal context exposing the real container for BeansProvider."""
+
+ def __init__(self, container: object) -> None:
+ self.container = container
+
+
+@pytest.mark.asyncio
+async def test_union_return_does_not_register_union_key() -> None:
+ ctx = ApplicationContext(Config({}))
+ ctx.register_bean(WidgetConfig)
+ await ctx.start()
+
+ # No non-class object (e.g. a types.UnionType) may become a registration key.
+ for key in ctx.container._registrations:
+ assert isinstance(key, type), f"non-class registration key leaked: {key!r}"
+
+
+@pytest.mark.asyncio
+async def test_union_return_bean_still_resolves() -> None:
+ ctx = ApplicationContext(Config({}))
+ ctx.register_bean(WidgetConfig)
+ await ctx.start()
+
+ # The concrete bean is still resolvable by its real type.
+ assert isinstance(ctx.get_bean(Widget), Widget)
+
+
+@pytest.mark.asyncio
+async def test_admin_endpoints_survive_union_return_bean() -> None:
+ ctx = ApplicationContext(Config({}))
+ ctx.register_bean(WidgetConfig)
+ await ctx.start()
+
+ provider = BeansProvider(_ProviderCtx(ctx.container)) # type: ignore[arg-type]
+
+ # Each admin endpoint must return a JSON-serialisable payload (no 500).
+ graph = await provider.get_bean_graph()
+ json.dumps(graph)
+
+ beans = await provider.get_beans()
+ json.dumps(beans)
+
+ for node in graph["nodes"]:
+ detail = await provider.get_bean_detail(node["name"])
+ json.dumps(detail)
diff --git a/uv.lock b/uv.lock
index ba0246ba..71e6a08c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2160,7 +2160,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.6.98"
+version = "26.6.99"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },