From 44125260cfc5c31e835c63d95a0bc22b68a35162 Mon Sep 17 00:00:00 2001 From: Gustav Larsson Date: Sun, 1 Feb 2026 16:29:10 +0200 Subject: [PATCH 1/2] Fix non-deterministic openapi spec v0.128.0 introduced non-deterministic openapi spec schema naming in the case of multiple pydantic models with the same name. Commit [e300630](https://github.com/fastapi/fastapi/commit/e3006305518a56ea35f62a31748ad26fe4356fcc#diff-1086603fdd56511aafd1d279396b142b803e48164327148e6de26cef4cdaed81L504) removed the check for conflicting names. The component names two classes named `User` would randomly be either ``` User tests__test_duplicate_model_names_openapi__b__model__User ``` ``` tests__test_duplicate_model_names_openapi__a__model__User User ``` With this change, we reintroduce the conflict check to always use fully qualified names in case of conflict. --- fastapi/_compat/v2.py | 16 ++- .../__init__.py | 0 .../a/__init__.py | 0 .../a/model.py | 5 + .../test_duplicate_model_names_openapi/app.py | 22 ++++ .../b/__init__.py | 0 .../b/model.py | 5 + .../c/__init__.py | 0 .../c/model.py | 5 + .../test_duplicate_model_names_openapi.py | 104 ++++++++++++++++++ 10 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 tests/test_duplicate_model_names_openapi/__init__.py create mode 100644 tests/test_duplicate_model_names_openapi/a/__init__.py create mode 100644 tests/test_duplicate_model_names_openapi/a/model.py create mode 100644 tests/test_duplicate_model_names_openapi/app.py create mode 100644 tests/test_duplicate_model_names_openapi/b/__init__.py create mode 100644 tests/test_duplicate_model_names_openapi/b/model.py create mode 100644 tests/test_duplicate_model_names_openapi/c/__init__.py create mode 100644 tests/test_duplicate_model_names_openapi/c/model.py create mode 100644 tests/test_duplicate_model_names_openapi/test_duplicate_model_names_openapi.py diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index 25b6814536..a532be96aa 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -491,11 +491,25 @@ def normalize_name(name: str) -> str: return re.sub(r"[^a-zA-Z0-9.\-_]", "_", name) +def get_long_model_name(model: TypeModelOrEnum) -> str: + return f"{model.__module__}__{model.__qualname__}".replace(".", "__") + + def get_model_name_map(unique_models: TypeModelSet) -> dict[TypeModelOrEnum, str]: name_model_map = {} + conflicting_names: set[str] = set() for model in unique_models: model_name = normalize_name(model.__name__) - name_model_map[model_name] = model + if model_name in conflicting_names: + model_name = get_long_model_name(model) + name_model_map[model_name] = model + elif model_name in name_model_map: + conflicting_names.add(model_name) + conflicting_model = name_model_map.pop(model_name) + name_model_map[get_long_model_name(conflicting_model)] = conflicting_model + name_model_map[get_long_model_name(model)] = model + else: + name_model_map[model_name] = model return {v: k for k, v in name_model_map.items()} diff --git a/tests/test_duplicate_model_names_openapi/__init__.py b/tests/test_duplicate_model_names_openapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_duplicate_model_names_openapi/a/__init__.py b/tests/test_duplicate_model_names_openapi/a/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_duplicate_model_names_openapi/a/model.py b/tests/test_duplicate_model_names_openapi/a/model.py new file mode 100644 index 0000000000..f60d4531b8 --- /dev/null +++ b/tests/test_duplicate_model_names_openapi/a/model.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class User(BaseModel): + a: int diff --git a/tests/test_duplicate_model_names_openapi/app.py b/tests/test_duplicate_model_names_openapi/app.py new file mode 100644 index 0000000000..d817cf5726 --- /dev/null +++ b/tests/test_duplicate_model_names_openapi/app.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI + +from tests.test_duplicate_model_names_openapi.a.model import User as UserA +from tests.test_duplicate_model_names_openapi.b.model import User as UserB +from tests.test_duplicate_model_names_openapi.c.model import User as UserC + +app = FastAPI() + + +@app.get("/a", response_model=UserA) +def user_a(): + ... + + +@app.get("/b", response_model=UserB) +def user_b(): + ... + + +@app.get("/c", response_model=UserC) +def user_c(): + ... diff --git a/tests/test_duplicate_model_names_openapi/b/__init__.py b/tests/test_duplicate_model_names_openapi/b/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_duplicate_model_names_openapi/b/model.py b/tests/test_duplicate_model_names_openapi/b/model.py new file mode 100644 index 0000000000..ca31e2dae2 --- /dev/null +++ b/tests/test_duplicate_model_names_openapi/b/model.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class User(BaseModel): + b: int diff --git a/tests/test_duplicate_model_names_openapi/c/__init__.py b/tests/test_duplicate_model_names_openapi/c/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_duplicate_model_names_openapi/c/model.py b/tests/test_duplicate_model_names_openapi/c/model.py new file mode 100644 index 0000000000..80d62c738f --- /dev/null +++ b/tests/test_duplicate_model_names_openapi/c/model.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class User(BaseModel): + c: int diff --git a/tests/test_duplicate_model_names_openapi/test_duplicate_model_names_openapi.py b/tests/test_duplicate_model_names_openapi/test_duplicate_model_names_openapi.py new file mode 100644 index 0000000000..fd19c1c1ed --- /dev/null +++ b/tests/test_duplicate_model_names_openapi/test_duplicate_model_names_openapi.py @@ -0,0 +1,104 @@ + +from fastapi.testclient import TestClient + +from .app import app + +client = TestClient(app) + + +def test_openapi_schema(): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + + schema = response.json() + component_names = schema["components"]["schemas"].keys() + + # Fully qualified names due to conflict + assert set(component_names) == { + "tests__test_duplicate_model_names_openapi__a__model__User", + "tests__test_duplicate_model_names_openapi__b__model__User", + "tests__test_duplicate_model_names_openapi__c__model__User", + } + + assert schema == { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/a": { + "get": { + "summary": "User A", + "operationId": "user_a_a_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/tests__test_duplicate_model_names_openapi__a__model__User" + } + } + }, + } + }, + } + }, + "/b": { + "get": { + "summary": "User B", + "operationId": "user_b_b_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/tests__test_duplicate_model_names_openapi__b__model__User" + } + } + }, + } + }, + } + }, + "/c": { + "get": { + "summary": "User C", + "operationId": "user_c_c_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/tests__test_duplicate_model_names_openapi__c__model__User" + } + } + }, + } + }, + } + }, + }, + "components": { + "schemas": { + "tests__test_duplicate_model_names_openapi__a__model__User": { + "properties": {"a": {"type": "integer", "title": "A"}}, + "type": "object", + "required": ["a"], + "title": "User", + }, + "tests__test_duplicate_model_names_openapi__b__model__User": { + "properties": {"b": {"type": "integer", "title": "B"}}, + "type": "object", + "required": ["b"], + "title": "User", + }, + "tests__test_duplicate_model_names_openapi__c__model__User": { + "properties": {"c": {"type": "integer", "title": "C"}}, + "type": "object", + "required": ["c"], + "title": "User", + }, + } + }, + } From 5d6d932d892ec422e55bda32ae14c3ab1a92802d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 14:29:47 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_duplicate_model_names_openapi/app.py | 9 +++------ .../test_duplicate_model_names_openapi.py | 1 - 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/test_duplicate_model_names_openapi/app.py b/tests/test_duplicate_model_names_openapi/app.py index d817cf5726..0e724f7047 100644 --- a/tests/test_duplicate_model_names_openapi/app.py +++ b/tests/test_duplicate_model_names_openapi/app.py @@ -8,15 +8,12 @@ app = FastAPI() @app.get("/a", response_model=UserA) -def user_a(): - ... +def user_a(): ... @app.get("/b", response_model=UserB) -def user_b(): - ... +def user_b(): ... @app.get("/c", response_model=UserC) -def user_c(): - ... +def user_c(): ... diff --git a/tests/test_duplicate_model_names_openapi/test_duplicate_model_names_openapi.py b/tests/test_duplicate_model_names_openapi/test_duplicate_model_names_openapi.py index fd19c1c1ed..578d3fef1e 100644 --- a/tests/test_duplicate_model_names_openapi/test_duplicate_model_names_openapi.py +++ b/tests/test_duplicate_model_names_openapi/test_duplicate_model_names_openapi.py @@ -1,4 +1,3 @@ - from fastapi.testclient import TestClient from .app import app