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

---

## v26.09.01 (2026-09-09)

Found by building a real service on `26.07.01`. Two defects, both of the same shape: a capability the
framework advertises, wired in a way that silently did nothing.

### Fixed

- **The OTLP endpoint is now built to the OpenTelemetry specification.** `OTEL_EXPORTER_OTLP_ENDPOINT`
is a BASE url — the SDK appends the per-signal path to it — while `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`
and the exporter's own `endpoint=` argument are the COMPLETE url, used verbatim. `TracingAutoConfiguration`
read the base variable and handed it straight to `OTLPSpanExporter(endpoint=...)`, collapsing the two: an
operator who set the spec-correct `http://collector:4318` got an exporter POSTing to
`http://collector:4318`, which is not a signal endpoint, so **every span was dropped and nothing was
logged**. The only way to make it work was to write a value into the base variable that the spec says is
not a base. Both spellings work now — a url with no path is treated as a base and gains `/v1/traces`,
a url that already has one is left alone — and the same normalisation applies to
`pyfly.observability.tracing.otlp.endpoint`.

- **`@sse_mapping` routes appear in the OpenAPI document.** `collect_route_metadata()` looked only at
`__pyfly_mapping__`, so server-sent-event routes were absent from `/openapi.json` and from
`pyfly openapi` with nothing said about it — a CI job that exports the document and diffs it, which is
the standard way to keep an HTTP surface honest, could not see the streaming half of the API at all, and
a deleted stream read as no change. SSE is plain HTTP, so it is now emitted as the GET it is, with a
`text/event-stream` success response.

### Added

- **`x-pyfly-websocket-routes`.** WebSocket has no OpenAPI representation — that is what AsyncAPI is for —
but leaving `@websocket_mapping` routes out of the document entirely made it quietly incomplete in the
same way SSE was. `ControllerRegistrar.collect_websocket_routes()` now reports them and the generator
publishes them under this document-level extension: still not operations, but visible, diffable, and
honest about what the document does not cover. Wired through `pyfly openapi`, the Starlette adapter and
the FastAPI adapter alike.

- **`opentelemetry-exporter-otlp-proto-http` is a dev dependency.** OTLP is the exporter
`TracingAutoConfiguration` selects by default as soon as an endpoint is configured, yet it appeared in no
extra, so no CI job ever imported it and the entire OTLP path — the endpoint bug above included — was
unexercised. It is a dev dependency rather than a new runtime one: applications still choose and pay for
their own exporter.

### Changed

- The README version badge, which the `v26.07.01` release left at `26.06.114`.

---

## v26.07.01 (2026-07-16)

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,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="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.06.114-brightgreen" alt="Version: 26.06.114"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.01-brightgreen" alt="Version: 26.09.01"></a>
<a href="https://mypy-lang.org/"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="https://docs.astral.sh/ruff/"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#philosophy"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
7 changes: 6 additions & 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.7.1"
version = "26.9.1"
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 Expand Up @@ -171,6 +171,11 @@ dev = [
"jsonpath-ng>=1.8.0",
"respx>=0.21.0",
"aiosmtpd>=1.4",
# The OTLP exporter is the DEFAULT tracing exporter (auto_configuration selects it as soon as an
# endpoint is configured), yet it appears in no extra, so nothing in CI ever imported it and the
# whole OTLP path — including how the endpoint is built — went unexercised. It is a dev dependency
# rather than a new runtime one: applications choose their exporter and pay for it deliberately.
"opentelemetry-exporter-otlp-proto-http>=1.41.1",
]

[project.entry-points."pyfly.auto_configuration"]
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.07.01"
__version__ = "26.09.01"
6 changes: 4 additions & 2 deletions src/pyfly/cli/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ def _build_spec(ctx: Any) -> dict[str, Any]:
version: str = str(ctx.config.get("pyfly.app.version", "0.1.0"))
description: str = str(ctx.config.get("pyfly.app.description", ""))

route_metadata = ControllerRegistrar().collect_route_metadata(ctx)
registrar = ControllerRegistrar()
route_metadata = registrar.collect_route_metadata(ctx)
websocket_routes = registrar.collect_websocket_routes(ctx)
generator = OpenAPIGenerator(title=title, version=version, description=description)
return generator.generate(route_metadata or None)
return generator.generate(route_metadata or None, websocket_routes=websocket_routes or None)


@click.command("openapi")
Expand Down
32 changes: 31 additions & 1 deletion src/pyfly/observability/auto_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,32 @@ def tracer_provider(self, config: Config) -> TracerProvider:
trace.set_tracer_provider(provider)
return provider

_OTLP_TRACES_PATH = "/v1/traces"

@classmethod
def _otlp_traces_endpoint(cls, configured: str) -> str:
"""Turn a configured OTLP endpoint into the full traces URL the exporter wants.

The OpenTelemetry specification draws a line this method restores. ``OTEL_EXPORTER_OTLP_ENDPOINT``
is a BASE url — the SDK appends the per-signal path to it — while ``OTEL_EXPORTER_OTLP_TRACES_ENDPOINT``
and the exporter's own ``endpoint=`` argument are the COMPLETE url, used verbatim. Reading the base
variable and passing it straight to ``OTLPSpanExporter(endpoint=...)`` collapsed the two: an operator
who set the spec-correct ``http://collector:4318`` got an exporter POSTing to ``http://collector:4318``,
which is not a signal endpoint, and every span was dropped with nothing logged. The only way to make
it work was to write a value into the base variable that the spec says is not a base.

Both spellings work now. A url whose path is empty (or bare ``/``) is treated as a base and gains
``/v1/traces``; anything with a path is taken as already complete and returned untouched.
"""
from urllib.parse import urlparse

parsed = urlparse(configured)

if parsed.path in ("", "/"):
return configured.rstrip("/") + cls._OTLP_TRACES_PATH

return configured

@staticmethod
def _install_span_processor(provider: Any, config: Config) -> None:
"""Wire a BatchSpanProcessor + exporter chosen from configuration.
Expand Down Expand Up @@ -116,7 +142,11 @@ def _install_span_processor(provider: Any, config: Config) -> None:
"pyfly.observability.tracing.exporter=console."
)
return
exporter = OTLPSpanExporter(endpoint=otlp_endpoint) if otlp_endpoint else OTLPSpanExporter()
exporter = (
OTLPSpanExporter(endpoint=TracingAutoConfiguration._otlp_traces_endpoint(otlp_endpoint))
if otlp_endpoint
else OTLPSpanExporter()
)
provider.add_span_processor(BatchSpanProcessor(exporter))
return

Expand Down
3 changes: 2 additions & 1 deletion src/pyfly/web/adapters/fastapi/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,8 @@ def _install_indicators() -> None:
# the ``openapi_url``/``docs_url``/``redoc_url`` set on the constructor.
if docs_enabled:
generator = OpenAPIGenerator(title=title, version=version, description=description)
spec = generator.generate(route_metadata or None)
websocket_routes = registrar.collect_websocket_routes(context) if context is not None else []
spec = generator.generate(route_metadata or None, websocket_routes=websocket_routes or None)

def _custom_openapi() -> dict[str, object]:
app.openapi_schema = spec
Expand Down
4 changes: 4 additions & 0 deletions src/pyfly/web/adapters/fastapi/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ def collect_route_metadata(self, ctx: Any) -> list[RouteMetadata]:
"""
return ControllerRegistrar().collect_route_metadata(ctx)

def collect_websocket_routes(self, ctx: Any) -> list[dict[str, str]]:
"""Delegate to the Starlette registrar; the attributes it reads are adapter-independent."""
return ControllerRegistrar().collect_websocket_routes(ctx)

def _collect_exception_handlers(self, instance: Any) -> dict[type[Exception], Any]:
"""Collect all @exception_handler methods from a controller instance.

Expand Down
3 changes: 2 additions & 1 deletion src/pyfly/web/adapters/starlette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,8 @@ def _install_indicators() -> None:
# Generate OpenAPI spec and doc routes
if docs_enabled:
generator = OpenAPIGenerator(title=title, version=version, description=description)
spec = generator.generate(route_metadata or None)
websocket_routes = registrar.collect_websocket_routes(context) if context is not None else []
spec = generator.generate(route_metadata or None, websocket_routes=websocket_routes or None)

routes.extend(
[
Expand Down
68 changes: 64 additions & 4 deletions src/pyfly/web/adapters/starlette/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ class RouteMetadata:
summary: str = ""
description: str = ""
deprecated: bool = False
media_type: str = "application/json"
"""Media type of the success response.

``application/json`` for an ordinary mapping, ``text/event-stream`` for an ``@sse_mapping``. SSE is
plain HTTP — a GET whose body is a stream of events — so it is perfectly describable in OpenAPI, and
it only ever went missing because the collector looked at a single attribute.
"""


async def _maybe_await(result: Any) -> Any:
Expand Down Expand Up @@ -139,12 +146,28 @@ class — no bean resolution needed.
continue

mapping = getattr(method_obj, "__pyfly_mapping__", None)
if mapping is None:
sse_mapping = getattr(method_obj, "__pyfly_sse_mapping__", None)

if mapping is None and sse_mapping is None:
# A @websocket_mapping lands here. WebSocket is a different protocol with no
# OpenAPI representation, so it is deliberately not an operation — but the
# omission is no longer silent: collect_websocket_routes() reports those routes
# and the generator publishes them as x-pyfly-websocket-routes.
continue

full_path = base_path + mapping["path"]
http_method = mapping["method"]
status_code = mapping.get("status_code", 200)
if mapping is not None:
full_path = base_path + mapping["path"]
http_method = mapping["method"]
status_code = mapping.get("status_code", 200)
media_type = "application/json"
elif sse_mapping is not None:
# Server-sent events are a GET that does not close. Describing it as one is what
# lets a CI job export /openapi.json and diff the WHOLE surface rather than only
# its request/response half.
full_path = base_path + sse_mapping["path"]
http_method = "GET"
status_code = 200
media_type = "text/event-stream"

# Extract parameter metadata and request body model from type hints
params, body_model = self._extract_param_metadata(method_obj)
Expand Down Expand Up @@ -173,11 +196,48 @@ class — no bean resolution needed.
summary=summary,
description=description,
deprecated=deprecated,
media_type=media_type,
)
)

return metadata

def collect_websocket_routes(self, ctx: Any) -> list[dict[str, str]]:
"""The ``@websocket_mapping`` routes of every controller, in declaration order.

WebSocket is not expressible in OpenAPI — that is what AsyncAPI is for — so these are
deliberately not operations. They were also simply absent from the generated document with
nothing said about them, which meant a service could delete a socket route and an OpenAPI diff
would report no change at all. Returning them here lets the generator publish them under the
document-level ``x-pyfly-websocket-routes`` extension: still not an operation, but visible,
diffable, and honest about what the document does not cover.
"""
routes: list[dict[str, str]] = []

for cls, _reg in ctx.container._registrations.items():
if getattr(cls, "__pyfly_stereotype__", "") not in self._CONTROLLER_STEREOTYPES:
continue

base_path = getattr(cls, "__pyfly_request_mapping__", "")

for attr_name in dir(cls):
method_obj = getattr(cls, attr_name, None)
ws_mapping = getattr(method_obj, "__pyfly_ws_mapping__", None) if method_obj else None

if ws_mapping is None:
continue

summary, _description = self._parse_docstring(method_obj)
routes.append(
{
"path": base_path + ws_mapping["path"],
"handler": attr_name,
"summary": summary,
}
)

return routes

@staticmethod
def _derive_tag(cls: type) -> str:
"""Derive an OpenAPI tag from the controller class name.
Expand Down
24 changes: 22 additions & 2 deletions src/pyfly/web/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,18 @@ def __init__(
self._description = description
self._schemas: dict[str, Any] = {}

def generate(self, route_metadata: list[RouteMetadata] | None = None) -> dict[str, Any]:
"""Generate a complete OpenAPI 3.1 spec as a dict."""
def generate(
self,
route_metadata: list[RouteMetadata] | None = None,
websocket_routes: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
"""Generate a complete OpenAPI 3.1 spec as a dict.

``websocket_routes`` — from ``ControllerRegistrar.collect_websocket_routes()`` — is published
under the ``x-pyfly-websocket-routes`` extension rather than as operations. WebSocket has no
OpenAPI representation, but leaving it out entirely made the document quietly incomplete: a
service could delete a socket route and a CI diff of /openapi.json would report no change.
"""
self._schemas = {}

paths: dict[str, Any] = {}
Expand All @@ -114,6 +124,9 @@ def generate(self, route_metadata: list[RouteMetadata] | None = None) -> dict[st
if self._schemas:
spec["components"] = {"schemas": self._schemas}

if websocket_routes:
spec["x-pyfly-websocket-routes"] = websocket_routes

return spec

# ------------------------------------------------------------------
Expand Down Expand Up @@ -233,6 +246,13 @@ def _build_responses(self, meta: RouteMetadata) -> dict[str, Any]:
}
},
}
elif meta.media_type != "application/json":
# An @sse_mapping: the body is a stream of text/event-stream frames, not a JSON document,
# and saying so is the difference between a described stream and an undescribed one.
responses[status] = {
"description": "Event stream",
"content": {meta.media_type: {"schema": {"type": "string"}}},
}
else:
responses[status] = {"description": "Successful response"}

Expand Down
Loading
Loading