mirror of
https://github.com/fastapi/fastapi.git
synced 2026-03-01 13:19:09 -05:00
Compare commits
6 Commits
0.134.0
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed95917a76 | ||
|
|
f5c7b20933 | ||
|
|
924a535a4f | ||
|
|
12ea7be0be | ||
|
|
4cd76acafc | ||
|
|
2238155844 |
@@ -4,6 +4,12 @@ If you want to stream data that can be structured as JSON, you should [Stream JS
|
||||
|
||||
But if you want to **stream pure binary data** or strings, here's how you can do it.
|
||||
|
||||
/// info
|
||||
|
||||
Added in FastAPI 0.134.0.
|
||||
|
||||
///
|
||||
|
||||
## Use Cases { #use-cases }
|
||||
|
||||
You could use this if you want to stream pure strings, for example directly from the output of an **AI LLM** service.
|
||||
|
||||
@@ -7,6 +7,17 @@ hide:
|
||||
|
||||
## Latest Changes
|
||||
|
||||
### Docs
|
||||
|
||||
* 📝 Update Skill, optimize context, trim and refactor into references. PR [#15031](https://github.com/fastapi/fastapi/pull/15031) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.135.0
|
||||
|
||||
### Features
|
||||
|
||||
* ✨ Add support for Server Sent Events. PR [#15030](https://github.com/fastapi/fastapi/pull/15030) by [@tiangolo](https://github.com/tiangolo).
|
||||
* New docs: [Server-Sent Events (SSE)](https://fastapi.tiangolo.com/tutorial/server-sent-events/).
|
||||
|
||||
## 0.134.0
|
||||
|
||||
### Features
|
||||
|
||||
120
docs/en/docs/tutorial/server-sent-events.md
Normal file
120
docs/en/docs/tutorial/server-sent-events.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Server-Sent Events (SSE) { #server-sent-events-sse }
|
||||
|
||||
You can stream data to the client using **Server-Sent Events** (SSE).
|
||||
|
||||
This is similar to [Stream JSON Lines](stream-json-lines.md){.internal-link target=_blank}, but uses the `text/event-stream` format, which is supported natively by browsers with the <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource" class="external-link" target="_blank">`EventSource` API</a>.
|
||||
|
||||
/// info
|
||||
|
||||
Added in FastAPI 0.135.0.
|
||||
|
||||
///
|
||||
|
||||
## What are Server-Sent Events? { #what-are-server-sent-events }
|
||||
|
||||
SSE is a standard for streaming data from the server to the client over HTTP.
|
||||
|
||||
Each event is a small text block with "fields" like `data`, `event`, `id`, and `retry`, separated by blank lines.
|
||||
|
||||
It looks like this:
|
||||
|
||||
```
|
||||
data: {"name": "Portal Gun", "price": 999.99}
|
||||
|
||||
data: {"name": "Plumbus", "price": 32.99}
|
||||
|
||||
```
|
||||
|
||||
SSE is commonly used for AI chat streaming, live notifications, logs and observability, and other cases where the server pushes updates to the client.
|
||||
|
||||
/// tip
|
||||
|
||||
If you want to stream binary data, for example video or audio, check the advanced guide: [Stream Data](../advanced/stream-data.md){.internal-link target=_blank}.
|
||||
|
||||
///
|
||||
|
||||
## Stream SSE with FastAPI { #stream-sse-with-fastapi }
|
||||
|
||||
To stream SSE with FastAPI, use `yield` in your *path operation function* and set `response_class=EventSourceResponse`.
|
||||
|
||||
Import `EventSourceResponse` from `fastapi.sse`:
|
||||
|
||||
{* ../../docs_src/server_sent_events/tutorial001_py310.py ln[1:25] hl[4,22] *}
|
||||
|
||||
Each yielded item is encoded as JSON and sent in the `data:` field of an SSE event.
|
||||
|
||||
If you declare the return type as `AsyncIterable[Item]`, FastAPI will use it to **validate**, **document**, and **serialize** the data using Pydantic.
|
||||
|
||||
{* ../../docs_src/server_sent_events/tutorial001_py310.py ln[1:25] hl[10:12,23] *}
|
||||
|
||||
/// tip
|
||||
|
||||
As Pydantic will serialize it in the **Rust** side, you will get much higher **performance** than if you don't declare a return type.
|
||||
|
||||
///
|
||||
|
||||
### Non-async *path operation functions* { #non-async-path-operation-functions }
|
||||
|
||||
You can also use regular `def` functions (without `async`), and use `yield` the same way.
|
||||
|
||||
FastAPI will make sure it's run correctly so that it doesn't block the event loop.
|
||||
|
||||
As in this case the function is not async, the right return type would be `Iterable[Item]`:
|
||||
|
||||
{* ../../docs_src/server_sent_events/tutorial001_py310.py ln[28:31] hl[29] *}
|
||||
|
||||
### No Return Type { #no-return-type }
|
||||
|
||||
You can also omit the return type. FastAPI will use the [`jsonable_encoder`](./encoder.md){.internal-link target=_blank} to convert the data and send it.
|
||||
|
||||
{* ../../docs_src/server_sent_events/tutorial001_py310.py ln[34:37] hl[35] *}
|
||||
|
||||
## `ServerSentEvent` { #serversentevent }
|
||||
|
||||
If you need to set SSE fields like `event`, `id`, `retry`, or `comment`, you can yield `ServerSentEvent` objects instead of plain data.
|
||||
|
||||
Import `ServerSentEvent` from `fastapi.sse`:
|
||||
|
||||
{* ../../docs_src/server_sent_events/tutorial002_py310.py hl[4,26] *}
|
||||
|
||||
The `data` field is always encoded as JSON. You can pass any value that can be serialized as JSON, including Pydantic models.
|
||||
|
||||
## Raw Data { #raw-data }
|
||||
|
||||
If you need to send data **without** JSON encoding, use `raw_data` instead of `data`.
|
||||
|
||||
This is useful for sending pre-formatted text, log lines, or special <dfn title="A value used to indicate a special condition or state">"sentinel"</dfn> values like `[DONE]`.
|
||||
|
||||
{* ../../docs_src/server_sent_events/tutorial003_py310.py hl[17] *}
|
||||
|
||||
/// note
|
||||
|
||||
`data` and `raw_data` are mutually exclusive. You can only set one of them on each `ServerSentEvent`.
|
||||
|
||||
///
|
||||
|
||||
## Resuming with `Last-Event-ID` { #resuming-with-last-event-id }
|
||||
|
||||
When a browser reconnects after a connection drop, it sends the last received `id` in the `Last-Event-ID` header.
|
||||
|
||||
You can read it as a header parameter and use it to resume the stream from where the client left off:
|
||||
|
||||
{* ../../docs_src/server_sent_events/tutorial004_py310.py hl[25,27,31] *}
|
||||
|
||||
## SSE with POST { #sse-with-post }
|
||||
|
||||
SSE works with **any HTTP method**, not just `GET`.
|
||||
|
||||
This is useful for protocols like <a href="https://modelcontextprotocol.io" class="external-link" target="_blank">MCP</a> that stream SSE over `POST`:
|
||||
|
||||
{* ../../docs_src/server_sent_events/tutorial005_py310.py hl[14] *}
|
||||
|
||||
## Technical Details { #technical-details }
|
||||
|
||||
FastAPI implements some SSE best practices out of the box.
|
||||
|
||||
* Send a **"keep alive" `ping` comment** every 15 seconds when there hasn't been any message, to prevent some proxies from closing the connection, as suggested in the <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html#authoring-notes" class="external-link" target="_blank">HTML specification: Server-Sent Events</a>.
|
||||
* Set the `Cache-Control: no-cache` header to **prevent caching** of the stream.
|
||||
* Set a special header `X-Accel-Buffering: no` to **prevent buffering** in some proxies like Nginx.
|
||||
|
||||
You don't have to do anything about it, it works out of the box. 🤓
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
You could have a sequence of data that you would like to send in a "**stream**", you could do it with **JSON Lines**.
|
||||
|
||||
/// info
|
||||
|
||||
Added in FastAPI 0.134.0.
|
||||
|
||||
///
|
||||
|
||||
## What is a Stream? { #what-is-a-stream }
|
||||
|
||||
"**Streaming**" data means that your app will start sending data items to the client without waiting for the entire sequence of items to be ready.
|
||||
@@ -100,6 +106,6 @@ You can also omit the return type. FastAPI will then use the [`jsonable_encoder`
|
||||
|
||||
{* ../../docs_src/stream_json_lines/tutorial001_py310.py ln[33:36] hl[34] *}
|
||||
|
||||
## Server Sent Events (SSE) { #server-sent-events-sse }
|
||||
## Server-Sent Events (SSE) { #server-sent-events-sse }
|
||||
|
||||
A future version of FastAPI will also have first-class support for Server Sent Events (SSE), which are quite similar, but with a couple of extra details. 🤓
|
||||
FastAPI also has first-class support for Server-Sent Events (SSE), which are quite similar but with a couple of extra details. You can learn about them in the next chapter: [Server-Sent Events (SSE)](server-sent-events.md){.internal-link target=_blank}. 🤓
|
||||
|
||||
@@ -155,6 +155,7 @@ nav:
|
||||
- tutorial/sql-databases.md
|
||||
- tutorial/bigger-applications.md
|
||||
- tutorial/stream-json-lines.md
|
||||
- tutorial/server-sent-events.md
|
||||
- tutorial/background-tasks.md
|
||||
- tutorial/metadata.md
|
||||
- tutorial/static-files.md
|
||||
|
||||
0
docs_src/server_sent_events/__init__.py
Normal file
0
docs_src/server_sent_events/__init__.py
Normal file
43
docs_src/server_sent_events/tutorial001_py310.py
Normal file
43
docs_src/server_sent_events/tutorial001_py310.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from collections.abc import AsyncIterable, Iterable
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.sse import EventSourceResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
|
||||
items = [
|
||||
Item(name="Plumbus", description="A multi-purpose household device."),
|
||||
Item(name="Portal Gun", description="A portal opening device."),
|
||||
Item(name="Meeseeks Box", description="A box that summons a Meeseeks."),
|
||||
]
|
||||
|
||||
|
||||
@app.get("/items/stream", response_class=EventSourceResponse)
|
||||
async def sse_items() -> AsyncIterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@app.get("/items/stream-no-async", response_class=EventSourceResponse)
|
||||
def sse_items_no_async() -> Iterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@app.get("/items/stream-no-annotation", response_class=EventSourceResponse)
|
||||
async def sse_items_no_annotation():
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@app.get("/items/stream-no-async-no-annotation", response_class=EventSourceResponse)
|
||||
def sse_items_no_async_no_annotation():
|
||||
for item in items:
|
||||
yield item
|
||||
26
docs_src/server_sent_events/tutorial002_py310.py
Normal file
26
docs_src/server_sent_events/tutorial002_py310.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from collections.abc import AsyncIterable
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.sse import EventSourceResponse, ServerSentEvent
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
|
||||
items = [
|
||||
Item(name="Plumbus", price=32.99),
|
||||
Item(name="Portal Gun", price=999.99),
|
||||
Item(name="Meeseeks Box", price=49.99),
|
||||
]
|
||||
|
||||
|
||||
@app.get("/items/stream", response_class=EventSourceResponse)
|
||||
async def stream_items() -> AsyncIterable[ServerSentEvent]:
|
||||
yield ServerSentEvent(comment="stream of item updates")
|
||||
for i, item in enumerate(items):
|
||||
yield ServerSentEvent(data=item, event="item_update", id=str(i + 1), retry=5000)
|
||||
17
docs_src/server_sent_events/tutorial003_py310.py
Normal file
17
docs_src/server_sent_events/tutorial003_py310.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from collections.abc import AsyncIterable
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.sse import EventSourceResponse, ServerSentEvent
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/logs/stream", response_class=EventSourceResponse)
|
||||
async def stream_logs() -> AsyncIterable[ServerSentEvent]:
|
||||
logs = [
|
||||
"2025-01-01 INFO Application started",
|
||||
"2025-01-01 DEBUG Connected to database",
|
||||
"2025-01-01 WARN High memory usage detected",
|
||||
]
|
||||
for log_line in logs:
|
||||
yield ServerSentEvent(raw_data=log_line)
|
||||
31
docs_src/server_sent_events/tutorial004_py310.py
Normal file
31
docs_src/server_sent_events/tutorial004_py310.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI, Header
|
||||
from fastapi.sse import EventSourceResponse, ServerSentEvent
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
|
||||
items = [
|
||||
Item(name="Plumbus", price=32.99),
|
||||
Item(name="Portal Gun", price=999.99),
|
||||
Item(name="Meeseeks Box", price=49.99),
|
||||
]
|
||||
|
||||
|
||||
@app.get("/items/stream", response_class=EventSourceResponse)
|
||||
async def stream_items(
|
||||
last_event_id: Annotated[int | None, Header()] = None,
|
||||
) -> AsyncIterable[ServerSentEvent]:
|
||||
start = last_event_id + 1 if last_event_id is not None else 0
|
||||
for i, item in enumerate(items):
|
||||
if i < start:
|
||||
continue
|
||||
yield ServerSentEvent(data=item, id=str(i))
|
||||
19
docs_src/server_sent_events/tutorial005_py310.py
Normal file
19
docs_src/server_sent_events/tutorial005_py310.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from collections.abc import AsyncIterable
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.sse import EventSourceResponse, ServerSentEvent
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Prompt(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@app.post("/chat/stream", response_class=EventSourceResponse)
|
||||
async def stream_chat(prompt: Prompt) -> AsyncIterable[ServerSentEvent]:
|
||||
words = prompt.text.split()
|
||||
for word in words:
|
||||
yield ServerSentEvent(data=word, event="token")
|
||||
yield ServerSentEvent(raw_data="[DONE]", event="done")
|
||||
@@ -290,146 +290,11 @@ Apply shared dependencies at the router level via `dependencies=[Depends(...)]`.
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
Use dependencies when:
|
||||
See [the dependency injection reference](references/dependencies.md) for detailed patterns including `yield` with `scope`, and class dependencies.
|
||||
|
||||
* They can't be declared in Pydantic validation and require additional logic
|
||||
* The logic depends on external resources or could block in any other way
|
||||
* Other dependencies need their results (it's a sub-dependency)
|
||||
* The logic can be shared by multiple endpoints to do things like error early, authentication, etc.
|
||||
* They need to handle cleanup (e.g., DB sessions, file handles), using dependencies with `yield`
|
||||
* Their logic needs input data from the request, like headers, query parameters, etc.
|
||||
Use dependencies when the logic can't be declared in Pydantic validation, depends on external resources, needs cleanup (with `yield`), or is shared across endpoints.
|
||||
|
||||
### Dependencies with `yield` and `scope`
|
||||
|
||||
When using dependencies with `yield`, they can have a `scope` that defines when the exit code is run.
|
||||
|
||||
Use the default scope `"request"` to run the exit code after the response is sent back.
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = DBSession()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
DBDep = Annotated[DBSession, Depends(get_db)]
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
async def read_items(db: DBDep):
|
||||
return db.query(Item).all()
|
||||
```
|
||||
|
||||
Use the scope `"function"` when they should run the exit code after the response data is generated but before the response is sent back to the client.
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def get_username():
|
||||
try:
|
||||
yield "Rick"
|
||||
finally:
|
||||
print("Cleanup up before response is sent")
|
||||
|
||||
UserNameDep = Annotated[str, Depends(get_username, scope="function")]
|
||||
|
||||
@app.get("/users/me")
|
||||
def get_user_me(username: UserNameDep):
|
||||
return username
|
||||
```
|
||||
|
||||
### Class Dependencies
|
||||
|
||||
Avoid creating class dependencies when possible.
|
||||
|
||||
If a class is needed, instead create a regular function dependency that returns a class instance.
|
||||
|
||||
Do this:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabasePaginator:
|
||||
offset: int = 0
|
||||
limit: int = 100
|
||||
q: str | None = None
|
||||
|
||||
def get_page(self) -> dict:
|
||||
# Simulate a page of data
|
||||
return {
|
||||
"offset": self.offset,
|
||||
"limit": self.limit,
|
||||
"q": self.q,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
|
||||
def get_db_paginator(
|
||||
offset: int = 0, limit: int = 100, q: str | None = None
|
||||
) -> DatabasePaginator:
|
||||
return DatabasePaginator(offset=offset, limit=limit, q=q)
|
||||
|
||||
|
||||
PaginatorDep = Annotated[DatabasePaginator, Depends(get_db_paginator)]
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
async def read_items(paginator: PaginatorDep):
|
||||
return paginator.get_page()
|
||||
```
|
||||
|
||||
instead of this:
|
||||
|
||||
```python
|
||||
# DO NOT DO THIS
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class DatabasePaginator:
|
||||
def __init__(self, offset: int = 0, limit: int = 100, q: str | None = None):
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
self.q = q
|
||||
|
||||
def get_page(self) -> dict:
|
||||
# Simulate a page of data
|
||||
return {
|
||||
"offset": self.offset,
|
||||
"limit": self.limit,
|
||||
"q": self.q,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
async def read_items(paginator: Annotated[DatabasePaginator, Depends()]):
|
||||
return paginator.get_page()
|
||||
```
|
||||
Apply shared dependencies at the router level via `dependencies=[Depends(...)]`.
|
||||
|
||||
## Async vs Sync *path operations*
|
||||
|
||||
@@ -461,120 +326,23 @@ The same rules apply to dependencies.
|
||||
|
||||
Make sure blocking code is not run inside of `async` functions. The logic will work, but will damage the performance heavily.
|
||||
|
||||
### Asyncer
|
||||
When needing to mix blocking and async code, see Asyncer in [the other tools reference](references/other-tools.md).
|
||||
|
||||
When needing to run blocking code inside of async functions, or async code inside of blocking functions, suggest using Asyncer.
|
||||
## Streaming (JSON Lines, SSE, bytes)
|
||||
|
||||
Install:
|
||||
See [the streaming reference](references/streaming.md) for JSON Lines, Server-Sent Events (`EventSourceResponse`, `ServerSentEvent`), and byte streaming (`StreamingResponse`) patterns.
|
||||
|
||||
```bash
|
||||
uv add asyncer
|
||||
```
|
||||
## Tooling
|
||||
|
||||
Run blocking sync code inside of async with `asyncify()`:
|
||||
See [the other tools reference](references/other-tools.md) for details on uv, Ruff, ty for package management, linting, type checking, formatting, etc.
|
||||
|
||||
```python
|
||||
from asyncer import asyncify
|
||||
from fastapi import FastAPI
|
||||
## Other Libraries
|
||||
|
||||
app = FastAPI()
|
||||
See [the other tools reference](references/other-tools.md) for details on other libraries:
|
||||
|
||||
|
||||
def do_blocking_work(name: str) -> str:
|
||||
# Some blocking I/O operation
|
||||
return f"Hello {name}"
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
async def read_items():
|
||||
result = await asyncify(do_blocking_work)(name="World")
|
||||
return {"message": result}
|
||||
```
|
||||
|
||||
And run async code inside of blocking sync code with `syncify()`:
|
||||
|
||||
```python
|
||||
from asyncer import syncify
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
async def do_async_work(name: str) -> str:
|
||||
return f"Hello {name}"
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
def read_items():
|
||||
result = syncify(do_async_work)(name="World")
|
||||
return {"message": result}
|
||||
```
|
||||
|
||||
## Stream JSON Lines
|
||||
|
||||
To stream JSON Lines, declare the return type and use `yield` to return the data.
|
||||
|
||||
```python
|
||||
@app.get("/items/stream")
|
||||
async def stream_items() -> AsyncIterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
```
|
||||
|
||||
## Stream bytes
|
||||
|
||||
To stream bytes, declare a `response_class=` of `StreamingResponse` or a sub-class, and use `yield` to return the data.
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.utils import read_image
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class PNGStreamingResponse(StreamingResponse):
|
||||
media_type = "image/png"
|
||||
|
||||
@app.get("/image", response_class=PNGStreamingResponse)
|
||||
def stream_image_no_async_no_annotation():
|
||||
with read_image() as image_file:
|
||||
yield from image_file
|
||||
```
|
||||
|
||||
prefer this over returning a `StreamingResponse` directly:
|
||||
|
||||
```python
|
||||
# DO NOT DO THIS
|
||||
|
||||
import anyio
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.utils import read_image
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class PNGStreamingResponse(StreamingResponse):
|
||||
media_type = "image/png"
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def main():
|
||||
return PNGStreamingResponse(read_image())
|
||||
```
|
||||
|
||||
## Use uv, ruff, ty
|
||||
|
||||
If uv is available, use it to manage dependencies.
|
||||
|
||||
If Ruff is available, use it to lint and format the code. Consider enabling the FastAPI rules.
|
||||
|
||||
If ty is available, use it to check types.
|
||||
|
||||
## SQLModel for SQL databases
|
||||
|
||||
When working with SQL databases, prefer using SQLModel as it is integrated with Pydantic and will allow declaring data validation with the same models.
|
||||
* Asyncer for handling async and await, concurrency, mixing async and blocking code, prefer it over AnyIO or asyncio.
|
||||
* SQLModel for working with SQL databases, prefer it over SQLAlchemy.
|
||||
* HTTPX for interacting with HTTP (other APIs), prefer it over Requests.
|
||||
|
||||
## Do not use Pydantic RootModels
|
||||
|
||||
|
||||
142
fastapi/.agents/skills/fastapi/references/dependencies.md
Normal file
142
fastapi/.agents/skills/fastapi/references/dependencies.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Dependency Injection
|
||||
|
||||
Use dependencies when:
|
||||
|
||||
* They can't be declared in Pydantic validation and require additional logic
|
||||
* The logic depends on external resources or could block in any other way
|
||||
* Other dependencies need their results (it's a sub-dependency)
|
||||
* The logic can be shared by multiple endpoints to do things like error early, authentication, etc.
|
||||
* They need to handle cleanup (e.g., DB sessions, file handles), using dependencies with `yield`
|
||||
* Their logic needs input data from the request, like headers, query parameters, etc.
|
||||
|
||||
## Dependencies with `yield` and `scope`
|
||||
|
||||
When using dependencies with `yield`, they can have a `scope` that defines when the exit code is run.
|
||||
|
||||
Use the default scope `"request"` to run the exit code after the response is sent back.
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = DBSession()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
DBDep = Annotated[DBSession, Depends(get_db)]
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
async def read_items(db: DBDep):
|
||||
return db.query(Item).all()
|
||||
```
|
||||
|
||||
Use the scope `"function"` when they should run the exit code after the response data is generated but before the response is sent back to the client.
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def get_username():
|
||||
try:
|
||||
yield "Rick"
|
||||
finally:
|
||||
print("Cleanup up before response is sent")
|
||||
|
||||
UserNameDep = Annotated[str, Depends(get_username, scope="function")]
|
||||
|
||||
@app.get("/users/me")
|
||||
def get_user_me(username: UserNameDep):
|
||||
return username
|
||||
```
|
||||
|
||||
## Class Dependencies
|
||||
|
||||
Avoid creating class dependencies when possible.
|
||||
|
||||
If a class is needed, instead create a regular function dependency that returns a class instance.
|
||||
|
||||
Do this:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabasePaginator:
|
||||
offset: int = 0
|
||||
limit: int = 100
|
||||
q: str | None = None
|
||||
|
||||
def get_page(self) -> dict:
|
||||
# Simulate a page of data
|
||||
return {
|
||||
"offset": self.offset,
|
||||
"limit": self.limit,
|
||||
"q": self.q,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
|
||||
def get_db_paginator(
|
||||
offset: int = 0, limit: int = 100, q: str | None = None
|
||||
) -> DatabasePaginator:
|
||||
return DatabasePaginator(offset=offset, limit=limit, q=q)
|
||||
|
||||
|
||||
PaginatorDep = Annotated[DatabasePaginator, Depends(get_db_paginator)]
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
async def read_items(paginator: PaginatorDep):
|
||||
return paginator.get_page()
|
||||
```
|
||||
|
||||
instead of this:
|
||||
|
||||
```python
|
||||
# DO NOT DO THIS
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class DatabasePaginator:
|
||||
def __init__(self, offset: int = 0, limit: int = 100, q: str | None = None):
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
self.q = q
|
||||
|
||||
def get_page(self) -> dict:
|
||||
# Simulate a page of data
|
||||
return {
|
||||
"offset": self.offset,
|
||||
"limit": self.limit,
|
||||
"q": self.q,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
async def read_items(paginator: Annotated[DatabasePaginator, Depends()]):
|
||||
return paginator.get_page()
|
||||
```
|
||||
76
fastapi/.agents/skills/fastapi/references/other-tools.md
Normal file
76
fastapi/.agents/skills/fastapi/references/other-tools.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Other Tools
|
||||
|
||||
## uv
|
||||
|
||||
If uv is available, use it to manage dependencies.
|
||||
|
||||
## Ruff
|
||||
|
||||
If Ruff is available, use it to lint and format the code. Consider enabling the FastAPI rules.
|
||||
|
||||
## ty
|
||||
|
||||
If ty is available, use it to check types.
|
||||
|
||||
## Asyncer
|
||||
|
||||
When needing to run blocking code inside of async functions, or async code inside of blocking functions, suggest using Asyncer.
|
||||
|
||||
Prefer it over AnyIO or asyncio.
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
uv add asyncer
|
||||
```
|
||||
|
||||
Run blocking sync code inside of async with `asyncify()`:
|
||||
|
||||
```python
|
||||
from asyncer import asyncify
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def do_blocking_work(name: str) -> str:
|
||||
# Some blocking I/O operation
|
||||
return f"Hello {name}"
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
async def read_items():
|
||||
result = await asyncify(do_blocking_work)(name="World")
|
||||
return {"message": result}
|
||||
```
|
||||
|
||||
And run async code inside of blocking sync code with `syncify()`:
|
||||
|
||||
```python
|
||||
from asyncer import syncify
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
async def do_async_work(name: str) -> str:
|
||||
return f"Hello {name}"
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
def read_items():
|
||||
result = syncify(do_async_work)(name="World")
|
||||
return {"message": result}
|
||||
```
|
||||
|
||||
## SQLModel for SQL databases
|
||||
|
||||
When working with SQL databases, prefer using SQLModel as it is integrated with Pydantic and will allow declaring data validation with the same models.
|
||||
|
||||
Prefer it over SQLAlchemy.
|
||||
|
||||
## HTTPX
|
||||
|
||||
Use HTTPX for handling HTTP communication (e.g. with other APIs). It support sync and async usage.
|
||||
|
||||
Prefer it over Requests.
|
||||
105
fastapi/.agents/skills/fastapi/references/streaming.md
Normal file
105
fastapi/.agents/skills/fastapi/references/streaming.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Streaming
|
||||
|
||||
## Stream JSON Lines
|
||||
|
||||
To stream JSON Lines, declare the return type and use `yield` to return the data.
|
||||
|
||||
```python
|
||||
@app.get("/items/stream")
|
||||
async def stream_items() -> AsyncIterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
```
|
||||
|
||||
## Server-Sent Events (SSE)
|
||||
|
||||
To stream Server-Sent Events, use `response_class=EventSourceResponse` and `yield` items from the endpoint.
|
||||
|
||||
Plain objects are automatically JSON-serialized as `data:` fields, declare the return type so the serialization is done by Pydantic:
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncIterable
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.sse import EventSourceResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
|
||||
@app.get("/items/stream", response_class=EventSourceResponse)
|
||||
async def stream_items() -> AsyncIterable[Item]:
|
||||
yield Item(name="Plumbus", price=32.99)
|
||||
yield Item(name="Portal Gun", price=999.99)
|
||||
```
|
||||
|
||||
For full control over SSE fields (`event`, `id`, `retry`, `comment`), yield `ServerSentEvent` instances:
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncIterable
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.sse import EventSourceResponse, ServerSentEvent
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/events", response_class=EventSourceResponse)
|
||||
async def stream_events() -> AsyncIterable[ServerSentEvent]:
|
||||
yield ServerSentEvent(data={"status": "started"}, event="status", id="1")
|
||||
yield ServerSentEvent(data={"progress": 50}, event="progress", id="2")
|
||||
```
|
||||
|
||||
Use `raw_data` instead of `data` to send pre-formatted strings without JSON encoding:
|
||||
|
||||
```python
|
||||
yield ServerSentEvent(raw_data="plain text line", event="log")
|
||||
```
|
||||
|
||||
## Stream bytes
|
||||
|
||||
To stream bytes, declare a `response_class=` of `StreamingResponse` or a sub-class, and use `yield` to return the data.
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.utils import read_image
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class PNGStreamingResponse(StreamingResponse):
|
||||
media_type = "image/png"
|
||||
|
||||
@app.get("/image", response_class=PNGStreamingResponse)
|
||||
def stream_image_no_async_no_annotation():
|
||||
with read_image() as image_file:
|
||||
yield from image_file
|
||||
```
|
||||
|
||||
prefer this over returning a `StreamingResponse` directly:
|
||||
|
||||
```python
|
||||
# DO NOT DO THIS
|
||||
|
||||
import anyio
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.utils import read_image
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class PNGStreamingResponse(StreamingResponse):
|
||||
media_type = "image/png"
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def main():
|
||||
return PNGStreamingResponse(read_image())
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
|
||||
|
||||
__version__ = "0.134.0"
|
||||
__version__ = "0.135.0"
|
||||
|
||||
from starlette import status as status
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ from fastapi.openapi.constants import METHODS_WITH_BODY, REF_PREFIX
|
||||
from fastapi.openapi.models import OpenAPI
|
||||
from fastapi.params import Body, ParamTypes
|
||||
from fastapi.responses import Response
|
||||
from fastapi.sse import _SSE_EVENT_SCHEMA
|
||||
from fastapi.types import ModelNameMap
|
||||
from fastapi.utils import (
|
||||
deep_dict_update,
|
||||
@@ -372,6 +373,26 @@ def get_openapi_path(
|
||||
operation.setdefault("responses", {}).setdefault(
|
||||
status_code, {}
|
||||
).setdefault("content", {})["application/jsonl"] = jsonl_content
|
||||
elif route.is_sse_stream:
|
||||
sse_content: dict[str, Any] = {}
|
||||
item_schema = copy.deepcopy(_SSE_EVENT_SCHEMA)
|
||||
if route.stream_item_field:
|
||||
content_schema = get_schema_from_model_field(
|
||||
field=route.stream_item_field,
|
||||
model_name_map=model_name_map,
|
||||
field_mapping=field_mapping,
|
||||
separate_input_output_schemas=separate_input_output_schemas,
|
||||
)
|
||||
item_schema["required"] = ["data"]
|
||||
item_schema["properties"]["data"] = {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": content_schema,
|
||||
}
|
||||
sse_content["itemSchema"] = item_schema
|
||||
operation.setdefault("responses", {}).setdefault(
|
||||
status_code, {}
|
||||
).setdefault("content", {})["text/event-stream"] = sse_content
|
||||
elif route_response_media_type:
|
||||
response_schema = {"type": "string"}
|
||||
if lenient_issubclass(current_response_class, JSONResponse):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi.exceptions import FastAPIDeprecationWarning
|
||||
from fastapi.sse import EventSourceResponse as EventSourceResponse # noqa
|
||||
from starlette.responses import FileResponse as FileResponse # noqa
|
||||
from starlette.responses import HTMLResponse as HTMLResponse # noqa
|
||||
from starlette.responses import JSONResponse as JSONResponse # noqa
|
||||
|
||||
@@ -56,6 +56,13 @@ from fastapi.exceptions import (
|
||||
ResponseValidationError,
|
||||
WebSocketRequestValidationError,
|
||||
)
|
||||
from fastapi.sse import (
|
||||
_PING_INTERVAL,
|
||||
KEEPALIVE_COMMENT,
|
||||
EventSourceResponse,
|
||||
ServerSentEvent,
|
||||
format_sse_event,
|
||||
)
|
||||
from fastapi.types import DecoratedCallable, IncEx
|
||||
from fastapi.utils import (
|
||||
create_model_field,
|
||||
@@ -66,7 +73,7 @@ from fastapi.utils import (
|
||||
from starlette import routing
|
||||
from starlette._exception_handler import wrap_app_handling_exceptions
|
||||
from starlette._utils import is_async_callable
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.concurrency import iterate_in_threadpool, run_in_threadpool
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response, StreamingResponse
|
||||
@@ -361,6 +368,7 @@ def get_request_handler(
|
||||
actual_response_class: type[Response] = response_class.value
|
||||
else:
|
||||
actual_response_class = response_class
|
||||
is_sse_stream = lenient_issubclass(actual_response_class, EventSourceResponse)
|
||||
if isinstance(strict_content_type, DefaultPlaceholder):
|
||||
actual_strict_content_type: bool = strict_content_type.value
|
||||
else:
|
||||
@@ -452,35 +460,125 @@ def get_request_handler(
|
||||
errors = solved_result.errors
|
||||
assert dependant.call # For types
|
||||
if not errors:
|
||||
if is_json_stream:
|
||||
# Shared serializer for stream items (JSONL and SSE).
|
||||
# Validates against stream_item_field when set, then
|
||||
# serializes to JSON bytes.
|
||||
def _serialize_data(data: Any) -> bytes:
|
||||
if stream_item_field:
|
||||
value, errors_ = stream_item_field.validate(
|
||||
data, {}, loc=("response",)
|
||||
)
|
||||
if errors_:
|
||||
ctx = endpoint_ctx or EndpointContext()
|
||||
raise ResponseValidationError(
|
||||
errors=errors_,
|
||||
body=data,
|
||||
endpoint_ctx=ctx,
|
||||
)
|
||||
return stream_item_field.serialize_json(
|
||||
value,
|
||||
include=response_model_include,
|
||||
exclude=response_model_exclude,
|
||||
by_alias=response_model_by_alias,
|
||||
exclude_unset=response_model_exclude_unset,
|
||||
exclude_defaults=response_model_exclude_defaults,
|
||||
exclude_none=response_model_exclude_none,
|
||||
)
|
||||
else:
|
||||
data = jsonable_encoder(data)
|
||||
return json.dumps(data).encode("utf-8")
|
||||
|
||||
if is_sse_stream:
|
||||
# Generator endpoint: stream as Server-Sent Events
|
||||
gen = dependant.call(**solved_result.values)
|
||||
|
||||
def _serialize_sse_item(item: Any) -> bytes:
|
||||
if isinstance(item, ServerSentEvent):
|
||||
# User controls the event structure.
|
||||
# Serialize the data payload if present.
|
||||
# For ServerSentEvent items we skip stream_item_field
|
||||
# validation (the user may mix types intentionally).
|
||||
if item.raw_data is not None:
|
||||
data_str: str | None = item.raw_data
|
||||
elif item.data is not None:
|
||||
if hasattr(item.data, "model_dump_json"):
|
||||
data_str = item.data.model_dump_json()
|
||||
else:
|
||||
data_str = json.dumps(jsonable_encoder(item.data))
|
||||
else:
|
||||
data_str = None
|
||||
return format_sse_event(
|
||||
data_str=data_str,
|
||||
event=item.event,
|
||||
id=item.id,
|
||||
retry=item.retry,
|
||||
comment=item.comment,
|
||||
)
|
||||
else:
|
||||
# Plain object: validate + serialize via
|
||||
# stream_item_field (if set) and wrap in data field
|
||||
return format_sse_event(
|
||||
data_str=_serialize_data(item).decode("utf-8")
|
||||
)
|
||||
|
||||
if dependant.is_async_gen_callable:
|
||||
sse_aiter: AsyncIterator[Any] = gen.__aiter__()
|
||||
else:
|
||||
sse_aiter = iterate_in_threadpool(gen)
|
||||
|
||||
async def _async_stream_sse() -> AsyncIterator[bytes]:
|
||||
# Use a memory stream to decouple generator iteration
|
||||
# from the keepalive timer. A producer task pulls items
|
||||
# from the generator independently, so
|
||||
# `anyio.fail_after` never wraps the generator's
|
||||
# `__anext__` directly - avoiding CancelledError that
|
||||
# would finalize the generator and also working for sync
|
||||
# generators running in a thread pool.
|
||||
send_stream, receive_stream = anyio.create_memory_object_stream[
|
||||
bytes
|
||||
](max_buffer_size=1)
|
||||
|
||||
async def _producer() -> None:
|
||||
async with send_stream:
|
||||
async for raw_item in sse_aiter:
|
||||
await send_stream.send(_serialize_sse_item(raw_item))
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(_producer)
|
||||
async with receive_stream:
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
with anyio.fail_after(_PING_INTERVAL):
|
||||
data = await receive_stream.receive()
|
||||
yield data
|
||||
# To allow for cancellation to trigger
|
||||
# Ref: https://github.com/fastapi/fastapi/issues/14680
|
||||
await anyio.sleep(0)
|
||||
except TimeoutError:
|
||||
yield KEEPALIVE_COMMENT
|
||||
except anyio.EndOfStream:
|
||||
pass
|
||||
|
||||
sse_stream_content: AsyncIterator[bytes] | Iterator[bytes] = (
|
||||
_async_stream_sse()
|
||||
)
|
||||
|
||||
response = StreamingResponse(
|
||||
sse_stream_content,
|
||||
media_type="text/event-stream",
|
||||
background=solved_result.background_tasks,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
# For Nginx proxies to not buffer server sent events
|
||||
response.headers["X-Accel-Buffering"] = "no"
|
||||
response.headers.raw.extend(solved_result.response.headers.raw)
|
||||
elif is_json_stream:
|
||||
# Generator endpoint: stream as JSONL
|
||||
gen = dependant.call(**solved_result.values)
|
||||
|
||||
def _serialize_item(item: Any) -> bytes:
|
||||
if stream_item_field:
|
||||
value, errors = stream_item_field.validate(
|
||||
item, {}, loc=("response",)
|
||||
)
|
||||
if errors:
|
||||
ctx = endpoint_ctx or EndpointContext()
|
||||
raise ResponseValidationError(
|
||||
errors=errors,
|
||||
body=item,
|
||||
endpoint_ctx=ctx,
|
||||
)
|
||||
line = stream_item_field.serialize_json(
|
||||
value,
|
||||
include=response_model_include,
|
||||
exclude=response_model_exclude,
|
||||
by_alias=response_model_by_alias,
|
||||
exclude_unset=response_model_exclude_unset,
|
||||
exclude_defaults=response_model_exclude_defaults,
|
||||
exclude_none=response_model_exclude_none,
|
||||
)
|
||||
return line + b"\n"
|
||||
else:
|
||||
data = jsonable_encoder(item)
|
||||
return json.dumps(data).encode("utf-8") + b"\n"
|
||||
return _serialize_data(item) + b"\n"
|
||||
|
||||
if dependant.is_async_gen_callable:
|
||||
|
||||
@@ -491,7 +589,7 @@ def get_request_handler(
|
||||
# Ref: https://github.com/fastapi/fastapi/issues/14680
|
||||
await anyio.sleep(0)
|
||||
|
||||
stream_content: AsyncIterator[bytes] | Iterator[bytes] = (
|
||||
jsonl_stream_content: AsyncIterator[bytes] | Iterator[bytes] = (
|
||||
_async_stream_jsonl()
|
||||
)
|
||||
else:
|
||||
@@ -500,10 +598,10 @@ def get_request_handler(
|
||||
for item in gen:
|
||||
yield _serialize_item(item)
|
||||
|
||||
stream_content = _sync_stream_jsonl()
|
||||
jsonl_stream_content = _sync_stream_jsonl()
|
||||
|
||||
response = StreamingResponse(
|
||||
stream_content,
|
||||
jsonl_stream_content,
|
||||
media_type="application/jsonl",
|
||||
background=solved_result.background_tasks,
|
||||
)
|
||||
@@ -709,9 +807,16 @@ class APIRoute(routing.Route):
|
||||
else:
|
||||
stream_item = get_stream_item_type(return_annotation)
|
||||
if stream_item is not None:
|
||||
# Only extract item type for JSONL streaming when no
|
||||
# explicit response_class (e.g. StreamingResponse) was set
|
||||
if isinstance(response_class, DefaultPlaceholder):
|
||||
# Extract item type for JSONL or SSE streaming when
|
||||
# response_class is DefaultPlaceholder (JSONL) or
|
||||
# EventSourceResponse (SSE).
|
||||
# ServerSentEvent is excluded: it's a transport
|
||||
# wrapper, not a data model, so it shouldn't feed
|
||||
# into validation or OpenAPI schema generation.
|
||||
if (
|
||||
isinstance(response_class, DefaultPlaceholder)
|
||||
or lenient_issubclass(response_class, EventSourceResponse)
|
||||
) and not lenient_issubclass(stream_item, ServerSentEvent):
|
||||
self.stream_item_type = stream_item
|
||||
response_model = None
|
||||
else:
|
||||
@@ -814,11 +919,16 @@ class APIRoute(routing.Route):
|
||||
name=self.unique_id,
|
||||
embed_body_fields=self._embed_body_fields,
|
||||
)
|
||||
# Detect generator endpoints that should stream as JSONL
|
||||
# (only when no explicit response_class like StreamingResponse is set)
|
||||
self.is_json_stream = isinstance(response_class, DefaultPlaceholder) and (
|
||||
# Detect generator endpoints that should stream as JSONL or SSE
|
||||
is_generator = (
|
||||
self.dependant.is_async_gen_callable or self.dependant.is_gen_callable
|
||||
)
|
||||
self.is_sse_stream = is_generator and lenient_issubclass(
|
||||
response_class, EventSourceResponse
|
||||
)
|
||||
self.is_json_stream = is_generator and isinstance(
|
||||
response_class, DefaultPlaceholder
|
||||
)
|
||||
self.app = request_response(self.get_route_handler())
|
||||
|
||||
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
||||
|
||||
222
fastapi/sse.py
Normal file
222
fastapi/sse.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from typing import Annotated, Any
|
||||
|
||||
from annotated_doc import Doc
|
||||
from pydantic import AfterValidator, BaseModel, Field, model_validator
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
# 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.
|
||||
|
||||
Works with **any HTTP method** (`GET`, `POST`, etc.), which makes it compatible
|
||||
with protocols like MCP that stream SSE over `POST`.
|
||||
|
||||
The actual encoding logic lives in the FastAPI routing layer. This class
|
||||
serves mainly as a marker and sets the correct `Content-Type`.
|
||||
"""
|
||||
|
||||
media_type = "text/event-stream"
|
||||
|
||||
|
||||
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
|
||||
@@ -324,6 +324,7 @@ ignore = [
|
||||
"docs_src/stream_json_lines/tutorial001_py310.py" = ["UP028"]
|
||||
"docs_src/stream_data/tutorial001_py310.py" = ["UP028"]
|
||||
"docs_src/stream_data/tutorial002_py310.py" = ["UP028"]
|
||||
"docs_src/server_sent_events/tutorial001_py310.py" = ["UP028"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-third-party = ["fastapi", "pydantic", "starlette"]
|
||||
|
||||
318
tests/test_sse.py
Normal file
318
tests/test_sse.py
Normal file
@@ -0,0 +1,318 @@
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import AsyncIterable, Iterable
|
||||
|
||||
import fastapi.routing
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.responses import EventSourceResponse
|
||||
from fastapi.sse import ServerSentEvent
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
items = [
|
||||
Item(name="Plumbus", description="A multi-purpose household device."),
|
||||
Item(name="Portal Gun", description="A portal opening device."),
|
||||
Item(name="Meeseeks Box", description="A box that summons a Meeseeks."),
|
||||
]
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/items/stream", response_class=EventSourceResponse)
|
||||
async def sse_items() -> AsyncIterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@app.get("/items/stream-sync", response_class=EventSourceResponse)
|
||||
def sse_items_sync() -> Iterable[Item]:
|
||||
yield from items
|
||||
|
||||
|
||||
@app.get("/items/stream-no-annotation", response_class=EventSourceResponse)
|
||||
async def sse_items_no_annotation():
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@app.get("/items/stream-sync-no-annotation", response_class=EventSourceResponse)
|
||||
def sse_items_sync_no_annotation():
|
||||
yield from items
|
||||
|
||||
|
||||
@app.get("/items/stream-dict", response_class=EventSourceResponse)
|
||||
async def sse_items_dict():
|
||||
for item in items:
|
||||
yield {"name": item.name, "description": item.description}
|
||||
|
||||
|
||||
@app.get("/items/stream-sse-event", response_class=EventSourceResponse)
|
||||
async def sse_items_event():
|
||||
yield ServerSentEvent(data="hello", event="greeting", id="1")
|
||||
yield ServerSentEvent(data={"key": "value"}, event="json-data", id="2")
|
||||
yield ServerSentEvent(comment="just a comment")
|
||||
yield ServerSentEvent(data="retry-test", retry=5000)
|
||||
|
||||
|
||||
@app.get("/items/stream-mixed", response_class=EventSourceResponse)
|
||||
async def sse_items_mixed() -> AsyncIterable[Item]:
|
||||
yield items[0]
|
||||
yield ServerSentEvent(data="custom-event", event="special")
|
||||
yield items[1]
|
||||
|
||||
|
||||
@app.get("/items/stream-string", response_class=EventSourceResponse)
|
||||
async def sse_items_string():
|
||||
yield ServerSentEvent(data="plain text data")
|
||||
|
||||
|
||||
@app.post("/items/stream-post", response_class=EventSourceResponse)
|
||||
async def sse_items_post() -> AsyncIterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@app.get("/items/stream-raw", response_class=EventSourceResponse)
|
||||
async def sse_items_raw():
|
||||
yield ServerSentEvent(raw_data="plain text without quotes")
|
||||
yield ServerSentEvent(raw_data="<div>html fragment</div>", event="html")
|
||||
yield ServerSentEvent(raw_data="cpu,87.3,1709145600", event="csv")
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/events", response_class=EventSourceResponse)
|
||||
async def stream_events():
|
||||
yield {"msg": "hello"}
|
||||
yield {"msg": "world"}
|
||||
|
||||
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def client_fixture():
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_async_generator_with_model(client: TestClient):
|
||||
response = client.get("/items/stream")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
assert response.headers["cache-control"] == "no-cache"
|
||||
assert response.headers["x-accel-buffering"] == "no"
|
||||
|
||||
lines = response.text.strip().split("\n")
|
||||
data_lines = [line for line in lines if line.startswith("data: ")]
|
||||
assert len(data_lines) == 3
|
||||
assert '"name":"Plumbus"' in data_lines[0] or '"name": "Plumbus"' in data_lines[0]
|
||||
assert (
|
||||
'"name":"Portal Gun"' in data_lines[1]
|
||||
or '"name": "Portal Gun"' in data_lines[1]
|
||||
)
|
||||
assert (
|
||||
'"name":"Meeseeks Box"' in data_lines[2]
|
||||
or '"name": "Meeseeks Box"' in data_lines[2]
|
||||
)
|
||||
|
||||
|
||||
def test_sync_generator_with_model(client: TestClient):
|
||||
response = client.get("/items/stream-sync")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
|
||||
def test_async_generator_no_annotation(client: TestClient):
|
||||
response = client.get("/items/stream-no-annotation")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
|
||||
def test_sync_generator_no_annotation(client: TestClient):
|
||||
response = client.get("/items/stream-sync-no-annotation")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
|
||||
def test_dict_items(client: TestClient):
|
||||
response = client.get("/items/stream-dict")
|
||||
assert response.status_code == 200
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
assert '"name"' in data_lines[0]
|
||||
|
||||
|
||||
def test_post_method_sse(client: TestClient):
|
||||
"""SSE should work with POST (needed for MCP compatibility)."""
|
||||
response = client.post("/items/stream-post")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
|
||||
def test_sse_events_with_fields(client: TestClient):
|
||||
response = client.get("/items/stream-sse-event")
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
|
||||
assert "event: greeting\n" in text
|
||||
assert 'data: "hello"\n' in text
|
||||
assert "id: 1\n" in text
|
||||
|
||||
assert "event: json-data\n" in text
|
||||
assert "id: 2\n" in text
|
||||
assert 'data: {"key": "value"}\n' in text
|
||||
|
||||
assert ": just a comment\n" in text
|
||||
|
||||
assert "retry: 5000\n" in text
|
||||
assert 'data: "retry-test"\n' in text
|
||||
|
||||
|
||||
def test_mixed_plain_and_sse_events(client: TestClient):
|
||||
response = client.get("/items/stream-mixed")
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
|
||||
assert "event: special\n" in text
|
||||
assert 'data: "custom-event"\n' in text
|
||||
assert '"name"' in text
|
||||
|
||||
|
||||
def test_string_data_json_encoded(client: TestClient):
|
||||
"""Strings are always JSON-encoded (quoted)."""
|
||||
response = client.get("/items/stream-string")
|
||||
assert response.status_code == 200
|
||||
assert 'data: "plain text data"\n' in response.text
|
||||
|
||||
|
||||
def test_server_sent_event_null_id_rejected():
|
||||
with pytest.raises(ValueError, match="null"):
|
||||
ServerSentEvent(data="test", id="has\0null")
|
||||
|
||||
|
||||
def test_server_sent_event_negative_retry_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
ServerSentEvent(data="test", retry=-1)
|
||||
|
||||
|
||||
def test_server_sent_event_float_retry_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
ServerSentEvent(data="test", retry=1.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_raw_data_sent_without_json_encoding(client: TestClient):
|
||||
"""raw_data is sent as-is, not JSON-encoded."""
|
||||
response = client.get("/items/stream-raw")
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
|
||||
# raw_data should appear without JSON quotes
|
||||
assert "data: plain text without quotes\n" in text
|
||||
# Not JSON-quoted
|
||||
assert 'data: "plain text without quotes"' not in text
|
||||
|
||||
assert "event: html\n" in text
|
||||
assert "data: <div>html fragment</div>\n" in text
|
||||
|
||||
assert "event: csv\n" in text
|
||||
assert "data: cpu,87.3,1709145600\n" in text
|
||||
|
||||
|
||||
def test_data_and_raw_data_mutually_exclusive():
|
||||
"""Cannot set both data and raw_data."""
|
||||
with pytest.raises(ValueError, match="Cannot set both"):
|
||||
ServerSentEvent(data="json", raw_data="raw")
|
||||
|
||||
|
||||
def test_sse_on_router_included_in_app(client: TestClient):
|
||||
response = client.get("/api/events")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 2
|
||||
|
||||
|
||||
# Keepalive ping tests
|
||||
|
||||
|
||||
keepalive_app = FastAPI()
|
||||
|
||||
|
||||
@keepalive_app.get("/slow-async", response_class=EventSourceResponse)
|
||||
async def slow_async_stream():
|
||||
yield {"n": 1}
|
||||
# Sleep longer than the (monkeypatched) ping interval so a keepalive
|
||||
# comment is emitted before the next item.
|
||||
await asyncio.sleep(0.3)
|
||||
yield {"n": 2}
|
||||
|
||||
|
||||
@keepalive_app.get("/slow-sync", response_class=EventSourceResponse)
|
||||
def slow_sync_stream():
|
||||
yield {"n": 1}
|
||||
time.sleep(0.3)
|
||||
yield {"n": 2}
|
||||
|
||||
|
||||
def test_keepalive_ping_async(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(fastapi.routing, "_PING_INTERVAL", 0.05)
|
||||
with TestClient(keepalive_app) as c:
|
||||
response = c.get("/slow-async")
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
# The keepalive comment ": ping" should appear between the two data events
|
||||
assert ": ping\n" in text
|
||||
data_lines = [line for line in text.split("\n") if line.startswith("data: ")]
|
||||
assert len(data_lines) == 2
|
||||
|
||||
|
||||
def test_keepalive_ping_sync(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(fastapi.routing, "_PING_INTERVAL", 0.05)
|
||||
with TestClient(keepalive_app) as c:
|
||||
response = c.get("/slow-sync")
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
assert ": ping\n" in text
|
||||
data_lines = [line for line in text.split("\n") if line.startswith("data: ")]
|
||||
assert len(data_lines) == 2
|
||||
|
||||
|
||||
def test_no_keepalive_when_fast(client: TestClient):
|
||||
"""No keepalive comment when items arrive quickly."""
|
||||
response = client.get("/items/stream")
|
||||
assert response.status_code == 200
|
||||
# KEEPALIVE_COMMENT is ": ping\n\n".
|
||||
assert ": ping\n" not in response.text
|
||||
191
tests/test_tutorial/test_server_sent_events/test_tutorial001.py
Normal file
191
tests/test_tutorial/test_server_sent_events/test_tutorial001.py
Normal file
@@ -0,0 +1,191 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
name="client",
|
||||
params=[
|
||||
pytest.param("tutorial001_py310"),
|
||||
],
|
||||
)
|
||||
def get_client(request: pytest.FixtureRequest):
|
||||
mod = importlib.import_module(f"docs_src.server_sent_events.{request.param}")
|
||||
|
||||
client = TestClient(mod.app)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/items/stream",
|
||||
"/items/stream-no-async",
|
||||
"/items/stream-no-annotation",
|
||||
"/items/stream-no-async-no-annotation",
|
||||
],
|
||||
)
|
||||
def test_stream_items(client: TestClient, path: str):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
|
||||
def test_openapi_schema(client: TestClient):
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == snapshot(
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "FastAPI", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/items/stream": {
|
||||
"get": {
|
||||
"summary": "Sse Items",
|
||||
"operationId": "sse_items_items_stream_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": {
|
||||
"$ref": "#/components/schemas/Item"
|
||||
},
|
||||
},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/items/stream-no-async": {
|
||||
"get": {
|
||||
"summary": "Sse Items No Async",
|
||||
"operationId": "sse_items_no_async_items_stream_no_async_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/json",
|
||||
"contentSchema": {
|
||||
"$ref": "#/components/schemas/Item"
|
||||
},
|
||||
},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
"required": ["data"],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/items/stream-no-annotation": {
|
||||
"get": {
|
||||
"summary": "Sse Items No Annotation",
|
||||
"operationId": "sse_items_no_annotation_items_stream_no_annotation_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"type": "string"},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/items/stream-no-async-no-annotation": {
|
||||
"get": {
|
||||
"summary": "Sse Items No Async No Annotation",
|
||||
"operationId": "sse_items_no_async_no_annotation_items_stream_no_async_no_annotation_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"type": "string"},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Item": {
|
||||
"properties": {
|
||||
"name": {"type": "string", "title": "Name"},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "null"},
|
||||
],
|
||||
"title": "Description",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["name", "description"],
|
||||
"title": "Item",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
name="client",
|
||||
params=[
|
||||
pytest.param("tutorial002_py310"),
|
||||
],
|
||||
)
|
||||
def get_client(request: pytest.FixtureRequest):
|
||||
mod = importlib.import_module(f"docs_src.server_sent_events.{request.param}")
|
||||
client = TestClient(mod.app)
|
||||
return client
|
||||
|
||||
|
||||
def test_stream_items(client: TestClient):
|
||||
response = client.get("/items/stream")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
|
||||
lines = response.text.strip().split("\n")
|
||||
|
||||
# First event is a comment-only event
|
||||
assert lines[0] == ": stream of item updates"
|
||||
|
||||
# Remaining lines contain event:, data:, id:, retry: fields
|
||||
event_lines = [line for line in lines if line.startswith("event: ")]
|
||||
assert len(event_lines) == 3
|
||||
assert all(line == "event: item_update" for line in event_lines)
|
||||
|
||||
data_lines = [line for line in lines if line.startswith("data: ")]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
id_lines = [line for line in lines if line.startswith("id: ")]
|
||||
assert id_lines == ["id: 1", "id: 2", "id: 3"]
|
||||
|
||||
retry_lines = [line for line in lines if line.startswith("retry: ")]
|
||||
assert len(retry_lines) == 3
|
||||
assert all(line == "retry: 5000" for line in retry_lines)
|
||||
|
||||
|
||||
def test_openapi_schema(client: TestClient):
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == snapshot(
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "FastAPI", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/items/stream": {
|
||||
"get": {
|
||||
"summary": "Stream Items",
|
||||
"operationId": "stream_items_items_stream_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"type": "string"},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
name="client",
|
||||
params=[
|
||||
pytest.param("tutorial003_py310"),
|
||||
],
|
||||
)
|
||||
def get_client(request: pytest.FixtureRequest):
|
||||
mod = importlib.import_module(f"docs_src.server_sent_events.{request.param}")
|
||||
client = TestClient(mod.app)
|
||||
return client
|
||||
|
||||
|
||||
def test_stream_logs(client: TestClient):
|
||||
response = client.get("/logs/stream")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
# raw_data is sent without JSON encoding (no quotes around the string)
|
||||
assert data_lines[0] == "data: 2025-01-01 INFO Application started"
|
||||
assert data_lines[1] == "data: 2025-01-01 DEBUG Connected to database"
|
||||
assert data_lines[2] == "data: 2025-01-01 WARN High memory usage detected"
|
||||
|
||||
|
||||
def test_openapi_schema(client: TestClient):
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == snapshot(
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "FastAPI", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/logs/stream": {
|
||||
"get": {
|
||||
"summary": "Stream Logs",
|
||||
"operationId": "stream_logs_logs_stream_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"type": "string"},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
164
tests/test_tutorial/test_server_sent_events/test_tutorial004.py
Normal file
164
tests/test_tutorial/test_server_sent_events/test_tutorial004.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
name="client",
|
||||
params=[
|
||||
pytest.param("tutorial004_py310"),
|
||||
],
|
||||
)
|
||||
def get_client(request: pytest.FixtureRequest):
|
||||
mod = importlib.import_module(f"docs_src.server_sent_events.{request.param}")
|
||||
client = TestClient(mod.app)
|
||||
return client
|
||||
|
||||
|
||||
def test_stream_all_items(client: TestClient):
|
||||
response = client.get("/items/stream")
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 3
|
||||
|
||||
id_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("id: ")
|
||||
]
|
||||
assert id_lines == ["id: 0", "id: 1", "id: 2"]
|
||||
|
||||
|
||||
def test_resume_from_last_event_id(client: TestClient):
|
||||
response = client.get(
|
||||
"/items/stream",
|
||||
headers={"last-event-id": "0"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 2
|
||||
|
||||
id_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("id: ")
|
||||
]
|
||||
assert id_lines == ["id: 1", "id: 2"]
|
||||
|
||||
|
||||
def test_resume_from_last_item(client: TestClient):
|
||||
response = client.get(
|
||||
"/items/stream",
|
||||
headers={"last-event-id": "1"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
data_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("data: ")
|
||||
]
|
||||
assert len(data_lines) == 1
|
||||
|
||||
id_lines = [
|
||||
line for line in response.text.strip().split("\n") if line.startswith("id: ")
|
||||
]
|
||||
assert id_lines == ["id: 2"]
|
||||
|
||||
|
||||
def test_openapi_schema(client: TestClient):
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == snapshot(
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "FastAPI", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/items/stream": {
|
||||
"get": {
|
||||
"summary": "Stream Items",
|
||||
"operationId": "stream_items_items_stream_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "last-event-id",
|
||||
"in": "header",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"title": "Last-Event-Id",
|
||||
},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"type": "string"},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Detail",
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "HTTPValidationError",
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"loc": {
|
||||
"items": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer"}]
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Location",
|
||||
},
|
||||
"msg": {"type": "string", "title": "Message"},
|
||||
"type": {"type": "string", "title": "Error Type"},
|
||||
"input": {"title": "Input"},
|
||||
"ctx": {"type": "object", "title": "Context"},
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["loc", "msg", "type"],
|
||||
"title": "ValidationError",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
141
tests/test_tutorial/test_server_sent_events/test_tutorial005.py
Normal file
141
tests/test_tutorial/test_server_sent_events/test_tutorial005.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
name="client",
|
||||
params=[
|
||||
pytest.param("tutorial005_py310"),
|
||||
],
|
||||
)
|
||||
def get_client(request: pytest.FixtureRequest):
|
||||
mod = importlib.import_module(f"docs_src.server_sent_events.{request.param}")
|
||||
client = TestClient(mod.app)
|
||||
return client
|
||||
|
||||
|
||||
def test_stream_chat(client: TestClient):
|
||||
response = client.post(
|
||||
"/chat/stream",
|
||||
json={"text": "hello world"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
|
||||
lines = response.text.strip().split("\n")
|
||||
|
||||
event_lines = [line for line in lines if line.startswith("event: ")]
|
||||
assert event_lines == [
|
||||
"event: token",
|
||||
"event: token",
|
||||
"event: done",
|
||||
]
|
||||
|
||||
data_lines = [line for line in lines if line.startswith("data: ")]
|
||||
assert data_lines == [
|
||||
'data: "hello"',
|
||||
'data: "world"',
|
||||
"data: [DONE]",
|
||||
]
|
||||
|
||||
|
||||
def test_openapi_schema(client: TestClient):
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == snapshot(
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "FastAPI", "version": "0.1.0"},
|
||||
"paths": {
|
||||
"/chat/stream": {
|
||||
"post": {
|
||||
"summary": "Stream Chat",
|
||||
"operationId": "stream_chat_chat_stream_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Prompt"}
|
||||
}
|
||||
},
|
||||
"required": True,
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"text/event-stream": {
|
||||
"itemSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {"type": "string"},
|
||||
"event": {"type": "string"},
|
||||
"id": {"type": "string"},
|
||||
"retry": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Detail",
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "HTTPValidationError",
|
||||
},
|
||||
"Prompt": {
|
||||
"properties": {"text": {"type": "string", "title": "Text"}},
|
||||
"type": "object",
|
||||
"required": ["text"],
|
||||
"title": "Prompt",
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"loc": {
|
||||
"items": {
|
||||
"anyOf": [{"type": "string"}, {"type": "integer"}]
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Location",
|
||||
},
|
||||
"msg": {"type": "string", "title": "Message"},
|
||||
"type": {"type": "string", "title": "Error Type"},
|
||||
"input": {"title": "Input"},
|
||||
"ctx": {"type": "object", "title": "Context"},
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["loc", "msg", "type"],
|
||||
"title": "ValidationError",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
30
uv.lock
generated
30
uv.lock
generated
@@ -4406,28 +4406,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-codspeed"
|
||||
version = "4.2.0"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi" },
|
||||
{ name = "pytest" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/e8/27fcbe6516a1c956614a4b61a7fccbf3791ea0b992e07416e8948184327d/pytest_codspeed-4.2.0.tar.gz", hash = "sha256:04b5d0bc5a1851ba1504d46bf9d7dbb355222a69f2cd440d54295db721b331f7", size = 113263, upload-time = "2025-10-24T09:02:55.704Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/ab/eca41967d11c95392829a8b4bfa9220a51cffc4a33ec4653358000356918/pytest_codspeed-4.3.0.tar.gz", hash = "sha256:5230d9d65f39063a313ed1820df775166227ec5c20a1122968f85653d5efee48", size = 124745, upload-time = "2026-02-09T15:23:34.745Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/b8/d599a466c50af3f04001877ae8b17c12b803f3b358235736b91a0769de0d/pytest_codspeed-4.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609828b03972966b75b9b7416fa2570c4a0f6124f67e02d35cd3658e64312a7b", size = 261943, upload-time = "2025-10-24T09:02:37.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/19/ccc1a2fcd28357a8db08ba6b60f381832088a3850abc262c8e0b3406491a/pytest_codspeed-4.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23a0c0fbf8bb4de93a3454fd9e5efcdca164c778aaef0a9da4f233d85cb7f5b8", size = 250782, upload-time = "2025-10-24T09:02:39.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/2d/f0083a2f14ecf008d961d40439a71da0ae0d568e5f8dc2fccd3e8a2ab3e4/pytest_codspeed-4.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2de87bde9fbc6fd53f0fd21dcf2599c89e0b8948d49f9bad224edce51c47e26b", size = 261960, upload-time = "2025-10-24T09:02:40.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/0c/1f514c553db4ea5a69dfbe2706734129acd0eca8d5101ec16f1dd00dbc0f/pytest_codspeed-4.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95aeb2479ca383f6b18e2cc9ebcd3b03ab184980a59a232aea6f370bbf59a1e3", size = 250808, upload-time = "2025-10-24T09:02:42.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/04/479905bd6653bc981c0554fcce6df52d7ae1594e1eefd53e6cf31810ec7f/pytest_codspeed-4.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d4fefbd4ae401e2c60f6be920a0be50eef0c3e4a1f0a1c83962efd45be38b39", size = 262084, upload-time = "2025-10-24T09:02:43.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/46/d6f345d7907bac6cbb6224bd697ecbc11cf7427acc9e843c3618f19e3476/pytest_codspeed-4.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309b4227f57fcbb9df21e889ea1ae191d0d1cd8b903b698fdb9ea0461dbf1dfe", size = 251100, upload-time = "2025-10-24T09:02:44.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/dc/e864f45e994a50390ff49792256f1bdcbf42f170e3bc0470ee1a7d2403f3/pytest_codspeed-4.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72aab8278452a6d020798b9e4f82780966adb00f80d27a25d1274272c54630d5", size = 262057, upload-time = "2025-10-24T09:02:45.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/1c/f1d2599784486879cf6579d8d94a3e22108f0e1f130033dab8feefd29249/pytest_codspeed-4.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:684fcd9491d810ded653a8d38de4835daa2d001645f4a23942862950664273f8", size = 251013, upload-time = "2025-10-24T09:02:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/fd/eafd24db5652a94b4d00fe9b309b607de81add0f55f073afb68a378a24b6/pytest_codspeed-4.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50794dabea6ec90d4288904452051e2febace93e7edf4ca9f2bce8019dd8cd37", size = 262065, upload-time = "2025-10-24T09:02:48.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/14/8d9340d7dc0ae647991b28a396e16b3403e10def883cde90d6b663d3f7ec/pytest_codspeed-4.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0ebd87f2a99467a1cfd8e83492c4712976e43d353ee0b5f71cbb057f1393aca", size = 251057, upload-time = "2025-10-24T09:02:49.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/39/48cf6afbca55bc7c8c93c3d4ae926a1068bcce3f0241709db19b078d5418/pytest_codspeed-4.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbbb2d61b85bef8fc7e2193f723f9ac2db388a48259d981bbce96319043e9830", size = 267983, upload-time = "2025-10-24T09:02:50.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/86/4407341efb5dceb3e389635749ce1d670542d6ca148bd34f9d5334295faf/pytest_codspeed-4.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:748411c832147bfc85f805af78a1ab1684f52d08e14aabe22932bbe46c079a5f", size = 256732, upload-time = "2025-10-24T09:02:51.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/0e/8cb71fd3ed4ed08c07aec1245aea7bc1b661ba55fd9c392db76f1978d453/pytest_codspeed-4.2.0-py3-none-any.whl", hash = "sha256:e81bbb45c130874ef99aca97929d72682733527a49f84239ba575b5cb843bab0", size = 113726, upload-time = "2025-10-24T09:02:54.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/64/800bdaeabd3eb126aff7e3e22dc45b2826305f61cbfd093284caf8d9ca01/pytest_codspeed-4.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2acecc4126658abebc683b38121adec405a46e18a619d49d6154c6e60c5deb2", size = 347077, upload-time = "2026-02-09T15:23:17.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f1/d69707440829adab86d078d5f1c8c070df116b1624f8eae4ff36933ba612/pytest_codspeed-4.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:619120775e92a3f43fb4ff4c256a251b1554c904d95e2154a382484283f0388a", size = 342234, upload-time = "2026-02-09T15:23:18.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/15/ec0ac1f022173b3134c9638f2a35f21fbb3142c75da066d9e49e5a8bb4bd/pytest_codspeed-4.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbeff1eb2f2e36df088658b556fa993e6937bf64ffb07406de4db16fd2b26874", size = 347076, upload-time = "2026-02-09T15:23:19.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/e8/1fe375794ad02b7835f378a7bcfa8fbac9acadefe600a782a7c4a7064db7/pytest_codspeed-4.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:878aad5e4bb7b401ad8d82f3af5186030cd2bd0d0446782e10dabb9db8827466", size = 342215, upload-time = "2026-02-09T15:23:20.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/58/50df94e9a78e1c77818a492c90557eeb1309af025120c9a21e6375950c52/pytest_codspeed-4.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527a3a02eaa3e4d4583adc4ba2327eef79628f3e1c682a4b959439551a72588e", size = 347395, upload-time = "2026-02-09T15:23:21.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/56/7dfbd3eefd112a14e6fb65f9ff31dacf2e9c381cb94b27332b81d2b13f8d/pytest_codspeed-4.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9858c2a6e1f391d5696757e7b6e9484749a7376c46f8b4dd9aebf093479a9667", size = 342625, upload-time = "2026-02-09T15:23:23.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/53/7255f6a25bc56ff1745b254b21545dfe0be2268f5b91ce78f7e8a908f0ad/pytest_codspeed-4.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34f2fd8497456eefbd325673f677ea80d93bb1bc08a578c1fa43a09cec3d1879", size = 347325, upload-time = "2026-02-09T15:23:23.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/f8/82ae570d8b9ad30f33c9d4002a7a1b2740de0e090540c69a28e4f711ebe2/pytest_codspeed-4.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df6a36a2a9da1406bc50428437f657f0bd8c842ae54bee5fb3ad30e01d50c0f5", size = 342558, upload-time = "2026-02-09T15:23:25.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e1/55cfe9474f91d174c7a4b04d257b5fc6d4d06f3d3680f2da672ee59ccc10/pytest_codspeed-4.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bec30f4fc9c4973143cd80f0d33fa780e9fa3e01e4dbe8cedf229e72f1212c62", size = 347383, upload-time = "2026-02-09T15:23:26.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3b/8fd781d959bbe789b3de8ce4c50d5706a684a0df377147dfb27b200c20c1/pytest_codspeed-4.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6584e641cadf27d894ae90b87c50377232a97cbfd76ee0c7ecd0c056fa3f7f4", size = 342481, upload-time = "2026-02-09T15:23:27.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/0c/368045133c6effa2c665b1634b7b8a9c88b307f877fa31f1f8df47885b51/pytest_codspeed-4.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0d1f6ea594f29b745c634d66d5f5f1caa1c3abd2af82fea49d656038e8fc77", size = 353680, upload-time = "2026-02-09T15:23:28.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/21/e543abcd72244294e25ae88ec3a9311ade24d6913f8c8f42569d671700bc/pytest_codspeed-4.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2f5bb6d8898bea7db45e3c8b916ee48e36905b929477bb511b79c5a3ccacda4", size = 347888, upload-time = "2026-02-09T15:23:30.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/d9/b8a53c20cf5b41042c205bb9d36d37da00418d30fd1a94bf9eb147820720/pytest_codspeed-4.3.0-py3-none-any.whl", hash = "sha256:05baff2a61dc9f3e92b92b9c2ab5fb45d9b802438f5373073f5766a91319ed7a", size = 125224, upload-time = "2026-02-09T15:23:33.774Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user