aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Crute <mike@crute.us>2019-04-02 02:57:04 +0000
committerMike Crute <mike@crute.us>2019-04-02 03:24:37 +0000
commit8d316038441194526d6183c0496f765c6ff5a418 (patch)
treebe9ba05bed1e0edda7d788b9dfb7104ab9d581f6
parent98b2f30026e060ed578e6105ca4c0df7a69a9263 (diff)
downloadpydora-8d316038441194526d6183c0496f765c6ff5a418.tar.bz2
pydora-8d316038441194526d6183c0496f765c6ff5a418.tar.xz
pydora-8d316038441194526d6183c0496f765c6ff5a418.zip
Migrate model bases to _base
-rw-r--r--pandora/models/__init__.py309
-rw-r--r--pandora/models/_base.py309
-rw-r--r--pandora/models/pandora.py4
-rw-r--r--tests/test_pandora/test_models.py2
4 files changed, 312 insertions, 312 deletions
diff --git a/pandora/models/__init__.py b/pandora/models/__init__.py
index 34ee2f7..e69de29 100644
--- a/pandora/models/__init__.py
+++ b/pandora/models/__init__.py
@@ -1,309 +0,0 @@
1from datetime import datetime
2from collections import namedtuple
3
4
5class Field(namedtuple("Field", ["field", "default", "formatter", "model"])):
6 """Model Field
7
8 Model fields represent JSON key/value pairs. When added to a PandoraModel
9 the describe the unpacking logic for the API JSON and will be replaced at
10 runtime with the values from the parsed JSON or their defaults.
11
12 field
13 name of the field from the incoming JSON
14 default
15 default value if key does not exist in the incoming JSON, None if not
16 provided
17 formatter
18 formatter function accepting an API client and the value of the field
19 as arguments, will be called on the value of the data for the field key
20 in the incoming JSON. The return value of this function is used as the
21 value of the field on the model object.
22 model
23 the model class that the value of this field should be constructed into
24 the model construction logic will handle building a list or single
25 model based on the type of data in the JSON
26 """
27
28 def __new__(cls, field, default=None, formatter=None, model=None):
29 return super(Field, cls).__new__(cls, field, default, formatter, model)
30
31
32class SyntheticField(namedtuple("SyntheticField", ["field"])):
33 """Field Requiring Synthesis
34
35 Synthetic fields may exist in the data but generally do not and require
36 additional synthesis to arrive ate a sane value. Subclasses must define
37 a formatter method that receives an API client, field name, and full data
38 payload.
39 """
40
41 def formatter(self, api_client, data, newval): # pragma: no cover
42 """Format Value for Model
43
44 The return value of this method is used as a value for the field in the
45 model of which this field is a member
46
47 api_client
48 instance of a Pandora API client
49 data
50 complete JSON data blob for the parent model of which this field is
51 a member
52 newval
53 the value of this field as retrieved from the JSON data after
54 having resolved default value logic
55 """
56 raise NotImplementedError
57
58
59class DateField(SyntheticField):
60 """Date Field
61
62 Handles a JSON map that contains a time field which is the timestamp with
63 nanosecond precision.
64 """
65
66 def formatter(self, api_client, data, value):
67 if not value:
68 return None
69
70 return datetime.utcfromtimestamp(value["time"] / 1000)
71
72
73class ModelMetaClass(type):
74
75 def __new__(cls, name, parents, dct):
76 dct["_fields"] = fields = {}
77 new_dct = dct.copy()
78
79 for key, val in dct.items():
80 if key.startswith("__"):
81 continue
82
83 if isinstance(val, Field) or isinstance(val, SyntheticField):
84 fields[key] = val
85 del new_dct[key]
86
87 return super(ModelMetaClass, cls).__new__(cls, name, parents, new_dct)
88
89
90class PandoraModel(object, metaclass=ModelMetaClass):
91 """Pandora API Model
92
93 A single object representing a Pandora data object. Subclasses are
94 specified declaratively and contain Field objects as well as optionally
95 other methods. The end result object after loading from JSON will be a
96 normal python object with all fields declared in the schema populated and
97 consumers of these instances can ignore all of the details of this class.
98 """
99
100 @classmethod
101 def from_json_list(cls, api_client, data):
102 """Convert a list of JSON values to a list of models
103 """
104 return [cls.from_json(api_client, item) for item in data]
105
106 def __init__(self, api_client):
107 self._api_client = api_client
108
109 safe_types = (type(None), str, bytes, int, bool)
110
111 for key, value in self._fields.items():
112 default = getattr(value, "default", None)
113
114 if not isinstance(default, safe_types):
115 default = type(default)()
116
117 setattr(self, key, default)
118
119 @staticmethod
120 def populate_fields(api_client, instance, data):
121 """Populate all fields of a model with data
122
123 Given a model with a PandoraModel superclass will enumerate all
124 declared fields on that model and populate the values of their Field
125 and SyntheticField classes. All declared fields will have a value after
126 this function runs even if they are missing from the incoming JSON.
127 """
128 for key, value in instance.__class__._fields.items():
129 default = getattr(value, "default", None)
130 newval = data.get(value.field, default)
131
132 if isinstance(value, SyntheticField):
133 newval = value.formatter(api_client, data, newval)
134 setattr(instance, key, newval)
135 continue
136
137 model_class = getattr(value, "model", None)
138 if newval and model_class:
139 if isinstance(newval, list):
140 newval = model_class.from_json_list(api_client, newval)
141 else:
142 newval = model_class.from_json(api_client, newval)
143
144 if newval and value.formatter:
145 newval = value.formatter(api_client, newval)
146
147 setattr(instance, key, newval)
148
149 @classmethod
150 def from_json(cls, api_client, data):
151 """Convert one JSON value to a model object
152 """
153 self = cls(api_client)
154 PandoraModel.populate_fields(api_client, self, data)
155 return self
156
157 def _base_repr(self, and_also=None):
158 """Common repr logic for subclasses to hook
159 """
160 items = [
161 "=".join((key, repr(getattr(self, key))))
162 for key in sorted(self._fields.keys())]
163
164 if items:
165 output = ", ".join(items)
166 else:
167 output = None
168
169 if and_also:
170 return "{}({}, {})".format(self.__class__.__name__,
171 output, and_also)
172 else:
173 return "{}({})".format(self.__class__.__name__, output)
174
175 def __repr__(self):
176 return self._base_repr()
177
178
179class PandoraListModel(PandoraModel, list):
180 """Dict-like List of Pandora Models
181
182 Processes a JSON map, expecting a key that contains a list of maps. Will
183 process each item in the list, creating models for each one and a secondary
184 index based on the value in each item. This object behaves like a list and
185 like a dict.
186
187 Example JSON:
188
189 {
190 "__list_key__": [
191 { "__index_key__": "key", "other": "fields" },
192 { "__index_key__": "key", "other": "fields" }
193 ],
194 "other": "fields"
195 }
196
197 __list_key__
198 they key within the parent map containing a list
199 __list_model__
200 model class to use when constructing models for list contents
201 __index_key__
202 key from each object in the model list that will be used as an index
203 within this object
204 """
205
206 __list_key__ = None
207 __list_model__ = None
208 __index_key__ = None
209
210 def __init__(self, *args, **kwargs):
211 super(PandoraListModel, self).__init__(*args, **kwargs)
212 self._index = {}
213
214 @classmethod
215 def from_json(cls, api_client, data):
216 self = cls(api_client)
217 PandoraModel.populate_fields(api_client, self, data)
218
219 for item in data[cls.__list_key__]:
220 model = cls.__list_model__.from_json(api_client, item)
221
222 if self.__index_key__:
223 value = getattr(model, self.__index_key__)
224 self._index[value] = model
225
226 self.append(model)
227
228 return self
229
230 def __getitem__(self, key):
231 item = self._index.get(key, None)
232 if item:
233 return item
234 else:
235 return list.__getitem__(self, key)
236
237 def __contains__(self, key):
238 if key in self._index:
239 return True
240 else:
241 return list.__contains__(self, key)
242
243 def keys(self):
244 return self._index.keys()
245
246 def items(self):
247 return self._index.items()
248
249 def __repr__(self):
250 return self._base_repr(and_also=list.__repr__(self))
251
252
253class PandoraDictListModel(PandoraModel, dict):
254 """Dict of Models
255
256 Processes a JSON map, expecting a key that contains a list of maps, each of
257 which contain a key and a list of values which are the final models. Will
258 process each item in the list, creating models for each one and storing the
259 constructed models in a map indexed by the dict key. Duplicated sub-maps
260 will be merged into one key for this model.
261
262 Example JSON:
263
264 {
265 "__dict_list_key__": [
266 {
267 "__dict_key__": "key for this model",
268 "__list_key__": [
269 { "model": "fields" },
270 { "model": "fields" }
271 ]
272 }
273 ],
274 "other": "fields"
275 }
276
277 __dict_list_key__
278 the key within the parent map that contains the maps that contain
279 lists of models
280 __dict_key__
281 the key within the nested map that contains the key for this object
282 __list_key__
283 they key within the nested map that contains the list of models
284 __list_model__
285 model class to use when constructing models for list contents
286 """
287
288 __dict_list_key__ = None
289 __dict_key__ = None
290 __list_key__ = None
291 __list_model__ = None
292
293 @classmethod
294 def from_json(cls, api_client, data):
295 self = cls(api_client)
296 PandoraModel.populate_fields(api_client, self, data)
297
298 for item in data[self.__dict_list_key__]:
299 key = item[self.__dict_key__]
300 self[key] = []
301
302 for part in item[self.__list_key__]:
303 self[key].append(
304 cls.__list_model__.from_json(api_client, part))
305
306 return self
307
308 def __repr__(self):
309 return self._base_repr(and_also=dict.__repr__(self))
diff --git a/pandora/models/_base.py b/pandora/models/_base.py
new file mode 100644
index 0000000..34ee2f7
--- /dev/null
+++ b/pandora/models/_base.py
@@ -0,0 +1,309 @@
1from datetime import datetime
2from collections import namedtuple
3
4
5class Field(namedtuple("Field", ["field", "default", "formatter", "model"])):
6 """Model Field
7
8 Model fields represent JSON key/value pairs. When added to a PandoraModel
9 the describe the unpacking logic for the API JSON and will be replaced at
10 runtime with the values from the parsed JSON or their defaults.
11
12 field
13 name of the field from the incoming JSON
14 default
15 default value if key does not exist in the incoming JSON, None if not
16 provided
17 formatter
18 formatter function accepting an API client and the value of the field
19 as arguments, will be called on the value of the data for the field key
20 in the incoming JSON. The return value of this function is used as the
21 value of the field on the model object.
22 model
23 the model class that the value of this field should be constructed into
24 the model construction logic will handle building a list or single
25 model based on the type of data in the JSON
26 """
27
28 def __new__(cls, field, default=None, formatter=None, model=None):
29 return super(Field, cls).__new__(cls, field, default, formatter, model)
30
31
32class SyntheticField(namedtuple("SyntheticField", ["field"])):
33 """Field Requiring Synthesis
34
35 Synthetic fields may exist in the data but generally do not and require
36 additional synthesis to arrive ate a sane value. Subclasses must define
37 a formatter method that receives an API client, field name, and full data
38 payload.
39 """
40
41 def formatter(self, api_client, data, newval): # pragma: no cover
42 """Format Value for Model
43
44 The return value of this method is used as a value for the field in the
45 model of which this field is a member
46
47 api_client
48 instance of a Pandora API client
49 data
50 complete JSON data blob for the parent model of which this field is
51 a member
52 newval
53 the value of this field as retrieved from the JSON data after
54 having resolved default value logic
55 """
56 raise NotImplementedError
57
58
59class DateField(SyntheticField):
60 """Date Field
61
62 Handles a JSON map that contains a time field which is the timestamp with
63 nanosecond precision.
64 """
65
66 def formatter(self, api_client, data, value):
67 if not value:
68 return None
69
70 return datetime.utcfromtimestamp(value["time"] / 1000)
71
72
73class ModelMetaClass(type):
74
75 def __new__(cls, name, parents, dct):
76 dct["_fields"] = fields = {}
77 new_dct = dct.copy()
78
79 for key, val in dct.items():
80 if key.startswith("__"):
81 continue
82
83 if isinstance(val, Field) or isinstance(val, SyntheticField):
84 fields[key] = val
85 del new_dct[key]
86
87 return super(ModelMetaClass, cls).__new__(cls, name, parents, new_dct)
88
89
90class PandoraModel(object, metaclass=ModelMetaClass):
91 """Pandora API Model
92
93 A single object representing a Pandora data object. Subclasses are
94 specified declaratively and contain Field objects as well as optionally
95 other methods. The end result object after loading from JSON will be a
96 normal python object with all fields declared in the schema populated and
97 consumers of these instances can ignore all of the details of this class.
98 """
99
100 @classmethod
101 def from_json_list(cls, api_client, data):
102 """Convert a list of JSON values to a list of models
103 """
104 return [cls.from_json(api_client, item) for item in data]
105
106 def __init__(self, api_client):
107 self._api_client = api_client
108
109 safe_types = (type(None), str, bytes, int, bool)
110
111 for key, value in self._fields.items():
112 default = getattr(value, "default", None)
113
114 if not isinstance(default, safe_types):
115 default = type(default)()
116
117 setattr(self, key, default)
118
119 @staticmethod
120 def populate_fields(api_client, instance, data):
121 """Populate all fields of a model with data
122
123 Given a model with a PandoraModel superclass will enumerate all
124 declared fields on that model and populate the values of their Field
125 and SyntheticField classes. All declared fields will have a value after
126 this function runs even if they are missing from the incoming JSON.
127 """
128 for key, value in instance.__class__._fields.items():
129 default = getattr(value, "default", None)
130 newval = data.get(value.field, default)
131
132 if isinstance(value, SyntheticField):
133 newval = value.formatter(api_client, data, newval)
134 setattr(instance, key, newval)
135 continue
136
137 model_class = getattr(value, "model", None)
138 if newval and model_class:
139 if isinstance(newval, list):
140 newval = model_class.from_json_list(api_client, newval)
141 else:
142 newval = model_class.from_json(api_client, newval)
143
144 if newval and value.formatter:
145 newval = value.formatter(api_client, newval)
146
147 setattr(instance, key, newval)
148
149 @classmethod
150 def from_json(cls, api_client, data):
151 """Convert one JSON value to a model object
152 """
153 self = cls(api_client)
154 PandoraModel.populate_fields(api_client, self, data)
155 return self
156
157 def _base_repr(self, and_also=None):
158 """Common repr logic for subclasses to hook
159 """
160 items = [
161 "=".join((key, repr(getattr(self, key))))
162 for key in sorted(self._fields.keys())]
163
164 if items:
165 output = ", ".join(items)
166 else:
167 output = None
168
169 if and_also:
170 return "{}({}, {})".format(self.__class__.__name__,
171 output, and_also)
172 else:
173 return "{}({})".format(self.__class__.__name__, output)
174
175 def __repr__(self):
176 return self._base_repr()
177
178
179class PandoraListModel(PandoraModel, list):
180 """Dict-like List of Pandora Models
181
182 Processes a JSON map, expecting a key that contains a list of maps. Will
183 process each item in the list, creating models for each one and a secondary
184 index based on the value in each item. This object behaves like a list and
185 like a dict.
186
187 Example JSON:
188
189 {
190 "__list_key__": [
191 { "__index_key__": "key", "other": "fields" },
192 { "__index_key__": "key", "other": "fields" }
193 ],
194 "other": "fields"
195 }
196
197 __list_key__
198 they key within the parent map containing a list
199 __list_model__
200 model class to use when constructing models for list contents
201 __index_key__
202 key from each object in the model list that will be used as an index
203 within this object
204 """
205
206 __list_key__ = None
207 __list_model__ = None
208 __index_key__ = None
209
210 def __init__(self, *args, **kwargs):
211 super(PandoraListModel, self).__init__(*args, **kwargs)
212 self._index = {}
213
214 @classmethod
215 def from_json(cls, api_client, data):
216 self = cls(api_client)
217 PandoraModel.populate_fields(api_client, self, data)
218
219 for item in data[cls.__list_key__]:
220 model = cls.__list_model__.from_json(api_client, item)
221
222 if self.__index_key__:
223 value = getattr(model, self.__index_key__)
224 self._index[value] = model
225
226 self.append(model)
227
228 return self
229
230 def __getitem__(self, key):
231 item = self._index.get(key, None)
232 if item:
233 return item
234 else:
235 return list.__getitem__(self, key)
236
237 def __contains__(self, key):
238 if key in self._index:
239 return True
240 else:
241 return list.__contains__(self, key)
242
243 def keys(self):
244 return self._index.keys()
245
246 def items(self):
247 return self._index.items()
248
249 def __repr__(self):
250 return self._base_repr(and_also=list.__repr__(self))
251
252
253class PandoraDictListModel(PandoraModel, dict):
254 """Dict of Models
255
256 Processes a JSON map, expecting a key that contains a list of maps, each of
257 which contain a key and a list of values which are the final models. Will
258 process each item in the list, creating models for each one and storing the
259 constructed models in a map indexed by the dict key. Duplicated sub-maps
260 will be merged into one key for this model.
261
262 Example JSON:
263
264 {
265 "__dict_list_key__": [
266 {
267 "__dict_key__": "key for this model",
268 "__list_key__": [
269 { "model": "fields" },
270 { "model": "fields" }
271 ]
272 }
273 ],
274 "other": "fields"
275 }
276
277 __dict_list_key__
278 the key within the parent map that contains the maps that contain
279 lists of models
280 __dict_key__
281 the key within the nested map that contains the key for this object
282 __list_key__
283 they key within the nested map that contains the list of models
284 __list_model__
285 model class to use when constructing models for list contents
286 """
287
288 __dict_list_key__ = None
289 __dict_key__ = None
290 __list_key__ = None
291 __list_model__ = None
292
293 @classmethod
294 def from_json(cls, api_client, data):
295 self = cls(api_client)
296 PandoraModel.populate_fields(api_client, self, data)
297
298 for item in data[self.__dict_list_key__]:
299 key = item[self.__dict_key__]
300 self[key] = []
301
302 for part in item[self.__list_key__]:
303 self[key].append(
304 cls.__list_model__.from_json(api_client, part))
305
306 return self
307
308 def __repr__(self):
309 return self._base_repr(and_also=dict.__repr__(self))
diff --git a/pandora/models/pandora.py b/pandora/models/pandora.py
index ecc1225..341b0be 100644
--- a/pandora/models/pandora.py
+++ b/pandora/models/pandora.py
@@ -2,8 +2,8 @@ from enum import Enum
2 2
3from ..client import BaseAPIClient 3from ..client import BaseAPIClient
4from ..errors import ParameterMissing 4from ..errors import ParameterMissing
5from . import Field, DateField, SyntheticField 5from ._base import Field, DateField, SyntheticField
6from . import PandoraModel, PandoraListModel, PandoraDictListModel 6from ._base import PandoraModel, PandoraListModel, PandoraDictListModel
7 7
8 8
9class AdditionalAudioUrl(Enum): 9class AdditionalAudioUrl(Enum):
diff --git a/tests/test_pandora/test_models.py b/tests/test_pandora/test_models.py
index 13ca366..8e0e0b5 100644
--- a/tests/test_pandora/test_models.py
+++ b/tests/test_pandora/test_models.py
@@ -5,7 +5,7 @@ from unittest.mock import Mock, patch
5from pandora.client import APIClient 5from pandora.client import APIClient
6from pandora.errors import ParameterMissing 6from pandora.errors import ParameterMissing
7 7
8import pandora.models as m 8import pandora.models._base as m
9import pandora.models.pandora as pm 9import pandora.models.pandora as pm
10 10
11 11