From 8288a4e3001ef449b744076ce058182be2a7b815 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Wed, 9 Sep 2026 19:11:33 -0700 Subject: [PATCH] fix: two silently-inert capabilities found by building a service on 26.07.01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - observability: TracingAutoConfiguration read OTEL_EXPORTER_OTLP_ENDPOINT — a BASE url per the OTel spec — and passed it to OTLPSpanExporter(endpoint=...), which wants the COMPLETE signal url. An operator setting the spec-correct http://collector:4318 got an exporter POSTing to a non-signal path, so every span was dropped with nothing logged. Both spellings now work, from either the env var or pyfly.observability.tracing.otlp.endpoint. - web: collect_route_metadata() looked only at __pyfly_mapping__, so @sse_mapping routes never reached /openapi.json or `pyfly openapi`. SSE is plain HTTP and is now emitted as the GET it is, with a text/event-stream response. WebSocket has no OpenAPI representation, so @websocket_mapping routes are published under the new document-level x-pyfly-websocket-routes extension rather than omitted in silence — a deleted stream used to read as no change in an OpenAPI diff. - dev: opentelemetry-exporter-otlp-proto-http joins the dev group. OTLP is the default exporter yet appeared in no extra, so CI never imported it and the whole OTLP path, endpoint bug included, went unexercised. Also corrects the README version badge, left at 26.06.114 by the v26.07.01 release. Release 26.09.01. --- CHANGELOG.md | 46 ++++++ README.md | 2 +- pyproject.toml | 7 +- src/pyfly/__init__.py | 2 +- src/pyfly/cli/openapi.py | 6 +- src/pyfly/observability/auto_configuration.py | 32 +++- src/pyfly/web/adapters/fastapi/app.py | 3 +- src/pyfly/web/adapters/fastapi/controller.py | 4 + src/pyfly/web/adapters/starlette/app.py | 3 +- .../web/adapters/starlette/controller.py | 68 ++++++++- src/pyfly/web/openapi.py | 24 ++- .../observability/test_wave_observability.py | 56 +++++++ tests/web/test_openapi.py | 89 ++++++++++++ uv.lock | 137 +++++++++++++++++- 14 files changed, 462 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58139371..eacb06c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8b9a71b8..be188bce 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.114 + Version: 26.09.01 Type Checked: mypy strict Code Style: Ruff Async First diff --git a/pyproject.toml b/pyproject.toml index 60bdc59c..5dc9e7b5 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.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" @@ -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"] diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index e0a5fafc..94b20eb2 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.07.01" +__version__ = "26.09.01" diff --git a/src/pyfly/cli/openapi.py b/src/pyfly/cli/openapi.py index f23c7b75..245f2ef1 100644 --- a/src/pyfly/cli/openapi.py +++ b/src/pyfly/cli/openapi.py @@ -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") diff --git a/src/pyfly/observability/auto_configuration.py b/src/pyfly/observability/auto_configuration.py index 390813b4..5dff78c9 100644 --- a/src/pyfly/observability/auto_configuration.py +++ b/src/pyfly/observability/auto_configuration.py @@ -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. @@ -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 diff --git a/src/pyfly/web/adapters/fastapi/app.py b/src/pyfly/web/adapters/fastapi/app.py index 4bbd8608..5b723290 100644 --- a/src/pyfly/web/adapters/fastapi/app.py +++ b/src/pyfly/web/adapters/fastapi/app.py @@ -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 diff --git a/src/pyfly/web/adapters/fastapi/controller.py b/src/pyfly/web/adapters/fastapi/controller.py index 7e06a498..01890107 100644 --- a/src/pyfly/web/adapters/fastapi/controller.py +++ b/src/pyfly/web/adapters/fastapi/controller.py @@ -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. diff --git a/src/pyfly/web/adapters/starlette/app.py b/src/pyfly/web/adapters/starlette/app.py index cae5aca7..2ccbdf0e 100644 --- a/src/pyfly/web/adapters/starlette/app.py +++ b/src/pyfly/web/adapters/starlette/app.py @@ -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( [ diff --git a/src/pyfly/web/adapters/starlette/controller.py b/src/pyfly/web/adapters/starlette/controller.py index fd1ecc22..ed0c839e 100644 --- a/src/pyfly/web/adapters/starlette/controller.py +++ b/src/pyfly/web/adapters/starlette/controller.py @@ -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: @@ -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) @@ -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. diff --git a/src/pyfly/web/openapi.py b/src/pyfly/web/openapi.py index d0f7c09b..36fcedc3 100644 --- a/src/pyfly/web/openapi.py +++ b/src/pyfly/web/openapi.py @@ -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] = {} @@ -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 # ------------------------------------------------------------------ @@ -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"} diff --git a/tests/observability/test_wave_observability.py b/tests/observability/test_wave_observability.py index 3c830a38..88e43d63 100644 --- a/tests/observability/test_wave_observability.py +++ b/tests/observability/test_wave_observability.py @@ -17,6 +17,7 @@ import importlib.util +import pytest from opentelemetry.sdk.trace import TracerProvider from pyfly.core.config import Config @@ -58,3 +59,58 @@ def test_tracer_provider_bean_installs_processor(self): Config({"pyfly": {"observability": {"tracing": {"exporter": "console"}}}}) ) assert len(_processors(provider)) == 1 + + +class TestOtlpEndpointNormalisation: + """``OTEL_EXPORTER_OTLP_ENDPOINT`` is a BASE url; the exporter's ``endpoint=`` is a FULL one. + + The OpenTelemetry specification is explicit: ``OTEL_EXPORTER_OTLP_ENDPOINT`` is a base to which the + SDK appends the per-signal path, while ``OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`` — and the exporter's + own ``endpoint=`` argument — is the complete URL, used verbatim. Reading the base variable and + handing 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 set the base variable to a value the spec says is not a base. + + Both spellings must now work, from either source. + """ + + @staticmethod + def _endpoint_of(provider) -> str: + processor = _processors(provider)[0] + return processor.span_exporter._endpoint + + @pytest.mark.skipif(not _OTLP_AVAILABLE, reason="opentelemetry-exporter-otlp is not installed") + def test_a_base_endpoint_gains_the_signal_path(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318") + provider = TracerProvider() + TracingAutoConfiguration._install_span_processor(provider, Config({})) + + assert self._endpoint_of(provider) == "http://collector:4318/v1/traces" + + @pytest.mark.skipif(not _OTLP_AVAILABLE, reason="opentelemetry-exporter-otlp is not installed") + def test_a_base_endpoint_with_a_trailing_slash_gains_it_once(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318/") + provider = TracerProvider() + TracingAutoConfiguration._install_span_processor(provider, Config({})) + + assert self._endpoint_of(provider) == "http://collector:4318/v1/traces" + + @pytest.mark.skipif(not _OTLP_AVAILABLE, reason="opentelemetry-exporter-otlp is not installed") + def test_a_full_signal_endpoint_is_left_alone(self, monkeypatch): + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318/v1/traces") + provider = TracerProvider() + TracingAutoConfiguration._install_span_processor(provider, Config({})) + + assert self._endpoint_of(provider) == "http://collector:4318/v1/traces" + + @pytest.mark.skipif(not _OTLP_AVAILABLE, reason="opentelemetry-exporter-otlp is not installed") + def test_the_pyfly_config_key_is_normalised_the_same_way(self, monkeypatch): + monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) + provider = TracerProvider() + TracingAutoConfiguration._install_span_processor( + provider, + Config({"pyfly": {"observability": {"tracing": {"otlp": {"endpoint": "http://collector:4318"}}}}}), + ) + + assert self._endpoint_of(provider) == "http://collector:4318/v1/traces" diff --git a/tests/web/test_openapi.py b/tests/web/test_openapi.py index 03ecf2d0..5cfe2565 100644 --- a/tests/web/test_openapi.py +++ b/tests/web/test_openapi.py @@ -803,3 +803,92 @@ async def test_redoc_has_expand_responses(self): client = TestClient(app) response = client.get("/redoc") assert "expandResponses" in response.text + + +# --------------------------------------------------------------------------- +# Streaming routes: @sse_mapping and @websocket_mapping +# +# collect_route_metadata() only ever looked at ``__pyfly_mapping__``, so a +# controller's SSE and WebSocket routes were absent from /openapi.json with +# nothing said about it. A CI job that exports the document and diffs it — the +# standard way to keep an HTTP surface honest — therefore could not see the +# streaming half of the API at all, and a removed stream looked like no change. +# +# SSE *is* expressible: it is a GET whose response body is text/event-stream, so +# it belongs in ``paths`` like any other operation. WebSocket is a different +# protocol and has no OpenAPI representation (that is what AsyncAPI is for), so +# it stays out of ``paths`` — but it is now listed under the document-level +# ``x-pyfly-websocket-routes`` extension, which makes the omission explicit and +# keeps the diff meaningful. +# --------------------------------------------------------------------------- + + +class _StreamingController: + __pyfly_stereotype__ = "rest_controller" + __pyfly_request_mapping__ = "/api/v1" + + def room_events(self): # pragma: no cover - metadata only + """Stream room events. + + Server-sent events for one room. + """ + + room_events.__pyfly_sse_mapping__ = {"path": "/rooms/{room_id}/events"} + + def room_socket(self): # pragma: no cover - metadata only + """Bidirectional room channel.""" + + room_socket.__pyfly_ws_mapping__ = {"path": "/rooms/{room_id}/ws"} + + +class _StreamingContainer: + def __init__(self, cls): + self._registrations = {cls: object()} + + +class _StreamingCtx: + def __init__(self, cls): + self.container = _StreamingContainer(cls) + + +def test_collect_route_metadata_includes_sse_routes_as_get(): + metadata = ControllerRegistrar().collect_route_metadata(_StreamingCtx(_StreamingController)) + + sse = [m for m in metadata if m.path == "/api/v1/rooms/{room_id}/events"] + assert len(sse) == 1, "an @sse_mapping route must reach the OpenAPI document" + assert sse[0].http_method == "GET" + assert sse[0].media_type == "text/event-stream" + + +def test_collect_route_metadata_leaves_websocket_routes_out_of_paths(): + metadata = ControllerRegistrar().collect_route_metadata(_StreamingCtx(_StreamingController)) + + assert not [m for m in metadata if m.path.endswith("/ws")], ( + "a WebSocket route has no OpenAPI representation and must not be emitted as an HTTP operation" + ) + + +def test_websocket_routes_are_declared_in_a_document_extension(): + registrar = ControllerRegistrar() + ctx = _StreamingCtx(_StreamingController) + + spec = OpenAPIGenerator("Streams", "1.0.0").generate( + registrar.collect_route_metadata(ctx), + websocket_routes=registrar.collect_websocket_routes(ctx), + ) + + assert spec["x-pyfly-websocket-routes"] == [ + {"path": "/api/v1/rooms/{room_id}/ws", "handler": "room_socket", "summary": "Bidirectional room channel."} + ] + + +def test_sse_operation_declares_an_event_stream_response(): + registrar = ControllerRegistrar() + spec = OpenAPIGenerator("Streams", "1.0.0").generate( + registrar.collect_route_metadata(_StreamingCtx(_StreamingController)) + ) + + operation = spec["paths"]["/api/v1/rooms/{room_id}/events"]["get"] + + assert "text/event-stream" in operation["responses"]["200"]["content"] + assert operation["summary"] == "Stream room events." diff --git a/uv.lock b/uv.lock index 78e4a5c9..2df823d0 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version < '3.14'", ] [[package]] @@ -130,6 +131,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" }, + { url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" }, + { url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" }, + { url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" }, + { url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" }, + { url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" }, +] + [[package]] name = "asgiref" version = "3.11.1" @@ -870,6 +926,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/c5/4353a188e2c335aee33269e8b654af228278cca8e5f0b4b5f11e5d0e9adb/googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a", size = 153905, upload-time = "2026-09-03T22:31:21.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/7a/7d79170c6ce6f12e109df2b3879d6b934010cf4f99aea8de8b7e5408c174/googleapis_common_protos-1.75.3-py3-none-any.whl", hash = "sha256:a018d2bf098ca9fb6faa08d5bb780e2a2c2f73c566f069761331386c9596d3f2", size = 306984, upload-time = "2026-09-03T22:30:45.133Z" }, +] + [[package]] name = "granian" version = "2.7.4" @@ -1737,6 +1805,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, +] + [[package]] name = "opentelemetry-instrumentation" version = "0.62b1" @@ -1784,6 +1882,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/5d/a6ab143d01772dab7d9e284893b12a630883c84168ac6dbcc2c4d95dca5b/opentelemetry_instrumentation_starlette-0.62b1-py3-none-any.whl", hash = "sha256:cef8901660d5742d867327fada336e7465abbda31933377edc20d07fa0475920", size = 11947, upload-time = "2026-04-24T13:22:17.097Z" }, ] +[[package]] +name = "opentelemetry-proto" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, +] + [[package]] name = "opentelemetry-sdk" version = "1.41.1" @@ -2059,6 +2169,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2160,7 +2285,7 @@ wheels = [ [[package]] name = "pyfly" -version = "26.6.110" +version = "26.9.1" source = { editable = "." } dependencies = [ { name = "pydantic" }, @@ -2168,6 +2293,9 @@ dependencies = [ ] [package.optional-dependencies] +argon2 = [ + { name = "argon2-cffi" }, +] cache = [ { name = "redis", extra = ["hiredis"] }, ] @@ -2331,6 +2459,7 @@ dev = [ { name = "jsonpath-ng" }, { name = "mongomock-motor" }, { name = "mypy" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -2346,6 +2475,7 @@ requires-dist = [ { name = "aiokafka", marker = "extra == 'kafka'", specifier = ">=0.14.0" }, { name = "aiosqlite", marker = "extra == 'data-relational'", specifier = ">=0.22.1" }, { name = "alembic", marker = "extra == 'data-relational'", specifier = ">=1.18.4" }, + { name = "argon2-cffi", marker = "extra == 'argon2'", specifier = ">=23.1.0" }, { name = "asyncpg", marker = "extra == 'postgresql'", specifier = ">=0.31.0" }, { name = "azure-storage-blob", marker = "extra == 'ecm-azure'", specifier = ">=12.19.0" }, { name = "bcrypt", marker = "extra == 'security'", specifier = ">=5.0.0" }, @@ -2395,7 +2525,7 @@ requires-dist = [ { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'web-fastapi'", specifier = ">=0.22.1" }, { name = "websockets", marker = "extra == 'websocket'", specifier = ">=12.0" }, ] -provides-extras = ["web", "data-relational", "testing", "testcontainers", "data-document", "postgresql", "eda", "fastapi", "granian", "hypercorn", "kafka", "rabbitmq", "redis", "cache", "client", "config-server-git", "grpc", "websocket", "idp-azure", "idp-keycloak", "idp-cognito", "ecm-aws", "ecm-azure", "observability", "scheduling", "pii", "security", "notifications", "cli", "shell", "web-fast", "web-fastapi", "full"] +provides-extras = ["web", "data-relational", "testing", "argon2", "testcontainers", "data-document", "postgresql", "eda", "fastapi", "granian", "hypercorn", "kafka", "rabbitmq", "redis", "cache", "client", "config-server-git", "grpc", "websocket", "idp-azure", "idp-keycloak", "idp-cognito", "ecm-aws", "ecm-azure", "observability", "scheduling", "pii", "security", "notifications", "cli", "shell", "web-fast", "web-fastapi", "full"] [package.metadata.requires-dev] dev = [ @@ -2404,6 +2534,7 @@ dev = [ { name = "jsonpath-ng", specifier = ">=1.8.0" }, { name = "mongomock-motor", specifier = ">=0.0.36" }, { name = "mypy", specifier = ">=1.20.2" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.41.1" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7.1.0" },