Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fall_back_to_schema_key_when_titles_collide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
default: patch
---

# Fall back to schema key when two schemas share a `title`

Tools like FastAPI emit duplicate `title` values for input and output variants of the same model (for example `Thing-Input` and `Thing-Output` both carrying `title: Thing`). The first variant took the title-derived class name and the second was silently dropped with an `Attempted to generate duplicate models` error, along with every endpoint that referenced it.

The second variant now falls back to a class name derived from its schema key (`Thing-Output` becomes `ThingOutput`), so both schemas survive and their endpoints generate.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from end_to_end_tests.functional_tests.helpers import (
with_generated_client_fixture,
with_generated_code_imports,
)


@with_generated_client_fixture(
"""
components:
schemas:
Thing-Input:
title: Thing
type: object
properties:
name: {"type": "string"}
Thing-Output:
title: Thing
type: object
properties:
name: {"type": "string"}
id: {"type": "string"}
"""
)
@with_generated_code_imports(".models.Thing", ".models.ThingOutput")
class TestCollidingTitlesFallBackToSchemaKey:
"""FastAPI emits the same ``title`` for input and output variants of a model
(for example ``Thing-Input`` and ``Thing-Output`` both carrying ``title: Thing``).
The first variant takes the title-derived class name, and the second falls back
to its schema key so both schemas survive.
"""

def test_first_variant_uses_title(self, Thing):
assert Thing.__name__ == "Thing"
instance = Thing(name="x")
assert instance.to_dict() == {"name": "x"}

def test_second_variant_uses_schema_key(self, ThingOutput):
assert ThingOutput.__name__ == "ThingOutput"
instance = ThingOutput(name="x", id="123")
assert instance.to_dict() == {"name": "x", "id": "123"}
4 changes: 4 additions & 0 deletions openapi_python_client/parser/properties/model_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ def build(
else:
class_string = title
class_info = Class.from_string(string=class_string, config=config)
if class_info.name in schemas.classes_by_name and data.title and name:
fallback_class_info = Class.from_string(string=name, config=config)
if fallback_class_info.name not in schemas.classes_by_name:
class_info = fallback_class_info
model_roots = {*roots, class_info.name}
required_properties: list[Property] | None = None
optional_properties: list[Property] | None = None
Expand Down
17 changes: 17 additions & 0 deletions tests/test_parser/test_properties/test_model_property.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,23 @@ def test_model_name_conflict(self, config):
assert new_schemas == schemas
assert err == PropertyError(detail='Attempted to generate duplicate models with name "OtherModel"', data=data)

def test_model_name_conflict_fallback(self, config):
data = oai.Schema.model_construct(title="OtherModel")
schemas = Schemas(classes_by_name={"OtherModel": None})

model, _new_schemas = ModelProperty.build(
data=data,
name="UniqueModelName",
schemas=schemas,
required=True,
parent_name=None,
config=config,
roots={"root"},
process_properties=True,
)

assert model.class_info.name == "UniqueModelName"

@pytest.mark.parametrize(
"name, title, parent_name, use_title_prefixing, expected",
ids=(
Expand Down