From 9452426c28bcd14ed5633315e88ea8c953c4582f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Fri, 12 Jun 2026 18:55:30 +0200 Subject: [PATCH] =?UTF-8?q?fix(admin):=20union/TypeVar-safe=20bean=20intro?= =?UTF-8?q?spection=20=E2=80=94=20admin=20bean-graph=20500=20(v26.06.99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin dashboard's #bean-graph view (and /beans, /beans/{name}) 500'd 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, which the configuration processor registered directly as a _registrations key. UnionType/TypeVar have no __name__/__qualname__/__module__, so BeansProvider — deriving names/types straight from the key — raised AttributeError. This only surfaced after the v26.06.95 startup fix made union beans bootable. Fixed at three layers: - Source: ApplicationContext no longer registers a non-class return hint as a _registrations key (the concrete type(result) registration already backs single-bean resolution, so nothing is lost). - Provider: BeansProvider derives names/types via union/TypeVar-safe _key_name / _key_qualname helpers; condition values are coerced JSON-safe; a raising on_class check() is reported not-passed; a raising descriptor in the autowired-field scan is skipped. - Backstop: admin API handlers convert any unexpected error into a logged, structured JSON 500 instead of a raw Starlette 500. Also realigns pyfly.__version__ (had drifted to 26.06.94) so framework_version, the CLI banner, SBOMs, and admin asset cache-busting report the shipped version. Regression tests drive the REAL ApplicationContext/Container (not the mocked container the prior admin tests used, which is why these 500s shipped): tests/context/test_bean_method_union_return.py, tests/admin/test_beans_provider_realcontext.py. Bumps 26.6.98 -> 26.6.99; updates CHANGELOG + README badge. --- CHANGELOG.md | 42 ++++++ README.md | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/admin/adapters/starlette.py | 21 ++- src/pyfly/admin/providers/beans_provider.py | 78 +++++++--- src/pyfly/context/application_context.py | 12 +- .../admin/test_beans_provider_realcontext.py | 134 ++++++++++++++++++ .../context/test_bean_method_union_return.py | 97 +++++++++++++ uv.lock | 2 +- 10 files changed, 367 insertions(+), 25 deletions(-) create mode 100644 tests/admin/test_beans_provider_realcontext.py create mode 100644 tests/context/test_bean_method_union_return.py 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 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.97 + Version: 26.06.99 Type Checked: mypy strict Code Style: Ruff Async First 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" },