diff --git a/end_to_end_tests/__snapshots__/test_end_to_end.ambr b/end_to_end_tests/__snapshots__/test_end_to_end.ambr index dac0127aa..01e5b471c 100644 --- a/end_to_end_tests/__snapshots__/test_end_to_end.ambr +++ b/end_to_end_tests/__snapshots__/test_end_to_end.ambr @@ -9,6 +9,25 @@ Circular $ref in request body + If you believe this was a mistake or this tool is missing a feature you need, please open an issue at https://github.com/openapi-generators/openapi-python-client/issues/new/choose + + ''' +# --- +# name: test_documents_with_errors[invalid-uuid-defaults] + ''' + Generating /test-documents-with-errors + Warning(s) encountered while generating. Client was generated, but some pieces may be missing + + WARNING parsing PUT / within default. Endpoint will not be generated. + + cannot parse parameter of endpoint put_: Invalid UUID value: 3 + + + WARNING parsing POST / within default. Endpoint will not be generated. + + cannot parse parameter of endpoint post_: Invalid UUID value: notauuid + + If you believe this was a mistake or this tool is missing a feature you need, please open an issue at https://github.com/openapi-generators/openapi-python-client/issues/new/choose ''' diff --git a/end_to_end_tests/baseline_openapi_3.0.json b/end_to_end_tests/baseline_openapi_3.0.json index 468e3532d..e5bbaf6fc 100644 --- a/end_to_end_tests/baseline_openapi_3.0.json +++ b/end_to_end_tests/baseline_openapi_3.0.json @@ -1714,6 +1714,8 @@ "aCamelDateTime", "a_date", "a_nullable_date", + "a_uuid", + "a_nullable_uuid", "required_nullable", "required_not_nullable", "model", @@ -1784,6 +1786,23 @@ "type": "string", "format": "date" }, + "a_uuid": { + "title": "A Uuid", + "type": "string", + "format": "uuid" + }, + "a_nullable_uuid": { + "title": "A Nullable Uuid", + "type": "string", + "format": "uuid", + "nullable": true, + "default": "07EF8B4D-AA09-4FFA-898D-C710796AFF41" + }, + "a_not_required_uuid": { + "title": "A Not Required Uuid", + "type": "string", + "format": "uuid" + }, "1_leading_digit": { "title": "Leading Digit", "type": "string" diff --git a/end_to_end_tests/baseline_openapi_3.1.yaml b/end_to_end_tests/baseline_openapi_3.1.yaml index 5b01d9e29..b6a6941e2 100644 --- a/end_to_end_tests/baseline_openapi_3.1.yaml +++ b/end_to_end_tests/baseline_openapi_3.1.yaml @@ -1699,6 +1699,8 @@ info: "aCamelDateTime", "a_date", "a_nullable_date", + "a_uuid", + "a_nullable_uuid", "required_nullable", "required_not_nullable", "model", @@ -1771,6 +1773,29 @@ info: "type": "string", "format": "date" }, + "a_uuid": { + "title": "A Uuid", + "type": "string", + "format": "uuid" + }, + "a_nullable_uuid": { + "title": "A Nullable Uuid", + "anyOf": [ + { + "type": "string", + "format": "uuid", + }, + { + "type": "null" + } + ], + "default": "07EF8B4D-AA09-4FFA-898D-C710796AFF41" + }, + "a_not_required_uuid": { + "title": "A Not Required Uuid", + "type": "string", + "format": "uuid" + }, "1_leading_digit": { "title": "Leading Digit", "type": "string" diff --git a/end_to_end_tests/documents_with_errors/invalid-uuid-defaults.yaml b/end_to_end_tests/documents_with_errors/invalid-uuid-defaults.yaml new file mode 100644 index 000000000..dd768de4f --- /dev/null +++ b/end_to_end_tests/documents_with_errors/invalid-uuid-defaults.yaml @@ -0,0 +1,30 @@ +openapi: "3.1.0" +info: + title: "Circular Body Ref" + version: "0.1.0" +paths: + /: + post: + parameters: + - name: id + in: query + required: false + schema: + type: string + format: uuid + default: "notauuid" + responses: + "200": + description: "Successful Response" + put: + parameters: + - name: another_id + in: query + required: false + schema: + type: string + format: uuid + default: 3 + responses: + "200": + description: "Successful Response" \ No newline at end of file diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/a_model.py b/end_to_end_tests/golden-record/my_test_api_client/models/a_model.py index 054e6bfa7..a14400c9d 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/a_model.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/a_model.py @@ -1,5 +1,6 @@ import datetime from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union, cast +from uuid import UUID from attrs import define as _attrs_define from dateutil.parser import isoparse @@ -27,6 +28,8 @@ class AModel: a_camel_date_time (Union[datetime.date, datetime.datetime]): a_date (datetime.date): a_nullable_date (Union[None, datetime.date]): + a_uuid (UUID): + a_nullable_uuid (Union[None, UUID]): Default: UUID('07EF8B4D-AA09-4FFA-898D-C710796AFF41'). required_nullable (Union[None, str]): required_not_nullable (str): one_of_models (Union['FreeFormModel', 'ModelWithUnionProperty', Any]): @@ -37,6 +40,7 @@ class AModel: an_optional_allof_enum (Union[Unset, AnAllOfEnum]): nested_list_of_enums (Union[Unset, List[List[DifferentEnum]]]): a_not_required_date (Union[Unset, datetime.date]): + a_not_required_uuid (Union[Unset, UUID]): attr_1_leading_digit (Union[Unset, str]): attr_leading_underscore (Union[Unset, str]): not_required_nullable (Union[None, Unset, str]): @@ -51,6 +55,7 @@ class AModel: a_camel_date_time: Union[datetime.date, datetime.datetime] a_date: datetime.date a_nullable_date: Union[None, datetime.date] + a_uuid: UUID required_nullable: Union[None, str] required_not_nullable: str one_of_models: Union["FreeFormModel", "ModelWithUnionProperty", Any] @@ -58,10 +63,12 @@ class AModel: model: "ModelWithUnionProperty" nullable_model: Union["ModelWithUnionProperty", None] an_allof_enum_with_overridden_default: AnAllOfEnum = AnAllOfEnum.OVERRIDDEN_DEFAULT + a_nullable_uuid: Union[None, UUID] = UUID("07EF8B4D-AA09-4FFA-898D-C710796AFF41") any_value: Union[Unset, Any] = "default" an_optional_allof_enum: Union[Unset, AnAllOfEnum] = UNSET nested_list_of_enums: Union[Unset, List[List[DifferentEnum]]] = UNSET a_not_required_date: Union[Unset, datetime.date] = UNSET + a_not_required_uuid: Union[Unset, UUID] = UNSET attr_1_leading_digit: Union[Unset, str] = UNSET attr_leading_underscore: Union[Unset, str] = UNSET not_required_nullable: Union[None, Unset, str] = UNSET @@ -93,6 +100,14 @@ def to_dict(self) -> Dict[str, Any]: else: a_nullable_date = self.a_nullable_date + a_uuid = str(self.a_uuid) + + a_nullable_uuid: Union[None, str] + if isinstance(self.a_nullable_uuid, UUID): + a_nullable_uuid = str(self.a_nullable_uuid) + else: + a_nullable_uuid = self.a_nullable_uuid + required_nullable: Union[None, str] required_nullable = self.required_nullable @@ -143,6 +158,10 @@ def to_dict(self) -> Dict[str, Any]: if not isinstance(self.a_not_required_date, Unset): a_not_required_date = self.a_not_required_date.isoformat() + a_not_required_uuid: Union[Unset, str] = UNSET + if not isinstance(self.a_not_required_uuid, Unset): + a_not_required_uuid = str(self.a_not_required_uuid) + attr_1_leading_digit = self.attr_1_leading_digit attr_leading_underscore = self.attr_leading_underscore @@ -193,6 +212,8 @@ def to_dict(self) -> Dict[str, Any]: "aCamelDateTime": a_camel_date_time, "a_date": a_date, "a_nullable_date": a_nullable_date, + "a_uuid": a_uuid, + "a_nullable_uuid": a_nullable_uuid, "required_nullable": required_nullable, "required_not_nullable": required_not_nullable, "one_of_models": one_of_models, @@ -209,6 +230,8 @@ def to_dict(self) -> Dict[str, Any]: field_dict["nested_list_of_enums"] = nested_list_of_enums if a_not_required_date is not UNSET: field_dict["a_not_required_date"] = a_not_required_date + if a_not_required_uuid is not UNSET: + field_dict["a_not_required_uuid"] = a_not_required_uuid if attr_1_leading_digit is not UNSET: field_dict["1_leading_digit"] = attr_1_leading_digit if attr_leading_underscore is not UNSET: @@ -272,6 +295,23 @@ def _parse_a_nullable_date(data: object) -> Union[None, datetime.date]: a_nullable_date = _parse_a_nullable_date(d.pop("a_nullable_date")) + a_uuid = UUID(d.pop("a_uuid")) + + def _parse_a_nullable_uuid(data: object) -> Union[None, UUID]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + a_nullable_uuid_type_0 = UUID(data) + + return a_nullable_uuid_type_0 + except: # noqa: E722 + pass + return cast(Union[None, UUID], data) + + a_nullable_uuid = _parse_a_nullable_uuid(d.pop("a_nullable_uuid")) + def _parse_required_nullable(data: object) -> Union[None, str]: if data is None: return data @@ -370,6 +410,13 @@ def _parse_nullable_model(data: object) -> Union["ModelWithUnionProperty", None] else: a_not_required_date = isoparse(_a_not_required_date).date() + _a_not_required_uuid = d.pop("a_not_required_uuid", UNSET) + a_not_required_uuid: Union[Unset, UUID] + if isinstance(_a_not_required_uuid, Unset): + a_not_required_uuid = UNSET + else: + a_not_required_uuid = UUID(_a_not_required_uuid) + attr_1_leading_digit = d.pop("1_leading_digit", UNSET) attr_leading_underscore = d.pop("_leading_underscore", UNSET) @@ -463,6 +510,8 @@ def _parse_not_required_nullable_model(data: object) -> Union["ModelWithUnionPro a_camel_date_time=a_camel_date_time, a_date=a_date, a_nullable_date=a_nullable_date, + a_uuid=a_uuid, + a_nullable_uuid=a_nullable_uuid, required_nullable=required_nullable, required_not_nullable=required_not_nullable, one_of_models=one_of_models, @@ -473,6 +522,7 @@ def _parse_not_required_nullable_model(data: object) -> Union["ModelWithUnionPro an_optional_allof_enum=an_optional_allof_enum, nested_list_of_enums=nested_list_of_enums, a_not_required_date=a_not_required_date, + a_not_required_uuid=a_not_required_uuid, attr_1_leading_digit=attr_1_leading_digit, attr_leading_underscore=attr_leading_underscore, not_required_nullable=not_required_nullable, diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/extended.py b/end_to_end_tests/golden-record/my_test_api_client/models/extended.py index c3659222b..324513d3a 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/extended.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/extended.py @@ -1,5 +1,6 @@ import datetime from typing import TYPE_CHECKING, Any, Dict, List, Type, TypeVar, Union, cast +from uuid import UUID from attrs import define as _attrs_define from attrs import field as _attrs_field @@ -27,6 +28,8 @@ class Extended: a_camel_date_time (Union[datetime.date, datetime.datetime]): a_date (datetime.date): a_nullable_date (Union[None, datetime.date]): + a_uuid (UUID): + a_nullable_uuid (Union[None, UUID]): Default: UUID('07EF8B4D-AA09-4FFA-898D-C710796AFF41'). required_nullable (Union[None, str]): required_not_nullable (str): one_of_models (Union['FreeFormModel', 'ModelWithUnionProperty', Any]): @@ -37,6 +40,7 @@ class Extended: an_optional_allof_enum (Union[Unset, AnAllOfEnum]): nested_list_of_enums (Union[Unset, List[List[DifferentEnum]]]): a_not_required_date (Union[Unset, datetime.date]): + a_not_required_uuid (Union[Unset, UUID]): attr_1_leading_digit (Union[Unset, str]): attr_leading_underscore (Union[Unset, str]): not_required_nullable (Union[None, Unset, str]): @@ -52,6 +56,7 @@ class Extended: a_camel_date_time: Union[datetime.date, datetime.datetime] a_date: datetime.date a_nullable_date: Union[None, datetime.date] + a_uuid: UUID required_nullable: Union[None, str] required_not_nullable: str one_of_models: Union["FreeFormModel", "ModelWithUnionProperty", Any] @@ -59,10 +64,12 @@ class Extended: model: "ModelWithUnionProperty" nullable_model: Union["ModelWithUnionProperty", None] an_allof_enum_with_overridden_default: AnAllOfEnum = AnAllOfEnum.OVERRIDDEN_DEFAULT + a_nullable_uuid: Union[None, UUID] = UUID("07EF8B4D-AA09-4FFA-898D-C710796AFF41") any_value: Union[Unset, Any] = "default" an_optional_allof_enum: Union[Unset, AnAllOfEnum] = UNSET nested_list_of_enums: Union[Unset, List[List[DifferentEnum]]] = UNSET a_not_required_date: Union[Unset, datetime.date] = UNSET + a_not_required_uuid: Union[Unset, UUID] = UNSET attr_1_leading_digit: Union[Unset, str] = UNSET attr_leading_underscore: Union[Unset, str] = UNSET not_required_nullable: Union[None, Unset, str] = UNSET @@ -96,6 +103,14 @@ def to_dict(self) -> Dict[str, Any]: else: a_nullable_date = self.a_nullable_date + a_uuid = str(self.a_uuid) + + a_nullable_uuid: Union[None, str] + if isinstance(self.a_nullable_uuid, UUID): + a_nullable_uuid = str(self.a_nullable_uuid) + else: + a_nullable_uuid = self.a_nullable_uuid + required_nullable: Union[None, str] required_nullable = self.required_nullable @@ -146,6 +161,10 @@ def to_dict(self) -> Dict[str, Any]: if not isinstance(self.a_not_required_date, Unset): a_not_required_date = self.a_not_required_date.isoformat() + a_not_required_uuid: Union[Unset, str] = UNSET + if not isinstance(self.a_not_required_uuid, Unset): + a_not_required_uuid = str(self.a_not_required_uuid) + attr_1_leading_digit = self.attr_1_leading_digit attr_leading_underscore = self.attr_leading_underscore @@ -199,6 +218,8 @@ def to_dict(self) -> Dict[str, Any]: "aCamelDateTime": a_camel_date_time, "a_date": a_date, "a_nullable_date": a_nullable_date, + "a_uuid": a_uuid, + "a_nullable_uuid": a_nullable_uuid, "required_nullable": required_nullable, "required_not_nullable": required_not_nullable, "one_of_models": one_of_models, @@ -215,6 +236,8 @@ def to_dict(self) -> Dict[str, Any]: field_dict["nested_list_of_enums"] = nested_list_of_enums if a_not_required_date is not UNSET: field_dict["a_not_required_date"] = a_not_required_date + if a_not_required_uuid is not UNSET: + field_dict["a_not_required_uuid"] = a_not_required_uuid if attr_1_leading_digit is not UNSET: field_dict["1_leading_digit"] = attr_1_leading_digit if attr_leading_underscore is not UNSET: @@ -280,6 +303,23 @@ def _parse_a_nullable_date(data: object) -> Union[None, datetime.date]: a_nullable_date = _parse_a_nullable_date(d.pop("a_nullable_date")) + a_uuid = UUID(d.pop("a_uuid")) + + def _parse_a_nullable_uuid(data: object) -> Union[None, UUID]: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + a_nullable_uuid_type_0 = UUID(data) + + return a_nullable_uuid_type_0 + except: # noqa: E722 + pass + return cast(Union[None, UUID], data) + + a_nullable_uuid = _parse_a_nullable_uuid(d.pop("a_nullable_uuid")) + def _parse_required_nullable(data: object) -> Union[None, str]: if data is None: return data @@ -378,6 +418,13 @@ def _parse_nullable_model(data: object) -> Union["ModelWithUnionProperty", None] else: a_not_required_date = isoparse(_a_not_required_date).date() + _a_not_required_uuid = d.pop("a_not_required_uuid", UNSET) + a_not_required_uuid: Union[Unset, UUID] + if isinstance(_a_not_required_uuid, Unset): + a_not_required_uuid = UNSET + else: + a_not_required_uuid = UUID(_a_not_required_uuid) + attr_1_leading_digit = d.pop("1_leading_digit", UNSET) attr_leading_underscore = d.pop("_leading_underscore", UNSET) @@ -473,6 +520,8 @@ def _parse_not_required_nullable_model(data: object) -> Union["ModelWithUnionPro a_camel_date_time=a_camel_date_time, a_date=a_date, a_nullable_date=a_nullable_date, + a_uuid=a_uuid, + a_nullable_uuid=a_nullable_uuid, required_nullable=required_nullable, required_not_nullable=required_not_nullable, one_of_models=one_of_models, @@ -483,6 +532,7 @@ def _parse_not_required_nullable_model(data: object) -> Union["ModelWithUnionPro an_optional_allof_enum=an_optional_allof_enum, nested_list_of_enums=nested_list_of_enums, a_not_required_date=a_not_required_date, + a_not_required_uuid=a_not_required_uuid, attr_1_leading_digit=attr_1_leading_digit, attr_leading_underscore=attr_leading_underscore, not_required_nullable=not_required_nullable, diff --git a/openapi_python_client/parser/properties/__init__.py b/openapi_python_client/parser/properties/__init__.py index 02a6fdafe..c1e94c3c8 100644 --- a/openapi_python_client/parser/properties/__init__.py +++ b/openapi_python_client/parser/properties/__init__.py @@ -44,11 +44,12 @@ ) from .string import StringProperty from .union import UnionProperty +from .uuid import UuidProperty def _string_based_property( name: str, required: bool, data: oai.Schema, config: Config -) -> StringProperty | DateProperty | DateTimeProperty | FileProperty | PropertyError: +) -> StringProperty | DateProperty | DateTimeProperty | FileProperty | UuidProperty | PropertyError: """Construct a Property from the type "string" """ string_format = data.schema_format python_name = utils.PythonIdentifier(value=name, prefix=config.field_prefix) @@ -79,6 +80,15 @@ def _string_based_property( description=data.description, example=data.example, ) + if string_format == "uuid": + return UuidProperty.build( + name=name, + required=required, + default=data.default, + python_name=python_name, + description=data.description, + example=data.example, + ) return StringProperty.build( name=name, default=data.default, diff --git a/openapi_python_client/parser/properties/property.py b/openapi_python_client/parser/properties/property.py index fa3a26beb..aeac32a7f 100644 --- a/openapi_python_client/parser/properties/property.py +++ b/openapi_python_client/parser/properties/property.py @@ -18,6 +18,7 @@ from .none import NoneProperty from .string import StringProperty from .union import UnionProperty +from .uuid import UuidProperty Property: TypeAlias = Union[ AnyProperty, @@ -34,4 +35,5 @@ NoneProperty, StringProperty, UnionProperty, + UuidProperty, ] diff --git a/openapi_python_client/parser/properties/uuid.py b/openapi_python_client/parser/properties/uuid.py new file mode 100644 index 000000000..86d7d6a0a --- /dev/null +++ b/openapi_python_client/parser/properties/uuid.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import Any, ClassVar +from uuid import UUID + +from attr import define + +from ... import schema as oai +from ...utils import PythonIdentifier +from ..errors import PropertyError +from .protocol import PropertyProtocol, Value + + +@define +class UuidProperty(PropertyProtocol): + """A property of type uuid.UUID""" + + name: str + required: bool + default: Value | None + python_name: PythonIdentifier + description: str | None + example: str | None + + _type_string: ClassVar[str] = "UUID" + _json_type_string: ClassVar[str] = "str" + _allowed_locations: ClassVar[set[oai.ParameterLocation]] = { + oai.ParameterLocation.QUERY, + oai.ParameterLocation.PATH, + oai.ParameterLocation.COOKIE, + oai.ParameterLocation.HEADER, + } + template: ClassVar[str] = "uuid_property.py.jinja" + + @classmethod + def build( + cls, + name: str, + required: bool, + default: Any, + python_name: PythonIdentifier, + description: str | None, + example: str | None, + ) -> UuidProperty | PropertyError: + checked_default = cls.convert_value(default) + if isinstance(checked_default, PropertyError): + return checked_default + + return cls( + name=name, + required=required, + default=checked_default, + python_name=python_name, + description=description, + example=example, + ) + + @classmethod + def convert_value(cls, value: Any) -> Value | None | PropertyError: + if value is None or isinstance(value, Value): + return value + if isinstance(value, str): + try: + UUID(value) + except ValueError: + return PropertyError(f"Invalid UUID value: {value}") + return Value(python_code=f"UUID('{value}')", raw_value=value) + return PropertyError(f"Invalid UUID value: {value}") + + 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. This should be the number of . to get + back to the root of the generated client. + """ + imports = super().get_imports(prefix=prefix) + imports.update({"from uuid import UUID"}) + return imports diff --git a/openapi_python_client/templates/property_templates/uuid_property.py.jinja b/openapi_python_client/templates/property_templates/uuid_property.py.jinja new file mode 100644 index 000000000..1f91c1e3a --- /dev/null +++ b/openapi_python_client/templates/property_templates/uuid_property.py.jinja @@ -0,0 +1,38 @@ +{% macro construct_function(property, source) %} +UUID({{ source }}) +{% endmacro %} + +{% from "property_templates/property_macros.py.jinja" import construct_template %} + +{% macro construct(property, source) %} +{{ construct_template(construct_function, property, source) }} +{% endmacro %} + +{% macro check_type_for_construct(property, source) %}isinstance({{ source }}, str){% endmacro %} + +{% macro transform(property, source, destination, declare_type=True) %} +{% set transformed = "str(" + source + ")" %} +{% if property.required %} +{{ destination }} = {{ transformed }} +{%- else %} +{% if declare_type %} +{% set type_annotation = property.get_type_string(json=True) %} +{{ destination }}: {{ type_annotation }} = UNSET +{% else %} +{{ destination }} = UNSET +{% endif %} +if not isinstance({{ source }}, Unset): + {{ destination }} = {{ transformed }} +{%- endif %} +{% endmacro %} + +{% macro transform_multipart(property, source, destination) %} +{% if property.required %} +{{ destination }} = str({{ source }}) +{%- else %} +{% set type_annotation = property.get_type_string(json=True) | replace("str", "bytes") %} +{{ destination }}: {{ type_annotation }} = UNSET +if not isinstance({{ source }}, Unset): + {{ destination }} = str({{ source }}) +{%- endif %} +{% endmacro %} diff --git a/pyproject.toml b/pyproject.toml index 582e0806f..e9cdda65d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,7 +114,7 @@ mypy = "mypy openapi_python_client" check = { composite = ["lint", "format", "mypy", "test"] } regen = {composite = ["regen_e2e", "regen_integration"]} e2e = "pytest openapi_python_client end_to_end_tests/test_end_to_end.py" -re = {composite = ["regen_e2e", "e2e"]} +re = {composite = ["regen_e2e", "e2e --snapshot-update"]} regen_e2e = "python -m end_to_end_tests.regen_golden_record" [tool.pdm.scripts.test]