From b548c27a40674aabba557ee685ba41c8985c9d58 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 22 Oct 2026 12:35:45 +0100 Subject: [PATCH 01/21] feat(python): preserve unknown model fields --- .changesets/1792668682-0165685a.yaml | 10 ++ Makefile | 2 + scripts/build-openapi-document.sh | 4 +- templates/perms.go | 4 + .../utils/serializers.py.stmpl | 2 +- templates/templates/pythonv2/config.ts | 9 + templates/templates/pythonv2/model.py.stmpl | 11 +- .../test_unknown_fields.py.stmpl | 1 + .../test_unknown_fields.py.stmpl | 93 +++++++++++ .../pythonv2/.speakeasy/gen.yaml | 16 ++ .../pythonv2/.speakeasy/gen.yaml | 16 ++ tests/specs/unknown-fields.yaml | 157 ++++++++++++++++++ tests/tests/unknown-fields-default/.gitkeep | 0 tests/tests/unknown-fields/.gitkeep | 0 zSDKs/sdk-pythonv2/.speakeasy/gen.yaml | 1 + 15 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 .changesets/1792668682-0165685a.yaml create mode 100644 templates/templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl create mode 100644 templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl create mode 100644 tests/config/unknown-fields-default/pythonv2/.speakeasy/gen.yaml create mode 100644 tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml create mode 100644 tests/specs/unknown-fields.yaml create mode 100644 tests/tests/unknown-fields-default/.gitkeep create mode 100644 tests/tests/unknown-fields/.gitkeep diff --git a/.changesets/1792668682-0165685a.yaml b/.changesets/1792668682-0165685a.yaml new file mode 100644 index 000000000..8692a95a8 --- /dev/null +++ b/.changesets/1792668682-0165685a.yaml @@ -0,0 +1,10 @@ +id: 1792668682-0165685a +features: + - core +targets: + - pythonv2 +type: feat +bump: patch +description: add opt-in preservation of unknown model fields +author: ThomasRooney +date: "2026-10-22" diff --git a/Makefile b/Makefile index 7655657f2..1cf513b5f 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,8 @@ VARIANTS := primary \ secondary \ tertiary \ quaternary \ +unknown-fields \ +unknown-fields-default \ no-zod \ client-credentials \ client-credentials-basic \ diff --git a/scripts/build-openapi-document.sh b/scripts/build-openapi-document.sh index f0903f8cf..20c865350 100755 --- a/scripts/build-openapi-document.sh +++ b/scripts/build-openapi-document.sh @@ -16,7 +16,9 @@ fi template_spec="./tests/specs/uber.yaml" component_spec="./tests/specs/components.yaml" -if [ "${VARIANT}" == "oauth2-password" ] || [ "${VARIANT}" == "custom-http" ]; then +if [ "${VARIANT}" == "unknown-fields" ] || [ "${VARIANT}" == "unknown-fields-default" ]; then + template_spec="./tests/specs/unknown-fields.yaml" +elif [ "${VARIANT}" == "oauth2-password" ] || [ "${VARIANT}" == "custom-http" ]; then template_spec="./tests/specs/ecommerce.yaml" component_spec="./tests/specs/components.ecommerce.yaml" elif [ "${VARIANT}" == "no-servers" ] || [ "${VARIANT}" == "relative-servers" ]; then diff --git a/templates/perms.go b/templates/perms.go index 3bd7b0c7e..b3325761b 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -1732,6 +1732,10 @@ var fileMode = map[string]fs.FileMode{ "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/unknown-fields": 0o20000000755, + "templates/pythonv2/tests/unknown-fields-default": 0o20000000755, + "templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl": 0o644, + "templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl": 0o644, "templates/pythonv2/testusage": 0o20000000755, "templates/pythonv2/testusage/pylintrc": 0o644, "templates/pythonv2/testusage/pyproject.toml.stmpl": 0o644, diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/serializers.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/serializers.py.stmpl index a3ea844b5..18746fa63 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/serializers.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/serializers.py.stmpl @@ -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}}False{{else}}True{{end}}) if len(d) == 0: return "" diff --git a/templates/templates/pythonv2/config.ts b/templates/templates/pythonv2/config.ts index e441cf95b..09150c55f 100644 --- a/templates/templates/pythonv2/config.ts +++ b/templates/templates/pythonv2/config.ts @@ -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 request and response models, including nested models. Explicitly typed additional properties retain their validation. Disabled by default.", + ValidationRegex: /^(true|false)$/.source, + ValidationMessage: "true or false only", + }, responseSchemaValidation: { Name: "responseSchemaValidation", Required: false, diff --git a/templates/templates/pythonv2/model.py.stmpl b/templates/templates/pythonv2/model.py.stmpl index c4d42bc95..6cb671425 100644 --- a/templates/templates/pythonv2/model.py.stmpl +++ b/templates/templates/pythonv2/model.py.stmpl @@ -7,7 +7,8 @@ {{- $hasOptionalField := gt (len $optionalFields) 0 -}} {{- $hasNullableField := gt (len $nullableFields) 0 -}} {{- $hasAdditionalProperties := hasAdditionalPropertiesField $type -}} -{{- if $hasAdditionalProperties}}{{addImport "pydantic" "ConfigDict"}}{{end -}} +{{- $preserveUnknownFields := .Global.Config.PreserveUnknownFields -}} +{{- if or $hasAdditionalProperties $preserveUnknownFields}}{{addImport "pydantic" "ConfigDict"}}{{end -}} {{- $fields := (sortFields $type.Fields) -}} {{- $className := sanitizeClassName $type.Name }} {{- $startsWithTest := hasPrefix "Test" $className }} @@ -22,8 +23,10 @@ class {{$className}}(BaseModel): {{- if $startsWithTest}} __test__ = False {{- end}} - {{- if $hasAdditionalProperties}} + {{- if or $hasAdditionalProperties $preserveUnknownFields}} model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, extra="allow") + {{- end}} + {{- if $hasAdditionalProperties}} __pydantic_extra__: {{sanitizeAdditionalPropertiesField (getAdditionalPropertiesField $type) $usageLocation $local}} {{- end}} @@ -94,7 +97,7 @@ class {{$className}}(BaseModel): for n, f in type(self).model_fields.items(): k = f.alias or n val = serialized.get(k, serialized.get(n)) - {{- if $hasAdditionalProperties}} + {{- if or $hasAdditionalProperties $preserveUnknownFields}} serialized.pop(k, serialized.pop(n, None)) {{- end}} @@ -124,7 +127,7 @@ class {{$className}}(BaseModel): m[k] = val {{- end}} - {{- if $hasAdditionalProperties}} + {{- if or $hasAdditionalProperties $preserveUnknownFields}} for k, v in serialized.items(): m[k] = v {{- end}} diff --git a/templates/templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl new file mode 100644 index 000000000..c83ca0359 --- /dev/null +++ b/templates/templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl @@ -0,0 +1 @@ +{{templateString "tests/unknown-fields/test_unknown_fields.py.stmpl" .Local}} diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl new file mode 100644 index 000000000..5ada92a2a --- /dev/null +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -0,0 +1,93 @@ +import asyncio +import json + +import httpx +import pydantic +import pytest + +from openapi import SDK, models +from .common_helpers import HTTPBIN_URL + + +PRESERVE_UNKNOWN_FIELDS = {{if .Global.Config.PreserveUnknownFields}}True{{else}}False{{end}} + + +CASES = [ + ({"future": "value"}, {}), + ({"future": False}, {}), + ({"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", "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"}}), + ({"arbitrary": {"future": {"nullable": None}}}, {"arbitrary": {"future": {"nullable": None}}}), + ({"child": {"displayName": "child", "nullable": None}}, {"child": {"displayName": "child", "nullable": None}}), +] + + +@pytest.mark.parametrize("fields, default_fields", CASES) +@pytest.mark.parametrize("as_model", [False, True]) +@pytest.mark.parametrize("as_async", [False, True]) +def test_unknown_fields_echo(fields, default_fields, as_model, as_async): + payload = {"label": "root", **fields} + request = models.FieldEnvelopeInput.model_validate(payload) if as_model else payload + if as_async: + async def send(): + async with SDK(server_url=HTTPBIN_URL) as sdk: + return await sdk.echo_unknown_fields_async(request=request) + response = asyncio.run(send()) + else: + with SDK(server_url=HTTPBIN_URL) as sdk: + response = sdk.echo_unknown_fields(request=request) + expected = {"label": "root", **(fields if PRESERVE_UNKNOWN_FIELDS else default_fields)} + assert response.http_meta.response.json()["json"] == expected + assert response.object.json_.model_dump(mode="json", by_alias=True, exclude_none=not PRESERVE_UNKNOWN_FIELDS) == expected + assert json.loads(response.object.json_.model_dump_json(by_alias=True, exclude_none=not PRESERVE_UNKNOWN_FIELDS)) == expected + assert ("headers" in response.object.model_dump()) is PRESERVE_UNKNOWN_FIELDS + + +def test_response_only_unknown_fields(): + payload = {"json": {"label": "root", "child": {"displayName": "child", "future": True}}, "future": {"value": 1}} + transport = httpx.MockTransport(lambda request: httpx.Response(200, json=payload)) + with httpx.Client(transport=transport) as client: + with SDK(server_url=HTTPBIN_URL, client=client) as sdk: + response = sdk.echo_unknown_fields(request={"label": "root"}) + dumped = response.object.model_dump(mode="json", by_alias=True, exclude_none=not PRESERVE_UNKNOWN_FIELDS) + assert ("future" in dumped) is PRESERVE_UNKNOWN_FIELDS + assert ("future" in dumped["json"]["child"]) is PRESERVE_UNKNOWN_FIELDS + + +def test_typed_additional_properties_still_validate(): + with pytest.raises(pydantic.ValidationError): + models.FieldEnvelopeInput.model_validate({"label": "root", "typed": {"future": {"invalid": True}}}) + + +def test_unknown_fields_dump_filters(): + model = models.FieldEnvelopeInput.model_validate({"label": "root", "future": True, "future_null": None}) + assert ("future_null" in model.model_dump()) is PRESERVE_UNKNOWN_FIELDS + assert "future_null" not in model.model_dump(exclude_none=True) + assert "future" not in model.model_dump(exclude={"future"}) + assert model.model_dump(include={"label"}) == {"label": "root"} + + +def test_response_model_can_be_sent_again(): + payload = {"label": "root", "child": {"displayName": "child", "future": {"enabled": False}}} + with SDK(server_url=HTTPBIN_URL) as sdk: + first = sdk.echo_unknown_fields(request=payload) + second = sdk.echo_unknown_fields(request=first.object.json_.model_dump(by_alias=True)) + expected = payload if PRESERVE_UNKNOWN_FIELDS else {"label": "root", "child": {"displayName": "child"}} + assert second.http_meta.response.json()["json"] == expected + assert second.object.json_.model_dump(by_alias=True, exclude_none=True) == expected diff --git a/tests/config/unknown-fields-default/pythonv2/.speakeasy/gen.yaml b/tests/config/unknown-fields-default/pythonv2/.speakeasy/gen.yaml new file mode 100644 index 000000000..e9adf4d18 --- /dev/null +++ b/tests/config/unknown-fields-default/pythonv2/.speakeasy/gen.yaml @@ -0,0 +1,16 @@ +configVersion: 2.0.0 +generation: + sdkClassName: SDK + tests: + generateTests: true + generateNewTests: false +python: + packageName: openapi + version: 0.0.1 + templateVersion: v2 + packageManager: uv + preserveUnknownFields: false + forwardCompatibleUnionsByDefault: tagged-only + inferUnionDiscriminators: true + flattenRequests: false + responseFormat: envelope-http diff --git a/tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml b/tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml new file mode 100644 index 000000000..d58c19b18 --- /dev/null +++ b/tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml @@ -0,0 +1,16 @@ +configVersion: 2.0.0 +generation: + sdkClassName: SDK + tests: + generateTests: true + generateNewTests: false +python: + packageName: openapi + version: 0.0.1 + templateVersion: v2 + packageManager: uv + preserveUnknownFields: true + forwardCompatibleUnionsByDefault: tagged-only + inferUnionDiscriminators: true + flattenRequests: false + responseFormat: envelope-http diff --git a/tests/specs/unknown-fields.yaml b/tests/specs/unknown-fields.yaml new file mode 100644 index 000000000..528e93c60 --- /dev/null +++ b/tests/specs/unknown-fields.yaml @@ -0,0 +1,157 @@ +openapi: 3.1.0 +info: + title: Unknown fields + version: 1.0.0 +servers: + - url: http://localhost:35123 +paths: + /anything/unknown-fields: + post: + operationId: echoUnknownFields + description: Echoes nested objects to exercise preservation of undeclared fields in requests and responses. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FieldEnvelope' + responses: + '200': + description: The echoed JSON request and transport metadata. + content: + application/json: + schema: + type: object + required: [json] + properties: + json: + $ref: '#/components/schemas/FieldEnvelope' +components: + schemas: + FieldEnvelope: + type: object + required: [label] + properties: + label: + type: string + child: + $ref: '#/components/schemas/FieldNode' + children: + type: array + items: + $ref: '#/components/schemas/FieldNode' + lookup: + type: object + additionalProperties: + $ref: '#/components/schemas/FieldNode' + choice: + oneOf: + - $ref: '#/components/schemas/TextChoice' + - $ref: '#/components/schemas/NumberChoice' + discriminator: + propertyName: kind + mapping: + text: '#/components/schemas/TextChoice' + number: '#/components/schemas/NumberChoice' + plainChoice: + anyOf: + - $ref: '#/components/schemas/TextFields' + - $ref: '#/components/schemas/NumberFields' + requiredOnly: + $ref: '#/components/schemas/RequiredFields' + composed: + allOf: + - $ref: '#/components/schemas/FieldNode' + - type: object + properties: + category: + type: string + split: + $ref: '#/components/schemas/SplitFields' + open: + $ref: '#/components/schemas/OpenFields' + typed: + $ref: '#/components/schemas/TypedFields' + closed: + $ref: '#/components/schemas/ClosedFields' + arbitrary: {} + FieldNode: + type: object + required: [displayName] + properties: + displayName: + type: string + nullable: + type: [string, 'null'] + child: + $ref: '#/components/schemas/FieldNode' + TextChoice: + type: object + required: [kind, text] + properties: + kind: + type: string + enum: [text] + text: + type: string + NumberChoice: + type: object + required: [kind, number] + properties: + kind: + type: string + enum: [number] + number: + type: integer + OpenFields: + type: object + additionalProperties: true + properties: + label: + type: string + TypedFields: + type: object + additionalProperties: + type: string + properties: + label: + type: string + ClosedFields: + type: object + additionalProperties: false + properties: + label: + type: string + TextFields: + type: object + required: [text] + properties: + text: + type: string + label: + type: string + NumberFields: + type: object + required: [number] + properties: + number: + type: integer + label: + type: string + RequiredFields: + type: object + required: [label] + properties: + label: + type: string + SplitFields: + type: object + properties: + label: + type: string + id: + type: string + readOnly: true + secret: + type: string + writeOnly: true diff --git a/tests/tests/unknown-fields-default/.gitkeep b/tests/tests/unknown-fields-default/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tests/unknown-fields/.gitkeep b/tests/tests/unknown-fields/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.yaml b/zSDKs/sdk-pythonv2/.speakeasy/gen.yaml index 7d4876daa..357bc40e1 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.yaml +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.yaml @@ -99,6 +99,7 @@ python: packageManager: uv packageName: openapi preApplyUnionDiscriminators: false + preserveUnknownFields: false pytestFilterWarnings: [] pytestTimeout: 0 rawResponseHelpers: false From 6b32e7f352f14f71fdbf65b6044d87a38c3eec4a Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 22 Oct 2026 12:39:23 +0100 Subject: [PATCH 02/21] fix(python): cover errors and empty models --- .github/workflows/test.yml | 14 ++++++++++++++ internal/features/tests.go | 1 + internal/features/tests_generated.go | 5 +++++ templates/templates/cli/features.ts | 1 + templates/templates/csharp/features.ts | 1 + templates/templates/go/features.ts | 1 + templates/templates/javav2/features.ts | 1 + .../templates/mcp-typescript/features.ts | 1 + templates/templates/php/features.ts | 1 + templates/templates/pythonv2/config.ts | 2 +- templates/templates/pythonv2/error.py.stmpl | 6 ++++++ templates/templates/pythonv2/model.py.stmpl | 4 ++-- .../test_unknown_fields.py.stmpl | 19 +++++++++++++++++-- templates/templates/ruby/features.ts | 1 + templates/templates/typescriptv2/features.ts | 1 + templates/templates/unity/features.ts | 1 + tests/specs/unknown-fields.yaml | 17 +++++++++++++++++ 17 files changed, 72 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b52fe2884..bd7e43749 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -663,6 +663,18 @@ jobs: uses: ./.github/workflows/test-pythonv2.yml with: test-group: "quaternary" + test-pythonv2-unknown-fields: + needs: [detect-targets] + if: needs.detect-targets.outputs.run-pythonv2 == 'true' + uses: ./.github/workflows/test-pythonv2.yml + with: + test-group: "unknown-fields" + test-pythonv2-unknown-fields-default: + needs: [detect-targets] + if: needs.detect-targets.outputs.run-pythonv2 == 'true' + uses: ./.github/workflows/test-pythonv2.yml + with: + test-group: "unknown-fields-default" test-pythonv2-client-credentials: needs: [detect-targets] if: needs.detect-targets.outputs.run-pythonv2 == 'true' @@ -720,6 +732,8 @@ jobs: - test-pythonv2-secondary - test-pythonv2-tertiary - test-pythonv2-quaternary + - test-pythonv2-unknown-fields + - test-pythonv2-unknown-fields-default - test-pythonv2-client-credentials - test-pythonv2-basic-http - test-pythonv2-security-options diff --git a/internal/features/tests.go b/internal/features/tests.go index a4031fcb5..c936b8b3f 100644 --- a/internal/features/tests.go +++ b/internal/features/tests.go @@ -781,6 +781,7 @@ const ( TestJsonlStreamTimeoutStillBoundsSlowStream TestJsonlStreamWithTimeoutStreamsToCompletion TestSharedEnumConstructorDefault + TestUnknownFieldsJsonRoundTrip ) func GetAllTests() []Test { diff --git a/internal/features/tests_generated.go b/internal/features/tests_generated.go index b6dd807de..01446360c 100644 --- a/internal/features/tests_generated.go +++ b/internal/features/tests_generated.go @@ -1502,6 +1502,8 @@ func (t Test) String() string { return "unions-weakly-typed-one-of-post-basic" case TestUnionsWeaklyTypedOneOfPostDeep: return "unions-weakly-typed-one-of-post-deep" + case TestUnknownFieldsJsonRoundTrip: + return "unknown-fields-json-round-trip" case TestWebhooksConsume: return "webhooks-consume" case TestWebhooksConsumeBadData: @@ -3022,6 +3024,8 @@ func TestFromString(s string) Test { return TestUnionsWeaklyTypedOneOfPostBasic case "unions-weakly-typed-one-of-post-deep": return TestUnionsWeaklyTypedOneOfPostDeep + case "unknown-fields-json-round-trip": + return TestUnknownFieldsJsonRoundTrip case "webhooks-consume": return TestWebhooksConsume case "webhooks-consume-bad-data": @@ -3792,6 +3796,7 @@ var testList = []Test{ TestUnionsUnionOfArrays, TestUnionsWeaklyTypedOneOfPostBasic, TestUnionsWeaklyTypedOneOfPostDeep, + TestUnknownFieldsJsonRoundTrip, TestWebhooksConsume, TestWebhooksConsumeBadData, TestWebhooksConsumeBadSignature, diff --git a/templates/templates/cli/features.ts b/templates/templates/cli/features.ts index 32078df7e..9675948e0 100644 --- a/templates/templates/cli/features.ts +++ b/templates/templates/cli/features.ts @@ -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); } diff --git a/templates/templates/csharp/features.ts b/templates/templates/csharp/features.ts index 8c3bd71eb..6f8672df7 100644 --- a/templates/templates/csharp/features.ts +++ b/templates/templates/csharp/features.ts @@ -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); } diff --git a/templates/templates/go/features.ts b/templates/templates/go/features.ts index 1283baf4b..a82334d3c 100644 --- a/templates/templates/go/features.ts +++ b/templates/templates/go/features.ts @@ -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); } diff --git a/templates/templates/javav2/features.ts b/templates/templates/javav2/features.ts index f9d5e49b3..47b5f3f9a 100644 --- a/templates/templates/javav2/features.ts +++ b/templates/templates/javav2/features.ts @@ -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); } diff --git a/templates/templates/mcp-typescript/features.ts b/templates/templates/mcp-typescript/features.ts index 70a1d2124..66dc387a4 100644 --- a/templates/templates/mcp-typescript/features.ts +++ b/templates/templates/mcp-typescript/features.ts @@ -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); } diff --git a/templates/templates/php/features.ts b/templates/templates/php/features.ts index 0254cdade..3c421743e 100644 --- a/templates/templates/php/features.ts +++ b/templates/templates/php/features.ts @@ -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); } diff --git a/templates/templates/pythonv2/config.ts b/templates/templates/pythonv2/config.ts index 09150c55f..5a2e84821 100644 --- a/templates/templates/pythonv2/config.ts +++ b/templates/templates/pythonv2/config.ts @@ -330,7 +330,7 @@ function getConfigFields( Required: false, DefaultValue: false, Description: - "Preserve undeclared fields in request and response models, including nested models. Explicitly typed additional properties retain their validation. Disabled by default.", + "Preserve undeclared fields in JSON request and response models, including nested models. Explicitly typed additional properties retain their validation. Disabled by default.", ValidationRegex: /^(true|false)$/.source, ValidationMessage: "true or false only", }, diff --git a/templates/templates/pythonv2/error.py.stmpl b/templates/templates/pythonv2/error.py.stmpl index 8bb25b386..93ba84771 100644 --- a/templates/templates/pythonv2/error.py.stmpl +++ b/templates/templates/pythonv2/error.py.stmpl @@ -12,14 +12,20 @@ {{- if eq $type.Type "error"}}{{$subTypeName = print $className "Data"}}{{else}}{{$subTypeName = print $className "Union"}}{{end -}} {{- if eq $type.Type "error"}} {{- addImport "types" "BaseModel" true -}} +{{- if .Global.Config.PreserveUnknownFields}}{{addImport "pydantic" "ConfigDict"}}{{end}} class {{$className}}Data(BaseModel): + {{- if .Global.Config.PreserveUnknownFields}} + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, extra="allow") + {{- end}} {{- range $fields}} {{- $fieldName := sanitizeFieldName .Name}} {{ if and .Const useUpperCaseConsts}}{{upper $fieldName}}{{else}}{{$fieldName}}{{end}}: {{sanitizeFieldTypeAndAnnotations . $usageLocation $local}}{{templateDefaultValue . $usageLocation $local}} {{- templateComments .Comments 1}} {{- else}} + {{- if not $.Global.Config.PreserveUnknownFields}} pass {{- end}} + {{- end}} {{- else}} {{- if and $type.Discriminator $type.IsUnionOpen }} {{templateString "open-union.py.stmpl" $local}} diff --git a/templates/templates/pythonv2/model.py.stmpl b/templates/templates/pythonv2/model.py.stmpl index 6cb671425..7006c69fb 100644 --- a/templates/templates/pythonv2/model.py.stmpl +++ b/templates/templates/pythonv2/model.py.stmpl @@ -18,7 +18,7 @@ {{- templateDeprecated $type.Comments }} class {{$className}}(BaseModel): {{- $comments}} - {{- if and (not $startsWithTest) (not $hasAdditionalProperties) (le (len $fields) 0) (not $pagination) (not $comments)}} + {{- if and (not $startsWithTest) (not $hasAdditionalProperties) (not $preserveUnknownFields) (le (len $fields) 0) (not $pagination) (not $comments)}} pass{{end -}} {{- if $startsWithTest}} __test__ = False @@ -40,7 +40,7 @@ class {{$className}}(BaseModel): {{- else}} next: Callable[[], Optional[{{ sanitizeClass $type $usageLocation true }}]] {{- end }} - {{- end }}{{if or $comments $startsWithTest $hasAdditionalProperties $pagination}}{{/* Add a newline before the fields if necessary */}} + {{- end }}{{if or $comments $startsWithTest $hasAdditionalProperties $preserveUnknownFields $pagination}}{{/* Add a newline before the fields if necessary */}} {{end}} {{- range $fields}} {{- if not .IsAdditionalProperties}} diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl index 5ada92a2a..d85c4fa98 100644 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -5,8 +5,8 @@ import httpx import pydantic import pytest -from openapi import SDK, models -from .common_helpers import HTTPBIN_URL +from openapi import SDK, errors, models +from .common_helpers import HTTPBIN_URL, record_test PRESERVE_UNKNOWN_FIELDS = {{if .Global.Config.PreserveUnknownFields}}True{{else}}False{{end}} @@ -15,6 +15,7 @@ PRESERVE_UNKNOWN_FIELDS = {{if .Global.Config.PreserveUnknownFields}}True{{else} CASES = [ ({"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}}), @@ -42,6 +43,7 @@ CASES = [ @pytest.mark.parametrize("as_model", [False, True]) @pytest.mark.parametrize("as_async", [False, True]) def test_unknown_fields_echo(fields, default_fields, as_model, as_async): + record_test("unknown-fields-json-round-trip") payload = {"label": "root", **fields} request = models.FieldEnvelopeInput.model_validate(payload) if as_model else payload if as_async: @@ -91,3 +93,16 @@ def test_response_model_can_be_sent_again(): expected = payload if PRESERVE_UNKNOWN_FIELDS else {"label": "root", "child": {"displayName": "child"}} assert second.http_meta.response.json()["json"] == expected assert second.object.json_.model_dump(by_alias=True, exclude_none=True) == expected + + +def test_error_response_unknown_fields(): + payload = {"message": "invalid", "future": None, "detail": {"displayName": "child", "future": {"flag": True}}} + transport = httpx.MockTransport(lambda request: httpx.Response(400, json=payload)) + with httpx.Client(transport=transport) as client: + with SDK(server_url=HTTPBIN_URL, client=client) as sdk: + with pytest.raises(errors.SDKError) as caught: + sdk.echo_unknown_fields(request={"label": "root"}) + data = getattr(caught.value, "data") + dumped = data.model_dump(mode="json", by_alias=True) + assert ("future" in dumped) is PRESERVE_UNKNOWN_FIELDS + assert ("future" in dumped["detail"]) is PRESERVE_UNKNOWN_FIELDS diff --git a/templates/templates/ruby/features.ts b/templates/templates/ruby/features.ts index 3435fe0de..fec3e49fc 100644 --- a/templates/templates/ruby/features.ts +++ b/templates/templates/ruby/features.ts @@ -475,6 +475,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); } diff --git a/templates/templates/typescriptv2/features.ts b/templates/templates/typescriptv2/features.ts index b052b86a0..4f9056742 100644 --- a/templates/templates/typescriptv2/features.ts +++ b/templates/templates/typescriptv2/features.ts @@ -423,6 +423,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); } diff --git a/templates/templates/unity/features.ts b/templates/templates/unity/features.ts index e69b828ac..4276f2af6 100644 --- a/templates/templates/unity/features.ts +++ b/templates/templates/unity/features.ts @@ -596,6 +596,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); } diff --git a/tests/specs/unknown-fields.yaml b/tests/specs/unknown-fields.yaml index 528e93c60..0c29acb9f 100644 --- a/tests/specs/unknown-fields.yaml +++ b/tests/specs/unknown-fields.yaml @@ -26,6 +26,18 @@ paths: properties: json: $ref: '#/components/schemas/FieldEnvelope' + '400': + description: A structured error with additional server fields. + content: + application/json: + schema: + type: object + required: [message] + properties: + message: + type: string + detail: + $ref: '#/components/schemas/FieldNode' components: schemas: FieldEnvelope: @@ -68,6 +80,8 @@ components: type: string split: $ref: '#/components/schemas/SplitFields' + empty: + $ref: '#/components/schemas/EmptyFields' open: $ref: '#/components/schemas/OpenFields' typed: @@ -155,3 +169,6 @@ components: secret: type: string writeOnly: true + EmptyFields: + type: object + additionalProperties: false From 0001e9fd7ae767c2fa0b88b49babcecf96642836 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 22 Oct 2026 12:43:40 +0100 Subject: [PATCH 03/21] fix(python): retain top-level null semantics --- .../utils/serializers.py.stmpl | 2 +- templates/templates/pythonv2/config.ts | 2 +- .../test_unknown_fields.py.stmpl | 26 ++++++++++++++++++- tests/specs/unknown-fields.yaml | 25 ++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/serializers.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/serializers.py.stmpl index 18746fa63..2062de6bb 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/serializers.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/serializers.py.stmpl @@ -278,7 +278,7 @@ def marshal_json(val, typ): m = marshaller(body=val) - d = m.model_dump(by_alias=True, mode="json", exclude_none={{if .Global.Config.PreserveUnknownFields}}False{{else}}True{{end}}) + 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 "" diff --git a/templates/templates/pythonv2/config.ts b/templates/templates/pythonv2/config.ts index 5a2e84821..bbbd048f9 100644 --- a/templates/templates/pythonv2/config.ts +++ b/templates/templates/pythonv2/config.ts @@ -330,7 +330,7 @@ function getConfigFields( Required: false, DefaultValue: false, Description: - "Preserve undeclared fields in JSON request and response models, including nested models. Explicitly typed additional properties retain their validation. Disabled by default.", + "Preserve undeclared fields in JSON request and response models, including nested models and schemas with additionalProperties: false. Explicitly typed additional properties retain their validation. Disabled by default.", ValidationRegex: /^(true|false)$/.source, ValidationMessage: "true or false only", }, diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl index d85c4fa98..7953bcf0c 100644 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -1,11 +1,13 @@ import asyncio import json +from typing import Annotated, Any, List, Optional import httpx import pydantic import pytest -from openapi import SDK, errors, models +from openapi import SDK, errors, models, utils +from openapi.types import Nullable from .common_helpers import HTTPBIN_URL, record_test @@ -106,3 +108,25 @@ def test_error_response_unknown_fields(): dumped = data.model_dump(mode="json", by_alias=True) assert ("future" in dumped) is PRESERVE_UNKNOWN_FIELDS assert ("future" in dumped["detail"]) is PRESERVE_UNKNOWN_FIELDS + + +def test_omitted_json_query_stays_empty(): + with SDK(server_url=HTTPBIN_URL) as sdk: + response = sdk.echo_optional_json_query() + assert response.http_meta.response.request.url.params.get("filter", "") == "" + + +@pytest.mark.parametrize("typ", [Optional[int], Optional[List[int]], Optional[models.FieldNode], Any]) +def test_top_level_none_serialization(typ): + assert utils.marshal_json(None, typ) == "" + + +def test_top_level_none_still_validates(): + with pytest.raises(pydantic.ValidationError): + utils.marshal_json(None, models.FieldNode) + assert utils.marshal_json(None, Nullable[models.FieldNode]) == "null" + + +def test_coerced_top_level_none_stays_empty(): + typ = Annotated[Optional[int], pydantic.BeforeValidator(lambda value: None)] + assert utils.marshal_json("empty", typ) == "" diff --git a/tests/specs/unknown-fields.yaml b/tests/specs/unknown-fields.yaml index 0c29acb9f..82d988102 100644 --- a/tests/specs/unknown-fields.yaml +++ b/tests/specs/unknown-fields.yaml @@ -5,6 +5,31 @@ info: servers: - url: http://localhost:35123 paths: + /anything/unknown-fields-query: + get: + operationId: echoOptionalJsonQuery + description: Echoes an optional JSON query parameter without changing its omission semantics. + parameters: + - name: filter + in: query + required: false + content: + application/json: + schema: + type: object + properties: + label: + type: string + responses: + '200': + description: The echoed request URL. + content: + application/json: + schema: + type: object + properties: + url: + type: string /anything/unknown-fields: post: operationId: echoUnknownFields From 3ef3e90f5ca2616ecec6d55286c79fa729cd19b0 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 22 Oct 2026 12:49:15 +0100 Subject: [PATCH 04/21] test(python): assert empty JSON query exactly --- .../pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl index 7953bcf0c..e0f2fffb3 100644 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -113,7 +113,7 @@ def test_error_response_unknown_fields(): def test_omitted_json_query_stays_empty(): with SDK(server_url=HTTPBIN_URL) as sdk: response = sdk.echo_optional_json_query() - assert response.http_meta.response.request.url.params.get("filter", "") == "" + assert response.http_meta.response.request.url.params["filter"] == "" @pytest.mark.parametrize("typ", [Optional[int], Optional[List[int]], Optional[models.FieldNode], Any]) From e7093b043c2218419c3a2cf90e4d6c8e72a8f32f Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 22 Oct 2026 12:50:25 +0100 Subject: [PATCH 05/21] chore(python): sync review configuration --- tests/config/review/pythonv2/.speakeasy/gen.yaml | 1 + zSDKs/sdk-pythonv2/.speakeasy/gen.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/config/review/pythonv2/.speakeasy/gen.yaml b/tests/config/review/pythonv2/.speakeasy/gen.yaml index 7d4876daa..357bc40e1 100644 --- a/tests/config/review/pythonv2/.speakeasy/gen.yaml +++ b/tests/config/review/pythonv2/.speakeasy/gen.yaml @@ -99,6 +99,7 @@ python: packageManager: uv packageName: openapi preApplyUnionDiscriminators: false + preserveUnknownFields: false pytestFilterWarnings: [] pytestTimeout: 0 rawResponseHelpers: false diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock index e672ea8e3..6546bd4c7 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock @@ -6,7 +6,7 @@ management: speakeasyVersion: internal generationVersion: internal releaseVersion: 0.0.1 - configChecksum: d67daf2ec44e985625b98d32573578c6 + configChecksum: 908ffac3f2935a4cd93222913042d257 repoURL: https://github.com/speakeasy-sdks/test-sdk installationURL: https://github.com/speakeasy-sdks/test-sdk.git features: From ef883eddf6cec5bf8fe6a5e3f912fe545bf8a7dd Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 22 Oct 2026 13:05:10 +0100 Subject: [PATCH 06/21] ci: install patched PHP for generator tests --- .github/workflows/test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd7e43749..5613bfe91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,13 @@ jobs: with: ruby-version: "3.2" bundler-cache: true + - name: Setup PHP + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 + with: + php-version: "8.3" + extensions: mbstring, xml, curl + env: + update: true - name: Configure Composer run: | composer config --global preferred-install auto From 220de1f545be8008e4b438553b297cc211bce659 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 22 Oct 2026 16:31:33 +0100 Subject: [PATCH 07/21] feat(python): replace model serialization overrides --- .changesets/1792668682-0165685a.yaml | 2 +- templates/perms.go | 2 +- .../{basemodel.py => basemodel.py.stmpl} | 44 ++++++++++++++++++- templates/templates/pythonv2/config.ts | 9 ++++ templates/templates/pythonv2/error.py.stmpl | 6 --- .../pythonv2/includes/sanitization.ts | 4 +- templates/templates/pythonv2/model.py.stmpl | 10 ++--- .../test_unknown_fields.py.stmpl | 43 +++++++++++++++++- .../review/pythonv2/.speakeasy/gen.yaml | 1 + .../pythonv2/.speakeasy/gen.yaml | 1 + tests/specs/unknown-fields.yaml | 15 +++++++ zSDKs/sdk-pythonv2/.speakeasy/gen.lock | 4 +- zSDKs/sdk-pythonv2/.speakeasy/gen.yaml | 1 + .../speakeasy/new_openapi/types/basemodel.py | 35 ++++++++++----- 14 files changed, 146 insertions(+), 31 deletions(-) rename templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/{basemodel.py => basemodel.py.stmpl} (66%) diff --git a/.changesets/1792668682-0165685a.yaml b/.changesets/1792668682-0165685a.yaml index 8692a95a8..1ec90ee6b 100644 --- a/.changesets/1792668682-0165685a.yaml +++ b/.changesets/1792668682-0165685a.yaml @@ -5,6 +5,6 @@ targets: - pythonv2 type: feat bump: patch -description: add opt-in preservation of unknown model fields +description: add opt-in model serialization helpers and unknown-field preservation; fix open enum JSON serialization author: ThomasRooney date: "2026-10-22" diff --git a/templates/perms.go b/templates/perms.go index b3325761b..aa8b4caa2 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -1552,7 +1552,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, diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl similarity index 66% rename from templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py rename to templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl index d4744c6eb..fd7393ced 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl @@ -7,9 +7,49 @@ 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, + ) + + 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, + ) + +{{end}} class Unset(BaseModel): @model_serializer(mode="plain") @@ -54,6 +94,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=core_schema.plain_serializer_function_ser_schema(str), ) @@ -72,4 +113,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=core_schema.plain_serializer_function_ser_schema(int), ) diff --git a/templates/templates/pythonv2/config.ts b/templates/templates/pythonv2/config.ts index bbbd048f9..a6e565518 100644 --- a/templates/templates/pythonv2/config.ts +++ b/templates/templates/pythonv2/config.ts @@ -325,6 +325,15 @@ 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, diff --git a/templates/templates/pythonv2/error.py.stmpl b/templates/templates/pythonv2/error.py.stmpl index 93ba84771..8bb25b386 100644 --- a/templates/templates/pythonv2/error.py.stmpl +++ b/templates/templates/pythonv2/error.py.stmpl @@ -12,20 +12,14 @@ {{- if eq $type.Type "error"}}{{$subTypeName = print $className "Data"}}{{else}}{{$subTypeName = print $className "Union"}}{{end -}} {{- if eq $type.Type "error"}} {{- addImport "types" "BaseModel" true -}} -{{- if .Global.Config.PreserveUnknownFields}}{{addImport "pydantic" "ConfigDict"}}{{end}} class {{$className}}Data(BaseModel): - {{- if .Global.Config.PreserveUnknownFields}} - model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, extra="allow") - {{- end}} {{- range $fields}} {{- $fieldName := sanitizeFieldName .Name}} {{ if and .Const useUpperCaseConsts}}{{upper $fieldName}}{{else}}{{$fieldName}}{{end}}: {{sanitizeFieldTypeAndAnnotations . $usageLocation $local}}{{templateDefaultValue . $usageLocation $local}} {{- templateComments .Comments 1}} {{- else}} - {{- if not $.Global.Config.PreserveUnknownFields}} pass {{- end}} - {{- end}} {{- else}} {{- if and $type.Discriminator $type.IsUnionOpen }} {{templateString "open-union.py.stmpl" $local}} diff --git a/templates/templates/pythonv2/includes/sanitization.ts b/templates/templates/pythonv2/includes/sanitization.ts index 74309517a..0a85a76ad 100644 --- a/templates/templates/pythonv2/includes/sanitization.ts +++ b/templates/templates/pythonv2/includes/sanitization.ts @@ -319,7 +319,9 @@ function sanitizeFieldName(name: string): string { if ( fieldReservedKeywords.includes(name) || - pythonReservedKeywords.includes(name) + pythonReservedKeywords.includes(name) || + (context.Global.Config.ModelSerializationMethods && + ["to_dict", "to_json"].includes(name)) ) { name += "_"; } diff --git a/templates/templates/pythonv2/model.py.stmpl b/templates/templates/pythonv2/model.py.stmpl index 7006c69fb..7d7aa2e11 100644 --- a/templates/templates/pythonv2/model.py.stmpl +++ b/templates/templates/pythonv2/model.py.stmpl @@ -8,7 +8,7 @@ {{- $hasNullableField := gt (len $nullableFields) 0 -}} {{- $hasAdditionalProperties := hasAdditionalPropertiesField $type -}} {{- $preserveUnknownFields := .Global.Config.PreserveUnknownFields -}} -{{- if or $hasAdditionalProperties $preserveUnknownFields}}{{addImport "pydantic" "ConfigDict"}}{{end -}} +{{- if $hasAdditionalProperties}}{{addImport "pydantic" "ConfigDict"}}{{end -}} {{- $fields := (sortFields $type.Fields) -}} {{- $className := sanitizeClassName $type.Name }} {{- $startsWithTest := hasPrefix "Test" $className }} @@ -18,15 +18,13 @@ {{- templateDeprecated $type.Comments }} class {{$className}}(BaseModel): {{- $comments}} - {{- if and (not $startsWithTest) (not $hasAdditionalProperties) (not $preserveUnknownFields) (le (len $fields) 0) (not $pagination) (not $comments)}} + {{- if and (not $startsWithTest) (not $hasAdditionalProperties) (le (len $fields) 0) (not $pagination) (not $comments)}} pass{{end -}} {{- if $startsWithTest}} __test__ = False {{- end}} - {{- if or $hasAdditionalProperties $preserveUnknownFields}} - model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, extra="allow") - {{- end}} {{- if $hasAdditionalProperties}} + model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, extra="allow") __pydantic_extra__: {{sanitizeAdditionalPropertiesField (getAdditionalPropertiesField $type) $usageLocation $local}} {{- end}} @@ -40,7 +38,7 @@ class {{$className}}(BaseModel): {{- else}} next: Callable[[], Optional[{{ sanitizeClass $type $usageLocation true }}]] {{- end }} - {{- end }}{{if or $comments $startsWithTest $hasAdditionalProperties $preserveUnknownFields $pagination}}{{/* Add a newline before the fields if necessary */}} + {{- end }}{{if or $comments $startsWithTest $hasAdditionalProperties $pagination}}{{/* Add a newline before the fields if necessary */}} {{end}} {{- range $fields}} {{- if not .IsAdditionalProperties}} diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl index e0f2fffb3..a4db330a3 100644 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -7,14 +7,17 @@ import pydantic import pytest from openapi import SDK, errors, models, utils -from openapi.types import Nullable +from openapi.types import BaseModel, Nullable, UnrecognizedInt, UnrecognizedStr from .common_helpers import HTTPBIN_URL, record_test PRESERVE_UNKNOWN_FIELDS = {{if .Global.Config.PreserveUnknownFields}}True{{else}}False{{end}} +MODEL_SERIALIZATION_METHODS = {{if .Global.Config.ModelSerializationMethods}}True{{else}}False{{end}} CASES = [ + ({"futureKind": "third", "futureCode": 3}, {"futureKind": "third", "futureCode": 3}), + ({"methodNames": {"toDict": "dictionary", "toJson": "json"}}, {"methodNames": {"toDict": "dictionary", "toJson": "json"}}), ({"future": "value"}, {}), ({"future": False}, {}), ({"empty": {"future": True}}, {"empty": {}}), @@ -61,6 +64,9 @@ def test_unknown_fields_echo(fields, default_fields, as_model, as_async): assert response.object.json_.model_dump(mode="json", by_alias=True, exclude_none=not PRESERVE_UNKNOWN_FIELDS) == expected assert json.loads(response.object.json_.model_dump_json(by_alias=True, exclude_none=not PRESERVE_UNKNOWN_FIELDS)) == expected assert ("headers" in response.object.model_dump()) is PRESERVE_UNKNOWN_FIELDS + if MODEL_SERIALIZATION_METHODS: + assert response.object.json_.to_dict(mode="json") == expected + assert json.loads(response.object.json_.to_json()) == expected def test_response_only_unknown_fields(): @@ -87,6 +93,14 @@ def test_unknown_fields_dump_filters(): assert model.model_dump(include={"label"}) == {"label": "root"} +def test_base_model_unknown_field_policy(): + class CustomModel(BaseModel): + label: str + + model = CustomModel(label="value", future=None) + assert ("future" in model.model_dump()) is PRESERVE_UNKNOWN_FIELDS + + def test_response_model_can_be_sent_again(): payload = {"label": "root", "child": {"displayName": "child", "future": {"enabled": False}}} with SDK(server_url=HTTPBIN_URL) as sdk: @@ -130,3 +144,30 @@ def test_top_level_none_still_validates(): def test_coerced_top_level_none_stays_empty(): typ = Annotated[Optional[int], pydantic.BeforeValidator(lambda value: None)] assert utils.marshal_json("empty", typ) == "" + + +@pytest.mark.parametrize("typ, value", [(UnrecognizedStr, "future"), (UnrecognizedInt, 3)]) +def test_unrecognized_enum_serialization(typ, value): + adapter = pydantic.TypeAdapter(typ) + enum_value = adapter.validate_python(value) + assert isinstance(enum_value, typ) + assert adapter.dump_python(enum_value, mode="json") == value + assert json.loads(adapter.dump_json(enum_value)) == value + with pytest.raises(pydantic.ValidationError): + adapter.validate_python(value, strict=True) + + +@pytest.mark.parametrize("options", [{}, {"use_api_names": False}, {"exclude_unset": False}, {"exclude_defaults": True}, {"exclude_none": True}, {"warnings": False}]) +def test_model_serialization_methods(options): + model = models.FieldEnvelopeInput.model_validate({"label": "root", "child": {"displayName": "child", "future": None}, "futureKind": "third", "futureCode": 3}) + if not MODEL_SERIALIZATION_METHODS: + assert not hasattr(model, "to_dict") + assert not hasattr(model, "to_json") + return + dump_options = {"by_alias": True, "exclude_unset": True, **options} + if "use_api_names" in dump_options: + dump_options["by_alias"] = dump_options.pop("use_api_names") + assert model.to_dict(**options) == model.model_dump(**dump_options) + assert model.to_dict(mode="json", **options) == model.model_dump(mode="json", **dump_options) + assert model.to_json(indent=None, **options) == model.model_dump_json(indent=None, **dump_options) + assert json.loads(model.to_json(**options)) == model.to_dict(mode="json", **options) diff --git a/tests/config/review/pythonv2/.speakeasy/gen.yaml b/tests/config/review/pythonv2/.speakeasy/gen.yaml index 357bc40e1..b06dd1876 100644 --- a/tests/config/review/pythonv2/.speakeasy/gen.yaml +++ b/tests/config/review/pythonv2/.speakeasy/gen.yaml @@ -88,6 +88,7 @@ python: methodArguments: infer-optional-args methodTimeoutArgument: timeout-ms methodTimeoutUnits: milliseconds + modelSerializationMethods: false moduleName: speakeasy.new_openapi multipartArrayFormat: standard optionalDependencies: diff --git a/tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml b/tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml index d58c19b18..4bd8b8453 100644 --- a/tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml +++ b/tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml @@ -10,6 +10,7 @@ python: templateVersion: v2 packageManager: uv preserveUnknownFields: true + modelSerializationMethods: true forwardCompatibleUnionsByDefault: tagged-only inferUnionDiscriminators: true flattenRequests: false diff --git a/tests/specs/unknown-fields.yaml b/tests/specs/unknown-fields.yaml index 82d988102..5635c3689 100644 --- a/tests/specs/unknown-fields.yaml +++ b/tests/specs/unknown-fields.yaml @@ -114,6 +114,21 @@ components: closed: $ref: '#/components/schemas/ClosedFields' arbitrary: {} + futureKind: + type: string + enum: [first, second] + x-speakeasy-unknown-values: allow + futureCode: + type: integer + enum: [1, 2] + x-speakeasy-unknown-values: allow + methodNames: + type: object + properties: + toDict: + type: string + toJson: + type: string FieldNode: type: object required: [displayName] diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock index 6546bd4c7..5879519f6 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock @@ -6,7 +6,7 @@ management: speakeasyVersion: internal generationVersion: internal releaseVersion: 0.0.1 - configChecksum: 908ffac3f2935a4cd93222913042d257 + configChecksum: 3b5207abcf7df0a028f020866f6e1112 repoURL: https://github.com/speakeasy-sdks/test-sdk installationURL: https://github.com/speakeasy-sdks/test-sdk.git features: @@ -746,7 +746,7 @@ trackedFiles: src/speakeasy/new_openapi/types/base64fileinput.py: last_write_checksum: sha1:1522687ae3398374c35710cad993a6e82b5ab99d src/speakeasy/new_openapi/types/basemodel.py: - last_write_checksum: sha1:10d84aedeb9d35edfdadf2c3020caa1d24d8b584 + last_write_checksum: sha1:c30ea7638b770493eff489028496bcf7f909be3a src/speakeasy/new_openapi/types_.py: last_write_checksum: sha1:05e36f6428f04b1ea7d2609aa55f3966868b58f0 src/speakeasy/new_openapi/utils/__init__.py: diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.yaml b/zSDKs/sdk-pythonv2/.speakeasy/gen.yaml index 357bc40e1..b06dd1876 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.yaml +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.yaml @@ -88,6 +88,7 @@ python: methodArguments: infer-optional-args methodTimeoutArgument: timeout-ms methodTimeoutUnits: milliseconds + modelSerializationMethods: false moduleName: speakeasy.new_openapi multipartArrayFormat: standard optionalDependencies: diff --git a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py index a9a640a1a..4f608b751 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py @@ -39,10 +39,12 @@ def __bool__(self) -> Literal[False]: class UnrecognizedStr(str): @classmethod - def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> core_schema.CoreSchema: + def __get_pydantic_core_schema__( + cls, _source_type: Any, _handler: Any + ) -> core_schema.CoreSchema: # Make UnrecognizedStr only work in lax mode, not strict mode # This makes it a "fallback" option when more specific types (like Literals) don't match - def validate_lax(v: Any) -> 'UnrecognizedStr': + def validate_lax(v: Any) -> "UnrecognizedStr": if isinstance(v, cls): return v return cls(str(v)) @@ -51,27 +53,36 @@ def validate_lax(v: Any) -> 'UnrecognizedStr': # This forces Pydantic to prefer other union members in strict mode # and only fall back to UnrecognizedStr in lax mode return core_schema.lax_or_strict_schema( - lax_schema=core_schema.chain_schema([ - core_schema.str_schema(), - core_schema.no_info_plain_validator_function(validate_lax) - ]), + lax_schema=core_schema.chain_schema( + [ + core_schema.str_schema(), + core_schema.no_info_plain_validator_function(validate_lax), + ] + ), strict_schema=core_schema.none_schema(), # Always fails in strict mode + serialization=core_schema.plain_serializer_function_ser_schema(str), ) class UnrecognizedInt(int): @classmethod - def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> core_schema.CoreSchema: + def __get_pydantic_core_schema__( + cls, _source_type: Any, _handler: Any + ) -> core_schema.CoreSchema: # Make UnrecognizedInt only work in lax mode, not strict mode # This makes it a "fallback" option when more specific types (like Literals) don't match - def validate_lax(v: Any) -> 'UnrecognizedInt': + def validate_lax(v: Any) -> "UnrecognizedInt": if isinstance(v, cls): return v return cls(int(v)) + return core_schema.lax_or_strict_schema( - lax_schema=core_schema.chain_schema([ - core_schema.int_schema(), - core_schema.no_info_plain_validator_function(validate_lax) - ]), + lax_schema=core_schema.chain_schema( + [ + core_schema.int_schema(), + core_schema.no_info_plain_validator_function(validate_lax), + ] + ), strict_schema=core_schema.none_schema(), # Always fails in strict mode + serialization=core_schema.plain_serializer_function_ser_schema(int), ) From 7c9ed796bfc42c2b1e69e957675de9c53a13f123 Mon Sep 17 00:00:00 2001 From: Thomas Rooney Date: Thu, 22 Oct 2026 16:35:27 +0100 Subject: [PATCH 08/21] fix(python): preserve Python enum objects Exercise all four model serialization configurations in CI. --- .github/workflows/test.yml | 14 ++++++++++++++ Makefile | 2 ++ scripts/build-openapi-document.sh | 2 +- templates/perms.go | 4 ++++ .../{{getSourcePath}}/types/basemodel.py.stmpl | 10 ++++++++-- .../test_unknown_fields.py.stmpl | 1 + .../test_unknown_fields.py.stmpl | 1 + .../unknown-fields/test_unknown_fields.py.stmpl | 8 ++++++-- .../pythonv2/.speakeasy/gen.yaml | 17 +++++++++++++++++ .../pythonv2/.speakeasy/gen.yaml | 17 +++++++++++++++++ .../tests/unknown-fields-methods-only/.gitkeep | 0 tests/tests/unknown-fields-only/.gitkeep | 0 zSDKs/sdk-pythonv2/.speakeasy/gen.lock | 2 +- .../speakeasy/new_openapi/types/basemodel.py | 16 ++++++++++++++-- 14 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 templates/templates/pythonv2/tests/unknown-fields-methods-only/test_unknown_fields.py.stmpl create mode 100644 templates/templates/pythonv2/tests/unknown-fields-only/test_unknown_fields.py.stmpl create mode 100644 tests/config/unknown-fields-methods-only/pythonv2/.speakeasy/gen.yaml create mode 100644 tests/config/unknown-fields-only/pythonv2/.speakeasy/gen.yaml create mode 100644 tests/tests/unknown-fields-methods-only/.gitkeep create mode 100644 tests/tests/unknown-fields-only/.gitkeep diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5613bfe91..c9700acb6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -682,6 +682,18 @@ jobs: uses: ./.github/workflows/test-pythonv2.yml with: test-group: "unknown-fields-default" + test-pythonv2-unknown-fields-only: + needs: [detect-targets] + if: needs.detect-targets.outputs.run-pythonv2 == 'true' + uses: ./.github/workflows/test-pythonv2.yml + with: + test-group: "unknown-fields-only" + test-pythonv2-unknown-fields-methods-only: + needs: [detect-targets] + if: needs.detect-targets.outputs.run-pythonv2 == 'true' + uses: ./.github/workflows/test-pythonv2.yml + with: + test-group: "unknown-fields-methods-only" test-pythonv2-client-credentials: needs: [detect-targets] if: needs.detect-targets.outputs.run-pythonv2 == 'true' @@ -741,6 +753,8 @@ jobs: - test-pythonv2-quaternary - test-pythonv2-unknown-fields - test-pythonv2-unknown-fields-default + - test-pythonv2-unknown-fields-only + - test-pythonv2-unknown-fields-methods-only - test-pythonv2-client-credentials - test-pythonv2-basic-http - test-pythonv2-security-options diff --git a/Makefile b/Makefile index 1cf513b5f..21e75c20f 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,8 @@ tertiary \ quaternary \ unknown-fields \ unknown-fields-default \ +unknown-fields-only \ +unknown-fields-methods-only \ no-zod \ client-credentials \ client-credentials-basic \ diff --git a/scripts/build-openapi-document.sh b/scripts/build-openapi-document.sh index 20c865350..1d2c21b82 100755 --- a/scripts/build-openapi-document.sh +++ b/scripts/build-openapi-document.sh @@ -16,7 +16,7 @@ fi template_spec="./tests/specs/uber.yaml" component_spec="./tests/specs/components.yaml" -if [ "${VARIANT}" == "unknown-fields" ] || [ "${VARIANT}" == "unknown-fields-default" ]; then +if [[ "${VARIANT}" == unknown-fields* ]]; then template_spec="./tests/specs/unknown-fields.yaml" elif [ "${VARIANT}" == "oauth2-password" ] || [ "${VARIANT}" == "custom-http" ]; then template_spec="./tests/specs/ecommerce.yaml" diff --git a/templates/perms.go b/templates/perms.go index aa8b4caa2..8e4809472 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -1735,6 +1735,10 @@ var fileMode = map[string]fs.FileMode{ "templates/pythonv2/tests/unknown-fields": 0o20000000755, "templates/pythonv2/tests/unknown-fields-default": 0o20000000755, "templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl": 0o644, + "templates/pythonv2/tests/unknown-fields-methods-only": 0o20000000755, + "templates/pythonv2/tests/unknown-fields-methods-only/test_unknown_fields.py.stmpl": 0o644, + "templates/pythonv2/tests/unknown-fields-only": 0o20000000755, + "templates/pythonv2/tests/unknown-fields-only/test_unknown_fields.py.stmpl": 0o644, "templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl": 0o644, "templates/pythonv2/testusage": 0o20000000755, "templates/pythonv2/testusage/pylintrc": 0o644, diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl index fd7393ced..f577cf371 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl @@ -75,6 +75,12 @@ else: ) +def _serialize_unrecognized(value: Union[str, int], info: core_schema.SerializationInfo) -> Union[str, int]: + if info.mode == "python": + return value + return str(value) if isinstance(value, str) else int(value) + + class UnrecognizedStr(str): @classmethod def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> core_schema.CoreSchema: @@ -94,7 +100,7 @@ class UnrecognizedStr(str): core_schema.no_info_plain_validator_function(validate_lax) ]), strict_schema=core_schema.none_schema(), # Always fails in strict mode - serialization=core_schema.plain_serializer_function_ser_schema(str), + serialization=core_schema.plain_serializer_function_ser_schema(_serialize_unrecognized, info_arg=True), ) @@ -113,5 +119,5 @@ class UnrecognizedInt(int): core_schema.no_info_plain_validator_function(validate_lax) ]), strict_schema=core_schema.none_schema(), # Always fails in strict mode - serialization=core_schema.plain_serializer_function_ser_schema(int), + serialization=core_schema.plain_serializer_function_ser_schema(_serialize_unrecognized, info_arg=True), ) diff --git a/templates/templates/pythonv2/tests/unknown-fields-methods-only/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields-methods-only/test_unknown_fields.py.stmpl new file mode 100644 index 000000000..c83ca0359 --- /dev/null +++ b/templates/templates/pythonv2/tests/unknown-fields-methods-only/test_unknown_fields.py.stmpl @@ -0,0 +1 @@ +{{templateString "tests/unknown-fields/test_unknown_fields.py.stmpl" .Local}} diff --git a/templates/templates/pythonv2/tests/unknown-fields-only/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields-only/test_unknown_fields.py.stmpl new file mode 100644 index 000000000..c83ca0359 --- /dev/null +++ b/templates/templates/pythonv2/tests/unknown-fields-only/test_unknown_fields.py.stmpl @@ -0,0 +1 @@ +{{templateString "tests/unknown-fields/test_unknown_fields.py.stmpl" .Local}} diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl index a4db330a3..33dc1e684 100644 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -1,5 +1,6 @@ import asyncio import json +import warnings from typing import Annotated, Any, List, Optional import httpx @@ -151,8 +152,11 @@ def test_unrecognized_enum_serialization(typ, value): adapter = pydantic.TypeAdapter(typ) enum_value = adapter.validate_python(value) assert isinstance(enum_value, typ) - assert adapter.dump_python(enum_value, mode="json") == value - assert json.loads(adapter.dump_json(enum_value)) == value + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert adapter.dump_python(enum_value) is enum_value + assert adapter.dump_python(enum_value, mode="json") == value + assert json.loads(adapter.dump_json(enum_value)) == value with pytest.raises(pydantic.ValidationError): adapter.validate_python(value, strict=True) diff --git a/tests/config/unknown-fields-methods-only/pythonv2/.speakeasy/gen.yaml b/tests/config/unknown-fields-methods-only/pythonv2/.speakeasy/gen.yaml new file mode 100644 index 000000000..369e64ef1 --- /dev/null +++ b/tests/config/unknown-fields-methods-only/pythonv2/.speakeasy/gen.yaml @@ -0,0 +1,17 @@ +configVersion: 2.0.0 +generation: + sdkClassName: SDK + tests: + generateTests: true + generateNewTests: false +python: + packageName: openapi + version: 0.0.1 + templateVersion: v2 + packageManager: uv + preserveUnknownFields: false + modelSerializationMethods: true + forwardCompatibleUnionsByDefault: tagged-only + inferUnionDiscriminators: true + flattenRequests: false + responseFormat: envelope-http diff --git a/tests/config/unknown-fields-only/pythonv2/.speakeasy/gen.yaml b/tests/config/unknown-fields-only/pythonv2/.speakeasy/gen.yaml new file mode 100644 index 000000000..3bbed3ca0 --- /dev/null +++ b/tests/config/unknown-fields-only/pythonv2/.speakeasy/gen.yaml @@ -0,0 +1,17 @@ +configVersion: 2.0.0 +generation: + sdkClassName: SDK + tests: + generateTests: true + generateNewTests: false +python: + packageName: openapi + version: 0.0.1 + templateVersion: v2 + packageManager: uv + preserveUnknownFields: true + modelSerializationMethods: false + forwardCompatibleUnionsByDefault: tagged-only + inferUnionDiscriminators: true + flattenRequests: false + responseFormat: envelope-http diff --git a/tests/tests/unknown-fields-methods-only/.gitkeep b/tests/tests/unknown-fields-methods-only/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tests/unknown-fields-only/.gitkeep b/tests/tests/unknown-fields-only/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock index 5879519f6..2c351ffe5 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock @@ -746,7 +746,7 @@ trackedFiles: src/speakeasy/new_openapi/types/base64fileinput.py: last_write_checksum: sha1:1522687ae3398374c35710cad993a6e82b5ab99d src/speakeasy/new_openapi/types/basemodel.py: - last_write_checksum: sha1:c30ea7638b770493eff489028496bcf7f909be3a + last_write_checksum: sha1:c05508a1c15002a768362b0754ae539af068a31a src/speakeasy/new_openapi/types_.py: last_write_checksum: sha1:05e36f6428f04b1ea7d2609aa55f3966868b58f0 src/speakeasy/new_openapi/utils/__init__.py: diff --git a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py index 4f608b751..13cf4ebf8 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py @@ -37,6 +37,14 @@ def __bool__(self) -> Literal[False]: ) +def _serialize_unrecognized( + value: Union[str, int], info: core_schema.SerializationInfo +) -> Union[str, int]: + if info.mode == "python": + return value + return str(value) if isinstance(value, str) else int(value) + + class UnrecognizedStr(str): @classmethod def __get_pydantic_core_schema__( @@ -60,7 +68,9 @@ def validate_lax(v: Any) -> "UnrecognizedStr": ] ), strict_schema=core_schema.none_schema(), # Always fails in strict mode - serialization=core_schema.plain_serializer_function_ser_schema(str), + serialization=core_schema.plain_serializer_function_ser_schema( + _serialize_unrecognized, info_arg=True + ), ) @@ -84,5 +94,7 @@ def validate_lax(v: Any) -> "UnrecognizedInt": ] ), strict_schema=core_schema.none_schema(), # Always fails in strict mode - serialization=core_schema.plain_serializer_function_ser_schema(int), + serialization=core_schema.plain_serializer_function_ser_schema( + _serialize_unrecognized, info_arg=True + ), ) From 85c371a85b6144d23410db3f7856856075d2e6c1 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 14:34:47 -0400 Subject: [PATCH 09/21] fix(python): keep consts in to_dict and to_json output Const fields, including union discriminators, are populated by their default, so the exclude_unset default of to_dict and to_json dropped them. A discriminated union value then failed re-validation, and a const beside optional fields was emitted as None. When modelSerializationMethods is enabled, models with const fields now mark them as set after initialisation, so the helpers emit the same consts as the request payload. Optional consts whose value is null are not marked, so they stay out of request payloads and helper output unless the caller sets them. --- .../templates/pythonv2/includes/templating.ts | 18 ++++++++++++++++++ templates/templates/pythonv2/model.py.stmpl | 8 ++++++++ .../test_unknown_fields.py.stmpl | 11 +++++++++++ tests/specs/unknown-fields.yaml | 12 ++++++++++++ 4 files changed, 49 insertions(+) diff --git a/templates/templates/pythonv2/includes/templating.ts b/templates/templates/pythonv2/includes/templating.ts index 4ed12dd07..d45b96097 100644 --- a/templates/templates/pythonv2/includes/templating.ts +++ b/templates/templates/pythonv2/includes/templating.ts @@ -1850,6 +1850,24 @@ function templateOptionalFieldList(typeDef: TypeDef): string { } registerTemplateFunc("templateOptionalFieldList", templateOptionalFieldList); +function templateConstFieldAttributeList(typeDef: TypeDef): string { + return typeDef.Fields.filter( + (f) => + f.Const && + !(f.Optional && f.Const.Value === null) && + !f.IsAdditionalProperties, + ) + .map((f) => { + const fieldName = sanitizeFieldName(f.Name); + return `"${useUpperCaseConsts() ? fieldName.toUpperCase() : fieldName}"`; + }) + .join(", "); +} +registerTemplateFunc( + "templateConstFieldAttributeList", + templateConstFieldAttributeList, +); + function templateNullableFieldList(typeDef: TypeDef): string { return templateFieldList(typeDef, (f) => f.Nullable); } diff --git a/templates/templates/pythonv2/model.py.stmpl b/templates/templates/pythonv2/model.py.stmpl index 7d7aa2e11..254fcbcc0 100644 --- a/templates/templates/pythonv2/model.py.stmpl +++ b/templates/templates/pythonv2/model.py.stmpl @@ -57,6 +57,14 @@ class {{$className}}(BaseModel): self.__pydantic_extra__ = value # pyright: ignore[reportIncompatibleVariableOverride] {{end}} + {{- $constFieldAttributes := templateConstFieldAttributeList $type}} + {{- if and .Global.Config.ModelSerializationMethods $constFieldAttributes}} + {{- addImport "typing" "Any"}} + + def model_post_init(self, context: Any, /) -> None: + self.__pydantic_fields_set__.update([{{$constFieldAttributes}}]) + {{- end}} + {{- if hasOpenEnumField $type}} {{- range openEnums $type}} {{- if and (not .Const) (not (eq (getEnumFormat .Type) "union"))}} diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl index 33dc1e684..9c60325fa 100644 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -175,3 +175,14 @@ def test_model_serialization_methods(options): assert model.to_dict(mode="json", **options) == model.model_dump(mode="json", **dump_options) assert model.to_json(indent=None, **options) == model.model_dump_json(indent=None, **dump_options) assert json.loads(model.to_json(**options)) == model.to_dict(mode="json", **options) + + +def test_model_serialization_methods_keep_consts(): + model = models.FieldEnvelopeInput(label="root", choice=models.TextChoice(text="t"), constant=models.ConstFields()) + expected = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed"}} + assert json.loads(utils.marshal_json(model, models.FieldEnvelopeInput)) == expected + if not MODEL_SERIALIZATION_METHODS: + return + assert model.to_dict() == expected + assert json.loads(model.to_json()) == expected + assert models.FieldEnvelopeInput.model_validate(model.to_dict()).to_dict() == expected diff --git a/tests/specs/unknown-fields.yaml b/tests/specs/unknown-fields.yaml index 5635c3689..400fd6454 100644 --- a/tests/specs/unknown-fields.yaml +++ b/tests/specs/unknown-fields.yaml @@ -113,6 +113,8 @@ components: $ref: '#/components/schemas/TypedFields' closed: $ref: '#/components/schemas/ClosedFields' + constant: + $ref: '#/components/schemas/ConstFields' arbitrary: {} futureKind: type: string @@ -212,3 +214,13 @@ components: EmptyFields: type: object additionalProperties: false + ConstFields: + type: object + description: A required const alongside an optional field, so the const is populated by its default rather than by the caller. + required: [kind] + properties: + kind: + type: string + const: fixed + label: + type: string From 520c608c3ce17b8a25d364f9456292aa5f219cf8 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 21:49:19 -0400 Subject: [PATCH 10/21] fix(python): reserve helper names only on model fields Enabling modelSerializationMethods appended a trailing underscore to every name matching a helper, not only to model fields. A toJson operation group became sdk.to_json_ in code and usage snippets, and globals or server variables named like a helper were renamed in SDK constructor arguments. Sub-SDK classes and the SDK constructor have no helper methods, so only model fields, including those of the generated globals model, keep the trailing underscore. Usage snippets name globals with the same constructor argument names. --- .../pythonv2/includes/sanitization.ts | 29 ++++++++++++------- .../templates/pythonv2/includes/templating.ts | 9 ++++-- .../test_unknown_fields.py.stmpl | 8 +++++ tests/specs/unknown-fields.yaml | 15 ++++++++++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/templates/templates/pythonv2/includes/sanitization.ts b/templates/templates/pythonv2/includes/sanitization.ts index 0a85a76ad..929808871 100644 --- a/templates/templates/pythonv2/includes/sanitization.ts +++ b/templates/templates/pythonv2/includes/sanitization.ts @@ -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 != "") { @@ -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); @@ -319,16 +333,13 @@ function sanitizeFieldName(name: string): string { if ( fieldReservedKeywords.includes(name) || - pythonReservedKeywords.includes(name) || - (context.Global.Config.ModelSerializationMethods && - ["to_dict", "to_json"].includes(name)) + pythonReservedKeywords.includes(name) ) { name += "_"; } return name; } -registerTemplateFunc("sanitizeFieldName", sanitizeFieldName); function useConflictResistantModelImports(): boolean { return !!context.Global.Config.FixFlags?.[ @@ -415,9 +426,7 @@ function doesModuleNameConflictAcrossNamespaces( // @ts-ignore function sanitizeSubSDKFieldName(name: string): string { - name = sanitizeFieldName(name); - - return name; + return sanitizeAttributeName(name); } registerTemplateFunc("sanitizeSubSDKFieldName", sanitizeSubSDKFieldName); diff --git a/templates/templates/pythonv2/includes/templating.ts b/templates/templates/pythonv2/includes/templating.ts index d45b96097..9b006ef2a 100644 --- a/templates/templates/pythonv2/includes/templating.ts +++ b/templates/templates/pythonv2/includes/templating.ts @@ -922,7 +922,7 @@ function templateSDKInitFieldName(name: string, suffix: string): string { reserved.push("server_idx"); } - let fieldName = sanitizeFieldName(name); + let fieldName = sanitizeAttributeName(name); if (reserved.includes(name)) { fieldName += "_" + suffix; @@ -1096,7 +1096,12 @@ function templateUsageSDKParams(local: UsageContext): string { return; } - params.push(`${sanitizeFieldName(parameter.field.Name)}=${value},`); + params.push( + `${templateSDKInitFieldName( + parameter.field.Name, + "global", + )}=${value},`, + ); break; } } diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl index 9c60325fa..cdfedf484 100644 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -177,6 +177,14 @@ def test_model_serialization_methods(options): assert json.loads(model.to_json(**options)) == model.to_dict(mode="json", **options) +def test_helper_names_reserved_only_on_models(): + expected_fields = {"to_dict_", "to_json_"} if MODEL_SERIALIZATION_METHODS else {"to_dict", "to_json"} + assert set(models.MethodNames.model_fields) == expected_fields + with SDK(server_url=HTTPBIN_URL) as sdk: + response = sdk.to_json.echo_grouped_fields() + assert response.http_meta.response.status_code == 200 + + def test_model_serialization_methods_keep_consts(): model = models.FieldEnvelopeInput(label="root", choice=models.TextChoice(text="t"), constant=models.ConstFields()) expected = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed"}} diff --git a/tests/specs/unknown-fields.yaml b/tests/specs/unknown-fields.yaml index 400fd6454..cd4c591c8 100644 --- a/tests/specs/unknown-fields.yaml +++ b/tests/specs/unknown-fields.yaml @@ -5,6 +5,21 @@ info: servers: - url: http://localhost:35123 paths: + /anything/unknown-fields-group: + get: + operationId: echoGroupedFields + x-speakeasy-group: toJson + description: Belongs to a group whose name matches a model serialization helper, which must not rename the group accessor. + responses: + '200': + description: The echoed request URL. + content: + application/json: + schema: + type: object + properties: + url: + type: string /anything/unknown-fields-query: get: operationId: echoOptionalJsonQuery From 0a3a7648b92f6940feda78510a80b2b851eee3d4 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 14:51:55 -0400 Subject: [PATCH 11/21] fix(python): honour use_api_names=False in to_dict and to_json The generated model serializer always wrote declared fields under their API names, so to_dict(use_api_names=False) and to_json(use_api_names=False) returned API names for any model with optional or nullable fields. When modelSerializationMethods is enabled, the serializer now writes field names when by_alias is explicitly False. model_dump() without by_alias, by_alias=True and request serialization are unchanged. --- templates/templates/pythonv2/model.py.stmpl | 12 +++++++++--- .../unknown-fields/test_unknown_fields.py.stmpl | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/templates/templates/pythonv2/model.py.stmpl b/templates/templates/pythonv2/model.py.stmpl index 254fcbcc0..44b744e0f 100644 --- a/templates/templates/pythonv2/model.py.stmpl +++ b/templates/templates/pythonv2/model.py.stmpl @@ -8,6 +8,9 @@ {{- $hasNullableField := gt (len $nullableFields) 0 -}} {{- $hasAdditionalProperties := hasAdditionalPropertiesField $type -}} {{- $preserveUnknownFields := .Global.Config.PreserveUnknownFields -}} +{{- $modelSerializationMethods := .Global.Config.ModelSerializationMethods -}} +{{- $outputKey := "k" -}} +{{- if $modelSerializationMethods}}{{$outputKey = "n if use_field_names else k"}}{{end -}} {{- if $hasAdditionalProperties}}{{addImport "pydantic" "ConfigDict"}}{{end -}} {{- $fields := (sortFields $type.Fields) -}} {{- $className := sanitizeClassName $type.Name }} @@ -85,7 +88,7 @@ class {{$className}}(BaseModel): {{- addImport "pydantic" "model_serializer"}} {{- addImport "types" "UNSET_SENTINEL" true}} @model_serializer(mode="wrap") - def {{sanitizeSerializeMethodName $type}}(self, handler): + def {{sanitizeSerializeMethodName $type}}(self, handler{{if $modelSerializationMethods}}, info{{end}}): {{- $nullDefaultFields := templateNullDefaultFieldList $type}} {{- $hasNullDefaultField := gt (len $nullDefaultFields) 0}} {{- if $hasOptionalField}} @@ -99,6 +102,9 @@ class {{$className}}(BaseModel): {{- end}} serialized = handler(self) m = {} + {{- if $modelSerializationMethods}} + use_field_names = info.by_alias is False + {{- end}} for n, f in type(self).model_fields.items(): k = f.alias or n @@ -121,7 +127,7 @@ class {{$className}}(BaseModel): if val != UNSET_SENTINEL: {{- if not $hasOptionalField}} - m[k] = val + m[{{$outputKey}}] = val {{- else }} if ( val is not None @@ -130,7 +136,7 @@ class {{$className}}(BaseModel): or is_nullable_and_explicitly_set {{- end}} ): - m[k] = val + m[{{$outputKey}}] = val {{- end}} {{- if or $hasAdditionalProperties $preserveUnknownFields}} diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl index cdfedf484..71d689452 100644 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -177,6 +177,20 @@ def test_model_serialization_methods(options): assert json.loads(model.to_json(**options)) == model.to_dict(mode="json", **options) +def test_model_serialization_methods_field_names(): + model = models.FieldEnvelopeInput.model_validate({"label": "root", "child": {"displayName": "child", "child": {"displayName": "grandchild"}}, "futureKind": "third", "plainChoice": {"text": "t"}, "future": True}) + extra = {"future": True} if PRESERVE_UNKNOWN_FIELDS else {} + api_names = {"label": "root", "child": {"displayName": "child", "child": {"displayName": "grandchild"}}, "futureKind": "third", "plainChoice": {"text": "t"}, **extra} + field_names = {"label": "root", "child": {"display_name": "child", "child": {"display_name": "grandchild"}}, "future_kind": "third", "plain_choice": {"text": "t"}, **extra} + assert model.model_dump(exclude_unset=True) == api_names + if not MODEL_SERIALIZATION_METHODS: + return + assert model.to_dict() == api_names + assert model.to_dict(use_api_names=False) == field_names + assert json.loads(model.to_json(use_api_names=False)) == field_names + assert models.FieldEnvelopeInput.model_validate(model.to_dict(use_api_names=False)).to_dict() == api_names + + def test_helper_names_reserved_only_on_models(): expected_fields = {"to_dict_", "to_json_"} if MODEL_SERIALIZATION_METHODS else {"to_dict", "to_json"} assert set(models.MethodNames.model_fields) == expected_fields From 0cbf652a9a08279f04aa2a395b9b9211af7f78c8 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 14:53:51 -0400 Subject: [PATCH 12/21] ci: drop generator PHP setup already on main main installs the same patched PHP in the test-generator job, so this branch no longer needs its own copy of the step. --- .github/workflows/test.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c9700acb6..0fe5dce3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,13 +58,6 @@ jobs: with: ruby-version: "3.2" bundler-cache: true - - name: Setup PHP - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 - with: - php-version: "8.3" - extensions: mbstring, xml, curl - env: - update: true - name: Configure Composer run: | composer config --global preferred-install auto From 9735841d387fa0301456bb3719917ec5549a35ca Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 17:36:10 -0400 Subject: [PATCH 13/21] fix(python): keep UNSET free of unknown fields With preserveUnknownFields, Unset inherited extra="allow", so an invalid dict on an OptionalNullable field validated to Unset(...) carrying that dict and no longer compared equal to UNSET. --- .../auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl | 3 +++ .../tests/unknown-fields/test_unknown_fields.py.stmpl | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl index f577cf371..b142caf7b 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl @@ -52,6 +52,9 @@ class BaseModel(PydanticBaseModel): {{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 diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl index 71d689452..3b7f69aee 100644 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -8,7 +8,7 @@ import pydantic import pytest from openapi import SDK, errors, models, utils -from openapi.types import BaseModel, Nullable, UnrecognizedInt, UnrecognizedStr +from openapi.types import UNSET, BaseModel, Nullable, UnrecognizedInt, UnrecognizedStr from .common_helpers import HTTPBIN_URL, record_test @@ -102,6 +102,11 @@ def test_base_model_unknown_field_policy(): assert ("future" in model.model_dump()) is PRESERVE_UNKNOWN_FIELDS +def test_unset_ignores_unknown_fields(): + node = models.FieldNode.model_validate({"displayName": "child", "nullable": {"future": True}}) + assert node.nullable == UNSET + + def test_response_model_can_be_sent_again(): payload = {"label": "root", "child": {"displayName": "child", "future": {"enabled": False}}} with SDK(server_url=HTTPBIN_URL) as sdk: From 71f82ade5170f78f000213656215b91671f5fa3a Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 21:49:20 -0400 Subject: [PATCH 14/21] fix(python): read each field from the key the serializer emitted The generated model serializer looked a field up under its API name, fell back to its Python name, and removed both keys. With preserveUnknownFields or modelSerializationMethods enabled this: - dropped an unknown field named like a declared field's Python name, because serialized.pop(n) ran as the eager default of serialized.pop(k); - took that unknown field as the declared field's value, written under the API name, when exclude_none or exclude_defaults had dropped the declared field; - wrote None for required fields Pydantic excluded because they held their default, so to_dict(exclude_defaults=True) emitted {"kind": None} for consts; - returned None for nullable fields from to_dict(exclude_none=True) and to_json(exclude_none=True), because the serializer puts explicit nulls back after Pydantic drops them for request serialization. When either option is enabled, the serializer now receives the serialization info and reads each field only from the key the dump used, by_alias deciding between the API and Python name. With modelSerializationMethods, fields dropped by exclude_defaults stay out, and to_dict and to_json dump with a serialization context so fields dropped by exclude_none stay out too. Request payloads still send explicit nulls. --- .../{{getSourcePath}}/types/basemodel.py.stmpl | 5 +++++ templates/templates/pythonv2/model.py.stmpl | 18 ++++++++++++++++-- .../test_unknown_fields.py.stmpl | 9 ++++++++- tests/specs/unknown-fields.yaml | 5 ++++- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl index b142caf7b..48976f0ad 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl @@ -28,6 +28,7 @@ class BaseModel(PydanticBaseModel): exclude_defaults=exclude_defaults, exclude_none=exclude_none, warnings=warnings, + context={SERIALIZATION_METHODS_DUMP: True}, ) def to_json( @@ -47,6 +48,7 @@ class BaseModel(PydanticBaseModel): exclude_defaults=exclude_defaults, exclude_none=exclude_none, warnings=warnings, + context={SERIALIZATION_METHODS_DUMP: True}, ) {{end}} @@ -65,6 +67,9 @@ class Unset(BaseModel): UNSET = Unset() UNSET_SENTINEL = "~?~unset~?~sentinel~?~" +{{- if .Global.Config.ModelSerializationMethods}} +SERIALIZATION_METHODS_DUMP = "speakeasy_serialization_methods_dump" +{{- end}} T = TypeVar("T") diff --git a/templates/templates/pythonv2/model.py.stmpl b/templates/templates/pythonv2/model.py.stmpl index 44b744e0f..f4523ad36 100644 --- a/templates/templates/pythonv2/model.py.stmpl +++ b/templates/templates/pythonv2/model.py.stmpl @@ -9,6 +9,7 @@ {{- $hasAdditionalProperties := hasAdditionalPropertiesField $type -}} {{- $preserveUnknownFields := .Global.Config.PreserveUnknownFields -}} {{- $modelSerializationMethods := .Global.Config.ModelSerializationMethods -}} +{{- $serializerUsesInfo := or $preserveUnknownFields $modelSerializationMethods -}} {{- $outputKey := "k" -}} {{- if $modelSerializationMethods}}{{$outputKey = "n if use_field_names else k"}}{{end -}} {{- if $hasAdditionalProperties}}{{addImport "pydantic" "ConfigDict"}}{{end -}} @@ -88,7 +89,7 @@ class {{$className}}(BaseModel): {{- addImport "pydantic" "model_serializer"}} {{- addImport "types" "UNSET_SENTINEL" true}} @model_serializer(mode="wrap") - def {{sanitizeSerializeMethodName $type}}(self, handler{{if $modelSerializationMethods}}, info{{end}}): + def {{sanitizeSerializeMethodName $type}}(self, handler{{if $serializerUsesInfo}}, info{{end}}): {{- $nullDefaultFields := templateNullDefaultFieldList $type}} {{- $hasNullDefaultField := gt (len $nullDefaultFields) 0}} {{- if $hasOptionalField}} @@ -103,14 +104,27 @@ class {{$className}}(BaseModel): serialized = handler(self) m = {} {{- if $modelSerializationMethods}} + {{- addImport "types.basemodel" "SERIALIZATION_METHODS_DUMP" true}} use_field_names = info.by_alias is False + exclude_none = info.exclude_none and isinstance(info.context, dict) and SERIALIZATION_METHODS_DUMP in info.context {{- end}} for n, f in type(self).model_fields.items(): k = f.alias or n + {{- if $serializerUsesInfo}} + key = k if info.by_alias else n + {{- end}} + {{- if $modelSerializationMethods}} + if key not in serialized and (info.exclude_defaults or exclude_none): + continue + {{- end}} + {{- if $serializerUsesInfo}} + val = serialized.pop(key, None) + {{- else}} val = serialized.get(k, serialized.get(n)) - {{- if or $hasAdditionalProperties $preserveUnknownFields}} + {{- if $hasAdditionalProperties}} serialized.pop(k, serialized.pop(n, None)) + {{- end}} {{- end}} {{- if and $hasOptionalField $hasNullableField}} diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl index 3b7f69aee..97a1eb299 100644 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl @@ -32,6 +32,7 @@ CASES = [ ({"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"}}}), @@ -207,9 +208,15 @@ def test_helper_names_reserved_only_on_models(): def test_model_serialization_methods_keep_consts(): model = models.FieldEnvelopeInput(label="root", choice=models.TextChoice(text="t"), constant=models.ConstFields()) expected = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed"}} - assert json.loads(utils.marshal_json(model, models.FieldEnvelopeInput)) == expected + wire = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed", "priority": 1}} + assert json.loads(utils.marshal_json(model, models.FieldEnvelopeInput)) == wire if not MODEL_SERIALIZATION_METHODS: return assert model.to_dict() == expected + assert model.to_dict(exclude_unset=False) == wire assert json.loads(model.to_json()) == expected assert models.FieldEnvelopeInput.model_validate(model.to_dict()).to_dict() == expected + assert models.FieldEnvelopeInput.model_validate(wire).to_dict() == wire + assert models.ConstFields(priority=1).to_dict() == {"kind": "fixed", "priority": 1} + assert models.ConstFields(priority=1).to_dict(exclude_defaults=True) == {} + assert models.ConstFields(priority=2).to_dict(exclude_defaults=True) == {"priority": 2} diff --git a/tests/specs/unknown-fields.yaml b/tests/specs/unknown-fields.yaml index cd4c591c8..9a2a92eca 100644 --- a/tests/specs/unknown-fields.yaml +++ b/tests/specs/unknown-fields.yaml @@ -231,7 +231,7 @@ components: additionalProperties: false ConstFields: type: object - description: A required const alongside an optional field, so the const is populated by its default rather than by the caller. + description: A required const alongside optional fields, one of them with a default, so values are populated by their defaults rather than by the caller. required: [kind] properties: kind: @@ -239,3 +239,6 @@ components: const: fixed label: type: string + priority: + type: integer + default: 1 From 11ac62047e4db04e3156e1c3ef384bd9f0213f19 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 21:49:20 -0400 Subject: [PATCH 15/21] fix(python): ignore unknown response fields in generated test assertions With preserveUnknownFields, response models keep fields the server returns but the spec does not declare, and model equality includes them. Generated tests compared whole response models with ==, so they failed whenever the server sent such fields. Compare declared fields exactly and undeclared fields only where the expected value has them. Additional properties of models that declare additionalProperties are still compared exactly. The helper ships in tests/unknown_fields.py, generated whenever tests are generated with preserveUnknownFields. --- templates/perms.go | 1 + .../auxiliary/tests/unknown_fields.py.stmpl | 31 +++++++++++++++ .../templates/pythonv2/includes/tests.ts | 39 +++++++++++-------- 3 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 templates/templates/pythonv2/auxiliary/tests/unknown_fields.py.stmpl diff --git a/templates/perms.go b/templates/perms.go index 8e4809472..18412ef7b 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -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, diff --git a/templates/templates/pythonv2/auxiliary/tests/unknown_fields.py.stmpl b/templates/templates/pythonv2/auxiliary/tests/unknown_fields.py.stmpl new file mode 100644 index 000000000..467f8f68b --- /dev/null +++ b/templates/templates/pythonv2/auxiliary/tests/unknown_fields.py.stmpl @@ -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}} diff --git a/templates/templates/pythonv2/includes/tests.ts b/templates/templates/pythonv2/includes/tests.ts index 7a5a2de87..9da99b1ac 100644 --- a/templates/templates/pythonv2/includes/tests.ts +++ b/templates/templates/pythonv2/includes/tests.ts @@ -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()) + ) { + 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; } From 290f4841da9337d678d52ba1820db9713507c14a Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 21:49:20 -0400 Subject: [PATCH 16/21] test(python): cover unknown-field options in existing uber variants Run the unknown-field cases from a shared uber fragment instead of four dedicated variants with their own spec: - secondary enables preserveUnknownFields and keeps whole request bodies, so top-level unknown fields can be sent and echoed. - tertiary enables modelSerializationMethods. - quaternary enables both with flattened requests, covering to_dict and to_json on responses that carry unknown fields. Every other variant keeps both options off. The four unknown-fields variants, their CI jobs and the separate spec are removed. Generated models holding unrecognized enum values are also dumped with warnings raised as errors, not only a bare TypeAdapter. The echo operation is excluded from javav2, whose generated union members repeat the same interface when the parent schema has input and output variants. The discriminator enums of the fragment are named schemas, so Go does not give the NumberChoice kind the type name Kind used by an existing enum. --- .github/workflows/test.yml | 28 --- Makefile | 4 - scripts/build-openapi-document.sh | 4 +- templates/perms.go | 10 +- .../test_unknown_fields_additional.py | 110 +++++++++ .../test_unknown_fields_additional.py | 148 ++++++++++++ .../test_unknown_fields.py.stmpl | 1 - .../test_unknown_fields.py.stmpl | 1 - .../test_unknown_fields.py.stmpl | 1 - .../test_unknown_fields.py.stmpl | 222 ------------------ .../quaternary/pythonv2/.speakeasy/gen.yaml | 2 + .../secondary/pythonv2/.speakeasy/gen.yaml | 1 + .../tertiary/pythonv2/.speakeasy/gen.yaml | 1 + .../pythonv2/.speakeasy/gen.yaml | 16 -- .../pythonv2/.speakeasy/gen.yaml | 17 -- .../pythonv2/.speakeasy/gen.yaml | 17 -- .../pythonv2/.speakeasy/gen.yaml | 17 -- .../{ => fragments/uber}/unknown-fields.yaml | 57 ++++- tests/tests/unknown-fields-default/.gitkeep | 0 .../unknown-fields-methods-only/.gitkeep | 0 tests/tests/unknown-fields-only/.gitkeep | 0 tests/tests/unknown-fields/.gitkeep | 0 22 files changed, 313 insertions(+), 344 deletions(-) create mode 100644 templates/templates/pythonv2/tests/quaternary/test_unknown_fields_additional.py create mode 100644 templates/templates/pythonv2/tests/secondary/test_unknown_fields_additional.py delete mode 100644 templates/templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl delete mode 100644 templates/templates/pythonv2/tests/unknown-fields-methods-only/test_unknown_fields.py.stmpl delete mode 100644 templates/templates/pythonv2/tests/unknown-fields-only/test_unknown_fields.py.stmpl delete mode 100644 templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl delete mode 100644 tests/config/unknown-fields-default/pythonv2/.speakeasy/gen.yaml delete mode 100644 tests/config/unknown-fields-methods-only/pythonv2/.speakeasy/gen.yaml delete mode 100644 tests/config/unknown-fields-only/pythonv2/.speakeasy/gen.yaml delete mode 100644 tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml rename tests/specs/{ => fragments/uber}/unknown-fields.yaml (81%) delete mode 100644 tests/tests/unknown-fields-default/.gitkeep delete mode 100644 tests/tests/unknown-fields-methods-only/.gitkeep delete mode 100644 tests/tests/unknown-fields-only/.gitkeep delete mode 100644 tests/tests/unknown-fields/.gitkeep diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0fe5dce3c..b52fe2884 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -663,30 +663,6 @@ jobs: uses: ./.github/workflows/test-pythonv2.yml with: test-group: "quaternary" - test-pythonv2-unknown-fields: - needs: [detect-targets] - if: needs.detect-targets.outputs.run-pythonv2 == 'true' - uses: ./.github/workflows/test-pythonv2.yml - with: - test-group: "unknown-fields" - test-pythonv2-unknown-fields-default: - needs: [detect-targets] - if: needs.detect-targets.outputs.run-pythonv2 == 'true' - uses: ./.github/workflows/test-pythonv2.yml - with: - test-group: "unknown-fields-default" - test-pythonv2-unknown-fields-only: - needs: [detect-targets] - if: needs.detect-targets.outputs.run-pythonv2 == 'true' - uses: ./.github/workflows/test-pythonv2.yml - with: - test-group: "unknown-fields-only" - test-pythonv2-unknown-fields-methods-only: - needs: [detect-targets] - if: needs.detect-targets.outputs.run-pythonv2 == 'true' - uses: ./.github/workflows/test-pythonv2.yml - with: - test-group: "unknown-fields-methods-only" test-pythonv2-client-credentials: needs: [detect-targets] if: needs.detect-targets.outputs.run-pythonv2 == 'true' @@ -744,10 +720,6 @@ jobs: - test-pythonv2-secondary - test-pythonv2-tertiary - test-pythonv2-quaternary - - test-pythonv2-unknown-fields - - test-pythonv2-unknown-fields-default - - test-pythonv2-unknown-fields-only - - test-pythonv2-unknown-fields-methods-only - test-pythonv2-client-credentials - test-pythonv2-basic-http - test-pythonv2-security-options diff --git a/Makefile b/Makefile index 21e75c20f..7655657f2 100644 --- a/Makefile +++ b/Makefile @@ -38,10 +38,6 @@ VARIANTS := primary \ secondary \ tertiary \ quaternary \ -unknown-fields \ -unknown-fields-default \ -unknown-fields-only \ -unknown-fields-methods-only \ no-zod \ client-credentials \ client-credentials-basic \ diff --git a/scripts/build-openapi-document.sh b/scripts/build-openapi-document.sh index 1d2c21b82..f0903f8cf 100755 --- a/scripts/build-openapi-document.sh +++ b/scripts/build-openapi-document.sh @@ -16,9 +16,7 @@ fi template_spec="./tests/specs/uber.yaml" component_spec="./tests/specs/components.yaml" -if [[ "${VARIANT}" == unknown-fields* ]]; then - template_spec="./tests/specs/unknown-fields.yaml" -elif [ "${VARIANT}" == "oauth2-password" ] || [ "${VARIANT}" == "custom-http" ]; then +if [ "${VARIANT}" == "oauth2-password" ] || [ "${VARIANT}" == "custom-http" ]; then template_spec="./tests/specs/ecommerce.yaml" component_spec="./tests/specs/components.ecommerce.yaml" elif [ "${VARIANT}" == "no-servers" ] || [ "${VARIANT}" == "relative-servers" ]; then diff --git a/templates/perms.go b/templates/perms.go index 18412ef7b..8aa50d906 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -1712,6 +1712,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, @@ -1726,6 +1727,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, @@ -1733,14 +1735,6 @@ var fileMode = map[string]fs.FileMode{ "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/unknown-fields": 0o20000000755, - "templates/pythonv2/tests/unknown-fields-default": 0o20000000755, - "templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl": 0o644, - "templates/pythonv2/tests/unknown-fields-methods-only": 0o20000000755, - "templates/pythonv2/tests/unknown-fields-methods-only/test_unknown_fields.py.stmpl": 0o644, - "templates/pythonv2/tests/unknown-fields-only": 0o20000000755, - "templates/pythonv2/tests/unknown-fields-only/test_unknown_fields.py.stmpl": 0o644, - "templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl": 0o644, "templates/pythonv2/testusage": 0o20000000755, "templates/pythonv2/testusage/pylintrc": 0o644, "templates/pythonv2/testusage/pyproject.toml.stmpl": 0o644, diff --git a/templates/templates/pythonv2/tests/quaternary/test_unknown_fields_additional.py b/templates/templates/pythonv2/tests/quaternary/test_unknown_fields_additional.py new file mode 100644 index 000000000..cbbe9e37c --- /dev/null +++ b/templates/templates/pythonv2/tests/quaternary/test_unknown_fields_additional.py @@ -0,0 +1,110 @@ +import json +import warnings + +import pydantic +import pytest + +from openapi import SDK, utils +from openapi.models import shared +from openapi.types import UnrecognizedInt, UnrecognizedStr +from .common_helpers import HTTPBIN_URL, record_test + + +@pytest.mark.parametrize("child", [ + {"displayName": "child", "future": True}, + {"displayName": "child", "display_name": "unknown"}, + {"displayName": "child", "child": {"displayName": "grandchild", "future": {"nested": [None, 0]}}}, + {"displayName": "child", "nullable": None, "future": None}, +]) +def test_flattened_request_keeps_nested_unknown_fields(child): + record_test("unknown-fields-json-round-trip") + with SDK(server_url=HTTPBIN_URL) as sdk: + response = sdk.echo_unknown_fields(label="root", child=child) + payload = {"label": "root", "child": child} + assert response.raw_response.json()["json"] == payload + assert response.object.json_.to_dict() == payload + assert json.loads(response.object.json_.to_json()) == payload + + +def test_flattened_request_keeps_unknown_fields_on_nested_models(): + child = shared.FieldNode(display_name="child", future=None) + with SDK(server_url=HTTPBIN_URL) as sdk: + response = sdk.echo_flattened_unknown_fields(label="root", child=child) + assert response.raw_response.json()["json"] == {"label": "root", "child": {"displayName": "child", "future": None}} + + +def test_flattened_request_has_no_argument_for_top_level_unknown_fields(): + with SDK(server_url=HTTPBIN_URL) as sdk: + with pytest.raises(TypeError): + sdk.echo_flattened_unknown_fields(label="root", future=True) # type: ignore[call-arg] + + +@pytest.mark.parametrize("options", [{}, {"use_api_names": False}, {"exclude_unset": False}, {"exclude_defaults": True}, {"exclude_none": True}, {"warnings": False}]) +def test_model_serialization_methods(options): + model = shared.FieldEnvelopeInput.model_validate({"label": "root", "child": {"displayName": "child", "future": None}, "futureKind": "third", "futureCode": 3}) + dump_options = {"by_alias": True, "exclude_unset": True, **options} + if "use_api_names" in dump_options: + dump_options["by_alias"] = dump_options.pop("use_api_names") + assert model.to_dict(**options) == model.model_dump(**dump_options) + assert model.to_dict(mode="json", **options) == model.model_dump(mode="json", **dump_options) + assert model.to_json(indent=None, **options) == model.model_dump_json(indent=None, **dump_options) + assert json.loads(model.to_json(**options)) == model.to_dict(mode="json", **options) + + +def test_model_serialization_methods_field_names(): + model = shared.FieldEnvelopeInput.model_validate({"label": "root", "child": {"displayName": "child", "child": {"displayName": "grandchild"}}, "futureKind": "third", "plainChoice": {"text": "t"}, "future": True}) + api_names = {"label": "root", "child": {"displayName": "child", "child": {"displayName": "grandchild"}}, "futureKind": "third", "plainChoice": {"text": "t"}, "future": True} + field_names = {"label": "root", "child": {"display_name": "child", "child": {"display_name": "grandchild"}}, "future_kind": "third", "plain_choice": {"text": "t"}, "future": True} + assert model.model_dump(exclude_unset=True) == api_names + assert model.to_dict() == api_names + assert model.to_dict(use_api_names=False) == field_names + assert json.loads(model.to_json(use_api_names=False)) == field_names + assert shared.FieldEnvelopeInput.model_validate(model.to_dict(use_api_names=False)).to_dict() == api_names + + +def test_helper_names_reserved_only_on_models(): + assert set(shared.MethodNames.model_fields) == {"to_dict_", "to_json_"} + model = shared.MethodNames.model_validate({"toDict": "d", "toJson": "j"}) + assert model.to_dict() == {"toDict": "d", "toJson": "j"} + with SDK(server_url=HTTPBIN_URL) as sdk: + response = sdk.to_json.echo_grouped_fields() + assert response.status_code == 200 + + +def test_model_serialization_methods_keep_consts(): + model = shared.FieldEnvelopeInput(label="root", choice=shared.TextChoice(text="t"), constant=shared.ConstFields()) + expected = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed"}} + wire = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed", "priority": 1}} + assert json.loads(utils.marshal_json(model, shared.FieldEnvelopeInput)) == wire + assert model.to_dict() == expected + assert model.to_dict(exclude_unset=False) == wire + assert json.loads(model.to_json()) == expected + assert shared.FieldEnvelopeInput.model_validate(model.to_dict()).to_dict() == expected + assert shared.FieldEnvelopeInput.model_validate(wire).to_dict() == wire + assert shared.ConstFields(priority=1).to_dict() == {"kind": "fixed", "priority": 1} + assert shared.ConstFields(priority=1).to_dict(exclude_defaults=True) == {} + assert shared.ConstFields(priority=2).to_dict(exclude_defaults=True) == {"priority": 2} + + +@pytest.mark.parametrize("typ, value", [(UnrecognizedStr, "future"), (UnrecognizedInt, 3)]) +def test_unrecognized_enum_serialization(typ, value): + adapter = pydantic.TypeAdapter(typ) + enum_value = adapter.validate_python(value) + assert isinstance(enum_value, typ) + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert adapter.dump_python(enum_value) is enum_value + assert adapter.dump_python(enum_value, mode="json") == value + assert json.loads(adapter.dump_json(enum_value)) == value + with pytest.raises(pydantic.ValidationError): + adapter.validate_python(value, strict=True) + + +def test_unrecognized_enum_generated_model(): + model = shared.FieldEnvelope.model_validate({"label": "root", "futureKind": "third", "futureCode": 3}) + with warnings.catch_warnings(): + warnings.simplefilter("error") + dumped = model.model_dump(by_alias=True) + assert type(dumped["futureKind"]) is UnrecognizedStr + assert type(dumped["futureCode"]) is UnrecognizedInt + assert json.loads(utils.marshal_json(model, shared.FieldEnvelope)) == {"label": "root", "futureKind": "third", "futureCode": 3} diff --git a/templates/templates/pythonv2/tests/secondary/test_unknown_fields_additional.py b/templates/templates/pythonv2/tests/secondary/test_unknown_fields_additional.py new file mode 100644 index 000000000..97fcfd0d4 --- /dev/null +++ b/templates/templates/pythonv2/tests/secondary/test_unknown_fields_additional.py @@ -0,0 +1,148 @@ +import json +from typing import Annotated, Any, List, Optional + +import httpx +import pydantic +import pytest + +from openapi import SDK, utils +from openapi.models import errors, shared +from openapi.types import UNSET, BaseModel, Nullable +from .common_helpers import HTTPBIN_URL, record_test + + +CASES = [ + {"futureKind": "third", "futureCode": 3}, + {"methodNames": {"toDict": "dictionary", "toJson": "json"}}, + {"future": "value"}, + {"future": False}, + {"empty": {"future": True}}, + {"future": None}, + {"plainChoice": {"text": "value", "future": True}}, + {"plainChoice": {"number": 3, "future": True}}, + {"requiredOnly": {"label": "required", "future": True}}, + {"future": 0}, + {"composed": {"displayName": "child", "category": "combined", "future": True}}, + {"split": {"label": "split", "future": True}}, + {"future": []}, + {"future": {"nested": [None, False, 0, "value"]}}, + {"child": {"displayName": "child", "future": True}}, + {"child": {"displayName": "child", "display_name": "unknown"}}, + {"child": {"displayName": "child", "child": {"displayName": "grandchild", "future": True}}}, + {"children": [{"displayName": "child", "future": True}]}, + {"lookup": {"one": {"displayName": "child", "future": True}}}, + {"choice": {"kind": "text", "text": "value", "future": True}}, + {"choice": {"kind": "number", "number": 3, "future": True}}, + {"closed": {"label": "closed", "future": True}}, + {"open": {"label": "open", "future": True}}, + {"typed": {"label": "typed", "future": "value"}}, + {"arbitrary": {"future": {"nullable": None}}}, + {"child": {"displayName": "child", "nullable": None}}, +] + + +def echo(request): + sent = [] + with httpx.Client(event_hooks={"request": [sent.append]}) as client: + with SDK(server_url=HTTPBIN_URL, client=client) as sdk: + response = sdk.echo_unknown_fields(request=request) + return response, json.loads(sent[-1].content) + + +@pytest.mark.parametrize("fields", CASES) +@pytest.mark.parametrize("as_model", [False, True]) +def test_unknown_fields_echo(fields, as_model): + record_test("unknown-fields-json-round-trip") + payload = {"label": "root", **fields} + request = shared.FieldEnvelope.model_validate(payload) if as_model else payload + response, sent = echo(request) + assert sent == payload + assert response.json_.model_dump(mode="json", by_alias=True) == payload + assert json.loads(response.json_.model_dump_json(by_alias=True)) == payload + assert "headers" in response.model_dump() + + +def test_response_only_unknown_fields(): + payload = {"json": {"label": "root", "child": {"displayName": "child", "future": True}}, "future": {"value": 1}} + transport = httpx.MockTransport(lambda request: httpx.Response(200, json=payload)) + with httpx.Client(transport=transport) as client: + with SDK(server_url=HTTPBIN_URL, client=client) as sdk: + response = sdk.echo_unknown_fields(request={"label": "root"}) + dumped = response.model_dump(mode="json", by_alias=True) + assert "future" in dumped + assert "future" in dumped["json"]["child"] + + +def test_response_model_can_be_sent_again(): + payload = {"label": "root", "child": {"displayName": "child", "future": {"enabled": False}}} + first, _ = echo(payload) + second, sent = echo(first.json_.model_dump(by_alias=True)) + assert sent == payload + assert second.json_.model_dump(by_alias=True, exclude_none=True) == payload + + +def test_error_response_unknown_fields(): + payload = {"message": "invalid", "future": None, "detail": {"displayName": "child", "future": {"flag": True}}} + transport = httpx.MockTransport(lambda request: httpx.Response(400, json=payload)) + with httpx.Client(transport=transport) as client: + with SDK(server_url=HTTPBIN_URL, client=client) as sdk: + with pytest.raises(errors.EchoUnknownFieldsResponseBody) as caught: + sdk.echo_unknown_fields(request={"label": "root"}) + dumped = getattr(caught.value, "data").model_dump(mode="json", by_alias=True) + assert "future" in dumped + assert "future" in dumped["detail"] + + +def test_omitted_json_query_stays_empty(): + sent = [] + with httpx.Client(event_hooks={"request": [sent.append]}) as client: + with SDK(server_url=HTTPBIN_URL, client=client) as sdk: + sdk.echo_optional_json_query() + assert sent[-1].url.params["filter"] == "" + + +def test_typed_additional_properties_still_validate(): + with pytest.raises(pydantic.ValidationError): + shared.FieldEnvelope.model_validate({"label": "root", "typed": {"future": {"invalid": True}}}) + + +def test_unknown_fields_dump_filters(): + model = shared.FieldEnvelope.model_validate({"label": "root", "future": True, "future_null": None}) + assert "future_null" in model.model_dump() + assert "future_null" not in model.model_dump(exclude_none=True) + assert "future" not in model.model_dump(exclude={"future"}) + assert model.model_dump(include={"label"}) == {"label": "root"} + + +def test_base_model_unknown_field_policy(): + class CustomModel(BaseModel): + label: str + + model = CustomModel(label="value", future=None) + assert "future" in model.model_dump() + + +def test_unset_ignores_unknown_fields(): + node = shared.FieldNode.model_validate({"displayName": "child", "nullable": {"future": True}}) + assert node.nullable == UNSET + + +def test_helper_names_not_reserved_without_methods(): + assert set(shared.MethodNames.model_fields) == {"to_dict", "to_json"} + assert not hasattr(shared.FieldEnvelope, "to_dict") + + +@pytest.mark.parametrize("typ", [Optional[int], Optional[List[int]], Optional[shared.FieldNode], Any]) +def test_top_level_none_serialization(typ): + assert utils.marshal_json(None, typ) == "" + + +def test_top_level_none_still_validates(): + with pytest.raises(pydantic.ValidationError): + utils.marshal_json(None, shared.FieldNode) + assert utils.marshal_json(None, Nullable[shared.FieldNode]) == "null" + + +def test_coerced_top_level_none_stays_empty(): + typ = Annotated[Optional[int], pydantic.BeforeValidator(lambda value: None)] + assert utils.marshal_json("empty", typ) == "" diff --git a/templates/templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl deleted file mode 100644 index c83ca0359..000000000 --- a/templates/templates/pythonv2/tests/unknown-fields-default/test_unknown_fields.py.stmpl +++ /dev/null @@ -1 +0,0 @@ -{{templateString "tests/unknown-fields/test_unknown_fields.py.stmpl" .Local}} diff --git a/templates/templates/pythonv2/tests/unknown-fields-methods-only/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields-methods-only/test_unknown_fields.py.stmpl deleted file mode 100644 index c83ca0359..000000000 --- a/templates/templates/pythonv2/tests/unknown-fields-methods-only/test_unknown_fields.py.stmpl +++ /dev/null @@ -1 +0,0 @@ -{{templateString "tests/unknown-fields/test_unknown_fields.py.stmpl" .Local}} diff --git a/templates/templates/pythonv2/tests/unknown-fields-only/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields-only/test_unknown_fields.py.stmpl deleted file mode 100644 index c83ca0359..000000000 --- a/templates/templates/pythonv2/tests/unknown-fields-only/test_unknown_fields.py.stmpl +++ /dev/null @@ -1 +0,0 @@ -{{templateString "tests/unknown-fields/test_unknown_fields.py.stmpl" .Local}} diff --git a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl b/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl deleted file mode 100644 index 97a1eb299..000000000 --- a/templates/templates/pythonv2/tests/unknown-fields/test_unknown_fields.py.stmpl +++ /dev/null @@ -1,222 +0,0 @@ -import asyncio -import json -import warnings -from typing import Annotated, Any, List, Optional - -import httpx -import pydantic -import pytest - -from openapi import SDK, errors, models, utils -from openapi.types import UNSET, BaseModel, Nullable, UnrecognizedInt, UnrecognizedStr -from .common_helpers import HTTPBIN_URL, record_test - - -PRESERVE_UNKNOWN_FIELDS = {{if .Global.Config.PreserveUnknownFields}}True{{else}}False{{end}} -MODEL_SERIALIZATION_METHODS = {{if .Global.Config.ModelSerializationMethods}}True{{else}}False{{end}} - - -CASES = [ - ({"futureKind": "third", "futureCode": 3}, {"futureKind": "third", "futureCode": 3}), - ({"methodNames": {"toDict": "dictionary", "toJson": "json"}}, {"methodNames": {"toDict": "dictionary", "toJson": "json"}}), - ({"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"}}), - ({"arbitrary": {"future": {"nullable": None}}}, {"arbitrary": {"future": {"nullable": None}}}), - ({"child": {"displayName": "child", "nullable": None}}, {"child": {"displayName": "child", "nullable": None}}), -] - - -@pytest.mark.parametrize("fields, default_fields", CASES) -@pytest.mark.parametrize("as_model", [False, True]) -@pytest.mark.parametrize("as_async", [False, True]) -def test_unknown_fields_echo(fields, default_fields, as_model, as_async): - record_test("unknown-fields-json-round-trip") - payload = {"label": "root", **fields} - request = models.FieldEnvelopeInput.model_validate(payload) if as_model else payload - if as_async: - async def send(): - async with SDK(server_url=HTTPBIN_URL) as sdk: - return await sdk.echo_unknown_fields_async(request=request) - response = asyncio.run(send()) - else: - with SDK(server_url=HTTPBIN_URL) as sdk: - response = sdk.echo_unknown_fields(request=request) - expected = {"label": "root", **(fields if PRESERVE_UNKNOWN_FIELDS else default_fields)} - assert response.http_meta.response.json()["json"] == expected - assert response.object.json_.model_dump(mode="json", by_alias=True, exclude_none=not PRESERVE_UNKNOWN_FIELDS) == expected - assert json.loads(response.object.json_.model_dump_json(by_alias=True, exclude_none=not PRESERVE_UNKNOWN_FIELDS)) == expected - assert ("headers" in response.object.model_dump()) is PRESERVE_UNKNOWN_FIELDS - if MODEL_SERIALIZATION_METHODS: - assert response.object.json_.to_dict(mode="json") == expected - assert json.loads(response.object.json_.to_json()) == expected - - -def test_response_only_unknown_fields(): - payload = {"json": {"label": "root", "child": {"displayName": "child", "future": True}}, "future": {"value": 1}} - transport = httpx.MockTransport(lambda request: httpx.Response(200, json=payload)) - with httpx.Client(transport=transport) as client: - with SDK(server_url=HTTPBIN_URL, client=client) as sdk: - response = sdk.echo_unknown_fields(request={"label": "root"}) - dumped = response.object.model_dump(mode="json", by_alias=True, exclude_none=not PRESERVE_UNKNOWN_FIELDS) - assert ("future" in dumped) is PRESERVE_UNKNOWN_FIELDS - assert ("future" in dumped["json"]["child"]) is PRESERVE_UNKNOWN_FIELDS - - -def test_typed_additional_properties_still_validate(): - with pytest.raises(pydantic.ValidationError): - models.FieldEnvelopeInput.model_validate({"label": "root", "typed": {"future": {"invalid": True}}}) - - -def test_unknown_fields_dump_filters(): - model = models.FieldEnvelopeInput.model_validate({"label": "root", "future": True, "future_null": None}) - assert ("future_null" in model.model_dump()) is PRESERVE_UNKNOWN_FIELDS - assert "future_null" not in model.model_dump(exclude_none=True) - assert "future" not in model.model_dump(exclude={"future"}) - assert model.model_dump(include={"label"}) == {"label": "root"} - - -def test_base_model_unknown_field_policy(): - class CustomModel(BaseModel): - label: str - - model = CustomModel(label="value", future=None) - assert ("future" in model.model_dump()) is PRESERVE_UNKNOWN_FIELDS - - -def test_unset_ignores_unknown_fields(): - node = models.FieldNode.model_validate({"displayName": "child", "nullable": {"future": True}}) - assert node.nullable == UNSET - - -def test_response_model_can_be_sent_again(): - payload = {"label": "root", "child": {"displayName": "child", "future": {"enabled": False}}} - with SDK(server_url=HTTPBIN_URL) as sdk: - first = sdk.echo_unknown_fields(request=payload) - second = sdk.echo_unknown_fields(request=first.object.json_.model_dump(by_alias=True)) - expected = payload if PRESERVE_UNKNOWN_FIELDS else {"label": "root", "child": {"displayName": "child"}} - assert second.http_meta.response.json()["json"] == expected - assert second.object.json_.model_dump(by_alias=True, exclude_none=True) == expected - - -def test_error_response_unknown_fields(): - payload = {"message": "invalid", "future": None, "detail": {"displayName": "child", "future": {"flag": True}}} - transport = httpx.MockTransport(lambda request: httpx.Response(400, json=payload)) - with httpx.Client(transport=transport) as client: - with SDK(server_url=HTTPBIN_URL, client=client) as sdk: - with pytest.raises(errors.SDKError) as caught: - sdk.echo_unknown_fields(request={"label": "root"}) - data = getattr(caught.value, "data") - dumped = data.model_dump(mode="json", by_alias=True) - assert ("future" in dumped) is PRESERVE_UNKNOWN_FIELDS - assert ("future" in dumped["detail"]) is PRESERVE_UNKNOWN_FIELDS - - -def test_omitted_json_query_stays_empty(): - with SDK(server_url=HTTPBIN_URL) as sdk: - response = sdk.echo_optional_json_query() - assert response.http_meta.response.request.url.params["filter"] == "" - - -@pytest.mark.parametrize("typ", [Optional[int], Optional[List[int]], Optional[models.FieldNode], Any]) -def test_top_level_none_serialization(typ): - assert utils.marshal_json(None, typ) == "" - - -def test_top_level_none_still_validates(): - with pytest.raises(pydantic.ValidationError): - utils.marshal_json(None, models.FieldNode) - assert utils.marshal_json(None, Nullable[models.FieldNode]) == "null" - - -def test_coerced_top_level_none_stays_empty(): - typ = Annotated[Optional[int], pydantic.BeforeValidator(lambda value: None)] - assert utils.marshal_json("empty", typ) == "" - - -@pytest.mark.parametrize("typ, value", [(UnrecognizedStr, "future"), (UnrecognizedInt, 3)]) -def test_unrecognized_enum_serialization(typ, value): - adapter = pydantic.TypeAdapter(typ) - enum_value = adapter.validate_python(value) - assert isinstance(enum_value, typ) - with warnings.catch_warnings(): - warnings.simplefilter("error") - assert adapter.dump_python(enum_value) is enum_value - assert adapter.dump_python(enum_value, mode="json") == value - assert json.loads(adapter.dump_json(enum_value)) == value - with pytest.raises(pydantic.ValidationError): - adapter.validate_python(value, strict=True) - - -@pytest.mark.parametrize("options", [{}, {"use_api_names": False}, {"exclude_unset": False}, {"exclude_defaults": True}, {"exclude_none": True}, {"warnings": False}]) -def test_model_serialization_methods(options): - model = models.FieldEnvelopeInput.model_validate({"label": "root", "child": {"displayName": "child", "future": None}, "futureKind": "third", "futureCode": 3}) - if not MODEL_SERIALIZATION_METHODS: - assert not hasattr(model, "to_dict") - assert not hasattr(model, "to_json") - return - dump_options = {"by_alias": True, "exclude_unset": True, **options} - if "use_api_names" in dump_options: - dump_options["by_alias"] = dump_options.pop("use_api_names") - assert model.to_dict(**options) == model.model_dump(**dump_options) - assert model.to_dict(mode="json", **options) == model.model_dump(mode="json", **dump_options) - assert model.to_json(indent=None, **options) == model.model_dump_json(indent=None, **dump_options) - assert json.loads(model.to_json(**options)) == model.to_dict(mode="json", **options) - - -def test_model_serialization_methods_field_names(): - model = models.FieldEnvelopeInput.model_validate({"label": "root", "child": {"displayName": "child", "child": {"displayName": "grandchild"}}, "futureKind": "third", "plainChoice": {"text": "t"}, "future": True}) - extra = {"future": True} if PRESERVE_UNKNOWN_FIELDS else {} - api_names = {"label": "root", "child": {"displayName": "child", "child": {"displayName": "grandchild"}}, "futureKind": "third", "plainChoice": {"text": "t"}, **extra} - field_names = {"label": "root", "child": {"display_name": "child", "child": {"display_name": "grandchild"}}, "future_kind": "third", "plain_choice": {"text": "t"}, **extra} - assert model.model_dump(exclude_unset=True) == api_names - if not MODEL_SERIALIZATION_METHODS: - return - assert model.to_dict() == api_names - assert model.to_dict(use_api_names=False) == field_names - assert json.loads(model.to_json(use_api_names=False)) == field_names - assert models.FieldEnvelopeInput.model_validate(model.to_dict(use_api_names=False)).to_dict() == api_names - - -def test_helper_names_reserved_only_on_models(): - expected_fields = {"to_dict_", "to_json_"} if MODEL_SERIALIZATION_METHODS else {"to_dict", "to_json"} - assert set(models.MethodNames.model_fields) == expected_fields - with SDK(server_url=HTTPBIN_URL) as sdk: - response = sdk.to_json.echo_grouped_fields() - assert response.http_meta.response.status_code == 200 - - -def test_model_serialization_methods_keep_consts(): - model = models.FieldEnvelopeInput(label="root", choice=models.TextChoice(text="t"), constant=models.ConstFields()) - expected = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed"}} - wire = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed", "priority": 1}} - assert json.loads(utils.marshal_json(model, models.FieldEnvelopeInput)) == wire - if not MODEL_SERIALIZATION_METHODS: - return - assert model.to_dict() == expected - assert model.to_dict(exclude_unset=False) == wire - assert json.loads(model.to_json()) == expected - assert models.FieldEnvelopeInput.model_validate(model.to_dict()).to_dict() == expected - assert models.FieldEnvelopeInput.model_validate(wire).to_dict() == wire - assert models.ConstFields(priority=1).to_dict() == {"kind": "fixed", "priority": 1} - assert models.ConstFields(priority=1).to_dict(exclude_defaults=True) == {} - assert models.ConstFields(priority=2).to_dict(exclude_defaults=True) == {"priority": 2} diff --git a/tests/config/quaternary/pythonv2/.speakeasy/gen.yaml b/tests/config/quaternary/pythonv2/.speakeasy/gen.yaml index a61edf83b..b8c4abd36 100644 --- a/tests/config/quaternary/pythonv2/.speakeasy/gen.yaml +++ b/tests/config/quaternary/pythonv2/.speakeasy/gen.yaml @@ -35,6 +35,8 @@ python: responseFormat: envelope responseSchemaValidation: lenient flattenRequests: true + preserveUnknownFields: true + modelSerializationMethods: true pytestFilterWarnings: - error::RuntimeWarning # Ensure __del__ method warnings are raised diff --git a/tests/config/secondary/pythonv2/.speakeasy/gen.yaml b/tests/config/secondary/pythonv2/.speakeasy/gen.yaml index 97ddd5bd2..fa854c67a 100644 --- a/tests/config/secondary/pythonv2/.speakeasy/gen.yaml +++ b/tests/config/secondary/pythonv2/.speakeasy/gen.yaml @@ -38,6 +38,7 @@ python: webhooks: models/webhooks maxMethodParams: 5 flattenRequests: false + preserveUnknownFields: true packageName: openapi rawResponseHelpers: true responseFormat: flat diff --git a/tests/config/tertiary/pythonv2/.speakeasy/gen.yaml b/tests/config/tertiary/pythonv2/.speakeasy/gen.yaml index ffe89c4a4..41aa32644 100644 --- a/tests/config/tertiary/pythonv2/.speakeasy/gen.yaml +++ b/tests/config/tertiary/pythonv2/.speakeasy/gen.yaml @@ -32,6 +32,7 @@ python: relative: true maxMethodParams: 0 flattenRequests: true + modelSerializationMethods: true packageName: openapi responseFormat: envelope inputTypedDictSuffix: Param diff --git a/tests/config/unknown-fields-default/pythonv2/.speakeasy/gen.yaml b/tests/config/unknown-fields-default/pythonv2/.speakeasy/gen.yaml deleted file mode 100644 index e9adf4d18..000000000 --- a/tests/config/unknown-fields-default/pythonv2/.speakeasy/gen.yaml +++ /dev/null @@ -1,16 +0,0 @@ -configVersion: 2.0.0 -generation: - sdkClassName: SDK - tests: - generateTests: true - generateNewTests: false -python: - packageName: openapi - version: 0.0.1 - templateVersion: v2 - packageManager: uv - preserveUnknownFields: false - forwardCompatibleUnionsByDefault: tagged-only - inferUnionDiscriminators: true - flattenRequests: false - responseFormat: envelope-http diff --git a/tests/config/unknown-fields-methods-only/pythonv2/.speakeasy/gen.yaml b/tests/config/unknown-fields-methods-only/pythonv2/.speakeasy/gen.yaml deleted file mode 100644 index 369e64ef1..000000000 --- a/tests/config/unknown-fields-methods-only/pythonv2/.speakeasy/gen.yaml +++ /dev/null @@ -1,17 +0,0 @@ -configVersion: 2.0.0 -generation: - sdkClassName: SDK - tests: - generateTests: true - generateNewTests: false -python: - packageName: openapi - version: 0.0.1 - templateVersion: v2 - packageManager: uv - preserveUnknownFields: false - modelSerializationMethods: true - forwardCompatibleUnionsByDefault: tagged-only - inferUnionDiscriminators: true - flattenRequests: false - responseFormat: envelope-http diff --git a/tests/config/unknown-fields-only/pythonv2/.speakeasy/gen.yaml b/tests/config/unknown-fields-only/pythonv2/.speakeasy/gen.yaml deleted file mode 100644 index 3bbed3ca0..000000000 --- a/tests/config/unknown-fields-only/pythonv2/.speakeasy/gen.yaml +++ /dev/null @@ -1,17 +0,0 @@ -configVersion: 2.0.0 -generation: - sdkClassName: SDK - tests: - generateTests: true - generateNewTests: false -python: - packageName: openapi - version: 0.0.1 - templateVersion: v2 - packageManager: uv - preserveUnknownFields: true - modelSerializationMethods: false - forwardCompatibleUnionsByDefault: tagged-only - inferUnionDiscriminators: true - flattenRequests: false - responseFormat: envelope-http diff --git a/tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml b/tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml deleted file mode 100644 index 4bd8b8453..000000000 --- a/tests/config/unknown-fields/pythonv2/.speakeasy/gen.yaml +++ /dev/null @@ -1,17 +0,0 @@ -configVersion: 2.0.0 -generation: - sdkClassName: SDK - tests: - generateTests: true - generateNewTests: false -python: - packageName: openapi - version: 0.0.1 - templateVersion: v2 - packageManager: uv - preserveUnknownFields: true - modelSerializationMethods: true - forwardCompatibleUnionsByDefault: tagged-only - inferUnionDiscriminators: true - flattenRequests: false - responseFormat: envelope-http diff --git a/tests/specs/unknown-fields.yaml b/tests/specs/fragments/uber/unknown-fields.yaml similarity index 81% rename from tests/specs/unknown-fields.yaml rename to tests/specs/fragments/uber/unknown-fields.yaml index 9a2a92eca..5d27b7868 100644 --- a/tests/specs/unknown-fields.yaml +++ b/tests/specs/fragments/uber/unknown-fields.yaml @@ -1,9 +1,12 @@ openapi: 3.1.0 -info: - title: Unknown fields - version: 1.0.0 -servers: - - url: http://localhost:35123 +security: + - apiKeyAuth: [] + - oauth2: [] + - clientCredentials: [read, write] + - customSchemeAppId: [] + - basicHttp: [] + - accessToken: [] + - {} paths: /anything/unknown-fields-group: get: @@ -31,6 +34,7 @@ paths: content: application/json: schema: + title: UnknownFieldsQueryFilter type: object properties: label: @@ -48,6 +52,7 @@ paths: /anything/unknown-fields: post: operationId: echoUnknownFields + x-speakeasy-exclude-test-sdk: [javav2] description: Echoes nested objects to exercise preservation of undeclared fields in requests and responses. requestBody: required: true @@ -78,6 +83,27 @@ paths: type: string detail: $ref: '#/components/schemas/FieldNode' + /anything/unknown-fields-flattened: + post: + operationId: echoFlattenedUnknownFields + description: Echoes a body small enough to be flattened into operation arguments, so undeclared fields can only travel in nested objects. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/FlattenedFields' + responses: + '200': + description: The echoed JSON request. + content: + application/json: + schema: + type: object + required: [json] + properties: + json: + $ref: '#/components/schemas/FlattenedFields' components: schemas: FieldEnvelope: @@ -161,8 +187,7 @@ components: required: [kind, text] properties: kind: - type: string - enum: [text] + $ref: '#/components/schemas/TextChoiceKind' text: type: string NumberChoice: @@ -170,10 +195,15 @@ components: required: [kind, number] properties: kind: - type: string - enum: [number] + $ref: '#/components/schemas/NumberChoiceKind' number: type: integer + TextChoiceKind: + type: string + enum: [text] + NumberChoiceKind: + type: string + enum: [number] OpenFields: type: object additionalProperties: true @@ -242,3 +272,12 @@ components: priority: type: integer default: 1 + FlattenedFields: + type: object + description: A body with few enough properties to be flattened into operation arguments. + required: [label] + properties: + label: + type: string + child: + $ref: '#/components/schemas/FieldNode' diff --git a/tests/tests/unknown-fields-default/.gitkeep b/tests/tests/unknown-fields-default/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/tests/unknown-fields-methods-only/.gitkeep b/tests/tests/unknown-fields-methods-only/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/tests/unknown-fields-only/.gitkeep b/tests/tests/unknown-fields-only/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/tests/unknown-fields/.gitkeep b/tests/tests/unknown-fields/.gitkeep deleted file mode 100644 index e69de29bb..000000000 From 769f3188b67bd569d307cad61a5a18a8de379aee Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 20:57:39 -0400 Subject: [PATCH 17/21] test(python): cover every unknown-field option combination Share the unknown-field cases between variants, each paired with the fields left when unknown fields are dropped: - primary (both options off) checks unknown fields are dropped from models and request bodies. - secondary (preserveUnknownFields) echoes them as before. - tertiary (modelSerializationMethods) checks to_dict and to_json drop unknown fields, keep consts, reserve helper names only on models and honour exclude_none. - quaternary (both) round trips every case through to_dict and to_json, compares them with literal expected values instead of model_dump with the same options, sends flattened requests asynchronously, and covers unknown fields named like an excluded field and additional properties in the assertion helper. The serialization-helper group is excluded from ruby, where its accessor would shadow Object#to_json. --- templates/perms.go | 3 + .../tests/common/unknown_fields_cases.py | 28 ++++++ .../primary/test_unknown_fields_additional.py | 23 +++++ .../test_unknown_fields_additional.py | 83 +++++++++-------- .../test_unknown_fields_additional.py | 32 +------ .../test_unknown_fields_additional.py | 88 +++++++++++++++++++ .../specs/fragments/uber/unknown-fields.yaml | 3 +- 7 files changed, 195 insertions(+), 65 deletions(-) create mode 100644 templates/templates/pythonv2/tests/common/unknown_fields_cases.py create mode 100644 templates/templates/pythonv2/tests/primary/test_unknown_fields_additional.py create mode 100644 templates/templates/pythonv2/tests/tertiary/test_unknown_fields_additional.py diff --git a/templates/perms.go b/templates/perms.go index 8aa50d906..15d73e517 100644 --- a/templates/perms.go +++ b/templates/perms.go @@ -1669,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, @@ -1703,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, @@ -1735,6 +1737,7 @@ var fileMode = map[string]fs.FileMode{ "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, diff --git a/templates/templates/pythonv2/tests/common/unknown_fields_cases.py b/templates/templates/pythonv2/tests/common/unknown_fields_cases.py new file mode 100644 index 000000000..5c8dc447b --- /dev/null +++ b/templates/templates/pythonv2/tests/common/unknown_fields_cases.py @@ -0,0 +1,28 @@ +UNKNOWN_FIELD_CASES = [ + ({"futureKind": "third", "futureCode": 3}, {"futureKind": "third", "futureCode": 3}), + ({"methodNames": {"toDict": "dictionary", "toJson": "json"}}, {"methodNames": {"toDict": "dictionary", "toJson": "json"}}), + ({"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"}}), + ({"arbitrary": {"future": {"nullable": None}}}, {"arbitrary": {"future": {"nullable": None}}}), + ({"child": {"displayName": "child", "nullable": None}}, {"child": {"displayName": "child", "nullable": None}}), +] diff --git a/templates/templates/pythonv2/tests/primary/test_unknown_fields_additional.py b/templates/templates/pythonv2/tests/primary/test_unknown_fields_additional.py new file mode 100644 index 000000000..a87a602a5 --- /dev/null +++ b/templates/templates/pythonv2/tests/primary/test_unknown_fields_additional.py @@ -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}) + 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"}}} diff --git a/templates/templates/pythonv2/tests/quaternary/test_unknown_fields_additional.py b/templates/templates/pythonv2/tests/quaternary/test_unknown_fields_additional.py index cbbe9e37c..4420da343 100644 --- a/templates/templates/pythonv2/tests/quaternary/test_unknown_fields_additional.py +++ b/templates/templates/pythonv2/tests/quaternary/test_unknown_fields_additional.py @@ -4,10 +4,12 @@ import pydantic import pytest -from openapi import SDK, utils +from openapi import SDK, AsyncSDK, utils from openapi.models import shared from openapi.types import UnrecognizedInt, UnrecognizedStr from .common_helpers import HTTPBIN_URL, record_test +from .unknown_fields import drop_unexpected_fields +from .unknown_fields_cases import UNKNOWN_FIELD_CASES @pytest.mark.parametrize("child", [ @@ -26,6 +28,17 @@ def test_flattened_request_keeps_nested_unknown_fields(child): assert json.loads(response.object.json_.to_json()) == payload +@pytest.mark.asyncio() +async def test_flattened_request_keeps_nested_unknown_fields_async(): + child = {"displayName": "child", "child": {"displayName": "grandchild", "future": {"nested": [None, 0]}}} + async with AsyncSDK(server_url=HTTPBIN_URL) as sdk: + response = await sdk.echo_unknown_fields(label="root", child=child) + payload = {"label": "root", "child": child} + assert response.raw_response.json()["json"] == payload + assert response.object.json_.to_dict() == payload + assert json.loads(response.object.json_.to_json()) == payload + + def test_flattened_request_keeps_unknown_fields_on_nested_models(): child = shared.FieldNode(display_name="child", future=None) with SDK(server_url=HTTPBIN_URL) as sdk: @@ -39,16 +52,40 @@ def test_flattened_request_has_no_argument_for_top_level_unknown_fields(): sdk.echo_flattened_unknown_fields(label="root", future=True) # type: ignore[call-arg] -@pytest.mark.parametrize("options", [{}, {"use_api_names": False}, {"exclude_unset": False}, {"exclude_defaults": True}, {"exclude_none": True}, {"warnings": False}]) -def test_model_serialization_methods(options): - model = shared.FieldEnvelopeInput.model_validate({"label": "root", "child": {"displayName": "child", "future": None}, "futureKind": "third", "futureCode": 3}) - dump_options = {"by_alias": True, "exclude_unset": True, **options} - if "use_api_names" in dump_options: - dump_options["by_alias"] = dump_options.pop("use_api_names") - assert model.to_dict(**options) == model.model_dump(**dump_options) - assert model.to_dict(mode="json", **options) == model.model_dump(mode="json", **dump_options) - assert model.to_json(indent=None, **options) == model.model_dump_json(indent=None, **dump_options) - assert json.loads(model.to_json(**options)) == model.to_dict(mode="json", **options) +@pytest.mark.parametrize("fields", [fields for fields, _ in UNKNOWN_FIELD_CASES]) +def test_to_dict_round_trips_unknown_fields(fields): + payload = {"label": "root", **fields} + model = shared.FieldEnvelopeInput.model_validate(payload) + assert model.to_dict(mode="json") == payload + assert json.loads(model.to_json()) == payload + assert shared.FieldEnvelopeInput.model_validate(model.to_dict()).to_dict(mode="json") == payload + + +@pytest.mark.parametrize("options, expected", [ + ({}, {"displayName": "child", "nullable": None, "future": None}), + ({"use_api_names": False}, {"display_name": "child", "nullable": None, "future": None}), + ({"exclude_unset": False}, {"displayName": "child", "nullable": None, "future": None}), + ({"exclude_defaults": True}, {"displayName": "child", "nullable": None, "future": None}), + ({"exclude_none": True}, {"displayName": "child"}), +]) +def test_model_serialization_methods(options, expected): + model = shared.FieldNode.model_validate({"displayName": "child", "nullable": None, "future": None}) + assert model.to_dict(**options) == expected + assert model.to_dict(mode="json", **options) == expected + assert json.loads(model.to_json(**options)) == expected + + +@pytest.mark.parametrize("options", [{"exclude_none": True}, {"exclude_defaults": True}]) +def test_excluded_field_keeps_unknown_field_named_like_it(options): + model = shared.FieldEnvelopeInput.model_validate({"label": "root", "plainChoice": None, "plain_choice": "unknown"}) + assert model.to_dict(**options) == {"label": "root", "plain_choice": "unknown"} + assert model.model_dump(by_alias=True, **options) == {"label": "root", "plain_choice": "unknown"} + + +def test_drop_unexpected_fields_ignores_only_undeclared_fields(): + expected = shared.TypedFields(label="typed") + assert drop_unexpected_fields(shared.TypedFields.model_validate({"label": "typed", "future": "value"}), expected) != expected + assert drop_unexpected_fields(shared.FieldNode.model_validate({"displayName": "child", "future": True}), shared.FieldNode(display_name="child")) == shared.FieldNode(display_name="child") def test_model_serialization_methods_field_names(): @@ -62,30 +99,6 @@ def test_model_serialization_methods_field_names(): assert shared.FieldEnvelopeInput.model_validate(model.to_dict(use_api_names=False)).to_dict() == api_names -def test_helper_names_reserved_only_on_models(): - assert set(shared.MethodNames.model_fields) == {"to_dict_", "to_json_"} - model = shared.MethodNames.model_validate({"toDict": "d", "toJson": "j"}) - assert model.to_dict() == {"toDict": "d", "toJson": "j"} - with SDK(server_url=HTTPBIN_URL) as sdk: - response = sdk.to_json.echo_grouped_fields() - assert response.status_code == 200 - - -def test_model_serialization_methods_keep_consts(): - model = shared.FieldEnvelopeInput(label="root", choice=shared.TextChoice(text="t"), constant=shared.ConstFields()) - expected = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed"}} - wire = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed", "priority": 1}} - assert json.loads(utils.marshal_json(model, shared.FieldEnvelopeInput)) == wire - assert model.to_dict() == expected - assert model.to_dict(exclude_unset=False) == wire - assert json.loads(model.to_json()) == expected - assert shared.FieldEnvelopeInput.model_validate(model.to_dict()).to_dict() == expected - assert shared.FieldEnvelopeInput.model_validate(wire).to_dict() == wire - assert shared.ConstFields(priority=1).to_dict() == {"kind": "fixed", "priority": 1} - assert shared.ConstFields(priority=1).to_dict(exclude_defaults=True) == {} - assert shared.ConstFields(priority=2).to_dict(exclude_defaults=True) == {"priority": 2} - - @pytest.mark.parametrize("typ, value", [(UnrecognizedStr, "future"), (UnrecognizedInt, 3)]) def test_unrecognized_enum_serialization(typ, value): adapter = pydantic.TypeAdapter(typ) diff --git a/templates/templates/pythonv2/tests/secondary/test_unknown_fields_additional.py b/templates/templates/pythonv2/tests/secondary/test_unknown_fields_additional.py index 97fcfd0d4..6aa3af1e5 100644 --- a/templates/templates/pythonv2/tests/secondary/test_unknown_fields_additional.py +++ b/templates/templates/pythonv2/tests/secondary/test_unknown_fields_additional.py @@ -9,36 +9,10 @@ from openapi.models import errors, shared from openapi.types import UNSET, BaseModel, Nullable from .common_helpers import HTTPBIN_URL, record_test +from .unknown_fields_cases import UNKNOWN_FIELD_CASES -CASES = [ - {"futureKind": "third", "futureCode": 3}, - {"methodNames": {"toDict": "dictionary", "toJson": "json"}}, - {"future": "value"}, - {"future": False}, - {"empty": {"future": True}}, - {"future": None}, - {"plainChoice": {"text": "value", "future": True}}, - {"plainChoice": {"number": 3, "future": True}}, - {"requiredOnly": {"label": "required", "future": True}}, - {"future": 0}, - {"composed": {"displayName": "child", "category": "combined", "future": True}}, - {"split": {"label": "split", "future": True}}, - {"future": []}, - {"future": {"nested": [None, False, 0, "value"]}}, - {"child": {"displayName": "child", "future": True}}, - {"child": {"displayName": "child", "display_name": "unknown"}}, - {"child": {"displayName": "child", "child": {"displayName": "grandchild", "future": True}}}, - {"children": [{"displayName": "child", "future": True}]}, - {"lookup": {"one": {"displayName": "child", "future": True}}}, - {"choice": {"kind": "text", "text": "value", "future": True}}, - {"choice": {"kind": "number", "number": 3, "future": True}}, - {"closed": {"label": "closed", "future": True}}, - {"open": {"label": "open", "future": True}}, - {"typed": {"label": "typed", "future": "value"}}, - {"arbitrary": {"future": {"nullable": None}}}, - {"child": {"displayName": "child", "nullable": None}}, -] +CASES = [fields for fields, _ in UNKNOWN_FIELD_CASES] def echo(request): @@ -122,7 +96,7 @@ class CustomModel(BaseModel): assert "future" in model.model_dump() -def test_unset_ignores_unknown_fields(): +def test_invalid_nullable_value_becomes_unset_like_default_policy(): node = shared.FieldNode.model_validate({"displayName": "child", "nullable": {"future": True}}) assert node.nullable == UNSET diff --git a/templates/templates/pythonv2/tests/tertiary/test_unknown_fields_additional.py b/templates/templates/pythonv2/tests/tertiary/test_unknown_fields_additional.py new file mode 100644 index 000000000..a81b034ab --- /dev/null +++ b/templates/templates/pythonv2/tests/tertiary/test_unknown_fields_additional.py @@ -0,0 +1,88 @@ +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_to_dict_drops_unknown_fields(fields, known_fields): + model = shared.FieldEnvelopeInput.model_validate({"label": "root", **fields}) + expected = {"label": "root", **known_fields} + assert model.to_dict(mode="json") == expected + assert json.loads(model.to_json()) == expected + assert shared.FieldEnvelopeInput.model_validate(model.to_dict()).to_dict(mode="json") == expected + + +def test_flattened_request_drops_nested_unknown_fields(): + with SDK(server_url=HTTPBIN_URL) as sdk: + response = sdk.echo_flattened_unknown_fields(label="root", child={"displayName": "child", "future": True}) + assert response.raw_response.json()["json"] == {"label": "root", "child": {"displayName": "child"}} + assert response.object.json_.to_dict() == {"label": "root", "child": {"displayName": "child"}} + assert "headers" not in response.object.to_dict() + + +@pytest.mark.parametrize("options, expected", [ + ({}, {"displayName": "child", "nullable": None}), + ({"use_api_names": False}, {"display_name": "child", "nullable": None}), + ({"exclude_unset": False}, {"displayName": "child", "nullable": None}), + ({"exclude_defaults": True}, {"displayName": "child", "nullable": None}), + ({"exclude_none": True}, {"displayName": "child"}), +]) +def test_model_serialization_methods(options, expected): + model = shared.FieldNode.model_validate({"displayName": "child", "nullable": None}) + assert model.to_dict(**options) == expected + assert model.to_dict(mode="json", **options) == expected + assert json.loads(model.to_json(**options)) == expected + + +def test_exclude_none_keeps_explicit_nulls_on_the_wire(): + model = shared.FieldNode(display_name="child", nullable=None) + assert model.to_dict(exclude_none=True) == {"displayName": "child"} + assert json.loads(utils.marshal_json(model, shared.FieldNode)) == {"displayName": "child", "nullable": None} + + +def test_model_serialization_methods_field_names(): + model = shared.FieldEnvelopeInput.model_validate({"label": "root", "child": {"displayName": "child"}, "futureKind": "third", "plainChoice": {"text": "t"}}) + api_names = {"label": "root", "child": {"displayName": "child"}, "futureKind": "third", "plainChoice": {"text": "t"}} + field_names = {"label": "root", "child": {"display_name": "child"}, "future_kind": "third", "plain_choice": {"text": "t"}} + assert model.to_dict() == api_names + assert model.to_dict(use_api_names=False) == field_names + assert json.loads(model.to_json(use_api_names=False)) == field_names + assert shared.FieldEnvelopeInput.model_validate(model.to_dict(use_api_names=False)).to_dict() == api_names + + +def test_helper_names_reserved_only_on_models(): + assert set(shared.MethodNames.model_fields) == {"to_dict_", "to_json_"} + model = shared.MethodNames.model_validate({"toDict": "d", "toJson": "j"}) + assert model.to_dict() == {"toDict": "d", "toJson": "j"} + with SDK(server_url=HTTPBIN_URL) as sdk: + response = sdk.to_json.echo_grouped_fields() + assert response.status_code == 200 + + +def test_model_serialization_methods_keep_consts(): + model = shared.FieldEnvelopeInput(label="root", choice=shared.TextChoice(text="t"), constant=shared.ConstFields()) + expected = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed"}} + wire = {"label": "root", "choice": {"kind": "text", "text": "t"}, "constant": {"kind": "fixed", "priority": 1}} + assert json.loads(utils.marshal_json(model, shared.FieldEnvelopeInput)) == wire + assert model.to_dict() == expected + assert model.to_dict(exclude_unset=False) == wire + assert json.loads(model.to_json()) == expected + assert shared.FieldEnvelopeInput.model_validate(model.to_dict()).to_dict() == expected + assert shared.FieldEnvelopeInput.model_validate(wire).to_dict() == wire + assert shared.ConstFields(priority=1).to_dict() == {"kind": "fixed", "priority": 1} + assert shared.ConstFields(priority=1).to_dict(exclude_defaults=True) == {} + assert shared.ConstFields(priority=2).to_dict(exclude_defaults=True) == {"priority": 2} + + +def test_optional_null_consts_keep_wire_output(): + model = shared.ObjectWithNullEnums() + assert json.loads(utils.marshal_json(model, shared.ObjectWithNullEnums)) == {"nullEnum": None} + assert model.to_dict() == {"nullEnum": None} + explicit = shared.ObjectWithNullEnums(opt_null_enum=None) + assert json.loads(utils.marshal_json(explicit, shared.ObjectWithNullEnums)) == {"nullEnum": None, "optNullEnum": None} + assert explicit.to_dict() == {"nullEnum": None, "optNullEnum": None} diff --git a/tests/specs/fragments/uber/unknown-fields.yaml b/tests/specs/fragments/uber/unknown-fields.yaml index 5d27b7868..b7f39f586 100644 --- a/tests/specs/fragments/uber/unknown-fields.yaml +++ b/tests/specs/fragments/uber/unknown-fields.yaml @@ -12,7 +12,8 @@ paths: get: operationId: echoGroupedFields x-speakeasy-group: toJson - description: Belongs to a group whose name matches a model serialization helper, which must not rename the group accessor. + x-speakeasy-exclude-test-sdk: [ruby] + description: Belongs to a group named like a common model serialization method, so the group accessor must keep its name when models reserve that method name. responses: '200': description: The echoed request URL. From bc57c9015b8aaafc7dd1e6e22d7c676888db97d7 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 21:49:44 -0400 Subject: [PATCH 18/21] fix(python): leave excluded fields out of to_dict and to_json The model serializer put back every field the dump had excluded. As a result, to_dict() returned unset nullable fields whose default is null, although it leaves every other unset defaulted field out, and dumps with exclude_unset, include or exclude wrote None for the fields they excluded. Only request serialization needs fields put back: it dumps with exclude_none and still sends explicit nulls on nullable fields. When modelSerializationMethods is enabled, excluded fields now stay out of every dump except exclude_none dumps outside to_dict and to_json. --- templates/templates/pythonv2/model.py.stmpl | 4 ++-- .../tests/tertiary/test_unknown_fields_additional.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/templates/templates/pythonv2/model.py.stmpl b/templates/templates/pythonv2/model.py.stmpl index f4523ad36..4d0dacc04 100644 --- a/templates/templates/pythonv2/model.py.stmpl +++ b/templates/templates/pythonv2/model.py.stmpl @@ -106,7 +106,7 @@ class {{$className}}(BaseModel): {{- if $modelSerializationMethods}} {{- addImport "types.basemodel" "SERIALIZATION_METHODS_DUMP" true}} use_field_names = info.by_alias is False - exclude_none = info.exclude_none and isinstance(info.context, dict) and SERIALIZATION_METHODS_DUMP in info.context + serialization_methods_dump = isinstance(info.context, dict) and SERIALIZATION_METHODS_DUMP in info.context {{- end}} for n, f in type(self).model_fields.items(): @@ -115,7 +115,7 @@ class {{$className}}(BaseModel): key = k if info.by_alias else n {{- end}} {{- if $modelSerializationMethods}} - if key not in serialized and (info.exclude_defaults or exclude_none): + if key not in serialized and (serialization_methods_dump or not info.exclude_none): continue {{- end}} {{- if $serializerUsesInfo}} diff --git a/templates/templates/pythonv2/tests/tertiary/test_unknown_fields_additional.py b/templates/templates/pythonv2/tests/tertiary/test_unknown_fields_additional.py index a81b034ab..4f0bf4abf 100644 --- a/templates/templates/pythonv2/tests/tertiary/test_unknown_fields_additional.py +++ b/templates/templates/pythonv2/tests/tertiary/test_unknown_fields_additional.py @@ -45,6 +45,17 @@ def test_exclude_none_keeps_explicit_nulls_on_the_wire(): assert json.loads(utils.marshal_json(model, shared.FieldNode)) == {"displayName": "child", "nullable": None} +def test_excluded_fields_stay_out_of_serialization_methods(): + model = shared.DefaultsAndConsts(normal_field="x") + assert "defaultStrNullable" not in model.to_dict() + assert model.to_dict(exclude_unset=False)["defaultStrNullable"] is None + assert shared.DefaultsAndConsts(normal_field="x", default_str_nullable=None).to_dict()["defaultStrNullable"] is None + assert json.loads(utils.marshal_json(model, shared.DefaultsAndConsts))["defaultStrNullable"] is None + node = shared.FieldNode(display_name="child", nullable=None) + assert node.model_dump(by_alias=True, exclude={"display_name"}) == {"nullable": None} + assert node.model_dump(by_alias=True, exclude_none=True) == {"displayName": "child", "nullable": None} + + def test_model_serialization_methods_field_names(): model = shared.FieldEnvelopeInput.model_validate({"label": "root", "child": {"displayName": "child"}, "futureKind": "third", "plainChoice": {"text": "t"}}) api_names = {"label": "root", "child": {"displayName": "child"}, "futureKind": "third", "plainChoice": {"text": "t"}} From 537c6c372f589a70484d2fcc651cc2c4aef13341 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 22:25:07 -0400 Subject: [PATCH 19/21] fix(python): keep numbers and booleans beside open enums in unions The JSON serializer of UnrecognizedStr and UnrecognizedInt accepted any value. While serializing a union, Pydantic can try it for values that validated as another member, so an open enum followed by a number or boolean branch sent 1.5 as 1 and true as 1. This applied to every SDK with such a union, with or without the new model options. Each serializer now declines values that are not its own type, so the union serializes them with the matching member. Unrecognized enum values are still returned as UnrecognizedStr and UnrecognizedInt in Python mode and as plain strings and integers in JSON. The test fragment declares its enums as named schemas. Inline, the integer enum generated a type named One in other targets, replacing the existing One model. --- .../types/basemodel.py.stmpl | 22 ++++++---- .../test_open_enum_union_discrimination.py | 29 +++++++++++++- .../primary/open-enum-scalar-unions.yaml | 40 +++++++++++++++++++ zSDKs/sdk-pythonv2/.speakeasy/gen.lock | 2 +- .../speakeasy/new_openapi/types/basemodel.py | 30 +++++++------- 5 files changed, 99 insertions(+), 24 deletions(-) create mode 100644 tests/specs/fragments/primary/open-enum-scalar-unions.yaml diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl index 48976f0ad..6fd3a4868 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py.stmpl @@ -1,7 +1,7 @@ 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 @@ -83,10 +83,16 @@ else: ) -def _serialize_unrecognized(value: Union[str, int], info: core_schema.SerializationInfo) -> Union[str, int]: - if info.mode == "python": - return value - return str(value) if isinstance(value, str) else int(value) +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): @@ -108,7 +114,7 @@ class UnrecognizedStr(str): core_schema.no_info_plain_validator_function(validate_lax) ]), strict_schema=core_schema.none_schema(), # Always fails in strict mode - serialization=core_schema.plain_serializer_function_ser_schema(_serialize_unrecognized, info_arg=True), + serialization=_unrecognized_serialization(cls), ) @@ -127,5 +133,5 @@ class UnrecognizedInt(int): core_schema.no_info_plain_validator_function(validate_lax) ]), strict_schema=core_schema.none_schema(), # Always fails in strict mode - serialization=core_schema.plain_serializer_function_ser_schema(_serialize_unrecognized, info_arg=True), + serialization=_unrecognized_serialization(cls), ) diff --git a/templates/templates/pythonv2/tests/primary/test_open_enum_union_discrimination.py b/templates/templates/pythonv2/tests/primary/test_open_enum_union_discrimination.py index 202c7ee36..405a2dbff 100644 --- a/templates/templates/pythonv2/tests/primary/test_open_enum_union_discrimination.py +++ b/templates/templates/pythonv2/tests/primary/test_open_enum_union_discrimination.py @@ -7,12 +7,14 @@ - Smart union mode correctly discriminates between union members based on field matches """ +import warnings + import pytest from typing import Union, Literal, Optional from pydantic import BaseModel, ValidationError, TypeAdapter, ConfigDict from .common_helpers import record_test -from openapi import SDK +from openapi import SDK, utils from openapi.models import shared, operations from openapi.types import UnrecognizedStr, UnrecognizedInt @@ -1368,3 +1370,28 @@ def test_unions_discriminated_open_enum(self): assert res.res is not None assert isinstance(res.res.json_, shared.ObjectWithOpenEnumStatus2) assert res.res.json_.reason == "User requested deactivation" + + +class TestOpenEnumScalarUnionSerialization: + @pytest.mark.parametrize("payload, wire", [ + ({"stringOrNumber": 1.5}, '{"stringOrNumber":1.5}'), + ({"stringOrNumber": "future"}, '{"stringOrNumber":"future"}'), + ({"stringOrNumber": "known"}, '{"stringOrNumber":"known"}'), + ({"stringOrBoolean": True}, '{"stringOrBoolean":true}'), + ({"stringOrBoolean": "future"}, '{"stringOrBoolean":"future"}'), + ({"integerOrNumber": 2.5}, '{"integerOrNumber":2.5}'), + ({"integerOrNumber": 1}, '{"integerOrNumber":1}'), + ]) + def test_scalar_member_serialization(self, payload, wire): + model = shared.OpenEnumScalarUnions.model_validate(payload) + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert utils.marshal_json(model, shared.OpenEnumScalarUnions) == wire + assert model.model_dump_json(by_alias=True, exclude_none=True) == wire + assert model.model_dump(by_alias=True, exclude_none=True) == payload + + def test_unrecognized_values_keep_their_type_in_python_mode(self): + model = shared.OpenEnumScalarUnions.model_validate({"stringOrNumber": "future", "stringOrBoolean": "future"}) + dumped = model.model_dump(by_alias=True, exclude_none=True) + assert type(dumped["stringOrNumber"]) is UnrecognizedStr + assert type(dumped["stringOrBoolean"]) is UnrecognizedStr diff --git a/tests/specs/fragments/primary/open-enum-scalar-unions.yaml b/tests/specs/fragments/primary/open-enum-scalar-unions.yaml new file mode 100644 index 000000000..15a8e39db --- /dev/null +++ b/tests/specs/fragments/primary/open-enum-scalar-unions.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +security: + - apiKeyAuth: [] + - oauth2: [] + - clientCredentials: [read, write] + - customSchemeAppId: [] + - basicHttp: [] + - accessToken: [] + - {} +components: + schemas: + OpenEnumScalarUnions: + x-speakeasy-include: true + type: object + description: >- + Properties whose union combines an open enum with a number or boolean. + A number or boolean held in such a union must keep its value and JSON + type when serialized, rather than being converted as if it were an + unrecognized enum value. + properties: + stringOrNumber: + anyOf: + - $ref: '#/components/schemas/OpenEnumScalarString' + - type: number + stringOrBoolean: + anyOf: + - $ref: '#/components/schemas/OpenEnumScalarString' + - type: boolean + integerOrNumber: + anyOf: + - $ref: '#/components/schemas/OpenEnumScalarInteger' + - type: number + OpenEnumScalarString: + type: string + enum: [known] + x-speakeasy-unknown-values: allow + OpenEnumScalarInteger: + type: integer + enum: [1, 2] + x-speakeasy-unknown-values: allow diff --git a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock index 2c351ffe5..87edb846a 100644 --- a/zSDKs/sdk-pythonv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-pythonv2/.speakeasy/gen.lock @@ -746,7 +746,7 @@ trackedFiles: src/speakeasy/new_openapi/types/base64fileinput.py: last_write_checksum: sha1:1522687ae3398374c35710cad993a6e82b5ab99d src/speakeasy/new_openapi/types/basemodel.py: - last_write_checksum: sha1:c05508a1c15002a768362b0754ae539af068a31a + last_write_checksum: sha1:4995f30bbd8f292e6a1608ee4bf5a730f0a5d14e src/speakeasy/new_openapi/types_.py: last_write_checksum: sha1:05e36f6428f04b1ea7d2609aa55f3966868b58f0 src/speakeasy/new_openapi/utils/__init__.py: diff --git a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py index 13cf4ebf8..e7c6fe749 100644 --- a/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py +++ b/zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py @@ -2,8 +2,8 @@ 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 @@ -37,12 +37,18 @@ def __bool__(self) -> Literal[False]: ) -def _serialize_unrecognized( - value: Union[str, int], info: core_schema.SerializationInfo -) -> Union[str, int]: - if info.mode == "python": - return value - return str(value) if isinstance(value, str) else int(value) +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): @@ -68,9 +74,7 @@ def validate_lax(v: Any) -> "UnrecognizedStr": ] ), strict_schema=core_schema.none_schema(), # Always fails in strict mode - serialization=core_schema.plain_serializer_function_ser_schema( - _serialize_unrecognized, info_arg=True - ), + serialization=_unrecognized_serialization(cls), ) @@ -94,7 +98,5 @@ def validate_lax(v: Any) -> "UnrecognizedInt": ] ), strict_schema=core_schema.none_schema(), # Always fails in strict mode - serialization=core_schema.plain_serializer_function_ser_schema( - _serialize_unrecognized, info_arg=True - ), + serialization=_unrecognized_serialization(cls), ) From 20d42855dce726d318fc8bf1753f295d561a401a Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 22:25:07 -0400 Subject: [PATCH 20/21] docs(python): describe preserveUnknownFields limits Flattened operation arguments only accept declared fields, so undeclared top-level request fields need flattenRequests: false. Nested models passed as arguments keep their undeclared fields. State this in the option description. Note at the model serializer that Pydantic writes extras after declared fields, so an extra under a field's emitted key replaces that field's value. The template comment is not emitted into generated code. --- templates/templates/pythonv2/config.ts | 2 +- templates/templates/pythonv2/model.py.stmpl | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/templates/pythonv2/config.ts b/templates/templates/pythonv2/config.ts index a6e565518..8582264c6 100644 --- a/templates/templates/pythonv2/config.ts +++ b/templates/templates/pythonv2/config.ts @@ -339,7 +339,7 @@ function getConfigFields( 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. Disabled by default.", + "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", }, diff --git a/templates/templates/pythonv2/model.py.stmpl b/templates/templates/pythonv2/model.py.stmpl index 4d0dacc04..ce2608017 100644 --- a/templates/templates/pythonv2/model.py.stmpl +++ b/templates/templates/pythonv2/model.py.stmpl @@ -101,6 +101,7 @@ class {{$className}}(BaseModel): {{- end}} {{- end}} {{- end}} + {{- /* Pydantic writes extras after declared fields, so an extra under a field's emitted key replaces that field's value */}} serialized = handler(self) m = {} {{- if $modelSerializationMethods}} From 2f57f92f95b525aa7e9efe9d581d78c3d0ee89c8 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Thu, 22 Oct 2026 22:40:46 -0400 Subject: [PATCH 21/21] test(typescript): skip echoUnknownFields in the tertiary variant The tertiary TypeScript configuration combines flat responses, no index modules and the legacy naming scheme. With that combination, an operation whose success and error bodies are both inline gives both the same generated name, and generation fails with a duplicate import. Remove echoUnknownFields only from the tertiary TypeScript spec, so the other TypeScript variants and every other target keep the operation. Delete this overlay once the generator names the two bodies apart. --- tests/overlays/tertiary/typescriptv2/overlay.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 tests/overlays/tertiary/typescriptv2/overlay.yaml diff --git a/tests/overlays/tertiary/typescriptv2/overlay.yaml b/tests/overlays/tertiary/typescriptv2/overlay.yaml new file mode 100644 index 000000000..fcc524d36 --- /dev/null +++ b/tests/overlays/tertiary/typescriptv2/overlay.yaml @@ -0,0 +1,9 @@ +overlay: 1.0.0 +info: + title: Overlay ./tests/specs/uber.yaml => tertiary typescriptv2 test spec + version: 0.0.0 +actions: + # Inline success and error bodies share a generated name, which collides on import + # without index modules, flat responses and the legacy naming scheme + - target: $.paths.*.*[?(@.operationId == 'echoUnknownFields')] + remove: true