Skip to content

Commit daf07d4

Browse files
authored
Allow enums and structs to be handled by pydantic (#428)
* Pydantic integration with csp Enums, Structs, and csp numpy array typing --------- Signed-off-by: Nijat Khanbabayev <nijat.khanbabayev@cubistsystematic.com>
1 parent f376bd6 commit daf07d4

6 files changed

Lines changed: 1146 additions & 9 deletions

File tree

csp/impl/enum.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,49 @@ def __repr__(self):
6464
def __str__(self):
6565
return f"{type(self).__name__}.{self.name}"
6666

67+
@classmethod
68+
def _validate(cls, v) -> "Enum":
69+
if isinstance(v, cls):
70+
return v
71+
elif isinstance(v, str):
72+
return cls[v]
73+
elif isinstance(v, int):
74+
return cls(v)
75+
raise ValueError(f"Cannot convert value to enum: {v}")
76+
77+
@staticmethod
78+
def _serialize(value: typing.Union[str, "Enum"]) -> str:
79+
return value.name
80+
81+
@classmethod
82+
def __get_pydantic_json_schema__(cls, _core_schema, handler):
83+
from pydantic_core import core_schema
84+
85+
field_schema = handler(core_schema.str_schema())
86+
field_schema.update(
87+
type="string",
88+
title=cls.__name__,
89+
description=cls.__doc__ or "An enumeration of {}".format(cls.__name__),
90+
enum=list(cls.__members__.keys()),
91+
)
92+
return field_schema
93+
94+
@classmethod
95+
def __get_pydantic_core_schema__(
96+
cls,
97+
source_type,
98+
handler,
99+
):
100+
from pydantic_core import core_schema
101+
102+
return core_schema.no_info_before_validator_function(
103+
cls._validate,
104+
core_schema.any_schema(),
105+
serialization=core_schema.plain_serializer_function_ser_schema(
106+
cls._serialize, info_arg=False, return_schema=core_schema.str_schema(), when_used="json"
107+
),
108+
)
109+
67110

68111
def DynamicEnum(name: str, values: typing.Union[dict, list], start=0, module_name=None):
69112
"""create a csp.Enum type dynamically

csp/impl/struct.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ def __new__(cls, name, bases, dct):
6868
dct["__metadata__"] = metadata
6969
dct["__defaults__"] = defaults
7070

71-
return super().__new__(cls, name, bases, dct)
71+
res = super().__new__(cls, name, bases, dct)
72+
# This is how we make sure we construct the pydantic schema from the new class
73+
res.__get_pydantic_core_schema__ = classmethod(res._get_pydantic_core_schema)
74+
return res
7275

7376
def layout(self, num_cols=8):
7477
layout = super()._layout()
@@ -81,6 +84,66 @@ def layout(self, num_cols=8):
8184
out += layout[idx : idx + num_cols]
8285
return out
8386

87+
@staticmethod
88+
def _get_pydantic_core_schema(cls, _source_type, handler):
89+
"""Tell Pydantic how to validate and serialize this Struct class."""
90+
from pydantic import PydanticSchemaGenerationError
91+
from pydantic_core import core_schema
92+
93+
fields = {}
94+
for field_name, field_type in cls.__full_metadata_typed__.items():
95+
if field_name.startswith("_"):
96+
continue # we skip fields with underscore, like pydantic does
97+
try:
98+
field_schema = handler.generate_schema(field_type)
99+
except PydanticSchemaGenerationError:
100+
# This logic allows for handling generic types with types we cant get a schema for, only 1 layer deep, same as csp
101+
item_tp = typing.Any if typing.get_origin(field_type) is None else typing.get_args(field_type)[0]
102+
try:
103+
field_schema = handler.generate_schema(item_tp)
104+
except PydanticSchemaGenerationError:
105+
field_schema = core_schema.any_schema() # give up finally
106+
107+
if field_name in cls.__defaults__:
108+
field_schema = core_schema.with_default_schema(
109+
schema=field_schema, default=cls.__defaults__[field_name]
110+
)
111+
fields[field_name] = core_schema.typed_dict_field(
112+
schema=field_schema,
113+
required=False, # Make all fields optional
114+
)
115+
# Schema for dictionary inputs
116+
fields_schema = core_schema.typed_dict_schema(
117+
fields=fields,
118+
total=False, # Allow missing fields
119+
extra_behavior="allow", # let csp catch extra attributes, allows underscore fields to pass through
120+
)
121+
122+
def create_instance(raw_data, validator):
123+
# We choose to not revalidate, this is the default behavior in pydantic
124+
if isinstance(raw_data, cls):
125+
return raw_data
126+
try:
127+
return cls(**validator(raw_data))
128+
except AttributeError as e:
129+
# Pydantic can't use AttributeError to check other classes, like in Union annotations
130+
raise ValueError(str(e)) from None
131+
132+
def serializer(val, handler):
133+
# We don't use 'to_dict' since that works recursively, we ignore underscore leading fields
134+
new_val = {
135+
k: getattr(val, k) for k in val.__full_metadata_typed__ if not k.startswith("_") and hasattr(val, k)
136+
}
137+
return handler(new_val)
138+
139+
return core_schema.no_info_wrap_validator_function(
140+
function=create_instance,
141+
schema=fields_schema,
142+
serialization=core_schema.wrap_serializer_function_ser_schema(
143+
function=serializer, schema=fields_schema, when_used="always"
144+
),
145+
)
146+
84147

85148
class Struct(_csptypesimpl.PyStruct, metaclass=StructMeta):
86149
@classmethod

csp/tests/impl/test_enum.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import _pickle
2+
import json
3+
import pytest
24
import unittest
35
from datetime import datetime, timedelta
6+
from pydantic import BaseModel, ConfigDict, RootModel
7+
from typing import Dict, List
48

59
import csp
610
from csp import ts
@@ -36,6 +40,22 @@ def s1():
3640
MyDEnum = csp.DynamicEnum("MyDEnum", ["A", "B", "C"])
3741

3842

43+
class MyEnum3(csp.Enum):
44+
FIELD1 = csp.Enum.auto()
45+
FIELD2 = csp.Enum.auto()
46+
47+
48+
class MyModel(BaseModel):
49+
enum: MyEnum3
50+
enum_default: MyEnum3 = MyEnum3.FIELD1
51+
52+
53+
class MyDictModel(BaseModel):
54+
model_config = ConfigDict(use_enum_values=True)
55+
56+
enum_dict: Dict[MyEnum3, int] = None
57+
58+
3959
class TestCspEnum(unittest.TestCase):
4060
def test_basic(self):
4161
self.assertEqual(MyEnum("A"), MyEnum.A)
@@ -152,6 +172,73 @@ class B(A):
152172

153173
self.assertEqual("Cannot extend csp.Enum 'A': inheriting from an Enum is prohibited", str(cm.exception))
154174

175+
def test_pydantic_validation(self):
176+
assert MyModel(enum="FIELD2").enum == MyEnum3.FIELD2
177+
assert MyModel(enum=0).enum == MyEnum3.FIELD1
178+
assert MyModel(enum=MyEnum3.FIELD1).enum == MyEnum3.FIELD1
179+
with pytest.raises(ValueError):
180+
MyModel(enum=3.14)
181+
182+
def test_pydantic_dict(self):
183+
assert dict(MyModel(enum=MyEnum3.FIELD2)) == {"enum": MyEnum3.FIELD2, "enum_default": MyEnum3.FIELD1}
184+
assert MyModel(enum=MyEnum3.FIELD2).model_dump(mode="python") == {
185+
"enum": MyEnum3.FIELD2,
186+
"enum_default": MyEnum3.FIELD1,
187+
}
188+
assert MyModel(enum=MyEnum3.FIELD2).model_dump(mode="json") == {"enum": "FIELD2", "enum_default": "FIELD1"}
189+
190+
def test_pydantic_serialization(self):
191+
assert "enum" in MyModel.model_fields
192+
assert "enum_default" in MyModel.model_fields
193+
tm = MyModel(enum=MyEnum3.FIELD2)
194+
assert json.loads(tm.model_dump_json()) == json.loads('{"enum": "FIELD2", "enum_default": "FIELD1"}')
195+
196+
def test_enum_as_dict_key_json_serialization(self):
197+
class DictWrapper(RootModel[Dict[MyEnum3, int]]):
198+
model_config = ConfigDict(use_enum_values=True)
199+
200+
def __getitem__(self, item):
201+
return self.root[item]
202+
203+
class MyDictWrapperModel(BaseModel):
204+
model_config = ConfigDict(use_enum_values=True)
205+
206+
enum_dict: DictWrapper
207+
208+
dict_model = MyDictModel(enum_dict={MyEnum3.FIELD1: 8, MyEnum3.FIELD2: 19})
209+
assert dict_model.enum_dict[MyEnum3.FIELD1] == 8
210+
assert dict_model.enum_dict[MyEnum3.FIELD2] == 19
211+
212+
assert json.loads(dict_model.model_dump_json()) == json.loads('{"enum_dict":{"FIELD1":8,"FIELD2":19}}')
213+
214+
dict_wrapper_model = MyDictWrapperModel(enum_dict=DictWrapper({MyEnum3.FIELD1: 8, MyEnum3.FIELD2: 19}))
215+
216+
assert dict_wrapper_model.enum_dict[MyEnum3.FIELD1] == 8
217+
assert dict_wrapper_model.enum_dict[MyEnum3.FIELD2] == 19
218+
assert json.loads(dict_wrapper_model.model_dump_json()) == json.loads('{"enum_dict":{"FIELD1":8,"FIELD2":19}}')
219+
220+
def test_json_schema_csp(self):
221+
assert MyModel.model_json_schema() == {
222+
"properties": {
223+
"enum": {
224+
"description": "An enumeration of MyEnum3",
225+
"enum": ["FIELD1", "FIELD2"],
226+
"title": "MyEnum3",
227+
"type": "string",
228+
},
229+
"enum_default": {
230+
"default": "FIELD1",
231+
"description": "An enumeration of MyEnum3",
232+
"enum": ["FIELD1", "FIELD2"],
233+
"title": "MyEnum3",
234+
"type": "string",
235+
},
236+
},
237+
"required": ["enum"],
238+
"title": "MyModel",
239+
"type": "object",
240+
}
241+
155242

156243
if __name__ == "__main__":
157244
unittest.main()

0 commit comments

Comments
 (0)