diff --git a/.gitignore b/.gitignore index 79a2c3d73..2429c4b7f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ dmypy.json /coverage.xml /.coverage +htmlcov/ diff --git a/CHANGELOG.md b/CHANGELOG.md index efc199c49..6f0da24d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 0.3.0 - Unreleased ### Additions - Link to the GitHub repository from PyPI (#26). Thanks @theY4Kman! +- Support for date properties (#30, #37). Thanks @acgray! ### Fixes - Fixed some typing issues in generated clients and incorporate mypy into end to end tests (#32). Thanks @acgray! diff --git a/README.md b/README.md index 182196ee1..eaa36f47f 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ OpenAPI document. ## OpenAPI features supported 1. All HTTP Methods 1. JSON and form bodies, path and query parameters -1. float, string, int, datetimes, string enums, and custom schemas or lists containing any of those +1. float, string, int, date, datetime, string enums, and custom schemas or lists containing any of those 1. html/text or application/json responses containing any of the previous types 1. Bearer token security diff --git a/openapi_python_client/openapi_parser/openapi.py b/openapi_python_client/openapi_parser/openapi.py index 12e5f01a2..97d424c46 100644 --- a/openapi_python_client/openapi_parser/openapi.py +++ b/openapi_python_client/openapi_parser/openapi.py @@ -4,7 +4,16 @@ from enum import Enum from typing import Any, Dict, Generator, Iterable, List, Optional, Set, Union -from .properties import EnumListProperty, EnumProperty, Property, ReferenceListProperty, RefProperty, property_from_dict +from .properties import ( + DateProperty, + DateTimeProperty, + EnumListProperty, + EnumProperty, + Property, + ReferenceListProperty, + RefProperty, + property_from_dict, +) from .reference import Reference from .responses import ListRefResponse, RefResponse, Response, response_from_dict @@ -108,11 +117,16 @@ 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 ( + 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")) + if param_dict["in"] == ParameterLocation.QUERY: self.query_parameters.append(prop) elif param_dict["in"] == ParameterLocation.PATH: @@ -168,7 +182,11 @@ def from_dict(d: Dict[str, Any], /) -> Schema: required_properties.append(p) else: optional_properties.append(p) - if isinstance(p, (ReferenceListProperty, EnumListProperty, RefProperty, EnumProperty)) and p.reference: + 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: relative_imports.add(import_string_from_reference(p.reference)) schema = Schema( reference=Reference.from_ref(d["title"]), diff --git a/openapi_python_client/openapi_parser/properties.py b/openapi_python_client/openapi_parser/properties.py index e96a93a9d..eb7f14262 100644 --- a/openapi_python_client/openapi_parser/properties.py +++ b/openapi_python_client/openapi_parser/properties.py @@ -76,6 +76,20 @@ class DateTimeProperty(Property): _type_string: ClassVar[str] = "datetime" constructor_template: ClassVar[str] = "datetime_property.pyi" + def transform(self) -> str: + return f"{self.python_name}.isoformat()" + + +@dataclass +class DateProperty(Property): + """ A property of type datetime.date """ + + _type_string: ClassVar[str] = "date" + constructor_template: ClassVar[str] = "date_property.pyi" + + def transform(self) -> str: + return f"{self.python_name}.isoformat()" + @dataclass class FloatProperty(Property): @@ -245,12 +259,14 @@ def property_from_dict(name: str, required: bool, data: Dict[str, Any]) -> Prope if "$ref" in data: return RefProperty(name=name, required=required, reference=Reference.from_ref(data["$ref"]), default=None) if data["type"] == "string": - if "format" not in data: - return StringProperty( - name=name, default=data.get("default"), required=required, pattern=data.get("pattern"), - ) - elif data["format"] == "date-time": - return DateTimeProperty(name=name, required=required, default=data.get("default")) + if "format" in data: + if data.get("format") == "date-time": + return DateTimeProperty(name=name, required=required, default=data.get("default")) + elif data.get("format") == "date": + return DateProperty(name=name, required=required, default=data.get("default")) + else: + raise ValueError(f'Unsupported string format:{data["format"]}') + return StringProperty(name=name, default=data.get("default"), required=required, pattern=data.get("pattern"),) elif data["type"] == "number": return FloatProperty(name=name, default=data.get("default"), required=required) elif data["type"] == "integer": diff --git a/openapi_python_client/templates/date_property.pyi b/openapi_python_client/templates/date_property.pyi new file mode 100644 index 000000000..ec0fd1aa8 --- /dev/null +++ b/openapi_python_client/templates/date_property.pyi @@ -0,0 +1,7 @@ +{% if property.required %} + {{ property.name }} = date.fromisoformat(d["{{ property.name }}"]) +{% else %} + {{ property.name }} = None + if ({{ property.name }}_string := d.get("{{ property.name }}")) is not None: + {{ property.name }} = date.fromisoformat(cast(str, {{ property.name }}_string)) +{% endif %} diff --git a/openapi_python_client/templates/model.pyi b/openapi_python_client/templates/model.pyi index 57bebf8ac..38096731a 100644 --- a/openapi_python_client/templates/model.pyi +++ b/openapi_python_client/templates/model.pyi @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime from typing import Any, Dict, List, Optional, cast {% for relative in schema.relative_imports %} diff --git a/tests/test_end_to_end/fastapi/__init__.py b/tests/test_end_to_end/fastapi/__init__.py index c50950aea..b723510c7 100644 --- a/tests/test_end_to_end/fastapi/__init__.py +++ b/tests/test_end_to_end/fastapi/__init__.py @@ -1,6 +1,6 @@ """ A FastAPI app used to create an OpenAPI document for end-to-end testing """ import json -from datetime import datetime +from datetime import date, datetime from enum import Enum from pathlib import Path from typing import List @@ -45,10 +45,11 @@ class AModel(BaseModel): a_list_of_strings: List[str] a_list_of_objects: List[OtherModel] aCamelDateTime: datetime + a_date: date @test_router.get("/", response_model=List[AModel], operation_id="getUserList") -def get_list(statuses: List[AnEnum] = Query(...),): +def get_list(statuses: List[AnEnum] = Query(...), some_date: date = Query(...), some_datetime: datetime = Query(...)): """ Get users, filtered by statuses """ return diff --git a/tests/test_end_to_end/fastapi/openapi.json b/tests/test_end_to_end/fastapi/openapi.json index d8c9665a5..b6b1f88c6 100644 --- a/tests/test_end_to_end/fastapi/openapi.json +++ b/tests/test_end_to_end/fastapi/openapi.json @@ -48,6 +48,26 @@ }, "name": "statuses", "in": "query" + }, + { + "required": true, + "schema": { + "title": "Some Date", + "type": "string", + "format": "date" + }, + "name": "some_date", + "in": "query" + }, + { + "required": true, + "schema": { + "title": "Some Datetime", + "type": "string", + "format": "date-time" + }, + "name": "some_datetime", + "in": "query" } ], "responses": { @@ -88,7 +108,8 @@ "a_list_of_enums", "a_list_of_strings", "a_list_of_objects", - "aCamelDateTime" + "aCamelDateTime", + "a_date" ], "type": "object", "properties": { @@ -127,6 +148,11 @@ "title": "Acameldatetime", "type": "string", "format": "date-time" + }, + "a_date": { + "title": "A Date", + "type": "string", + "format": "date" } }, "description": "A Model for testing all the ways custom objects can be used " diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/api/users.py b/tests/test_end_to_end/golden-master/my_test_api_client/api/users.py index eb6f8515a..c3de077f4 100644 --- a/tests/test_end_to_end/golden-master/my_test_api_client/api/users.py +++ b/tests/test_end_to_end/golden-master/my_test_api_client/api/users.py @@ -1,4 +1,5 @@ from dataclasses import asdict +from datetime import date, datetime from typing import Any, Dict, List, Optional, Union, cast import httpx @@ -11,7 +12,7 @@ def get_user_list( - *, client: Client, statuses: List[Statuses], + *, client: Client, statuses: List[Statuses], some_date: date, some_datetime: datetime, ) -> Union[ List[AModel], HTTPValidationError, ]: @@ -20,6 +21,8 @@ def get_user_list( params = { "statuses": statuses, + "some_date": some_date.isoformat(), + "some_datetime": some_datetime.isoformat(), } response = httpx.get(url=url, headers=client.get_headers(), params=params,) diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/async_api/users.py b/tests/test_end_to_end/golden-master/my_test_api_client/async_api/users.py index 0bd8cd973..5af4b3fb7 100644 --- a/tests/test_end_to_end/golden-master/my_test_api_client/async_api/users.py +++ b/tests/test_end_to_end/golden-master/my_test_api_client/async_api/users.py @@ -1,4 +1,5 @@ from dataclasses import asdict +from datetime import date, datetime from typing import Any, Dict, List, Optional, Union, cast import httpx @@ -11,7 +12,7 @@ async def get_user_list( - *, client: Client, statuses: List[Statuses], + *, client: Client, statuses: List[Statuses], some_date: date, some_datetime: datetime, ) -> Union[ List[AModel], HTTPValidationError, ]: @@ -20,6 +21,8 @@ async def get_user_list( params = { "statuses": statuses, + "some_date": some_date.isoformat(), + "some_datetime": some_datetime.isoformat(), } async with httpx.AsyncClient() as _client: diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/models/a_model.py b/tests/test_end_to_end/golden-master/my_test_api_client/models/a_model.py index f9a2089ac..dd0f864eb 100644 --- a/tests/test_end_to_end/golden-master/my_test_api_client/models/a_model.py +++ b/tests/test_end_to_end/golden-master/my_test_api_client/models/a_model.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime +from datetime import date, datetime from typing import Any, Dict, List, Optional, cast from .a_list_of_enums import AListOfEnums @@ -18,6 +18,7 @@ class AModel: a_list_of_strings: List[str] a_list_of_objects: List[OtherModel] a_camel_date_time: datetime + a_date: date def to_dict(self) -> Dict[str, Any]: return { @@ -25,7 +26,8 @@ def to_dict(self) -> Dict[str, Any]: "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, - "aCamelDateTime": self.a_camel_date_time, + "aCamelDateTime": self.a_camel_date_time.isoformat(), + "a_date": self.a_date.isoformat(), } @staticmethod @@ -45,10 +47,13 @@ def from_dict(d: Dict[str, Any]) -> AModel: a_camel_date_time = datetime.fromisoformat(d["aCamelDateTime"]) + a_date = date.fromisoformat(d["a_date"]) + 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, a_camel_date_time=a_camel_date_time, + a_date=a_date, ) diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/models/abc_response.py b/tests/test_end_to_end/golden-master/my_test_api_client/models/abc_response.py index d8562b64d..8acd6e578 100644 --- a/tests/test_end_to_end/golden-master/my_test_api_client/models/abc_response.py +++ b/tests/test_end_to_end/golden-master/my_test_api_client/models/abc_response.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime from typing import Any, Dict, List, Optional, cast diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/models/http_validation_error.py b/tests/test_end_to_end/golden-master/my_test_api_client/models/http_validation_error.py index 738096e39..206340536 100644 --- a/tests/test_end_to_end/golden-master/my_test_api_client/models/http_validation_error.py +++ b/tests/test_end_to_end/golden-master/my_test_api_client/models/http_validation_error.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime from typing import Any, Dict, List, Optional, cast from .validation_error import ValidationError diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/models/other_model.py b/tests/test_end_to_end/golden-master/my_test_api_client/models/other_model.py index c2e7a90e9..382221c70 100644 --- a/tests/test_end_to_end/golden-master/my_test_api_client/models/other_model.py +++ b/tests/test_end_to_end/golden-master/my_test_api_client/models/other_model.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime from typing import Any, Dict, List, Optional, cast diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/models/validation_error.py b/tests/test_end_to_end/golden-master/my_test_api_client/models/validation_error.py index c3b91642f..f5b11c636 100644 --- a/tests/test_end_to_end/golden-master/my_test_api_client/models/validation_error.py +++ b/tests/test_end_to_end/golden-master/my_test_api_client/models/validation_error.py @@ -1,7 +1,6 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime from typing import Any, Dict, List, Optional, cast diff --git a/tests/test_openapi_parser/test_openapi.py b/tests/test_openapi_parser/test_openapi.py index 82119d151..2d5f4a6b2 100644 --- a/tests/test_openapi_parser/test_openapi.py +++ b/tests/test_openapi_parser/test_openapi.py @@ -1,5 +1,7 @@ import pytest +from openapi_python_client.openapi_parser.properties import DateProperty, DateTimeProperty + MODULE_NAME = "openapi_python_client.openapi_parser.openapi" @@ -152,12 +154,18 @@ def test_from_dict(self, mocker): "title": mocker.MagicMock(), "description": mocker.MagicMock(), "required": ["RequiredEnum"], - "properties": {"RequiredEnum": mocker.MagicMock(), "OptionalString": mocker.MagicMock(),}, + "properties": { + "RequiredEnum": mocker.MagicMock(), + "OptionalDateTime": mocker.MagicMock(), + "OptionalDate": mocker.MagicMock(), + }, } required_property = EnumProperty(name="RequiredEnum", required=True, default=None, values={},) - optional_property = StringProperty(name="OptionalString", required=False, default=None) + optional_property = DateTimeProperty(name="OptionalDateTime", required=False, default=None) + optional_date_property = DateProperty(name="OptionalDate", required=False, default=None) property_from_dict = mocker.patch( - f"{MODULE_NAME}.property_from_dict", side_effect=[required_property, optional_property] + f"{MODULE_NAME}.property_from_dict", + side_effect=[required_property, optional_property, optional_date_property], ) from_ref = mocker.patch(f"{MODULE_NAME}.Reference.from_ref") import_string_from_reference = mocker.patch(f"{MODULE_NAME}.import_string_from_reference") @@ -170,15 +178,20 @@ def test_from_dict(self, mocker): property_from_dict.assert_has_calls( [ mocker.call(name="RequiredEnum", required=True, data=in_data["properties"]["RequiredEnum"]), - mocker.call(name="OptionalString", required=False, data=in_data["properties"]["OptionalString"]), + 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) assert result == Schema( reference=from_ref(), required_properties=[required_property], - optional_properties=[optional_property], - relative_imports={import_string_from_reference()}, + optional_properties=[optional_property, optional_date_property], + relative_imports={ + import_string_from_reference(), + "from datetime import datetime", + "from datetime import date", + }, description=in_data["description"], ) @@ -348,17 +361,22 @@ def test__add_parameters_happy(self, mocker): relative_imports={"import_3"}, ) path_prop = EnumProperty(name="path_enum", required=True, default=None, values={}) - query_prop = EnumProperty(name="query_enum", required=False, default=None, values={}) - propety_from_dict = mocker.patch(f"{MODULE_NAME}.property_from_dict", side_effect=[path_prop, query_prop]) + 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_schema = mocker.MagicMock() - query_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", "import_2"] + f"{MODULE_NAME}.import_string_from_reference", side_effect=["import_1"] ) data = { "parameters": [ {"name": "path_prop_name", "required": True, "schema": path_schema, "in": "path"}, - {"name": "query_prop_name", "required": False, "schema": query_schema, "in": "query"}, + {"name": "query_datetime", "required": False, "schema": query_datetime_schema, "in": "query"}, + {"name": "query_date", "required": False, "schema": query_date_schema, "in": "query"}, ] } @@ -367,15 +385,19 @@ def test__add_parameters_happy(self, mocker): propety_from_dict.assert_has_calls( [ mocker.call(name="path_prop_name", required=True, data=path_schema), - mocker.call(name="query_prop_name", required=False, data=query_schema), + mocker.call(name="query_datetime", required=False, data=query_datetime_schema), + mocker.call(name="query_date", required=False, data=query_date_schema), ] ) - import_string_from_reference.assert_has_calls( - [mocker.call(path_prop.reference, prefix="..models"), mocker.call(query_prop.reference, prefix="..models"),] - ) - assert endpoint.relative_imports == {"import_1", "import_2", "import_3"} + import_string_from_reference.assert_called_once_with(path_prop.reference, prefix="..models") + assert endpoint.relative_imports == { + "import_1", + "import_3", + "from datetime import datetime", + "from datetime import date", + } assert endpoint.path_parameters == [path_prop] - assert endpoint.query_parameters == [query_prop] + assert endpoint.query_parameters == [query_prop_datetime, query_prop_date] 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 a14990351..d04de014e 100644 --- a/tests/test_openapi_parser/test_properties.py +++ b/tests/test_openapi_parser/test_properties.py @@ -69,6 +69,30 @@ def test_get_type_string(self): assert p.get_type_string() == "Optional[str]" +class TestDateTimeProperty: + def test_transform(self, mocker): + name = "thePropertyName" + mocker.patch(f"{MODULE_NAME}.Reference.from_ref") + + from openapi_python_client.openapi_parser.properties import DateTimeProperty + + prop = DateTimeProperty(name=name, required=True, default=None) + + assert prop.transform() == f"the_property_name.isoformat()" + + +class TestDateProperty: + def test_transform(self, mocker): + name = "thePropertyName" + mocker.patch(f"{MODULE_NAME}.Reference.from_ref") + + from openapi_python_client.openapi_parser.properties import DateProperty + + prop = DateProperty(name=name, required=True, default=None) + + assert prop.transform() == f"the_property_name.isoformat()" + + class TestBasicListProperty: def test_constructor_from_dict(self): from openapi_python_client.openapi_parser.properties import BasicListProperty @@ -316,6 +340,30 @@ def test_property_from_dict_string_datetime_format(self, mocker): ) DateTimeProperty.assert_called_once_with(name=name, required=required, default=data["default"]) + def test_property_from_dict_string_date_format(self, mocker): + name = mocker.MagicMock() + required = mocker.MagicMock() + data = { + "type": "string", + "format": "date", + } + DateProperty = mocker.patch(f"{MODULE_NAME}.DateProperty") + + from openapi_python_client.openapi_parser.properties import property_from_dict + + p = property_from_dict(name=name, required=required, data=data) + DateProperty.assert_called_once_with(name=name, required=required, default=None) + assert p == DateProperty() + + # Test optional values + DateProperty.reset_mock() + data["default"] = mocker.MagicMock() + + property_from_dict( + name=name, required=required, data=data, + ) + DateProperty.assert_called_once_with(name=name, required=required, default=data["default"]) + def test_property_from_dict_string_unsupported_format(self, mocker): name = mocker.MagicMock() required = mocker.MagicMock()