Skip to content

Commit 095ff12

Browse files
authored
Serialize unsubstituted type vars as Any (#7606)
1 parent 1f59075 commit 095ff12

File tree

3 files changed

+258
-24
lines changed

3 files changed

+258
-24
lines changed

docs/concepts/models.md

+89-19
Original file line numberDiff line numberDiff line change
@@ -783,43 +783,113 @@ print(concrete_model(a=1, b=1))
783783

784784
If you need to perform isinstance checks against parametrized generics, you can do this by subclassing the parametrized generic class. This looks like `class MyIntModel(MyGenericModel[int]): ...` and `isinstance(my_model, MyIntModel)`.
785785

786-
If a Pydantic model is used in a `TypeVar` constraint, [`SerializeAsAny`](serialization.md#serializing-with-duck-typing) can be used to
787-
serialize it using the concrete model instead of the model `TypeVar` is bound to.
786+
If a Pydantic model is used in a `TypeVar` bound and the generic type is never parametrized then Pydantic will use the bound for validation but treat the value as `Any` in terms of serialization:
788787

789788
```py
790-
from typing import Generic, TypeVar
789+
from typing import Generic, Optional, TypeVar
791790

792-
from pydantic import BaseModel, SerializeAsAny
791+
from pydantic import BaseModel
793792

794793

795-
class Model(BaseModel):
796-
a: int = 42
794+
class ErrorDetails(BaseModel):
795+
foo: str
797796

798797

799-
class DataModel(Model):
800-
b: int = 2
801-
c: int = 3
798+
ErrorDataT = TypeVar('ErrorDataT', bound=ErrorDetails)
799+
800+
801+
class Error(BaseModel, Generic[ErrorDataT]):
802+
message: str
803+
details: Optional[ErrorDataT]
802804

803805

804-
BoundT = TypeVar('BoundT', bound=Model)
806+
class MyErrorDetails(ErrorDetails):
807+
bar: str
805808

806809

807-
class GenericModel(BaseModel, Generic[BoundT]):
808-
data: BoundT
810+
# serialized as Any
811+
error = Error(
812+
message='We just had an error',
813+
details=MyErrorDetails(foo='var', bar='var2'),
814+
)
815+
assert error.model_dump() == {
816+
'message': 'We just had an error',
817+
'details': {
818+
'foo': 'var',
819+
'bar': 'var2',
820+
},
821+
}
809822

823+
# serialized using the concrete parametrization
824+
# note that `'bar': 'var2'` is missing
825+
error = Error[ErrorDetails](
826+
message='We just had an error',
827+
details=ErrorDetails(foo='var'),
828+
)
829+
assert error.model_dump() == {
830+
'message': 'We just had an error',
831+
'details': {
832+
'foo': 'var',
833+
},
834+
}
835+
```
810836

811-
class SerializeAsAnyModel(BaseModel, Generic[BoundT]):
812-
data: SerializeAsAny[BoundT]
837+
If you use a `default=...` (available in Python >= 3.13 or via `typing-extensions`) or constraints (`TypeVar('T', str, int)`; note that you rarely want to use this form of a `TypeVar`) then the default value or constraints will be used for both validation and serialization if the type variable is not parametrized. You can override this behavior using `pydantic.SerializeAsAny`:
813838

839+
```py
840+
from typing import Generic, Optional
841+
842+
from typing_extensions import TypeVar
814843

815-
data_model = DataModel()
844+
from pydantic import BaseModel, SerializeAsAny
816845

817-
print(GenericModel(data=data_model).model_dump())
818-
#> {'data': {'a': 42}}
819846

847+
class ErrorDetails(BaseModel):
848+
foo: str
820849

821-
print(SerializeAsAnyModel(data=data_model).model_dump())
822-
#> {'data': {'a': 42, 'b': 2, 'c': 3}}
850+
851+
ErrorDataT = TypeVar('ErrorDataT', default=ErrorDetails)
852+
853+
854+
class Error(BaseModel, Generic[ErrorDataT]):
855+
message: str
856+
details: Optional[ErrorDataT]
857+
858+
859+
class MyErrorDetails(ErrorDetails):
860+
bar: str
861+
862+
863+
# serialized using the default's serializer
864+
error = Error(
865+
message='We just had an error',
866+
details=MyErrorDetails(foo='var', bar='var2'),
867+
)
868+
assert error.model_dump() == {
869+
'message': 'We just had an error',
870+
'details': {
871+
'foo': 'var',
872+
},
873+
}
874+
875+
876+
class SerializeAsAnyError(BaseModel, Generic[ErrorDataT]):
877+
message: str
878+
details: Optional[SerializeAsAny[ErrorDataT]]
879+
880+
881+
# serialized as Any
882+
error = SerializeAsAnyError(
883+
message='We just had an error',
884+
details=MyErrorDetails(foo='var', bar='baz'),
885+
)
886+
assert error.model_dump() == {
887+
'message': 'We just had an error',
888+
'details': {
889+
'foo': 'var',
890+
'bar': 'baz',
891+
},
892+
}
823893
```
824894

825895
## Dynamic model creation

pydantic/_internal/_generate_schema.py

+20-4
Original file line numberDiff line numberDiff line change
@@ -1449,10 +1449,26 @@ def _callable_schema(self, function: Callable[..., Any]) -> core_schema.CallSche
14491449
def _unsubstituted_typevar_schema(self, typevar: typing.TypeVar) -> core_schema.CoreSchema:
14501450
assert isinstance(typevar, typing.TypeVar)
14511451

1452-
if typevar.__bound__:
1453-
return self.generate_schema(typevar.__bound__)
1454-
elif typevar.__constraints__:
1455-
return self._union_schema(typing.Union[typevar.__constraints__]) # type: ignore
1452+
bound = typevar.__bound__
1453+
constraints = typevar.__constraints__
1454+
not_set = object()
1455+
default = getattr(typevar, '__default__', not_set)
1456+
1457+
if (bound is not None) + (len(constraints) != 0) + (default is not not_set) > 1:
1458+
raise NotImplementedError(
1459+
'Pydantic does not support mixing more than one of TypeVar bounds, constraints and defaults'
1460+
)
1461+
1462+
if default is not not_set:
1463+
return self.generate_schema(default)
1464+
elif constraints:
1465+
return self._union_schema(typing.Union[constraints]) # type: ignore
1466+
elif bound:
1467+
schema = self.generate_schema(bound)
1468+
schema['serialization'] = core_schema.wrap_serializer_function_ser_schema(
1469+
lambda x, h: h(x), schema=core_schema.any_schema()
1470+
)
1471+
return schema
14561472
else:
14571473
return core_schema.any_schema()
14581474

tests/test_generics.py

+149-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,18 @@
3232
import pytest
3333
from dirty_equals import HasRepr, IsStr
3434
from pydantic_core import CoreSchema, core_schema
35-
from typing_extensions import Annotated, Literal, OrderedDict, ParamSpec, TypeVarTuple, Unpack, get_args
35+
from typing_extensions import (
36+
Annotated,
37+
Literal,
38+
OrderedDict,
39+
ParamSpec,
40+
TypeVarTuple,
41+
Unpack,
42+
get_args,
43+
)
44+
from typing_extensions import (
45+
TypeVar as TypingExtensionsTypeVar,
46+
)
3647

3748
from pydantic import (
3849
BaseModel,
@@ -2622,3 +2633,140 @@ class Model(Generic[T], BaseModel):
26222633
m1 = Model[int](x=1)
26232634
m2 = Model[int](x=1)
26242635
assert len({m1, m2}) == 1
2636+
2637+
2638+
def test_serialize_unsubstituted_typevars_bound() -> None:
2639+
class ErrorDetails(BaseModel):
2640+
foo: str
2641+
2642+
ErrorDataT = TypeVar('ErrorDataT', bound=ErrorDetails)
2643+
2644+
class Error(BaseModel, Generic[ErrorDataT]):
2645+
message: str
2646+
details: ErrorDataT
2647+
2648+
class MyErrorDetails(ErrorDetails):
2649+
bar: str
2650+
2651+
sample_error = Error(
2652+
message='We just had an error',
2653+
details=MyErrorDetails(foo='var', bar='baz'),
2654+
)
2655+
assert sample_error.details.model_dump() == {
2656+
'foo': 'var',
2657+
'bar': 'baz',
2658+
}
2659+
assert sample_error.model_dump() == {
2660+
'message': 'We just had an error',
2661+
'details': {
2662+
'foo': 'var',
2663+
'bar': 'baz',
2664+
},
2665+
}
2666+
2667+
sample_error = Error[ErrorDetails](
2668+
message='We just had an error',
2669+
details=MyErrorDetails(foo='var', bar='baz'),
2670+
)
2671+
assert sample_error.details.model_dump() == {
2672+
'foo': 'var',
2673+
'bar': 'baz',
2674+
}
2675+
assert sample_error.model_dump() == {
2676+
'message': 'We just had an error',
2677+
'details': {
2678+
'foo': 'var',
2679+
},
2680+
}
2681+
2682+
sample_error = Error[MyErrorDetails](
2683+
message='We just had an error',
2684+
details=MyErrorDetails(foo='var', bar='baz'),
2685+
)
2686+
assert sample_error.details.model_dump() == {
2687+
'foo': 'var',
2688+
'bar': 'baz',
2689+
}
2690+
assert sample_error.model_dump() == {
2691+
'message': 'We just had an error',
2692+
'details': {
2693+
'foo': 'var',
2694+
'bar': 'baz',
2695+
},
2696+
}
2697+
2698+
2699+
@pytest.mark.parametrize(
2700+
'type_var',
2701+
[
2702+
TypingExtensionsTypeVar('ErrorDataT', default=BaseModel),
2703+
TypeVar('ErrorDataT', BaseModel, str),
2704+
],
2705+
ids=['default', 'constraint'],
2706+
)
2707+
def test_serialize_unsubstituted_typevars_bound(
2708+
type_var: Type[BaseModel],
2709+
) -> None:
2710+
class ErrorDetails(BaseModel):
2711+
foo: str
2712+
2713+
class Error(BaseModel, Generic[type_var]): # type: ignore
2714+
message: str
2715+
details: type_var
2716+
2717+
class MyErrorDetails(ErrorDetails):
2718+
bar: str
2719+
2720+
sample_error = Error(
2721+
message='We just had an error',
2722+
details=MyErrorDetails(foo='var', bar='baz'),
2723+
)
2724+
assert sample_error.details.model_dump() == {
2725+
'foo': 'var',
2726+
'bar': 'baz',
2727+
}
2728+
assert sample_error.model_dump() == {
2729+
'message': 'We just had an error',
2730+
'details': {},
2731+
}
2732+
2733+
sample_error = Error[ErrorDetails](
2734+
message='We just had an error',
2735+
details=MyErrorDetails(foo='var', bar='baz'),
2736+
)
2737+
assert sample_error.details.model_dump() == {
2738+
'foo': 'var',
2739+
'bar': 'baz',
2740+
}
2741+
assert sample_error.model_dump() == {
2742+
'message': 'We just had an error',
2743+
'details': {
2744+
'foo': 'var',
2745+
},
2746+
}
2747+
2748+
sample_error = Error[MyErrorDetails](
2749+
message='We just had an error',
2750+
details=MyErrorDetails(foo='var', bar='baz'),
2751+
)
2752+
assert sample_error.details.model_dump() == {
2753+
'foo': 'var',
2754+
'bar': 'baz',
2755+
}
2756+
assert sample_error.model_dump() == {
2757+
'message': 'We just had an error',
2758+
'details': {
2759+
'foo': 'var',
2760+
'bar': 'baz',
2761+
},
2762+
}
2763+
2764+
2765+
def test_mix_default_and_constraints() -> None:
2766+
T = TypingExtensionsTypeVar('T', str, int, default=str)
2767+
2768+
msg = 'Pydantic does not support mixing more than one of TypeVar bounds, constraints and defaults'
2769+
with pytest.raises(NotImplementedError, match=msg):
2770+
2771+
class _(BaseModel, Generic[T]):
2772+
x: T

0 commit comments

Comments
 (0)