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

---

## v26.05.06 (2026-05-31)

### Hardening pass — framework-wide bug fixes

A deep audit of the whole framework surfaced a class of *silent wiring gaps*
and correctness bugs — features that existed but were never connected to the
runtime path, so the test suite passed while the behaviour was broken. This
release fixes them. The full test suite passes and CI (`ruff`, `ruff format`,
`mypy --strict`) is green.

#### Admin dashboard

- **HTTP traces are now recorded.** The `TraceCollectorFilter` was resolved
from the DI container at `create_app()` time — before beans are instantiated —
so it was always `None` and never joined the request filter chain. It is now
created and owned by `create_app` and wired into the chain; `/admin/api/traces`
and the SSE trace stream show real traffic.
- **Live updates (SSE) now stream.** `WebFilterChainMiddleware` buffered the
*entire* response body before returning, which hung every infinite SSE stream
(this broke **all** Server-Sent Events framework-wide, not just admin). The
filter chain now forwards streaming responses incrementally.
- **Server info** resolves lazily instead of showing `unknown`.

#### Dependency injection & AOP

- **Same-type beans no longer collapse.** Two `@bean` methods returning the same
concrete type overwrote each other in the type-keyed registry and vanished
from `list[T]` resolution. All registrations are now tracked and
`resolve_all`/`list[T]` returns every bean.
- **AOP advice is woven regardless of registration order.** Bean post-processing
is now two-pass (all `before_init`, then `post_construct`+`after_init`), so a
target initialised before its `@aspect` is still advised.
- **A side-effecting `@property` no longer aborts startup.** Weaving, scheduled-
task discovery and every context wiring/lifecycle scan now look attributes up
statically (`inspect.getattr_static`) instead of triggering property getters.
- **`RequestContextFilter` is wired by default**, so `REQUEST`-scoped beans and
`@pre_authorize`/`@post_authorize` work out of the box.

#### Web

- `RequestLoggingFilter`/middleware no longer crash on every request when
`structlog` is not installed (new `pyfly.logging.get_logger` shim).
- The FastAPI adapter now generates a correct OpenAPI document for controller
routes and honours `@controller_advice` global exception handlers.
- The health-indicator rescan hook now actually runs after startup, so
`/actuator/health` reflects `DOWN` subsystems instead of always reporting `UP`.
- `/actuator/prometheus` returns the Prometheus text exposition format (was JSON).

#### Transactional engine

- **Workflow `@compensation_step` now executes on failure** — completed
compensatable steps are rolled back in reverse order.
- **Transactional REST controllers** (`/api/orchestration`, `/dlq`, `/workflow`)
are now mounted as HTTP routes.
- The saga compensator records compensation outcomes, so
`SagaResult.compensated`/`compensation_result` are populated.
- Saga stale-recovery no longer raises `TypeError` (`started_at` is persisted as
a `datetime`; `get_stale` tolerates ISO strings).
- Workflow `@wait_for_all`/`@wait_for_any` timeouts are honoured (no unbounded waits).
- Fire-and-forget child workflows return the real child correlation id.

#### CQRS

- The query cache adapter now receives the `CacheAdapter`, so `@cacheable`
queries are actually cached.
- Domain-event publishing is wired when an EDA/messaging producer bean is
present (was a permanent no-op).

#### EDA, messaging & scheduling

- The EDA circuit breaker no longer gets permanently stuck `OPEN`.
- Kafka/RabbitMQ message-broker adapters handle `@message_listener` subscriptions
that arrive after `start()` (they previously never consumed).
- A scheduled `fixed_delay` task that raises no longer kills its loop.

#### Data, config, event sourcing, notifications, callbacks

- Derived-query stub detection no longer misclassifies documented repository
methods as real implementations (SQLAlchemy + MongoDB).
- MongoDB derived-query `LIKE` wildcards (`%`, `_`) are now translated to regex.
- `Config.bind()` resolves `${...}` placeholders and binds nested dataclass fields.
- `ConfigServer` filesystem backend writes back the file `fetch()` reads, so
saves are no longer silently shadowed by a stale `.yaml`.
- The event-sourcing `ProjectionRunner` no longer advances its cursor past a
failed event (at-least-once, in-order; was silent data loss).
- The SMTP notification provider no longer drops BCC recipients.
- Outbound callback/webhook HMAC signatures are computed over canonical JSON
(were computed over `str(dict)` — unverifiable).

#### Internal

- `pyfly.__version__` is back in sync with the packaged version (it was stale).
- Lint/format/type fixes across the EDA adapters and correlation surface.

---

## v26.05.05 (2026-05-19)

### Fixed — `PostgresEventBus` is multi-worker safe
Expand Down
2 changes: 2 additions & 0 deletions docs/modules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ The building blocks that every PyFly application relies on.
| [Core & Lifecycle](core.md) | Application bootstrap with `@pyfly_application`, startup/shutdown sequence, configuration loading, profile overlays, banner rendering |
| [Dependency Injection](dependency-injection.md) | `@service`, `@repository`, `@controller`, `@component` stereotypes, constructor injection, `Autowired()`, scopes (singleton, transient, request), `@bean` factories, `@primary`, `Qualifier`, conditional beans, lifecycle hooks |
| [Configuration](configuration.md) | YAML/TOML config files, `Config` class, profile-specific overlays, `@config_properties` binding, environment variable overrides |
| [Config Server](config-server.md) | Centralized config server (`ConfigServer`, `ConfigClient`), `ConfigBackend` SPI, in-memory & filesystem backends, Spring-Cloud-Config-compatible responses |
| [Error Handling](error-handling.md) | 25+ exception types, `ErrorResponse`, `ErrorCategory`, `ErrorSeverity`, HTTP status mapping, structured error responses, `@exception_handler` |

---
Expand Down Expand Up @@ -152,6 +153,7 @@ Monitor, schedule, and observe your applications in production.
| Guide | What You'll Learn |
|-------|-------------------|
| [Observability](observability.md) | `@timed`, `@counted`, `@span`, `MetricsRegistry`, `HealthChecker`, Prometheus metrics export, OpenTelemetry tracing, structured logging with correlation IDs |
| [Logging](logging.md) | `get_logger`, `LoggingPort`, structlog & stdlib adapters, structured `event + key=value` logging, runtime level control |
| [Scheduling](scheduling.md) | `@scheduled`, `@async_method`, `CronExpression`, `TaskScheduler`, fixed-rate tasks, fixed-delay tasks, asyncio and thread pool executors |

---
Expand Down
105 changes: 105 additions & 0 deletions docs/modules/config-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Config Server Guide

A lightweight, Spring-Cloud-Config-style **centralized configuration server**:
serve versioned config bundles (keyed by application + profile + label) to many
client services over HTTP.

---

## Table of Contents

1. [Introduction](#introduction)
2. [ConfigSource](#configsource)
3. [Backends](#backends)
4. [ConfigServer](#configserver)
5. [ConfigClient](#configclient)

---

## Introduction

The module has three parts:

- **`ConfigBackend`** — the storage SPI (a `Protocol`): `fetch`, `save`, `list`.
- **`ConfigServer`** — a framework-agnostic controller exposing config bundles
over HTTP (`base_path = "/config"`).
- **`ConfigClient`** — fetches a bundle from a remote `ConfigServer` on startup.

---

## ConfigSource

A bundle is identified by `application` + `profile` + `label`:

```python
from pyfly.config_server import ConfigSource

ConfigSource(
application="orders",
profile="prod",
label="main", # defaults to "main"
properties={"db.url": "..."},
)
```

---

## Backends

Two backends ship out of the box:

```python
from pyfly.config_server import InMemoryConfigBackend, FilesystemConfigBackend

backend = InMemoryConfigBackend() # great for tests
backend = FilesystemConfigBackend("/etc/pyfly") # reads/writes files
```

**`FilesystemConfigBackend`** stores each bundle as
`<root>/<label>/<application>-<profile>.{yaml,yml,json}`. `fetch()` resolves the
first matching file (preferring YAML); `save()` writes back to **the same file
`fetch()` reads** (in its own format) and removes stale duplicate-format files,
so a save can never be silently shadowed by a pre-existing `.yaml`.

Implement `ConfigBackend` to back the server with a database, S3, Git, etc.:

```python
@runtime_checkable
class ConfigBackend(Protocol):
async def fetch(self, application: str, profile: str, label: str = "main") -> ConfigSource | None: ...
async def save(self, source: ConfigSource) -> None: ...
async def list(self) -> list[ConfigSource]: ...
```

---

## ConfigServer

```python
from pyfly.config_server import ConfigServer, FilesystemConfigBackend

server = ConfigServer(FilesystemConfigBackend("/etc/pyfly"))

bundle = await server.fetch("orders", "prod") # Spring-Cloud-Config shaped dict
await server.save("orders", "prod", {"db.url": "postgres://..."})
all_sources = await server.list()
```

`fetch()` returns a Spring-Cloud-Config-compatible document
(`{name, profiles, label, propertySources: [...]}`), so existing Spring clients
can consume it. Mount the controller on your HTTP layer at `base_path` (`/config`).

---

## ConfigClient

A client service fetches its bundle from a remote server at startup:

```python
from pyfly.config_server import ConfigClient

client = ConfigClient(server_url="http://config:8888", application="orders", profile="prod")
properties = await client.fetch()
```

Merge the returned properties into your local `Config` before the context starts.
106 changes: 106 additions & 0 deletions docs/modules/logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Logging Guide

PyFly uses **structured logging** — every log call is an event name plus
key/value fields, rendered either by [`structlog`](https://www.structlog.org/)
(when installed) or a zero-dependency stdlib fallback.

---

## Table of Contents

1. [Introduction](#introduction)
2. [Getting a logger](#getting-a-logger)
3. [The LoggingPort](#the-loggingport)
4. [Adapters](#adapters)
5. [Configuration](#configuration)

---

## Introduction

The logging module is a hexagonal port (`LoggingPort`) with two adapters:

- **`StructlogAdapter`** — used when the `observability` extra (which ships
`structlog`) is installed. Produces rich, processor-based structured output.
- **`StdlibLoggingAdapter`** — a zero-dependency fallback built on the standard
library `logging` module. Renders structured fields as `event | key=value`.

Both accept the same **structlog-style call signature**: an event string
followed by arbitrary keyword fields.

```python
logger.info("http_request", method="GET", path="/orders", status_code=200)
```

---

## Getting a logger

Use `get_logger` — it returns a structured logger backed by `structlog` when
available, and the stdlib shim otherwise, so your call sites are identical
regardless of which extras are installed:

```python
from pyfly.logging import get_logger

logger = get_logger(__name__)

logger.info("order_placed", order_id="o-123", total=49.90)
logger.warning("retrying", attempt=2)
logger.error("payment_failed", error="declined", order_id="o-123")
```

> **Why not the stdlib logger directly?** A raw `logging.Logger` rejects
> arbitrary keyword arguments (`logger.info("event", method="GET")` raises
> `TypeError`). `get_logger` guarantees the structured signature works even
> without `structlog` installed.

---

## The LoggingPort

```python
from typing import Any, Protocol, runtime_checkable
from pyfly.core.config import Config

@runtime_checkable
class LoggingPort(Protocol):
def configure(self, config: Config) -> None: ...
def get_logger(self, name: str) -> Any: ...
def set_level(self, name: str, level: str) -> None: ...
```

The active adapter is selected automatically at startup and configured from the
`pyfly.logging.*` section.

---

## Adapters

| Adapter | When | Output |
|---|---|---|
| `StructlogAdapter` | `structlog` installed | structured (console or JSON) via structlog processors |
| `StdlibLoggingAdapter` | fallback | `event | key=value` via stdlib `logging` |

The stdlib shim (`_StructuredLogger`) wraps a `logging.Logger` and accepts
`debug/info/warning/error/critical/exception(event, **kwargs)`.

---

## Configuration

```yaml
pyfly:
logging:
level:
root: INFO
"pyfly.web": DEBUG # per-logger overrides
format: console # or "json"
```

- `level.root` — root log level.
- `level.<logger>` — per-logger level overrides.
- `format` — `console` (human-readable) or `json` (one JSON object per line).

The admin dashboard's **Loggers** view and the `/actuator/loggers` endpoint let
you inspect and change levels at runtime.
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.5.5"
version = "26.5.6"
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.05.04"
__version__ = "26.05.06"
19 changes: 18 additions & 1 deletion src/pyfly/actuator/adapters/starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@
import json

from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.responses import JSONResponse, PlainTextResponse, Response
from starlette.routing import Route

from pyfly.actuator.endpoints.health_endpoint import HealthEndpoint
from pyfly.actuator.endpoints.loggers_endpoint import LoggersEndpoint
from pyfly.actuator.endpoints.prometheus_endpoint import PrometheusEndpoint
from pyfly.actuator.registry import ActuatorRegistry


Expand Down Expand Up @@ -50,12 +51,28 @@ async def index_endpoint(request: Request) -> JSONResponse:
routes.extend(_make_health_routes(ep))
elif isinstance(ep, LoggersEndpoint):
routes.extend(_make_loggers_routes(ep))
elif isinstance(ep, PrometheusEndpoint):
routes.append(_make_prometheus_route(ep))
else:
routes.append(_make_generic_route(eid, ep))

return routes


def _make_prometheus_route(ep: PrometheusEndpoint) -> Route:
"""Prometheus scrape endpoint — must serve the raw text exposition format
(``text/plain; version=0.0.4``), not a JSON wrapper."""

async def handler(request: Request) -> Response:
data = await ep.handle()
return PlainTextResponse(
data.get("body", ""),
media_type=data.get("content_type") or "text/plain; version=0.0.4; charset=utf-8",
)

return Route("/actuator/prometheus", handler, methods=["GET"])


def _make_health_routes(ep: HealthEndpoint) -> list[Route]:
"""Health endpoint returns dynamic status codes (200/503)."""

Expand Down
Loading
Loading