diff --git a/CHANGELOG.md b/CHANGELOG.md index be5608af5..46e8ab3ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.7.3 - Unreleased +### Fixes +- Spacing and extra returns for Union types of `additionalProperties` (#266 & #268). Thanks @joshzana & @packyg! + ## 0.7.2 - 2020-12-08 ### Fixes - A bug in handling optional properties that are themselves models (introduced in 0.7.1) (#262). Thanks @packyg! diff --git a/end_to_end_tests/golden-record-custom/custom_e2e/api/tests/defaults_tests_defaults_post.py b/end_to_end_tests/golden-record-custom/custom_e2e/api/tests/defaults_tests_defaults_post.py index fa6e9e47c..574d5018b 100644 --- a/end_to_end_tests/golden-record-custom/custom_e2e/api/tests/defaults_tests_defaults_post.py +++ b/end_to_end_tests/golden-record-custom/custom_e2e/api/tests/defaults_tests_defaults_post.py @@ -71,21 +71,20 @@ def httpx_request( json_union_prop: Union[Unset, float, str] if isinstance(union_prop, Unset): json_union_prop = UNSET - elif isinstance(union_prop, float): - json_union_prop = union_prop else: json_union_prop = union_prop json_union_prop_with_ref: Union[Unset, float, AnEnum] if isinstance(union_prop_with_ref, Unset): json_union_prop_with_ref = UNSET - elif isinstance(union_prop_with_ref, float): - json_union_prop_with_ref = union_prop_with_ref - else: + elif isinstance(union_prop_with_ref, AnEnum): json_union_prop_with_ref = UNSET if not isinstance(union_prop_with_ref, Unset): json_union_prop_with_ref = union_prop_with_ref + else: + json_union_prop_with_ref = union_prop_with_ref + json_enum_prop: Union[Unset, AnEnum] = UNSET if not isinstance(enum_prop, Unset): json_enum_prop = enum_prop diff --git a/end_to_end_tests/golden-record-custom/custom_e2e/models/__init__.py b/end_to_end_tests/golden-record-custom/custom_e2e/models/__init__.py index 475172136..d3ca924b3 100644 --- a/end_to_end_tests/golden-record-custom/custom_e2e/models/__init__.py +++ b/end_to_end_tests/golden-record-custom/custom_e2e/models/__init__.py @@ -12,6 +12,8 @@ ModelWithAdditionalPropertiesInlinedAdditionalProperty, ) from .model_with_additional_properties_refed import ModelWithAdditionalPropertiesRefed +from .model_with_any_json_properties import ModelWithAnyJsonProperties +from .model_with_any_json_properties_additional_property import ModelWithAnyJsonPropertiesAdditionalProperty from .model_with_primitive_additional_properties import ModelWithPrimitiveAdditionalProperties from .model_with_primitive_additional_properties_a_date_holder import ModelWithPrimitiveAdditionalPropertiesADateHolder from .model_with_union_property import ModelWithUnionProperty diff --git a/end_to_end_tests/golden-record-custom/custom_e2e/models/model_with_any_json_properties.py b/end_to_end_tests/golden-record-custom/custom_e2e/models/model_with_any_json_properties.py new file mode 100644 index 000000000..62481f913 --- /dev/null +++ b/end_to_end_tests/golden-record-custom/custom_e2e/models/model_with_any_json_properties.py @@ -0,0 +1,88 @@ +from typing import Any, Dict, List, Union, cast + +import attr + +from ..models.model_with_any_json_properties_additional_property import ModelWithAnyJsonPropertiesAdditionalProperty +from ..types import Unset + + +@attr.s(auto_attribs=True) +class ModelWithAnyJsonProperties: + """ """ + + additional_properties: Dict[ + str, Union[ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool] + ] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + + field_dict: Dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + if isinstance(prop, ModelWithAnyJsonPropertiesAdditionalProperty): + field_dict[prop_name] = prop.to_dict() + + elif isinstance(prop, list): + field_dict[prop_name] = prop + + else: + field_dict[prop_name] = prop + + field_dict.update({}) + + return field_dict + + @staticmethod + def from_dict(src_dict: Dict[str, Any]) -> "ModelWithAnyJsonProperties": + d = src_dict.copy() + model_with_any_json_properties = ModelWithAnyJsonProperties() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + + def _parse_additional_property( + data: Any, + ) -> Union[ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool]: + data = None if isinstance(data, Unset) else data + additional_property: Union[ + ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool + ] + try: + additional_property = ModelWithAnyJsonPropertiesAdditionalProperty.from_dict(data) + + return additional_property + except: # noqa: E722 + pass + try: + additional_property = cast(List[str], data) + + return additional_property + except: # noqa: E722 + pass + return cast(Union[ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool], data) + + additional_property = _parse_additional_property(prop_dict) + + additional_properties[prop_name] = additional_property + + model_with_any_json_properties.additional_properties = additional_properties + return model_with_any_json_properties + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__( + self, key: str + ) -> Union[ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool]: + return self.additional_properties[key] + + def __setitem__( + self, key: str, value: Union[ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool] + ) -> 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-custom/custom_e2e/models/model_with_any_json_properties_additional_property.py b/end_to_end_tests/golden-record-custom/custom_e2e/models/model_with_any_json_properties_additional_property.py new file mode 100644 index 000000000..c823a73ed --- /dev/null +++ b/end_to_end_tests/golden-record-custom/custom_e2e/models/model_with_any_json_properties_additional_property.py @@ -0,0 +1,42 @@ +from typing import Any, Dict, List + +import attr + + +@attr.s(auto_attribs=True) +class ModelWithAnyJsonPropertiesAdditionalProperty: + """ """ + + additional_properties: Dict[str, str] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + + return field_dict + + @staticmethod + def from_dict(src_dict: Dict[str, Any]) -> "ModelWithAnyJsonPropertiesAdditionalProperty": + d = src_dict.copy() + model_with_any_json_properties_additional_property = ModelWithAnyJsonPropertiesAdditionalProperty() + + 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]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> 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/api/tests/defaults_tests_defaults_post.py b/end_to_end_tests/golden-record/my_test_api_client/api/tests/defaults_tests_defaults_post.py index e4e7a7c73..9242cddaa 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/api/tests/defaults_tests_defaults_post.py +++ b/end_to_end_tests/golden-record/my_test_api_client/api/tests/defaults_tests_defaults_post.py @@ -47,21 +47,20 @@ def _get_kwargs( json_union_prop: Union[Unset, float, str] if isinstance(union_prop, Unset): json_union_prop = UNSET - elif isinstance(union_prop, float): - json_union_prop = union_prop else: json_union_prop = union_prop json_union_prop_with_ref: Union[Unset, float, AnEnum] if isinstance(union_prop_with_ref, Unset): json_union_prop_with_ref = UNSET - elif isinstance(union_prop_with_ref, float): - json_union_prop_with_ref = union_prop_with_ref - else: + elif isinstance(union_prop_with_ref, AnEnum): json_union_prop_with_ref = UNSET if not isinstance(union_prop_with_ref, Unset): json_union_prop_with_ref = union_prop_with_ref + else: + json_union_prop_with_ref = union_prop_with_ref + json_enum_prop: Union[Unset, AnEnum] = UNSET if not isinstance(enum_prop, Unset): json_enum_prop = enum_prop 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 475172136..d3ca924b3 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 @@ -12,6 +12,8 @@ ModelWithAdditionalPropertiesInlinedAdditionalProperty, ) from .model_with_additional_properties_refed import ModelWithAdditionalPropertiesRefed +from .model_with_any_json_properties import ModelWithAnyJsonProperties +from .model_with_any_json_properties_additional_property import ModelWithAnyJsonPropertiesAdditionalProperty from .model_with_primitive_additional_properties import ModelWithPrimitiveAdditionalProperties from .model_with_primitive_additional_properties_a_date_holder import ModelWithPrimitiveAdditionalPropertiesADateHolder from .model_with_union_property import ModelWithUnionProperty 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 new file mode 100644 index 000000000..62481f913 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties.py @@ -0,0 +1,88 @@ +from typing import Any, Dict, List, Union, cast + +import attr + +from ..models.model_with_any_json_properties_additional_property import ModelWithAnyJsonPropertiesAdditionalProperty +from ..types import Unset + + +@attr.s(auto_attribs=True) +class ModelWithAnyJsonProperties: + """ """ + + additional_properties: Dict[ + str, Union[ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool] + ] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + + field_dict: Dict[str, Any] = {} + for prop_name, prop in self.additional_properties.items(): + if isinstance(prop, ModelWithAnyJsonPropertiesAdditionalProperty): + field_dict[prop_name] = prop.to_dict() + + elif isinstance(prop, list): + field_dict[prop_name] = prop + + else: + field_dict[prop_name] = prop + + field_dict.update({}) + + return field_dict + + @staticmethod + def from_dict(src_dict: Dict[str, Any]) -> "ModelWithAnyJsonProperties": + d = src_dict.copy() + model_with_any_json_properties = ModelWithAnyJsonProperties() + + additional_properties = {} + for prop_name, prop_dict in d.items(): + + def _parse_additional_property( + data: Any, + ) -> Union[ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool]: + data = None if isinstance(data, Unset) else data + additional_property: Union[ + ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool + ] + try: + additional_property = ModelWithAnyJsonPropertiesAdditionalProperty.from_dict(data) + + return additional_property + except: # noqa: E722 + pass + try: + additional_property = cast(List[str], data) + + return additional_property + except: # noqa: E722 + pass + return cast(Union[ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool], data) + + additional_property = _parse_additional_property(prop_dict) + + additional_properties[prop_name] = additional_property + + model_with_any_json_properties.additional_properties = additional_properties + return model_with_any_json_properties + + @property + def additional_keys(self) -> List[str]: + return list(self.additional_properties.keys()) + + def __getitem__( + self, key: str + ) -> Union[ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool]: + return self.additional_properties[key] + + def __setitem__( + self, key: str, value: Union[ModelWithAnyJsonPropertiesAdditionalProperty, List[str], str, float, int, bool] + ) -> 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_any_json_properties_additional_property.py b/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties_additional_property.py new file mode 100644 index 000000000..c823a73ed --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/model_with_any_json_properties_additional_property.py @@ -0,0 +1,42 @@ +from typing import Any, Dict, List + +import attr + + +@attr.s(auto_attribs=True) +class ModelWithAnyJsonPropertiesAdditionalProperty: + """ """ + + additional_properties: Dict[str, str] = attr.ib(init=False, factory=dict) + + def to_dict(self) -> Dict[str, Any]: + + field_dict: Dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + + return field_dict + + @staticmethod + def from_dict(src_dict: Dict[str, Any]) -> "ModelWithAnyJsonPropertiesAdditionalProperty": + d = src_dict.copy() + model_with_any_json_properties_additional_property = ModelWithAnyJsonPropertiesAdditionalProperty() + + 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]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> str: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: str) -> 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 1cbfd9597..2a0c1a644 100644 --- a/end_to_end_tests/openapi.json +++ b/end_to_end_tests/openapi.json @@ -806,6 +806,38 @@ "additionalProperties": { "$ref": "#/components/schemas/AnEnum" } + }, + "ModelWithAnyJsonProperties": { + "title": "ModelWithAnyJsonProperties", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "boolean" + } + ] + } } } } diff --git a/openapi_python_client/__init__.py b/openapi_python_client/__init__.py index b1adb07b6..152bce66e 100644 --- a/openapi_python_client/__init__.py +++ b/openapi_python_client/__init__.py @@ -25,8 +25,15 @@ __version__ = version(__package__) +TEMPLATE_FILTERS = { + "snakecase": utils.snake_case, + "kebabcase": utils.kebab_case, + "pascalcase": utils.pascal_case, + "any": any, +} + + class Project: - TEMPLATE_FILTERS = {"snakecase": utils.snake_case, "kebabcase": utils.kebab_case, "pascalcase": utils.pascal_case} project_name_override: Optional[str] = None package_name_override: Optional[str] = None package_version_override: Optional[str] = None @@ -57,7 +64,7 @@ def __init__(self, *, openapi: GeneratorData, custom_template_path: Optional[Pat ) self.version: str = self.package_version_override or openapi.version - self.env.filters.update(self.TEMPLATE_FILTERS) + self.env.filters.update(TEMPLATE_FILTERS) def build(self) -> Sequence[GeneratorError]: """ Create the project from templates """ diff --git a/openapi_python_client/parser/properties/__init__.py b/openapi_python_client/parser/properties/__init__.py index 98fc4fc14..427276692 100644 --- a/openapi_python_client/parser/properties/__init__.py +++ b/openapi_python_client/parser/properties/__init__.py @@ -1,5 +1,5 @@ from itertools import chain -from typing import Any, ClassVar, Dict, Generic, Iterable, List, Optional, Set, Tuple, TypeVar, Union +from typing import Any, ClassVar, Dict, Generic, Iterable, Iterator, List, Optional, Set, Tuple, TypeVar, Union import attr @@ -135,6 +135,10 @@ def get_type_string(self, no_optional: bool = False) -> str: type_string = f"Union[Unset, {type_string}]" return type_string + def get_instance_type_string(self) -> str: + """Get a string representation of runtime type that should be used for `isinstance` checks""" + return "list" + def get_imports(self, *, prefix: str) -> Set[str]: """ Get a set of import strings that should be included when this property is used somewhere @@ -155,6 +159,13 @@ class UnionProperty(Property): inner_properties: List[Property] template: ClassVar[str] = "union_property.pyi" + has_properties_without_templates: bool = attr.ib(init=False) + + def __attrs_post_init__(self) -> None: + super().__attrs_post_init__() + object.__setattr__( + self, "has_properties_without_templates", any(prop.template is None for prop in self.inner_properties) + ) def get_type_string(self, no_optional: bool = False) -> str: """ Get a string representation of type that should be used when declaring this property """ @@ -180,9 +191,12 @@ def get_imports(self, *, prefix: str) -> Set[str]: imports = super().get_imports(prefix=prefix) for inner_prop in self.inner_properties: imports.update(inner_prop.get_imports(prefix=prefix)) - imports.add("from typing import Union") + imports.add("from typing import cast, Union") return imports + def inner_properties_with_template(self) -> Iterator[Property]: + return (prop for prop in self.inner_properties if prop.template) + def _string_based_property( name: str, required: bool, data: oai.Schema diff --git a/openapi_python_client/parser/properties/property.py b/openapi_python_client/parser/properties/property.py index c7649200f..0b7047551 100644 --- a/openapi_python_client/parser/properties/property.py +++ b/openapi_python_client/parser/properties/property.py @@ -48,6 +48,10 @@ def get_type_string(self, no_optional: bool = False) -> str: type_string = f"Union[Unset, {type_string}]" return type_string + def get_instance_type_string(self) -> str: + """Get a string representation of runtime type that should be used for `isinstance` checks""" + return self.get_type_string(no_optional=True) + # noinspection PyUnusedLocal def get_imports(self, *, prefix: str) -> Set[str]: """ diff --git a/openapi_python_client/templates/model.pyi b/openapi_python_client/templates/model.pyi index c66ba46a3..0c81a32f9 100644 --- a/openapi_python_client/templates/model.pyi +++ b/openapi_python_client/templates/model.pyi @@ -51,7 +51,7 @@ class {{ model.reference.class_name }}: {% if model.additional_properties.template %} {% from "property_templates/" + model.additional_properties.template import transform %} for prop_name, prop in self.additional_properties.items(): - {{ transform(model.additional_properties, "prop", "field_dict[prop_name]") | indent(4) }} + {{ transform(model.additional_properties, "prop", "field_dict[prop_name]") | indent(12) }} {% else %} field_dict.update(self.additional_properties) {% endif %} diff --git a/openapi_python_client/templates/property_templates/union_property.pyi b/openapi_python_client/templates/property_templates/union_property.pyi index 114838b1c..4c632c60a 100644 --- a/openapi_python_client/templates/property_templates/union_property.pyi +++ b/openapi_python_client/templates/property_templates/union_property.pyi @@ -2,22 +2,24 @@ def _parse_{{ property.python_name }}(data: Any) -> {{ property.get_type_string() }}: data = None if isinstance(data, Unset) else data {{ property.python_name }}: {{ property.get_type_string() }} - {% for inner_property in property.inner_properties %} - {% if inner_property.template and not loop.last %} + {% for inner_property in property.inner_properties_with_template() %} + {% if not loop.last or property.has_properties_without_templates %} try: {% from "property_templates/" + inner_property.template import construct %} {{ construct(inner_property, "data", initial_value="UNSET") | indent(8) }} return {{ property.python_name }} except: # noqa: E722 pass - {% elif inner_property.template and loop.last %}{# Don't do try/except for the last one #} + {% else %}{# Don't do try/except for the last one #} {% from "property_templates/" + inner_property.template import construct %} {{ construct(inner_property, "data", initial_value="UNSET") | indent(4) }} return {{ property.python_name }} - {% else %} - return {{ source }} {% endif %} {% endfor %} + {% if property.has_properties_without_templates %} + {# Doesn't really matter what we cast it to as this type will be erased, so cast to one of the options #} + return cast({{ property.get_type_string() }}, data) + {% endif %} {{ property.python_name }} = _parse_{{ property.python_name }}({{ source }}) {% endmacro %} @@ -30,26 +32,29 @@ if isinstance({{ source }}, Unset): {{ destination }} = UNSET {% endif %} {% if property.nullable %} -{% if property.required %} + {% if property.required %} if {{ source }} is None: -{% else %}{# There's an if UNSET statement before this #} + {% else %}{# There's an if UNSET statement before this #} elif {{ source }} is None: -{% endif %} + {% endif %} {{ destination }}{% if declare_type %}: {{ property.get_type_string() }}{% endif %} = None {% endif %} -{% for inner_property in property.inner_properties %} +{% for inner_property in property.inner_properties_with_template() %} {% if loop.first and property.required and not property.nullable %}{# No if UNSET or if None statement before this #} -if isinstance({{ source }}, {{ inner_property.get_type_string(no_optional=True) }}): - {% elif not loop.last %} -elif isinstance({{ source }}, {{ inner_property.get_type_string(no_optional=True) }}): +if isinstance({{ source }}, {{ inner_property.get_instance_type_string() }}): + {% elif not loop.last or property.has_properties_without_templates %} +elif isinstance({{ source }}, {{ inner_property.get_instance_type_string() }}): {% else %} else: {% endif %} -{% if inner_property.template %} -{% from "property_templates/" + inner_property.template import transform %} + {% from "property_templates/" + inner_property.template import transform %} {{ transform(inner_property, source, destination, declare_type=False) | indent(4) }} -{% else %} +{% endfor %} +{% if property.has_properties_without_templates and (property.inner_properties_with_template() | any or not property.required)%} +else: {{ destination }} = {{ source }} +{% elif property.has_properties_without_templates %} +{{ destination }} = {{ source }} {% endif %} -{% endfor %} + {% endmacro %} diff --git a/tests/test_parser/test_properties/test_init.py b/tests/test_parser/test_properties/test_init.py index b2dd1bb2f..a7ea05881 100644 --- a/tests/test_parser/test_properties/test_init.py +++ b/tests/test_parser/test_properties/test_init.py @@ -292,7 +292,7 @@ def test_get_type_string(self, mocker): assert p.get_type_string() == base_type_string_with_unset assert p.get_type_string(no_optional=True) == base_type_string - def test_get_type_imports(self, mocker): + def test_get_imports(self, mocker): from openapi_python_client.parser.properties import UnionProperty inner_property_1 = mocker.MagicMock() @@ -313,7 +313,7 @@ def test_get_type_imports(self, mocker): assert p.get_imports(prefix=prefix) == { inner_import_1, inner_import_2, - "from typing import Union", + "from typing import cast, Union", } p = UnionProperty( @@ -327,6 +327,7 @@ def test_get_type_imports(self, mocker): inner_import_1, inner_import_2, "from typing import Union", + "from typing import cast, Union", "from ...types import UNSET, Unset", } @@ -341,6 +342,7 @@ def test_get_type_imports(self, mocker): inner_import_1, inner_import_2, "from typing import Union", + "from typing import cast, Union", "from typing import Optional", "from ...types import UNSET, Unset", }