Passed
Pull Request — main (#158)
by
unknown
01:29
created

pincer.utils.api_object._asdict_ignore_none()   C

Complexity

Conditions 9

Size

Total Lines 38
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 21
dl 0
loc 38
rs 6.6666
c 0
b 0
f 0
cc 9
nop 1
1
# Copyright Pincer 2021-Present
2
# Full MIT License can be found in `LICENSE` at the project root.
3
4
from __future__ import annotations
5
6
import copy
7
import logging
8
from dataclasses import dataclass, fields, _is_dataclass_instance
9
from enum import Enum, EnumMeta
10
from inspect import getfullargspec
11
from sys import modules
12
from typing import (
13
    Dict, Tuple, Union, Generic, TypeVar, Any, TYPE_CHECKING,
14
    List, get_type_hints, get_origin, get_args
15
)
16
17
from .conversion import convert
18
from .types import MissingType, Singleton, MISSING
19
from ..exceptions import InvalidAnnotation
20
21
if TYPE_CHECKING:
22
    from ..client import Client
23
    from ..core.http import HTTPClient
24
25
T = TypeVar("T")
26
27
_log = logging.getLogger(__package__)
28
29
30
def _asdict_ignore_none(obj: Generic[T]) -> Union[Tuple, Dict, T]:
31
    """
32
    Returns a dict from a dataclass that ignores
33
    all values that are None
34
    Modification of _asdict_inner from dataclasses
35
36
    :param obj:
37
        Dataclass obj
38
    """
39
40
    if _is_dataclass_instance(obj):
41
        result = []
42
        for f in fields(obj):
43
            value = _asdict_ignore_none(getattr(obj, f.name))
44
45
            if isinstance(value, Enum):
46
                result.append((f.name, value.value))
47
            # This if statement was added to the function
48
            elif not isinstance(value, MissingType):
49
                result.append((f.name, value))
50
51
        return dict(result)
52
53
    elif isinstance(obj, tuple) and hasattr(obj, '_fields'):
54
        return type(obj)(*[_asdict_ignore_none(v) for v in obj])
55
56
    elif isinstance(obj, (list, tuple)):
57
        return type(obj)(_asdict_ignore_none(v) for v in obj)
58
59
    elif isinstance(obj, dict):
60
        return type(obj)(
61
            (
62
                _asdict_ignore_none(k),
63
                _asdict_ignore_none(v)
64
            ) for k, v in obj.items()
65
        )
66
    else:
67
        return copy.deepcopy(obj)
68
69
70
class HTTPMeta(type):
71
    __meta_items: List[str] = ["_client", "_http"]
72
    __ori_annotations: Dict[str, type] = {}
73
74
    def __new__(mcs, name, base, mapping):
75
        # Iterates through the meta items, these are keys whom should
76
        # be added to every object. But to keep typehints we have to
77
        # define those in the parent class. Yet this gives a conflict
78
        # because the value is not defined. (thats why we remove it)
79
        for key in HTTPMeta.__meta_items:
80
            if mapping.get("__annotations__") and \
81
                    (value := mapping["__annotations__"].get(key)):
0 ignored issues
show
introduced by
invalid syntax (<unknown>, line 81)
Loading history...
82
                # We want to keep the type annotations of the objects
83
                # tho, so lets statically store them so we can readd
84
                # them later.
85
                HTTPMeta.__ori_annotations.update({key: value})
86
                del mapping["__annotations__"][key]
87
88
        # Instanciate our object
89
        http_object = super().__new__(mcs, name, base, mapping)
90
91
        # Readd all removed items
92
        if getattr(http_object, "__annotations__", None):
93
            for k, v in HTTPMeta.__ori_annotations.items():
94
                http_object.__annotations__[k] = v
95
                setattr(http_object, k, None)
96
97
        return http_object
98
99
100
class TypeCache(metaclass=Singleton):
101
    cache = {}
102
103
    def __init__(self):
104
        # Register all known types to the cache. This gets used later
105
        # to auto-convert the properties to their desired type.
106
        lcp = modules.copy()
107
        for module in lcp:
108
            if not module.startswith("pincer"):
109
                continue
110
111
            TypeCache.cache.update(lcp[module].__dict__)
112
113
114
@dataclass
115
class APIObject(metaclass=HTTPMeta):
116
    """
117
    Represents an object which has been fetched from the Discord API.
118
    """
119
    _client: Client
120
    _http: HTTPClient
121
122
    def __get_types(self, attr: str, arg_type: type) -> Tuple[type]:
123
        """Get the types from type annotations.
124
125
        Parameters
126
        ----------
127
        attr: :class:`str`
128
            The attribute the typehint belongs to.
129
        arg_type: :class:`type`
130
            The type annotation for the attribute.
131
132
        Returns
133
        -------
134
        Tuple[:class:`type`]
135
            A collection of type annotation(s). Will most of the times
136
            consist of 1 item.
137
138
        Raises
139
        ------
140
        :class:`~pincer.exceptions.InvalidAnnotation`
141
            Exception which is raised when the type annotation has not enough
142
            or too many arguments for the parser to handle.
143
        """
144
        origin = get_origin(arg_type)
145
146
        if origin is Union:
147
            # Ahh yes, typing module has no type annotations for this...
148
            # noinspection PyTypeChecker
149
            args: Tuple[type] = get_args(arg_type)
150
151
            if 2 <= len(args) < 4:
152
                return args
153
154
            raise InvalidAnnotation(
155
                f"Attribute `{attr}` in `{type(self).__name__}` has too many "
156
                f"or not enough arguments! (got {len(args)} expected 2-3)"
157
            )
158
159
        return arg_type,
160
161
    def __attr_convert(self, attr: str, attr_type: T) -> T:
162
        """Convert an attribute to the requested attribute type using
163
        the factory or the __init__.
164
165
        Parameters
166
        ----------
167
        attr: :class:`str`
168
            The attribute the typehint belongs to.
169
        arg_type: T
170
            The type annotation for the attribute.
171
172
        Returns
173
        -------
174
        T
175
            The instanciated version of the arg_type.
176
        """
177
        factory = attr_type
178
179
        # Always use `__factory__` over __init__
180
        if getattr(attr_type, "__factory__", None):
181
            factory = attr_type.__factory__
182
183
        return convert(
184
            getattr(self, attr),
185
            factory,
186
            attr_type,
187
            self._client
188
        )
189
190
    def __post_init__(self):
191
        TypeCache()
192
193
        # Get all type annotations for the attributes.
194
        attributes = get_type_hints(self, globalns=TypeCache.cache).items()
195
196
        for attr, attr_type in attributes:
197
            # Ignore private attributes.
198
            if attr.startswith('_'):
199
                continue
200
201
            types = self.__get_types(attr, attr_type)
202
203
            types = tuple(filter(
204
                lambda tpe: tpe is not None and tpe is not MISSING,
205
                types
206
            ))
207
208
            if not types:
209
                raise InvalidAnnotation(
210
                    f"Attribute `{attr}` in `{type(self).__name__}` only "
211
                    "consisted of missing/optional type!"
212
                )
213
214
            specific_tp = types[0]
215
216
            if tp := get_origin(specific_tp):
217
                specific_tp = tp
218
219
            if isinstance(specific_tp, EnumMeta) and not getattr(self, attr):
220
                attr_value = MISSING
221
            else:
222
                attr_value = self.__attr_convert(attr, specific_tp)
223
224
            setattr(self, attr, attr_value)
225
226
    # Set default factory method to from_dict for APIObject's.
227
    @classmethod
228
    def __factory__(cls: Generic[T], *args, **kwargs) -> T:
229
        return cls.from_dict(*args, **kwargs)
230
231
    def __str__(self):
232
        if self.__dict__.get('name'):
233
            return self.name
234
235
        return super().__str__()
236
237
    @classmethod
238
    def from_dict(
239
            cls: Generic[T],
240
            data: Dict[str, Union[str, bool, int, Any]]
241
    ) -> T:
242
        """
243
        Parse an API object from a dictionary.
244
        """
245
        if isinstance(data, cls):
246
            return data
247
248
        # Disable inspection for IDE because this is valid code for the
249
        # inherited classes:
250
        # noinspection PyArgumentList
251
        return cls(**dict(map(
252
            lambda key: (
253
                key,
254
                data[key].value if isinstance(data[key], Enum) else data[key]
255
            ),
256
            filter(
257
                lambda object_argument: data.get(object_argument) is not None,
258
                getfullargspec(cls.__init__).args
259
            )
260
        )))
261
262
    def to_dict(self) -> Dict:
263
        """
264
        Transform the current object to a dictionary representation.
265
        """
266
        return _asdict_ignore_none(self)
267