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
|
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))
|