diff --git a/.github/workflows/translate.yml b/.github/workflows/translate.yml index 83518614b0..bb23fa32d9 100644 --- a/.github/workflows/translate.yml +++ b/.github/workflows/translate.yml @@ -35,6 +35,11 @@ on: type: boolean required: false default: false + max: + description: Maximum number of items to translate (e.g. 10) + type: number + required: false + default: 10 jobs: langs: @@ -115,3 +120,4 @@ jobs: EN_PATH: ${{ github.event.inputs.en_path }} COMMAND: ${{ matrix.command }} COMMIT_IN_PLACE: ${{ github.event.inputs.commit_in_place }} + MAX: ${{ github.event.inputs.max }} diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index 2fea9e79c2..c1a563f18b 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,17 @@ hide: ## Latest Changes +## 0.128.2 + +### Features + +* ✨ Add support for PEP695 `TypeAliasType`. PR [#13920](https://github.com/fastapi/fastapi/pull/13920) by [@cstruct](https://github.com/cstruct). +* ✨ Allow `Response` type hint as dependency annotation. PR [#14794](https://github.com/fastapi/fastapi/pull/14794) by [@jonathan-fulton](https://github.com/jonathan-fulton). + +### Fixes + +* 🐛 Fix using `Json[list[str]]` type (issue #10997). PR [#14616](https://github.com/fastapi/fastapi/pull/14616) by [@mkanetsuna](https://github.com/mkanetsuna). + ### Docs * 📝 Update docs for translations. PR [#14830](https://github.com/fastapi/fastapi/pull/14830) by [@tiangolo](https://github.com/tiangolo). @@ -14,6 +25,8 @@ hide: ### Translations +* 🌐 Enable Traditional Chinese translations. PR [#14842](https://github.com/fastapi/fastapi/pull/14842) by [@tiangolo](https://github.com/tiangolo). +* 🌐 Enable French docs translations. PR [#14841](https://github.com/fastapi/fastapi/pull/14841) by [@tiangolo](https://github.com/tiangolo). * 🌐 Update translations for fr (translate-page). PR [#14837](https://github.com/fastapi/fastapi/pull/14837) by [@tiangolo](https://github.com/tiangolo). * 🌐 Update translations for de (update-outdated). PR [#14836](https://github.com/fastapi/fastapi/pull/14836) by [@tiangolo](https://github.com/tiangolo). * 🌐 Update translations for pt (update-outdated). PR [#14833](https://github.com/fastapi/fastapi/pull/14833) by [@tiangolo](https://github.com/tiangolo). @@ -26,6 +39,10 @@ hide: * 🌐 Update translations for uk (update-outdated). PR [#14822](https://github.com/fastapi/fastapi/pull/14822) by [@tiangolo](https://github.com/tiangolo). * 🔨 Update docs and translations scripts, enable Turkish. PR [#14824](https://github.com/fastapi/fastapi/pull/14824) by [@tiangolo](https://github.com/tiangolo). +### Internal + +* 🔨 Add max pages to translate to configs. PR [#14840](https://github.com/fastapi/fastapi/pull/14840) by [@tiangolo](https://github.com/tiangolo). + ## 0.128.1 ### Features diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index f713f2b122..ccf5ffc7a0 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -317,6 +317,8 @@ extra: name: de - Deutsch - link: /es/ name: es - español + - link: /fr/ + name: fr - français - link: /ja/ name: ja - 日本語 - link: /ko/ @@ -329,6 +331,8 @@ extra: name: tr - Türkçe - link: /uk/ name: uk - українська мова + - link: /zh-hant/ + name: zh-hant - 繁體中文 extra_css: - css/termynal.css - css/custom.css diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 160e7235c3..99b9630d20 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.128.1" +__version__ = "0.128.2" from starlette import status as status diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 107049be2d..67c6b9787f 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -56,7 +56,7 @@ from fastapi.logger import logger from fastapi.security.oauth2 import SecurityScopes from fastapi.types import DependencyCacheKey from fastapi.utils import create_model_field, get_path_param_names -from pydantic import BaseModel +from pydantic import BaseModel, Json from pydantic.fields import FieldInfo from starlette.background import BackgroundTasks as StarletteBackgroundTasks from starlette.concurrency import run_in_threadpool @@ -71,6 +71,7 @@ from starlette.requests import HTTPConnection, Request from starlette.responses import Response from starlette.websockets import WebSocket from typing_extensions import Literal, get_args, get_origin +from typing_inspection.typing_objects import is_typealiastype multipart_not_installed_error = ( 'Form data requires "python-multipart" to be installed. \n' @@ -375,6 +376,9 @@ def analyze_param( depends = None type_annotation: Any = Any use_annotation: Any = Any + if is_typealiastype(annotation): + # unpack in case PEP 695 type syntax is used + annotation = annotation.__value__ if annotation is not inspect.Signature.empty: use_annotation = annotation type_annotation = annotation @@ -454,7 +458,9 @@ def analyze_param( depends = dataclasses.replace(depends, dependency=type_annotation) # Handle non-param type annotations like Request - if lenient_issubclass( + # Only apply special handling when there's no explicit Depends - if there's a Depends, + # the dependency will be called and its return value used instead of the special injection + if depends is None and lenient_issubclass( type_annotation, ( Request, @@ -465,7 +471,6 @@ def analyze_param( SecurityScopes, ), ): - assert depends is None, f"Cannot specify `Depends` for type {type_annotation!r}" assert field_info is None, ( f"Cannot specify FastAPI annotation for type {type_annotation!r}" ) @@ -740,12 +745,21 @@ def _validate_value_with_model_field( return v_, [] +def _is_json_field(field: ModelField) -> bool: + return any(type(item) is Json for item in field.field_info.metadata) + + def _get_multidict_value( field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None ) -> Any: alias = alias or get_validation_alias(field) value: Any = None - if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): + + if ( + (not _is_json_field(field)) + and is_sequence_field(field) + and isinstance(values, (ImmutableMultiDict, Headers)) + ): value = values.getlist(alias) elif alias in values: value = values[alias] diff --git a/pyproject.toml b/pyproject.toml index 0f6bf1e4ac..e62baa5f85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "starlette>=0.40.0,<0.51.0", "pydantic>=2.7.0", "typing-extensions>=4.8.0", + "typing-inspection>=0.4.2", "annotated-doc>=0.0.2", ] diff --git a/scripts/docs.py b/scripts/docs.py index c1fb84bba7..2c7d6d5e4e 100644 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -23,7 +23,7 @@ SUPPORTED_LANGS = { "de", "en", "es", - # "fr", + "fr", "ja", "ko", "pt", @@ -31,7 +31,7 @@ SUPPORTED_LANGS = { "tr", "uk", # "zh", - # "zh-hant", + "zh-hant", } diff --git a/scripts/translate.py b/scripts/translate.py index 31c44583d1..ddcfa311d6 100644 --- a/scripts/translate.py +++ b/scripts/translate.py @@ -347,9 +347,12 @@ def list_outdated(language: str) -> list[Path]: @app.command() -def update_outdated(language: Annotated[str, typer.Option(envvar="LANGUAGE")]) -> None: +def update_outdated( + language: Annotated[str, typer.Option(envvar="LANGUAGE")], + max: Annotated[int, typer.Option(envvar="MAX")] = 10, +) -> None: outdated_paths = list_outdated(language) - for path in outdated_paths: + for path in outdated_paths[:max]: print(f"Updating lang: {language} path: {path}") translate_page(language=language, en_path=path) print(f"Done updating: {path}") @@ -357,9 +360,12 @@ def update_outdated(language: Annotated[str, typer.Option(envvar="LANGUAGE")]) - @app.command() -def add_missing(language: Annotated[str, typer.Option(envvar="LANGUAGE")]) -> None: +def add_missing( + language: Annotated[str, typer.Option(envvar="LANGUAGE")], + max: Annotated[int, typer.Option(envvar="MAX")] = 10, +) -> None: missing_paths = list_missing(language) - for path in missing_paths: + for path in missing_paths[:max]: print(f"Adding lang: {language} path: {path}") translate_page(language=language, en_path=path) print(f"Done adding: {path}") @@ -367,11 +373,14 @@ def add_missing(language: Annotated[str, typer.Option(envvar="LANGUAGE")]) -> No @app.command() -def update_and_add(language: Annotated[str, typer.Option(envvar="LANGUAGE")]) -> None: +def update_and_add( + language: Annotated[str, typer.Option(envvar="LANGUAGE")], + max: Annotated[int, typer.Option(envvar="MAX")] = 10, +) -> None: print(f"Updating outdated translations for {language}") - update_outdated(language=language) + update_outdated(language=language, max=max) print(f"Adding missing translations for {language}") - add_missing(language=language) + add_missing(language=language, max=max) print(f"Done updating and adding for {language}") diff --git a/tests/test_dependency_pep695.py b/tests/test_dependency_pep695.py new file mode 100644 index 0000000000..ef5636638e --- /dev/null +++ b/tests/test_dependency_pep695.py @@ -0,0 +1,27 @@ +from typing import Annotated + +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from typing_extensions import TypeAliasType + + +async def some_value() -> int: + return 123 + + +DependedValue = TypeAliasType( + "DependedValue", Annotated[int, Depends(some_value)], type_params=() +) + + +def test_pep695_type_dependencies(): + app = FastAPI() + + @app.get("/") + async def get_with_dep(value: DependedValue) -> str: # noqa + return f"value: {value}" + + client = TestClient(app) + response = client.get("/") + assert response.status_code == 200 + assert response.text == '"value: 123"' diff --git a/tests/test_json_type.py b/tests/test_json_type.py new file mode 100644 index 0000000000..3e213eaca4 --- /dev/null +++ b/tests/test_json_type.py @@ -0,0 +1,63 @@ +import json +from typing import Annotated + +from fastapi import Cookie, FastAPI, Form, Header, Query +from fastapi.testclient import TestClient +from pydantic import Json + +app = FastAPI() + + +@app.post("/form-json-list") +def form_json_list(items: Annotated[Json[list[str]], Form()]) -> list[str]: + return items + + +@app.get("/query-json-list") +def query_json_list(items: Annotated[Json[list[str]], Query()]) -> list[str]: + return items + + +@app.get("/header-json-list") +def header_json_list(x_items: Annotated[Json[list[str]], Header()]) -> list[str]: + return x_items + + +@app.get("/cookie-json-list") +def cookie_json_list(items: Annotated[Json[list[str]], Cookie()]) -> list[str]: + return items + + +client = TestClient(app) + + +def test_form_json_list(): + response = client.post( + "/form-json-list", data={"items": json.dumps(["abc", "def"])} + ) + assert response.status_code == 200, response.text + assert response.json() == ["abc", "def"] + + +def test_query_json_list(): + response = client.get( + "/query-json-list", params={"items": json.dumps(["abc", "def"])} + ) + assert response.status_code == 200, response.text + assert response.json() == ["abc", "def"] + + +def test_header_json_list(): + response = client.get( + "/header-json-list", headers={"x-items": json.dumps(["abc", "def"])} + ) + assert response.status_code == 200, response.text + assert response.json() == ["abc", "def"] + + +def test_cookie_json_list(): + client.cookies.set("items", json.dumps(["abc", "def"])) + response = client.get("/cookie-json-list") + assert response.status_code == 200, response.text + assert response.json() == ["abc", "def"] + client.cookies.clear() diff --git a/tests/test_response_dependency.py b/tests/test_response_dependency.py new file mode 100644 index 0000000000..38c3595948 --- /dev/null +++ b/tests/test_response_dependency.py @@ -0,0 +1,173 @@ +"""Test using special types (Response, Request, BackgroundTasks) as dependency annotations. + +These tests verify that special FastAPI types can be used with Depends() annotations +and that the dependency injection system properly handles them. +""" + +from typing import Annotated + +from fastapi import BackgroundTasks, Depends, FastAPI, Request, Response +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + + +def test_response_with_depends_annotated(): + """Response type hint should work with Annotated[Response, Depends(...)].""" + app = FastAPI() + + def modify_response(response: Response) -> Response: + response.headers["X-Custom"] = "modified" + return response + + @app.get("/") + def endpoint(response: Annotated[Response, Depends(modify_response)]): + return {"status": "ok"} + + client = TestClient(app) + resp = client.get("/") + + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + assert resp.headers.get("X-Custom") == "modified" + + +def test_response_with_depends_default(): + """Response type hint should work with Response = Depends(...).""" + app = FastAPI() + + def modify_response(response: Response) -> Response: + response.headers["X-Custom"] = "modified" + return response + + @app.get("/") + def endpoint(response: Response = Depends(modify_response)): + return {"status": "ok"} + + client = TestClient(app) + resp = client.get("/") + + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + assert resp.headers.get("X-Custom") == "modified" + + +def test_response_without_depends(): + """Regular Response injection should still work.""" + app = FastAPI() + + @app.get("/") + def endpoint(response: Response): + response.headers["X-Direct"] = "set" + return {"status": "ok"} + + client = TestClient(app) + resp = client.get("/") + + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + assert resp.headers.get("X-Direct") == "set" + + +def test_response_dependency_chain(): + """Response dependency should work in a chain of dependencies.""" + app = FastAPI() + + def first_modifier(response: Response) -> Response: + response.headers["X-First"] = "1" + return response + + def second_modifier( + response: Annotated[Response, Depends(first_modifier)], + ) -> Response: + response.headers["X-Second"] = "2" + return response + + @app.get("/") + def endpoint(response: Annotated[Response, Depends(second_modifier)]): + return {"status": "ok"} + + client = TestClient(app) + resp = client.get("/") + + assert resp.status_code == 200 + assert resp.headers.get("X-First") == "1" + assert resp.headers.get("X-Second") == "2" + + +def test_response_dependency_returns_different_response_instance(): + """Dependency that returns a different Response instance should work. + + When a dependency returns a new Response object (e.g., JSONResponse) instead + of modifying the injected one, the returned response should be used and any + modifications to it in the endpoint should be preserved. + """ + app = FastAPI() + + def default_response() -> Response: + response = JSONResponse(content={"status": "ok"}) + response.headers["X-Custom"] = "initial" + return response + + @app.get("/") + def endpoint(response: Annotated[Response, Depends(default_response)]): + response.headers["X-Custom"] = "modified" + return response + + client = TestClient(app) + resp = client.get("/") + + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + assert resp.headers.get("X-Custom") == "modified" + + +# Tests for Request type hint with Depends +def test_request_with_depends_annotated(): + """Request type hint should work in dependency chain.""" + app = FastAPI() + + def extract_request_info(request: Request) -> dict: + return { + "path": request.url.path, + "user_agent": request.headers.get("user-agent", "unknown"), + } + + @app.get("/") + def endpoint( + info: Annotated[dict, Depends(extract_request_info)], + ): + return info + + client = TestClient(app) + resp = client.get("/", headers={"user-agent": "test-agent"}) + + assert resp.status_code == 200 + assert resp.json() == {"path": "/", "user_agent": "test-agent"} + + +# Tests for BackgroundTasks type hint with Depends +def test_background_tasks_with_depends_annotated(): + """BackgroundTasks type hint should work with Annotated[BackgroundTasks, Depends(...)].""" + app = FastAPI() + task_results = [] + + def background_task(message: str): + task_results.append(message) + + def add_background_task(background_tasks: BackgroundTasks) -> BackgroundTasks: + background_tasks.add_task(background_task, "from dependency") + return background_tasks + + @app.get("/") + def endpoint( + background_tasks: Annotated[BackgroundTasks, Depends(add_background_task)], + ): + background_tasks.add_task(background_task, "from endpoint") + return {"status": "ok"} + + client = TestClient(app) + resp = client.get("/") + + assert resp.status_code == 200 + assert "from dependency" in task_results + assert "from endpoint" in task_results diff --git a/uv.lock b/uv.lock index 931a27021b..db5d2e9b1a 100644 --- a/uv.lock +++ b/uv.lock @@ -1021,6 +1021,7 @@ dependencies = [ { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "starlette", version = "0.50.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] [package.optional-dependencies] @@ -1202,6 +1203,7 @@ requires-dist = [ { name = "pyyaml", marker = "extra == 'all'", specifier = ">=5.3.1" }, { name = "starlette", specifier = ">=0.40.0,<0.51.0" }, { name = "typing-extensions", specifier = ">=4.8.0" }, + { name = "typing-inspection", specifier = ">=0.4.2" }, { name = "ujson", marker = "extra == 'all'", specifier = ">=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.12.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'standard'", specifier = ">=0.12.0" },