From 2fd2248d40bb70340c065335bb5c9f0a7bf0ee81 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 11 Mar 2026 09:17:12 +0100 Subject: [PATCH 1/4] Add tests for response_model_* params with non-generator Iterable return type --- tests/test_skip_defaults.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_skip_defaults.py b/tests/test_skip_defaults.py index 238da7392..f6333c754 100644 --- a/tests/test_skip_defaults.py +++ b/tests/test_skip_defaults.py @@ -1,3 +1,5 @@ +from collections.abc import Iterable + from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel @@ -65,6 +67,21 @@ def get_exclude_unset_none() -> ModelDefaults: return ModelDefaults(x=None, y="y") +@app.get("/iterable_exclude_unset", response_model_exclude_unset=True) +def get_iterable_exclude_unset() -> Iterable[ModelDefaults]: + return [ModelDefaults(x=None, y="y")] + + +@app.get("/iterable_exclude_defaults", response_model_exclude_defaults=True) +def get_iterable_exclude_defaults() -> Iterable[ModelDefaults]: + return [ModelDefaults(x=None, y="y")] + + +@app.get("/iterable_exclude_none", response_model_exclude_none=True) +def get_iterable_exclude_none() -> Iterable[ModelDefaults]: + return [ModelDefaults(x=None, y="y")] + + client = TestClient(app) @@ -91,3 +108,18 @@ def test_return_exclude_none(): def test_return_exclude_unset_none(): response = client.get("/exclude_unset_none") assert response.json() == {"y": "y"} + + +def test_return_iterable_exclude_unset(): + response = client.get("/iterable_exclude_unset") + assert response.json() == [{"x": None, "y": "y"}] + + +def test_return_iterable_exclude_defaults(): + response = client.get("/iterable_exclude_defaults") + assert response.json() == [{}] + + +def test_return_iterable_exclude_none(): + response = client.get("/iterable_exclude_none") + assert response.json() == [{"y": "y", "z": "z"}] From 6ab384c423f37ff33f54a7c21c4ad0d2d3fbe207 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 15 Jun 2026 15:30:11 +0200 Subject: [PATCH 2/4] Move blocks down --- fastapi/routing.py | 87 +++++++++++++++++++++++----------------------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 48c0c2153..75579776d 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -930,28 +930,6 @@ def _populate_api_route_state( route.path = path route.endpoint = endpoint route.stream_item_type = None - if isinstance(response_model, DefaultPlaceholder): - return_annotation = get_typed_return_annotation(endpoint) - if lenient_issubclass(return_annotation, Response): - response_model = None - else: - stream_item = get_stream_item_type(return_annotation) - if stream_item is not None: - # Extract item type for JSONL or SSE streaming when - # response_class is DefaultPlaceholder (JSONL) or - # EventSourceResponse (SSE). - # ServerSentEvent is excluded: it's a transport - # wrapper, not a data model, so it shouldn't feed - # into validation or OpenAPI schema generation. - if ( - isinstance(response_class, DefaultPlaceholder) - or lenient_issubclass(response_class, EventSourceResponse) - ) and not lenient_issubclass(stream_item, ServerSentEvent): - route.stream_item_type = stream_item - response_model = None - else: - response_model = return_annotation - route.response_model = response_model route.summary = summary route.response_description = response_description route.deprecated = deprecated @@ -987,27 +965,6 @@ def _populate_api_route_state( if isinstance(status_code, IntEnum): status_code = int(status_code) route.status_code = status_code - if route.response_model: - assert is_body_allowed_for_status_code(status_code), ( - f"Status code {status_code} must not have a response body" - ) - response_name = "Response_" + route.unique_id - route.response_field = create_model_field( - name=response_name, - type_=route.response_model, - mode="serialization", - ) - else: - route.response_field = None - if route.stream_item_type: - stream_item_name = "StreamItem_" + route.unique_id - route.stream_item_field = create_model_field( - name=stream_item_name, - type_=route.stream_item_type, - mode="serialization", - ) - else: - route.stream_item_field = None route.dependencies = list(dependencies or []) route.description = description or inspect.cleandoc(route.endpoint.__doc__ or "") # if a "form feed" character (page break) is found in the description text, @@ -1059,7 +1016,49 @@ def _populate_api_route_state( route.is_json_stream = is_generator and isinstance( response_class, DefaultPlaceholder ) - + if isinstance(response_model, DefaultPlaceholder): + return_annotation = get_typed_return_annotation(endpoint) + if lenient_issubclass(return_annotation, Response): + response_model = None + else: + stream_item = get_stream_item_type(return_annotation) + if stream_item is not None: + # Extract item type for JSONL or SSE streaming when + # response_class is DefaultPlaceholder (JSONL) or + # EventSourceResponse (SSE). + # ServerSentEvent is excluded: it's a transport + # wrapper, not a data model, so it shouldn't feed + # into validation or OpenAPI schema generation. + if ( + isinstance(response_class, DefaultPlaceholder) + or lenient_issubclass(response_class, EventSourceResponse) + ) and not lenient_issubclass(stream_item, ServerSentEvent): + route.stream_item_type = stream_item + response_model = None + else: + response_model = return_annotation + route.response_model = response_model + if route.response_model: + assert is_body_allowed_for_status_code(status_code), ( + f"Status code {status_code} must not have a response body" + ) + response_name = "Response_" + route.unique_id + route.response_field = create_model_field( + name=response_name, + type_=route.response_model, + mode="serialization", + ) + else: + route.response_field = None + if route.stream_item_type: + stream_item_name = "StreamItem_" + route.unique_id + route.stream_item_field = create_model_field( + name=stream_item_name, + type_=route.stream_item_type, + mode="serialization", + ) + else: + route.stream_item_field = None class APIRoute(routing.Route): stream_item_type: Any | None From 3b0c595c2e160165847f31991a8850fc3d60f4c5 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 15 Jun 2026 15:31:57 +0200 Subject: [PATCH 3/4] Fix response_model being set to None for non-generator endpoints --- fastapi/routing.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 75579776d..3adf59250 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1022,10 +1022,11 @@ def _populate_api_route_state( response_model = None else: stream_item = get_stream_item_type(return_annotation) - if stream_item is not None: - # Extract item type for JSONL or SSE streaming when - # response_class is DefaultPlaceholder (JSONL) or - # EventSourceResponse (SSE). + if stream_item is not None and is_generator: + # Extract item type for JSONL or SSE streaming for + # generator endpoints when response_class is + # DefaultPlaceholder (JSONL) or EventSourceResponse + # (SSE). # ServerSentEvent is excluded: it's a transport # wrapper, not a data model, so it shouldn't feed # into validation or OpenAPI schema generation. From 63eb33ce1042d3649a0ba2f7f1ea37c91fb463d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 13:33:02 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/routing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fastapi/routing.py b/fastapi/routing.py index 3adf59250..96ac05e3a 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -1061,6 +1061,7 @@ def _populate_api_route_state( else: route.stream_item_field = None + class APIRoute(routing.Route): stream_item_type: Any | None response_model: Any