mirror of
https://github.com/fastapi/fastapi.git
synced 2026-03-01 05:08:40 -05:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ea7be0be | ||
|
|
4cd76acafc | ||
|
|
2238155844 | ||
|
|
48d58ae3b6 | ||
|
|
d3b1d6cbd4 | ||
|
|
d98eb74da9 | ||
|
|
d33ad3f90f | ||
|
|
b7fefb147e | ||
|
|
c01dc8b03c | ||
|
|
8344d078e2 | ||
|
|
1377052c6c | ||
|
|
c0836dc1b7 | ||
|
|
c3f54a0794 | ||
|
|
ea80e466db | ||
|
|
749cefdeb1 | ||
|
|
5a4d3aa26e | ||
|
|
0901b4092c | ||
|
|
873e48fb15 | ||
|
|
5aacc7b6a0 | ||
|
|
a4ad07b48a | ||
|
|
728b097564 | ||
|
|
84a8760a80 | ||
|
|
4d78ca6f95 | ||
|
|
4fce9ce172 | ||
|
|
2b476737b8 | ||
|
|
1fa1065f9e | ||
|
|
daba0aa328 | ||
|
|
0c3581d5c4 | ||
|
|
c73bc94537 | ||
|
|
6c68838615 | ||
|
|
29d082ba24 | ||
|
|
2686c7fbbf | ||
|
|
2f9c914d44 | ||
|
|
0cf27ecf88 | ||
|
|
3f30ca1a5e | ||
|
|
6af3832126 |
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -107,7 +107,7 @@ jobs:
|
||||
run: uv pip install "git+https://github.com/Kludex/starlette@main"
|
||||
- run: mkdir coverage
|
||||
- name: Test
|
||||
run: uv run --no-sync bash scripts/test.sh
|
||||
run: uv run --no-sync bash scripts/test-cov.sh
|
||||
env:
|
||||
COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}
|
||||
CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}
|
||||
@@ -208,4 +208,4 @@ jobs:
|
||||
uses: re-actors/alls-green@release/v1
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
allowed-skips: coverage-combine,test
|
||||
allowed-skips: coverage-combine,test,benchmark
|
||||
|
||||
@@ -38,13 +38,13 @@ In der Produktion hätten Sie eine der oben genannten Optionen.
|
||||
|
||||
Aber es ist der einfachste Weg, sich auf die Serverseite von WebSockets zu konzentrieren und ein funktionierendes Beispiel zu haben:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## Einen `websocket` erstellen { #create-a-websocket }
|
||||
|
||||
Erstellen Sie in Ihrer **FastAPI**-Anwendung einen `websocket`:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | Technische Details
|
||||
|
||||
@@ -58,7 +58,7 @@ Sie könnten auch `from starlette.websockets import WebSocket` verwenden.
|
||||
|
||||
In Ihrer WebSocket-Route können Sie Nachrichten `await`en und Nachrichten senden.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
Sie können Binär-, Text- und JSON-Daten empfangen und senden.
|
||||
|
||||
@@ -109,7 +109,7 @@ In WebSocket-Endpunkten können Sie Folgendes aus `fastapi` importieren und verw
|
||||
|
||||
Diese funktionieren auf die gleiche Weise wie für andere FastAPI-Endpunkte/*Pfadoperationen*:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info | Info
|
||||
|
||||
@@ -154,7 +154,7 @@ Damit können Sie den WebSocket verbinden und dann Nachrichten senden und empfan
|
||||
|
||||
Wenn eine WebSocket-Verbindung geschlossen wird, löst `await websocket.receive_text()` eine `WebSocketDisconnect`-Exception aus, die Sie dann wie in folgendem Beispiel abfangen und behandeln können.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
Zum Ausprobieren:
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,6 +138,14 @@ Takes some data and returns an `application/json` encoded response.
|
||||
|
||||
This is the default response used in **FastAPI**, as you read above.
|
||||
|
||||
/// note | Technical Details
|
||||
|
||||
But if you declare a response model or return type, that will be used directly to serialize the data to JSON, and a response with the right media type for JSON will be returned directly, without using the `JSONResponse` class.
|
||||
|
||||
This is the ideal way to get the best performance.
|
||||
|
||||
///
|
||||
|
||||
### `RedirectResponse` { #redirectresponse }
|
||||
|
||||
Returns an HTTP redirect. Uses a 307 status code (Temporary Redirect) by default.
|
||||
@@ -165,31 +173,25 @@ You can also use the `status_code` parameter combined with the `response_class`
|
||||
|
||||
### `StreamingResponse` { #streamingresponse }
|
||||
|
||||
Takes an async generator or a normal generator/iterator and streams the response body.
|
||||
Takes an async generator or a normal generator/iterator (a function with `yield`) and streams the response body.
|
||||
|
||||
{* ../../docs_src/custom_response/tutorial007_py310.py hl[2,14] *}
|
||||
{* ../../docs_src/custom_response/tutorial007_py310.py hl[3,16] *}
|
||||
|
||||
#### Using `StreamingResponse` with file-like objects { #using-streamingresponse-with-file-like-objects }
|
||||
/// note | Technical Details
|
||||
|
||||
If you have a <a href="https://docs.python.org/3/glossary.html#term-file-like-object" class="external-link" target="_blank">file-like</a> object (e.g. the object returned by `open()`), you can create a generator function to iterate over that file-like object.
|
||||
An `async` task can only be cancelled when it reaches an `await`. If there is no `await`, the generator (function with `yield`) can not be cancelled properly and may keep running even after cancellation is requested.
|
||||
|
||||
That way, you don't have to read it all first in memory, and you can pass that generator function to the `StreamingResponse`, and return it.
|
||||
Since this small example does not need any `await` statements, we add an `await anyio.sleep(0)` to give the event loop a chance to handle cancellation.
|
||||
|
||||
This includes many libraries to interact with cloud storage, video processing, and others.
|
||||
This would be even more important with large or infinite streams.
|
||||
|
||||
{* ../../docs_src/custom_response/tutorial008_py310.py hl[2,10:12,14] *}
|
||||
|
||||
1. This is the generator function. It's a "generator function" because it contains `yield` statements inside.
|
||||
2. By using a `with` block, we make sure that the file-like object is closed after the generator function is done. So, after it finishes sending the response.
|
||||
3. This `yield from` tells the function to iterate over that thing named `file_like`. And then, for each part iterated, yield that part as coming from this generator function (`iterfile`).
|
||||
|
||||
So, it is a generator function that transfers the "generating" work to something else internally.
|
||||
|
||||
By doing it this way, we can put it in a `with` block, and that way, ensure that the file-like object is closed after finishing.
|
||||
///
|
||||
|
||||
/// tip
|
||||
|
||||
Notice that here as we are using standard `open()` that doesn't support `async` and `await`, we declare the path operation with normal `def`.
|
||||
Instead of returning a `StreamingResponse` directly, you should probably follow the style in [Stream Data](./stream-data.md){.internal-link target=_blank}, it's much more convenient and handles cancellation behind the scenes for you.
|
||||
|
||||
If you are streaming JSON Lines, follow the [Stream JSON Lines](../tutorial/stream-json-lines.md){.internal-link target=_blank} tutorial.
|
||||
|
||||
///
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ You will normally have much better performance using a [Response Model](../tutor
|
||||
|
||||
## Return a `Response` { #return-a-response }
|
||||
|
||||
You can return any `Response` or any sub-class of it.
|
||||
You can return a `Response` or any sub-class of it.
|
||||
|
||||
/// info
|
||||
|
||||
@@ -28,7 +28,9 @@ And when you return a `Response`, **FastAPI** will pass it directly.
|
||||
|
||||
It won't do any data conversion with Pydantic models, it won't convert the contents to any type, etc.
|
||||
|
||||
This gives you a lot of flexibility. You can return any data type, override any data declaration or validation, etc.
|
||||
This gives you a lot of **flexibility**. You can return any data type, override any data declaration or validation, etc.
|
||||
|
||||
It also gives you a lot of **responsibility**. You have to make sure that the data you return is correct, in the correct format, that it can be serialized, etc.
|
||||
|
||||
## Using the `jsonable_encoder` in a `Response` { #using-the-jsonable-encoder-in-a-response }
|
||||
|
||||
@@ -62,15 +64,15 @@ You could put your XML content in a string, put that in a `Response`, and return
|
||||
|
||||
## How a Response Model Works { #how-a-response-model-works }
|
||||
|
||||
When you declare a [Response Model](../tutorial/response-model.md){.internal-link target=_blank} in a path operation, **FastAPI** will use it to serialize the data to JSON, using Pydantic.
|
||||
When you declare a [Response Model - Return Type](../tutorial/response-model.md){.internal-link target=_blank} in a path operation, **FastAPI** will use it to serialize the data to JSON, using Pydantic.
|
||||
|
||||
{* ../../docs_src/response_model/tutorial001_01_py310.py hl[16,21] *}
|
||||
|
||||
As that will happen on the Rust side, the performance will be much better than if it was done with regular Python and the `JSONResponse` class.
|
||||
|
||||
When using a response model FastAPI won't use the `jsonable_encoder` to convert the data (which would be slower) nor the `JSONResponse` class.
|
||||
When using a `response_model` or return type, FastAPI won't use the `jsonable_encoder` to convert the data (which would be slower) nor the `JSONResponse` class.
|
||||
|
||||
Instead it takes the JSON bytes generated with Pydantic using the response model and returns a `Response` with the right media type for JSON directly (`application/json`).
|
||||
Instead it takes the JSON bytes generated with Pydantic using the response model (or return type) and returns a `Response` with the right media type for JSON directly (`application/json`).
|
||||
|
||||
## Notes { #notes }
|
||||
|
||||
|
||||
117
docs/en/docs/advanced/stream-data.md
Normal file
117
docs/en/docs/advanced/stream-data.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Stream Data { #stream-data }
|
||||
|
||||
If you want to stream data that can be structured as JSON, you should [Stream JSON Lines](../tutorial/stream-json-lines.md){.internal-link target=_blank}.
|
||||
|
||||
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.
|
||||
|
||||
You could also use it to stream **large binary files**, where you stream each chunk of data as you read it, without having to read it all in memory at once.
|
||||
|
||||
You could also stream **video** or **audio** this way, it could even be generated as you process and send it.
|
||||
|
||||
## A `StreamingResponse` with `yield` { #a-streamingresponse-with-yield }
|
||||
|
||||
If you declare a `response_class=StreamingResponse` in your *path operation function*, you can use `yield` to send each chunk of data in turn.
|
||||
|
||||
{* ../../docs_src/stream_data/tutorial001_py310.py ln[1:23] hl[20,23] *}
|
||||
|
||||
FastAPI will give each chunk of data to the `StreamingResponse` as is, it won't try to convert it to JSON or anything similar.
|
||||
|
||||
### 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.
|
||||
|
||||
{* ../../docs_src/stream_data/tutorial001_py310.py ln[26:29] hl[27] *}
|
||||
|
||||
### No Annotation { #no-annotation }
|
||||
|
||||
You don't really need to declare the return type annotation for streaming binary data.
|
||||
|
||||
As FastAPI will not try to convert the data to JSON with Pydantic or serialize it in any way, in this case, the type annotation is only for your editor and tools to use, it won't be used by FastAPI.
|
||||
|
||||
{* ../../docs_src/stream_data/tutorial001_py310.py ln[32:35] hl[33] *}
|
||||
|
||||
This also means that with `StreamingResponse` you have the **freedom** and **responsibility** to produce and encode the data bytes exactly as you need them to be sent, independent of the type annotations. 🤓
|
||||
|
||||
### Stream Bytes { #stream-bytes }
|
||||
|
||||
One of the main use cases would be to stream `bytes` instead of strings, you can of course do it.
|
||||
|
||||
{* ../../docs_src/stream_data/tutorial001_py310.py ln[44:47] hl[47] *}
|
||||
|
||||
## A Custom `PNGStreamingResponse` { #a-custom-pngstreamingresponse }
|
||||
|
||||
In the examples above, the data bytes were streamed, but the response didn't have a `Content-Type` header, so the client didn't know what type of data it was receiving.
|
||||
|
||||
You can create a custom sub-class of `StreamingResponse` that sets the `Content-Type` header to the type of data you're streaming.
|
||||
|
||||
For example, you can create a `PNGStreamingResponse` that sets the `Content-Type` header to `image/png` using the `media_type` attribute:
|
||||
|
||||
{* ../../docs_src/stream_data/tutorial002_py310.py ln[6,19:20] hl[20] *}
|
||||
|
||||
Then you can use this new class in `response_class=PNGStreamingResponse` in your *path operation function*:
|
||||
|
||||
{* ../../docs_src/stream_data/tutorial002_py310.py ln[23:27] hl[23] *}
|
||||
|
||||
### Simulate a File { #simulate-a-file }
|
||||
|
||||
In this example, we are simulating a file with `io.BytesIO`, which is a file-like object that lives only in memory, but lets us use the same interface.
|
||||
|
||||
For example, we can iterate over it to consume its contents, as we could with a file.
|
||||
|
||||
{* ../../docs_src/stream_data/tutorial002_py310.py ln[1:27] hl[3,12:13,25] *}
|
||||
|
||||
/// note | Technical Details
|
||||
|
||||
The other two variables, `image_base64` and `binary_image`, are an image encoded in Base64, and then converted to bytes, to then pass it to `io.BytesIO`.
|
||||
|
||||
Only so that it can live in the same file for this example and you can copy it and run it as is. 🥚
|
||||
|
||||
///
|
||||
|
||||
By using a `with` block, we make sure that the file-like object is closed after the generator function (the function with `yield`) is done. So, after it finishes sending the response.
|
||||
|
||||
It wouldn't be that important in this specific example because it's a fake in-memory file (with `io.BytesIO`), but with a real file, it would be important to make sure the file is closed after the work with it is done.
|
||||
|
||||
### Files and Async { #files-and-async }
|
||||
|
||||
In most cases, file-like objects are not compatible with async and await by default.
|
||||
|
||||
For example, they don't have an `await file.read()`, or `async for chunk in file`.
|
||||
|
||||
And in many cases, reading them would be a blocking operation (that could block the event loop), because they are read from disk or from the network.
|
||||
|
||||
/// info
|
||||
|
||||
The example above is actually an exception, because the `io.BytesIO` object is already in memory, so reading it won't block anything.
|
||||
|
||||
But in many cases reading a file or a file-like object would block.
|
||||
|
||||
///
|
||||
|
||||
To avoid blocking the event loop, you can simply declare the *path operation function* with regular `def` instead of `async def`, that way FastAPI will run it on a threadpool worker, to avoid blocking the main loop.
|
||||
|
||||
{* ../../docs_src/stream_data/tutorial002_py310.py ln[30:34] hl[31] *}
|
||||
|
||||
/// tip
|
||||
|
||||
If you need to call blocking code from inside of an async function, or an async function from inside of a blocking function, you could use <a href="https://asyncer.tiangolo.com" class="external-link" target="_blank">Asyncer</a>, a sibling library to FastAPI.
|
||||
|
||||
///
|
||||
|
||||
### `yield from` { #yield-from }
|
||||
|
||||
When you are iterating over something, like a file-like object, and then you are doing `yield` for each item, you could also use `yield from` to yield each item directly and skip the `for` loop.
|
||||
|
||||
This is not particular to FastAPI, it's just Python, but it's a nice trick to know. 😎
|
||||
|
||||
{* ../../docs_src/stream_data/tutorial002_py310.py ln[37:40] hl[40] *}
|
||||
@@ -38,13 +38,13 @@ In production you would have one of the options above.
|
||||
|
||||
But it's the simplest way to focus on the server-side of WebSockets and have a working example:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## Create a `websocket` { #create-a-websocket }
|
||||
|
||||
In your **FastAPI** application, create a `websocket`:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | Technical Details
|
||||
|
||||
@@ -58,7 +58,7 @@ You could also use `from starlette.websockets import WebSocket`.
|
||||
|
||||
In your WebSocket route you can `await` for messages and send messages.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
You can receive and send binary, text, and JSON data.
|
||||
|
||||
@@ -109,7 +109,7 @@ In WebSocket endpoints you can import from `fastapi` and use:
|
||||
|
||||
They work the same way as for other FastAPI endpoints/*path operations*:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info
|
||||
|
||||
@@ -154,7 +154,7 @@ With that you can connect the WebSocket and then send and receive messages:
|
||||
|
||||
When a WebSocket connection is closed, the `await websocket.receive_text()` will raise a `WebSocketDisconnect` exception, which you can then catch and handle like in this example.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
To try it out:
|
||||
|
||||
|
||||
@@ -7,6 +7,61 @@ hide:
|
||||
|
||||
## Latest Changes
|
||||
|
||||
## 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
|
||||
|
||||
* ✨ Add support for streaming JSON Lines and binary data with `yield`. PR [#15022](https://github.com/fastapi/fastapi/pull/15022) by [@tiangolo](https://github.com/tiangolo).
|
||||
* This also upgrades Starlette from `>=0.40.0` to `>=0.46.0`, as it's needed to properly unrwap and re-raise exceptions from exception groups.
|
||||
* New docs: [Stream JSON Lines](https://fastapi.tiangolo.com/tutorial/stream-json-lines/).
|
||||
* And new docs: [Stream Data](https://fastapi.tiangolo.com/advanced/stream-data/).
|
||||
|
||||
### Docs
|
||||
|
||||
* 📝 Update Library Agent Skill with streaming responses. PR [#15024](https://github.com/fastapi/fastapi/pull/15024) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 📝 Update docs for responses and new stream with `yield`. PR [#15023](https://github.com/fastapi/fastapi/pull/15023) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 📝 Add `await` in `StreamingResponse` code example to allow cancellation. PR [#14681](https://github.com/fastapi/fastapi/pull/14681) by [@casperdcl](https://github.com/casperdcl).
|
||||
* 📝 Rename `docs_src/websockets` to `docs_src/websockets_` to avoid import errors. PR [#14979](https://github.com/fastapi/fastapi/pull/14979) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
|
||||
### Internal
|
||||
|
||||
* 🔨 Run tests with `pytest-xdist` and `pytest-cov`. PR [#14992](https://github.com/fastapi/fastapi/pull/14992) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
|
||||
## 0.133.1
|
||||
|
||||
### Features
|
||||
|
||||
* 🔧 Add FastAPI Agents Skill. PR [#14982](https://github.com/fastapi/fastapi/pull/14982) by [@tiangolo](https://github.com/tiangolo).
|
||||
* Read more about it in [Library Agent Skills](https://tiangolo.com/ideas/library-agent-skills/).
|
||||
|
||||
### Internal
|
||||
|
||||
* ✅ Fix all tests are skipped on Windows. PR [#14994](https://github.com/fastapi/fastapi/pull/14994) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
|
||||
## 0.133.0
|
||||
|
||||
### Upgrades
|
||||
|
||||
* ⬆️ Add support for Starlette 1.0.0+. PR [#14987](https://github.com/fastapi/fastapi/pull/14987) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.132.1
|
||||
|
||||
### Refactors
|
||||
|
||||
* ♻️ Refactor logic to handle OpenAPI and Swagger UI escaping data. PR [#14986](https://github.com/fastapi/fastapi/pull/14986) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Internal
|
||||
|
||||
* 👥 Update FastAPI People - Experts. PR [#14972](https://github.com/fastapi/fastapi/pull/14972) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 👷 Allow skipping `benchmark` job in `test` workflow. PR [#14974](https://github.com/fastapi/fastapi/pull/14974) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
|
||||
## 0.132.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
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. 🤓
|
||||
111
docs/en/docs/tutorial/stream-json-lines.md
Normal file
111
docs/en/docs/tutorial/stream-json-lines.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Stream JSON Lines { #stream-json-lines }
|
||||
|
||||
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.
|
||||
|
||||
So, it will send the first item, the client will receive and start processing it, and you might still be producing the next item.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App
|
||||
participant Client
|
||||
|
||||
App->>App: Produce Item 1
|
||||
App->>Client: Send Item 1
|
||||
App->>App: Produce Item 2
|
||||
Client->>Client: Process Item 1
|
||||
App->>Client: Send Item 2
|
||||
App->>App: Produce Item 3
|
||||
Client->>Client: Process Item 2
|
||||
App->>Client: Send Item 3
|
||||
Client->>Client: Process Item 3
|
||||
Note over App: Keeps producing...
|
||||
Note over Client: Keeps consuming...
|
||||
```
|
||||
|
||||
It could even be an infinite stream, where you keep sending data.
|
||||
|
||||
## JSON Lines { #json-lines }
|
||||
|
||||
In these cases, it's common to send "**JSON Lines**", which is a format where you send one JSON object per line.
|
||||
|
||||
A response would have a content type of `application/jsonl` (instead of `application/json`) and the body would be something like:
|
||||
|
||||
```json
|
||||
{"name": "Plumbus", "description": "A multi-purpose household device."}
|
||||
{"name": "Portal Gun", "description": "A portal opening device."}
|
||||
{"name": "Meeseeks Box", "description": "A box that summons a Meeseeks."}
|
||||
```
|
||||
|
||||
It's very similar to a JSON array (equivalent of a Python list), but instead of being wrapped in `[]` and having `,` between the items, it has **one JSON object per line**, they are separated by a new line character.
|
||||
|
||||
/// info
|
||||
|
||||
The important point is that your app will be able to produce each line in turn, while the client consumes the previous lines.
|
||||
|
||||
///
|
||||
|
||||
/// note | Technical Details
|
||||
|
||||
Because each JSON object will be separated by a new line, they can't contain literal new line characters in their content, but they can contain escaped new lines (`\n`), which is part of the JSON standard.
|
||||
|
||||
But normally you won't have to worry about it, it's done automatically, continue reading. 🤓
|
||||
|
||||
///
|
||||
|
||||
## Use Cases { #use-cases }
|
||||
|
||||
You could use this to stream data from an **AI LLM** service, from **logs** or **telemetry**, or from other types of data that can be structured in **JSON** items.
|
||||
|
||||
/// tip
|
||||
|
||||
If you want to stream binary data, for example video or audio, check the advanced guide: [Stream Data](../advanced/stream-data.md).
|
||||
|
||||
///
|
||||
|
||||
## Stream JSON Lines with FastAPI { #stream-json-lines-with-fastapi }
|
||||
|
||||
To stream JSON Lines with FastAPI you can, instead of using `return` in your *path operation function*, use `yield` to produce each item in turn.
|
||||
|
||||
{* ../../docs_src/stream_json_lines/tutorial001_py310.py ln[1:24] hl[24] *}
|
||||
|
||||
If each JSON item you want to send back is of type `Item` (a Pydantic model) and it's an async function, you can declare the return type as `AsyncIterable[Item]`:
|
||||
|
||||
{* ../../docs_src/stream_json_lines/tutorial001_py310.py ln[1:24] hl[9:11,22] *}
|
||||
|
||||
If you declare the return type, FastAPI will use it to **validate** the data, **document** it in OpenAPI, **filter** it, and **serialize** it using Pydantic.
|
||||
|
||||
/// 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/stream_json_lines/tutorial001_py310.py ln[27:30] hl[28] *}
|
||||
|
||||
### No Return Type { #no-return-type }
|
||||
|
||||
You can also omit the return type. FastAPI will then use the [`jsonable_encoder`](./encoder.md){.internal-link target=_blank} to convert the data to something that can be serialized to JSON and then send it as JSON Lines.
|
||||
|
||||
{* ../../docs_src/stream_json_lines/tutorial001_py310.py ln[33:36] hl[34] *}
|
||||
|
||||
## Server-Sent Events (SSE) { #server-sent-events-sse }
|
||||
|
||||
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}. 🤓
|
||||
@@ -154,6 +154,8 @@ nav:
|
||||
- tutorial/cors.md
|
||||
- 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
|
||||
@@ -161,6 +163,7 @@ nav:
|
||||
- tutorial/debugging.md
|
||||
- Advanced User Guide:
|
||||
- advanced/index.md
|
||||
- advanced/stream-data.md
|
||||
- advanced/path-operation-advanced-configuration.md
|
||||
- advanced/additional-status-codes.md
|
||||
- advanced/response-directly.md
|
||||
|
||||
@@ -38,13 +38,13 @@ En producción tendrías una de las opciones anteriores.
|
||||
|
||||
Pero es la forma más sencilla de enfocarse en el lado del servidor de WebSockets y tener un ejemplo funcional:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## Crear un `websocket` { #create-a-websocket }
|
||||
|
||||
En tu aplicación de **FastAPI**, crea un `websocket`:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | Detalles Técnicos
|
||||
|
||||
@@ -58,7 +58,7 @@ También podrías usar `from starlette.websockets import WebSocket`.
|
||||
|
||||
En tu ruta de WebSocket puedes `await` para recibir mensajes y enviar mensajes.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
Puedes recibir y enviar datos binarios, de texto y JSON.
|
||||
|
||||
@@ -109,7 +109,7 @@ En endpoints de WebSocket puedes importar desde `fastapi` y usar:
|
||||
|
||||
Funcionan de la misma manera que para otros endpoints de FastAPI/*path operations*:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info | Información
|
||||
|
||||
@@ -154,7 +154,7 @@ Con eso puedes conectar el WebSocket y luego enviar y recibir mensajes:
|
||||
|
||||
Cuando una conexión de WebSocket se cierra, el `await websocket.receive_text()` lanzará una excepción `WebSocketDisconnect`, que puedes capturar y manejar como en este ejemplo.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
Para probarlo:
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ En production, vous auriez l'une des options ci-dessus.
|
||||
|
||||
Mais c'est la façon la plus simple de se concentrer sur la partie serveur des WebSockets et d'avoir un exemple fonctionnel :
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## Créer un `websocket` { #create-a-websocket }
|
||||
|
||||
Dans votre application **FastAPI**, créez un `websocket` :
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | Détails techniques
|
||||
|
||||
@@ -58,7 +58,7 @@ Vous pourriez aussi utiliser `from starlette.websockets import WebSocket`.
|
||||
|
||||
Dans votre route WebSocket, vous pouvez `await` des messages et envoyer des messages.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
Vous pouvez recevoir et envoyer des données binaires, texte et JSON.
|
||||
|
||||
@@ -109,7 +109,7 @@ Dans les endpoints WebSocket, vous pouvez importer depuis `fastapi` et utiliser
|
||||
|
||||
Ils fonctionnent de la même manière que pour les autres endpoints/*chemins d'accès* FastAPI :
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info
|
||||
|
||||
@@ -154,7 +154,7 @@ Avec cela, vous pouvez connecter le WebSocket puis envoyer et recevoir des messa
|
||||
|
||||
Lorsqu'une connexion WebSocket est fermée, l'instruction `await websocket.receive_text()` lèvera une exception `WebSocketDisconnect`, que vous pouvez ensuite intercepter et gérer comme dans cet exemple.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
Pour l'essayer :
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ $ pip install websockets
|
||||
|
||||
しかし、これはWebSocketsのサーバーサイドに焦点を当て、動作する例を示す最も簡単な方法です。
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## `websocket` を作成する { #create-a-websocket }
|
||||
|
||||
**FastAPI** アプリケーションで、`websocket` を作成します。
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | 技術詳細
|
||||
|
||||
@@ -58,7 +58,7 @@ $ pip install websockets
|
||||
|
||||
WebSocketルートでは、メッセージを待機して送信するために `await` を使用できます。
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
バイナリやテキストデータ、JSONデータを送受信できます。
|
||||
|
||||
@@ -109,7 +109,7 @@ WebSocketエンドポイントでは、`fastapi` から以下をインポート
|
||||
|
||||
これらは、他のFastAPI エンドポイント/*path operations* の場合と同じように機能します。
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info | 情報
|
||||
|
||||
@@ -154,7 +154,7 @@ $ fastapi dev main.py
|
||||
|
||||
WebSocket接続が閉じられると、 `await websocket.receive_text()` は例外 `WebSocketDisconnect` を発生させ、この例のようにキャッチして処理することができます。
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
試してみるには、
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ $ pip install websockets
|
||||
|
||||
그러나 이는 WebSockets의 서버 측에 집중하고 동작하는 예제를 제공하는 가장 간단한 방법입니다:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## `websocket` 생성하기 { #create-a-websocket }
|
||||
|
||||
**FastAPI** 애플리케이션에서 `websocket`을 생성합니다:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | 기술 세부사항
|
||||
|
||||
@@ -58,7 +58,7 @@ $ pip install websockets
|
||||
|
||||
WebSocket 경로에서 메시지를 대기(`await`)하고 전송할 수 있습니다.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
여러분은 이진 데이터, 텍스트, JSON 데이터를 받을 수 있고 전송할 수 있습니다.
|
||||
|
||||
@@ -109,7 +109,7 @@ WebSocket 엔드포인트에서 `fastapi`에서 다음을 가져와 사용할
|
||||
|
||||
이들은 다른 FastAPI 엔드포인트/*경로 처리*와 동일하게 동작합니다:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info | 정보
|
||||
|
||||
@@ -154,7 +154,7 @@ $ fastapi dev main.py
|
||||
|
||||
WebSocket 연결이 닫히면, `await websocket.receive_text()`가 `WebSocketDisconnect` 예외를 발생시킵니다. 그러면 이 예제처럼 이를 잡아 처리할 수 있습니다.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
테스트해보기:
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ Na produção, você teria uma das opções acima.
|
||||
|
||||
Mas é a maneira mais simples de focar no lado do servidor de WebSockets e ter um exemplo funcional:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## Crie um `websocket` { #create-a-websocket }
|
||||
|
||||
Em sua aplicação **FastAPI**, crie um `websocket`:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | Detalhes Técnicos
|
||||
|
||||
@@ -58,7 +58,7 @@ A **FastAPI** fornece o mesmo `WebSocket` diretamente apenas como uma conveniên
|
||||
|
||||
Em sua rota WebSocket você pode esperar (`await`) por mensagens e enviar mensagens.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
Você pode receber e enviar dados binários, de texto e JSON.
|
||||
|
||||
@@ -109,7 +109,7 @@ Nos endpoints WebSocket você pode importar do `fastapi` e usar:
|
||||
|
||||
Eles funcionam da mesma forma que para outros endpoints FastAPI/*operações de rota*:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info | Informação
|
||||
|
||||
@@ -154,7 +154,7 @@ Com isso você pode conectar o WebSocket e então enviar e receber mensagens:
|
||||
|
||||
Quando uma conexão WebSocket é fechada, o `await websocket.receive_text()` levantará uma exceção `WebSocketDisconnect`, que você pode então capturar e lidar como neste exemplo.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
Para testar:
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ $ pip install websockets
|
||||
|
||||
Для примера нам нужен наиболее простой способ, который позволит сосредоточиться на серверной части веб‑сокетов и получить рабочий код:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## Создание `websocket` { #create-a-websocket }
|
||||
|
||||
Создайте `websocket` в своем **FastAPI** приложении:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | Технические детали
|
||||
|
||||
@@ -58,7 +58,7 @@ $ pip install websockets
|
||||
|
||||
Через эндпоинт веб-сокета вы можете получать и отправлять сообщения.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
Вы можете получать и отправлять двоичные, текстовые и JSON данные.
|
||||
|
||||
@@ -109,7 +109,7 @@ $ fastapi dev main.py
|
||||
|
||||
Они работают так же, как и в других FastAPI эндпоинтах/*операциях пути*:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info | Примечание
|
||||
|
||||
@@ -154,7 +154,7 @@ $ fastapi dev main.py
|
||||
|
||||
Если веб-сокет соединение закрыто, то `await websocket.receive_text()` вызовет исключение `WebSocketDisconnect`, которое можно поймать и обработать как в этом примере:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
Чтобы воспроизвести пример:
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ Production'da yukarıdaki seçeneklerden birini kullanırsınız.
|
||||
|
||||
Ama WebSockets'in server tarafına odaklanmak ve çalışan bir örnek görmek için en basit yol bu:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## Bir `websocket` Oluşturun { #create-a-websocket }
|
||||
|
||||
**FastAPI** uygulamanızda bir `websocket` oluşturun:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | Teknik Detaylar
|
||||
|
||||
@@ -58,7 +58,7 @@ Ama WebSockets'in server tarafına odaklanmak ve çalışan bir örnek görmek i
|
||||
|
||||
WebSocket route'unuzda mesajları `await` edebilir ve mesaj gönderebilirsiniz.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
Binary, text ve JSON verisi alıp gönderebilirsiniz.
|
||||
|
||||
@@ -109,7 +109,7 @@ WebSocket endpoint'lerinde `fastapi` içinden import edip şunları kullanabilir
|
||||
|
||||
Diğer FastAPI endpoint'leri/*path operations* ile aynı şekilde çalışırlar:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info | Bilgi
|
||||
|
||||
@@ -154,7 +154,7 @@ Bununla WebSocket'e bağlanabilir, ardından mesaj gönderip alabilirsiniz:
|
||||
|
||||
Bir WebSocket bağlantısı kapandığında, `await websocket.receive_text()` bir `WebSocketDisconnect` exception'ı raise eder; ardından bunu bu örnekteki gibi yakalayıp (catch) yönetebilirsiniz.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
Denemek için:
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ $ pip install websockets
|
||||
|
||||
Але це найпростіший спосіб зосередитися на серверній частині WebSockets і мати робочий приклад:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## Створіть `websocket` { #create-a-websocket }
|
||||
|
||||
У вашому застосунку **FastAPI** створіть `websocket`:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | Технічні деталі
|
||||
|
||||
@@ -58,7 +58,7 @@ $ pip install websockets
|
||||
|
||||
У вашому маршруті WebSocket ви можете `await` повідомлення і надсилати повідомлення.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
Ви можете отримувати та надсилати бінарні, текстові та JSON-дані.
|
||||
|
||||
@@ -109,7 +109,7 @@ $ fastapi dev main.py
|
||||
|
||||
Вони працюють так само, як для інших ендпойнтів FastAPI/*операцій шляху*:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info
|
||||
|
||||
@@ -154,7 +154,7 @@ $ fastapi dev main.py
|
||||
|
||||
Коли з'єднання WebSocket закривається, `await websocket.receive_text()` підніме виняток `WebSocketDisconnect`, який ви можете перехопити й обробити, як у цьому прикладі.
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
Щоб спробувати:
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ $ pip install websockets
|
||||
|
||||
但這是能讓我們專注於 WebSocket 伺服端並跑起一個可運作範例的最簡單方式:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## 建立一個 `websocket` { #create-a-websocket }
|
||||
|
||||
在你的 **FastAPI** 應用中,建立一個 `websocket`:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | 技術細節
|
||||
|
||||
@@ -58,7 +58,7 @@ $ pip install websockets
|
||||
|
||||
在你的 WebSocket 路由中,你可以 `await` 接收訊息並傳送訊息。
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
你可以接收與傳送二進位、文字與 JSON 資料。
|
||||
|
||||
@@ -109,7 +109,7 @@ $ fastapi dev main.py
|
||||
|
||||
它們的運作方式與其他 FastAPI 端點/*路徑操作* 相同:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info
|
||||
|
||||
@@ -154,7 +154,7 @@ $ fastapi dev main.py
|
||||
|
||||
當 WebSocket 連線關閉時,`await websocket.receive_text()` 會拋出 `WebSocketDisconnect` 例外,你可以像範例中那樣捕捉並處理。
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
試用方式:
|
||||
|
||||
|
||||
@@ -38,13 +38,13 @@ $ pip install websockets
|
||||
|
||||
但这是一种专注于 WebSockets 的服务器端并提供一个工作示例的最简单方式:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[2,6:38,41:43] *}
|
||||
|
||||
## 创建 `websocket` { #create-a-websocket }
|
||||
|
||||
在您的 **FastAPI** 应用程序中,创建一个 `websocket`:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
|
||||
|
||||
/// note | 技术细节
|
||||
|
||||
@@ -58,7 +58,7 @@ $ pip install websockets
|
||||
|
||||
在您的 WebSocket 路由中,您可以使用 `await` 等待消息并发送消息。
|
||||
|
||||
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *}
|
||||
{* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
|
||||
|
||||
您可以接收和发送二进制、文本和 JSON 数据。
|
||||
|
||||
@@ -109,7 +109,7 @@ $ fastapi dev main.py
|
||||
|
||||
它们的工作方式与其他 FastAPI 端点/*路径操作* 相同:
|
||||
|
||||
{* ../../docs_src/websockets/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
{* ../../docs_src/websockets_/tutorial002_an_py310.py hl[68:69,82] *}
|
||||
|
||||
/// info
|
||||
|
||||
@@ -154,7 +154,7 @@ $ fastapi dev main.py
|
||||
|
||||
当 WebSocket 连接关闭时,`await websocket.receive_text()` 将引发 `WebSocketDisconnect` 异常,您可以捕获并处理该异常,就像本示例中的示例一样。
|
||||
|
||||
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *}
|
||||
{* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
|
||||
|
||||
尝试以下操作:
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import anyio
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
@@ -7,6 +8,7 @@ app = FastAPI()
|
||||
async def fake_video_streamer():
|
||||
for i in range(10):
|
||||
yield b"some fake video bytes"
|
||||
await anyio.sleep(0)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
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")
|
||||
0
docs_src/stream_data/__init__.py
Normal file
0
docs_src/stream_data/__init__.py
Normal file
65
docs_src/stream_data/tutorial001_py310.py
Normal file
65
docs_src/stream_data/tutorial001_py310.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from collections.abc import AsyncIterable, Iterable
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
message = """
|
||||
Rick: (stumbles in drunkenly, and turns on the lights) Morty! You gotta come on. You got--... you gotta come with me.
|
||||
Morty: (rubs his eyes) What, Rick? What's going on?
|
||||
Rick: I got a surprise for you, Morty.
|
||||
Morty: It's the middle of the night. What are you talking about?
|
||||
Rick: (spills alcohol on Morty's bed) Come on, I got a surprise for you. (drags Morty by the ankle) Come on, hurry up. (pulls Morty out of his bed and into the hall)
|
||||
Morty: Ow! Ow! You're tugging me too hard!
|
||||
Rick: We gotta go, gotta get outta here, come on. Got a surprise for you Morty.
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/story/stream", response_class=StreamingResponse)
|
||||
async def stream_story() -> AsyncIterable[str]:
|
||||
for line in message.splitlines():
|
||||
yield line
|
||||
|
||||
|
||||
@app.get("/story/stream-no-async", response_class=StreamingResponse)
|
||||
def stream_story_no_async() -> Iterable[str]:
|
||||
for line in message.splitlines():
|
||||
yield line
|
||||
|
||||
|
||||
@app.get("/story/stream-no-annotation", response_class=StreamingResponse)
|
||||
async def stream_story_no_annotation():
|
||||
for line in message.splitlines():
|
||||
yield line
|
||||
|
||||
|
||||
@app.get("/story/stream-no-async-no-annotation", response_class=StreamingResponse)
|
||||
def stream_story_no_async_no_annotation():
|
||||
for line in message.splitlines():
|
||||
yield line
|
||||
|
||||
|
||||
@app.get("/story/stream-bytes", response_class=StreamingResponse)
|
||||
async def stream_story_bytes() -> AsyncIterable[bytes]:
|
||||
for line in message.splitlines():
|
||||
yield line.encode("utf-8")
|
||||
|
||||
|
||||
@app.get("/story/stream-no-async-bytes", response_class=StreamingResponse)
|
||||
def stream_story_no_async_bytes() -> Iterable[bytes]:
|
||||
for line in message.splitlines():
|
||||
yield line.encode("utf-8")
|
||||
|
||||
|
||||
@app.get("/story/stream-no-annotation-bytes", response_class=StreamingResponse)
|
||||
async def stream_story_no_annotation_bytes():
|
||||
for line in message.splitlines():
|
||||
yield line.encode("utf-8")
|
||||
|
||||
|
||||
@app.get("/story/stream-no-async-no-annotation-bytes", response_class=StreamingResponse)
|
||||
def stream_story_no_async_no_annotation_bytes():
|
||||
for line in message.splitlines():
|
||||
yield line.encode("utf-8")
|
||||
54
docs_src/stream_data/tutorial002_py310.py
Normal file
54
docs_src/stream_data/tutorial002_py310.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import base64
|
||||
from collections.abc import AsyncIterable, Iterable
|
||||
from io import BytesIO
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
image_base64 = "iVBORw0KGgoAAAANSUhEUgAAAB0AAAAdCAYAAABWk2cPAAAAbnpUWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYAAHjadYzRDYAwCET/mcIRDoq0jGOiJm7g+NJK0vjhS4DjIEfHfZ20DKqSrrWZmyFQV5ctRMOLACxglNCcXk7zVqFzJzF8kV6R5vOJ97yVH78HjfYAtg0ged033ZgAAAoCaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA0LjQuMC1FeGl2MiI+CiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICBleGlmOlBpeGVsWERpbWVuc2lvbj0iMjkiCiAgIGV4aWY6UGl4ZWxZRGltZW5zaW9uPSIyOSIKICAgdGlmZjpJbWFnZVdpZHRoPSIyOSIKICAgdGlmZjpJbWFnZUxlbmd0aD0iMjkiCiAgIHRpZmY6T3JpZW50YXRpb249IjEiLz4KIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAKPD94cGFja2V0IGVuZD0idyI/PnQkBZAAAAAEc0JJVAgICAh8CGSIAAABoklEQVRIx8VXwY7FIAjE5iXWU+P/f6RHPNW9LIaOoHYP+0yMShVkwNGG1lqjfy4HfaF0oyEEt+oSQqBaa//m9Wd6PlqhhbRMDiEQM3e59FNKw5qZHpnQfuPaW6lazsztvu/eElFj5j63lNLlMz2ttbZtVMu1MTGo5Sujn93gMzOllKiUQjHGB9QxxneZhJ5iwZ1rL2fwenoGeL0q3wVGhBPHMz0PeFccIfASEeWcO8xEROd50q6eAV6s1s5XXoncas1EKqVQznnwUBdJJmm1l3hmmdlOMrGO8Vl5gZ56Y0y8IZF0BuqkQWM4B6HXrRCKa1SEqyzEo7KK59RT/VHDjX3ZvSefeW3CO6O6vsiA1NrwVkxxAcYTCcHyTjZmJd00pugBQoTnzjvn+kzLBh9GtRDjhleZFwbx3kugP3GvFzdkqRlbDYw0u/HxKjuOw2QxZCGL5V5f4l7cd6qsffUa1DcLM9N1XcTMvep5ul1e4jNPtZfWGIkE6dI8MquXg/dS2CGVJQ2ushd5GmlxFdOw+1tRa32MY4zDQ9yaZ60J3/iX+QG4U3qGrFHmswAAAABJRU5ErkJggg=="
|
||||
binary_image = base64.b64decode(image_base64)
|
||||
|
||||
|
||||
def read_image() -> BytesIO:
|
||||
return BytesIO(binary_image)
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class PNGStreamingResponse(StreamingResponse):
|
||||
media_type = "image/png"
|
||||
|
||||
|
||||
@app.get("/image/stream", response_class=PNGStreamingResponse)
|
||||
async def stream_image() -> AsyncIterable[bytes]:
|
||||
with read_image() as image_file:
|
||||
for chunk in image_file:
|
||||
yield chunk
|
||||
|
||||
|
||||
@app.get("/image/stream-no-async", response_class=PNGStreamingResponse)
|
||||
def stream_image_no_async() -> Iterable[bytes]:
|
||||
with read_image() as image_file:
|
||||
for chunk in image_file:
|
||||
yield chunk
|
||||
|
||||
|
||||
@app.get("/image/stream-no-async-yield-from", response_class=PNGStreamingResponse)
|
||||
def stream_image_no_async_yield_from() -> Iterable[bytes]:
|
||||
with read_image() as image_file:
|
||||
yield from image_file
|
||||
|
||||
|
||||
@app.get("/image/stream-no-annotation", response_class=PNGStreamingResponse)
|
||||
async def stream_image_no_annotation():
|
||||
with read_image() as image_file:
|
||||
for chunk in image_file:
|
||||
yield chunk
|
||||
|
||||
|
||||
@app.get("/image/stream-no-async-no-annotation", response_class=PNGStreamingResponse)
|
||||
def stream_image_no_async_no_annotation():
|
||||
with read_image() as image_file:
|
||||
for chunk in image_file:
|
||||
yield chunk
|
||||
0
docs_src/stream_json_lines/__init__.py
Normal file
0
docs_src/stream_json_lines/__init__.py
Normal file
42
docs_src/stream_json_lines/tutorial001_py310.py
Normal file
42
docs_src/stream_json_lines/tutorial001_py310.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from collections.abc import AsyncIterable, Iterable
|
||||
|
||||
from fastapi import FastAPI
|
||||
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")
|
||||
async def stream_items() -> AsyncIterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@app.get("/items/stream-no-async")
|
||||
def stream_items_no_async() -> Iterable[Item]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@app.get("/items/stream-no-annotation")
|
||||
async def stream_items_no_annotation():
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
|
||||
@app.get("/items/stream-no-async-no-annotation")
|
||||
def stream_items_no_async_no_annotation():
|
||||
for item in items:
|
||||
yield item
|
||||
0
docs_src/websockets_/__init__.py
Normal file
0
docs_src/websockets_/__init__.py
Normal file
718
fastapi/.agents/skills/fastapi/SKILL.md
Normal file
718
fastapi/.agents/skills/fastapi/SKILL.md
Normal file
@@ -0,0 +1,718 @@
|
||||
---
|
||||
name: fastapi
|
||||
description: FastAPI best practices and conventions. Use when working with FastAPI APIs and Pydantic models for them. Keeps FastAPI code clean and up to date with the latest features and patterns, updated with new versions. Write new code or refactor and update old code.
|
||||
---
|
||||
|
||||
# FastAPI
|
||||
|
||||
Official FastAPI skill to write code with best practices, keeping up to date with new versions and features.
|
||||
|
||||
## Use the `fastapi` CLI
|
||||
|
||||
Run the development server on localhost with reload:
|
||||
|
||||
```bash
|
||||
fastapi dev
|
||||
```
|
||||
|
||||
|
||||
Run the production server:
|
||||
|
||||
```bash
|
||||
fastapi run
|
||||
```
|
||||
|
||||
### Add an entrypoint in `pyproject.toml`
|
||||
|
||||
FastAPI CLI will read the entrypoint in `pyproject.toml` to know where the FastAPI app is declared.
|
||||
|
||||
```toml
|
||||
[tool.fastapi]
|
||||
entrypoint = "my_app.main:app"
|
||||
```
|
||||
|
||||
### Use `fastapi` with a path
|
||||
|
||||
When adding the entrypoint to `pyproject.toml` is not possible, or the user explicitly asks not to, or it's running an independent small app, you can pass the app file path to the `fastapi` command:
|
||||
|
||||
```bash
|
||||
fastapi dev my_app/main.py
|
||||
```
|
||||
|
||||
Prefer to set the entrypoint in `pyproject.toml` when possible.
|
||||
|
||||
## Use `Annotated`
|
||||
|
||||
Always prefer the `Annotated` style for parameter and dependency declarations.
|
||||
|
||||
It keeps the function signatures working in other contexts, respects the types, allows reusability.
|
||||
|
||||
### In Parameter Declarations
|
||||
|
||||
Use `Annotated` for parameter declarations, including `Path`, `Query`, `Header`, etc.:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI, Path, Query
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/items/{item_id}")
|
||||
async def read_item(
|
||||
item_id: Annotated[int, Path(ge=1, description="The item ID")],
|
||||
q: Annotated[str | None, Query(max_length=50)] = None,
|
||||
):
|
||||
return {"message": "Hello World"}
|
||||
```
|
||||
|
||||
instead of:
|
||||
|
||||
```python
|
||||
# DO NOT DO THIS
|
||||
@app.get("/items/{item_id}")
|
||||
async def read_item(
|
||||
item_id: int = Path(ge=1, description="The item ID"),
|
||||
q: str | None = Query(default=None, max_length=50),
|
||||
):
|
||||
return {"message": "Hello World"}
|
||||
```
|
||||
|
||||
### For Dependencies
|
||||
|
||||
Use `Annotated` for dependencies with `Depends()`.
|
||||
|
||||
Unless asked not to, create a new type alias for the dependency to allow re-using it.
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
def get_current_user():
|
||||
return {"username": "johndoe"}
|
||||
|
||||
|
||||
CurrentUserDep = Annotated[dict, Depends(get_current_user)]
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
async def read_item(current_user: CurrentUserDep):
|
||||
return {"message": "Hello World"}
|
||||
```
|
||||
|
||||
instead of:
|
||||
|
||||
```python
|
||||
# DO NOT DO THIS
|
||||
@app.get("/items/")
|
||||
async def read_item(current_user: dict = Depends(get_current_user)):
|
||||
return {"message": "Hello World"}
|
||||
```
|
||||
|
||||
## Do not use Ellipsis for *path operations* or Pydantic models
|
||||
|
||||
Do not use `...` as a default value for required parameters, it's not needed and not recommended.
|
||||
|
||||
Do this, without Ellipsis (`...`):
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
price: float = Field(gt=0)
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.post("/items/")
|
||||
async def create_item(item: Item, project_id: Annotated[int, Query()]): ...
|
||||
```
|
||||
|
||||
instead of this:
|
||||
|
||||
```python
|
||||
# DO NOT DO THIS
|
||||
class Item(BaseModel):
|
||||
name: str = ...
|
||||
description: str | None = None
|
||||
price: float = Field(..., gt=0)
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.post("/items/")
|
||||
async def create_item(item: Item, project_id: Annotated[int, Query(...)]): ...
|
||||
```
|
||||
|
||||
## Return Type or Response Model
|
||||
|
||||
When possible, include a return type. It will be used to validate, filter, document, and serialize the response.
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@app.get("/items/me")
|
||||
async def get_item() -> Item:
|
||||
return Item(name="Plumbus", description="All-purpose home device")
|
||||
```
|
||||
|
||||
**Important**: Return types or response models are what filter data ensuring no sensitive information is exposed. And they are used to serialize data with Pydantic (in Rust), this is the main idea that can increase response performance.
|
||||
|
||||
The return type doesn't have to be a Pydantic model, it could be a different type, like a list of integers, or a dict, etc.
|
||||
|
||||
### When to use `response_model` instead
|
||||
|
||||
If the return type is not the same as the type that you want to use to validate, filter, or serialize, use the `response_model` parameter on the decorator instead.
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@app.get("/items/me", response_model=Item)
|
||||
async def get_item() -> Any:
|
||||
return {"name": "Foo", "description": "A very nice Item"}
|
||||
```
|
||||
|
||||
This can be particularly useful when filtering data to expose only the public fields and avoid exposing sensitive information.
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class InternalItem(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
secret_key: str
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@app.get("/items/me", response_model=Item)
|
||||
async def get_item() -> Any:
|
||||
item = InternalItem(
|
||||
name="Foo", description="A very nice Item", secret_key="supersecret"
|
||||
)
|
||||
return item
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Do not use `ORJSONResponse` or `UJSONResponse`, they are deprecated.
|
||||
|
||||
Instead, declare a return type or response model. Pydantic will handle the data serialization on the Rust side.
|
||||
|
||||
## Including Routers
|
||||
|
||||
When declaring routers, prefer to add router level parameters like prefix, tags, etc. to the router itself, instead of in `include_router()`.
|
||||
|
||||
Do this:
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
router = APIRouter(prefix="/items", tags=["items"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_items():
|
||||
return []
|
||||
|
||||
|
||||
# In main.py
|
||||
app.include_router(router)
|
||||
```
|
||||
|
||||
instead of this:
|
||||
|
||||
```python
|
||||
# DO NOT DO THIS
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_items():
|
||||
return []
|
||||
|
||||
|
||||
# In main.py
|
||||
app.include_router(router, prefix="/items", tags=["items"])
|
||||
```
|
||||
|
||||
There could be exceptions, but try to follow this convention.
|
||||
|
||||
Apply shared dependencies at the router level via `dependencies=[Depends(...)]`.
|
||||
|
||||
## 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()
|
||||
```
|
||||
|
||||
## Async vs Sync *path operations*
|
||||
|
||||
Use `async` *path operations* only when fully certain that the logic called inside is compatible with async and await (it's called with `await`) or that doesn't block.
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
# Use async def when calling async code
|
||||
@app.get("/async-items/")
|
||||
async def read_async_items():
|
||||
data = await some_async_library.fetch_items()
|
||||
return data
|
||||
|
||||
|
||||
# Use plain def when calling blocking/sync code or when in doubt
|
||||
@app.get("/items/")
|
||||
def read_items():
|
||||
data = some_blocking_library.fetch_items()
|
||||
return data
|
||||
```
|
||||
|
||||
In case of doubt, or by default, use regular `def` functions, those will be run in a threadpool so they don't block the event loop.
|
||||
|
||||
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 run blocking code inside of async functions, or async code inside of blocking functions, suggest using Asyncer.
|
||||
|
||||
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}
|
||||
```
|
||||
|
||||
## 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())
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## Do not use Pydantic RootModels
|
||||
|
||||
Do not use Pydantic `RootModel`, instead use regular type annotations with `Annotated` and Pydantic validation utilities.
|
||||
|
||||
For example, for a list with validations you could do:
|
||||
|
||||
```python
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Body, FastAPI
|
||||
from pydantic import Field
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.post("/items/")
|
||||
async def create_items(items: Annotated[list[int], Field(min_length=1), Body()]):
|
||||
return items
|
||||
```
|
||||
|
||||
instead of:
|
||||
|
||||
```python
|
||||
# DO NOT DO THIS
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic import Field, RootModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class ItemList(RootModel[Annotated[list[int], Field(min_length=1)]]):
|
||||
pass
|
||||
|
||||
|
||||
@app.post("/items/")
|
||||
async def create_items(items: ItemList):
|
||||
return items
|
||||
|
||||
```
|
||||
|
||||
FastAPI supports these type annotations and will create a Pydantic `TypeAdapter` for them, so that types can work as normally and there's no need for the custom logic and types in RootModels.
|
||||
|
||||
## Use one HTTP operation per function
|
||||
|
||||
Don't mix HTTP operations in a single function, having one function per HTTP operation helps separate concerns and organize the code.
|
||||
|
||||
Do this:
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@app.get("/items/")
|
||||
async def list_items():
|
||||
return []
|
||||
|
||||
|
||||
@app.post("/items/")
|
||||
async def create_item(item: Item):
|
||||
return item
|
||||
```
|
||||
|
||||
instead of this:
|
||||
|
||||
```python
|
||||
# DO NOT DO THIS
|
||||
from fastapi import FastAPI, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@app.api_route("/items/", methods=["GET", "POST"])
|
||||
async def handle_items(request: Request):
|
||||
if request.method == "GET":
|
||||
return []
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
|
||||
|
||||
__version__ = "0.132.0"
|
||||
__version__ = "0.135.0"
|
||||
|
||||
from starlette import status as status
|
||||
|
||||
|
||||
@@ -1101,16 +1101,18 @@ class FastAPI(Starlette):
|
||||
|
||||
def setup(self) -> None:
|
||||
if self.openapi_url:
|
||||
urls = (server_data.get("url") for server_data in self.servers)
|
||||
server_urls = {url for url in urls if url}
|
||||
|
||||
async def openapi(req: Request) -> JSONResponse:
|
||||
root_path = req.scope.get("root_path", "").rstrip("/")
|
||||
if root_path not in server_urls:
|
||||
if root_path and self.root_path_in_servers:
|
||||
self.servers.insert(0, {"url": root_path})
|
||||
server_urls.add(root_path)
|
||||
return JSONResponse(self.openapi())
|
||||
schema = self.openapi()
|
||||
if root_path and self.root_path_in_servers:
|
||||
server_urls = {s.get("url") for s in schema.get("servers", [])}
|
||||
if root_path not in server_urls:
|
||||
schema = dict(schema)
|
||||
schema["servers"] = [{"url": root_path}] + schema.get(
|
||||
"servers", []
|
||||
)
|
||||
return JSONResponse(schema)
|
||||
|
||||
self.add_route(self.openapi_url, openapi, include_in_schema=False)
|
||||
if self.openapi_url and self.docs_url:
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import dataclasses
|
||||
import inspect
|
||||
import sys
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from collections.abc import (
|
||||
AsyncGenerator,
|
||||
AsyncIterable,
|
||||
AsyncIterator,
|
||||
Callable,
|
||||
Generator,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Mapping,
|
||||
Sequence,
|
||||
)
|
||||
from contextlib import AsyncExitStack, contextmanager
|
||||
from copy import copy, deepcopy
|
||||
from dataclasses import dataclass
|
||||
@@ -251,6 +261,26 @@ def get_typed_return_annotation(call: Callable[..., Any]) -> Any:
|
||||
return get_typed_annotation(annotation, globalns)
|
||||
|
||||
|
||||
_STREAM_ORIGINS = {
|
||||
AsyncIterable,
|
||||
AsyncIterator,
|
||||
AsyncGenerator,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Generator,
|
||||
}
|
||||
|
||||
|
||||
def get_stream_item_type(annotation: Any) -> Any | None:
|
||||
origin = get_origin(annotation)
|
||||
if origin is not None and origin in _STREAM_ORIGINS:
|
||||
type_args = get_args(annotation)
|
||||
if type_args:
|
||||
return type_args[0]
|
||||
return Any
|
||||
return None
|
||||
|
||||
|
||||
def get_dependant(
|
||||
*,
|
||||
path: str,
|
||||
|
||||
@@ -5,6 +5,20 @@ from annotated_doc import Doc
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
|
||||
def _html_safe_json(value: Any) -> str:
|
||||
"""Serialize a value to JSON with HTML special characters escaped.
|
||||
|
||||
This prevents injection when the JSON is embedded inside a <script> tag.
|
||||
"""
|
||||
return (
|
||||
json.dumps(value)
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
)
|
||||
|
||||
|
||||
swagger_ui_default_parameters: Annotated[
|
||||
dict[str, Any],
|
||||
Doc(
|
||||
@@ -155,7 +169,7 @@ def get_swagger_ui_html(
|
||||
"""
|
||||
|
||||
for key, value in current_swagger_ui_parameters.items():
|
||||
html += f"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\n"
|
||||
html += f"{_html_safe_json(key)}: {_html_safe_json(jsonable_encoder(value))},\n"
|
||||
|
||||
if oauth2_redirect_url:
|
||||
html += f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"
|
||||
@@ -169,7 +183,7 @@ def get_swagger_ui_html(
|
||||
|
||||
if init_oauth:
|
||||
html += f"""
|
||||
ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))})
|
||||
ui.initOAuth({_html_safe_json(jsonable_encoder(init_oauth))})
|
||||
"""
|
||||
|
||||
html += """
|
||||
|
||||
@@ -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,
|
||||
@@ -355,25 +356,60 @@ def get_openapi_path(
|
||||
operation.setdefault("responses", {}).setdefault(status_code, {})[
|
||||
"description"
|
||||
] = route.response_description
|
||||
if route_response_media_type and is_body_allowed_for_status_code(
|
||||
route.status_code
|
||||
):
|
||||
response_schema = {"type": "string"}
|
||||
if lenient_issubclass(current_response_class, JSONResponse):
|
||||
if route.response_field:
|
||||
response_schema = get_schema_from_model_field(
|
||||
field=route.response_field,
|
||||
if is_body_allowed_for_status_code(route.status_code):
|
||||
# Check for JSONL streaming (generator endpoints)
|
||||
if route.is_json_stream:
|
||||
jsonl_content: dict[str, Any] = {}
|
||||
if route.stream_item_field:
|
||||
item_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,
|
||||
)
|
||||
jsonl_content["itemSchema"] = item_schema
|
||||
else:
|
||||
response_schema = {}
|
||||
operation.setdefault("responses", {}).setdefault(
|
||||
status_code, {}
|
||||
).setdefault("content", {}).setdefault(route_response_media_type, {})[
|
||||
"schema"
|
||||
] = response_schema
|
||||
jsonl_content["itemSchema"] = {}
|
||||
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):
|
||||
if route.response_field:
|
||||
response_schema = get_schema_from_model_field(
|
||||
field=route.response_field,
|
||||
model_name_map=model_name_map,
|
||||
field_mapping=field_mapping,
|
||||
separate_input_output_schemas=separate_input_output_schemas,
|
||||
)
|
||||
else:
|
||||
response_schema = {}
|
||||
operation.setdefault("responses", {}).setdefault(
|
||||
status_code, {}
|
||||
).setdefault("content", {}).setdefault(
|
||||
route_response_media_type, {}
|
||||
)["schema"] = response_schema
|
||||
if route.responses:
|
||||
operation_responses = operation.setdefault("responses", {})
|
||||
for (
|
||||
@@ -453,9 +489,9 @@ def get_fields_from_routes(
|
||||
request_fields_from_routes: list[ModelField] = []
|
||||
callback_flat_models: list[ModelField] = []
|
||||
for route in routes:
|
||||
if getattr(route, "include_in_schema", None) and isinstance(
|
||||
route, routing.APIRoute
|
||||
):
|
||||
if not isinstance(route, routing.APIRoute):
|
||||
continue
|
||||
if route.include_in_schema:
|
||||
if route.body_field:
|
||||
assert isinstance(route.body_field, ModelField), (
|
||||
"A request body must be a Pydantic Field"
|
||||
@@ -465,6 +501,8 @@ def get_fields_from_routes(
|
||||
responses_from_routes.append(route.response_field)
|
||||
if route.response_fields:
|
||||
responses_from_routes.extend(route.response_fields.values())
|
||||
if route.stream_item_field:
|
||||
responses_from_routes.append(route.stream_item_field)
|
||||
if route.callbacks:
|
||||
callback_flat_models.extend(get_fields_from_routes(route.callbacks))
|
||||
params = get_flat_params(route.dependant)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ from collections.abc import (
|
||||
Collection,
|
||||
Coroutine,
|
||||
Generator,
|
||||
Iterator,
|
||||
Mapping,
|
||||
Sequence,
|
||||
)
|
||||
@@ -27,6 +28,7 @@ from typing import (
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
import anyio
|
||||
from annotated_doc import Doc
|
||||
from fastapi import params
|
||||
from fastapi._compat import (
|
||||
@@ -42,6 +44,7 @@ from fastapi.dependencies.utils import (
|
||||
get_dependant,
|
||||
get_flat_dependant,
|
||||
get_parameterless_sub_dependant,
|
||||
get_stream_item_type,
|
||||
get_typed_return_annotation,
|
||||
solve_dependencies,
|
||||
)
|
||||
@@ -53,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,
|
||||
@@ -63,10 +73,10 @@ 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
|
||||
from starlette.responses import JSONResponse, Response, StreamingResponse
|
||||
from starlette.routing import (
|
||||
BaseRoute,
|
||||
Match,
|
||||
@@ -315,6 +325,24 @@ async def run_endpoint_function(
|
||||
return await run_in_threadpool(dependant.call, **values)
|
||||
|
||||
|
||||
def _build_response_args(
|
||||
*, status_code: int | None, solved_result: Any
|
||||
) -> dict[str, Any]:
|
||||
response_args: dict[str, Any] = {
|
||||
"background": solved_result.background_tasks,
|
||||
}
|
||||
# If status_code was set, use it, otherwise use the default from the
|
||||
# response class, in the case of redirect it's 307
|
||||
current_status_code = (
|
||||
status_code if status_code else solved_result.response.status_code
|
||||
)
|
||||
if current_status_code is not None:
|
||||
response_args["status_code"] = current_status_code
|
||||
if solved_result.response.status_code:
|
||||
response_args["status_code"] = solved_result.response.status_code
|
||||
return response_args
|
||||
|
||||
|
||||
def get_request_handler(
|
||||
dependant: Dependant,
|
||||
body_field: ModelField | None = None,
|
||||
@@ -330,6 +358,8 @@ def get_request_handler(
|
||||
dependency_overrides_provider: Any | None = None,
|
||||
embed_body_fields: bool = False,
|
||||
strict_content_type: bool | DefaultPlaceholder = Default(True),
|
||||
stream_item_field: ModelField | None = None,
|
||||
is_json_stream: bool = False,
|
||||
) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
||||
assert dependant.call is not None, "dependant.call must be a function"
|
||||
is_coroutine = dependant.is_coroutine_callable
|
||||
@@ -338,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:
|
||||
@@ -427,61 +458,220 @@ def get_request_handler(
|
||||
embed_body_fields=embed_body_fields,
|
||||
)
|
||||
errors = solved_result.errors
|
||||
assert dependant.call # For types
|
||||
if not errors:
|
||||
raw_response = await run_endpoint_function(
|
||||
dependant=dependant,
|
||||
values=solved_result.values,
|
||||
is_coroutine=is_coroutine,
|
||||
)
|
||||
if isinstance(raw_response, Response):
|
||||
if raw_response.background is None:
|
||||
raw_response.background = solved_result.background_tasks
|
||||
response = raw_response
|
||||
else:
|
||||
response_args: dict[str, Any] = {
|
||||
"background": solved_result.background_tasks
|
||||
}
|
||||
# If status_code was set, use it, otherwise use the default from the
|
||||
# response class, in the case of redirect it's 307
|
||||
current_status_code = (
|
||||
status_code if status_code else solved_result.response.status_code
|
||||
)
|
||||
if current_status_code is not None:
|
||||
response_args["status_code"] = current_status_code
|
||||
if solved_result.response.status_code:
|
||||
response_args["status_code"] = solved_result.response.status_code
|
||||
# Use the fast path (dump_json) when no custom response
|
||||
# class was set and a response field with a TypeAdapter
|
||||
# exists. Serializes directly to JSON bytes via Pydantic's
|
||||
# Rust core, skipping the intermediate Python dict +
|
||||
# json.dumps() step.
|
||||
use_dump_json = response_field is not None and isinstance(
|
||||
response_class, DefaultPlaceholder
|
||||
)
|
||||
content = await serialize_response(
|
||||
field=response_field,
|
||||
response_content=raw_response,
|
||||
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,
|
||||
is_coroutine=is_coroutine,
|
||||
endpoint_ctx=endpoint_ctx,
|
||||
dump_json=use_dump_json,
|
||||
)
|
||||
if use_dump_json:
|
||||
response = Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
**response_args,
|
||||
# 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:
|
||||
response = actual_response_class(content, **response_args)
|
||||
if not is_body_allowed_for_status_code(response.status_code):
|
||||
response.body = b""
|
||||
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:
|
||||
return _serialize_data(item) + b"\n"
|
||||
|
||||
if dependant.is_async_gen_callable:
|
||||
|
||||
async def _async_stream_jsonl() -> AsyncIterator[bytes]:
|
||||
async for item in gen:
|
||||
yield _serialize_item(item)
|
||||
# To allow for cancellation to trigger
|
||||
# Ref: https://github.com/fastapi/fastapi/issues/14680
|
||||
await anyio.sleep(0)
|
||||
|
||||
jsonl_stream_content: AsyncIterator[bytes] | Iterator[bytes] = (
|
||||
_async_stream_jsonl()
|
||||
)
|
||||
else:
|
||||
|
||||
def _sync_stream_jsonl() -> Iterator[bytes]:
|
||||
for item in gen:
|
||||
yield _serialize_item(item)
|
||||
|
||||
jsonl_stream_content = _sync_stream_jsonl()
|
||||
|
||||
response = StreamingResponse(
|
||||
jsonl_stream_content,
|
||||
media_type="application/jsonl",
|
||||
background=solved_result.background_tasks,
|
||||
)
|
||||
response.headers.raw.extend(solved_result.response.headers.raw)
|
||||
elif dependant.is_async_gen_callable or dependant.is_gen_callable:
|
||||
# Raw streaming with explicit response_class (e.g. StreamingResponse)
|
||||
gen = dependant.call(**solved_result.values)
|
||||
if dependant.is_async_gen_callable:
|
||||
|
||||
async def _async_stream_raw(
|
||||
async_gen: AsyncIterator[Any],
|
||||
) -> AsyncIterator[Any]:
|
||||
async for chunk in async_gen:
|
||||
yield chunk
|
||||
# To allow for cancellation to trigger
|
||||
# Ref: https://github.com/fastapi/fastapi/issues/14680
|
||||
await anyio.sleep(0)
|
||||
|
||||
gen = _async_stream_raw(gen)
|
||||
response_args = _build_response_args(
|
||||
status_code=status_code, solved_result=solved_result
|
||||
)
|
||||
response = actual_response_class(content=gen, **response_args)
|
||||
response.headers.raw.extend(solved_result.response.headers.raw)
|
||||
else:
|
||||
raw_response = await run_endpoint_function(
|
||||
dependant=dependant,
|
||||
values=solved_result.values,
|
||||
is_coroutine=is_coroutine,
|
||||
)
|
||||
if isinstance(raw_response, Response):
|
||||
if raw_response.background is None:
|
||||
raw_response.background = solved_result.background_tasks
|
||||
response = raw_response
|
||||
else:
|
||||
response_args = _build_response_args(
|
||||
status_code=status_code, solved_result=solved_result
|
||||
)
|
||||
# Use the fast path (dump_json) when no custom response
|
||||
# class was set and a response field with a TypeAdapter
|
||||
# exists. Serializes directly to JSON bytes via Pydantic's
|
||||
# Rust core, skipping the intermediate Python dict +
|
||||
# json.dumps() step.
|
||||
use_dump_json = response_field is not None and isinstance(
|
||||
response_class, DefaultPlaceholder
|
||||
)
|
||||
content = await serialize_response(
|
||||
field=response_field,
|
||||
response_content=raw_response,
|
||||
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,
|
||||
is_coroutine=is_coroutine,
|
||||
endpoint_ctx=endpoint_ctx,
|
||||
dump_json=use_dump_json,
|
||||
)
|
||||
if use_dump_json:
|
||||
response = Response(
|
||||
content=content,
|
||||
media_type="application/json",
|
||||
**response_args,
|
||||
)
|
||||
else:
|
||||
response = actual_response_class(content, **response_args)
|
||||
if not is_body_allowed_for_status_code(response.status_code):
|
||||
response.body = b""
|
||||
response.headers.raw.extend(solved_result.response.headers.raw)
|
||||
if errors:
|
||||
validation_error = RequestValidationError(
|
||||
errors, body=body, endpoint_ctx=endpoint_ctx
|
||||
@@ -609,12 +799,28 @@ class APIRoute(routing.Route):
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.endpoint = endpoint
|
||||
self.stream_item_type: Any | None = None
|
||||
if isinstance(response_model, DefaultPlaceholder):
|
||||
return_annotation = get_typed_return_annotation(endpoint)
|
||||
if lenient_issubclass(return_annotation, Response):
|
||||
response_model = None
|
||||
else:
|
||||
response_model = return_annotation
|
||||
stream_item = get_stream_item_type(return_annotation)
|
||||
if stream_item is not None:
|
||||
# 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:
|
||||
response_model = return_annotation
|
||||
self.response_model = response_model
|
||||
self.summary = summary
|
||||
self.response_description = response_description
|
||||
@@ -663,6 +869,15 @@ class APIRoute(routing.Route):
|
||||
)
|
||||
else:
|
||||
self.response_field = None # type: ignore
|
||||
if self.stream_item_type:
|
||||
stream_item_name = "StreamItem_" + self.unique_id
|
||||
self.stream_item_field: ModelField | None = create_model_field(
|
||||
name=stream_item_name,
|
||||
type_=self.stream_item_type,
|
||||
mode="serialization",
|
||||
)
|
||||
else:
|
||||
self.stream_item_field = None
|
||||
self.dependencies = list(dependencies or [])
|
||||
self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "")
|
||||
# if a "form feed" character (page break) is found in the description text,
|
||||
@@ -704,6 +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 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]]:
|
||||
@@ -722,6 +947,8 @@ class APIRoute(routing.Route):
|
||||
dependency_overrides_provider=self.dependency_overrides_provider,
|
||||
embed_body_fields=self._embed_body_fields,
|
||||
strict_content_type=self.strict_content_type,
|
||||
stream_item_field=self.stream_item_field,
|
||||
is_json_stream=self.is_json_stream,
|
||||
)
|
||||
|
||||
def matches(self, scope: Scope) -> tuple[Match, Scope]:
|
||||
|
||||
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
|
||||
@@ -42,7 +42,7 @@ classifiers = [
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
dependencies = [
|
||||
"starlette>=0.40.0,<1.0.0",
|
||||
"starlette>=0.46.0",
|
||||
"pydantic>=2.7.0",
|
||||
"typing-extensions>=4.8.0",
|
||||
"typing-inspection>=0.4.2",
|
||||
@@ -163,7 +163,7 @@ github-actions = [
|
||||
tests = [
|
||||
{ include-group = "docs-tests" },
|
||||
"anyio[trio] >=3.2.1,<5.0.0",
|
||||
"coverage[toml] >=6.5.0,<8.0",
|
||||
"coverage[toml] >=7.13,<8.0",
|
||||
"dirty-equals >=0.9.0",
|
||||
"flask >=3.0.0,<4.0.0",
|
||||
"inline-snapshot >=0.21.1",
|
||||
@@ -178,6 +178,10 @@ tests = [
|
||||
"types-orjson >=3.6.2",
|
||||
"types-ujson >=5.10.0.20240515",
|
||||
"a2wsgi >=1.9.0,<=2.0.0",
|
||||
"pytest-xdist[psutil]>=2.5.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest-sugar>=1.0.0",
|
||||
"pytest-timeout>=2.4.0",
|
||||
]
|
||||
translations = [
|
||||
"gitpython >=3.1.46",
|
||||
@@ -229,6 +233,7 @@ strict_xfail = true
|
||||
filterwarnings = [
|
||||
"error",
|
||||
]
|
||||
timeout = "20"
|
||||
|
||||
[tool.coverage.run]
|
||||
parallel = true
|
||||
@@ -240,7 +245,6 @@ source = [
|
||||
]
|
||||
relative_files = true
|
||||
context = '${CONTEXT}'
|
||||
dynamic_context = "test_function"
|
||||
omit = [
|
||||
"tests/benchmarks/*",
|
||||
"docs_src/response_model/tutorial003_04_py39.py",
|
||||
@@ -317,6 +321,10 @@ ignore = [
|
||||
"docs_src/security/tutorial005_py310.py" = ["B904"]
|
||||
"docs_src/security/tutorial005_py39.py" = ["B904"]
|
||||
"docs_src/json_base64_bytes/tutorial001_py310.py" = ["UP012"]
|
||||
"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"]
|
||||
|
||||
@@ -3,5 +3,4 @@
|
||||
set -e
|
||||
set -x
|
||||
|
||||
bash scripts/test.sh ${@}
|
||||
bash scripts/coverage.sh
|
||||
bash scripts/test-cov.sh --cov-report=term-missing --cov-report=html ${@}
|
||||
|
||||
6
scripts/test-cov.sh
Executable file
6
scripts/test-cov.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
bash scripts/test.sh --cov --cov-context=test ${@}
|
||||
@@ -4,4 +4,4 @@ set -e
|
||||
set -x
|
||||
|
||||
export PYTHONPATH=./docs_src
|
||||
coverage run -m pytest tests scripts/tests/ ${@}
|
||||
pytest -n auto --dist loadgroup tests scripts/tests/ ${@}
|
||||
|
||||
@@ -10,9 +10,17 @@ skip_on_windows = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
|
||||
THIS_DIR = Path(__file__).parent.resolve()
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items: list[pytest.Item]) -> None:
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
for item in items:
|
||||
item.add_marker(skip_on_windows)
|
||||
item_path = Path(item.fspath).resolve()
|
||||
if item_path.is_relative_to(THIS_DIR):
|
||||
item.add_marker(skip_on_windows)
|
||||
|
||||
|
||||
@pytest.fixture(name="runner")
|
||||
|
||||
75
tests/test_openapi_cache_root_path.py
Normal file
75
tests/test_openapi_cache_root_path.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_root_path_does_not_persist_across_requests():
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/")
|
||||
def read_root(): # pragma: no cover
|
||||
return {"ok": True}
|
||||
|
||||
# Attacker request with a spoofed root_path
|
||||
attacker_client = TestClient(app, root_path="/evil-api")
|
||||
response1 = attacker_client.get("/openapi.json")
|
||||
data1 = response1.json()
|
||||
assert any(s.get("url") == "/evil-api" for s in data1.get("servers", []))
|
||||
|
||||
# Subsequent legitimate request with no root_path
|
||||
clean_client = TestClient(app)
|
||||
response2 = clean_client.get("/openapi.json")
|
||||
data2 = response2.json()
|
||||
servers = [s.get("url") for s in data2.get("servers", [])]
|
||||
assert "/evil-api" not in servers
|
||||
|
||||
|
||||
def test_multiple_different_root_paths_do_not_accumulate():
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/")
|
||||
def read_root(): # pragma: no cover
|
||||
return {"ok": True}
|
||||
|
||||
for prefix in ["/path-a", "/path-b", "/path-c"]:
|
||||
c = TestClient(app, root_path=prefix)
|
||||
c.get("/openapi.json")
|
||||
|
||||
# A clean request should not have any of them
|
||||
clean_client = TestClient(app)
|
||||
response = clean_client.get("/openapi.json")
|
||||
data = response.json()
|
||||
servers = [s.get("url") for s in data.get("servers", [])]
|
||||
for prefix in ["/path-a", "/path-b", "/path-c"]:
|
||||
assert prefix not in servers, (
|
||||
f"root_path '{prefix}' leaked into clean request: {servers}"
|
||||
)
|
||||
|
||||
|
||||
def test_legitimate_root_path_still_appears():
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/")
|
||||
def read_root(): # pragma: no cover
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app, root_path="/api/v1")
|
||||
response = client.get("/openapi.json")
|
||||
data = response.json()
|
||||
servers = [s.get("url") for s in data.get("servers", [])]
|
||||
assert "/api/v1" in servers
|
||||
|
||||
|
||||
def test_configured_servers_not_mutated():
|
||||
configured_servers = [{"url": "https://prod.example.com"}]
|
||||
app = FastAPI(servers=configured_servers)
|
||||
|
||||
@app.get("/")
|
||||
def read_root(): # pragma: no cover
|
||||
return {"ok": True}
|
||||
|
||||
# Request with a rogue root_path
|
||||
attacker_client = TestClient(app, root_path="/evil")
|
||||
attacker_client.get("/openapi.json")
|
||||
|
||||
# The original servers list must be untouched
|
||||
assert configured_servers == [{"url": "https://prod.example.com"}]
|
||||
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
|
||||
42
tests/test_stream_bare_type.py
Normal file
42
tests/test_stream_bare_type.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import json
|
||||
from typing import AsyncIterable, Iterable # noqa: UP035 to test coverage
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/items/stream-bare-async")
|
||||
async def stream_bare_async() -> AsyncIterable:
|
||||
yield {"name": "foo"}
|
||||
|
||||
|
||||
@app.get("/items/stream-bare-sync")
|
||||
def stream_bare_sync() -> Iterable:
|
||||
yield {"name": "bar"}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_stream_bare_async_iterable():
|
||||
response = client.get("/items/stream-bare-async")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/jsonl"
|
||||
lines = [json.loads(line) for line in response.text.strip().splitlines()]
|
||||
assert lines == [{"name": "foo"}]
|
||||
|
||||
|
||||
def test_stream_bare_sync_iterable():
|
||||
response = client.get("/items/stream-bare-sync")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/jsonl"
|
||||
lines = [json.loads(line) for line in response.text.strip().splitlines()]
|
||||
assert lines == [{"name": "bar"}]
|
||||
88
tests/test_stream_cancellation.py
Normal file
88
tests/test_stream_cancellation.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Test that async streaming endpoints can be cancelled without hanging.
|
||||
|
||||
Ref: https://github.com/fastapi/fastapi/issues/14680
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.anyio,
|
||||
pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning"),
|
||||
]
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/stream-raw", response_class=StreamingResponse)
|
||||
async def stream_raw() -> AsyncIterable[str]:
|
||||
"""Async generator with no internal await - would hang without checkpoint."""
|
||||
i = 0
|
||||
while True:
|
||||
yield f"item {i}\n"
|
||||
i += 1
|
||||
|
||||
|
||||
@app.get("/stream-jsonl")
|
||||
async def stream_jsonl() -> AsyncIterable[int]:
|
||||
"""JSONL async generator with no internal await."""
|
||||
i = 0
|
||||
while True:
|
||||
yield i
|
||||
i += 1
|
||||
|
||||
|
||||
async def _run_asgi_and_cancel(app: FastAPI, path: str, timeout: float) -> bool:
|
||||
"""Call the ASGI app for *path* and cancel after *timeout* seconds.
|
||||
|
||||
Returns `True` if the cancellation was delivered (i.e. it did not hang).
|
||||
"""
|
||||
chunks: list[bytes] = []
|
||||
|
||||
async def receive(): # type: ignore[no-untyped-def]
|
||||
# Simulate a client that never disconnects, rely on cancellation
|
||||
await anyio.sleep(float("inf"))
|
||||
return {"type": "http.disconnect"} # pragma: no cover
|
||||
|
||||
async def send(message: dict) -> None: # type: ignore[type-arg]
|
||||
if message["type"] == "http.response.body":
|
||||
chunks.append(message.get("body", b""))
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"asgi": {"version": "3.0", "spec_version": "2.0"},
|
||||
"http_version": "1.1",
|
||||
"method": "GET",
|
||||
"path": path,
|
||||
"query_string": b"",
|
||||
"root_path": "",
|
||||
"headers": [],
|
||||
"server": ("test", 80),
|
||||
}
|
||||
|
||||
with anyio.move_on_after(timeout) as cancel_scope:
|
||||
await app(scope, receive, send) # type: ignore[arg-type]
|
||||
|
||||
# If we got here within the timeout the generator was cancellable.
|
||||
# cancel_scope.cancelled_caught is True when move_on_after fired.
|
||||
return cancel_scope.cancelled_caught or len(chunks) > 0
|
||||
|
||||
|
||||
async def test_raw_stream_cancellation() -> None:
|
||||
"""Raw streaming endpoint should be cancellable within a reasonable time."""
|
||||
cancelled = await _run_asgi_and_cancel(app, "/stream-raw", timeout=3.0)
|
||||
# The key assertion: we reached this line at all (didn't hang).
|
||||
# cancelled will be True because the infinite generator was interrupted.
|
||||
assert cancelled
|
||||
|
||||
|
||||
async def test_jsonl_stream_cancellation() -> None:
|
||||
"""JSONL streaming endpoint should be cancellable within a reasonable time."""
|
||||
cancelled = await _run_asgi_and_cancel(app, "/stream-jsonl", timeout=3.0)
|
||||
assert cancelled
|
||||
40
tests/test_stream_json_validation_error.py
Normal file
40
tests/test_stream_json_validation_error.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from collections.abc import AsyncIterable, Iterable
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import ResponseValidationError
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/items/stream-invalid")
|
||||
async def stream_items_invalid() -> AsyncIterable[Item]:
|
||||
yield {"name": "valid", "price": 1.0}
|
||||
yield {"name": "invalid", "price": "not-a-number"}
|
||||
|
||||
|
||||
@app.get("/items/stream-invalid-sync")
|
||||
def stream_items_invalid_sync() -> Iterable[Item]:
|
||||
yield {"name": "valid", "price": 1.0}
|
||||
yield {"name": "invalid", "price": "not-a-number"}
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_stream_json_validation_error_async():
|
||||
with pytest.raises(ResponseValidationError):
|
||||
client.get("/items/stream-invalid")
|
||||
|
||||
|
||||
def test_stream_json_validation_error_sync():
|
||||
with pytest.raises(ResponseValidationError):
|
||||
client.get("/items/stream-invalid-sync")
|
||||
37
tests/test_swagger_ui_escape.py
Normal file
37
tests/test_swagger_ui_escape.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
|
||||
|
||||
def test_init_oauth_html_chars_are_escaped():
|
||||
xss_payload = "Evil</script><script>alert(1)</script>"
|
||||
html = get_swagger_ui_html(
|
||||
openapi_url="/openapi.json",
|
||||
title="Test",
|
||||
init_oauth={"appName": xss_payload},
|
||||
)
|
||||
body = html.body.decode()
|
||||
|
||||
assert "</script><script>" not in body
|
||||
assert "\\u003c/script\\u003e\\u003cscript\\u003e" in body
|
||||
|
||||
|
||||
def test_swagger_ui_parameters_html_chars_are_escaped():
|
||||
html = get_swagger_ui_html(
|
||||
openapi_url="/openapi.json",
|
||||
title="Test",
|
||||
swagger_ui_parameters={"customKey": "<img src=x onerror=alert(1)>"},
|
||||
)
|
||||
body = html.body.decode()
|
||||
assert "<img src=x onerror=alert(1)>" not in body
|
||||
assert "\\u003cimg" in body
|
||||
|
||||
|
||||
def test_normal_init_oauth_still_works():
|
||||
html = get_swagger_ui_html(
|
||||
openapi_url="/openapi.json",
|
||||
title="Test",
|
||||
init_oauth={"clientId": "my-client", "appName": "My App"},
|
||||
)
|
||||
body = html.body.decode()
|
||||
assert '"clientId": "my-client"' in body
|
||||
assert '"appName": "My App"' in body
|
||||
assert "ui.initOAuth" in body
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from tests.utils import needs_py310
|
||||
from tests.utils import needs_py310, workdir_lock
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
@@ -29,6 +29,7 @@ def test_path_operation(client: TestClient):
|
||||
assert response.json() == {"id": "foo", "value": "there goes my hero"}
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_path_operation_img(client: TestClient):
|
||||
shutil.copy("./docs/en/docs/img/favicon.png", "./image.png")
|
||||
response = client.get("/items/foo?img=1")
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from tests.utils import needs_py310
|
||||
from tests.utils import needs_py310, workdir_lock
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
@@ -29,6 +29,7 @@ def test_path_operation(client: TestClient):
|
||||
assert response.json() == {"id": "foo", "value": "there goes my hero"}
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_path_operation_img(client: TestClient):
|
||||
shutil.copy("./docs/en/docs/img/favicon.png", "./image.png")
|
||||
response = client.get("/items/foo?img=1")
|
||||
|
||||
@@ -4,10 +4,12 @@ from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from docs_src.background_tasks.tutorial001_py310 import app
|
||||
from tests.utils import workdir_lock
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test():
|
||||
log = Path("log.txt")
|
||||
if log.is_file():
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ...utils import needs_py310
|
||||
from tests.utils import needs_py310, workdir_lock
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
@@ -22,6 +22,7 @@ def get_client(request: pytest.FixtureRequest):
|
||||
return client
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test(client: TestClient):
|
||||
log = Path("log.txt")
|
||||
if log.is_file():
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.utils import workdir_lock
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
@@ -17,6 +19,7 @@ def client():
|
||||
static_dir.rmdir()
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_swagger_ui_html(client: TestClient):
|
||||
response = client.get("/docs")
|
||||
assert response.status_code == 200, response.text
|
||||
@@ -24,18 +27,21 @@ def test_swagger_ui_html(client: TestClient):
|
||||
assert "https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" in response.text
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_swagger_ui_oauth2_redirect_html(client: TestClient):
|
||||
response = client.get("/docs/oauth2-redirect")
|
||||
assert response.status_code == 200, response.text
|
||||
assert "window.opener.swaggerUIRedirectOauth2" in response.text
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_redoc_html(client: TestClient):
|
||||
response = client.get("/redoc")
|
||||
assert response.status_code == 200, response.text
|
||||
assert "https://unpkg.com/redoc@2/bundles/redoc.standalone.js" in response.text
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_api(client: TestClient):
|
||||
response = client.get("/users/john")
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.utils import workdir_lock
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
@@ -17,6 +19,7 @@ def client():
|
||||
static_dir.rmdir()
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_swagger_ui_html(client: TestClient):
|
||||
response = client.get("/docs")
|
||||
assert response.status_code == 200, response.text
|
||||
@@ -24,18 +27,21 @@ def test_swagger_ui_html(client: TestClient):
|
||||
assert "/static/swagger-ui.css" in response.text
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_swagger_ui_oauth2_redirect_html(client: TestClient):
|
||||
response = client.get("/docs/oauth2-redirect")
|
||||
assert response.status_code == 200, response.text
|
||||
assert "window.opener.swaggerUIRedirectOauth2" in response.text
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_redoc_html(client: TestClient):
|
||||
response = client.get("/redoc")
|
||||
assert response.status_code == 200, response.text
|
||||
assert "/static/redoc.standalone.js" in response.text
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_api(client: TestClient):
|
||||
response = client.get("/users/john")
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
@@ -3,6 +3,8 @@ from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from tests.utils import workdir_lock
|
||||
|
||||
|
||||
@pytest.fixture(name="app", scope="module")
|
||||
def get_app():
|
||||
@@ -11,6 +13,7 @@ def get_app():
|
||||
yield app
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_events(app: FastAPI):
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/items/")
|
||||
@@ -20,6 +23,7 @@ def test_events(app: FastAPI):
|
||||
assert "Application shutdown" in log.read()
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_openapi_schema(app: FastAPI):
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/openapi.json")
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -22,7 +22,7 @@ def get_mod_name(request: pytest.FixtureRequest):
|
||||
@pytest.fixture(name="client")
|
||||
def get_test_client(mod_name: str, monkeypatch: MonkeyPatch) -> TestClient:
|
||||
if mod_name in sys.modules:
|
||||
del sys.modules[mod_name]
|
||||
del sys.modules[mod_name] # pragma: no cover
|
||||
monkeypatch.setenv("ADMIN_EMAIL", "admin@example.com")
|
||||
main_mod = importlib.import_module(mod_name)
|
||||
return TestClient(main_mod.app)
|
||||
|
||||
@@ -5,6 +5,8 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from tests.utils import workdir_lock
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
@@ -20,17 +22,20 @@ def client():
|
||||
static_dir.rmdir()
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_static_files(client: TestClient):
|
||||
response = client.get("/static/sample.txt")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.text == "This is a sample static file."
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_static_files_not_found(client: TestClient):
|
||||
response = client.get("/static/non_existent_file.txt")
|
||||
assert response.status_code == 404, response.text
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_openapi_schema(client: TestClient):
|
||||
response = client.get("/openapi.json")
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
0
tests/test_tutorial/test_stream_data/__init__.py
Normal file
0
tests/test_tutorial/test_stream_data/__init__.py
Normal file
154
tests/test_tutorial/test_stream_data/test_tutorial001.py
Normal file
154
tests/test_tutorial/test_stream_data/test_tutorial001.py
Normal file
@@ -0,0 +1,154 @@
|
||||
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.stream_data.{request.param}")
|
||||
|
||||
client = TestClient(mod.app)
|
||||
return client
|
||||
|
||||
|
||||
expected_text = (
|
||||
""
|
||||
"Rick: (stumbles in drunkenly, and turns on the lights)"
|
||||
" Morty! You gotta come on. You got--... you gotta come with me."
|
||||
"Morty: (rubs his eyes) What, Rick? What's going on?"
|
||||
"Rick: I got a surprise for you, Morty."
|
||||
"Morty: It's the middle of the night. What are you talking about?"
|
||||
"Rick: (spills alcohol on Morty's bed) Come on, I got a surprise for you."
|
||||
" (drags Morty by the ankle) Come on, hurry up."
|
||||
" (pulls Morty out of his bed and into the hall)"
|
||||
"Morty: Ow! Ow! You're tugging me too hard!"
|
||||
"Rick: We gotta go, gotta get outta here, come on."
|
||||
" Got a surprise for you Morty."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/story/stream",
|
||||
"/story/stream-no-async",
|
||||
"/story/stream-no-annotation",
|
||||
"/story/stream-no-async-no-annotation",
|
||||
"/story/stream-bytes",
|
||||
"/story/stream-no-async-bytes",
|
||||
"/story/stream-no-annotation-bytes",
|
||||
"/story/stream-no-async-no-annotation-bytes",
|
||||
],
|
||||
)
|
||||
def test_stream_story(client: TestClient, path: str):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.text == expected_text
|
||||
|
||||
|
||||
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": {
|
||||
"/story/stream": {
|
||||
"get": {
|
||||
"summary": "Stream Story",
|
||||
"operationId": "stream_story_story_stream_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/story/stream-no-async": {
|
||||
"get": {
|
||||
"summary": "Stream Story No Async",
|
||||
"operationId": "stream_story_no_async_story_stream_no_async_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/story/stream-no-annotation": {
|
||||
"get": {
|
||||
"summary": "Stream Story No Annotation",
|
||||
"operationId": "stream_story_no_annotation_story_stream_no_annotation_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/story/stream-no-async-no-annotation": {
|
||||
"get": {
|
||||
"summary": "Stream Story No Async No Annotation",
|
||||
"operationId": "stream_story_no_async_no_annotation_story_stream_no_async_no_annotation_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/story/stream-bytes": {
|
||||
"get": {
|
||||
"summary": "Stream Story Bytes",
|
||||
"operationId": "stream_story_bytes_story_stream_bytes_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/story/stream-no-async-bytes": {
|
||||
"get": {
|
||||
"summary": "Stream Story No Async Bytes",
|
||||
"operationId": "stream_story_no_async_bytes_story_stream_no_async_bytes_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/story/stream-no-annotation-bytes": {
|
||||
"get": {
|
||||
"summary": "Stream Story No Annotation Bytes",
|
||||
"operationId": "stream_story_no_annotation_bytes_story_stream_no_annotation_bytes_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/story/stream-no-async-no-annotation-bytes": {
|
||||
"get": {
|
||||
"summary": "Stream Story No Async No Annotation Bytes",
|
||||
"operationId": "stream_story_no_async_no_annotation_bytes_story_stream_no_async_no_annotation_bytes_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
121
tests/test_tutorial/test_stream_data/test_tutorial002.py
Normal file
121
tests/test_tutorial/test_stream_data/test_tutorial002.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
name="mod",
|
||||
params=[
|
||||
pytest.param("tutorial002_py310"),
|
||||
],
|
||||
)
|
||||
def get_mod(request: pytest.FixtureRequest):
|
||||
return importlib.import_module(f"docs_src.stream_data.{request.param}")
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def get_client(mod):
|
||||
client = TestClient(mod.app)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/image/stream",
|
||||
"/image/stream-no-async",
|
||||
"/image/stream-no-async-yield-from",
|
||||
"/image/stream-no-annotation",
|
||||
"/image/stream-no-async-no-annotation",
|
||||
],
|
||||
)
|
||||
def test_stream_image(mod, client: TestClient, path: str):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
assert response.content == mod.binary_image
|
||||
|
||||
|
||||
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": {
|
||||
"/image/stream": {
|
||||
"get": {
|
||||
"summary": "Stream Image",
|
||||
"operationId": "stream_image_image_stream_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"image/png": {"schema": {"type": "string"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/image/stream-no-async": {
|
||||
"get": {
|
||||
"summary": "Stream Image No Async",
|
||||
"operationId": "stream_image_no_async_image_stream_no_async_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"image/png": {"schema": {"type": "string"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/image/stream-no-async-yield-from": {
|
||||
"get": {
|
||||
"summary": "Stream Image No Async Yield From",
|
||||
"operationId": "stream_image_no_async_yield_from_image_stream_no_async_yield_from_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"image/png": {"schema": {"type": "string"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/image/stream-no-annotation": {
|
||||
"get": {
|
||||
"summary": "Stream Image No Annotation",
|
||||
"operationId": "stream_image_no_annotation_image_stream_no_annotation_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"image/png": {"schema": {"type": "string"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/image/stream-no-async-no-annotation": {
|
||||
"get": {
|
||||
"summary": "Stream Image No Async No Annotation",
|
||||
"operationId": "stream_image_no_async_no_annotation_image_stream_no_async_no_annotation_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"image/png": {"schema": {"type": "string"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
143
tests/test_tutorial/test_stream_json_lines/test_tutorial001.py
Normal file
143
tests/test_tutorial/test_stream_json_lines/test_tutorial001.py
Normal file
@@ -0,0 +1,143 @@
|
||||
import importlib
|
||||
import json
|
||||
|
||||
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.stream_json_lines.{request.param}")
|
||||
|
||||
client = TestClient(mod.app)
|
||||
return client
|
||||
|
||||
|
||||
expected_items = [
|
||||
{"name": "Plumbus", "description": "A multi-purpose household device."},
|
||||
{"name": "Portal Gun", "description": "A portal opening device."},
|
||||
{"name": "Meeseeks Box", "description": "A box that summons a Meeseeks."},
|
||||
]
|
||||
|
||||
|
||||
@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"] == "application/jsonl"
|
||||
lines = [json.loads(line) for line in response.text.strip().splitlines()]
|
||||
assert lines == expected_items
|
||||
|
||||
|
||||
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": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {
|
||||
"itemSchema": {
|
||||
"$ref": "#/components/schemas/Item"
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"summary": "Stream Items",
|
||||
"operationId": "stream_items_items_stream_get",
|
||||
}
|
||||
},
|
||||
"/items/stream-no-async": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {
|
||||
"itemSchema": {
|
||||
"$ref": "#/components/schemas/Item"
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"summary": "Stream Items No Async",
|
||||
"operationId": "stream_items_no_async_items_stream_no_async_get",
|
||||
}
|
||||
},
|
||||
"/items/stream-no-annotation": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {
|
||||
"itemSchema": {},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"summary": "Stream Items No Annotation",
|
||||
"operationId": "stream_items_no_annotation_items_stream_no_annotation_get",
|
||||
}
|
||||
},
|
||||
"/items/stream-no-async-no-annotation": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/jsonl": {
|
||||
"itemSchema": {},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"summary": "Stream Items No Async No Annotation",
|
||||
"operationId": "stream_items_no_async_no_annotation_items_stream_no_async_no_annotation_get",
|
||||
}
|
||||
},
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Item": {
|
||||
"properties": {
|
||||
"name": {"type": "string", "title": "Name"},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "null"},
|
||||
],
|
||||
"title": "Description",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["name", "description"],
|
||||
"title": "Item",
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -3,7 +3,10 @@ import shutil
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.utils import workdir_lock
|
||||
|
||||
|
||||
@workdir_lock
|
||||
def test_main():
|
||||
if os.path.isdir("./static"): # pragma: nocover
|
||||
shutil.rmtree("./static")
|
||||
|
||||
@@ -2,7 +2,7 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi.websockets import WebSocketDisconnect
|
||||
|
||||
from docs_src.websockets.tutorial001_py310 import app
|
||||
from docs_src.websockets_.tutorial001_py310 import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from ...utils import needs_py310
|
||||
],
|
||||
)
|
||||
def get_app(request: pytest.FixtureRequest):
|
||||
mod = importlib.import_module(f"docs_src.websockets.{request.param}")
|
||||
mod = importlib.import_module(f"docs_src.websockets_.{request.param}")
|
||||
|
||||
return mod.app
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import importlib
|
||||
import time
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
@@ -12,7 +13,7 @@ from fastapi.testclient import TestClient
|
||||
],
|
||||
)
|
||||
def get_mod(request: pytest.FixtureRequest):
|
||||
mod = importlib.import_module(f"docs_src.websockets.{request.param}")
|
||||
mod = importlib.import_module(f"docs_src.websockets_.{request.param}")
|
||||
|
||||
return mod
|
||||
|
||||
@@ -42,11 +43,13 @@ def test_websocket_handle_disconnection(client: TestClient):
|
||||
connection.send_text("Hello from 1234")
|
||||
data1 = connection.receive_text()
|
||||
assert data1 == "You wrote: Hello from 1234"
|
||||
time.sleep(0.01) # Give server time to process broadcast
|
||||
data2 = connection_two.receive_text()
|
||||
client1_says = "Client #1234 says: Hello from 1234"
|
||||
assert data2 == client1_says
|
||||
data1 = connection.receive_text()
|
||||
assert data1 == client1_says
|
||||
connection_two.close()
|
||||
time.sleep(0.01) # Give server time to process broadcast
|
||||
data1 = connection.receive_text()
|
||||
assert data1 == "Client #5678 left the chat"
|
||||
|
||||
@@ -9,6 +9,8 @@ needs_py314 = pytest.mark.skipif(
|
||||
sys.version_info < (3, 14), reason="requires python3.14+"
|
||||
)
|
||||
|
||||
workdir_lock = pytest.mark.xdist_group("workdir_lock")
|
||||
|
||||
|
||||
def skip_module_if_py_gte_314():
|
||||
"""Skip entire module on Python 3.14+ at import time."""
|
||||
|
||||
176
uv.lock
generated
176
uv.lock
generated
@@ -192,7 +192,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.78.0"
|
||||
version = "0.83.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -204,9 +204,9 @@ dependencies = [
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/51/32849a48f9b1cfe80a508fd269b20bd8f0b1357c70ba092890fde5a6a10b/anthropic-0.78.0.tar.gz", hash = "sha256:55fd978ab9b049c61857463f4c4e9e092b24f892519c6d8078cee1713d8af06e", size = 509136, upload-time = "2026-02-05T17:52:04.986Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/e5/02cd2919ec327b24234abb73082e6ab84c451182cc3cc60681af700f4c63/anthropic-0.83.0.tar.gz", hash = "sha256:a8732c68b41869266c3034541a31a29d8be0f8cd0a714f9edce3128b351eceb4", size = 534058, upload-time = "2026-02-19T19:26:38.904Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/03/2f50931a942e5e13f80e24d83406714672c57964be593fc046d81369335b/anthropic-0.78.0-py3-none-any.whl", hash = "sha256:2a9887d2e99d1b0f9fe08857a1e9fe5d2d4030455dbf9ac65aab052e2efaeac4", size = 405485, upload-time = "2026-02-05T17:52:03.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/75/b9d58e4e2a4b1fc3e75ffbab978f999baf8b7c4ba9f96e60edb918ba386b/anthropic-0.83.0-py3-none-any.whl", hash = "sha256:f069ef508c73b8f9152e8850830d92bd5ef185645dbacf234bb213344a274810", size = 456991, upload-time = "2026-02-19T19:26:40.114Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1037,6 +1037,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "execnet"
|
||||
version = "2.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "executing"
|
||||
version = "2.2.1"
|
||||
@@ -1142,6 +1151,10 @@ dev = [
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-codspeed" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-sugar" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "pytest-xdist", extra = ["psutil"] },
|
||||
{ name = "python-slugify" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "ruff" },
|
||||
@@ -1201,6 +1214,10 @@ tests = [
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-codspeed" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-sugar" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "pytest-xdist", extra = ["psutil"] },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "ruff" },
|
||||
{ name = "sqlmodel" },
|
||||
@@ -1242,7 +1259,7 @@ requires-dist = [
|
||||
{ name = "python-multipart", marker = "extra == 'standard'", specifier = ">=0.0.18" },
|
||||
{ name = "python-multipart", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.0.18" },
|
||||
{ name = "pyyaml", marker = "extra == 'all'", specifier = ">=5.3.1" },
|
||||
{ name = "starlette", specifier = ">=0.40.0,<1.0.0" },
|
||||
{ name = "starlette", specifier = ">=0.46.0" },
|
||||
{ name = "typing-extensions", specifier = ">=4.8.0" },
|
||||
{ name = "typing-inspection", specifier = ">=0.4.2" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.12.0" },
|
||||
@@ -1257,7 +1274,7 @@ dev = [
|
||||
{ name = "anyio", extras = ["trio"], specifier = ">=3.2.1,<5.0.0" },
|
||||
{ name = "black", specifier = ">=25.1.0" },
|
||||
{ name = "cairosvg", specifier = ">=2.8.2" },
|
||||
{ name = "coverage", extras = ["toml"], specifier = ">=6.5.0,<8.0" },
|
||||
{ name = "coverage", extras = ["toml"], specifier = ">=7.13,<8.0" },
|
||||
{ name = "dirty-equals", specifier = ">=0.9.0" },
|
||||
{ name = "flask", specifier = ">=3.0.0,<4.0.0" },
|
||||
{ name = "gitpython", specifier = ">=3.1.46" },
|
||||
@@ -1283,6 +1300,10 @@ dev = [
|
||||
{ name = "pyjwt", specifier = ">=2.9.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.0" },
|
||||
{ name = "pytest-codspeed", specifier = ">=4.2.0" },
|
||||
{ name = "pytest-cov", specifier = ">=4.0.0" },
|
||||
{ name = "pytest-sugar", specifier = ">=1.0.0" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
||||
{ name = "pytest-xdist", extras = ["psutil"], specifier = ">=2.5.0" },
|
||||
{ name = "python-slugify", specifier = ">=8.0.4" },
|
||||
{ name = "pyyaml", specifier = ">=5.3.1,<7.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.14.14" },
|
||||
@@ -1331,7 +1352,7 @@ github-actions = [
|
||||
tests = [
|
||||
{ name = "a2wsgi", specifier = ">=1.9.0,<=2.0.0" },
|
||||
{ name = "anyio", extras = ["trio"], specifier = ">=3.2.1,<5.0.0" },
|
||||
{ name = "coverage", extras = ["toml"], specifier = ">=6.5.0,<8.0" },
|
||||
{ name = "coverage", extras = ["toml"], specifier = ">=7.13,<8.0" },
|
||||
{ name = "dirty-equals", specifier = ">=0.9.0" },
|
||||
{ name = "flask", specifier = ">=3.0.0,<4.0.0" },
|
||||
{ name = "httpx", specifier = ">=0.23.0,<1.0.0" },
|
||||
@@ -1342,6 +1363,10 @@ tests = [
|
||||
{ name = "pyjwt", specifier = ">=2.9.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.0" },
|
||||
{ name = "pytest-codspeed", specifier = ">=4.2.0" },
|
||||
{ name = "pytest-cov", specifier = ">=4.0.0" },
|
||||
{ name = "pytest-sugar", specifier = ">=1.0.0" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
||||
{ name = "pytest-xdist", extras = ["psutil"], specifier = ">=2.5.0" },
|
||||
{ name = "pyyaml", specifier = ">=5.3.1,<7.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.14.14" },
|
||||
{ name = "sqlmodel", specifier = ">=0.0.31" },
|
||||
@@ -1922,14 +1947,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffe-typingdoc"
|
||||
version = "0.3.1"
|
||||
@@ -1955,6 +1972,14 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/3c/c2a9eee79bf2c8002d2fa370534bee93fdca39e8b1fc82e83d552d5d2c07/griffe_warnings_deprecated-1.1.1-py3-none-any.whl", hash = "sha256:4b7d765e82ca9139ed44ffe7bdebed0d3a46ce014ad5a35a2c22e9a16288737a", size = 6565, upload-time = "2026-02-20T15:35:23.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "groq"
|
||||
version = "1.0.0"
|
||||
@@ -2162,26 +2187,23 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "huggingface-hub"
|
||||
version = "0.36.2"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "fsspec" },
|
||||
{ name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
|
||||
{ name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
|
||||
{ name = "httpx" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "shellingham" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typer-slim" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/fc/eb9bc06130e8bbda6a616e1b80a7aa127681c448d6b49806f61db2670b61/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5", size = 642156, upload-time = "2026-02-06T09:20:03.013Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
inference = [
|
||||
{ name = "aiohttp" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3826,6 +3848,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pwdlib"
|
||||
version = "0.3.0"
|
||||
@@ -3995,7 +4045,7 @@ groq = [
|
||||
{ name = "groq" },
|
||||
]
|
||||
huggingface = [
|
||||
{ name = "huggingface-hub", extra = ["inference"] },
|
||||
{ name = "huggingface-hub" },
|
||||
]
|
||||
logfire = [
|
||||
{ name = "logfire", extra = ["httpx"] },
|
||||
@@ -4147,7 +4197,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-evals"
|
||||
version = "1.56.0"
|
||||
version = "1.62.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -4157,9 +4207,9 @@ dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/f2/8c59284a2978af3fbda45ae3217218eaf8b071207a9290b54b7613983e5d/pydantic_evals-1.56.0.tar.gz", hash = "sha256:206635107127af6a3ee4b1fc8f77af6afb14683615a2d6b3609f79467c1c0d28", size = 47210, upload-time = "2026-02-06T01:13:25.714Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/90/080f6722412263395d1d6d066ee90fa8bc2722ce097844220c2d9c946877/pydantic_evals-1.62.0.tar.gz", hash = "sha256:198c4bee936718a4acf6f504056b113e60b34eb49021df8889a394e14c803693", size = 56434, upload-time = "2026-02-19T05:07:11.793Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/51/9875d19ff6d584aaeb574aba76b49d931b822546fc60b29c4fc0da98170d/pydantic_evals-1.56.0-py3-none-any.whl", hash = "sha256:d1efb410c97135aabd2a22453b10c981b2b9851985e9354713af67ae0973b7a9", size = 56407, upload-time = "2026-02-06T01:13:17.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/b9/dc8dba744ec02b16c6fd1abe3fd8ef1b00fd05c72feef5069851b811952f/pydantic_evals-1.62.0-py3-none-any.whl", hash = "sha256:0ca7e10037ed90393c54b6cff41370d6d4bac63f8c878715599c58863c303db1", size = 67341, upload-time = "2026-02-19T05:07:03.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4380,6 +4430,63 @@ wheels = [
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-sugar"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "termcolor" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/4e/60fed105549297ba1a700e1ea7b828044842ea27d72c898990510b79b0e2/pytest-sugar-1.1.1.tar.gz", hash = "sha256:73b8b65163ebf10f9f671efab9eed3d56f20d2ca68bda83fa64740a92c08f65d", size = 16533, upload-time = "2025-08-23T12:19:35.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/d5/81d38a91c1fdafb6711f053f5a9b92ff788013b19821257c2c38c1e132df/pytest_sugar-1.1.1-py3-none-any.whl", hash = "sha256:2f8319b907548d5b9d03a171515c1d43d2e38e32bd8182a1781eb20b43344cc8", size = 11440, upload-time = "2025-08-23T12:19:34.894Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-timeout"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-xdist"
|
||||
version = "3.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "execnet" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
psutil = [
|
||||
{ name = "psutil" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -5558,6 +5665,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer-slim"
|
||||
version = "0.21.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "click" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-orjson"
|
||||
version = "3.6.2"
|
||||
|
||||
Reference in New Issue
Block a user