dataclasses-json
dataclasses-json copied to clipboard
Feature Request: User Generics decoders
Hi I would like to ask to add extended support for user-defined generics.
This means that the class should define its custom decoder and encoder which then accepts its data, generic types, field encoder/decoder (_asdict
/ _decode_dataclass
) and **kwargs
(like infer_missing
).
See DecodableGenericABC
in the example below.
Example Usage
Something like that
from abc import ABC, abstractmethod, ABCMeta
from dataclasses import dataclass, field
from types import GenericAlias
from typing import *
from uuid import uuid4
from dataclasses_json import DataClassJsonMixin
from dataclasses_json.core import Json
T = TypeVar('T')
K = TypeVar('K')
C = TypeVar('C')
class DecodableGenericABC(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def encode(self, *, data_encoder: Callable[[T], Json], **kwargs) -> Json:
raise NotImplementedError
@classmethod
@abstractmethod
def decode(cls: Type[C], data: Json, *types: Type[T], data_decoder: Callable[[Json], T], **kwargs) -> C:
raise NotImplementedError
__class_getitem__ = classmethod(GenericAlias)
_MISSING = object()
class CustomMapping(Mapping[K, T], Container[K], Generic[K, T], DecodableGenericABC):
__slots__ = ('_data', '_id')
_data: Dict[K, T]
_id: str
def __init__(self, data: Dict[K, T], *, id: str = None):
self._data = dict(data)
self._id = id or str(uuid4())
def __getitem__(self, item: K) -> T:
return self._data[item]
def __len__(self) -> int:
return len(self._data)
def __iter__(self) -> Iterator[K]:
return iter(self._data.keys())
def __bool__(self) -> bool:
return bool(self._data)
def __repr__(self) -> str:
return f'{self.__class__.__name__}(id={self._id!r}, data={self._data!r})'
@property
def id(self) -> str:
return self._id
def encode(self, *, data_encoder: Callable[[T], Json], **kwargs) -> Json:
data_encoded = data_encoder(self._data)
id_encoded = data_encoder(self._id)
return dict(id=id_encoded, data=data_encoded)
@classmethod
def decode(cls, data: Json, *types, data_decoder: Callable[[Json], T], **kwargs) -> 'CustomMapping[K, T]':
if (len(types) != 2):
raise TypeError(f"{'Too many' if len(types) > 2 else 'Not enough'} types for decoding CustomMapping: Expected 2, got {len(types)}.")
if (not isinstance(data, Mapping)):
raise TypeError(f"'data' is expected to be Mapping, got {type(data)}")
data = dict(data)
_data = data_decoder(data.pop('data'))
_id = data.pop('id', _MISSING)
if (_id is not _MISSING):
_id = data_decoder(_id)
else:
_id = None
if (data):
raise ValueError(f"Got unexpected fields while decoding CustomMapping: {list(data.keys())}")
return cls(data=_data, id=_id)
class OptionContainer(Collection[T], Generic[T], DecodableGenericABC):
__slots__ = ('_is_empty', '_data')
_is_empty: bool
_data: T
def __init__(self, data: Optional[T]):
self._data = data
self._is_empty = data is None
@classmethod
def init_non_empty(cls, data: T) -> 'OptionContainer[T]':
r = cls(data)
r._is_empty = False
return r
@classmethod
def init_empty(cls) -> 'OptionContainer[T]':
r = cls(None)
r._is_empty = True
return r
def __contains__(self, item: T) -> bool:
return not self._is_empty and item == self._data
def __iter__(self) -> Iterator[T]:
if (not self._is_empty):
yield self._data
def __len__(self) -> int:
return int(self._is_empty)
def __bool__(self) -> bool:
return self._is_empty
def __repr__(self) -> str:
if (self._is_empty):
return f'{self.__class__.__name__}<empty>'
else:
return f'{self.__class__.__name__}({self._data!r})'
def encode(self, *, data_encoder: Callable[[T], Json], **kwargs) -> Json:
if (self._is_empty):
return None
else:
return data_encoder(self._data)
@classmethod
def decode(cls, data: Json, *types: Type[T], data_decoder: Callable[[Json], T], **kwargs) -> 'OptionContainer[T]':
if (len(types) != 1):
raise TypeError(f"{'Too many' if len(types) > 1 else 'Not enough'} types for decoding CustomMapping: Expected 2, got {len(types)}.")
if (data is None):
return OptionContainer.init_empty()
else:
return OptionContainer.init_non_empty(data_decoder(data))
@dataclass
class MyClass(DataClassJsonMixin):
opt_int: OptionContainer[int]
opt_str: OptionContainer[str]
opt_mapping: OptionContainer[CustomMapping[str, int]]
def main():
opt_a = OptionContainer.init_non_empty(124)
opt_b = OptionContainer.init_empty()
opt_c = OptionContainer(CustomMapping(dict(f1=1, f2=2), id='135121-231566677'))
my_cls = MyClass(opt_a, opt_b, opt_c)
print(my_cls)
actual_json = my_cls.to_json()
print(actual_json)
# Actual: {"opt_int": [124], "opt_str": [], "opt_mapping": [{"f1": 1, "f2": 2}]}
# Wanted: {"opt_int": 124, "opt_str": null, "opt_mapping": {"id": "135121-231566677", "data": {"f1": 1, "f2": 2}}}
wanted_json = '''{"opt_int": 124, "opt_str": null, "opt_mapping": {"id": "135121-231566677", "data": {"f1": 1, "f2": 2}}}'''
decoded = my_cls.from_json(wanted_json)
print(decoded)
# Actual: MyClass(opt_int=OptionContainer(124), opt_str=None, opt_mapping=OptionContainer(<generator object _decode_items.<locals>.<genexpr> at 0x000002380CB59F90>))
# Wanted: MyClass(opt_int=OptionContainer(124), opt_str=OptionContainer<empty>, opt_mapping=OptionContainer(CustomMapping(id='135121-231566677', data={'f1': 1, 'f2': 2})))
return 0
if (__name__ == '__main__'):
exit_code = main()
exit(exit_code)
Should be possible in proposed v1 API: #442