From 86e515784dd6ac6beaa1d0cc4c696c15d645cd41 Mon Sep 17 00:00:00 2001 From: Max McLennan Date: Sat, 20 Sep 2025 13:10:37 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20inconsistent=20processing?= =?UTF-8?q?=20of=20model=20docstring=20formfeed=20char=20with=20Pydantic?= =?UTF-8?q?=20V1=20(#6039)?= 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: Yurii Motov Co-authored-by: Sebastián Ramírez --- fastapi/_compat.py | 3 +- ...t_get_model_definitions_formfeed_escape.py | 180 ++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 tests/test_get_model_definitions_formfeed_escape.py diff --git a/fastapi/_compat.py b/fastapi/_compat.py index 227ad837d..26b6638c8 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -393,9 +393,10 @@ else: ) definitions.update(m_definitions) model_name = model_name_map[model] + definitions[model_name] = m_schema + for m_schema in definitions.values(): if "description" in m_schema: m_schema["description"] = m_schema["description"].split("\f")[0] - definitions[model_name] = m_schema return definitions def is_pv1_scalar_field(field: ModelField) -> bool: diff --git a/tests/test_get_model_definitions_formfeed_escape.py b/tests/test_get_model_definitions_formfeed_escape.py new file mode 100644 index 000000000..f77195dc5 --- /dev/null +++ b/tests/test_get_model_definitions_formfeed_escape.py @@ -0,0 +1,180 @@ +from typing import Any, Iterator, Set, Type + +import fastapi._compat +import fastapi.openapi.utils +import pydantic.schema +import pytest +from fastapi import FastAPI +from pydantic import BaseModel +from starlette.testclient import TestClient + +from .utils import needs_pydanticv1 + + +class Address(BaseModel): + """ + This is a public description of an Address + \f + You can't see this part of the docstring, it's private! + """ + + line_1: str + city: str + state_province: str + + +class Facility(BaseModel): + id: str + address: Address + + +app = FastAPI() + +client = TestClient(app) + + +@app.get("/facilities/{facility_id}") +def get_facility(facility_id: str) -> Facility: ... + + +openapi_schema = { + "components": { + "schemas": { + "Address": { + # NOTE: the description of this model shows only the public-facing text, before the `\f` in docstring + "description": "This is a public description of an Address\n", + "properties": { + "city": {"title": "City", "type": "string"}, + "line_1": {"title": "Line 1", "type": "string"}, + "state_province": {"title": "State Province", "type": "string"}, + }, + "required": ["line_1", "city", "state_province"], + "title": "Address", + "type": "object", + }, + "Facility": { + "properties": { + "address": {"$ref": "#/components/schemas/Address"}, + "id": {"title": "Id", "type": "string"}, + }, + "required": ["id", "address"], + "title": "Facility", + "type": "object", + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": {"$ref": "#/components/schemas/ValidationError"}, + "title": "Detail", + "type": "array", + } + }, + "title": "HTTPValidationError", + "type": "object", + }, + "ValidationError": { + "properties": { + "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": { + "/facilities/{facility_id}": { + "get": { + "operationId": "get_facility_facilities__facility_id__get", + "parameters": [ + { + "in": "path", + "name": "facility_id", + "required": True, + "schema": {"title": "Facility Id", "type": "string"}, + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Facility"} + } + }, + "description": "Successful Response", + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error", + }, + }, + "summary": "Get Facility", + } + } + }, +} + + +def test_openapi_schema(): + """ + Sanity check to ensure our app's openapi schema renders as we expect + """ + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == openapi_schema + + +class SortedTypeSet(set): + """ + Set of Types whose `__iter__()` method yields results sorted by the type names + """ + + def __init__(self, seq: Set[Type[Any]], *, sort_reversed: bool): + super().__init__(seq) + self.sort_reversed = sort_reversed + + def __iter__(self) -> Iterator[Type[Any]]: + members_sorted = sorted( + super().__iter__(), + key=lambda type_: type_.__name__, + reverse=self.sort_reversed, + ) + yield from members_sorted + + +@needs_pydanticv1 +@pytest.mark.parametrize("sort_reversed", [True, False]) +def test_model_description_escaped_with_formfeed(sort_reversed: bool): + """ + Regression test for bug fixed by https://github.com/fastapi/fastapi/pull/6039. + + Test `get_model_definitions` with models passed in different order. + """ + all_fields = fastapi.openapi.utils.get_fields_from_routes(app.routes) + + flat_models = fastapi._compat.get_flat_models_from_fields( + all_fields, known_models=set() + ) + model_name_map = pydantic.schema.get_model_name_map(flat_models) + + expected_address_description = "This is a public description of an Address\n" + + models = fastapi._compat.get_model_definitions( + flat_models=SortedTypeSet(flat_models, sort_reversed=sort_reversed), + model_name_map=model_name_map, + ) + assert models["Address"]["description"] == expected_address_description