dataclasses-json icon indicating copy to clipboard operation
dataclasses-json copied to clipboard

Feature Request: User Generics decoders

Open USSX-Hares opened this issue 2 years ago • 1 comments

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)

USSX-Hares avatar Jul 19 '21 20:07 USSX-Hares

Should be possible in proposed v1 API: #442

george-zubrienko avatar Jul 20 '23 22:07 george-zubrienko