Compare commits

..

32 Commits

Author SHA1 Message Date
Sebastián Ramírez
5b0ef2ffa5 Format test module docstring 2026-02-27 17:57:56 +01:00
Sebastián Ramírez
4e85ad50c7 🐛 Fix support for cancellation without an extra await 2026-02-27 17:43:53 +01:00
Sebastián Ramírez
b51c8173de Add tests for streaming cancellation 2026-02-27 17:42:38 +01:00
Sebastián Ramírez
20fa416ecb ⬆️ Upgrade minimum version of Starlette 2026-02-27 16:27:55 +01:00
Sebastián Ramírez
527462fe0b Tweak tests for coverage 2026-02-27 15:31:37 +01:00
Sebastián Ramírez
212f6cd522 Add tests to increase coverage 2026-02-27 15:22:36 +01:00
Sebastián Ramírez
f0f131cd59 📝 Update docs highlights 2026-02-27 15:07:11 +01:00
Sebastián Ramírez
b7c570b066 Add test for streaming PNG example 2026-02-27 15:06:51 +01:00
Sebastián Ramírez
61338f2715 📝 Update streaming PNG source example 2026-02-27 15:06:16 +01:00
Sebastián Ramírez
aab9572d52 📝 Tweak docs for Streaming JSON Lines 2026-02-27 14:50:06 +01:00
Sebastián Ramírez
a853a5cc79 📝 Add docs for streaming data with StreamingResponse and yield 2026-02-27 14:49:27 +01:00
Sebastián Ramírez
5dd06561d6 📝 Add source examples for streaming data 2026-02-27 14:48:43 +01:00
Sebastián Ramírez
ce241f974a 🔧 Update ignores for Ruff 2026-02-27 14:48:16 +01:00
Sebastián Ramírez
0358475872 Merge branch 'master' into stream-json 2026-02-27 13:34:59 +01:00
Motov Yurii
0901b4092c 📝 Rename docs_src/websockets to docs_src/websockets_ to avoid import errors (#14979) 2026-02-27 13:34:37 +01:00
Sebastián Ramírez
bf49d49415 Update test 2026-02-27 13:18:40 +01:00
Sebastián Ramírez
7c959eef27 📝 Update the JSON Stream Lines source example, 3 items 2026-02-27 13:17:21 +01:00
Sebastián Ramírez
65dce225c8 📝 Add docs for Stream JSON Lines 2026-02-27 13:16:09 +01:00
Sebastián Ramírez
a6e4d9dd76 🔁 Merge branch 'master' into stream-json 2026-02-27 12:00:52 +01:00
github-actions[bot]
873e48fb15 📝 Update release notes
[skip ci]
2026-02-27 10:54:09 +00:00
Motov Yurii
5aacc7b6a0 🔨 Run tests with pytest-xdist and pytest-cov (#14992) 2026-02-27 11:53:47 +01:00
Sebastián Ramírez
07ab822c7c Implement support for JSON Lines and Streaming data with yield 2026-02-27 01:27:47 +01:00
Sebastián Ramírez
e6ddf0c122 Add tests for streaming data with yield 2026-02-26 23:13:12 +01:00
Sebastián Ramírez
e052f17c96 Add tests for JSON Lines 2026-02-26 23:11:30 +01:00
Sebastián Ramírez
724b03434c 📝 Add source example for streaming data with yield 2026-02-26 23:07:35 +01:00
Sebastián Ramírez
640ff5496f 📝 Add source example for JSON Lines 2026-02-26 23:06:54 +01:00
Sebastián Ramírez
30ed44fc86 🔧 Add Ruff ignores 2026-02-26 23:06:29 +01:00
Sebastián Ramírez
a4ad07b48a 📝 Update release notes 2026-02-25 19:14:58 +01:00
Sebastián Ramírez
728b097564 🔖 Release version 0.133.1 2026-02-25 19:13:57 +01:00
Sebastián Ramírez
84a8760a80 📝 Update release notes 2026-02-25 19:13:07 +01:00
github-actions[bot]
4d78ca6f95 📝 Update release notes
[skip ci]
2026-02-25 18:11:13 +00:00
Sebastián Ramírez
4fce9ce172 🔧 Add FastAPI Agents Skill (#14982)
Co-authored-by: Sofie Van Landeghem <svlandeg@users.noreply.github.com>
Co-authored-by: Alejandra <90076947+alejsdev@users.noreply.github.com>
2026-02-25 19:10:48 +01:00
66 changed files with 2024 additions and 4105 deletions

View File

@@ -107,7 +107,7 @@ jobs:
run: uv pip install "git+https://github.com/Kludex/starlette@main" run: uv pip install "git+https://github.com/Kludex/starlette@main"
- run: mkdir coverage - run: mkdir coverage
- name: Test - name: Test
run: uv run --no-sync bash scripts/test.sh run: uv run --no-sync bash scripts/test-cov.sh
env: env:
COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }} COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}
CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }} CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}

View File

@@ -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: 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 } ## Einen `websocket` erstellen { #create-a-websocket }
Erstellen Sie in Ihrer **FastAPI**-Anwendung einen `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 /// 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. 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. 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*: 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 /// 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. 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: Zum Ausprobieren:

View File

@@ -0,0 +1,99 @@
# 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.
## 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:26] 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:26] 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. 🥚
///
### 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[29:32] hl[30] *}
/// 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.
///

View File

@@ -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: 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 } ## Create a `websocket` { #create-a-websocket }
In your **FastAPI** application, 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 /// 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. 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. 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*: 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 /// 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. 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: To try it out:

View File

@@ -9,6 +9,17 @@ hide:
### Internal ### 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). * ✅ 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 ## 0.133.0

View File

@@ -0,0 +1,105 @@
# 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**.
## 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 }
A future version of FastAPI will also have first-class support for Server Sent Events (SSE), which are quite similar, but with a couple of extra details. 🤓

View File

@@ -154,6 +154,7 @@ nav:
- tutorial/cors.md - tutorial/cors.md
- tutorial/sql-databases.md - tutorial/sql-databases.md
- tutorial/bigger-applications.md - tutorial/bigger-applications.md
- tutorial/stream-json-lines.md
- tutorial/background-tasks.md - tutorial/background-tasks.md
- tutorial/metadata.md - tutorial/metadata.md
- tutorial/static-files.md - tutorial/static-files.md
@@ -161,6 +162,7 @@ nav:
- tutorial/debugging.md - tutorial/debugging.md
- Advanced User Guide: - Advanced User Guide:
- advanced/index.md - advanced/index.md
- advanced/stream-data.md
- advanced/path-operation-advanced-configuration.md - advanced/path-operation-advanced-configuration.md
- advanced/additional-status-codes.md - advanced/additional-status-codes.md
- advanced/response-directly.md - advanced/response-directly.md

View File

@@ -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: 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 } ## Crear un `websocket` { #create-a-websocket }
En tu aplicación de **FastAPI**, crea un `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 /// 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. 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. 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*: 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 /// 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. 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: Para probarlo:

View File

@@ -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 : 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 } ## Créer un `websocket` { #create-a-websocket }
Dans votre application **FastAPI**, créez un `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 /// 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. 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. 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 : 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 /// 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. 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 : Pour l'essayer :

View File

@@ -38,13 +38,13 @@ $ pip install websockets
しかし、これは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 } ## `websocket` を作成する { #create-a-websocket }
**FastAPI** アプリケーションで、`websocket` を作成します。 **FastAPI** アプリケーションで、`websocket` を作成します。
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
/// note | 技術詳細 /// note | 技術詳細
@@ -58,7 +58,7 @@ $ pip install websockets
WebSocketルートでは、メッセージを待機して送信するために `await` を使用できます。 WebSocketルートでは、メッセージを待機して送信するために `await` を使用できます。
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
バイナリやテキストデータ、JSONデータを送受信できます。 バイナリやテキストデータ、JSONデータを送受信できます。
@@ -109,7 +109,7 @@ WebSocketエンドポイントでは、`fastapi` から以下をインポート
これらは、他のFastAPI エンドポイント/*path operations* の場合と同じように機能します。 これらは、他の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 | 情報 /// info | 情報
@@ -154,7 +154,7 @@ $ fastapi dev main.py
WebSocket接続が閉じられると、 `await websocket.receive_text()` は例外 `WebSocketDisconnect` を発生させ、この例のようにキャッチして処理することができます。 WebSocket接続が閉じられると、 `await websocket.receive_text()` は例外 `WebSocketDisconnect` を発生させ、この例のようにキャッチして処理することができます。
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *} {* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
試してみるには、 試してみるには、

View File

@@ -38,13 +38,13 @@ $ pip install websockets
그러나 이는 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 } ## `websocket` 생성하기 { #create-a-websocket }
**FastAPI** 애플리케이션에서 `websocket`을 생성합니다: **FastAPI** 애플리케이션에서 `websocket`을 생성합니다:
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
/// note | 기술 세부사항 /// note | 기술 세부사항
@@ -58,7 +58,7 @@ $ pip install websockets
WebSocket 경로에서 메시지를 대기(`await`)하고 전송할 수 있습니다. WebSocket 경로에서 메시지를 대기(`await`)하고 전송할 수 있습니다.
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
여러분은 이진 데이터, 텍스트, JSON 데이터를 받을 수 있고 전송할 수 있습니다. 여러분은 이진 데이터, 텍스트, JSON 데이터를 받을 수 있고 전송할 수 있습니다.
@@ -109,7 +109,7 @@ WebSocket 엔드포인트에서 `fastapi`에서 다음을 가져와 사용할
이들은 다른 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 | 정보 /// info | 정보
@@ -154,7 +154,7 @@ $ fastapi dev main.py
WebSocket 연결이 닫히면, `await websocket.receive_text()``WebSocketDisconnect` 예외를 발생시킵니다. 그러면 이 예제처럼 이를 잡아 처리할 수 있습니다. WebSocket 연결이 닫히면, `await websocket.receive_text()``WebSocketDisconnect` 예외를 발생시킵니다. 그러면 이 예제처럼 이를 잡아 처리할 수 있습니다.
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *} {* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
테스트해보기: 테스트해보기:

View File

@@ -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: 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 } ## Crie um `websocket` { #create-a-websocket }
Em sua aplicação **FastAPI**, crie um `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 /// 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. 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. 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*: 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 /// 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. 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: Para testar:

View File

@@ -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` { #create-a-websocket }
Создайте `websocket` в своем **FastAPI** приложении: Создайте `websocket` в своем **FastAPI** приложении:
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
/// note | Технические детали /// 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 данные. Вы можете получать и отправлять двоичные, текстовые и JSON данные.
@@ -109,7 +109,7 @@ $ fastapi dev main.py
Они работают так же, как и в других 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 | Примечание /// info | Примечание
@@ -154,7 +154,7 @@ $ fastapi dev main.py
Если веб-сокет соединение закрыто, то `await websocket.receive_text()` вызовет исключение `WebSocketDisconnect`, которое можно поймать и обработать как в этом примере: Если веб-сокет соединение закрыто, то `await websocket.receive_text()` вызовет исключение `WebSocketDisconnect`, которое можно поймать и обработать как в этом примере:
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *} {* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
Чтобы воспроизвести пример: Чтобы воспроизвести пример:

View File

@@ -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: 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 } ## Bir `websocket` Oluşturun { #create-a-websocket }
**FastAPI** uygulamanızda bir `websocket` oluşturun: **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 /// 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. 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. 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: 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 /// 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. 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: Denemek için:

View File

@@ -38,13 +38,13 @@ $ pip install websockets
Але це найпростіший спосіб зосередитися на серверній частині 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 } ## Створіть `websocket` { #create-a-websocket }
У вашому застосунку **FastAPI** створіть `websocket`: У вашому застосунку **FastAPI** створіть `websocket`:
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
/// note | Технічні деталі /// note | Технічні деталі
@@ -58,7 +58,7 @@ $ pip install websockets
У вашому маршруті WebSocket ви можете `await` повідомлення і надсилати повідомлення. У вашому маршруті WebSocket ви можете `await` повідомлення і надсилати повідомлення.
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
Ви можете отримувати та надсилати бінарні, текстові та JSON-дані. Ви можете отримувати та надсилати бінарні, текстові та JSON-дані.
@@ -109,7 +109,7 @@ $ fastapi dev main.py
Вони працюють так само, як для інших ендпойнтів 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 /// info
@@ -154,7 +154,7 @@ $ fastapi dev main.py
Коли з'єднання WebSocket закривається, `await websocket.receive_text()` підніме виняток `WebSocketDisconnect`, який ви можете перехопити й обробити, як у цьому прикладі. Коли з'єднання WebSocket закривається, `await websocket.receive_text()` підніме виняток `WebSocketDisconnect`, який ви можете перехопити й обробити, як у цьому прикладі.
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *} {* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
Щоб спробувати: Щоб спробувати:

View File

@@ -38,13 +38,13 @@ $ pip install websockets
但這是能讓我們專注於 WebSocket 伺服端並跑起一個可運作範例的最簡單方式: 但這是能讓我們專注於 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 } ## 建立一個 `websocket` { #create-a-websocket }
在你的 **FastAPI** 應用中,建立一個 `websocket` 在你的 **FastAPI** 應用中,建立一個 `websocket`
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
/// note | 技術細節 /// note | 技術細節
@@ -58,7 +58,7 @@ $ pip install websockets
在你的 WebSocket 路由中,你可以 `await` 接收訊息並傳送訊息。 在你的 WebSocket 路由中,你可以 `await` 接收訊息並傳送訊息。
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
你可以接收與傳送二進位、文字與 JSON 資料。 你可以接收與傳送二進位、文字與 JSON 資料。
@@ -109,7 +109,7 @@ $ fastapi dev main.py
它們的運作方式與其他 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 /// info
@@ -154,7 +154,7 @@ $ fastapi dev main.py
當 WebSocket 連線關閉時,`await websocket.receive_text()` 會拋出 `WebSocketDisconnect` 例外,你可以像範例中那樣捕捉並處理。 當 WebSocket 連線關閉時,`await websocket.receive_text()` 會拋出 `WebSocketDisconnect` 例外,你可以像範例中那樣捕捉並處理。
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *} {* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
試用方式: 試用方式:

View File

@@ -38,13 +38,13 @@ $ pip install websockets
但这是一种专注于 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 } ## 创建 `websocket` { #create-a-websocket }
在您的 **FastAPI** 应用程序中,创建一个 `websocket` 在您的 **FastAPI** 应用程序中,创建一个 `websocket`
{* ../../docs_src/websockets/tutorial001_py310.py hl[1,46:47] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[1,46:47] *}
/// note | 技术细节 /// note | 技术细节
@@ -58,7 +58,7 @@ $ pip install websockets
在您的 WebSocket 路由中,您可以使用 `await` 等待消息并发送消息。 在您的 WebSocket 路由中,您可以使用 `await` 等待消息并发送消息。
{* ../../docs_src/websockets/tutorial001_py310.py hl[48:52] *} {* ../../docs_src/websockets_/tutorial001_py310.py hl[48:52] *}
您可以接收和发送二进制、文本和 JSON 数据。 您可以接收和发送二进制、文本和 JSON 数据。
@@ -109,7 +109,7 @@ $ fastapi dev main.py
它们的工作方式与其他 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 /// info
@@ -154,7 +154,7 @@ $ fastapi dev main.py
当 WebSocket 连接关闭时,`await websocket.receive_text()` 将引发 `WebSocketDisconnect` 异常,您可以捕获并处理该异常,就像本示例中的示例一样。 当 WebSocket 连接关闭时,`await websocket.receive_text()` 将引发 `WebSocketDisconnect` 异常,您可以捕获并处理该异常,就像本示例中的示例一样。
{* ../../docs_src/websockets/tutorial003_py310.py hl[79:81] *} {* ../../docs_src/websockets_/tutorial003_py310.py hl[79:81] *}
尝试以下操作: 尝试以下操作:

View 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")

View File

@@ -0,0 +1,44 @@
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]:
for chunk in read_image():
yield chunk
@app.get("/image/stream-no-async", response_class=PNGStreamingResponse)
def stream_image_no_async() -> Iterable[bytes]:
for chunk in read_image():
yield chunk
@app.get("/image/stream-no-annotation", response_class=PNGStreamingResponse)
async def stream_image_no_annotation():
for chunk in read_image():
yield chunk
@app.get("/image/stream-no-async-no-annotation", response_class=PNGStreamingResponse)
def stream_image_no_async_no_annotation():
for chunk in read_image():
yield chunk

View File

View 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

View File

View File

@@ -0,0 +1,614 @@
---
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}
```
## 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 []
```

View File

@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production""" """FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.133.0" __version__ = "0.133.1"
from starlette import status as status from starlette import status as status

View File

@@ -1,7 +1,17 @@
import dataclasses import dataclasses
import inspect import inspect
import sys 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 contextlib import AsyncExitStack, contextmanager
from copy import copy, deepcopy from copy import copy, deepcopy
from dataclasses import dataclass from dataclasses import dataclass
@@ -251,6 +261,26 @@ def get_typed_return_annotation(call: Callable[..., Any]) -> Any:
return get_typed_annotation(annotation, globalns) 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( def get_dependant(
*, *,
path: str, path: str,

View File

@@ -355,25 +355,40 @@ def get_openapi_path(
operation.setdefault("responses", {}).setdefault(status_code, {})[ operation.setdefault("responses", {}).setdefault(status_code, {})[
"description" "description"
] = route.response_description ] = route.response_description
if route_response_media_type and is_body_allowed_for_status_code( if is_body_allowed_for_status_code(route.status_code):
route.status_code # Check for JSONL streaming (generator endpoints)
): if route.is_json_stream:
response_schema = {"type": "string"} jsonl_content: dict[str, Any] = {}
if lenient_issubclass(current_response_class, JSONResponse): if route.stream_item_field:
if route.response_field: item_schema = get_schema_from_model_field(
response_schema = get_schema_from_model_field( field=route.stream_item_field,
field=route.response_field,
model_name_map=model_name_map, model_name_map=model_name_map,
field_mapping=field_mapping, field_mapping=field_mapping,
separate_input_output_schemas=separate_input_output_schemas, separate_input_output_schemas=separate_input_output_schemas,
) )
jsonl_content["itemSchema"] = item_schema
else: else:
response_schema = {} jsonl_content["itemSchema"] = {}
operation.setdefault("responses", {}).setdefault( operation.setdefault("responses", {}).setdefault(
status_code, {} status_code, {}
).setdefault("content", {}).setdefault(route_response_media_type, {})[ ).setdefault("content", {})["application/jsonl"] = jsonl_content
"schema" elif route_response_media_type:
] = response_schema 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: if route.responses:
operation_responses = operation.setdefault("responses", {}) operation_responses = operation.setdefault("responses", {})
for ( for (
@@ -453,9 +468,9 @@ def get_fields_from_routes(
request_fields_from_routes: list[ModelField] = [] request_fields_from_routes: list[ModelField] = []
callback_flat_models: list[ModelField] = [] callback_flat_models: list[ModelField] = []
for route in routes: for route in routes:
if getattr(route, "include_in_schema", None) and isinstance( if not isinstance(route, routing.APIRoute):
route, routing.APIRoute continue
): if route.include_in_schema:
if route.body_field: if route.body_field:
assert isinstance(route.body_field, ModelField), ( assert isinstance(route.body_field, ModelField), (
"A request body must be a Pydantic Field" "A request body must be a Pydantic Field"
@@ -465,6 +480,8 @@ def get_fields_from_routes(
responses_from_routes.append(route.response_field) responses_from_routes.append(route.response_field)
if route.response_fields: if route.response_fields:
responses_from_routes.extend(route.response_fields.values()) responses_from_routes.extend(route.response_fields.values())
if route.stream_item_field:
responses_from_routes.append(route.stream_item_field)
if route.callbacks: if route.callbacks:
callback_flat_models.extend(get_fields_from_routes(route.callbacks)) callback_flat_models.extend(get_fields_from_routes(route.callbacks))
params = get_flat_params(route.dependant) params = get_flat_params(route.dependant)

View File

@@ -11,6 +11,7 @@ from collections.abc import (
Collection, Collection,
Coroutine, Coroutine,
Generator, Generator,
Iterator,
Mapping, Mapping,
Sequence, Sequence,
) )
@@ -27,6 +28,7 @@ from typing import (
TypeVar, TypeVar,
) )
import anyio
from annotated_doc import Doc from annotated_doc import Doc
from fastapi import params from fastapi import params
from fastapi._compat import ( from fastapi._compat import (
@@ -42,6 +44,7 @@ from fastapi.dependencies.utils import (
get_dependant, get_dependant,
get_flat_dependant, get_flat_dependant,
get_parameterless_sub_dependant, get_parameterless_sub_dependant,
get_stream_item_type,
get_typed_return_annotation, get_typed_return_annotation,
solve_dependencies, solve_dependencies,
) )
@@ -66,7 +69,7 @@ from starlette._utils import is_async_callable
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response, StreamingResponse
from starlette.routing import ( from starlette.routing import (
BaseRoute, BaseRoute,
Match, Match,
@@ -315,6 +318,24 @@ async def run_endpoint_function(
return await run_in_threadpool(dependant.call, **values) 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( def get_request_handler(
dependant: Dependant, dependant: Dependant,
body_field: ModelField | None = None, body_field: ModelField | None = None,
@@ -330,6 +351,8 @@ def get_request_handler(
dependency_overrides_provider: Any | None = None, dependency_overrides_provider: Any | None = None,
embed_body_fields: bool = False, embed_body_fields: bool = False,
strict_content_type: bool | DefaultPlaceholder = Default(True), strict_content_type: bool | DefaultPlaceholder = Default(True),
stream_item_field: ModelField | None = None,
is_json_stream: bool = False,
) -> Callable[[Request], Coroutine[Any, Any, Response]]: ) -> Callable[[Request], Coroutine[Any, Any, Response]]:
assert dependant.call is not None, "dependant.call must be a function" assert dependant.call is not None, "dependant.call must be a function"
is_coroutine = dependant.is_coroutine_callable is_coroutine = dependant.is_coroutine_callable
@@ -427,61 +450,130 @@ def get_request_handler(
embed_body_fields=embed_body_fields, embed_body_fields=embed_body_fields,
) )
errors = solved_result.errors errors = solved_result.errors
assert dependant.call # For types
if not errors: if not errors:
raw_response = await run_endpoint_function( if is_json_stream:
dependant=dependant, # Generator endpoint: stream as JSONL
values=solved_result.values, gen = dependant.call(**solved_result.values)
is_coroutine=is_coroutine,
) def _serialize_item(item: Any) -> bytes:
if isinstance(raw_response, Response): if stream_item_field:
if raw_response.background is None: value, errors = stream_item_field.validate(
raw_response.background = solved_result.background_tasks item, {}, loc=("response",)
response = raw_response )
else: if errors:
response_args: dict[str, Any] = { ctx = endpoint_ctx or EndpointContext()
"background": solved_result.background_tasks raise ResponseValidationError(
} errors=errors,
# If status_code was set, use it, otherwise use the default from the body=item,
# response class, in the case of redirect it's 307 endpoint_ctx=ctx,
current_status_code = ( )
status_code if status_code else solved_result.response.status_code line = stream_item_field.serialize_json(
) value,
if current_status_code is not None: include=response_model_include,
response_args["status_code"] = current_status_code exclude=response_model_exclude,
if solved_result.response.status_code: by_alias=response_model_by_alias,
response_args["status_code"] = solved_result.response.status_code exclude_unset=response_model_exclude_unset,
# Use the fast path (dump_json) when no custom response exclude_defaults=response_model_exclude_defaults,
# class was set and a response field with a TypeAdapter exclude_none=response_model_exclude_none,
# exists. Serializes directly to JSON bytes via Pydantic's )
# Rust core, skipping the intermediate Python dict + return line + b"\n"
# json.dumps() step. else:
use_dump_json = response_field is not None and isinstance( data = jsonable_encoder(item)
response_class, DefaultPlaceholder return json.dumps(data).encode("utf-8") + b"\n"
)
content = await serialize_response( if dependant.is_async_gen_callable:
field=response_field,
response_content=raw_response, async def _async_stream_jsonl() -> AsyncIterator[bytes]:
include=response_model_include, async for item in gen:
exclude=response_model_exclude, yield _serialize_item(item)
by_alias=response_model_by_alias, # To allow for cancellation to trigger
exclude_unset=response_model_exclude_unset, # Ref: https://github.com/fastapi/fastapi/issues/14680
exclude_defaults=response_model_exclude_defaults, await anyio.sleep(0)
exclude_none=response_model_exclude_none,
is_coroutine=is_coroutine, stream_content: AsyncIterator[bytes] | Iterator[bytes] = (
endpoint_ctx=endpoint_ctx, _async_stream_jsonl()
dump_json=use_dump_json,
)
if use_dump_json:
response = Response(
content=content,
media_type="application/json",
**response_args,
) )
else: else:
response = actual_response_class(content, **response_args)
if not is_body_allowed_for_status_code(response.status_code): def _sync_stream_jsonl() -> Iterator[bytes]:
response.body = b"" for item in gen:
yield _serialize_item(item)
stream_content = _sync_stream_jsonl()
response = StreamingResponse(
stream_content,
media_type="application/jsonl",
background=solved_result.background_tasks,
)
response.headers.raw.extend(solved_result.response.headers.raw) 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: if errors:
validation_error = RequestValidationError( validation_error = RequestValidationError(
errors, body=body, endpoint_ctx=endpoint_ctx errors, body=body, endpoint_ctx=endpoint_ctx
@@ -609,12 +701,21 @@ class APIRoute(routing.Route):
) -> None: ) -> None:
self.path = path self.path = path
self.endpoint = endpoint self.endpoint = endpoint
self.stream_item_type: Any | None = None
if isinstance(response_model, DefaultPlaceholder): if isinstance(response_model, DefaultPlaceholder):
return_annotation = get_typed_return_annotation(endpoint) return_annotation = get_typed_return_annotation(endpoint)
if lenient_issubclass(return_annotation, Response): if lenient_issubclass(return_annotation, Response):
response_model = None response_model = None
else: else:
response_model = return_annotation stream_item = get_stream_item_type(return_annotation)
if stream_item is not None:
# Only extract item type for JSONL streaming when no
# explicit response_class (e.g. StreamingResponse) was set
if isinstance(response_class, DefaultPlaceholder):
self.stream_item_type = stream_item
response_model = None
else:
response_model = return_annotation
self.response_model = response_model self.response_model = response_model
self.summary = summary self.summary = summary
self.response_description = response_description self.response_description = response_description
@@ -663,6 +764,15 @@ class APIRoute(routing.Route):
) )
else: else:
self.response_field = None # type: ignore 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.dependencies = list(dependencies or [])
self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "") self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "")
# if a "form feed" character (page break) is found in the description text, # if a "form feed" character (page break) is found in the description text,
@@ -704,6 +814,11 @@ class APIRoute(routing.Route):
name=self.unique_id, name=self.unique_id,
embed_body_fields=self._embed_body_fields, embed_body_fields=self._embed_body_fields,
) )
# Detect generator endpoints that should stream as JSONL
# (only when no explicit response_class like StreamingResponse is set)
self.is_json_stream = isinstance(response_class, DefaultPlaceholder) and (
self.dependant.is_async_gen_callable or self.dependant.is_gen_callable
)
self.app = request_response(self.get_route_handler()) self.app = request_response(self.get_route_handler())
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
@@ -722,6 +837,8 @@ class APIRoute(routing.Route):
dependency_overrides_provider=self.dependency_overrides_provider, dependency_overrides_provider=self.dependency_overrides_provider,
embed_body_fields=self._embed_body_fields, embed_body_fields=self._embed_body_fields,
strict_content_type=self.strict_content_type, 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]: def matches(self, scope: Scope) -> tuple[Match, Scope]:

View File

@@ -42,7 +42,7 @@ classifiers = [
"Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP",
] ]
dependencies = [ dependencies = [
"starlette>=0.40.0", "starlette>=0.46.0",
"pydantic>=2.7.0", "pydantic>=2.7.0",
"typing-extensions>=4.8.0", "typing-extensions>=4.8.0",
"typing-inspection>=0.4.2", "typing-inspection>=0.4.2",
@@ -163,7 +163,7 @@ github-actions = [
tests = [ tests = [
{ include-group = "docs-tests" }, { include-group = "docs-tests" },
"anyio[trio] >=3.2.1,<5.0.0", "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", "dirty-equals >=0.9.0",
"flask >=3.0.0,<4.0.0", "flask >=3.0.0,<4.0.0",
"inline-snapshot >=0.21.1", "inline-snapshot >=0.21.1",
@@ -178,6 +178,10 @@ tests = [
"types-orjson >=3.6.2", "types-orjson >=3.6.2",
"types-ujson >=5.10.0.20240515", "types-ujson >=5.10.0.20240515",
"a2wsgi >=1.9.0,<=2.0.0", "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 = [ translations = [
"gitpython >=3.1.46", "gitpython >=3.1.46",
@@ -229,6 +233,7 @@ strict_xfail = true
filterwarnings = [ filterwarnings = [
"error", "error",
] ]
timeout = "20"
[tool.coverage.run] [tool.coverage.run]
parallel = true parallel = true
@@ -240,7 +245,6 @@ source = [
] ]
relative_files = true relative_files = true
context = '${CONTEXT}' context = '${CONTEXT}'
dynamic_context = "test_function"
omit = [ omit = [
"tests/benchmarks/*", "tests/benchmarks/*",
"docs_src/response_model/tutorial003_04_py39.py", "docs_src/response_model/tutorial003_04_py39.py",
@@ -317,6 +321,9 @@ ignore = [
"docs_src/security/tutorial005_py310.py" = ["B904"] "docs_src/security/tutorial005_py310.py" = ["B904"]
"docs_src/security/tutorial005_py39.py" = ["B904"] "docs_src/security/tutorial005_py39.py" = ["B904"]
"docs_src/json_base64_bytes/tutorial001_py310.py" = ["UP012"] "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"]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]
known-third-party = ["fastapi", "pydantic", "starlette"] known-third-party = ["fastapi", "pydantic", "starlette"]

View File

@@ -3,5 +3,4 @@
set -e set -e
set -x set -x
bash scripts/test.sh ${@} bash scripts/test-cov.sh --cov-report=term-missing --cov-report=html ${@}
bash scripts/coverage.sh

6
scripts/test-cov.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -e
set -x
bash scripts/test.sh --cov --cov-context=test ${@}

View File

@@ -4,4 +4,4 @@ set -e
set -x set -x
export PYTHONPATH=./docs_src export PYTHONPATH=./docs_src
coverage run -m pytest tests scripts/tests/ ${@} pytest -n auto --dist loadgroup tests scripts/tests/ ${@}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,431 +0,0 @@
from typing import Annotated, Any
from unittest.mock import Mock, patch
import pytest
from dirty_equals import IsList, IsOneOf
from fastapi import Cookie, FastAPI
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from pydantic import BaseModel, BeforeValidator, field_validator
app = FastAPI()
def convert(v: Any) -> Any:
return v
# =====================================================================================
# Nullable required
@app.get("/nullable-required")
async def read_nullable_required(
int_val: Annotated[
int | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
],
str_val: Annotated[
str | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"fields_set": None,
}
class ModelNullableRequired(BaseModel):
int_val: int | None
str_val: str | None
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-required")
async def read_model_nullable_required(
params: Annotated[ModelNullableRequired, Cookie()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": True,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
},
"name": "int_val",
"in": "cookie",
},
{
"required": True,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
},
"name": "str_val",
"in": "cookie",
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
{
"type": "missing",
"loc": ["cookie", "int_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
{
"type": "missing",
"loc": ["cookie", "str_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test"},
{"int_val": "0", "str_val": ""},
],
)
def test_nullable_required_pass_value(path: str, values: dict[str, str]):
client = TestClient(app)
client.cookies.set("int_val", values["int_val"])
client.cookies.set("str_val", values["str_val"])
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)),
}
# =====================================================================================
# Nullable with default=None
@app.get("/nullable-non-required")
async def read_nullable_non_required(
int_val: Annotated[
int | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
] = None,
str_val: Annotated[
str | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"int_val": int_val,
"str_val": str_val,
"fields_set": None,
}
class ModelNullableNonRequired(BaseModel):
int_val: int | None = None
str_val: str | None = None
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-non-required")
async def read_model_nullable_non_required(
params: Annotated[ModelNullableNonRequired, Cookie()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "int_val",
"in": "cookie",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "str_val",
"in": "cookie",
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200
assert response.json() == {
"int_val": None,
"str_val": None,
"fields_set": IsOneOf(None, []),
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test"},
{"int_val": "0", "str_val": ""},
],
)
def test_nullable_non_required_pass_value(path: str, values: dict[str, str]):
client = TestClient(app)
client.cookies.set("int_val", values["int_val"])
client.cookies.set("str_val", values["str_val"])
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)),
}
# =====================================================================================
# Nullable with not-None default
@app.get("/nullable-with-non-null-default")
async def read_nullable_with_non_null_default(
*,
int_val: Annotated[
int | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
] = -1,
str_val: Annotated[
str | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
] = "default",
):
return {
"int_val": int_val,
"str_val": str_val,
"fields_set": None,
}
class ModelNullableWithNonNullDefault(BaseModel):
int_val: int | None = -1
str_val: str | None = "default"
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-with-non-null-default")
async def read_model_nullable_with_non_null_default(
params: Annotated[ModelNullableWithNonNullDefault, Cookie()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
"default": -1,
},
"name": "int_val",
"in": "cookie",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": "default",
},
"name": "str_val",
"in": "cookie",
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.xfail(
reason="Missing parameters are pre-populated with default values before validation"
)
def test_nullable_with_non_null_default_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200 # pragma: no cover
assert response.json() == { # pragma: no cover
"int_val": -1,
"str_val": "default",
"fields_set": IsOneOf(None, []),
}
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test"},
{"int_val": "0", "str_val": ""},
],
)
def test_nullable_with_non_null_default_pass_value(path: str, values: dict[str, str]):
client = TestClient(app)
client.cookies.set("int_val", values["int_val"])
client.cookies.set("str_val", values["str_val"])
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)),
}

View File

@@ -1,525 +0,0 @@
from typing import Annotated, Any
from unittest.mock import Mock, patch
import pytest
from dirty_equals import IsOneOf
from fastapi import FastAPI, File, UploadFile
from fastapi.testclient import TestClient
from inline_snapshot import Is, snapshot
from pydantic import BeforeValidator
from starlette.datastructures import UploadFile as StarletteUploadFile
from .utils import get_body_model_name
app = FastAPI()
def convert(v: Any) -> Any:
return v
# =====================================================================================
# Nullable required
@app.post("/nullable-required-bytes")
async def read_nullable_required_bytes(
file: Annotated[
bytes | None,
File(),
BeforeValidator(lambda v: convert(v)),
],
files: Annotated[
list[bytes] | None,
File(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"file": len(file) if file is not None else None,
"files": [len(f) for f in files] if files is not None else None,
}
@app.post("/nullable-required-uploadfile")
async def read_nullable_required_uploadfile(
file: Annotated[
UploadFile | None,
File(),
BeforeValidator(lambda v: convert(v)),
],
files: Annotated[
list[UploadFile] | None,
File(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"file": file.size if file is not None else None,
"files": [f.size for f in files] if files is not None else None,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-required-bytes",
"/nullable-required-uploadfile",
],
)
def test_nullable_required_schema(path: str):
openapi = app.openapi()
body_model_name = get_body_model_name(openapi, path)
assert openapi["components"]["schemas"][body_model_name] == snapshot(
{
"properties": {
"file": {
"title": "File",
"anyOf": [
{
"type": "string",
"contentMediaType": "application/octet-stream",
},
{"type": "null"},
],
},
"files": {
"title": "Files",
"anyOf": [
{
"type": "array",
"items": {
"type": "string",
"contentMediaType": "application/octet-stream",
},
},
{"type": "null"},
],
},
},
"required": ["file", "files"],
"title": Is(body_model_name),
"type": "object",
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required-bytes",
"/nullable-required-uploadfile",
],
)
def test_nullable_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
{
"type": "missing",
"loc": ["body", "file"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
{
"type": "missing",
"loc": ["body", "files"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required-bytes",
"/nullable-required-uploadfile",
],
)
def test_nullable_required_pass_empty_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[("file", b""), ("files", b""), ("files", b"")],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
call_args = [call_args_item.args for call_args_item in mock_convert.call_args_list]
file_call_arg_1 = call_args[0][0]
files_call_arg_1 = call_args[1][0]
assert (
(file_call_arg_1 == b"") # file as bytes
or isinstance(file_call_arg_1, StarletteUploadFile) # file as UploadFile
)
assert (
(files_call_arg_1 == [b"", b""]) # files as bytes
or all( # files as UploadFile
isinstance(f, StarletteUploadFile) for f in files_call_arg_1
)
)
assert response.status_code == 200, response.text
assert response.json() == {
"file": 0,
"files": [0, 0],
}
@pytest.mark.parametrize(
"path",
[
"/nullable-required-bytes",
"/nullable-required-uploadfile",
],
)
def test_nullable_required_pass_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[
("file", b"test 1"),
("files", b"test 2"),
("files", b"test 3"),
],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {"file": 6, "files": [6, 6]}
# =====================================================================================
# Nullable with default=None
@app.post("/nullable-non-required-bytes")
async def read_nullable_non_required_bytes(
file: Annotated[
bytes | None,
File(),
BeforeValidator(lambda v: convert(v)),
] = None,
files: Annotated[
list[bytes] | None,
File(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"file": len(file) if file is not None else None,
"files": [len(f) for f in files] if files is not None else None,
}
@app.post("/nullable-non-required-uploadfile")
async def read_nullable_non_required_uploadfile(
file: Annotated[
UploadFile | None,
File(),
BeforeValidator(lambda v: convert(v)),
] = None,
files: Annotated[
list[UploadFile] | None,
File(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"file": file.size if file is not None else None,
"files": [f.size for f in files] if files is not None else None,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required-bytes",
"/nullable-non-required-uploadfile",
],
)
def test_nullable_non_required_schema(path: str):
openapi = app.openapi()
body_model_name = get_body_model_name(openapi, path)
assert openapi["components"]["schemas"][body_model_name] == snapshot(
{
"properties": {
"file": {
"title": "File",
"anyOf": [
{
"type": "string",
"contentMediaType": "application/octet-stream",
},
{"type": "null"},
],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"files": {
"title": "Files",
"anyOf": [
{
"type": "array",
"items": {
"type": "string",
"contentMediaType": "application/octet-stream",
},
},
{"type": "null"},
],
# "default": None, # `None` values are omitted in OpenAPI schema
},
},
"title": Is(body_model_name),
"type": "object",
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required-bytes",
"/nullable-non-required-uploadfile",
],
)
def test_nullable_non_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200
assert response.json() == {
"file": None,
"files": None,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required-bytes",
"/nullable-non-required-uploadfile",
],
)
def test_nullable_non_required_pass_empty_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[("file", b""), ("files", b""), ("files", b"")],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
call_args = [call_args_item.args for call_args_item in mock_convert.call_args_list]
file_call_arg_1 = call_args[0][0]
files_call_arg_1 = call_args[1][0]
assert (
(file_call_arg_1 == b"") # file as bytes
or isinstance(file_call_arg_1, StarletteUploadFile) # file as UploadFile
)
assert (
(files_call_arg_1 == [b"", b""]) # files as bytes
or all( # files as UploadFile
isinstance(f, StarletteUploadFile) for f in files_call_arg_1
)
)
assert response.status_code == 200, response.text
assert response.json() == {"file": 0, "files": [0, 0]}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required-bytes",
"/nullable-non-required-uploadfile",
],
)
def test_nullable_non_required_pass_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[("file", b"test 1"), ("files", b"test 2"), ("files", b"test 3")],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {"file": 6, "files": [6, 6]}
# =====================================================================================
# Nullable with not-None default
@app.post("/nullable-with-non-null-default-bytes")
async def read_nullable_with_non_null_default_bytes(
*,
file: Annotated[
bytes | None,
File(),
BeforeValidator(lambda v: convert(v)),
] = b"default",
files: Annotated[
list[bytes] | None,
File(default_factory=lambda: [b"default"]),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"file": len(file) if file is not None else None,
"files": [len(f) for f in files] if files is not None else None,
}
# Note: It seems to be not possible to create endpoint with UploadFile and non-None default
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default-bytes",
],
)
def test_nullable_with_non_null_default_schema(path: str):
openapi = app.openapi()
body_model_name = get_body_model_name(openapi, path)
assert openapi["components"]["schemas"][body_model_name] == snapshot(
{
"properties": {
"file": {
"title": "File",
"anyOf": [
{
"type": "string",
"contentMediaType": "application/octet-stream",
},
{"type": "null"},
],
"default": "default", # <= Default value here looks strange to me
},
"files": {
"title": "Files",
"anyOf": [
{
"type": "array",
"items": {
"type": "string",
"contentMediaType": "application/octet-stream",
},
},
{"type": "null"},
],
},
},
"title": Is(body_model_name),
"type": "object",
}
)
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-with-non-null-default-bytes",
marks=pytest.mark.xfail(
reason="AttributeError: 'bytes' object has no attribute 'read'",
),
),
],
)
def test_nullable_with_non_null_default_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, ( # pragma: no cover
"Validator should not be called if the value is missing"
)
assert response.status_code == 200 # pragma: no cover
assert response.json() == {"file": None, "files": None} # pragma: no cover
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default-bytes",
],
)
def test_nullable_with_non_null_default_pass_empty_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[("file", b""), ("files", b""), ("files", b"")],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
call_args = [call_args_item.args for call_args_item in mock_convert.call_args_list]
file_call_arg_1 = call_args[0][0]
files_call_arg_1 = call_args[1][0]
assert (
(file_call_arg_1 == b"") # file as bytes
or isinstance(file_call_arg_1, StarletteUploadFile) # file as UploadFile
)
assert (
(files_call_arg_1 == [b"", b""]) # files as bytes
or all( # files as UploadFile
isinstance(f, StarletteUploadFile) for f in files_call_arg_1
)
)
assert response.status_code == 200, response.text
assert response.json() == {"file": 0, "files": [0, 0]}
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default-bytes",
],
)
def test_nullable_with_non_null_default_pass_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[("file", b"test 1"), ("files", b"test 2"), ("files", b"test 3")],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {"file": 6, "files": [6, 6]}

View File

@@ -1,746 +0,0 @@
from typing import Annotated, Any
from unittest.mock import Mock, call, patch
import pytest
from dirty_equals import IsList, IsOneOf, IsPartialDict
from fastapi import FastAPI, Form
from fastapi.testclient import TestClient
from inline_snapshot import Is, snapshot
from pydantic import BaseModel, BeforeValidator, field_validator
from .utils import get_body_model_name
app = FastAPI()
def convert(v: Any) -> Any:
return v
# =====================================================================================
# Nullable required
@app.post("/nullable-required")
async def read_nullable_required(
int_val: Annotated[
int | None,
Form(),
BeforeValidator(lambda v: convert(v)),
],
str_val: Annotated[
str | None,
Form(),
BeforeValidator(lambda v: convert(v)),
],
list_val: Annotated[
list[int] | None,
Form(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableRequired(BaseModel):
int_val: int | None
str_val: str | None
list_val: list[int] | None
@field_validator("*", mode="before")
def convert_fields(cls, v):
return convert(v)
@app.post("/model-nullable-required")
async def read_model_nullable_required(
params: Annotated[ModelNullableRequired, Form()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_schema(path: str):
openapi = app.openapi()
body_model_name = get_body_model_name(openapi, path)
assert openapi["components"]["schemas"][body_model_name] == snapshot(
{
"properties": {
"int_val": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
},
"str_val": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
},
"list_val": {
"title": "List Val",
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
},
},
"required": ["int_val", "str_val", "list_val"],
"title": Is(body_model_name),
"type": "object",
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
{
"type": "missing",
"loc": ["body", "int_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
{
"type": "missing",
"loc": ["body", "str_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
{
"type": "missing",
"loc": ["body", "list_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
]
}
)
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-required",
marks=pytest.mark.xfail(
reason="Empty str is replaced with None even for required parameters"
),
),
"/model-nullable-required",
],
)
def test_nullable_required_pass_empty_str_to_str_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "0", # Empty string would cause validation error (see below)
"str_val": "",
"list_val": "0", # Empty string would cause validation error (see below)
},
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert mock_convert.call_args_list == [
call("0"), # int_val
call(""), # str_val
call(["0"]), # list_val
]
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 0,
"str_val": "",
"list_val": [0],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-required",
marks=pytest.mark.xfail(
reason="Empty str is replaced with None even for required parameters"
),
),
"/model-nullable-required",
],
)
def test_nullable_required_pass_empty_str_to_int_val_and_list_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "",
"str_val": "",
"list_val": "",
},
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert mock_convert.call_args_list == [
call(""), # int_val
call(""), # str_val
call([""]), # list_val
]
assert response.status_code == 422, response.text
assert response.json() == snapshot(
{
"detail": [
{
"input": "",
"loc": ["body", "int_val"],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"type": "int_parsing",
},
{
"input": "",
"loc": ["body", "list_val", 0],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"type": "int_parsing",
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_pass_value(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path, data={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "test",
"list_val": [1, 2],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
# =====================================================================================
# Nullable with default=None
@app.post("/nullable-non-required")
async def read_nullable_non_required(
int_val: Annotated[
int | None,
Form(),
BeforeValidator(lambda v: convert(v)),
] = None,
str_val: Annotated[
str | None,
Form(),
BeforeValidator(lambda v: convert(v)),
] = None,
list_val: Annotated[
list[int] | None,
Form(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableNonRequired(BaseModel):
int_val: int | None = None
str_val: str | None = None
list_val: list[int] | None = None
@field_validator("*", mode="before")
def convert_fields(cls, v):
return convert(v)
@app.post("/model-nullable-non-required")
async def read_model_nullable_non_required(
params: Annotated[ModelNullableNonRequired, Form()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_schema(path: str):
openapi = app.openapi()
body_model_name = get_body_model_name(openapi, path)
assert openapi["components"]["schemas"][body_model_name] == snapshot(
{
"properties": {
"int_val": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"str_val": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"list_val": {
"title": "List Val",
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
# "default": None, # `None` values are omitted in OpenAPI schema
},
},
"title": Is(body_model_name),
"type": "object",
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200
assert response.json() == {
"int_val": None,
"str_val": None,
"list_val": None,
"fields_set": IsOneOf(None, []),
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
pytest.param(
"/model-nullable-non-required",
marks=pytest.mark.xfail(
reason="Empty strings are not replaced with None for parameters declared as model"
),
),
],
)
def test_nullable_non_required_pass_empty_str_to_str_val_and_int_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "",
"str_val": "",
"list_val": "0", # Empty string would cause validation error (see below)
},
)
assert mock_convert.call_count == 1, "Validator should be called for list_val only"
assert mock_convert.call_args_list == [
call(["0"]), # list_val
]
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": None,
"str_val": None,
"list_val": [0],
"fields_set": IsOneOf(None, IsList("list_val", check_order=False)),
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
pytest.param(
"/model-nullable-non-required",
marks=pytest.mark.xfail(
reason="Empty strings are not replaced with None for parameters declared as model"
),
),
],
)
def test_nullable_non_required_pass_empty_str_to_all(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "",
"str_val": "",
"list_val": "",
},
)
assert mock_convert.call_count == 1, "Validator should be called for list_val only"
assert mock_convert.call_args_list == [
call([""]), # list_val
]
assert response.status_code == 422, response.text
assert response.json() == snapshot(
{
"detail": [
{
"input": "",
"loc": ["body", "list_val", 0],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"type": "int_parsing",
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_pass_value(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path, data={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "test",
"list_val": [1, 2],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
# =====================================================================================
# Nullable with not-None default
@app.post("/nullable-with-non-null-default")
async def read_nullable_with_non_null_default(
*,
int_val: Annotated[
int | None,
Form(),
BeforeValidator(lambda v: convert(v)),
] = -1,
str_val: Annotated[
str | None,
Form(),
BeforeValidator(lambda v: convert(v)),
] = "default",
list_val: Annotated[
list[int] | None,
Form(default_factory=lambda: [0]),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableWithNonNullDefault(BaseModel):
int_val: int | None = -1
str_val: str | None = "default"
list_val: list[int] | None = [0]
@field_validator("*", mode="before")
def convert_fields(cls, v):
return convert(v)
@app.post("/model-nullable-with-non-null-default")
async def read_model_nullable_with_non_null_default(
params: Annotated[ModelNullableWithNonNullDefault, Form()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_schema(path: str):
openapi = app.openapi()
body_model_name = get_body_model_name(openapi, path)
body_model = openapi["components"]["schemas"][body_model_name]
assert body_model == snapshot(
{
"properties": {
"int_val": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
"default": -1,
},
"str_val": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": "default",
},
"list_val": IsPartialDict(
{
"title": "List Val",
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
}
),
},
"title": Is(body_model_name),
"type": "object",
}
)
if path == "/model-nullable-with-non-null-default":
# Check default value for list_val param for model-based parameters only.
# default_factory is not reflected in OpenAPI schema
assert body_model["properties"]["list_val"]["default"] == [0]
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.xfail(
reason="Missing parameters are pre-populated with default values before validation"
)
def test_nullable_with_non_null_default_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200 # pragma: no cover
assert response.json() == { # pragma: no cover
"int_val": -1,
"str_val": "default",
"list_val": [0],
"fields_set": IsOneOf(None, []),
}
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-with-non-null-default",
marks=pytest.mark.xfail(
reason="Empty strings are replaced with default values before validation"
),
),
pytest.param(
"/model-nullable-with-non-null-default",
marks=pytest.mark.xfail(
reason="Empty strings are not replaced with None for parameters declared as model"
),
),
],
)
def test_nullable_with_non_null_default_pass_empty_str_to_str_val_and_int_val(
path: str,
):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "",
"str_val": "",
"list_val": "0", # Empty string would cause validation error (see below)
},
)
assert mock_convert.call_count == 1, "Validator should be called for list_val only"
assert mock_convert.call_args_list == [ # pragma: no cover
call(["0"]), # list_val
]
assert response.status_code == 200, response.text # pragma: no cover
assert response.json() == { # pragma: no cover
"int_val": -1,
"str_val": "default",
"list_val": [0],
"fields_set": IsOneOf(None, IsList("list_val", check_order=False)),
}
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-with-non-null-default",
marks=pytest.mark.xfail(
reason="Empty strings are replaced with default values before validation"
),
),
pytest.param(
"/model-nullable-with-non-null-default",
marks=pytest.mark.xfail(
reason="Empty strings are not replaced with None for parameters declared as model"
),
),
],
)
def test_nullable_with_non_null_default_pass_empty_str_to_all(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "",
"str_val": "",
"list_val": "",
},
)
assert mock_convert.call_count == 1, "Validator should be called for list_val only"
assert mock_convert.call_args_list == [ # pragma: no cover
call([""]), # list_val
]
assert response.status_code == 422, response.text # pragma: no cover
assert response.json() == snapshot( # pragma: no cover
{
"detail": [
{
"input": "",
"loc": ["body", "list_val", 0],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"type": "int_parsing",
},
]
}
)
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_pass_value(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path, data={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "test",
"list_val": [1, 2],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}

View File

@@ -1,634 +0,0 @@
from typing import Annotated, Any
from unittest.mock import Mock, patch
import pytest
from dirty_equals import AnyThing, IsList, IsOneOf, IsPartialDict
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from pydantic import BaseModel, BeforeValidator, field_validator
app = FastAPI()
def convert(v: Any) -> Any:
return v
# =====================================================================================
# Nullable required
@app.get("/nullable-required")
async def read_nullable_required(
int_val: Annotated[
int | None,
Header(),
BeforeValidator(lambda v: convert(v)),
],
str_val: Annotated[
str | None,
Header(),
BeforeValidator(lambda v: convert(v)),
],
list_val: Annotated[
list[int] | None,
Header(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableRequired(BaseModel):
int_val: int | None
str_val: str | None
list_val: list[int] | None
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-required")
async def read_model_nullable_required(
params: Annotated[ModelNullableRequired, Header()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-required",
marks=pytest.mark.xfail(
reason="Title contains hyphens for single Header parameters"
),
),
"/model-nullable-required",
],
)
def test_nullable_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": True,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
},
"name": "int-val",
"in": "header",
},
{
"required": True,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
},
"name": "str-val",
"in": "header",
},
{
"required": True,
"schema": {
"title": "List Val",
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
},
"name": "list-val",
"in": "header",
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
pytest.param(
"/model-nullable-required",
marks=pytest.mark.xfail(
reason=(
"For parameters declared as model, underscores are not replaced "
"with hyphens in error loc"
)
),
),
],
)
def test_nullable_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
{
"type": "missing",
"loc": ["header", "int-val"],
"msg": "Field required",
"input": AnyThing(),
},
{
"type": "missing",
"loc": ["header", "str-val"],
"msg": "Field required",
"input": AnyThing(),
},
{
"type": "missing",
"loc": ["header", "list-val"],
"msg": "Field required",
"input": AnyThing(),
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_pass_value(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", "test"),
("list-val", "1"),
("list-val", "2"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "test",
"list_val": [1, 2],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_pass_empty_str_to_str_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", ""),
("list-val", "1"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "",
"list_val": [1],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
# =====================================================================================
# Nullable with default=None
@app.get("/nullable-non-required")
async def read_nullable_non_required(
int_val: Annotated[
int | None,
Header(),
BeforeValidator(lambda v: convert(v)),
] = None,
str_val: Annotated[
str | None,
Header(),
BeforeValidator(lambda v: convert(v)),
] = None,
list_val: Annotated[
list[int] | None,
Header(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableNonRequired(BaseModel):
int_val: int | None = None
str_val: str | None = None
list_val: list[int] | None = None
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-non-required")
async def read_model_nullable_non_required(
params: Annotated[ModelNullableNonRequired, Header()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-non-required",
marks=pytest.mark.xfail(
reason="Title contains hyphens for single Header parameters"
),
),
"/model-nullable-non-required",
],
)
def test_nullable_non_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "int-val",
"in": "header",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "str-val",
"in": "header",
},
{
"required": False,
"schema": {
"title": "List Val",
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "list-val",
"in": "header",
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200
assert response.json() == {
"int_val": None,
"str_val": None,
"list_val": None,
"fields_set": IsOneOf(None, []),
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_pass_value(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", "test"),
("list-val", "1"),
("list-val", "2"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "test",
"list_val": [1, 2],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_pass_empty_str_to_str_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", ""),
("list-val", "1"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "",
"list_val": [1],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
# =====================================================================================
# Nullable with not-None default
@app.get("/nullable-with-non-null-default")
async def read_nullable_with_non_null_default(
*,
int_val: Annotated[
int | None,
Header(),
BeforeValidator(lambda v: convert(v)),
] = -1,
str_val: Annotated[
str | None,
Header(),
BeforeValidator(lambda v: convert(v)),
] = "default",
list_val: Annotated[
list[int] | None,
Header(default_factory=lambda: [0]),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableWithNonNullDefault(BaseModel):
int_val: int | None = -1
str_val: str | None = "default"
list_val: list[int] | None = [0]
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-with-non-null-default")
async def read_model_nullable_with_non_null_default(
params: Annotated[ModelNullableWithNonNullDefault, Header()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-with-non-null-default",
marks=pytest.mark.xfail(
reason="Title contains hyphens for single Header parameters"
),
),
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_schema(path: str):
parameters = app.openapi()["paths"][path]["get"]["parameters"]
assert parameters == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
"default": -1,
},
"name": "int-val",
"in": "header",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": "default",
},
"name": "str-val",
"in": "header",
},
{
"required": False,
"schema": IsPartialDict(
{
"title": "List Val",
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
}
),
"name": "list-val",
"in": "header",
},
]
)
if path == "/model-nullable-with-non-null-default":
# Check default value for list_val param for model-based parameters only.
# default_factory is not reflected in OpenAPI schema
assert parameters[2]["schema"]["default"] == [0]
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.xfail(
reason="Missing parameters are pre-populated with default values before validation"
)
def test_nullable_with_non_null_default_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200 # pragma: no cover
assert response.json() == { # pragma: no cover
"int_val": -1,
"str_val": "default",
"list_val": [0],
"fields_set": IsOneOf(None, []),
}
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_pass_value(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", "test"),
("list-val", "1"),
("list-val", "2"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "test",
"list_val": [1, 2],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_pass_empty_str_to_str_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", ""),
("list-val", "1"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "",
"list_val": [1],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}

View File

@@ -1,2 +0,0 @@
# Not appllicable for Path parameters
# Path parameters cannot have default values or be nullable

View File

@@ -1,507 +0,0 @@
from typing import Annotated, Any
from unittest.mock import Mock, patch
import pytest
from dirty_equals import IsList, IsOneOf, IsPartialDict
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from pydantic import BaseModel, BeforeValidator, field_validator
app = FastAPI()
def convert(v: Any) -> Any:
return v
# =====================================================================================
# Nullable required
@app.get("/nullable-required")
async def read_nullable_required(
int_val: Annotated[
int | None,
BeforeValidator(lambda v: convert(v)),
],
str_val: Annotated[
str | None,
BeforeValidator(lambda v: convert(v)),
],
list_val: Annotated[
list[int] | None,
Query(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableRequired(BaseModel):
int_val: int | None
str_val: str | None
list_val: list[int] | None
@field_validator("*", mode="before")
@classmethod
def convert_all(cls, v: Any) -> Any:
return convert(v)
@app.get("/model-nullable-required")
async def read_model_nullable_required(
params: Annotated[ModelNullableRequired, Query()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": True,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
},
"name": "int_val",
"in": "query",
},
{
"required": True,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
},
"name": "str_val",
"in": "query",
},
{
"in": "query",
"name": "list_val",
"required": True,
"schema": {
"anyOf": [
{"items": {"type": "integer"}, "type": "array"},
{"type": "null"},
],
"title": "List Val",
},
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
{
"type": "missing",
"loc": ["query", "int_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
{
"type": "missing",
"loc": ["query", "str_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
{
"type": "missing",
"loc": ["query", "list_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test", "list_val": ["1", "2"]},
{"int_val": "0", "str_val": "", "list_val": ["0"]},
],
)
def test_nullable_required_pass_value(path: str, values: dict[str, Any]):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path, params=values)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"list_val": [int(v) for v in values["list_val"]],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
# =====================================================================================
# Nullable with default=None
@app.get("/nullable-non-required")
async def read_nullable_non_required(
int_val: Annotated[
int | None,
BeforeValidator(lambda v: convert(v)),
] = None,
str_val: Annotated[
str | None,
BeforeValidator(lambda v: convert(v)),
] = None,
list_val: Annotated[
list[int] | None,
Query(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableNonRequired(BaseModel):
int_val: int | None = None
str_val: str | None = None
list_val: list[int] | None = None
@field_validator("*", mode="before")
@classmethod
def convert_all(cls, v: Any) -> Any:
return convert(v)
@app.get("/model-nullable-non-required")
async def read_model_nullable_non_required(
params: Annotated[ModelNullableNonRequired, Query()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "int_val",
"in": "query",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "str_val",
"in": "query",
},
{
"in": "query",
"name": "list_val",
"required": False,
"schema": {
"anyOf": [
{"items": {"type": "integer"}, "type": "array"},
{"type": "null"},
],
"title": "List Val",
# "default": None, # `None` values are omitted in OpenAPI schema
},
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200
assert response.json() == {
"int_val": None,
"str_val": None,
"list_val": None,
"fields_set": IsOneOf(None, []),
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test", "list_val": ["1", "2"]},
{"int_val": "0", "str_val": "", "list_val": ["0"]},
],
)
def test_nullable_non_required_pass_value(path: str, values: dict[str, Any]):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path, params=values)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"list_val": [int(v) for v in values["list_val"]],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
# =====================================================================================
# Nullable with not-None default
@app.get("/nullable-with-non-null-default")
async def read_nullable_with_non_null_default(
*,
int_val: Annotated[
int | None,
BeforeValidator(lambda v: convert(v)),
] = -1,
str_val: Annotated[
str | None,
BeforeValidator(lambda v: convert(v)),
] = "default",
list_val: Annotated[
list[int] | None,
Query(default_factory=lambda: [0]),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableWithNonNullDefault(BaseModel):
int_val: int | None = -1
str_val: str | None = "default"
list_val: list[int] | None = [0]
@field_validator("*", mode="before")
@classmethod
def convert_all(cls, v: Any) -> Any:
return convert(v)
@app.get("/model-nullable-with-non-null-default")
async def read_model_nullable_with_non_null_default(
params: Annotated[ModelNullableWithNonNullDefault, Query()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_schema(path: str):
parameters = app.openapi()["paths"][path]["get"]["parameters"]
assert parameters == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
"default": -1,
},
"name": "int_val",
"in": "query",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": "default",
},
"name": "str_val",
"in": "query",
},
{
"in": "query",
"name": "list_val",
"required": False,
"schema": IsPartialDict(
{
"anyOf": [
{"items": {"type": "integer"}, "type": "array"},
{"type": "null"},
],
"title": "List Val",
}
),
},
]
)
if path == "/model-nullable-with-non-null-default":
# Check default value for list_val param for model-based parameters only.
# default_factory is not reflected in OpenAPI schema
assert parameters[2]["schema"]["default"] == [0]
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.xfail(
reason="Missing parameters are pre-populated with default values before validation"
)
def test_nullable_with_non_null_default_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200 # pragma: no cover
assert response.json() == { # pragma: no cover
"int_val": -1,
"str_val": "default",
"list_val": [0],
"fields_set": IsOneOf(None, []),
}
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test", "list_val": ["1", "2"]},
{"int_val": "0", "str_val": "", "list_val": ["0"]},
],
)
def test_nullable_with_non_null_default_pass_value(path: str, values: dict[str, Any]):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path, params=values)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"list_val": [int(v) for v in values["list_val"]],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}

View 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"}]

View 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"}
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

View 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")

View File

@@ -6,7 +6,7 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from inline_snapshot import snapshot from inline_snapshot import snapshot
from tests.utils import needs_py310 from tests.utils import needs_py310, workdir_lock
@pytest.fixture( @pytest.fixture(
@@ -29,6 +29,7 @@ def test_path_operation(client: TestClient):
assert response.json() == {"id": "foo", "value": "there goes my hero"} assert response.json() == {"id": "foo", "value": "there goes my hero"}
@workdir_lock
def test_path_operation_img(client: TestClient): def test_path_operation_img(client: TestClient):
shutil.copy("./docs/en/docs/img/favicon.png", "./image.png") shutil.copy("./docs/en/docs/img/favicon.png", "./image.png")
response = client.get("/items/foo?img=1") response = client.get("/items/foo?img=1")

View File

@@ -6,7 +6,7 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from inline_snapshot import snapshot from inline_snapshot import snapshot
from tests.utils import needs_py310 from tests.utils import needs_py310, workdir_lock
@pytest.fixture( @pytest.fixture(
@@ -29,6 +29,7 @@ def test_path_operation(client: TestClient):
assert response.json() == {"id": "foo", "value": "there goes my hero"} assert response.json() == {"id": "foo", "value": "there goes my hero"}
@workdir_lock
def test_path_operation_img(client: TestClient): def test_path_operation_img(client: TestClient):
shutil.copy("./docs/en/docs/img/favicon.png", "./image.png") shutil.copy("./docs/en/docs/img/favicon.png", "./image.png")
response = client.get("/items/foo?img=1") response = client.get("/items/foo?img=1")

View File

@@ -4,10 +4,12 @@ from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from docs_src.background_tasks.tutorial001_py310 import app from docs_src.background_tasks.tutorial001_py310 import app
from tests.utils import workdir_lock
client = TestClient(app) client = TestClient(app)
@workdir_lock
def test(): def test():
log = Path("log.txt") log = Path("log.txt")
if log.is_file(): if log.is_file():

View File

@@ -5,7 +5,7 @@ from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from ...utils import needs_py310 from tests.utils import needs_py310, workdir_lock
@pytest.fixture( @pytest.fixture(
@@ -22,6 +22,7 @@ def get_client(request: pytest.FixtureRequest):
return client return client
@workdir_lock
def test(client: TestClient): def test(client: TestClient):
log = Path("log.txt") log = Path("log.txt")
if log.is_file(): if log.is_file():

View File

@@ -4,6 +4,8 @@ from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.utils import workdir_lock
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def client(): def client():
@@ -17,6 +19,7 @@ def client():
static_dir.rmdir() static_dir.rmdir()
@workdir_lock
def test_swagger_ui_html(client: TestClient): def test_swagger_ui_html(client: TestClient):
response = client.get("/docs") response = client.get("/docs")
assert response.status_code == 200, response.text 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 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): def test_swagger_ui_oauth2_redirect_html(client: TestClient):
response = client.get("/docs/oauth2-redirect") response = client.get("/docs/oauth2-redirect")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
assert "window.opener.swaggerUIRedirectOauth2" in response.text assert "window.opener.swaggerUIRedirectOauth2" in response.text
@workdir_lock
def test_redoc_html(client: TestClient): def test_redoc_html(client: TestClient):
response = client.get("/redoc") response = client.get("/redoc")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
assert "https://unpkg.com/redoc@2/bundles/redoc.standalone.js" in response.text assert "https://unpkg.com/redoc@2/bundles/redoc.standalone.js" in response.text
@workdir_lock
def test_api(client: TestClient): def test_api(client: TestClient):
response = client.get("/users/john") response = client.get("/users/john")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text

View File

@@ -4,6 +4,8 @@ from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.utils import workdir_lock
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def client(): def client():
@@ -17,6 +19,7 @@ def client():
static_dir.rmdir() static_dir.rmdir()
@workdir_lock
def test_swagger_ui_html(client: TestClient): def test_swagger_ui_html(client: TestClient):
response = client.get("/docs") response = client.get("/docs")
assert response.status_code == 200, response.text 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 assert "/static/swagger-ui.css" in response.text
@workdir_lock
def test_swagger_ui_oauth2_redirect_html(client: TestClient): def test_swagger_ui_oauth2_redirect_html(client: TestClient):
response = client.get("/docs/oauth2-redirect") response = client.get("/docs/oauth2-redirect")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
assert "window.opener.swaggerUIRedirectOauth2" in response.text assert "window.opener.swaggerUIRedirectOauth2" in response.text
@workdir_lock
def test_redoc_html(client: TestClient): def test_redoc_html(client: TestClient):
response = client.get("/redoc") response = client.get("/redoc")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
assert "/static/redoc.standalone.js" in response.text assert "/static/redoc.standalone.js" in response.text
@workdir_lock
def test_api(client: TestClient): def test_api(client: TestClient):
response = client.get("/users/john") response = client.get("/users/john")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text

View File

@@ -3,6 +3,8 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from inline_snapshot import snapshot from inline_snapshot import snapshot
from tests.utils import workdir_lock
@pytest.fixture(name="app", scope="module") @pytest.fixture(name="app", scope="module")
def get_app(): def get_app():
@@ -11,6 +13,7 @@ def get_app():
yield app yield app
@workdir_lock
def test_events(app: FastAPI): def test_events(app: FastAPI):
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/items/") response = client.get("/items/")
@@ -20,6 +23,7 @@ def test_events(app: FastAPI):
assert "Application shutdown" in log.read() assert "Application shutdown" in log.read()
@workdir_lock
def test_openapi_schema(app: FastAPI): def test_openapi_schema(app: FastAPI):
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/openapi.json") response = client.get("/openapi.json")

View File

@@ -22,7 +22,7 @@ def get_mod_name(request: pytest.FixtureRequest):
@pytest.fixture(name="client") @pytest.fixture(name="client")
def get_test_client(mod_name: str, monkeypatch: MonkeyPatch) -> TestClient: def get_test_client(mod_name: str, monkeypatch: MonkeyPatch) -> TestClient:
if mod_name in sys.modules: 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") monkeypatch.setenv("ADMIN_EMAIL", "admin@example.com")
main_mod = importlib.import_module(mod_name) main_mod = importlib.import_module(mod_name)
return TestClient(main_mod.app) return TestClient(main_mod.app)

View File

@@ -5,6 +5,8 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from inline_snapshot import snapshot from inline_snapshot import snapshot
from tests.utils import workdir_lock
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def client(): def client():
@@ -20,17 +22,20 @@ def client():
static_dir.rmdir() static_dir.rmdir()
@workdir_lock
def test_static_files(client: TestClient): def test_static_files(client: TestClient):
response = client.get("/static/sample.txt") response = client.get("/static/sample.txt")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
assert response.text == "This is a sample static file." assert response.text == "This is a sample static file."
@workdir_lock
def test_static_files_not_found(client: TestClient): def test_static_files_not_found(client: TestClient):
response = client.get("/static/non_existent_file.txt") response = client.get("/static/non_existent_file.txt")
assert response.status_code == 404, response.text assert response.status_code == 404, response.text
@workdir_lock
def test_openapi_schema(client: TestClient): def test_openapi_schema(client: TestClient):
response = client.get("/openapi.json") response = client.get("/openapi.json")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text

View File

View 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",
}
},
}
},
},
}
)

View File

@@ -0,0 +1,106 @@
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-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-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"}}
},
}
},
}
},
},
}
)

View File

View 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",
}
}
},
}
)

View File

@@ -3,7 +3,10 @@ import shutil
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from tests.utils import workdir_lock
@workdir_lock
def test_main(): def test_main():
if os.path.isdir("./static"): # pragma: nocover if os.path.isdir("./static"): # pragma: nocover
shutil.rmtree("./static") shutil.rmtree("./static")

View File

@@ -2,7 +2,7 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from fastapi.websockets import WebSocketDisconnect from fastapi.websockets import WebSocketDisconnect
from docs_src.websockets.tutorial001_py310 import app from docs_src.websockets_.tutorial001_py310 import app
client = TestClient(app) client = TestClient(app)

View File

@@ -16,7 +16,7 @@ from ...utils import needs_py310
], ],
) )
def get_app(request: pytest.FixtureRequest): 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 return mod.app

View File

@@ -1,4 +1,5 @@
import importlib import importlib
import time
from types import ModuleType from types import ModuleType
import pytest import pytest
@@ -12,7 +13,7 @@ from fastapi.testclient import TestClient
], ],
) )
def get_mod(request: pytest.FixtureRequest): 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 return mod
@@ -42,11 +43,13 @@ def test_websocket_handle_disconnection(client: TestClient):
connection.send_text("Hello from 1234") connection.send_text("Hello from 1234")
data1 = connection.receive_text() data1 = connection.receive_text()
assert data1 == "You wrote: Hello from 1234" assert data1 == "You wrote: Hello from 1234"
time.sleep(0.01) # Give server time to process broadcast
data2 = connection_two.receive_text() data2 = connection_two.receive_text()
client1_says = "Client #1234 says: Hello from 1234" client1_says = "Client #1234 says: Hello from 1234"
assert data2 == client1_says assert data2 == client1_says
data1 = connection.receive_text() data1 = connection.receive_text()
assert data1 == client1_says assert data1 == client1_says
connection_two.close() connection_two.close()
time.sleep(0.01) # Give server time to process broadcast
data1 = connection.receive_text() data1 = connection.receive_text()
assert data1 == "Client #5678 left the chat" assert data1 == "Client #5678 left the chat"

View File

@@ -9,6 +9,8 @@ needs_py314 = pytest.mark.skipif(
sys.version_info < (3, 14), reason="requires python3.14+" sys.version_info < (3, 14), reason="requires python3.14+"
) )
workdir_lock = pytest.mark.xdist_group("workdir_lock")
def skip_module_if_py_gte_314(): def skip_module_if_py_gte_314():
"""Skip entire module on Python 3.14+ at import time.""" """Skip entire module on Python 3.14+ at import time."""

116
uv.lock generated
View File

@@ -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" }, { 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]] [[package]]
name = "executing" name = "executing"
version = "2.2.1" version = "2.2.1"
@@ -1142,6 +1151,10 @@ dev = [
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-codspeed" }, { name = "pytest-codspeed" },
{ name = "pytest-cov" },
{ name = "pytest-sugar" },
{ name = "pytest-timeout" },
{ name = "pytest-xdist", extra = ["psutil"] },
{ name = "python-slugify" }, { name = "python-slugify" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "ruff" }, { name = "ruff" },
@@ -1201,6 +1214,10 @@ tests = [
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-codspeed" }, { name = "pytest-codspeed" },
{ name = "pytest-cov" },
{ name = "pytest-sugar" },
{ name = "pytest-timeout" },
{ name = "pytest-xdist", extra = ["psutil"] },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "ruff" }, { name = "ruff" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
@@ -1242,7 +1259,7 @@ requires-dist = [
{ name = "python-multipart", marker = "extra == 'standard'", specifier = ">=0.0.18" }, { 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 = "python-multipart", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.0.18" },
{ name = "pyyaml", marker = "extra == 'all'", specifier = ">=5.3.1" }, { name = "pyyaml", marker = "extra == 'all'", specifier = ">=5.3.1" },
{ name = "starlette", specifier = ">=0.40.0" }, { name = "starlette", specifier = ">=0.46.0" },
{ name = "typing-extensions", specifier = ">=4.8.0" }, { name = "typing-extensions", specifier = ">=4.8.0" },
{ name = "typing-inspection", specifier = ">=0.4.2" }, { name = "typing-inspection", specifier = ">=0.4.2" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.12.0" }, { 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 = "anyio", extras = ["trio"], specifier = ">=3.2.1,<5.0.0" },
{ name = "black", specifier = ">=25.1.0" }, { name = "black", specifier = ">=25.1.0" },
{ name = "cairosvg", specifier = ">=2.8.2" }, { 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 = "dirty-equals", specifier = ">=0.9.0" },
{ name = "flask", specifier = ">=3.0.0,<4.0.0" }, { name = "flask", specifier = ">=3.0.0,<4.0.0" },
{ name = "gitpython", specifier = ">=3.1.46" }, { name = "gitpython", specifier = ">=3.1.46" },
@@ -1283,6 +1300,10 @@ dev = [
{ name = "pyjwt", specifier = ">=2.9.0" }, { name = "pyjwt", specifier = ">=2.9.0" },
{ name = "pytest", specifier = ">=9.0.0" }, { name = "pytest", specifier = ">=9.0.0" },
{ name = "pytest-codspeed", specifier = ">=4.2.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 = "python-slugify", specifier = ">=8.0.4" },
{ name = "pyyaml", specifier = ">=5.3.1,<7.0.0" }, { name = "pyyaml", specifier = ">=5.3.1,<7.0.0" },
{ name = "ruff", specifier = ">=0.14.14" }, { name = "ruff", specifier = ">=0.14.14" },
@@ -1331,7 +1352,7 @@ github-actions = [
tests = [ tests = [
{ name = "a2wsgi", specifier = ">=1.9.0,<=2.0.0" }, { name = "a2wsgi", specifier = ">=1.9.0,<=2.0.0" },
{ name = "anyio", extras = ["trio"], specifier = ">=3.2.1,<5.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 = "dirty-equals", specifier = ">=0.9.0" },
{ name = "flask", specifier = ">=3.0.0,<4.0.0" }, { name = "flask", specifier = ">=3.0.0,<4.0.0" },
{ name = "httpx", specifier = ">=0.23.0,<1.0.0" }, { name = "httpx", specifier = ">=0.23.0,<1.0.0" },
@@ -1342,6 +1363,10 @@ tests = [
{ name = "pyjwt", specifier = ">=2.9.0" }, { name = "pyjwt", specifier = ">=2.9.0" },
{ name = "pytest", specifier = ">=9.0.0" }, { name = "pytest", specifier = ">=9.0.0" },
{ name = "pytest-codspeed", specifier = ">=4.2.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 = "pyyaml", specifier = ">=5.3.1,<7.0.0" },
{ name = "ruff", specifier = ">=0.14.14" }, { name = "ruff", specifier = ">=0.14.14" },
{ name = "sqlmodel", specifier = ">=0.0.31" }, { name = "sqlmodel", specifier = ">=0.0.31" },
@@ -3823,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" }, { 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]] [[package]]
name = "pwdlib" name = "pwdlib"
version = "0.3.0" version = "0.3.0"
@@ -4377,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" }, { 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]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"