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,755 @@
|
|
|
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 collections
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
from itertools import chain
|
|
37
|
+
from typing import (TYPE_CHECKING, Set, Optional, Tuple, Dict, Union, Iterable,
|
|
38
|
+
AsyncIterable, Awaitable, AsyncIterator, Iterator, List)
|
|
39
|
+
from urllib.parse import ParseResult, urlparse
|
|
40
|
+
|
|
41
|
+
import jsonschema
|
|
42
|
+
|
|
43
|
+
from .common import jsonify_attribute_name, error_from_response, \
|
|
44
|
+
HttpStatus, HttpMethod
|
|
45
|
+
from .exceptions import DocumentError, AsyncError
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from asyncio import AbstractEventLoop
|
|
49
|
+
from .objects import ResourceIdentifier
|
|
50
|
+
from .document import Document
|
|
51
|
+
from .resourceobject import ResourceObject
|
|
52
|
+
from .relationships import ResourceTuple
|
|
53
|
+
from .filter import Modifier
|
|
54
|
+
|
|
55
|
+
import json
|
|
56
|
+
from uuid import UUID
|
|
57
|
+
|
|
58
|
+
class UUIDEncoder(json.JSONEncoder):
|
|
59
|
+
def default(self, obj):
|
|
60
|
+
if isinstance(obj, UUID):
|
|
61
|
+
return str(obj)
|
|
62
|
+
return json.JSONEncoder.default(self, obj)
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
NOT_FOUND = object()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SessionHistory(list):
|
|
69
|
+
@property
|
|
70
|
+
def latest(self):
|
|
71
|
+
if len(self):
|
|
72
|
+
return self[-1]
|
|
73
|
+
else:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SessionHistoryItem:
|
|
78
|
+
def __init__(self, session: 'Session', url: str, http_method: str, response, send_json: dict=None):
|
|
79
|
+
self.session = session
|
|
80
|
+
self.response = response
|
|
81
|
+
self.url = url
|
|
82
|
+
self.send_json = send_json
|
|
83
|
+
self.http_method = http_method.upper()
|
|
84
|
+
|
|
85
|
+
def __repr__(self):
|
|
86
|
+
content = self.content
|
|
87
|
+
content_cut_after = 100
|
|
88
|
+
if len(content) > content_cut_after:
|
|
89
|
+
content = f"{content[:content_cut_after]}..."
|
|
90
|
+
request_str = f"Request: {self.url}\n method: {self.http_method}\n"
|
|
91
|
+
if self.send_json:
|
|
92
|
+
request_content = json.dumps(self.send_json)
|
|
93
|
+
if len(request_content) > content_cut_after:
|
|
94
|
+
request_content = f"{request_content[:content_cut_after]}..."
|
|
95
|
+
request_str += f" payload: {request_content}\n"
|
|
96
|
+
r = f"{request_str}" \
|
|
97
|
+
f"Response: \n status code: {self.status_code}\n" \
|
|
98
|
+
f" content length: {self.content_length}\n" \
|
|
99
|
+
f" content: {content}"
|
|
100
|
+
return r
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def content(self):
|
|
104
|
+
if self.session.enable_async:
|
|
105
|
+
return self.response._body
|
|
106
|
+
else:
|
|
107
|
+
return self.response.content
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def response_content(self):
|
|
111
|
+
"""
|
|
112
|
+
This is used to pretty print the contents for debugging purposes.
|
|
113
|
+
If you don't want pretty print, please use self.response.content directly
|
|
114
|
+
Example: If session is s, you can pretty print out the latest content by
|
|
115
|
+
print(s.history.latest.content)
|
|
116
|
+
"""
|
|
117
|
+
loaded = json.loads(self.content)
|
|
118
|
+
return json.dumps(loaded, indent=4, sort_keys=True)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def payload(self):
|
|
122
|
+
return json.dumps(self.send_json, indent=4, sort_keys=True)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def content_length(self):
|
|
126
|
+
return len(self.content)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def headers(self):
|
|
130
|
+
return self.response.headers
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def status_code(self):
|
|
134
|
+
if self.session.enable_async:
|
|
135
|
+
return self.response.status
|
|
136
|
+
else:
|
|
137
|
+
return self.response.status_code
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Schema:
|
|
141
|
+
"""
|
|
142
|
+
Container for model schemas with associated methods.
|
|
143
|
+
Session contains Schema.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, schema_data: dict=None) -> None:
|
|
147
|
+
self._schema_data = schema_data
|
|
148
|
+
|
|
149
|
+
def find_spec(self, model_name: str, attribute_name: str) -> dict:
|
|
150
|
+
"""
|
|
151
|
+
Find specification from model_name for attribute_name which can
|
|
152
|
+
be nested format, i.e. 'attribute-group1.attribute-group2.attribute-item'
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
# We need to support meta, which can contain whatever schemaless metadata
|
|
156
|
+
if attribute_name == 'meta' or attribute_name.endswith('.meta'):
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
model = self.schema_for_model(model_name)
|
|
160
|
+
if not model:
|
|
161
|
+
return {}
|
|
162
|
+
if not attribute_name:
|
|
163
|
+
return model
|
|
164
|
+
attr_struct = attribute_name.split('.')
|
|
165
|
+
for a in attr_struct:
|
|
166
|
+
model = model['properties'].get(a, NOT_FOUND)
|
|
167
|
+
if model is NOT_FOUND:
|
|
168
|
+
return {}
|
|
169
|
+
return model
|
|
170
|
+
|
|
171
|
+
def add_model_schema(self, data: dict) -> None:
|
|
172
|
+
self._schema_data.update(data)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def is_enabled(self):
|
|
176
|
+
return bool(self._schema_data)
|
|
177
|
+
|
|
178
|
+
def schema_for_model(self, model_type: str) -> dict:
|
|
179
|
+
return self._schema_data.get(model_type) if self.is_enabled else {}
|
|
180
|
+
|
|
181
|
+
def validate(self, model_type: str, data: dict) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Validate model data against schema.
|
|
184
|
+
"""
|
|
185
|
+
schema = self.schema_for_model(model_type)
|
|
186
|
+
if not schema:
|
|
187
|
+
return
|
|
188
|
+
jsonschema.validate(data, schema)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Session:
|
|
192
|
+
"""
|
|
193
|
+
Resources are fetched and cached in a session.
|
|
194
|
+
|
|
195
|
+
:param server_url: Server base url
|
|
196
|
+
:param enable_async: Toggle AsyncIO mode for session
|
|
197
|
+
:param schema: Schema in jsonschema format. See example from :ref:`usage-schema`.
|
|
198
|
+
:param request_kwargs: Additional keyword arguments that are passed to requests.request or
|
|
199
|
+
aiohttp.request functions (such as authentication object)
|
|
200
|
+
|
|
201
|
+
"""
|
|
202
|
+
def __init__(self, server_url: str=None,
|
|
203
|
+
enable_async: bool=False,
|
|
204
|
+
schema: dict=None,
|
|
205
|
+
request_kwargs: dict=None,
|
|
206
|
+
loop: 'AbstractEventLoop'=None,
|
|
207
|
+
use_relationship_iterator: bool=False,
|
|
208
|
+
enable_history: bool=False,
|
|
209
|
+
event_hooks: dict=None) -> None:
|
|
210
|
+
self._server: ParseResult
|
|
211
|
+
self.enable_async = enable_async
|
|
212
|
+
|
|
213
|
+
self._request_kwargs: dict = request_kwargs or {}
|
|
214
|
+
|
|
215
|
+
if server_url:
|
|
216
|
+
self._server = urlparse(server_url)
|
|
217
|
+
else:
|
|
218
|
+
self._server = None
|
|
219
|
+
|
|
220
|
+
self.resources_by_resource_identifier: \
|
|
221
|
+
'Dict[Tuple[str, str], ResourceObject]' = {}
|
|
222
|
+
self.resources_by_link: 'Dict[str, ResourceObject]' = {}
|
|
223
|
+
self.documents_by_link: 'Dict[str, Document]' = {}
|
|
224
|
+
self.schema: Schema = Schema(schema)
|
|
225
|
+
if enable_async:
|
|
226
|
+
import aiohttp
|
|
227
|
+
self._prepare_async_event_hooks(event_hooks)
|
|
228
|
+
self._aiohttp_session = aiohttp.ClientSession(
|
|
229
|
+
loop=loop,
|
|
230
|
+
trace_configs=[self.trace_config]
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
if event_hooks is not None:
|
|
234
|
+
hooks = self._request_kwargs.get('hooks', {})
|
|
235
|
+
hooks.update(**event_hooks)
|
|
236
|
+
self._request_kwargs['hooks'] = hooks
|
|
237
|
+
self.use_relationship_iterator = use_relationship_iterator
|
|
238
|
+
self.enable_history = enable_history
|
|
239
|
+
self.history = SessionHistory()
|
|
240
|
+
|
|
241
|
+
def _prepare_async_event_hooks(self, event_hooks: dict=None) -> None:
|
|
242
|
+
import aiohttp
|
|
243
|
+
self.trace_config = aiohttp.TraceConfig()
|
|
244
|
+
if event_hooks is None:
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
for event, hook in event_hooks.items():
|
|
248
|
+
getattr(self.trace_config, event).append(hook)
|
|
249
|
+
|
|
250
|
+
def add_resources(self, *resources: 'ResourceObject') -> None:
|
|
251
|
+
"""
|
|
252
|
+
Add resources to session cache.
|
|
253
|
+
"""
|
|
254
|
+
for res in resources:
|
|
255
|
+
self.resources_by_resource_identifier[(res.type, res.id)] = res
|
|
256
|
+
lnk = res.links.self.url if res.links.self else res.url
|
|
257
|
+
if lnk:
|
|
258
|
+
self.resources_by_link[lnk] = res
|
|
259
|
+
|
|
260
|
+
def remove_resource(self, res: 'ResourceObject') -> None:
|
|
261
|
+
"""
|
|
262
|
+
Remove resource from session cache.
|
|
263
|
+
|
|
264
|
+
:param res: Resource to be removed
|
|
265
|
+
"""
|
|
266
|
+
del self.resources_by_resource_identifier[(res.type, res.id)]
|
|
267
|
+
del self.resources_by_link[res.url]
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _value_to_dict(value: 'Union[ResourceObject, ResourceIdentifier, ResourceTuple]',
|
|
271
|
+
res_types: 'List[str]') -> dict:
|
|
272
|
+
from .objects import RESOURCE_TYPES
|
|
273
|
+
|
|
274
|
+
res_type = res_types[0] if len(res_types) == 1 else None
|
|
275
|
+
|
|
276
|
+
if isinstance(value, RESOURCE_TYPES):
|
|
277
|
+
if res_type and value.type != res_type:
|
|
278
|
+
raise TypeError(f'Invalid resource type {value.type}. '
|
|
279
|
+
f'Should be {res_type}')
|
|
280
|
+
elif res_types and value.type not in res_types:
|
|
281
|
+
raise TypeError(f'Invalid resource type {value.type}. '
|
|
282
|
+
f'Should be one of {res_types}')
|
|
283
|
+
return {'id': value.id, 'type': value.type}
|
|
284
|
+
else:
|
|
285
|
+
if not res_type:
|
|
286
|
+
raise ValueError('Use ResourceTuple to identify types '
|
|
287
|
+
'if there are more than 1 type')
|
|
288
|
+
return {'id': value, 'type': res_types[0]}
|
|
289
|
+
|
|
290
|
+
def create(self, _type: str, fields: dict=None, **more_fields) -> 'ResourceObject':
|
|
291
|
+
"""
|
|
292
|
+
Create a new ResourceObject of model _type. This requires that schema is defined
|
|
293
|
+
for model.
|
|
294
|
+
|
|
295
|
+
If you have field names that have underscores, you can pass those fields
|
|
296
|
+
in fields dictionary.
|
|
297
|
+
|
|
298
|
+
"""
|
|
299
|
+
from .objects import RESOURCE_TYPES
|
|
300
|
+
from .resourceobject import ResourceObject
|
|
301
|
+
|
|
302
|
+
if fields is None:
|
|
303
|
+
fields = {}
|
|
304
|
+
|
|
305
|
+
attrs: dict = {}
|
|
306
|
+
rels: dict = {}
|
|
307
|
+
schema = self.schema.schema_for_model(_type)
|
|
308
|
+
more_fields.update(fields)
|
|
309
|
+
|
|
310
|
+
for key, value in more_fields.items():
|
|
311
|
+
if key not in fields:
|
|
312
|
+
key = jsonify_attribute_name(key)
|
|
313
|
+
props = schema['properties'].get(key, {})
|
|
314
|
+
if 'relation' in props:
|
|
315
|
+
res_types = props['resource']
|
|
316
|
+
if isinstance(value, RESOURCE_TYPES + (str,)):
|
|
317
|
+
value = self._value_to_dict(value, res_types)
|
|
318
|
+
elif isinstance(value, collections.abc.Iterable):
|
|
319
|
+
value = [self._value_to_dict(id_, res_types) for id_ in value]
|
|
320
|
+
rels[key] = {'data': value}
|
|
321
|
+
else:
|
|
322
|
+
key = key.split('.')
|
|
323
|
+
a = attrs
|
|
324
|
+
for k in key[:-1]:
|
|
325
|
+
a_ = a[k] = a.get(k, {})
|
|
326
|
+
a = a_
|
|
327
|
+
|
|
328
|
+
a[key[-1]] = value
|
|
329
|
+
|
|
330
|
+
data = {'type': _type,
|
|
331
|
+
'id': None,
|
|
332
|
+
'attributes': attrs,
|
|
333
|
+
'relationships': rels,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
res = ResourceObject(self, data)
|
|
337
|
+
return res
|
|
338
|
+
|
|
339
|
+
def _create_and_commit_sync(self, type_: str, fields: dict=None, **more_fields) -> 'ResourceObject':
|
|
340
|
+
res = self.create(type_, fields, **more_fields)
|
|
341
|
+
res.commit()
|
|
342
|
+
return res
|
|
343
|
+
|
|
344
|
+
async def _create_and_commit_async(self, type_: str, fields: dict=None, **more_fields) -> 'ResourceObject':
|
|
345
|
+
res = self.create(type_, fields, **more_fields)
|
|
346
|
+
await res.commit()
|
|
347
|
+
return res
|
|
348
|
+
|
|
349
|
+
def create_and_commit(self, type_: str, fields: dict=None, **more_fields) \
|
|
350
|
+
-> 'Union[Awaitable[ResourceObject], ResourceObject]':
|
|
351
|
+
"""
|
|
352
|
+
Create resource and commit (PUSH) it into server.
|
|
353
|
+
If session is used with enable_async=True, this needs
|
|
354
|
+
to be awaited.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
if self.enable_async:
|
|
358
|
+
return self._create_and_commit_async(type_, fields, **more_fields)
|
|
359
|
+
else:
|
|
360
|
+
return self._create_and_commit_sync(type_, fields, **more_fields)
|
|
361
|
+
|
|
362
|
+
def __enter__(self):
|
|
363
|
+
self.assert_sync()
|
|
364
|
+
logger.info('Entering session')
|
|
365
|
+
return self
|
|
366
|
+
|
|
367
|
+
async def __aenter__(self):
|
|
368
|
+
self.assert_async()
|
|
369
|
+
logger.info('Entering session')
|
|
370
|
+
return self
|
|
371
|
+
|
|
372
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
373
|
+
self.assert_sync()
|
|
374
|
+
logger.info('Exiting session')
|
|
375
|
+
if not exc_type:
|
|
376
|
+
self.commit()
|
|
377
|
+
self.close()
|
|
378
|
+
|
|
379
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
380
|
+
self.assert_async()
|
|
381
|
+
logger.info('Exiting session')
|
|
382
|
+
if not exc_type:
|
|
383
|
+
await self.commit()
|
|
384
|
+
await self.close()
|
|
385
|
+
|
|
386
|
+
def close(self):
|
|
387
|
+
"""
|
|
388
|
+
Close session and invalidate resources.
|
|
389
|
+
"""
|
|
390
|
+
self.invalidate()
|
|
391
|
+
if self.enable_async:
|
|
392
|
+
return self._aiohttp_session.close()
|
|
393
|
+
|
|
394
|
+
def invalidate(self):
|
|
395
|
+
"""
|
|
396
|
+
Invalidate resources and documents associated with this Session.
|
|
397
|
+
"""
|
|
398
|
+
for resource in chain(self.documents_by_link.values(),
|
|
399
|
+
self.resources_by_link.values(),
|
|
400
|
+
self.resources_by_resource_identifier.values()):
|
|
401
|
+
resource.mark_invalid()
|
|
402
|
+
|
|
403
|
+
self.documents_by_link.clear()
|
|
404
|
+
self.resources_by_link.clear()
|
|
405
|
+
self.resources_by_resource_identifier.clear()
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def server_url(self) -> str:
|
|
409
|
+
return f'{self._server.scheme}://{self._server.netloc}'
|
|
410
|
+
|
|
411
|
+
@property
|
|
412
|
+
def url_prefix(self) -> str:
|
|
413
|
+
return self._server.geturl().rstrip('/')
|
|
414
|
+
|
|
415
|
+
def _url_for_resource(self, resource_type: str,
|
|
416
|
+
resource_id: str=None,
|
|
417
|
+
filter: 'Modifier'=None) -> str:
|
|
418
|
+
url = f'{self.url_prefix}/{resource_type}'
|
|
419
|
+
if resource_id is not None:
|
|
420
|
+
url = f'{url}/{resource_id}'
|
|
421
|
+
if filter:
|
|
422
|
+
url = filter.url_with_modifiers(url)
|
|
423
|
+
return url
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _resource_type_and_filter(
|
|
427
|
+
resource_id_or_filter: 'Union[Modifier, str]'=None)\
|
|
428
|
+
-> 'Tuple[Optional[str], Optional[Modifier]]':
|
|
429
|
+
from .filter import Modifier
|
|
430
|
+
if isinstance(resource_id_or_filter, Modifier):
|
|
431
|
+
resource_id = None
|
|
432
|
+
filter = resource_id_or_filter
|
|
433
|
+
else:
|
|
434
|
+
resource_id = resource_id_or_filter
|
|
435
|
+
filter = None
|
|
436
|
+
return resource_id, filter
|
|
437
|
+
|
|
438
|
+
def _get_sync(self, resource_type: str,
|
|
439
|
+
resource_id_or_filter: 'Union[Modifier, str]'=None) -> 'Document':
|
|
440
|
+
resource_id, filter_ = self._resource_type_and_filter(
|
|
441
|
+
resource_id_or_filter)
|
|
442
|
+
url = self._url_for_resource(resource_type, resource_id, filter_)
|
|
443
|
+
return self.fetch_document_by_url(url)
|
|
444
|
+
|
|
445
|
+
async def _get_async(self, resource_type: str,
|
|
446
|
+
resource_id_or_filter: 'Union[Modifier, str]'=None) -> 'Document':
|
|
447
|
+
resource_id, filter_ = self._resource_type_and_filter(
|
|
448
|
+
resource_id_or_filter)
|
|
449
|
+
url = self._url_for_resource(resource_type, resource_id, filter_)
|
|
450
|
+
return await self.fetch_document_by_url_async(url)
|
|
451
|
+
|
|
452
|
+
def get(self, resource_type: str,
|
|
453
|
+
resource_id_or_filter: 'Union[Modifier, str]'=None) \
|
|
454
|
+
-> 'Union[Awaitable[Document], Document]':
|
|
455
|
+
"""
|
|
456
|
+
Request (GET) Document from server.
|
|
457
|
+
|
|
458
|
+
:param resource_id_or_filter: Resource id or Modifier instance to filter
|
|
459
|
+
resulting resources.
|
|
460
|
+
|
|
461
|
+
If session is used with enable_async=True, this needs
|
|
462
|
+
to be awaited.
|
|
463
|
+
"""
|
|
464
|
+
if self.enable_async:
|
|
465
|
+
return self._get_async(resource_type, resource_id_or_filter)
|
|
466
|
+
else:
|
|
467
|
+
return self._get_sync(resource_type, resource_id_or_filter)
|
|
468
|
+
|
|
469
|
+
def _iterate_sync(self, resource_type: str, filter: 'Modifier'=None) \
|
|
470
|
+
-> 'Iterator[ResourceObject]':
|
|
471
|
+
doc = self.get(resource_type, filter)
|
|
472
|
+
yield from doc._iterator_sync()
|
|
473
|
+
|
|
474
|
+
async def _iterate_async(self, resource_type: str, filter: 'Modifier'=None) \
|
|
475
|
+
-> 'AsyncIterator[ResourceObject]':
|
|
476
|
+
doc = await self._get_async(resource_type, filter)
|
|
477
|
+
async for res in doc._iterator_async():
|
|
478
|
+
yield res
|
|
479
|
+
|
|
480
|
+
def iterate(self, resource_type: str, filter: 'Modifier'=None) \
|
|
481
|
+
-> 'Union[AsyncIterator[ResourceObject], Iterator[ResourceObject]]':
|
|
482
|
+
"""
|
|
483
|
+
Request (GET) Document from server and iterate through resources.
|
|
484
|
+
If Document uses pagination, fetch results as long as there are new
|
|
485
|
+
results.
|
|
486
|
+
|
|
487
|
+
If session is used with enable_async=True, this needs to iterated with
|
|
488
|
+
async for.
|
|
489
|
+
|
|
490
|
+
:param filter: Modifier instance to filter resulting resources.
|
|
491
|
+
"""
|
|
492
|
+
if self.enable_async:
|
|
493
|
+
return self._iterate_async(resource_type, filter)
|
|
494
|
+
else:
|
|
495
|
+
return self._iterate_sync(resource_type, filter)
|
|
496
|
+
|
|
497
|
+
def read(self, json_data: dict, url='', no_cache=False)-> 'Document':
|
|
498
|
+
"""
|
|
499
|
+
Read document from json_data dictionary instead of fetching it from the server.
|
|
500
|
+
|
|
501
|
+
:param json_data: JSON API document as dictionary.
|
|
502
|
+
:param url: Set source url to resulting document.
|
|
503
|
+
:param no_cache: do not store results into Session's cache.
|
|
504
|
+
"""
|
|
505
|
+
from .document import Document
|
|
506
|
+
doc = self.documents_by_link[url] = Document(self, json_data, url,
|
|
507
|
+
no_cache=no_cache)
|
|
508
|
+
return doc
|
|
509
|
+
|
|
510
|
+
def fetch_resource_by_resource_identifier(
|
|
511
|
+
self,
|
|
512
|
+
resource: 'Union[ResourceIdentifier, ResourceObject, ResourceTuple]',
|
|
513
|
+
cache_only=False,
|
|
514
|
+
force=False) -> 'Optional[ResourceObject]':
|
|
515
|
+
"""
|
|
516
|
+
Internal use.
|
|
517
|
+
|
|
518
|
+
Fetch resource from server by resource identifier.
|
|
519
|
+
"""
|
|
520
|
+
type_, id_ = resource.type, resource.id
|
|
521
|
+
new_res = not force and self.resources_by_resource_identifier.get((type_, id_))
|
|
522
|
+
if new_res:
|
|
523
|
+
return new_res
|
|
524
|
+
elif cache_only:
|
|
525
|
+
return None
|
|
526
|
+
else:
|
|
527
|
+
# Note: Document creation will add its resources to cache via .add_resources,
|
|
528
|
+
# no need to do it manually here
|
|
529
|
+
return self._ext_fetch_by_url(resource.url).resource
|
|
530
|
+
|
|
531
|
+
async def fetch_resource_by_resource_identifier_async(
|
|
532
|
+
self,
|
|
533
|
+
resource: 'Union[ResourceIdentifier, ResourceObject, ResourceTuple]',
|
|
534
|
+
cache_only=False,
|
|
535
|
+
force=False) -> 'Optional[ResourceObject]':
|
|
536
|
+
"""
|
|
537
|
+
Internal use. Async version.
|
|
538
|
+
|
|
539
|
+
Fetch resource from server by resource identifier.
|
|
540
|
+
"""
|
|
541
|
+
type_, id_ = resource.type, resource.id
|
|
542
|
+
new_res = not force and self.resources_by_resource_identifier.get((type_, id_))
|
|
543
|
+
if new_res:
|
|
544
|
+
return new_res
|
|
545
|
+
elif cache_only:
|
|
546
|
+
return None
|
|
547
|
+
else:
|
|
548
|
+
# Note: Document creation will add its resources to cache via .add_resources,
|
|
549
|
+
# no need to do it manually here
|
|
550
|
+
return (await self._ext_fetch_by_url_async(resource.url)).resource
|
|
551
|
+
|
|
552
|
+
def fetch_document_by_url(self, url: str) -> 'Document':
|
|
553
|
+
"""
|
|
554
|
+
Internal use.
|
|
555
|
+
|
|
556
|
+
Fetch Document from server by url.
|
|
557
|
+
"""
|
|
558
|
+
|
|
559
|
+
# TODO: should we try to guess type, id from url?
|
|
560
|
+
return self.documents_by_link.get(url) or self._ext_fetch_by_url(url)
|
|
561
|
+
|
|
562
|
+
async def fetch_document_by_url_async(self, url: str) -> 'Document':
|
|
563
|
+
"""
|
|
564
|
+
Internal use. Async version.
|
|
565
|
+
|
|
566
|
+
Fetch Document from server by url.
|
|
567
|
+
"""
|
|
568
|
+
|
|
569
|
+
# TODO: should we try to guess type, id from url?
|
|
570
|
+
return (self.documents_by_link.get(url) or
|
|
571
|
+
await self._ext_fetch_by_url_async(url))
|
|
572
|
+
|
|
573
|
+
def _ext_fetch_by_url(self, url: str) -> 'Document':
|
|
574
|
+
json_data = self._fetch_json(url)
|
|
575
|
+
return self.read(json_data, url)
|
|
576
|
+
|
|
577
|
+
async def _ext_fetch_by_url_async(self, url: str) -> 'Document':
|
|
578
|
+
json_data = await self._fetch_json_async(url)
|
|
579
|
+
return self.read(json_data, url)
|
|
580
|
+
|
|
581
|
+
def _append_to_session_history(self, url: str, http_method: str,
|
|
582
|
+
response, send_json: dict=None):
|
|
583
|
+
if self.enable_history:
|
|
584
|
+
self.history.append(
|
|
585
|
+
SessionHistoryItem(self, url, http_method, response, send_json)
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
def _fetch_json(self, url: str) -> dict:
|
|
589
|
+
"""
|
|
590
|
+
Internal use.
|
|
591
|
+
|
|
592
|
+
Fetch document raw json from server using requests library.
|
|
593
|
+
"""
|
|
594
|
+
self.assert_sync()
|
|
595
|
+
import requests
|
|
596
|
+
parsed_url = urlparse(url)
|
|
597
|
+
logger.info('Fetching document from url %s', parsed_url)
|
|
598
|
+
response = requests.get(parsed_url.geturl(), **self._request_kwargs)
|
|
599
|
+
response_content = response.json()
|
|
600
|
+
self._append_to_session_history(url, 'GET', response)
|
|
601
|
+
if response.status_code == HttpStatus.OK_200:
|
|
602
|
+
return response_content
|
|
603
|
+
else:
|
|
604
|
+
|
|
605
|
+
raise DocumentError(f'Error {response.status_code}: '
|
|
606
|
+
f'{error_from_response(response_content)}',
|
|
607
|
+
errors={'status_code': response.status_code},
|
|
608
|
+
response=response)
|
|
609
|
+
|
|
610
|
+
async def _fetch_json_async(self, url: str) -> dict:
|
|
611
|
+
"""
|
|
612
|
+
Internal use. Async version.
|
|
613
|
+
|
|
614
|
+
Fetch document raw json from server using aiohttp library.
|
|
615
|
+
"""
|
|
616
|
+
self.assert_async()
|
|
617
|
+
parsed_url = urlparse(url)
|
|
618
|
+
logger.info('Fetching document from url %s', parsed_url)
|
|
619
|
+
async with self._aiohttp_session.get(parsed_url.geturl(),
|
|
620
|
+
**self._request_kwargs) as response:
|
|
621
|
+
response_content = await response.json(content_type='application/vnd.api+json')
|
|
622
|
+
self._append_to_session_history(url, 'GET', response)
|
|
623
|
+
if response.status == HttpStatus.OK_200:
|
|
624
|
+
return response_content
|
|
625
|
+
else:
|
|
626
|
+
raise DocumentError(f'Error {response.status}: '
|
|
627
|
+
f'{error_from_response(response_content)}',
|
|
628
|
+
errors={'status_code': response.status},
|
|
629
|
+
response=response)
|
|
630
|
+
|
|
631
|
+
def http_request(self, http_method: str, url: str, send_json: dict,
|
|
632
|
+
expected_statuses: List[str]=None) -> Tuple[int, dict, str]:
|
|
633
|
+
"""
|
|
634
|
+
Internal use.
|
|
635
|
+
|
|
636
|
+
Method to make PATCH/POST requests to server using requests library.
|
|
637
|
+
"""
|
|
638
|
+
self.assert_sync()
|
|
639
|
+
import requests
|
|
640
|
+
logger.debug('%s request: %s', http_method.upper(), send_json)
|
|
641
|
+
expected_statuses = expected_statuses or HttpStatus.ALL_OK
|
|
642
|
+
kwargs = {**self._request_kwargs}
|
|
643
|
+
headers = {'Content-Type':'application/vnd.api+json'}
|
|
644
|
+
headers.update(kwargs.pop('headers', {}))
|
|
645
|
+
|
|
646
|
+
response = requests.request(http_method, url, data=json.dumps(send_json, cls=UUIDEncoder),
|
|
647
|
+
headers=headers,
|
|
648
|
+
**kwargs)
|
|
649
|
+
self._append_to_session_history(url, http_method, response, send_json)
|
|
650
|
+
|
|
651
|
+
if (response.status_code == HttpStatus.NO_CONTENT_204):
|
|
652
|
+
response_json = {}
|
|
653
|
+
else:
|
|
654
|
+
response_json = response.json()
|
|
655
|
+
if response.status_code not in expected_statuses:
|
|
656
|
+
raise DocumentError(f'Could not {http_method.upper()} '
|
|
657
|
+
f'({response.status_code}): '
|
|
658
|
+
f'{error_from_response(response_json)}',
|
|
659
|
+
errors={'status_code': response.status_code},
|
|
660
|
+
response=response,
|
|
661
|
+
json_data=send_json)
|
|
662
|
+
|
|
663
|
+
return response.status_code, response_json \
|
|
664
|
+
if response.content \
|
|
665
|
+
else {}, response.headers.get('Location')
|
|
666
|
+
|
|
667
|
+
async def http_request_async(
|
|
668
|
+
self,
|
|
669
|
+
http_method: str,
|
|
670
|
+
url: str,
|
|
671
|
+
send_json: dict,
|
|
672
|
+
expected_statuses: List[str]=None) \
|
|
673
|
+
-> Tuple[int, dict, str]:
|
|
674
|
+
"""
|
|
675
|
+
Internal use. Async version.
|
|
676
|
+
|
|
677
|
+
Method to make PATCH/POST requests to server using aiohttp library.
|
|
678
|
+
"""
|
|
679
|
+
|
|
680
|
+
self.assert_async()
|
|
681
|
+
logger.debug('%s request: %s', http_method.upper(), send_json)
|
|
682
|
+
expected_statuses = expected_statuses or HttpStatus.ALL_OK
|
|
683
|
+
content_type = '' if http_method == HttpMethod.DELETE else 'application/vnd.api+json'
|
|
684
|
+
kwargs = {**self._request_kwargs}
|
|
685
|
+
headers = {'Content-Type':'application/vnd.api+json'}
|
|
686
|
+
headers.update(kwargs.pop('headers', {}))
|
|
687
|
+
async with self._aiohttp_session.request(
|
|
688
|
+
http_method, url, data=json.dumps(send_json, cls=UUIDEncoder),
|
|
689
|
+
headers=headers,
|
|
690
|
+
**kwargs) as response:
|
|
691
|
+
|
|
692
|
+
response_json = await response.json(content_type=content_type)
|
|
693
|
+
self._append_to_session_history(url, http_method, response, send_json)
|
|
694
|
+
if response.status not in expected_statuses:
|
|
695
|
+
raise DocumentError(f'Could not {http_method.upper()} '
|
|
696
|
+
f'({response.status}): '
|
|
697
|
+
f'{error_from_response(response_json)}',
|
|
698
|
+
errors={'status_code': response.status},
|
|
699
|
+
response=response,
|
|
700
|
+
json_data=send_json)
|
|
701
|
+
|
|
702
|
+
return response.status, response_json or {}, response.headers.get('Location')
|
|
703
|
+
|
|
704
|
+
@property
|
|
705
|
+
def dirty_resources(self) -> 'Set[ResourceObject]':
|
|
706
|
+
"""
|
|
707
|
+
Set of all resources in Session cache that are marked as dirty,
|
|
708
|
+
i.e. waiting for commit.
|
|
709
|
+
"""
|
|
710
|
+
return {i for i in self.resources_by_resource_identifier.values() if i.is_dirty}
|
|
711
|
+
|
|
712
|
+
@property
|
|
713
|
+
def is_dirty(self) -> bool:
|
|
714
|
+
return bool(self.dirty_resources)
|
|
715
|
+
|
|
716
|
+
def _commit_sync(self) -> None:
|
|
717
|
+
self.assert_sync()
|
|
718
|
+
logger.info('Committing dirty resources')
|
|
719
|
+
for res in self.dirty_resources:
|
|
720
|
+
res.commit()
|
|
721
|
+
|
|
722
|
+
async def _commit_async(self) -> None:
|
|
723
|
+
self.assert_async()
|
|
724
|
+
logger.info('Committing dirty resources')
|
|
725
|
+
for res in self.dirty_resources:
|
|
726
|
+
await res._commit_async()
|
|
727
|
+
|
|
728
|
+
def commit(self) -> Optional[Awaitable]:
|
|
729
|
+
"""
|
|
730
|
+
Commit (PATCH) all dirty resources to server.
|
|
731
|
+
|
|
732
|
+
If session is used with enable_async=True, this needs to be awaited.
|
|
733
|
+
"""
|
|
734
|
+
if self.enable_async:
|
|
735
|
+
return self._commit_async()
|
|
736
|
+
else:
|
|
737
|
+
return self._commit_sync()
|
|
738
|
+
|
|
739
|
+
def assert_sync(self, msg=''):
|
|
740
|
+
"""
|
|
741
|
+
Internal method to assert that async is not enabled.
|
|
742
|
+
"""
|
|
743
|
+
msg = msg or 'Async requires manual fetching of resources'
|
|
744
|
+
if self.enable_async:
|
|
745
|
+
logger.error(msg)
|
|
746
|
+
raise AsyncError(msg)
|
|
747
|
+
|
|
748
|
+
def assert_async(self, msg=''):
|
|
749
|
+
"""
|
|
750
|
+
Internal method to assert that async is enabled.
|
|
751
|
+
"""
|
|
752
|
+
msg = msg or 'Calling this method is needed only when async is enabled'
|
|
753
|
+
if not self.enable_async:
|
|
754
|
+
logger.error(msg)
|
|
755
|
+
raise AsyncError(msg)
|