diff --git a/.changeset/union_fixes.md b/.changeset/union_fixes.md new file mode 100644 index 000000000..099ef37eb --- /dev/null +++ b/.changeset/union_fixes.md @@ -0,0 +1,7 @@ +--- +default: patch +--- + +# Fix class generation for some union types + +Fixed issue #1120, where certain combinations of types-- such as a `oneOf` between a model or an enum and null, or the OpenAPI 3.0 equivalent of using `nullable: true`-- could cause unnecessary suffixes like "Type0" to be added to the class name, and/or could cause extra copies of the class to be generated. diff --git a/end_to_end_tests/baseline_openapi_3.0.json b/end_to_end_tests/baseline_openapi_3.0.json index 22a786a4f..01fa8fe9e 100644 --- a/end_to_end_tests/baseline_openapi_3.0.json +++ b/end_to_end_tests/baseline_openapi_3.0.json @@ -1762,7 +1762,9 @@ "model", "nullable_model", "one_of_models", - "nullable_one_of_models" + "nullable_one_of_models", + "nullable_enum_as_ref", + "nullable_enum_inline" ], "type": "object", "properties": { @@ -1951,6 +1953,14 @@ } ], "nullable": true + }, + "nullable_enum_as_ref": { + "$ref": "#/components/schemas/AnEnumWithNull" + }, + "nullable_enum_inline": { + "type": "string", + "enum": ["FIRST_VALUE", "SECOND_VALUE", null], + "nullable": true } }, "description": "A Model for testing all the ways custom objects can be used ", @@ -1971,6 +1981,7 @@ "SECOND_VALUE", null ], + "nullable": true, "description": "For testing Enums with mixed string / null values " }, "AnEnumWithOnlyNull": { diff --git a/end_to_end_tests/baseline_openapi_3.1.yaml b/end_to_end_tests/baseline_openapi_3.1.yaml index a19e46ce3..651c50ebd 100644 --- a/end_to_end_tests/baseline_openapi_3.1.yaml +++ b/end_to_end_tests/baseline_openapi_3.1.yaml @@ -1747,7 +1747,9 @@ info: "model", "nullable_model", "one_of_models", - "nullable_one_of_models" + "nullable_one_of_models", + "nullable_enum_as_ref", + "nullable_enum_inline" ], "type": "object", "properties": { @@ -1950,6 +1952,13 @@ info: "$ref": "#/components/schemas/ModelWithUnionProperty" } ] + }, + "nullable_enum_as_ref": { + "$ref": "#/components/schemas/AnEnumWithNull" + }, + "nullable_enum_inline": { + "type": ["string", "null"], + "enum": ["FIRST_VALUE", "SECOND_VALUE", null] } }, "description": "A Model for testing all the ways custom objects can be used ", @@ -1965,6 +1974,7 @@ info: }, "AnEnumWithNull": { "title": "AnEnumWithNull", + "type": ["string", "null"], "enum": [ "FIRST_VALUE", "SECOND_VALUE", diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py b/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py index f354c31c7..14fd3d92e 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py @@ -4,6 +4,7 @@ from .a_discriminated_union_type_2 import ADiscriminatedUnionType2 from .a_form_data import AFormData from .a_model import AModel +from .a_model_nullable_enum_inline import AModelNullableEnumInline from .a_model_with_properties_reference_that_are_not_object import AModelWithPropertiesReferenceThatAreNotObject from .all_of_has_properties_but_no_type import AllOfHasPropertiesButNoType from .all_of_has_properties_but_no_type_type_enum import AllOfHasPropertiesButNoTypeTypeEnum @@ -54,7 +55,7 @@ ) from .model_with_additional_properties_refed import ModelWithAdditionalPropertiesRefed from .model_with_any_json_properties import ModelWithAnyJsonProperties -from .model_with_any_json_properties_additional_property_type_0 import ModelWithAnyJsonPropertiesAdditionalPropertyType0 +from .model_with_any_json_properties_additional_property import ModelWithAnyJsonPropertiesAdditionalProperty from .model_with_backslash_in_description import ModelWithBackslashInDescription from .model_with_circular_ref_a import ModelWithCircularRefA from .model_with_circular_ref_b import ModelWithCircularRefB @@ -82,8 +83,8 @@ from .post_naming_property_conflict_with_import_body import PostNamingPropertyConflictWithImportBody from .post_naming_property_conflict_with_import_response_200 import PostNamingPropertyConflictWithImportResponse200 from .post_responses_unions_simple_before_complex_response_200 import PostResponsesUnionsSimpleBeforeComplexResponse200 -from .post_responses_unions_simple_before_complex_response_200a_type_1 import ( - PostResponsesUnionsSimpleBeforeComplexResponse200AType1, +from .post_responses_unions_simple_before_complex_response_200a import ( + PostResponsesUnionsSimpleBeforeComplexResponse200A, ) from .test_inline_objects_body import TestInlineObjectsBody from .test_inline_objects_response_200 import TestInlineObjectsResponse200 @@ -98,6 +99,7 @@ "AllOfSubModel", "AllOfSubModelTypeEnum", "AModel", + "AModelNullableEnumInline", "AModelWithPropertiesReferenceThatAreNotObject", "AnAllOfEnum", "AnArrayWithACircularRefInItemsObjectAdditionalPropertiesAItem", @@ -136,7 +138,7 @@ "ModelWithAdditionalPropertiesInlinedAdditionalProperty", "ModelWithAdditionalPropertiesRefed", "ModelWithAnyJsonProperties", - "ModelWithAnyJsonPropertiesAdditionalPropertyType0", + "ModelWithAnyJsonPropertiesAdditionalProperty", "ModelWithBackslashInDescription", "ModelWithCircularRefA", "ModelWithCircularRefB", @@ -164,7 +166,7 @@ "PostNamingPropertyConflictWithImportBody", "PostNamingPropertyConflictWithImportResponse200", "PostResponsesUnionsSimpleBeforeComplexResponse200", - "PostResponsesUnionsSimpleBeforeComplexResponse200AType1", + "PostResponsesUnionsSimpleBeforeComplexResponse200A", "TestInlineObjectsBody", "TestInlineObjectsResponse200", "ValidationError", diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/a_model.py b/end_to_end_tests/golden-record/my_test_api_client/models/a_model.py index 5a81e6365..54329e73a 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/a_model.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/a_model.py @@ -5,8 +5,10 @@ from attrs import define as _attrs_define from dateutil.parser import isoparse +from ..models.a_model_nullable_enum_inline import AModelNullableEnumInline from ..models.an_all_of_enum import AnAllOfEnum from ..models.an_enum import AnEnum +from ..models.an_enum_with_null import AnEnumWithNull from ..models.different_enum import DifferentEnum from ..types import UNSET, Unset @@ -36,6 +38,8 @@ class AModel: nullable_one_of_models (Union['FreeFormModel', 'ModelWithUnionProperty', None]): model (ModelWithUnionProperty): nullable_model (Union['ModelWithUnionProperty', None]): + nullable_enum_as_ref (Union[AnEnumWithNull, None]): For testing Enums with mixed string / null values + nullable_enum_inline (Union[AModelNullableEnumInline, None]): any_value (Union[Unset, Any]): Default: 'default'. an_optional_allof_enum (Union[Unset, AnAllOfEnum]): nested_list_of_enums (Union[Unset, list[list[DifferentEnum]]]): @@ -62,6 +66,8 @@ class AModel: nullable_one_of_models: Union["FreeFormModel", "ModelWithUnionProperty", None] model: "ModelWithUnionProperty" nullable_model: Union["ModelWithUnionProperty", None] + nullable_enum_as_ref: Union[AnEnumWithNull, None] + nullable_enum_inline: Union[AModelNullableEnumInline, None] an_allof_enum_with_overridden_default: AnAllOfEnum = AnAllOfEnum.OVERRIDDEN_DEFAULT a_nullable_uuid: Union[None, UUID] = UUID("07EF8B4D-AA09-4FFA-898D-C710796AFF41") any_value: Union[Unset, Any] = "default" @@ -137,6 +143,18 @@ def to_dict(self) -> dict[str, Any]: else: nullable_model = self.nullable_model + nullable_enum_as_ref: Union[None, str] + if isinstance(self.nullable_enum_as_ref, AnEnumWithNull): + nullable_enum_as_ref = self.nullable_enum_as_ref.value + else: + nullable_enum_as_ref = self.nullable_enum_as_ref + + nullable_enum_inline: Union[None, str] + if isinstance(self.nullable_enum_inline, AModelNullableEnumInline): + nullable_enum_inline = self.nullable_enum_inline.value + else: + nullable_enum_inline = self.nullable_enum_inline + any_value = self.any_value an_optional_allof_enum: Union[Unset, str] = UNSET @@ -220,6 +238,8 @@ def to_dict(self) -> dict[str, Any]: "nullable_one_of_models": nullable_one_of_models, "model": model, "nullable_model": nullable_model, + "nullable_enum_as_ref": nullable_enum_as_ref, + "nullable_enum_inline": nullable_enum_inline, } ) if any_value is not UNSET: @@ -373,15 +393,45 @@ def _parse_nullable_model(data: object) -> Union["ModelWithUnionProperty", None] try: if not isinstance(data, dict): raise TypeError() - nullable_model_type_1 = ModelWithUnionProperty.from_dict(data) + nullable_model = ModelWithUnionProperty.from_dict(data) - return nullable_model_type_1 + return nullable_model except: # noqa: E722 pass return cast(Union["ModelWithUnionProperty", None], data) nullable_model = _parse_nullable_model(d.pop("nullable_model")) + def _parse_nullable_enum_as_ref(data: object) -> Union[AnEnumWithNull, None]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + componentsschemas_an_enum_with_null = AnEnumWithNull(data) + + return componentsschemas_an_enum_with_null + except: # noqa: E722 + pass + return cast(Union[AnEnumWithNull, None], data) + + nullable_enum_as_ref = _parse_nullable_enum_as_ref(d.pop("nullable_enum_as_ref")) + + def _parse_nullable_enum_inline(data: object) -> Union[AModelNullableEnumInline, None]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + nullable_enum_inline = AModelNullableEnumInline(data) + + return nullable_enum_inline + except: # noqa: E722 + pass + return cast(Union[AModelNullableEnumInline, None], data) + + nullable_enum_inline = _parse_nullable_enum_inline(d.pop("nullable_enum_inline")) + any_value = d.pop("any_value", UNSET) _an_optional_allof_enum = d.pop("an_optional_allof_enum", UNSET) @@ -495,9 +545,9 @@ def _parse_not_required_nullable_model(data: object) -> Union["ModelWithUnionPro try: if not isinstance(data, dict): raise TypeError() - not_required_nullable_model_type_1 = ModelWithUnionProperty.from_dict(data) + not_required_nullable_model = ModelWithUnionProperty.from_dict(data) - return not_required_nullable_model_type_1 + return not_required_nullable_model except: # noqa: E722 pass return cast(Union["ModelWithUnionProperty", None, Unset], data) @@ -518,6 +568,8 @@ def _parse_not_required_nullable_model(data: object) -> Union["ModelWithUnionPro nullable_one_of_models=nullable_one_of_models, model=model, nullable_model=nullable_model, + nullable_enum_as_ref=nullable_enum_as_ref, + nullable_enum_inline=nullable_enum_inline, any_value=any_value, an_optional_allof_enum=an_optional_allof_enum, nested_list_of_enums=nested_list_of_enums, diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/a_model_nullable_enum_inline.py b/end_to_end_tests/golden-record/my_test_api_client/models/a_model_nullable_enum_inline.py new file mode 100644 index 000000000..21634624e --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/a_model_nullable_enum_inline.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class AModelNullableEnumInline(str, Enum): + FIRST_VALUE = "FIRST_VALUE" + SECOND_VALUE = "SECOND_VALUE" + + def __str__(self) -> str: + return str(self.value) diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/body_upload_file_tests_upload_post.py b/end_to_end_tests/golden-record/my_test_api_client/models/body_upload_file_tests_upload_post.py index 1c255cfcb..4bcc53863 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/body_upload_file_tests_upload_post.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/body_upload_file_tests_upload_post.py @@ -304,9 +304,9 @@ def _parse_some_nullable_object(data: object) -> Union["BodyUploadFileTestsUploa try: if not isinstance(data, dict): raise TypeError() - some_nullable_object_type_0 = BodyUploadFileTestsUploadPostSomeNullableObject.from_dict(data) + some_nullable_object = BodyUploadFileTestsUploadPostSomeNullableObject.from_dict(data) - return some_nullable_object_type_0 + return some_nullable_object except: # noqa: E722 pass return cast(Union["BodyUploadFileTestsUploadPostSomeNullableObject", None], data) diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/extended.py b/end_to_end_tests/golden-record/my_test_api_client/models/extended.py index ffd6406c7..cc67ad784 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/extended.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/extended.py @@ -6,8 +6,10 @@ from attrs import field as _attrs_field from dateutil.parser import isoparse +from ..models.a_model_nullable_enum_inline import AModelNullableEnumInline from ..models.an_all_of_enum import AnAllOfEnum from ..models.an_enum import AnEnum +from ..models.an_enum_with_null import AnEnumWithNull from ..models.different_enum import DifferentEnum from ..types import UNSET, Unset @@ -36,6 +38,8 @@ class Extended: nullable_one_of_models (Union['FreeFormModel', 'ModelWithUnionProperty', None]): model (ModelWithUnionProperty): nullable_model (Union['ModelWithUnionProperty', None]): + nullable_enum_as_ref (Union[AnEnumWithNull, None]): For testing Enums with mixed string / null values + nullable_enum_inline (Union[AModelNullableEnumInline, None]): any_value (Union[Unset, Any]): Default: 'default'. an_optional_allof_enum (Union[Unset, AnAllOfEnum]): nested_list_of_enums (Union[Unset, list[list[DifferentEnum]]]): @@ -63,6 +67,8 @@ class Extended: nullable_one_of_models: Union["FreeFormModel", "ModelWithUnionProperty", None] model: "ModelWithUnionProperty" nullable_model: Union["ModelWithUnionProperty", None] + nullable_enum_as_ref: Union[AnEnumWithNull, None] + nullable_enum_inline: Union[AModelNullableEnumInline, None] an_allof_enum_with_overridden_default: AnAllOfEnum = AnAllOfEnum.OVERRIDDEN_DEFAULT a_nullable_uuid: Union[None, UUID] = UUID("07EF8B4D-AA09-4FFA-898D-C710796AFF41") any_value: Union[Unset, Any] = "default" @@ -140,6 +146,18 @@ def to_dict(self) -> dict[str, Any]: else: nullable_model = self.nullable_model + nullable_enum_as_ref: Union[None, str] + if isinstance(self.nullable_enum_as_ref, AnEnumWithNull): + nullable_enum_as_ref = self.nullable_enum_as_ref.value + else: + nullable_enum_as_ref = self.nullable_enum_as_ref + + nullable_enum_inline: Union[None, str] + if isinstance(self.nullable_enum_inline, AModelNullableEnumInline): + nullable_enum_inline = self.nullable_enum_inline.value + else: + nullable_enum_inline = self.nullable_enum_inline + any_value = self.any_value an_optional_allof_enum: Union[Unset, str] = UNSET @@ -226,6 +244,8 @@ def to_dict(self) -> dict[str, Any]: "nullable_one_of_models": nullable_one_of_models, "model": model, "nullable_model": nullable_model, + "nullable_enum_as_ref": nullable_enum_as_ref, + "nullable_enum_inline": nullable_enum_inline, } ) if any_value is not UNSET: @@ -381,15 +401,45 @@ def _parse_nullable_model(data: object) -> Union["ModelWithUnionProperty", None] try: if not isinstance(data, dict): raise TypeError() - nullable_model_type_1 = ModelWithUnionProperty.from_dict(data) + nullable_model = ModelWithUnionProperty.from_dict(data) - return nullable_model_type_1 + return nullable_model except: # noqa: E722 pass return cast(Union["ModelWithUnionProperty", None], data) nullable_model = _parse_nullable_model(d.pop("nullable_model")) + def _parse_nullable_enum_as_ref(data: object) -> Union[AnEnumWithNull, None]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + componentsschemas_an_enum_with_null = AnEnumWithNull(data) + + return componentsschemas_an_enum_with_null + except: # noqa: E722 + pass + return cast(Union[AnEnumWithNull, None], data) + + nullable_enum_as_ref = _parse_nullable_enum_as_ref(d.pop("nullable_enum_as_ref")) + + def _parse_nullable_enum_inline(data: object) -> Union[AModelNullableEnumInline, None]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + nullable_enum_inline = AModelNullableEnumInline(data) + + return nullable_enum_inline + except: # noqa: E722 + pass + return cast(Union[AModelNullableEnumInline, None], data) + + nullable_enum_inline = _parse_nullable_enum_inline(d.pop("nullable_enum_inline")) + any_value = d.pop("any_value", UNSET) _an_optional_allof_enum = d.pop("an_optional_allof_enum", UNSET) @@ -503,9 +553,9 @@ def _parse_not_required_nullable_model(data: object) -> Union["ModelWithUnionPro try: if not isinstance(data, dict): raise TypeError() - not_required_nullable_model_type_1 = ModelWithUnionProperty.from_dict(data) + not_required_nullable_model = ModelWithUnionProperty.from_dict(data) - return not_required_nullable_model_type_1 + return not_required_nullable_model except: # noqa: E722 pass return cast(Union["ModelWithUnionProperty", None, Unset], data) @@ -528,6 +578,8 @@ def _parse_not_required_nullable_model(data: object) -> Union["ModelWithUnionPro nullable_one_of_models=nullable_one_of_models, model=model, nullable_model=nullable_model, + nullable_enum_as_ref=nullable_enum_as_ref, + nullable_enum_inline=nullable_enum_inline, any_value=any_value, an_optional_allof_enum=an_optional_allof_enum, nested_list_of_enums=nested_list_of_enums, diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties.py b/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties.py index f71fe7c1e..deb8aeea5 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties.py @@ -4,9 +4,7 @@ from attrs import field as _attrs_field if TYPE_CHECKING: - from ..models.model_with_any_json_properties_additional_property_type_0 import ( - ModelWithAnyJsonPropertiesAdditionalPropertyType0, - ) + from ..models.model_with_any_json_properties_additional_property import ModelWithAnyJsonPropertiesAdditionalProperty T = TypeVar("T", bound="ModelWithAnyJsonProperties") @@ -17,17 +15,17 @@ class ModelWithAnyJsonProperties: """ """ additional_properties: dict[ - str, Union["ModelWithAnyJsonPropertiesAdditionalPropertyType0", bool, float, int, list[str], str] + str, Union["ModelWithAnyJsonPropertiesAdditionalProperty", bool, float, int, list[str], str] ] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: - from ..models.model_with_any_json_properties_additional_property_type_0 import ( - ModelWithAnyJsonPropertiesAdditionalPropertyType0, + from ..models.model_with_any_json_properties_additional_property import ( + ModelWithAnyJsonPropertiesAdditionalProperty, ) field_dict: dict[str, Any] = {} for prop_name, prop in self.additional_properties.items(): - if isinstance(prop, ModelWithAnyJsonPropertiesAdditionalPropertyType0): + if isinstance(prop, ModelWithAnyJsonPropertiesAdditionalProperty): field_dict[prop_name] = prop.to_dict() elif isinstance(prop, list): field_dict[prop_name] = prop @@ -39,8 +37,8 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T: - from ..models.model_with_any_json_properties_additional_property_type_0 import ( - ModelWithAnyJsonPropertiesAdditionalPropertyType0, + from ..models.model_with_any_json_properties_additional_property import ( + ModelWithAnyJsonPropertiesAdditionalProperty, ) d = src_dict.copy() @@ -51,13 +49,13 @@ def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T: def _parse_additional_property( data: object, - ) -> Union["ModelWithAnyJsonPropertiesAdditionalPropertyType0", bool, float, int, list[str], str]: + ) -> Union["ModelWithAnyJsonPropertiesAdditionalProperty", bool, float, int, list[str], str]: try: if not isinstance(data, dict): raise TypeError() - additional_property_type_0 = ModelWithAnyJsonPropertiesAdditionalPropertyType0.from_dict(data) + additional_property = ModelWithAnyJsonPropertiesAdditionalProperty.from_dict(data) - return additional_property_type_0 + return additional_property except: # noqa: E722 pass try: @@ -69,7 +67,7 @@ def _parse_additional_property( except: # noqa: E722 pass return cast( - Union["ModelWithAnyJsonPropertiesAdditionalPropertyType0", bool, float, int, list[str], str], data + Union["ModelWithAnyJsonPropertiesAdditionalProperty", bool, float, int, list[str], str], data ) additional_property = _parse_additional_property(prop_dict) @@ -85,13 +83,11 @@ def additional_keys(self) -> list[str]: def __getitem__( self, key: str - ) -> Union["ModelWithAnyJsonPropertiesAdditionalPropertyType0", bool, float, int, list[str], str]: + ) -> Union["ModelWithAnyJsonPropertiesAdditionalProperty", bool, float, int, list[str], str]: return self.additional_properties[key] def __setitem__( - self, - key: str, - value: Union["ModelWithAnyJsonPropertiesAdditionalPropertyType0", bool, float, int, list[str], str], + self, key: str, value: Union["ModelWithAnyJsonPropertiesAdditionalProperty", bool, float, int, list[str], str] ) -> None: self.additional_properties[key] = value diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties_additional_property_type_0.py b/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties_additional_property.py similarity index 82% rename from end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties_additional_property_type_0.py rename to end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties_additional_property.py index 65993e8be..e7e244eb7 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties_additional_property_type_0.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties_additional_property.py @@ -3,11 +3,11 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field -T = TypeVar("T", bound="ModelWithAnyJsonPropertiesAdditionalPropertyType0") +T = TypeVar("T", bound="ModelWithAnyJsonPropertiesAdditionalProperty") @_attrs_define -class ModelWithAnyJsonPropertiesAdditionalPropertyType0: +class ModelWithAnyJsonPropertiesAdditionalProperty: """ """ additional_properties: dict[str, str] = _attrs_field(init=False, factory=dict) @@ -21,10 +21,10 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T: d = src_dict.copy() - model_with_any_json_properties_additional_property_type_0 = cls() + model_with_any_json_properties_additional_property = cls() - model_with_any_json_properties_additional_property_type_0.additional_properties = d - return model_with_any_json_properties_additional_property_type_0 + model_with_any_json_properties_additional_property.additional_properties = d + return model_with_any_json_properties_additional_property @property def additional_keys(self) -> list[str]: diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/post_responses_unions_simple_before_complex_response_200.py b/end_to_end_tests/golden-record/my_test_api_client/models/post_responses_unions_simple_before_complex_response_200.py index 9962f552c..51f6dade2 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/post_responses_unions_simple_before_complex_response_200.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/post_responses_unions_simple_before_complex_response_200.py @@ -4,8 +4,8 @@ from attrs import field as _attrs_field if TYPE_CHECKING: - from ..models.post_responses_unions_simple_before_complex_response_200a_type_1 import ( - PostResponsesUnionsSimpleBeforeComplexResponse200AType1, + from ..models.post_responses_unions_simple_before_complex_response_200a import ( + PostResponsesUnionsSimpleBeforeComplexResponse200A, ) @@ -16,19 +16,19 @@ class PostResponsesUnionsSimpleBeforeComplexResponse200: """ Attributes: - a (Union['PostResponsesUnionsSimpleBeforeComplexResponse200AType1', str]): + a (Union['PostResponsesUnionsSimpleBeforeComplexResponse200A', str]): """ - a: Union["PostResponsesUnionsSimpleBeforeComplexResponse200AType1", str] + a: Union["PostResponsesUnionsSimpleBeforeComplexResponse200A", str] additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: - from ..models.post_responses_unions_simple_before_complex_response_200a_type_1 import ( - PostResponsesUnionsSimpleBeforeComplexResponse200AType1, + from ..models.post_responses_unions_simple_before_complex_response_200a import ( + PostResponsesUnionsSimpleBeforeComplexResponse200A, ) a: Union[dict[str, Any], str] - if isinstance(self.a, PostResponsesUnionsSimpleBeforeComplexResponse200AType1): + if isinstance(self.a, PostResponsesUnionsSimpleBeforeComplexResponse200A): a = self.a.to_dict() else: a = self.a @@ -45,22 +45,22 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T: - from ..models.post_responses_unions_simple_before_complex_response_200a_type_1 import ( - PostResponsesUnionsSimpleBeforeComplexResponse200AType1, + from ..models.post_responses_unions_simple_before_complex_response_200a import ( + PostResponsesUnionsSimpleBeforeComplexResponse200A, ) d = src_dict.copy() - def _parse_a(data: object) -> Union["PostResponsesUnionsSimpleBeforeComplexResponse200AType1", str]: + def _parse_a(data: object) -> Union["PostResponsesUnionsSimpleBeforeComplexResponse200A", str]: try: if not isinstance(data, dict): raise TypeError() - a_type_1 = PostResponsesUnionsSimpleBeforeComplexResponse200AType1.from_dict(data) + a = PostResponsesUnionsSimpleBeforeComplexResponse200A.from_dict(data) - return a_type_1 + return a except: # noqa: E722 pass - return cast(Union["PostResponsesUnionsSimpleBeforeComplexResponse200AType1", str], data) + return cast(Union["PostResponsesUnionsSimpleBeforeComplexResponse200A", str], data) a = _parse_a(d.pop("a")) diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/post_responses_unions_simple_before_complex_response_200a_type_1.py b/end_to_end_tests/golden-record/my_test_api_client/models/post_responses_unions_simple_before_complex_response_200a.py similarity index 89% rename from end_to_end_tests/golden-record/my_test_api_client/models/post_responses_unions_simple_before_complex_response_200a_type_1.py rename to end_to_end_tests/golden-record/my_test_api_client/models/post_responses_unions_simple_before_complex_response_200a.py index 5e8ef0207..be97429f5 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/post_responses_unions_simple_before_complex_response_200a_type_1.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/post_responses_unions_simple_before_complex_response_200a.py @@ -3,11 +3,11 @@ from attrs import define as _attrs_define from attrs import field as _attrs_field -T = TypeVar("T", bound="PostResponsesUnionsSimpleBeforeComplexResponse200AType1") +T = TypeVar("T", bound="PostResponsesUnionsSimpleBeforeComplexResponse200A") @_attrs_define -class PostResponsesUnionsSimpleBeforeComplexResponse200AType1: +class PostResponsesUnionsSimpleBeforeComplexResponse200A: """ """ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -21,10 +21,10 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T: d = src_dict.copy() - post_responses_unions_simple_before_complex_response_200a_type_1 = cls() + post_responses_unions_simple_before_complex_response_200a = cls() - post_responses_unions_simple_before_complex_response_200a_type_1.additional_properties = d - return post_responses_unions_simple_before_complex_response_200a_type_1 + post_responses_unions_simple_before_complex_response_200a.additional_properties = d + return post_responses_unions_simple_before_complex_response_200a @property def additional_keys(self) -> list[str]: diff --git a/end_to_end_tests/literal-enums-golden-record/my_enum_api_client/models/post_user_list_body.py b/end_to_end_tests/literal-enums-golden-record/my_enum_api_client/models/post_user_list_body.py index 5566f1b3b..6af995f66 100644 --- a/end_to_end_tests/literal-enums-golden-record/my_enum_api_client/models/post_user_list_body.py +++ b/end_to_end_tests/literal-enums-golden-record/my_enum_api_client/models/post_user_list_body.py @@ -187,9 +187,9 @@ def _parse_an_enum_value_with_null_item(data: object) -> Union[AnEnumWithNull, N try: if not isinstance(data, str): raise TypeError() - componentsschemas_an_enum_with_null_type_1 = check_an_enum_with_null(data) + componentsschemas_an_enum_with_null = check_an_enum_with_null(data) - return componentsschemas_an_enum_with_null_type_1 + return componentsschemas_an_enum_with_null except: # noqa: E722 pass return cast(Union[AnEnumWithNull, None], data) diff --git a/openapi_python_client/parser/properties/enum_property.py b/openapi_python_client/parser/properties/enum_property.py index fc7f20bd9..6007ce4f1 100644 --- a/openapi_python_client/parser/properties/enum_property.py +++ b/openapi_python_client/parser/properties/enum_property.py @@ -1,5 +1,8 @@ from __future__ import annotations +from openapi_python_client.parser.properties.has_named_class import HasNamedClass +from openapi_python_client.schema.data_type import DataType + __all__ = ["EnumProperty", "ValueType"] from typing import Any, ClassVar, Union, cast @@ -9,7 +12,6 @@ from ... import Config, utils from ... import schema as oai -from ...schema import DataType from ..errors import PropertyError from .none import NoneProperty from .protocol import PropertyProtocol, Value @@ -20,7 +22,7 @@ @define -class EnumProperty(PropertyProtocol): +class EnumProperty(PropertyProtocol, HasNamedClass): """A property that should use an enum""" name: str @@ -75,9 +77,10 @@ def build( # noqa: PLR0911 # So instead, if null is a possible value, make the property nullable. # Mypy is not smart enough to know that the type is right though unchecked_value_list = [value for value in enum if value is not None] # type: ignore + allow_null = len(unchecked_value_list) < len(enum) # It's legal to have an enum that only contains null as a value, we don't bother constructing an enum for that - if len(unchecked_value_list) == 0: + if len(unchecked_value_list) == 0 and allow_null: return ( NoneProperty.build( name=name, @@ -102,7 +105,7 @@ def build( # noqa: PLR0911 Union[list[int], list[str]], unchecked_value_list ) # We checked this with all the value_types stuff - if len(value_list) < len(enum): # Only one of the values was None, that becomes a union + if allow_null: # Only one of the values was None, that becomes a union data.oneOf = [ oai.Schema(type=DataType.NULL), data.model_copy(update={"enum": value_list, "default": data.default}), diff --git a/openapi_python_client/parser/properties/has_named_class.py b/openapi_python_client/parser/properties/has_named_class.py new file mode 100644 index 000000000..d24b77420 --- /dev/null +++ b/openapi_python_client/parser/properties/has_named_class.py @@ -0,0 +1,8 @@ +from typing import Protocol, runtime_checkable + +from .schemas import Class + + +@runtime_checkable +class HasNamedClass(Protocol): + class_info: Class diff --git a/openapi_python_client/parser/properties/model_property.py b/openapi_python_client/parser/properties/model_property.py index 762624501..83ba86063 100644 --- a/openapi_python_client/parser/properties/model_property.py +++ b/openapi_python_client/parser/properties/model_property.py @@ -5,6 +5,8 @@ from attrs import define, evolve +from openapi_python_client.parser.properties.has_named_class import HasNamedClass + from ... import Config, utils from ... import schema as oai from ...utils import PythonIdentifier @@ -15,7 +17,7 @@ @define -class ModelProperty(PropertyProtocol): +class ModelProperty(PropertyProtocol, HasNamedClass): """A property which refers to another Schema""" name: str diff --git a/openapi_python_client/parser/properties/union.py b/openapi_python_client/parser/properties/union.py index 8b7b02a48..32c9b5288 100644 --- a/openapi_python_client/parser/properties/union.py +++ b/openapi_python_client/parser/properties/union.py @@ -1,10 +1,12 @@ from __future__ import annotations from itertools import chain -from typing import Any, ClassVar, cast +from typing import Any, Callable, ClassVar, cast from attr import define, evolve +from openapi_python_client.parser.properties.has_named_class import HasNamedClass + from ... import Config from ... import schema as oai from ...utils import PythonIdentifier @@ -47,25 +49,92 @@ def build( """ from . import property_from_data - sub_properties: list[PropertyProtocol] = [] - type_list_data = [] - if isinstance(data.type, list): + if isinstance(data.type, list) and not (data.anyOf or data.oneOf): + # The schema specifies "type:" with a list of allowable types. If there is *not* also an "anyOf" + # or "oneOf", then we should treat that as a shorthand for a oneOf where each variant is just + # a single "type:". For example: + # {"type": ["string", "int"]} becomes + # {"oneOf": [{"type": "string"}, {"type": "int"}]} + # However, if there *is* also an "anyOf" or "oneOf" list, then the information from "type:" is + # redundant since every allowable variant type is already fully described in the list. for _type in data.type: type_list_data.append(data.model_copy(update={"type": _type, "default": None})) + # Here we're copying properties from the top-level union schema that might apply to one + # of the type variants, like "format" for a string. But we don't copy "default" because + # default values will be handled at the top level by the UnionProperty. + + def _add_index_suffix_to_variant_names(index: int) -> str: + return f"{name}_type_{index}" + + def process_items( + variant_name_from_index_func: Callable[[int], str] = _add_index_suffix_to_variant_names, + ) -> tuple[list[PropertyProtocol] | PropertyError, Schemas]: + props: list[PropertyProtocol] = [] + new_schemas = schemas + for i, sub_prop_data in enumerate(chain(data.anyOf, data.oneOf, type_list_data)): + sub_prop_name = variant_name_from_index_func(i) + + # The sub_prop_name logic is what makes this a bit complicated. That value is used only + # if sub_prop is an *inline* schema and needs us to make up a name for it. For instance, + # in the following schema-- + # + # MyModel: + # properties: + # unionThing: + # oneOf: + # - type: object + # properties: ... + # - type: object + # properties: ... + # + # --both of the variants under oneOf are inline schemas. And since they're objects, we + # will be creating model classes for them, which need names. Inline schemas are named by + # concatenating names of parents; so, when we're in UnionProperty.build() for unionThing, + # the value of "name" is "my_model_union_thing", and then we set sub_prop_name to + # "my_model_union_thing_type_0" and "my_model_union_thing_type_1" for the two variants, + # and their model classes will be MyModelUnionThingType0 and MyModelUnionThingType1. + # + # However, in this example, if the second variant was just a scalar type instead of an + # object (like "type: null" or "type: string"), so that the first variant is the only + # one that needs a class... then it would be friendlier to call the first variant's + # class just MyModelUnionThing, not MyModelUnionThingType0. We'll check for that special + # case below; we can't know if that's the situation until after we've processed them all. + + sub_prop, new_schemas = property_from_data( + name=sub_prop_name, + required=True, + data=sub_prop_data, + schemas=new_schemas, + parent_name=parent_name, + config=config, + ) + if isinstance(sub_prop, PropertyError): + return PropertyError(detail=f"Invalid property in union {name}", data=sub_prop_data), new_schemas + props.append(sub_prop) + + return props, new_schemas + + sub_properties, new_schemas = process_items() + # Here's the check for the special case described above. If just one of the variants is + # an inline schema whose name matters, then we'll re-process them to simplify the naming. + # Unfortunately we do have to re-process them all; we can't just modify that one variant + # in place, because new_schemas already contains several references to its old name. + if not isinstance(sub_properties, PropertyError): + if len([p for p in sub_properties if isinstance(p, HasNamedClass)]) == 1: + original_sub_props = sub_properties + + def _use_same_name_as_parent_for_that_one_variant(index: int) -> str: + for i, p in enumerate(original_sub_props): + if i == index and isinstance(p, HasNamedClass): + return name + return _add_index_suffix_to_variant_names(index) + + sub_properties, new_schemas = process_items(_use_same_name_as_parent_for_that_one_variant) - for i, sub_prop_data in enumerate(chain(data.anyOf, data.oneOf, type_list_data)): - sub_prop, schemas = property_from_data( - name=f"{name}_type_{i}", - required=True, - data=sub_prop_data, - schemas=schemas, - parent_name=parent_name, - config=config, - ) - if isinstance(sub_prop, PropertyError): - return PropertyError(detail=f"Invalid property in union {name}", data=sub_prop_data), schemas - sub_properties.append(sub_prop) + if isinstance(sub_properties, PropertyError): + return sub_properties, schemas + schemas = new_schemas def flatten_union_properties(sub_properties: list[PropertyProtocol]) -> list[PropertyProtocol]: flattened = [] diff --git a/tests/test_parser/test_properties/test_enum_property.py b/tests/test_parser/test_properties/test_enum_property.py index 282298aaf..dfcf6970d 100644 --- a/tests/test_parser/test_properties/test_enum_property.py +++ b/tests/test_parser/test_properties/test_enum_property.py @@ -5,8 +5,13 @@ import openapi_python_client.schema as oai from openapi_python_client import Config from openapi_python_client.parser.errors import PropertyError -from openapi_python_client.parser.properties import LiteralEnumProperty, Schemas -from openapi_python_client.parser.properties.enum_property import EnumProperty +from openapi_python_client.parser.properties import ( + EnumProperty, + LiteralEnumProperty, + NoneProperty, + Schemas, + UnionProperty, +) PropertyClass = Union[type[EnumProperty], type[LiteralEnumProperty]] @@ -79,3 +84,22 @@ def test_unsupported_type(config: Config, property_class: PropertyClass) -> None ) assert isinstance(err, PropertyError) + + +def test_nullable_enum(config): + data = oai.Schema( + type="string", + enum=["a", "b", None], + nullable=True, + ) + schemas = Schemas() + + p, _ = EnumProperty.build( + data=data, name="prop1", required=True, schemas=schemas, parent_name="parent", config=config + ) + + assert isinstance(p, UnionProperty) + assert len(p.inner_properties) == 2 + assert isinstance(p.inner_properties[0], NoneProperty) + assert isinstance(p.inner_properties[1], EnumProperty) + assert p.inner_properties[1].class_info.name == "ParentProp1" diff --git a/tests/test_parser/test_properties/test_init.py b/tests/test_parser/test_properties/test_init.py index 918defcdb..359ded8db 100644 --- a/tests/test_parser/test_properties/test_init.py +++ b/tests/test_parser/test_properties/test_init.py @@ -12,6 +12,9 @@ StringProperty, UnionProperty, ) +from openapi_python_client.parser.properties.float import FloatProperty +from openapi_python_client.parser.properties.int import IntProperty +from openapi_python_client.parser.properties.none import NoneProperty from openapi_python_client.parser.properties.protocol import ModelProperty, Value from openapi_python_client.parser.properties.schemas import Class from openapi_python_client.schema import DataType @@ -441,14 +444,23 @@ def test_property_from_data_str_enum(self, enum_property_factory, config): "ParentAnEnum": prop, } + @pytest.mark.parametrize( + "desc,extra_props", + [ + ("3_1_implicit_type", {}), + ("3_1_explicit_type", {"type": ["string", "null"]}), + ("3_0_implicit_type", {"nullable": True}), + ("3_0_explicit_type", {"type": "string", "nullable": True}), + ], + ) def test_property_from_data_str_enum_with_null( - self, enum_property_factory, union_property_factory, none_property_factory, config + self, desc, extra_props, enum_property_factory, union_property_factory, none_property_factory, config ): from openapi_python_client.parser.properties import Class, Schemas, property_from_data from openapi_python_client.schema import Schema existing = enum_property_factory() - data = Schema(title="AnEnum", enum=["A", "B", "C", None], default="B") + data = Schema(title="AnEnum", enum=["A", "B", "C", None], default="B", **extra_props) name = "my_enum" required = True @@ -461,18 +473,19 @@ def test_property_from_data_str_enum_with_null( # None / null is removed from enum, and property is now nullable assert isinstance(prop, UnionProperty), "Enums with None should be converted to UnionProperties" enum_prop = enum_property_factory( - name="my_enum_type_1", + name=name, required=required, values={"A": "A", "B": "B", "C": "C"}, class_info=Class(name=ClassName("ParentAnEnum", ""), module_name=PythonIdentifier("parent_an_enum", "")), value_type=str, default=Value(python_code="ParentAnEnum.B", raw_value="B"), ) - none_property = none_property_factory(name="my_enum_type_0", required=required) + none_property = none_property_factory(name=f"{name}_type_0", required=required) assert prop == union_property_factory( name=name, default=Value(python_code="ParentAnEnum.B", raw_value="B"), inner_properties=[none_property, enum_prop], + required=required, ) assert schemas != new_schemas, "Provided Schemas was mutated" assert new_schemas.classes_by_name == { @@ -748,6 +761,33 @@ def test_property_from_data_list_of_types(self, config): assert isinstance(response, UnionProperty) assert len(response.inner_properties) == 2 + assert isinstance(response.inner_properties[0], FloatProperty) + assert isinstance(response.inner_properties[1], NoneProperty) + + def test_property_from_data_list_of_types_and_oneof(self, config): + from openapi_python_client.parser.properties import Schemas, property_from_data + + name = "union_prop" + required = True + data = oai.Schema( + type=[DataType.NUMBER, DataType.NULL], + anyOf=[ + oai.Schema(type=DataType.NUMBER), + oai.Schema(type=DataType.INTEGER), # this is OK because an integer is also a number + oai.Schema(type=DataType.NULL), + ], + ) + schemas = Schemas() + + response = property_from_data( + name=name, required=required, data=data, schemas=schemas, parent_name="parent", config=config + )[0] + + assert isinstance(response, UnionProperty) + assert len(response.inner_properties) == 3 + assert isinstance(response.inner_properties[0], FloatProperty) + assert isinstance(response.inner_properties[1], IntProperty) + assert isinstance(response.inner_properties[2], NoneProperty) def test_property_from_data_union_of_one_element(self, model_property_factory, config): from openapi_python_client.parser.properties import Schemas, property_from_data diff --git a/tests/test_parser/test_properties/test_union.py b/tests/test_parser/test_properties/test_union.py index acbbd06d6..decf6c412 100644 --- a/tests/test_parser/test_properties/test_union.py +++ b/tests/test_parser/test_properties/test_union.py @@ -1,8 +1,12 @@ import openapi_python_client.schema as oai from openapi_python_client.parser.errors import ParseError, PropertyError from openapi_python_client.parser.properties import Schemas, UnionProperty +from openapi_python_client.parser.properties.enum_property import EnumProperty +from openapi_python_client.parser.properties.model_property import ModelProperty from openapi_python_client.parser.properties.protocol import Value +from openapi_python_client.parser.properties.schemas import Class from openapi_python_client.schema import DataType, ParameterLocation +from openapi_python_client.utils import ClassName def test_property_from_data_union(union_property_factory, date_time_property_factory, string_property_factory, config): @@ -33,6 +37,97 @@ def test_property_from_data_union(union_property_factory, date_time_property_fac assert s == Schemas() +def test_name_is_preserved_if_union_is_nullable_model(config): + from openapi_python_client.parser.properties import Schemas, property_from_data + + parent_name = "parent" + name = "prop_1" + required = True + data = oai.Schema( + oneOf=[ + oai.Schema(type=DataType.OBJECT), + oai.Schema(type=DataType.NULL), + ], + ) + expected_model_class = Class(name=ClassName("ParentProp1", ""), module_name="parent_prop_1") + + p, s = property_from_data( + name=name, required=required, data=data, schemas=Schemas(), parent_name=parent_name, config=config + ) + + assert isinstance(p, UnionProperty) + assert len(p.inner_properties) == 2 + prop1 = p.inner_properties[0] + assert isinstance(prop1, ModelProperty) + assert prop1.name == name + assert prop1.class_info == expected_model_class + + assert s == Schemas(classes_by_name={expected_model_class.name: prop1}, models_to_process=[prop1]) + + +def test_name_is_preserved_if_union_is_nullable_enum(config): + from openapi_python_client.parser.properties import Schemas, property_from_data + + parent_name = "parent" + name = "prop_1" + required = True + data = oai.Schema( + oneOf=[ + oai.Schema(type=DataType.INTEGER, enum=[10, 20]), + oai.Schema(type=DataType.NULL), + ], + ) + expected_enum_class = Class(name=ClassName("ParentProp1", ""), module_name="parent_prop_1") + + p, s = property_from_data( + name=name, required=required, data=data, schemas=Schemas(), parent_name=parent_name, config=config + ) + + assert isinstance(p, UnionProperty) + assert len(p.inner_properties) == 2 + prop1 = p.inner_properties[0] + assert isinstance(prop1, EnumProperty) + assert prop1.name == name + assert prop1.class_info == expected_enum_class + + assert s == Schemas(classes_by_name={expected_enum_class.name: prop1}) + + +def test_name_is_preserved_if_union_has_multiple_models_or_enums(config): + from openapi_python_client.parser.properties import Schemas, property_from_data + + parent_name = "parent" + name = "prop_1" + required = True + data = oai.Schema( + oneOf=[ + oai.Schema(type=DataType.OBJECT), + oai.Schema(type=DataType.INTEGER, enum=[10, 20]), + ], + ) + expected_model_class = Class(name=ClassName("ParentProp1Type0", ""), module_name="parent_prop_1_type_0") + expected_enum_class = Class(name=ClassName("ParentProp1Type1", ""), module_name="parent_prop_1_type_1") + + p, s = property_from_data( + name=name, required=required, data=data, schemas=Schemas(), parent_name=parent_name, config=config + ) + + assert isinstance(p, UnionProperty) + assert len(p.inner_properties) == 2 + [prop1, prop2] = p.inner_properties + assert isinstance(prop1, ModelProperty) + assert prop1.name == f"{name}_type_0" + assert prop1.class_info == expected_model_class + assert isinstance(prop2, EnumProperty) + assert prop2.name == f"{name}_type_1" + assert prop2.class_info == expected_enum_class + + assert s == Schemas( + classes_by_name={expected_model_class.name: prop1, expected_enum_class.name: prop2}, + models_to_process=[prop1], + ) + + def test_build_union_property_invalid_property(config): name = "bad_union" required = True