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

---

## v26.06.98 (2026-06-12)

### Added

- **Public health-indicator container scan: `pyfly.actuator.install_health_indicators`.**
The scan that discovers `HealthIndicator` beans in a started `ApplicationContext` and
registers them on a `HealthAggregator` existed only as private closures inside the
FastAPI and Starlette `create_app` factories, each reaching into
`container._registrations`. It is now a public, documented helper in
`pyfly.actuator.wiring` (exported from `pyfly.actuator`, alongside
`build_actuator_routes`) built on the container's public
`registered_types()` / `get_registration()` surface, with an optional `groups=`
parameter to assign probe-group membership to every scanned indicator. Both web
adapters (actuator + admin paths) now delegate to it, and out-of-web-stack
processes — e.g. EDA workers that serve `/actuator/health/*` from a standalone
Starlette app — can wire indicators without copying framework internals.
`HealthAggregator` also gains a public `has_indicator(name)` accessor.
Tests in `tests/actuator/test_health_indicators_wiring.py`.

---

## v26.06.97 (2026-06-11)

### Fixed
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.97"
version = "26.6.98"
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
3 changes: 3 additions & 0 deletions src/pyfly/actuator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from pyfly.actuator.health import HealthAggregator, HealthIndicator, HealthResult, HealthStatus, ProbeGroup
from pyfly.actuator.ports import ActuatorEndpoint
from pyfly.actuator.registry import ActuatorRegistry
from pyfly.actuator.wiring import build_actuator_routes, install_health_indicators

__all__ = [
"ActuatorEndpoint",
Expand All @@ -25,4 +26,6 @@
"HealthResult",
"HealthStatus",
"ProbeGroup",
"build_actuator_routes",
"install_health_indicators",
]
6 changes: 5 additions & 1 deletion src/pyfly/actuator/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ def add_indicator(
) -> None:
"""Register a named health indicator with optional probe group membership."""
self._indicators[name] = indicator
self._groups[name] = groups if groups else set()
self._groups[name] = set(groups) if groups else set()

def has_indicator(self, name: str) -> bool:
"""Whether an indicator is registered under *name*."""
return name in self._indicators

async def check(self) -> HealthResult:
"""Run all indicators and return an aggregated health result.
Expand Down
40 changes: 39 additions & 1 deletion src/pyfly/actuator/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from pyfly.actuator.health import HealthAggregator
from pyfly.actuator.health import HealthAggregator, ProbeGroup
from pyfly.actuator.http_exchanges import HttpExchangeRecorder
from pyfly.context.application_context import ApplicationContext

Expand All @@ -45,6 +45,44 @@ def resolve_actuator_active(context: ApplicationContext | None, actuator_enabled
).lower() in ("true", "1", "yes")


def install_health_indicators(
context: ApplicationContext | None,
aggregator: HealthAggregator,
*,
groups: set[ProbeGroup] | None = None,
) -> None:
"""Register every instantiated ``HealthIndicator`` bean from *context* on *aggregator*.

The scan walks the container's registrations and picks up every bean whose
singleton instance implements :class:`pyfly.actuator.health.HealthIndicator`,
named after the bean name (falling back to the class name). Beans that have
never been instantiated (e.g. ``@lazy`` singletons that were never resolved)
are skipped, so callers should run the scan after the application context has
started. Indicators already registered on *aggregator* keep their existing
registration — re-running the scan is idempotent.

*groups* assigns probe-group membership to every indicator the scan adds;
``None`` keeps the default (the indicator participates in both liveness and
readiness).

The container keeps one registration per bean type, so two indicator beans
of the same concrete class are discovered only once (the last registered).
"""
if context is None:
return
from pyfly.actuator.health import HealthIndicator

container = context.container
for cls in container.registered_types():
reg = container.get_registration(cls)
if reg is None or reg.instance is None or not isinstance(reg.instance, HealthIndicator):
continue
name = reg.name or cls.__name__
if aggregator.has_indicator(name):
continue
aggregator.add_indicator(name, reg.instance, groups=groups)


def _health_show(config: Any, key: str) -> bool:
"""Map ``pyfly.management.endpoint.health.{key}`` (never|when-authorized|always)
to a boolean. Defaults to ``when-authorized`` (shown)."""
Expand Down
23 changes: 6 additions & 17 deletions src/pyfly/web/adapters/fastapi/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,8 @@ def _add(new_routes: object) -> None:
# Mount actuator endpoints when active (actuator_active resolved above).
agg = None
if actuator_active:
from pyfly.actuator.health import HealthAggregator, HealthIndicator
from pyfly.actuator.wiring import build_actuator_routes
from pyfly.actuator.health import HealthAggregator
from pyfly.actuator.wiring import build_actuator_routes, install_health_indicators

agg = HealthAggregator()

Expand All @@ -306,16 +306,7 @@ def _add(new_routes: object) -> None:
# ``create_app`` time, so we expose the scanner on ``app.state``
# and let the downstream lifespan rerun it after startup.
def _install_indicators() -> None:
if context is None:
return
seen = set(agg._indicators.keys()) # noqa: SLF001
for cls, reg in context.container._registrations.items():
if reg.instance is not None and isinstance(reg.instance, HealthIndicator):
indicator_name = reg.name or cls.__name__
if indicator_name in seen:
continue
agg.add_indicator(indicator_name, reg.instance)
seen.add(indicator_name)
install_health_indicators(context, agg)

_install_indicators()
app.state.pyfly_install_health_indicators = _install_indicators
Expand Down Expand Up @@ -363,13 +354,11 @@ def _install_indicators() -> None:
# Reuse health aggregator from actuator, or create one for admin
health_agg = agg
if health_agg is None:
from pyfly.actuator.health import HealthAggregator, HealthIndicator
from pyfly.actuator.health import HealthAggregator
from pyfly.actuator.wiring import install_health_indicators

health_agg = HealthAggregator()
for cls, reg in context.container._registrations.items():
if reg.instance is not None and isinstance(reg.instance, HealthIndicator):
indicator_name = reg.name or cls.__name__
health_agg.add_indicator(indicator_name, reg.instance)
install_health_indicators(context, health_agg)

admin_builder = AdminRouteBuilder(
properties=admin_props,
Expand Down
30 changes: 7 additions & 23 deletions src/pyfly/web/adapters/starlette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,8 +290,8 @@ def _collect_context_routes() -> list[Route]:
# Mount actuator endpoints when active (actuator_active resolved above).
agg = None
if actuator_active:
from pyfly.actuator.health import HealthAggregator, HealthIndicator
from pyfly.actuator.wiring import build_actuator_routes
from pyfly.actuator.health import HealthAggregator
from pyfly.actuator.wiring import build_actuator_routes, install_health_indicators

agg = HealthAggregator()

Expand All @@ -300,24 +300,15 @@ def _collect_context_routes() -> list[Route]:
# NOTE: ``create_app`` is typically called BEFORE the ApplicationContext
# has started (the startup happens inside the ASGI ``lifespan``
# function). At this point user / auto-configuration beans have
# been *registered* but not *instantiated*, so the eager loop only
# been *registered* but not *instantiated*, so the eager scan only
# finds indicators that were attached as static singletons.
#
# The remaining indicators are picked up by ``_install_indicators``
# which we attach to the Starlette ``on_startup`` hook below — by
# the time on_startup fires, the lifespan has already triggered
# ``PyFlyApplication.startup()`` and every bean has been built.
def _install_indicators() -> None:
if context is None:
return
seen = set(agg._indicators.keys()) # noqa: SLF001 — intentional, indicator names
for cls, reg in context.container._registrations.items():
if reg.instance is not None and isinstance(reg.instance, HealthIndicator):
indicator_name = reg.name or cls.__name__
if indicator_name in seen:
continue
agg.add_indicator(indicator_name, reg.instance)
seen.add(indicator_name)
install_health_indicators(context, agg)

_install_indicators()
_extra_post_start.append(_install_indicators)
Expand Down Expand Up @@ -380,23 +371,16 @@ def _install_indicators() -> None:
# Reuse health aggregator from actuator, or create one for admin
health_agg = agg
if health_agg is None:
from pyfly.actuator.health import HealthAggregator, HealthIndicator
from pyfly.actuator.health import HealthAggregator
from pyfly.actuator.wiring import install_health_indicators

health_agg = HealthAggregator()

def _install_admin_indicators(_agg: HealthAggregator = health_agg) -> None:
# HealthIndicator beans are only instantiated during start();
# rescan post-start so the admin health view isn't a frozen empty
# pre-startup snapshot when the actuator is disabled (audit #70).
if context is None:
return
seen = set(_agg._indicators.keys()) # noqa: SLF001
for cls, reg in context.container._registrations.items():
if reg.instance is not None and isinstance(reg.instance, HealthIndicator):
name = reg.name or cls.__name__
if name not in seen:
_agg.add_indicator(name, reg.instance)
seen.add(name)
install_health_indicators(context, _agg)

_install_admin_indicators()
_extra_post_start.append(_install_admin_indicators)
Expand Down
101 changes: 99 additions & 2 deletions tests/actuator/test_health_indicators_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,23 @@
# 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.
"""Tests for health-indicator wiring: protocol conformance, aggregation, show-details."""
"""Tests for health-indicator wiring: protocol conformance, aggregation, show-details,
and the public container scan (``install_health_indicators``)."""

from __future__ import annotations

import pytest
from starlette.testclient import TestClient

from pyfly.actuator.health import HealthIndicator, HealthResult, HealthStatus, aggregate_status
from pyfly.actuator.health import (
HealthAggregator,
HealthIndicator,
HealthResult,
HealthStatus,
ProbeGroup,
aggregate_status,
)
from pyfly.actuator.wiring import install_health_indicators
from pyfly.context.application_context import ApplicationContext
from pyfly.core.config import Config
from pyfly.cqrs.actuator.health import CqrsHealthIndicator
Expand Down Expand Up @@ -139,3 +148,91 @@ async def test_show_details_never_hides_details(self):
comp = body["components"]["_OutOfServiceIndicator"]
assert comp["status"] == "OUT_OF_SERVICE"
assert "details" not in comp


class _UpIndicator:
async def health(self) -> HealthStatus:
return HealthStatus(status="UP")


class _DownIndicator:
async def health(self) -> HealthStatus:
return HealthStatus(status="DOWN", details={"reason": "offline"})


class _NotAnIndicator:
pass


class TestInstallHealthIndicators:
@pytest.mark.asyncio
async def test_scans_started_context_for_indicator_beans(self):
ctx = ApplicationContext(Config({}))
ctx.register_bean(_UpIndicator)
ctx.register_bean(_NotAnIndicator)
await ctx.start()

agg = HealthAggregator()
install_health_indicators(ctx, agg)

assert agg.has_indicator("_UpIndicator")
assert not agg.has_indicator("_NotAnIndicator")
result = await agg.check()
assert result.status == "UP"
assert "_UpIndicator" in result.components

def test_none_context_is_a_no_op(self):
agg = HealthAggregator()
install_health_indicators(None, agg)
assert not agg.has_indicator("_UpIndicator")

def test_bean_name_wins_over_class_name(self):
ctx = ApplicationContext(Config({}))
ctx.container.register_instance(_UpIndicator, _UpIndicator(), name="database_health")

agg = HealthAggregator()
install_health_indicators(ctx, agg)

assert agg.has_indicator("database_health")
assert not agg.has_indicator("_UpIndicator")

@pytest.mark.asyncio
async def test_rescan_is_idempotent_and_preserves_existing_registration(self):
ctx = ApplicationContext(Config({}))
ctx.container.register_instance(_DownIndicator, _DownIndicator(), name="db")

agg = HealthAggregator()
pre_registered = _UpIndicator()
agg.add_indicator("db", pre_registered, groups={ProbeGroup.READINESS})
install_health_indicators(ctx, agg)
install_health_indicators(ctx, agg)

# The pre-registered "db" indicator (UP, readiness-only) is kept: the
# scanned DOWN indicator of the same name must not displace it.
result = await agg.check()
assert result.status == "UP"
liveness = await agg.check_liveness()
assert "db" not in liveness.components

@pytest.mark.asyncio
async def test_groups_apply_to_scanned_indicators(self):
ctx = ApplicationContext(Config({}))
ctx.container.register_instance(_DownIndicator, _DownIndicator(), name="db")

agg = HealthAggregator()
install_health_indicators(ctx, agg, groups={ProbeGroup.READINESS})

readiness = await agg.check_readiness()
assert readiness.status == "DOWN"
liveness = await agg.check_liveness()
assert "db" not in liveness.components
assert liveness.status == "UP"

def test_uninstantiated_beans_are_skipped(self):
ctx = ApplicationContext(Config({}))
ctx.register_bean(_UpIndicator) # registered, never resolved -> no instance

agg = HealthAggregator()
install_health_indicators(ctx, agg)

assert not agg.has_indicator("_UpIndicator")
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