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 2679739df..97515ed35 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 @@ -5,12 +5,15 @@ from .a_model_not_required_model import AModelNotRequiredModel from .a_model_not_required_nullable_model import AModelNotRequiredNullableModel from .a_model_nullable_model import AModelNullableModel +from .all_of_sub_model import AllOfSubModel from .an_enum import AnEnum from .an_int_enum import AnIntEnum +from .another_all_of_sub_model import AnotherAllOfSubModel from .body_upload_file_tests_upload_post import BodyUploadFileTestsUploadPost from .different_enum import DifferentEnum from .free_form_model import FreeFormModel from .http_validation_error import HTTPValidationError +from .model_from_all_of import ModelFromAllOf from .model_with_additional_properties_inlined import ModelWithAdditionalPropertiesInlined from .model_with_additional_properties_inlined_additional_property import ( ModelWithAdditionalPropertiesInlinedAdditionalProperty, diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/a_model_model.py b/end_to_end_tests/golden-record/my_test_api_client/models/a_model_model.py index 55ff303f9..0a7a54bd6 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/a_model_model.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/a_model_model.py @@ -1,7 +1,11 @@ -from typing import Any, Dict, List, Type, TypeVar +from typing import Any, Dict, List, Type, TypeVar, Union import attr +from ..models.an_enum import AnEnum +from ..models.an_int_enum import AnIntEnum +from ..types import UNSET, Unset + T = TypeVar("T", bound="AModelModel") @@ -9,20 +13,65 @@ class AModelModel: """ """ + a_property: Union[AnEnum, AnIntEnum, Unset] = UNSET additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) def to_dict(self) -> Dict[str, Any]: + a_property: Union[Unset, int, str] + if isinstance(self.a_property, Unset): + a_property = UNSET + elif isinstance(self.a_property, AnEnum): + a_property = UNSET + if not isinstance(self.a_property, Unset): + a_property = self.a_property.value + + else: + a_property = UNSET + if not isinstance(self.a_property, Unset): + a_property = self.a_property.value field_dict: Dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update({}) + if a_property is not UNSET: + field_dict["a_property"] = a_property return field_dict @classmethod def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: d = src_dict.copy() - a_model_model = cls() + + def _parse_a_property(data: object) -> Union[AnEnum, AnIntEnum, Unset]: + if isinstance(data, Unset): + return data + try: + a_property_type0: Union[Unset, AnEnum] + if not isinstance(data, str): + raise TypeError() + a_property_type0 = UNSET + _a_property_type0 = data + if not isinstance(_a_property_type0, Unset): + a_property_type0 = AnEnum(_a_property_type0) + + return a_property_type0 + except: # noqa: E722 + pass + if not isinstance(data, int): + raise TypeError() + a_property_type1: Union[Unset, AnIntEnum] + a_property_type1 = UNSET + _a_property_type1 = data + if not isinstance(_a_property_type1, Unset): + a_property_type1 = AnIntEnum(_a_property_type1) + + return a_property_type1 + + a_property = _parse_a_property(d.pop("a_property", UNSET)) + + a_model_model = cls( + a_property=a_property, + ) a_model_model.additional_properties = d return a_model_model diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/a_model_not_required_model.py b/end_to_end_tests/golden-record/my_test_api_client/models/a_model_not_required_model.py index 4c86e1019..fd568db52 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/a_model_not_required_model.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/a_model_not_required_model.py @@ -1,7 +1,11 @@ -from typing import Any, Dict, List, Type, TypeVar +from typing import Any, Dict, List, Type, TypeVar, Union import attr +from ..models.an_enum import AnEnum +from ..models.an_int_enum import AnIntEnum +from ..types import UNSET, Unset + T = TypeVar("T", bound="AModelNotRequiredModel") @@ -9,20 +13,65 @@ class AModelNotRequiredModel: """ """ + a_property: Union[AnEnum, AnIntEnum, Unset] = UNSET additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) def to_dict(self) -> Dict[str, Any]: + a_property: Union[Unset, int, str] + if isinstance(self.a_property, Unset): + a_property = UNSET + elif isinstance(self.a_property, AnEnum): + a_property = UNSET + if not isinstance(self.a_property, Unset): + a_property = self.a_property.value + + else: + a_property = UNSET + if not isinstance(self.a_property, Unset): + a_property = self.a_property.value field_dict: Dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update({}) + if a_property is not UNSET: + field_dict["a_property"] = a_property return field_dict @classmethod def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: d = src_dict.copy() - a_model_not_required_model = cls() + + def _parse_a_property(data: object) -> Union[AnEnum, AnIntEnum, Unset]: + if isinstance(data, Unset): + return data + try: + a_property_type0: Union[Unset, AnEnum] + if not isinstance(data, str): + raise TypeError() + a_property_type0 = UNSET + _a_property_type0 = data + if not isinstance(_a_property_type0, Unset): + a_property_type0 = AnEnum(_a_property_type0) + + return a_property_type0 + except: # noqa: E722 + pass + if not isinstance(data, int): + raise TypeError() + a_property_type1: Union[Unset, AnIntEnum] + a_property_type1 = UNSET + _a_property_type1 = data + if not isinstance(_a_property_type1, Unset): + a_property_type1 = AnIntEnum(_a_property_type1) + + return a_property_type1 + + a_property = _parse_a_property(d.pop("a_property", UNSET)) + + a_model_not_required_model = cls( + a_property=a_property, + ) a_model_not_required_model.additional_properties = d return a_model_not_required_model diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/a_model_not_required_nullable_model.py b/end_to_end_tests/golden-record/my_test_api_client/models/a_model_not_required_nullable_model.py index c0bf0fafb..6413e7f9a 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/a_model_not_required_nullable_model.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/a_model_not_required_nullable_model.py @@ -1,7 +1,11 @@ -from typing import Any, Dict, List, Type, TypeVar +from typing import Any, Dict, List, Type, TypeVar, Union import attr +from ..models.an_enum import AnEnum +from ..models.an_int_enum import AnIntEnum +from ..types import UNSET, Unset + T = TypeVar("T", bound="AModelNotRequiredNullableModel") @@ -9,20 +13,65 @@ class AModelNotRequiredNullableModel: """ """ + a_property: Union[AnEnum, AnIntEnum, Unset] = UNSET additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) def to_dict(self) -> Dict[str, Any]: + a_property: Union[Unset, int, str] + if isinstance(self.a_property, Unset): + a_property = UNSET + elif isinstance(self.a_property, AnEnum): + a_property = UNSET + if not isinstance(self.a_property, Unset): + a_property = self.a_property.value + + else: + a_property = UNSET + if not isinstance(self.a_property, Unset): + a_property = self.a_property.value field_dict: Dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update({}) + if a_property is not UNSET: + field_dict["a_property"] = a_property return field_dict @classmethod def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: d = src_dict.copy() - a_model_not_required_nullable_model = cls() + + def _parse_a_property(data: object) -> Union[AnEnum, AnIntEnum, Unset]: + if isinstance(data, Unset): + return data + try: + a_property_type0: Union[Unset, AnEnum] + if not isinstance(data, str): + raise TypeError() + a_property_type0 = UNSET + _a_property_type0 = data + if not isinstance(_a_property_type0, Unset): + a_property_type0 = AnEnum(_a_property_type0) + + return a_property_type0 + except: # noqa: E722 + pass + if not isinstance(data, int): + raise TypeError() + a_property_type1: Union[Unset, AnIntEnum] + a_property_type1 = UNSET + _a_property_type1 = data + if not isinstance(_a_property_type1, Unset): + a_property_type1 = AnIntEnum(_a_property_type1) + + return a_property_type1 + + a_property = _parse_a_property(d.pop("a_property", UNSET)) + + a_model_not_required_nullable_model = cls( + a_property=a_property, + ) a_model_not_required_nullable_model.additional_properties = d return a_model_not_required_nullable_model diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/a_model_nullable_model.py b/end_to_end_tests/golden-record/my_test_api_client/models/a_model_nullable_model.py index fe66227fb..cc6484d5f 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/a_model_nullable_model.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/a_model_nullable_model.py @@ -1,7 +1,11 @@ -from typing import Any, Dict, List, Type, TypeVar +from typing import Any, Dict, List, Type, TypeVar, Union import attr +from ..models.an_enum import AnEnum +from ..models.an_int_enum import AnIntEnum +from ..types import UNSET, Unset + T = TypeVar("T", bound="AModelNullableModel") @@ -9,20 +13,65 @@ class AModelNullableModel: """ """ + a_property: Union[AnEnum, AnIntEnum, Unset] = UNSET additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) def to_dict(self) -> Dict[str, Any]: + a_property: Union[Unset, int, str] + if isinstance(self.a_property, Unset): + a_property = UNSET + elif isinstance(self.a_property, AnEnum): + a_property = UNSET + if not isinstance(self.a_property, Unset): + a_property = self.a_property.value + + else: + a_property = UNSET + if not isinstance(self.a_property, Unset): + a_property = self.a_property.value field_dict: Dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update({}) + if a_property is not UNSET: + field_dict["a_property"] = a_property return field_dict @classmethod def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: d = src_dict.copy() - a_model_nullable_model = cls() + + def _parse_a_property(data: object) -> Union[AnEnum, AnIntEnum, Unset]: + if isinstance(data, Unset): + return data + try: + a_property_type0: Union[Unset, AnEnum] + if not isinstance(data, str): + raise TypeError() + a_property_type0 = UNSET + _a_property_type0 = data + if not isinstance(_a_property_type0, Unset): + a_property_type0 = AnEnum(_a_property_type0) + + return a_property_type0 + except: # noqa: E722 + pass + if not isinstance(data, int): + raise TypeError() + a_property_type1: Union[Unset, AnIntEnum] + a_property_type1 = UNSET + _a_property_type1 = data + if not isinstance(_a_property_type1, Unset): + a_property_type1 = AnIntEnum(_a_property_type1) + + return a_property_type1 + + a_property = _parse_a_property(d.pop("a_property", UNSET)) + + a_model_nullable_model = cls( + a_property=a_property, + ) a_model_nullable_model.additional_properties = d return a_model_nullable_model diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/all_of_sub_model.py b/end_to_end_tests/golden-record/my_test_api_client/models/all_of_sub_model.py new file mode 100644 index 000000000..baa59d06a --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/all_of_sub_model.py @@ -0,0 +1,54 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +import attr + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="AllOfSubModel") + + +@attr.s(auto_attribs=True) +class AllOfSubModel: + """ """ + + a_sub_property: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + a_sub_property = self.a_sub_property + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if a_sub_property is not UNSET: + field_dict["a_sub_property"] = a_sub_property + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + a_sub_property = d.pop("a_sub_property", UNSET) + + all_of_sub_model = cls( + a_sub_property=a_sub_property, + ) + + all_of_sub_model.additional_properties = d + return all_of_sub_model + + @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/another_all_of_sub_model.py b/end_to_end_tests/golden-record/my_test_api_client/models/another_all_of_sub_model.py new file mode 100644 index 000000000..3949852c6 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/another_all_of_sub_model.py @@ -0,0 +1,54 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +import attr + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="AnotherAllOfSubModel") + + +@attr.s(auto_attribs=True) +class AnotherAllOfSubModel: + """ """ + + another_sub_property: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + another_sub_property = self.another_sub_property + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if another_sub_property is not UNSET: + field_dict["another_sub_property"] = another_sub_property + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + another_sub_property = d.pop("another_sub_property", UNSET) + + another_all_of_sub_model = cls( + another_sub_property=another_sub_property, + ) + + another_all_of_sub_model.additional_properties = d + return another_all_of_sub_model + + @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_from_all_of.py b/end_to_end_tests/golden-record/my_test_api_client/models/model_from_all_of.py new file mode 100644 index 000000000..ce26a3bbb --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/model_from_all_of.py @@ -0,0 +1,61 @@ +from typing import Any, Dict, List, Type, TypeVar, Union + +import attr + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ModelFromAllOf") + + +@attr.s(auto_attribs=True) +class ModelFromAllOf: + """ """ + + a_sub_property: Union[Unset, str] = UNSET + another_sub_property: Union[Unset, str] = UNSET + additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + a_sub_property = self.a_sub_property + another_sub_property = self.another_sub_property + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if a_sub_property is not UNSET: + field_dict["a_sub_property"] = a_sub_property + if another_sub_property is not UNSET: + field_dict["another_sub_property"] = another_sub_property + + return field_dict + + @classmethod + def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: + d = src_dict.copy() + a_sub_property = d.pop("a_sub_property", UNSET) + + another_sub_property = d.pop("another_sub_property", UNSET) + + model_from_all_of = cls( + a_sub_property=a_sub_property, + another_sub_property=another_sub_property, + ) + + model_from_all_of.additional_properties = d + return model_from_all_of + + @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/openapi.json b/end_to_end_tests/openapi.json index 5bd42221a..359419473 100644 --- a/end_to_end_tests/openapi.json +++ b/end_to_end_tests/openapi.json @@ -790,7 +790,7 @@ "a_not_required_date": { "title": "A Nullable Date", "type": "string", - "format": "date", + "format": "date" }, "1_leading_digit": { "title": "Leading Digit", @@ -1084,6 +1084,32 @@ } ] } + }, + "ModelFromAllOf": { + "title": "ModelFromAllOf", + "type": "object", + "allOf": [ + {"$ref": "#/components/schemas/AllOfSubModel"}, + {"$ref": "#/components/schemas/AnotherAllOfSubModel"} + ] + }, + "AllOfSubModel": { + "title": "AllOfSubModel", + "type": "object", + "properties": { + "a_sub_property": { + "type": "string" + } + } + }, + "AnotherAllOfSubModel": { + "title": "AnotherAllOfSubModel", + "type": "object", + "properties": { + "another_sub_property": { + "type": "string" + } + } } } } diff --git a/mypy.ini b/mypy.ini index a85aa5f4d..b37bb1eb0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,4 +1,5 @@ [mypy] +plugins = pydantic.mypy disallow_any_generics = True disallow_untyped_defs = True warn_redundant_casts = True diff --git a/openapi_python_client/parser/properties/__init__.py b/openapi_python_client/parser/properties/__init__.py index f3cfa7ff9..dfa832468 100644 --- a/openapi_python_client/parser/properties/__init__.py +++ b/openapi_python_client/parser/properties/__init__.py @@ -9,7 +9,7 @@ from ..reference import Reference from .converter import convert, convert_chain from .enum_property import EnumProperty -from .model_property import ModelProperty +from .model_property import ModelProperty, build_model_property from .property import Property from .schemas import Schemas @@ -259,84 +259,6 @@ def _string_based_property( ) -def build_model_property( - *, data: oai.Schema, name: str, schemas: Schemas, required: bool, parent_name: Optional[str] -) -> Tuple[Union[ModelProperty, PropertyError], Schemas]: - """ - A single ModelProperty from its OAI data - - Args: - data: Data of a single Schema - name: Name by which the schema is referenced, such as a model name. - Used to infer the type name if a `title` property is not available. - schemas: Existing Schemas which have already been processed (to check name conflicts) - """ - required_set = set(data.required or []) - required_properties: List[Property] = [] - optional_properties: List[Property] = [] - relative_imports: Set[str] = set() - - class_name = data.title or name - if parent_name: - class_name = f"{utils.pascal_case(parent_name)}{utils.pascal_case(class_name)}" - ref = Reference.from_ref(class_name) - - for key, value in (data.properties or {}).items(): - prop_required = key in required_set - prop, schemas = property_from_data( - name=key, required=prop_required, data=value, schemas=schemas, parent_name=class_name - ) - if isinstance(prop, PropertyError): - return prop, schemas - if prop_required and not prop.nullable: - required_properties.append(prop) - else: - optional_properties.append(prop) - relative_imports.update(prop.get_imports(prefix="..")) - - additional_properties: Union[bool, Property, PropertyError] - if data.additionalProperties is None: - additional_properties = True - elif isinstance(data.additionalProperties, bool): - additional_properties = data.additionalProperties - elif isinstance(data.additionalProperties, oai.Schema) and not any(data.additionalProperties.dict().values()): - # An empty schema - additional_properties = True - else: - assert isinstance(data.additionalProperties, (oai.Schema, oai.Reference)) - additional_properties, schemas = property_from_data( - name="AdditionalProperty", - required=True, # in the sense that if present in the dict will not be None - data=data.additionalProperties, - schemas=schemas, - parent_name=class_name, - ) - if isinstance(additional_properties, PropertyError): - return additional_properties, schemas - relative_imports.update(additional_properties.get_imports(prefix="..")) - - prop = ModelProperty( - reference=ref, - required_properties=required_properties, - optional_properties=optional_properties, - relative_imports=relative_imports, - description=data.description or "", - default=None, - nullable=data.nullable, - required=required, - name=name, - additional_properties=additional_properties, - ) - if prop.reference.class_name in schemas.models: - error = PropertyError( - data=data, detail=f'Attempted to generate duplicate models with name "{prop.reference.class_name}"' - ) - return error, schemas - - schemas = attr.evolve(schemas, models={**schemas.models, prop.reference.class_name: prop}) - return prop, schemas - - def build_enum_property( *, data: oai.Schema, @@ -480,9 +402,6 @@ def _property_from_data( ) if data.anyOf or data.oneOf: return build_union_property(data=data, name=name, required=required, schemas=schemas, parent_name=parent_name) - if not data.type: - return NoneProperty(name=name, required=required, nullable=False, default=None), schemas - if data.type == "string": return _string_based_property(name=name, required=required, data=data), schemas elif data.type == "number": @@ -517,8 +436,10 @@ def _property_from_data( ) elif data.type == "array": return build_list_property(data=data, name=name, required=required, schemas=schemas, parent_name=parent_name) - elif data.type == "object": + elif data.type == "object" or data.allOf: return build_model_property(data=data, name=name, schemas=schemas, required=required, parent_name=parent_name) + elif not data.type: + return NoneProperty(name=name, required=required, nullable=False, default=None), schemas return PropertyError(data=data, detail=f"unknown type {data.type}"), schemas @@ -575,6 +496,6 @@ def build_schemas(*, components: Dict[str, Union[oai.Reference, oai.Schema]]) -> schemas = schemas_or_err processing = True # We made some progress this round, do another after it's done to_process = next_round - schemas.errors.extend(errors) + schemas.errors.extend(errors) return schemas diff --git a/openapi_python_client/parser/properties/model_property.py b/openapi_python_client/parser/properties/model_property.py index 0439a4ca4..35717e8ba 100644 --- a/openapi_python_client/parser/properties/model_property.py +++ b/openapi_python_client/parser/properties/model_property.py @@ -1,9 +1,14 @@ -from typing import ClassVar, List, Set, Union +from itertools import chain +from typing import ClassVar, Dict, List, NamedTuple, Optional, Set, Tuple, Union import attr +from ... import schema as oai +from ... import utils +from ..errors import PropertyError from ..reference import Reference from .property import Property +from .schemas import Schemas @attr.s(auto_attribs=True, frozen=True) @@ -11,7 +16,6 @@ class ModelProperty(Property): """ A property which refers to another Schema """ reference: Reference - required_properties: List[Property] optional_properties: List[Property] description: str @@ -42,3 +46,163 @@ def get_imports(self, *, prefix: str) -> Set[str]: } ) return imports + + +def _merge_properties(first: Property, second: Property) -> Union[Property, PropertyError]: + if first.__class__ != second.__class__: + return PropertyError(header="Cannot merge properties", detail="Properties are two different types") + nullable = first.nullable and second.nullable + required = first.required or second.required + first = attr.evolve(first, nullable=nullable, required=required) + second = attr.evolve(second, nullable=nullable, required=required) + if first != second: + return PropertyError(header="Cannot merge properties", detail="Properties has conflicting values") + return first + + +class _PropertyData(NamedTuple): + optional_props: List[Property] + required_props: List[Property] + relative_imports: Set[str] + schemas: Schemas + + +def _process_properties(*, data: oai.Schema, schemas: Schemas, class_name: str) -> Union[_PropertyData, PropertyError]: + from . import property_from_data + + properties: Dict[str, Property] = {} + relative_imports: Set[str] = set() + required_set = set(data.required or []) + + def _check_existing(prop: Property) -> Union[Property, PropertyError]: + nonlocal properties + + existing = properties.get(prop.name) + prop_or_error = _merge_properties(existing, prop) if existing else prop + if isinstance(prop_or_error, PropertyError): + prop_or_error.header = f"Found conflicting properties named {prop.name} when creating {class_name}" + return prop_or_error + properties[prop_or_error.name] = prop_or_error + return prop_or_error + + unprocessed_props = data.properties or {} + for sub_prop in data.allOf or []: + if isinstance(sub_prop, oai.Reference): + source_name = Reference.from_ref(sub_prop.ref).class_name + sub_model = schemas.models.get(source_name) + if sub_model is None: + return PropertyError(f"Reference {sub_prop.ref} not found") + for prop in chain(sub_model.required_properties, sub_model.optional_properties): + prop_or_error = _check_existing(prop) + if isinstance(prop_or_error, PropertyError): + return prop_or_error + else: + unprocessed_props.update(sub_prop.properties or {}) + required_set.update(sub_prop.required or []) + + for key, value in unprocessed_props.items(): + prop_required = key in required_set + prop_or_error, schemas = property_from_data( + name=key, required=prop_required, data=value, schemas=schemas, parent_name=class_name + ) + if isinstance(prop_or_error, Property): + prop_or_error = _check_existing(prop_or_error) + if isinstance(prop_or_error, PropertyError): + return prop_or_error + + properties[prop_or_error.name] = prop_or_error + + required_properties = [] + optional_properties = [] + for prop in properties.values(): + if prop.required and not prop.nullable: + required_properties.append(prop) + else: + optional_properties.append(prop) + relative_imports.update(prop.get_imports(prefix="..")) + + return _PropertyData( + optional_props=optional_properties, + required_props=required_properties, + relative_imports=relative_imports, + schemas=schemas, + ) + + +def _get_additional_properties( + *, schema_additional: Union[None, bool, oai.Reference, oai.Schema], schemas: Schemas, class_name: str +) -> Tuple[Union[bool, Property, PropertyError], Schemas]: + from . import property_from_data + + if schema_additional is None: + return True, schemas + + if isinstance(schema_additional, bool): + return schema_additional, schemas + + if isinstance(schema_additional, oai.Schema) and not any(schema_additional.dict().values()): + # An empty schema + return True, schemas + + additional_properties, schemas = property_from_data( + name="AdditionalProperty", + required=True, # in the sense that if present in the dict will not be None + data=schema_additional, + schemas=schemas, + parent_name=class_name, + ) + return additional_properties, schemas + + +def build_model_property( + *, data: oai.Schema, name: str, schemas: Schemas, required: bool, parent_name: Optional[str] +) -> Tuple[Union[ModelProperty, PropertyError], Schemas]: + """ + A single ModelProperty from its OAI data + + Args: + data: Data of a single Schema + name: Name by which the schema is referenced, such as a model name. + Used to infer the type name if a `title` property is not available. + schemas: Existing Schemas which have already been processed (to check name conflicts) + required: Whether or not this property is required by the parent (affects typing) + parent_name: The name of the property that this property is inside of (affects class naming) + """ + class_name = data.title or name + if parent_name: + class_name = f"{utils.pascal_case(parent_name)}{utils.pascal_case(class_name)}" + ref = Reference.from_ref(class_name) + + property_data = _process_properties(data=data, schemas=schemas, class_name=class_name) + if isinstance(property_data, PropertyError): + return property_data, schemas + schemas = property_data.schemas + + additional_properties, schemas = _get_additional_properties( + schema_additional=data.additionalProperties, schemas=schemas, class_name=class_name + ) + if isinstance(additional_properties, Property): + property_data.relative_imports.update(additional_properties.get_imports(prefix="..")) + elif isinstance(additional_properties, PropertyError): + return additional_properties, schemas + + prop = ModelProperty( + reference=ref, + required_properties=property_data.required_props, + optional_properties=property_data.optional_props, + relative_imports=property_data.relative_imports, + description=data.description or "", + default=None, + nullable=data.nullable, + required=required, + name=name, + additional_properties=additional_properties, + ) + if prop.reference.class_name in schemas.models: + error = PropertyError( + data=data, detail=f'Attempted to generate duplicate models with name "{prop.reference.class_name}"' + ) + return error, schemas + + schemas = attr.evolve(schemas, models={**schemas.models, prop.reference.class_name: prop}) + return prop, schemas diff --git a/openapi_python_client/parser/properties/schemas.py b/openapi_python_client/parser/properties/schemas.py index 338938673..c30f6a059 100644 --- a/openapi_python_client/parser/properties/schemas.py +++ b/openapi_python_client/parser/properties/schemas.py @@ -1,12 +1,17 @@ __all__ = ["Schemas"] -from typing import Dict, List +from typing import TYPE_CHECKING, Dict, List import attr from ..errors import ParseError -from .enum_property import EnumProperty -from .model_property import ModelProperty + +if TYPE_CHECKING: # pragma: no cover + from .enum_property import EnumProperty + from .model_property import ModelProperty +else: + EnumProperty = "EnumProperty" + ModelProperty = "ModelProperty" @attr.s(auto_attribs=True, frozen=True) diff --git a/tests/test_parser/test_properties/test_init.py b/tests/test_parser/test_properties/test_init.py index 024a6e15e..69a1289d1 100644 --- a/tests/test_parser/test_properties/test_init.py +++ b/tests/test_parser/test_properties/test_init.py @@ -2,15 +2,7 @@ import openapi_python_client.schema as oai from openapi_python_client.parser.errors import PropertyError, ValidationError -from openapi_python_client.parser.properties import ( - BooleanProperty, - DateTimeProperty, - FloatProperty, - IntProperty, - ModelProperty, - StringProperty, -) -from openapi_python_client.parser.reference import Reference +from openapi_python_client.parser.properties import BooleanProperty, FloatProperty, IntProperty MODULE_NAME = "openapi_python_client.parser.properties" @@ -1102,139 +1094,6 @@ def test_build_enums(mocker): build_model_property.assert_not_called() -@pytest.mark.parametrize( - "additional_properties_schema, expected_additional_properties", - [ - (True, True), - (oai.Schema.construct(), True), - (None, True), - (False, False), - ( - oai.Schema.construct(type="string"), - StringProperty(name="AdditionalProperty", required=True, nullable=False, default=None), - ), - ], -) -def test_build_model_property(additional_properties_schema, expected_additional_properties): - from openapi_python_client.parser.properties import Schemas, build_model_property - - data = oai.Schema.construct( - required=["req"], - title="MyModel", - properties={ - "req": oai.Schema.construct(type="string"), - "opt": oai.Schema(type="string", format="date-time"), - }, - description="A class called MyModel", - nullable=False, - additionalProperties=additional_properties_schema, - ) - schemas = Schemas(models={"OtherModel": None}) - - model, new_schemas = build_model_property( - data=data, - name="prop", - schemas=schemas, - required=True, - parent_name="parent", - ) - - assert new_schemas != schemas - assert new_schemas.models == { - "OtherModel": None, - "ParentMyModel": model, - } - assert model == ModelProperty( - name="prop", - required=True, - nullable=False, - default=None, - reference=Reference(class_name="ParentMyModel", module_name="parent_my_model"), - required_properties=[StringProperty(name="req", required=True, nullable=False, default=None)], - optional_properties=[DateTimeProperty(name="opt", required=False, nullable=False, default=None)], - description=data.description, - relative_imports={ - "from dateutil.parser import isoparse", - "from typing import cast", - "import datetime", - "from ..types import UNSET, Unset", - "from typing import Union", - }, - additional_properties=expected_additional_properties, - ) - - -def test_build_model_property_conflict(): - from openapi_python_client.parser.properties import Schemas, build_model_property - - data = oai.Schema.construct( - required=["req"], - properties={ - "req": oai.Schema.construct(type="string"), - "opt": oai.Schema(type="string", format="date-time"), - }, - nullable=False, - ) - schemas = Schemas(models={"OtherModel": None}) - - err, new_schemas = build_model_property( - data=data, - name="OtherModel", - schemas=schemas, - required=True, - parent_name=None, - ) - - assert new_schemas == schemas - assert err == PropertyError(detail='Attempted to generate duplicate models with name "OtherModel"', data=data) - - -def test_build_model_property_bad_prop(): - from openapi_python_client.parser.properties import Schemas, build_model_property - - data = oai.Schema( - properties={ - "bad": oai.Schema(type="not_real"), - }, - ) - schemas = Schemas(models={"OtherModel": None}) - - err, new_schemas = build_model_property( - data=data, - name="prop", - schemas=schemas, - required=True, - parent_name=None, - ) - - assert new_schemas == schemas - assert err == PropertyError(detail="unknown type not_real", data=oai.Schema(type="not_real")) - - -def test_build_model_property_bad_additional_props(): - from openapi_python_client.parser.properties import Schemas, build_model_property - - additional_properties = oai.Schema( - type="object", - properties={ - "bad": oai.Schema(type="not_real"), - }, - ) - data = oai.Schema(additionalProperties=additional_properties) - schemas = Schemas(models={"OtherModel": None}) - - err, new_schemas = build_model_property( - data=data, - name="prop", - schemas=schemas, - required=True, - parent_name=None, - ) - - assert new_schemas == schemas - assert err == PropertyError(detail="unknown type not_real", data=oai.Schema(type="not_real")) - - def test_build_enum_property_conflict(mocker): from openapi_python_client.parser.properties import Schemas, build_enum_property diff --git a/tests/test_parser/test_properties/test_model_property.py b/tests/test_parser/test_properties/test_model_property.py index def1734e7..9a856c190 100644 --- a/tests/test_parser/test_properties/test_model_property.py +++ b/tests/test_parser/test_properties/test_model_property.py @@ -1,5 +1,12 @@ +from typing import Callable + import pytest +import openapi_python_client.schema as oai +from openapi_python_client.parser.errors import PropertyError +from openapi_python_client.parser.properties import DateTimeProperty, ModelProperty, StringProperty +from openapi_python_client.parser.reference import Reference + @pytest.mark.parametrize( "no_optional,nullable,required,json,expected", @@ -58,3 +65,288 @@ def test_get_imports(): "from typing import Dict", "from typing import cast", } + + +class TestBuildModelProperty: + @pytest.mark.parametrize( + "additional_properties_schema, expected_additional_properties", + [ + (True, True), + (oai.Schema.construct(), True), + (None, True), + (False, False), + ( + oai.Schema.construct(type="string"), + StringProperty(name="AdditionalProperty", required=True, nullable=False, default=None), + ), + ], + ) + def test_additional_schemas(self, additional_properties_schema, expected_additional_properties): + from openapi_python_client.parser.properties import Schemas, build_model_property + + data = oai.Schema.construct( + additionalProperties=additional_properties_schema, + ) + + model, _ = build_model_property( + data=data, + name="prop", + schemas=Schemas(), + required=True, + parent_name="parent", + ) + + assert model.additional_properties == expected_additional_properties + + def test_happy_path(self): + from openapi_python_client.parser.properties import Schemas, build_model_property + + data = oai.Schema.construct( + required=["req"], + title="MyModel", + properties={ + "req": oai.Schema.construct(type="string"), + "opt": oai.Schema(type="string", format="date-time"), + }, + description="A class called MyModel", + nullable=False, + ) + schemas = Schemas(models={"OtherModel": None}) + + model, new_schemas = build_model_property( + data=data, + name="prop", + schemas=schemas, + required=True, + parent_name="parent", + ) + + assert new_schemas != schemas + assert new_schemas.models == { + "OtherModel": None, + "ParentMyModel": model, + } + assert model == ModelProperty( + name="prop", + required=True, + nullable=False, + default=None, + reference=Reference(class_name="ParentMyModel", module_name="parent_my_model"), + required_properties=[StringProperty(name="req", required=True, nullable=False, default=None)], + optional_properties=[DateTimeProperty(name="opt", required=False, nullable=False, default=None)], + description=data.description, + relative_imports={ + "from dateutil.parser import isoparse", + "from typing import cast", + "import datetime", + "from ..types import UNSET, Unset", + "from typing import Union", + }, + additional_properties=True, + ) + + def test_model_name_conflict(self): + from openapi_python_client.parser.properties import Schemas, build_model_property + + data = oai.Schema.construct() + schemas = Schemas(models={"OtherModel": None}) + + err, new_schemas = build_model_property( + data=data, + name="OtherModel", + schemas=schemas, + required=True, + parent_name=None, + ) + + assert new_schemas == schemas + assert err == PropertyError(detail='Attempted to generate duplicate models with name "OtherModel"', data=data) + + def test_bad_props_return_error(self): + from openapi_python_client.parser.properties import Schemas, build_model_property + + data = oai.Schema( + properties={ + "bad": oai.Schema(type="not_real"), + }, + ) + schemas = Schemas() + + err, new_schemas = build_model_property( + data=data, + name="prop", + schemas=schemas, + required=True, + parent_name=None, + ) + + assert new_schemas == schemas + assert err == PropertyError(detail="unknown type not_real", data=oai.Schema(type="not_real")) + + def test_bad_additional_props_return_error(self): + from openapi_python_client.parser.properties import Schemas, build_model_property + + additional_properties = oai.Schema( + type="object", + properties={ + "bad": oai.Schema(type="not_real"), + }, + ) + data = oai.Schema(additionalProperties=additional_properties) + schemas = Schemas() + + err, new_schemas = build_model_property( + data=data, + name="prop", + schemas=schemas, + required=True, + parent_name=None, + ) + + assert new_schemas == schemas + assert err == PropertyError(detail="unknown type not_real", data=oai.Schema(type="not_real")) + + +@pytest.fixture +def model_property() -> Callable[..., ModelProperty]: + from openapi_python_client.parser.reference import Reference + + def _factory(**kwargs): + kwargs = { + "name": "", + "description": "", + "required": True, + "nullable": True, + "default": None, + "reference": Reference(class_name="", module_name=""), + "required_properties": [], + "optional_properties": [], + "relative_imports": set(), + "additional_properties": False, + **kwargs, + } + return ModelProperty(**kwargs) + + return _factory + + +def string_property(**kwargs) -> StringProperty: + kwargs = { + "name": "", + "required": True, + "nullable": True, + "default": None, + **kwargs, + } + return StringProperty(**kwargs) + + +class TestProcessProperties: + def test_conflicting_properties_different_types(self, model_property): + from openapi_python_client.parser.properties import Schemas + from openapi_python_client.parser.properties.model_property import _process_properties + + data = oai.Schema.construct(allOf=[oai.Reference.construct(ref="First"), oai.Reference.construct(ref="Second")]) + schemas = Schemas( + models={ + "First": model_property( + optional_properties=[StringProperty(name="prop", required=True, nullable=True, default=None)] + ), + "Second": model_property( + optional_properties=[DateTimeProperty(name="prop", required=True, nullable=True, default=None)] + ), + } + ) + + result = _process_properties(data=data, schemas=schemas, class_name="") + + assert isinstance(result, PropertyError) + + def test_conflicting_properties_same_types(self, model_property): + from openapi_python_client.parser.properties import Schemas + from openapi_python_client.parser.properties.model_property import _process_properties + + data = oai.Schema.construct(allOf=[oai.Reference.construct(ref="First"), oai.Reference.construct(ref="Second")]) + schemas = Schemas( + models={ + "First": model_property(optional_properties=[string_property(default="abc")]), + "Second": model_property(optional_properties=[string_property()]), + } + ) + + result = _process_properties(data=data, schemas=schemas, class_name="") + + assert isinstance(result, PropertyError) + + def test_duplicate_properties(self, model_property): + from openapi_python_client.parser.properties import Schemas + from openapi_python_client.parser.properties.model_property import _process_properties + + data = oai.Schema.construct(allOf=[oai.Reference.construct(ref="First"), oai.Reference.construct(ref="Second")]) + prop = string_property() + schemas = Schemas( + models={ + "First": model_property(optional_properties=[prop]), + "Second": model_property(optional_properties=[prop]), + } + ) + + result = _process_properties(data=data, schemas=schemas, class_name="") + + assert result.optional_props == [prop], "There should only be one copy of duplicate properties" + + @pytest.mark.parametrize("first_nullable", [True, False]) + @pytest.mark.parametrize("second_nullable", [True, False]) + @pytest.mark.parametrize("first_required", [True, False]) + @pytest.mark.parametrize("second_required", [True, False]) + def test_mixed_requirements(self, model_property, first_nullable, second_nullable, first_required, second_required): + from openapi_python_client.parser.properties import Schemas + from openapi_python_client.parser.properties.model_property import _process_properties + + data = oai.Schema.construct(allOf=[oai.Reference.construct(ref="First"), oai.Reference.construct(ref="Second")]) + schemas = Schemas( + models={ + "First": model_property( + optional_properties=[string_property(required=first_required, nullable=first_nullable)] + ), + "Second": model_property( + optional_properties=[string_property(required=second_required, nullable=second_nullable)] + ), + } + ) + + result = _process_properties(data=data, schemas=schemas, class_name="") + + nullable = first_nullable and second_nullable + required = first_required or second_required + expected_prop = string_property( + nullable=nullable, + required=required, + ) + + if nullable or not required: + assert result.optional_props == [expected_prop] + else: + assert result.required_props == [expected_prop] + + def test_direct_properties_non_ref(self): + from openapi_python_client.parser.properties import Schemas + from openapi_python_client.parser.properties.model_property import _process_properties + + data = oai.Schema.construct( + allOf=[ + oai.Schema.construct( + required=["first"], + properties={ + "first": oai.Schema.construct(type="string"), + "second": oai.Schema.construct(type="string"), + }, + ) + ] + ) + schemas = Schemas() + + result = _process_properties(data=data, schemas=schemas, class_name="") + + assert result.optional_props == [string_property(name="second", required=False, nullable=False)] + assert result.required_props == [string_property(name="first", required=True, nullable=False)]