Dict to dataclass

# Copyright (c) 2021 Christopher Prohm
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
import dataclasses
import typing


def fromdict(d, cls):
    type_origin = typing.get_origin(cls)
    type_args = typing.get_args(cls)

    if dataclasses.is_dataclass(cls):
        return fromdict_dataclass(d, cls)

    elif type_origin is dict:
        return fromdict_dict(d, type_args)

    elif type_origin is typing.Union:
        return fromdict_union(d, type_args)

    elif type_origin is tuple:
        return fromdict_tuple(d, type_args)

    elif isinstance(cls, type) and hasattr(cls, "_fromdict"):
        return cls._fromdict(d)

    elif isinstance(cls, type):
        return d

    else:
        raise NotImplementedError(f"Unsupported type {cls}")


def fromdict_dataclass(d, cls):
    assert isinstance(d, dict)

    data = {}
    for field in dataclasses.fields(cls):
        try:
            data[field.name] = fromdict(d[field.name], field.type)

        except Exception as cause:
            raise TypeError(f"Could not convert {field.name}: {cause}") from cause

    return cls(**data)


def fromdict_union(d, type_args):
    none_type = type(None)
    has_none = any(t is none_type for t in type_args)
    not_none_types = [t for t in type_args if t is not none_type]

    if d is None and has_none:
        return None

    if not not_none_types:
        raise TypeError("No not None types in union with no None arg")

    causes = []
    for t in not_none_types:
        try:
            return fromdict(d, t)

        except Exception as e:
            causes.append((t, e))

    else:
        raise UnionError(*causes)


class UnionError(Exception):
    def __str__(self):
        res = ["Error during fromdict for Union type. No matching type found:"]
        for t, err in self.args:
            res.append(f"Conversion for {t} failed with {err}")

        return "\n".join(res)


def fromdict_dict(d, type_args):
    assert len(type_args) == 2
    assert isinstance(d, dict)

    key_type, val_type = type_args
    return {fromdict(k, key_type): fromdict(v, val_type) for (k, v) in d.items()}


def fromdict_tuple(d, type_args):
    assert len(type_args) == 1
    assert isinstance(d, (tuple, list))

    (item_type,) = type_args
    return tuple(fromdict(item, item_type) for item in d)