Skip to content

Commit dce293e

Browse files
committed
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.
1 parent 10066cd commit dce293e

6 files changed

Lines changed: 116 additions & 6 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
id: 1792768199-b0e4a14b
2+
features:
3+
- core
4+
targets:
5+
- pythonv2
6+
type: fix
7+
bump: patch
8+
description: serialize unrecognized open enum values without warnings and keep them in optional nullable fields
9+
author: 2ynn
10+
date: "2026-10-23"

‎templates/templates/pythonv2/auxiliary/{{getSourcePath}}/types/basemodel.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from pydantic import ConfigDict, model_serializer
22
from pydantic import BaseModel as PydanticBaseModel
3-
from pydantic_core import core_schema
4-
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union
3+
from pydantic_core import PydanticSerializationUnexpectedValue, core_schema
4+
from typing import TYPE_CHECKING, Any, Literal, Optional, Type, TypeVar, Union
55
from typing_extensions import TypeAliasType, TypeAlias
66

77

@@ -35,6 +35,18 @@ def __bool__(self) -> Literal[False]:
3535
)
3636

3737

38+
def _unrecognized_serialization(cls: Type[Union[str, int]]) -> core_schema.PlainSerializerFunctionSerSchema:
39+
def serialize(value: Any, info: core_schema.SerializationInfo) -> Any:
40+
# Decline other values so union serialization tries the remaining members
41+
if not isinstance(value, cls):
42+
raise PydanticSerializationUnexpectedValue(f"Expected {cls.__name__}")
43+
if not info.mode_is_json():
44+
return value
45+
return str(value) if isinstance(value, str) else int(value)
46+
47+
return core_schema.plain_serializer_function_ser_schema(serialize, info_arg=True)
48+
49+
3850
class UnrecognizedStr(str):
3951
@classmethod
4052
def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> core_schema.CoreSchema:
@@ -54,6 +66,7 @@ def validate_lax(v: Any) -> 'UnrecognizedStr':
5466
core_schema.no_info_plain_validator_function(validate_lax)
5567
]),
5668
strict_schema=core_schema.none_schema(), # Always fails in strict mode
69+
serialization=_unrecognized_serialization(cls),
5770
)
5871

5972

@@ -72,4 +85,5 @@ def validate_lax(v: Any) -> 'UnrecognizedInt':
7285
core_schema.no_info_plain_validator_function(validate_lax)
7386
]),
7487
strict_schema=core_schema.none_schema(), # Always fails in strict mode
88+
serialization=_unrecognized_serialization(cls),
7589
)

‎templates/templates/pythonv2/tests/primary/test_open_enum_union_discrimination.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
- Smart union mode correctly discriminates between union members based on field matches
88
"""
99

10+
import warnings
11+
1012
import pytest
1113
from typing import Union, Literal, Optional
1214
from pydantic import BaseModel, ValidationError, TypeAdapter, ConfigDict
1315
from .common_helpers import record_test
1416

15-
from openapi import SDK
17+
from openapi import SDK, utils
1618
from openapi.models import shared, operations
1719
from openapi.types import UnrecognizedStr, UnrecognizedInt
1820

@@ -1368,3 +1370,30 @@ def test_unions_discriminated_open_enum(self):
13681370
assert res.res is not None
13691371
assert isinstance(res.res.json_, shared.ObjectWithOpenEnumStatus2)
13701372
assert res.res.json_.reason == "User requested deactivation"
1373+
1374+
1375+
class TestOpenEnumScalarUnionSerialization:
1376+
@pytest.mark.parametrize("payload, wire", [
1377+
({"stringOrNumber": 1.5}, '{"stringOrNumber":1.5}'),
1378+
({"stringOrNumber": "future"}, '{"stringOrNumber":"future"}'),
1379+
({"stringOrNumber": "known"}, '{"stringOrNumber":"known"}'),
1380+
({"stringOrBoolean": True}, '{"stringOrBoolean":true}'),
1381+
({"stringOrBoolean": "future"}, '{"stringOrBoolean":"future"}'),
1382+
({"integerOrNumber": 2.5}, '{"integerOrNumber":2.5}'),
1383+
({"integerOrNumber": 1}, '{"integerOrNumber":1}'),
1384+
({"stringOrNull": "future"}, '{"stringOrNull":"future"}'),
1385+
({"stringOrNull": "known"}, '{"stringOrNull":"known"}'),
1386+
])
1387+
def test_scalar_member_serialization(self, payload, wire):
1388+
model = shared.OpenEnumScalarUnions.model_validate(payload)
1389+
with warnings.catch_warnings():
1390+
warnings.simplefilter("error")
1391+
assert utils.marshal_json(model, shared.OpenEnumScalarUnions) == wire
1392+
assert model.model_dump_json(by_alias=True, exclude_none=True) == wire
1393+
assert model.model_dump(by_alias=True, exclude_none=True) == payload
1394+
1395+
def test_unrecognized_values_keep_their_type_in_python_mode(self):
1396+
model = shared.OpenEnumScalarUnions.model_validate({"stringOrNumber": "future", "stringOrBoolean": "future"})
1397+
dumped = model.model_dump(by_alias=True, exclude_none=True)
1398+
assert type(dumped["stringOrNumber"]) is UnrecognizedStr
1399+
assert type(dumped["stringOrBoolean"]) is UnrecognizedStr
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
openapi: 3.1.0
2+
security:
3+
- apiKeyAuth: []
4+
- oauth2: []
5+
- clientCredentials: [read, write]
6+
- customSchemeAppId: []
7+
- basicHttp: []
8+
- accessToken: []
9+
- {}
10+
components:
11+
schemas:
12+
OpenEnumScalarUnions:
13+
x-speakeasy-include: true
14+
type: object
15+
description: >-
16+
Properties whose type combines an open enum with a number, a boolean or
17+
null. An unrecognized enum value must serialize as its plain string or
18+
integer, and a number or boolean must keep its value and JSON type.
19+
properties:
20+
stringOrNumber:
21+
anyOf:
22+
- $ref: '#/components/schemas/OpenEnumScalarString'
23+
- type: number
24+
stringOrBoolean:
25+
anyOf:
26+
- $ref: '#/components/schemas/OpenEnumScalarString'
27+
- type: boolean
28+
integerOrNumber:
29+
anyOf:
30+
- $ref: '#/components/schemas/OpenEnumScalarInteger'
31+
- type: number
32+
stringOrNull:
33+
anyOf:
34+
- $ref: '#/components/schemas/OpenEnumScalarString'
35+
- type: 'null'
36+
OpenEnumScalarString:
37+
type: string
38+
enum: [known]
39+
x-speakeasy-unknown-values: allow
40+
OpenEnumScalarInteger:
41+
type: integer
42+
enum: [1, 2]
43+
x-speakeasy-unknown-values: allow

‎zSDKs/sdk-pythonv2/.speakeasy/gen.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
from pydantic import ConfigDict, model_serializer
44
from pydantic import BaseModel as PydanticBaseModel
5-
from pydantic_core import core_schema
6-
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union
5+
from pydantic_core import PydanticSerializationUnexpectedValue, core_schema
6+
from typing import TYPE_CHECKING, Any, Literal, Optional, Type, TypeVar, Union
77
from typing_extensions import TypeAliasType, TypeAlias
88

99

@@ -37,6 +37,18 @@ def __bool__(self) -> Literal[False]:
3737
)
3838

3939

40+
def _unrecognized_serialization(cls: Type[Union[str, int]]) -> core_schema.PlainSerializerFunctionSerSchema:
41+
def serialize(value: Any, info: core_schema.SerializationInfo) -> Any:
42+
# Decline other values so union serialization tries the remaining members
43+
if not isinstance(value, cls):
44+
raise PydanticSerializationUnexpectedValue(f"Expected {cls.__name__}")
45+
if not info.mode_is_json():
46+
return value
47+
return str(value) if isinstance(value, str) else int(value)
48+
49+
return core_schema.plain_serializer_function_ser_schema(serialize, info_arg=True)
50+
51+
4052
class UnrecognizedStr(str):
4153
@classmethod
4254
def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> core_schema.CoreSchema:
@@ -56,6 +68,7 @@ def validate_lax(v: Any) -> 'UnrecognizedStr':
5668
core_schema.no_info_plain_validator_function(validate_lax)
5769
]),
5870
strict_schema=core_schema.none_schema(), # Always fails in strict mode
71+
serialization=_unrecognized_serialization(cls),
5972
)
6073

6174

@@ -74,4 +87,5 @@ def validate_lax(v: Any) -> 'UnrecognizedInt':
7487
core_schema.no_info_plain_validator_function(validate_lax)
7588
]),
7689
strict_schema=core_schema.none_schema(), # Always fails in strict mode
90+
serialization=_unrecognized_serialization(cls),
7791
)

0 commit comments

Comments
 (0)