Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changesets/1792781031-100ce902.yaml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions templates/perms.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions templates/templates/pythonv2/includes/sanitization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1718,7 +1718,6 @@ function sanitizeAdditionalPropertiesField(
}

addImport("typing", "Dict");
addImport("pydantic", "");
return `Dict[str, ${sanitizePydanticType(
typeDefToFieldDef(
field.Type.ItemType,
Expand All @@ -1729,7 +1728,7 @@ function sanitizeAdditionalPropertiesField(
{ optional: false, nullable: field.Type.ContainsNull },
usageLocation,
owningModelInfo,
)}] = pydantic.Field(init=False)`;
)}]`;
}
registerTemplateFunc(
"sanitizeAdditionalPropertiesField",
Expand Down
11 changes: 10 additions & 1 deletion templates/templates/pythonv2/model.py.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions zSDKs/sdk-pythonv2/.speakeasy/gen.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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


Expand All @@ -16,7 +16,10 @@ 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)
if TYPE_CHECKING:
__pydantic_extra__: Dict[str, str] = pydantic.Field(init=False)
else:
__pydantic_extra__: Dict[str, str]

allergies: Optional[str] = None

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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


Expand All @@ -20,7 +20,10 @@ 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)
if TYPE_CHECKING:
__pydantic_extra__: Dict[str, Any] = pydantic.Field(init=False)
else:
__pydantic_extra__: Dict[str, Any]

notes: Optional[str] = None

Expand Down
Loading