Skip to content

Commit 75db779

Browse files
committed
✨ Added TypedDict
xtl.common.typed_vars - New TypedDict class for enforcing types on keys and values using Pydantic
1 parent 243b29c commit 75db779

1 file changed

Lines changed: 181 additions & 20 deletions

File tree

‎src/xtl/common/typed_vars.py‎

Lines changed: 181 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,39 @@
1+
from __future__ import annotations
2+
13
from abc import ABC, abstractmethod
2-
from collections.abc import Iterable, MutableSequence
3-
from typing import get_args, Generic, TypeVar, overload
4+
from collections.abc import Iterable, Mapping, MutableMapping, MutableSequence
5+
from typing import Any, Generic, TypeVar, Protocol, get_args, overload, runtime_checkable
46

57
from pydantic import ValidationError, TypeAdapter
68
from pydantic_core import InitErrorDetails, PydanticCustomError, core_schema
79

10+
from xtl.exceptions.pydantic import OptionsValidationError
11+
812

913
T = TypeVar('T')
14+
KT = TypeVar('KT')
15+
VT = TypeVar('VT')
16+
17+
18+
@runtime_checkable
19+
class TypedMutable(Protocol):
20+
21+
def _validate(self, value: Any) -> bool: ...
1022

23+
def _serialize(self) -> Any: ...
1124

12-
class TypedIterable(ABC, Generic[T]):
25+
@staticmethod
26+
def _serialize_value(value: Any) -> Any:
27+
if isinstance(value, TypedMutable):
28+
return value._serialize()
29+
return value
30+
31+
32+
def _type_name(tp: Any) -> str:
33+
return getattr(tp, '__name__', repr(tp))
34+
35+
36+
class TypedIterable(ABC, TypedMutable, Generic[T]):
1337

1438
_builtin_type: type
1539
_data: Iterable[T]
@@ -24,42 +48,39 @@ def __init__(self, item_type: type[T], initial=None):
2448
def _validate(self, value) -> T:
2549
try:
2650
return self._adapter.validate_python(value)
27-
except (ValidationError, TypeError) as e:
28-
raise ValidationError.from_exception_data(
29-
title=f'{self.__class__.__name__}<{self._item_type.__name__}>',
51+
except (ValidationError, TypeError):
52+
exc = ValidationError.from_exception_data(
53+
title=f'{self.__class__.__name__}<{_type_name(self._item_type)}>',
3054
line_errors=[
3155
InitErrorDetails(
3256
type=PydanticCustomError(
3357
'value_error',
3458
'Value error, Invalid data type for validation, ' + \
3559
'expected {itemtype} got {valuetype} instead',
36-
{'itemtype': self._item_type.__name__, 'valuetype': type(value)}
60+
{'itemtype': _type_name(self._item_type), 'valuetype': _type_name(type(value))}
3761
),
3862
loc=('item',),
3963
input=value,
4064
)
4165
]
42-
) from None
66+
)
67+
raise OptionsValidationError(exc, type(self).__name__) from exc
4368

4469
def _serialize(self) -> MutableSequence[T]:
4570
result = self._builtin_type()
4671
for item in self:
47-
if isinstance(item, TypedIterable):
48-
result.append(item._serialize())
49-
else:
50-
result.append(item)
72+
result.append(self._serialize_value(item))
5173
return result
5274

5375
@classmethod
5476
def __get_pydantic_core_schema__(cls, source_type, handler):
55-
(item_type,) = get_args(source_type)
56-
# Use list schema as the base
77+
(item_type, ) = get_args(source_type)
5778
list_schema = handler.generate_schema(list[item_type])
5879

59-
def validate(value):
80+
def validate(value: TypedIterable[T]):
6081
return cls(item_type=item_type, initial=value)
6182

62-
def serialize(instance):
83+
def serialize(instance: TypedIterable[T]):
6384
return instance._serialize()
6485

6586
return core_schema.no_info_after_validator_function(
@@ -78,12 +99,111 @@ def __contains__(self, item) -> bool:
7899
return item in self._data
79100

80101
@abstractmethod
81-
def extend(self, values) -> None:
82-
...
102+
def extend(self, values) -> None: ...
103+
104+
def __repr__(self):
105+
return f'{self.__class__.__name__}<{_type_name(self._item_type)}>({self._data})'
106+
107+
108+
class TypedMapping(ABC, TypedMutable, Generic[KT, VT]):
109+
110+
_builtin_type: type
111+
_data: Mapping[KT, VT]
112+
113+
def __init__(self, key_type: type[KT], value_type: type[VT], initial=None):
114+
self._data = self._builtin_type()
115+
if not hasattr(key_type, '__hash__'):
116+
raise TypeError(f'Key type {key_type} is not hashable, cannot be used as a key in '
117+
f'{type(self).__name__}')
118+
self._key_type = key_type
119+
self._value_type = value_type
120+
self._key_adapter = TypeAdapter(self._key_type)
121+
self._value_adapter = TypeAdapter(self._value_type)
122+
if initial is not None:
123+
self.update(initial)
124+
125+
def _validate_key(self, value) -> KT:
126+
try:
127+
return self._key_adapter.validate_python(value)
128+
except (ValidationError, TypeError):
129+
from xtl.exceptions.pydantic import OptionsValidationError
130+
131+
exc = ValidationError.from_exception_data(
132+
title=f'{self.__class__.__name__}<{_type_name(self._key_type)}, {_type_name(self._value_type)}>',
133+
line_errors=[
134+
InitErrorDetails(
135+
type=PydanticCustomError(
136+
'value_error',
137+
'Value error, Invalid data type for validation, ' + \
138+
'expected {keytype} got {valuetype} instead',
139+
{'keytype': _type_name(self._key_type), 'valuetype': _type_name(type(value))}
140+
),
141+
loc=('key',),
142+
input=value,
143+
)
144+
]
145+
)
146+
147+
raise OptionsValidationError(exc, type(self).__name__) from exc
148+
149+
def _validate_value(self, value) -> VT:
150+
try:
151+
return self._value_adapter.validate_python(value)
152+
except (ValidationError, TypeError):
153+
from xtl.exceptions.pydantic import OptionsValidationError
154+
155+
exc = ValidationError.from_exception_data(
156+
title=f'{self.__class__.__name__}<{_type_name(self._key_type)}, {_type_name(self._value_type)}>',
157+
line_errors=[
158+
InitErrorDetails(
159+
type=PydanticCustomError(
160+
'value_error',
161+
'Value error, Invalid data type for validation, ' + \
162+
'expected {valuetype} got {inputtype} instead',
163+
{'valuetype': _type_name(self._value_type), 'inputtype': _type_name(type(value))}
164+
),
165+
loc=('value',),
166+
input=value,
167+
)
168+
]
169+
)
170+
raise OptionsValidationError(exc, type(self).__name__) from exc
171+
172+
def _serialize(self) -> dict[KT, VT]:
173+
result = self._builtin_type()
174+
for key, value in self._data.items():
175+
result[self._serialize_value(key)] = self._serialize_value(value)
176+
return result
177+
178+
@classmethod
179+
def __get_pydantic_core_schema__(cls, source_type, handler):
180+
key_type, value_type = get_args(source_type)
181+
dict_schema = handler.generate_schema(dict[key_type, value_type])
182+
183+
def validate(value: TypedMapping[KT, VT]):
184+
return cls(key_type=key_type, value_type=value_type, initial=value)
185+
186+
def serialize(instance: TypedMapping[KT, VT]):
187+
return instance._serialize()
188+
189+
return core_schema.no_info_after_validator_function(
190+
validate,
191+
dict_schema,
192+
serialization=core_schema.plain_serializer_function_ser_schema(
193+
serialize,
194+
return_schema=dict_schema,
195+
),
196+
)
197+
198+
@abstractmethod
199+
def update(self, values: Mapping[KT, VT]) -> None: ...
83200

84201
def __repr__(self):
85-
# 'TypedList<ClassName>(values)'
86-
return f'{self.__class__.__name__}<{self._item_type.__name__}>({self._data})'
202+
return (
203+
f'{self.__class__.__name__}'
204+
f'<{_type_name(self._key_type)}, {_type_name(self._value_type)}>'
205+
f'({self._data})'
206+
)
87207

88208

89209
class TypedList(TypedIterable[T], MutableSequence[T]):
@@ -140,3 +260,44 @@ def append(self, value) -> None:
140260
def extend(self, values) -> None:
141261
self._data.extend([self._validate(v) for v in values])
142262

263+
264+
class TypedDict(TypedMapping[KT, VT], MutableMapping[KT, VT]):
265+
266+
_builtin_type = dict
267+
_data: dict[KT, VT]
268+
269+
def __getitem__(self, key: KT) -> VT:
270+
return self._data[key]
271+
272+
def __setitem__(self, key: KT, value: VT) -> None:
273+
self._data[self._validate_key(key)] = self._validate_value(value)
274+
275+
def __delitem__(self, key: KT) -> None:
276+
del self._data[key]
277+
278+
def __iter__(self):
279+
return iter(self._data)
280+
281+
def __len__(self) -> int:
282+
return len(self._data)
283+
284+
def __contains__(self, key: KT) -> bool:
285+
return key in self._data
286+
287+
def __eq__(self, other):
288+
if isinstance(other, TypedMapping):
289+
return self._data == other._data
290+
elif isinstance(other, Mapping):
291+
return self._data == dict(other)
292+
else:
293+
raise NotImplementedError
294+
295+
@staticmethod
296+
def _iter_items(values):
297+
if isinstance(values, MutableMapping):
298+
return values.items()
299+
return values
300+
301+
def update(self, values) -> None:
302+
for key, value in self._iter_items(values):
303+
self[key] = value

0 commit comments

Comments
 (0)