signingflow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- signingflow/__init__.py +35 -0
- signingflow/allowlist.py +29 -0
- signingflow/client.py +19 -0
- signingflow/clock.py +13 -0
- signingflow/config.py +105 -0
- signingflow/envelopes.py +279 -0
- signingflow/errors.py +79 -0
- signingflow/files.py +81 -0
- signingflow/jail.py +94 -0
- signingflow/models.py +188 -0
- signingflow/pagination.py +63 -0
- signingflow/path.py +139 -0
- signingflow/redact.py +46 -0
- signingflow/request.py +247 -0
- signingflow/resources/allowlist.json +110 -0
- signingflow/status.py +111 -0
- signingflow/templates.py +18 -0
- signingflow/version.py +4 -0
- signingflow/webhook_verify.py +49 -0
- signingflow/webhooks.py +147 -0
- signingflow-0.1.0.dist-info/METADATA +117 -0
- signingflow-0.1.0.dist-info/RECORD +25 -0
- signingflow-0.1.0.dist-info/WHEEL +5 -0
- signingflow-0.1.0.dist-info/licenses/LICENSE +21 -0
- signingflow-0.1.0.dist-info/top_level.txt +1 -0
signingflow/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Official Python SDK for the Signingflow calls used by skills and MCP tools."""
|
|
2
|
+
|
|
3
|
+
from signingflow.client import Client
|
|
4
|
+
from signingflow.errors import (
|
|
5
|
+
APIError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ConnectionError,
|
|
8
|
+
Error,
|
|
9
|
+
IdempotencyError,
|
|
10
|
+
InvalidRequestError,
|
|
11
|
+
PermissionError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
SignatureVerificationError,
|
|
14
|
+
)
|
|
15
|
+
from signingflow.version import VERSION
|
|
16
|
+
from signingflow.webhook_verify import Event, construct_event
|
|
17
|
+
from signingflow.webhooks import HTTPConfig, signing_secret_of
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"VERSION",
|
|
21
|
+
"APIError",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"Client",
|
|
24
|
+
"ConnectionError",
|
|
25
|
+
"Error",
|
|
26
|
+
"Event",
|
|
27
|
+
"HTTPConfig",
|
|
28
|
+
"IdempotencyError",
|
|
29
|
+
"InvalidRequestError",
|
|
30
|
+
"PermissionError",
|
|
31
|
+
"RateLimitError",
|
|
32
|
+
"SignatureVerificationError",
|
|
33
|
+
"construct_event",
|
|
34
|
+
"signing_secret_of",
|
|
35
|
+
]
|
signingflow/allowlist.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The 15 operations used by skills and MCP tools."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
|
|
7
|
+
from signingflow.errors import Error
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load():
|
|
11
|
+
try:
|
|
12
|
+
raw = files("signingflow").joinpath("resources/allowlist.json").read_text(encoding="utf-8")
|
|
13
|
+
parsed = json.loads(raw)
|
|
14
|
+
operations = parsed["operations"]
|
|
15
|
+
if len(operations) != 15:
|
|
16
|
+
raise ValueError("allowlist length")
|
|
17
|
+
return {item["operationId"]: item for item in operations}
|
|
18
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
|
19
|
+
raise RuntimeError("allowlist.json is invalid") from exc
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
OPERATIONS = _load()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def operation(operation_id):
|
|
26
|
+
try:
|
|
27
|
+
return OPERATIONS[operation_id]
|
|
28
|
+
except KeyError as exc:
|
|
29
|
+
raise Error(f"Unknown operation {operation_id}.") from exc
|
signingflow/client.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The instance client. There is no module-level API key."""
|
|
2
|
+
|
|
3
|
+
from signingflow.config import Config
|
|
4
|
+
from signingflow.envelopes import Envelopes
|
|
5
|
+
from signingflow.files import Files
|
|
6
|
+
from signingflow.request import Sender
|
|
7
|
+
from signingflow.templates import Templates
|
|
8
|
+
from signingflow.webhooks import WebhookDeliveries, WebhookURLs
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Client:
|
|
12
|
+
def __init__(self, api_key, **options):
|
|
13
|
+
self.config = Config(api_key, **options)
|
|
14
|
+
sender = Sender(self.config)
|
|
15
|
+
self.files = Files(sender)
|
|
16
|
+
self.templates = Templates(sender)
|
|
17
|
+
self.envelopes = Envelopes(sender, self.files, self.templates)
|
|
18
|
+
self.webhook_urls = WebhookURLs(sender)
|
|
19
|
+
self.webhook_deliveries = WebhookDeliveries(sender)
|
signingflow/clock.py
ADDED
signingflow/config.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Client settings. The API key stays on the instance."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlsplit
|
|
4
|
+
|
|
5
|
+
OFFICIAL_HOST = "api.signingflow.com"
|
|
6
|
+
DEFAULT_BASE_URL = "https://api.signingflow.com"
|
|
7
|
+
DEFAULT_TIMEOUT = 30
|
|
8
|
+
DEFAULT_RETRIES = 2
|
|
9
|
+
DEFAULT_UPLOAD_HOSTS = ("storage.googleapis.com",)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Config:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
api_key,
|
|
16
|
+
*,
|
|
17
|
+
workspace_id,
|
|
18
|
+
env,
|
|
19
|
+
base_url=None,
|
|
20
|
+
allow_custom_base_url=False,
|
|
21
|
+
timeout=None,
|
|
22
|
+
max_network_retries=None,
|
|
23
|
+
channel=None,
|
|
24
|
+
funnel=None,
|
|
25
|
+
upload_hosts=None,
|
|
26
|
+
upload_root=None,
|
|
27
|
+
ssl_context=None,
|
|
28
|
+
):
|
|
29
|
+
from signingflow.errors import Error, invalid
|
|
30
|
+
|
|
31
|
+
self.api_key = (api_key or "").strip()
|
|
32
|
+
if not self.api_key:
|
|
33
|
+
raise Error("API key is required.")
|
|
34
|
+
self.workspace_id = (workspace_id or "").strip()
|
|
35
|
+
if not self.workspace_id:
|
|
36
|
+
raise Error("workspace_id is required.")
|
|
37
|
+
self.wire_env = _normalize_env(env)
|
|
38
|
+
base = (base_url or "").strip() or DEFAULT_BASE_URL
|
|
39
|
+
base = base.rstrip("/")
|
|
40
|
+
parsed = urlsplit(base)
|
|
41
|
+
if not parsed.scheme or not parsed.netloc:
|
|
42
|
+
raise Error("base_url must be an absolute URL.")
|
|
43
|
+
if parsed.scheme != "https":
|
|
44
|
+
raise Error("base_url must be https.")
|
|
45
|
+
if parsed.username is not None or parsed.password is not None:
|
|
46
|
+
raise Error("base_url must not include a username or password.")
|
|
47
|
+
if "\x00" in base:
|
|
48
|
+
raise Error("base_url must be an absolute URL.")
|
|
49
|
+
raw_host = _raw_host(parsed.netloc)
|
|
50
|
+
if raw_host != OFFICIAL_HOST and not allow_custom_base_url:
|
|
51
|
+
raise Error("base_url host must be api.signingflow.com, or set allow_custom_base_url.")
|
|
52
|
+
self.base_url = base
|
|
53
|
+
self.allow_custom_base_url = bool(allow_custom_base_url)
|
|
54
|
+
self.timeout = DEFAULT_TIMEOUT if timeout is None else timeout
|
|
55
|
+
if not isinstance(self.timeout, (int, float)) or isinstance(self.timeout, bool) or self.timeout <= 0:
|
|
56
|
+
raise Error("timeout must be positive.")
|
|
57
|
+
self.retries = DEFAULT_RETRIES if max_network_retries is None else max_network_retries
|
|
58
|
+
if isinstance(self.retries, bool) or not isinstance(self.retries, int) or self.retries < 0:
|
|
59
|
+
raise Error("max_network_retries must be a whole number of zero or more.")
|
|
60
|
+
self.upload_hosts = _upload_hosts(upload_hosts)
|
|
61
|
+
self.channel = (channel or "").strip()
|
|
62
|
+
self.funnel = (funnel or "").strip()
|
|
63
|
+
self.upload_root = (upload_root or "").strip()
|
|
64
|
+
self.ssl_context = ssl_context
|
|
65
|
+
if any(bad_header(value) for value in (self.api_key, self.workspace_id, self.channel, self.funnel)):
|
|
66
|
+
raise invalid("invalid header value")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def bad_header(value):
|
|
70
|
+
return "\r" in value or "\n" in value
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _normalize_env(env):
|
|
74
|
+
from signingflow.errors import Error
|
|
75
|
+
|
|
76
|
+
if env in ("sandbox", "SANDBOX"):
|
|
77
|
+
return "SANDBOX"
|
|
78
|
+
if env in ("live", "LIVE"):
|
|
79
|
+
return "LIVE"
|
|
80
|
+
raise Error("env must be sandbox or live.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _raw_host(netloc):
|
|
84
|
+
host = netloc.rsplit("@", 1)[-1]
|
|
85
|
+
if host.startswith("["):
|
|
86
|
+
end = host.find("]")
|
|
87
|
+
return host[: end + 1] if end != -1 else host
|
|
88
|
+
return host.split(":", 1)[0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _upload_hosts(hosts):
|
|
92
|
+
from signingflow.errors import Error
|
|
93
|
+
|
|
94
|
+
if hosts is None:
|
|
95
|
+
hosts = DEFAULT_UPLOAD_HOSTS
|
|
96
|
+
cleaned = []
|
|
97
|
+
for host in hosts:
|
|
98
|
+
host = str(host).strip().lower()
|
|
99
|
+
if not host:
|
|
100
|
+
continue
|
|
101
|
+
dots = host.count(".")
|
|
102
|
+
if dots < 2 or host.startswith(".") or host.endswith(".") or ".." in host:
|
|
103
|
+
raise Error("Each upload_hosts entry must be a specific host, not a public suffix.")
|
|
104
|
+
cleaned.append(host)
|
|
105
|
+
return tuple(cleaned)
|
signingflow/envelopes.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Create, send, and read envelopes."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta, timezone
|
|
4
|
+
|
|
5
|
+
from signingflow import clock
|
|
6
|
+
from signingflow.errors import APIError, ConnectionError, IdempotencyError, attach, invalid
|
|
7
|
+
from signingflow.models import (
|
|
8
|
+
Download,
|
|
9
|
+
clean_recipients,
|
|
10
|
+
created_envelope,
|
|
11
|
+
decode_envelope,
|
|
12
|
+
envelope_state,
|
|
13
|
+
require_envelope_name,
|
|
14
|
+
)
|
|
15
|
+
from signingflow.pagination import List, list_items, next_token, page_size_of
|
|
16
|
+
from signingflow.path import _parse_retry
|
|
17
|
+
from signingflow.request import key_for
|
|
18
|
+
from signingflow.status import as_map, as_str
|
|
19
|
+
|
|
20
|
+
_SEND_WINDOW = timedelta(minutes=2)
|
|
21
|
+
_SEND_SKEW = timedelta(minutes=1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Envelopes:
|
|
25
|
+
def __init__(self, sender, files, templates):
|
|
26
|
+
self._sender = sender
|
|
27
|
+
self._files = files
|
|
28
|
+
self._templates = templates
|
|
29
|
+
|
|
30
|
+
def create(self, *, display_name, storage_id, description="", idempotency_key=""):
|
|
31
|
+
title = (display_name or "").strip()
|
|
32
|
+
if not title:
|
|
33
|
+
raise invalid("Name the draft title.")
|
|
34
|
+
storage_id = (storage_id or "").strip()
|
|
35
|
+
if not storage_id:
|
|
36
|
+
raise invalid("storage_id is required.")
|
|
37
|
+
created = self._sender.call(
|
|
38
|
+
"EnvelopeService_AddEnvelope",
|
|
39
|
+
idempotency_key=idempotency_key,
|
|
40
|
+
body={
|
|
41
|
+
"envelope": {
|
|
42
|
+
"display_name": title,
|
|
43
|
+
"description": (description or "").strip(),
|
|
44
|
+
"documents": [
|
|
45
|
+
{
|
|
46
|
+
"display_name": title,
|
|
47
|
+
"attachments": [{"storage_id": storage_id}],
|
|
48
|
+
"supplemental": False,
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
return created_envelope(created)
|
|
55
|
+
|
|
56
|
+
def retrieve(self, name, *, read_mask=""):
|
|
57
|
+
envelope = require_envelope_name(name)
|
|
58
|
+
viewed = self._sender.call(
|
|
59
|
+
"EnvelopeService_ViewEnvelope",
|
|
60
|
+
path_params={"name": envelope},
|
|
61
|
+
query={"readMask": read_mask},
|
|
62
|
+
)
|
|
63
|
+
decoded = decode_envelope(viewed)
|
|
64
|
+
if not decoded.name:
|
|
65
|
+
decoded.name = envelope
|
|
66
|
+
return decoded
|
|
67
|
+
|
|
68
|
+
def update(self, name, body):
|
|
69
|
+
envelope = require_envelope_name(name)
|
|
70
|
+
payload = dict(body or {})
|
|
71
|
+
if not as_str(payload.get("name")):
|
|
72
|
+
payload["name"] = envelope
|
|
73
|
+
updated = self._sender.call(
|
|
74
|
+
"EnvelopeService_UpdateEnvelope",
|
|
75
|
+
path_params={"name": envelope},
|
|
76
|
+
body=payload,
|
|
77
|
+
)
|
|
78
|
+
return decode_envelope(updated)
|
|
79
|
+
|
|
80
|
+
def send(self, name, *, idempotency_key=""):
|
|
81
|
+
envelope = require_envelope_name(name)
|
|
82
|
+
sent = self._sender.call(
|
|
83
|
+
"EnvelopeService_SendEnvelope",
|
|
84
|
+
path_params={"name": envelope},
|
|
85
|
+
idempotency_key=idempotency_key,
|
|
86
|
+
body={},
|
|
87
|
+
)
|
|
88
|
+
decoded = decode_envelope(sent)
|
|
89
|
+
if not decoded.name:
|
|
90
|
+
decoded.name = envelope
|
|
91
|
+
return decoded
|
|
92
|
+
|
|
93
|
+
def list(self, *, directory, page_size=None, page_token="", read_mask=""):
|
|
94
|
+
directory = (directory or "").strip()
|
|
95
|
+
if not directory:
|
|
96
|
+
raise invalid("directory is required.")
|
|
97
|
+
size = page_size_of(page_size)
|
|
98
|
+
|
|
99
|
+
def load(token):
|
|
100
|
+
return self._list_page(directory, size, token, read_mask)
|
|
101
|
+
|
|
102
|
+
page = load(page_token)
|
|
103
|
+
page._load = load
|
|
104
|
+
return page
|
|
105
|
+
|
|
106
|
+
def remind(self, name):
|
|
107
|
+
envelope = require_envelope_name(name)
|
|
108
|
+
reminded = self._sender.call(
|
|
109
|
+
"EnvelopeService_RemindEnvelopeSigners",
|
|
110
|
+
path_params={"name": envelope},
|
|
111
|
+
body={},
|
|
112
|
+
)
|
|
113
|
+
decoded = decode_envelope(reminded)
|
|
114
|
+
if not decoded.name:
|
|
115
|
+
decoded.name = envelope
|
|
116
|
+
return decoded
|
|
117
|
+
|
|
118
|
+
def download(self, name):
|
|
119
|
+
envelope = require_envelope_name(name)
|
|
120
|
+
downloaded = self._sender.call(
|
|
121
|
+
"EnvelopeService_DownloadEnvelope",
|
|
122
|
+
path_params={"name": envelope},
|
|
123
|
+
)
|
|
124
|
+
record = as_map(downloaded) or {}
|
|
125
|
+
return Download(
|
|
126
|
+
uri=as_str(record.get("uri")),
|
|
127
|
+
download_time=_first(record, "download_time", "downloadTime"),
|
|
128
|
+
etag=as_str(record.get("etag")),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def prepare_draft(self, *, title, file=None, path="", filename="", message=""):
|
|
132
|
+
title = (title or "").strip()
|
|
133
|
+
if not title:
|
|
134
|
+
raise invalid("Name the PDF and the draft title.")
|
|
135
|
+
stored = self._files.create(file=file, path=path, filename=filename)
|
|
136
|
+
return self.create(display_name=title, description=message, storage_id=stored.id)
|
|
137
|
+
|
|
138
|
+
def send_document(self, *, title, file=None, path="", filename="", recipients=None, message=""):
|
|
139
|
+
title = (title or "").strip()
|
|
140
|
+
if not title:
|
|
141
|
+
raise invalid("Name the PDF, the title, and the recipients.")
|
|
142
|
+
people = clean_recipients(recipients)
|
|
143
|
+
stored = self._files.create(file=file, path=path, filename=filename)
|
|
144
|
+
created = self.create(display_name=title, description=message, storage_id=stored.id)
|
|
145
|
+
return self._finish_send(created, people, message, title)
|
|
146
|
+
|
|
147
|
+
def send_from_template(self, *, template, recipients=None, message=""):
|
|
148
|
+
people = clean_recipients(recipients)
|
|
149
|
+
created = self._templates.use(template)
|
|
150
|
+
return self._finish_send(created, people, message, template)
|
|
151
|
+
|
|
152
|
+
def download_completed(self, name):
|
|
153
|
+
envelope = require_envelope_name(name)
|
|
154
|
+
viewed = self.retrieve(envelope)
|
|
155
|
+
if envelope_state(viewed).strip().upper() != "COMPLETED":
|
|
156
|
+
err = invalid("The signed file is not ready.")
|
|
157
|
+
err.envelope_name = envelope
|
|
158
|
+
raise err
|
|
159
|
+
return self.download(envelope)
|
|
160
|
+
|
|
161
|
+
def _list_page(self, directory, page_size, page_token, read_mask):
|
|
162
|
+
body = self._sender.call(
|
|
163
|
+
"EnvelopeService_ListEnvelopes",
|
|
164
|
+
path_params={"parent": "envelopes"},
|
|
165
|
+
query={
|
|
166
|
+
"directory": directory,
|
|
167
|
+
"pageSize": str(page_size),
|
|
168
|
+
"pageToken": page_token or "",
|
|
169
|
+
"readMask": read_mask or "",
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
record = as_map(body) or {}
|
|
173
|
+
data = [decode_envelope(item) for item in list_items(record, "envelopes")]
|
|
174
|
+
return List(data, next_token(record))
|
|
175
|
+
|
|
176
|
+
def _finish_send(self, created, people, message, label):
|
|
177
|
+
name = created.name
|
|
178
|
+
try:
|
|
179
|
+
require_envelope_name(name)
|
|
180
|
+
except Exception:
|
|
181
|
+
raise APIError(f"{label} was saved without an envelope name.") from None
|
|
182
|
+
try:
|
|
183
|
+
viewed = self.retrieve(name, read_mask="documents,compliance")
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
raise attach(exc, name) from exc
|
|
186
|
+
patched = _with_recipients(viewed, people, message)
|
|
187
|
+
if patched is None:
|
|
188
|
+
err = invalid("The draft has no document to address.")
|
|
189
|
+
err.envelope_name = name
|
|
190
|
+
raise err
|
|
191
|
+
try:
|
|
192
|
+
self.update(name, patched)
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
raise attach(exc, name) from exc
|
|
195
|
+
return self._send_or_view(name)
|
|
196
|
+
|
|
197
|
+
def _send_or_view(self, name):
|
|
198
|
+
started = clock.now()
|
|
199
|
+
try:
|
|
200
|
+
return self.send(name)
|
|
201
|
+
except IdempotencyError as exc:
|
|
202
|
+
if exc.resource_name == name:
|
|
203
|
+
try:
|
|
204
|
+
return self.retrieve(name)
|
|
205
|
+
except Exception:
|
|
206
|
+
raise attach(exc, name) from exc
|
|
207
|
+
raise attach(exc, name) from exc
|
|
208
|
+
except ConnectionError as exc:
|
|
209
|
+
try:
|
|
210
|
+
viewed = self.retrieve(name)
|
|
211
|
+
except Exception:
|
|
212
|
+
raise attach(exc, name) from exc
|
|
213
|
+
sent = _parse_retry(viewed.sent_time)
|
|
214
|
+
if sent is not None and _sent_during(sent, started, clock.now()):
|
|
215
|
+
return viewed
|
|
216
|
+
raise attach(exc, name) from exc
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
raise attach(exc, name) from exc
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _with_recipients(envelope, people, message):
|
|
222
|
+
refs = []
|
|
223
|
+
for item in envelope.documents:
|
|
224
|
+
uid = _first(item, "uid", "id")
|
|
225
|
+
if not uid:
|
|
226
|
+
continue
|
|
227
|
+
refs.append({"uid": uid, "display_name": _first(item, "display_name", "displayName")})
|
|
228
|
+
if not refs:
|
|
229
|
+
return None
|
|
230
|
+
participants = []
|
|
231
|
+
for index, person in enumerate(people):
|
|
232
|
+
participants.append(
|
|
233
|
+
{
|
|
234
|
+
"uid": key_for("", None),
|
|
235
|
+
"display_name": person["name"],
|
|
236
|
+
"alternate_email": person["email"],
|
|
237
|
+
"role": "SIGNER",
|
|
238
|
+
"type": "PERSONAL",
|
|
239
|
+
"action_order_type": "SERIAL",
|
|
240
|
+
"action_order_number": index + 1,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
profile = "STANDARD"
|
|
244
|
+
if envelope.compliance and as_str(envelope.compliance.get("profile")):
|
|
245
|
+
profile = as_str(envelope.compliance.get("profile"))
|
|
246
|
+
description = (message or "").strip() or envelope.description
|
|
247
|
+
documents = []
|
|
248
|
+
for index, document in enumerate(refs):
|
|
249
|
+
item = {"uid": document["uid"], "display_name": document["display_name"]}
|
|
250
|
+
if index == 0:
|
|
251
|
+
item["participants"] = participants
|
|
252
|
+
documents.append(item)
|
|
253
|
+
return {
|
|
254
|
+
"name": envelope.name,
|
|
255
|
+
"display_name": envelope.display_name,
|
|
256
|
+
"description": description,
|
|
257
|
+
"compliance": {"profile": profile},
|
|
258
|
+
"documents": documents,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _sent_during(sent, started, current):
|
|
263
|
+
if sent.tzinfo is None:
|
|
264
|
+
sent = sent.replace(tzinfo=timezone.utc)
|
|
265
|
+
if started.tzinfo is None:
|
|
266
|
+
started = started.replace(tzinfo=timezone.utc)
|
|
267
|
+
if current.tzinfo is None:
|
|
268
|
+
current = current.replace(tzinfo=timezone.utc)
|
|
269
|
+
return started <= sent <= started + _SEND_WINDOW and sent <= current + _SEND_SKEW
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _first(record, *keys):
|
|
273
|
+
if not record:
|
|
274
|
+
return ""
|
|
275
|
+
for key in keys:
|
|
276
|
+
value = record.get(key)
|
|
277
|
+
if isinstance(value, str) and value:
|
|
278
|
+
return value
|
|
279
|
+
return ""
|
signingflow/errors.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""SDK errors. Every specific error is also an Error."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FieldViolation:
|
|
5
|
+
def __init__(self, field, description):
|
|
6
|
+
self.field = field
|
|
7
|
+
self.description = description
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Error(Exception):
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message="",
|
|
14
|
+
*,
|
|
15
|
+
status_code=0,
|
|
16
|
+
grpc_code=None,
|
|
17
|
+
trace_code="",
|
|
18
|
+
request_id="",
|
|
19
|
+
idempotency_key="",
|
|
20
|
+
capability="",
|
|
21
|
+
resource_name="",
|
|
22
|
+
envelope_name="",
|
|
23
|
+
retry_after="",
|
|
24
|
+
field_violations=None,
|
|
25
|
+
):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.grpc_code = grpc_code
|
|
30
|
+
self.trace_code = trace_code or ""
|
|
31
|
+
self.request_id = request_id or ""
|
|
32
|
+
self.idempotency_key = idempotency_key or ""
|
|
33
|
+
self.capability = capability or ""
|
|
34
|
+
self.resource_name = resource_name or ""
|
|
35
|
+
self.envelope_name = envelope_name or ""
|
|
36
|
+
self.retry_after = retry_after or ""
|
|
37
|
+
self.field_violations = list(field_violations or [])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthenticationError(Error):
|
|
41
|
+
"""HTTP 401."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PermissionError(Error):
|
|
45
|
+
"""HTTP 403."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class IdempotencyError(Error):
|
|
49
|
+
"""HTTP 409 when the body says the key was already used."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RateLimitError(Error):
|
|
53
|
+
"""HTTP 429."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InvalidRequestError(Error):
|
|
57
|
+
"""Any other HTTP 4xx, or a rejected argument."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class APIError(Error):
|
|
61
|
+
"""HTTP 5xx, or a body the client will not parse."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ConnectionError(Error):
|
|
65
|
+
"""The API was not reached, including a timeout."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SignatureVerificationError(Error):
|
|
69
|
+
"""A webhook MAC did not match."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def invalid(message):
|
|
73
|
+
return InvalidRequestError(message)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def attach(err, envelope_name):
|
|
77
|
+
if isinstance(err, Error):
|
|
78
|
+
err.envelope_name = envelope_name
|
|
79
|
+
return err
|
signingflow/files.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Upload one file through a pre-signed URL."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from signingflow.config import bad_header
|
|
6
|
+
from signingflow.errors import APIError, invalid
|
|
7
|
+
from signingflow.jail import read_path
|
|
8
|
+
from signingflow.models import StoredFile, find_storage
|
|
9
|
+
from signingflow.path import allowed_upload_url
|
|
10
|
+
|
|
11
|
+
_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
|
12
|
+
_MIME_BY_EXTENSION = {
|
|
13
|
+
".pdf": "application/pdf",
|
|
14
|
+
".png": "image/png",
|
|
15
|
+
".jpg": "image/jpeg",
|
|
16
|
+
".jpeg": "image/jpeg",
|
|
17
|
+
".txt": "text/plain",
|
|
18
|
+
".json": "application/json",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Files:
|
|
23
|
+
def __init__(self, sender):
|
|
24
|
+
self._sender = sender
|
|
25
|
+
|
|
26
|
+
def create(self, *, file=None, path="", filename="", mime_type="", idempotency_key=""):
|
|
27
|
+
loaded = _load_file(file=file, path=path, filename=filename, mime_type=mime_type, upload_root=self._sender.config.upload_root)
|
|
28
|
+
added = self._sender.call(
|
|
29
|
+
"StorageService_BatchAddStorage",
|
|
30
|
+
idempotency_key=idempotency_key,
|
|
31
|
+
body={
|
|
32
|
+
"storages": [
|
|
33
|
+
{
|
|
34
|
+
"originalName": loaded["filename"],
|
|
35
|
+
"displayName": loaded["filename"],
|
|
36
|
+
"size": str(len(loaded["data"])),
|
|
37
|
+
"extension": loaded["extension"],
|
|
38
|
+
"mimeType": loaded["mime_type"],
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
found = find_storage(added)
|
|
44
|
+
if found is None:
|
|
45
|
+
raise APIError("Storage did not return an upload URL.")
|
|
46
|
+
name, file_id, raw_url = found
|
|
47
|
+
upload = allowed_upload_url(raw_url, self._sender.config.upload_hosts)
|
|
48
|
+
self._sender.put_bytes(upload, loaded["mime_type"], loaded["data"])
|
|
49
|
+
self._sender.call("StorageService_BatchAcknowledgeUpload", body={"names": [name]})
|
|
50
|
+
return StoredFile(name, file_id)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_file(*, file, path, filename, mime_type, upload_root):
|
|
54
|
+
has_file = file is not None
|
|
55
|
+
has_path = bool((path or "").strip())
|
|
56
|
+
if has_file and has_path:
|
|
57
|
+
raise invalid("Pass file bytes or a path, not both.")
|
|
58
|
+
if not has_file and not has_path:
|
|
59
|
+
raise invalid("Pass file bytes or a path.")
|
|
60
|
+
if has_file:
|
|
61
|
+
if not isinstance(file, (bytes, bytearray)):
|
|
62
|
+
raise invalid("Pass file bytes or a path.")
|
|
63
|
+
data = bytes(file)
|
|
64
|
+
else:
|
|
65
|
+
data = read_path(upload_root, path)
|
|
66
|
+
if len(data) > _MAX_UPLOAD_BYTES:
|
|
67
|
+
raise invalid("The file is larger than 25 MB.")
|
|
68
|
+
filename = (filename or "").strip()
|
|
69
|
+
if not filename and has_path:
|
|
70
|
+
filename = os.path.basename(path)
|
|
71
|
+
if filename in ("", ".", "/"):
|
|
72
|
+
raise invalid("filename is required when file is not a path.")
|
|
73
|
+
extension = os.path.splitext(filename)[1].lower().lstrip(".")
|
|
74
|
+
mime_type = (mime_type or "").strip()
|
|
75
|
+
if not mime_type:
|
|
76
|
+
mime_type = _MIME_BY_EXTENSION.get("." + extension, "")
|
|
77
|
+
if not mime_type:
|
|
78
|
+
mime_type = "application/octet-stream"
|
|
79
|
+
if bad_header(mime_type):
|
|
80
|
+
raise invalid("invalid header value")
|
|
81
|
+
return {"data": data, "filename": filename, "extension": extension, "mime_type": mime_type}
|