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.
@@ -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
+ ]
@@ -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
@@ -0,0 +1,13 @@
1
+ """Wall clock and sleep. Tests replace these."""
2
+
3
+ import time
4
+ from datetime import datetime, timezone
5
+
6
+
7
+ def now():
8
+ return datetime.now(timezone.utc)
9
+
10
+
11
+ def sleep(seconds):
12
+ if seconds > 0:
13
+ time.sleep(seconds)
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)
@@ -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}