Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 8fcfa06

Browse files
committed
feat(python): SSE streaming client support
Generated Python clients now expose endpoints declared with Builder::stream_route as iterators (AsyncIterator on the async client, Iterator on the sync client) instead of being filtered out of codegen. Runtime - New reflectapi_runtime.sse module: a small dependency-free SSE parser with sync and async variants, covering multi-line data, comments, CRLF, and EOF flush. - ClientBase / AsyncClientBase gained _make_sse_request, which sends Accept: text/event-stream, validates each event via TypeAdapter, and raises ApplicationError / NetworkError / TimeoutError / ValidationError in line with the non-streaming flow. The connection is released when the (async) generator is exhausted, closed, or garbage-collected. Codegen - Removed the OutputType::Stream filter and unreachable! in python.rs. - Function template carries stream_item_type; write_function emits a plain def returning the runtime (async) generator and imports Iterator / AsyncIterator from collections.abc when needed. Tests - 16 SSE parser/runtime unit tests using httpx.MockTransport. - 4 integration tests exercising the generated demo client end-to-end through MockTransport (sync + async, 4xx error, partial consumption). - A real-network test against the running demo server (gated on RUN_DEMO_E2E=1) confirming the full SSE round-trip. Docs - New "Streaming Endpoints" table in docs/src/clients/README.md. - New reflectapi-python-runtime README covering scope and the streaming consumer pattern.
1 parent a09aa0e commit 8fcfa06

10 files changed

Lines changed: 940 additions & 15 deletions

File tree

docs/src/clients/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ The demo repository includes extra project scaffolding around some generated cli
8383
- Supports optional tracing instrumentation through `--instrument`.
8484
- Generates serde-compatible types and request helpers for JSON-based transport.
8585

86+
## Streaming Endpoints
87+
88+
Endpoints registered with `Builder::stream_route` produce a stream of items
89+
rather than a single response. The wire format is Server-Sent Events: each
90+
item is sent as a `data: <json>\n\n` event. Errors raised before the stream
91+
opens are returned as a normal HTTP 4xx/5xx response, not as SSE events; the
92+
server does not emit heartbeats or end-of-stream markers, so streams end
93+
when the connection closes.
94+
95+
| Output | Streaming client surface |
96+
|--------|--------------------------|
97+
| TypeScript | Method returns `Promise<Result<AsyncIterable<Item>, Err<Error>>>`; consume with `for await`. |
98+
| Rust | Method returns `reflectapi::rt::StreamResponse<Item, AppError, NetError>` (a `Result<BoxStream<Result<Item, _>>, _>`). |
99+
| Python | Method returns `AsyncIterator[Item]` on the async client and `Iterator[Item]` on the sync client; raises `ApplicationError` on init 4xx/5xx and `ValidationError` on a malformed event. |
100+
| OpenAPI | Operation is described with `text/event-stream` response content. |
101+
86102
## Shared Characteristics
87103

88104
The generated clients all aim to provide:
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"api_client client generated by ReflectAPI."
22

3-
from .generated import AsyncClient, SyncClient
3+
from .generated import AsyncClient, Client
44

5-
__all__ = ["AsyncClient", "SyncClient"]
5+
__all__ = ["AsyncClient", "Client"]

reflectapi-demo/clients/python/generated.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
# Standard library imports
1212
import warnings
13+
from collections.abc import AsyncIterator, Iterator
1314
from datetime import datetime
1415
from enum import Enum
1516
from typing import Annotated, Any, Generic, Literal, Optional, TypeVar, Union
@@ -527,6 +528,27 @@ class AsyncPetsClient:
527528
def __init__(self, client: AsyncClientBase) -> None:
528529
self._client = client
529530

531+
def cdc_events(
532+
self,
533+
headers: Optional[myapi.proto.Headers] = None,
534+
) -> AsyncIterator[myapi.model.output.Pet]:
535+
"""Stream of change data capture events for pets
536+
537+
Returns:
538+
AsyncIterator[myapi.model.output.Pet]: SSE stream of myapi.model.output.Pet items
539+
"""
540+
path = "/pets.cdc-events"
541+
542+
params: dict[str, Any] = {}
543+
return self._client._make_sse_request(
544+
"POST",
545+
path,
546+
params=params if params else None,
547+
headers_model=headers,
548+
item_model=myapi.model.output.Pet,
549+
error_model=None,
550+
)
551+
530552
async def create(
531553
self,
532554
data: Optional[myapi.model.input.Pet] = None,
@@ -738,6 +760,27 @@ class PetsClient:
738760
def __init__(self, client: ClientBase) -> None:
739761
self._client = client
740762

763+
def cdc_events(
764+
self,
765+
headers: Optional[myapi.proto.Headers] = None,
766+
) -> Iterator[myapi.model.output.Pet]:
767+
"""Stream of change data capture events for pets
768+
769+
Returns:
770+
Iterator[myapi.model.output.Pet]: SSE stream of myapi.model.output.Pet items
771+
"""
772+
path = "/pets.cdc-events"
773+
774+
params: dict[str, Any] = {}
775+
return self._client._make_sse_request(
776+
"POST",
777+
path,
778+
params=params if params else None,
779+
headers_model=headers,
780+
item_model=myapi.model.output.Pet,
781+
error_model=None,
782+
)
783+
741784
def create(
742785
self,
743786
data: Optional[myapi.model.input.Pet] = None,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""End-to-end test of generated SSE streaming methods.
2+
3+
Uses ``httpx.MockTransport`` so the test does not need a running server,
4+
but exercises the *generated* client classes (sync + async) to make sure
5+
the codegen wiring is correct end-to-end.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
12+
import httpx
13+
import pytest
14+
15+
from generated import AsyncClient, Client, MyapiModelOutputPet as Pet
16+
from reflectapi_runtime import ApplicationError
17+
18+
19+
def _sse(items: list[dict]) -> bytes:
20+
return ("".join(f"data: {json.dumps(it)}\n\n" for it in items)).encode()
21+
22+
23+
@pytest.fixture
24+
def pet_payloads() -> list[dict]:
25+
# Pet on the wire from the demo schema: {"name": str, "kind": ..., "behaviors": [...]}.
26+
# Use minimal values that pass Pydantic validation.
27+
return [
28+
{
29+
"name": "fido",
30+
"kind": {"type": "dog", "breed": "lab"},
31+
"behaviors": [],
32+
"updated_at": "2026-01-01T00:00:00Z",
33+
},
34+
{
35+
"name": "whiskers",
36+
"kind": {"type": "cat", "lives": 9},
37+
"behaviors": [],
38+
"updated_at": "2026-01-02T00:00:00Z",
39+
},
40+
]
41+
42+
43+
def _async_client(handler) -> AsyncClient:
44+
return AsyncClient(
45+
"http://test",
46+
client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
47+
)
48+
49+
50+
def _sync_client(handler) -> Client:
51+
return Client(
52+
"http://test",
53+
client=httpx.Client(transport=httpx.MockTransport(handler)),
54+
)
55+
56+
57+
@pytest.mark.asyncio
58+
async def test_async_cdc_events_stream(pet_payloads):
59+
captured: dict[str, str] = {}
60+
61+
def handler(request: httpx.Request) -> httpx.Response:
62+
captured["accept"] = request.headers.get("accept", "")
63+
captured["path"] = request.url.path
64+
captured["method"] = request.method
65+
return httpx.Response(200, content=_sse(pet_payloads))
66+
67+
client = _async_client(handler)
68+
received = [pet async for pet in client.pets.cdc_events()]
69+
70+
assert captured["accept"] == "text/event-stream"
71+
assert captured["path"] == "/pets.cdc-events"
72+
assert captured["method"] == "POST"
73+
assert all(isinstance(p, Pet) for p in received)
74+
assert [p.name for p in received] == ["fido", "whiskers"]
75+
await client.aclose()
76+
77+
78+
def test_sync_cdc_events_stream(pet_payloads):
79+
def handler(request: httpx.Request) -> httpx.Response:
80+
return httpx.Response(200, content=_sse(pet_payloads))
81+
82+
client = _sync_client(handler)
83+
received = list(client.pets.cdc_events())
84+
assert [p.name for p in received] == ["fido", "whiskers"]
85+
client.close()
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_async_cdc_events_4xx_raises():
90+
def handler(request: httpx.Request) -> httpx.Response:
91+
return httpx.Response(401, json={"reason": "denied"})
92+
93+
client = _async_client(handler)
94+
with pytest.raises(ApplicationError) as ei:
95+
async for _ in client.pets.cdc_events():
96+
break
97+
assert ei.value.status_code == 401
98+
await client.aclose()
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_async_cdc_events_partial_consumption_closes_response():
103+
pets_lots = [
104+
{
105+
"name": f"p{i}",
106+
"kind": {"type": "dog", "breed": "x"},
107+
"behaviors": [],
108+
"updated_at": "2026-01-01T00:00:00Z",
109+
}
110+
for i in range(50)
111+
]
112+
113+
def handler(request: httpx.Request) -> httpx.Response:
114+
return httpx.Response(200, content=_sse(pets_lots))
115+
116+
client = _async_client(handler)
117+
stream = client.pets.cdc_events()
118+
first = await stream.__anext__()
119+
assert first.name == "p0"
120+
# closing the generator triggers the runtime's `finally: response.aclose()`.
121+
await stream.aclose()
122+
await client.aclose()
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Real-network end-to-end SSE test against the demo server.
2+
3+
Run with:
4+
5+
RUN_DEMO_E2E=1 uv run pytest test_streaming_e2e.py -p no:cacheprovider \\
6+
--override-ini=testpaths=.
7+
8+
Skipped unless ``RUN_DEMO_E2E=1`` is set.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import asyncio
14+
import os
15+
import uuid
16+
17+
import pytest
18+
19+
if os.environ.get("RUN_DEMO_E2E") != "1": # pragma: no cover
20+
pytest.skip("set RUN_DEMO_E2E=1 to run", allow_module_level=True)
21+
22+
from generated import ( # noqa: E402
23+
AsyncClient,
24+
MyapiModelInputPet as Pet,
25+
MyapiModelKindDog as Dog,
26+
MyapiProtoHeaders as Headers,
27+
MyapiProtoPetsRemoveRequest as RemoveRequest,
28+
)
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_cdc_events_receives_created_pet():
33+
"""Subscribe to the CDC stream, create a pet, and confirm we observe it."""
34+
client = AsyncClient("http://localhost:3000")
35+
headers = Headers(authorization="Bearer test")
36+
pet_name = f"sse-test-{uuid.uuid4().hex[:8]}"
37+
38+
received: list[str] = []
39+
cancel = asyncio.Event()
40+
41+
async def consumer() -> None:
42+
async for pet in client.pets.cdc_events(headers=headers):
43+
received.append(pet.name)
44+
if cancel.is_set() or pet.name == pet_name:
45+
break
46+
47+
task = asyncio.create_task(consumer())
48+
49+
# Give the subscriber a moment to register before publishing.
50+
await asyncio.sleep(0.2)
51+
52+
create = await client.pets.create(
53+
data=Pet(name=pet_name, kind=Dog(type="dog", breed="lab")),
54+
headers=headers,
55+
)
56+
assert create.metadata.status_code == 200
57+
58+
try:
59+
await asyncio.wait_for(task, timeout=5.0)
60+
except asyncio.TimeoutError:
61+
cancel.set()
62+
task.cancel()
63+
raise
64+
finally:
65+
# Cleanup: remove the test pet.
66+
try:
67+
await client.pets.remove(
68+
data=RemoveRequest(name=pet_name), headers=headers
69+
)
70+
except Exception:
71+
pass
72+
await client.aclose()
73+
74+
assert pet_name in received
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# reflectapi-runtime
2+
3+
Runtime support library for Python clients generated by
4+
[`reflectapi`](https://github.com/thepartly/reflectapi). Generated code
5+
imports from `reflectapi_runtime`; you don't normally call this library
6+
directly.
7+
8+
## What it provides
9+
10+
- `ClientBase` / `AsyncClientBase` — base classes used by the generated
11+
`Client` / `AsyncClient`. They wrap `httpx` and handle request build-up,
12+
Pydantic-based response validation, and middleware.
13+
- `ApiResponse[T, E]` — typed wrapper around the response value, transport
14+
metadata, and the optional typed error.
15+
- `ApplicationError`, `NetworkError`, `TimeoutError`, `ValidationError`
16+
exceptions raised by the generated methods on non-2xx, transport, and
17+
validation failures respectively.
18+
- `ReflectapiOption` — three-state Option used by the generated models
19+
(`some` / `none` / `undefined`) so absent and explicit-null can round-trip.
20+
- Authentication helpers (`BearerTokenAuth`, `APIKeyAuth`, `BasicAuth`,
21+
`OAuth2ClientCredentialsAuth`, `OAuth2AuthorizationCodeAuth`).
22+
- Middleware, batching, and testing utilities (`MockClient`,
23+
`CassetteMiddleware`).
24+
25+
## Streaming endpoints
26+
27+
Endpoints declared with `Builder::stream_route` on the server are exposed on
28+
the generated client as ordinary methods that return an iterator:
29+
30+
```python
31+
async with AsyncClient("http://localhost:3000") as client:
32+
async for event in client.pets.cdc_events(headers=headers):
33+
... # process each event
34+
```
35+
36+
The sync client returns a regular `Iterator`; both validate each event
37+
against the declared item model. The wire format is Server-Sent Events
38+
(`data: <json>\n\n`); init failures (4xx/5xx) raise `ApplicationError` and
39+
do not enter the iterator. Breaking out of the loop or calling
40+
`stream.aclose()` releases the underlying HTTP connection.
41+
42+
Validation is strict: an event whose payload doesn't match the item model
43+
(for example, an unknown discriminated-union variant added by a newer
44+
server) raises `ValidationError` mid-stream and the iterator terminates.
45+
Items received before the bad event are still delivered.
46+
47+
## Compatibility
48+
49+
Python 3.12+. The package targets the same `reflectapi` minor version as
50+
the schema you generate from; mismatches will surface at import time.

0 commit comments

Comments
 (0)