From 812a1926f06391b22b081fdb11fe7528e3b91293 Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:19:30 +0100 Subject: [PATCH 01/14] =?UTF-8?q?=F0=9F=90=9B=20Fix=20`separate=5Finput=5F?= =?UTF-8?q?output=5Fschemas=3DFalse`=20with=20`computed=5Ffield`=20(#14453?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/_compat/v2.py | 31 ++-- ...t_openapi_separate_input_output_schemas.py | 151 ++++++++++++++++++ 2 files changed, 168 insertions(+), 14 deletions(-) diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 0faa7d5a8..acd23d846 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -171,6 +171,13 @@ def _get_model_config(model: BaseModel) -> Any: return model.model_config +def _has_computed_fields(field: ModelField) -> bool: + computed_fields = field._type_adapter.core_schema.get("schema", {}).get( + "computed_fields", [] + ) + return len(computed_fields) > 0 + + def get_schema_from_model_field( *, field: ModelField, @@ -180,12 +187,9 @@ def get_schema_from_model_field( ], separate_input_output_schemas: bool = True, ) -> Dict[str, Any]: - computed_fields = field._type_adapter.core_schema.get("schema", {}).get( - "computed_fields", [] - ) override_mode: Union[Literal["validation"], None] = ( None - if (separate_input_output_schemas or len(computed_fields) > 0) + if (separate_input_output_schemas or _has_computed_fields(field)) else "validation" ) # This expects that GenerateJsonSchema was already used to generate the definitions @@ -208,15 +212,7 @@ def get_definitions( Dict[Tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue], Dict[str, Dict[str, Any]], ]: - has_computed_fields: bool = any( - field._type_adapter.core_schema.get("schema", {}).get("computed_fields", []) - for field in fields - ) - schema_generator = GenerateJsonSchema(ref_template=REF_TEMPLATE) - override_mode: Union[Literal["validation"], None] = ( - None if (separate_input_output_schemas or has_computed_fields) else "validation" - ) validation_fields = [field for field in fields if field.mode == "validation"] serialization_fields = [field for field in fields if field.mode == "serialization"] flat_validation_models = get_flat_models_from_fields( @@ -246,9 +242,16 @@ def get_definitions( unique_flat_model_fields = { f for f in flat_model_fields if f.type_ not in input_types } - inputs = [ - (field, override_mode or field.mode, field._type_adapter.core_schema) + ( + field, + ( + field.mode + if (separate_input_output_schemas or _has_computed_fields(field)) + else "validation" + ), + field._type_adapter.core_schema, + ) for field in list(fields) + list(unique_flat_model_fields) ] field_mapping, definitions = schema_generator.generate_definitions(inputs=inputs) diff --git a/tests/test_openapi_separate_input_output_schemas.py b/tests/test_openapi_separate_input_output_schemas.py index fa73620ea..c9a05418b 100644 --- a/tests/test_openapi_separate_input_output_schemas.py +++ b/tests/test_openapi_separate_input_output_schemas.py @@ -24,6 +24,18 @@ class Item(BaseModel): model_config = {"json_schema_serialization_defaults_required": True} +if PYDANTIC_V2: + from pydantic import computed_field + + class WithComputedField(BaseModel): + name: str + + @computed_field + @property + def computed_field(self) -> str: + return f"computed {self.name}" + + def get_app_client(separate_input_output_schemas: bool = True) -> TestClient: app = FastAPI(separate_input_output_schemas=separate_input_output_schemas) @@ -46,6 +58,14 @@ def get_app_client(separate_input_output_schemas: bool = True) -> TestClient: Item(name="Plumbus"), ] + if PYDANTIC_V2: + + @app.post("/with-computed-field/") + def create_with_computed_field( + with_computed_field: WithComputedField, + ) -> WithComputedField: + return with_computed_field + client = TestClient(app) return client @@ -131,6 +151,23 @@ def test_read_items(): ) +@needs_pydanticv2 +def test_with_computed_field(): + client = get_app_client() + client_no = get_app_client(separate_input_output_schemas=False) + response = client.post("/with-computed-field/", json={"name": "example"}) + response2 = client_no.post("/with-computed-field/", json={"name": "example"}) + assert response.status_code == response2.status_code == 200, response.text + assert ( + response.json() + == response2.json() + == { + "name": "example", + "computed_field": "computed example", + } + ) + + @needs_pydanticv2 def test_openapi_schema(): client = get_app_client() @@ -245,6 +282,44 @@ def test_openapi_schema(): }, } }, + "/with-computed-field/": { + "post": { + "summary": "Create With Computed Field", + "operationId": "create_with_computed_field_with_computed_field__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WithComputedField-Input" + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WithComputedField-Output" + } + } + }, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + }, + }, }, "components": { "schemas": { @@ -333,6 +408,25 @@ def test_openapi_schema(): "required": ["subname", "sub_description", "tags"], "title": "SubItem", }, + "WithComputedField-Input": { + "properties": {"name": {"type": "string", "title": "Name"}}, + "type": "object", + "required": ["name"], + "title": "WithComputedField", + }, + "WithComputedField-Output": { + "properties": { + "name": {"type": "string", "title": "Name"}, + "computed_field": { + "type": "string", + "title": "Computed Field", + "readOnly": True, + }, + }, + "type": "object", + "required": ["name", "computed_field"], + "title": "WithComputedField", + }, "ValidationError": { "properties": { "loc": { @@ -458,6 +552,44 @@ def test_openapi_schema_no_separate(): }, } }, + "/with-computed-field/": { + "post": { + "summary": "Create With Computed Field", + "operationId": "create_with_computed_field_with_computed_field__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WithComputedField-Input" + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WithComputedField-Output" + } + } + }, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + }, + }, }, "components": { "schemas": { @@ -508,6 +640,25 @@ def test_openapi_schema_no_separate(): "required": ["subname"], "title": "SubItem", }, + "WithComputedField-Input": { + "properties": {"name": {"type": "string", "title": "Name"}}, + "type": "object", + "required": ["name"], + "title": "WithComputedField", + }, + "WithComputedField-Output": { + "properties": { + "name": {"type": "string", "title": "Name"}, + "computed_field": { + "type": "string", + "title": "Computed Field", + "readOnly": True, + }, + }, + "type": "object", + "required": ["name", "computed_field"], + "title": "WithComputedField", + }, "ValidationError": { "properties": { "loc": { From 516169428d2fa189d34318ebc469a082c49c1189 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 5 Dec 2025 20:19:54 +0000 Subject: [PATCH 02/14] =?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 ed39da111..aa8a85843 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Fixes + +* 🐛 Fix `separate_input_output_schemas=False` with `computed_field`. PR [#14453](https://github.com/fastapi/fastapi/pull/14453) by [@YuriiMotov](https://github.com/YuriiMotov). + ## 0.123.9 ### Fixes From da0ffab0b260475499294d3dc767409d7bca5c34 Mon Sep 17 00:00:00 2001 From: Motov Yurii <109919500+YuriiMotov@users.noreply.github.com> Date: Fri, 5 Dec 2025 22:21:05 +0100 Subject: [PATCH 03/14] =?UTF-8?q?=F0=9F=90=9B=20Fix=20using=20class=20(not?= =?UTF-8?q?=20instance)=20dependency=20that=20has=20`=5F=5Fcall=5F=5F`=20m?= =?UTF-8?q?ethod=20(#14458)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sebastián Ramírez --- fastapi/dependencies/models.py | 7 ++++++- tests/test_dependency_class.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/fastapi/dependencies/models.py b/fastapi/dependencies/models.py index af168a177..6c4bf18b3 100644 --- a/fastapi/dependencies/models.py +++ b/fastapi/dependencies/models.py @@ -110,6 +110,8 @@ class Dependant: _impartial(self.call) ) or inspect.isgeneratorfunction(_unwrapped_call(self.call)): return True + if inspect.isclass(_unwrapped_call(self.call)): + return False dunder_call = getattr(_impartial(self.call), "__call__", None) # noqa: B004 if dunder_call is None: return False # pragma: no cover @@ -134,6 +136,8 @@ class Dependant: _impartial(self.call) ) or inspect.isasyncgenfunction(_unwrapped_call(self.call)): return True + if inspect.isclass(_unwrapped_call(self.call)): + return False dunder_call = getattr(_impartial(self.call), "__call__", None) # noqa: B004 if dunder_call is None: return False # pragma: no cover @@ -162,6 +166,8 @@ class Dependant: _unwrapped_call(self.call) ): return True + if inspect.isclass(_unwrapped_call(self.call)): + return False dunder_call = getattr(_impartial(self.call), "__call__", None) # noqa: B004 if dunder_call is None: return False # pragma: no cover @@ -176,7 +182,6 @@ class Dependant: _impartial(dunder_unwrapped_call) ) or iscoroutinefunction(_unwrapped_call(dunder_unwrapped_call)): return True - # if inspect.isclass(self.call): False, covered by default return return False @cached_property diff --git a/tests/test_dependency_class.py b/tests/test_dependency_class.py index 0233492e6..75241b467 100644 --- a/tests/test_dependency_class.py +++ b/tests/test_dependency_class.py @@ -48,6 +48,34 @@ async_callable_gen_dependency = AsyncCallableGenDependency() methods_dependency = MethodsDependency() +@app.get("/callable-dependency-class") +async def get_callable_dependency_class( + value: str, instance: CallableDependency = Depends() +): + return instance(value) + + +@app.get("/callable-gen-dependency-class") +async def get_callable_gen_dependency_class( + value: str, instance: CallableGenDependency = Depends() +): + return next(instance(value)) + + +@app.get("/async-callable-dependency-class") +async def get_async_callable_dependency_class( + value: str, instance: AsyncCallableDependency = Depends() +): + return await instance(value) + + +@app.get("/async-callable-gen-dependency-class") +async def get_async_callable_gen_dependency_class( + value: str, instance: AsyncCallableGenDependency = Depends() +): + return await instance(value).__anext__() + + @app.get("/callable-dependency") async def get_callable_dependency(value: str = Depends(callable_dependency)): return value @@ -114,6 +142,10 @@ client = TestClient(app) ("/synchronous-method-gen-dependency", "synchronous-method-gen-dependency"), ("/asynchronous-method-dependency", "asynchronous-method-dependency"), ("/asynchronous-method-gen-dependency", "asynchronous-method-gen-dependency"), + ("/callable-dependency-class", "callable-dependency-class"), + ("/callable-gen-dependency-class", "callable-gen-dependency-class"), + ("/async-callable-dependency-class", "async-callable-dependency-class"), + ("/async-callable-gen-dependency-class", "async-callable-gen-dependency-class"), ], ) def test_class_dependency(route, value): From e7d7038dfa35fc923f20fd11a969d2e65e1b9df1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 5 Dec 2025 21:21:29 +0000 Subject: [PATCH 04/14] =?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 aa8a85843..ce620b132 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -9,6 +9,7 @@ hide: ### Fixes +* 🐛 Fix using class (not instance) dependency that has `__call__` method. PR [#14458](https://github.com/fastapi/fastapi/pull/14458) by [@YuriiMotov](https://github.com/YuriiMotov). * 🐛 Fix `separate_input_output_schemas=False` with `computed_field`. PR [#14453](https://github.com/fastapi/fastapi/pull/14453) by [@YuriiMotov](https://github.com/YuriiMotov). ## 0.123.9 From 08b09e5236e315b6f10265ed229f130d4befb4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Fri, 5 Dec 2025 22:26:36 +0100 Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.123.?= =?UTF-8?q?10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 ce620b132..d27c47383 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,8 @@ hide: ## Latest Changes +## 0.123.10 + ### Fixes * 🐛 Fix using class (not instance) dependency that has `__call__` method. PR [#14458](https://github.com/fastapi/fastapi/pull/14458) by [@YuriiMotov](https://github.com/YuriiMotov). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index dc5467b0f..2396c501d 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.123.9" +__version__ = "0.123.10" from starlette import status as status From e1117f75505bbdb2d42321a009dbf26c9c2b8b6d Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Sat, 6 Dec 2025 04:21:57 -0800 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=9A=B8=20=20Improve=20tracebacks=20?= =?UTF-8?q?by=20adding=20endpoint=20metadata=20(#14306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sebastián Ramírez --- fastapi/exceptions.py | 75 +++++++++-- fastapi/routing.py | 62 ++++++++- tests/test_validation_error_context.py | 168 +++++++++++++++++++++++++ 3 files changed, 289 insertions(+), 16 deletions(-) create mode 100644 tests/test_validation_error_context.py diff --git a/fastapi/exceptions.py b/fastapi/exceptions.py index 0620428be..a46e82350 100644 --- a/fastapi/exceptions.py +++ b/fastapi/exceptions.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, Sequence, Type, Union +from typing import Any, Dict, Optional, Sequence, Type, TypedDict, Union from annotated_doc import Doc from pydantic import BaseModel, create_model @@ -7,6 +7,13 @@ from starlette.exceptions import WebSocketException as StarletteWebSocketExcepti from typing_extensions import Annotated +class EndpointContext(TypedDict, total=False): + function: str + path: str + file: str + line: int + + class HTTPException(StarletteHTTPException): """ An HTTP exception you can raise in your own code to show errors to the client. @@ -155,30 +162,72 @@ class DependencyScopeError(FastAPIError): class ValidationException(Exception): - def __init__(self, errors: Sequence[Any]) -> None: + def __init__( + self, + errors: Sequence[Any], + *, + endpoint_ctx: Optional[EndpointContext] = None, + ) -> None: self._errors = errors + self.endpoint_ctx = endpoint_ctx + + ctx = endpoint_ctx or {} + self.endpoint_function = ctx.get("function") + self.endpoint_path = ctx.get("path") + self.endpoint_file = ctx.get("file") + self.endpoint_line = ctx.get("line") def errors(self) -> Sequence[Any]: return self._errors + def _format_endpoint_context(self) -> str: + if not (self.endpoint_file and self.endpoint_line and self.endpoint_function): + if self.endpoint_path: + return f"\n Endpoint: {self.endpoint_path}" + return "" + + context = f'\n File "{self.endpoint_file}", line {self.endpoint_line}, in {self.endpoint_function}' + if self.endpoint_path: + context += f"\n {self.endpoint_path}" + return context + + def __str__(self) -> str: + message = f"{len(self._errors)} validation error{'s' if len(self._errors) != 1 else ''}:\n" + for err in self._errors: + message += f" {err}\n" + message += self._format_endpoint_context() + return message.rstrip() + class RequestValidationError(ValidationException): - def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None: - super().__init__(errors) + def __init__( + self, + errors: Sequence[Any], + *, + body: Any = None, + endpoint_ctx: Optional[EndpointContext] = None, + ) -> None: + super().__init__(errors, endpoint_ctx=endpoint_ctx) self.body = body class WebSocketRequestValidationError(ValidationException): - pass + def __init__( + self, + errors: Sequence[Any], + *, + endpoint_ctx: Optional[EndpointContext] = None, + ) -> None: + super().__init__(errors, endpoint_ctx=endpoint_ctx) class ResponseValidationError(ValidationException): - def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None: - super().__init__(errors) + def __init__( + self, + errors: Sequence[Any], + *, + body: Any = None, + endpoint_ctx: Optional[EndpointContext] = None, + ) -> None: + super().__init__(errors, endpoint_ctx=endpoint_ctx) self.body = body - - def __str__(self) -> str: - message = f"{len(self._errors)} validation errors:\n" - for err in self._errors: - message += f" {err}\n" - return message diff --git a/fastapi/routing.py b/fastapi/routing.py index c10175b16..9be2b44bc 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -46,6 +46,7 @@ from fastapi.dependencies.utils import ( ) from fastapi.encoders import jsonable_encoder from fastapi.exceptions import ( + EndpointContext, FastAPIError, RequestValidationError, ResponseValidationError, @@ -212,6 +213,33 @@ def _merge_lifespan_context( return merged_lifespan # type: ignore[return-value] +# Cache for endpoint context to avoid re-extracting on every request +_endpoint_context_cache: Dict[int, EndpointContext] = {} + + +def _extract_endpoint_context(func: Any) -> EndpointContext: + """Extract endpoint context with caching to avoid repeated file I/O.""" + func_id = id(func) + + if func_id in _endpoint_context_cache: + return _endpoint_context_cache[func_id] + + try: + ctx: EndpointContext = {} + + if (source_file := inspect.getsourcefile(func)) is not None: + ctx["file"] = source_file + if (line_number := inspect.getsourcelines(func)[1]) is not None: + ctx["line"] = line_number + if (func_name := getattr(func, "__name__", None)) is not None: + ctx["function"] = func_name + except Exception: + ctx = EndpointContext() + + _endpoint_context_cache[func_id] = ctx + return ctx + + async def serialize_response( *, field: Optional[ModelField] = None, @@ -223,6 +251,7 @@ async def serialize_response( exclude_defaults: bool = False, exclude_none: bool = False, is_coroutine: bool = True, + endpoint_ctx: Optional[EndpointContext] = None, ) -> Any: if field: errors = [] @@ -245,8 +274,11 @@ async def serialize_response( elif errors_: errors.append(errors_) if errors: + ctx = endpoint_ctx or EndpointContext() raise ResponseValidationError( - errors=_normalize_errors(errors), body=response_content + errors=_normalize_errors(errors), + body=response_content, + endpoint_ctx=ctx, ) if hasattr(field, "serialize"): @@ -318,6 +350,18 @@ def get_request_handler( "fastapi_middleware_astack not found in request scope" ) + # Extract endpoint context for error messages + endpoint_ctx = ( + _extract_endpoint_context(dependant.call) + if dependant.call + else EndpointContext() + ) + + if dependant.path: + # For mounted sub-apps, include the mount path prefix + mount_path = request.scope.get("root_path", "").rstrip("/") + endpoint_ctx["path"] = f"{request.method} {mount_path}{dependant.path}" + # Read body and auto-close files try: body: Any = None @@ -355,6 +399,7 @@ def get_request_handler( } ], body=e.doc, + endpoint_ctx=endpoint_ctx, ) raise validation_error from e except HTTPException: @@ -414,6 +459,7 @@ def get_request_handler( exclude_defaults=response_model_exclude_defaults, exclude_none=response_model_exclude_none, is_coroutine=is_coroutine, + endpoint_ctx=endpoint_ctx, ) response = actual_response_class(content, **response_args) if not is_body_allowed_for_status_code(response.status_code): @@ -421,7 +467,7 @@ def get_request_handler( response.headers.raw.extend(solved_result.response.headers.raw) if errors: validation_error = RequestValidationError( - _normalize_errors(errors), body=body + _normalize_errors(errors), body=body, endpoint_ctx=endpoint_ctx ) raise validation_error @@ -438,6 +484,15 @@ def get_websocket_app( embed_body_fields: bool = False, ) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]: async def app(websocket: WebSocket) -> None: + endpoint_ctx = ( + _extract_endpoint_context(dependant.call) + if dependant.call + else EndpointContext() + ) + if dependant.path: + # For mounted sub-apps, include the mount path prefix + mount_path = websocket.scope.get("root_path", "").rstrip("/") + endpoint_ctx["path"] = f"WS {mount_path}{dependant.path}" async_exit_stack = websocket.scope.get("fastapi_inner_astack") assert isinstance(async_exit_stack, AsyncExitStack), ( "fastapi_inner_astack not found in request scope" @@ -451,7 +506,8 @@ def get_websocket_app( ) if solved_result.errors: raise WebSocketRequestValidationError( - _normalize_errors(solved_result.errors) + _normalize_errors(solved_result.errors), + endpoint_ctx=endpoint_ctx, ) assert dependant.call is not None, "dependant.call must be a function" await dependant.call(**solved_result.values) diff --git a/tests/test_validation_error_context.py b/tests/test_validation_error_context.py new file mode 100644 index 000000000..844b8a64f --- /dev/null +++ b/tests/test_validation_error_context.py @@ -0,0 +1,168 @@ +from fastapi import FastAPI, Request, WebSocket +from fastapi.exceptions import ( + RequestValidationError, + ResponseValidationError, + WebSocketRequestValidationError, +) +from fastapi.testclient import TestClient +from pydantic import BaseModel + + +class Item(BaseModel): + id: int + name: str + + +class ExceptionCapture: + def __init__(self): + self.exception = None + + def capture(self, exc): + self.exception = exc + return exc + + +app = FastAPI() +sub_app = FastAPI() +captured_exception = ExceptionCapture() + +app.mount(path="/sub", app=sub_app) + + +@app.exception_handler(RequestValidationError) +@sub_app.exception_handler(RequestValidationError) +async def request_validation_handler(request: Request, exc: RequestValidationError): + captured_exception.capture(exc) + raise exc + + +@app.exception_handler(ResponseValidationError) +@sub_app.exception_handler(ResponseValidationError) +async def response_validation_handler(_: Request, exc: ResponseValidationError): + captured_exception.capture(exc) + raise exc + + +@app.exception_handler(WebSocketRequestValidationError) +@sub_app.exception_handler(WebSocketRequestValidationError) +async def websocket_validation_handler( + websocket: WebSocket, exc: WebSocketRequestValidationError +): + captured_exception.capture(exc) + raise exc + + +@app.get("/users/{user_id}") +def get_user(user_id: int): + return {"user_id": user_id} # pragma: no cover + + +@app.get("/items/", response_model=Item) +def get_item(): + return {"name": "Widget"} + + +@sub_app.get("/items/", response_model=Item) +def get_sub_item(): + return {"name": "Widget"} # pragma: no cover + + +@app.websocket("/ws/{item_id}") +async def websocket_endpoint(websocket: WebSocket, item_id: int): + await websocket.accept() # pragma: no cover + await websocket.send_text(f"Item: {item_id}") # pragma: no cover + await websocket.close() # pragma: no cover + + +@sub_app.websocket("/ws/{item_id}") +async def subapp_websocket_endpoint(websocket: WebSocket, item_id: int): + await websocket.accept() # pragma: no cover + await websocket.send_text(f"Item: {item_id}") # pragma: no cover + await websocket.close() # pragma: no cover + + +client = TestClient(app) + + +def test_request_validation_error_includes_endpoint_context(): + captured_exception.exception = None + try: + client.get("/users/invalid") + except Exception: + pass + + assert captured_exception.exception is not None + error_str = str(captured_exception.exception) + assert "get_user" in error_str + assert "/users/" in error_str + + +def test_response_validation_error_includes_endpoint_context(): + captured_exception.exception = None + try: + client.get("/items/") + except Exception: + pass + + assert captured_exception.exception is not None + error_str = str(captured_exception.exception) + assert "get_item" in error_str + assert "/items/" in error_str + + +def test_websocket_validation_error_includes_endpoint_context(): + captured_exception.exception = None + try: + with client.websocket_connect("/ws/invalid"): + pass # pragma: no cover + except Exception: + pass + + assert captured_exception.exception is not None + error_str = str(captured_exception.exception) + assert "websocket_endpoint" in error_str + assert "/ws/" in error_str + + +def test_subapp_request_validation_error_includes_endpoint_context(): + captured_exception.exception = None + try: + client.get("/sub/items/") + except Exception: + pass + + assert captured_exception.exception is not None + error_str = str(captured_exception.exception) + assert "get_sub_item" in error_str + assert "/sub/items/" in error_str + + +def test_subapp_websocket_validation_error_includes_endpoint_context(): + captured_exception.exception = None + try: + with client.websocket_connect("/sub/ws/invalid"): + pass # pragma: no cover + except Exception: + pass + + assert captured_exception.exception is not None + error_str = str(captured_exception.exception) + assert "subapp_websocket_endpoint" in error_str + assert "/sub/ws/" in error_str + + +def test_validation_error_with_only_path(): + errors = [{"type": "missing", "loc": ("body", "name"), "msg": "Field required"}] + exc = RequestValidationError(errors, endpoint_ctx={"path": "GET /api/test"}) + error_str = str(exc) + assert "Endpoint: GET /api/test" in error_str + assert 'File "' not in error_str + + +def test_validation_error_with_no_context(): + errors = [{"type": "missing", "loc": ("body", "name"), "msg": "Field required"}] + exc = RequestValidationError(errors, endpoint_ctx={}) + error_str = str(exc) + assert "1 validation error:" in error_str + assert "Endpoint" not in error_str + assert 'File "' not in error_str From dbd34f15789f4afa851e339cea4fcd49f421039d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 6 Dec 2025 12:22:24 +0000 Subject: [PATCH 07/14] =?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 d27c47383..b294de906 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Features + +* 🚸 Improve tracebacks by adding endpoint metadata. PR [#14306](https://github.com/fastapi/fastapi/pull/14306) by [@savannahostrowski](https://github.com/savannahostrowski). + ## 0.123.10 ### Fixes From 5b6245666b8a499d2551caff7567056ef7f881b2 Mon Sep 17 00:00:00 2001 From: Yuji Teshima <36704166+yujiteshima@users.noreply.github.com> Date: Sat, 6 Dec 2025 21:23:01 +0900 Subject: [PATCH 08/14] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20Fix=20typo=20in=20`s?= =?UTF-8?q?cripts/mkdocs=5Fhooks.py`=20(#14457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/mkdocs_hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/mkdocs_hooks.py b/scripts/mkdocs_hooks.py index b9e4ff59e..09cfa99e3 100644 --- a/scripts/mkdocs_hooks.py +++ b/scripts/mkdocs_hooks.py @@ -132,7 +132,7 @@ def on_pre_page(page: Page, *, config: MkDocsConfig, files: Files) -> Page: def on_page_markdown( markdown: str, *, page: Page, config: MkDocsConfig, files: Files ) -> str: - # Set matadata["social"]["cards_layout_options"]["title"] to clean title (without + # Set metadata["social"]["cards_layout_options"]["title"] to clean title (without # permalink) title = page.title clean_title = title.split("{ #")[0] From a2cef707e30fb6eb14812e4e273e34079d30ae6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 6 Dec 2025 12:23:23 +0000 Subject: [PATCH 09/14] =?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 b294de906..f49d46a8f 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -11,6 +11,10 @@ hide: * 🚸 Improve tracebacks by adding endpoint metadata. PR [#14306](https://github.com/fastapi/fastapi/pull/14306) by [@savannahostrowski](https://github.com/savannahostrowski). +### Internal + +* ✏️ Fix typo in `scripts/mkdocs_hooks.py`. PR [#14457](https://github.com/fastapi/fastapi/pull/14457) by [@yujiteshima](https://github.com/yujiteshima). + ## 0.123.10 ### Fixes From b5ca13249e3f2002c70c3f2de528a128af2008f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sat, 6 Dec 2025 14:09:51 +0100 Subject: [PATCH 10/14] =?UTF-8?q?=F0=9F=94=96=20Release=20version=200.124.?= =?UTF-8?q?0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 f49d46a8f..f40223338 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,8 @@ hide: ## Latest Changes +## 0.124.0 + ### Features * 🚸 Improve tracebacks by adding endpoint metadata. PR [#14306](https://github.com/fastapi/fastapi/pull/14306) by [@savannahostrowski](https://github.com/savannahostrowski). diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 2396c501d..7009a7777 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.123.10" +__version__ = "0.124.0" from starlette import status as status From 81517f66ccb906afef898570c823053afba994d8 Mon Sep 17 00:00:00 2001 From: Alejandra <90076947+alejsdev@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:04:54 +0100 Subject: [PATCH 11/14] =?UTF-8?q?=F0=9F=93=9D=20Update=20tech=20stack=20in?= =?UTF-8?q?=20project=20generation=20docs=20(#14472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/de/docs/project-generation.md | 2 +- docs/en/docs/project-generation.md | 18 +++++++++--------- docs/pt/docs/project-generation.md | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/de/docs/project-generation.md b/docs/de/docs/project-generation.md index f830f0f4d..dd3c3b427 100644 --- a/docs/de/docs/project-generation.md +++ b/docs/de/docs/project-generation.md @@ -14,7 +14,7 @@ GitHub-Repository: Date: Mon, 8 Dec 2025 13:05:20 +0000 Subject: [PATCH 12/14] =?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 f40223338..0af51573f 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,6 +7,10 @@ hide: ## Latest Changes +### Docs + +* 📝 Update tech stack in project generation docs. PR [#14472](https://github.com/fastapi/fastapi/pull/14472) by [@alejsdev](https://github.com/alejsdev). + ## 0.124.0 ### Features From 8cedb742cb59f72a752bb0a6b4f73c02aeb15bf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Tue, 9 Dec 2025 03:12:24 -0800 Subject: [PATCH 13/14] =?UTF-8?q?=E2=9C=85=20Add=20test=20for=20Pydantic?= =?UTF-8?q?=20v2,=20dataclasses,=20UUID,=20and=20`=5F=5Fannotations=5F=5F`?= =?UTF-8?q?=20(#14477)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ataclasses_uuid_stringified_annotations.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/test_pydanticv2_dataclasses_uuid_stringified_annotations.py diff --git a/tests/test_pydanticv2_dataclasses_uuid_stringified_annotations.py b/tests/test_pydanticv2_dataclasses_uuid_stringified_annotations.py new file mode 100644 index 000000000..c9f94563b --- /dev/null +++ b/tests/test_pydanticv2_dataclasses_uuid_stringified_annotations.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from typing import List, Union + +from dirty_equals import IsUUID +from fastapi import FastAPI +from fastapi.testclient import TestClient +from inline_snapshot import snapshot + + +@dataclass +class Item: + id: uuid.UUID + name: str + price: float + tags: List[str] = field(default_factory=list) + description: Union[str, None] = None + tax: Union[float, None] = None + + +app = FastAPI() + + +@app.get("/item", response_model=Item) +async def read_item(): + return { + "id": uuid.uuid4(), + "name": "Island In The Moon", + "price": 12.99, + "description": "A place to be be playin' and havin' fun", + "tags": ["breater"], + } + + +client = TestClient(app) + + +def test_annotations(): + response = client.get("/item") + assert response.status_code == 200, response.text + assert response.json() == snapshot( + { + "id": IsUUID(), + "name": "Island In The Moon", + "price": 12.99, + "tags": ["breater"], + "description": "A place to be be playin' and havin' fun", + "tax": None, + } + ) From 5b28a04d550d95ef0061f41f5a5dcfe36045e8c8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Dec 2025 11:12:49 +0000 Subject: [PATCH 14/14] =?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 0af51573f..60bc1cc6f 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -11,6 +11,10 @@ hide: * 📝 Update tech stack in project generation docs. PR [#14472](https://github.com/fastapi/fastapi/pull/14472) by [@alejsdev](https://github.com/alejsdev). +### Internal + +* ✅ Add test for Pydantic v2, dataclasses, UUID, and `__annotations__`. PR [#14477](https://github.com/fastapi/fastapi/pull/14477) by [@tiangolo](https://github.com/tiangolo). + ## 0.124.0 ### Features