python-anvil 2.0.0__py3-none-any.whl → 3.0.1__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.
- python_anvil/api.py +107 -49
- python_anvil/api_resources/mutations/create_etch_packet.py +23 -45
- python_anvil/api_resources/mutations/forge_submit.py +0 -2
- python_anvil/api_resources/payload.py +10 -16
- python_anvil/api_resources/requests.py +7 -0
- python_anvil/cli.py +2 -1
- python_anvil/constants.py +18 -0
- python_anvil/http.py +69 -13
- python_anvil/tests/test_api.py +73 -45
- python_anvil/tests/test_cli.py +41 -2
- {python_anvil-2.0.0.dist-info → python_anvil-3.0.1.dist-info}/METADATA +4 -6
- {python_anvil-2.0.0.dist-info → python_anvil-3.0.1.dist-info}/RECORD +15 -16
- {python_anvil-2.0.0.dist-info → python_anvil-3.0.1.dist-info}/WHEEL +1 -1
- python_anvil/multipart_helpers.py +0 -124
- python_anvil/tests/test_multipart_helpers.py +0 -38
- {python_anvil-2.0.0.dist-info → python_anvil-3.0.1.dist-info}/LICENSE.md +0 -0
- {python_anvil-2.0.0.dist-info → python_anvil-3.0.1.dist-info}/entry_points.txt +0 -0
python_anvil/api.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import logging
|
|
2
|
+
from gql import gql
|
|
3
|
+
from graphql import DocumentNode
|
|
2
4
|
from typing import Any, AnyStr, Callable, Dict, List, Optional, Text, Tuple, Union
|
|
3
5
|
|
|
4
6
|
from .api_resources.mutations import (
|
|
@@ -13,12 +15,11 @@ from .api_resources.payload import (
|
|
|
13
15
|
ForgeSubmitPayload,
|
|
14
16
|
GeneratePDFPayload,
|
|
15
17
|
)
|
|
16
|
-
from .api_resources.requests import
|
|
17
|
-
from .http import HTTPClient
|
|
18
|
-
from .multipart_helpers import get_multipart_payload
|
|
18
|
+
from .api_resources.requests import PlainRequest, RestRequest
|
|
19
|
+
from .http import GQLClient, HTTPClient
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
logger = getLogger(__name__)
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
def _get_return(res: Dict, get_data: Callable[[Dict], Union[Dict, List]]):
|
|
@@ -47,39 +48,70 @@ class Anvil:
|
|
|
47
48
|
# This is the default when a version is not provided.
|
|
48
49
|
VERSION_LATEST_PUBLISHED = -2
|
|
49
50
|
|
|
50
|
-
def __init__(
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
api_key: Optional[str] = None,
|
|
54
|
+
environment="dev",
|
|
55
|
+
endpoint_url=None,
|
|
56
|
+
):
|
|
57
|
+
if not api_key:
|
|
58
|
+
raise ValueError('`api_key` must be a valid string')
|
|
59
|
+
|
|
51
60
|
self.client = HTTPClient(api_key=api_key, environment=environment)
|
|
61
|
+
self.gql_client = GQLClient.get_client(
|
|
62
|
+
api_key=api_key,
|
|
63
|
+
environment=environment,
|
|
64
|
+
endpoint_url=endpoint_url,
|
|
65
|
+
)
|
|
52
66
|
|
|
53
67
|
def query(
|
|
54
68
|
self,
|
|
55
|
-
query: str,
|
|
56
|
-
variables:
|
|
69
|
+
query: Union[str, DocumentNode],
|
|
70
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
57
71
|
**kwargs,
|
|
58
72
|
):
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
"""Execute a GraphQL query.
|
|
74
|
+
|
|
75
|
+
:param query:
|
|
76
|
+
:type query: Union[str, DocumentNode]
|
|
77
|
+
:param variables:
|
|
78
|
+
:type variables: Optional[Dict[str, Any]]
|
|
79
|
+
:param kwargs:
|
|
80
|
+
:return:
|
|
81
|
+
"""
|
|
82
|
+
# Remove `debug` for now.
|
|
83
|
+
kwargs.pop("debug", None)
|
|
84
|
+
if isinstance(query, str):
|
|
85
|
+
query = gql(query)
|
|
61
86
|
|
|
62
|
-
|
|
63
|
-
gql = GraphqlRequest(client=self.client)
|
|
64
|
-
return gql.post(query.get_mutation(), variables, **kwargs)
|
|
87
|
+
return self.gql_client.execute(query, variable_values=variables, **kwargs)
|
|
65
88
|
|
|
66
|
-
def
|
|
89
|
+
def mutate(
|
|
90
|
+
self, query: Union[str, BaseQuery], variables: Dict[str, Any], **kwargs
|
|
91
|
+
) -> Dict[str, Any]:
|
|
67
92
|
"""
|
|
68
|
-
|
|
93
|
+
Execute a GraphQL mutation.
|
|
69
94
|
|
|
70
|
-
|
|
95
|
+
NOTE: Any files attached provided in `variables` will be sent via the
|
|
96
|
+
multipart spec:
|
|
71
97
|
https://github.com/jaydenseric/graphql-multipart-request-spec
|
|
72
98
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
:param files:
|
|
99
|
+
:param query:
|
|
100
|
+
:type query: Union[str, BaseQuery]
|
|
101
|
+
:param variables:
|
|
102
|
+
:type variables: Dict[str, Any]
|
|
78
103
|
:param kwargs:
|
|
79
104
|
:return:
|
|
80
105
|
"""
|
|
81
|
-
|
|
82
|
-
|
|
106
|
+
# Remove `debug` for now.
|
|
107
|
+
kwargs.pop("debug", None)
|
|
108
|
+
if isinstance(query, str):
|
|
109
|
+
use_query = gql(query)
|
|
110
|
+
else:
|
|
111
|
+
mutation = query.get_mutation()
|
|
112
|
+
use_query = gql(mutation)
|
|
113
|
+
|
|
114
|
+
return self.gql_client.execute(use_query, variable_values=variables, **kwargs)
|
|
83
115
|
|
|
84
116
|
def request_rest(self, options: Optional[dict] = None):
|
|
85
117
|
api = RestRequest(self.client, options=options)
|
|
@@ -180,20 +212,34 @@ class Anvil:
|
|
|
180
212
|
arg_str = f"({','.join(joined_args)})"
|
|
181
213
|
|
|
182
214
|
res = self.query(
|
|
183
|
-
|
|
215
|
+
gql(
|
|
216
|
+
f"""{{
|
|
184
217
|
cast {arg_str} {{
|
|
185
218
|
{" ".join(fields)}
|
|
186
219
|
}}
|
|
187
|
-
}}"""
|
|
220
|
+
}}"""
|
|
221
|
+
),
|
|
188
222
|
**kwargs,
|
|
189
223
|
)
|
|
190
224
|
|
|
191
|
-
def get_data(r) -> Dict[str, Any]:
|
|
192
|
-
return r["
|
|
225
|
+
def get_data(r: dict) -> Dict[str, Any]:
|
|
226
|
+
return r["cast"]
|
|
193
227
|
|
|
194
228
|
return _get_return(res, get_data=get_data)
|
|
195
229
|
|
|
196
|
-
def get_casts(
|
|
230
|
+
def get_casts(
|
|
231
|
+
self, fields: Optional[List[str]] = None, show_all: bool = False, **kwargs
|
|
232
|
+
) -> List[Dict[str, Any]]:
|
|
233
|
+
"""Retrieve all Cast objects for the current user across all organizations.
|
|
234
|
+
|
|
235
|
+
:param fields: List of fields to retrieve for each cast object
|
|
236
|
+
:type fields: Optional[List[str]]
|
|
237
|
+
:param show_all: Boolean to show all Cast objects.
|
|
238
|
+
Defaults to showing only templates.
|
|
239
|
+
:type show_all: bool
|
|
240
|
+
:param kwargs:
|
|
241
|
+
:return:
|
|
242
|
+
"""
|
|
197
243
|
if not fields:
|
|
198
244
|
# Use default fields
|
|
199
245
|
fields = ["eid", "title", "fieldInfo"]
|
|
@@ -201,7 +247,8 @@ class Anvil:
|
|
|
201
247
|
cast_args = "" if show_all else "(isTemplate: true)"
|
|
202
248
|
|
|
203
249
|
res = self.query(
|
|
204
|
-
|
|
250
|
+
gql(
|
|
251
|
+
f"""{{
|
|
205
252
|
currentUser {{
|
|
206
253
|
organizations {{
|
|
207
254
|
casts {cast_args} {{
|
|
@@ -209,19 +256,26 @@ class Anvil:
|
|
|
209
256
|
}}
|
|
210
257
|
}}
|
|
211
258
|
}}
|
|
212
|
-
}}"""
|
|
259
|
+
}}"""
|
|
260
|
+
),
|
|
213
261
|
**kwargs,
|
|
214
262
|
)
|
|
215
263
|
|
|
216
|
-
def get_data(r):
|
|
217
|
-
orgs = r["
|
|
264
|
+
def get_data(r: dict):
|
|
265
|
+
orgs = r["currentUser"]["organizations"]
|
|
218
266
|
return [item for org in orgs for item in org["casts"]]
|
|
219
267
|
|
|
220
268
|
return _get_return(res, get_data=get_data)
|
|
221
269
|
|
|
222
270
|
def get_current_user(self, **kwargs):
|
|
271
|
+
"""Retrieve current user data.
|
|
272
|
+
|
|
273
|
+
:param kwargs:
|
|
274
|
+
:return:
|
|
275
|
+
"""
|
|
223
276
|
res = self.query(
|
|
224
|
-
|
|
277
|
+
gql(
|
|
278
|
+
"""{
|
|
225
279
|
currentUser {
|
|
226
280
|
name
|
|
227
281
|
email
|
|
@@ -237,22 +291,23 @@ class Anvil:
|
|
|
237
291
|
}
|
|
238
292
|
}
|
|
239
293
|
}
|
|
240
|
-
}
|
|
241
|
-
|
|
294
|
+
}"""
|
|
295
|
+
),
|
|
242
296
|
**kwargs,
|
|
243
297
|
)
|
|
244
298
|
|
|
245
|
-
return _get_return(res, get_data=lambda r: r["
|
|
299
|
+
return _get_return(res, get_data=lambda r: r["currentUser"])
|
|
246
300
|
|
|
247
301
|
def get_welds(self, **kwargs) -> Union[List, Tuple[List, Dict]]:
|
|
248
302
|
res = self.query(
|
|
249
|
-
|
|
303
|
+
gql(
|
|
304
|
+
"""{
|
|
250
305
|
currentUser {
|
|
251
306
|
organizations {
|
|
252
307
|
welds {
|
|
253
308
|
eid
|
|
254
309
|
slug
|
|
255
|
-
|
|
310
|
+
name
|
|
256
311
|
forges {
|
|
257
312
|
eid
|
|
258
313
|
name
|
|
@@ -260,19 +315,21 @@ class Anvil:
|
|
|
260
315
|
}
|
|
261
316
|
}
|
|
262
317
|
}
|
|
263
|
-
}"""
|
|
318
|
+
}"""
|
|
319
|
+
),
|
|
264
320
|
**kwargs,
|
|
265
321
|
)
|
|
266
322
|
|
|
267
|
-
def get_data(r):
|
|
268
|
-
orgs = r["
|
|
323
|
+
def get_data(r: dict):
|
|
324
|
+
orgs = r["currentUser"]["organizations"]
|
|
269
325
|
return [item for org in orgs for item in org["welds"]]
|
|
270
326
|
|
|
271
327
|
return _get_return(res, get_data=get_data)
|
|
272
328
|
|
|
273
329
|
def get_weld(self, eid: Text, **kwargs):
|
|
274
330
|
res = self.query(
|
|
275
|
-
|
|
331
|
+
gql(
|
|
332
|
+
"""
|
|
276
333
|
query WeldQuery(
|
|
277
334
|
#$organizationSlug: String!,
|
|
278
335
|
#$slug: String!
|
|
@@ -292,13 +349,14 @@ class Anvil:
|
|
|
292
349
|
slug
|
|
293
350
|
}
|
|
294
351
|
}
|
|
295
|
-
}"""
|
|
352
|
+
}"""
|
|
353
|
+
),
|
|
296
354
|
variables=dict(eid=eid),
|
|
297
355
|
**kwargs,
|
|
298
356
|
)
|
|
299
357
|
|
|
300
|
-
def get_data(r):
|
|
301
|
-
return r["
|
|
358
|
+
def get_data(r: dict):
|
|
359
|
+
return r["weld"]
|
|
302
360
|
|
|
303
361
|
return _get_return(res, get_data=get_data)
|
|
304
362
|
|
|
@@ -337,10 +395,10 @@ class Anvil:
|
|
|
337
395
|
"`payload` must be a valid CreateEtchPacket instance or dict"
|
|
338
396
|
)
|
|
339
397
|
|
|
340
|
-
|
|
398
|
+
payload = mutation.create_payload()
|
|
399
|
+
variables = payload.dict(by_alias=True, exclude_none=True)
|
|
341
400
|
|
|
342
|
-
|
|
343
|
-
return res
|
|
401
|
+
return self.mutate(mutation, variables=variables, upload_files=True, **kwargs)
|
|
344
402
|
|
|
345
403
|
def generate_etch_signing_url(self, signer_eid: str, client_user_id: str, **kwargs):
|
|
346
404
|
"""Generate a signing URL for a given user."""
|
|
@@ -361,7 +419,7 @@ class Anvil:
|
|
|
361
419
|
payload: Optional[Union[Dict[Text, Any], ForgeSubmitPayload]] = None,
|
|
362
420
|
json=None,
|
|
363
421
|
**kwargs,
|
|
364
|
-
):
|
|
422
|
+
) -> Dict[str, Any]:
|
|
365
423
|
"""Create a Webform (forge) submission via a graphql mutation."""
|
|
366
424
|
if not any([json, payload]):
|
|
367
425
|
raise TypeError('One of arguments `json` or `payload` are required')
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# pylint: disable=too-many-instance-attributes
|
|
2
|
-
|
|
3
|
-
from
|
|
2
|
+
import logging
|
|
3
|
+
from io import BufferedIOBase
|
|
4
|
+
from logging import Logger
|
|
5
|
+
from mimetypes import guess_type
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
4
7
|
|
|
5
8
|
from python_anvil.api_resources.mutations.base import BaseQuery
|
|
6
9
|
from python_anvil.api_resources.payload import (
|
|
7
10
|
AttachableEtchFile,
|
|
8
|
-
Base64Upload,
|
|
9
11
|
CreateEtchFilePayload,
|
|
10
12
|
CreateEtchPacketPayload,
|
|
11
13
|
DocumentUpload,
|
|
@@ -14,6 +16,8 @@ from python_anvil.api_resources.payload import (
|
|
|
14
16
|
from python_anvil.utils import create_unique_id
|
|
15
17
|
|
|
16
18
|
|
|
19
|
+
logger: Logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
17
21
|
DEFAULT_RESPONSE_QUERY = """{
|
|
18
22
|
id
|
|
19
23
|
eid
|
|
@@ -193,6 +197,18 @@ class CreateEtchPacket(BaseQuery):
|
|
|
193
197
|
Files will not be uploaded when running this method. They will be
|
|
194
198
|
uploaded when the mutation actually runs.
|
|
195
199
|
"""
|
|
200
|
+
if (
|
|
201
|
+
isinstance(file, DocumentUpload)
|
|
202
|
+
and isinstance(file.file, BufferedIOBase)
|
|
203
|
+
and getattr(file.file, "content_type", None) is None
|
|
204
|
+
):
|
|
205
|
+
# Don't clobber existing `content_type`s provided.
|
|
206
|
+
content_type, _ = guess_type(file.file.name) # type: ignore
|
|
207
|
+
logger.debug(
|
|
208
|
+
"File did not have a `content_type`, guessing as '%s'", content_type
|
|
209
|
+
)
|
|
210
|
+
file.file.content_type = content_type # type: ignore
|
|
211
|
+
|
|
196
212
|
self.files.append(file)
|
|
197
213
|
|
|
198
214
|
def add_file_payloads(self, file_id: str, fill_payload):
|
|
@@ -215,43 +231,7 @@ class CreateEtchPacket(BaseQuery):
|
|
|
215
231
|
)
|
|
216
232
|
return self.file_payloads
|
|
217
233
|
|
|
218
|
-
def
|
|
219
|
-
"""Scan through and gather files for use in multipart requests.
|
|
220
|
-
|
|
221
|
-
During the process, files will be replaced with a dummy `Path`
|
|
222
|
-
instance as `Callable` instances and likely their returns of some
|
|
223
|
-
sort of IO reader is not serializable and not easily dealt with in
|
|
224
|
-
pydantic (the underlying model system for the types used in this
|
|
225
|
-
library). Closer to the actual request, when the multipart payload is
|
|
226
|
-
being constructed, the dummy instances will be replaced by their actual
|
|
227
|
-
file instances.
|
|
228
|
-
|
|
229
|
-
:return: Tuple containing a list of uploadable file types, and a list
|
|
230
|
-
of actual file references
|
|
231
|
-
:rtype: Tuple[List, List]
|
|
232
|
-
"""
|
|
233
|
-
contains_uploads = any( # pylint: disable=use-a-generator
|
|
234
|
-
[isinstance(f, DocumentUpload) for f in self.files]
|
|
235
|
-
)
|
|
236
|
-
if not contains_uploads:
|
|
237
|
-
return self.files, []
|
|
238
|
-
|
|
239
|
-
files = []
|
|
240
|
-
for f in self.files:
|
|
241
|
-
if not isinstance(f, DocumentUpload):
|
|
242
|
-
continue
|
|
243
|
-
|
|
244
|
-
if getattr(f.Config, "contains_uploads", False):
|
|
245
|
-
attr_name = getattr(f.Config, "contains_uploads")
|
|
246
|
-
upload = getattr(f, attr_name)
|
|
247
|
-
|
|
248
|
-
if not isinstance(upload, Base64Upload):
|
|
249
|
-
files.append(upload)
|
|
250
|
-
setattr(f, attr_name, Path())
|
|
251
|
-
|
|
252
|
-
return self.files, files
|
|
253
|
-
|
|
254
|
-
def create_payload(self) -> Tuple[CreateEtchPacketPayload, List]:
|
|
234
|
+
def create_payload(self) -> CreateEtchPacketPayload:
|
|
255
235
|
"""Create a payload based on data set on the class instance.
|
|
256
236
|
|
|
257
237
|
Check `api_resources.payload.CreateEtchPacketPayload` for full payload
|
|
@@ -260,19 +240,17 @@ class CreateEtchPacket(BaseQuery):
|
|
|
260
240
|
"""
|
|
261
241
|
# If there's an existing payload instance attribute, just return that.
|
|
262
242
|
if self.payload:
|
|
263
|
-
return self.payload
|
|
243
|
+
return self.payload
|
|
264
244
|
|
|
265
245
|
if not self.name:
|
|
266
246
|
raise TypeError("`name` and `signature_email_subject` cannot be None")
|
|
267
247
|
|
|
268
|
-
payload_files, file_refs = self.get_files_for_payload()
|
|
269
|
-
|
|
270
248
|
payload = CreateEtchPacketPayload(
|
|
271
249
|
is_test=self.is_test,
|
|
272
250
|
is_draft=self.is_draft,
|
|
273
251
|
name=self.name,
|
|
274
252
|
signers=self.signers,
|
|
275
|
-
files=
|
|
253
|
+
files=self.files,
|
|
276
254
|
data=CreateEtchFilePayload(payloads=self.get_file_payloads()),
|
|
277
255
|
signature_email_subject=self.signature_email_subject,
|
|
278
256
|
signature_email_body=self.signature_email_body,
|
|
@@ -286,4 +264,4 @@ class CreateEtchPacket(BaseQuery):
|
|
|
286
264
|
duplicate_casts=self.duplicate_casts,
|
|
287
265
|
)
|
|
288
266
|
|
|
289
|
-
return payload
|
|
267
|
+
return payload
|
|
@@ -47,7 +47,6 @@ mutation ForgeSubmit(
|
|
|
47
47
|
$timezone: String,
|
|
48
48
|
$groupArrayId: String,
|
|
49
49
|
$groupArrayIndex: Int,
|
|
50
|
-
$errorType: String,
|
|
51
50
|
$webhookURL: String,
|
|
52
51
|
) {{
|
|
53
52
|
forgeSubmit (
|
|
@@ -61,7 +60,6 @@ mutation ForgeSubmit(
|
|
|
61
60
|
timezone: $timezone,
|
|
62
61
|
groupArrayId: $groupArrayId,
|
|
63
62
|
groupArrayIndex: $groupArrayIndex,
|
|
64
|
-
errorType: $errorType,
|
|
65
63
|
webhookURL: $webhookURL
|
|
66
64
|
) {query}
|
|
67
65
|
}}
|
|
@@ -7,12 +7,11 @@ from io import BufferedIOBase
|
|
|
7
7
|
# import `BaseModel` and it's not broken, so let's keep it.
|
|
8
8
|
from pydantic import ( # pylint: disable=no-name-in-module
|
|
9
9
|
Field,
|
|
10
|
-
FilePath,
|
|
11
10
|
HttpUrl,
|
|
12
11
|
root_validator,
|
|
13
12
|
validator,
|
|
14
13
|
)
|
|
15
|
-
from typing import Any,
|
|
14
|
+
from typing import Any, Dict, List, Optional, Text, Union
|
|
16
15
|
|
|
17
16
|
from .base import BaseModel
|
|
18
17
|
|
|
@@ -184,7 +183,13 @@ class DocumentUpload(BaseModel):
|
|
|
184
183
|
|
|
185
184
|
id: str
|
|
186
185
|
title: str
|
|
187
|
-
|
|
186
|
+
# Previously "UploadableFile", however, that seems to cause weird upload
|
|
187
|
+
# issues where a PDF file would have its first few bytes removed.
|
|
188
|
+
# We're now relying on the backend to validate this property instead of on
|
|
189
|
+
# the client library side.
|
|
190
|
+
# This might be a bug on the `pydantic` side(?) when this object gets
|
|
191
|
+
# converted into a dict.
|
|
192
|
+
file: Any
|
|
188
193
|
fields: List[SignatureField]
|
|
189
194
|
font_size: int = 14
|
|
190
195
|
text_color: str = "#000000"
|
|
@@ -192,10 +197,8 @@ class DocumentUpload(BaseModel):
|
|
|
192
197
|
class Config:
|
|
193
198
|
"""Special rules for this model class."""
|
|
194
199
|
|
|
200
|
+
# Needed to allow `BufferedIOBase` as an allowed `file` type.
|
|
195
201
|
arbitrary_types_allowed = True
|
|
196
|
-
# String to help point out the file upload attr.
|
|
197
|
-
# Used in multipart uploads and should not affect base64-encoded files.
|
|
198
|
-
contains_uploads = "file"
|
|
199
202
|
|
|
200
203
|
|
|
201
204
|
class EtchCastRef(BaseModel):
|
|
@@ -271,16 +274,7 @@ class ForgeSubmitPayload(BaseModel):
|
|
|
271
274
|
return values
|
|
272
275
|
|
|
273
276
|
|
|
274
|
-
|
|
275
|
-
# Base64Upload: An entire PDF file base64 encoded
|
|
276
|
-
# FilePath: The absolute path of the file. When the pydantic model is used,
|
|
277
|
-
# it will convert a path string (i.e. "/home/me/my_file.pdf") into a
|
|
278
|
-
# `pathlib.Path` instance.
|
|
279
|
-
# UploadMethod: A function that returns a tuple of:
|
|
280
|
-
# * BufferedIOBase instance for use in `requests`
|
|
281
|
-
# * String containing the name for the file for use in Anvil
|
|
282
|
-
UploadMethod = Callable[[Any], Tuple[BufferedIOBase, str]]
|
|
283
|
-
UploadableFile = Union[Base64Upload, FilePath, UploadMethod]
|
|
277
|
+
UploadableFile = Union[Base64Upload, BufferedIOBase]
|
|
284
278
|
AttachableEtchFile = Union[
|
|
285
279
|
DocumentUpload, EtchCastRef, DocumentMarkup, DocumentMarkdown
|
|
286
280
|
]
|
|
@@ -34,6 +34,7 @@ class AnvilRequest:
|
|
|
34
34
|
else:
|
|
35
35
|
message = f"Error: {status_code}: {response} {extra}"
|
|
36
36
|
|
|
37
|
+
# pylint: disable=broad-exception-raised
|
|
37
38
|
raise Exception(message)
|
|
38
39
|
|
|
39
40
|
def process_response(self, response, status_code, headers, **kwargs):
|
|
@@ -78,6 +79,12 @@ class BaseAnvilHttpRequest(AnvilRequest):
|
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
class GraphqlRequest(AnvilRequest):
|
|
82
|
+
"""Create a GraphQL request.
|
|
83
|
+
|
|
84
|
+
.. deprecated :: 2.0.0
|
|
85
|
+
Use `python_anvil.http.GQLClient` to make GraphQL queries and mutations.
|
|
86
|
+
"""
|
|
87
|
+
|
|
81
88
|
API_HOST = "https://graphql.useanvil.com"
|
|
82
89
|
|
|
83
90
|
def __init__(self, client: HTTPClient):
|
python_anvil/cli.py
CHANGED
|
@@ -102,11 +102,12 @@ def weld(ctx, eid, list_all):
|
|
|
102
102
|
if debug:
|
|
103
103
|
click.echo(headers)
|
|
104
104
|
|
|
105
|
-
data = [(w["eid"], w.get("slug"), w.get("
|
|
105
|
+
data = [(w["eid"], w.get("slug"), w.get("name"), w.get("forges")) for w in res]
|
|
106
106
|
click.echo(tabulate(data, tablefmt="pretty", headers=["eid", "slug", "title"]))
|
|
107
107
|
return
|
|
108
108
|
|
|
109
109
|
if not eid:
|
|
110
|
+
# pylint: disable=broad-exception-raised
|
|
110
111
|
raise Exception("You need to pass in a weld eid")
|
|
111
112
|
|
|
112
113
|
res = anvil.get_weld(eid)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Basic constants used in the library."""
|
|
2
|
+
|
|
3
|
+
GRAPHQL_ENDPOINT: str = "https://graphql.useanvil.com"
|
|
4
|
+
REST_ENDPOINT = "https://app.useanvil.com/api/v1/"
|
|
5
|
+
|
|
6
|
+
RETRIES_LIMIT = 5
|
|
7
|
+
REQUESTS_LIMIT = {
|
|
8
|
+
"dev": {
|
|
9
|
+
"calls": 2,
|
|
10
|
+
"seconds": 1,
|
|
11
|
+
},
|
|
12
|
+
"prod": {
|
|
13
|
+
"calls": 40,
|
|
14
|
+
"seconds": 1,
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
RATELIMIT_ENV = "dev"
|
python_anvil/http.py
CHANGED
|
@@ -1,33 +1,89 @@
|
|
|
1
|
+
import os
|
|
1
2
|
import requests
|
|
2
3
|
from base64 import b64encode
|
|
4
|
+
from gql import Client
|
|
5
|
+
from gql.dsl import DSLSchema
|
|
6
|
+
from gql.transport.requests import RequestsHTTPTransport
|
|
3
7
|
from logging import getLogger
|
|
4
8
|
from ratelimit import limits, sleep_and_retry
|
|
5
9
|
from ratelimit.exception import RateLimitException
|
|
6
10
|
from requests.auth import HTTPBasicAuth
|
|
11
|
+
from typing import Optional
|
|
7
12
|
|
|
8
13
|
from python_anvil.exceptions import AnvilRequestException
|
|
9
14
|
|
|
15
|
+
from .constants import GRAPHQL_ENDPOINT, RATELIMIT_ENV, REQUESTS_LIMIT, RETRIES_LIMIT
|
|
10
16
|
|
|
11
|
-
logger = getLogger(__name__)
|
|
12
|
-
|
|
13
|
-
REQUESTS_LIMIT = {
|
|
14
|
-
"dev": {
|
|
15
|
-
"calls": 2,
|
|
16
|
-
"seconds": 1,
|
|
17
|
-
},
|
|
18
|
-
"prod": {
|
|
19
|
-
"calls": 40,
|
|
20
|
-
"seconds": 1,
|
|
21
|
-
},
|
|
22
|
-
}
|
|
23
17
|
|
|
24
|
-
|
|
18
|
+
logger = getLogger(__name__)
|
|
25
19
|
|
|
26
20
|
|
|
27
21
|
def _handle_request_error(e: Exception):
|
|
28
22
|
raise e
|
|
29
23
|
|
|
30
24
|
|
|
25
|
+
def get_local_schema(raise_on_error=False) -> Optional[str]:
|
|
26
|
+
"""
|
|
27
|
+
Retrieve local GraphQL schema.
|
|
28
|
+
|
|
29
|
+
:param raise_on_error:
|
|
30
|
+
:return:
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
file_dir = os.path.dirname(os.path.realpath(__file__))
|
|
34
|
+
file_path = os.path.join(file_dir, "..", "schema", "anvil_schema.graphql")
|
|
35
|
+
with open(file_path, encoding="utf-8") as file:
|
|
36
|
+
schema = file.read()
|
|
37
|
+
except Exception: # pylint: disable
|
|
38
|
+
logger.warning(
|
|
39
|
+
"Unable to find local schema. Will not use schema for local "
|
|
40
|
+
"validation. Use `fetch_schema_from_transport=True` to allow "
|
|
41
|
+
"fetching the remote schema."
|
|
42
|
+
)
|
|
43
|
+
if raise_on_error:
|
|
44
|
+
raise
|
|
45
|
+
schema = None
|
|
46
|
+
|
|
47
|
+
return schema
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_gql_ds(client: Client) -> DSLSchema:
|
|
51
|
+
if not client.schema:
|
|
52
|
+
raise ValueError("Client does not have a valid GraphQL schema.")
|
|
53
|
+
return DSLSchema(client.schema)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GQLClient:
|
|
57
|
+
"""GraphQL client factory class."""
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def get_client(
|
|
61
|
+
api_key: str,
|
|
62
|
+
environment: str = "dev", # pylint: disable=unused-argument
|
|
63
|
+
endpoint_url: Optional[str] = None,
|
|
64
|
+
fetch_schema_from_transport: bool = False,
|
|
65
|
+
force_local_schema: bool = False,
|
|
66
|
+
) -> Client:
|
|
67
|
+
auth = HTTPBasicAuth(username=api_key, password="")
|
|
68
|
+
endpoint_url = endpoint_url or GRAPHQL_ENDPOINT
|
|
69
|
+
transport = RequestsHTTPTransport(
|
|
70
|
+
retries=RETRIES_LIMIT,
|
|
71
|
+
auth=auth,
|
|
72
|
+
url=endpoint_url,
|
|
73
|
+
verify=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
schema = None
|
|
77
|
+
if force_local_schema or not fetch_schema_from_transport:
|
|
78
|
+
schema = get_local_schema(raise_on_error=False)
|
|
79
|
+
|
|
80
|
+
return Client(
|
|
81
|
+
schema=schema,
|
|
82
|
+
transport=transport,
|
|
83
|
+
fetch_schema_from_transport=fetch_schema_from_transport,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
31
87
|
class HTTPClient:
|
|
32
88
|
def __init__(self, api_key=None, environment="dev"):
|
|
33
89
|
self._session = requests.Session()
|
python_anvil/tests/test_api.py
CHANGED
|
@@ -17,22 +17,58 @@ from . import payloads
|
|
|
17
17
|
|
|
18
18
|
DEV_KEY = "MY-SECRET-KEY"
|
|
19
19
|
|
|
20
|
+
TEST_SCHEMA = """
|
|
21
|
+
type GenerateEmbedURLResponse {
|
|
22
|
+
eid: String!
|
|
23
|
+
url: String!
|
|
24
|
+
}
|
|
25
|
+
"""
|
|
26
|
+
|
|
20
27
|
|
|
21
28
|
def describe_api():
|
|
22
29
|
@pytest.fixture
|
|
23
|
-
|
|
30
|
+
@mock.patch("builtins.open", new_callable=mock.mock_open, read_data=TEST_SCHEMA)
|
|
31
|
+
def anvil(m_open):
|
|
24
32
|
return Anvil(api_key=DEV_KEY)
|
|
25
33
|
|
|
26
34
|
def describe_init():
|
|
35
|
+
@mock.patch('python_anvil.api.GQLClient')
|
|
27
36
|
@mock.patch('python_anvil.api.HTTPClient')
|
|
28
|
-
def test_init_key_default(mock_client):
|
|
37
|
+
def test_init_key_default(mock_client, mock_gql):
|
|
29
38
|
Anvil(api_key="what")
|
|
30
39
|
mock_client.assert_called_once_with(api_key="what", environment="dev")
|
|
40
|
+
mock_gql.get_client.assert_called_once_with(
|
|
41
|
+
api_key="what", environment="dev", endpoint_url=None
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@mock.patch('python_anvil.api.GQLClient')
|
|
45
|
+
@mock.patch('python_anvil.api.HTTPClient')
|
|
46
|
+
def test_init_with_endpoint(mock_client, mock_gql):
|
|
47
|
+
Anvil(api_key="what", endpoint_url="http://somewhere.example")
|
|
48
|
+
mock_client.assert_called_once_with(api_key="what", environment="dev")
|
|
49
|
+
mock_gql.get_client.assert_called_once_with(
|
|
50
|
+
api_key="what",
|
|
51
|
+
environment="dev",
|
|
52
|
+
endpoint_url="http://somewhere.example",
|
|
53
|
+
)
|
|
31
54
|
|
|
55
|
+
@mock.patch('python_anvil.api.GQLClient')
|
|
32
56
|
@mock.patch('python_anvil.api.HTTPClient')
|
|
33
|
-
def test_init_key_prod(mock_client):
|
|
57
|
+
def test_init_key_prod(mock_client, mock_gql):
|
|
34
58
|
Anvil(api_key="what", environment="prod")
|
|
35
59
|
mock_client.assert_called_once_with(api_key="what", environment="prod")
|
|
60
|
+
mock_gql.get_client.assert_called_once_with(
|
|
61
|
+
api_key="what", environment="prod", endpoint_url=None
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@mock.patch('python_anvil.api.GQLClient')
|
|
65
|
+
@mock.patch('python_anvil.api.HTTPClient')
|
|
66
|
+
def test_init_no_key(mock_client, mock_gql):
|
|
67
|
+
with pytest.raises(ValueError):
|
|
68
|
+
Anvil(environment="prod")
|
|
69
|
+
|
|
70
|
+
assert mock_client.call_count == 0
|
|
71
|
+
assert mock_gql.get_client.call_count == 0
|
|
36
72
|
|
|
37
73
|
def describe_query():
|
|
38
74
|
def test_query():
|
|
@@ -175,19 +211,19 @@ def describe_api():
|
|
|
175
211
|
assert m_request_post.call_count == 0
|
|
176
212
|
|
|
177
213
|
def describe_current_user_query():
|
|
178
|
-
@mock.patch('
|
|
214
|
+
@mock.patch('gql.Client.execute')
|
|
179
215
|
def test_get_current_user(m_request_post, anvil):
|
|
180
216
|
anvil.get_current_user()
|
|
181
217
|
assert m_request_post.call_count == 1
|
|
182
218
|
|
|
183
219
|
def describe_get_welds():
|
|
184
|
-
@mock.patch('
|
|
220
|
+
@mock.patch('gql.Client.execute')
|
|
185
221
|
def test_get_weld(m_request_post, anvil):
|
|
186
222
|
anvil.get_welds()
|
|
187
223
|
assert m_request_post.call_count == 1
|
|
188
224
|
|
|
189
225
|
def describe_generate_etch_signing_url():
|
|
190
|
-
@mock.patch('
|
|
226
|
+
@mock.patch('gql.Client.execute')
|
|
191
227
|
def test_get_url(m_request_post, anvil):
|
|
192
228
|
anvil.generate_etch_signing_url(
|
|
193
229
|
signer_eid='someId', client_user_id='anotherId'
|
|
@@ -201,13 +237,13 @@ def describe_api():
|
|
|
201
237
|
assert m_request_post.call_count == 1
|
|
202
238
|
|
|
203
239
|
def describe_get_cast():
|
|
204
|
-
@mock.patch('
|
|
240
|
+
@mock.patch('gql.Client.execute')
|
|
205
241
|
def test_get_cast(m_request_post, anvil):
|
|
206
242
|
anvil.get_cast('castEid')
|
|
207
243
|
assert m_request_post.call_count == 1
|
|
208
244
|
|
|
209
245
|
def describe_get_casts():
|
|
210
|
-
@mock.patch('
|
|
246
|
+
@mock.patch('gql.Client.execute')
|
|
211
247
|
def test_get_casts(m_request_post, anvil):
|
|
212
248
|
anvil.get_casts()
|
|
213
249
|
assert m_request_post.call_count == 1
|
|
@@ -224,19 +260,19 @@ def describe_api():
|
|
|
224
260
|
'signaturePageOptions': {},
|
|
225
261
|
}
|
|
226
262
|
|
|
227
|
-
@mock.patch('
|
|
263
|
+
@mock.patch('gql.Client.execute')
|
|
228
264
|
def test_create_etch_packet_empty_payload(m_request_post, anvil):
|
|
229
265
|
with pytest.raises(TypeError):
|
|
230
266
|
anvil.create_etch_packet(payload={})
|
|
231
267
|
assert m_request_post.call_count == 0
|
|
232
268
|
|
|
233
|
-
@mock.patch('
|
|
269
|
+
@mock.patch('gql.Client.execute')
|
|
234
270
|
def test_create_etch_packet_invalid_payload(m_request_post, anvil):
|
|
235
271
|
with pytest.raises(TypeError):
|
|
236
272
|
anvil.create_etch_packet({})
|
|
237
273
|
assert m_request_post.call_count == 0
|
|
238
274
|
|
|
239
|
-
@mock.patch('
|
|
275
|
+
@mock.patch('gql.Client.execute')
|
|
240
276
|
def test_create_etch_packet_valid_payload_type(m_request_post, anvil):
|
|
241
277
|
payload = CreateEtchPacket(
|
|
242
278
|
name="Packet name",
|
|
@@ -245,12 +281,10 @@ def describe_api():
|
|
|
245
281
|
anvil.create_etch_packet(payload=payload)
|
|
246
282
|
assert m_request_post.call_count == 1
|
|
247
283
|
|
|
248
|
-
|
|
249
|
-
files_payload = json.loads(files["operations"][1])
|
|
250
|
-
variables = files_payload["variables"]
|
|
284
|
+
variables = m_request_post.call_args[1]["variable_values"]
|
|
251
285
|
assert expected_data == variables
|
|
252
286
|
|
|
253
|
-
@mock.patch('
|
|
287
|
+
@mock.patch('gql.Client.execute')
|
|
254
288
|
def test_create_etch_packet_passes_options(m_request_post, anvil):
|
|
255
289
|
payload = CreateEtchPacket(
|
|
256
290
|
is_test=False,
|
|
@@ -262,43 +296,39 @@ def describe_api():
|
|
|
262
296
|
new_expected["isTest"] = False
|
|
263
297
|
new_expected["isDraft"] = True
|
|
264
298
|
anvil.create_etch_packet(payload)
|
|
265
|
-
|
|
266
299
|
assert m_request_post.call_count == 1
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
variables = files_payload["variables"]
|
|
300
|
+
|
|
301
|
+
variables = m_request_post.call_args[1]["variable_values"]
|
|
270
302
|
assert new_expected == variables
|
|
271
303
|
|
|
272
|
-
@mock.patch('
|
|
304
|
+
@mock.patch('gql.Client.execute')
|
|
273
305
|
def test_create_etch_packet_valid_dict_type(m_request_post, anvil):
|
|
274
306
|
anvil.create_etch_packet(
|
|
275
307
|
dict(name="Packet name", signature_email_subject="The subject")
|
|
276
308
|
)
|
|
277
309
|
assert m_request_post.call_count == 1
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
variables = files_payload["variables"]
|
|
310
|
+
|
|
311
|
+
variables = m_request_post.call_args[1]["variable_values"]
|
|
281
312
|
assert expected_data == variables
|
|
282
313
|
|
|
283
314
|
@mock.patch(
|
|
284
315
|
'python_anvil.api_resources.mutations.create_etch_packet.create_unique_id'
|
|
285
316
|
)
|
|
286
|
-
@mock.patch('
|
|
317
|
+
@mock.patch('gql.Client.execute')
|
|
287
318
|
def test_create_etch_packet_dict_with_signer(
|
|
288
319
|
m_request_post, m_create_unique, anvil
|
|
289
320
|
):
|
|
290
321
|
m_create_unique.return_value = "signer-mock-generated"
|
|
291
322
|
anvil.create_etch_packet(payload=payloads.ETCH_TEST_PAYLOAD)
|
|
292
323
|
assert m_request_post.call_count == 1
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
variables = files_payload["variables"]
|
|
324
|
+
|
|
325
|
+
variables = m_request_post.call_args[1]["variable_values"]
|
|
296
326
|
assert payloads.EXPECTED_ETCH_TEST_PAYLOAD == variables
|
|
297
327
|
|
|
298
328
|
@mock.patch(
|
|
299
329
|
'python_anvil.api_resources.mutations.create_etch_packet.create_unique_id'
|
|
300
330
|
)
|
|
301
|
-
@mock.patch('
|
|
331
|
+
@mock.patch('gql.Client.execute')
|
|
302
332
|
def test_create_etch_packet_json(m_request_post, m_create_unique, anvil):
|
|
303
333
|
m_create_unique.return_value = "signer-mock-generated"
|
|
304
334
|
# We are currently removing `None`s from the payload, so do that here too.
|
|
@@ -310,15 +340,13 @@ def describe_api():
|
|
|
310
340
|
anvil.create_etch_packet(json=json.dumps(payload))
|
|
311
341
|
assert m_request_post.call_count == 1
|
|
312
342
|
|
|
313
|
-
|
|
314
|
-
files_payload = json.loads(files["operations"][1])
|
|
315
|
-
variables = files_payload["variables"]
|
|
343
|
+
variables = m_request_post.call_args[1]["variable_values"]
|
|
316
344
|
assert payload == variables
|
|
317
345
|
|
|
318
346
|
@mock.patch(
|
|
319
347
|
'python_anvil.api_resources.mutations.create_etch_packet.create_unique_id'
|
|
320
348
|
)
|
|
321
|
-
@mock.patch('
|
|
349
|
+
@mock.patch('gql.Client.execute')
|
|
322
350
|
def test_adding_unsupported_fields(m_request_post, m_create_unique, anvil):
|
|
323
351
|
m_create_unique.return_value = "signer-mock-generated"
|
|
324
352
|
# We are currently removing `None`s from the payload, so do that here too.
|
|
@@ -332,9 +360,7 @@ def describe_api():
|
|
|
332
360
|
anvil.create_etch_packet(payload=cep)
|
|
333
361
|
assert m_request_post.call_count == 1
|
|
334
362
|
|
|
335
|
-
|
|
336
|
-
files_payload = json.loads(files["operations"][1])
|
|
337
|
-
variables = files_payload["variables"]
|
|
363
|
+
variables = m_request_post.call_args[1]["variable_values"]
|
|
338
364
|
assert cep.dict(by_alias=True, exclude_none=True) == variables
|
|
339
365
|
assert "newFeature" in variables
|
|
340
366
|
|
|
@@ -345,22 +371,22 @@ def describe_api():
|
|
|
345
371
|
"isTest": True,
|
|
346
372
|
}
|
|
347
373
|
|
|
348
|
-
@mock.patch('
|
|
374
|
+
@mock.patch('gql.Client.execute')
|
|
349
375
|
def test_invalid_payload(m_request_post, anvil):
|
|
350
376
|
with pytest.raises(TypeError):
|
|
351
377
|
anvil.forge_submit({})
|
|
352
378
|
assert m_request_post.call_count == 0
|
|
353
379
|
|
|
354
|
-
@mock.patch('
|
|
380
|
+
@mock.patch('gql.Client.execute')
|
|
355
381
|
def test_minimum_valid_data_forge_eid(m_request_post, anvil):
|
|
356
382
|
payload = ForgeSubmitPayload(
|
|
357
383
|
forge_eid="forge1234", payload=dict(field1="Some data")
|
|
358
384
|
)
|
|
359
385
|
anvil.forge_submit(payload=payload)
|
|
360
386
|
assert m_request_post.call_count == 1
|
|
361
|
-
assert expected_data
|
|
387
|
+
assert {"variable_values": expected_data} in m_request_post.call_args
|
|
362
388
|
|
|
363
|
-
@mock.patch('
|
|
389
|
+
@mock.patch('gql.Client.execute')
|
|
364
390
|
def test_invalid_wd_submission(m_request_post, anvil):
|
|
365
391
|
with pytest.raises(ValidationError):
|
|
366
392
|
payload = dict(
|
|
@@ -384,7 +410,7 @@ def describe_api():
|
|
|
384
410
|
anvil.forge_submit(payload=payload)
|
|
385
411
|
assert m_request_post.call_count == 0
|
|
386
412
|
|
|
387
|
-
@mock.patch('
|
|
413
|
+
@mock.patch('gql.Client.execute')
|
|
388
414
|
def test_minimum_valid_data_submission(m_request_post, anvil):
|
|
389
415
|
payload = ForgeSubmitPayload(
|
|
390
416
|
forge_eid="forge1234",
|
|
@@ -396,12 +422,14 @@ def describe_api():
|
|
|
396
422
|
# We copy `expected_data` here as it can cause a race condition
|
|
397
423
|
# in other tests that use it.
|
|
398
424
|
_expected_data: MutableMapping[str, Any] = {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
425
|
+
"variable_values": {
|
|
426
|
+
**expected_data,
|
|
427
|
+
"submissionEid": "sub1234",
|
|
428
|
+
"weldDataEid": "wd1234",
|
|
429
|
+
"payload": {"field1": "Updated data"},
|
|
430
|
+
}
|
|
403
431
|
}
|
|
404
432
|
|
|
405
433
|
anvil.forge_submit(payload=payload)
|
|
406
434
|
assert m_request_post.call_count == 1
|
|
407
|
-
assert _expected_data in m_request_post.call_args
|
|
435
|
+
assert _expected_data in m_request_post.call_args
|
python_anvil/tests/test_cli.py
CHANGED
|
@@ -35,7 +35,7 @@ def describe_cli():
|
|
|
35
35
|
@mock.patch("python_anvil.api.Anvil.query")
|
|
36
36
|
def it_queries(query, runner, monkeypatch):
|
|
37
37
|
set_key(monkeypatch)
|
|
38
|
-
query.return_value = {"
|
|
38
|
+
query.return_value = {"currentUser": {"name": "Cameron"}}
|
|
39
39
|
|
|
40
40
|
res = runner.invoke(cli, ['current-user'])
|
|
41
41
|
assert "{'name': 'Cameron'}" in res.output
|
|
@@ -46,7 +46,7 @@ def describe_cli():
|
|
|
46
46
|
def it_handles_headers(query, runner, monkeypatch):
|
|
47
47
|
set_key(monkeypatch)
|
|
48
48
|
query.return_value = {
|
|
49
|
-
"response": {"
|
|
49
|
+
"response": {"currentUser": {"name": "Cameron"}},
|
|
50
50
|
"headers": {"Header-1": "val1", "Header-2": "val2"},
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -71,3 +71,42 @@ def describe_cli():
|
|
|
71
71
|
)
|
|
72
72
|
generate_pdf.assert_called_once_with(in_data, debug=False)
|
|
73
73
|
m().write.assert_called_once_with("Some bytes")
|
|
74
|
+
|
|
75
|
+
def describe_gql_query():
|
|
76
|
+
@mock.patch("python_anvil.api.Anvil.query")
|
|
77
|
+
def it_works_query_only(query, runner, monkeypatch):
|
|
78
|
+
set_key(monkeypatch)
|
|
79
|
+
|
|
80
|
+
query.return_value = dict(
|
|
81
|
+
eid="abc123",
|
|
82
|
+
name="Some User",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
query_str = """
|
|
86
|
+
query SomeQuery {
|
|
87
|
+
someQuery { eid name }
|
|
88
|
+
}
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
runner.invoke(cli, ['gql-query', '-q', query_str])
|
|
92
|
+
query.assert_called_once_with(query_str, variables=None, debug=False)
|
|
93
|
+
|
|
94
|
+
@mock.patch("python_anvil.api.Anvil.query")
|
|
95
|
+
def it_works_query_and_variables(query, runner, monkeypatch):
|
|
96
|
+
set_key(monkeypatch)
|
|
97
|
+
|
|
98
|
+
query.return_value = dict(
|
|
99
|
+
eid="abc123",
|
|
100
|
+
name="Some User",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
query_str = """
|
|
104
|
+
query SomeQuery ($eid: String) {
|
|
105
|
+
someQuery(eid: $eid) { eid name }
|
|
106
|
+
}
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
variables = json.dumps(dict(eid="abc123"))
|
|
110
|
+
|
|
111
|
+
runner.invoke(cli, ['gql-query', '-q', query_str, '-v', variables])
|
|
112
|
+
query.assert_called_once_with(query_str, variables=variables, debug=False)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: python-anvil
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.1
|
|
4
4
|
Summary: Anvil API
|
|
5
5
|
Home-page: https://www.useanvil.com/
|
|
6
6
|
License: MIT
|
|
@@ -18,17 +18,15 @@ Classifier: Programming Language :: Python :: 3.8
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.9
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
-
Classifier: Programming Language :: Python :: 3
|
|
22
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
23
21
|
Classifier: Programming Language :: Python :: 3.7
|
|
24
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
25
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
26
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
23
|
Requires-Dist: click (>=8.0,<9.0)
|
|
24
|
+
Requires-Dist: gql[requests] (==3.5.0b1)
|
|
28
25
|
Requires-Dist: pydantic (>=1.8.2,<2.0.0)
|
|
29
26
|
Requires-Dist: ratelimit (>=2.2.1,<3.0.0)
|
|
30
27
|
Requires-Dist: requests (>=2.25.0,<3.0.0)
|
|
31
28
|
Requires-Dist: tabulate (>=0.9.0,<0.10.0)
|
|
29
|
+
Requires-Dist: urllib3 (<2)
|
|
32
30
|
Project-URL: Documentation, https://github.com/anvilco/python-anvil
|
|
33
31
|
Project-URL: Repository, https://github.com/anvilco/python-anvil
|
|
34
32
|
Description-Content-Type: text/markdown
|
|
@@ -61,7 +59,7 @@ General API documentation: [Anvil API docs](https://www.useanvil.com/docs)
|
|
|
61
59
|
|
|
62
60
|
## Requirements
|
|
63
61
|
|
|
64
|
-
* Python 3.
|
|
62
|
+
* Python 3.7.2+
|
|
65
63
|
|
|
66
64
|
## Installation
|
|
67
65
|
|
|
@@ -1,31 +1,30 @@
|
|
|
1
1
|
python_anvil/__init__.py,sha256=hY54dJl0FjAPEVKv_dNJ9wrWy-YHocDHD7WPG-x3Pg4,223
|
|
2
|
-
python_anvil/api.py,sha256=
|
|
2
|
+
python_anvil/api.py,sha256=lyqBapTFLVQOX4L4ryCvpH84riQOfAcQ8Afw2XcyDdc,13709
|
|
3
3
|
python_anvil/api_resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
python_anvil/api_resources/base.py,sha256=Y8Q1mM9RJ27SedmtNA0bzzQoXODpQuFeatY8d6DzWC4,941
|
|
5
5
|
python_anvil/api_resources/mutations/__init__.py,sha256=9JOe1MiowInNzCJmuRLBTkVZcT-mOzETMoL5S_E_Y84,177
|
|
6
6
|
python_anvil/api_resources/mutations/base.py,sha256=qYo_vwhNrFY-azvze_llQiz7P5KcuREjNjGPrleBbT4,588
|
|
7
|
-
python_anvil/api_resources/mutations/create_etch_packet.py,sha256=
|
|
8
|
-
python_anvil/api_resources/mutations/forge_submit.py,sha256=
|
|
7
|
+
python_anvil/api_resources/mutations/create_etch_packet.py,sha256=3FHjzc0EPXJQBwMxLglkEkvbQCi_FWjl8DbMcbWJdOw,9271
|
|
8
|
+
python_anvil/api_resources/mutations/forge_submit.py,sha256=i4EYPXVdZpYP3gmsiM3ltrmvsAF1Q11vlgn1Sn28PNM,3584
|
|
9
9
|
python_anvil/api_resources/mutations/generate_etch_signing_url.py,sha256=O3uBgHmd5WStuHtn6I54ujFjSbQFbP_E7zE9BlGV-yg,737
|
|
10
10
|
python_anvil/api_resources/mutations/helpers.py,sha256=-Us_rfqnXRR6aVwc6wBBwoXluppNkIchccAdvQINTnI,211
|
|
11
|
-
python_anvil/api_resources/payload.py,sha256=
|
|
12
|
-
python_anvil/api_resources/requests.py,sha256=
|
|
13
|
-
python_anvil/cli.py,sha256=
|
|
11
|
+
python_anvil/api_resources/payload.py,sha256=F5WUlFZdgPrwEKbcDPoiEbGCuWGJ-DCZeRizKq6WFCY,9006
|
|
12
|
+
python_anvil/api_resources/requests.py,sha256=X5YUV_DBBryWqhEkxXzJFphMx2HVqcUd1ZzJ7b9iHk0,5146
|
|
13
|
+
python_anvil/cli.py,sha256=X6g5L41z5nszLIvOOQYTVdBrkQxweluqAzY2WKq3R0c,10337
|
|
14
|
+
python_anvil/constants.py,sha256=MxuTbJf6PPa7_2P_SqyaXrqO0Nwo-8EwP6bA0FTvG7A,339
|
|
14
15
|
python_anvil/exceptions.py,sha256=XhgeG6oGQ78_DZDrmsLGX7789ygQ78aIlpJZbhCfRhg,102
|
|
15
|
-
python_anvil/http.py,sha256=
|
|
16
|
-
python_anvil/multipart_helpers.py,sha256=vUcA-ul5TvzieVhIcrqU5H16-8OJtlEWbyZa2sf6pLo,4064
|
|
16
|
+
python_anvil/http.py,sha256=eVmGji6x86r3M7NkHeeWkQ0aWTM-juUF4cUzdPQEcXY,6554
|
|
17
17
|
python_anvil/py.typed,sha256=bWew9mHgMy8LqMu7RuqQXFXLBxh2CRx0dUbSx-3wE48,27
|
|
18
18
|
python_anvil/tests/__init__.py,sha256=LURUPwwDqcFmVe1XvEzadPdXxro8RdEF-rTaMw-AfiI,34
|
|
19
19
|
python_anvil/tests/conftest.py,sha256=Noz5CUwT-xh2CJNKsYyu_bUFaoMMKavOiAuhHzZeOp8,267
|
|
20
20
|
python_anvil/tests/payloads.py,sha256=bn__agyTyRl6Lw0nZPRLvpjErt1mElDZQ5T2WteqcOU,2367
|
|
21
|
-
python_anvil/tests/test_api.py,sha256=
|
|
22
|
-
python_anvil/tests/test_cli.py,sha256=
|
|
21
|
+
python_anvil/tests/test_api.py,sha256=aIoAVs3DGVRLDbmW27ZCzUtJr-HHaAyFpBaM3d3VrQw,17441
|
|
22
|
+
python_anvil/tests/test_cli.py,sha256=Ghnqx2q0V0w-Y9aWGaMpi6_OTo8x7E7NaSkfjVLZixY,3845
|
|
23
23
|
python_anvil/tests/test_http.py,sha256=4BLJ0A7HKWq-9_NKoUmHKiBwbNLasig6ppGMhYJIisQ,4908
|
|
24
|
-
python_anvil/tests/test_multipart_helpers.py,sha256=nwq35mGaQhNef6uOFOkgIVYsMB6Lk9Rr9XrZ0l59oIU,1237
|
|
25
24
|
python_anvil/tests/test_utils.py,sha256=VQ3u0WQd7Oa_aMRF-GibRKqAlo3Bf62tHH6auCBF5o4,1352
|
|
26
25
|
python_anvil/utils.py,sha256=xKbrMV6RnPMz_5eEnD-N1uvUn9895yiUhjSIUUnh13w,1103
|
|
27
|
-
python_anvil-
|
|
28
|
-
python_anvil-
|
|
29
|
-
python_anvil-
|
|
30
|
-
python_anvil-
|
|
31
|
-
python_anvil-
|
|
26
|
+
python_anvil-3.0.1.dist-info/LICENSE.md,sha256=0Z9mdiVAIMBp50iEbXt4bDHqlnAwAfKbj9Yi2b6O8fo,1091
|
|
27
|
+
python_anvil-3.0.1.dist-info/METADATA,sha256=cVLWTK41GBsU033ejHlDi2rartdu7Nv3eJ1Z5aBDSNA,3346
|
|
28
|
+
python_anvil-3.0.1.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
|
|
29
|
+
python_anvil-3.0.1.dist-info/entry_points.txt,sha256=nf_xgj2f9K2Zascpk2MtOP-CT2fmKc1xUDWnTdq4P_Y,46
|
|
30
|
+
python_anvil-3.0.1.dist-info/RECORD,,
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import copy
|
|
2
|
-
import json
|
|
3
|
-
import mimetypes
|
|
4
|
-
from io import BufferedIOBase
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
7
|
-
|
|
8
|
-
from python_anvil.api_resources.mutations import BaseQuery
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
FileLikeObject = Union[Path, BufferedIOBase]
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def get_extractable_files_from_payload(
|
|
15
|
-
variables: Any,
|
|
16
|
-
is_match: Callable,
|
|
17
|
-
cur_path: Optional[str] = None,
|
|
18
|
-
cur_files: Optional[List] = None,
|
|
19
|
-
) -> Tuple[List[Tuple[str, FileLikeObject]], bool]:
|
|
20
|
-
"""Process variables bound for a Graphql multipart request.
|
|
21
|
-
|
|
22
|
-
IMPORTANT: This will __overwrite__ the file-like objects to `None` due to
|
|
23
|
-
the expected request format in the spec.
|
|
24
|
-
|
|
25
|
-
Spec: https://github.com/jaydenseric/graphql-multipart-request-spec
|
|
26
|
-
|
|
27
|
-
:param variables:
|
|
28
|
-
:param is_match:
|
|
29
|
-
:param cur_path:
|
|
30
|
-
:param cur_files:
|
|
31
|
-
:return: List containing a tuple of (file path string, file path/buffer object)
|
|
32
|
-
:rtype: Tuple[List[Tuple[str, Union[Path, BufferedIOBase]], bool, List]
|
|
33
|
-
"""
|
|
34
|
-
|
|
35
|
-
if cur_path is None:
|
|
36
|
-
cur_path = "variables"
|
|
37
|
-
if cur_files is None:
|
|
38
|
-
cur_files = []
|
|
39
|
-
if not variables:
|
|
40
|
-
return [], False
|
|
41
|
-
|
|
42
|
-
if is_match(variables):
|
|
43
|
-
cur_files.append((cur_path, copy.deepcopy(variables)))
|
|
44
|
-
return cur_files, True
|
|
45
|
-
|
|
46
|
-
if isinstance(variables, dict):
|
|
47
|
-
to_remove = []
|
|
48
|
-
for key in variables:
|
|
49
|
-
_, remove = get_extractable_files_from_payload(
|
|
50
|
-
variables[key],
|
|
51
|
-
is_match=is_match,
|
|
52
|
-
cur_path=f"{cur_path}.{key}",
|
|
53
|
-
cur_files=cur_files,
|
|
54
|
-
)
|
|
55
|
-
if remove:
|
|
56
|
-
to_remove.append(key)
|
|
57
|
-
|
|
58
|
-
for key in to_remove:
|
|
59
|
-
variables[key] = None
|
|
60
|
-
|
|
61
|
-
elif isinstance(variables, list):
|
|
62
|
-
to_remove = []
|
|
63
|
-
for idx, item in enumerate(variables):
|
|
64
|
-
_, remove = get_extractable_files_from_payload(
|
|
65
|
-
item,
|
|
66
|
-
is_match=is_match,
|
|
67
|
-
cur_path=f"{cur_path}.{idx}",
|
|
68
|
-
cur_files=cur_files,
|
|
69
|
-
)
|
|
70
|
-
if remove:
|
|
71
|
-
to_remove.append(idx)
|
|
72
|
-
|
|
73
|
-
for idx in to_remove:
|
|
74
|
-
variables[idx] = None
|
|
75
|
-
|
|
76
|
-
return cur_files, False
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def get_multipart_payload(mutation: BaseQuery): # pylint: disable=too-many-locals
|
|
80
|
-
def is_match(item):
|
|
81
|
-
return isinstance(item, (BufferedIOBase, Path))
|
|
82
|
-
|
|
83
|
-
payload, to_upload = mutation.create_payload()
|
|
84
|
-
variables = payload.dict(by_alias=True, exclude_none=True)
|
|
85
|
-
multipart_map, _ = get_extractable_files_from_payload(variables, is_match=is_match)
|
|
86
|
-
|
|
87
|
-
operations = json.dumps(
|
|
88
|
-
{
|
|
89
|
-
"query": mutation.get_mutation(),
|
|
90
|
-
"variables": variables,
|
|
91
|
-
}
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
filemap = {}
|
|
95
|
-
for idx, filepath in enumerate(multipart_map):
|
|
96
|
-
filemap[idx] = [filepath[0]]
|
|
97
|
-
|
|
98
|
-
files = {
|
|
99
|
-
"operations": (None, operations),
|
|
100
|
-
"map": (None, json.dumps(filemap)),
|
|
101
|
-
} # type: Dict[str, Union[Tuple[None, str], Tuple[Any, Any, Optional[str]]]]
|
|
102
|
-
|
|
103
|
-
for idx, file_or_path in enumerate(to_upload):
|
|
104
|
-
# `key` here is most likely going to be a list index (int), but
|
|
105
|
-
# `requests` will expect an actual string when it constructs the
|
|
106
|
-
# multipart request. We make sure this is a string here.
|
|
107
|
-
actual_key = str(idx)
|
|
108
|
-
|
|
109
|
-
# This is already a file-like object, pass it directly to `requests`.
|
|
110
|
-
if isinstance(file_or_path, Callable): # type: ignore
|
|
111
|
-
# If you have a `ValueError` here, make sure your callable returns
|
|
112
|
-
# a tuple of the file and filename:
|
|
113
|
-
# Example: (open(file, "rb"), "file.pdf"))
|
|
114
|
-
file_part, name_part = file_or_path()
|
|
115
|
-
elif isinstance(file_or_path, Path):
|
|
116
|
-
file_part = open(file_or_path, "rb") # pylint: disable=consider-using-with
|
|
117
|
-
name_part = file_or_path.parts[-1]
|
|
118
|
-
else:
|
|
119
|
-
raise AssertionError("File path or file-like object not given")
|
|
120
|
-
|
|
121
|
-
mimetype, _ = mimetypes.guess_type(file_part.name)
|
|
122
|
-
files[actual_key] = (name_part, file_part, mimetype)
|
|
123
|
-
|
|
124
|
-
return files
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# pylint: disable=redefined-outer-name,unused-variable,expression-not-assigned,singleton-comparison
|
|
2
|
-
|
|
3
|
-
from python_anvil.multipart_helpers import get_extractable_files_from_payload
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def describe_get_extractable_files_from_payload():
|
|
7
|
-
def test_it_works():
|
|
8
|
-
def is_match(x):
|
|
9
|
-
return isinstance(x, int)
|
|
10
|
-
|
|
11
|
-
variables = {
|
|
12
|
-
"one": [{"f1": "data", "f2": 1}],
|
|
13
|
-
"two": {"s2-1": "more data", "s2-2": "more more data", "s2-3": 2},
|
|
14
|
-
"three": "nothing here",
|
|
15
|
-
"four": 4,
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
paths, _ = get_extractable_files_from_payload(
|
|
19
|
-
variables, is_match, cur_path="variables"
|
|
20
|
-
)
|
|
21
|
-
assert len(paths) == 3
|
|
22
|
-
assert ("variables.one.0.f2", 1) in paths
|
|
23
|
-
assert ("variables.two.s2-3", 2) in paths
|
|
24
|
-
assert ("variables.four", 4) in paths
|
|
25
|
-
|
|
26
|
-
def test_handles_empty_things():
|
|
27
|
-
payload_expected_pairs = [
|
|
28
|
-
({}, 0),
|
|
29
|
-
([], 0),
|
|
30
|
-
('', 0),
|
|
31
|
-
(' ', 1),
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
for var_input, exp_len in payload_expected_pairs:
|
|
35
|
-
paths, _ = get_extractable_files_from_payload(
|
|
36
|
-
var_input, lambda x: x, cur_path="variables"
|
|
37
|
-
)
|
|
38
|
-
assert len(paths) == exp_len
|
|
File without changes
|
|
File without changes
|