From dce293e6008f5e8695ef3465b8957d7059d1dba7 Mon Sep 17 00:00:00 2001 From: 2ynn Date: Fri, 23 Oct 2026 11:49:25 -0400 Subject: [PATCH] fix(python): serialize unrecognized open enum values UnrecognizedStr and UnrecognizedInt had no serialization schema, so dumping a model holding an unknown open enum value emitted Pydantic serialization warnings. In an optional nullable open enum field, the value was serialized by the Unset member of the union and then dropped from the output, so the request body omitted it. Give both types an explicit serializer: Python-mode dumps return the original object, JSON-mode dumps return a plain str or int, and values of any other type are declined so union serialization tries the remaining members. --- .changesets/1792768199-b0e4a14b.yaml | 10 +++++ .../{{getSourcePath}}/types/basemodel.py | 18 +++++++- .../test_open_enum_union_discrimination.py | 31 ++++++++++++- .../primary/open-enum-scalar-unions.yaml | 43 +++++++++++++++++++ zSDKs/sdk-pythonv2/.speakeasy/gen.lock | 2 +- .../speakeasy/new_openapi/types/basemodel.py | 18 +++++++- 6 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 .changesets/1792768199-b0e4a14b.yaml create mode 100644 tests/specs/fragments/primary/open-enum-scalar-unions.yaml diff --git a/.changesets/1792768199-b0e4a14b.yaml b/.changesets/1792768199-b0e4a14b.yaml new file mode 100644 index 000000000..f0681da8c --- /dev/null +++ b/.changesets/1792768199-b0e4a14b.yaml @@ -0,0 +1,10 @@ +id: 1792768199-b0e4a14b +features: + - core +targets: + - pythonv2 +type: fix +bump: patch +description: serialize unrecognized open enum values without warnings and keep them in optional nullable fields +author: 2ynn +date: "2026-10-23" diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py index d4744c6eb..23751499a 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py @@ -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 @@ -35,6 +35,18 @@ def __bool__(self) -> Literal[False]: ) +def _unrecognized_serialization(cls: Type[Union[str, int]]) -> core_schema.PlainSerializerFunctionSerSchema: + def serialize(value: Any, info: core_schema.SerializationInfo) -> Any: + # Decline other values so union serialization tries the remaining members + if not isinstance(value, cls): + raise PydanticSerializationUnexpectedValue(f"Expected {cls.__name__}") + if not info.mode_is_json(): + return value + return str(value) if isinstance(value, str) else int(value) + + return core_schema.plain_serializer_function_ser_schema(serialize, info_arg=True) + + class UnrecognizedStr(str): @classmethod def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> core_schema.CoreSchema: @@ -54,6 +66,7 @@ def validate_lax(v: Any) -> 'UnrecognizedStr': core_schema.no_info_plain_validator_function(validate_lax) ]), strict_schema=core_schema.none_schema(), # Always fails in strict mode + serialization=_unrecognized_serialization(cls), ) @@ -72,4 +85,5 @@ def validate_lax(v: Any) -> 'UnrecognizedInt': core_schema.no_info_plain_validator_function(validate_lax) ]), strict_schema=core_schema.none_schema(), # Always fails in strict mode + serialization=_unrecognized_serialization(cls), ) 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..ee955b5a4 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,30 @@ 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}'), + ({"stringOrNull": "future"}, '{"stringOrNull":"future"}'), + ({"stringOrNull": "known"}, '{"stringOrNull":"known"}'), + ]) + 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..205fa9a82 --- /dev/null +++ b/tests/specs/fragments/primary/open-enum-scalar-unions.yaml @@ -0,0 +1,43 @@ +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 type combines an open enum with a number, a boolean or + null. An unrecognized enum value must serialize as its plain string or + integer, and a number or boolean must keep its value and JSON type. + 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 + stringOrNull: + anyOf: + - $ref: '#/components/schemas/OpenEnumScalarString' + - type: 'null' + 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 8d782d4eb..d81167dc3 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:10d84aedeb9d35edfdadf2c3020caa1d24d8b584 + last_write_checksum: sha1:6295cc5750e00e0323902092f7e52549937b0b9c 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 a9a640a1a..06c13bfcd 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,6 +37,18 @@ def __bool__(self) -> Literal[False]: ) +def _unrecognized_serialization(cls: Type[Union[str, int]]) -> core_schema.PlainSerializerFunctionSerSchema: + def serialize(value: Any, info: core_schema.SerializationInfo) -> Any: + # Decline other values so union serialization tries the remaining members + if not isinstance(value, cls): + raise PydanticSerializationUnexpectedValue(f"Expected {cls.__name__}") + if not info.mode_is_json(): + return value + return str(value) if isinstance(value, str) else int(value) + + return core_schema.plain_serializer_function_ser_schema(serialize, info_arg=True) + + class UnrecognizedStr(str): @classmethod def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> core_schema.CoreSchema: @@ -56,6 +68,7 @@ def validate_lax(v: Any) -> 'UnrecognizedStr': core_schema.no_info_plain_validator_function(validate_lax) ]), strict_schema=core_schema.none_schema(), # Always fails in strict mode + serialization=_unrecognized_serialization(cls), ) @@ -74,4 +87,5 @@ def validate_lax(v: Any) -> 'UnrecognizedInt': core_schema.no_info_plain_validator_function(validate_lax) ]), strict_schema=core_schema.none_schema(), # Always fails in strict mode + serialization=_unrecognized_serialization(cls), )