Compare commits

...
69 Commits
Author SHA1 Message Date
Sebastián Ramírezandgithub-actions[bot] 95f8322ee1 🔖 Release version 0.141.1 (#16106)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-29 17:15:38 +00:00
github-actions[bot] f137944c43 📝 Update release notes
[skip ci]
2026-07-29 17:05:15 +00:00
Sebastián Ramírez d62354434b 🐛 Fix support for background tasks and headers from dependencies in app.frontend() (#16105) 2026-07-29 19:04:36 +02:00
github-actions[bot] 1d211b9c10 📝 Update release notes
[skip ci]
2026-07-29 15:32:23 +00:00
Sebastián Ramírez 8a1f876841 📝 Document FASTAPI_ENV in FastAPI CLI guide (#16104) 2026-07-29 17:27:41 +02:00
Sebastián Ramírezandgithub-actions[bot] c7e7b651d6 🔖 Release version 0.141.0 (#16103)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-29 14:45:21 +00:00
github-actions[bot] 6bceb84053 📝 Update release notes
[skip ci]
2026-07-29 14:32:16 +00:00
Sebastián Ramírez 5429fed84e Add app.frontend(check_dir="auto"), to make local development more convenient with fastapi dev (#16102) 2026-07-29 16:31:28 +02:00
Sebastián Ramírezandgithub-actions[bot] 628663f4f8 🔖 Release version 0.140.13 (#16096)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-28 15:34:37 +00:00
github-actions[bot] 0b54fd0027 📝 Update release notes
[skip ci]
2026-07-28 15:25:56 +00:00
e92a0dc3ce 🐛 Fix status_code being ignored for SSE and JSONL streaming endpoints (#15937)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Yurii Motov <yurii.motov.monte@gmail.com>
2026-07-28 17:25:12 +02:00
github-actions[bot] 6215d8a6f3 📝 Update release notes
[skip ci]
2026-07-28 15:15:44 +00:00
12242c4fba 📝 Fix format_sse_event docstring rendering of \n\n terminator (#15613)
Co-authored-by: Yurii Motov <109919500+YuriiMotov@users.noreply.github.com>
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2026-07-28 15:14:58 +00:00
github-actions[bot] 70a8f3dd03 📝 Update release notes
[skip ci]
2026-07-28 15:09:33 +00:00
Saurabh Vijay SalveandCursor 4f0152c028 📝 Add API reference page for fastapi.sse (#15930)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 17:08:21 +02:00
Sebastián Ramírezandgithub-actions[bot] 5f255058f4 🔖 Release version 0.140.12 (#16095)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-28 15:04:01 +00:00
github-actions[bot] 76e2c833b5 📝 Update release notes
[skip ci]
2026-07-28 14:55:27 +00:00
0f3e7bd682 🐛 Fix line splitting in format_sse_event to comply with SSE spec (#15515)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Yurii Motov <109919500+YuriiMotov@users.noreply.github.com>
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2026-07-28 16:54:54 +02:00
Sebastián Ramírezandgithub-actions[bot] 31ce3cb8d7 🔖 Release version 0.140.11 (#16094)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-28 14:31:44 +00:00
github-actions[bot] 4a01c7f1a5 📝 Update release notes
[skip ci]
2026-07-28 14:23:09 +00:00
Yurii MotovandSebastián Ramírez d6537f774b 🐛 Fix response_model_* params ignored for non-generator endpoints with Iterable[..] return type (#15093)
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2026-07-28 16:22:05 +02:00
Sebastián Ramírezandgithub-actions[bot] 0f3d3b2f9f 🔖 Release version 0.140.10 (#16093)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-28 16:19:13 +02:00
github-actions[bot] 584efa0981 📝 Update release notes
[skip ci]
2026-07-28 14:04:58 +00:00
Yurii MotovandSebastián Ramírez 65e42bd5ec 🐛 Fix handling sequences with nested Annotated types (#14874)
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2026-07-28 16:04:16 +02:00
github-actions[bot] 9db320278c 📝 Update release notes
[skip ci]
2026-07-28 13:48:40 +00:00
Sebastián Ramírez d3cd6054e4 🐛 Accept any base test failure as regression (#16092) 2026-07-28 13:47:58 +00:00
github-actions[bot] 19a461a19e 📝 Update release notes
[skip ci]
2026-07-28 13:31:47 +00:00
Sebastián Ramírez 0a4cd1c78f 🐛 Preserve pytest exit code in regression check (#16091) 2026-07-28 15:31:02 +02:00
github-actions[bot] 64ae6c977c 📝 Update release notes
[skip ci]
2026-07-28 13:16:59 +00:00
Sebastián Ramírez 7d123d9537 Test PR regressions against base code (#16090) 2026-07-28 13:16:17 +00:00
Sebastián Ramírezandgithub-actions[bot] 6b6c032658 🔖 Release version 0.140.9 (#16089)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-28 12:36:53 +00:00
github-actions[bot] 506eba8ac3 📝 Update release notes
[skip ci]
2026-07-28 12:28:57 +00:00
Muhammad Bin GulzarandSebastián Ramírez aadfcce763 🐛 Fix exclude_defaults not propagated to dict keys and values in jsonable_encoder (#16043)
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2026-07-28 12:28:18 +00:00
github-actions[bot] 4ffd451720 📝 Update release notes
[skip ci]
2026-07-28 12:21:55 +00:00
dependabot[bot] 5e8b7f1cb5 ⬆ Bump gitpython from 3.1.50 to 3.1.54 (#16047)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 14:18:40 +02:00
github-actions[bot] c4e91df63b 📝 Update release notes
[skip ci]
2026-07-28 12:17:20 +00:00
dependabot[bot] 53363a6be1 ⬆ Bump pymdown-extensions from 10.21.3 to 11.0 (#16048)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 14:16:46 +02:00
github-actions[bot] 8b041fe96a 📝 Update release notes
[skip ci]
2026-07-28 12:10:23 +00:00
dependabot[bot] ba86fc13b4 ⬆ Bump pyasn1 from 0.6.3 to 0.6.4 (#16045)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 14:09:41 +02:00
Sebastián Ramírezandgithub-actions[bot] 773342f978 🔖 Release version 0.140.8 (#16088)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-28 10:29:51 +00:00
github-actions[bot] fd557905ea 📝 Update release notes
[skip ci]
2026-07-28 10:22:53 +00:00
ad03e117c0 🐛 Fix stream item type lost when using include_router() (#15077)
Co-authored-by: Alexander Rauhut <alexander.rauhut@adnova.gmbh>
Co-authored-by: Sebastián Ramírez <tiangolo@gmail.com>
2026-07-28 12:22:08 +02:00
Sebastián Ramírezandgithub-actions[bot] 98b12fe56f 🔖 Release version 0.140.7 (#16078)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 17:28:03 +00:00
github-actions[bot] e772894447 📝 Update release notes
[skip ci]
2026-07-27 17:26:20 +00:00
Sebastián Ramírez 24c2a9fdf9 ⬆️ Upgrade latest-changes to 0.7.1 (#16077) 2026-07-27 19:25:00 +02:00
github-actions[bot] add1d2685b 📝 Update release notes
[skip ci]
2026-07-27 17:17:14 +00:00
Sebastián Ramírez 7bcb78d10d ️ Avoid flattening dependencies for OpenAPI (#16076) 2026-07-27 17:16:40 +00:00
github-actions[bot] 87095aa581 📝 Update release notes
[skip ci]
2026-07-27 16:47:36 +00:00
Sebastián Ramírez 3d3c6913e8 👷 Add OpenAPI dependency benchmarks (#16075) 2026-07-27 18:46:54 +02:00
Sebastián Ramírezandgithub-actions[bot] bcd4e895e1 🔖 Release version 0.140.6 (#16074)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 16:28:46 +00:00
github-actions[bot] 27c922c403 📝 Update release notes
[skip ci]
2026-07-27 16:18:08 +00:00
Sebastián Ramírez cc96fd3a7c ️ Avoid flattening dependencies for request parameters, mainly for OpenAPI (#16073) 2026-07-27 16:17:33 +00:00
Sebastián Ramírezandgithub-actions[bot] ac068db630 🔖 Release version 0.140.5 (#16072)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 16:00:25 +00:00
github-actions[bot] 6be87d13a7 📝 Update release notes
[skip ci]
2026-07-27 15:53:30 +00:00
Sebastián Ramírez 23e4a10434 ️ Avoid flattening dependencies for body fields (#16071) 2026-07-27 15:52:55 +00:00
Sebastián Ramírezandgithub-actions[bot] 7ac0f1b541 🔖 Release version 0.140.4 (#16070)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 15:45:50 +00:00
github-actions[bot] 44e4eeaa78 📝 Update release notes
[skip ci]
2026-07-27 15:36:07 +00:00
Sebastián Ramírez 7134121a71 ️ Skip unused dependency repeat bookkeeping (#16069) 2026-07-27 15:35:29 +00:00
Sebastián Ramírezandgithub-actions[bot] 415d37fa6f 🔖 Release version 0.140.3 (#16068)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 15:16:54 +00:00
github-actions[bot] ca353d7215 📝 Update release notes
[skip ci]
2026-07-27 15:09:32 +00:00
Sebastián Ramírez d012979f9a ️ Avoid repeated dependency flattening in OpenAPI (#16067) 2026-07-27 15:08:45 +00:00
Sebastián Ramírezandgithub-actions[bot] 051dfeddcc 🔖 Release version 0.140.2 (#16066)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 14:14:11 +00:00
github-actions[bot] e9980492f2 📝 Update release notes
[skip ci]
2026-07-27 14:07:09 +00:00
Sebastián RamírezandMarcelo Trylesinski 8069eadf5e ️ Stop retaining flat dependency trees (#16065)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
2026-07-27 16:06:31 +02:00
github-actions[bot] 2b4349d6c2 📝 Update release notes
[skip ci]
2026-07-27 12:40:07 +00:00
Sebastián Ramírez 2f34b90742 👷 Add new memory benchmark (#16064) 2026-07-27 12:39:36 +00:00
Sebastián Ramírezandgithub-actions[bot] 3e310c90fa 🔖 Release version 0.140.1 (#16063)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-27 14:06:27 +02:00
github-actions[bot] 43eafc6a28 📝 Update release notes
[skip ci]
2026-07-27 11:18:58 +00:00
Sebastián Ramírez 65ef53ae6a ♻️ Update the lru_cache limit for dependencies to account for large apps (#16062) 2026-07-27 11:18:26 +00:00
30 changed files with 1620 additions and 227 deletions

No files matched your search

+1 -5
View File
@@ -40,11 +40,7 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }}
with:
limit-access-to-actor: true
- uses: tiangolo/latest-changes@c9b73efbc8992ef1a401e4235ea307a8ca8a724b # 0.6.1
- uses: tiangolo/latest-changes@8a940392f4c65274539453a5d5a76d9550203ac1 # 0.7.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
latest_changes_file: docs/en/docs/release-notes.md
latest_changes_header: '## Latest Changes'
end_regex: '^## '
debug_logs: true
label_header_prefix: '### '
+73
View File
@@ -201,6 +201,78 @@ jobs:
mode: memory
run: uv run --no-sync pytest tests/memory_benchmarks --codspeed
regression-test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out the pull request
if: github.event_name == 'pull_request'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.sha }}
path: pr
persist-credentials: false
fetch-depth: 0
- name: Find changed tests
if: github.event_name == 'pull_request'
id: changed-tests
working-directory: pr
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git diff --name-only --diff-filter=AM -z "$BASE_SHA" "$HEAD_SHA" -- tests \
| while IFS= read -r -d '' file; do
case "$(basename "$file")" in
test_*.py) printf '%s\0' "$file" ;;
esac
done > "$RUNNER_TEMP/changed-tests"
if [ -s "$RUNNER_TEMP/changed-tests" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
git diff --binary "$BASE_SHA" "$HEAD_SHA" -- tests \
> "$RUNNER_TEMP/tests.patch"
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "No added or modified test files; regression proof is not applicable."
fi
- name: Check out the base revision
if: steps.changed-tests.outputs.found == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
persist-credentials: false
- name: Set up Python
if: steps.changed-tests.outputs.found == 'true'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version-file: "base/.python-version"
- name: Setup uv
if: steps.changed-tests.outputs.found == 'true'
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
version: "0.11.18"
enable-cache: true
- name: Run the changed tests against the base code
if: steps.changed-tests.outputs.found == 'true'
working-directory: base
run: |
git apply "$RUNNER_TEMP/tests.patch"
uv sync --locked --no-dev --group tests --extra all
set +e
xargs -0 uv run --no-sync pytest -- < "$RUNNER_TEMP/changed-tests"
status=$?
set -e
if [ "$status" -eq 0 ]; then
echo "::warning::The changed tests already pass on the base revision. Check whether the fix is still needed."
echo "### Regression proof: base already passes :warning:" >> "$GITHUB_STEP_SUMMARY"
echo "The changed tests pass without the pull request's code changes." >> "$GITHUB_STEP_SUMMARY"
else
echo "The changed tests fail on the base revision as expected (pytest exit code $status)."
echo "### Regression proof: base fails as expected :white_check_mark:" >> "$GITHUB_STEP_SUMMARY"
fi
coverage-combine:
needs:
- test
@@ -253,6 +325,7 @@ jobs:
- test
- coverage-combine
- benchmark
- regression-test
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
+4
View File
@@ -119,6 +119,10 @@ Running `fastapi dev` initiates development mode.
By default, **auto-reload** is enabled, automatically reloading the server when you make changes to your code. This is resource-intensive and could be less stable than when it's disabled. You should only use it for development. It also listens on the IP address `127.0.0.1`, which is the IP for your machine to communicate with itself alone (`localhost`).
Before importing your app, `fastapi dev` sets the `FASTAPI_ENV` environment variable to `development`. If `FASTAPI_ENV` is already set, its existing value is preserved. This lets app startup code choose development-friendly behavior while allowing you to provide an app-specific environment such as `staging`.
The conventional `FASTAPI_ENV` values are `development` and `production`. `fastapi run` currently leaves `FASTAPI_ENV` unchanged, so set it explicitly if your app needs to detect production mode.
## `fastapi run` { #fastapi-run }
Executing `fastapi run` starts FastAPI in production mode.
+17
View File
@@ -0,0 +1,17 @@
# Server-Sent Events - `EventSourceResponse` and `ServerSentEvent`
To stream Server-Sent Events (SSE), use `yield` in your *path operation function* and set `response_class=EventSourceResponse`.
If you need to set SSE fields like `event`, `id`, `retry`, or `comment`, you can `yield` `ServerSentEvent` objects instead of plain data.
Read more about it in the [FastAPI docs for Server-Sent Events (SSE)](https://fastapi.tiangolo.com/tutorial/server-sent-events/).
You can import them directly from `fastapi.sse`:
```python
from fastapi.sse import EventSourceResponse, ServerSentEvent
```
::: fastapi.sse.EventSourceResponse
::: fastapi.sse.ServerSentEvent
+120
View File
@@ -7,6 +7,126 @@ hide:
## Latest Changes
## 0.141.1 (2026-07-29)
### Fixes
* 🐛 Fix support for background tasks and headers from dependencies in `app.frontend()`. PR [#16105](https://github.com/fastapi/fastapi/pull/16105) by [@tiangolo](https://github.com/tiangolo).
### Docs
* 📝 Document `FASTAPI_ENV` in FastAPI CLI guide. PR [#16104](https://github.com/fastapi/fastapi/pull/16104) by [@tiangolo](https://github.com/tiangolo).
## 0.141.0 (2026-07-29)
### Features
* ✨ Add `app.frontend(check_dir="auto")`, to make local development more convenient with `fastapi dev`. PR [#16102](https://github.com/fastapi/fastapi/pull/16102) by [@tiangolo](https://github.com/tiangolo).
## 0.140.13 (2026-07-28)
### Fixes
* 🐛 Fix `status_code` being ignored for SSE and JSONL streaming endpoints. PR [#15937](https://github.com/fastapi/fastapi/pull/15937) by [@SAURABHSALVE](https://github.com/SAURABHSALVE).
### Docs
* 📝 Fix `format_sse_event` docstring rendering of `\n\n` terminator. PR [#15613](https://github.com/fastapi/fastapi/pull/15613) by [@AshNicolus](https://github.com/AshNicolus).
* 📝 Add API reference page for fastapi.sse. PR [#15930](https://github.com/fastapi/fastapi/pull/15930) by [@SAURABHSALVE](https://github.com/SAURABHSALVE).
## 0.140.12 (2026-07-28)
### Fixes
* 🐛 Fix line splitting in `format_sse_event` to comply with SSE spec. PR [#15515](https://github.com/fastapi/fastapi/pull/15515) by [@Zawwarsami16](https://github.com/Zawwarsami16).
## 0.140.11 (2026-07-28)
### Fixes
* 🐛 Fix `response_model_*` params ignored for non-generator endpoints with `Iterable[..]` return type. PR [#15093](https://github.com/fastapi/fastapi/pull/15093) by [@YuriiMotov](https://github.com/YuriiMotov).
## 0.140.10 (2026-07-28)
### Fixes
* 🐛 Fix handling sequences with nested Annotated types. PR [#14874](https://github.com/fastapi/fastapi/pull/14874) by [@YuriiMotov](https://github.com/YuriiMotov).
### Internal
* 🐛 Accept any base test failure as regression. PR [#16092](https://github.com/fastapi/fastapi/pull/16092) by [@tiangolo](https://github.com/tiangolo).
* 🐛 Preserve pytest exit code in regression check. PR [#16091](https://github.com/fastapi/fastapi/pull/16091) by [@tiangolo](https://github.com/tiangolo).
* ✅ Test PR regressions against base code. PR [#16090](https://github.com/fastapi/fastapi/pull/16090) by [@tiangolo](https://github.com/tiangolo).
## 0.140.9 (2026-07-28)
### Fixes
* 🐛 Fix `exclude_defaults` not propagated to dict keys and values in `jsonable_encoder`. PR [#16043](https://github.com/fastapi/fastapi/pull/16043) by [@MBGrao](https://github.com/MBGrao).
### Internal
* ⬆ Bump gitpython from 3.1.50 to 3.1.54. PR [#16047](https://github.com/fastapi/fastapi/pull/16047) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump pymdown-extensions from 10.21.3 to 11.0. PR [#16048](https://github.com/fastapi/fastapi/pull/16048) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Bump pyasn1 from 0.6.3 to 0.6.4. PR [#16045](https://github.com/fastapi/fastapi/pull/16045) by [@dependabot[bot]](https://github.com/apps/dependabot).
## 0.140.8 (2026-07-28)
### Fixes
* 🐛 Fix stream item type lost when using `include_router()`. PR [#15077](https://github.com/fastapi/fastapi/pull/15077) by [@alex-raw](https://github.com/alex-raw).
## 0.140.7 (2026-07-27)
### Refactors
* ⚡️ Avoid flattening dependencies for OpenAPI. PR [#16076](https://github.com/fastapi/fastapi/pull/16076) by [@tiangolo](https://github.com/tiangolo).
### Internal
* ⬆️ Upgrade latest-changes to 0.7.1. PR [#16077](https://github.com/fastapi/fastapi/pull/16077) by [@tiangolo](https://github.com/tiangolo).
* 👷 Add OpenAPI dependency benchmarks. PR [#16075](https://github.com/fastapi/fastapi/pull/16075) by [@tiangolo](https://github.com/tiangolo).
## 0.140.6 (2026-07-27)
### Refactors
* ⚡️ Avoid flattening dependencies for request parameters, mainly for OpenAPI. PR [#16073](https://github.com/fastapi/fastapi/pull/16073) by [@tiangolo](https://github.com/tiangolo).
## 0.140.5 (2026-07-27)
### Refactors
* ⚡️ Avoid flattening dependencies for body fields. PR [#16071](https://github.com/fastapi/fastapi/pull/16071) by [@tiangolo](https://github.com/tiangolo).
## 0.140.4 (2026-07-27)
### Refactors
* ⚡️ Skip unused dependency repeat bookkeeping. PR [#16069](https://github.com/fastapi/fastapi/pull/16069) by [@tiangolo](https://github.com/tiangolo).
## 0.140.3 (2026-07-27)
### Refactors
* ⚡️ Avoid repeated dependency flattening in OpenAPI. PR [#16067](https://github.com/fastapi/fastapi/pull/16067) by [@tiangolo](https://github.com/tiangolo).
## 0.140.2 (2026-07-27)
### Refactors
* ⚡️ Stop retaining flat dependency trees. PR [#16065](https://github.com/fastapi/fastapi/pull/16065) by [@tiangolo](https://github.com/tiangolo).
### Internal
* 👷 Add new memory benchmark. PR [#16064](https://github.com/fastapi/fastapi/pull/16064) by [@tiangolo](https://github.com/tiangolo).
## 0.140.1 (2026-07-27)
### Refactors
* ♻️ Update the lru_cache limit for dependencies to account for large apps. PR [#16062](https://github.com/fastapi/fastapi/pull/16062) by [@tiangolo](https://github.com/tiangolo).
## 0.140.0 (2026-07-24)
### Refactors
+8 -2
View File
@@ -106,9 +106,13 @@ Then missing frontend paths return the normal `404`.
## Check Directory { #check-directory }
By default, `app.frontend()` checks that the directory exists when the app is created.
By default, `app.frontend()` uses `check_dir="auto"`.
This helps catch configuration errors early. For example, if the frontend build output directory is missing, **FastAPI** will raise an error on startup.
When the `FASTAPI_ENV` environment variable is set to `development`, **FastAPI** only shows a warning if the frontend build output directory is missing. The [`fastapi dev` command](https://github.com/fastapi/fastapi-cli#fastapi-dev) sets this environment variable for you if it is not already set. This lets you start the backend before building or starting the frontend during development.
In any other environment, **FastAPI** raises an error when the app is created. This helps catch configuration errors early before deploying an app without its frontend files.
You can also set `check_dir=True` to always check the directory when the app is created.
If your frontend files are created later, for example by a separate build step after the app object is created, set `check_dir=False`:
@@ -132,6 +136,8 @@ Frontend responses run inside the normal **FastAPI** application, so HTTP middle
Dependencies from the app, from an `APIRouter`, and from `include_router()` also apply to frontend responses. This can be useful for protecting a frontend with cookie authentication or similar.
Dependencies can also modify response headers and add background tasks, as with normal *path operations*.
## Static Build Output Only { #static-build-output-only }
`app.frontend()` serves files already generated by your frontend build.
+1
View File
@@ -211,6 +211,7 @@ nav:
- reference/httpconnection.md
- reference/response.md
- reference/responses.md
- reference/sse.md
- reference/middleware.md
- "":
- reference/openapi/index.md
+1 -1
View File
@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
__version__ = "0.140.0"
__version__ = "0.141.1"
from starlette import status as status
+8
View File
@@ -63,6 +63,10 @@ def _annotation_is_sequence(annotation: type[Any] | None) -> bool:
def field_annotation_is_sequence(annotation: type[Any] | None) -> bool:
origin = get_origin(annotation)
if origin is Annotated:
return field_annotation_is_sequence(get_args(annotation)[0])
if origin is Union or origin is UnionType:
for arg in get_args(annotation):
if field_annotation_is_sequence(arg):
@@ -108,6 +112,10 @@ def field_annotation_is_scalar(annotation: Any) -> bool:
def field_annotation_is_scalar_sequence(annotation: type[Any] | None) -> bool:
origin = get_origin(annotation)
if origin is Annotated:
return field_annotation_is_scalar_sequence(get_args(annotation)[0])
if origin is Union or origin is UnionType:
at_least_one_scalar_sequence = False
for arg in get_args(annotation):
+9 -3
View File
@@ -1247,13 +1247,16 @@ class FastAPI(Starlette):
),
] = "auto",
check_dir: Annotated[
bool,
bool | Literal["auto"],
Doc(
"""
Check that the frontend directory exists when the app is created.
Check that the frontend directory exists when the app is created. When
set to `"auto"`, skip the check with a warning when `FASTAPI_ENV` is
`"development"`, and check it otherwise. The `fastapi dev` command
sets `FASTAPI_ENV` to `"development"` if it is not already set.
"""
),
] = True,
] = "auto",
) -> None:
"""
Serve a static frontend build as low-priority routes.
@@ -1285,6 +1288,9 @@ class FastAPI(Starlette):
app.frontend("/", directory="dist")
```
"""
check_dir = routing._resolve_frontend_check_dir(
directory=directory, check_dir=check_dir
)
self.router.frontend(
path,
directory=directory,
+4 -7
View File
@@ -52,6 +52,7 @@ class Dependant:
_UsesScopesCache = dict[int, tuple[Dependant, bool]]
_CALLABLE_CLASSIFICATION_CACHE_SIZE = 4096
class _CallIdentity:
@@ -133,11 +134,7 @@ def _get_security_scheme(*, dependant: Dependant) -> SecurityBase:
return unwrapped
def _get_security_dependencies(*, dependant: Dependant) -> list[Dependant]:
return [dep for dep in dependant.dependencies if _is_security_scheme(dependant=dep)]
@lru_cache(maxsize=1024)
@lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE)
def _is_gen_callable_cached(call_identity: _CallIdentity) -> bool:
call = call_identity.call
if inspect.isgeneratorfunction(_impartial(call)) or inspect.isgeneratorfunction(
@@ -167,7 +164,7 @@ def _is_gen_callable(call: Callable[..., Any] | None) -> bool:
return _is_gen_callable_cached(_CallIdentity(call))
@lru_cache(maxsize=1024)
@lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE)
def _is_async_gen_callable_cached(call_identity: _CallIdentity) -> bool:
call = call_identity.call
if inspect.isasyncgenfunction(_impartial(call)) or inspect.isasyncgenfunction(
@@ -197,7 +194,7 @@ def _is_async_gen_callable(call: Callable[..., Any] | None) -> bool:
return _is_async_gen_callable_cached(_CallIdentity(call))
@lru_cache(maxsize=1024)
@lru_cache(maxsize=_CALLABLE_CLASSIFICATION_CACHE_SIZE)
def _is_coroutine_callable_cached(call_identity: _CallIdentity) -> bool:
call = call_identity.call
if inspect.isroutine(_impartial(call)) and iscoroutinefunction(_impartial(call)):
+42 -86
View File
@@ -144,74 +144,14 @@ def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> De
)
def get_flat_dependant(
dependant: Dependant,
*,
skip_repeats: bool = False,
visited: list[DependencyCacheKey] | None = None,
parent_oauth_scopes: list[str] | None = None,
_uses_scopes_cache: _UsesScopesCache | None = None,
) -> Dependant:
if visited is None:
visited = []
if _uses_scopes_cache is None:
_uses_scopes_cache = {}
visited.append(
_get_cache_key(
dependant=dependant,
uses_scopes_cache=_uses_scopes_cache,
)
)
use_parent_oauth_scopes = (parent_oauth_scopes or []) + (
_get_oauth_scopes(dependant=dependant)
)
flat_dependant = Dependant(
path_params=dependant.path_params.copy(),
query_params=dependant.query_params.copy(),
header_params=dependant.header_params.copy(),
cookie_params=dependant.cookie_params.copy(),
body_params=dependant.body_params.copy(),
name=dependant.name,
call=dependant.call,
request_param_name=dependant.request_param_name,
websocket_param_name=dependant.websocket_param_name,
http_connection_param_name=dependant.http_connection_param_name,
response_param_name=dependant.response_param_name,
background_tasks_param_name=dependant.background_tasks_param_name,
security_scopes_param_name=dependant.security_scopes_param_name,
own_oauth_scopes=dependant.own_oauth_scopes,
parent_oauth_scopes=use_parent_oauth_scopes,
use_cache=dependant.use_cache,
path=dependant.path,
scope=dependant.scope,
)
for sub_dependant in dependant.dependencies:
if (
skip_repeats
and _get_cache_key(
dependant=sub_dependant,
uses_scopes_cache=_uses_scopes_cache,
)
in visited
):
continue
flat_sub = get_flat_dependant(
sub_dependant,
skip_repeats=skip_repeats,
visited=visited,
parent_oauth_scopes=_get_oauth_scopes(dependant=flat_dependant),
_uses_scopes_cache=_uses_scopes_cache,
)
flat_dependant.dependencies.append(flat_sub)
flat_dependant.path_params.extend(flat_sub.path_params)
flat_dependant.query_params.extend(flat_sub.query_params)
flat_dependant.header_params.extend(flat_sub.header_params)
flat_dependant.cookie_params.extend(flat_sub.cookie_params)
flat_dependant.body_params.extend(flat_sub.body_params)
flat_dependant.dependencies.extend(flat_sub.dependencies)
return flat_dependant
def _get_flat_body_params(dependant: Dependant) -> list[ModelField]:
body_params: list[ModelField] = []
dependants = [dependant]
while dependants:
current_dependant = dependants.pop()
body_params.extend(current_dependant.body_params)
dependants.extend(reversed(current_dependant.dependencies))
return body_params
def _get_flat_fields_from_params(fields: list[ModelField]) -> list[ModelField]:
@@ -227,11 +167,31 @@ def _get_flat_fields_from_params(fields: list[ModelField]) -> list[ModelField]:
def get_flat_params(dependant: Dependant) -> list[ModelField]:
flat_dependant = get_flat_dependant(dependant, skip_repeats=True)
path_params = _get_flat_fields_from_params(flat_dependant.path_params)
query_params = _get_flat_fields_from_params(flat_dependant.query_params)
header_params = _get_flat_fields_from_params(flat_dependant.header_params)
cookie_params = _get_flat_fields_from_params(flat_dependant.cookie_params)
path_params: list[ModelField] = []
query_params: list[ModelField] = []
header_params: list[ModelField] = []
cookie_params: list[ModelField] = []
visited: list[DependencyCacheKey] = []
uses_scopes_cache: _UsesScopesCache = {}
dependants = [dependant]
while dependants:
current_dependant = dependants.pop()
cache_key = _get_cache_key(
dependant=current_dependant,
uses_scopes_cache=uses_scopes_cache,
)
if cache_key in visited:
continue
visited.append(cache_key)
path_params.extend(current_dependant.path_params)
query_params.extend(current_dependant.query_params)
header_params.extend(current_dependant.header_params)
cookie_params.extend(current_dependant.cookie_params)
dependants.extend(reversed(current_dependant.dependencies))
path_params = _get_flat_fields_from_params(path_params)
query_params = _get_flat_fields_from_params(query_params)
header_params = _get_flat_fields_from_params(header_params)
cookie_params = _get_flat_fields_from_params(cookie_params)
return path_params + query_params + header_params + cookie_params
@@ -1038,8 +998,8 @@ async def request_body_to_args(
return values, errors
def get_body_field(
*, flat_dependant: Dependant, name: str, embed_body_fields: bool
def _get_body_field(
*, body_params: list[ModelField], name: str, embed_body_fields: bool
) -> ModelField | None:
"""
Get a ModelField representing the request body for a path operation, combining
@@ -1051,34 +1011,30 @@ def get_body_field(
This is **not** used to validate/parse the request body, that's done with each
individual body parameter.
"""
if not flat_dependant.body_params:
if not body_params:
return None
first_param = flat_dependant.body_params[0]
first_param = body_params[0]
if not embed_body_fields:
return first_param
model_name = "Body_" + name
BodyModel = create_body_model(
fields=flat_dependant.body_params, model_name=model_name
)
required = any(
True for f in flat_dependant.body_params if f.field_info.is_required()
)
BodyModel = create_body_model(fields=body_params, model_name=model_name)
required = any(True for f in body_params if f.field_info.is_required())
BodyFieldInfo_kwargs: dict[str, Any] = {
"annotation": BodyModel,
"alias": "body",
}
if not required:
BodyFieldInfo_kwargs["default"] = None
if any(isinstance(f.field_info, params.File) for f in flat_dependant.body_params):
if any(isinstance(f.field_info, params.File) for f in body_params):
BodyFieldInfo: type[params.Body] = params.File
elif any(isinstance(f.field_info, params.Form) for f in flat_dependant.body_params):
elif any(isinstance(f.field_info, params.Form) for f in body_params):
BodyFieldInfo = params.Form
else:
BodyFieldInfo = params.Body
body_param_media_types = [
f.field_info.media_type
for f in flat_dependant.body_params
for f in body_params
if isinstance(f.field_info, params.Body)
]
if len(set(body_param_media_types)) == 1:
+2
View File
@@ -299,6 +299,7 @@ def jsonable_encoder(
key,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
custom_encoder=custom_encoder,
sqlalchemy_safe=sqlalchemy_safe,
@@ -307,6 +308,7 @@ def jsonable_encoder(
value,
by_alias=by_alias,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
custom_encoder=custom_encoder,
sqlalchemy_safe=sqlalchemy_safe,
+76 -20
View File
@@ -3,6 +3,7 @@ import http.client
import inspect
import warnings
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any, Literal, cast
from fastapi import routing
@@ -17,13 +18,14 @@ from fastapi._compat import (
from fastapi.datastructures import DefaultPlaceholder, _Unset
from fastapi.dependencies.models import (
Dependant,
_get_cache_key,
_get_oauth_scopes,
_get_security_dependencies,
_get_security_scheme,
_is_security_scheme,
_UsesScopesCache,
)
from fastapi.dependencies.utils import (
_get_flat_fields_from_params,
get_flat_dependant,
get_flat_params,
get_validation_alias,
)
@@ -34,7 +36,7 @@ from fastapi.openapi.models import OpenAPI
from fastapi.params import Body, ParamTypes
from fastapi.responses import Response
from fastapi.sse import _SSE_EVENT_SCHEMA
from fastapi.types import ModelNameMap
from fastapi.types import DependencyCacheKey, ModelNameMap
from fastapi.utils import (
deep_dict_update,
generate_operation_id_for_path,
@@ -83,13 +85,57 @@ status_code_ranges: dict[str, str] = {
}
def get_openapi_security_definitions(
flat_dependant: Dependant,
@dataclass
class _OpenAPIDependencyData:
path_params: list[ModelField] = field(default_factory=list)
query_params: list[ModelField] = field(default_factory=list)
header_params: list[ModelField] = field(default_factory=list)
cookie_params: list[ModelField] = field(default_factory=list)
security_dependencies: list[tuple[Dependant, list[str]]] = field(
default_factory=list
)
def _get_openapi_dependency_data(dependant: Dependant) -> _OpenAPIDependencyData:
dependency_data = _OpenAPIDependencyData()
visited: list[DependencyCacheKey] = []
uses_scopes_cache: _UsesScopesCache = {}
dependants: list[tuple[Dependant, list[str], bool]] = [(dependant, [], True)]
while dependants:
current_dependant, parent_oauth_scopes, is_root = dependants.pop()
cache_key = _get_cache_key(
dependant=current_dependant,
uses_scopes_cache=uses_scopes_cache,
)
if cache_key in visited:
continue
visited.append(cache_key)
dependency_data.path_params.extend(current_dependant.path_params)
dependency_data.query_params.extend(current_dependant.query_params)
dependency_data.header_params.extend(current_dependant.header_params)
dependency_data.cookie_params.extend(current_dependant.cookie_params)
oauth_scopes = parent_oauth_scopes.copy()
for scope in _get_oauth_scopes(dependant=current_dependant):
if scope not in oauth_scopes:
oauth_scopes.append(scope)
if not is_root and _is_security_scheme(dependant=current_dependant):
dependency_data.security_dependencies.append(
(current_dependant, oauth_scopes)
)
dependants.extend(
(sub_dependant, oauth_scopes, False)
for sub_dependant in reversed(current_dependant.dependencies)
)
return dependency_data
def _get_openapi_security_definitions(
security_dependencies: list[tuple[Dependant, list[str]]],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
security_definitions = {}
# Use a dict to merge scopes for same security scheme
operation_security_dict: dict[str, list[str]] = {}
for security_dependency in _get_security_dependencies(dependant=flat_dependant):
for security_dependency, oauth_scopes in security_dependencies:
security_scheme = _get_security_scheme(dependant=security_dependency)
security_definition = jsonable_encoder(
security_scheme.model,
@@ -101,7 +147,7 @@ def get_openapi_security_definitions(
# Merge scopes for the same security scheme
if security_name not in operation_security_dict:
operation_security_dict[security_name] = []
for scope in _get_oauth_scopes(dependant=security_dependency):
for scope in oauth_scopes:
if scope not in operation_security_dict[security_name]:
operation_security_dict[security_name].append(scope)
operation_security = [
@@ -112,7 +158,7 @@ def get_openapi_security_definitions(
def _get_openapi_operation_parameters(
*,
dependant: Dependant,
dependency_data: _OpenAPIDependencyData,
model_name_map: ModelNameMap,
field_mapping: dict[
tuple[ModelField, Literal["validation", "serialization"]], dict[str, Any]
@@ -120,11 +166,10 @@ def _get_openapi_operation_parameters(
separate_input_output_schemas: bool = True,
) -> list[dict[str, Any]]:
parameters = []
flat_dependant = get_flat_dependant(dependant, skip_repeats=True)
path_params = _get_flat_fields_from_params(flat_dependant.path_params)
query_params = _get_flat_fields_from_params(flat_dependant.query_params)
header_params = _get_flat_fields_from_params(flat_dependant.header_params)
cookie_params = _get_flat_fields_from_params(flat_dependant.cookie_params)
path_params = _get_flat_fields_from_params(dependency_data.path_params)
query_params = _get_flat_fields_from_params(dependency_data.query_params)
header_params = _get_flat_fields_from_params(dependency_data.header_params)
cookie_params = _get_flat_fields_from_params(dependency_data.cookie_params)
parameter_groups = [
(ParamTypes.path, path_params),
(ParamTypes.query, query_params),
@@ -132,8 +177,8 @@ def _get_openapi_operation_parameters(
(ParamTypes.cookie, cookie_params),
]
default_convert_underscores = True
if len(flat_dependant.header_params) == 1:
first_field = flat_dependant.header_params[0]
if len(dependency_data.header_params) == 1:
first_field = dependency_data.header_params[0]
if lenient_issubclass(first_field.field_info.annotation, BaseModel):
default_convert_underscores = getattr(
first_field.field_info, "convert_underscores", True
@@ -284,21 +329,33 @@ def get_openapi_path(
assert current_response_class, "A response class is needed to generate OpenAPI"
route_response_media_type: str | None = current_response_class.media_type
if route.include_in_schema:
dependency_data = _get_openapi_dependency_data(route.dependant)
all_route_params = [
field
for fields in (
dependency_data.path_params,
dependency_data.query_params,
dependency_data.header_params,
dependency_data.cookie_params,
)
for field in _get_flat_fields_from_params(fields)
]
for method in route.methods:
operation = get_openapi_operation_metadata(
route=route, method=method, operation_ids=operation_ids
)
parameters: list[dict[str, Any]] = []
flat_dependant = get_flat_dependant(route.dependant, skip_repeats=True)
security_definitions, operation_security = get_openapi_security_definitions(
flat_dependant=flat_dependant
security_definitions, operation_security = (
_get_openapi_security_definitions(
security_dependencies=dependency_data.security_dependencies
)
)
if operation_security:
operation.setdefault("security", []).extend(operation_security)
if security_definitions:
security_schemes.update(security_definitions)
operation_parameters = _get_openapi_operation_parameters(
dependant=route.dependant,
dependency_data=dependency_data,
model_name_map=model_name_map,
field_mapping=field_mapping,
separate_input_output_schemas=separate_input_output_schemas,
@@ -458,7 +515,6 @@ def get_openapi_path(
deep_dict_update(openapi_response, process_response)
openapi_response["description"] = description
http422 = "422"
all_route_params = get_flat_params(route.dependant)
if (all_route_params or route.body_field) and not any(
status in operation["responses"]
for status in [http422, "4XX", "default"]
+114 -72
View File
@@ -9,6 +9,7 @@ import os
import stat
import threading
import types
import warnings
from collections.abc import (
AsyncIterator,
Awaitable,
@@ -55,10 +56,11 @@ from fastapi.dependencies.models import (
_is_gen_callable,
)
from fastapi.dependencies.utils import (
SolvedDependency,
_get_body_field,
_get_flat_body_params,
_should_embed_body_fields,
get_body_field,
get_dependant,
get_flat_dependant,
get_parameterless_sub_dependant,
get_stream_item_type,
get_typed_return_annotation,
@@ -630,10 +632,13 @@ def get_request_handler(
_sse_with_checkpoints(sse_receive_stream)
)
response_args = _build_response_args(
status_code=status_code, solved_result=solved_result
)
response = StreamingResponse(
sse_stream_content,
media_type="text/event-stream",
background=solved_result.background_tasks,
**response_args,
)
response.headers["Cache-Control"] = "no-cache"
# For Nginx proxies to not buffer server sent events
@@ -666,10 +671,13 @@ def get_request_handler(
jsonl_stream_content = _sync_stream_jsonl()
response_args = _build_response_args(
status_code=status_code, solved_result=solved_result
)
response = StreamingResponse(
jsonl_stream_content,
media_type="application/jsonl",
background=solved_result.background_tasks,
**response_args,
)
response.headers.raw.extend(solved_result.response.headers.raw)
elif _is_async_gen_callable(dependant.call) or _is_gen_callable(
@@ -807,7 +815,7 @@ class APIWebSocketRoute(routing.WebSocketRoute):
self.path_regex, self.path_format, self.param_convertors = compile_path(path)
(
self.dependant,
self._flat_dependant,
_,
self._embed_body_fields,
) = _build_dependant_with_parameterless_dependencies(
path=self.path_format,
@@ -849,16 +857,16 @@ def _build_dependant_with_parameterless_dependencies(
path: str,
call: Callable[..., Any],
dependencies: Sequence[params.Depends],
) -> tuple[Dependant, Dependant, bool]:
) -> tuple[Dependant, list[ModelField], bool]:
dependant = get_dependant(path=path, call=call, scope="function")
for depends in dependencies[::-1]:
dependant.dependencies.insert(
0,
get_parameterless_sub_dependant(depends=depends, path=path),
)
flat_dependant = get_flat_dependant(dependant)
embed_body_fields = _should_embed_body_fields(flat_dependant.body_params)
return dependant, flat_dependant, embed_body_fields
body_params = _get_flat_body_params(dependant)
embed_body_fields = _should_embed_body_fields(body_params)
return dependant, body_params, embed_body_fields
class _RouteWithPath(Protocol):
@@ -944,7 +952,6 @@ class _APIRouteLike(Protocol):
description: str
response_fields: dict[int | str, ModelField]
dependant: Dependant
_flat_dependant: Dependant
_embed_body_fields: bool
body_field: ModelField | None
is_sse_stream: bool
@@ -983,32 +990,11 @@ def _populate_api_route_state(
generate_unique_id
),
strict_content_type: bool | DefaultPlaceholder = Default(True),
stream_item_type: Any | None = None,
) -> None:
route.path = path
route.endpoint = endpoint
route.stream_item_type = None
if isinstance(response_model, DefaultPlaceholder):
return_annotation = get_typed_return_annotation(endpoint)
if lenient_issubclass(return_annotation, Response):
response_model = None
else:
stream_item = get_stream_item_type(return_annotation)
if stream_item is not None:
# Extract item type for JSONL or SSE streaming when
# response_class is DefaultPlaceholder (JSONL) or
# EventSourceResponse (SSE).
# ServerSentEvent is excluded: it's a transport
# wrapper, not a data model, so it shouldn't feed
# into validation or OpenAPI schema generation.
if (
isinstance(response_class, DefaultPlaceholder)
or lenient_issubclass(response_class, EventSourceResponse)
) and not lenient_issubclass(stream_item, ServerSentEvent):
route.stream_item_type = stream_item
response_model = None
else:
response_model = return_annotation
route.response_model = response_model
route.stream_item_type = stream_item_type
route.summary = summary
route.response_description = response_description
route.deprecated = deprecated
@@ -1044,27 +1030,6 @@ def _populate_api_route_state(
if isinstance(status_code, IntEnum):
status_code = int(status_code)
route.status_code = status_code
if route.response_model:
assert is_body_allowed_for_status_code(status_code), (
f"Status code {status_code} must not have a response body"
)
response_name = "Response_" + route.unique_id
route.response_field = create_model_field(
name=response_name,
type_=route.response_model,
mode="serialization",
)
else:
route.response_field = None
if route.stream_item_type:
stream_item_name = "StreamItem_" + route.unique_id
route.stream_item_field = create_model_field(
name=stream_item_name,
type_=route.stream_item_type,
mode="serialization",
)
else:
route.stream_item_field = None
route.dependencies = list(dependencies or [])
route.description = description or inspect.cleandoc(route.endpoint.__doc__ or "")
# if a "form feed" character (page break) is found in the description text,
@@ -1091,15 +1056,15 @@ def _populate_api_route_state(
assert callable(endpoint), "An endpoint must be a callable"
(
route.dependant,
route._flat_dependant,
body_params,
route._embed_body_fields,
) = _build_dependant_with_parameterless_dependencies(
path=route.path_format,
call=route.endpoint,
dependencies=route.dependencies,
)
route.body_field = get_body_field(
flat_dependant=route._flat_dependant,
route.body_field = _get_body_field(
body_params=body_params,
name=route.unique_id,
embed_body_fields=route._embed_body_fields,
)
@@ -1113,6 +1078,49 @@ def _populate_api_route_state(
route.is_json_stream = is_generator and isinstance(
response_class, DefaultPlaceholder
)
if isinstance(response_model, DefaultPlaceholder):
return_annotation = get_typed_return_annotation(endpoint)
if lenient_issubclass(return_annotation, Response):
response_model = None
else:
stream_item = get_stream_item_type(return_annotation)
if stream_item is not None and is_generator:
# Extract item type for JSONL or SSE streaming for
# generator endpoints when response_class is
# DefaultPlaceholder (JSONL) or EventSourceResponse (SSE).
# ServerSentEvent is excluded: it's a transport
# wrapper, not a data model, so it shouldn't feed
# into validation or OpenAPI schema generation.
if (
isinstance(response_class, DefaultPlaceholder)
or lenient_issubclass(response_class, EventSourceResponse)
) and not lenient_issubclass(stream_item, ServerSentEvent):
route.stream_item_type = stream_item
response_model = None
else:
response_model = return_annotation
route.response_model = response_model
if route.response_model:
assert is_body_allowed_for_status_code(status_code), (
f"Status code {status_code} must not have a response body"
)
response_name = "Response_" + route.unique_id
route.response_field = create_model_field(
name=response_name,
type_=route.response_model,
mode="serialization",
)
else:
route.response_field = None
if route.stream_item_type:
stream_item_name = "StreamItem_" + route.unique_id
route.stream_item_field = create_model_field(
name=stream_item_name,
type_=route.stream_item_type,
mode="serialization",
)
else:
route.stream_item_field = None
class APIRoute(routing.Route):
@@ -1145,7 +1153,6 @@ class APIRoute(routing.Route):
description: str
response_fields: dict[int | str, ModelField]
dependant: Dependant
_flat_dependant: Dependant
_embed_body_fields: bool
body_field: ModelField | None
is_sse_stream: bool
@@ -1410,7 +1417,6 @@ class _EffectiveRouteContext:
description: str = ""
response_fields: dict[int | str, ModelField] = field(default_factory=dict)
dependant: Dependant | None = None
_flat_dependant: Dependant | None = None
_embed_body_fields: bool = False
body_field: ModelField | None = None
is_sse_stream: bool = False
@@ -1467,6 +1473,7 @@ class _EffectiveRouteContext:
include_context.included_router.strict_content_type,
include_context.strict_content_type,
),
stream_item_type=route.stream_item_type,
)
return context
@@ -1486,7 +1493,7 @@ class _EffectiveRouteContext:
)
(
context.dependant,
context._flat_dependant,
_,
context._embed_body_fields,
) = _build_dependant_with_parameterless_dependencies(
path="",
@@ -1871,13 +1878,31 @@ def _get_resolved_absolute_path(path: str | os.PathLike[str]) -> str:
return os.path.realpath(os.fspath(path))
def _resolve_frontend_check_dir(
*,
directory: str | os.PathLike[str],
check_dir: bool | Literal["auto"],
) -> bool:
if check_dir != "auto":
return check_dir
if os.environ.get("FASTAPI_ENV") != "development":
return True
if not os.path.isdir(directory):
warnings.warn(
f"Frontend directory '{directory}' does not exist. "
f"Resolved absolute path: '{_get_resolved_absolute_path(directory)}'",
stacklevel=3,
)
return False
class _FrontendStaticFiles(StaticFiles):
def __init__(
self,
*,
directory: str | os.PathLike[str],
fallback: Literal["auto", "index.html", "404.html"] | None,
check_dir: bool = True,
check_dir: bool,
) -> None:
self.fallback = fallback
if check_dir and not os.path.isdir(directory):
@@ -1912,6 +1937,12 @@ class _FrontendStaticFiles(StaticFiles):
assert isinstance(path, str)
return os.path.normpath(os.path.join(*path.split("/")))
async def get_response_for_scope(self, scope: Scope) -> Response:
if not self.config_checked:
await self.check_config()
self.config_checked = True
return await self.get_response(self.get_path(scope), scope)
async def get_response(self, path: str, scope: Scope) -> Response:
if scope["method"] not in ("GET", "HEAD"):
if await self._lookup_static_resource(path) is not None:
@@ -2020,7 +2051,7 @@ class _FrontendRoute(BaseRoute):
*,
directory: str | os.PathLike[str],
fallback: Literal["auto", "index.html", "404.html"] | None = "auto",
check_dir: bool = True,
check_dir: bool,
) -> None:
if fallback not in {"auto", "index.html", "404.html", None}:
raise AssertionError(
@@ -2062,7 +2093,8 @@ class _FrontendRoute(BaseRoute):
return None
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send)
response = await self.app.get_response_for_scope(scope)
await response(scope, receive, send)
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
raise NoMatchFound(name, path_params)
@@ -2080,7 +2112,7 @@ class _FrontendRouteGroup(BaseRoute):
self.dependency_overrides_provider = dependency_overrides_provider
(
self.dependant,
self._flat_dependant,
_,
self._embed_body_fields,
) = _build_dependant_with_parameterless_dependencies(
path="",
@@ -2094,7 +2126,7 @@ class _FrontendRouteGroup(BaseRoute):
*,
directory: str | os.PathLike[str],
fallback: Literal["auto", "index.html", "404.html"] | None = "auto",
check_dir: bool = True,
check_dir: bool,
) -> None:
self.routes.append(
_FrontendRoute(
@@ -2165,8 +2197,12 @@ class _FrontendRouteGroup(BaseRoute):
dependant=dependant,
dependency_overrides_provider=dependency_overrides_provider,
embed_body_fields=embed_body_fields,
):
await route.handle(scope, receive, send)
) as solved_result:
response = await route.app.get_response_for_scope(scope)
if response.background is None:
response.background = solved_result.background_tasks
response.headers.raw.extend(solved_result.response.headers.raw)
await response(scope, receive, send)
return
await route.handle(scope, receive, send)
@@ -2186,7 +2222,7 @@ class _FrontendRouteGroup(BaseRoute):
dependant: Dependant,
dependency_overrides_provider: Any | None,
embed_body_fields: bool,
) -> AsyncIterator[None]:
) -> AsyncIterator[SolvedDependency]:
request = Request(scope, receive, send)
previous_inner_astack = scope.get("fastapi_inner_astack", _SCOPE_MISSING)
previous_function_astack = scope.get("fastapi_function_astack", _SCOPE_MISSING)
@@ -2204,7 +2240,7 @@ class _FrontendRouteGroup(BaseRoute):
)
if solved_result.errors:
raise RequestValidationError(solved_result.errors)
yield
yield solved_result
finally:
if previous_inner_astack is _SCOPE_MISSING:
scope.pop("fastapi_inner_astack", None)
@@ -2619,13 +2655,16 @@ class APIRouter(routing.Router):
),
] = "auto",
check_dir: Annotated[
bool,
bool | Literal["auto"],
Doc(
"""
Check that the frontend directory exists when the app is created.
Check that the frontend directory exists when the app is created. When
set to `"auto"`, skip the check with a warning when `FASTAPI_ENV` is
`"development"`, and check it otherwise. The `fastapi dev` command
sets `FASTAPI_ENV` to `"development"` if it is not already set.
"""
),
] = True,
] = "auto",
) -> None:
"""
Serve a static frontend build as low-priority routes.
@@ -2659,6 +2698,9 @@ class APIRouter(routing.Router):
app.include_router(router)
```
"""
check_dir = _resolve_frontend_check_dir(
directory=directory, check_dir=check_dir
)
normalized_path = _normalize_frontend_path(path)
if self._frontend_routes is None:
self._frontend_routes = _FrontendRouteGroup(
+9 -3
View File
@@ -156,6 +156,12 @@ class ServerSentEvent(BaseModel):
return self
def _split_sse_lines(value: str) -> list[str]:
# Split on SSE-spec line terminators only (\n, \r\n, \r), preserving
# trailing empty strings.
return value.replace("\r\n", "\n").replace("\r", "\n").split("\n")
def format_sse_event(
*,
data_str: Annotated[
@@ -201,19 +207,19 @@ def format_sse_event(
) -> bytes:
"""Build SSE wire-format bytes from **pre-serialized** data.
The result always ends with `\n\n` (the event terminator).
The result always ends with `\\n\\n` (the event terminator).
"""
lines: list[str] = []
if comment is not None:
for line in comment.splitlines():
for line in _split_sse_lines(comment):
lines.append(f": {line}")
if event is not None:
lines.append(f"event: {event}")
if data_str is not None:
for line in data_str.splitlines():
for line in _split_sse_lines(data_str):
lines.append(f"data: {line}")
if id is not None:
+3 -3
View File
@@ -58,7 +58,7 @@ Changelog = "https://fastapi.tiangolo.com/release-notes/"
[project.optional-dependencies]
standard = [
"fastapi-cli[standard] >=0.0.8",
"fastapi-cli[standard] >=0.0.32",
"fastar >= 0.9.0",
# For the test client
"httpx >=0.23.0,<1.0.0",
@@ -77,7 +77,7 @@ standard = [
]
standard-no-fastapi-cloud-cli = [
"fastapi-cli[standard-no-fastapi-cloud-cli] >=0.0.8",
"fastapi-cli[standard-no-fastapi-cloud-cli] >=0.0.32",
# For the test client
"httpx >=0.23.0,<1.0.0",
# For templates
@@ -95,7 +95,7 @@ standard-no-fastapi-cloud-cli = [
]
all = [
"fastapi-cli[standard] >=0.0.8",
"fastapi-cli[standard] >=0.0.32",
# # For the test client
"httpx >=0.23.0,<1.0.0",
# For templates
+33
View File
@@ -0,0 +1,33 @@
import sys
import pytest
from tests.benchmarks.utils import (
ROUTE_COUNT,
ROUTE_PATH_PREFIX,
create_openapi_app,
generate_openapi,
)
if "--codspeed" not in sys.argv:
pytest.skip(
"Benchmark tests are skipped by default; run with --codspeed.",
allow_module_level=True,
)
@pytest.mark.timeout(60)
def test_openapi_dependency_graph(benchmark) -> None:
app = create_openapi_app()
schema = benchmark(generate_openapi, app)
dynamic_paths = [
path for path in schema["paths"] if path.startswith(ROUTE_PATH_PREFIX)
]
assert len(dynamic_paths) == ROUTE_COUNT
assert all(
any(
parameter["in"] == "query" and parameter["name"] == "query_value"
for parameter in schema["paths"][path]["get"]["parameters"]
)
for path in dynamic_paths
)
+51
View File
@@ -0,0 +1,51 @@
from collections.abc import Callable
from typing import Annotated, Any
from fastapi import Depends, FastAPI
LAST_DEPENDENCY_INDEX = 100
ROUTE_COUNT = 20
ROUTE_PATH_PREFIX = "/openapi-route-"
def create_openapi_app() -> FastAPI:
app = FastAPI()
dependencies: dict[int, Callable[..., Any]] = {}
def create_dependency(index: int) -> Callable[..., Any]:
if index == LAST_DEPENDENCY_INDEX:
def dependency(query_value: int = index) -> str:
return str(query_value)
dependency.__name__ = f"dependency_{index}"
return dependency
next_dependency = dependencies[index + 1]
async def dependency(
sub_dependency: Annotated[str, Depends(next_dependency)],
query_value: int = index,
) -> str:
return f"{query_value} -> {sub_dependency}"
dependency.__name__ = f"dependency_{index}"
return dependency
for index in reversed(range(LAST_DEPENDENCY_INDEX + 1)):
dependencies[index] = create_dependency(index)
async def endpoint(
value: Annotated[str, Depends(dependencies[0])],
) -> dict[str, str]:
return {"value": value}
for index in range(ROUTE_COUNT):
app.add_api_route(f"{ROUTE_PATH_PREFIX}{index}", endpoint, methods=["GET"])
return app
def generate_openapi(app: FastAPI) -> dict[str, Any]:
app.openapi_schema = None
return app.openapi()
+33
View File
@@ -0,0 +1,33 @@
import sys
import pytest
from tests.benchmarks.utils import (
ROUTE_COUNT,
ROUTE_PATH_PREFIX,
create_openapi_app,
generate_openapi,
)
if "--codspeed" not in sys.argv:
pytest.skip(
"Benchmark tests are skipped by default; run with --codspeed.",
allow_module_level=True,
)
@pytest.mark.timeout(60)
def test_openapi_dependency_graph(benchmark) -> None:
app = create_openapi_app()
schema = benchmark(generate_openapi, app)
dynamic_paths = [
path for path in schema["paths"] if path.startswith(ROUTE_PATH_PREFIX)
]
assert len(dynamic_paths) == ROUTE_COUNT
assert all(
any(
parameter["in"] == "query" and parameter["name"] == "query_value"
for parameter in schema["paths"][path]["get"]["parameters"]
)
for path in dynamic_paths
)
@@ -0,0 +1,64 @@
import sys
from collections.abc import Callable
from typing import Annotated, Any
import pytest
from fastapi import Depends, FastAPI
from fastapi.routing import APIRoute
if "--codspeed" not in sys.argv:
pytest.skip(
"Benchmark tests are skipped by default; run with --codspeed.",
allow_module_level=True,
)
LAST_DEPENDENCY_INDEX = 100
ROUTE_COUNT = 20
ROUTE_PATH_PREFIX = "/route-"
def _create_app() -> FastAPI:
app = FastAPI()
dependencies: dict[int, Callable[..., Any]] = {}
def create_dependency(index: int) -> Callable[..., Any]:
if index == LAST_DEPENDENCY_INDEX:
def dependency() -> str:
return str(index)
dependency.__name__ = f"dependency_{index}"
return dependency
next_dependency = dependencies[index + 1]
async def dependency(
sub_dependency: Annotated[str, Depends(next_dependency)],
) -> str:
return f"{index} -> {sub_dependency}"
dependency.__name__ = f"dependency_{index}"
return dependency
for index in reversed(range(LAST_DEPENDENCY_INDEX + 1)):
dependencies[index] = create_dependency(index)
async def endpoint(
value: Annotated[str, Depends(dependencies[0])],
) -> dict[str, str]:
return {"value": value}
for index in range(ROUTE_COUNT):
app.add_api_route(f"{ROUTE_PATH_PREFIX}{index}", endpoint)
return app
def test_route_dependency_graph(benchmark) -> None:
app = benchmark(_create_app)
api_routes = [
route
for route in app.routes
if isinstance(route, APIRoute) and route.path.startswith(ROUTE_PATH_PREFIX)
]
assert len(api_routes) == ROUTE_COUNT
+22 -5
View File
@@ -1,4 +1,4 @@
from collections.abc import AsyncGenerator, Generator
from collections.abc import AsyncGenerator, Callable, Generator
from typing import Any
from fastapi.dependencies.models import (
@@ -6,7 +6,6 @@ from fastapi.dependencies.models import (
_get_cache_key,
_get_computed_scope,
_get_oauth_scopes,
_get_security_dependencies,
_get_security_scheme,
_is_async_gen_callable,
_is_async_gen_callable_cached,
@@ -93,7 +92,27 @@ def test_callable_classification_is_shared_by_call() -> None:
cache_info = cached_function.cache_info()
assert cache_info.hits == 1
assert cache_info.misses == 1
assert cache_info.maxsize == 1024
assert cache_info.maxsize == 4096
def test_callable_classification_cache_supports_large_apps() -> None:
callables: list[Callable[[], None]] = [lambda: None for _ in range(3000)]
for classifier, cached_classifier in (
(_is_gen_callable, _is_gen_callable_cached),
(_is_async_gen_callable, _is_async_gen_callable_cached),
(_is_coroutine_callable, _is_coroutine_callable_cached),
):
cached_classifier.cache_clear()
for _ in range(2):
assert all(not classifier(call) for call in callables)
cache_info = cached_classifier.cache_info()
assert cache_info.hits == len(callables)
assert cache_info.misses == len(callables)
assert cache_info.maxsize == 4096
cached_classifier.cache_clear()
def test_unhashable_callable_classification() -> None:
@@ -126,7 +145,6 @@ def test_derived_values_are_not_stored_on_dependant() -> None:
assert _get_oauth_scopes(dependant=dependant) == []
assert not _uses_scopes(dependant=dependant, cache=uses_scopes_cache)
assert not _uses_scopes(dependant=dependant, cache=uses_scopes_cache)
assert _get_security_dependencies(dependant=dependant) == []
assert _get_computed_scope(dependant=dependant) is None
assert _get_cache_key(dependant=dependant) == (async_dependency, (), "")
@@ -140,7 +158,6 @@ def test_security_scheme_helpers() -> None:
assert _is_security_scheme(dependant=security_dependant)
assert _get_security_scheme(dependant=security_dependant) is security_scheme
assert _get_security_dependencies(dependant=dependant) == [security_dependant]
assert _uses_scopes(dependant=dependant)
+71 -2
View File
@@ -7,7 +7,15 @@ from typing import Literal
import anyio
import pytest
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, WebSocket
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
FastAPI,
HTTPException,
Request,
WebSocket,
)
from fastapi.testclient import TestClient
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import PlainTextResponse, Response
@@ -491,6 +499,30 @@ def test_app_middleware_still_runs_for_frontend_dependencies(tmp_path: Path):
assert calls == ["middleware-before", "dependency", "middleware-after"]
def test_frontend_dependency_response_headers_and_background_tasks(tmp_path: Path):
calls: list[str] = []
def frontend_dependency(
response: Response, background_tasks: BackgroundTasks
) -> None:
response.headers["X-Frontend-Dependency"] = "applied"
response.set_cookie("frontend", "dependency")
background_tasks.add_task(calls.append, "background")
dist = tmp_path / "dist"
write_file(dist / "index.html", "app")
app = FastAPI(dependencies=[Depends(frontend_dependency)])
app.frontend("/", directory=dist)
response = TestClient(app).get("/")
assert response.status_code == 200
assert response.text == "app"
assert response.headers["X-Frontend-Dependency"] == "applied"
assert response.cookies["frontend"] == "dependency"
assert calls == ["background"]
def test_frontend_dependency_validation_errors_return_422(tmp_path: Path):
def require_token(token: str) -> None:
pass # pragma: no cover
@@ -1176,13 +1208,50 @@ def test_check_dir_true_fails_early_for_missing_directory(monkeypatch, tmp_path:
monkeypatch.chdir(tmp_path)
with pytest.raises(RuntimeError, match="does not exist") as exc_info:
app.frontend("/", directory="missing")
app.frontend("/", directory="missing", check_dir=True)
message = str(exc_info.value)
assert "'missing'" in message
assert str(tmp_path / "missing") in message
def test_check_dir_auto_warns_in_development(monkeypatch, tmp_path: Path):
monkeypatch.setenv("FASTAPI_ENV", "development")
app = FastAPI()
with pytest.warns(UserWarning, match="does not exist") as warnings:
app.frontend("/", directory=tmp_path / "missing")
assert str(tmp_path / "missing") in str(warnings[0].message)
assert warnings[0].filename == __file__
def test_check_dir_auto_router_warning_points_to_user_code(monkeypatch, tmp_path: Path):
monkeypatch.setenv("FASTAPI_ENV", "development")
router = APIRouter()
with pytest.warns(UserWarning, match="does not exist") as warnings:
router.frontend("/", directory=tmp_path / "missing")
assert warnings[0].filename == __file__
def test_check_dir_true_fails_in_development(monkeypatch, tmp_path: Path):
monkeypatch.setenv("FASTAPI_ENV", "development")
app = FastAPI()
with pytest.raises(RuntimeError, match="does not exist"):
app.frontend("/", directory=tmp_path / "missing", check_dir=True)
def test_check_dir_auto_fails_outside_development(monkeypatch, tmp_path: Path):
monkeypatch.setenv("FASTAPI_ENV", "production")
router = APIRouter()
with pytest.raises(RuntimeError, match="does not exist"):
router.frontend("/", directory=tmp_path / "missing")
def test_check_dir_false_allows_missing_directory_and_fails_on_request(tmp_path: Path):
app = FastAPI()
app.frontend("/", directory=tmp_path / "missing", check_dir=False)
+14
View File
@@ -202,6 +202,20 @@ def test_encode_model_with_default():
}
def test_encode_model_with_default_in_dict_and_list():
model = ModelWithDefault(foo="foo", bar="bar")
assert jsonable_encoder([model], exclude_defaults=True) == [{"foo": "foo"}]
assert jsonable_encoder({"key": model}, exclude_defaults=True) == {
"key": {"foo": "foo"}
}
assert jsonable_encoder({"key": [model]}, exclude_defaults=True) == {
"key": [{"foo": "foo"}]
}
assert jsonable_encoder({"key": model}) == {
"key": {"foo": "foo", "bar": "bar", "bla": "bla"}
}
def test_custom_encoders():
class safe_datetime(datetime):
pass
+143
View File
@@ -0,0 +1,143 @@
from typing import Annotated
from dirty_equals import IsList
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from pydantic import Field
MaxSizedSet = Annotated[set[str], Field(max_length=3)]
app = FastAPI()
@app.get("/")
def read_root(foo: Annotated[MaxSizedSet | None, Query()] = None):
return {"foo": foo}
client = TestClient(app)
def test_endpoint_none():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"foo": None}
def test_endpoint_valid():
response = client.get("/", params={"foo": ["a", "b"]})
assert response.status_code == 200
assert response.json() == {"foo": IsList("a", "b", check_order=False)}
def test_endpoint_too_long():
response = client.get("/", params={"foo": ["a", "b", "c", "d"]})
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
{
"type": "too_long",
"loc": ["query", "foo"],
"msg": "Set should have at most 3 items after validation, not more",
"input": IsList("a", "b", "c", "d", check_order=False),
"ctx": {
"actual_length": None,
"field_type": "Set",
"max_length": 3,
},
}
]
}
)
def test_openapi():
assert app.openapi() == snapshot(
{
"components": {
"schemas": {
"HTTPValidationError": {
"properties": {
"detail": {
"items": {
"$ref": "#/components/schemas/ValidationError"
},
"title": "Detail",
"type": "array",
},
},
"title": "HTTPValidationError",
"type": "object",
},
"ValidationError": {
"properties": {
"ctx": {"title": "Context", "type": "object"},
"input": {"title": "Input"},
"loc": {
"items": {
"anyOf": [{"type": "string"}, {"type": "integer"}],
},
"title": "Location",
"type": "array",
},
"msg": {"title": "Message", "type": "string"},
"type": {"title": "Error Type", "type": "string"},
},
"required": ["loc", "msg", "type"],
"title": "ValidationError",
"type": "object",
},
},
},
"info": {
"title": "FastAPI",
"version": "0.1.0",
},
"openapi": "3.1.0",
"paths": {
"/": {
"get": {
"operationId": "read_root__get",
"parameters": [
{
"in": "query",
"name": "foo",
"required": False,
"schema": {
"anyOf": [
{
"items": {"type": "string"},
"maxItems": 3,
"type": "array",
"uniqueItems": True,
},
{"type": "null"},
],
"title": "Foo",
},
},
],
"responses": {
"200": {
"content": {"application/json": {"schema": {}}},
"description": "Successful Response",
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError",
},
},
},
"description": "Validation Error",
},
},
"summary": "Read Root",
},
},
},
}
)
+32
View File
@@ -1,3 +1,5 @@
from collections.abc import Iterable
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
@@ -65,6 +67,21 @@ def get_exclude_unset_none() -> ModelDefaults:
return ModelDefaults(x=None, y="y")
@app.get("/iterable_exclude_unset", response_model_exclude_unset=True)
def get_iterable_exclude_unset() -> Iterable[ModelDefaults]:
return [ModelDefaults(x=None, y="y")]
@app.get("/iterable_exclude_defaults", response_model_exclude_defaults=True)
def get_iterable_exclude_defaults() -> Iterable[ModelDefaults]:
return [ModelDefaults(x=None, y="y")]
@app.get("/iterable_exclude_none", response_model_exclude_none=True)
def get_iterable_exclude_none() -> Iterable[ModelDefaults]:
return [ModelDefaults(x=None, y="y")]
client = TestClient(app)
@@ -91,3 +108,18 @@ def test_return_exclude_none():
def test_return_exclude_unset_none():
response = client.get("/exclude_unset_none")
assert response.json() == {"y": "y"}
def test_return_iterable_exclude_unset():
response = client.get("/iterable_exclude_unset")
assert response.json() == [{"x": None, "y": "y"}]
def test_return_iterable_exclude_defaults():
response = client.get("/iterable_exclude_defaults")
assert response.json() == [{}]
def test_return_iterable_exclude_none():
response = client.get("/iterable_exclude_none")
assert response.json() == [{"y": "y", "z": "z"}]
+165 -2
View File
@@ -6,7 +6,7 @@ import fastapi.routing
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.responses import EventSourceResponse
from fastapi.sse import ServerSentEvent
from fastapi.sse import ServerSentEvent, format_sse_event
from fastapi.testclient import TestClient
from pydantic import BaseModel
@@ -64,7 +64,8 @@ async def sse_items_event():
@app.get("/items/stream-mixed", response_class=EventSourceResponse)
async def sse_items_mixed() -> AsyncIterable[Item]:
yield items[0]
for item in items:
yield item
yield ServerSentEvent(data="custom-event", event="special")
yield items[1]
@@ -96,6 +97,12 @@ async def stream_events():
yield {"msg": "world"}
@router.get("/events-typed", response_class=EventSourceResponse)
async def stream_events_typed() -> AsyncIterable[Item]:
for item in items:
yield item
app.include_router(router, prefix="/api")
@@ -274,6 +281,45 @@ def test_sse_on_router_included_in_app(client: TestClient):
assert len(data_lines) == 2
def test_sse_router_typed_stream(client: TestClient):
response = client.get("/api/events-typed")
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
data_lines = [
line for line in response.text.strip().split("\n") if line.startswith("data: ")
]
assert len(data_lines) == 3
def test_sse_router_typed_openapi_schema(client: TestClient):
"""Typed SSE endpoint on a router should preserve itemSchema with contentSchema."""
response = client.get("/openapi.json")
assert response.status_code == 200
paths = response.json()["paths"]
sse_response = paths["/api/events-typed"]["get"]["responses"]["200"]
assert sse_response == {
"description": "Successful Response",
"content": {
"text/event-stream": {
"itemSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {"$ref": "#/components/schemas/Item"},
},
"event": {"type": "string"},
"id": {"type": "string"},
"retry": {"type": "integer", "minimum": 0},
},
"required": ["data"],
}
}
},
}
# Keepalive ping tests
@@ -325,3 +371,120 @@ def test_no_keepalive_when_fast(client: TestClient):
assert response.status_code == 200
# KEEPALIVE_COMMENT is ": ping\n\n".
assert ": ping\n" not in response.text
@pytest.mark.parametrize(
("data", "expected_result"),
[
("Hello\n", b"data: Hello\ndata: \n\n"),
("Hello\n\n", b"data: Hello\ndata: \ndata: \n\n"),
("\n", b"data: \ndata: \n\n"),
("Hello\r\nWorld", b"data: Hello\ndata: World\n\n"),
("Hello\rWorld", b"data: Hello\ndata: World\n\n"),
("A\u2028B", "data: A\u2028B\n\n".encode()),
("A\vB", b"data: A\x0bB\n\n"),
("", b"data: \n\n"),
],
)
def test_format_sse_event_splitlines_behavior_in_data(
data: str, expected_result: bytes
) -> None:
assert format_sse_event(data_str=data) == expected_result
def test_format_sse_event_splitlines_behavior_in_comment():
assert format_sse_event(comment="hi\n") == b": hi\n: \n\n"
# default_response_class tests
sse_schema_response = {
"description": "Successful Response",
"content": {
"text/event-stream": {
"itemSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {"$ref": "#/components/schemas/Item"},
},
"event": {"type": "string"},
"id": {"type": "string"},
"retry": {"type": "integer", "minimum": 0},
},
"required": ["data"],
}
}
},
}
# default_response_class on app
default_app_app = FastAPI(default_response_class=EventSourceResponse)
default_app_router = APIRouter()
@default_app_router.get("/stream")
async def default_app_stream() -> AsyncIterable[Item]:
for item in items:
yield item
default_app_app.include_router(default_app_router, prefix="/api")
def test_default_response_class_on_app_stream():
with TestClient(default_app_app) as client:
response = client.get("/api/stream")
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
data_lines = [
line for line in response.text.strip().split("\n") if line.startswith("data: ")
]
assert len(data_lines) == 3
def test_default_response_class_on_app_openapi_schema():
assert (
default_app_app.openapi()["paths"]["/api/stream"]["get"]["responses"]["200"]
== sse_schema_response
)
# default_response_class on parent router
default_parent_app = FastAPI()
parent_router = APIRouter(default_response_class=EventSourceResponse)
child_router = APIRouter()
@child_router.get("/stream")
async def default_parent_stream() -> AsyncIterable[Item]:
for item in items:
yield item
parent_router.include_router(child_router)
default_parent_app.include_router(parent_router, prefix="/api")
def test_default_response_class_on_parent_router_stream():
with TestClient(default_parent_app) as client:
response = client.get("/api/stream")
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
data_lines = [
line for line in response.text.strip().split("\n") if line.startswith("data: ")
]
assert len(data_lines) == 3
def test_default_response_class_on_parent_router_openapi_schema():
assert (
default_parent_app.openapi()["paths"]["/api/stream"]["get"]["responses"]["200"]
== sse_schema_response
)
+33 -1
View File
@@ -1,13 +1,14 @@
import json
from typing import AsyncIterable, Iterable # noqa: UP035 to test coverage
from fastapi import FastAPI
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
class Item(BaseModel):
name: str
optional: str | None = None
app = FastAPI()
@@ -23,6 +24,16 @@ def stream_bare_sync() -> Iterable:
yield {"name": "bar"}
router = APIRouter()
@router.get("/events-jsonl", response_model_exclude_none=True)
async def stream_events_jsonl() -> AsyncIterable[Item]:
yield Item(name="foo")
app.include_router(router, prefix="/api")
client = TestClient(app)
@@ -40,3 +51,24 @@ def test_stream_bare_sync_iterable():
assert response.headers["content-type"] == "application/jsonl"
lines = [json.loads(line) for line in response.text.strip().splitlines()]
assert lines == [{"name": "bar"}]
def test_jsonl_router_typed_stream():
response = client.get("/api/events-jsonl")
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_jsonl_router_typed_openapi_schema():
response = client.get("/openapi.json")
assert response.status_code == 200
paths = response.json()["paths"]
jsonl_response = paths["/api/events-jsonl"]["get"]["responses"]["200"]
assert jsonl_response == {
"description": "Successful Response",
"content": {
"application/jsonl": {"itemSchema": {"$ref": "#/components/schemas/Item"}}
},
}
+452
View File
@@ -0,0 +1,452 @@
from collections.abc import AsyncIterable
import pytest
from fastapi import Depends, FastAPI, Response
from fastapi.responses import EventSourceResponse, StreamingResponse
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
SSE_RESPONSE = {
"description": "Successful Response",
"content": {
"text/event-stream": {
"itemSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {
"type": "object",
"additionalProperties": {"type": "string"},
"title": "SSE stream item",
},
},
"event": {"type": "string"},
"id": {"type": "string"},
"retry": {"type": "integer", "minimum": 0},
},
"required": ["data"],
}
}
},
}
JSONL_RESPONSE = {
"description": "Successful Response",
"content": {
"application/jsonl": {
"itemSchema": {
"type": "object",
"additionalProperties": {"type": "string"},
"title": "JSONL stream item",
}
}
},
}
app = FastAPI()
def set_accepted(response: Response) -> None:
response.status_code = 202
@app.post("/sse", response_class=EventSourceResponse, status_code=201)
async def sse() -> AsyncIterable[dict[str, str]]:
yield {"message": "created"}
@app.post("/jsonl", status_code=201)
async def jsonl() -> AsyncIterable[dict[str, str]]:
yield {"message": "created"}
@app.post("/raw", response_class=StreamingResponse, status_code=201)
async def raw() -> AsyncIterable[str]:
yield "accepted"
@app.post(
"/sse-dependency",
response_class=EventSourceResponse,
responses={202: SSE_RESPONSE},
)
async def sse_dependency(
accepted: None = Depends(set_accepted),
) -> AsyncIterable[dict[str, str]]:
yield {"message": "accepted"}
@app.post("/jsonl-dependency", responses={202: JSONL_RESPONSE})
async def jsonl_dependency(
accepted: None = Depends(set_accepted),
) -> AsyncIterable[dict[str, str]]:
yield {"message": "accepted"}
@app.post(
"/raw-dependency",
response_class=StreamingResponse,
responses={202: {"description": "Accepted"}},
)
async def raw_dependency(
accepted: None = Depends(set_accepted),
) -> AsyncIterable[str]:
yield "accepted"
@app.post(
"/sse-dependency-override",
response_class=EventSourceResponse,
status_code=201,
responses={202: SSE_RESPONSE},
)
async def sse_dependency_override(
accepted: None = Depends(set_accepted),
) -> AsyncIterable[dict[str, str]]:
yield {"message": "overridden"}
@app.post(
"/jsonl-dependency-override",
status_code=201,
responses={202: JSONL_RESPONSE},
)
async def jsonl_dependency_override(
accepted: None = Depends(set_accepted),
) -> AsyncIterable[dict[str, str]]:
yield {"message": "overridden"}
@app.post(
"/raw-dependency-override",
response_class=StreamingResponse,
status_code=201,
responses={202: {"description": "Accepted"}},
)
async def raw_dependency_override(
accepted: None = Depends(set_accepted),
) -> AsyncIterable[str]:
yield "overridden"
client = TestClient(app)
@pytest.mark.parametrize(
"path,expected_status_code",
[
("/sse", 201),
("/jsonl", 201),
("/raw", 201),
("/sse-dependency", 202),
("/jsonl-dependency", 202),
("/raw-dependency", 202),
("/sse-dependency-override", 202),
("/jsonl-dependency-override", 202),
("/raw-dependency-override", 202),
],
)
def test_status_code(path: str, expected_status_code: int) -> None:
response = client.post(path)
assert response.status_code == expected_status_code
def test_openapi() -> None:
openapi = app.openapi()
assert openapi == snapshot(
{
"openapi": "3.1.0",
"info": {"title": "FastAPI", "version": "0.1.0"},
"paths": {
"/sse": {
"post": {
"summary": "Sse",
"operationId": "sse_sse_post",
"responses": {
"201": {
"description": "Successful Response",
"content": {
"text/event-stream": {
"itemSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"title": "Streamitem Sse Sse Post",
},
},
"event": {"type": "string"},
"id": {"type": "string"},
"retry": {
"type": "integer",
"minimum": 0,
},
},
"required": ["data"],
}
}
},
}
},
}
},
"/jsonl": {
"post": {
"summary": "Jsonl",
"operationId": "jsonl_jsonl_post",
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/jsonl": {
"itemSchema": {
"type": "object",
"additionalProperties": {"type": "string"},
"title": "Streamitem Jsonl Jsonl Post",
}
}
},
}
},
}
},
"/raw": {
"post": {
"summary": "Raw",
"operationId": "raw_raw_post",
"responses": {"201": {"description": "Successful Response"}},
}
},
"/sse-dependency": {
"post": {
"summary": "Sse Dependency",
"operationId": "sse_dependency_sse_dependency_post",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/event-stream": {
"itemSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"title": "Streamitem Sse Dependency Sse Dependency Post",
},
},
"event": {"type": "string"},
"id": {"type": "string"},
"retry": {
"type": "integer",
"minimum": 0,
},
},
"required": ["data"],
}
}
},
},
"202": {
"description": "Successful Response",
"content": {
"text/event-stream": {
"itemSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"title": "SSE stream item",
},
},
"event": {"type": "string"},
"id": {"type": "string"},
"retry": {
"type": "integer",
"minimum": 0,
},
},
"required": ["data"],
}
}
},
},
},
}
},
"/jsonl-dependency": {
"post": {
"summary": "Jsonl Dependency",
"operationId": "jsonl_dependency_jsonl_dependency_post",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/jsonl": {
"itemSchema": {
"type": "object",
"additionalProperties": {"type": "string"},
"title": "Streamitem Jsonl Dependency Jsonl Dependency Post",
}
}
},
},
"202": {
"description": "Successful Response",
"content": {
"application/jsonl": {
"itemSchema": {
"type": "object",
"additionalProperties": {"type": "string"},
"title": "JSONL stream item",
}
}
},
},
},
}
},
"/raw-dependency": {
"post": {
"summary": "Raw Dependency",
"operationId": "raw_dependency_raw_dependency_post",
"responses": {
"200": {"description": "Successful Response"},
"202": {"description": "Accepted"},
},
}
},
"/sse-dependency-override": {
"post": {
"summary": "Sse Dependency Override",
"operationId": "sse_dependency_override_sse_dependency_override_post",
"responses": {
"201": {
"description": "Successful Response",
"content": {
"text/event-stream": {
"itemSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"title": "Streamitem Sse Dependency Override Sse Dependency Override Post",
},
},
"event": {"type": "string"},
"id": {"type": "string"},
"retry": {
"type": "integer",
"minimum": 0,
},
},
"required": ["data"],
}
}
},
},
"202": {
"description": "Successful Response",
"content": {
"text/event-stream": {
"itemSchema": {
"type": "object",
"properties": {
"data": {
"type": "string",
"contentMediaType": "application/json",
"contentSchema": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"title": "SSE stream item",
},
},
"event": {"type": "string"},
"id": {"type": "string"},
"retry": {
"type": "integer",
"minimum": 0,
},
},
"required": ["data"],
}
}
},
},
},
}
},
"/jsonl-dependency-override": {
"post": {
"summary": "Jsonl Dependency Override",
"operationId": "jsonl_dependency_override_jsonl_dependency_override_post",
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/jsonl": {
"itemSchema": {
"type": "object",
"additionalProperties": {"type": "string"},
"title": "Streamitem Jsonl Dependency Override Jsonl Dependency Override Post",
}
}
},
},
"202": {
"description": "Successful Response",
"content": {
"application/jsonl": {
"itemSchema": {
"type": "object",
"additionalProperties": {"type": "string"},
"title": "JSONL stream item",
}
}
},
},
},
}
},
"/raw-dependency-override": {
"post": {
"summary": "Raw Dependency Override",
"operationId": "raw_dependency_override_raw_dependency_override_post",
"responses": {
"201": {"description": "Successful Response"},
"202": {"description": "Accepted"},
},
}
},
},
}
)
Generated
+15 -15
View File
@@ -1005,9 +1005,9 @@ requires-dist = [
{ name = "email-validator", marker = "extra == 'all'", specifier = ">=2.0.0" },
{ name = "email-validator", marker = "extra == 'standard'", specifier = ">=2.0.0" },
{ name = "email-validator", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=2.0.0" },
{ name = "fastapi-cli", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.0.8" },
{ name = "fastapi-cli", extras = ["standard"], marker = "extra == 'standard'", specifier = ">=0.0.8" },
{ name = "fastapi-cli", extras = ["standard-no-fastapi-cloud-cli"], marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.0.8" },
{ name = "fastapi-cli", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.0.32" },
{ name = "fastapi-cli", extras = ["standard"], marker = "extra == 'standard'", specifier = ">=0.0.32" },
{ name = "fastapi-cli", extras = ["standard-no-fastapi-cloud-cli"], marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.0.32" },
{ name = "fastar", marker = "extra == 'standard'", specifier = ">=0.9.0" },
{ name = "httpx", marker = "extra == 'all'", specifier = ">=0.23.0,<1.0.0" },
{ name = "httpx", marker = "extra == 'standard'", specifier = ">=0.23.0,<1.0.0" },
@@ -1144,7 +1144,7 @@ translations = [
[[package]]
name = "fastapi-cli"
version = "0.0.20"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "rich-toolkit" },
@@ -1152,9 +1152,9 @@ dependencies = [
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/d90fb3bfbcbd6e56c77afd9d114dd6ce8955d8bb90094399d1c70e659e40/fastapi_cli-0.0.20.tar.gz", hash = "sha256:d17c2634f7b96b6b560bc16b0035ed047d523c912011395f49f00a421692bc3a", size = 19786, upload-time = "2025-12-22T17:13:33.794Z" }
sdist = { url = "https://files.pythonhosted.org/packages/33/eb/3b534c6f8e157f9ddbf2a153512307c886cad0b258739c200dd8ff8c4452/fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424", size = 26636, upload-time = "2026-07-16T12:16:58.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/89/5c4eef60524d0fd704eb0706885b82cd5623a43396b94e4a5b17d3a3f516/fastapi_cli-0.0.20-py3-none-any.whl", hash = "sha256:e58b6a0038c0b1532b7a0af690656093dee666201b6b19d3c87175b358e9f783", size = 12390, upload-time = "2025-12-22T17:13:31.708Z" },
{ url = "https://files.pythonhosted.org/packages/d5/53/56ae5ae17bb0a5d89d1d31e5320eb1865553ebbfbde91cdc4c221245f2a8/fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4", size = 14670, upload-time = "2026-07-16T12:16:57.297Z" },
]
[package.optional-dependencies]
@@ -1384,14 +1384,14 @@ wheels = [
[[package]]
name = "gitpython"
version = "3.1.50"
version = "3.1.54"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitdb" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/d5/3da0b92033887033f4c27f2dd109a303c4ca62813c7b3bb2511edb4777de/gitpython-3.1.54.tar.gz", hash = "sha256:53f2085e24a2cda300eed7c3fc5f1559ae289634b725e98acaf4791940247aa0", size = 225076, upload-time = "2026-07-22T04:08:51.403Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b9/876f442a28df5c068ca69b0122d5c35e65fd2d2fa9992ea5cb5944ea00a6/gitpython-3.1.54-py3-none-any.whl", hash = "sha256:b90d7b3d9bc0238681d24369130826f0dcdb0ceaa45db67cf1d4ffa4c302dedf", size = 216575, upload-time = "2026-07-22T04:08:50.05Z" },
]
[[package]]
@@ -2820,11 +2820,11 @@ memory = [
[[package]]
name = "pyasn1"
version = "0.6.3"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
@@ -3166,15 +3166,15 @@ crypto = [
[[package]]
name = "pymdown-extensions"
version = "10.21.3"
version = "11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" },
{ url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" },
]
[[package]]