aboutsummaryrefslogtreecommitdiff
path: root/pandora/models/_base.py
blob: 8904be1d83932a5fa77d64f9a8dcfed4e0bb9e8c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
from datetime import datetime
from collections import namedtuple


class Field(namedtuple("Field", ["field", "default", "formatter", "model"])):
    """Model Field

    Model fields represent JSON key/value pairs. When added to a PandoraModel
    the describe the unpacking logic for the API JSON and will be replaced at
    runtime with the values from the parsed JSON or their defaults.

    field
        name of the field from the incoming JSON
    default
        default value if key does not exist in the incoming JSON, None if not
        provided
    formatter
        formatter function accepting an API client and the value of the field
        as arguments, will be called on the value of the data for the field key
        in the incoming JSON. The return value of this function is used as the
        value of the field on the model object.
    model
        the model class that the value of this field should be constructed into
        the model construction logic will handle building a list or single
        model based on the type of data in the JSON
    """

    def __new__(cls, field, default=None, formatter=None, model=None):
        return super().__new__(cls, field, default, formatter, model)


class SyntheticField(namedtuple("SyntheticField", ["field"])):
    """Field Requiring Synthesis

    Synthetic fields may exist in the data but generally do not and require
    additional synthesis to arrive ate a sane value. Subclasses must define
    a formatter method that receives an API client, field name, and full data
    payload.
    """

    def formatter(self, api_client, data, newval):
        """Format Value for Model

        The return value of this method is used as a value for the field in the
        model of which this field is a member

        api_client
            instance of a Pandora API client
        data
            complete JSON data blob for the parent model of which this field is
            a member
        newval
            the value of this field as retrieved from the JSON data after
            having resolved default value logic
        """
        raise NotImplementedError


class DateField(SyntheticField):
    """Date Field

    Handles a JSON map that contains a time field which is the timestamp with
    nanosecond precision.
    """

    def formatter(self, api_client, data, newval):
        if not newval:
            return None

        return datetime.utcfromtimestamp(newval["time"] / 1000)


class ModelMetaClass(type):
    def __new__(cls, name, parents, dct):
        dct["_fields"] = fields = {}
        new_dct = dct.copy()

        for key, val in dct.items():
            if key.startswith("__"):
                continue

            if isinstance(val, Field) or isinstance(val, SyntheticField):
                fields[key] = val
                del new_dct[key]

        return super().__new__(cls, name, parents, new_dct)


class PandoraModel(metaclass=ModelMetaClass):
    """Pandora API Model

    A single object representing a Pandora data object. Subclasses are
    specified declaratively and contain Field objects as well as optionally
    other methods. The end result object after loading from JSON will be a
    normal python object with all fields declared in the schema populated and
    consumers of these instances can ignore all of the details of this class.
    """

    @classmethod
    def from_json_list(cls, api_client, data):
        """Convert a list of JSON values to a list of models
        """
        return [cls.from_json(api_client, item) for item in data]

    def __init__(self, api_client):
        self._api_client = api_client

        safe_types = (type(None), str, bytes, int, bool)

        for key, value in self._fields.items():
            default = getattr(value, "default", None)

            if not isinstance(default, safe_types):
                default = type(default)()

            setattr(self, key, default)

    @staticmethod
    def populate_fields(api_client, instance, data):
        """Populate all fields of a model with data

        Given a model with a PandoraModel superclass will enumerate all
        declared fields on that model and populate the values of their Field
        and SyntheticField classes. All declared fields will have a value after
        this function runs even if they are missing from the incoming JSON.
        """
        for key, value in instance.__class__._fields.items():
            default = getattr(value, "default", None)
            newval = data.get(value.field, default)

            if isinstance(value, SyntheticField):
                newval = value.formatter(api_client, data, newval)
                setattr(instance, key, newval)
                continue

            model_class = getattr(value, "model", None)
            if newval and model_class:
                if isinstance(newval, list):
                    newval = model_class.from_json_list(api_client, newval)
                else:
                    newval = model_class.from_json(api_client, newval)

            if newval and value.formatter:
                newval = value.formatter(api_client, newval)

            setattr(instance, key, newval)

    @classmethod
    def from_json(cls, api_client, data):
        """Convert one JSON value to a model object
        """
        self = cls(api_client)
        PandoraModel.populate_fields(api_client, self, data)
        return self

    def _base_repr(self, and_also=None):
        """Common repr logic for subclasses to hook
        """
        items = [
            "=".join((key, repr(getattr(self, key))))
            for key in sorted(self._fields.keys())
        ]

        if items:
            output = ", ".join(items)
        else:
            output = None

        if and_also:
            return "{}({}, {})".format(
                self.__class__.__name__, output, and_also
            )
        else:
            return "{}({})".format(self.__class__.__name__, output)

    def __repr__(self):
        return self._base_repr()


class PandoraListModel(PandoraModel, list):
    """Dict-like List of Pandora Models

    Processes a JSON map, expecting a key that contains a list of maps. Will
    process each item in the list, creating models for each one and a secondary
    index based on the value in each item. This object behaves like a list and
    like a dict.

    Example JSON:

        {
            "__list_key__": [
                { "__index_key__": "key", "other": "fields" },
                { "__index_key__": "key", "other": "fields" }
            ],
            "other": "fields"
        }

    __list_key__
        they key within the parent map containing a list
    __list_model__
        model class to use when constructing models for list contents
    __index_key__
        key from each object in the model list that will be used as an index
        within this object
    """

    __list_key__ = None
    __list_model__ = None
    __index_key__ = None

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._index = {}

    @classmethod
    def from_json(cls, api_client, data):
        self = cls(api_client)
        PandoraModel.populate_fields(api_client, self, data)

        for item in data[cls.__list_key__]:
            model = cls.__list_model__.from_json(api_client, item)

            if self.__index_key__:
                value = getattr(model, self.__index_key__)
                self._index[value] = model

            self.append(model)

        return self

    def __getitem__(self, key):
        item = self._index.get(key, None)
        if item:
            return item
        else:
            return list.__getitem__(self, key)

    def __contains__(self, key):
        if key in self._index:
            return True
        else:
            return list.__contains__(self, key)

    def keys(self):
        return self._index.keys()

    def items(self):
        return self._index.items()

    def __repr__(self):
        return self._base_repr(and_also=list.__repr__(self))


class PandoraDictListModel(PandoraModel, dict):
    """Dict of Models

    Processes a JSON map, expecting a key that contains a list of maps, each of
    which contain a key and a list of values which are the final models. Will
    process each item in the list, creating models for each one and storing the
    constructed models in a map indexed by the dict key. Duplicated sub-maps
    will be merged into one key for this model.

    Example JSON:

        {
            "__dict_list_key__": [
                {
                    "__dict_key__": "key for this model",
                    "__list_key__": [
                        { "model": "fields" },
                        { "model": "fields" }
                    ]
                }
            ],
            "other": "fields"
        }

    __dict_list_key__
        the key within the parent map that contains the maps that contain
        lists of models
    __dict_key__
        the key within the nested map that contains the key for this object
    __list_key__
        they key within the nested map that contains the list of models
    __list_model__
        model class to use when constructing models for list contents
    """

    __dict_list_key__ = None
    __dict_key__ = None
    __list_key__ = None
    __list_model__ = None

    @classmethod
    def from_json(cls, api_client, data):
        self = cls(api_client)
        PandoraModel.populate_fields(api_client, self, data)

        for item in data[self.__dict_list_key__]:
            key = item[self.__dict_key__]
            self[key] = []

            for part in item[self.__list_key__]:
                self[key].append(
                    cls.__list_model__.from_json(api_client, part)
                )

        return self

    def __repr__(self):
        return self._base_repr(and_also=dict.__repr__(self))