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..8699ef6ab 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: @@ -31,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 """ @@ -163,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 """ @@ -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..e7effdc77 --- /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_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" + + +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] 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")