From cda2003ff55b6aa38d8dbb253bbdf281d5c16d33 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Mon, 9 Feb 2026 12:04:36 +0100 Subject: [PATCH 1/3] Fix --- fastapi/_compat/shared.py | 8 ++ tests/test_nested_annotated_in_sequence.py | 136 +++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tests/test_nested_annotated_in_sequence.py diff --git a/fastapi/_compat/shared.py b/fastapi/_compat/shared.py index c009da8fd..db4cb541b 100644 --- a/fastapi/_compat/shared.py +++ b/fastapi/_compat/shared.py @@ -65,6 +65,10 @@ def _annotation_is_sequence(annotation: Union[type[Any], None]) -> bool: def field_annotation_is_sequence(annotation: Union[type[Any], None]) -> bool: origin = get_origin(annotation) + + if origin is Annotated: + return field_annotation_is_sequence(get_args(annotation)[0]) + if origin is Union or origin is UnionType: for arg in get_args(annotation): if field_annotation_is_sequence(arg): @@ -110,6 +114,10 @@ def field_annotation_is_scalar(annotation: Any) -> bool: def field_annotation_is_scalar_sequence(annotation: Union[type[Any], None]) -> bool: origin = get_origin(annotation) + + if origin is Annotated: + return field_annotation_is_scalar_sequence(get_args(annotation)[0]) + if origin is Union or origin is UnionType: at_least_one_scalar_sequence = False for arg in get_args(annotation): diff --git a/tests/test_nested_annotated_in_sequence.py b/tests/test_nested_annotated_in_sequence.py new file mode 100644 index 000000000..09e4363e2 --- /dev/null +++ b/tests/test_nested_annotated_in_sequence.py @@ -0,0 +1,136 @@ +from typing import Annotated, Union + +from dirty_equals import IsList +from fastapi import FastAPI, Query +from fastapi.testclient import TestClient +from pydantic import Field + +MaxSizedSet = Annotated[set[str], Field(max_length=3)] + +app = FastAPI() + + +@app.get("/") +def read_root(foo: Annotated[Union[MaxSizedSet, None], Query()] = None): + return {"foo": foo} + + +client = TestClient(app) + + +def test_endpoint_none(): + response = client.get("/") + assert response.status_code == 200 + assert response.json() == {"foo": None} + + +def test_endpoint_valid(): + response = client.get("/", params={"foo": ["a", "b"]}) + assert response.status_code == 200 + assert response.json() == {"foo": IsList("a", "b", check_order=False)} + + +def test_endpoint_too_long(): + response = client.get("/", params={"foo": ["a", "b", "c", "d"]}) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "too_long", + "loc": ["query", "foo"], + "msg": "Set should have at most 3 items after validation, not more", + "input": IsList("a", "b", "c", "d", check_order=False), + "ctx": { + "actual_length": None, + "field_type": "Set", + "max_length": 3, + }, + } + ] + } + + +def test_openapi(): + assert app.openapi() == { + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": {"$ref": "#/components/schemas/ValidationError"}, + "title": "Detail", + "type": "array", + }, + }, + "title": "HTTPValidationError", + "type": "object", + }, + "ValidationError": { + "properties": { + "ctx": {"title": "Context", "type": "object"}, + "input": {"title": "Input"}, + "loc": { + "items": { + "anyOf": [{"type": "string"}, {"type": "integer"}], + }, + "title": "Location", + "type": "array", + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + "required": ["loc", "msg", "type"], + "title": "ValidationError", + "type": "object", + }, + }, + }, + "info": { + "title": "FastAPI", + "version": "0.1.0", + }, + "openapi": "3.1.0", + "paths": { + "/": { + "get": { + "operationId": "read_root__get", + "parameters": [ + { + "in": "query", + "name": "foo", + "required": False, + "schema": { + "anyOf": [ + { + "items": {"type": "string"}, + "maxItems": 3, + "type": "array", + "uniqueItems": True, + }, + {"type": "null"}, + ], + "title": "Foo", + }, + }, + ], + "responses": { + "200": { + "content": {"application/json": {"schema": {}}}, + "description": "Successful Response", + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError", + }, + }, + }, + "description": "Validation Error", + }, + }, + "summary": "Read Root", + }, + }, + }, + } From d34f1e6bcf83d555d1a64bd02e131f250738a5db Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 12 Feb 2026 11:02:52 +0100 Subject: [PATCH 2/3] Update syntax to Python 3.10+ --- tests/test_nested_annotated_in_sequence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_nested_annotated_in_sequence.py b/tests/test_nested_annotated_in_sequence.py index 09e4363e2..40b314559 100644 --- a/tests/test_nested_annotated_in_sequence.py +++ b/tests/test_nested_annotated_in_sequence.py @@ -1,4 +1,4 @@ -from typing import Annotated, Union +from typing import Annotated from dirty_equals import IsList from fastapi import FastAPI, Query @@ -11,7 +11,7 @@ app = FastAPI() @app.get("/") -def read_root(foo: Annotated[Union[MaxSizedSet, None], Query()] = None): +def read_root(foo: Annotated[MaxSizedSet | None, Query()] = None): return {"foo": foo} From 1a18de1153ede1798ca449eda1f549e38246160f Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 12 Feb 2026 11:06:20 +0100 Subject: [PATCH 3/3] Use `snapshot` in tests --- tests/test_nested_annotated_in_sequence.py | 181 +++++++++++---------- 1 file changed, 94 insertions(+), 87 deletions(-) diff --git a/tests/test_nested_annotated_in_sequence.py b/tests/test_nested_annotated_in_sequence.py index 40b314559..4a725c22f 100644 --- a/tests/test_nested_annotated_in_sequence.py +++ b/tests/test_nested_annotated_in_sequence.py @@ -3,6 +3,7 @@ from typing import Annotated from dirty_equals import IsList from fastapi import FastAPI, Query from fastapi.testclient import TestClient +from inline_snapshot import snapshot from pydantic import Field MaxSizedSet = Annotated[set[str], Field(max_length=3)] @@ -33,104 +34,110 @@ def test_endpoint_valid(): def test_endpoint_too_long(): response = client.get("/", params={"foo": ["a", "b", "c", "d"]}) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "type": "too_long", - "loc": ["query", "foo"], - "msg": "Set should have at most 3 items after validation, not more", - "input": IsList("a", "b", "c", "d", check_order=False), - "ctx": { - "actual_length": None, - "field_type": "Set", - "max_length": 3, - }, - } - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "type": "too_long", + "loc": ["query", "foo"], + "msg": "Set should have at most 3 items after validation, not more", + "input": IsList("a", "b", "c", "d", check_order=False), + "ctx": { + "actual_length": None, + "field_type": "Set", + "max_length": 3, + }, + } + ] + } + ) def test_openapi(): - assert app.openapi() == { - "components": { - "schemas": { - "HTTPValidationError": { - "properties": { - "detail": { - "items": {"$ref": "#/components/schemas/ValidationError"}, - "title": "Detail", - "type": "array", - }, - }, - "title": "HTTPValidationError", - "type": "object", - }, - "ValidationError": { - "properties": { - "ctx": {"title": "Context", "type": "object"}, - "input": {"title": "Input"}, - "loc": { - "items": { - "anyOf": [{"type": "string"}, {"type": "integer"}], + assert app.openapi() == snapshot( + { + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array", }, - "title": "Location", - "type": "array", }, - "msg": {"title": "Message", "type": "string"}, - "type": {"title": "Error Type", "type": "string"}, + "title": "HTTPValidationError", + "type": "object", + }, + "ValidationError": { + "properties": { + "ctx": {"title": "Context", "type": "object"}, + "input": {"title": "Input"}, + "loc": { + "items": { + "anyOf": [{"type": "string"}, {"type": "integer"}], + }, + "title": "Location", + "type": "array", + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + "required": ["loc", "msg", "type"], + "title": "ValidationError", + "type": "object", }, - "required": ["loc", "msg", "type"], - "title": "ValidationError", - "type": "object", }, }, - }, - "info": { - "title": "FastAPI", - "version": "0.1.0", - }, - "openapi": "3.1.0", - "paths": { - "/": { - "get": { - "operationId": "read_root__get", - "parameters": [ - { - "in": "query", - "name": "foo", - "required": False, - "schema": { - "anyOf": [ - { - "items": {"type": "string"}, - "maxItems": 3, - "type": "array", - "uniqueItems": True, - }, - {"type": "null"}, - ], - "title": "Foo", - }, - }, - ], - "responses": { - "200": { - "content": {"application/json": {"schema": {}}}, - "description": "Successful Response", - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError", - }, + "info": { + "title": "FastAPI", + "version": "0.1.0", + }, + "openapi": "3.1.0", + "paths": { + "/": { + "get": { + "operationId": "read_root__get", + "parameters": [ + { + "in": "query", + "name": "foo", + "required": False, + "schema": { + "anyOf": [ + { + "items": {"type": "string"}, + "maxItems": 3, + "type": "array", + "uniqueItems": True, + }, + {"type": "null"}, + ], + "title": "Foo", }, }, - "description": "Validation Error", + ], + "responses": { + "200": { + "content": {"application/json": {"schema": {}}}, + "description": "Successful Response", + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError", + }, + }, + }, + "description": "Validation Error", + }, }, + "summary": "Read Root", }, - "summary": "Read Root", }, }, - }, - } + } + )