From 210ee421dcb9fe153f69b6347e030cc4e47df7f9 Mon Sep 17 00:00:00 2001 From: Melody Liu Date: Fri, 23 Oct 2026 14:48:01 -0400 Subject: [PATCH 1/2] fix(python): preserve optional nested additional properties --- .changesets/1792781031-100ce902.yaml | 10 +++ templates/perms.go | 1 + .../pythonv2/includes/sanitization.ts | 5 +- .../test_optional_nested_additional.py | 34 +++++++++ ...optional-nested-additional-properties.yaml | 76 +++++++++++++++++++ zSDKs/sdk-pythonv2/.speakeasy/gen.lock | 4 +- .../speakeasy/new_openapi/models/metadata.py | 3 +- .../new_openapi/models/metadataany.py | 3 +- 8 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 .changesets/1792781031-100ce902.yaml create mode 100644 templates/templates/pythonv2/tests/primary/test_optional_nested_additional.py create mode 100644 tests/specs/fragments/primary/optional-nested-additional-properties.yaml diff --git a/.changesets/1792781031-100ce902.yaml b/.changesets/1792781031-100ce902.yaml new file mode 100644 index 000000000..95c5ac975 --- /dev/null +++ b/.changesets/1792781031-100ce902.yaml @@ -0,0 +1,10 @@ +id: 1792781031-100ce902 +features: + - additionalProperties +targets: + - pythonv2 +type: fix +bump: patch +description: preserve optional nested objects with additional properties +author: liutmelody +date: "2026-10-23" diff --git a/templates/perms.go b/templates/perms.go index 3bd7b0c7e..0d35c3b09 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -1691,6 +1691,7 @@ var fileMode = map[string]fs.FileMode{ "templates/pythonv2/tests/primary/test_model_serializer_alias.py": 0o644, "templates/pythonv2/tests/primary/test_open_discriminated_union.py": 0o644, "templates/pythonv2/tests/primary/test_open_enum_union_discrimination.py": 0o644, + "templates/pythonv2/tests/primary/test_optional_nested_additional.py": 0o644, "templates/pythonv2/tests/primary/test_pagination.py": 0o644, "templates/pythonv2/tests/primary/test_parameter_additional.py": 0o644, "templates/pythonv2/tests/primary/test_positional_arguments.py": 0o644, diff --git a/templates/templates/pythonv2/includes/sanitization.ts b/templates/templates/pythonv2/includes/sanitization.ts index 74309517a..03d75bfa7 100644 --- a/templates/templates/pythonv2/includes/sanitization.ts +++ b/templates/templates/pythonv2/includes/sanitization.ts @@ -1718,7 +1718,8 @@ function sanitizeAdditionalPropertiesField( } addImport("typing", "Dict"); - addImport("pydantic", ""); + // A Field default shadows BaseModel's __pydantic_extra__ slot. Nested union + // serialization then treats it as an unexpected field and can select Unset. return `Dict[str, ${sanitizePydanticType( typeDefToFieldDef( field.Type.ItemType, @@ -1729,7 +1730,7 @@ function sanitizeAdditionalPropertiesField( { optional: false, nullable: field.Type.ContainsNull }, usageLocation, owningModelInfo, - )}] = pydantic.Field(init=False)`; + )}]`; } registerTemplateFunc( "sanitizeAdditionalPropertiesField", diff --git a/templates/templates/pythonv2/tests/primary/test_optional_nested_additional.py b/templates/templates/pythonv2/tests/primary/test_optional_nested_additional.py new file mode 100644 index 000000000..8576ac2d7 --- /dev/null +++ b/templates/templates/pythonv2/tests/primary/test_optional_nested_additional.py @@ -0,0 +1,34 @@ +import json + +from openapi.models.shared import OptionalNestedRequest, OptionalNestedResponse + + +def test_optional_nested_request_preserves_additional_properties(): + payload = { + "configuration": { + "options": {"kind": "structured", "custom_options": True}, + "custom_configuration": "keep", + }, + "custom_request": 7, + } + request = OptionalNestedRequest.model_validate(payload) + + assert json.loads(request.model_dump_json(by_alias=True)) == payload + assert "configuration" not in OptionalNestedRequest().model_dump(by_alias=True) + assert OptionalNestedRequest(configuration=None).model_dump(by_alias=True)["configuration"] is None + + +def test_optional_nested_response_preserves_usage(): + payload = { + "usage": { + "total": 7, + "details": {"reused": 0, "custom_details": 2}, + "custom_usage": "keep", + }, + "custom_response": True, + } + response = OptionalNestedResponse.model_validate(payload) + + assert response.usage.total == 7 + assert response.model_dump(by_alias=True) == payload + assert json.loads(response.model_dump_json(by_alias=True)) == payload diff --git a/tests/specs/fragments/primary/optional-nested-additional-properties.yaml b/tests/specs/fragments/primary/optional-nested-additional-properties.yaml new file mode 100644 index 000000000..cab7dacc5 --- /dev/null +++ b/tests/specs/fragments/primary/optional-nested-additional-properties.yaml @@ -0,0 +1,76 @@ +openapi: 3.1.0 +security: + - apiKeyAuth: [] + - oauth2: [] + - clientCredentials: [read, write] + - customSchemeAppId: [] + - basicHttp: [] + - accessToken: [] + - {} +paths: + /primary/optional-nested-additional-properties: + post: + operationId: primaryOptionalNestedAdditionalProperties + description: >- + Optional nullable objects can contain nested objects with declared + fields and additional properties. Serialization must preserve both. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OptionalNestedRequest" + responses: + "200": + description: Optional nested usage details with additional properties. + content: + application/json: + schema: + $ref: "#/components/schemas/OptionalNestedResponse" +components: + schemas: + OptionalNestedRequest: + type: object + additionalProperties: true + properties: + configuration: + anyOf: + - $ref: "#/components/schemas/OptionalNestedConfiguration" + - type: "null" + OptionalNestedConfiguration: + type: object + additionalProperties: true + properties: + options: + $ref: "#/components/schemas/OptionalNestedOptions" + OptionalNestedOptions: + type: object + additionalProperties: true + required: [kind] + properties: + kind: + type: string + OptionalNestedResponse: + type: object + additionalProperties: true + properties: + usage: + anyOf: + - $ref: "#/components/schemas/OptionalNestedUsage" + - type: "null" + OptionalNestedUsage: + type: object + additionalProperties: true + required: [total, details] + properties: + total: + type: integer + details: + $ref: "#/components/schemas/OptionalNestedUsageDetails" + OptionalNestedUsageDetails: + type: object + additionalProperties: true + required: [reused] + properties: + reused: + type: integer diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock index 8d782d4eb..117456985 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock @@ -634,9 +634,9 @@ trackedFiles: src/speakeasy/new_openapi/models/mcppolicy.py: last_write_checksum: sha1:79b406c5b22b34cc7d9b42eed4d5763ed4dffb81 src/speakeasy/new_openapi/models/metadata.py: - last_write_checksum: sha1:928fb3c7da2cbfa0a7f3fe25a2b87811ff4527b0 + last_write_checksum: sha1:2eeb23413ad9d48a9487c62da3f04c7fece235d2 src/speakeasy/new_openapi/models/metadataany.py: - last_write_checksum: sha1:10e645df6416f193bcb1ea032b6d8fd34604ff83 + last_write_checksum: sha1:3befe574e13b0adebf89da691c6ba96c66ad5875 src/speakeasy/new_openapi/models/namespaceconflicttest.py: last_write_checksum: sha1:3c086e737bdd18289b7b56a14e9813070773cafa src/speakeasy/new_openapi/models/namespacetypestest.py: diff --git a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadata.py b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadata.py index 550f864b0..607aed0d7 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadata.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadata.py @@ -2,7 +2,6 @@ from __future__ import annotations from ..types import BaseModel, UNSET_SENTINEL -import pydantic from pydantic import ConfigDict, model_serializer from typing import Dict, Optional from typing_extensions import NotRequired, TypedDict @@ -16,7 +15,7 @@ class Metadata(BaseModel): model_config = ConfigDict( populate_by_name=True, arbitrary_types_allowed=True, extra="allow" ) - __pydantic_extra__: Dict[str, str] = pydantic.Field(init=False) + __pydantic_extra__: Dict[str, str] allergies: Optional[str] = None diff --git a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadataany.py b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadataany.py index 9e397163b..4eb1aa551 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadataany.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadataany.py @@ -2,7 +2,6 @@ from __future__ import annotations from ..types import BaseModel, UNSET_SENTINEL -import pydantic from pydantic import ConfigDict, model_serializer from typing import Any, Dict, Optional from typing_extensions import NotRequired, TypedDict @@ -20,7 +19,7 @@ class MetadataAny(BaseModel): model_config = ConfigDict( populate_by_name=True, arbitrary_types_allowed=True, extra="allow" ) - __pydantic_extra__: Dict[str, Any] = pydantic.Field(init=False) + __pydantic_extra__: Dict[str, Any] notes: Optional[str] = None From 6517a5150f34175befff318455dd60721bcef18c Mon Sep 17 00:00:00 2001 From: Melody Liu Date: Fri, 23 Oct 2026 15:32:38 -0400 Subject: [PATCH 2/2] fix(python): keep __pydantic_extra__ Field default for type checkers only --- templates/templates/pythonv2/includes/sanitization.ts | 2 -- templates/templates/pythonv2/model.py.stmpl | 11 ++++++++++- zSDKs/sdk-pythonv2/.speakeasy/gen.lock | 4 ++-- .../src/speakeasy/new_openapi/models/metadata.py | 8 ++++++-- .../src/speakeasy/new_openapi/models/metadataany.py | 8 ++++++-- 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/templates/templates/pythonv2/includes/sanitization.ts b/templates/templates/pythonv2/includes/sanitization.ts index 03d75bfa7..1e20102a9 100644 --- a/templates/templates/pythonv2/includes/sanitization.ts +++ b/templates/templates/pythonv2/includes/sanitization.ts @@ -1718,8 +1718,6 @@ function sanitizeAdditionalPropertiesField( } addImport("typing", "Dict"); - // A Field default shadows BaseModel's __pydantic_extra__ slot. Nested union - // serialization then treats it as an unexpected field and can select Unset. return `Dict[str, ${sanitizePydanticType( typeDefToFieldDef( field.Type.ItemType, diff --git a/templates/templates/pythonv2/model.py.stmpl b/templates/templates/pythonv2/model.py.stmpl index c4d42bc95..9a12bcb41 100644 --- a/templates/templates/pythonv2/model.py.stmpl +++ b/templates/templates/pythonv2/model.py.stmpl @@ -24,7 +24,16 @@ class {{$className}}(BaseModel): {{- end}} {{- if $hasAdditionalProperties}} model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, extra="allow") - __pydantic_extra__: {{sanitizeAdditionalPropertiesField (getAdditionalPropertiesField $type) $usageLocation $local}} + {{- $additionalPropertiesType := sanitizeAdditionalPropertiesField (getAdditionalPropertiesField $type) $usageLocation $local}} + {{- addImport "typing" "TYPE_CHECKING"}} + {{- addImport "pydantic" ""}} + {{- /* A runtime Field default shadows BaseModel's __pydantic_extra__ slot, so + nested union serialization can select Unset and drop the value. Keep the + default for type checkers only so that constructors stay valid. */}} + if TYPE_CHECKING: + __pydantic_extra__: {{$additionalPropertiesType}} = pydantic.Field(init=False) + else: + __pydantic_extra__: {{$additionalPropertiesType}} {{- end}} {{- if $pagination }} diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock index 117456985..f7fdc3bf6 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock @@ -634,9 +634,9 @@ trackedFiles: src/speakeasy/new_openapi/models/mcppolicy.py: last_write_checksum: sha1:79b406c5b22b34cc7d9b42eed4d5763ed4dffb81 src/speakeasy/new_openapi/models/metadata.py: - last_write_checksum: sha1:2eeb23413ad9d48a9487c62da3f04c7fece235d2 + last_write_checksum: sha1:0986d143f3fd9ca6db2c01df8d5ebd6c8a23e68c src/speakeasy/new_openapi/models/metadataany.py: - last_write_checksum: sha1:3befe574e13b0adebf89da691c6ba96c66ad5875 + last_write_checksum: sha1:99aa26007a11ff5cdf43d07c2ba340c54227f0c4 src/speakeasy/new_openapi/models/namespaceconflicttest.py: last_write_checksum: sha1:3c086e737bdd18289b7b56a14e9813070773cafa src/speakeasy/new_openapi/models/namespacetypestest.py: diff --git a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadata.py b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadata.py index 607aed0d7..ed56d22b0 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadata.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadata.py @@ -2,8 +2,9 @@ from __future__ import annotations from ..types import BaseModel, UNSET_SENTINEL +import pydantic from pydantic import ConfigDict, model_serializer -from typing import Dict, Optional +from typing import Dict, Optional, TYPE_CHECKING from typing_extensions import NotRequired, TypedDict @@ -15,7 +16,10 @@ class Metadata(BaseModel): model_config = ConfigDict( populate_by_name=True, arbitrary_types_allowed=True, extra="allow" ) - __pydantic_extra__: Dict[str, str] + if TYPE_CHECKING: + __pydantic_extra__: Dict[str, str] = pydantic.Field(init=False) + else: + __pydantic_extra__: Dict[str, str] allergies: Optional[str] = None diff --git a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadataany.py b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadataany.py index 4eb1aa551..991b40c6c 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadataany.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/models/metadataany.py @@ -2,8 +2,9 @@ from __future__ import annotations from ..types import BaseModel, UNSET_SENTINEL +import pydantic from pydantic import ConfigDict, model_serializer -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, TYPE_CHECKING from typing_extensions import NotRequired, TypedDict @@ -19,7 +20,10 @@ class MetadataAny(BaseModel): model_config = ConfigDict( populate_by_name=True, arbitrary_types_allowed=True, extra="allow" ) - __pydantic_extra__: Dict[str, Any] + if TYPE_CHECKING: + __pydantic_extra__: Dict[str, Any] = pydantic.Field(init=False) + else: + __pydantic_extra__: Dict[str, Any] notes: Optional[str] = None