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

---

## v26.06.108 (2026-06-16)

### Added

- **The live actuator ``HealthAggregator`` is exposed on
``app.state.pyfly_health_aggregator``.** Consumers can now register extra
health indicators after ``create_app`` (e.g. a readiness-only probe for an
external dependency) without introspecting route closures. It is the *same*
aggregator the live health routes use — whether actuator runs on the main app
(shared management mode) or on the separate management port — so indicators
added through it are reflected on ``/actuator/health`` in either mode.

---

## v26.06.107 (2026-06-16)

### Added
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.107"
version = "26.6.108"
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.107"
__version__ = "26.06.108"
9 changes: 9 additions & 0 deletions src/pyfly/web/adapters/starlette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,15 @@ async def _lifespan_with_dynamic_wiring(app_: Starlette) -> AsyncIterator[None]:
app.state.pyfly_route_metadata = route_metadata
app.state.pyfly_docs_enabled = docs_enabled

# Expose the live actuator HealthAggregator so consumers can register extra
# health indicators after create_app (e.g. a readiness-only probe for an
# external dependency). This is the SAME aggregator the live health routes
# use — whether actuator runs on the main app (shared mode) or on the
# separate management app — so indicators added here are reflected on
# /actuator/health regardless of management mode.
if agg is not None:
app.state.pyfly_health_aggregator = agg

# Expose the post-start rescan for callers that manage their own lifespan.
app.state.pyfly_install_dynamic_wiring = lambda: _install_dynamic_wiring(app)
if actuator_active:
Expand Down
65 changes: 65 additions & 0 deletions tests/web/test_management_separation.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,71 @@ async def test_separate_mode_without_lifespan_degrades_to_shared() -> None:
assert any(p.startswith("/admin") for p in paths)


@pytest.mark.asyncio
async def test_health_aggregator_exposed_and_live_in_shared_mode() -> None:
# app.state.pyfly_health_aggregator must be the SAME aggregator backing the
# live health routes: an indicator added to it post-create_app shows up on
# /actuator/health (the mechanism cdm-mexico uses for its Fabric readiness probe).
from starlette.testclient import TestClient

from pyfly.actuator.health import HealthAggregator, HealthStatus, ProbeGroup

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

ctx = ApplicationContext(
Config({"pyfly": {"management": {"endpoints": {"web": {"exposure": {"include": "health"}}}}}})
)

@contextlib.asynccontextmanager
async def _lifespan(app_: Any):
await ctx.start()
app_.state.pyfly_install_dynamic_wiring()
yield
await ctx.stop()

app = create_app(context=ctx, docs_enabled=False, lifespan=_lifespan)
agg = getattr(app.state, "pyfly_health_aggregator", None)
assert isinstance(agg, HealthAggregator)

with TestClient(app) as client:
assert client.get("/actuator/health/readiness").status_code == 200
# Register a readiness-only DOWN indicator on the exposed aggregator.
agg.add_indicator("dep", _DownDep(), groups={ProbeGroup.READINESS})
readiness = client.get("/actuator/health/readiness")
assert readiness.status_code == 503
assert readiness.json()["components"]["dep"]["status"] == "DOWN"
# Readiness-only: liveness stays UP (the probe-group semantics hold).
assert client.get("/actuator/health/liveness").status_code == 200


@pytest.mark.asyncio
async def test_health_aggregator_exposed_in_separate_mode() -> None:
# Even when actuator runs on a separate management port, the aggregator is
# exposed on the main app's state (it is the same instance the management app
# serves), so consumers holding the main app can still register indicators.
ctx = ApplicationContext(
Config(
{
"pyfly": {
"server": {"port": 8080},
"management": {"server": {"port": 9096}, "endpoints": {"web": {"exposure": {"include": "*"}}}},
}
}
)
)
await ctx.start()
try:
from pyfly.actuator.health import HealthAggregator

# No lifespan entry → the management listener never binds a real port.
app = create_app(context=ctx, docs_enabled=False, lifespan=_noop_lifespan)
assert isinstance(getattr(app.state, "pyfly_health_aggregator", None), HealthAggregator)
finally:
await ctx.stop()


@pytest.mark.asyncio
async def test_equal_port_stays_shared() -> None:
paths = await _main_paths(
Expand Down
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