diff --git a/.changeset/fall_back_to_schema_key_when_titles_collide.md b/.changeset/fall_back_to_schema_key_when_titles_collide.md new file mode 100644 index 000000000..d5f41d3a1 --- /dev/null +++ b/.changeset/fall_back_to_schema_key_when_titles_collide.md @@ -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. diff --git a/end_to_end_tests/functional_tests/generated_code_execution/test_title_collisions.py b/end_to_end_tests/functional_tests/generated_code_execution/test_title_collisions.py new file mode 100644 index 000000000..5319c7827 --- /dev/null +++ b/end_to_end_tests/functional_tests/generated_code_execution/test_title_collisions.py @@ -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"} diff --git a/openapi_python_client/parser/properties/model_property.py b/openapi_python_client/parser/properties/model_property.py index 636b71a34..cb9358e22 100644 --- a/openapi_python_client/parser/properties/model_property.py +++ b/openapi_python_client/parser/properties/model_property.py @@ -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 diff --git a/tests/test_parser/test_properties/test_model_property.py b/tests/test_parser/test_properties/test_model_property.py index 8d686edd7..c7cbacca0 100644 --- a/tests/test_parser/test_properties/test_model_property.py +++ b/tests/test_parser/test_properties/test_model_property.py @@ -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=(