From 0b1c3c24f234885827841206433dfa72a9a27c47 Mon Sep 17 00:00:00 2001 From: Dylan Anthony Date: Wed, 5 Nov 2025 18:05:21 -0700 Subject: [PATCH] Fix optional bodies --- .changeset/fix_optional_bodies.md | 13 +++ end_to_end_tests/baseline_openapi_3.0.json | 24 ++++ end_to_end_tests/baseline_openapi_3.1.yaml | 24 ++++ .../my_test_api_client/api/bodies/__init__.py | 9 +- .../test_docstrings.py | 2 +- .../api/bodies/json_like.py | 15 +-- .../api/bodies/optional_body.py | 104 ++++++++++++++++++ .../api/bodies/post_bodies_multiple.py | 57 +++++++--- .../my_test_api_client/api/bodies/refs.py | 15 +-- .../api/config/content_type_override.py | 23 ++-- ...st_naming_property_conflict_with_import.py | 23 ++-- .../octet_stream_tests_octet_stream_post.py | 23 ++-- .../my_test_api_client/models/__init__.py | 2 + .../models/optional_body_body.py | 46 ++++++++ .../api/tests/post_user_list.py | 23 ++-- .../api/body/post_body_multipart.py | 23 ++-- openapi_python_client/parser/bodies.py | 2 +- .../templates/endpoint_macros.py.jinja | 23 +++- .../templates/endpoint_module.py.jinja | 2 +- .../property_templates/date_property.py.jinja | 10 +- .../datetime_property.py.jinja | 12 +- .../property_templates/enum_property.py.jinja | 4 +- .../property_templates/file_property.py.jinja | 4 +- .../property_templates/list_property.py.jinja | 4 +- .../literal_enum_property.py.jinja | 4 +- .../model_property.py.jinja | 5 +- .../union_property.py.jinja | 4 +- .../property_templates/uuid_property.py.jinja | 10 +- 28 files changed, 391 insertions(+), 119 deletions(-) create mode 100644 .changeset/fix_optional_bodies.md create mode 100644 end_to_end_tests/golden-record/my_test_api_client/api/bodies/optional_body.py create mode 100644 end_to_end_tests/golden-record/my_test_api_client/models/optional_body_body.py diff --git a/.changeset/fix_optional_bodies.md b/.changeset/fix_optional_bodies.md new file mode 100644 index 000000000..13da69ca5 --- /dev/null +++ b/.changeset/fix_optional_bodies.md @@ -0,0 +1,13 @@ +--- +default: patch +--- + +# Fix optional bodies + +If a body is not required (the default), it will now: + +1. Have `Unset` as part of its type annotation. +2. Default to a value of `UNSET` +3. Not be included in the request if it is `UNSET` + +Thanks @orelmaliach for the report! Fixes #1354 diff --git a/end_to_end_tests/baseline_openapi_3.0.json b/end_to_end_tests/baseline_openapi_3.0.json index f3ce912c9..f6128ba83 100644 --- a/end_to_end_tests/baseline_openapi_3.0.json +++ b/end_to_end_tests/baseline_openapi_3.0.json @@ -104,6 +104,30 @@ } } }, + "/bodies/optional": { + "post": { + "tags": [ + "bodies" + ], + "description": "Test optional request body", + "operationId": "optional-body", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/tests/": { "get": { "tags": [ diff --git a/end_to_end_tests/baseline_openapi_3.1.yaml b/end_to_end_tests/baseline_openapi_3.1.yaml index 74b85229d..7f3ef2682 100644 --- a/end_to_end_tests/baseline_openapi_3.1.yaml +++ b/end_to_end_tests/baseline_openapi_3.1.yaml @@ -100,6 +100,30 @@ info: } } }, + "/bodies/optional": { + "post": { + "tags": [ + "bodies" + ], + "description": "Test optional request body", + "operationId": "optional-body", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/tests/": { "get": { "tags": [ diff --git a/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/bodies/__init__.py b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/bodies/__init__.py index 89304dde0..5ff7fceb8 100644 --- a/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/bodies/__init__.py +++ b/end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/bodies/__init__.py @@ -2,7 +2,7 @@ import types -from . import json_like, post_bodies_multiple, refs +from . import json_like, optional_body, post_bodies_multiple, refs class BodiesEndpoints: @@ -26,3 +26,10 @@ def refs(cls) -> types.ModuleType: Test request body defined via ref """ return refs + + @classmethod + def optional_body(cls) -> types.ModuleType: + """ + Test optional request body + """ + return optional_body diff --git a/end_to_end_tests/functional_tests/generated_code_execution/test_docstrings.py b/end_to_end_tests/functional_tests/generated_code_execution/test_docstrings.py index b49599a93..4ed00ac4a 100644 --- a/end_to_end_tests/functional_tests/generated_code_execution/test_docstrings.py +++ b/end_to_end_tests/functional_tests/generated_code_execution/test_docstrings.py @@ -152,7 +152,7 @@ def test_response_union_type(self, post_simple_thing_sync): def test_request_body(self, post_simple_thing_sync): assert DocstringParser(post_simple_thing_sync).get_section("Args:") == [ - "body (Thing): The thing." + "body (Thing | Unset): The thing." ] def test_params(self, get_attribute_by_index_sync): diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/bodies/json_like.py b/end_to_end_tests/golden-record/my_test_api_client/api/bodies/json_like.py index 4b22b6030..1a4fc2fd9 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/api/bodies/json_like.py +++ b/end_to_end_tests/golden-record/my_test_api_client/api/bodies/json_like.py @@ -6,12 +6,12 @@ from ... import errors from ...client import AuthenticatedClient, Client from ...models.json_like_body import JsonLikeBody -from ...types import Response +from ...types import UNSET, Response, Unset def _get_kwargs( *, - body: JsonLikeBody, + body: JsonLikeBody | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -20,7 +20,8 @@ def _get_kwargs( "url": "/bodies/json-like", } - _kwargs["json"] = body.to_dict() + if not isinstance(body, Unset): + _kwargs["json"] = body.to_dict() headers["Content-Type"] = "application/vnd+json" @@ -50,12 +51,12 @@ def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Res def sync_detailed( *, client: AuthenticatedClient | Client, - body: JsonLikeBody, + body: JsonLikeBody | Unset = UNSET, ) -> Response[Any]: """A content type that works like json but isn't application/json Args: - body (JsonLikeBody): + body (JsonLikeBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -79,12 +80,12 @@ def sync_detailed( async def asyncio_detailed( *, client: AuthenticatedClient | Client, - body: JsonLikeBody, + body: JsonLikeBody | Unset = UNSET, ) -> Response[Any]: """A content type that works like json but isn't application/json Args: - body (JsonLikeBody): + body (JsonLikeBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/bodies/optional_body.py b/end_to_end_tests/golden-record/my_test_api_client/api/bodies/optional_body.py new file mode 100644 index 000000000..8402cf086 --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/api/bodies/optional_body.py @@ -0,0 +1,104 @@ +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.optional_body_body import OptionalBodyBody +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + body: OptionalBodyBody | Unset = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/bodies/optional", + } + + if not isinstance(body, Unset): + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | None: + if response.status_code == 200: + return None + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: OptionalBodyBody | Unset = UNSET, +) -> Response[Any]: + """Test optional request body + + Args: + body (OptionalBodyBody | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: OptionalBodyBody | Unset = UNSET, +) -> Response[Any]: + """Test optional request body + + Args: + body (OptionalBodyBody | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Any] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/bodies/post_bodies_multiple.py b/end_to_end_tests/golden-record/my_test_api_client/api/bodies/post_bodies_multiple.py index cbc2651c0..4e49f3c29 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/api/bodies/post_bodies_multiple.py +++ b/end_to_end_tests/golden-record/my_test_api_client/api/bodies/post_bodies_multiple.py @@ -8,12 +8,19 @@ from ...models.post_bodies_multiple_data_body import PostBodiesMultipleDataBody from ...models.post_bodies_multiple_files_body import PostBodiesMultipleFilesBody from ...models.post_bodies_multiple_json_body import PostBodiesMultipleJsonBody -from ...types import File, Response +from ...types import UNSET, File, Response, Unset def _get_kwargs( *, - body: PostBodiesMultipleJsonBody | File | PostBodiesMultipleDataBody | PostBodiesMultipleFilesBody, + body: PostBodiesMultipleJsonBody + | Unset + | File + | Unset + | PostBodiesMultipleDataBody + | Unset + | PostBodiesMultipleFilesBody + | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -23,19 +30,23 @@ def _get_kwargs( } if isinstance(body, PostBodiesMultipleJsonBody): - _kwargs["json"] = body.to_dict() + if not isinstance(body, Unset): + _kwargs["json"] = body.to_dict() headers["Content-Type"] = "application/json" if isinstance(body, File): - _kwargs["content"] = body.payload + if not isinstance(body, Unset): + _kwargs["content"] = body.payload headers["Content-Type"] = "application/octet-stream" if isinstance(body, PostBodiesMultipleDataBody): - _kwargs["data"] = body.to_dict() + if not isinstance(body, Unset): + _kwargs["data"] = body.to_dict() headers["Content-Type"] = "application/x-www-form-urlencoded" if isinstance(body, PostBodiesMultipleFilesBody): - _kwargs["files"] = body.to_multipart() + if not isinstance(body, Unset): + _kwargs["files"] = body.to_multipart() headers["Content-Type"] = "multipart/form-data" @@ -65,15 +76,22 @@ def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Res def sync_detailed( *, client: AuthenticatedClient | Client, - body: PostBodiesMultipleJsonBody | File | PostBodiesMultipleDataBody | PostBodiesMultipleFilesBody, + body: PostBodiesMultipleJsonBody + | Unset + | File + | Unset + | PostBodiesMultipleDataBody + | Unset + | PostBodiesMultipleFilesBody + | Unset = UNSET, ) -> Response[Any]: """Test multiple bodies Args: - body (PostBodiesMultipleJsonBody): - body (File): - body (PostBodiesMultipleDataBody): - body (PostBodiesMultipleFilesBody): + body (PostBodiesMultipleJsonBody | Unset): + body (File | Unset): + body (PostBodiesMultipleDataBody | Unset): + body (PostBodiesMultipleFilesBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -97,15 +115,22 @@ def sync_detailed( async def asyncio_detailed( *, client: AuthenticatedClient | Client, - body: PostBodiesMultipleJsonBody | File | PostBodiesMultipleDataBody | PostBodiesMultipleFilesBody, + body: PostBodiesMultipleJsonBody + | Unset + | File + | Unset + | PostBodiesMultipleDataBody + | Unset + | PostBodiesMultipleFilesBody + | Unset = UNSET, ) -> Response[Any]: """Test multiple bodies Args: - body (PostBodiesMultipleJsonBody): - body (File): - body (PostBodiesMultipleDataBody): - body (PostBodiesMultipleFilesBody): + body (PostBodiesMultipleJsonBody | Unset): + body (File | Unset): + body (PostBodiesMultipleDataBody | Unset): + body (PostBodiesMultipleFilesBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/bodies/refs.py b/end_to_end_tests/golden-record/my_test_api_client/api/bodies/refs.py index 548dee8fa..2e224bc8c 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/api/bodies/refs.py +++ b/end_to_end_tests/golden-record/my_test_api_client/api/bodies/refs.py @@ -6,12 +6,12 @@ from ... import errors from ...client import AuthenticatedClient, Client from ...models.a_model import AModel -from ...types import Response +from ...types import UNSET, Response, Unset def _get_kwargs( *, - body: AModel, + body: AModel | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -20,7 +20,8 @@ def _get_kwargs( "url": "/bodies/refs", } - _kwargs["json"] = body.to_dict() + if not isinstance(body, Unset): + _kwargs["json"] = body.to_dict() headers["Content-Type"] = "application/json" @@ -50,12 +51,12 @@ def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Res def sync_detailed( *, client: AuthenticatedClient | Client, - body: AModel, + body: AModel | Unset = UNSET, ) -> Response[Any]: """Test request body defined via ref Args: - body (AModel): A Model for testing all the ways custom objects can be used + body (AModel | Unset): A Model for testing all the ways custom objects can be used Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -79,12 +80,12 @@ def sync_detailed( async def asyncio_detailed( *, client: AuthenticatedClient | Client, - body: AModel, + body: AModel | Unset = UNSET, ) -> Response[Any]: """Test request body defined via ref Args: - body (AModel): A Model for testing all the ways custom objects can be used + body (AModel | Unset): A Model for testing all the ways custom objects can be used Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/config/content_type_override.py b/end_to_end_tests/golden-record/my_test_api_client/api/config/content_type_override.py index 667605270..be06459be 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/api/config/content_type_override.py +++ b/end_to_end_tests/golden-record/my_test_api_client/api/config/content_type_override.py @@ -5,12 +5,12 @@ from ... import errors from ...client import AuthenticatedClient, Client -from ...types import Response +from ...types import UNSET, Response, Unset def _get_kwargs( *, - body: str, + body: str | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -19,7 +19,8 @@ def _get_kwargs( "url": "/config/content-type-override", } - _kwargs["json"] = body + if not isinstance(body, Unset): + _kwargs["json"] = body headers["Content-Type"] = "openapi/python/client" @@ -50,12 +51,12 @@ def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Res def sync_detailed( *, client: AuthenticatedClient | Client, - body: str, + body: str | Unset = UNSET, ) -> Response[str]: """Content Type Override Args: - body (str): + body (str | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -79,12 +80,12 @@ def sync_detailed( def sync( *, client: AuthenticatedClient | Client, - body: str, + body: str | Unset = UNSET, ) -> str | None: """Content Type Override Args: - body (str): + body (str | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -103,12 +104,12 @@ def sync( async def asyncio_detailed( *, client: AuthenticatedClient | Client, - body: str, + body: str | Unset = UNSET, ) -> Response[str]: """Content Type Override Args: - body (str): + body (str | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -130,12 +131,12 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient | Client, - body: str, + body: str | Unset = UNSET, ) -> str | None: """Content Type Override Args: - body (str): + body (str | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/naming/post_naming_property_conflict_with_import.py b/end_to_end_tests/golden-record/my_test_api_client/api/naming/post_naming_property_conflict_with_import.py index daf214f76..9e848f7af 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/api/naming/post_naming_property_conflict_with_import.py +++ b/end_to_end_tests/golden-record/my_test_api_client/api/naming/post_naming_property_conflict_with_import.py @@ -9,12 +9,12 @@ from ...models.post_naming_property_conflict_with_import_response_200 import ( PostNamingPropertyConflictWithImportResponse200, ) -from ...types import Response +from ...types import UNSET, Response, Unset def _get_kwargs( *, - body: PostNamingPropertyConflictWithImportBody, + body: PostNamingPropertyConflictWithImportBody | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -23,7 +23,8 @@ def _get_kwargs( "url": "/naming/property-conflict-with-import", } - _kwargs["json"] = body.to_dict() + if not isinstance(body, Unset): + _kwargs["json"] = body.to_dict() headers["Content-Type"] = "application/json" @@ -59,11 +60,11 @@ def _build_response( def sync_detailed( *, client: AuthenticatedClient | Client, - body: PostNamingPropertyConflictWithImportBody, + body: PostNamingPropertyConflictWithImportBody | Unset = UNSET, ) -> Response[PostNamingPropertyConflictWithImportResponse200]: """ Args: - body (PostNamingPropertyConflictWithImportBody): + body (PostNamingPropertyConflictWithImportBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -87,11 +88,11 @@ def sync_detailed( def sync( *, client: AuthenticatedClient | Client, - body: PostNamingPropertyConflictWithImportBody, + body: PostNamingPropertyConflictWithImportBody | Unset = UNSET, ) -> PostNamingPropertyConflictWithImportResponse200 | None: """ Args: - body (PostNamingPropertyConflictWithImportBody): + body (PostNamingPropertyConflictWithImportBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -110,11 +111,11 @@ def sync( async def asyncio_detailed( *, client: AuthenticatedClient | Client, - body: PostNamingPropertyConflictWithImportBody, + body: PostNamingPropertyConflictWithImportBody | Unset = UNSET, ) -> Response[PostNamingPropertyConflictWithImportResponse200]: """ Args: - body (PostNamingPropertyConflictWithImportBody): + body (PostNamingPropertyConflictWithImportBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -136,11 +137,11 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient | Client, - body: PostNamingPropertyConflictWithImportBody, + body: PostNamingPropertyConflictWithImportBody | Unset = UNSET, ) -> PostNamingPropertyConflictWithImportResponse200 | None: """ Args: - body (PostNamingPropertyConflictWithImportBody): + body (PostNamingPropertyConflictWithImportBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/end_to_end_tests/golden-record/my_test_api_client/api/tests/octet_stream_tests_octet_stream_post.py b/end_to_end_tests/golden-record/my_test_api_client/api/tests/octet_stream_tests_octet_stream_post.py index a0e9bbac0..0d140359d 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/api/tests/octet_stream_tests_octet_stream_post.py +++ b/end_to_end_tests/golden-record/my_test_api_client/api/tests/octet_stream_tests_octet_stream_post.py @@ -7,12 +7,12 @@ from ...client import AuthenticatedClient, Client from ...models.http_validation_error import HTTPValidationError from ...models.octet_stream_tests_octet_stream_post_response_200 import OctetStreamTestsOctetStreamPostResponse200 -from ...types import File, Response +from ...types import UNSET, File, Response, Unset def _get_kwargs( *, - body: File, + body: File | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -21,7 +21,8 @@ def _get_kwargs( "url": "/tests/octet_stream", } - _kwargs["content"] = body.payload + if not isinstance(body, Unset): + _kwargs["content"] = body.payload headers["Content-Type"] = "application/octet-stream" @@ -62,12 +63,12 @@ def _build_response( def sync_detailed( *, client: AuthenticatedClient | Client, - body: File, + body: File | Unset = UNSET, ) -> Response[HTTPValidationError | OctetStreamTestsOctetStreamPostResponse200]: """Binary (octet stream) request body Args: - body (File): A file to upload + body (File | Unset): A file to upload Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -91,12 +92,12 @@ def sync_detailed( def sync( *, client: AuthenticatedClient | Client, - body: File, + body: File | Unset = UNSET, ) -> HTTPValidationError | OctetStreamTestsOctetStreamPostResponse200 | None: """Binary (octet stream) request body Args: - body (File): A file to upload + body (File | Unset): A file to upload Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -115,12 +116,12 @@ def sync( async def asyncio_detailed( *, client: AuthenticatedClient | Client, - body: File, + body: File | Unset = UNSET, ) -> Response[HTTPValidationError | OctetStreamTestsOctetStreamPostResponse200]: """Binary (octet stream) request body Args: - body (File): A file to upload + body (File | Unset): A file to upload Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -142,12 +143,12 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient | Client, - body: File, + body: File | Unset = UNSET, ) -> HTTPValidationError | OctetStreamTestsOctetStreamPostResponse200 | None: """Binary (octet stream) request body Args: - body (File): A file to upload + body (File | Unset): A file to upload Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py b/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py index da9b57b85..cd897d9fe 100644 --- a/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py +++ b/end_to_end_tests/golden-record/my_test_api_client/models/__init__.py @@ -76,6 +76,7 @@ from .model_with_union_property_inlined_bananas import ModelWithUnionPropertyInlinedBananas from .none import None_ from .octet_stream_tests_octet_stream_post_response_200 import OctetStreamTestsOctetStreamPostResponse200 +from .optional_body_body import OptionalBodyBody from .post_bodies_multiple_data_body import PostBodiesMultipleDataBody from .post_bodies_multiple_files_body import PostBodiesMultipleFilesBody from .post_bodies_multiple_json_body import PostBodiesMultipleJsonBody @@ -162,6 +163,7 @@ "ModelWithUnionPropertyInlinedBananas", "None_", "OctetStreamTestsOctetStreamPostResponse200", + "OptionalBodyBody", "PostBodiesMultipleDataBody", "PostBodiesMultipleFilesBody", "PostBodiesMultipleJsonBody", diff --git a/end_to_end_tests/golden-record/my_test_api_client/models/optional_body_body.py b/end_to_end_tests/golden-record/my_test_api_client/models/optional_body_body.py new file mode 100644 index 000000000..a4665b7df --- /dev/null +++ b/end_to_end_tests/golden-record/my_test_api_client/models/optional_body_body.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="OptionalBodyBody") + + +@_attrs_define +class OptionalBodyBody: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + optional_body_body = cls() + + optional_body_body.additional_properties = d + return optional_body_body + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/literal-enums-golden-record/my_enum_api_client/api/tests/post_user_list.py b/end_to_end_tests/literal-enums-golden-record/my_enum_api_client/api/tests/post_user_list.py index 148e68a35..920c35f78 100644 --- a/end_to_end_tests/literal-enums-golden-record/my_enum_api_client/api/tests/post_user_list.py +++ b/end_to_end_tests/literal-enums-golden-record/my_enum_api_client/api/tests/post_user_list.py @@ -7,12 +7,12 @@ from ...client import AuthenticatedClient, Client from ...models.a_model import AModel from ...models.post_user_list_body import PostUserListBody -from ...types import Response +from ...types import UNSET, Response, Unset def _get_kwargs( *, - body: PostUserListBody, + body: PostUserListBody | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -21,7 +21,8 @@ def _get_kwargs( "url": "/tests/", } - _kwargs["files"] = body.to_multipart() + if not isinstance(body, Unset): + _kwargs["files"] = body.to_multipart() _kwargs["headers"] = headers return _kwargs @@ -56,14 +57,14 @@ def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Res def sync_detailed( *, client: AuthenticatedClient | Client, - body: PostUserListBody, + body: PostUserListBody | Unset = UNSET, ) -> Response[list[AModel]]: """Post List Post a list of things Args: - body (PostUserListBody): + body (PostUserListBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -87,14 +88,14 @@ def sync_detailed( def sync( *, client: AuthenticatedClient | Client, - body: PostUserListBody, + body: PostUserListBody | Unset = UNSET, ) -> list[AModel] | None: """Post List Post a list of things Args: - body (PostUserListBody): + body (PostUserListBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -113,14 +114,14 @@ def sync( async def asyncio_detailed( *, client: AuthenticatedClient | Client, - body: PostUserListBody, + body: PostUserListBody | Unset = UNSET, ) -> Response[list[AModel]]: """Post List Post a list of things Args: - body (PostUserListBody): + body (PostUserListBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -142,14 +143,14 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient | Client, - body: PostUserListBody, + body: PostUserListBody | Unset = UNSET, ) -> list[AModel] | None: """Post List Post a list of things Args: - body (PostUserListBody): + body (PostUserListBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/integration-tests/integration_tests/api/body/post_body_multipart.py b/integration-tests/integration_tests/api/body/post_body_multipart.py index 32754e8cc..58c217231 100644 --- a/integration-tests/integration_tests/api/body/post_body_multipart.py +++ b/integration-tests/integration_tests/api/body/post_body_multipart.py @@ -8,12 +8,12 @@ from ...models.post_body_multipart_body import PostBodyMultipartBody from ...models.post_body_multipart_response_200 import PostBodyMultipartResponse200 from ...models.public_error import PublicError -from ...types import Response +from ...types import UNSET, Response, Unset def _get_kwargs( *, - body: PostBodyMultipartBody, + body: PostBodyMultipartBody | Unset = UNSET, ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -22,7 +22,8 @@ def _get_kwargs( "url": "/body/multipart", } - _kwargs["files"] = body.to_multipart() + if not isinstance(body, Unset): + _kwargs["files"] = body.to_multipart() _kwargs["headers"] = headers return _kwargs @@ -61,11 +62,11 @@ def _build_response( def sync_detailed( *, client: AuthenticatedClient | Client, - body: PostBodyMultipartBody, + body: PostBodyMultipartBody | Unset = UNSET, ) -> Response[PostBodyMultipartResponse200 | PublicError]: """ Args: - body (PostBodyMultipartBody): + body (PostBodyMultipartBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -89,11 +90,11 @@ def sync_detailed( def sync( *, client: AuthenticatedClient | Client, - body: PostBodyMultipartBody, + body: PostBodyMultipartBody | Unset = UNSET, ) -> PostBodyMultipartResponse200 | PublicError | None: """ Args: - body (PostBodyMultipartBody): + body (PostBodyMultipartBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -112,11 +113,11 @@ def sync( async def asyncio_detailed( *, client: AuthenticatedClient | Client, - body: PostBodyMultipartBody, + body: PostBodyMultipartBody | Unset = UNSET, ) -> Response[PostBodyMultipartResponse200 | PublicError]: """ Args: - body (PostBodyMultipartBody): + body (PostBodyMultipartBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -138,11 +139,11 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient | Client, - body: PostBodyMultipartBody, + body: PostBodyMultipartBody | Unset = UNSET, ) -> PostBodyMultipartResponse200 | PublicError | None: """ Args: - body (PostBodyMultipartBody): + body (PostBodyMultipartBody | Unset): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/openapi_python_client/parser/bodies.py b/openapi_python_client/parser/bodies.py index 679fef601..643848cc1 100644 --- a/openapi_python_client/parser/bodies.py +++ b/openapi_python_client/parser/bodies.py @@ -99,7 +99,7 @@ def body_from_data( continue prop, schemas = property_from_data( name="body", - required=True, + required=body.required, data=media_type_schema, schemas=schemas, parent_name=f"{endpoint_name}_{body_type}" if prefix_type_names else endpoint_name, diff --git a/openapi_python_client/templates/endpoint_macros.py.jinja b/openapi_python_client/templates/endpoint_macros.py.jinja index 127979970..cbcae6773 100644 --- a/openapi_python_client/templates/endpoint_macros.py.jinja +++ b/openapi_python_client/templates/endpoint_macros.py.jinja @@ -60,13 +60,23 @@ params = {k: v for k, v in params.items() if v is not UNSET and v is not None} {% macro body_to_kwarg(body) %} {% if body.body_type == "data" %} + {% if body.prop.required %} _kwargs["data"] = body.to_dict() + {% else %} +if not isinstance(body, Unset): + _kwargs["data"] = body.to_dict() + {% endif %} {% elif body.body_type == "files"%} {{ multipart_body(body) }} {% elif body.body_type == "json" %} {{ json_body(body) }} {% elif body.body_type == "content" %} + {% if body.prop.required %} _kwargs["content"] = body.payload + {% else %} +if not isinstance(body, Unset): + _kwargs["content"] = body.payload + {% endif %} {% endif %} {% endmacro %} @@ -74,9 +84,12 @@ _kwargs["content"] = body.payload {% set property = body.prop %} {% import "property_templates/" + property.template as prop_template %} {% if prop_template.transform %} -{{ prop_template.transform(property, property.python_name, "_kwargs[\"json\"]") }} -{% else %} +{{ prop_template.transform(property, property.python_name, "_kwargs[\"json\"]", skip_unset=True) }} +{% elif property.required %} _kwargs["json"] = {{ property.python_name }} +{% else %} +if not isinstance({{property.python_name}}, Unset): + _kwargs["json"] = {{ property.python_name }} {% endif %} {% endmacro %} @@ -107,12 +120,12 @@ client: AuthenticatedClient | Client, {% endif %} {# Any allowed bodies #} {% if endpoint.bodies | length == 1 %} -body: {{ endpoint.bodies[0].prop.get_type_string() }}, +body: {{ endpoint.bodies[0].prop.get_type_string() }}{% if not endpoint.bodies[0].prop.required %} = UNSET{% endif %}, {% elif endpoint.bodies | length > 1 %} body: - {%- for body in endpoint.bodies -%} + {%- for body in endpoint.bodies -%}{% set body_required = body_required and body.prop.required %} {{ body.prop.get_type_string() }} {% if not loop.last %} | {% endif %} - {%- endfor -%} + {%- endfor -%}{% if not body_required %} = UNSET{% endif %} , {% endif %} {# query parameters #} diff --git a/openapi_python_client/templates/endpoint_module.py.jinja b/openapi_python_client/templates/endpoint_module.py.jinja index 27213ecf4..23fd2a40d 100644 --- a/openapi_python_client/templates/endpoint_module.py.jinja +++ b/openapi_python_client/templates/endpoint_module.py.jinja @@ -48,7 +48,7 @@ def _get_kwargs( {% if endpoint.bodies | length > 1 %} {% for body in endpoint.bodies %} - if isinstance(body, {{body.prop.get_type_string() }}): + if isinstance(body, {{body.prop.get_type_string(no_optional=True) }}): {{ body_to_kwarg(body) | indent(8) }} headers["Content-Type"] = "{{ body.content_type }}" {% endfor %} diff --git a/openapi_python_client/templates/property_templates/date_property.py.jinja b/openapi_python_client/templates/property_templates/date_property.py.jinja index 3ca8faee9..8659ab729 100644 --- a/openapi_python_client/templates/property_templates/date_property.py.jinja +++ b/openapi_python_client/templates/property_templates/date_property.py.jinja @@ -10,16 +10,18 @@ isoparse({{ source }}).date() {% macro check_type_for_construct(property, source) %}isinstance({{ source }}, str){% endmacro %} -{% macro transform(property, source, destination, declare_type=True) %} +{% macro transform(property, source, destination, declare_type=True, skip_unset=False) %} {% set transformed = source + ".isoformat()" %} {% if property.required %} {{ destination }} = {{ transformed }} {%- else %} -{% if declare_type %} -{% set type_annotation = property.get_type_string(json=True) %} +{% if not skip_unset %} + {% if declare_type %} + {% set type_annotation = property.get_type_string(json=True) %} {{ destination }}: {{ type_annotation }} = UNSET -{% else %} + {% else %} {{ destination }} = UNSET + {% endif %} {% endif %} if not isinstance({{ source }}, Unset): {{ destination }} = {{ transformed }} diff --git a/openapi_python_client/templates/property_templates/datetime_property.py.jinja b/openapi_python_client/templates/property_templates/datetime_property.py.jinja index bf7e601d1..1759e593e 100644 --- a/openapi_python_client/templates/property_templates/datetime_property.py.jinja +++ b/openapi_python_client/templates/property_templates/datetime_property.py.jinja @@ -10,17 +10,19 @@ isoparse({{ source }}) {% macro check_type_for_construct(property, source) %}isinstance({{ source }}, str){% endmacro %} -{% macro transform(property, source, destination, declare_type=True) %} +{% macro transform(property, source, destination, declare_type=True, skip_unset=False) %} {% set transformed = source + ".isoformat()" %} {% if property.required %} {{ destination }} = {{ transformed }} {%- else %} -{% if declare_type %} -{% set type_annotation = property.get_type_string(json=True) %} + {% if not skip_unset %} + {% if declare_type %} + {% set type_annotation = property.get_type_string(json=True) %} {{ destination }}: {{ type_annotation }} = UNSET -{% else %} + {% else %} {{ destination }} = UNSET -{% endif %} + {% endif %} + {% endif %} if not isinstance({{ source }}, Unset): {{ destination }} = {{ transformed }} {%- endif %} diff --git a/openapi_python_client/templates/property_templates/enum_property.py.jinja b/openapi_python_client/templates/property_templates/enum_property.py.jinja index af8ca6eff..19ea03b28 100644 --- a/openapi_python_client/templates/property_templates/enum_property.py.jinja +++ b/openapi_python_client/templates/property_templates/enum_property.py.jinja @@ -10,13 +10,13 @@ {% macro check_type_for_construct(property, source) %}isinstance({{ source }}, {{ property.value_type.__name__ }}){% endmacro %} -{% macro transform(property, source, destination, declare_type=True) %} +{% macro transform(property, source, destination, declare_type=True, skip_unset=False) %} {% set transformed = source + ".value" %} {% set type_string = property.get_type_string(json=True) %} {% if property.required %} {{ destination }} = {{ transformed }} {%- else %} -{{ destination }}{% if declare_type %}: {{ type_string }}{% endif %} = UNSET +{% if not skip_unset %}{{ destination }}{% if declare_type %}: {{ type_string }}{% endif %} = UNSET{% endif +%} if not isinstance({{ source }}, Unset): {{ destination }} = {{ transformed }} {% endif %} diff --git a/openapi_python_client/templates/property_templates/file_property.py.jinja b/openapi_python_client/templates/property_templates/file_property.py.jinja index b08a13b46..ac76470a7 100644 --- a/openapi_python_client/templates/property_templates/file_property.py.jinja +++ b/openapi_python_client/templates/property_templates/file_property.py.jinja @@ -12,11 +12,11 @@ File( {% macro check_type_for_construct(property, source) %}isinstance({{ source }}, bytes){% endmacro %} -{% macro transform(property, source, destination, declare_type=True) %} +{% macro transform(property, source, destination, declare_type=True, skip_unset=False) %} {% if property.required %} {{ destination }} = {{ source }}.to_tuple() {% else %} -{{ destination }}{% if declare_type %}: {{ property.get_type_string(json=True) }}{% endif %} = UNSET +{% if not skip_unset %}{{ destination }}{% if declare_type %}: {{ property.get_type_string(json=True) }}{% endif %} = UNSET{% endif +%} if not isinstance({{ source }}, Unset): {{ destination }} = {{ source }}.to_tuple() {% endif %} diff --git a/openapi_python_client/templates/property_templates/list_property.py.jinja b/openapi_python_client/templates/property_templates/list_property.py.jinja index 60b93abd0..e765ed3b2 100644 --- a/openapi_python_client/templates/property_templates/list_property.py.jinja +++ b/openapi_python_client/templates/property_templates/list_property.py.jinja @@ -39,13 +39,13 @@ for {{ inner_source }} in {{ source }}: {% macro check_type_for_construct(property, source) %}isinstance({{ source }}, list){% endmacro %} -{% macro transform(property, source, destination, declare_type=True) %} +{% macro transform(property, source, destination, declare_type=True, skip_unset=False) %} {% set inner_property = property.inner_property %} {% set type_string = property.get_type_string(json=True) %} {% if property.required %} {{ _transform(property, source, destination, "to_dict") }} {% else %} -{{ destination }}{% if declare_type %}: {{ type_string }}{% endif %} = UNSET +{% if not skip_unset %}{{ destination }}{% if declare_type %}: {{ type_string }}{% endif %} = UNSET{% endif +%} if not isinstance({{ source }}, Unset): {{ _transform(property, source, destination, "to_dict") | indent(4)}} {% endif %} diff --git a/openapi_python_client/templates/property_templates/literal_enum_property.py.jinja b/openapi_python_client/templates/property_templates/literal_enum_property.py.jinja index 2cc4558c6..a0dd3d19b 100644 --- a/openapi_python_client/templates/property_templates/literal_enum_property.py.jinja +++ b/openapi_python_client/templates/property_templates/literal_enum_property.py.jinja @@ -10,12 +10,12 @@ check_{{ property.get_class_name_snake_case() }}({{ source }}) {% macro check_type_for_construct(property, source) %}isinstance({{ source }}, {{ property.get_instance_type_string() }}){% endmacro %} -{% macro transform(property, source, destination, declare_type=True) %} +{% macro transform(property, source, destination, declare_type=True, skip_unset=False) %} {% set type_string = property.get_type_string(json=True) %} {% if property.required %} {{ destination }}{% if declare_type %}: {{ type_string }}{% endif %} = {{ source }} {%- else %} -{{ destination }}{% if declare_type %}: {{ type_string }}{% endif %} = UNSET +{% if not skip_unset %}{{ destination }}{% if declare_type %}: {{ type_string }}{% endif %} = UNSET{% endif +%} if not isinstance({{ source }}, Unset): {{ destination }} = {{ source }} {% endif %} diff --git a/openapi_python_client/templates/property_templates/model_property.py.jinja b/openapi_python_client/templates/property_templates/model_property.py.jinja index d1a4b5d34..a442ccb70 100644 --- a/openapi_python_client/templates/property_templates/model_property.py.jinja +++ b/openapi_python_client/templates/property_templates/model_property.py.jinja @@ -10,13 +10,14 @@ {% macro check_type_for_construct(property, source) %}isinstance({{ source }}, dict){% endmacro %} -{% macro transform(property, source, destination, declare_type=True) %} +{% macro transform(property, source, destination, declare_type=True, skip_unset=False) %} {% set transformed = source + ".to_dict()" %} {% set type_string = property.get_type_string(json=True) %} {% if property.required %} {{ destination }} = {{ transformed }} {%- else %} -{{ destination }}{% if declare_type %}: {{ type_string }}{% endif %} = UNSET +{% if not skip_unset %}{{ destination }}{% if declare_type %}: {{ type_string }}{% endif %} = UNSET{% endif %} + if not isinstance({{ source }}, Unset): {{ destination }} = {{ transformed }} {%- endif %} diff --git a/openapi_python_client/templates/property_templates/union_property.py.jinja b/openapi_python_client/templates/property_templates/union_property.py.jinja index e21e08808..f4fd19f34 100644 --- a/openapi_python_client/templates/property_templates/union_property.py.jinja +++ b/openapi_python_client/templates/property_templates/union_property.py.jinja @@ -39,11 +39,11 @@ def _parse_{{ property.python_name }}(data: object) -> {{ property.get_type_stri {{ property.python_name }} = _parse_{{ property.python_name }}({{ source }}) {% endmacro %} -{% macro transform(property, source, destination, declare_type=True) %} +{% macro transform(property, source, destination, declare_type=True, skip_unset=False) %} {% set ns = namespace(contains_properties_without_transform = false, contains_modified_properties = not property.required, has_if = false) %} {% if declare_type %}{{ destination }}: {{ property.get_type_string(json=True) }}{% endif %} -{% if not property.required %} +{% if not property.required and not skip_unset %} if isinstance({{ source }}, Unset): {{ destination }} = UNSET {% set ns.has_if = true %} diff --git a/openapi_python_client/templates/property_templates/uuid_property.py.jinja b/openapi_python_client/templates/property_templates/uuid_property.py.jinja index 3a6ce46bb..044256b8a 100644 --- a/openapi_python_client/templates/property_templates/uuid_property.py.jinja +++ b/openapi_python_client/templates/property_templates/uuid_property.py.jinja @@ -10,16 +10,18 @@ UUID({{ source }}) {% macro check_type_for_construct(property, source) %}isinstance({{ source }}, str){% endmacro %} -{% macro transform(property, source, destination, declare_type=True) %} +{% macro transform(property, source, destination, declare_type=True, skip_unset=False) %} {% set transformed = "str(" + source + ")" %} {% if property.required %} {{ destination }} = {{ transformed }} {%- else %} -{% if declare_type %} -{% set type_annotation = property.get_type_string(json=True) %} +{% if not skip_unset %} + {% if declare_type %} + {% set type_annotation = property.get_type_string(json=True) %} {{ destination }}: {{ type_annotation }} = UNSET -{% else %} + {% else %} {{ destination }} = UNSET + {% endif %} {% endif %} if not isinstance({{ source }}, Unset): {{ destination }} = {{ transformed }}