cacholong-cloud-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cacholong_cli/AsyncTyper.py +17 -0
- cacholong_cli/__init__.py +0 -0
- cacholong_cli/cli.py +110 -0
- cacholong_cli/commands/accounts.py +105 -0
- cacholong_cli/commands/addresses.py +280 -0
- cacholong_cli/commands/companies.py +100 -0
- cacholong_cli/commands/dns_records.py +275 -0
- cacholong_cli/commands/dns_templates.py +141 -0
- cacholong_cli/commands/dns_zones.py +277 -0
- cacholong_cli/commands/products.py +121 -0
- cacholong_cli/commands/purchases.py +127 -0
- cacholong_cli/common.py +179 -0
- cacholong_cli/connection.py +18 -0
- cacholong_cloud_cli-0.3.0.dist-info/METADATA +115 -0
- cacholong_cloud_cli-0.3.0.dist-info/RECORD +39 -0
- cacholong_cloud_cli-0.3.0.dist-info/WHEEL +4 -0
- cacholong_cloud_cli-0.3.0.dist-info/entry_points.txt +3 -0
- cacholong_sdk/__init__.py +18 -0
- cacholong_sdk/account.py +14 -0
- cacholong_sdk/address.py +14 -0
- cacholong_sdk/api_schema.py +124 -0
- cacholong_sdk/common.py +119 -0
- cacholong_sdk/company.py +14 -0
- cacholong_sdk/connection.py +22 -0
- cacholong_sdk/dns_record.py +14 -0
- cacholong_sdk/dns_template.py +14 -0
- cacholong_sdk/dns_zone.py +14 -0
- cacholong_sdk/exception.py +7 -0
- cacholong_sdk/product.py +14 -0
- cacholong_sdk/purchase.py +14 -0
- jsonapi_client/__init__.py +42 -0
- jsonapi_client/common.py +186 -0
- jsonapi_client/document.py +161 -0
- jsonapi_client/exceptions.py +62 -0
- jsonapi_client/filter.py +155 -0
- jsonapi_client/objects.py +187 -0
- jsonapi_client/relationships.py +447 -0
- jsonapi_client/resourceobject.py +656 -0
- jsonapi_client/session.py +755 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON API Python client
|
|
3
|
+
https://github.com/qvantel/jsonapi-client
|
|
4
|
+
|
|
5
|
+
(see JSON API specification in http://jsonapi.org/)
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2017, Qvantel
|
|
8
|
+
All rights reserved.
|
|
9
|
+
|
|
10
|
+
Redistribution and use in source and binary forms, with or without
|
|
11
|
+
modification, are permitted provided that the following conditions are met:
|
|
12
|
+
* Redistributions of source code must retain the above copyright
|
|
13
|
+
notice, this list of conditions and the following disclaimer.
|
|
14
|
+
* Redistributions in binary form must reproduce the above copyright
|
|
15
|
+
notice, this list of conditions and the following disclaimer in the
|
|
16
|
+
documentation and/or other materials provided with the distribution.
|
|
17
|
+
* Neither the name of the Qvantel nor the
|
|
18
|
+
names of its contributors may be used to endorse or promote products
|
|
19
|
+
derived from this software without specific prior written permission.
|
|
20
|
+
|
|
21
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
22
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
23
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
24
|
+
DISCLAIMED. IN NO EVENT SHALL QVANTEL BE LIABLE FOR ANY
|
|
25
|
+
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
26
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
27
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
|
28
|
+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
29
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
30
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from itertools import chain
|
|
35
|
+
from typing import Set, Optional, Awaitable, Union, Iterable, TYPE_CHECKING
|
|
36
|
+
|
|
37
|
+
from .common import (jsonify_attribute_name, AbstractJsonObject,
|
|
38
|
+
dejsonify_attribute_names, HttpMethod, HttpStatus, AttributeProxy,
|
|
39
|
+
cached_property, RelationType)
|
|
40
|
+
from .exceptions import ValidationError, DocumentInvalid
|
|
41
|
+
|
|
42
|
+
NOT_FOUND = object()
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from .session import Schema, Session
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AttributeDict(dict):
|
|
51
|
+
"""
|
|
52
|
+
Container for JSON API attributes in ResourceObjects.
|
|
53
|
+
In addition to standard dictionary this offers:
|
|
54
|
+
- access to attributes via getattr (attribute names jsonified, i.e.
|
|
55
|
+
my_attr -> my-attr)
|
|
56
|
+
- dirty-flagging attributes upon change (keep track of changed attributes)
|
|
57
|
+
and ability to generate diff structure containing only changed data
|
|
58
|
+
(for PATCHing)
|
|
59
|
+
- etc.
|
|
60
|
+
"""
|
|
61
|
+
def __init__(self, data: dict,
|
|
62
|
+
resource: 'ResourceObject',
|
|
63
|
+
name: str ='',
|
|
64
|
+
parent: 'AttributeDict'=None) -> None:
|
|
65
|
+
"""
|
|
66
|
+
:param data: Input data (dictionary) that is stored here.
|
|
67
|
+
:param resource: root ResourceObject
|
|
68
|
+
:param name: name of this attribute, if this is contained within another
|
|
69
|
+
AttributeDict. Otherwise None.
|
|
70
|
+
:param parent: Parent AttributeDict if this is contained within another
|
|
71
|
+
AttributeDict. Otherwise None
|
|
72
|
+
"""
|
|
73
|
+
super().__init__()
|
|
74
|
+
self._parent = parent
|
|
75
|
+
self._name = name
|
|
76
|
+
self._resource = resource
|
|
77
|
+
self._schema: 'Schema' = resource.session.schema
|
|
78
|
+
self._full_name: str = name
|
|
79
|
+
self._invalid = False
|
|
80
|
+
self._dirty_attributes: Set[str] = set()
|
|
81
|
+
|
|
82
|
+
if self._parent is not None and self._parent._full_name:
|
|
83
|
+
self._full_name = f'{parent._full_name}.{name}'
|
|
84
|
+
|
|
85
|
+
specification = self._schema.find_spec(self._resource.type, self._full_name)
|
|
86
|
+
|
|
87
|
+
# Using .pop() below modifies the data, so we make a shallow copy of it first
|
|
88
|
+
data = data.copy()
|
|
89
|
+
# If there's schema for this object, we will use it to construct object.
|
|
90
|
+
if specification:
|
|
91
|
+
for field_name, field_spec in specification['properties'].items():
|
|
92
|
+
if field_spec.get('type') == 'object':
|
|
93
|
+
_data = data.pop(field_name, {})
|
|
94
|
+
# Workaround a strange bug where _data is None instead of
|
|
95
|
+
# default value {}
|
|
96
|
+
if _data is None:
|
|
97
|
+
_data = {}
|
|
98
|
+
self[field_name] = AttributeDict(data=_data,
|
|
99
|
+
name=field_name,
|
|
100
|
+
parent=self,
|
|
101
|
+
resource=resource)
|
|
102
|
+
elif 'relation' in field_spec:
|
|
103
|
+
pass # Special handling for relationships
|
|
104
|
+
else:
|
|
105
|
+
self[field_name] = data.pop(field_name, field_spec.get('default'))
|
|
106
|
+
|
|
107
|
+
if data:
|
|
108
|
+
logger.warning('There was extra data (not specified in schema): %s',
|
|
109
|
+
data)
|
|
110
|
+
# If not, we will use the source data as it is.
|
|
111
|
+
if data:
|
|
112
|
+
self.update(data)
|
|
113
|
+
for key, value in data.items():
|
|
114
|
+
if isinstance(value, dict):
|
|
115
|
+
self[key] = AttributeDict(data=value, name=key, parent=self, resource=resource)
|
|
116
|
+
self._dirty_attributes.clear()
|
|
117
|
+
|
|
118
|
+
def create_map(self, attr_name):
|
|
119
|
+
"""
|
|
120
|
+
Create a new map of values (i.e. child AttributeDict) within this AttributeDict
|
|
121
|
+
|
|
122
|
+
:param attr_name: Name of this map object.
|
|
123
|
+
"""
|
|
124
|
+
self._check_invalid()
|
|
125
|
+
name = jsonify_attribute_name(attr_name)
|
|
126
|
+
self[name] = AttributeDict(data={}, name=name, parent=self, resource=self._resource)
|
|
127
|
+
|
|
128
|
+
def _check_invalid(self):
|
|
129
|
+
if self._invalid:
|
|
130
|
+
raise DocumentInvalid('Resource has been invalidated.')
|
|
131
|
+
|
|
132
|
+
def __getattr__(self, name):
|
|
133
|
+
name = jsonify_attribute_name(name)
|
|
134
|
+
if name not in self:
|
|
135
|
+
raise AttributeError(f'No such attribute '
|
|
136
|
+
f'{self._resource.type}.{self._full_name}.{name}')
|
|
137
|
+
return self[name]
|
|
138
|
+
|
|
139
|
+
def __setitem__(self, key, value):
|
|
140
|
+
if self.get(key) != value:
|
|
141
|
+
self.mark_dirty(key)
|
|
142
|
+
super().__setitem__(key, value)
|
|
143
|
+
|
|
144
|
+
def __setattr__(self, name, value):
|
|
145
|
+
if name.startswith('_'):
|
|
146
|
+
return super().__setattr__(name, value)
|
|
147
|
+
name = jsonify_attribute_name(name)
|
|
148
|
+
self[name] = value
|
|
149
|
+
|
|
150
|
+
def mark_dirty(self, name: str):
|
|
151
|
+
"""
|
|
152
|
+
Mark one attribute within this dictionary as dirty.
|
|
153
|
+
|
|
154
|
+
:param name: Name of the attribute that is to be marked as dirty.
|
|
155
|
+
"""
|
|
156
|
+
self._dirty_attributes.add(name)
|
|
157
|
+
if self._parent:
|
|
158
|
+
self._parent.mark_dirty(self._name)
|
|
159
|
+
|
|
160
|
+
def mark_clean(self):
|
|
161
|
+
"""
|
|
162
|
+
Mark all attributes recursively as clean..
|
|
163
|
+
"""
|
|
164
|
+
for attr in self._dirty_attributes:
|
|
165
|
+
value = self[attr]
|
|
166
|
+
if isinstance(value, AttributeDict):
|
|
167
|
+
value.mark_clean()
|
|
168
|
+
self._dirty_attributes.clear()
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def diff(self) -> dict:
|
|
172
|
+
"""
|
|
173
|
+
Produce JSON containing only changed elements based on dirty fields.
|
|
174
|
+
"""
|
|
175
|
+
self._check_invalid()
|
|
176
|
+
diff = {}
|
|
177
|
+
for name in self._dirty_attributes:
|
|
178
|
+
value = self[name]
|
|
179
|
+
if isinstance(value, AttributeDict) and value.is_dirty:
|
|
180
|
+
diff[name] = value.diff
|
|
181
|
+
else:
|
|
182
|
+
diff[name] = value
|
|
183
|
+
return diff
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def post_data(self) -> dict:
|
|
187
|
+
"""
|
|
188
|
+
Produce JSON which does not contain values which are null.
|
|
189
|
+
"""
|
|
190
|
+
self._check_invalid()
|
|
191
|
+
result = self.copy()
|
|
192
|
+
for key, value in self.items():
|
|
193
|
+
if isinstance(value, AttributeDict):
|
|
194
|
+
result[key] = new_value = value.post_data
|
|
195
|
+
if len(new_value) == 0:
|
|
196
|
+
del result[key]
|
|
197
|
+
|
|
198
|
+
if value is None:
|
|
199
|
+
del result[key]
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def is_dirty(self) -> bool:
|
|
204
|
+
return bool(self._dirty_attributes)
|
|
205
|
+
|
|
206
|
+
def mark_invalid(self):
|
|
207
|
+
"""
|
|
208
|
+
Recursively mark this and contained objects as invalid.
|
|
209
|
+
"""
|
|
210
|
+
self._invalid = True
|
|
211
|
+
for value in self.values():
|
|
212
|
+
if isinstance(value, AttributeDict):
|
|
213
|
+
value.mark_invalid()
|
|
214
|
+
|
|
215
|
+
def change_resource(self, new_resource: 'ResourceObject') -> None:
|
|
216
|
+
"""
|
|
217
|
+
Change parent ResourceObject recursively
|
|
218
|
+
:param new_resource: new resource that is used as a new root ResourceObject
|
|
219
|
+
"""
|
|
220
|
+
self._resource = new_resource
|
|
221
|
+
for value in self.values():
|
|
222
|
+
if isinstance(value, AttributeDict):
|
|
223
|
+
value.change_resource(new_resource)
|
|
224
|
+
|
|
225
|
+
def keys_python(self) -> Iterable[str]:
|
|
226
|
+
"""
|
|
227
|
+
Pythonized version of contained keys (attribute names).
|
|
228
|
+
"""
|
|
229
|
+
yield from dejsonify_attribute_names(self.keys())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class RelationshipDict(dict):
|
|
233
|
+
"""
|
|
234
|
+
Container for relationships that is stored in ResourceObject
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def __init__(self, data: dict, resource: 'ResourceObject'):
|
|
238
|
+
"""
|
|
239
|
+
:param data: Raw input data where Relationship objects are built from.
|
|
240
|
+
:param resource: Parent ResourceObject
|
|
241
|
+
"""
|
|
242
|
+
super().__init__()
|
|
243
|
+
self._invalid = False
|
|
244
|
+
self._resource = resource
|
|
245
|
+
self.session = resource.session
|
|
246
|
+
self._schema = schema = resource.session.schema
|
|
247
|
+
model_schema = schema.schema_for_model(resource.type)
|
|
248
|
+
if model_schema:
|
|
249
|
+
for rel_name, rel_value in model_schema['properties'].items():
|
|
250
|
+
rel_type = rel_value.get('relation')
|
|
251
|
+
if not rel_type:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
resource_types = rel_value['resource']
|
|
255
|
+
self[rel_name] = self._make_relationship(data.pop(rel_name, {}), rel_type,
|
|
256
|
+
resource_types)
|
|
257
|
+
else:
|
|
258
|
+
relationships = {key: self._make_relationship(value)
|
|
259
|
+
for key, value in data.items()}
|
|
260
|
+
self.update(relationships)
|
|
261
|
+
|
|
262
|
+
def mark_invalid(self):
|
|
263
|
+
"""
|
|
264
|
+
Mark invalid this dictionary and contained Relationships.
|
|
265
|
+
"""
|
|
266
|
+
self._invalid = True
|
|
267
|
+
for value in self.values():
|
|
268
|
+
value.mark_invalid()
|
|
269
|
+
|
|
270
|
+
def change_resource(self, new_resource: 'ResourceObject') -> None:
|
|
271
|
+
"""
|
|
272
|
+
:param new_resource: Change parent ResourceObject to new_resource.
|
|
273
|
+
"""
|
|
274
|
+
self._resource = new_resource
|
|
275
|
+
|
|
276
|
+
def _determine_class(self, data: dict, relation_type: str=None):
|
|
277
|
+
"""
|
|
278
|
+
From data and/or provided relation_type, determine Relationship class
|
|
279
|
+
to be used.
|
|
280
|
+
|
|
281
|
+
:param data: Source data dictionary
|
|
282
|
+
:param relation_type: either 'to-one' or 'to-many'
|
|
283
|
+
"""
|
|
284
|
+
from . import relationships as rel
|
|
285
|
+
if 'data' in data:
|
|
286
|
+
relationship_data = data['data']
|
|
287
|
+
if isinstance(relationship_data, list):
|
|
288
|
+
if not (not relation_type or relation_type == RelationType.TO_MANY):
|
|
289
|
+
logger.error('Conflicting information about relationship')
|
|
290
|
+
return rel.MultiRelationship
|
|
291
|
+
elif relationship_data is None or isinstance(relationship_data, dict):
|
|
292
|
+
if not(not relation_type or relation_type == RelationType.TO_ONE):
|
|
293
|
+
logger.error('Conflicting information about relationship')
|
|
294
|
+
return rel.SingleRelationship
|
|
295
|
+
else:
|
|
296
|
+
raise ValidationError('Relationship data key is invalid')
|
|
297
|
+
elif 'links' in data:
|
|
298
|
+
return rel.LinkRelationship
|
|
299
|
+
elif 'meta' in data:
|
|
300
|
+
return rel.MetaRelationship
|
|
301
|
+
elif relation_type == RelationType.TO_MANY:
|
|
302
|
+
return rel.MultiRelationship
|
|
303
|
+
elif relation_type == RelationType.TO_ONE:
|
|
304
|
+
return rel.SingleRelationship
|
|
305
|
+
else:
|
|
306
|
+
raise ValidationError('Must have either links, data or meta in relationship')
|
|
307
|
+
|
|
308
|
+
def _make_relationship(self, data, relation_type=None, resource_types=None):
|
|
309
|
+
cls = self._determine_class(data, relation_type)
|
|
310
|
+
return cls(self.session, data, resource_types=resource_types,
|
|
311
|
+
relation_type=relation_type)
|
|
312
|
+
|
|
313
|
+
def mark_clean(self):
|
|
314
|
+
"""
|
|
315
|
+
Mark all relationships as clean (not dirty).
|
|
316
|
+
"""
|
|
317
|
+
for attr in self.values():
|
|
318
|
+
attr.mark_clean()
|
|
319
|
+
|
|
320
|
+
def keys_python(self) -> Iterable[str]:
|
|
321
|
+
"""
|
|
322
|
+
Pythonized version of contained keys (relationship names)
|
|
323
|
+
"""
|
|
324
|
+
yield from dejsonify_attribute_names(self.keys())
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def is_dirty(self) -> bool:
|
|
328
|
+
return any(r.is_dirty for r in self.values())
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class ResourceObject(AbstractJsonObject):
|
|
332
|
+
"""
|
|
333
|
+
Basic JSON API resourceobject type. Field (attribute and relationship) access directly
|
|
334
|
+
via instance attributes (__getattr__). In case of namespace collisions, there is also
|
|
335
|
+
.fields attribute proxy.
|
|
336
|
+
|
|
337
|
+
http://jsonapi.org/format/#document-resource-objects
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
#: Attributes (that are not starting with _) that we want to ignore in __setattr__
|
|
341
|
+
__attributes = ['id', 'type', 'links', 'meta', 'commit_meta']
|
|
342
|
+
|
|
343
|
+
def __init__(self, session: 'Session', data: Union[dict, list]) -> None:
|
|
344
|
+
self._delete = False
|
|
345
|
+
self._commit_metadata = {}
|
|
346
|
+
super().__init__(session, data)
|
|
347
|
+
|
|
348
|
+
@cached_property
|
|
349
|
+
def fields(self):
|
|
350
|
+
"""
|
|
351
|
+
Proxy to all fields (both attributes and relationship target resources)
|
|
352
|
+
"""
|
|
353
|
+
class Proxy(AttributeProxy):
|
|
354
|
+
def __getitem__(proxy, item):
|
|
355
|
+
rv = self._attributes.get(item, NOT_FOUND)
|
|
356
|
+
if rv is NOT_FOUND:
|
|
357
|
+
return self.relationship_resource[item]
|
|
358
|
+
else:
|
|
359
|
+
return rv
|
|
360
|
+
|
|
361
|
+
def __setitem__(proxy, item, value):
|
|
362
|
+
if item in self._relationships:
|
|
363
|
+
return self._relationships[item].set(value)
|
|
364
|
+
else:
|
|
365
|
+
self._attributes[item] = value
|
|
366
|
+
|
|
367
|
+
def __dir__(proxy):
|
|
368
|
+
return chain(super().__dir__(), self._attributes.keys_python(),
|
|
369
|
+
self._relationships.keys_python())
|
|
370
|
+
|
|
371
|
+
return Proxy()
|
|
372
|
+
|
|
373
|
+
@cached_property
|
|
374
|
+
def attributes(self):
|
|
375
|
+
"""
|
|
376
|
+
Proxy to all attributes (not relationships)
|
|
377
|
+
"""
|
|
378
|
+
return AttributeProxy(self._attributes)
|
|
379
|
+
|
|
380
|
+
@cached_property
|
|
381
|
+
def relationships(self):
|
|
382
|
+
"""
|
|
383
|
+
Proxy to relationship objects
|
|
384
|
+
"""
|
|
385
|
+
class Proxy(AttributeProxy):
|
|
386
|
+
def __setitem__(proxy, key, value):
|
|
387
|
+
rel = self._relationships[key]
|
|
388
|
+
rel.set(value)
|
|
389
|
+
|
|
390
|
+
return Proxy(self._relationships)
|
|
391
|
+
|
|
392
|
+
@cached_property
|
|
393
|
+
def relationship_resource(self):
|
|
394
|
+
"""
|
|
395
|
+
If async enabled, proxy to relationship objects.
|
|
396
|
+
If async disabled, proxy to resources behind relationships.
|
|
397
|
+
"""
|
|
398
|
+
class Proxy(AttributeProxy):
|
|
399
|
+
def __getitem__(proxy, item):
|
|
400
|
+
rel = self.relationships[item]
|
|
401
|
+
if self.session.enable_async:
|
|
402
|
+
# With async it's more convenient to access Relationship object
|
|
403
|
+
return self.relationships[item]
|
|
404
|
+
|
|
405
|
+
if rel.is_single:
|
|
406
|
+
return rel.resource
|
|
407
|
+
else:
|
|
408
|
+
return rel.resources
|
|
409
|
+
|
|
410
|
+
return Proxy()
|
|
411
|
+
|
|
412
|
+
def _handle_data(self, data):
|
|
413
|
+
from .objects import Links, Meta
|
|
414
|
+
self.id = data['id']
|
|
415
|
+
self.type = data['type']
|
|
416
|
+
self.links = Links(self.session, data.get('links', {}))
|
|
417
|
+
self.meta = Meta(self.session, data.get('meta', {}))
|
|
418
|
+
|
|
419
|
+
self._relationships = RelationshipDict(
|
|
420
|
+
data=data.get('relationships', {}),
|
|
421
|
+
resource=self)
|
|
422
|
+
self._attributes = AttributeDict(data=data.get('attributes', {}), resource=self)
|
|
423
|
+
|
|
424
|
+
def create_map(self, name):
|
|
425
|
+
"""
|
|
426
|
+
Create a map of values (AttributeDict) with name in attribute container.
|
|
427
|
+
"""
|
|
428
|
+
return self._attributes.create_map(name)
|
|
429
|
+
|
|
430
|
+
def __dir__(self):
|
|
431
|
+
return chain(super().__dir__(), self._attributes.keys_python(),
|
|
432
|
+
self._relationships.keys_python())
|
|
433
|
+
|
|
434
|
+
def __str__(self):
|
|
435
|
+
return f'{self.type}: {self.id} ({id(self)})'
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def json(self) -> dict:
|
|
439
|
+
"""
|
|
440
|
+
Return full JSON API resource object as json-serializable dictionary.
|
|
441
|
+
"""
|
|
442
|
+
return self._commit_data(full=True)['data']
|
|
443
|
+
|
|
444
|
+
@property
|
|
445
|
+
def is_dirty(self) -> bool:
|
|
446
|
+
return (self.id is None
|
|
447
|
+
or self._delete
|
|
448
|
+
or self._attributes.is_dirty
|
|
449
|
+
or self._relationships.is_dirty)
|
|
450
|
+
|
|
451
|
+
def __getitem__(self, item):
|
|
452
|
+
return self.fields[item]
|
|
453
|
+
|
|
454
|
+
def __setitem__(self, item, value):
|
|
455
|
+
self.fields[item] = value
|
|
456
|
+
|
|
457
|
+
def __getattr__(self, attr_name):
|
|
458
|
+
return getattr(self.fields, attr_name)
|
|
459
|
+
|
|
460
|
+
def __setattr__(self, attr_name, value):
|
|
461
|
+
if attr_name.startswith('_') or attr_name in self.__attributes:
|
|
462
|
+
return super().__setattr__(attr_name, value)
|
|
463
|
+
|
|
464
|
+
return setattr(self.fields, attr_name, value)
|
|
465
|
+
|
|
466
|
+
@property
|
|
467
|
+
def dirty_fields(self):
|
|
468
|
+
return (self._attributes._dirty_attributes |
|
|
469
|
+
{name for name, rel in self._relationships.items() if rel.is_dirty})
|
|
470
|
+
|
|
471
|
+
@property
|
|
472
|
+
def url(self) -> str:
|
|
473
|
+
url = str(self.links.self)
|
|
474
|
+
return url or self.id and f'{self.session.url_prefix}/{self.type}/{self.id}'
|
|
475
|
+
|
|
476
|
+
@property
|
|
477
|
+
def post_url(self) -> str:
|
|
478
|
+
return f'{self.session.url_prefix}/{self.type}'
|
|
479
|
+
|
|
480
|
+
def validate(self):
|
|
481
|
+
"""
|
|
482
|
+
Validate our attributes against schema.
|
|
483
|
+
"""
|
|
484
|
+
# TODO: what about relationships? Shouldn't we somehow validate those too?
|
|
485
|
+
self.session.schema.validate(self.type, self._attributes)
|
|
486
|
+
|
|
487
|
+
def _commit_data(self, meta: dict = None, full: bool=False) -> dict:
|
|
488
|
+
"""
|
|
489
|
+
Give JSON data for PATCH/POST request, requested by commit
|
|
490
|
+
"""
|
|
491
|
+
meta = meta or self._commit_metadata
|
|
492
|
+
|
|
493
|
+
res_json = {'type': self.type}
|
|
494
|
+
if self.id:
|
|
495
|
+
res_json['id'] = self.id
|
|
496
|
+
|
|
497
|
+
if self._http_method == 'post' or full:
|
|
498
|
+
# When creating new resources, we need to specify explicitly all
|
|
499
|
+
# relationships, as SingleRelationships, or MultiRelationships.
|
|
500
|
+
|
|
501
|
+
relationships = {key: {'data': value.as_json_resource_identifiers}
|
|
502
|
+
for key, value in self._relationships.items() if bool(value)}
|
|
503
|
+
res_json.update({
|
|
504
|
+
'attributes': self._attributes.post_data,
|
|
505
|
+
'relationships': relationships,
|
|
506
|
+
})
|
|
507
|
+
else:
|
|
508
|
+
changed_relationships = {key: {'data': value.as_json_resource_identifiers}
|
|
509
|
+
for key, value in self._relationships.items()
|
|
510
|
+
if value.is_dirty}
|
|
511
|
+
res_json.update({
|
|
512
|
+
'attributes': self._attributes.diff,
|
|
513
|
+
'relationships': changed_relationships,
|
|
514
|
+
})
|
|
515
|
+
if meta:
|
|
516
|
+
res_json['meta'] = meta
|
|
517
|
+
return {'data': res_json}
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def _http_method(self):
|
|
521
|
+
return HttpMethod.PATCH if self.id else HttpMethod.POST
|
|
522
|
+
|
|
523
|
+
def _pre_commit(self, custom_url):
|
|
524
|
+
url = custom_url or (self.post_url if self._http_method == HttpMethod.POST else self.url)
|
|
525
|
+
logger.info('Committing %s to %s', self, url)
|
|
526
|
+
self.validate()
|
|
527
|
+
return url
|
|
528
|
+
|
|
529
|
+
def _post_commit(self, status, result, location):
|
|
530
|
+
if status in HttpStatus.HAS_RESOURCES:
|
|
531
|
+
self._update_resource(result, location)
|
|
532
|
+
|
|
533
|
+
# If no resources are returned (which is the case when 202 (Accepted)
|
|
534
|
+
# is received for PATCH, for example).
|
|
535
|
+
self.mark_clean()
|
|
536
|
+
|
|
537
|
+
if status == HttpStatus.ACCEPTED_202:
|
|
538
|
+
return self.session.read(result, location, no_cache=True).resource
|
|
539
|
+
|
|
540
|
+
async def _commit_async(self, url: str= '', meta=None) -> None:
|
|
541
|
+
self.session.assert_async()
|
|
542
|
+
if self._delete:
|
|
543
|
+
return await self._perform_delete_async(url)
|
|
544
|
+
|
|
545
|
+
url = self._pre_commit(url)
|
|
546
|
+
status, result, location = await self.session.http_request_async(
|
|
547
|
+
self._http_method, url,
|
|
548
|
+
self._commit_data(meta))
|
|
549
|
+
return self._post_commit(status, result, location)
|
|
550
|
+
|
|
551
|
+
def _commit_sync(self, url: str= '', meta: dict=None) -> 'None':
|
|
552
|
+
self.session.assert_sync()
|
|
553
|
+
if self._delete:
|
|
554
|
+
return self._perform_delete(url)
|
|
555
|
+
|
|
556
|
+
url = self._pre_commit(url)
|
|
557
|
+
status, result, location = self.session.http_request(self._http_method, url,
|
|
558
|
+
self._commit_data(meta))
|
|
559
|
+
return self._post_commit(status, result, location)
|
|
560
|
+
|
|
561
|
+
def commit(self, custom_url: str = '', meta: dict = None) \
|
|
562
|
+
-> 'Union[None, ResourceObject, Awaitable[Optional[ResourceObject]]':
|
|
563
|
+
"""
|
|
564
|
+
Commit (PATCH/POST) this resource to server.
|
|
565
|
+
|
|
566
|
+
:param custom_url: Use this url instead of automatically determined one.
|
|
567
|
+
:param meta: Optional metadata that is passed to server in POST/PATCH request
|
|
568
|
+
|
|
569
|
+
If in async mode, this needs to be awaited.
|
|
570
|
+
"""
|
|
571
|
+
if not self._delete:
|
|
572
|
+
self.validate()
|
|
573
|
+
|
|
574
|
+
if self.session.enable_async:
|
|
575
|
+
return self._commit_async(custom_url, meta)
|
|
576
|
+
else:
|
|
577
|
+
return self._commit_sync(custom_url, meta)
|
|
578
|
+
|
|
579
|
+
def _update_resource(self,
|
|
580
|
+
resource_dict: 'Union[dict, ResourceObject]',
|
|
581
|
+
location: str=None) -> None:
|
|
582
|
+
if isinstance(resource_dict, dict):
|
|
583
|
+
new_res = self.session.read(resource_dict, location, no_cache=True).resource
|
|
584
|
+
else:
|
|
585
|
+
new_res = resource_dict
|
|
586
|
+
self.id = new_res.id
|
|
587
|
+
self._attributes.mark_invalid()
|
|
588
|
+
self._relationships.mark_invalid()
|
|
589
|
+
|
|
590
|
+
self._attributes: AttributeDict = new_res._attributes
|
|
591
|
+
self._attributes.change_resource(self)
|
|
592
|
+
self._relationships: RelationshipDict = new_res._relationships
|
|
593
|
+
self._relationships.change_resource(self)
|
|
594
|
+
self.meta = new_res.meta
|
|
595
|
+
self.links = new_res.links
|
|
596
|
+
self.session.add_resources(self)
|
|
597
|
+
|
|
598
|
+
def _refresh_sync(self):
|
|
599
|
+
self.session.assert_sync()
|
|
600
|
+
new_res = self.session.fetch_resource_by_resource_identifier(self, force=True)
|
|
601
|
+
self._update_resource(new_res)
|
|
602
|
+
|
|
603
|
+
async def _refresh_async(self):
|
|
604
|
+
self.session.assert_async()
|
|
605
|
+
new_res = await self.session.fetch_resource_by_resource_identifier_async(
|
|
606
|
+
self,
|
|
607
|
+
force=True)
|
|
608
|
+
self._update_resource(new_res)
|
|
609
|
+
|
|
610
|
+
def refresh(self):
|
|
611
|
+
"""
|
|
612
|
+
Manual way to refresh the data contained in this ResourceObject from server.
|
|
613
|
+
|
|
614
|
+
If in async mode, this needs to be awaited.
|
|
615
|
+
"""
|
|
616
|
+
if self.session.enable_async:
|
|
617
|
+
return self._refresh_async()
|
|
618
|
+
else:
|
|
619
|
+
return self._refresh_sync()
|
|
620
|
+
|
|
621
|
+
def delete(self):
|
|
622
|
+
"""
|
|
623
|
+
Mark resource to be deleted. Resource will be deleted upon commit.
|
|
624
|
+
"""
|
|
625
|
+
self._delete = True
|
|
626
|
+
|
|
627
|
+
def _perform_delete(self, url=''):
|
|
628
|
+
url = url or self.url
|
|
629
|
+
self.session.http_request(HttpMethod.DELETE, url, {})
|
|
630
|
+
self.session.remove_resource(self)
|
|
631
|
+
|
|
632
|
+
async def _perform_delete_async(self, url=''):
|
|
633
|
+
url = url or self.url
|
|
634
|
+
await self.session.http_request_async(HttpMethod.DELETE, url, {})
|
|
635
|
+
self.session.remove_resource(self)
|
|
636
|
+
|
|
637
|
+
def mark_clean(self):
|
|
638
|
+
"""
|
|
639
|
+
Mark this resource and attributes / relationships as clean (not dirty).
|
|
640
|
+
"""
|
|
641
|
+
self._attributes.mark_clean()
|
|
642
|
+
self._relationships.mark_clean()
|
|
643
|
+
|
|
644
|
+
def mark_invalid(self):
|
|
645
|
+
"""
|
|
646
|
+
Mark this resource and it's related objects as invalid.
|
|
647
|
+
"""
|
|
648
|
+
super().mark_invalid()
|
|
649
|
+
self._attributes.mark_invalid()
|
|
650
|
+
self._relationships.mark_invalid()
|
|
651
|
+
self.meta.mark_invalid()
|
|
652
|
+
self.links.mark_invalid()
|
|
653
|
+
|
|
654
|
+
def as_resource_identifier_dict(self) -> dict:
|
|
655
|
+
return {'id': self.id, 'type': self.type}
|
|
656
|
+
|