Compare commits

..

24 Commits

Author SHA1 Message Date
Sebastián Ramírez
daba0aa328 🔖 Release version 0.133.0 2026-02-24 10:51:43 +01:00
github-actions[bot]
0c3581d5c4 📝 Update release notes
[skip ci]
2026-02-24 09:50:02 +00:00
Sebastián Ramírez
c73bc94537 ⬆️ Add support for Starlette 1.0.0+ (#14987) 2026-02-24 09:49:32 +00:00
Sebastián Ramírez
6c68838615 🔖 Release version 0.132.1 2026-02-24 10:32:08 +01:00
github-actions[bot]
29d082ba24 📝 Update release notes
[skip ci]
2026-02-24 09:28:36 +00:00
Sebastián Ramírez
2686c7fbbf ♻️ Refactor logic to handle OpenAPI and Swagger UI escaping data (#14986) 2026-02-24 09:28:10 +00:00
github-actions[bot]
2f9c914d44 📝 Update release notes
[skip ci]
2026-02-23 18:48:43 +00:00
Sebastián Ramírez
0cf27ecf88 👥 Update FastAPI People - Experts (#14972)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com>
2026-02-23 19:47:59 +01:00
github-actions[bot]
3f30ca1a5e 📝 Update release notes
[skip ci]
2026-02-23 18:32:32 +00:00
Motov Yurii
6af3832126 👷 Allow skipping benchmark job in test workflow (#14974) 2026-02-23 19:31:54 +01:00
Sebastián Ramírez
acdf52e0c8 📝 Update release notes 2026-02-23 18:54:18 +01:00
Sebastián Ramírez
5c863d0718 🔖 Release version 0.132.0 2026-02-23 18:49:58 +01:00
github-actions[bot]
ac8621a76e 📝 Update release notes
[skip ci]
2026-02-23 17:46:11 +00:00
Sebastián Ramírez
22354a2530 🔒️ Add strict_content_type checking for JSON requests (#14978) 2026-02-23 18:45:20 +01:00
github-actions[bot]
94a1ee749e 📝 Update release notes
[skip ci]
2026-02-23 16:50:41 +00:00
dependabot[bot]
248d7fb9f5 ⬆ Bump flask from 3.1.2 to 3.1.3 (#14949)
Bumps [flask](https://github.com/pallets/flask) from 3.1.2 to 3.1.3.
- [Release notes](https://github.com/pallets/flask/releases)
- [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/flask/compare/3.1.2...3.1.3)

---
updated-dependencies:
- dependency-name: flask
  dependency-version: 3.1.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 17:49:53 +01:00
github-actions[bot]
da1937443d 📝 Update release notes
[skip ci]
2026-02-23 15:04:55 +00:00
Sofie Van Landeghem
5161f7b42b ⬆ Update all dependencies to use griffelib instead of griffe (#14973)
* update to griffelib

* also update pydantic-ai

* move griffelib to get better GH diff

* restore accidental edit
2026-02-23 16:04:24 +01:00
github-actions[bot]
fef2ce70d9 📝 Update release notes
[skip ci]
2026-02-23 11:45:11 +00:00
Motov Yurii
a3c8c37272 🔨 Fix FastAPI People workflow (#14951) 2026-02-23 12:44:47 +01:00
github-actions[bot]
2826124378 📝 Update release notes
[skip ci]
2026-02-22 18:22:03 +00:00
Sebastián Ramírez
4da264f0f3 👷 Do not run codspeed with coverage as it's not tracked (#14966) 2026-02-22 19:21:38 +01:00
github-actions[bot]
c5559a66dd 📝 Update release notes
[skip ci]
2026-02-22 18:14:11 +00:00
Sebastián Ramírez
1cea8f659c 👷 Do not include benchmark tests in coverage to speed up coverage processing (#14965) 2026-02-22 19:13:49 +01:00
22 changed files with 1224 additions and 560 deletions

View File

@@ -68,10 +68,8 @@ jobs:
python-version: "3.13" python-version: "3.13"
coverage: coverage coverage: coverage
uv-resolution: highest uv-resolution: highest
# Ubuntu with 3.13 needs coverage for CodSpeed benchmarks
- os: ubuntu-latest - os: ubuntu-latest
python-version: "3.13" python-version: "3.13"
coverage: coverage
uv-resolution: highest uv-resolution: highest
codspeed: codspeed codspeed: codspeed
- os: ubuntu-latest - os: ubuntu-latest
@@ -109,20 +107,10 @@ 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
if: matrix.codspeed != 'codspeed'
run: uv run --no-sync bash scripts/test.sh run: uv run --no-sync bash scripts/test.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 }}
- name: CodSpeed benchmarks
if: matrix.codspeed == 'codspeed'
uses: CodSpeedHQ/action@v4
env:
COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}
CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}
with:
mode: simulation
run: uv run --no-sync coverage run -m pytest tests/ --codspeed
# Do not store coverage for all possible combinations to avoid file size max errors in Smokeshow # Do not store coverage for all possible combinations to avoid file size max errors in Smokeshow
- name: Store coverage files - name: Store coverage files
if: matrix.coverage == 'coverage' if: matrix.coverage == 'coverage'
@@ -132,6 +120,39 @@ jobs:
path: coverage path: coverage
include-hidden-files: true include-hidden-files: true
benchmark:
needs:
- changes
if: needs.changes.outputs.src == 'true' || github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
env:
UV_PYTHON: "3.13"
UV_RESOLUTION: highest
steps:
- name: Dump GitHub context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
run: echo "$GITHUB_CONTEXT"
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Setup uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Install Dependencies
run: uv sync --no-dev --group tests --extra all
- name: CodSpeed benchmarks
uses: CodSpeedHQ/action@v4
with:
mode: simulation
run: uv run --no-sync pytest tests/benchmarks --codspeed
coverage-combine: coverage-combine:
needs: needs:
- test - test
@@ -176,6 +197,7 @@ jobs:
if: always() if: always()
needs: needs:
- coverage-combine - coverage-combine
- benchmark
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Dump GitHub context - name: Dump GitHub context
@@ -186,4 +208,4 @@ jobs:
uses: re-actors/alls-green@release/v1 uses: re-actors/alls-green@release/v1
with: with:
jobs: ${{ toJSON(needs) }} jobs: ${{ toJSON(needs) }}
allowed-skips: coverage-combine,test allowed-skips: coverage-combine,test,benchmark

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
# Strict Content-Type Checking { #strict-content-type-checking }
By default, **FastAPI** uses strict `Content-Type` header checking for JSON request bodies, this means that JSON requests **must** include a valid `Content-Type` header (e.g. `application/json`) in order for the body to be parsed as JSON.
## CSRF Risk { #csrf-risk }
This default behavior provides protection against a class of **Cross-Site Request Forgery (CSRF)** attacks in a very specific scenario.
These attacks exploit the fact that browsers allow scripts to send requests without doing any CORS preflight check when they:
* don't have a `Content-Type` header (e.g. using `fetch()` with a `Blob` body)
* and don't send any authentication credentials.
This type of attack is mainly relevant when:
* the application is running locally (e.g. on `localhost`) or in an internal network
* and the application doesn't have any authentication, it expects that any request from the same network can be trusted.
## Example Attack { #example-attack }
Imagine you build a way to run a local AI agent.
It provides an API at
```
http://localhost:8000/v1/agents/multivac
```
There's also a frontend at
```
http://localhost:8000
```
/// tip
Note that both have the same host.
///
Then using the frontend you can make the AI agent do things on your behalf.
As it's running **locally**, and not in the open internet, you decide to **not have any authentication** set up, just trusting the access to the local network.
Then one of your users could install it and run it locally.
Then they could open a malicious website, e.g. something like
```
https://evilhackers.example.com
```
And that malicious website sends requests using `fetch()` with a `Blob` body to the local API at
```
http://localhost:8000/v1/agents/multivac
```
Even though the host of the malicious website and the local app is different, the browser won't trigger a CORS preflight request because:
* It's running without any authentication, it doesn't have to send any credentials.
* The browser thinks it's not sending JSON (because of the missing `Content-Type` header).
Then the malicious website could make the local AI agent send angry messages to the user's ex-boss... or worse. 😅
## Open Internet { #open-internet }
If your app is in the open internet, you wouldn't "trust the network" and let anyone send privileged requests without authentication.
Attackers could simply run a script to send requests to your API, no need for browser interaction, so you are probably already securing any privileged endpoints.
In that case **this attack / risk doesn't apply to you**.
This risk and attack is mainly relevant when the app runs on the **local network** and that is the **only assumed protection**.
## Allowing Requests Without Content-Type { #allowing-requests-without-content-type }
If you need to support clients that don't send a `Content-Type` header, you can disable strict checking by setting `strict_content_type=False`:
{* ../../docs_src/strict_content_type/tutorial001_py310.py hl[4] *}
With this setting, requests without a `Content-Type` header will have their body parsed as JSON, which is the same behavior as older versions of FastAPI.
/// info
This behavior and configuration was added in FastAPI 0.132.0.
///

View File

@@ -7,6 +7,40 @@ hide:
## Latest Changes ## Latest Changes
## 0.133.0
### Upgrades
* ⬆️ Add support for Starlette 1.0.0+. PR [#14987](https://github.com/fastapi/fastapi/pull/14987) by [@tiangolo](https://github.com/tiangolo).
## 0.132.1
### Refactors
* ♻️ Refactor logic to handle OpenAPI and Swagger UI escaping data. PR [#14986](https://github.com/fastapi/fastapi/pull/14986) by [@tiangolo](https://github.com/tiangolo).
### Internal
* 👥 Update FastAPI People - Experts. PR [#14972](https://github.com/fastapi/fastapi/pull/14972) by [@tiangolo](https://github.com/tiangolo).
* 👷 Allow skipping `benchmark` job in `test` workflow. PR [#14974](https://github.com/fastapi/fastapi/pull/14974) by [@YuriiMotov](https://github.com/YuriiMotov).
## 0.132.0
### Breaking Changes
* 🔒️ Add `strict_content_type` checking for JSON requests. PR [#14978](https://github.com/fastapi/fastapi/pull/14978) by [@tiangolo](https://github.com/tiangolo).
* Now FastAPI checks, by default, that JSON requests have a `Content-Type` header with a valid JSON value, like `application/json`, and rejects requests that don't.
* If the clients for your app don't send a valid `Content-Type` header you can disable this with `strict_content_type=False`.
* Check the new docs: [Strict Content-Type Checking](https://fastapi.tiangolo.com/advanced/strict-content-type/).
### Internal
* ⬆ Bump flask from 3.1.2 to 3.1.3. PR [#14949](https://github.com/fastapi/fastapi/pull/14949) by [@dependabot[bot]](https://github.com/apps/dependabot).
* ⬆ Update all dependencies to use `griffelib` instead of `griffe`. PR [#14973](https://github.com/fastapi/fastapi/pull/14973) by [@svlandeg](https://github.com/svlandeg).
* 🔨 Fix `FastAPI People` workflow. PR [#14951](https://github.com/fastapi/fastapi/pull/14951) by [@YuriiMotov](https://github.com/YuriiMotov).
* 👷 Do not run codspeed with coverage as it's not tracked. PR [#14966](https://github.com/fastapi/fastapi/pull/14966) by [@tiangolo](https://github.com/tiangolo).
* 👷 Do not include benchmark tests in coverage to speed up coverage processing. PR [#14965](https://github.com/fastapi/fastapi/pull/14965) by [@tiangolo](https://github.com/tiangolo).
## 0.131.0 ## 0.131.0
### Breaking Changes ### Breaking Changes

View File

@@ -193,6 +193,7 @@ nav:
- advanced/generate-clients.md - advanced/generate-clients.md
- advanced/advanced-python-types.md - advanced/advanced-python-types.md
- advanced/json-base64-bytes.md - advanced/json-base64-bytes.md
- advanced/strict-content-type.md
- fastapi-cli.md - fastapi-cli.md
- Deployment: - Deployment:
- deployment/index.md - deployment/index.md

View File

View File

@@ -0,0 +1,14 @@
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(strict_content_type=False)
class Item(BaseModel):
name: str
price: float
@app.post("/items/")
async def create_item(item: Item):
return item

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.131.0" __version__ = "0.133.0"
from starlette import status as status from starlette import status as status

View File

@@ -840,6 +840,29 @@ class FastAPI(Starlette):
""" """
), ),
] = None, ] = None,
strict_content_type: Annotated[
bool,
Doc(
"""
Enable strict checking for request Content-Type headers.
When `True` (the default), requests with a body that do not include
a `Content-Type` header will **not** be parsed as JSON.
This prevents potential cross-site request forgery (CSRF) attacks
that exploit the browser's ability to send requests without a
Content-Type header, bypassing CORS preflight checks. In particular
applicable for apps that need to be run locally (in localhost).
When `False`, requests without a `Content-Type` header will have
their body parsed as JSON, which maintains compatibility with
certain clients that don't send `Content-Type` headers.
Read more about it in the
[FastAPI docs for Strict Content-Type](https://fastapi.tiangolo.com/advanced/strict-content-type/).
"""
),
] = True,
**extra: Annotated[ **extra: Annotated[
Any, Any,
Doc( Doc(
@@ -974,6 +997,7 @@ class FastAPI(Starlette):
include_in_schema=include_in_schema, include_in_schema=include_in_schema,
responses=responses, responses=responses,
generate_unique_id_function=generate_unique_id_function, generate_unique_id_function=generate_unique_id_function,
strict_content_type=strict_content_type,
) )
self.exception_handlers: dict[ self.exception_handlers: dict[
Any, Callable[[Request, Any], Response | Awaitable[Response]] Any, Callable[[Request, Any], Response | Awaitable[Response]]
@@ -1077,16 +1101,18 @@ class FastAPI(Starlette):
def setup(self) -> None: def setup(self) -> None:
if self.openapi_url: if self.openapi_url:
urls = (server_data.get("url") for server_data in self.servers)
server_urls = {url for url in urls if url}
async def openapi(req: Request) -> JSONResponse: async def openapi(req: Request) -> JSONResponse:
root_path = req.scope.get("root_path", "").rstrip("/") root_path = req.scope.get("root_path", "").rstrip("/")
if root_path not in server_urls: schema = self.openapi()
if root_path and self.root_path_in_servers: if root_path and self.root_path_in_servers:
self.servers.insert(0, {"url": root_path}) server_urls = {s.get("url") for s in schema.get("servers", [])}
server_urls.add(root_path) if root_path not in server_urls:
return JSONResponse(self.openapi()) schema = dict(schema)
schema["servers"] = [{"url": root_path}] + schema.get(
"servers", []
)
return JSONResponse(schema)
self.add_route(self.openapi_url, openapi, include_in_schema=False) self.add_route(self.openapi_url, openapi, include_in_schema=False)
if self.openapi_url and self.docs_url: if self.openapi_url and self.docs_url:

View File

@@ -5,6 +5,20 @@ from annotated_doc import Doc
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse
def _html_safe_json(value: Any) -> str:
"""Serialize a value to JSON with HTML special characters escaped.
This prevents injection when the JSON is embedded inside a <script> tag.
"""
return (
json.dumps(value)
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
swagger_ui_default_parameters: Annotated[ swagger_ui_default_parameters: Annotated[
dict[str, Any], dict[str, Any],
Doc( Doc(
@@ -155,7 +169,7 @@ def get_swagger_ui_html(
""" """
for key, value in current_swagger_ui_parameters.items(): for key, value in current_swagger_ui_parameters.items():
html += f"{json.dumps(key)}: {json.dumps(jsonable_encoder(value))},\n" html += f"{_html_safe_json(key)}: {_html_safe_json(jsonable_encoder(value))},\n"
if oauth2_redirect_url: if oauth2_redirect_url:
html += f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}'," html += f"oauth2RedirectUrl: window.location.origin + '{oauth2_redirect_url}',"
@@ -169,7 +183,7 @@ def get_swagger_ui_html(
if init_oauth: if init_oauth:
html += f""" html += f"""
ui.initOAuth({json.dumps(jsonable_encoder(init_oauth))}) ui.initOAuth({_html_safe_json(jsonable_encoder(init_oauth))})
""" """
html += """ html += """

View File

@@ -329,6 +329,7 @@ def get_request_handler(
response_model_exclude_none: bool = False, response_model_exclude_none: bool = False,
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),
) -> 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
@@ -337,6 +338,10 @@ def get_request_handler(
actual_response_class: type[Response] = response_class.value actual_response_class: type[Response] = response_class.value
else: else:
actual_response_class = response_class actual_response_class = response_class
if isinstance(strict_content_type, DefaultPlaceholder):
actual_strict_content_type: bool = strict_content_type.value
else:
actual_strict_content_type = strict_content_type
async def app(request: Request) -> Response: async def app(request: Request) -> Response:
response: Response | None = None response: Response | None = None
@@ -370,7 +375,8 @@ def get_request_handler(
json_body: Any = Undefined json_body: Any = Undefined
content_type_value = request.headers.get("content-type") content_type_value = request.headers.get("content-type")
if not content_type_value: if not content_type_value:
json_body = await request.json() if not actual_strict_content_type:
json_body = await request.json()
else: else:
message = email.message.Message() message = email.message.Message()
message["content-type"] = content_type_value message["content-type"] = content_type_value
@@ -599,6 +605,7 @@ class APIRoute(routing.Route):
openapi_extra: dict[str, Any] | None = None, openapi_extra: dict[str, Any] | None = None,
generate_unique_id_function: Callable[["APIRoute"], str] generate_unique_id_function: Callable[["APIRoute"], str]
| DefaultPlaceholder = Default(generate_unique_id), | DefaultPlaceholder = Default(generate_unique_id),
strict_content_type: bool | DefaultPlaceholder = Default(True),
) -> None: ) -> None:
self.path = path self.path = path
self.endpoint = endpoint self.endpoint = endpoint
@@ -625,6 +632,7 @@ class APIRoute(routing.Route):
self.callbacks = callbacks self.callbacks = callbacks
self.openapi_extra = openapi_extra self.openapi_extra = openapi_extra
self.generate_unique_id_function = generate_unique_id_function self.generate_unique_id_function = generate_unique_id_function
self.strict_content_type = strict_content_type
self.tags = tags or [] self.tags = tags or []
self.responses = responses or {} self.responses = responses or {}
self.name = get_name(endpoint) if name is None else name self.name = get_name(endpoint) if name is None else name
@@ -713,6 +721,7 @@ class APIRoute(routing.Route):
response_model_exclude_none=self.response_model_exclude_none, response_model_exclude_none=self.response_model_exclude_none,
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,
) )
def matches(self, scope: Scope) -> tuple[Match, Scope]: def matches(self, scope: Scope) -> tuple[Match, Scope]:
@@ -963,6 +972,29 @@ class APIRouter(routing.Router):
""" """
), ),
] = Default(generate_unique_id), ] = Default(generate_unique_id),
strict_content_type: Annotated[
bool,
Doc(
"""
Enable strict checking for request Content-Type headers.
When `True` (the default), requests with a body that do not include
a `Content-Type` header will **not** be parsed as JSON.
This prevents potential cross-site request forgery (CSRF) attacks
that exploit the browser's ability to send requests without a
Content-Type header, bypassing CORS preflight checks. In particular
applicable for apps that need to be run locally (in localhost).
When `False`, requests without a `Content-Type` header will have
their body parsed as JSON, which maintains compatibility with
certain clients that don't send `Content-Type` headers.
Read more about it in the
[FastAPI docs for Strict Content-Type](https://fastapi.tiangolo.com/advanced/strict-content-type/).
"""
),
] = Default(True),
) -> None: ) -> None:
# Determine the lifespan context to use # Determine the lifespan context to use
if lifespan is None: if lifespan is None:
@@ -1009,6 +1041,7 @@ class APIRouter(routing.Router):
self.route_class = route_class self.route_class = route_class
self.default_response_class = default_response_class self.default_response_class = default_response_class
self.generate_unique_id_function = generate_unique_id_function self.generate_unique_id_function = generate_unique_id_function
self.strict_content_type = strict_content_type
def route( def route(
self, self,
@@ -1059,6 +1092,7 @@ class APIRouter(routing.Router):
openapi_extra: dict[str, Any] | None = None, openapi_extra: dict[str, Any] | None = None,
generate_unique_id_function: Callable[[APIRoute], str] generate_unique_id_function: Callable[[APIRoute], str]
| DefaultPlaceholder = Default(generate_unique_id), | DefaultPlaceholder = Default(generate_unique_id),
strict_content_type: bool | DefaultPlaceholder = Default(True),
) -> None: ) -> None:
route_class = route_class_override or self.route_class route_class = route_class_override or self.route_class
responses = responses or {} responses = responses or {}
@@ -1105,6 +1139,9 @@ class APIRouter(routing.Router):
callbacks=current_callbacks, callbacks=current_callbacks,
openapi_extra=openapi_extra, openapi_extra=openapi_extra,
generate_unique_id_function=current_generate_unique_id, generate_unique_id_function=current_generate_unique_id,
strict_content_type=get_value_or_default(
strict_content_type, self.strict_content_type
),
) )
self.routes.append(route) self.routes.append(route)
@@ -1480,6 +1517,11 @@ class APIRouter(routing.Router):
callbacks=current_callbacks, callbacks=current_callbacks,
openapi_extra=route.openapi_extra, openapi_extra=route.openapi_extra,
generate_unique_id_function=current_generate_unique_id, generate_unique_id_function=current_generate_unique_id,
strict_content_type=get_value_or_default(
route.strict_content_type,
router.strict_content_type,
self.strict_content_type,
),
) )
elif isinstance(route, routing.Route): elif isinstance(route, routing.Route):
methods = list(route.methods or []) methods = list(route.methods or [])

View File

@@ -42,7 +42,7 @@ classifiers = [
"Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP",
] ]
dependencies = [ dependencies = [
"starlette>=0.40.0,<1.0.0", "starlette>=0.40.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",
@@ -242,6 +242,7 @@ relative_files = true
context = '${CONTEXT}' context = '${CONTEXT}'
dynamic_context = "test_function" dynamic_context = "test_function"
omit = [ omit = [
"tests/benchmarks/*",
"docs_src/response_model/tutorial003_04_py39.py", "docs_src/response_model/tutorial003_04_py39.py",
"docs_src/response_model/tutorial003_04_py310.py", "docs_src/response_model/tutorial003_04_py310.py",
"docs_src/dependencies/tutorial013_an_py310.py", # temporary code example? "docs_src/dependencies/tutorial013_an_py310.py", # temporary code example?

View File

@@ -5,6 +5,7 @@ import time
from collections import Counter from collections import Counter
from collections.abc import Container from collections.abc import Container
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from math import ceil
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -15,12 +16,63 @@ from pydantic import BaseModel, SecretStr
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
github_graphql_url = "https://api.github.com/graphql" github_graphql_url = "https://api.github.com/graphql"
questions_category_id = "MDE4OkRpc2N1c3Npb25DYXRlZ29yeTMyMDAxNDM0" questions_category_id = "DIC_kwDOCZduT84B6E2a"
POINTS_PER_MINUTE_LIMIT = 84 # 5000 points per hour
class RateLimiter:
def __init__(self) -> None:
self.last_query_cost: int = 1
self.remaining_points: int = 5000
self.reset_at: datetime = datetime.fromtimestamp(0, timezone.utc)
self.last_request_start_time: datetime = datetime.fromtimestamp(0, timezone.utc)
self.speed_multiplier: float = 1.0
def __enter__(self) -> "RateLimiter":
now = datetime.now(tz=timezone.utc)
# Handle primary rate limits
primary_limit_wait_time = 0.0
if self.remaining_points <= self.last_query_cost:
primary_limit_wait_time = (self.reset_at - now).total_seconds() + 2
logging.warning(
f"Approaching GitHub API rate limit, remaining points: {self.remaining_points}, "
f"reset time in {primary_limit_wait_time} seconds"
)
# Handle secondary rate limits
secondary_limit_wait_time = 0.0
points_per_minute = POINTS_PER_MINUTE_LIMIT * self.speed_multiplier
interval = 60 / (points_per_minute / self.last_query_cost)
time_since_last_request = (now - self.last_request_start_time).total_seconds()
if time_since_last_request < interval:
secondary_limit_wait_time = interval - time_since_last_request
final_wait_time = ceil(max(primary_limit_wait_time, secondary_limit_wait_time))
logging.info(f"Sleeping for {final_wait_time} seconds to respect rate limit")
time.sleep(max(final_wait_time, 1))
self.last_request_start_time = datetime.now(tz=timezone.utc)
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
pass
def update_request_info(self, cost: int, remaining: int, reset_at: str) -> None:
self.last_query_cost = cost
self.remaining_points = remaining
self.reset_at = datetime.fromisoformat(reset_at.replace("Z", "+00:00"))
rate_limiter = RateLimiter()
discussions_query = """ discussions_query = """
query Q($after: String, $category_id: ID) { query Q($after: String, $category_id: ID) {
repository(name: "fastapi", owner: "fastapi") { repository(name: "fastapi", owner: "fastapi") {
discussions(first: 100, after: $after, categoryId: $category_id) { discussions(first: 30, after: $after, categoryId: $category_id) {
edges { edges {
cursor cursor
node { node {
@@ -58,6 +110,11 @@ query Q($after: String, $category_id: ID) {
} }
} }
} }
rateLimit {
cost
remaining
resetAt
}
} }
""" """
@@ -120,7 +177,7 @@ class Settings(BaseSettings):
github_token: SecretStr github_token: SecretStr
github_repository: str github_repository: str
httpx_timeout: int = 30 httpx_timeout: int = 30
sleep_interval: int = 5 speed_multiplier: float = 1.0
def get_graphql_response( def get_graphql_response(
@@ -158,11 +215,18 @@ def get_graphql_question_discussion_edges(
settings: Settings, settings: Settings,
after: str | None = None, after: str | None = None,
) -> list[DiscussionsEdge]: ) -> list[DiscussionsEdge]:
data = get_graphql_response( with rate_limiter:
settings=settings, data = get_graphql_response(
query=discussions_query, settings=settings,
after=after, query=discussions_query,
category_id=questions_category_id, after=after,
category_id=questions_category_id,
)
rate_limiter.update_request_info(
cost=data["data"]["rateLimit"]["cost"],
remaining=data["data"]["rateLimit"]["remaining"],
reset_at=data["data"]["rateLimit"]["resetAt"],
) )
graphql_response = DiscussionsResponse.model_validate(data) graphql_response = DiscussionsResponse.model_validate(data)
return graphql_response.data.repository.discussions.edges return graphql_response.data.repository.discussions.edges
@@ -185,8 +249,6 @@ def get_discussion_nodes(settings: Settings) -> list[DiscussionsNode]:
for discussion_edge in discussion_edges: for discussion_edge in discussion_edges:
discussion_nodes.append(discussion_edge.node) discussion_nodes.append(discussion_edge.node)
last_edge = discussion_edges[-1] last_edge = discussion_edges[-1]
# Handle GitHub secondary rate limits, requests per minute
time.sleep(settings.sleep_interval)
discussion_edges = get_graphql_question_discussion_edges( discussion_edges = get_graphql_question_discussion_edges(
settings=settings, after=last_edge.cursor settings=settings, after=last_edge.cursor
) )
@@ -318,6 +380,7 @@ def main() -> None:
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
settings = Settings() settings = Settings()
logging.info(f"Using config: {settings.model_dump_json()}") logging.info(f"Using config: {settings.model_dump_json()}")
rate_limiter.speed_multiplier = settings.speed_multiplier
g = Github(settings.github_token.get_secret_value()) g = Github(settings.github_token.get_secret_value())
repo = g.get_repo(settings.github_repository) repo = g.get_repo(settings.github_repository)

View File

@@ -0,0 +1,75 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
def test_root_path_does_not_persist_across_requests():
app = FastAPI()
@app.get("/")
def read_root(): # pragma: no cover
return {"ok": True}
# Attacker request with a spoofed root_path
attacker_client = TestClient(app, root_path="/evil-api")
response1 = attacker_client.get("/openapi.json")
data1 = response1.json()
assert any(s.get("url") == "/evil-api" for s in data1.get("servers", []))
# Subsequent legitimate request with no root_path
clean_client = TestClient(app)
response2 = clean_client.get("/openapi.json")
data2 = response2.json()
servers = [s.get("url") for s in data2.get("servers", [])]
assert "/evil-api" not in servers
def test_multiple_different_root_paths_do_not_accumulate():
app = FastAPI()
@app.get("/")
def read_root(): # pragma: no cover
return {"ok": True}
for prefix in ["/path-a", "/path-b", "/path-c"]:
c = TestClient(app, root_path=prefix)
c.get("/openapi.json")
# A clean request should not have any of them
clean_client = TestClient(app)
response = clean_client.get("/openapi.json")
data = response.json()
servers = [s.get("url") for s in data.get("servers", [])]
for prefix in ["/path-a", "/path-b", "/path-c"]:
assert prefix not in servers, (
f"root_path '{prefix}' leaked into clean request: {servers}"
)
def test_legitimate_root_path_still_appears():
app = FastAPI()
@app.get("/")
def read_root(): # pragma: no cover
return {"ok": True}
client = TestClient(app, root_path="/api/v1")
response = client.get("/openapi.json")
data = response.json()
servers = [s.get("url") for s in data.get("servers", [])]
assert "/api/v1" in servers
def test_configured_servers_not_mutated():
configured_servers = [{"url": "https://prod.example.com"}]
app = FastAPI(servers=configured_servers)
@app.get("/")
def read_root(): # pragma: no cover
return {"ok": True}
# Request with a rogue root_path
attacker_client = TestClient(app, root_path="/evil")
attacker_client.get("/openapi.json")
# The original servers list must be untouched
assert configured_servers == [{"url": "https://prod.example.com"}]

View File

@@ -0,0 +1,44 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
app_default = FastAPI()
@app_default.post("/items/")
async def app_default_post(data: dict):
return data
app_lax = FastAPI(strict_content_type=False)
@app_lax.post("/items/")
async def app_lax_post(data: dict):
return data
client_default = TestClient(app_default)
client_lax = TestClient(app_lax)
def test_default_strict_rejects_no_content_type():
response = client_default.post("/items/", content='{"key": "value"}')
assert response.status_code == 422
def test_default_strict_accepts_json_content_type():
response = client_default.post("/items/", json={"key": "value"})
assert response.status_code == 200
assert response.json() == {"key": "value"}
def test_lax_accepts_no_content_type():
response = client_lax.post("/items/", content='{"key": "value"}')
assert response.status_code == 200
assert response.json() == {"key": "value"}
def test_lax_accepts_json_content_type():
response = client_lax.post("/items/", json={"key": "value"})
assert response.status_code == 200
assert response.json() == {"key": "value"}

View File

@@ -0,0 +1,91 @@
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
# Lax app with nested routers, inner overrides to strict
app_nested = FastAPI(strict_content_type=False) # lax app
outer_router = APIRouter(prefix="/outer") # inherits lax from app
inner_strict = APIRouter(prefix="/strict", strict_content_type=True)
inner_default = APIRouter(prefix="/default")
@inner_strict.post("/items/")
async def inner_strict_post(data: dict):
return data
@inner_default.post("/items/")
async def inner_default_post(data: dict):
return data
outer_router.include_router(inner_strict)
outer_router.include_router(inner_default)
app_nested.include_router(outer_router)
client_nested = TestClient(app_nested)
def test_strict_inner_on_lax_app_rejects_no_content_type():
response = client_nested.post("/outer/strict/items/", content='{"key": "value"}')
assert response.status_code == 422
def test_default_inner_inherits_lax_from_app():
response = client_nested.post("/outer/default/items/", content='{"key": "value"}')
assert response.status_code == 200
assert response.json() == {"key": "value"}
def test_strict_inner_accepts_json_content_type():
response = client_nested.post("/outer/strict/items/", json={"key": "value"})
assert response.status_code == 200
def test_default_inner_accepts_json_content_type():
response = client_nested.post("/outer/default/items/", json={"key": "value"})
assert response.status_code == 200
# Strict app -> lax outer router -> strict inner router
app_mixed = FastAPI(strict_content_type=True)
mixed_outer = APIRouter(prefix="/outer", strict_content_type=False)
mixed_inner = APIRouter(prefix="/inner", strict_content_type=True)
@mixed_outer.post("/items/")
async def mixed_outer_post(data: dict):
return data
@mixed_inner.post("/items/")
async def mixed_inner_post(data: dict):
return data
mixed_outer.include_router(mixed_inner)
app_mixed.include_router(mixed_outer)
client_mixed = TestClient(app_mixed)
def test_lax_outer_on_strict_app_accepts_no_content_type():
response = client_mixed.post("/outer/items/", content='{"key": "value"}')
assert response.status_code == 200
assert response.json() == {"key": "value"}
def test_strict_inner_on_lax_outer_rejects_no_content_type():
response = client_mixed.post("/outer/inner/items/", content='{"key": "value"}')
assert response.status_code == 422
def test_lax_outer_accepts_json_content_type():
response = client_mixed.post("/outer/items/", json={"key": "value"})
assert response.status_code == 200
def test_strict_inner_on_lax_outer_accepts_json_content_type():
response = client_mixed.post("/outer/inner/items/", json={"key": "value"})
assert response.status_code == 200

View File

@@ -0,0 +1,61 @@
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
router_lax = APIRouter(prefix="/lax", strict_content_type=False)
router_strict = APIRouter(prefix="/strict", strict_content_type=True)
router_default = APIRouter(prefix="/default")
@router_lax.post("/items/")
async def router_lax_post(data: dict):
return data
@router_strict.post("/items/")
async def router_strict_post(data: dict):
return data
@router_default.post("/items/")
async def router_default_post(data: dict):
return data
app.include_router(router_lax)
app.include_router(router_strict)
app.include_router(router_default)
client = TestClient(app)
def test_lax_router_on_strict_app_accepts_no_content_type():
response = client.post("/lax/items/", content='{"key": "value"}')
assert response.status_code == 200
assert response.json() == {"key": "value"}
def test_strict_router_on_strict_app_rejects_no_content_type():
response = client.post("/strict/items/", content='{"key": "value"}')
assert response.status_code == 422
def test_default_router_inherits_strict_from_app():
response = client.post("/default/items/", content='{"key": "value"}')
assert response.status_code == 422
def test_lax_router_accepts_json_content_type():
response = client.post("/lax/items/", json={"key": "value"})
assert response.status_code == 200
def test_strict_router_accepts_json_content_type():
response = client.post("/strict/items/", json={"key": "value"})
assert response.status_code == 200
def test_default_router_accepts_json_content_type():
response = client.post("/default/items/", json={"key": "value"})
assert response.status_code == 200

View File

@@ -0,0 +1,37 @@
from fastapi.openapi.docs import get_swagger_ui_html
def test_init_oauth_html_chars_are_escaped():
xss_payload = "Evil</script><script>alert(1)</script>"
html = get_swagger_ui_html(
openapi_url="/openapi.json",
title="Test",
init_oauth={"appName": xss_payload},
)
body = html.body.decode()
assert "</script><script>" not in body
assert "\\u003c/script\\u003e\\u003cscript\\u003e" in body
def test_swagger_ui_parameters_html_chars_are_escaped():
html = get_swagger_ui_html(
openapi_url="/openapi.json",
title="Test",
swagger_ui_parameters={"customKey": "<img src=x onerror=alert(1)>"},
)
body = html.body.decode()
assert "<img src=x onerror=alert(1)>" not in body
assert "\\u003cimg" in body
def test_normal_init_oauth_still_works():
html = get_swagger_ui_html(
openapi_url="/openapi.json",
title="Test",
init_oauth={"clientId": "my-client", "appName": "My App"},
)
body = html.body.decode()
assert '"clientId": "my-client"' in body
assert '"appName": "My App"' in body
assert "ui.initOAuth" in body

View File

@@ -189,18 +189,12 @@ def test_geo_json(client: TestClient):
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
def test_no_content_type_is_json(client: TestClient): def test_no_content_type_json(client: TestClient):
response = client.post( response = client.post(
"/items/", "/items/",
content='{"name": "Foo", "price": 50.5}', content='{"name": "Foo", "price": 50.5}',
) )
assert response.status_code == 200, response.text assert response.status_code == 422, response.text
assert response.json() == {
"name": "Foo",
"description": None,
"price": 50.5,
"tax": None,
}
def test_wrong_headers(client: TestClient): def test_wrong_headers(client: TestClient):

View File

View File

@@ -0,0 +1,43 @@
import importlib
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(
name="client",
params=[
"tutorial001_py310",
],
)
def get_client(request: pytest.FixtureRequest):
mod = importlib.import_module(f"docs_src.strict_content_type.{request.param}")
client = TestClient(mod.app)
return client
def test_lax_post_without_content_type_is_parsed_as_json(client: TestClient):
response = client.post(
"/items/",
content='{"name": "Foo", "price": 50.5}',
)
assert response.status_code == 200, response.text
assert response.json() == {"name": "Foo", "price": 50.5}
def test_lax_post_with_json_content_type(client: TestClient):
response = client.post(
"/items/",
json={"name": "Foo", "price": 50.5},
)
assert response.status_code == 200, response.text
assert response.json() == {"name": "Foo", "price": 50.5}
def test_lax_post_with_text_plain_is_still_rejected(client: TestClient):
response = client.post(
"/items/",
content='{"name": "Foo", "price": 50.5}',
headers={"Content-Type": "text/plain"},
)
assert response.status_code == 422, response.text

116
uv.lock generated
View File

@@ -192,7 +192,7 @@ wheels = [
[[package]] [[package]]
name = "anthropic" name = "anthropic"
version = "0.78.0" version = "0.83.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
@@ -204,9 +204,9 @@ dependencies = [
{ name = "sniffio" }, { name = "sniffio" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ec/51/32849a48f9b1cfe80a508fd269b20bd8f0b1357c70ba092890fde5a6a10b/anthropic-0.78.0.tar.gz", hash = "sha256:55fd978ab9b049c61857463f4c4e9e092b24f892519c6d8078cee1713d8af06e", size = 509136, upload-time = "2026-02-05T17:52:04.986Z" } sdist = { url = "https://files.pythonhosted.org/packages/db/e5/02cd2919ec327b24234abb73082e6ab84c451182cc3cc60681af700f4c63/anthropic-0.83.0.tar.gz", hash = "sha256:a8732c68b41869266c3034541a31a29d8be0f8cd0a714f9edce3128b351eceb4", size = 534058, upload-time = "2026-02-19T19:26:38.904Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/03/2f50931a942e5e13f80e24d83406714672c57964be593fc046d81369335b/anthropic-0.78.0-py3-none-any.whl", hash = "sha256:2a9887d2e99d1b0f9fe08857a1e9fe5d2d4030455dbf9ac65aab052e2efaeac4", size = 405485, upload-time = "2026-02-05T17:52:03.674Z" }, { url = "https://files.pythonhosted.org/packages/5f/75/b9d58e4e2a4b1fc3e75ffbab978f999baf8b7c4ba9f96e60edb918ba386b/anthropic-0.83.0-py3-none-any.whl", hash = "sha256:f069ef508c73b8f9152e8850830d92bd5ef185645dbacf234bb213344a274810", size = 456991, upload-time = "2026-02-19T19:26:40.114Z" },
] ]
[[package]] [[package]]
@@ -1242,7 +1242,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,<1.0.0" }, { name = "starlette", specifier = ">=0.40.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" },
@@ -1607,7 +1607,7 @@ wheels = [
[[package]] [[package]]
name = "flask" name = "flask"
version = "3.1.2" version = "3.1.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "blinker" }, { name = "blinker" },
@@ -1617,9 +1617,9 @@ dependencies = [
{ name = "markupsafe" }, { name = "markupsafe" },
{ name = "werkzeug" }, { name = "werkzeug" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
] ]
[[package]] [[package]]
@@ -1922,41 +1922,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
] ]
[[package]]
name = "griffe"
version = "1.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" },
]
[[package]] [[package]]
name = "griffe-typingdoc" name = "griffe-typingdoc"
version = "0.3.0" version = "0.3.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "griffe" }, { name = "griffelib" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/be/77/d5e5fa0a8391bc2890ae45255847197299739833108dd76ee3c9b2ff0bba/griffe_typingdoc-0.3.0.tar.gz", hash = "sha256:59d9ef98d02caa7aed88d8df1119c9e48c02ed049ea50ce4018ace9331d20f8b", size = 33169, upload-time = "2025-10-23T12:01:39.037Z" } sdist = { url = "https://files.pythonhosted.org/packages/ce/26/28182e0c8055842bf3da774dee1d5b789c0f236c078dcbdca1937b5214dc/griffe_typingdoc-0.3.1.tar.gz", hash = "sha256:2ff4703115cb7f8a65b9fdcdd1f3c3a15f813b6554621b52eaad094c4782ce96", size = 31218, upload-time = "2026-02-21T09:38:54.409Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/af/aa32c13f753e2625ec895b1f56eee3c9380a2088a88a2c028955e223856e/griffe_typingdoc-0.3.0-py3-none-any.whl", hash = "sha256:4f6483fff7733a679d1dce142fb029f314125f3caaf0d620eb82e7390c8564bb", size = 9923, upload-time = "2025-10-23T12:01:37.601Z" }, { url = "https://files.pythonhosted.org/packages/b6/c4/cf543fbde49e1ae44830ef0840a4d6ee9f4e4f338138a7766d4e37cf6440/griffe_typingdoc-0.3.1-py3-none-any.whl", hash = "sha256:ecbd457ef6883126b8b6023abf12e08c58e1c152238a2f0e2afdd67a64b07021", size = 10092, upload-time = "2026-02-20T14:53:47.84Z" },
] ]
[[package]] [[package]]
name = "griffe-warnings-deprecated" name = "griffe-warnings-deprecated"
version = "1.1.0" version = "1.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "griffe" }, { name = "griffelib" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7e/0e/f034e1714eb2c694d6196c75f77a02f9c69d19f9961c4804a016397bf3e5/griffe_warnings_deprecated-1.1.0.tar.gz", hash = "sha256:7bf21de327d59c66c7ce08d0166aa4292ce0577ff113de5878f428d102b6f7c5", size = 33260, upload-time = "2024-12-10T21:02:18.395Z" } sdist = { url = "https://files.pythonhosted.org/packages/da/9e/fc86f1e9270f143a395a601de81aa42a871722c34d4b3c7763658dc2e04d/griffe_warnings_deprecated-1.1.1.tar.gz", hash = "sha256:9261369bf2acb8b5d24a0dc7895cce788208513d4349031d4ea315b979b2e99f", size = 26262, upload-time = "2026-02-21T09:38:55.858Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/4c/b7241f03ad1f22ec2eed33b0f90c4f8c949e3395c4b7488670b07225a20b/griffe_warnings_deprecated-1.1.0-py3-none-any.whl", hash = "sha256:e7b0e8bfd6e5add3945d4d9805b2a41c72409e456733965be276d55f01e8a7a2", size = 5854, upload-time = "2024-12-10T21:02:16.96Z" }, { url = "https://files.pythonhosted.org/packages/2f/3c/c2a9eee79bf2c8002d2fa370534bee93fdca39e8b1fc82e83d552d5d2c07/griffe_warnings_deprecated-1.1.1-py3-none-any.whl", hash = "sha256:4b7d765e82ca9139ed44ffe7bdebed0d3a46ce014ad5a35a2c22e9a16288737a", size = 6565, upload-time = "2026-02-20T15:35:23.577Z" },
]
[[package]]
name = "griffelib"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
] ]
[[package]] [[package]]
@@ -2166,26 +2162,23 @@ wheels = [
[[package]] [[package]]
name = "huggingface-hub" name = "huggingface-hub"
version = "0.36.2" version = "1.4.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "filelock" }, { name = "filelock" },
{ name = "fsspec" }, { name = "fsspec" },
{ name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
{ name = "httpx" },
{ name = "packaging" }, { name = "packaging" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "requests" }, { name = "shellingham" },
{ name = "tqdm" }, { name = "tqdm" },
{ name = "typer-slim" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } sdist = { url = "https://files.pythonhosted.org/packages/c4/fc/eb9bc06130e8bbda6a616e1b80a7aa127681c448d6b49806f61db2670b61/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5", size = 642156, upload-time = "2026-02-06T09:20:03.013Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, { url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" },
]
[package.optional-dependencies]
inference = [
{ name = "aiohttp" },
] ]
[[package]] [[package]]
@@ -3003,17 +2996,17 @@ python = [
[[package]] [[package]]
name = "mkdocstrings-python" name = "mkdocstrings-python"
version = "2.0.1" version = "2.0.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "griffe" }, { name = "griffelib" },
{ name = "mkdocs-autorefs" }, { name = "mkdocs-autorefs" },
{ name = "mkdocstrings" }, { name = "mkdocstrings" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" },
] ]
[[package]] [[package]]
@@ -3937,33 +3930,33 @@ email = [
[[package]] [[package]]
name = "pydantic-ai" name = "pydantic-ai"
version = "1.56.0" version = "1.62.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "ui", "vertexai", "xai"] }, { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "temporal", "ui", "vertexai", "xai"] },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/60/1a/800a1e02b259152a49d4c11d9103784a7482c7e9b067eeea23e949d3d80f/pydantic_ai-1.56.0.tar.gz", hash = "sha256:643ff71612df52315b3b4c4b41543657f603f567223eb33245dc8098f005bdc4", size = 11795, upload-time = "2026-02-06T01:13:21.122Z" } sdist = { url = "https://files.pythonhosted.org/packages/20/97/e3158fa976a29e9580ba1c59601590424bbb81179c359fd29de0dc23aa09/pydantic_ai-1.62.0.tar.gz", hash = "sha256:d6ae517e365ea3ea162ca8ae643f319e105b71b0b6218b83dcad1d1eb2e38c9b", size = 12130, upload-time = "2026-02-19T05:07:07.853Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/35/f4a7fd2b9962ddb9b021f76f293e74fda71da190bb74b57ed5b343c93022/pydantic_ai-1.56.0-py3-none-any.whl", hash = "sha256:b6b3ac74bdc004693834750da4420ea2cde0d3cbc3f134c0b7544f98f1c00859", size = 7222, upload-time = "2026-02-06T01:13:11.755Z" }, { url = "https://files.pythonhosted.org/packages/bc/7a/053aebfab576603e95fcfce1139de4a87e12bd5a2ef1ba00007a931c3ff0/pydantic_ai-1.62.0-py3-none-any.whl", hash = "sha256:1eb88f745ae045e63da41ad68966e8876c964d0f023fbf5d6a3f5d243370bd04", size = 7227, upload-time = "2026-02-19T05:06:58.341Z" },
] ]
[[package]] [[package]]
name = "pydantic-ai-slim" name = "pydantic-ai-slim"
version = "1.56.0" version = "1.62.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "genai-prices" }, { name = "genai-prices" },
{ name = "griffe" }, { name = "griffelib" },
{ name = "httpx" }, { name = "httpx" },
{ name = "opentelemetry-api" }, { name = "opentelemetry-api" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-graph" }, { name = "pydantic-graph" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ce/5c/3a577825b9c1da8f287be7f2ee6fe9aab48bc8a80e65c8518052c589f51c/pydantic_ai_slim-1.56.0.tar.gz", hash = "sha256:9f9f9c56b1c735837880a515ae5661b465b40207b25f3a3434178098b2137f05", size = 415265, upload-time = "2026-02-06T01:13:23.58Z" } sdist = { url = "https://files.pythonhosted.org/packages/cc/8d/6350a49f2e4b636efbcfc233221420ab576e4ba4edba38254cb84ae4a1e6/pydantic_ai_slim-1.62.0.tar.gz", hash = "sha256:00d84f659107bbbd88823a3d3dbe7348385935a9870b9d7d4ba799256f6b6983", size = 422452, upload-time = "2026-02-19T05:07:10.292Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/4b/34682036528eeb9aaf093c2073540ddf399ab37b99d282a69ca41356f1aa/pydantic_ai_slim-1.56.0-py3-none-any.whl", hash = "sha256:d657e4113485020500b23b7390b0066e2a0277edc7577eaad2290735ca5dd7d5", size = 542270, upload-time = "2026-02-06T01:13:14.918Z" }, { url = "https://files.pythonhosted.org/packages/3d/67/21e9b3b0944568662e3790c936226bd48a9f27c6b5f27b5916f5857bc4d8/pydantic_ai_slim-1.62.0-py3-none-any.whl", hash = "sha256:5210073fadd46f65859a67da67845093c487f025fa430ed027151f22ec684ab2", size = 549296, upload-time = "2026-02-19T05:07:01.624Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -3999,7 +3992,7 @@ groq = [
{ name = "groq" }, { name = "groq" },
] ]
huggingface = [ huggingface = [
{ name = "huggingface-hub", extra = ["inference"] }, { name = "huggingface-hub" },
] ]
logfire = [ logfire = [
{ name = "logfire", extra = ["httpx"] }, { name = "logfire", extra = ["httpx"] },
@@ -4151,7 +4144,7 @@ wheels = [
[[package]] [[package]]
name = "pydantic-evals" name = "pydantic-evals"
version = "1.56.0" version = "1.62.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
@@ -4161,9 +4154,9 @@ dependencies = [
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "rich" }, { name = "rich" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/98/f2/8c59284a2978af3fbda45ae3217218eaf8b071207a9290b54b7613983e5d/pydantic_evals-1.56.0.tar.gz", hash = "sha256:206635107127af6a3ee4b1fc8f77af6afb14683615a2d6b3609f79467c1c0d28", size = 47210, upload-time = "2026-02-06T01:13:25.714Z" } sdist = { url = "https://files.pythonhosted.org/packages/23/90/080f6722412263395d1d6d066ee90fa8bc2722ce097844220c2d9c946877/pydantic_evals-1.62.0.tar.gz", hash = "sha256:198c4bee936718a4acf6f504056b113e60b34eb49021df8889a394e14c803693", size = 56434, upload-time = "2026-02-19T05:07:11.793Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/51/9875d19ff6d584aaeb574aba76b49d931b822546fc60b29c4fc0da98170d/pydantic_evals-1.56.0-py3-none-any.whl", hash = "sha256:d1efb410c97135aabd2a22453b10c981b2b9851985e9354713af67ae0973b7a9", size = 56407, upload-time = "2026-02-06T01:13:17.098Z" }, { url = "https://files.pythonhosted.org/packages/d8/b9/dc8dba744ec02b16c6fd1abe3fd8ef1b00fd05c72feef5069851b811952f/pydantic_evals-1.62.0-py3-none-any.whl", hash = "sha256:0ca7e10037ed90393c54b6cff41370d6d4bac63f8c878715599c58863c303db1", size = 67341, upload-time = "2026-02-19T05:07:03.83Z" },
] ]
[[package]] [[package]]
@@ -4181,7 +4174,7 @@ wheels = [
[[package]] [[package]]
name = "pydantic-graph" name = "pydantic-graph"
version = "1.56.0" version = "1.62.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -4189,9 +4182,9 @@ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ff/03/f92881cdb12d6f43e60e9bfd602e41c95408f06e2324d3729f7a194e2bcd/pydantic_graph-1.56.0.tar.gz", hash = "sha256:5e22972dbb43dbc379ab9944252ff864019abf3c7d465dcdf572fc8aec9a44a1", size = 58460, upload-time = "2026-02-06T01:13:26.708Z" } sdist = { url = "https://files.pythonhosted.org/packages/3b/b6/0b084c847ecd99624f4fbc5c8ecd3f67a2388a282a32612b2a68c3b3595f/pydantic_graph-1.62.0.tar.gz", hash = "sha256:efe56bee3a8ca35b11a3be6a5f7352419fe182ef1e1323a3267ee12dec95f3c7", size = 58529, upload-time = "2026-02-19T05:07:12.947Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/08/07/8c823eb4d196137c123d4d67434e185901d3cbaea3b0c2b7667da84e72c1/pydantic_graph-1.56.0-py3-none-any.whl", hash = "sha256:ec3f0a1d6fcedd4eb9c59fef45079c2ee4d4185878d70dae26440a9c974c6bb3", size = 72346, upload-time = "2026-02-06T01:13:18.792Z" }, { url = "https://files.pythonhosted.org/packages/f0/12/1a9cbcd59fd070ba72b0fe544caa6ca97758518643523ec2bf1162084e0d/pydantic_graph-1.62.0-py3-none-any.whl", hash = "sha256:abe0e7b356b4d3202b069ec020d8dd1f647f55e9a0e85cd272dab48250bde87d", size = 72350, upload-time = "2026-02-19T05:07:05.305Z" },
] ]
[[package]] [[package]]
@@ -5562,6 +5555,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
] ]
[[package]]
name = "typer-slim"
version = "0.21.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" },
]
[[package]] [[package]]
name = "types-orjson" name = "types-orjson"
version = "3.6.2" version = "3.6.2"