mirror of
https://github.com/fastapi/fastapi.git
synced 2026-02-24 10:46:42 -05:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daba0aa328 | ||
|
|
0c3581d5c4 | ||
|
|
c73bc94537 | ||
|
|
6c68838615 | ||
|
|
29d082ba24 | ||
|
|
2686c7fbbf | ||
|
|
2f9c914d44 | ||
|
|
0cf27ecf88 | ||
|
|
3f30ca1a5e | ||
|
|
6af3832126 | ||
|
|
acdf52e0c8 | ||
|
|
5c863d0718 | ||
|
|
ac8621a76e | ||
|
|
22354a2530 | ||
|
|
94a1ee749e | ||
|
|
248d7fb9f5 | ||
|
|
da1937443d | ||
|
|
5161f7b42b | ||
|
|
fef2ce70d9 | ||
|
|
a3c8c37272 | ||
|
|
2826124378 | ||
|
|
4da264f0f3 | ||
|
|
c5559a66dd | ||
|
|
1cea8f659c |
48
.github/workflows/test.yml
vendored
48
.github/workflows/test.yml
vendored
@@ -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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
88
docs/en/docs/advanced/strict-content-type.md
Normal file
88
docs/en/docs/advanced/strict-content-type.md
Normal 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.
|
||||||
|
|
||||||
|
///
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
0
docs_src/strict_content_type/__init__.py
Normal file
0
docs_src/strict_content_type/__init__.py
Normal file
14
docs_src/strict_content_type/tutorial001_py310.py
Normal file
14
docs_src/strict_content_type/tutorial001_py310.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 += """
|
||||||
|
|||||||
@@ -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 [])
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
75
tests/test_openapi_cache_root_path.py
Normal file
75
tests/test_openapi_cache_root_path.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_path_does_not_persist_across_requests():
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def read_root(): # pragma: no cover
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# Attacker request with a spoofed root_path
|
||||||
|
attacker_client = TestClient(app, root_path="/evil-api")
|
||||||
|
response1 = attacker_client.get("/openapi.json")
|
||||||
|
data1 = response1.json()
|
||||||
|
assert any(s.get("url") == "/evil-api" for s in data1.get("servers", []))
|
||||||
|
|
||||||
|
# Subsequent legitimate request with no root_path
|
||||||
|
clean_client = TestClient(app)
|
||||||
|
response2 = clean_client.get("/openapi.json")
|
||||||
|
data2 = response2.json()
|
||||||
|
servers = [s.get("url") for s in data2.get("servers", [])]
|
||||||
|
assert "/evil-api" not in servers
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_different_root_paths_do_not_accumulate():
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def read_root(): # pragma: no cover
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
for prefix in ["/path-a", "/path-b", "/path-c"]:
|
||||||
|
c = TestClient(app, root_path=prefix)
|
||||||
|
c.get("/openapi.json")
|
||||||
|
|
||||||
|
# A clean request should not have any of them
|
||||||
|
clean_client = TestClient(app)
|
||||||
|
response = clean_client.get("/openapi.json")
|
||||||
|
data = response.json()
|
||||||
|
servers = [s.get("url") for s in data.get("servers", [])]
|
||||||
|
for prefix in ["/path-a", "/path-b", "/path-c"]:
|
||||||
|
assert prefix not in servers, (
|
||||||
|
f"root_path '{prefix}' leaked into clean request: {servers}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legitimate_root_path_still_appears():
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def read_root(): # pragma: no cover
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
client = TestClient(app, root_path="/api/v1")
|
||||||
|
response = client.get("/openapi.json")
|
||||||
|
data = response.json()
|
||||||
|
servers = [s.get("url") for s in data.get("servers", [])]
|
||||||
|
assert "/api/v1" in servers
|
||||||
|
|
||||||
|
|
||||||
|
def test_configured_servers_not_mutated():
|
||||||
|
configured_servers = [{"url": "https://prod.example.com"}]
|
||||||
|
app = FastAPI(servers=configured_servers)
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def read_root(): # pragma: no cover
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# Request with a rogue root_path
|
||||||
|
attacker_client = TestClient(app, root_path="/evil")
|
||||||
|
attacker_client.get("/openapi.json")
|
||||||
|
|
||||||
|
# The original servers list must be untouched
|
||||||
|
assert configured_servers == [{"url": "https://prod.example.com"}]
|
||||||
44
tests/test_strict_content_type_app_level.py
Normal file
44
tests/test_strict_content_type_app_level.py
Normal 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"}
|
||||||
91
tests/test_strict_content_type_nested.py
Normal file
91
tests/test_strict_content_type_nested.py
Normal 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
|
||||||
61
tests/test_strict_content_type_router_level.py
Normal file
61
tests/test_strict_content_type_router_level.py
Normal 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
|
||||||
37
tests/test_swagger_ui_escape.py
Normal file
37
tests/test_swagger_ui_escape.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_oauth_html_chars_are_escaped():
|
||||||
|
xss_payload = "Evil</script><script>alert(1)</script>"
|
||||||
|
html = get_swagger_ui_html(
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
title="Test",
|
||||||
|
init_oauth={"appName": xss_payload},
|
||||||
|
)
|
||||||
|
body = html.body.decode()
|
||||||
|
|
||||||
|
assert "</script><script>" not in body
|
||||||
|
assert "\\u003c/script\\u003e\\u003cscript\\u003e" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_swagger_ui_parameters_html_chars_are_escaped():
|
||||||
|
html = get_swagger_ui_html(
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
title="Test",
|
||||||
|
swagger_ui_parameters={"customKey": "<img src=x onerror=alert(1)>"},
|
||||||
|
)
|
||||||
|
body = html.body.decode()
|
||||||
|
assert "<img src=x onerror=alert(1)>" not in body
|
||||||
|
assert "\\u003cimg" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_normal_init_oauth_still_works():
|
||||||
|
html = get_swagger_ui_html(
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
title="Test",
|
||||||
|
init_oauth={"clientId": "my-client", "appName": "My App"},
|
||||||
|
)
|
||||||
|
body = html.body.decode()
|
||||||
|
assert '"clientId": "my-client"' in body
|
||||||
|
assert '"appName": "My App"' in body
|
||||||
|
assert "ui.initOAuth" in body
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
116
uv.lock
generated
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user