From 414319d450afcaf15d9494843ed507a0933358c8 Mon Sep 17 00:00:00 2001 From: Adam Gray Date: Sun, 12 Apr 2020 09:44:36 +0100 Subject: [PATCH 01/10] Convert property and endpoint names to snake case in Python code --- openapi_python_client/__init__.py | 6 ++++++ .../openapi_parser/properties.py | 7 ++++++- .../openapi_parser/reference.py | 6 +++--- .../templates/async_endpoint_module.pyi | 13 ++++++++---- .../templates/datetime_property.pyi | 8 ++++---- .../templates/endpoint_module.pyi | 13 ++++++++---- .../templates/enum_list_property.pyi | 6 +++--- openapi_python_client/templates/model.pyi | 8 ++++---- .../templates/ref_property.pyi | 8 ++++---- .../templates/reference_list_property.pyi | 6 +++--- openapi_python_client/utils.py | 12 +++++++++++ .../my_test_api_client/api/users.py | 2 +- .../my_test_api_client/async_api/users.py | 2 +- .../my_test_api_client/models/__init__.py | 2 +- ...tion_error.py => http_validation_error.py} | 0 tests/test_end_to_end/test_end_to_end.py | 3 ++- tests/test_end_to_end/test_utils.py | 20 +++++++++++++++++++ tests/test_openapi_parser/test_openapi.py | 4 ++-- 18 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 openapi_python_client/utils.py rename tests/test_end_to_end/golden-master/my_test_api_client/models/{h_t_t_p_validation_error.py => http_validation_error.py} (100%) create mode 100644 tests/test_end_to_end/test_utils.py diff --git a/openapi_python_client/__init__.py b/openapi_python_client/__init__.py index 2985af7b7..9a51e3738 100644 --- a/openapi_python_client/__init__.py +++ b/openapi_python_client/__init__.py @@ -8,10 +8,12 @@ from pathlib import Path from typing import Any, Dict, Optional +import stringcase import httpx import yaml from jinja2 import Environment, PackageLoader +from openapi_python_client import utils from .openapi_parser import OpenAPI, import_string_from_reference __version__ = version(__package__) @@ -61,6 +63,8 @@ def _get_json(*, url: Optional[str], path: Optional[Path]) -> Dict[str, Any]: class _Project: + TEMPLATE_FILTERS = {"snakecase": utils.snake_case} + def __init__(self, *, openapi: OpenAPI) -> None: self.openapi: OpenAPI = openapi self.env: Environment = Environment(loader=PackageLoader(__package__), trim_blocks=True, lstrip_blocks=True) @@ -72,6 +76,8 @@ def __init__(self, *, openapi: OpenAPI) -> None: self.package_dir: Path = self.project_dir / self.package_name self.package_description = f"A client library for accessing {self.openapi.title}" + self.env.filters.update(self.TEMPLATE_FILTERS) + def build(self) -> None: """ Create the project from templates """ diff --git a/openapi_python_client/openapi_parser/properties.py b/openapi_python_client/openapi_parser/properties.py index fc092b1a6..70cae2634 100644 --- a/openapi_python_client/openapi_parser/properties.py +++ b/openapi_python_client/openapi_parser/properties.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, field from typing import Any, ClassVar, Dict, List, Optional +from openapi_python_client import utils from .reference import Reference @@ -15,6 +16,10 @@ class Property: constructor_template: ClassVar[Optional[str]] = None _type_string: ClassVar[str] + @property + def python_name(self): + return utils.snake_case(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: @@ -201,7 +206,7 @@ def get_type_string(self) -> str: def transform(self) -> str: """ Convert this into a JSONable value """ - return f"{self.name}.to_dict()" + return f"{self.python_name}.to_dict()" @dataclass diff --git a/openapi_python_client/openapi_parser/reference.py b/openapi_python_client/openapi_parser/reference.py index 8174dc071..1b04402aa 100644 --- a/openapi_python_client/openapi_parser/reference.py +++ b/openapi_python_client/openapi_parser/reference.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Dict -import stringcase +from .. import utils class_overrides: Dict[str, Reference] = {} @@ -21,9 +21,9 @@ class Reference: def from_ref(ref: str) -> Reference: """ Get a Reference from the openapi #/schemas/blahblah string """ ref_value = ref.split("/")[-1] - class_name = stringcase.pascalcase(ref_value) + class_name = utils.pascal_case(ref_value) if class_name in class_overrides: return class_overrides[class_name] - return Reference(class_name=class_name, module_name=stringcase.snakecase(ref_value),) + return Reference(class_name=class_name, module_name=utils.snake_case(ref_value),) diff --git a/openapi_python_client/templates/async_endpoint_module.pyi b/openapi_python_client/templates/async_endpoint_module.pyi index 8025cb2b0..f2260a6cc 100644 --- a/openapi_python_client/templates/async_endpoint_module.pyi +++ b/openapi_python_client/templates/async_endpoint_module.pyi @@ -12,7 +12,7 @@ from ..errors import ApiResponseError {% for endpoint in collection.endpoints %} -async def {{ endpoint.name }}( +async def {{ endpoint.name | snakecase }}( *, {# Proper client based on whether or not the endpoint requires authentication #} {% if endpoint.requires_security %} @@ -42,7 +42,12 @@ async def {{ endpoint.name }}( {% endfor %} ]: """ {{ endpoint.description }} """ - url = f"{client.base_url}{{ endpoint.path }}" + url = "{}{{ endpoint.path }}".format( + client.base_url + {%- for parameter in endpoint.path_parameters -%} + ,{{parameter.name}}={{parameter.python_name}} + {%- endfor -%} + ) {% if endpoint.query_parameters %} params = { @@ -54,8 +59,8 @@ async def {{ endpoint.name }}( } {% for parameter in endpoint.query_parameters %} {% if not parameter.required %} - if {{ parameter.name }} is not None: - params["{{ parameter.name }}"] = {{ parameter.transform() }} + if {{ parameter.python_name }} is not None: + params["{{ parameter.name }}"] = str({{ parameter.transform() }}) {% endif %} {% endfor %} {% endif %} diff --git a/openapi_python_client/templates/datetime_property.pyi b/openapi_python_client/templates/datetime_property.pyi index a4fcec28a..007a15d47 100644 --- a/openapi_python_client/templates/datetime_property.pyi +++ b/openapi_python_client/templates/datetime_property.pyi @@ -1,7 +1,7 @@ {% if property.required %} - {{ property.name }} = datetime.fromisoformat(d["{{ property.name }}"]) + {{ property.python_name }} = datetime.fromisoformat(d["{{ property.name }}"]) {% else %} - {{ property.name }} = None - if ({{ property.name }}_string := d.get("{{ property.name }}")) is not None: - {{ property.name }} = datetime.fromisoformat(cast(str, {{ property.name }}_string)) + {{ 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)) {% endif %} diff --git a/openapi_python_client/templates/endpoint_module.pyi b/openapi_python_client/templates/endpoint_module.pyi index 5383da950..5fdb836f1 100644 --- a/openapi_python_client/templates/endpoint_module.pyi +++ b/openapi_python_client/templates/endpoint_module.pyi @@ -12,7 +12,7 @@ from ..errors import ApiResponseError {% for endpoint in collection.endpoints %} -def {{ endpoint.name }}( +def {{ endpoint.name | snakecase }}( *, {# Proper client based on whether or not the endpoint requires authentication #} {% if endpoint.requires_security %} @@ -42,7 +42,12 @@ def {{ endpoint.name }}( {% endfor %} ]: """ {{ endpoint.description }} """ - url = f"{client.base_url}{{ endpoint.path }}" + url = "{}{{ endpoint.path }}".format( + client.base_url + {%- for parameter in endpoint.path_parameters -%} + ,{{parameter.name}}={{parameter.python_name}} + {%- endfor -%} + ) {% if endpoint.query_parameters %} params = { @@ -54,8 +59,8 @@ def {{ endpoint.name }}( } {% for parameter in endpoint.query_parameters %} {% if not parameter.required %} - if {{ parameter.name }} is not None: - params["{{ parameter.name }}"] = {{ parameter.transform() }} + if {{ parameter.python_name }} is not None: + params["{{ parameter.name }}"] = str({{ parameter.transform() }}) {% endif %} {% endfor %} {% endif %} diff --git a/openapi_python_client/templates/enum_list_property.pyi b/openapi_python_client/templates/enum_list_property.pyi index d3f1e9a91..b73ed0dcf 100644 --- a/openapi_python_client/templates/enum_list_property.pyi +++ b/openapi_python_client/templates/enum_list_property.pyi @@ -1,3 +1,3 @@ - {{ property.name }} = [] - for {{ property.name }}_item in d.get("{{ property.name }}", []): - {{ property.name }}.append({{ property.reference.class_name }}({{ property.name }}_item)) + {{ 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/model.pyi b/openapi_python_client/templates/model.pyi index 25d0648e6..acf2b3bad 100644 --- a/openapi_python_client/templates/model.pyi +++ b/openapi_python_client/templates/model.pyi @@ -22,8 +22,8 @@ class {{ schema.reference.class_name }}: "{{ property.name }}": self.{{ property.transform() }}, {% endfor %} {% for property in schema.optional_properties %} - "{{ property.name }}": self.{{ property.transform() }} if self.{{ property.name }} is not None else None, - {% endfor %} + "{{ property.name }}": self.{{ property.transform() }} if self.{{property.python_name}} is not None else None, + {% endfor %} } @staticmethod @@ -33,12 +33,12 @@ class {{ schema.reference.class_name }}: {% if property.constructor_template %} {% include property.constructor_template %} {% else %} - {{ property.name }} = {{ property.constructor_from_dict("d") }} + {{property.python_name}} = {{property.constructor_from_dict("d")}} {% endif %} {% endfor %} return {{ schema.reference.class_name }}( {% for property in schema.required_properties + schema.optional_properties %} - {{ property.name }}={{ property.name }}, + {{ property.python_name }}={{ property.python_name }}, {% endfor %} ) diff --git a/openapi_python_client/templates/ref_property.pyi b/openapi_python_client/templates/ref_property.pyi index aeefddc0c..9ba9fb858 100644 --- a/openapi_python_client/templates/ref_property.pyi +++ b/openapi_python_client/templates/ref_property.pyi @@ -1,7 +1,7 @@ {% if property.required %} - {{ property.name }} = {{ property.reference.class_name }}.from_dict(d["{{ property.name }}"]) + {{ property.python_name }} = {{ property.reference.class_name }}.from_dict(d["{{ property.name }}"]) {% else %} - {{ property.name }} = None - if ({{ property.name }}_data := d.get("{{ property.name }}")) is not None: - {{ property.name }} = {{ property.reference.class_name }}.from_dict(cast(Dict, {{ property.name }}_data)) + {{ 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)) {% endif %} diff --git a/openapi_python_client/templates/reference_list_property.pyi b/openapi_python_client/templates/reference_list_property.pyi index f635ebc0b..7bea9c252 100644 --- a/openapi_python_client/templates/reference_list_property.pyi +++ b/openapi_python_client/templates/reference_list_property.pyi @@ -1,3 +1,3 @@ - {{ property.name }} = [] - for {{ property.name }}_item in d.get("{{ property.name }}", []): - {{ property.name }}.append({{ property.reference.class_name }}.from_dict({{ property.name }}_item)) + {{ 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/openapi_python_client/utils.py b/openapi_python_client/utils.py new file mode 100644 index 000000000..9c41e756d --- /dev/null +++ b/openapi_python_client/utils.py @@ -0,0 +1,12 @@ +import stringcase +import re + + +def snake_case(value: str) -> str: + value = re.sub(r"([A-Z]{2,})([A-Z][a-z]|[ -_]|$)", lambda m: m.group(1).title() + m.group(2), value.strip()) + value = re.sub(r"(^|[ _-])([A-Z])", lambda m: m.group(1) + m.group(2).lower(), value) + return stringcase.snakecase(value) + + +def pascal_case(value: str) -> str: + return stringcase.pascalcase(value) 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 9695442a5..98069c344 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 @@ -6,7 +6,7 @@ from ..client import AuthenticatedClient, Client from ..errors import ApiResponseError from ..models.a_model import AModel -from ..models.h_t_t_p_validation_error import HTTPValidationError +from ..models.http_validation_error import HTTPValidationError from ..models.statuses import Statuses 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 32ceb45b6..fd8ebc26c 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 @@ -6,7 +6,7 @@ from ..client import AuthenticatedClient, Client from ..errors import ApiResponseError from ..models.a_model import AModel -from ..models.h_t_t_p_validation_error import HTTPValidationError +from ..models.http_validation_error import HTTPValidationError from ..models.statuses import Statuses diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/models/__init__.py b/tests/test_end_to_end/golden-master/my_test_api_client/models/__init__.py index 09b1e8734..6b456ed3b 100644 --- a/tests/test_end_to_end/golden-master/my_test_api_client/models/__init__.py +++ b/tests/test_end_to_end/golden-master/my_test_api_client/models/__init__.py @@ -4,7 +4,7 @@ from .a_model import AModel from .abc_response import ABCResponse from .an_enum_value import AnEnumValue -from .h_t_t_p_validation_error import HTTPValidationError +from .http_validation_error import HTTPValidationError from .other_model import OtherModel from .statuses import Statuses from .validation_error import ValidationError diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/models/h_t_t_p_validation_error.py b/tests/test_end_to_end/golden-master/my_test_api_client/models/http_validation_error.py similarity index 100% rename from tests/test_end_to_end/golden-master/my_test_api_client/models/h_t_t_p_validation_error.py rename to tests/test_end_to_end/golden-master/my_test_api_client/models/http_validation_error.py diff --git a/tests/test_end_to_end/test_end_to_end.py b/tests/test_end_to_end/test_end_to_end.py index adca12695..a043871c9 100644 --- a/tests/test_end_to_end/test_end_to_end.py +++ b/tests/test_end_to_end/test_end_to_end.py @@ -18,7 +18,8 @@ def _compare_directories(first: Path, second: Path, /): match, mismatch, errors = cmpfiles(first, second, dc.common_files, shallow=False) if mismatch: - pytest.fail(f"{first_printable} and {second_printable} had differing files: {mismatch}", pytrace=False) + for error in errors: + pytest.fail(f"{first_printable} and {second_printable} had differing files: {mismatch}, first error is {error}", pytrace=False) for sub_path in dc.common_dirs: _compare_directories(first / sub_path, second / sub_path) diff --git a/tests/test_end_to_end/test_utils.py b/tests/test_end_to_end/test_utils.py new file mode 100644 index 000000000..e665e9ab3 --- /dev/null +++ b/tests/test_end_to_end/test_utils.py @@ -0,0 +1,20 @@ +from openapi_python_client import utils + + +def test_snake_case_uppercase_str(): + assert utils.snake_case("HTTP") == "http" + assert utils.snake_case("HTTP RESPONSE") == "http_response" + + +def test_snake_case_from_pascal_with_acronums(): + assert utils.snake_case("HTTPResponse") == "http_response" + assert utils.snake_case("APIClientHTTPResponse") == "api_client_http_response" + assert utils.snake_case("OAuthClientHTTPResponse") == "o_auth_client_http_response" + + +def test_snake_Case_from_pascal(): + assert utils.snake_case("HttpResponsePascalCase") == "http_response_pascal_case" + + +def test_snake_case_from_camel(): + assert utils.snake_case("httpResponseLowerCamel") == "http_response_lower_camel" diff --git a/tests/test_openapi_parser/test_openapi.py b/tests/test_openapi_parser/test_openapi.py index 537193562..82119d151 100644 --- a/tests/test_openapi_parser/test_openapi.py +++ b/tests/test_openapi_parser/test_openapi.py @@ -43,7 +43,7 @@ def test__check_enums(self, mocker): from openapi_python_client.openapi_parser.properties import EnumProperty, StringProperty def _make_enum(): - return EnumProperty(name=mocker.MagicMock(), required=True, default=None, values=mocker.MagicMock(),) + return EnumProperty(name=str(mocker.MagicMock()), required=True, default=None, values=mocker.MagicMock(),) # Multiple schemas with both required and optional properties for making sure iteration works correctly schema_1 = mocker.MagicMock() @@ -119,7 +119,7 @@ def test__check_enums_bad_duplicate(self, mocker): schema = mocker.MagicMock() - enum_1 = EnumProperty(name=mocker.MagicMock(), required=True, default=None, values=mocker.MagicMock(),) + enum_1 = EnumProperty(name=str(mocker.MagicMock()), required=True, default=None, values=mocker.MagicMock(),) enum_2 = replace(enum_1, values=mocker.MagicMock()) schema.required_properties = [enum_1, enum_2] From 74f1bee3a6edc3a5e346ab7f2c216612363b2e52 Mon Sep 17 00:00:00 2001 From: Adam Gray Date: Sun, 12 Apr 2020 10:46:39 +0100 Subject: [PATCH 02/10] typo --- tests/test_end_to_end/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_end_to_end/test_utils.py b/tests/test_end_to_end/test_utils.py index e665e9ab3..6ddd87d04 100644 --- a/tests/test_end_to_end/test_utils.py +++ b/tests/test_end_to_end/test_utils.py @@ -12,7 +12,7 @@ def test_snake_case_from_pascal_with_acronums(): assert utils.snake_case("OAuthClientHTTPResponse") == "o_auth_client_http_response" -def test_snake_Case_from_pascal(): +def test_snake_case_from_pascal(): assert utils.snake_case("HttpResponsePascalCase") == "http_response_pascal_case" From a1a154bcefc3a44d85910dd1c2c74d0cc722c6f8 Mon Sep 17 00:00:00 2001 From: Adam Gray Date: Sun, 12 Apr 2020 10:48:24 +0100 Subject: [PATCH 03/10] another typo --- tests/test_end_to_end/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_end_to_end/test_utils.py b/tests/test_end_to_end/test_utils.py index 6ddd87d04..e7effdc77 100644 --- a/tests/test_end_to_end/test_utils.py +++ b/tests/test_end_to_end/test_utils.py @@ -6,7 +6,7 @@ def test_snake_case_uppercase_str(): assert utils.snake_case("HTTP RESPONSE") == "http_response" -def test_snake_case_from_pascal_with_acronums(): +def test_snake_case_from_pascal_with_acronyms(): assert utils.snake_case("HTTPResponse") == "http_response" assert utils.snake_case("APIClientHTTPResponse") == "api_client_http_response" assert utils.snake_case("OAuthClientHTTPResponse") == "o_auth_client_http_response" From b2c73fd7d6c340ac608a4866df8a82ac4f497bec Mon Sep 17 00:00:00 2001 From: Adam Gray Date: Sun, 12 Apr 2020 12:06:27 +0100 Subject: [PATCH 04/10] a few missed names --- openapi_python_client/openapi_parser/properties.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openapi_python_client/openapi_parser/properties.py b/openapi_python_client/openapi_parser/properties.py index 70cae2634..70a8b5d7d 100644 --- a/openapi_python_client/openapi_parser/properties.py +++ b/openapi_python_client/openapi_parser/properties.py @@ -36,13 +36,13 @@ def to_string(self) -> str: default = None if default is not None: - return f"{self.name}: {self.get_type_string()} = {self.default}" + return f"{self.python_name}: {self.get_type_string()} = {self.default}" else: - return f"{self.name}: {self.get_type_string()}" + return f"{self.python_name}: {self.get_type_string()}" def transform(self) -> str: """ What it takes to turn this object into a native python type """ - return self.name + 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 """ From ec5c87a1e228c73abdf9d4f3ca2ec4d78fd408b7 Mon Sep 17 00:00:00 2001 From: Adam Gray Date: Sun, 12 Apr 2020 13:07:14 +0100 Subject: [PATCH 05/10] One more missed case --- openapi_python_client/openapi_parser/properties.py | 2 +- tests/test_openapi_parser/test_properties.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/openapi_python_client/openapi_parser/properties.py b/openapi_python_client/openapi_parser/properties.py index 70a8b5d7d..8699ef6ab 100644 --- a/openapi_python_client/openapi_parser/properties.py +++ b/openapi_python_client/openapi_parser/properties.py @@ -168,7 +168,7 @@ def get_type_string(self) -> str: def transform(self) -> str: """ Output to the template, convert this Enum into a JSONable value """ - return f"{self.name}.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 """ diff --git a/tests/test_openapi_parser/test_properties.py b/tests/test_openapi_parser/test_properties.py index ead21aed0..bfd962366 100644 --- a/tests/test_openapi_parser/test_properties.py +++ b/tests/test_openapi_parser/test_properties.py @@ -20,20 +20,22 @@ def test_to_string(self, mocker): name = mocker.MagicMock() p = Property(name=name, required=True, default=None) get_type_string = mocker.patch.object(p, "get_type_string") + snake_case = mocker.patch(f"openapi_python_client.utils.snake_case") - assert p.to_string() == f"{name}: {get_type_string()}" + assert p.to_string() == f"{snake_case(name)}: {get_type_string()}" p.required = False - assert p.to_string() == f"{name}: {get_type_string()} = None" + assert p.to_string() == f"{snake_case(name)}: {get_type_string()} = None" p.default = "TEST" - assert p.to_string() == f"{name}: {get_type_string()} = TEST" + assert p.to_string() == f"{snake_case(name)}: {get_type_string()} = TEST" def test_transform(self, mocker): from openapi_python_client.openapi_parser.properties import Property name = mocker.MagicMock() p = Property(name=name, required=True, default=None) - assert p.transform() == name + snake_case = mocker.patch(f"openapi_python_client.utils.snake_case") + assert p.transform() == snake_case(name) def test_constructor_from_dict(self, mocker): from openapi_python_client.openapi_parser.properties import Property @@ -150,14 +152,14 @@ def test_get_type_string(self, mocker): assert enum_property.get_type_string() == "Optional[MyTestEnum]" def test_transform(self, mocker): - name = mocker.MagicMock() + name = "thePropertyName" mocker.patch(f"{MODULE_NAME}.Reference.from_ref") from openapi_python_client.openapi_parser.properties import EnumProperty enum_property = EnumProperty(name=name, required=True, default=None, values={}) - assert enum_property.transform() == f"{name}.value" + assert enum_property.transform() == f"the_property_name.value" def test_constructor_from_dict(self, mocker): fake_reference = mocker.MagicMock(class_name="MyTestEnum") From aed967c61caafd52eb3ef29f84be39cf3ef5fbdf Mon Sep 17 00:00:00 2001 From: Dylan Anthony Date: Sat, 25 Apr 2020 16:09:48 -0400 Subject: [PATCH 06/10] Clean up a few items for #29 --- openapi_python_client/__init__.py | 2 +- openapi_python_client/openapi_parser/properties.py | 8 +++++--- openapi_python_client/templates/model.pyi | 4 ++-- openapi_python_client/utils.py | 3 ++- tests/test_end_to_end/test_end_to_end.py | 5 ++++- tests/{test_end_to_end => }/test_utils.py | 0 6 files changed, 14 insertions(+), 8 deletions(-) rename tests/{test_end_to_end => }/test_utils.py (100%) diff --git a/openapi_python_client/__init__.py b/openapi_python_client/__init__.py index 9a51e3738..bc0e0ae03 100644 --- a/openapi_python_client/__init__.py +++ b/openapi_python_client/__init__.py @@ -8,12 +8,12 @@ from pathlib import Path from typing import Any, Dict, Optional -import stringcase import httpx import yaml from jinja2 import Environment, PackageLoader from openapi_python_client import utils + from .openapi_parser import OpenAPI, import_string_from_reference __version__ = version(__package__) diff --git a/openapi_python_client/openapi_parser/properties.py b/openapi_python_client/openapi_parser/properties.py index 8e30f0cd4..3a6ecbf2f 100644 --- a/openapi_python_client/openapi_parser/properties.py +++ b/openapi_python_client/openapi_parser/properties.py @@ -2,6 +2,7 @@ from typing import Any, ClassVar, Dict, List, Optional from openapi_python_client import utils + from .reference import Reference @@ -16,9 +17,10 @@ class Property: constructor_template: ClassVar[Optional[str]] = None _type_string: ClassVar[str] - @property - def python_name(self): - return utils.snake_case(self.name) + python_name: str = field(init=False) + + def __post_init__(self) -> None: + self.python_name = utils.snake_case(self.name) def get_type_string(self) -> str: """ Get a string representation of type that should be used when declaring this property """ diff --git a/openapi_python_client/templates/model.pyi b/openapi_python_client/templates/model.pyi index 1cf0e7020..57bebf8ac 100644 --- a/openapi_python_client/templates/model.pyi +++ b/openapi_python_client/templates/model.pyi @@ -22,7 +22,7 @@ class {{ schema.reference.class_name }}: "{{ property.name }}": self.{{ property.transform() }}, {% endfor %} {% for property in schema.optional_properties %} - "{{ property.name }}": self.{{ property.transform() }} if self.{{property.python_name}} is not None else None, + "{{ property.name }}": self.{{ property.transform() }} if self.{{ property.python_name }} is not None else None, {% endfor %} } @@ -33,7 +33,7 @@ class {{ schema.reference.class_name }}: {% if property.constructor_template %} {% include property.constructor_template %} {% else %} - {{property.python_name}} = {{property.constructor_from_dict("d")}} + {{ property.python_name }} = {{ property.constructor_from_dict("d") }} {% endif %} {% endfor %} diff --git a/openapi_python_client/utils.py b/openapi_python_client/utils.py index 9c41e756d..bce831deb 100644 --- a/openapi_python_client/utils.py +++ b/openapi_python_client/utils.py @@ -1,6 +1,7 @@ -import stringcase import re +import stringcase + def snake_case(value: str) -> str: value = re.sub(r"([A-Z]{2,})([A-Z][a-z]|[ -_]|$)", lambda m: m.group(1).title() + m.group(2), value.strip()) diff --git a/tests/test_end_to_end/test_end_to_end.py b/tests/test_end_to_end/test_end_to_end.py index e312c2873..8e5c3a95c 100644 --- a/tests/test_end_to_end/test_end_to_end.py +++ b/tests/test_end_to_end/test_end_to_end.py @@ -19,7 +19,10 @@ def _compare_directories(first: Path, second: Path, /): match, mismatch, errors = cmpfiles(first, second, dc.common_files, shallow=False) if mismatch: for error in errors: - pytest.fail(f"{first_printable} and {second_printable} had differing files: {mismatch}, first error is {error}", pytrace=False) + pytest.fail( + f"{first_printable} and {second_printable} had differing files: {mismatch}, first error is {error}", + pytrace=False, + ) for sub_path in dc.common_dirs: _compare_directories(first / sub_path, second / sub_path) diff --git a/tests/test_end_to_end/test_utils.py b/tests/test_utils.py similarity index 100% rename from tests/test_end_to_end/test_utils.py rename to tests/test_utils.py From b523f9058b86a27142b768c0bdf6a4f5f05e7cc5 Mon Sep 17 00:00:00 2001 From: Dylan Anthony Date: Sat, 25 Apr 2020 16:21:27 -0400 Subject: [PATCH 07/10] Call super post_init and fix some broken tests. #29 --- openapi_python_client/openapi_parser/properties.py | 3 +++ tests/test_openapi_parser/test_properties.py | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/openapi_python_client/openapi_parser/properties.py b/openapi_python_client/openapi_parser/properties.py index 3a6ecbf2f..e96a93a9d 100644 --- a/openapi_python_client/openapi_parser/properties.py +++ b/openapi_python_client/openapi_parser/properties.py @@ -64,6 +64,7 @@ class StringProperty(Property): _type_string: ClassVar[str] = "str" def __post_init__(self) -> None: + super().__post_init__() if self.default is not None: self.default = f'"{self.default}"' @@ -139,6 +140,7 @@ class EnumListProperty(Property): 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: @@ -156,6 +158,7 @@ class EnumProperty(Property): reference: Reference = field(init=False) def __post_init__(self) -> None: + super().__post_init__() self.reference = Reference.from_ref(self.name) inverse_values = {v: k for k, v in self.values.items()} if self.default is not None: diff --git a/tests/test_openapi_parser/test_properties.py b/tests/test_openapi_parser/test_properties.py index 5ce8b699e..a14990351 100644 --- a/tests/test_openapi_parser/test_properties.py +++ b/tests/test_openapi_parser/test_properties.py @@ -18,9 +18,9 @@ def test_to_string(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") p = Property(name=name, required=True, default=None) get_type_string = mocker.patch.object(p, "get_type_string") - snake_case = mocker.patch(f"openapi_python_client.utils.snake_case") assert p.to_string() == f"{snake_case(name)}: {get_type_string()}" p.required = False @@ -33,14 +33,15 @@ def test_transform(self, mocker): from openapi_python_client.openapi_parser.properties import Property name = mocker.MagicMock() - p = Property(name=name, required=True, default=None) snake_case = mocker.patch(f"openapi_python_client.utils.snake_case") + p = Property(name=name, required=True, default=None) assert p.transform() == snake_case(name) def test_constructor_from_dict(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") p = Property(name=name, required=True, default=None) dict_name = mocker.MagicMock() @@ -102,6 +103,7 @@ def test_get_type_string(self, mocker): 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) @@ -127,6 +129,7 @@ def test_get_type_string(self, mocker): class TestEnumProperty: def test___post_init__(self, mocker): name = mocker.MagicMock() + snake_case = 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) @@ -138,6 +141,7 @@ def test___post_init__(self, mocker): from_ref.assert_called_once_with(name) assert enum_property.default == "MyTestEnum.SECOND" + assert enum_property.python_name == snake_case(name) def test_get_type_string(self, mocker): fake_reference = mocker.MagicMock(class_name="MyTestEnum") From 7a66ec9850fa3eddec319956eb9f99007b02136a Mon Sep 17 00:00:00 2001 From: Dylan Anthony Date: Sat, 25 Apr 2020 16:37:15 -0400 Subject: [PATCH 08/10] Update end to end tests with camel case check - Fix broken end to end check - Pretty-print generated OpenAPI document - Add camelCase property and endpoint to E2E --- tests/test_end_to_end/fastapi/__init__.py | 6 +- tests/test_end_to_end/fastapi/openapi.json | 203 +++++++++++++++++- .../my_test_api_client/api/default.py | 2 +- .../my_test_api_client/api/users.py | 4 +- .../my_test_api_client/async_api/default.py | 2 +- .../my_test_api_client/async_api/users.py | 4 +- .../my_test_api_client/models/a_model.py | 6 + tests/test_end_to_end/test_end_to_end.py | 9 +- 8 files changed, 222 insertions(+), 14 deletions(-) diff --git a/tests/test_end_to_end/fastapi/__init__.py b/tests/test_end_to_end/fastapi/__init__.py index 9b7f67a9d..c50950aea 100644 --- a/tests/test_end_to_end/fastapi/__init__.py +++ b/tests/test_end_to_end/fastapi/__init__.py @@ -1,5 +1,6 @@ """ A FastAPI app used to create an OpenAPI document for end-to-end testing """ import json +from datetime import datetime from enum import Enum from pathlib import Path from typing import List @@ -43,9 +44,10 @@ class AModel(BaseModel): a_list_of_enums: List[AnEnum] a_list_of_strings: List[str] a_list_of_objects: List[OtherModel] + aCamelDateTime: datetime -@test_router.get("/", response_model=List[AModel]) +@test_router.get("/", response_model=List[AModel], operation_id="getUserList") def get_list(statuses: List[AnEnum] = Query(...),): """ Get users, filtered by statuses """ return @@ -55,4 +57,4 @@ def get_list(statuses: List[AnEnum] = Query(...),): if __name__ == "__main__": path = Path(__file__).parent / "openapi.json" - path.write_text(json.dumps(app.openapi())) + path.write_text(json.dumps(app.openapi(), indent=4)) diff --git a/tests/test_end_to_end/fastapi/openapi.json b/tests/test_end_to_end/fastapi/openapi.json index 9c20adc69..d8c9665a5 100644 --- a/tests/test_end_to_end/fastapi/openapi.json +++ b/tests/test_end_to_end/fastapi/openapi.json @@ -1 +1,202 @@ -{"openapi": "3.0.2", "info": {"title": "My Test API", "description": "An API for testing openapi-python-client", "version": "0.1.0"}, "paths": {"/ping": {"get": {"summary": "Ping", "description": "A quick check to see if the system is running ", "operationId": "ping_ping_get", "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/_ABCResponse"}}}}}}}, "/tests/": {"get": {"tags": ["users"], "summary": "Get List", "description": "Get users, filtered by statuses ", "operationId": "get_list_tests__get", "parameters": [{"required": true, "schema": {"title": "Statuses", "type": "array", "items": {"enum": ["FIRST_VALUE", "SECOND_VALUE"]}}, "name": "statuses", "in": "query"}], "responses": {"200": {"description": "Successful Response", "content": {"application/json": {"schema": {"title": "Response Get List Tests Get", "type": "array", "items": {"$ref": "#/components/schemas/AModel"}}}}}, "422": {"description": "Validation Error", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/HTTPValidationError"}}}}}}}}, "components": {"schemas": {"AModel": {"title": "AModel", "required": ["an_enum_value", "a_list_of_enums", "a_list_of_strings", "a_list_of_objects"], "type": "object", "properties": {"an_enum_value": {"title": "An Enum Value", "enum": ["FIRST_VALUE", "SECOND_VALUE"]}, "a_list_of_enums": {"title": "A 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"}}}, "description": "A Model for testing all the ways custom objects can be used "}, "HTTPValidationError": {"title": "HTTPValidationError", "type": "object", "properties": {"detail": {"title": "Detail", "type": "array", "items": {"$ref": "#/components/schemas/ValidationError"}}}}, "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": ["loc", "msg", "type"], "type": "object", "properties": {"loc": {"title": "Location", "type": "array", "items": {"type": "string"}}, "msg": {"title": "Message", "type": "string"}, "type": {"title": "Error Type", "type": "string"}}}, "_ABCResponse": {"title": "_ABCResponse", "required": ["success"], "type": "object", "properties": {"success": {"title": "Success", "type": "boolean"}}}}}} \ No newline at end of file +{ + "openapi": "3.0.2", + "info": { + "title": "My Test API", + "description": "An API for testing openapi-python-client", + "version": "0.1.0" + }, + "paths": { + "/ping": { + "get": { + "summary": "Ping", + "description": "A quick check to see if the system is running ", + "operationId": "ping_ping_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/_ABCResponse" + } + } + } + } + } + } + }, + "/tests/": { + "get": { + "tags": [ + "users" + ], + "summary": "Get List", + "description": "Get users, filtered by statuses ", + "operationId": "getUserList", + "parameters": [ + { + "required": true, + "schema": { + "title": "Statuses", + "type": "array", + "items": { + "enum": [ + "FIRST_VALUE", + "SECOND_VALUE" + ] + } + }, + "name": "statuses", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "title": "Response Get List Tests Get", + "type": "array", + "items": { + "$ref": "#/components/schemas/AModel" + } + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AModel": { + "title": "AModel", + "required": [ + "an_enum_value", + "a_list_of_enums", + "a_list_of_strings", + "a_list_of_objects", + "aCamelDateTime" + ], + "type": "object", + "properties": { + "an_enum_value": { + "title": "An Enum Value", + "enum": [ + "FIRST_VALUE", + "SECOND_VALUE" + ] + }, + "a_list_of_enums": { + "title": "A 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" + } + }, + "aCamelDateTime": { + "title": "Acameldatetime", + "type": "string", + "format": "date-time" + } + }, + "description": "A Model for testing all the ways custom objects can be used " + }, + "HTTPValidationError": { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "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": [ + "loc", + "msg", + "type" + ], + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": { + "type": "string" + } + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + } + }, + "_ABCResponse": { + "title": "_ABCResponse", + "required": [ + "success" + ], + "type": "object", + "properties": { + "success": { + "title": "Success", + "type": "boolean" + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/api/default.py b/tests/test_end_to_end/golden-master/my_test_api_client/api/default.py index 58af0a0e8..cfd352ae2 100644 --- a/tests/test_end_to_end/golden-master/my_test_api_client/api/default.py +++ b/tests/test_end_to_end/golden-master/my_test_api_client/api/default.py @@ -14,7 +14,7 @@ def ping_ping_get( ABCResponse, ]: """ A quick check to see if the system is running """ - url = f"{client.base_url}/ping" + url = "{}/ping".format(client.base_url) response = httpx.get(url=url, headers=client.get_headers(),) 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 4c8b513f1..eb6f8515a 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 @@ -10,13 +10,13 @@ from ..models.statuses import Statuses -def get_list_tests__get( +def get_user_list( *, client: Client, statuses: List[Statuses], ) -> Union[ List[AModel], HTTPValidationError, ]: """ Get users, filtered by statuses """ - url = f"{client.base_url}/tests/" + url = "{}/tests/".format(client.base_url) params = { "statuses": statuses, diff --git a/tests/test_end_to_end/golden-master/my_test_api_client/async_api/default.py b/tests/test_end_to_end/golden-master/my_test_api_client/async_api/default.py index 59c54c3be..b0e42fadb 100644 --- a/tests/test_end_to_end/golden-master/my_test_api_client/async_api/default.py +++ b/tests/test_end_to_end/golden-master/my_test_api_client/async_api/default.py @@ -14,7 +14,7 @@ async def ping_ping_get( ABCResponse, ]: """ A quick check to see if the system is running """ - url = f"{client.base_url}/ping" + url = "{}/ping".format(client.base_url) async with httpx.AsyncClient() as _client: response = await _client.get(url=url, headers=client.get_headers(),) 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 1ad89dc7f..0bd8cd973 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 @@ -10,13 +10,13 @@ from ..models.statuses import Statuses -async def get_list_tests__get( +async def get_user_list( *, client: Client, statuses: List[Statuses], ) -> Union[ List[AModel], HTTPValidationError, ]: """ Get users, filtered by statuses """ - url = f"{client.base_url}/tests/" + url = "{}/tests/".format(client.base_url) params = { "statuses": statuses, 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 026d63a25..f9a2089ac 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 @@ -17,6 +17,7 @@ class AModel: a_list_of_enums: List[AListOfEnums] a_list_of_strings: List[str] a_list_of_objects: List[OtherModel] + a_camel_date_time: datetime def to_dict(self) -> Dict[str, Any]: return { @@ -24,6 +25,7 @@ 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, } @staticmethod @@ -40,9 +42,13 @@ def from_dict(d: Dict[str, Any]) -> AModel: 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)) + + a_camel_date_time = datetime.fromisoformat(d["aCamelDateTime"]) + 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, ) diff --git a/tests/test_end_to_end/test_end_to_end.py b/tests/test_end_to_end/test_end_to_end.py index 8e5c3a95c..75516f03f 100644 --- a/tests/test_end_to_end/test_end_to_end.py +++ b/tests/test_end_to_end/test_end_to_end.py @@ -18,11 +18,10 @@ def _compare_directories(first: Path, second: Path, /): match, mismatch, errors = cmpfiles(first, second, dc.common_files, shallow=False) if mismatch: - for error in errors: - pytest.fail( - f"{first_printable} and {second_printable} had differing files: {mismatch}, first error is {error}", - pytrace=False, - ) + pytest.fail( + f"{first_printable} and {second_printable} had differing files: {mismatch}, and errors {errors}", + pytrace=False, + ) for sub_path in dc.common_dirs: _compare_directories(first / sub_path, second / sub_path) From a4cd35e77a7930a9814df85581b5bbd8eda3d459 Mon Sep 17 00:00:00 2001 From: Dylan Anthony Date: Sat, 25 Apr 2020 16:39:32 -0400 Subject: [PATCH 09/10] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67649146c..efc199c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixes - Fixed some typing issues in generated clients and incorporate mypy into end to end tests (#32). Thanks @acgray! +- Properly handle camelCase endpoint names and properties (#29, #36). Thanks @acgray! ## 0.2.1 - 2020-03-22 ### Fixes From a6b59e4abe37e4fff460d54422591141e4bf7976 Mon Sep 17 00:00:00 2001 From: Dylan Anthony Date: Sat, 25 Apr 2020 16:44:59 -0400 Subject: [PATCH 10/10] Update class overrides example to remove irrelevant limitation --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 33d1ede27..182196ee1 100644 --- a/README.md +++ b/README.md @@ -67,16 +67,17 @@ You can pass a YAML (or JSON) file to openapi-python-client in order to change s are supported: ### class_overrides -Used to change the name of generated model classes, especially useful if you have a name like ABCModel which, when -converted to snake case for module naming will be a_b_c_model. This param should be a mapping of existing class name -(usually a key in the "schemas" section of your OpenAPI document) to class_name and module_name. +Used to change the name of generated model classes. This param should be a mapping of existing class name +(usually a key in the "schemas" section of your OpenAPI document) to class_name and module_name. As an example, if the +name of the a model in OpenAPI (and therefore the generated class name) was something like "_PrivateInternalLongName" +and you want the generated client's model to be called "ShortName" in a module called "short_name" you could do this: Example: ```yaml class_overrides: - ABCModel: - class_name: ABCModel - module_name: abc_model + _PrivateInternalLongName: + class_name: ShortName + module_name: short_name ``` The easiest way to find what needs to be overridden is probably to generate your client and go look at everything in the