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