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 CHANGED
@@ -1,4 +1,6 @@
1
- from logging import getLogger
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 GraphqlRequest, PlainRequest, RestRequest
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__(self, api_key=None, environment='dev'):
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: Union[Optional[Text], Dict[Text, Any]] = None,
69
+ query: Union[str, DocumentNode],
70
+ variables: Optional[Dict[str, Any]] = None,
57
71
  **kwargs,
58
72
  ):
59
- gql = GraphqlRequest(client=self.client)
60
- return gql.post(query, variables=variables, **kwargs)
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
- def mutate(self, query: BaseQuery, variables: dict, **kwargs):
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 mutate_multipart(self, files: Dict, **kwargs):
89
+ def mutate(
90
+ self, query: Union[str, BaseQuery], variables: Dict[str, Any], **kwargs
91
+ ) -> Dict[str, Any]:
67
92
  """
68
- Multipart version of `mutate`.
93
+ Execute a GraphQL mutation.
69
94
 
70
- This will send a mutation based on the multipart spec defined here:
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
- Note that `variables` and `query` have been removed and replaced with
74
- `files`. The entire GraphQL payload should already be prepared as a
75
- `dict` beforehand.
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
- gql = GraphqlRequest(client=self.client)
82
- return gql.post_multipart(files=files, **kwargs)
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
- f"""{{
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["data"]["cast"]
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(self, fields=None, show_all=False, **kwargs) -> List[Dict[str, Any]]:
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
- f"""{{
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["data"]["currentUser"]["organizations"]
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["data"]["currentUser"])
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
- title
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["data"]["currentUser"]["organizations"]
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["data"]["weld"]
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
- files = get_multipart_payload(mutation)
398
+ payload = mutation.create_payload()
399
+ variables = payload.dict(by_alias=True, exclude_none=True)
341
400
 
342
- res = self.mutate_multipart(files, **kwargs)
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
- from pathlib import Path
3
- from typing import Any, Dict, List, Optional, Tuple, Union
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 get_files_for_payload(self) -> Tuple[List[AttachableEtchFile], List[Any]]:
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=payload_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, file_refs
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, Callable, Dict, List, Optional, Text, Tuple, Union
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
- file: "UploadableFile"
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
- # Types usable for uploads:
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("title"), w.get("forges")) for w in res]
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
- RATELIMIT_ENV = "dev"
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()
@@ -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
- def anvil():
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('python_anvil.api.GraphqlRequest.post')
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('python_anvil.api.GraphqlRequest.post')
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('python_anvil.api.GraphqlRequest.post')
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('python_anvil.api.GraphqlRequest.post')
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('python_anvil.api.GraphqlRequest.post')
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('python_anvil.api.GraphqlRequest.post_multipart')
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('python_anvil.api.GraphqlRequest.post_multipart')
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('python_anvil.api.GraphqlRequest.post_multipart')
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
- files = m_request_post.call_args[1]["files"]
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('python_anvil.api.GraphqlRequest.post_multipart')
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
- files = m_request_post.call_args[1]["files"]
268
- files_payload = json.loads(files["operations"][1])
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('python_anvil.api.GraphqlRequest.post_multipart')
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
- files = m_request_post.call_args[1]["files"]
279
- files_payload = json.loads(files["operations"][1])
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('python_anvil.api.GraphqlRequest.post_multipart')
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
- files = m_request_post.call_args[1]["files"]
294
- files_payload = json.loads(files["operations"][1])
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('python_anvil.api.GraphqlRequest.post_multipart')
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
- files = m_request_post.call_args[1]["files"]
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('python_anvil.api.GraphqlRequest.post_multipart')
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
- files = m_request_post.call_args[1]["files"]
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('python_anvil.api.GraphqlRequest.post')
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('python_anvil.api.GraphqlRequest.post')
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 == m_request_post.call_args[0][1]
387
+ assert {"variable_values": expected_data} in m_request_post.call_args
362
388
 
363
- @mock.patch('python_anvil.api.GraphqlRequest.post')
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('python_anvil.api.GraphqlRequest.post')
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
- **expected_data,
400
- "submissionEid": "sub1234",
401
- "weldDataEid": "wd1234",
402
- "payload": {"field1": "Updated data"},
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[0]
435
+ assert _expected_data in m_request_post.call_args
@@ -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 = {"data": {"currentUser": {"name": "Cameron"}}}
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": {"data": {"currentUser": {"name": "Cameron"}}},
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: 2.0.0
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.6.2+
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=axTBN2STiGMDzedWLZEsWW5Lh7PV_autUkB8VJYpxTI,12234
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=uGEAiyEJ_htV6GQZzgehJYtuGIY6UeFu7VjxHu5N9RE,10235
8
- python_anvil/api_resources/mutations/forge_submit.py,sha256=Ir2w4-FtbLPhWJOwSx8ORzS7qedVMs32ErsSfhVY6XI,3639
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=n9I9X_01-IOqsISCYFFh0WYRnVP0MgtHaZuPnWJEaUY,9279
12
- python_anvil/api_resources/requests.py,sha256=x7kNIYo8lCvS0oVDeAJIsUTKgi4B0JYtQwUTVmVk7yA,4947
13
- python_anvil/cli.py,sha256=cfVjLC1GeFzzVLa8yNwZWAYHeTMjVQnjlcGDGHSe0DQ,10289
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=ouaYAp0fXhl2McMZiZtUD3LOcH-GSBV1BqT8otw5sdw,4620
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=3IokYxw6kB_32OvrS3XCui-XKwYp-7AXNnem7W_tlgw,16904
22
- python_anvil/tests/test_cli.py,sha256=IBnlS8OEpDfznRUlLh4u92cyHbc7R3M3DrUA1wLfcIg,2644
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-2.0.0.dist-info/WHEEL,sha256=vVCvjcmxuUltf8cYhJ0sJMRDLr1XsPuxEId8YDzbyCY,88
28
- python_anvil-2.0.0.dist-info/LICENSE.md,sha256=0Z9mdiVAIMBp50iEbXt4bDHqlnAwAfKbj9Yi2b6O8fo,1091
29
- python_anvil-2.0.0.dist-info/METADATA,sha256=bys085a_OZZpDN64hEdtyDFtYZyE8nnnWMCiowpgdu0,3476
30
- python_anvil-2.0.0.dist-info/entry_points.txt,sha256=nf_xgj2f9K2Zascpk2MtOP-CT2fmKc1xUDWnTdq4P_Y,46
31
- python_anvil-2.0.0.dist-info/RECORD,,
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,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.4.0
2
+ Generator: poetry-core 1.6.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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