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
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.97-brightgreen" alt="Version: 26.06.97"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.99-brightgreen" alt="Version: 26.06.99"></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.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"
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.94"
__version__ = "26.06.99"
21 changes: 19 additions & 2 deletions src/pyfly/admin/adapters/starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from __future__ import annotations

import json
import logging
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
78 changes: 61 additions & 17 deletions src/pyfly/admin/providers/beans_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
{
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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),
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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),
Expand Down
12 changes: 10 additions & 2 deletions src/pyfly/context/application_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading