pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
480 lines
15 KiB
480 lines
15 KiB
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import AsyncIterable, Callable, Iterable
|
|
from functools import partial
|
|
from typing import Annotated, Any
|
|
|
|
import anyio
|
|
from annotated_doc import Doc
|
|
from pydantic import AfterValidator, BaseModel, Field, model_validator
|
|
from starlette.concurrency import iterate_in_threadpool
|
|
from starlette.requests import ClientDisconnect
|
|
from starlette.responses import StreamingResponse
|
|
from starlette.types import Receive, Scope, Send
|
|
|
|
try:
|
|
from starlette._utils import collapse_excgroups
|
|
except ImportError:
|
|
# Fallback for older starlette versions
|
|
import contextlib
|
|
|
|
@contextlib.contextmanager
|
|
def collapse_excgroups():
|
|
yield
|
|
|
|
|
|
# Canonical SSE event schema matching the OpenAPI 3.2 spec
|
|
# (Section 4.14.4 "Special Considerations for Server-Sent Events")
|
|
_SSE_EVENT_SCHEMA: dict[str, Any] = {
|
|
"type": "object",
|
|
"properties": {
|
|
"data": {"type": "string"},
|
|
"event": {"type": "string"},
|
|
"id": {"type": "string"},
|
|
"retry": {"type": "integer", "minimum": 0},
|
|
},
|
|
}
|
|
|
|
|
|
class EventSourceResponse(StreamingResponse):
|
|
"""Streaming response with ``text/event-stream`` media type.
|
|
|
|
Use as ``response_class=EventSourceResponse`` on a *path operation* that
|
|
uses ``yield`` to enable Server-Sent Events (SSE) responses via the
|
|
routing layer's automatic encoding.
|
|
|
|
Can also be used **directly as a return value**, similar to
|
|
``StreamingResponse``, by passing an async iterable of pre-formatted
|
|
SSE bytes or ``ServerSentEvent`` objects::
|
|
|
|
@app.get("/stream")
|
|
def stream_events():
|
|
return EventSourceResponse(my_event_generator(), retry=3000)
|
|
|
|
When used directly the class formats ``ServerSentEvent`` objects into
|
|
SSE wire format, emits an optional initial ``retry:`` field, and
|
|
inserts keep-alive comment pings on idle periods.
|
|
|
|
**Parameters**
|
|
|
|
* ``content`` – async iterable of ``ServerSentEvent`` objects or raw
|
|
``bytes``. Required for direct-use mode; omit when used as
|
|
``response_class`` (the routing layer provides content).
|
|
* ``retry`` – default reconnection time in **milliseconds**. Emitted
|
|
as an initial ``retry:`` field. Individual ``ServerSentEvent``
|
|
objects can override per-event.
|
|
* ``ping_interval`` – seconds between keep-alive ``: ping`` comments
|
|
in direct-use mode. Set to ``0`` to disable. Default ``15.0``.
|
|
* ``disconnect_callback`` – async callable invoked when the client
|
|
disconnects. Useful for resource cleanup.
|
|
"""
|
|
|
|
media_type = "text/event-stream"
|
|
|
|
def __init__(
|
|
self,
|
|
content: AsyncIterable[Any] | None = None,
|
|
status_code: int = 200,
|
|
headers: dict[str, str] | None = None,
|
|
media_type: str | None = None,
|
|
background: Any = None,
|
|
retry: int | None = None,
|
|
ping_interval: float = 15.0,
|
|
disconnect_callback: (
|
|
Callable[[], Any] | None # sync or async callable
|
|
) = None,
|
|
) -> None:
|
|
if retry is not None and retry < 0:
|
|
raise ValueError("retry must be non-negative")
|
|
|
|
# When content is provided (direct-use mode), wrap it to auto-format
|
|
# ServerSentEvent objects, emit an initial retry field, and add
|
|
# keepalive pings. Also set SSE-appropriate headers.
|
|
if content is not None:
|
|
content = _wrap_content(content, retry=retry, ping_interval=ping_interval)
|
|
if headers is None:
|
|
headers = {}
|
|
headers.setdefault("Cache-Control", "no-cache")
|
|
# Prevent Nginx and other proxies from buffering SSE
|
|
headers.setdefault("X-Accel-Buffering", "no")
|
|
|
|
# Store callback and content flag for __call__
|
|
self._disconnect_callback = disconnect_callback
|
|
self._direct_use = True
|
|
else:
|
|
self._disconnect_callback = None
|
|
self._direct_use = False
|
|
|
|
super().__init__(
|
|
content=content,
|
|
status_code=status_code,
|
|
headers=headers,
|
|
media_type=media_type,
|
|
background=background,
|
|
)
|
|
|
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
if scope["type"] == "websocket":
|
|
send = self._wrap_websocket_denial_send(send)
|
|
await self.stream_response(send)
|
|
if self.background is not None:
|
|
await self.background()
|
|
return
|
|
|
|
if not self._direct_use or self._disconnect_callback is None:
|
|
# Routing-layer mode or no callback: delegate to parent
|
|
await super().__call__(scope, receive, send)
|
|
return
|
|
|
|
# Direct-use mode with disconnect callback
|
|
spec_version = tuple(
|
|
map(int, scope.get("asgi", {}).get("spec_version", "2.0").split("."))
|
|
)
|
|
|
|
if spec_version >= (2, 4):
|
|
try:
|
|
await self.stream_response(send)
|
|
except OSError:
|
|
cb = self._disconnect_callback
|
|
if cb is not None:
|
|
result = cb()
|
|
if result is not None and hasattr(result, "__await__"):
|
|
await result
|
|
raise ClientDisconnect()
|
|
else:
|
|
with collapse_excgroups():
|
|
async with anyio.create_task_group() as task_group:
|
|
|
|
async def wrap(
|
|
func: Callable[[], Any],
|
|
) -> None:
|
|
await func()
|
|
task_group.cancel_scope.cancel()
|
|
|
|
task_group.start_soon(wrap, partial(self.stream_response, send))
|
|
await wrap(
|
|
partial(
|
|
_listen_for_disconnect,
|
|
receive,
|
|
task_group,
|
|
self._disconnect_callback,
|
|
)
|
|
)
|
|
|
|
if self.background is not None:
|
|
await self.background()
|
|
|
|
|
|
async def _listen_for_disconnect(
|
|
receive: Receive,
|
|
task_group: anyio.abc.TaskGroup,
|
|
callback: Callable[[], Any] | None,
|
|
) -> None:
|
|
"""Listen for disconnect, invoke callback, then cancel the task group."""
|
|
while True:
|
|
message = await receive()
|
|
if message["type"] == "http.disconnect":
|
|
break
|
|
if callback is not None:
|
|
result = callback()
|
|
if result is not None and hasattr(result, "__await__"):
|
|
await result
|
|
task_group.cancel_scope.cancel()
|
|
|
|
|
|
def _wrap_content(
|
|
content: AsyncIterable[Any] | Iterable[Any],
|
|
*,
|
|
retry: int | None,
|
|
ping_interval: float,
|
|
) -> AsyncIterable[bytes]:
|
|
"""Wrap content to auto-format ServerSentEvent objects and emit an
|
|
initial ``retry:`` field if configured.
|
|
|
|
When ``ping_interval > 0``, a keep-alive comment is inserted whenever
|
|
the source generator is idle for longer than the interval.
|
|
"""
|
|
if isinstance(content, AsyncIterable):
|
|
ait: AsyncIterable[Any] = content
|
|
else:
|
|
ait = iterate_in_threadpool(content)
|
|
|
|
if ping_interval <= 0:
|
|
# No keepalive needed: simple generator
|
|
async def _simple_generator() -> AsyncIterable[bytes]:
|
|
if retry is not None:
|
|
yield format_sse_event(retry=retry)
|
|
async for event in ait:
|
|
yield _format_one(event)
|
|
await anyio.sleep(0)
|
|
|
|
return _simple_generator()
|
|
|
|
# Keepalive-enabled: use anyio memory stream to decouple
|
|
# the producer from the timeout logic.
|
|
send_stream, receive_stream = anyio.create_memory_object_stream[bytes](
|
|
max_buffer_size=1
|
|
)
|
|
|
|
async def _format_and_send() -> None:
|
|
async with send_stream:
|
|
if retry is not None:
|
|
await send_stream.send(format_sse_event(retry=retry))
|
|
async for event in ait:
|
|
await send_stream.send(_format_one(event))
|
|
await anyio.sleep(0)
|
|
|
|
async def _insert_pings() -> AsyncIterable[bytes]:
|
|
async with receive_stream:
|
|
try:
|
|
while True:
|
|
try:
|
|
with anyio.fail_after(ping_interval):
|
|
data = await receive_stream.receive()
|
|
yield data
|
|
except TimeoutError:
|
|
yield KEEPALIVE_COMMENT
|
|
except anyio.EndOfStream:
|
|
pass
|
|
|
|
# We need to return an async iterable that starts the producer task
|
|
# when iterated. Use a wrapper async generator.
|
|
async def _keepalive_generator() -> AsyncIterable[bytes]:
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(_format_and_send)
|
|
async for chunk in _insert_pings():
|
|
yield chunk
|
|
await anyio.sleep(0)
|
|
tg.cancel_scope.cancel()
|
|
|
|
return _keepalive_generator()
|
|
|
|
|
|
def _format_one(event: Any) -> bytes:
|
|
"""Format a single event into SSE wire-format bytes."""
|
|
if isinstance(event, ServerSentEvent):
|
|
if event.raw_data is not None:
|
|
data_str: str | None = event.raw_data
|
|
elif event.data is not None:
|
|
if hasattr(event.data, "model_dump_json"):
|
|
data_str = event.data.model_dump_json()
|
|
else:
|
|
data_str = json.dumps(jsonable_encoder(event.data))
|
|
else:
|
|
data_str = None
|
|
return format_sse_event(
|
|
data_str=data_str,
|
|
event=event.event,
|
|
id=event.id,
|
|
retry=event.retry,
|
|
comment=event.comment,
|
|
)
|
|
else:
|
|
# Plain bytes or string (already formatted by user)
|
|
if isinstance(event, bytes):
|
|
return event
|
|
if isinstance(event, str):
|
|
return event.encode("utf-8")
|
|
# Unexpected type — try to convert
|
|
return str(event).encode("utf-8")
|
|
|
|
|
|
def jsonable_encoder(obj: Any) -> Any:
|
|
"""Minimal jsonable_encoder fallback for standalone use."""
|
|
from fastapi.encoders import jsonable_encoder as _enc
|
|
|
|
return _enc(obj)
|
|
|
|
|
|
def _check_id_no_null(v: str | None) -> str | None:
|
|
if v is not None and "\0" in v:
|
|
raise ValueError("SSE 'id' must not contain null characters")
|
|
return v
|
|
|
|
|
|
class ServerSentEvent(BaseModel):
|
|
"""Represents a single Server-Sent Event.
|
|
|
|
When ``yield``ed from a *path operation function* that uses
|
|
``response_class=EventSourceResponse``, each ``ServerSentEvent`` is
|
|
encoded into the `SSE wire format
|
|
<https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream>`_
|
|
(``text/event-stream``).
|
|
|
|
If you yield a plain object (dict, Pydantic model, etc.) instead, it is
|
|
automatically JSON-encoded and sent as the ``data:`` field.
|
|
|
|
All ``data`` values **including plain strings** are JSON-serialized.
|
|
|
|
For example, ``data="hello"`` produces ``data: "hello"`` on the wire
|
|
(with quotes).
|
|
"""
|
|
|
|
data: Annotated[
|
|
Any,
|
|
Doc(
|
|
"""
|
|
The event payload.
|
|
|
|
Can be any JSON-serializable value: a Pydantic model, dict, list,
|
|
string, number, etc. It is **always** serialized to JSON: strings
|
|
are quoted (``"hello"`` becomes ``data: "hello"`` on the wire).
|
|
|
|
Mutually exclusive with ``raw_data``.
|
|
"""
|
|
),
|
|
] = None
|
|
raw_data: Annotated[
|
|
str | None,
|
|
Doc(
|
|
"""
|
|
Raw string to send as the ``data:`` field **without** JSON
|
|
encoding.
|
|
|
|
Use this when you need to send pre-formatted text, HTML
|
|
fragments, CSV lines, or any non-JSON payload. The string is
|
|
placed directly into the ``data:`` field as-is.
|
|
|
|
Mutually exclusive with ``data``.
|
|
"""
|
|
),
|
|
] = None
|
|
event: Annotated[
|
|
str | None,
|
|
Doc(
|
|
"""
|
|
Optional event type name.
|
|
|
|
Maps to ``addEventListener(event, ...)`` on the browser. When
|
|
omitted, the browser dispatches on the generic ``message``
|
|
event.
|
|
"""
|
|
),
|
|
] = None
|
|
id: Annotated[
|
|
str | None,
|
|
AfterValidator(_check_id_no_null),
|
|
Doc(
|
|
"""
|
|
Optional event ID.
|
|
|
|
The browser sends this value back as the ``Last-Event-ID``
|
|
header on automatic reconnection. **Must not contain null
|
|
(``\\0``) characters.**
|
|
"""
|
|
),
|
|
] = None
|
|
retry: Annotated[
|
|
int | None,
|
|
Field(ge=0),
|
|
Doc(
|
|
"""
|
|
Optional reconnection time in **milliseconds**.
|
|
|
|
Tells the browser how long to wait before reconnecting after
|
|
the connection is lost. Must be a non-negative integer.
|
|
"""
|
|
),
|
|
] = None
|
|
comment: Annotated[
|
|
str | None,
|
|
Doc(
|
|
"""
|
|
Optional comment line(s).
|
|
|
|
Comment lines start with ``:`` in the SSE wire format and are
|
|
ignored by ``EventSource`` clients. Useful for keep-alive
|
|
pings to prevent proxy/load-balancer timeouts.
|
|
"""
|
|
),
|
|
] = None
|
|
|
|
@model_validator(mode="after")
|
|
def _check_data_exclusive(self) -> ServerSentEvent:
|
|
if self.data is not None and self.raw_data is not None:
|
|
raise ValueError(
|
|
"Cannot set both 'data' and 'raw_data' on the same "
|
|
"ServerSentEvent. Use 'data' for JSON-serialized payloads "
|
|
"or 'raw_data' for pre-formatted strings."
|
|
)
|
|
return self
|
|
|
|
|
|
def format_sse_event(
|
|
*,
|
|
data_str: Annotated[
|
|
str | None,
|
|
Doc(
|
|
"""
|
|
Pre-serialized data string to use as the ``data:`` field.
|
|
"""
|
|
),
|
|
] = None,
|
|
event: Annotated[
|
|
str | None,
|
|
Doc(
|
|
"""
|
|
Optional event type name (``event:`` field).
|
|
"""
|
|
),
|
|
] = None,
|
|
id: Annotated[
|
|
str | None,
|
|
Doc(
|
|
"""
|
|
Optional event ID (``id:`` field).
|
|
"""
|
|
),
|
|
] = None,
|
|
retry: Annotated[
|
|
int | None,
|
|
Doc(
|
|
"""
|
|
Optional reconnection time in milliseconds (``retry:`` field).
|
|
"""
|
|
),
|
|
] = None,
|
|
comment: Annotated[
|
|
str | None,
|
|
Doc(
|
|
"""
|
|
Optional comment line(s) (``:`` prefix).
|
|
"""
|
|
),
|
|
] = None,
|
|
) -> bytes:
|
|
"""Build SSE wire-format bytes from **pre-serialized** data.
|
|
|
|
The result always ends with ``\\n\\n`` (the event terminator).
|
|
"""
|
|
lines: list[str] = []
|
|
|
|
if comment is not None:
|
|
for line in comment.splitlines():
|
|
lines.append(f": {line}")
|
|
|
|
if event is not None:
|
|
lines.append(f"event: {event}")
|
|
|
|
if data_str is not None:
|
|
for line in data_str.splitlines():
|
|
lines.append(f"data: {line}")
|
|
|
|
if id is not None:
|
|
lines.append(f"id: {id}")
|
|
|
|
if retry is not None:
|
|
lines.append(f"retry: {retry}")
|
|
|
|
lines.append("")
|
|
lines.append("")
|
|
return "\n".join(lines).encode("utf-8")
|
|
|
|
|
|
# Keep-alive comment, per the SSE spec recommendation
|
|
KEEPALIVE_COMMENT = b": ping\n\n"
|
|
|
|
# Seconds between keep-alive pings when a generator is idle.
|
|
# Private but importable so tests can monkeypatch it.
|
|
_PING_INTERVAL: float = 15.0
|
|
|