diff --git a/.changeset/fix_invalid_type_check_for_nested_unions.md b/.changeset/fix_invalid_type_check_for_nested_unions.md new file mode 100644 index 000000000..6d3c512a2 --- /dev/null +++ b/.changeset/fix_invalid_type_check_for_nested_unions.md @@ -0,0 +1,9 @@ +--- +default: patch +--- + +# Fix invalid type check for nested unions + +Nested union types (unions of unions) were generating `isinstance()` checks that were not valid (at least for Python 3.9). + +Thanks to @codebutler for PR #959 which fixes #958 and #967. diff --git a/end_to_end_tests/baseline_openapi_3.0.json b/end_to_end_tests/baseline_openapi_3.0.json index 6753bb2a4..af8badc10 100644 --- a/end_to_end_tests/baseline_openapi_3.0.json +++ b/end_to_end_tests/baseline_openapi_3.0.json @@ -801,7 +801,7 @@ } } } - }, + }, "/enum/int": { "post": { "tags": [ @@ -2531,6 +2531,53 @@ "ModelWithBackslashInDescription": { "type": "object", "description": "Description with special character: \\" + }, + "ModelWithDiscriminatedUnion": { + "type": "object", + "properties": { + "discriminated_union": { + "allOf": [ + { + "$ref": "#/components/schemas/ADiscriminatedUnion" + } + ], + "nullable": true + } + } + }, + "ADiscriminatedUnion": { + "type": "object", + "discriminator": { + "propertyName": "modelType", + "mapping": { + "type1": "#/components/schemas/ADiscriminatedUnionType1", + "type2": "#/components/schemas/ADiscriminatedUnionType2" + } + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ADiscriminatedUnionType1" + }, + { + "$ref": "#/components/schemas/ADiscriminatedUnionType2" + } + ] + }, + "ADiscriminatedUnionType1": { + "type": "object", + "properties": { + "modelType": { + "type": "string" + } + } + }, + "ADiscriminatedUnionType2": { + "type": "object", + "properties": { + "modelType": { + "type": "string" + } + } } }, "parameters": { diff --git a/end_to_end_tests/baseline_openapi_3.1.yaml b/end_to_end_tests/baseline_openapi_3.1.yaml index b630ce674..f29cdfd21 100644 --- a/end_to_end_tests/baseline_openapi_3.1.yaml +++ b/end_to_end_tests/baseline_openapi_3.1.yaml @@ -2543,6 +2543,55 @@ info: "ModelWithBackslashInDescription": { "type": "object", "description": "Description with special character: \\" + }, + "ModelWithDiscriminatedUnion": { + "type": "object", + "properties": { + "discriminated_union": { + "oneOf": [ + { + "$ref": "#/components/schemas/ADiscriminatedUnion" + }, + { + "type": "null" + } + ], + } + } + }, + "ADiscriminatedUnion": { + "type": "object", + "discriminator": { + "propertyName": "modelType", + "mapping": { + "type1": "#/components/schemas/ADiscriminatedUnionType1", + "type2": "#/components/schemas/ADiscriminatedUnionType2" + } + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ADiscriminatedUnionType1" + }, + { + "$ref": "#/components/schemas/ADiscriminatedUnionType2" + } + ] + }, + "ADiscriminatedUnionType1": { + "type": "object", + "properties": { + "modelType": { + "type": "string" + } + } + }, + "ADiscriminatedUnionType2": { + "type": "object", + "properties": { + "modelType": { + "type": "string" + } + } } }, "parameters": { 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 5166f321b..39c021a37 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 @@ -1,5 +1,7 @@ """ Contains all the data models used in inputs/outputs """ +from .a_discriminated_union_type_1 import ADiscriminatedUnionType1 +from .a_discriminated_union_type_2 import ADiscriminatedUnionType2 from .a_form_data import AFormData from .a_model import AModel from .a_model_with_properties_reference_that_are_not_object import AModelWithPropertiesReferenceThatAreNotObject @@ -54,6 +56,7 @@ from .model_with_circular_ref_in_additional_properties_a import ModelWithCircularRefInAdditionalPropertiesA from .model_with_circular_ref_in_additional_properties_b import ModelWithCircularRefInAdditionalPropertiesB from .model_with_date_time_property import ModelWithDateTimeProperty +from .model_with_discriminated_union import ModelWithDiscriminatedUnion from .model_with_primitive_additional_properties import ModelWithPrimitiveAdditionalProperties from .model_with_primitive_additional_properties_a_date_holder import ModelWithPrimitiveAdditionalPropertiesADateHolder from .model_with_property_ref import ModelWithPropertyRef @@ -79,6 +82,8 @@ from .validation_error import ValidationError __all__ = ( + "ADiscriminatedUnionType1", + "ADiscriminatedUnionType2", "AFormData", "AllOfHasPropertiesButNoType", "AllOfHasPropertiesButNoTypeTypeEnum", @@ -125,6 +130,7 @@ "ModelWithCircularRefInAdditionalPropertiesA", "ModelWithCircularRefInAdditionalPropertiesB", "ModelWithDateTimeProperty", + "ModelWithDiscriminatedUnion", "ModelWithPrimitiveAdditionalProperties", "ModelWithPrimitiveAdditionalPropertiesADateHolder", "ModelWithPropertyRef", diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/a_discriminated_union_type_1.py b/end_to_end_tests/golden-record/my_test_api_client/models/a_discriminated_union_type_1.py new file mode 100644 index 000000000..cb1184b18 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/a_discriminated_union_type_1.py @@ -0,0 +1,58 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ADiscriminatedUnionType1") + + +@_attrs_define +class ADiscriminatedUnionType1: + """ + Attributes: + model_type (Union[Unset, str]): + """ + + model_type: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + model_type = self.model_type + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if model_type is not UNSET: + field_dict["modelType"] = model_type + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + model_type = d.pop("modelType", UNSET) + + a_discriminated_union_type_1 = cls( + model_type=model_type, + ) + + a_discriminated_union_type_1.additional_properties = d + return a_discriminated_union_type_1 + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/a_discriminated_union_type_2.py b/end_to_end_tests/golden-record/my_test_api_client/models/a_discriminated_union_type_2.py new file mode 100644 index 000000000..734f3bef4 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/a_discriminated_union_type_2.py @@ -0,0 +1,58 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ADiscriminatedUnionType2") + + +@_attrs_define +class ADiscriminatedUnionType2: + """ + Attributes: + model_type (Union[Unset, str]): + """ + + model_type: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + model_type = self.model_type + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if model_type is not UNSET: + field_dict["modelType"] = model_type + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + model_type = d.pop("modelType", UNSET) + + a_discriminated_union_type_2 = cls( + model_type=model_type, + ) + + a_discriminated_union_type_2.additional_properties = d + return a_discriminated_union_type_2 + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/model_with_discriminated_union.py b/end_to_end_tests/golden-record/my_test_api_client/models/model_with_discriminated_union.py new file mode 100644 index 000000000..e03a6e698 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/model_with_discriminated_union.py @@ -0,0 +1,103 @@ +from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.a_discriminated_union_type_1 import ADiscriminatedUnionType1 + from ..models.a_discriminated_union_type_2 import ADiscriminatedUnionType2 + + +T = TypeVar("T", bound="ModelWithDiscriminatedUnion") + + +@_attrs_define +class ModelWithDiscriminatedUnion: + """ + Attributes: + discriminated_union (Union['ADiscriminatedUnionType1', 'ADiscriminatedUnionType2', None, Unset]): + """ + + discriminated_union: Union["ADiscriminatedUnionType1", "ADiscriminatedUnionType2", None, Unset] = UNSET + additional_properties: Dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + from ..models.a_discriminated_union_type_1 import ADiscriminatedUnionType1 + from ..models.a_discriminated_union_type_2 import ADiscriminatedUnionType2 + + discriminated_union: Union[Dict[str, Any], None, Unset] + if isinstance(self.discriminated_union, Unset): + discriminated_union = UNSET + elif isinstance(self.discriminated_union, ADiscriminatedUnionType1): + discriminated_union = self.discriminated_union.to_dict() + elif isinstance(self.discriminated_union, ADiscriminatedUnionType2): + discriminated_union = self.discriminated_union.to_dict() + else: + discriminated_union = self.discriminated_union + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if discriminated_union is not UNSET: + field_dict["discriminated_union"] = discriminated_union + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + from ..models.a_discriminated_union_type_1 import ADiscriminatedUnionType1 + from ..models.a_discriminated_union_type_2 import ADiscriminatedUnionType2 + + d = src_dict.copy() + + def _parse_discriminated_union( + data: object, + ) -> Union["ADiscriminatedUnionType1", "ADiscriminatedUnionType2", None, Unset]: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_a_discriminated_union_type_0 = ADiscriminatedUnionType1.from_dict(data) + + return componentsschemas_a_discriminated_union_type_0 + except: # noqa: E722 + pass + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_a_discriminated_union_type_1 = ADiscriminatedUnionType2.from_dict(data) + + return componentsschemas_a_discriminated_union_type_1 + except: # noqa: E722 + pass + return cast(Union["ADiscriminatedUnionType1", "ADiscriminatedUnionType2", None, Unset], data) + + discriminated_union = _parse_discriminated_union(d.pop("discriminated_union", UNSET)) + + model_with_discriminated_union = cls( + discriminated_union=discriminated_union, + ) + + model_with_discriminated_union.additional_properties = d + return model_with_discriminated_union + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/openapi_python_client/parser/properties/union.py b/openapi_python_client/parser/properties/union.py index ec7d18c7e..8b7b02a48 100644 --- a/openapi_python_client/parser/properties/union.py +++ b/openapi_python_client/parser/properties/union.py @@ -67,6 +67,17 @@ def build( return PropertyError(detail=f"Invalid property in union {name}", data=sub_prop_data), schemas sub_properties.append(sub_prop) + def flatten_union_properties(sub_properties: list[PropertyProtocol]) -> list[PropertyProtocol]: + flattened = [] + for sub_prop in sub_properties: + if isinstance(sub_prop, UnionProperty): + flattened.extend(flatten_union_properties(sub_prop.inner_properties)) + else: + flattened.append(sub_prop) + return flattened + + sub_properties = flatten_union_properties(sub_properties) + prop = UnionProperty( name=name, required=required,