Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b548c27
feat(python): preserve unknown model fields
ThomasRooney Oct 22, 2026
6b32e7f
fix(python): cover errors and empty models
ThomasRooney Oct 22, 2026
0001e9f
fix(python): retain top-level null semantics
ThomasRooney Oct 22, 2026
3ef3e90
test(python): assert empty JSON query exactly
ThomasRooney Oct 22, 2026
e7093b0
chore(python): sync review configuration
ThomasRooney Oct 22, 2026
ef883ed
ci: install patched PHP for generator tests
ThomasRooney Oct 22, 2026
220de1f
feat(python): replace model serialization overrides
ThomasRooney Oct 22, 2026
7c9ed79
fix(python): preserve Python enum objects
ThomasRooney Oct 22, 2026
85c371a
fix(python): keep consts in to_dict and to_json output
2ynn Oct 22, 2026
520c608
fix(python): reserve helper names only on model fields
2ynn Oct 23, 2026
0a3a764
fix(python): honour use_api_names=False in to_dict and to_json
2ynn Oct 22, 2026
0cbf652
ci: drop generator PHP setup already on main
2ynn Oct 22, 2026
9735841
fix(python): keep UNSET free of unknown fields
2ynn Oct 22, 2026
71f82ad
fix(python): read each field from the key the serializer emitted
2ynn Oct 23, 2026
11ac620
fix(python): ignore unknown response fields in generated test assertions
2ynn Oct 23, 2026
290f484
test(python): cover unknown-field options in existing uber variants
2ynn Oct 23, 2026
769f318
test(python): cover every unknown-field option combination
2ynn Oct 23, 2026
bc57c90
fix(python): leave excluded fields out of to_dict and to_json
2ynn Oct 23, 2026
537c6c3
fix(python): keep numbers and booleans beside open enums in unions
2ynn Oct 23, 2026
20d4285
docs(python): describe preserveUnknownFields limits
2ynn Oct 23, 2026
2f57f92
test(typescript): skip echoUnknownFields in the tertiary variant
2ynn Oct 23, 2026
c904549
Merge remote-tracking branch 'origin/main' into thomas/python-preserv…
2ynn Oct 23, 2026
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/1792668682-0165685a.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1792668682-0165685a
features:
- core
targets:
- pythonv2
type: feat
bump: patch
description: add opt-in model serialization helpers and unknown-field preservation; fix open enum JSON serialization
author: ThomasRooney
date: "2026-10-22"
1 change: 1 addition & 0 deletions internal/features/tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,7 @@ const (
TestJsonlStreamTimeoutStillBoundsSlowStream
TestJsonlStreamWithTimeoutStreamsToCompletion
TestSharedEnumConstructorDefault
TestUnknownFieldsJsonRoundTrip
)

func GetAllTests() []Test {
Expand Down
5 changes: 5 additions & 0 deletions internal/features/tests_generated.go

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

8 changes: 7 additions & 1 deletion templates/perms.go
Original file line number Diff line number Diff line change
Expand Up @@ -1543,6 +1543,7 @@ var fileMode = map[string]fs.FileMode{
"templates/pythonv2/auxiliary/tests/conftest.py.stmpl": 0o644,
"templates/pythonv2/auxiliary/tests/helpers.py.stmpl": 0o644,
"templates/pythonv2/auxiliary/tests/test_client.py.stmpl": 0o644,
"templates/pythonv2/auxiliary/tests/unknown_fields.py.stmpl": 0o644,
"templates/pythonv2/auxiliary/{{getSourcePath}}": 0o20000000755,
"templates/pythonv2/auxiliary/{{getSourcePath}}/__init__.py.stmpl": 0o644,
"templates/pythonv2/auxiliary/{{getSourcePath}}/_version.py.stmpl": 0o644,
Expand All @@ -1552,7 +1553,7 @@ var fileMode = map[string]fs.FileMode{
"templates/pythonv2/auxiliary/{{getSourcePath}}/types": 0o20000000755,
"templates/pythonv2/auxiliary/{{getSourcePath}}/types/__init__.py": 0o644,
"templates/pythonv2/auxiliary/{{getSourcePath}}/types/base64fileinput.py": 0o644,
"templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py": 0o644,
"templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl": 0o644,
"templates/pythonv2/auxiliary/{{getSourcePath}}/utils": 0o20000000755,
"templates/pythonv2/auxiliary/{{getSourcePath}}/utils/__init__.py.stmpl": 0o644,
"templates/pythonv2/auxiliary/{{getSourcePath}}/utils/annotations.py": 0o644,
Expand Down Expand Up @@ -1668,6 +1669,7 @@ var fileMode = map[string]fs.FileMode{
"templates/pythonv2/tests/client-credentials/test_client_credentials_hook.py": 0o644,
"templates/pythonv2/tests/common": 0o20000000755,
"templates/pythonv2/tests/common/common_helpers.py": 0o644,
"templates/pythonv2/tests/common/unknown_fields_cases.py": 0o644,
"templates/pythonv2/tests/custom-http": 0o20000000755,
"templates/pythonv2/tests/custom-http/test_auth_additional.py": 0o644,
"templates/pythonv2/tests/oauth2-password": 0o20000000755,
Expand Down Expand Up @@ -1702,6 +1704,7 @@ var fileMode = map[string]fs.FileMode{
"templates/pythonv2/tests/primary/test_telemetry.py.stmpl": 0o644,
"templates/pythonv2/tests/primary/test_timeout_additional.py": 0o644,
"templates/pythonv2/tests/primary/test_unions_additional.py": 0o644,
"templates/pythonv2/tests/primary/test_unknown_fields_additional.py": 0o644,
"templates/pythonv2/tests/primary/test_validation_errors.py": 0o644,
"templates/pythonv2/tests/primary/test_x_ndjson_additional.py": 0o644,
"templates/pythonv2/tests/quaternary": 0o20000000755,
Expand All @@ -1711,6 +1714,7 @@ var fileMode = map[string]fs.FileMode{
"templates/pythonv2/tests/quaternary/test_globals_additional.py": 0o644,
"templates/pythonv2/tests/quaternary/test_jsonl_streaming_additional.py": 0o644,
"templates/pythonv2/tests/quaternary/test_servers.py": 0o644,
"templates/pythonv2/tests/quaternary/test_unknown_fields_additional.py": 0o644,
"templates/pythonv2/tests/quaternary/test_validation_errors.py": 0o644,
"templates/pythonv2/tests/secondary": 0o20000000755,
"templates/pythonv2/tests/secondary/test_auth_additional.py": 0o644,
Expand All @@ -1725,13 +1729,15 @@ var fileMode = map[string]fs.FileMode{
"templates/pythonv2/tests/secondary/test_pagination.py": 0o644,
"templates/pythonv2/tests/secondary/test_redirects_additional.py": 0o644,
"templates/pythonv2/tests/secondary/test_servers.py": 0o644,
"templates/pythonv2/tests/secondary/test_unknown_fields_additional.py": 0o644,
"templates/pythonv2/tests/security-options": 0o20000000755,
"templates/pythonv2/tests/security-options/test_auth_additional.py": 0o644,
"templates/pythonv2/tests/tertiary": 0o20000000755,
"templates/pythonv2/tests/tertiary/test_auth_additional.py": 0o644,
"templates/pythonv2/tests/tertiary/test_body_typeddict_suffix.py": 0o644,
"templates/pythonv2/tests/tertiary/test_relative_imports.py": 0o644,
"templates/pythonv2/tests/tertiary/test_servers.py": 0o644,
"templates/pythonv2/tests/tertiary/test_unknown_fields_additional.py": 0o644,
"templates/pythonv2/testusage": 0o20000000755,
"templates/pythonv2/testusage/pylintrc": 0o644,
"templates/pythonv2/testusage/pyproject.toml.stmpl": 0o644,
Expand Down
1 change: 1 addition & 0 deletions templates/templates/cli/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ function isTestSkipped(test: string): boolean {
"parameters-path-parameter-formats",
"shared-enum-constructor-default",
"open-union-invalid-json-is-an-error",
"unknown-fields-json-round-trip",
].includes(test);
}

Expand Down
1 change: 1 addition & 0 deletions templates/templates/csharp/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ function isTestSkipped(test: string): boolean {
"event-stream-absent-required-union",
"event-stream-equivalent-number-lexemes",
"event-stream-null-and-undeclared-fields",
"unknown-fields-json-round-trip",
].includes(test);
}

Expand Down
1 change: 1 addition & 0 deletions templates/templates/go/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ function isTestSkipped(test: string): boolean {
"event-stream-absent-required-union",
"event-stream-equivalent-number-lexemes",
"event-stream-null-and-undeclared-fields",
"unknown-fields-json-round-trip",
].includes(test);
}

Expand Down
1 change: 1 addition & 0 deletions templates/templates/javav2/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ function isTestSkipped(test: string): boolean {
"event-stream-absent-required-union",
"event-stream-equivalent-number-lexemes",
"event-stream-null-and-undeclared-fields",
"unknown-fields-json-round-trip",
].includes(test);
}

Expand Down
1 change: 1 addition & 0 deletions templates/templates/mcp-typescript/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ function isTestSkipped(test: string): boolean {
"event-stream-absent-required-union",
"event-stream-equivalent-number-lexemes",
"event-stream-null-and-undeclared-fields",
"unknown-fields-json-round-trip",
].includes(test);
}

Expand Down
1 change: 1 addition & 0 deletions templates/templates/php/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ function isTestSkipped(test: string): boolean {
"event-stream-absent-required-union",
"event-stream-equivalent-number-lexemes",
"event-stream-null-and-undeclared-fields",
"unknown-fields-json-round-trip",
].includes(test);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{{- if and (sdkHasTests .Global.AST) .Global.Config.PreserveUnknownFields -}}
from typing import Any

from pydantic import BaseModel


def drop_unexpected_fields(actual: Any, expected: Any) -> Any:
if isinstance(actual, BaseModel) and type(actual) is type(expected):
trimmed = actual.model_copy(
update={
name: drop_unexpected_fields(getattr(actual, name), getattr(expected, name))
for name in type(actual).model_fields
}
)
if actual.__pydantic_extra__ is not None and "__pydantic_extra__" not in type(actual).__annotations__:
expected_extra = expected.__pydantic_extra__ or {}
trimmed.__pydantic_extra__ = {
key: drop_unexpected_fields(value, expected_extra[key])
for key, value in actual.__pydantic_extra__.items()
if key in expected_extra
}
return trimmed
if isinstance(actual, list) and isinstance(expected, list) and len(actual) == len(expected):
return [drop_unexpected_fields(a, e) for a, e in zip(actual, expected)]
if isinstance(actual, dict) and isinstance(expected, dict):
return {
key: drop_unexpected_fields(value, expected[key]) if key in expected else value
for key, value in actual.items()
}
return actual
{{- end}}
Original file line number Diff line number Diff line change
@@ -1,17 +1,62 @@
from pydantic import ConfigDict, model_serializer
from pydantic import BaseModel as PydanticBaseModel
from pydantic_core import core_schema
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union
from pydantic_core import PydanticSerializationUnexpectedValue, core_schema
from typing import TYPE_CHECKING, Any, Literal, Optional, Type, TypeVar, Union
from typing_extensions import TypeAliasType, TypeAlias


class BaseModel(PydanticBaseModel):
model_config = ConfigDict(
populate_by_name=True, arbitrary_types_allowed=True, protected_namespaces=()
populate_by_name=True, arbitrary_types_allowed=True, protected_namespaces=(){{if .Global.Config.PreserveUnknownFields}}, extra="allow"{{end}}
)

{{if .Global.Config.ModelSerializationMethods}}
def to_dict(
self,
*,
mode: Literal["json", "python"] = "python",
use_api_names: bool = True,
exclude_unset: bool = True,
exclude_defaults: bool = False,
exclude_none: bool = False,
warnings: bool = True,
) -> dict[str, Any]:
return self.model_dump(
mode=mode,
by_alias=use_api_names,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
warnings=warnings,
context={SERIALIZATION_METHODS_DUMP: True},
)

def to_json(
self,
*,
indent: Optional[int] = 2,
use_api_names: bool = True,
exclude_unset: bool = True,
exclude_defaults: bool = False,
exclude_none: bool = False,
warnings: bool = True,
) -> str:
return self.model_dump_json(
indent=indent,
by_alias=use_api_names,
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
warnings=warnings,
context={SERIALIZATION_METHODS_DUMP: True},
)

{{end}}

class Unset(BaseModel):
{{- if .Global.Config.PreserveUnknownFields}}
model_config = ConfigDict(extra="ignore")
{{end}}
@model_serializer(mode="plain")
def serialize_model(self):
return UNSET_SENTINEL
Expand All @@ -22,6 +67,9 @@ def __bool__(self) -> Literal[False]:

UNSET = Unset()
UNSET_SENTINEL = "~?~unset~?~sentinel~?~"
{{- if .Global.Config.ModelSerializationMethods}}
SERIALIZATION_METHODS_DUMP = "speakeasy_serialization_methods_dump"
{{- end}}


T = TypeVar("T")
Expand All @@ -35,6 +83,18 @@ def __bool__(self) -> Literal[False]:
)


def _unrecognized_serialization(cls: Type[Union[str, int]]) -> core_schema.PlainSerializerFunctionSerSchema:
def serialize(value: Any, info: core_schema.SerializationInfo) -> Any:
# Decline other values so union serialization tries the remaining members
if not isinstance(value, cls):
raise PydanticSerializationUnexpectedValue(f"Expected {cls.__name__}")
if info.mode == "python":
return value
return str(value) if isinstance(value, str) else int(value)

return core_schema.plain_serializer_function_ser_schema(serialize, info_arg=True)


class UnrecognizedStr(str):
@classmethod
def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> core_schema.CoreSchema:
Expand All @@ -54,6 +114,7 @@ def validate_lax(v: Any) -> 'UnrecognizedStr':
core_schema.no_info_plain_validator_function(validate_lax)
]),
strict_schema=core_schema.none_schema(), # Always fails in strict mode
serialization=_unrecognized_serialization(cls),
)


Expand All @@ -72,4 +133,5 @@ def validate_lax(v: Any) -> 'UnrecognizedInt':
core_schema.no_info_plain_validator_function(validate_lax)
]),
strict_schema=core_schema.none_schema(), # Always fails in strict mode
serialization=_unrecognized_serialization(cls),
)
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ def marshal_json(val, typ):

m = marshaller(body=val)

d = m.model_dump(by_alias=True, mode="json", exclude_none=True)
d = m.model_dump(by_alias=True, mode="json", exclude_none={{if .Global.Config.PreserveUnknownFields}}getattr(m, "body") is None{{else}}True{{end}})

if len(d) == 0:
return ""
Expand Down
18 changes: 18 additions & 0 deletions templates/templates/pythonv2/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,24 @@ function getConfigFields(
ValidationRegex: /^(true|false)$/.source,
ValidationMessage: "true or false only",
},
modelSerializationMethods: {
Name: "modelSerializationMethods",
Required: false,
DefaultValue: false,
Description:
"Add to_dict and to_json convenience methods to models, using API field names and excluding unset fields by default.",
ValidationRegex: /^(true|false)$/.source,
ValidationMessage: "true or false only",
},
preserveUnknownFields: {
Name: "preserveUnknownFields",
Required: false,
DefaultValue: false,
Description:
"Preserve undeclared fields in JSON request and response models, including nested models and schemas with additionalProperties: false. Explicitly typed additional properties retain their validation. Flattened operation arguments accept only declared fields, so undeclared top-level request fields require flattenRequests: false; nested models passed as arguments keep theirs. Disabled by default.",
ValidationRegex: /^(true|false)$/.source,
ValidationMessage: "true or false only",
},
responseSchemaValidation: {
Name: "responseSchemaValidation",
Required: false,
Expand Down
25 changes: 18 additions & 7 deletions templates/templates/pythonv2/includes/sanitization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,12 @@ function sanitizeSDKAccess(sdk: SDK): string {
let formattedName = "";
if (group.includes(".") && ".".repeat(group.length) !== group) {
const pieces = group.split(".");
formattedName = sanitizeFieldName(pieces[0]);
formattedName = sanitizeSubSDKFieldName(pieces[0]);
for (const i of pieces.slice(1)) {
formattedName += "." + sanitizeFieldName(i);
formattedName += "." + sanitizeSubSDKFieldName(i);
}
} else {
formattedName = sanitizeFieldName(group);
formattedName = sanitizeSubSDKFieldName(group);
}

if (formattedName != "") {
Expand Down Expand Up @@ -311,6 +311,20 @@ registerTemplateFunc("sanitizeSDKFileName", sanitizeSDKFileName);

// @ts-ignore
function sanitizeFieldName(name: string): string {
name = sanitizeAttributeName(name);

if (
context.Global.Config.ModelSerializationMethods &&
["to_dict", "to_json"].includes(name)
) {
name += "_";
}

return name;
}
registerTemplateFunc("sanitizeFieldName", sanitizeFieldName);

function sanitizeAttributeName(name: string): string {
name = sanitizeName(name);
if (name.endsWith("_")) {
name = name.substring(0, name.length - 1);
Expand All @@ -326,7 +340,6 @@ function sanitizeFieldName(name: string): string {

return name;
}
registerTemplateFunc("sanitizeFieldName", sanitizeFieldName);

function useConflictResistantModelImports(): boolean {
return !!context.Global.Config.FixFlags?.[
Expand Down Expand Up @@ -413,9 +426,7 @@ function doesModuleNameConflictAcrossNamespaces(

// @ts-ignore
function sanitizeSubSDKFieldName(name: string): string {
name = sanitizeFieldName(name);

return name;
return sanitizeAttributeName(name);
}
registerTemplateFunc("sanitizeSubSDKFieldName", sanitizeSubSDKFieldName);

Expand Down
Loading
Loading