From d8aad201eba558b20419a832e51a026b0e6ede0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 15 Jun 2026 12:55:06 +0200 Subject: [PATCH 01/51] =?UTF-8?q?=F0=9F=90=9B=20Fix=20bug,=20allow=20empty?= =?UTF-8?q?=20path=20in=20path=20operation=20in=20prefixless=20router=20(#?= =?UTF-8?q?15763)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/routing.py | 13 +++++-- tests/test_router_include_context.py | 55 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index fb4784309..f33b96479 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -2435,9 +2435,16 @@ class APIRouter(routing.Router): "A path prefix must not end with '/', as the routes will start with '/'" ) else: - for r in _iter_included_route_candidates(router.routes): - path = getattr(r, "path", None) - name = getattr(r, "name", "unknown") + for route, route_context in _iter_routes_with_context(router.routes): + if route_context is None: + path = getattr(route, "path", None) + name = getattr(route, "name", "unknown") + elif route_context.starlette_route is not None: + path = getattr(route_context.starlette_route, "path", None) + name = getattr(route_context.starlette_route, "name", "unknown") + else: + path = route_context.path + name = route_context.name if path is not None and not path: raise FastAPIError( f"Prefix and path cannot be both empty (path operation: {name})" diff --git a/tests/test_router_include_context.py b/tests/test_router_include_context.py index 408cdd3f1..c2679aa11 100644 --- a/tests/test_router_include_context.py +++ b/tests/test_router_include_context.py @@ -2,6 +2,7 @@ from typing import Annotated, cast import pytest from fastapi import APIRouter, Body, Depends, FastAPI, Request +from fastapi.exceptions import FastAPIError from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from fastapi.routing import ( APIRoute, @@ -807,6 +808,60 @@ def test_no_prefix_include_validation_sees_effective_starlette_route_candidates( assert cast(Route, candidates[0]).path == "/child/items" +def test_no_prefix_include_validation_sees_effective_api_route_path(): + leaf_router = APIRouter() + + @leaf_router.get("") + def read_items(): + return [] + + parent_router = APIRouter() + parent_router.include_router(leaf_router, prefix="/items") + + # for coverage + candidates = list(_iter_included_route_candidates(parent_router.routes)) + assert cast(APIRoute, candidates[0]).path == "" + + app = FastAPI() + app.include_router(parent_router) + client = TestClient(app) + + response = client.get("/items") + + assert response.status_code == 200, response.text + assert response.json() == [] + + +def test_no_prefix_include_validation_sees_effective_starlette_route_path(): + def endpoint(request): + return PlainTextResponse("ok") + + child_router = APIRouter(routes=[Route("/items", endpoint, name="read_items")]) + parent_router = APIRouter() + parent_router.include_router(child_router, prefix="/child") + + app = FastAPI() + app.include_router(parent_router) + client = TestClient(app) + + response = client.get("/child/items") + + assert response.status_code == 200, response.text + assert response.text == "ok" + + +def test_no_prefix_include_validation_rejects_empty_effective_api_route_path(): + router = APIRouter() + + @router.get("") + def read_items(): # pragma: no cover + return [] + + app = FastAPI() + with pytest.raises(FastAPIError): + app.include_router(router) + + def test_apirouter_matches_fallback_without_include_context(): router = APIRouter() From e0f8cadf094bb4cb7a21e7757333d5dfbae46712 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 10:55:32 +0000 Subject: [PATCH 02/51] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 704c5599f..991f62c91 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Fixes + +* 🐛 Fix bug, allow empty path in path operation in prefixless router. PR [#15763](https://github.com/fastapi/fastapi/pull/15763) by [@tiangolo](https://github.com/tiangolo). + ## 0.137.0 (2026-06-14) ### Breaking Changes From b78c82262f5170831ebd3f27873b09d8e579bac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 15 Jun 2026 13:19:51 +0200 Subject: [PATCH 03/51] =?UTF-8?q?=F0=9F=9A=A8=20Fix=20typing=20checks=20fo?= =?UTF-8?q?r=20APIRoute=20(#15765)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/routing.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/fastapi/routing.py b/fastapi/routing.py index f33b96479..48c0c2153 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1062,6 +1062,41 @@ def _populate_api_route_state( class APIRoute(routing.Route): + stream_item_type: Any | None + response_model: Any + summary: str | None + response_description: str + deprecated: bool | None + operation_id: str | None + response_model_include: IncEx | None + response_model_exclude: IncEx | None + response_model_by_alias: bool + response_model_exclude_unset: bool + response_model_exclude_defaults: bool + response_model_exclude_none: bool + include_in_schema: bool + response_class: type[Response] | DefaultPlaceholder + dependency_overrides_provider: Any | None + callbacks: list[BaseRoute] | None + openapi_extra: dict[str, Any] | None + generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder + strict_content_type: bool | DefaultPlaceholder + tags: list[str | Enum] + responses: dict[int | str, dict[str, Any]] + unique_id: str + status_code: int | None + response_field: ModelField | None + stream_item_field: ModelField | None + dependencies: list[params.Depends] + description: str + response_fields: dict[int | str, ModelField] + dependant: Dependant + _flat_dependant: Dependant + _embed_body_fields: bool + body_field: ModelField | None + is_sse_stream: bool + is_json_stream: bool + def __init__( self, path: str, From edd1461589287bb432c8eb81ace92d000fd5c1b3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 11:20:24 +0000 Subject: [PATCH 04/51] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 991f62c91..e2a29ea8a 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,7 @@ hide: ### Fixes +* 🚨 Fix typing checks for APIRoute. PR [#15765](https://github.com/fastapi/fastapi/pull/15765) by [@tiangolo](https://github.com/tiangolo). * 🐛 Fix bug, allow empty path in path operation in prefixless router. PR [#15763](https://github.com/fastapi/fastapi/pull/15763) by [@tiangolo](https://github.com/tiangolo). ## 0.137.0 (2026-06-14) From a82e5f2fac102a8b12c0795da80b2cb1d2a8b016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 15 Jun 2026 13:26:48 +0200 Subject: [PATCH 05/51] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.137.?= =?UTF-8?q?1=20(#15766)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] --- docs/en/docs/release-notes.md | 2 ++ fastapi/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index e2a29ea8a..9effecd9e 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,8 @@ hide: ## Latest Changes +## 0.137.1 (2026-06-15) + ### Fixes * 🚨 Fix typing checks for APIRoute. PR [#15765](https://github.com/fastapi/fastapi/pull/15765) by [@tiangolo](https://github.com/tiangolo). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 808f6b294..fdd584b40 100644 --- a/fastapi/__init__.py +++ b/fastapi/__init__.py @@ -1,6 +1,6 @@ """FastAPI framework, high performance, easy to learn, fast to code, ready for production""" -__version__ = "0.137.0" +__version__ = "0.137.1" from starlette import status as status From 76876e5a812268860148ba3a2059795b4d65ae55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 15 Jun 2026 16:31:14 +0200 Subject: [PATCH 06/51] =?UTF-8?q?=F0=9F=94=A7=20Add=20ty=20configs=20to=20?= =?UTF-8?q?check=20docs=20sources=20(#15769)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- pyproject.toml | 36 ++++++++++++++++++++++++++++++++++++ scripts/lint.sh | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cb5b86e3..de6f56a53 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: - id: local-ty name: ty check - entry: uv run ty check fastapi + entry: uv run ty check fastapi docs_src --force-exclude require_serial: true language: unsupported pass_filenames: false diff --git a/pyproject.toml b/pyproject.toml index daa523ce2..8b633a928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -349,5 +349,41 @@ havin = "havin" Ines = "Ines" ser = "ser" +[tool.ty.src] +exclude = [ + # These docs examples are intentionally partial, dynamic, environment-driven, + # deprecated, or currently require broader tutorial rewrites to satisfy ty. + "docs_src/additional_status_codes/", + "docs_src/app_testing/tutorial003_py310.py", + "docs_src/body_multiple_params/", + "docs_src/body_updates/tutorial002_py310.py", + "docs_src/custom_docs_ui/", + "docs_src/custom_response/tutorial001_py310.py", + "docs_src/custom_response/tutorial001b_py310.py", + "docs_src/custom_response/tutorial009c_py310.py", + "docs_src/dependencies/tutorial007_py310.py", + "docs_src/dependencies/tutorial008_an_py310.py", + "docs_src/dependencies/tutorial008_py310.py", + "docs_src/dependencies/tutorial010_py310.py", + "docs_src/events/", + "docs_src/extending_openapi/tutorial001_py310.py", + "docs_src/path_params_numeric_validations/", + "docs_src/pydantic_v1_in_v2/tutorial004_an_py310.py", + "docs_src/python_types/tutorial003_py310.py", + "docs_src/python_types/tutorial011_py310.py", + "docs_src/query_params_str_validations/", + "docs_src/response_model/tutorial006_py310.py", + "docs_src/security/tutorial003_an_py310.py", + "docs_src/security/tutorial003_py310.py", + "docs_src/security/tutorial004_an_py310.py", + "docs_src/security/tutorial004_py310.py", + "docs_src/security/tutorial005_an_py310.py", + "docs_src/security/tutorial005_py310.py", + "docs_src/settings/", + "docs_src/sql_databases/", + "docs_src/using_request_directly/tutorial001_py310.py", + "docs_src/wsgi/tutorial001_py310.py", +] + [tool.ty.terminal] error-on-warning = true diff --git a/scripts/lint.sh b/scripts/lint.sh index a4d3422d3..291674e32 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -4,6 +4,6 @@ set -e set -x mypy fastapi -ty check fastapi +ty check fastapi docs_src --force-exclude ruff check fastapi tests docs_src scripts ruff format fastapi tests --check From 4473a0cd9129f9623f8d23d1a3bc86914967d9c5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 14:31:50 +0000 Subject: [PATCH 07/51] =?UTF-8?q?=F0=9F=93=9D=20Update=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- docs/en/docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 9effecd9e..979c1f9a0 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Internal + +* 🔧 Add ty configs to check docs sources. PR [#15769](https://github.com/fastapi/fastapi/pull/15769) by [@tiangolo](https://github.com/tiangolo). + ## 0.137.1 (2026-06-15) ### Fixes From b7de2b7feb207b0a157b81c237b5a0cba6e21cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Mon, 15 Jun 2026 17:53:46 +0200 Subject: [PATCH 08/51] =?UTF-8?q?=F0=9F=94=A7=20Add=20ty=20configs=20to=20?= =?UTF-8?q?check=20docs=20sources=20(#15770)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 2 +- scripts/contributors.py | 2 +- scripts/deploy_docs_status.py | 2 +- scripts/doc_parsing_utils.py | 4 +-- scripts/docs.py | 4 +-- scripts/label_approved.py | 2 +- scripts/lint.sh | 2 +- scripts/notify_translations.py | 3 ++- scripts/people.py | 2 +- scripts/sponsors.py | 2 +- scripts/topic_repos.py | 2 +- tests/test_compat.py | 18 +++++++++---- tests/test_custom_middleware_exception.py | 4 ++- tests/test_datastructures.py | 7 ++--- tests/test_default_response_class.py | 2 +- tests/test_deprecated_responses.py | 10 +++---- tests/test_inherited_custom_class.py | 2 +- tests/test_jsonable_encoder.py | 10 +++---- tests/test_local_docs.py | 20 +++++++------- tests/test_openapi_schema_type.py | 2 +- tests/test_orjson_response_class.py | 4 +-- ...est_response_model_as_return_annotation.py | 26 +++++++++---------- tests/test_router_events.py | 18 ++++++------- tests/test_schema_compat_pydantic_v2.py | 2 +- tests/test_serialize_response_model.py | 4 +-- tests/test_skip_defaults.py | 2 +- tests/test_sse.py | 4 +-- tests/test_starlette_urlconvertors.py | 4 +-- tests/test_stream_cancellation.py | 9 ++++--- tests/test_swagger_ui_escape.py | 6 ++--- .../test_body/test_tutorial001.py | 2 +- ...est_tutorial001_tutorial002_tutorial003.py | 3 ++- .../test_custom_response/test_tutorial008.py | 3 ++- .../test_custom_response/test_tutorial009.py | 3 ++- .../test_custom_response/test_tutorial009b.py | 3 ++- .../test_debugging/test_tutorial001.py | 10 +++---- .../test_openapi_webhooks/test_tutorial001.py | 5 +++- .../test_python_types/test_tutorial003.py | 2 +- .../test_python_types/test_tutorial005.py | 8 +++--- .../test_security/test_tutorial005.py | 8 +++--- .../test_sql_databases/test_tutorial001.py | 14 ++++++---- .../test_sql_databases/test_tutorial002.py | 14 ++++++---- tests/test_webhooks_security.py | 5 +++- tests/utils.py | 6 ++--- 44 files changed, 149 insertions(+), 118 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de6f56a53..e5c359eeb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: - id: local-ty name: ty check - entry: uv run ty check fastapi docs_src --force-exclude + entry: uv run ty check require_serial: true language: unsupported pass_filenames: false diff --git a/scripts/contributors.py b/scripts/contributors.py index af1434d79..5e9d72d64 100644 --- a/scripts/contributors.py +++ b/scripts/contributors.py @@ -237,7 +237,7 @@ def update_content(*, content_path: Path, new_content: Any) -> bool: def main() -> None: logging.basicConfig(level=logging.INFO) - settings = Settings() + settings = Settings() # ty: ignore[missing-argument] logging.info(f"Using config: {settings.model_dump_json()}") g = Github(settings.github_token.get_secret_value()) repo = g.get_repo(settings.github_repository) diff --git a/scripts/deploy_docs_status.py b/scripts/deploy_docs_status.py index e620b15ba..8e5a2e38b 100644 --- a/scripts/deploy_docs_status.py +++ b/scripts/deploy_docs_status.py @@ -24,7 +24,7 @@ class LinkData(BaseModel): def main() -> None: logging.basicConfig(level=logging.INFO) - settings = Settings() + settings = Settings() # ty: ignore[missing-argument] logging.info(f"Using config: {settings.model_dump_json()}") g = Github(auth=Auth.Token(settings.github_token.get_secret_value())) diff --git a/scripts/doc_parsing_utils.py b/scripts/doc_parsing_utils.py index 88ff2c50b..f2f047c3d 100644 --- a/scripts/doc_parsing_utils.py +++ b/scripts/doc_parsing_utils.py @@ -625,14 +625,14 @@ def replace_multiline_code_block( _line_b_code, line_b_comment = _split_hash_comment(line_b) res_line = line_b if line_b_comment: - res_line = res_line.replace(line_b_comment, line_a_comment, 1) + res_line = res_line.replace(line_b_comment, line_a_comment or "", 1) code_block.append(res_line) elif block_language in {"console", "json", "slash-style-comments"}: _line_a_code, line_a_comment = _split_slashes_comment(line_a) _line_b_code, line_b_comment = _split_slashes_comment(line_b) res_line = line_b if line_b_comment: - res_line = res_line.replace(line_b_comment, line_a_comment, 1) + res_line = res_line.replace(line_b_comment, line_a_comment or "", 1) code_block.append(res_line) else: code_block.append(line_b) diff --git a/scripts/docs.py b/scripts/docs.py index a478d59a0..07c951cc7 100644 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -155,7 +155,7 @@ def build_lang( """ build_zensical_lang_to_stage(lang) copy_zensical_stage_to_site(lang) - typer.secho(f"Successfully built docs for: {lang}", color=typer.colors.GREEN) + typer.secho(f"Successfully built docs for: {lang}", fg=typer.colors.GREEN) def split_markdown_header(markdown: str) -> tuple[str, str]: @@ -408,7 +408,7 @@ def build_all() -> None: for lang in langs: if lang != "en": copy_zensical_stage_to_site(lang) - typer.secho("Successfully built all docs", color=typer.colors.GREEN) + typer.secho("Successfully built all docs", fg=typer.colors.GREEN) @app.command() diff --git a/scripts/label_approved.py b/scripts/label_approved.py index 81de92efb..397a79663 100644 --- a/scripts/label_approved.py +++ b/scripts/label_approved.py @@ -22,7 +22,7 @@ class Settings(BaseSettings): config: dict[str, LabelSettings] | Literal[""] = default_config -settings = Settings() +settings = Settings() # ty: ignore[missing-argument] if settings.debug: logging.basicConfig(level=logging.DEBUG) else: diff --git a/scripts/lint.sh b/scripts/lint.sh index 291674e32..a7d1f2f66 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -4,6 +4,6 @@ set -e set -x mypy fastapi -ty check fastapi docs_src --force-exclude +ty check ruff check fastapi tests docs_src scripts ruff format fastapi tests --check diff --git a/scripts/notify_translations.py b/scripts/notify_translations.py index 3484b69c7..22fa633f4 100644 --- a/scripts/notify_translations.py +++ b/scripts/notify_translations.py @@ -304,7 +304,7 @@ def update_comment(*, settings: Settings, comment_id: str, body: str) -> Comment def main() -> None: - settings = Settings() + settings = Settings() # ty: ignore[missing-argument] if settings.debug: logging.basicConfig(level=logging.DEBUG) else: @@ -324,6 +324,7 @@ def main() -> None: ) or settings.number if number is None: raise RuntimeError("No PR number available") + number = cast(int, number) # Avoid race conditions with multiple labels sleep_time = random.random() * 10 # random number between 0 and 10 seconds diff --git a/scripts/people.py b/scripts/people.py index 5718d65da..72b591367 100644 --- a/scripts/people.py +++ b/scripts/people.py @@ -394,7 +394,7 @@ def update_content(*, content_path: Path, new_content: Any) -> bool: def main() -> None: logging.basicConfig(level=logging.INFO) - settings = Settings() + settings = Settings() # ty: ignore[missing-argument] logging.info(f"Using config: {settings.model_dump_json()}") rate_limiter.speed_multiplier = settings.speed_multiplier g = Github(settings.github_token.get_secret_value()) diff --git a/scripts/sponsors.py b/scripts/sponsors.py index fdcabc737..38d8cbfa4 100644 --- a/scripts/sponsors.py +++ b/scripts/sponsors.py @@ -158,7 +158,7 @@ def update_content(*, content_path: Path, new_content: Any) -> bool: def main() -> None: logging.basicConfig(level=logging.INFO) - settings = Settings() + settings = Settings() # ty: ignore[missing-argument] logging.info(f"Using config: {settings.model_dump_json()}") g = Github(settings.pr_token.get_secret_value()) repo = g.get_repo(settings.github_repository) diff --git a/scripts/topic_repos.py b/scripts/topic_repos.py index b7afc0864..94379d384 100644 --- a/scripts/topic_repos.py +++ b/scripts/topic_repos.py @@ -24,7 +24,7 @@ class Repo(BaseModel): def main() -> None: logging.basicConfig(level=logging.INFO) - settings = Settings() + settings = Settings() # ty: ignore[missing-argument] logging.info(f"Using config: {settings.model_dump_json()}") g = Github(settings.github_token.get_secret_value(), per_page=100) diff --git a/tests/test_compat.py b/tests/test_compat.py index 772bd305e..76151fac9 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -1,3 +1,5 @@ +from typing import Any, cast + from fastapi import FastAPI, UploadFile from fastapi._compat import ( Undefined, @@ -56,9 +58,15 @@ def test_propagates_pydantic2_model_config(): @app.post("/") def foo(req: Model) -> dict[str, str | None]: + value = req.value + if isinstance(value, Missing): + value = None + embedded_value = req.embedded_model.value + if isinstance(embedded_value, Missing): + embedded_value = None return { - "value": req.value or None, - "embedded_value": req.embedded_model.value or None, + "value": value, + "embedded_value": embedded_value, } client = TestClient(app) @@ -100,7 +108,7 @@ def test_serialize_sequence_value_with_optional_list(): """Test that serialize_sequence_value handles optional lists correctly.""" from fastapi._compat import v2 - field_info = FieldInfo(annotation=list[str] | None) + field_info = FieldInfo(annotation=cast(Any, list[str] | None)) field = v2.ModelField(name="items", field_info=field_info) result = v2.serialize_sequence_value(field=field, value=["a", "b", "c"]) assert result == ["a", "b", "c"] @@ -111,7 +119,7 @@ def test_serialize_sequence_value_with_optional_list_pipe_union(): """Test that serialize_sequence_value handles optional lists correctly (with new syntax).""" from fastapi._compat import v2 - field_info = FieldInfo(annotation=list[str] | None) + field_info = FieldInfo(annotation=cast(Any, list[str] | None)) field = v2.ModelField(name="items", field_info=field_info) result = v2.serialize_sequence_value(field=field, value=["a", "b", "c"]) assert result == ["a", "b", "c"] @@ -125,7 +133,7 @@ def test_serialize_sequence_value_with_none_first_in_union(): from fastapi._compat import v2 # Use Union[None, list[str]] to ensure None comes first in the union args - field_info = FieldInfo(annotation=Union[None, list[str]]) # noqa: UP007 + field_info = FieldInfo(annotation=cast(Any, Union[None, list[str]])) # noqa: UP007 field = v2.ModelField(name="items", field_info=field_info) result = v2.serialize_sequence_value(field=field, value=["x", "y"]) assert result == ["x", "y"] diff --git a/tests/test_custom_middleware_exception.py b/tests/test_custom_middleware_exception.py index cf548f4ae..989ab58bc 100644 --- a/tests/test_custom_middleware_exception.py +++ b/tests/test_custom_middleware_exception.py @@ -3,6 +3,7 @@ from pathlib import Path from fastapi import APIRouter, FastAPI, File, UploadFile from fastapi.exceptions import HTTPException from fastapi.testclient import TestClient +from starlette.types import ASGIApp app = FastAPI() @@ -16,7 +17,7 @@ class ContentSizeLimitMiddleware: max_content_size (optional): the maximum content size allowed in bytes, None for no limit """ - def __init__(self, app: APIRouter, max_content_size: int | None = None): + def __init__(self, app: ASGIApp, max_content_size: int | None = None): self.app = app self.max_content_size = max_content_size @@ -31,6 +32,7 @@ class ContentSizeLimitMiddleware: body_len = len(message.get("body", b"")) received += body_len + assert self.max_content_size is not None if received > self.max_content_size: raise HTTPException( 422, diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py index 29a70cae0..1b5335ea9 100644 --- a/tests/test_datastructures.py +++ b/tests/test_datastructures.py @@ -1,9 +1,10 @@ import io from pathlib import Path +from typing import cast import pytest from fastapi import FastAPI, UploadFile -from fastapi.datastructures import Default +from fastapi.datastructures import Default, DefaultPlaceholder from fastapi.testclient import TestClient @@ -13,8 +14,8 @@ def test_upload_file_invalid_pydantic_v2(): def test_default_placeholder_equals(): - placeholder_1 = Default("a") - placeholder_2 = Default("a") + placeholder_1 = cast(DefaultPlaceholder, Default("a")) + placeholder_2 = cast(DefaultPlaceholder, Default("a")) assert placeholder_1 == placeholder_2 assert placeholder_1.value == placeholder_2.value diff --git a/tests/test_default_response_class.py b/tests/test_default_response_class.py index 88498e560..bc60e0b14 100644 --- a/tests/test_default_response_class.py +++ b/tests/test_default_response_class.py @@ -11,7 +11,7 @@ class ORJSONResponse(JSONResponse): media_type = "application/x-orjson" def render(self, content: Any) -> bytes: - import orjson + import orjson # ty: ignore[unresolved-import] return orjson.dumps(content) diff --git a/tests/test_deprecated_responses.py b/tests/test_deprecated_responses.py index 8cbd9c11f..8a5663744 100644 --- a/tests/test_deprecated_responses.py +++ b/tests/test_deprecated_responses.py @@ -3,7 +3,7 @@ import warnings import pytest from fastapi import FastAPI from fastapi.exceptions import FastAPIDeprecationWarning -from fastapi.responses import ORJSONResponse, UJSONResponse +from fastapi.responses import ORJSONResponse, UJSONResponse # ty: ignore[deprecated] from fastapi.testclient import TestClient from pydantic import BaseModel @@ -21,7 +21,7 @@ class Item(BaseModel): def _make_orjson_app() -> FastAPI: with warnings.catch_warnings(): warnings.simplefilter("ignore", FastAPIDeprecationWarning) - app = FastAPI(default_response_class=ORJSONResponse) + app = FastAPI(default_response_class=ORJSONResponse) # ty: ignore[deprecated] @app.get("/items") def get_items() -> Item: @@ -44,7 +44,7 @@ def test_orjson_response_returns_correct_data(): @needs_orjson def test_orjson_response_emits_deprecation_warning(): with pytest.warns(FastAPIDeprecationWarning, match="ORJSONResponse is deprecated"): - ORJSONResponse(content={"hello": "world"}) + ORJSONResponse(content={"hello": "world"}) # ty: ignore[deprecated] # UJSON @@ -53,7 +53,7 @@ def test_orjson_response_emits_deprecation_warning(): def _make_ujson_app() -> FastAPI: with warnings.catch_warnings(): warnings.simplefilter("ignore", FastAPIDeprecationWarning) - app = FastAPI(default_response_class=UJSONResponse) + app = FastAPI(default_response_class=UJSONResponse) # ty: ignore[deprecated] @app.get("/items") def get_items() -> Item: @@ -76,4 +76,4 @@ def test_ujson_response_returns_correct_data(): @needs_ujson def test_ujson_response_emits_deprecation_warning(): with pytest.warns(FastAPIDeprecationWarning, match="UJSONResponse is deprecated"): - UJSONResponse(content={"hello": "world"}) + UJSONResponse(content={"hello": "world"}) # ty: ignore[deprecated] diff --git a/tests/test_inherited_custom_class.py b/tests/test_inherited_custom_class.py index 8cf8952f9..54c6566a0 100644 --- a/tests/test_inherited_custom_class.py +++ b/tests/test_inherited_custom_class.py @@ -13,7 +13,7 @@ class MyUuid: def __str__(self): return self.uuid - @property # type: ignore + @property def __class__(self): return uuid.UUID diff --git a/tests/test_jsonable_encoder.py b/tests/test_jsonable_encoder.py index c23a9e5d7..8f8bd3fcb 100644 --- a/tests/test_jsonable_encoder.py +++ b/tests/test_jsonable_encoder.py @@ -87,10 +87,10 @@ def test_encode_dict(): def test_encode_dict_include_exclude_list(): pet = {"name": "Firulais", "owner": {"name": "Foo"}} assert jsonable_encoder(pet) == {"name": "Firulais", "owner": {"name": "Foo"}} - assert jsonable_encoder(pet, include=["name"]) == {"name": "Firulais"} - assert jsonable_encoder(pet, exclude=["owner"]) == {"name": "Firulais"} - assert jsonable_encoder(pet, include=[]) == {} - assert jsonable_encoder(pet, exclude=[]) == { + assert jsonable_encoder(pet, include=["name"]) == {"name": "Firulais"} # ty: ignore[invalid-argument-type] + assert jsonable_encoder(pet, exclude=["owner"]) == {"name": "Firulais"} # ty: ignore[invalid-argument-type] + assert jsonable_encoder(pet, include=[]) == {} # ty: ignore[invalid-argument-type] + assert jsonable_encoder(pet, exclude=[]) == { # ty: ignore[invalid-argument-type] "name": "Firulais", "owner": {"name": "Foo"}, } @@ -176,7 +176,7 @@ def test_encode_model_with_config(): def test_encode_model_with_alias_raises(): with pytest.raises(ValidationError): - ModelWithAlias(foo="Bar") + ModelWithAlias(foo="Bar") # ty: ignore[missing-argument, unknown-argument] def test_encode_model_with_alias(): diff --git a/tests/test_local_docs.py b/tests/test_local_docs.py index 5f102edf1..351161182 100644 --- a/tests/test_local_docs.py +++ b/tests/test_local_docs.py @@ -9,7 +9,7 @@ def test_strings_in_generated_swagger(): swagger_css_url = sig.parameters.get("swagger_css_url").default # type: ignore swagger_favicon_url = sig.parameters.get("swagger_favicon_url").default # type: ignore html = get_swagger_ui_html(openapi_url="/docs", title="title") - body_content = html.body.decode() + body_content = bytes(html.body).decode() assert swagger_js_url in body_content assert swagger_css_url in body_content assert swagger_favicon_url in body_content @@ -26,7 +26,7 @@ def test_strings_in_custom_swagger(): swagger_css_url=swagger_css_url, swagger_favicon_url=swagger_favicon_url, ) - body_content = html.body.decode() + body_content = bytes(html.body).decode() assert swagger_js_url in body_content assert swagger_css_url in body_content assert swagger_favicon_url in body_content @@ -37,7 +37,7 @@ def test_strings_in_generated_redoc(): redoc_js_url = sig.parameters.get("redoc_js_url").default # type: ignore redoc_favicon_url = sig.parameters.get("redoc_favicon_url").default # type: ignore html = get_redoc_html(openapi_url="/docs", title="title") - body_content = html.body.decode() + body_content = bytes(html.body).decode() assert redoc_js_url in body_content assert redoc_favicon_url in body_content @@ -51,17 +51,17 @@ def test_strings_in_custom_redoc(): redoc_js_url=redoc_js_url, redoc_favicon_url=redoc_favicon_url, ) - body_content = html.body.decode() + body_content = bytes(html.body).decode() assert redoc_js_url in body_content assert redoc_favicon_url in body_content def test_google_fonts_in_generated_redoc(): - body_with_google_fonts = get_redoc_html( - openapi_url="/docs", title="title" - ).body.decode() + body_with_google_fonts = bytes( + get_redoc_html(openapi_url="/docs", title="title").body + ).decode() assert "fonts.googleapis.com" in body_with_google_fonts - body_without_google_fonts = get_redoc_html( - openapi_url="/docs", title="title", with_google_fonts=False - ).body.decode() + body_without_google_fonts = bytes( + get_redoc_html(openapi_url="/docs", title="title", with_google_fonts=False).body + ).decode() assert "fonts.googleapis.com" not in body_without_google_fonts diff --git a/tests/test_openapi_schema_type.py b/tests/test_openapi_schema_type.py index e8166d2fb..610375b77 100644 --- a/tests/test_openapi_schema_type.py +++ b/tests/test_openapi_schema_type.py @@ -21,4 +21,4 @@ def test_allowed_schema_type( def test_invalid_type_value() -> None: """Test that Schema raises ValueError for invalid type values.""" with pytest.raises(ValueError, match="2 validation errors for Schema"): - Schema(type=True) # type: ignore[arg-type] + Schema(type=True) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] diff --git a/tests/test_orjson_response_class.py b/tests/test_orjson_response_class.py index 3e34041dc..499b3e585 100644 --- a/tests/test_orjson_response_class.py +++ b/tests/test_orjson_response_class.py @@ -6,13 +6,13 @@ pytest.importorskip("orjson") from fastapi import FastAPI from fastapi.exceptions import FastAPIDeprecationWarning -from fastapi.responses import ORJSONResponse +from fastapi.responses import ORJSONResponse # ty: ignore[deprecated] from fastapi.testclient import TestClient from sqlalchemy.sql.elements import quoted_name with warnings.catch_warnings(): warnings.simplefilter("ignore", FastAPIDeprecationWarning) - app = FastAPI(default_response_class=ORJSONResponse) + app = FastAPI(default_response_class=ORJSONResponse) # ty: ignore[deprecated] @app.get("/orjson_non_str_keys") diff --git a/tests/test_response_model_as_return_annotation.py b/tests/test_response_model_as_return_annotation.py index 7be7902ad..36d50afa9 100644 --- a/tests/test_response_model_as_return_annotation.py +++ b/tests/test_response_model_as_return_annotation.py @@ -78,22 +78,22 @@ def no_response_model_annotation_return_same_model() -> User: @app.get("/no_response_model-annotation-return_exact_dict") def no_response_model_annotation_return_exact_dict() -> User: - return {"name": "John", "surname": "Doe"} + return {"name": "John", "surname": "Doe"} # ty: ignore[invalid-return-type] @app.get("/no_response_model-annotation-return_invalid_dict") def no_response_model_annotation_return_invalid_dict() -> User: - return {"name": "John"} + return {"name": "John"} # ty: ignore[invalid-return-type] @app.get("/no_response_model-annotation-return_invalid_model") def no_response_model_annotation_return_invalid_model() -> User: - return Item(name="Foo", price=42.0) + return Item(name="Foo", price=42.0) # ty: ignore[invalid-return-type] @app.get("/no_response_model-annotation-return_dict_with_extra_data") def no_response_model_annotation_return_dict_with_extra_data() -> User: - return {"name": "John", "surname": "Doe", "password_hash": "secret"} + return {"name": "John", "surname": "Doe", "password_hash": "secret"} # ty: ignore[invalid-return-type] @app.get("/no_response_model-annotation-return_submodel_with_extra_data") @@ -108,24 +108,24 @@ def response_model_none_annotation_return_same_model() -> User: @app.get("/response_model_none-annotation-return_exact_dict", response_model=None) def response_model_none_annotation_return_exact_dict() -> User: - return {"name": "John", "surname": "Doe"} + return {"name": "John", "surname": "Doe"} # ty: ignore[invalid-return-type] @app.get("/response_model_none-annotation-return_invalid_dict", response_model=None) def response_model_none_annotation_return_invalid_dict() -> User: - return {"name": "John"} + return {"name": "John"} # ty: ignore[invalid-return-type] @app.get("/response_model_none-annotation-return_invalid_model", response_model=None) def response_model_none_annotation_return_invalid_model() -> User: - return Item(name="Foo", price=42.0) + return Item(name="Foo", price=42.0) # ty: ignore[invalid-return-type] @app.get( "/response_model_none-annotation-return_dict_with_extra_data", response_model=None ) def response_model_none_annotation_return_dict_with_extra_data() -> User: - return {"name": "John", "surname": "Doe", "password_hash": "secret"} + return {"name": "John", "surname": "Doe", "password_hash": "secret"} # ty: ignore[invalid-return-type] @app.get( @@ -140,21 +140,21 @@ def response_model_none_annotation_return_submodel_with_extra_data() -> User: "/response_model_model1-annotation_model2-return_same_model", response_model=User ) def response_model_model1_annotation_model2_return_same_model() -> Item: - return User(name="John", surname="Doe") + return User(name="John", surname="Doe") # ty: ignore[invalid-return-type] @app.get( "/response_model_model1-annotation_model2-return_exact_dict", response_model=User ) def response_model_model1_annotation_model2_return_exact_dict() -> Item: - return {"name": "John", "surname": "Doe"} + return {"name": "John", "surname": "Doe"} # ty: ignore[invalid-return-type] @app.get( "/response_model_model1-annotation_model2-return_invalid_dict", response_model=User ) def response_model_model1_annotation_model2_return_invalid_dict() -> Item: - return {"name": "John"} + return {"name": "John"} # ty: ignore[invalid-return-type] @app.get( @@ -169,7 +169,7 @@ def response_model_model1_annotation_model2_return_invalid_model() -> Item: response_model=User, ) def response_model_model1_annotation_model2_return_dict_with_extra_data() -> Item: - return {"name": "John", "surname": "Doe", "password_hash": "secret"} + return {"name": "John", "surname": "Doe", "password_hash": "secret"} # ty: ignore[invalid-return-type] @app.get( @@ -177,7 +177,7 @@ def response_model_model1_annotation_model2_return_dict_with_extra_data() -> Ite response_model=User, ) def response_model_model1_annotation_model2_return_submodel_with_extra_data() -> Item: - return DBUser(name="John", surname="Doe", password_hash="secret") + return DBUser(name="John", surname="Doe", password_hash="secret") # ty: ignore[invalid-return-type] @app.get( diff --git a/tests/test_router_events.py b/tests/test_router_events.py index 7869a7afc..e4f5a58f0 100644 --- a/tests/test_router_events.py +++ b/tests/test_router_events.py @@ -31,31 +31,31 @@ def test_router_events(state: State) -> None: def main() -> dict[str, str]: return {"message": "Hello World"} - @app.on_event("startup") + @app.on_event("startup") # ty: ignore[deprecated] def app_startup() -> None: state.app_startup = True - @app.on_event("shutdown") + @app.on_event("shutdown") # ty: ignore[deprecated] def app_shutdown() -> None: state.app_shutdown = True router = APIRouter() - @router.on_event("startup") + @router.on_event("startup") # ty: ignore[deprecated] def router_startup() -> None: state.router_startup = True - @router.on_event("shutdown") + @router.on_event("shutdown") # ty: ignore[deprecated] def router_shutdown() -> None: state.router_shutdown = True sub_router = APIRouter() - @sub_router.on_event("startup") + @sub_router.on_event("startup") # ty: ignore[deprecated] def sub_router_startup() -> None: state.sub_router_startup = True - @sub_router.on_event("shutdown") + @sub_router.on_event("shutdown") # ty: ignore[deprecated] def sub_router_shutdown() -> None: state.sub_router_shutdown = True @@ -253,7 +253,7 @@ def test_router_async_shutdown_handler(state: State) -> None: def main() -> dict[str, str]: return {"message": "Hello World"} - @app.on_event("shutdown") + @app.on_event("shutdown") # ty: ignore[deprecated] async def app_shutdown() -> None: state.app_shutdown = True @@ -274,7 +274,7 @@ def test_router_sync_generator_lifespan(state: State) -> None: yield state.app_shutdown = True - app = FastAPI(lifespan=lifespan) # type: ignore[arg-type] + app = FastAPI(lifespan=lifespan) # type: ignore[invalid-argument-type] # ty: ignore[invalid-argument-type] @app.get("/") def main() -> dict[str, str]: @@ -300,7 +300,7 @@ def test_router_async_generator_lifespan(state: State) -> None: yield state.app_shutdown = True - app = FastAPI(lifespan=lifespan) # type: ignore[arg-type] + app = FastAPI(lifespan=lifespan) # type: ignore[invalid-argument-type] # ty: ignore[invalid-argument-type] @app.get("/") def main() -> dict[str, str]: diff --git a/tests/test_schema_compat_pydantic_v2.py b/tests/test_schema_compat_pydantic_v2.py index 7612c6ab5..bf47e62b2 100644 --- a/tests/test_schema_compat_pydantic_v2.py +++ b/tests/test_schema_compat_pydantic_v2.py @@ -26,7 +26,7 @@ def get_client(): @app.get("/users") async def get_user() -> User: - return {"username": "alice", "role": "admin"} + return {"username": "alice", "role": "admin"} # ty: ignore[invalid-return-type] client = TestClient(app) return client diff --git a/tests/test_serialize_response_model.py b/tests/test_serialize_response_model.py index bb05f7bc4..6ee55ead8 100644 --- a/tests/test_serialize_response_model.py +++ b/tests/test_serialize_response_model.py @@ -18,7 +18,7 @@ def get_valid(): @app.get("/items/coerce", response_model=Item) def get_coerce(): - return Item(aliased_name="coerce", price="1.0") + return Item(aliased_name="coerce", price="1.0") # ty: ignore[invalid-argument-type] @app.get("/items/validlist", response_model=list[Item]) @@ -52,7 +52,7 @@ def get_valid_exclude_unset(): response_model_exclude_unset=True, ) def get_coerce_exclude_unset(): - return Item(aliased_name="coerce", price="1.0") + return Item(aliased_name="coerce", price="1.0") # ty: ignore[invalid-argument-type] @app.get( diff --git a/tests/test_skip_defaults.py b/tests/test_skip_defaults.py index 238da7392..170cf21e3 100644 --- a/tests/test_skip_defaults.py +++ b/tests/test_skip_defaults.py @@ -29,7 +29,7 @@ class ModelDefaults(BaseModel): @app.get("/", response_model=Model, response_model_exclude_unset=True) def get_root() -> ModelSubclass: - return ModelSubclass(sub={}, y=1, z=0) + return ModelSubclass(sub={}, y=1, z=0) # ty: ignore[invalid-argument-type] @app.get( diff --git a/tests/test_sse.py b/tests/test_sse.py index 86a67f8f9..6a9d669fe 100644 --- a/tests/test_sse.py +++ b/tests/test_sse.py @@ -227,7 +227,7 @@ def test_server_sent_event_single_line_fields_reject_newlines( field_name: str, value: str ): with pytest.raises(ValueError, match=f"SSE '{field_name}' must be a single line"): - ServerSentEvent(data="test", **{field_name: value}) + ServerSentEvent(data="test", **{field_name: value}) # ty: ignore[invalid-argument-type] def test_server_sent_event_negative_retry_rejected(): @@ -237,7 +237,7 @@ def test_server_sent_event_negative_retry_rejected(): def test_server_sent_event_float_retry_rejected(): with pytest.raises(ValueError): - ServerSentEvent(data="test", retry=1.5) # type: ignore[arg-type] + ServerSentEvent(data="test", retry=1.5) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] def test_raw_data_sent_without_json_encoding(client: TestClient): diff --git a/tests/test_starlette_urlconvertors.py b/tests/test_starlette_urlconvertors.py index 5ef1b819c..cebe3dbe8 100644 --- a/tests/test_starlette_urlconvertors.py +++ b/tests/test_starlette_urlconvertors.py @@ -32,7 +32,7 @@ def test_route_converters_int(): response = client.get("/int/5") assert response.status_code == 200, response.text assert response.json() == {"int": 5} - assert app.url_path_for("int_convertor", param=5) == "/int/5" # type: ignore + assert app.url_path_for("int_convertor", param=5) == "/int/5" def test_route_converters_float(): @@ -40,7 +40,7 @@ def test_route_converters_float(): response = client.get("/float/25.5") assert response.status_code == 200, response.text assert response.json() == {"float": 25.5} - assert app.url_path_for("float_convertor", param=25.5) == "/float/25.5" # type: ignore + assert app.url_path_for("float_convertor", param=25.5) == "/float/25.5" def test_route_converters_path(): diff --git a/tests/test_stream_cancellation.py b/tests/test_stream_cancellation.py index 20069c5f6..18e6d67d5 100644 --- a/tests/test_stream_cancellation.py +++ b/tests/test_stream_cancellation.py @@ -10,6 +10,7 @@ import anyio import pytest from fastapi import FastAPI from fastapi.responses import StreamingResponse +from starlette.types import Message, Scope pytestmark = [ pytest.mark.anyio, @@ -45,16 +46,16 @@ async def _run_asgi_and_cancel(app: FastAPI, path: str, timeout: float) -> bool: """ chunks: list[bytes] = [] - async def receive(): # type: ignore[no-untyped-def] + async def receive() -> Message: # Simulate a client that never disconnects, rely on cancellation await anyio.sleep(float("inf")) return {"type": "http.disconnect"} # pragma: no cover - async def send(message: dict) -> None: # type: ignore[type-arg] + async def send(message: Message) -> None: if message["type"] == "http.response.body": chunks.append(message.get("body", b"")) - scope = { + scope: Scope = { "type": "http", "asgi": {"version": "3.0", "spec_version": "2.0"}, "http_version": "1.1", @@ -67,7 +68,7 @@ async def _run_asgi_and_cancel(app: FastAPI, path: str, timeout: float) -> bool: } with anyio.move_on_after(timeout) as cancel_scope: - await app(scope, receive, send) # type: ignore[arg-type] + await app(scope, receive, send) # If we got here within the timeout the generator was cancellable. # cancel_scope.cancelled_caught is True when move_on_after fired. diff --git a/tests/test_swagger_ui_escape.py b/tests/test_swagger_ui_escape.py index 072d21952..6b9851abd 100644 --- a/tests/test_swagger_ui_escape.py +++ b/tests/test_swagger_ui_escape.py @@ -8,7 +8,7 @@ def test_init_oauth_html_chars_are_escaped(): title="Test", init_oauth={"appName": xss_payload}, ) - body = html.body.decode() + body = bytes(html.body).decode() assert "