python-anvil 3.0.1__tar.gz → 5.0.0__tar.gz

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.
Files changed (33) hide show
  1. {python_anvil-3.0.1 → python_anvil-5.0.0}/PKG-INFO +7 -9
  2. {python_anvil-3.0.1 → python_anvil-5.0.0}/README.md +1 -1
  3. {python_anvil-3.0.1 → python_anvil-5.0.0}/pyproject.toml +17 -14
  4. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/__init__.py +3 -0
  5. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/api.py +18 -20
  6. python_anvil-5.0.0/python_anvil/api_resources/base.py +31 -0
  7. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/api_resources/mutations/create_etch_packet.py +0 -3
  8. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/api_resources/mutations/forge_submit.py +6 -8
  9. python_anvil-5.0.0/python_anvil/api_resources/mutations/helpers.py +7 -0
  10. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/api_resources/payload.py +33 -52
  11. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/cli.py +1 -1
  12. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/http.py +13 -0
  13. python_anvil-5.0.0/python_anvil/models.py +75 -0
  14. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/tests/test_api.py +2 -39
  15. python_anvil-5.0.0/python_anvil/tests/test_models.py +264 -0
  16. python_anvil-3.0.1/python_anvil/api_resources/base.py +0 -30
  17. python_anvil-3.0.1/python_anvil/api_resources/mutations/helpers.py +0 -7
  18. {python_anvil-3.0.1 → python_anvil-5.0.0}/LICENSE.md +0 -0
  19. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/api_resources/__init__.py +0 -0
  20. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/api_resources/mutations/__init__.py +0 -0
  21. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/api_resources/mutations/base.py +0 -0
  22. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/api_resources/mutations/generate_etch_signing_url.py +0 -0
  23. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/api_resources/requests.py +0 -0
  24. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/constants.py +0 -0
  25. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/exceptions.py +0 -0
  26. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/py.typed +0 -0
  27. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/tests/__init__.py +0 -0
  28. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/tests/conftest.py +0 -0
  29. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/tests/payloads.py +0 -0
  30. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/tests/test_cli.py +0 -0
  31. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/tests/test_http.py +0 -0
  32. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/tests/test_utils.py +0 -0
  33. {python_anvil-3.0.1 → python_anvil-5.0.0}/python_anvil/utils.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.1
2
- Name: python-anvil
3
- Version: 3.0.1
2
+ Name: python_anvil
3
+ Version: 5.0.0
4
4
  Summary: Anvil API
5
5
  Home-page: https://www.useanvil.com/
6
6
  License: MIT
7
7
  Keywords: anvil,api,pdf,signing
8
8
  Author: Anvil Foundry Inc.
9
9
  Author-email: developers@useanvil.com
10
- Requires-Python: >=3.7.2,<3.12
10
+ Requires-Python: >=3.8.0,<3.12
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: License :: OSI Approved :: MIT License
13
13
  Classifier: Natural Language :: English
@@ -18,15 +18,13 @@ 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.7
22
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
22
  Requires-Dist: click (>=8.0,<9.0)
24
- Requires-Dist: gql[requests] (==3.5.0b1)
25
- Requires-Dist: pydantic (>=1.8.2,<2.0.0)
23
+ Requires-Dist: gql[requests] (==3.6.0b1)
24
+ Requires-Dist: pydantic (>=2.6.1,<3.0.0)
26
25
  Requires-Dist: ratelimit (>=2.2.1,<3.0.0)
27
- Requires-Dist: requests (>=2.25.0,<3.0.0)
26
+ Requires-Dist: requests (>=2.28.2,<3.0.0)
28
27
  Requires-Dist: tabulate (>=0.9.0,<0.10.0)
29
- Requires-Dist: urllib3 (<2)
30
28
  Project-URL: Documentation, https://github.com/anvilco/python-anvil
31
29
  Project-URL: Repository, https://github.com/anvilco/python-anvil
32
30
  Description-Content-Type: text/markdown
@@ -59,7 +57,7 @@ General API documentation: [Anvil API docs](https://www.useanvil.com/docs)
59
57
 
60
58
  ## Requirements
61
59
 
62
- * Python 3.7.2+
60
+ * Python >=3.8
63
61
 
64
62
  ## Installation
65
63
 
@@ -26,7 +26,7 @@ General API documentation: [Anvil API docs](https://www.useanvil.com/docs)
26
26
 
27
27
  ## Requirements
28
28
 
29
- * Python 3.7.2+
29
+ * Python >=3.8
30
30
 
31
31
  ## Installation
32
32
 
@@ -1,7 +1,7 @@
1
1
  [tool.poetry]
2
2
 
3
3
  name = "python_anvil"
4
- version = "3.0.1"
4
+ version = "5.0.0"
5
5
  description = "Anvil API"
6
6
  license = "MIT"
7
7
  authors = ["Anvil Foundry Inc. <developers@useanvil.com>"]
@@ -23,28 +23,23 @@ classifiers = [
23
23
  "Operating System :: OS Independent",
24
24
  "Programming Language :: Python",
25
25
  "Programming Language :: Python :: 3",
26
- "Programming Language :: Python :: 3.7",
27
26
  "Programming Language :: Python :: 3.8",
28
27
  "Programming Language :: Python :: 3.9",
29
28
  "Programming Language :: Python :: 3.10",
29
+ "Programming Language :: Python :: 3.11",
30
30
  "Topic :: Software Development :: Libraries :: Python Modules",
31
31
  ]
32
32
 
33
33
  [tool.poetry.dependencies]
34
34
 
35
- python = ">=3.7.2,<3.12"
36
-
37
- # urllib must be pinned to <2 due to an issue with the `requests_toolbelt`
38
- # dependency in `gql`.
39
- # Similar: https://github.com/apache/arrow/commit/4295e3dd06f9f507f75ccc780eaca16a6b621a02
40
- urllib3 = "<2"
35
+ python = ">=3.8.0,<3.12"
41
36
 
42
37
  click = "^8.0"
43
- requests = "^2.25.0"
38
+ requests = "^2.28.2"
44
39
  ratelimit = "^2.2.1"
45
40
  tabulate = "^0.9.0"
46
- pydantic = "^1.8.2"
47
- gql = {version = "3.5.0b1", extras = ["requests"]}
41
+ pydantic = "^2.6.1"
42
+ gql = { version = "3.6.0b1", extras = ["requests"] }
48
43
 
49
44
  [tool.poetry.group.dev.dependencies]
50
45
 
@@ -53,10 +48,18 @@ black = "=22.12.0"
53
48
  isort = "^5.11.4"
54
49
 
55
50
  # Linters
56
- mypy = "*"
57
51
  pydocstyle = "^6.3.0"
58
52
  pylint = "^2.16.2"
59
53
 
54
+ # FIXME: Upgrading mypy will require updates to aliased fields. e.g.
55
+ #
56
+ # class EtchSigner(BaseModel):
57
+ # redirect_url: Optional[str] = Field(None, alias="redirectURL")
58
+ #
59
+ # Not sure what the solution is.
60
+ mypy = "1.0.1"
61
+
62
+
60
63
  # Testing
61
64
  pytest = "^7.2.1"
62
65
  pytest-cov = "^4.0.0"
@@ -74,7 +77,7 @@ pygments = "^2.14.0"
74
77
  # Tooling
75
78
  pyinstaller = "^5.8.0"
76
79
  sniffer = "*"
77
- MacFSEvents = { version = "*", platform = "darwin" }
80
+ macfsevents = { version = "^0.8.4", platform = "darwin" }
78
81
  pync = { version = "*", platform = "darwin" }
79
82
  pyinotify = {version = "^0.9.6", optional = true}
80
83
  tox = "^3.21.2"
@@ -90,7 +93,7 @@ anvil = "python_anvil.cli:cli"
90
93
 
91
94
  [tool.black]
92
95
 
93
- target-version = ["py37", "py38", "py39", "py310"]
96
+ target-version = ["py38", "py39", "py310", "py311"]
94
97
  skip-string-normalization = true
95
98
 
96
99
  [build-system]
@@ -1,9 +1,12 @@
1
1
  from pkg_resources import DistributionNotFound, get_distribution
2
2
 
3
3
  from python_anvil import api, cli
4
+ from python_anvil.models import FileCompatibleBaseModel
4
5
 
5
6
 
6
7
  try:
7
8
  __version__ = get_distribution('python_anvil').version
8
9
  except DistributionNotFound:
9
10
  __version__ = '(local)'
11
+
12
+ __all__ = ['api', 'cli', 'FileCompatibleBaseModel']
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  from gql import gql
3
3
  from graphql import DocumentNode
4
- from typing import Any, AnyStr, Callable, Dict, List, Optional, Text, Tuple, Union
4
+ from typing import Any, AnyStr, Callable, Dict, List, Optional, Tuple, Union
5
5
 
6
6
  from .api_resources.mutations import (
7
7
  BaseQuery,
@@ -137,9 +137,7 @@ class Anvil:
137
137
  if isinstance(payload, dict):
138
138
  data = FillPDFPayload(**payload)
139
139
  elif isinstance(payload, str):
140
- data = FillPDFPayload.parse_raw(
141
- payload, content_type="application/json"
142
- )
140
+ data = FillPDFPayload.model_validate_json(payload)
143
141
  elif isinstance(payload, FillPDFPayload):
144
142
  data = payload
145
143
  else:
@@ -158,7 +156,7 @@ class Anvil:
158
156
  api = RestRequest(client=self.client)
159
157
  return api.post(
160
158
  f"fill/{template_id}.pdf",
161
- data.dict(by_alias=True, exclude_none=True) if data else {},
159
+ data.model_dump(by_alias=True, exclude_none=True) if data else {},
162
160
  **kwargs,
163
161
  )
164
162
 
@@ -169,9 +167,7 @@ class Anvil:
169
167
  if isinstance(payload, dict):
170
168
  data = GeneratePDFPayload(**payload)
171
169
  elif isinstance(payload, str):
172
- data = GeneratePDFPayload.parse_raw(
173
- payload, content_type="application/json"
174
- )
170
+ data = GeneratePDFPayload.model_validate_json(payload)
175
171
  elif isinstance(payload, GeneratePDFPayload):
176
172
  data = payload
177
173
  else:
@@ -180,7 +176,9 @@ class Anvil:
180
176
  # Any data errors would come from here
181
177
  api = RestRequest(client=self.client)
182
178
  return api.post(
183
- "generate-pdf", data=data.dict(by_alias=True, exclude_none=True), **kwargs
179
+ "generate-pdf",
180
+ data=data.model_dump(by_alias=True, exclude_none=True),
181
+ **kwargs,
184
182
  )
185
183
 
186
184
  def get_cast(
@@ -326,7 +324,7 @@ class Anvil:
326
324
 
327
325
  return _get_return(res, get_data=get_data)
328
326
 
329
- def get_weld(self, eid: Text, **kwargs):
327
+ def get_weld(self, eid: str, **kwargs):
330
328
  res = self.query(
331
329
  gql(
332
330
  """
@@ -380,9 +378,7 @@ class Anvil:
380
378
  raise TypeError('One of the arguments `payload` or `json` must exist')
381
379
 
382
380
  if json:
383
- payload = CreateEtchPacketPayload.parse_raw(
384
- json, content_type="application/json"
385
- )
381
+ payload = CreateEtchPacketPayload.model_validate_json(json)
386
382
 
387
383
  if isinstance(payload, dict):
388
384
  mutation = CreateEtchPacket.create_from_dict(payload)
@@ -396,7 +392,7 @@ class Anvil:
396
392
  )
397
393
 
398
394
  payload = mutation.create_payload()
399
- variables = payload.dict(by_alias=True, exclude_none=True)
395
+ variables = payload.model_dump(by_alias=True, exclude_none=True)
400
396
 
401
397
  return self.mutate(mutation, variables=variables, upload_files=True, **kwargs)
402
398
 
@@ -407,7 +403,9 @@ class Anvil:
407
403
  client_user_id=client_user_id,
408
404
  )
409
405
  payload = mutation.create_payload()
410
- return self.mutate(mutation, variables=payload.dict(by_alias=True), **kwargs)
406
+ return self.mutate(
407
+ mutation, variables=payload.model_dump(by_alias=True), **kwargs
408
+ )
411
409
 
412
410
  def download_documents(self, document_group_eid: str, **kwargs):
413
411
  """Retrieve all completed documents in zip form."""
@@ -416,7 +414,7 @@ class Anvil:
416
414
 
417
415
  def forge_submit(
418
416
  self,
419
- payload: Optional[Union[Dict[Text, Any], ForgeSubmitPayload]] = None,
417
+ payload: Optional[Union[Dict[str, Any], ForgeSubmitPayload]] = None,
420
418
  json=None,
421
419
  **kwargs,
422
420
  ) -> Dict[str, Any]:
@@ -425,9 +423,7 @@ class Anvil:
425
423
  raise TypeError('One of arguments `json` or `payload` are required')
426
424
 
427
425
  if json:
428
- payload = ForgeSubmitPayload.parse_raw(
429
- json, content_type="application/json"
430
- )
426
+ payload = ForgeSubmitPayload.model_validate_json(json)
431
427
 
432
428
  if isinstance(payload, dict):
433
429
  mutation = ForgeSubmit.create_from_dict(payload)
@@ -440,6 +436,8 @@ class Anvil:
440
436
 
441
437
  return self.mutate(
442
438
  mutation,
443
- variables=mutation.create_payload().dict(by_alias=True, exclude_none=True),
439
+ variables=mutation.create_payload().model_dump(
440
+ by_alias=True, exclude_none=True
441
+ ),
444
442
  **kwargs,
445
443
  )
@@ -0,0 +1,31 @@
1
+ # pylint: disable=no-self-argument
2
+ import re
3
+
4
+ # Disabling pylint no-name-in-module because this is the documented way to
5
+ # import `BaseModel` and it's not broken, so let's keep it.
6
+ from pydantic import ( # pylint: disable=no-name-in-module
7
+ BaseModel as _BaseModel,
8
+ ConfigDict,
9
+ )
10
+
11
+
12
+ under_pat = re.compile(r"_([a-z])")
13
+
14
+
15
+ def underscore_to_camel(name):
16
+ ret = under_pat.sub(lambda x: x.group(1).upper(), name)
17
+ return ret
18
+
19
+
20
+ class BaseModel(_BaseModel):
21
+ """Config override for all models.
22
+
23
+ This override is mainly so everything can go from snake to camel-case.
24
+ """
25
+
26
+ # Allow extra fields even if it is not defined. This will allow models
27
+ # to be more flexible if features are added in the Anvil API, but
28
+ # explicit support hasn't been added yet to this library.
29
+ model_config = ConfigDict(
30
+ alias_generator=underscore_to_camel, populate_by_name=True, extra="allow"
31
+ )
@@ -19,17 +19,14 @@ from python_anvil.utils import create_unique_id
19
19
  logger: Logger = logging.getLogger(__name__)
20
20
 
21
21
  DEFAULT_RESPONSE_QUERY = """{
22
- id
23
22
  eid
24
23
  name
25
24
  detailsURL
26
25
  documentGroup {
27
- id
28
26
  eid
29
27
  status
30
28
  files
31
29
  signers {
32
- id
33
30
  eid
34
31
  aliasId
35
32
  routingOrder
@@ -1,4 +1,4 @@
1
- from typing import Any, Dict, Optional, Text, Union
1
+ from typing import Any, Dict, Optional, Union
2
2
 
3
3
  from python_anvil.api_resources.mutations.base import BaseQuery
4
4
  from python_anvil.api_resources.mutations.helpers import get_payload_attrs
@@ -7,7 +7,6 @@ from python_anvil.api_resources.payload import ForgeSubmitPayload
7
7
 
8
8
  DEFAULT_RESPONSE_QUERY = """
9
9
  {
10
- id
11
10
  eid
12
11
  status
13
12
  resolvedPayload
@@ -22,7 +21,6 @@ DEFAULT_RESPONSE_QUERY = """
22
21
  routingOrder
23
22
  }
24
23
  weldData {
25
- id
26
24
  eid
27
25
  status
28
26
  isTest
@@ -72,10 +70,10 @@ class ForgeSubmit(BaseQuery):
72
70
 
73
71
  def __init__(
74
72
  self,
75
- payload: Union[Dict[Text, Any], ForgeSubmitPayload],
76
- forge_eid: Optional[Text] = None,
77
- weld_data_eid: Optional[Text] = None,
78
- submission_eid: Optional[Text] = None,
73
+ payload: Union[Dict[str, Any], ForgeSubmitPayload],
74
+ forge_eid: Optional[str] = None,
75
+ weld_data_eid: Optional[str] = None,
76
+ submission_eid: Optional[str] = None,
79
77
  is_test: Optional[bool] = None,
80
78
  **kwargs,
81
79
  ):
@@ -109,7 +107,7 @@ class ForgeSubmit(BaseQuery):
109
107
  setattr(self, attr, kwargs[attr])
110
108
 
111
109
  @classmethod
112
- def create_from_dict(cls, payload: Dict[Text, Any]):
110
+ def create_from_dict(cls, payload: Dict[str, Any]):
113
111
  # Parse the data through the model class to validate and pass it back
114
112
  # as variables in this class.
115
113
  return cls(**payload)
@@ -0,0 +1,7 @@
1
+ from typing import List, Type
2
+
3
+ from python_anvil.api_resources.base import BaseModel
4
+
5
+
6
+ def get_payload_attrs(payload_model: Type[BaseModel]) -> List[str]:
7
+ return list(payload_model.model_fields.keys())
@@ -6,12 +6,14 @@ from io import BufferedIOBase
6
6
  # Disabling pylint no-name-in-module because this is the documented way to
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
+ ConfigDict,
9
10
  Field,
10
11
  HttpUrl,
11
- root_validator,
12
- validator,
12
+ field_validator,
13
13
  )
14
- from typing import Any, Dict, List, Optional, Text, Union
14
+ from typing import Any, Dict, List, Optional, Union
15
+
16
+ from python_anvil.models import FileCompatibleBaseModel
15
17
 
16
18
  from .base import BaseModel
17
19
 
@@ -24,8 +26,8 @@ else:
24
26
 
25
27
  class EmbeddedLogo(BaseModel):
26
28
  src: str
27
- max_width: Optional[int]
28
- max_height: Optional[int]
29
+ max_width: Optional[int] = None
30
+ max_height: Optional[int] = None
29
31
 
30
32
 
31
33
  class FillPDFPayload(BaseModel):
@@ -34,7 +36,8 @@ class FillPDFPayload(BaseModel):
34
36
  font_size: Optional[int] = None
35
37
  text_color: Optional[str] = None
36
38
 
37
- @validator("data")
39
+ @field_validator("data")
40
+ @classmethod
38
41
  def data_cannot_be_empty(cls, v):
39
42
  if isinstance(v, dict) and len(v) == 0:
40
43
  raise ValueError("cannot be empty")
@@ -43,25 +46,13 @@ class FillPDFPayload(BaseModel):
43
46
 
44
47
  class GeneratePDFPayload(BaseModel):
45
48
  data: Union[List[Dict[str, Any]], Dict[Literal["html", "css"], str]]
46
- logo: Optional[EmbeddedLogo]
47
- title: Optional[str]
49
+ logo: Optional[EmbeddedLogo] = None
50
+ title: Optional[str] = None
48
51
  type: Optional[Literal["markdown", "html"]] = "markdown"
49
-
50
- @validator("data")
51
- def data_must_match_type(cls, v, values):
52
- if "type" in values and values["type"] == "html":
53
- if "html" not in v:
54
- raise ValueError("must contain HTML if using `html` type")
55
- if isinstance(v, list):
56
- raise ValueError("must contain a data dict not a list")
57
- return v
58
-
59
- @validator("type")
60
- def type_must_match_data(cls, v, values):
61
- if v == "html":
62
- if "data" in values and not isinstance(values["data"], dict):
63
- raise ValueError("HTML types must used a dict data payload")
64
- return v
52
+ page: Optional[Dict[str, Any]] = None
53
+ font_size: Optional[int] = None
54
+ font_family: Optional[str] = None
55
+ text_color: Optional[str] = None
65
56
 
66
57
 
67
58
  class GenerateEtchSigningURLPayload(BaseModel):
@@ -178,7 +169,7 @@ class DocumentMarkdown(BaseModel):
178
169
  text_color: str = "#000000"
179
170
 
180
171
 
181
- class DocumentUpload(BaseModel):
172
+ class DocumentUpload(FileCompatibleBaseModel):
182
173
  """Dataclass representing an uploaded document."""
183
174
 
184
175
  id: str
@@ -189,16 +180,14 @@ class DocumentUpload(BaseModel):
189
180
  # the client library side.
190
181
  # This might be a bug on the `pydantic` side(?) when this object gets
191
182
  # converted into a dict.
192
- file: Any
183
+
184
+ # NOTE: This field name is referenced in the models.py file, if you change it you
185
+ # must change the reference
186
+ file: Any = None
193
187
  fields: List[SignatureField]
194
188
  font_size: int = 14
195
189
  text_color: str = "#000000"
196
-
197
- class Config:
198
- """Special rules for this model class."""
199
-
200
- # Needed to allow `BufferedIOBase` as an allowed `file` type.
201
- arbitrary_types_allowed = True
190
+ model_config = ConfigDict(arbitrary_types_allowed=True)
202
191
 
203
192
 
204
193
  class EtchCastRef(BaseModel):
@@ -212,7 +201,7 @@ class CreateEtchFilePayload(BaseModel):
212
201
  payloads: Union[str, Dict[str, FillPDFPayload]]
213
202
 
214
203
 
215
- class CreateEtchPacketPayload(BaseModel):
204
+ class CreateEtchPacketPayload(FileCompatibleBaseModel):
216
205
  """
217
206
  Payload for createEtchPacket.
218
207
 
@@ -222,6 +211,9 @@ class CreateEtchPacketPayload(BaseModel):
222
211
 
223
212
  name: str
224
213
  signers: List[EtchSigner]
214
+ # NOTE: This is a list of `AttachableEtchFile` objects, but we need to
215
+ # override the default `FileCompatibleBaseModel` to handle multipart/form-data
216
+ # uploads correctly. This field name is referenced in the models.py file.
225
217
  files: List["AttachableEtchFile"]
226
218
  signature_email_subject: Optional[str] = None
227
219
  signature_email_body: Optional[str] = None
@@ -246,10 +238,10 @@ class ForgeSubmitPayload(BaseModel):
246
238
  https://www.useanvil.com/docs/api/graphql/reference/#operation-forgesubmit-Mutations
247
239
  """
248
240
 
249
- forge_eid: Text
250
- payload: Dict[Text, Any]
251
- weld_data_eid: Optional[Text] = None
252
- submission_eid: Optional[Text] = None
241
+ forge_eid: str
242
+ payload: Dict[str, Any]
243
+ weld_data_eid: Optional[str] = None
244
+ submission_eid: Optional[str] = None
253
245
  # Defaults to True when not provided/is None
254
246
  enforce_payload_valid_on_create: Optional[bool] = None
255
247
  current_step: Optional[int] = None
@@ -257,22 +249,11 @@ class ForgeSubmitPayload(BaseModel):
257
249
  # Note that if using a development API key, this will be forced to `True`
258
250
  # even when `False` is used in the payload.
259
251
  is_test: Optional[bool] = True
260
- timezone: Optional[Text] = None
252
+ timezone: Optional[str] = None
261
253
  webhook_url: Optional[HttpUrl] = Field(None, alias="webhookURL")
262
- group_array_id: Optional[Text] = None
254
+ group_array_id: Optional[str] = None
263
255
  group_array_index: Optional[int] = None
264
256
 
265
- @root_validator
266
- def wd_submission_both_required(cls, values):
267
- both_required = ["weld_data_eid", "submission_eid"]
268
- picked = [k for k in both_required if k in values and values[k] is not None]
269
- if len(picked) == 1:
270
- raise ValueError(
271
- "Both `weld_data_eid` and `submission_eid` are "
272
- "required if either are provided."
273
- )
274
- return values
275
-
276
257
 
277
258
  UploadableFile = Union[Base64Upload, BufferedIOBase]
278
259
  AttachableEtchFile = Union[
@@ -282,5 +263,5 @@ AttachableEtchFile = Union[
282
263
  # Classes below use types wrapped in quotes avoid a circular dependency/weird
283
264
  # variable assignment locations with the aliases above. We need to manually
284
265
  # update the refs for them to point to the right things.
285
- DocumentUpload.update_forward_refs()
286
- CreateEtchPacketPayload.update_forward_refs()
266
+ DocumentUpload.model_rebuild()
267
+ CreateEtchPacketPayload.model_rebuild()
@@ -208,7 +208,7 @@ def fill_pdf(ctx, template_id, out_filename, payload_csv):
208
208
  with click.progressbar(payloads, label="Filling PDFs and saving") as ps:
209
209
  indexed_files = utils.build_batch_filenames(out_filename)
210
210
  for payload in ps:
211
- res = anvil.fill_pdf(template_id, payload.to_dict(), debug=debug)
211
+ res = anvil.fill_pdf(template_id, payload.model_dump(), debug=debug)
212
212
 
213
213
  if contains_headers(res):
214
214
  res, headers = process_response(res)
@@ -1,4 +1,6 @@
1
1
  import os
2
+
3
+ # import json
2
4
  import requests
3
5
  from base64 import b64encode
4
6
  from gql import Client
@@ -53,6 +55,15 @@ def get_gql_ds(client: Client) -> DSLSchema:
53
55
  return DSLSchema(client.schema)
54
56
 
55
57
 
58
+ # FIXME: when gql 3.6.0 is stable, we can use this to paper over pre-graphql
59
+ # handler errors. https://github.com/graphql-python/gql/releases/tag/v3.6.0b1
60
+ # def json_deserialize (response_text):
61
+ # try:
62
+ # return json.loads(response_text)
63
+ # except Exception as e:
64
+ # return {"errors": [{"message": response_text}]}
65
+
66
+
56
67
  class GQLClient:
57
68
  """GraphQL client factory class."""
58
69
 
@@ -71,6 +82,8 @@ class GQLClient:
71
82
  auth=auth,
72
83
  url=endpoint_url,
73
84
  verify=True,
85
+ # FIXME: when gql 3.6.0 is stable... see note above
86
+ # json_deserialize=json_deserialize,
74
87
  )
75
88
 
76
89
  schema = None
@@ -0,0 +1,75 @@
1
+ import base64
2
+ import os
3
+ from io import BufferedReader, BytesIO
4
+ from mimetypes import guess_type
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ from python_anvil.api_resources.base import underscore_to_camel
8
+
9
+
10
+ class FileCompatibleBaseModel(BaseModel):
11
+ """
12
+ Patched model_dump to extract file objects from SerializationIterator.
13
+
14
+ Becaus of Pydantic V2. Return as BufferedReader or base64 encoded dict as needed.
15
+ """
16
+
17
+ # Allow extra fields even if it is not defined. This will allow models
18
+ # to be more flexible if features are added in the Anvil API, but
19
+ # explicit support hasn't been added yet to this library.
20
+ model_config = ConfigDict(
21
+ alias_generator=underscore_to_camel, populate_by_name=True, extra="allow"
22
+ )
23
+
24
+ def _iterator_to_buffered_reader(self, value):
25
+ content = bytearray()
26
+ try:
27
+ while True:
28
+ content.extend(next(value))
29
+ except StopIteration:
30
+ # Create a BytesIO with the content
31
+ bio = BytesIO(bytes(content))
32
+ # Create a BufferedReader with the content
33
+ reader = BufferedReader(bio) # type: ignore[arg-type]
34
+ return reader
35
+
36
+ def _check_if_serialization_iterator(self, value):
37
+ return str(type(value).__name__) == 'SerializationIterator' and hasattr(
38
+ value, '__next__'
39
+ )
40
+
41
+ def _process_file_data(self, file_obj):
42
+ """Process file object into base64 encoded dict format."""
43
+ # Read the file data and encode it as base64
44
+ file_content = file_obj.read()
45
+
46
+ # Get filename - handle both regular files and BytesIO objects
47
+ filename = getattr(file_obj, 'name', "document.pdf")
48
+
49
+ if isinstance(filename, (bytes, bytearray)):
50
+ filename = filename.decode('utf-8')
51
+
52
+ # manage mimetype based on file extension
53
+ mimetype = guess_type(filename)[0] or 'application/pdf'
54
+
55
+ return {
56
+ 'data': base64.b64encode(file_content).decode('utf-8'),
57
+ 'mimetype': mimetype,
58
+ 'filename': os.path.basename(filename),
59
+ }
60
+
61
+ def model_dump(self, **kwargs):
62
+ data = super().model_dump(**kwargs)
63
+ for key, value in data.items():
64
+ if key == 'file' and self._check_if_serialization_iterator(value):
65
+ # Direct file case
66
+ file_obj = self._iterator_to_buffered_reader(value)
67
+ data[key] = self._process_file_data(file_obj)
68
+ elif key == 'files' and isinstance(value, list):
69
+ # List of objects case
70
+ for index, item in enumerate(value):
71
+ if isinstance(item, dict) and 'file' in item:
72
+ if self._check_if_serialization_iterator(item['file']):
73
+ file_obj = self._iterator_to_buffered_reader(item['file'])
74
+ data[key][index]['file'] = self._process_file_data(file_obj)
75
+ return data
@@ -1,7 +1,6 @@
1
1
  # pylint: disable=unused-variable,unused-argument,too-many-statements
2
2
  import json
3
3
  import pytest
4
- from pydantic import ValidationError
5
4
  from typing import Any, MutableMapping
6
5
  from unittest import mock
7
6
 
@@ -186,18 +185,6 @@ def describe_api():
186
185
  )
187
186
  assert m_request_post.call_count == 0
188
187
 
189
- @mock.patch('python_anvil.api.RestRequest.post')
190
- def test_invalid_data_for_html(m_request_post, anvil):
191
- with pytest.raises(ValueError):
192
- anvil.generate_pdf(
193
- {
194
- # This should be a plain dict, not a list
195
- "data": [{"d1": "data"}],
196
- "type": "html",
197
- }
198
- )
199
- assert m_request_post.call_count == 0
200
-
201
188
  @mock.patch('python_anvil.api.RestRequest.post')
202
189
  def test_invalid_data_for_markdown(m_request_post, anvil):
203
190
  with pytest.raises(ValueError):
@@ -355,13 +342,13 @@ def describe_api():
355
342
  for k, v in payloads.EXPECTED_ETCH_TEST_PAYLOAD_JSON.items()
356
343
  if v is not None
357
344
  }
358
- cep = CreateEtchPacketPayload.parse_obj(payload)
345
+ cep = CreateEtchPacketPayload.model_validate(payload)
359
346
  cep.newFeature = True # type: ignore[attr-defined]
360
347
  anvil.create_etch_packet(payload=cep)
361
348
  assert m_request_post.call_count == 1
362
349
 
363
350
  variables = m_request_post.call_args[1]["variable_values"]
364
- assert cep.dict(by_alias=True, exclude_none=True) == variables
351
+ assert cep.model_dump(by_alias=True, exclude_none=True) == variables
365
352
  assert "newFeature" in variables
366
353
 
367
354
  def describe_forge_submit():
@@ -386,30 +373,6 @@ def describe_api():
386
373
  assert m_request_post.call_count == 1
387
374
  assert {"variable_values": expected_data} in m_request_post.call_args
388
375
 
389
- @mock.patch('gql.Client.execute')
390
- def test_invalid_wd_submission(m_request_post, anvil):
391
- with pytest.raises(ValidationError):
392
- payload = dict(
393
- forge_eid="forge1234",
394
- # weld_data_eid and submission_eid must be provided
395
- # if one exists.
396
- weld_data_eid="wd1234",
397
- payload=dict(field1="Updated data"),
398
- )
399
- anvil.forge_submit(payload=payload)
400
- assert m_request_post.call_count == 0
401
-
402
- with pytest.raises(ValidationError):
403
- payload = dict(
404
- forge_eid="forge1234",
405
- # weld_data_eid and submission_eid must be provided
406
- # if one exists.
407
- submission_eid="sub1234",
408
- payload=dict(field1="Updated data"),
409
- )
410
- anvil.forge_submit(payload=payload)
411
- assert m_request_post.call_count == 0
412
-
413
376
  @mock.patch('gql.Client.execute')
414
377
  def test_minimum_valid_data_submission(m_request_post, anvil):
415
378
  payload = ForgeSubmitPayload(
@@ -0,0 +1,264 @@
1
+ import base64
2
+ import pytest
3
+ from pydantic import BaseModel
4
+ from typing import Any, List, Optional
5
+
6
+ from python_anvil.models import FileCompatibleBaseModel
7
+
8
+
9
+ def test_file_compat_base_model_handles_regular_data():
10
+ class TestModel(FileCompatibleBaseModel):
11
+ name: str
12
+ value: int
13
+
14
+ model = TestModel(name="test", value=42)
15
+ data = model.model_dump()
16
+ assert data == {"name": "test", "value": 42}
17
+
18
+
19
+ def test_file_compat_base_model_preserves_file_objects():
20
+ class FileModel(FileCompatibleBaseModel):
21
+ file: Any = None
22
+
23
+ # Create a test file object
24
+ with open(__file__, 'rb') as test_file:
25
+ model = FileModel(file=test_file)
26
+ data = model.model_dump()
27
+
28
+ # Verify we got a dictionary with the expected structure
29
+ assert isinstance(data['file'], dict)
30
+ assert 'data' in data['file']
31
+ assert 'mimetype' in data['file']
32
+ assert 'filename' in data['file']
33
+
34
+ # Verify the content matches
35
+ with open(__file__, 'rb') as original_file:
36
+ original_content = original_file.read()
37
+ decoded_content = base64.b64decode(data['file']['data'].encode('utf-8'))
38
+ assert (
39
+ decoded_content == original_content
40
+ ), "File content should match original"
41
+
42
+
43
+ def test_file_compat_base_model_validates_types():
44
+ class TestModel(FileCompatibleBaseModel):
45
+ name: str
46
+ age: int
47
+
48
+ # Should work with valid types
49
+ model = TestModel(name="Alice", age=30)
50
+ assert model.name == "Alice"
51
+ assert model.age == 30
52
+
53
+ # Should raise validation error for wrong types
54
+ with pytest.raises(ValueError):
55
+ TestModel(name="Alice", age="thirty")
56
+
57
+
58
+ def test_file_compat_base_model_handles_optional_fields():
59
+ class TestModel(FileCompatibleBaseModel):
60
+ required: str
61
+ optional: Optional[str] = None
62
+
63
+ # Should work with just required field
64
+ model = TestModel(required="test")
65
+ assert model.required == "test"
66
+ assert model.optional is None
67
+
68
+ # Should work with both fields
69
+ model = TestModel(required="test", optional="present")
70
+ assert model.optional == "present"
71
+
72
+
73
+ def test_file_compat_base_model_handles_nested_models():
74
+ class NestedModel(BaseModel):
75
+ value: str
76
+
77
+ class ParentModel(FileCompatibleBaseModel):
78
+ nested: NestedModel
79
+
80
+ nested = NestedModel(value="test")
81
+ model = ParentModel(nested=nested)
82
+
83
+ data = model.model_dump()
84
+ assert data == {"nested": {"value": "test"}}
85
+
86
+
87
+ def test_file_compat_base_model_handles_lists():
88
+ class TestModel(FileCompatibleBaseModel):
89
+ items: List[str]
90
+
91
+ model = TestModel(items=["a", "b", "c"])
92
+ data = model.model_dump()
93
+ assert data == {"items": ["a", "b", "c"]}
94
+
95
+
96
+ def test_document_upload_handles_file_objects():
97
+ # pylint: disable-next=import-outside-toplevel
98
+ from python_anvil.api_resources.payload import DocumentUpload, SignatureField
99
+
100
+ # Create a sample signature field
101
+ field = SignatureField(
102
+ id="sig1",
103
+ type="signature",
104
+ page_num=1,
105
+ rect={"x": 100.0, "y": 100.0, "width": 100.0},
106
+ )
107
+
108
+ # Test with a file object
109
+ with open(__file__, 'rb') as test_file:
110
+ doc = DocumentUpload(
111
+ id="doc1", title="Test Document", file=test_file, fields=[field]
112
+ )
113
+
114
+ data = doc.model_dump()
115
+
116
+ # Verify file is converted to expected dictionary format
117
+ assert isinstance(data['file'], dict)
118
+ assert 'data' in data['file']
119
+ assert 'mimetype' in data['file']
120
+ assert 'filename' in data['file']
121
+
122
+ # Verify content matches
123
+ with open(__file__, 'rb') as original_file:
124
+ original_content = original_file.read()
125
+ decoded_content = base64.b64decode(data['file']['data'].encode('utf-8'))
126
+ assert decoded_content == original_content
127
+
128
+ # Verify other fields are correct
129
+ assert data['id'] == "doc1"
130
+ assert data['title'] == "Test Document"
131
+ assert len(data['fields']) == 1
132
+ assert data['fields'][0]['id'] == "sig1"
133
+
134
+
135
+ def test_create_etch_packet_payload_handles_nested_file_objects():
136
+ # pylint: disable-next=import-outside-toplevel
137
+ from python_anvil.api_resources.payload import (
138
+ CreateEtchPacketPayload,
139
+ DocumentUpload,
140
+ EtchSigner,
141
+ SignatureField,
142
+ )
143
+
144
+ # Create a sample signature field
145
+ field = SignatureField(
146
+ id="sig1",
147
+ type="signature",
148
+ page_num=1,
149
+ rect={"x": 100.0, "y": 100.0, "width": 100.0},
150
+ )
151
+
152
+ # Create a signer
153
+ signer = EtchSigner(
154
+ name="Test Signer",
155
+ email="test@example.com",
156
+ fields=[{"file_id": "doc1", "field_id": "sig1"}],
157
+ )
158
+
159
+ # Test with a file object
160
+ with open(__file__, 'rb') as test_file:
161
+ # Create a DocumentUpload instance
162
+ doc = DocumentUpload(
163
+ id="doc1", title="Test Document", file=test_file, fields=[field]
164
+ )
165
+
166
+ # Create the packet payload
167
+ packet = CreateEtchPacketPayload(
168
+ name="Test Packet", signers=[signer], files=[doc], is_test=True
169
+ )
170
+
171
+ # Dump the model
172
+ data = packet.model_dump()
173
+
174
+ # Verify the structure
175
+ assert data['name'] == "Test Packet"
176
+ assert len(data['files']) == 1
177
+ assert len(data['signers']) == 1
178
+
179
+ # Verify file handling in the nested DocumentUpload
180
+ file_data = data['files'][0]
181
+ assert file_data['id'] == "doc1"
182
+ assert file_data['title'] == "Test Document"
183
+ assert isinstance(file_data['file'], dict)
184
+ assert 'data' in file_data['file']
185
+ assert 'mimetype' in file_data['file']
186
+ assert 'filename' in file_data['file']
187
+
188
+ # Verify content matches
189
+ with open(__file__, 'rb') as original_file:
190
+ original_content = original_file.read()
191
+ decoded_content = base64.b64decode(
192
+ file_data['file']['data'].encode('utf-8')
193
+ )
194
+ assert decoded_content == original_content
195
+
196
+
197
+ def test_create_etch_packet_payload_handles_multiple_files():
198
+ # pylint: disable-next=import-outside-toplevel
199
+ from python_anvil.api_resources.payload import (
200
+ CreateEtchPacketPayload,
201
+ DocumentUpload,
202
+ EtchSigner,
203
+ SignatureField,
204
+ )
205
+
206
+ # Create signature fields
207
+ field1 = SignatureField(
208
+ id="sig1",
209
+ type="signature",
210
+ page_num=1,
211
+ rect={"x": 100.0, "y": 100.0, "width": 100.0},
212
+ )
213
+
214
+ field2 = SignatureField(
215
+ id="sig2",
216
+ type="signature",
217
+ page_num=1,
218
+ rect={"x": 200.0, "y": 200.0, "width": 100.0},
219
+ )
220
+
221
+ signer = EtchSigner(
222
+ name="Test Signer",
223
+ email="test@example.com",
224
+ fields=[
225
+ {"file_id": "doc1", "field_id": "sig1"},
226
+ {"file_id": "doc2", "field_id": "sig2"},
227
+ ],
228
+ )
229
+
230
+ # Test with multiple file objects
231
+ with open(__file__, 'rb') as test_file1, open(__file__, 'rb') as test_file2:
232
+ doc1 = DocumentUpload(
233
+ id="doc1", title="Test Document 1", file=test_file1, fields=[field1]
234
+ )
235
+
236
+ doc2 = DocumentUpload(
237
+ id="doc2", title="Test Document 2", file=test_file2, fields=[field2]
238
+ )
239
+
240
+ packet = CreateEtchPacketPayload(
241
+ name="Test Packet", signers=[signer], files=[doc1, doc2], is_test=True
242
+ )
243
+
244
+ data = packet.model_dump()
245
+
246
+ # Verify structure
247
+ assert len(data['files']) == 2
248
+
249
+ # Verify both files are properly handled
250
+ for i, file_data in enumerate(data['files'], 1):
251
+ assert file_data['id'] == f"doc{i}"
252
+ assert file_data['title'] == f"Test Document {i}"
253
+ assert isinstance(file_data['file'], dict)
254
+ assert 'data' in file_data['file']
255
+ assert 'mimetype' in file_data['file']
256
+ assert 'filename' in file_data['file']
257
+
258
+ # Verify content matches
259
+ with open(__file__, 'rb') as original_file:
260
+ original_content = original_file.read()
261
+ decoded_content = base64.b64decode(
262
+ file_data['file']['data'].encode('utf-8')
263
+ )
264
+ assert decoded_content == original_content
@@ -1,30 +0,0 @@
1
- # pylint: disable=no-self-argument
2
- import re
3
-
4
- # Disabling pylint no-name-in-module because this is the documented way to
5
- # import `BaseModel` and it's not broken, so let's keep it.
6
- from pydantic import BaseModel as _BaseModel, Extra # pylint: disable=no-name-in-module
7
-
8
-
9
- under_pat = re.compile(r"_([a-z])")
10
-
11
-
12
- def underscore_to_camel(name):
13
- ret = under_pat.sub(lambda x: x.group(1).upper(), name)
14
- return ret
15
-
16
-
17
- class BaseModel(_BaseModel):
18
- class Config:
19
- """Config override for all models.
20
-
21
- This override is mainly so everything can go from snake to camel-case.
22
- """
23
-
24
- alias_generator = underscore_to_camel
25
- allow_population_by_field_name = True
26
-
27
- # Allow extra fields even if it is not defined. This will allow models
28
- # to be more flexible if features are added in the Anvil API, but
29
- # explicit support hasn't been added yet to this library.
30
- extra = Extra.allow
@@ -1,7 +0,0 @@
1
- from typing import List, Text, Type
2
-
3
- from python_anvil.api_resources.base import BaseModel
4
-
5
-
6
- def get_payload_attrs(payload_model: Type[BaseModel]) -> List[Text]:
7
- return list(payload_model.__fields__.keys())
File without changes