Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changesets/1792768199-b0e4a14b.yaml
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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:
Expand All @@ -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),
)


Expand All @@ -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),
)
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
43 changes: 43 additions & 0 deletions tests/specs/fragments/primary/open-enum-scalar-unions.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion zSDKs/sdk-pythonv2/.speakeasy/gen.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 16 additions & 2 deletions zSDKs/sdk-pythonv2/src/speakeasy/new_openapi/types/basemodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand All @@ -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),
)


Expand All @@ -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),
)
Loading