Skip to content
Open
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/1792768307-aaecd177.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1792768307-aaecd177
features:
- core
targets:
- pythonv2
type: feat
bump: patch
description: add preserveUnknownFields to keep undeclared fields in JSON request and response models
author: 2ynn
date: "2026-10-23"
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.

7 changes: 6 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 @@ -1703,6 +1705,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 @@ -1712,6 +1715,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 @@ -1726,6 +1730,7 @@ 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,
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,32 @@
{{- 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:
declared = "__pydantic_extra__" in type(actual).__annotations__
expected_extra = expected.__pydantic_extra__ or {}
trimmed.__pydantic_extra__ = {
key: drop_unexpected_fields(value, expected_extra[key]) if key in expected_extra else value
for key, value in actual.__pydantic_extra__.items()
if declared or 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
Expand Up @@ -7,11 +7,14 @@

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}}
)


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 Down
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
9 changes: 9 additions & 0 deletions templates/templates/pythonv2/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,15 @@ function getConfigFields(
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
39 changes: 23 additions & 16 deletions templates/templates/pythonv2/includes/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,22 +151,29 @@ function templateAssertion(
operator = "is";
}

lines.push(
`assert ${assertionValue} ${operator} ${templateValue(
field,
example,
false,
{
usageContext: usageContext,
operation: usageContext?.Operation,
test: usageContext.Test,
isTest: true,
typedDictDisabled: true,
isResponse: true,
targetingMockServer: usageContext.UsingMockServer,
},
)}`,
);
const expected = templateValue(field, example, false, {
usageContext: usageContext,
operation: usageContext?.Operation,
test: usageContext.Test,
isTest: true,
typedDictDisabled: true,
isResponse: true,
targetingMockServer: usageContext.UsingMockServer,
});

if (
context.Global.Config.PreserveUnknownFields &&
["class", "array", "map", "union"].includes(field.Type.Type.toString())
Comment thread
2ynn marked this conversation as resolved.
) {
addImport(".unknown_fields", "drop_unexpected_fields");
lines.push(`expected = ${expected}`);
lines.push(
`assert drop_unexpected_fields(${assertionValue}, expected) == expected`,
);
return lines;
}

lines.push(`assert ${assertionValue} ${operator} ${expected}`);
return lines;
}

Expand Down
14 changes: 12 additions & 2 deletions templates/templates/pythonv2/model.py.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
{{- $hasOptionalField := gt (len $optionalFields) 0 -}}
{{- $hasNullableField := gt (len $nullableFields) 0 -}}
{{- $hasAdditionalProperties := hasAdditionalPropertiesField $type -}}
{{- $preserveUnknownFields := .Global.Config.PreserveUnknownFields -}}
{{- if $hasAdditionalProperties}}{{addImport "pydantic" "ConfigDict"}}{{end -}}
{{- $fields := (sortFields $type.Fields) -}}
{{- $className := sanitizeClassName $type.Name }}
Expand Down Expand Up @@ -93,11 +94,20 @@ class {{$className}}(BaseModel):

for n, f in type(self).model_fields.items():
k = f.alias if f.alias is not None else n
{{- if $preserveUnknownFields}}
{{- /* Pydantic writes extras after declared fields, so an extra under a field's emitted key replaces that field's value */}}
{{- /* A nested model is serialized again from its own output, which may already use aliases */}}
key = k if info.by_alias or k in serialized else n
if key not in serialized and not info.exclude_none:
continue
val = serialized.pop(key, None)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
{{- else}}
if k not in serialized and n not in serialized and not info.exclude_none:
continue
val = serialized.get(k, serialized.get(n))
{{- if $hasAdditionalProperties}}
{{- if $hasAdditionalProperties}}
serialized.pop(k, serialized.pop(n, None))
{{- end}}
{{- end}}

{{- if and $hasOptionalField $hasNullableField}}
Expand Down Expand Up @@ -126,7 +136,7 @@ class {{$className}}(BaseModel):
m[k] = val
{{- end}}

{{- if $hasAdditionalProperties}}
{{- if or $hasAdditionalProperties $preserveUnknownFields}}
for k, v in serialized.items():
m[k] = v
{{- end}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
UNKNOWN_FIELD_CASES = [
({"futureKind": "third", "futureCode": 3}, {"futureKind": "third", "futureCode": 3}),
({"future": "value"}, {}),
({"future": False}, {}),
({"empty": {"future": True}}, {"empty": {}}),
({"future": None}, {}),
({"plainChoice": {"text": "value", "future": True}}, {"plainChoice": {"text": "value"}}),
({"plainChoice": {"number": 3, "future": True}}, {"plainChoice": {"number": 3}}),
({"requiredOnly": {"label": "required", "future": True}}, {"requiredOnly": {"label": "required"}}),
({"future": 0}, {}),
({"composed": {"displayName": "child", "category": "combined", "future": True}}, {"composed": {"displayName": "child", "category": "combined"}}),
({"split": {"label": "split", "future": True}}, {"split": {"label": "split"}}),
({"future": []}, {}),
({"future": {"nested": [None, False, 0, "value"]}}, {}),
({"child": {"displayName": "child", "future": True}}, {"child": {"displayName": "child"}}),
({"child": {"displayName": "child", "display_name": "unknown"}}, {"child": {"displayName": "child"}}),
({"child": {"displayName": "child", "child": {"displayName": "grandchild", "future": True}}}, {"child": {"displayName": "child", "child": {"displayName": "grandchild"}}}),
({"children": [{"displayName": "child", "future": True}]}, {"children": [{"displayName": "child"}]}),
({"lookup": {"one": {"displayName": "child", "future": True}}}, {"lookup": {"one": {"displayName": "child"}}}),
({"choice": {"kind": "text", "text": "value", "future": True}}, {"choice": {"kind": "text", "text": "value"}}),
({"choice": {"kind": "number", "number": 3, "future": True}}, {"choice": {"kind": "number", "number": 3}}),
({"closed": {"label": "closed", "future": True}}, {"closed": {"label": "closed"}}),
({"open": {"label": "open", "future": True}}, {"open": {"label": "open", "future": True}}),
({"typed": {"label": "typed", "future": "value"}}, {"typed": {"label": "typed", "future": "value"}}),
({"typedNodes": {"label": "nodes", "one": {"displayName": "child", "future": True}}}, {"typedNodes": {"label": "nodes", "one": {"displayName": "child"}}}),
({"arbitrary": {"future": {"nullable": None}}}, {"arbitrary": {"future": {"nullable": None}}}),
({"child": {"displayName": "child", "nullable": None}}, {"child": {"displayName": "child", "nullable": None}}),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import json

import pytest

from openapi import SDK, utils
from openapi.models import shared
from .common_helpers import HTTPBIN_URL
from .unknown_fields_cases import UNKNOWN_FIELD_CASES


@pytest.mark.parametrize("fields, known_fields", UNKNOWN_FIELD_CASES)
def test_unknown_fields_dropped_by_default(fields, known_fields):
model = shared.FieldEnvelopeInput.model_validate({"label": "root", **fields})
Comment thread
2ynn marked this conversation as resolved.
expected = {"label": "root", **known_fields}
assert model.model_dump(mode="json", by_alias=True, exclude_none=True) == expected
assert json.loads(utils.marshal_json(model, shared.FieldEnvelopeInput)) == expected


def test_flattened_request_drops_nested_unknown_fields_by_default():
with SDK(server_url=HTTPBIN_URL) as sdk:
response = sdk.echo_flattened_unknown_fields(label="root", child={"displayName": "child", "future": True})
assert response.http_meta.response.json()["json"] == {"label": "root", "child": {"displayName": "child"}}
assert response.object.model_dump(by_alias=True, exclude_none=True) == {"json": {"label": "root", "child": {"displayName": "child"}}}
Loading
Loading