From 70e6b7dabfd4266538fcd74789e1e3113639c24c Mon Sep 17 00:00:00 2001 From: Dylan Anthony Date: Sun, 24 May 2020 22:31:48 -0400 Subject: [PATCH] Support arbitrary list properties Closes #46 --- .gitignore | 3 + CHANGELOG.md | 14 +- end_to_end_tests/fastapi_app/__init__.py | 8 +- end_to_end_tests/fastapi_app/openapi.json | 51 +--- .../my_test_api_client/api/users.py | 6 +- .../my_test_api_client/async_api/users.py | 6 +- .../my_test_api_client/models/__init__.py | 5 +- .../my_test_api_client/models/a_model.py | 35 +-- .../my_test_api_client/models/abc_response.py | 7 +- ...list_of_enums.py => an_enum_value_item.py} | 2 +- .../body_upload_file_tests_upload_post.py | 5 +- .../models/http_validation_error.py | 11 +- ...s.py => nested_list_of_enums_item_item.py} | 2 +- .../my_test_api_client/models/other_model.py | 25 -- .../models/validation_error.py | 9 +- .../openapi_parser/openapi.py | 60 ++-- .../openapi_parser/properties.py | 192 ++++++++----- .../templates/date_property.pyi | 10 +- .../templates/datetime_property.pyi | 10 +- .../templates/enum_list_property.pyi | 3 - .../templates/enum_property.pyi | 9 + .../templates/list_property.pyi | 17 ++ openapi_python_client/templates/model.pyi | 31 +- .../templates/ref_property.pyi | 10 +- .../templates/reference_list_property.pyi | 3 - pyproject.toml | 6 +- tests/test_openapi_parser/test_openapi.py | 107 ++++--- tests/test_openapi_parser/test_properties.py | 265 ++++++++++-------- 28 files changed, 467 insertions(+), 445 deletions(-) rename end_to_end_tests/golden-master/my_test_api_client/models/{a_list_of_enums.py => an_enum_value_item.py} (72%) rename end_to_end_tests/golden-master/my_test_api_client/models/{statuses.py => nested_list_of_enums_item_item.py} (67%) delete mode 100644 end_to_end_tests/golden-master/my_test_api_client/models/other_model.py delete mode 100644 openapi_python_client/templates/enum_list_property.pyi create mode 100644 openapi_python_client/templates/enum_property.pyi create mode 100644 openapi_python_client/templates/list_property.pyi delete mode 100644 openapi_python_client/templates/reference_list_property.pyi diff --git a/.gitignore b/.gitignore index 2429c4b7f..e1c52fb4c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dmypy.json /coverage.xml /.coverage htmlcov/ + +# Generated end to end test data +my-test-api-client diff --git a/CHANGELOG.md b/CHANGELOG.md index 905db1ba9..9123b3417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 0.4.0 - Unreleased +### Breaking Changes +- Classes generated to be included within lists will now be named like Item. For example, if a property + named "statuses" is an array of enum values, previously the `Enum` class declared would be called "Statuses". Now it + will be called "StatusesItem". If a "title" attribute was used in the OpenAPI document, that should still be respected + and used instead of the generated name. + ### Additions -- Add support for binary format strings (file payloads) -- Add support for multipart/form bodies +- Support for binary format strings (file payloads) +- Support for multipart/form bodies +- Support for any supported property within a list (array), including other lists. + +### Changes +- The way most imports are handled was changed which *should* lead to fewer unused imports in generated files. ## 0.3.0 - 2020-04-25 ### Additions diff --git a/end_to_end_tests/fastapi_app/__init__.py b/end_to_end_tests/fastapi_app/__init__.py index 538284aa5..f984846a9 100644 --- a/end_to_end_tests/fastapi_app/__init__.py +++ b/end_to_end_tests/fastapi_app/__init__.py @@ -41,15 +41,15 @@ class AModel(BaseModel): """ A Model for testing all the ways custom objects can be used """ an_enum_value: AnEnum - a_list_of_enums: List[AnEnum] - a_list_of_strings: List[str] - a_list_of_objects: List[OtherModel] + nested_list_of_enums: List[List[AnEnum]] aCamelDateTime: datetime a_date: date @test_router.get("/", response_model=List[AModel], operation_id="getUserList") -def get_list(statuses: List[AnEnum] = Query(...), some_date: date = Query(...), some_datetime: datetime = Query(...)): +def get_list( + an_enum_value: List[AnEnum] = Query(...), some_date: date = Query(...), some_datetime: datetime = Query(...) +): """ Get a list of things """ return diff --git a/end_to_end_tests/fastapi_app/openapi.json b/end_to_end_tests/fastapi_app/openapi.json index 440974734..94b4bcde1 100644 --- a/end_to_end_tests/fastapi_app/openapi.json +++ b/end_to_end_tests/fastapi_app/openapi.json @@ -37,7 +37,7 @@ { "required": true, "schema": { - "title": "Statuses", + "title": "An Enum Value", "type": "array", "items": { "enum": [ @@ -46,7 +46,7 @@ ] } }, - "name": "statuses", + "name": "an_enum_value", "in": "query" }, { @@ -145,9 +145,7 @@ "title": "AModel", "required": [ "an_enum_value", - "a_list_of_enums", - "a_list_of_strings", - "a_list_of_objects", + "nested_list_of_enums", "aCamelDateTime", "a_date" ], @@ -160,28 +158,17 @@ "SECOND_VALUE" ] }, - "a_list_of_enums": { - "title": "A List Of Enums", + "nested_list_of_enums": { + "title": "Nested List Of Enums", "type": "array", "items": { - "enum": [ - "FIRST_VALUE", - "SECOND_VALUE" - ] - } - }, - "a_list_of_strings": { - "title": "A List Of Strings", - "type": "array", - "items": { - "type": "string" - } - }, - "a_list_of_objects": { - "title": "A List Of Objects", - "type": "array", - "items": { - "$ref": "#/components/schemas/OtherModel" + "type": "array", + "items": { + "enum": [ + "FIRST_VALUE", + "SECOND_VALUE" + ] + } } }, "aCamelDateTime": { @@ -224,20 +211,6 @@ } } }, - "OtherModel": { - "title": "OtherModel", - "required": [ - "a_value" - ], - "type": "object", - "properties": { - "a_value": { - "title": "A Value", - "type": "string" - } - }, - "description": "A different model for calling from TestModel " - }, "ValidationError": { "title": "ValidationError", "required": [ diff --git a/end_to_end_tests/golden-master/my_test_api_client/api/users.py b/end_to_end_tests/golden-master/my_test_api_client/api/users.py index 7a306d33a..7d1505709 100644 --- a/end_to_end_tests/golden-master/my_test_api_client/api/users.py +++ b/end_to_end_tests/golden-master/my_test_api_client/api/users.py @@ -7,13 +7,13 @@ from ..client import AuthenticatedClient, Client from ..errors import ApiResponseError from ..models.a_model import AModel +from ..models.an_enum_value_item import AnEnumValueItem from ..models.body_upload_file_tests_upload_post import BodyUploadFileTestsUploadPost from ..models.http_validation_error import HTTPValidationError -from ..models.statuses import Statuses def get_user_list( - *, client: Client, statuses: List[Statuses], some_date: date, some_datetime: datetime, + *, client: Client, an_enum_value: List[AnEnumValueItem], some_date: date, some_datetime: datetime, ) -> Union[ List[AModel], HTTPValidationError, ]: @@ -21,7 +21,7 @@ def get_user_list( url = "{}/tests/".format(client.base_url) params = { - "statuses": statuses, + "an_enum_value": an_enum_value, "some_date": some_date.isoformat(), "some_datetime": some_datetime.isoformat(), } diff --git a/end_to_end_tests/golden-master/my_test_api_client/async_api/users.py b/end_to_end_tests/golden-master/my_test_api_client/async_api/users.py index e3ebb980b..477ea9b3a 100644 --- a/end_to_end_tests/golden-master/my_test_api_client/async_api/users.py +++ b/end_to_end_tests/golden-master/my_test_api_client/async_api/users.py @@ -7,13 +7,13 @@ from ..client import AuthenticatedClient, Client from ..errors import ApiResponseError from ..models.a_model import AModel +from ..models.an_enum_value_item import AnEnumValueItem from ..models.body_upload_file_tests_upload_post import BodyUploadFileTestsUploadPost from ..models.http_validation_error import HTTPValidationError -from ..models.statuses import Statuses async def get_user_list( - *, client: Client, statuses: List[Statuses], some_date: date, some_datetime: datetime, + *, client: Client, an_enum_value: List[AnEnumValueItem], some_date: date, some_datetime: datetime, ) -> Union[ List[AModel], HTTPValidationError, ]: @@ -21,7 +21,7 @@ async def get_user_list( url = "{}/tests/".format(client.base_url) params = { - "statuses": statuses, + "an_enum_value": an_enum_value, "some_date": some_date.isoformat(), "some_datetime": some_datetime.isoformat(), } diff --git a/end_to_end_tests/golden-master/my_test_api_client/models/__init__.py b/end_to_end_tests/golden-master/my_test_api_client/models/__init__.py index 5d9c9f054..29bc17445 100644 --- a/end_to_end_tests/golden-master/my_test_api_client/models/__init__.py +++ b/end_to_end_tests/golden-master/my_test_api_client/models/__init__.py @@ -1,12 +1,11 @@ """ Contains all the data models used in inputs/outputs """ -from .a_list_of_enums import AListOfEnums from .a_model import AModel from .abc_response import ABCResponse from .an_enum_value import AnEnumValue +from .an_enum_value_item import AnEnumValueItem from .body_upload_file_tests_upload_post import BodyUploadFileTestsUploadPost from .http_validation_error import HTTPValidationError -from .other_model import OtherModel -from .statuses import Statuses +from .nested_list_of_enums_item_item import NestedListOfEnumsItemItem from .types import * from .validation_error import ValidationError diff --git a/end_to_end_tests/golden-master/my_test_api_client/models/a_model.py b/end_to_end_tests/golden-master/my_test_api_client/models/a_model.py index eed9ae136..af5fe8914 100644 --- a/end_to_end_tests/golden-master/my_test_api_client/models/a_model.py +++ b/end_to_end_tests/golden-master/my_test_api_client/models/a_model.py @@ -1,13 +1,11 @@ from __future__ import annotations -from dataclasses import astuple, dataclass +from dataclasses import dataclass from datetime import date, datetime -from typing import Any, Dict, List, Optional, cast +from typing import Any, Dict, List, cast -from .a_list_of_enums import AListOfEnums from .an_enum_value import AnEnumValue -from .other_model import OtherModel -from .types import * +from .nested_list_of_enums_item_item import NestedListOfEnumsItemItem @dataclass @@ -15,36 +13,31 @@ class AModel: """ A Model for testing all the ways custom objects can be used """ an_enum_value: AnEnumValue - a_list_of_enums: List[AListOfEnums] - a_list_of_strings: List[str] - a_list_of_objects: List[OtherModel] + nested_list_of_enums: List[List[NestedListOfEnumsItemItem]] a_camel_date_time: datetime a_date: date def to_dict(self) -> Dict[str, Any]: return { "an_enum_value": self.an_enum_value.value, - "a_list_of_enums": self.a_list_of_enums, - "a_list_of_strings": self.a_list_of_strings, - "a_list_of_objects": self.a_list_of_objects, + "nested_list_of_enums": self.nested_list_of_enums, "aCamelDateTime": self.a_camel_date_time.isoformat(), "a_date": self.a_date.isoformat(), } @staticmethod def from_dict(d: Dict[str, Any]) -> AModel: - an_enum_value = AnEnumValue(d["an_enum_value"]) - a_list_of_enums = [] - for a_list_of_enums_item in d.get("a_list_of_enums", []): - a_list_of_enums.append(AListOfEnums(a_list_of_enums_item)) + nested_list_of_enums = [] + for nested_list_of_enums_item_data in d["nested_list_of_enums"]: + nested_list_of_enums_item = [] + for nested_list_of_enums_item_item_data in nested_list_of_enums_item_data: + nested_list_of_enums_item_item = NestedListOfEnumsItemItem(nested_list_of_enums_item_item_data) - a_list_of_strings = d.get("a_list_of_strings", []) + nested_list_of_enums_item.append(nested_list_of_enums_item_item) - a_list_of_objects = [] - for a_list_of_objects_item in d.get("a_list_of_objects", []): - a_list_of_objects.append(OtherModel.from_dict(a_list_of_objects_item)) + nested_list_of_enums.append(nested_list_of_enums_item) a_camel_date_time = datetime.fromisoformat(d["aCamelDateTime"]) @@ -52,9 +45,7 @@ def from_dict(d: Dict[str, Any]) -> AModel: return AModel( an_enum_value=an_enum_value, - a_list_of_enums=a_list_of_enums, - a_list_of_strings=a_list_of_strings, - a_list_of_objects=a_list_of_objects, + nested_list_of_enums=nested_list_of_enums, a_camel_date_time=a_camel_date_time, a_date=a_date, ) diff --git a/end_to_end_tests/golden-master/my_test_api_client/models/abc_response.py b/end_to_end_tests/golden-master/my_test_api_client/models/abc_response.py index 1be8b4938..68dd86670 100644 --- a/end_to_end_tests/golden-master/my_test_api_client/models/abc_response.py +++ b/end_to_end_tests/golden-master/my_test_api_client/models/abc_response.py @@ -1,9 +1,7 @@ from __future__ import annotations -from dataclasses import astuple, dataclass -from typing import Any, Dict, List, Optional, cast - -from .types import * +from dataclasses import dataclass +from typing import Any, Dict @dataclass @@ -19,7 +17,6 @@ def to_dict(self) -> Dict[str, Any]: @staticmethod def from_dict(d: Dict[str, Any]) -> ABCResponse: - success = d["success"] return ABCResponse(success=success,) diff --git a/end_to_end_tests/golden-master/my_test_api_client/models/a_list_of_enums.py b/end_to_end_tests/golden-master/my_test_api_client/models/an_enum_value_item.py similarity index 72% rename from end_to_end_tests/golden-master/my_test_api_client/models/a_list_of_enums.py rename to end_to_end_tests/golden-master/my_test_api_client/models/an_enum_value_item.py index 68b14db20..363e74e54 100644 --- a/end_to_end_tests/golden-master/my_test_api_client/models/a_list_of_enums.py +++ b/end_to_end_tests/golden-master/my_test_api_client/models/an_enum_value_item.py @@ -1,6 +1,6 @@ from enum import Enum -class AListOfEnums(str, Enum): +class AnEnumValueItem(str, Enum): FIRST_VALUE = "FIRST_VALUE" SECOND_VALUE = "SECOND_VALUE" diff --git a/end_to_end_tests/golden-master/my_test_api_client/models/body_upload_file_tests_upload_post.py b/end_to_end_tests/golden-master/my_test_api_client/models/body_upload_file_tests_upload_post.py index e9a7523a3..7595a5ae1 100644 --- a/end_to_end_tests/golden-master/my_test_api_client/models/body_upload_file_tests_upload_post.py +++ b/end_to_end_tests/golden-master/my_test_api_client/models/body_upload_file_tests_upload_post.py @@ -1,9 +1,9 @@ from __future__ import annotations from dataclasses import astuple, dataclass -from typing import Any, Dict, List, Optional, cast +from typing import Any, Dict -from .types import * +from .types import File @dataclass @@ -19,7 +19,6 @@ def to_dict(self) -> Dict[str, Any]: @staticmethod def from_dict(d: Dict[str, Any]) -> BodyUploadFileTestsUploadPost: - some_file = d["some_file"] return BodyUploadFileTestsUploadPost(some_file=some_file,) diff --git a/end_to_end_tests/golden-master/my_test_api_client/models/http_validation_error.py b/end_to_end_tests/golden-master/my_test_api_client/models/http_validation_error.py index 22827235e..cf6c4eadc 100644 --- a/end_to_end_tests/golden-master/my_test_api_client/models/http_validation_error.py +++ b/end_to_end_tests/golden-master/my_test_api_client/models/http_validation_error.py @@ -1,9 +1,8 @@ from __future__ import annotations -from dataclasses import astuple, dataclass +from dataclasses import dataclass from typing import Any, Dict, List, Optional, cast -from .types import * from .validation_error import ValidationError @@ -20,8 +19,10 @@ def to_dict(self) -> Dict[str, Any]: @staticmethod def from_dict(d: Dict[str, Any]) -> HTTPValidationError: - detail = [] - for detail_item in d.get("detail", []): - detail.append(ValidationError.from_dict(detail_item)) + for detail_item_data in d.get("detail") or []: + detail_item = ValidationError.from_dict(detail_item_data) + + detail.append(detail_item) + return HTTPValidationError(detail=detail,) diff --git a/end_to_end_tests/golden-master/my_test_api_client/models/statuses.py b/end_to_end_tests/golden-master/my_test_api_client/models/nested_list_of_enums_item_item.py similarity index 67% rename from end_to_end_tests/golden-master/my_test_api_client/models/statuses.py rename to end_to_end_tests/golden-master/my_test_api_client/models/nested_list_of_enums_item_item.py index fc0440ac1..0aac50aa8 100644 --- a/end_to_end_tests/golden-master/my_test_api_client/models/statuses.py +++ b/end_to_end_tests/golden-master/my_test_api_client/models/nested_list_of_enums_item_item.py @@ -1,6 +1,6 @@ from enum import Enum -class Statuses(str, Enum): +class NestedListOfEnumsItemItem(str, Enum): FIRST_VALUE = "FIRST_VALUE" SECOND_VALUE = "SECOND_VALUE" diff --git a/end_to_end_tests/golden-master/my_test_api_client/models/other_model.py b/end_to_end_tests/golden-master/my_test_api_client/models/other_model.py deleted file mode 100644 index f420af0a2..000000000 --- a/end_to_end_tests/golden-master/my_test_api_client/models/other_model.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from dataclasses import astuple, dataclass -from typing import Any, Dict, List, Optional, cast - -from .types import * - - -@dataclass -class OtherModel: - """ A different model for calling from TestModel """ - - a_value: str - - def to_dict(self) -> Dict[str, Any]: - return { - "a_value": self.a_value, - } - - @staticmethod - def from_dict(d: Dict[str, Any]) -> OtherModel: - - a_value = d["a_value"] - - return OtherModel(a_value=a_value,) diff --git a/end_to_end_tests/golden-master/my_test_api_client/models/validation_error.py b/end_to_end_tests/golden-master/my_test_api_client/models/validation_error.py index e0245b83c..fb958077f 100644 --- a/end_to_end_tests/golden-master/my_test_api_client/models/validation_error.py +++ b/end_to_end_tests/golden-master/my_test_api_client/models/validation_error.py @@ -1,9 +1,7 @@ from __future__ import annotations -from dataclasses import astuple, dataclass -from typing import Any, Dict, List, Optional, cast - -from .types import * +from dataclasses import dataclass +from typing import Any, Dict, List @dataclass @@ -23,8 +21,7 @@ def to_dict(self) -> Dict[str, Any]: @staticmethod def from_dict(d: Dict[str, Any]) -> ValidationError: - - loc = d.get("loc", []) + loc = d["loc"] msg = d["msg"] diff --git a/openapi_python_client/openapi_parser/openapi.py b/openapi_python_client/openapi_parser/openapi.py index 3dcf48bec..191964393 100644 --- a/openapi_python_client/openapi_parser/openapi.py +++ b/openapi_python_client/openapi_parser/openapi.py @@ -2,18 +2,9 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, Generator, Iterable, List, Optional, Set, Union - -from .properties import ( - DateProperty, - DateTimeProperty, - EnumListProperty, - EnumProperty, - Property, - ReferenceListProperty, - RefProperty, - property_from_dict, -) +from typing import Any, Dict, Generator, Iterable, List, Optional, Set + +from .properties import EnumProperty, ListProperty, Property, property_from_dict from .reference import Reference from .responses import ListRefResponse, RefResponse, Response, response_from_dict @@ -111,12 +102,8 @@ def _add_body(self, data: Dict[str, Any]) -> None: self.relative_imports.add(import_string_from_reference(self.form_body_reference, prefix="..models")) if self.multipart_body_reference: self.relative_imports.add(import_string_from_reference(self.multipart_body_reference, prefix="..models")) - if ( - self.json_body is not None - and isinstance(self.json_body, (ReferenceListProperty, EnumListProperty, RefProperty, EnumProperty)) - and self.json_body.reference is not None - ): - self.relative_imports.add(import_string_from_reference(self.json_body.reference, prefix="..models")) + if self.json_body is not None: + self.relative_imports.update(self.json_body.get_imports(prefix="..models")) def _add_responses(self, data: Dict[str, Any]) -> None: for code, response_dict in data["responses"].items(): @@ -130,15 +117,7 @@ def _add_parameters(self, data: Dict[str, Any]) -> None: prop = property_from_dict( name=param_dict["name"], required=param_dict["required"], data=param_dict["schema"] ) - if isinstance(prop, DateProperty): - self.relative_imports.add("from datetime import date") - elif isinstance(prop, DateTimeProperty): - self.relative_imports.add("from datetime import datetime") - elif ( - isinstance(prop, (ReferenceListProperty, EnumListProperty, RefProperty, EnumProperty)) - and prop.reference - ): - self.relative_imports.add(import_string_from_reference(prop.reference, prefix="..models")) + self.relative_imports.update(prop.get_imports(prefix="..models")) if param_dict["in"] == ParameterLocation.QUERY: self.query_parameters.append(prop) @@ -200,14 +179,7 @@ def from_dict(d: Dict[str, Any], /, name: str) -> Schema: required_properties.append(p) else: optional_properties.append(p) - if isinstance(p, DateTimeProperty): - relative_imports.add("from datetime import datetime") - elif isinstance(p, DateProperty): - relative_imports.add("from datetime import date") - elif isinstance(p, (ReferenceListProperty, EnumListProperty, RefProperty, EnumProperty)) and p.reference: - # don't add an import for self-referencing schemas - if p.reference.class_name != ref.class_name: - relative_imports.add(import_string_from_reference(p.reference)) + relative_imports.update(p.get_imports(prefix="")) schema = Schema( reference=ref, @@ -237,19 +209,23 @@ class OpenAPI: version: str schemas: Dict[str, Schema] endpoint_collections_by_tag: Dict[str, EndpointCollection] - enums: Dict[str, Union[EnumProperty, EnumListProperty]] + enums: Dict[str, EnumProperty] @staticmethod - def _check_enums( - schemas: Iterable[Schema], collections: Iterable[EndpointCollection] - ) -> Dict[str, Union[EnumProperty, EnumListProperty]]: + def _check_enums(schemas: Iterable[Schema], collections: Iterable[EndpointCollection]) -> Dict[str, EnumProperty]: """ Create EnumProperties for every enum in any schema or collection. Enums are deduplicated by class name. :raises AssertionError: if two Enums with the same name but different values are detected """ - enums: Dict[str, Union[EnumProperty, EnumListProperty]] = {} + enums: Dict[str, EnumProperty] = {} + + def _unpack_list_property(list_prop: ListProperty[Property]) -> Property: + inner = list_prop.inner_property + if isinstance(inner, ListProperty): + return _unpack_list_property(inner) + return inner def _iterate_properties() -> Generator[Property, None, None]: for schema in schemas: @@ -261,7 +237,9 @@ def _iterate_properties() -> Generator[Property, None, None]: yield from endpoint.query_parameters for prop in _iterate_properties(): - if not isinstance(prop, (EnumProperty, EnumListProperty)): + if isinstance(prop, ListProperty): + prop = _unpack_list_property(prop) + if not isinstance(prop, EnumProperty): continue if prop.reference.class_name in enums: diff --git a/openapi_python_client/openapi_parser/properties.py b/openapi_python_client/openapi_parser/properties.py index 3f55ead12..bfbb7b8f3 100644 --- a/openapi_python_client/openapi_parser/properties.py +++ b/openapi_python_client/openapi_parser/properties.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Any, ClassVar, Dict, List, Optional, Union +from typing import Any, ClassVar, Dict, Generic, List, Optional, Set, TypeVar, Union from openapi_python_client import utils @@ -8,7 +8,12 @@ @dataclass class Property: - """ Describes a single property for a schema """ + """ + Describes a single property for a schema + + Attributes: + constructor_template: Name of the template file (if any) to use when constructing this property from JSON types. + """ name: str required: bool @@ -28,6 +33,17 @@ def get_type_string(self) -> str: return self._type_string return f"Optional[{self._type_string}]" + def get_imports(self, *, prefix: str) -> Set[str]: + """ + Get a set of import strings that should be included when this property is used somewhere + + Args: + prefix: A prefix to put before any relative (local) module names. + """ + if not self.required: + return {"from typing import Optional"} + return set() + def to_string(self) -> str: """ How this should be declared in a dataclass """ if self.default: @@ -46,13 +62,6 @@ def transform(self) -> str: """ What it takes to turn this object into a native python type """ return self.python_name - def constructor_from_dict(self, dict_name: str) -> str: - """ How to load this property from a dict (used in generated model from_dict function """ - if self.required: - return f'{dict_name}["{self.name}"]' - else: - return f'{dict_name}.get("{self.name}")' - @dataclass class StringProperty(Property): @@ -71,7 +80,9 @@ def __post_init__(self) -> None: @dataclass class DateTimeProperty(Property): - """ A property of type datetime.datetime """ + """ + A property of type datetime.datetime + """ _type_string: ClassVar[str] = "datetime" constructor_template: ClassVar[str] = "datetime_property.pyi" @@ -79,6 +90,19 @@ class DateTimeProperty(Property): def transform(self) -> str: return f"{self.python_name}.isoformat()" + def get_imports(self, *, prefix: str) -> Set[str]: + """ + Get a set of import strings that should be included when this property is used somewhere + + Args: + prefix: A prefix to put before any relative (local) module names. + """ + imports = super().get_imports(prefix=prefix) + imports.update( + {"from datetime import datetime", "from typing import cast",} + ) + return imports + @dataclass class DateProperty(Property): @@ -90,6 +114,19 @@ class DateProperty(Property): def transform(self) -> str: return f"{self.python_name}.isoformat()" + def get_imports(self, *, prefix: str) -> Set[str]: + """ + Get a set of import strings that should be included when this property is used somewhere + + Args: + prefix: A prefix to put before any relative (local) module names. + """ + imports = super().get_imports(prefix=prefix) + imports.update( + {"from datetime import date", "from typing import cast",} + ) + return imports + @dataclass class FileProperty(Property): @@ -100,6 +137,17 @@ class FileProperty(Property): def transform(self) -> str: return f"{self.python_name}.to_tuple()" + def get_imports(self, *, prefix: str) -> Set[str]: + """ + Get a set of import strings that should be included when this property is used somewhere + + Args: + prefix: A prefix to put before any relative (local) module names. + """ + imports = super().get_imports(prefix=prefix) + imports.update({f"from {prefix}.types import File", "from dataclasses import astuple"}) + return imports + @dataclass class FloatProperty(Property): @@ -124,54 +172,33 @@ class BooleanProperty(Property): _type_string: ClassVar[str] = "bool" -@dataclass -class BasicListProperty(Property): - """ A List of basic types """ - - type: str - - def constructor_from_dict(self, dict_name: str) -> str: - """ How to set this property from a dictionary of values """ - return f'{dict_name}.get("{self.name}", [])' - - def get_type_string(self) -> str: - """ Get a string representation of type that should be used when declaring this property """ - if self.required: - return f"List[{self.type}]" - return f"Optional[List[{self.type}]]" +InnerProp = TypeVar("InnerProp", bound=Property) @dataclass -class ReferenceListProperty(Property): - """ A List of References """ +class ListProperty(Property, Generic[InnerProp]): + """ A property representing a list (array) of other properties """ - reference: Reference - constructor_template: ClassVar[str] = "reference_list_property.pyi" + inner_property: InnerProp + constructor_template: ClassVar[str] = "list_property.pyi" def get_type_string(self) -> str: """ Get a string representation of type that should be used when declaring this property """ if self.required: - return f"List[{self.reference.class_name}]" - return f"Optional[List[{self.reference.class_name}]]" + return f"List[{self.inner_property.get_type_string()}]" + return f"Optional[List[{self.inner_property.get_type_string()}]]" + def get_imports(self, *, prefix: str) -> Set[str]: + """ + Get a set of import strings that should be included when this property is used somewhere -@dataclass -class EnumListProperty(Property): - """ List of Enum values """ - - values: Dict[str, str] - reference: Reference = field(init=False) - constructor_template: ClassVar[str] = "enum_list_property.pyi" - - def __post_init__(self) -> None: - super().__post_init__() - self.reference = Reference.from_ref(self.name) - - def get_type_string(self) -> str: - """ Get a string representation of type that should be used when declaring this property """ - if self.required: - return f"List[{self.reference.class_name}]" - return f"Optional[List[{self.reference.class_name}]]" + Args: + prefix: A prefix to put before any relative (local) module names. + """ + imports = super().get_imports(prefix=prefix) + imports.update(self.inner_property.get_imports(prefix=prefix)) + imports.add("from typing import List") + return imports @dataclass @@ -181,6 +208,8 @@ class EnumProperty(Property): values: Dict[str, str] reference: Reference + constructor_template: ClassVar[str] = "enum_property.pyi" + def __post_init__(self) -> None: super().__post_init__() inverse_values = {v: k for k, v in self.values.items()} @@ -194,17 +223,21 @@ def get_type_string(self) -> str: return self.reference.class_name return f"Optional[{self.reference.class_name}]" + def get_imports(self, *, prefix: str) -> Set[str]: + """ + Get a set of import strings that should be included when this property is used somewhere + + Args: + prefix: A prefix to put before any relative (local) module names. + """ + imports = super().get_imports(prefix=prefix) + imports.add(f"from {prefix}.{self.reference.module_name} import {self.reference.class_name}") + return imports + def transform(self) -> str: """ Output to the template, convert this Enum into a JSONable value """ return f"{self.python_name}.value" - def constructor_from_dict(self, dict_name: str) -> str: - """ How to load this property from a dict (used in generated model from_dict function """ - constructor = f'{self.reference.class_name}({dict_name}["{self.name}"])' - if not self.required: - constructor += f' if "{self.name}" in {dict_name} else None' - return constructor - @staticmethod def values_from_list(l: List[str], /) -> Dict[str, str]: """ Convert a list of values into dict of {name: value} """ @@ -235,6 +268,23 @@ def get_type_string(self) -> str: return self.reference.class_name return f"Optional[{self.reference.class_name}]" + def get_imports(self, *, prefix: str) -> Set[str]: + """ + Get a set of import strings that should be included when this property is used somewhere + + Args: + prefix: A prefix to put before any relative (local) module names. + """ + imports = super().get_imports(prefix=prefix) + imports.update( + { + f"from {prefix}.{self.reference.module_name} import {self.reference.class_name}", + "from typing import Dict", + "from typing import cast", + } + ) + return imports + def transform(self) -> str: """ Convert this into a JSONable value """ return f"{self.python_name}.to_dict()" @@ -246,6 +296,17 @@ class DictProperty(Property): _type_string: ClassVar[str] = "Dict[Any, Any]" + def get_imports(self, *, prefix: str) -> Set[str]: + """ + Get a set of import strings that should be included when this property is used somewhere + + Args: + prefix: A prefix to put before any relative (local) module names. + """ + imports = super().get_imports(prefix=prefix) + imports.add("from typing import Dict") + return imports + _openapi_types_to_python_type_strings = { "string": "str", @@ -294,21 +355,12 @@ def property_from_dict(name: str, required: bool, data: Dict[str, Any]) -> Prope elif data["type"] == "boolean": return BooleanProperty(name=name, required=required, default=data.get("default")) elif data["type"] == "array": - if "$ref" in data["items"]: - return ReferenceListProperty( - name=name, required=required, default=None, reference=Reference.from_ref(data["items"]["$ref"]) - ) - if "enum" in data["items"]: - return EnumListProperty( - name=name, required=required, default=None, values=EnumProperty.values_from_list(data["items"]["enum"]) - ) - if "type" in data["items"]: - return BasicListProperty( - name=name, - required=required, - default=None, - type=_openapi_types_to_python_type_strings[data["items"]["type"]], - ) + return ListProperty( + name=name, + required=required, + default=None, + inner_property=property_from_dict(name=f"{name}_item", required=True, data=data["items"]), + ) elif data["type"] == "object": return DictProperty(name=name, required=required, default=data.get("default")) raise ValueError(f"Did not recognize type of {data}") diff --git a/openapi_python_client/templates/date_property.pyi b/openapi_python_client/templates/date_property.pyi index ec0fd1aa8..066881405 100644 --- a/openapi_python_client/templates/date_property.pyi +++ b/openapi_python_client/templates/date_property.pyi @@ -1,7 +1,9 @@ +{% macro template(property, source) %} {% if property.required %} - {{ property.name }} = date.fromisoformat(d["{{ property.name }}"]) +{{ property.name }} = date.fromisoformat({{ source }}) {% else %} - {{ property.name }} = None - if ({{ property.name }}_string := d.get("{{ property.name }}")) is not None: - {{ property.name }} = date.fromisoformat(cast(str, {{ property.name }}_string)) +{{ property.name }} = None +if {{ source }} is not None: + {{ property.name }} = date.fromisoformat(cast(str, {{ source }})) {% endif %} +{% endmacro %} diff --git a/openapi_python_client/templates/datetime_property.pyi b/openapi_python_client/templates/datetime_property.pyi index 007a15d47..b74745310 100644 --- a/openapi_python_client/templates/datetime_property.pyi +++ b/openapi_python_client/templates/datetime_property.pyi @@ -1,7 +1,9 @@ +{% macro template(property, source) %} {% if property.required %} - {{ property.python_name }} = datetime.fromisoformat(d["{{ property.name }}"]) +{{ property.python_name }} = datetime.fromisoformat({{ source }}) {% else %} - {{ property.python_name }} = None - if ({{ property.python_name }}_string := d.get("{{ property.name }}")) is not None: - {{ property.python_name }} = datetime.fromisoformat(cast(str, {{ property.python_name }}_string)) +{{ property.python_name }} = None +if {{ source }} is not None: + {{ property.python_name }} = datetime.fromisoformat(cast(str, {{ source }})) {% endif %} +{% endmacro %} diff --git a/openapi_python_client/templates/enum_list_property.pyi b/openapi_python_client/templates/enum_list_property.pyi deleted file mode 100644 index b73ed0dcf..000000000 --- a/openapi_python_client/templates/enum_list_property.pyi +++ /dev/null @@ -1,3 +0,0 @@ - {{ property.python_name }} = [] - for {{ property.python_name }}_item in d.get("{{ property.name }}", []): - {{ property.python_name }}.append({{ property.reference.class_name }}({{ property.python_name }}_item)) diff --git a/openapi_python_client/templates/enum_property.pyi b/openapi_python_client/templates/enum_property.pyi new file mode 100644 index 000000000..f8b410790 --- /dev/null +++ b/openapi_python_client/templates/enum_property.pyi @@ -0,0 +1,9 @@ +{% macro template(property, source) %} +{% if property.required %} +{{ property.python_name }} = {{ property.reference.class_name }}({{ source }}) +{% else %} +{{ property.python_name }} = None +if {{ source }} is not None: + {{ property.python_name }} = {{ property.reference.class_name }}({{ source }}) +{% endif %} +{% endmacro %} diff --git a/openapi_python_client/templates/list_property.pyi b/openapi_python_client/templates/list_property.pyi new file mode 100644 index 000000000..6411240eb --- /dev/null +++ b/openapi_python_client/templates/list_property.pyi @@ -0,0 +1,17 @@ +{% macro template(property, source) %} +{% set inner_property = property.inner_property %} +{% if inner_property.constructor_template %} +{% set inner_source = inner_property.python_name + "_data" %} +{{ property.python_name }} = [] +{% if property.required %} +for {{ inner_source }} in ({{ source }}): +{% else %} +for {{ inner_source }} in ({{ source }} or []): +{% endif %} + {% from inner_property.constructor_template import template %} + {{ template(inner_property, inner_source) | indent(4) }} + {{ property.python_name }}.append({{ inner_property.python_name }}) +{% else %} +{{ property.python_name }} = {{ source }} +{% endif %} +{% endmacro %} diff --git a/openapi_python_client/templates/model.pyi b/openapi_python_client/templates/model.pyi index 58cd711e6..3f6e74989 100644 --- a/openapi_python_client/templates/model.pyi +++ b/openapi_python_client/templates/model.pyi @@ -1,9 +1,7 @@ from __future__ import annotations -from dataclasses import astuple, dataclass -from typing import Any, Dict, List, Optional, cast - -from .types import * +from dataclasses import dataclass +from typing import Any, Dict {% for relative in schema.relative_imports %} {{ relative }} @@ -29,17 +27,22 @@ class {{ schema.reference.class_name }}: @staticmethod def from_dict(d: Dict[str, Any]) -> {{ schema.reference.class_name }}: - {% for property in schema.required_properties + schema.optional_properties %} - - {% if property.constructor_template %} - {% include property.constructor_template %} - {% else %} - {{ property.python_name }} = {{ property.constructor_from_dict("d") }} - {% endif %} +{% for property in schema.required_properties + schema.optional_properties %} + {% if property.required %} + {% set property_source = 'd["' + property.name + '"]' %} + {% else %} + {% set property_source = 'd.get("' + property.name + '")' %} + {% endif %} + {% if property.constructor_template %} + {% from property.constructor_template import template %} + {{ template(property, property_source) | indent(8) }} + {% else %} + {{ property.python_name }} = {{ property_source }} + {% endif %} - {% endfor %} +{% endfor %} return {{ schema.reference.class_name }}( - {% for property in schema.required_properties + schema.optional_properties %} +{% for property in schema.required_properties + schema.optional_properties %} {{ property.python_name }}={{ property.python_name }}, - {% endfor %} +{% endfor %} ) diff --git a/openapi_python_client/templates/ref_property.pyi b/openapi_python_client/templates/ref_property.pyi index 9ba9fb858..222f1dc9e 100644 --- a/openapi_python_client/templates/ref_property.pyi +++ b/openapi_python_client/templates/ref_property.pyi @@ -1,7 +1,9 @@ +{% macro template(property, source) %} {% if property.required %} - {{ property.python_name }} = {{ property.reference.class_name }}.from_dict(d["{{ property.name }}"]) +{{ property.python_name }} = {{ property.reference.class_name }}.from_dict({{ source }}) {% else %} - {{ property.python_name }} = None - if ({{ property.python_name }}_data := d.get("{{ property.name }}")) is not None: - {{ property.python_name }} = {{ property.reference.class_name }}.from_dict(cast(Dict[str, Any], {{ property.python_name }}_data)) +{{ property.python_name }} = None +if {{ source }} is not None: + {{ property.python_name }} = {{ property.reference.class_name }}.from_dict(cast(Dict[str, Any], {{ source }})) {% endif %} +{% endmacro %} diff --git a/openapi_python_client/templates/reference_list_property.pyi b/openapi_python_client/templates/reference_list_property.pyi deleted file mode 100644 index 7bea9c252..000000000 --- a/openapi_python_client/templates/reference_list_property.pyi +++ /dev/null @@ -1,3 +0,0 @@ - {{ property.python_name }} = [] - for {{ property.python_name }}_item in d.get("{{ property.python_name }}", []): - {{ property.python_name }}.append({{ property.reference.class_name }}.from_dict({{ property.python_name }}_item)) diff --git a/pyproject.toml b/pyproject.toml index b722ef64f..4b9bfa21e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,8 @@ isort --recursive --apply\ && mypy openapi_python_client\ && pytest --cov openapi_python_client tests\ """ -e2e = "python -m end_to_end_tests.regen_golden_master && pytest end_to_end_tests" +gm = "python -m end_to_end_tests.regen_golden_master" +e2e = "pytest --cov openapi_python_client end_to_end_tests" [tool.black] line-length = 120 @@ -69,6 +70,3 @@ exclude = ''' line_length = 120 multi_line_output = 3 include_trailing_comma = true - -[tool.coverage.run] -omit = ["openapi_python_client/templates/*"] diff --git a/tests/test_openapi_parser/test_openapi.py b/tests/test_openapi_parser/test_openapi.py index 763ea674b..d95b7c9f9 100644 --- a/tests/test_openapi_parser/test_openapi.py +++ b/tests/test_openapi_parser/test_openapi.py @@ -39,8 +39,9 @@ def test__check_enums(self, mocker): # Test that all required and optional properties of all schemas are checked # Test that all path and query params of all endpoints of all collections are checked # Test that non EnumProperties are skipped + # Test that ListProperties are fully unpacked from openapi_python_client.openapi_parser.openapi import EndpointCollection, OpenAPI - from openapi_python_client.openapi_parser.properties import EnumProperty, StringProperty + from openapi_python_client.openapi_parser.properties import EnumProperty, StringProperty, ListProperty def _make_enum(): return EnumProperty( @@ -54,8 +55,16 @@ def _make_enum(): # Multiple schemas with both required and optional properties for making sure iteration works correctly schema_1 = mocker.MagicMock() schema_1_req_enum_1 = _make_enum() + schema_1_req_list = ListProperty( + name="list", + required=True, + default=None, + inner_property=ListProperty( + name="list_list", required=True, inner_property=schema_1_req_enum_1, default=None + ), + ) schema_1_req_enum_2 = _make_enum() - schema_1.required_properties = [schema_1_req_enum_1, schema_1_req_enum_2] + schema_1.required_properties = [schema_1_req_list, schema_1_req_enum_2] schema_1_opt_enum_1 = _make_enum() schema_1_opt_enum_2 = _make_enum() non_enum = mocker.MagicMock(autospec=StringProperty) # For checking non-enum properties @@ -158,34 +167,24 @@ def test_dict(self, mocker): } def test_from_dict(self, mocker): - from openapi_python_client.openapi_parser.properties import ( - EnumProperty, - DateProperty, - DateTimeProperty, - Reference, - ) + from openapi_python_client.openapi_parser.properties import Property in_data = { "title": mocker.MagicMock(), "description": mocker.MagicMock(), "required": ["RequiredEnum"], - "properties": { - "RequiredEnum": mocker.MagicMock(), - "OptionalDateTime": mocker.MagicMock(), - "OptionalDate": mocker.MagicMock(), - }, + "properties": {"RequiredEnum": mocker.MagicMock(), "OptionalDateTime": mocker.MagicMock(),}, } - required_property = EnumProperty( - name="RequiredEnum", required=True, default=None, values={}, reference=Reference.from_ref("RequiredEnum") - ) - optional_property = DateTimeProperty(name="OptionalDateTime", required=False, default=None) - optional_date_property = DateProperty(name="OptionalDate", required=False, default=None) + required_property = mocker.MagicMock(autospec=Property) + required_imports = mocker.MagicMock() + required_property.get_imports.return_value = {required_imports} + optional_property = mocker.MagicMock(autospec=Property) + optional_imports = mocker.MagicMock() + optional_property.get_imports.return_value = {optional_imports} property_from_dict = mocker.patch( - f"{MODULE_NAME}.property_from_dict", - side_effect=[required_property, optional_property, optional_date_property], + f"{MODULE_NAME}.property_from_dict", side_effect=[required_property, optional_property], ) from_ref = mocker.patch(f"{MODULE_NAME}.Reference.from_ref") - import_string_from_reference = mocker.patch(f"{MODULE_NAME}.import_string_from_reference") from openapi_python_client.openapi_parser.openapi import Schema @@ -196,19 +195,15 @@ def test_from_dict(self, mocker): [ mocker.call(name="RequiredEnum", required=True, data=in_data["properties"]["RequiredEnum"]), mocker.call(name="OptionalDateTime", required=False, data=in_data["properties"]["OptionalDateTime"]), - mocker.call(name="OptionalDate", required=False, data=in_data["properties"]["OptionalDate"]), ] ) - import_string_from_reference.assert_called_once_with(required_property.reference) + required_property.get_imports.assert_called_once_with(prefix="") + optional_property.get_imports.assert_called_once_with(prefix="") assert result == Schema( reference=from_ref(), required_properties=[required_property], - optional_properties=[optional_property, optional_date_property], - relative_imports={ - import_string_from_reference(), - "from datetime import datetime", - "from datetime import date", - }, + optional_properties=[optional_property], + relative_imports={required_imports, optional_imports,}, description=in_data["description"], ) @@ -296,7 +291,8 @@ def test_add_body_no_data(self, mocker): parse_request_form_body.assert_not_called() def test_add_body_happy(self, mocker): - from openapi_python_client.openapi_parser.openapi import Endpoint, Reference, RefProperty + from openapi_python_client.openapi_parser.openapi import Endpoint, Reference + from openapi_python_client.openapi_parser.properties import Property request_body = mocker.MagicMock() form_body_reference = Reference.from_ref(ref="a") @@ -307,10 +303,13 @@ def test_add_body_happy(self, mocker): parse_multipart_body = mocker.patch.object( Endpoint, "parse_multipart_body", return_value=multipart_body_reference ) - json_body = RefProperty(name="name", required=True, default=None, reference=Reference.from_ref("b")) + + json_body = mocker.MagicMock(autospec=Property) + json_body_imports = mocker.MagicMock() + json_body.get_imports.return_value = {json_body_imports} parse_request_json_body = mocker.patch.object(Endpoint, "parse_request_json_body", return_value=json_body) import_string_from_reference = mocker.patch( - f"{MODULE_NAME}.import_string_from_reference", side_effect=["import_1", "import_2", "import_4"] + f"{MODULE_NAME}.import_string_from_reference", side_effect=["import_1", "import_2"] ) endpoint = Endpoint( @@ -331,11 +330,11 @@ def test_add_body_happy(self, mocker): import_string_from_reference.assert_has_calls( [ mocker.call(form_body_reference, prefix="..models"), - mocker.call(json_body.reference, prefix="..models"), mocker.call(multipart_body_reference, prefix="..models"), ] ) - assert endpoint.relative_imports == {"import_1", "import_2", "import_3", "import_4"} + json_body.get_imports.assert_called_once_with(prefix="..models") + assert endpoint.relative_imports == {"import_1", "import_2", "import_3", json_body_imports} assert endpoint.json_body == json_body assert endpoint.form_body_reference == form_body_reference assert endpoint.multipart_body_reference == multipart_body_reference @@ -397,7 +396,8 @@ def test__add_parameters_fail_loudly_when_location_not_supported(self, mocker): ) def test__add_parameters_happy(self, mocker): - from openapi_python_client.openapi_parser.openapi import Endpoint, EnumProperty, DateTimeProperty, DateProperty + from openapi_python_client.openapi_parser.openapi import Endpoint + from openapi_python_client.openapi_parser.properties import Property endpoint = Endpoint( path="path", @@ -408,44 +408,39 @@ def test__add_parameters_happy(self, mocker): tag="tag", relative_imports={"import_3"}, ) - path_prop = EnumProperty(name="path_enum", required=True, default=None, values={}, reference=mocker.MagicMock()) - query_prop_datetime = DateTimeProperty(name="query_datetime", required=False, default=None) - query_prop_date = DateProperty(name="query_date", required=False, default=None) - propety_from_dict = mocker.patch( - f"{MODULE_NAME}.property_from_dict", side_effect=[path_prop, query_prop_datetime, query_prop_date] - ) + path_prop = mocker.MagicMock(autospec=Property) + path_prop_import = mocker.MagicMock() + path_prop.get_imports = mocker.MagicMock(return_value={path_prop_import}) + query_prop = mocker.MagicMock(autospec=Property) + query_prop_import = mocker.MagicMock() + query_prop.get_imports = mocker.MagicMock(return_value={query_prop_import}) + property_from_dict = mocker.patch(f"{MODULE_NAME}.property_from_dict", side_effect=[path_prop, query_prop]) path_schema = mocker.MagicMock() - query_datetime_schema = mocker.MagicMock() - query_date_schema = mocker.MagicMock() - import_string_from_reference = mocker.patch( - f"{MODULE_NAME}.import_string_from_reference", side_effect=["import_1"] - ) + query_schema = mocker.MagicMock() data = { "parameters": [ {"name": "path_prop_name", "required": True, "schema": path_schema, "in": "path"}, - {"name": "query_datetime", "required": False, "schema": query_datetime_schema, "in": "query"}, - {"name": "query_date", "required": False, "schema": query_date_schema, "in": "query"}, + {"name": "query_prop_name", "required": False, "schema": query_schema, "in": "query"}, ] } endpoint._add_parameters(data) - propety_from_dict.assert_has_calls( + property_from_dict.assert_has_calls( [ mocker.call(name="path_prop_name", required=True, data=path_schema), - mocker.call(name="query_datetime", required=False, data=query_datetime_schema), - mocker.call(name="query_date", required=False, data=query_date_schema), + mocker.call(name="query_prop_name", required=False, data=query_schema), ] ) - import_string_from_reference.assert_called_once_with(path_prop.reference, prefix="..models") + path_prop.get_imports.assert_called_once_with(prefix="..models") + query_prop.get_imports.assert_called_once_with(prefix="..models") assert endpoint.relative_imports == { - "import_1", "import_3", - "from datetime import datetime", - "from datetime import date", + path_prop_import, + query_prop_import, } assert endpoint.path_parameters == [path_prop] - assert endpoint.query_parameters == [query_prop_datetime, query_prop_date] + assert endpoint.query_parameters == [query_prop] def test_from_data(self, mocker): from openapi_python_client.openapi_parser.openapi import Endpoint diff --git a/tests/test_openapi_parser/test_properties.py b/tests/test_openapi_parser/test_properties.py index c7ed14730..1e23049bb 100644 --- a/tests/test_openapi_parser/test_properties.py +++ b/tests/test_openapi_parser/test_properties.py @@ -37,18 +37,16 @@ def test_transform(self, mocker): p = Property(name=name, required=True, default=None) assert p.transform() == snake_case(name) - def test_constructor_from_dict(self, mocker): + def test_get_imports(self, mocker): from openapi_python_client.openapi_parser.properties import Property name = mocker.MagicMock() - snake_case = mocker.patch(f"openapi_python_client.utils.snake_case") + mocker.patch(f"openapi_python_client.utils.snake_case") p = Property(name=name, required=True, default=None) - dict_name = mocker.MagicMock() - - assert p.constructor_from_dict(dict_name) == f'{dict_name}["{name}"]' + assert p.get_imports(prefix="") == set() p.required = False - assert p.constructor_from_dict(dict_name) == f'{dict_name}.get("{name}")' + assert p.get_imports(prefix="") == {"from typing import Optional"} class TestStringProperty: @@ -80,6 +78,24 @@ def test_transform(self, mocker): assert prop.transform() == f"the_property_name.isoformat()" + def test_get_imports(self, mocker): + from openapi_python_client.openapi_parser.properties import DateTimeProperty + + name = mocker.MagicMock() + mocker.patch(f"openapi_python_client.utils.snake_case") + p = DateTimeProperty(name=name, required=True, default=None) + assert p.get_imports(prefix="") == { + "from datetime import datetime", + "from typing import cast", + } + + p.required = False + assert p.get_imports(prefix="") == { + "from typing import Optional", + "from datetime import datetime", + "from typing import cast", + } + class TestDateProperty: def test_transform(self, mocker): @@ -92,6 +108,24 @@ def test_transform(self, mocker): assert prop.transform() == f"the_property_name.isoformat()" + def test_get_imports(self, mocker): + from openapi_python_client.openapi_parser.properties import DateProperty + + name = mocker.MagicMock() + mocker.patch(f"openapi_python_client.utils.snake_case") + p = DateProperty(name=name, required=True, default=None) + assert p.get_imports(prefix="") == { + "from datetime import date", + "from typing import cast", + } + + p.required = False + assert p.get_imports(prefix="") == { + "from typing import Optional", + "from datetime import date", + "from typing import cast", + } + class TestFileProperty: def test_transform(self): @@ -103,62 +137,55 @@ def test_transform(self): assert prop.transform() == f"the_property_name.to_tuple()" + def test_get_imports(self, mocker): + from openapi_python_client.openapi_parser.properties import FileProperty -class TestBasicListProperty: - def test_constructor_from_dict(self): - from openapi_python_client.openapi_parser.properties import BasicListProperty - - p = BasicListProperty(name="test", required=True, default=None, type="MyTestType") - - assert p.constructor_from_dict("d") == 'd.get("test", [])' - - def test_get_type_string(self): - from openapi_python_client.openapi_parser.properties import BasicListProperty - - p = BasicListProperty(name="test", required=True, default=None, type="MyTestType") + name = mocker.MagicMock() + mocker.patch(f"openapi_python_client.utils.snake_case") + prefix = "blah" + p = FileProperty(name=name, required=True, default=None) + assert p.get_imports(prefix=prefix) == {f"from {prefix}.types import File", "from dataclasses import astuple"} - assert p.get_type_string() == "List[MyTestType]" p.required = False - assert p.get_type_string() == "Optional[List[MyTestType]]" + assert p.get_imports(prefix=prefix) == { + "from typing import Optional", + f"from {prefix}.types import File", + "from dataclasses import astuple", + } -class TestReferenceListProperty: +class TestListProperty: def test_get_type_string(self, mocker): - from openapi_python_client.openapi_parser.properties import ReferenceListProperty, Reference + from openapi_python_client.openapi_parser.properties import ListProperty - reference = mocker.MagicMock(autospec=Reference) - reference.class_name = "MyTestClassName" - p = ReferenceListProperty(name="test", required=True, default=None, reference=reference) + inner_property = mocker.MagicMock() + inner_type_string = mocker.MagicMock() + inner_property.get_type_string.return_value = inner_type_string + p = ListProperty(name="test", required=True, default=None, inner_property=inner_property) - assert p.get_type_string() == "List[MyTestClassName]" + assert p.get_type_string() == f"List[{inner_type_string}]" p.required = False - assert p.get_type_string() == "Optional[List[MyTestClassName]]" - - -class TestEnumListProperty: - def test___post_init__(self, mocker): - name = mocker.MagicMock() - mocker.patch(f"openapi_python_client.utils.snake_case") - fake_reference = mocker.MagicMock(class_name="MyTestEnum") - from_ref = mocker.patch(f"{MODULE_NAME}.Reference.from_ref", return_value=fake_reference) + assert p.get_type_string() == f"Optional[List[{inner_type_string}]]" - from openapi_python_client.openapi_parser.properties import EnumListProperty - - EnumListProperty(name=name, required=True, default=None, values={}) - - from_ref.assert_called_once_with(name) - - def test_get_type_string(self, mocker): - from openapi_python_client.openapi_parser.properties import EnumListProperty - - fake_reference = mocker.MagicMock(class_name="MyTestEnum") - mocker.patch(f"{MODULE_NAME}.Reference.from_ref", return_value=fake_reference) + def test_get_type_imports(self, mocker): + from openapi_python_client.openapi_parser.properties import ListProperty - p = EnumListProperty(name="test", required=True, default=None, values={}) + inner_property = mocker.MagicMock() + inner_import = mocker.MagicMock() + inner_property.get_imports.return_value = {inner_import} + prefix = mocker.MagicMock() + p = ListProperty(name="test", required=True, default=None, inner_property=inner_property) - assert p.get_type_string() == "List[MyTestEnum]" + assert p.get_imports(prefix=prefix) == { + inner_import, + "from typing import List", + } p.required = False - assert p.get_type_string() == "Optional[List[MyTestEnum]]" + assert p.get_imports(prefix=prefix) == { + inner_import, + "from typing import List", + "from typing import Optional", + } class TestEnumProperty: @@ -193,33 +220,34 @@ def test_get_type_string(self, mocker): enum_property.required = False assert enum_property.get_type_string() == "Optional[MyTestEnum]" - def test_transform(self, mocker): - name = "thePropertyName" - mocker.patch(f"{MODULE_NAME}.Reference.from_ref") + def test_get_imports(self, mocker): + fake_reference = mocker.MagicMock(class_name="MyTestEnum", module_name="my_test_enum") + mocker.patch(f"{MODULE_NAME}.Reference.from_ref", return_value=fake_reference) + prefix = mocker.MagicMock() from openapi_python_client.openapi_parser.properties import EnumProperty - enum_property = EnumProperty(name=name, required=True, default=None, values={}, reference=mocker.MagicMock()) - - assert enum_property.transform() == f"the_property_name.value" + enum_property = EnumProperty(name="test", required=True, default=None, values={}, reference=fake_reference) - def test_constructor_from_dict(self, mocker): - fake_reference = mocker.MagicMock(class_name="MyTestEnum") + assert enum_property.get_imports(prefix=prefix) == { + f"from {prefix}.{fake_reference.module_name} import {fake_reference.class_name}" + } - from openapi_python_client.openapi_parser.properties import EnumProperty + enum_property.required = False + assert enum_property.get_imports(prefix=prefix) == { + f"from {prefix}.{fake_reference.module_name} import {fake_reference.class_name}", + "from typing import Optional", + } - enum_property = EnumProperty(name="test_enum", required=True, default=None, values={}, reference=fake_reference) + def test_transform(self, mocker): + name = "thePropertyName" + mocker.patch(f"{MODULE_NAME}.Reference.from_ref") - assert enum_property.constructor_from_dict("my_dict") == 'MyTestEnum(my_dict["test_enum"])' + from openapi_python_client.openapi_parser.properties import EnumProperty - enum_property = EnumProperty( - name="test_enum", required=False, default=None, values={}, reference=fake_reference - ) + enum_property = EnumProperty(name=name, required=True, default=None, values={}, reference=mocker.MagicMock()) - assert ( - enum_property.constructor_from_dict("my_dict") - == 'MyTestEnum(my_dict["test_enum"]) if "test_enum" in my_dict else None' - ) + assert enum_property.transform() == f"the_property_name.value" def test_values_from_list(self): from openapi_python_client.openapi_parser.properties import EnumProperty @@ -249,6 +277,28 @@ def test_get_type_string(self, mocker): ref_property.required = False assert ref_property.get_type_string() == "Optional[MyRefClass]" + def test_get_imports(self, mocker): + fake_reference = mocker.MagicMock(class_name="MyRefClass", module_name="my_test_enum") + prefix = mocker.MagicMock() + + from openapi_python_client.openapi_parser.properties import RefProperty + + p = RefProperty(name="test", required=True, default=None, reference=fake_reference) + + assert p.get_imports(prefix=prefix) == { + f"from {prefix}.{fake_reference.module_name} import {fake_reference.class_name}", + "from typing import Dict", + "from typing import cast", + } + + p.required = False + assert p.get_imports(prefix=prefix) == { + f"from {prefix}.{fake_reference.module_name} import {fake_reference.class_name}", + "from typing import Dict", + "from typing import cast", + "from typing import Optional", + } + def test_transform(self, mocker): from openapi_python_client.openapi_parser.properties import RefProperty @@ -257,6 +307,25 @@ def test_transform(self, mocker): assert ref_property.transform() == "super_unique_name.to_dict()" +class TestDictProperty: + def test_get_imports(self, mocker): + from openapi_python_client.openapi_parser.properties import DictProperty + + name = mocker.MagicMock() + mocker.patch(f"openapi_python_client.utils.snake_case") + prefix = mocker.MagicMock() + p = DictProperty(name=name, required=True, default=None) + assert p.get_imports(prefix=prefix) == { + "from typing import Dict", + } + + p.required = False + assert p.get_imports(prefix=prefix) == { + "from typing import Optional", + "from typing import Dict", + } + + class TestPropertyFromDict: def test_property_from_dict_enum(self, mocker): name = mocker.MagicMock() @@ -355,69 +424,25 @@ def test_property_from_dict_simple_types(self, mocker, openapi_type, python_type ) clazz.assert_called_once_with(name=name, required=required, default=data["default"]) - def test_property_from_dict_ref_array(self, mocker): - name = mocker.MagicMock() - required = mocker.MagicMock() - ref = mocker.MagicMock() - data = { - "type": "array", - "items": {"$ref": ref}, - } - ReferenceListProperty = mocker.patch(f"{MODULE_NAME}.ReferenceListProperty") - from_ref = mocker.patch(f"{MODULE_NAME}.Reference.from_ref") - - from openapi_python_client.openapi_parser.properties import property_from_dict - - p = property_from_dict(name=name, required=required, data=data) - - from_ref.assert_called_once_with(ref) - ReferenceListProperty.assert_called_once_with(name=name, required=required, default=None, reference=from_ref()) - assert p == ReferenceListProperty() - - def test_property_from_dict_enum_array(self, mocker): - name = mocker.MagicMock() - required = mocker.MagicMock() - enum = mocker.MagicMock() - data = { - "type": "array", - "items": {"enum": enum}, - } - values_from_list = mocker.patch(f"{MODULE_NAME}.EnumProperty.values_from_list") - EnumListProperty = mocker.patch(f"{MODULE_NAME}.EnumListProperty") - - from openapi_python_client.openapi_parser.properties import property_from_dict - - p = property_from_dict(name=name, required=required, data=data) - - values_from_list.assert_called_once_with(enum) - EnumListProperty.assert_called_once_with(name=name, required=required, default=None, values=values_from_list()) - assert p == EnumListProperty() - - @pytest.mark.parametrize( - "openapi_type,python_type", - [ - ("string", "str"), - ("number", "float"), - ("integer", "int"), - ("boolean", "bool"), - ("object", "Dict[Any, Any]"), - ], - ) - def test_property_from_dict_simple_array(self, mocker, openapi_type, python_type): + def test_property_from_dict_array(self, mocker): name = mocker.MagicMock() required = mocker.MagicMock() data = { "type": "array", - "items": {"type": openapi_type}, + "items": {"type": "number", "default": "0.0"}, } - BasicListProperty = mocker.patch(f"{MODULE_NAME}.BasicListProperty") + ListProperty = mocker.patch(f"{MODULE_NAME}.ListProperty") + FloatProperty = mocker.patch(f"{MODULE_NAME}.FloatProperty") from openapi_python_client.openapi_parser.properties import property_from_dict p = property_from_dict(name=name, required=required, data=data) - BasicListProperty.assert_called_once_with(name=name, required=required, default=None, type=python_type) - assert p == BasicListProperty() + FloatProperty.assert_called_once_with(name=f"{name}_item", required=True, default="0.0") + ListProperty.assert_called_once_with( + name=name, required=required, default=None, inner_property=FloatProperty.return_value + ) + assert p == ListProperty.return_value def test_property_from_dict_unsupported_type(self, mocker): name = mocker.MagicMock()