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