autosignly 0.1.0.dev0__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.
- autosignly/__init__.py +70 -0
- autosignly/_version.py +1 -0
- autosignly/client.py +401 -0
- autosignly/errors.py +69 -0
- autosignly/models.py +242 -0
- autosignly/webhooks.py +39 -0
- autosignly-0.1.0.dev0.dist-info/METADATA +147 -0
- autosignly-0.1.0.dev0.dist-info/RECORD +9 -0
- autosignly-0.1.0.dev0.dist-info/WHEEL +4 -0
autosignly/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Python client for the Autosignly API.
|
|
2
|
+
|
|
3
|
+
from autosignly import AutosignlyClient, Signer
|
|
4
|
+
|
|
5
|
+
with AutosignlyClient(api_key="api_key_...", api_secret="api_sct_...") as client:
|
|
6
|
+
result = client.upload_and_sign(
|
|
7
|
+
pdf=open("contract.pdf", "rb").read(),
|
|
8
|
+
document_name="Contract",
|
|
9
|
+
signers=[Signer(first_name="Anna", last_name="Nowak",
|
|
10
|
+
email="anna@example.com", country="PL")],
|
|
11
|
+
)
|
|
12
|
+
print(result.document_id)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .client import AutosignlyClient, PRODUCTION_BASE_URL
|
|
17
|
+
from .errors import (
|
|
18
|
+
AutosignlyError,
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
ConnectionError,
|
|
21
|
+
InvalidSignatureError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
PermissionDeniedError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
ServerError,
|
|
26
|
+
ValidationError,
|
|
27
|
+
)
|
|
28
|
+
from .models import (
|
|
29
|
+
Document,
|
|
30
|
+
DocumentStatus,
|
|
31
|
+
DocumentSummary,
|
|
32
|
+
Page,
|
|
33
|
+
SignatureType,
|
|
34
|
+
Signer,
|
|
35
|
+
SignerDetails,
|
|
36
|
+
SignerStatus,
|
|
37
|
+
SigningMode,
|
|
38
|
+
SigningRequestResult,
|
|
39
|
+
SigningStatus,
|
|
40
|
+
Tag,
|
|
41
|
+
VerificationMethod,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"AutosignlyClient",
|
|
46
|
+
"PRODUCTION_BASE_URL",
|
|
47
|
+
"AutosignlyError",
|
|
48
|
+
"AuthenticationError",
|
|
49
|
+
"ConnectionError",
|
|
50
|
+
"InvalidSignatureError",
|
|
51
|
+
"NotFoundError",
|
|
52
|
+
"PermissionDeniedError",
|
|
53
|
+
"RateLimitError",
|
|
54
|
+
"ServerError",
|
|
55
|
+
"ValidationError",
|
|
56
|
+
"Document",
|
|
57
|
+
"DocumentStatus",
|
|
58
|
+
"DocumentSummary",
|
|
59
|
+
"Page",
|
|
60
|
+
"SignatureType",
|
|
61
|
+
"Signer",
|
|
62
|
+
"SignerDetails",
|
|
63
|
+
"SignerStatus",
|
|
64
|
+
"SigningMode",
|
|
65
|
+
"SigningRequestResult",
|
|
66
|
+
"SigningStatus",
|
|
67
|
+
"Tag",
|
|
68
|
+
"VerificationMethod",
|
|
69
|
+
"webhooks",
|
|
70
|
+
]
|
autosignly/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0.dev0"
|
autosignly/client.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""HTTP client for the Autosignly public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, Iterator, Mapping, Sequence
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from . import errors
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
from .models import (
|
|
16
|
+
Document,
|
|
17
|
+
DocumentSummary,
|
|
18
|
+
Page,
|
|
19
|
+
Signer,
|
|
20
|
+
SigningRequestResult,
|
|
21
|
+
Tag,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
PRODUCTION_BASE_URL = "https://app.autosignly.eu/api"
|
|
25
|
+
API_PREFIX = "/publics/v1"
|
|
26
|
+
|
|
27
|
+
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
28
|
+
_MAX_RETRY_DELAY = 60.0
|
|
29
|
+
_IDEMPOTENT_METHODS = frozenset({"GET", "DELETE"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AutosignlyClient:
|
|
33
|
+
"""Client for the Autosignly API.
|
|
34
|
+
|
|
35
|
+
Credentials are a key and secret pair created in the Autosignly application.
|
|
36
|
+
Each environment, production or sandbox, has its own pair, and the pair
|
|
37
|
+
decides which environment a call operates on.
|
|
38
|
+
|
|
39
|
+
The secret must never reach a browser or a mobile app. This client is meant
|
|
40
|
+
to run on your own server.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str,
|
|
46
|
+
api_secret: str,
|
|
47
|
+
*,
|
|
48
|
+
base_url: str = PRODUCTION_BASE_URL,
|
|
49
|
+
timeout: float = 30.0,
|
|
50
|
+
max_retries: int = 2,
|
|
51
|
+
http_client: httpx.Client | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
if not api_key or not api_secret:
|
|
54
|
+
raise ValueError("api_key and api_secret are required")
|
|
55
|
+
|
|
56
|
+
self._base_url = base_url.rstrip("/")
|
|
57
|
+
self._max_retries = max(0, max_retries)
|
|
58
|
+
self._owns_client = http_client is None
|
|
59
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
60
|
+
self._headers = {
|
|
61
|
+
"X-API-KEY": api_key,
|
|
62
|
+
"X-API-SECRET": api_secret,
|
|
63
|
+
"Accept": "application/json",
|
|
64
|
+
"User-Agent": f"autosignly-python/{__version__}",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def __enter__(self) -> "AutosignlyClient":
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
71
|
+
self.close()
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
"""Release the underlying connection pool."""
|
|
75
|
+
if self._owns_client:
|
|
76
|
+
self._http.close()
|
|
77
|
+
|
|
78
|
+
# -- credentials ---------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def validate_credentials(self) -> bool:
|
|
81
|
+
"""Report whether this key and secret pair is accepted.
|
|
82
|
+
|
|
83
|
+
Invalid credentials return ``False`` rather than raising, mirroring the
|
|
84
|
+
API, which never answers this call with an authentication error so that
|
|
85
|
+
keys cannot be probed.
|
|
86
|
+
"""
|
|
87
|
+
payload = self._request("GET", "/api-key")
|
|
88
|
+
return bool(payload.get("valid", False))
|
|
89
|
+
|
|
90
|
+
# -- documents -----------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def list_documents(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
status: str | Sequence[str] | None = None,
|
|
96
|
+
page: int = 0,
|
|
97
|
+
size: int = 20,
|
|
98
|
+
sort: str | None = None,
|
|
99
|
+
) -> Page[DocumentSummary]:
|
|
100
|
+
"""Return one page of documents belonging to this environment."""
|
|
101
|
+
params: list[tuple[str, Any]] = [("page", page), ("size", size)]
|
|
102
|
+
if sort:
|
|
103
|
+
params.append(("sort", sort))
|
|
104
|
+
if status:
|
|
105
|
+
values = [status] if isinstance(status, str) else list(status)
|
|
106
|
+
params.extend(("status", value) for value in values)
|
|
107
|
+
|
|
108
|
+
payload = self._request("GET", "/documents", params=params)
|
|
109
|
+
return _to_page(payload, DocumentSummary.from_payload)
|
|
110
|
+
|
|
111
|
+
def iter_documents(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
status: str | Sequence[str] | None = None,
|
|
115
|
+
size: int = 50,
|
|
116
|
+
sort: str | None = None,
|
|
117
|
+
) -> Iterator[DocumentSummary]:
|
|
118
|
+
"""Walk every document, fetching further pages as needed."""
|
|
119
|
+
page_number = 0
|
|
120
|
+
while True:
|
|
121
|
+
page = self.list_documents(status=status, page=page_number, size=size, sort=sort)
|
|
122
|
+
yield from page.content
|
|
123
|
+
if not page.has_next:
|
|
124
|
+
return
|
|
125
|
+
page_number += 1
|
|
126
|
+
|
|
127
|
+
def get_document(self, document_id: str) -> Document:
|
|
128
|
+
"""Return one document with its signers."""
|
|
129
|
+
payload = self._request("GET", f"/documents/{document_id}")
|
|
130
|
+
return Document.from_payload(payload)
|
|
131
|
+
|
|
132
|
+
def download_document(self, document_id: str) -> bytes:
|
|
133
|
+
"""Fetch the current file of a document.
|
|
134
|
+
|
|
135
|
+
Resolves a fresh link through :meth:`get_document` and downloads it. A
|
|
136
|
+
document that is still being signed can be downloaded too; it then
|
|
137
|
+
carries only the signatures collected so far.
|
|
138
|
+
"""
|
|
139
|
+
document = self.get_document(document_id)
|
|
140
|
+
if not document.file_url:
|
|
141
|
+
raise errors.NotFoundError(
|
|
142
|
+
f"Document {document_id} has no file to download",
|
|
143
|
+
status_code=404,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
response = self._http.get(document.file_url)
|
|
148
|
+
except httpx.TransportError as exc:
|
|
149
|
+
raise errors.ConnectionError(f"Could not download {document_id}: {exc}") from exc
|
|
150
|
+
|
|
151
|
+
if response.status_code >= 400:
|
|
152
|
+
raise _to_error(response)
|
|
153
|
+
return response.content
|
|
154
|
+
|
|
155
|
+
def send_for_signing(
|
|
156
|
+
self,
|
|
157
|
+
document_id: str,
|
|
158
|
+
*,
|
|
159
|
+
signers: Sequence[Signer] | None = None,
|
|
160
|
+
signature_type: str | None = None,
|
|
161
|
+
verification_method: str | None = None,
|
|
162
|
+
initiator_email: str | None = None,
|
|
163
|
+
initiator_locale: str | None = None,
|
|
164
|
+
) -> SigningRequestResult:
|
|
165
|
+
"""Send an existing document to its signers.
|
|
166
|
+
|
|
167
|
+
A document in ``GENERATED`` status needs ``signers``; one already in
|
|
168
|
+
``SIGNERS_ASSIGNED`` must be sent without them, since its signers are
|
|
169
|
+
already stored.
|
|
170
|
+
"""
|
|
171
|
+
body: dict[str, Any] = {}
|
|
172
|
+
if signers:
|
|
173
|
+
body["signers"] = [signer.to_payload() for signer in signers]
|
|
174
|
+
if signature_type:
|
|
175
|
+
body["signatureType"] = signature_type
|
|
176
|
+
if verification_method:
|
|
177
|
+
body["verificationMethod"] = verification_method
|
|
178
|
+
if initiator_email:
|
|
179
|
+
body["signingInitiatorData"] = {
|
|
180
|
+
"email": initiator_email,
|
|
181
|
+
"locale": initiator_locale,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
payload = self._request("POST", f"/documents/{document_id}/send-for-signing", json_body=body)
|
|
185
|
+
return SigningRequestResult.from_payload(payload)
|
|
186
|
+
|
|
187
|
+
def upload_and_sign(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
pdf: bytes,
|
|
191
|
+
document_name: str,
|
|
192
|
+
signers: Sequence[Signer],
|
|
193
|
+
signature_type: str | None = None,
|
|
194
|
+
verification_method: str | None = None,
|
|
195
|
+
initiator_email: str | None = None,
|
|
196
|
+
initiator_locale: str | None = None,
|
|
197
|
+
file_name: str = "document.pdf",
|
|
198
|
+
) -> str:
|
|
199
|
+
"""Upload a PDF and send it for signature in one call.
|
|
200
|
+
|
|
201
|
+
Returns the identifier of the created document. Signing links are
|
|
202
|
+
e-mailed to the signers directly.
|
|
203
|
+
"""
|
|
204
|
+
request: dict[str, Any] = {
|
|
205
|
+
"documentName": document_name,
|
|
206
|
+
"signers": [signer.to_payload() for signer in signers],
|
|
207
|
+
}
|
|
208
|
+
if signature_type:
|
|
209
|
+
request["signatureType"] = signature_type
|
|
210
|
+
if verification_method:
|
|
211
|
+
request["verificationMethod"] = verification_method
|
|
212
|
+
if initiator_email:
|
|
213
|
+
request["signingInitiatorData"] = {
|
|
214
|
+
"email": initiator_email,
|
|
215
|
+
"locale": initiator_locale,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
files = {
|
|
219
|
+
"file": (file_name, pdf, "application/pdf"),
|
|
220
|
+
"request": (None, json.dumps(request), "application/json"),
|
|
221
|
+
}
|
|
222
|
+
payload = self._request("POST", "/documents/signings", files=files)
|
|
223
|
+
return payload.get("documentId", "")
|
|
224
|
+
|
|
225
|
+
# -- tags ----------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def list_tags(self, *, name: str | None = None, page: int = 0, size: int = 20) -> Page[Tag]:
|
|
228
|
+
"""Return one page of the company tag pool for this environment."""
|
|
229
|
+
params: list[tuple[str, Any]] = [("page", page), ("size", size)]
|
|
230
|
+
if name:
|
|
231
|
+
params.append(("name", name))
|
|
232
|
+
payload = self._request("GET", "/tags", params=params)
|
|
233
|
+
return _to_page(payload, Tag.from_payload)
|
|
234
|
+
|
|
235
|
+
def create_tag(self, name: str) -> Tag:
|
|
236
|
+
"""Add a tag, or return the existing one with the same name.
|
|
237
|
+
|
|
238
|
+
Names are matched without regard to case, so repeating this call is safe.
|
|
239
|
+
"""
|
|
240
|
+
payload = self._request("POST", "/tags", json_body={"name": name})
|
|
241
|
+
return Tag.from_payload(payload)
|
|
242
|
+
|
|
243
|
+
def delete_tag(self, tag_id: str) -> None:
|
|
244
|
+
"""Remove a tag from the pool and from every document carrying it."""
|
|
245
|
+
self._request("DELETE", f"/tags/{tag_id}")
|
|
246
|
+
|
|
247
|
+
def set_document_tags(
|
|
248
|
+
self,
|
|
249
|
+
document_id: str,
|
|
250
|
+
*,
|
|
251
|
+
tag_ids: Sequence[str] | None = None,
|
|
252
|
+
names: Sequence[str] | None = None,
|
|
253
|
+
) -> list[Tag]:
|
|
254
|
+
"""Replace the whole tag set of a document.
|
|
255
|
+
|
|
256
|
+
Tags left out are removed. Names that are not in the pool yet are added
|
|
257
|
+
to it. Passing neither argument clears every tag.
|
|
258
|
+
"""
|
|
259
|
+
body: dict[str, Any] = {}
|
|
260
|
+
if tag_ids is not None:
|
|
261
|
+
body["tagIds"] = list(tag_ids)
|
|
262
|
+
if names is not None:
|
|
263
|
+
body["names"] = list(names)
|
|
264
|
+
payload = self._request("PUT", f"/documents/{document_id}/tags", json_body=body)
|
|
265
|
+
return [Tag.from_payload(item) for item in payload or []]
|
|
266
|
+
|
|
267
|
+
# -- transport -----------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def _request(
|
|
270
|
+
self,
|
|
271
|
+
method: str,
|
|
272
|
+
path: str,
|
|
273
|
+
*,
|
|
274
|
+
params: Sequence[tuple[str, Any]] | None = None,
|
|
275
|
+
json_body: Mapping[str, Any] | None = None,
|
|
276
|
+
files: Mapping[str, Any] | None = None,
|
|
277
|
+
) -> Any:
|
|
278
|
+
url = f"{self._base_url}{API_PREFIX}{path}"
|
|
279
|
+
headers = dict(self._headers)
|
|
280
|
+
if method not in _IDEMPOTENT_METHODS:
|
|
281
|
+
headers["Idempotency-Key"] = str(uuid.uuid4())
|
|
282
|
+
|
|
283
|
+
last_error: Exception | None = None
|
|
284
|
+
for attempt in range(self._max_retries + 1):
|
|
285
|
+
try:
|
|
286
|
+
response = self._http.request(
|
|
287
|
+
method,
|
|
288
|
+
url,
|
|
289
|
+
params=params,
|
|
290
|
+
json=json_body,
|
|
291
|
+
files=files,
|
|
292
|
+
headers=headers,
|
|
293
|
+
)
|
|
294
|
+
except httpx.TransportError as exc:
|
|
295
|
+
last_error = exc
|
|
296
|
+
if attempt >= self._max_retries:
|
|
297
|
+
raise errors.ConnectionError(f"Could not reach {url}: {exc}") from exc
|
|
298
|
+
time.sleep(_backoff(attempt))
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
if response.status_code in _RETRY_STATUSES and attempt < self._max_retries:
|
|
302
|
+
delay = _retry_delay(response, attempt)
|
|
303
|
+
if delay is not None:
|
|
304
|
+
time.sleep(delay)
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
if response.status_code >= 400:
|
|
308
|
+
raise _to_error(response)
|
|
309
|
+
|
|
310
|
+
return _decode(response)
|
|
311
|
+
|
|
312
|
+
raise errors.ConnectionError(f"Could not reach {url}: {last_error}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _backoff(attempt: int) -> float:
|
|
316
|
+
"""Exponential backoff with jitter.
|
|
317
|
+
|
|
318
|
+
The jitter matters: without it every client retrying a shared outage wakes
|
|
319
|
+
up at the same moment and pushes the service back over.
|
|
320
|
+
"""
|
|
321
|
+
ceiling = min(_MAX_RETRY_DELAY, 0.5 * (2**attempt))
|
|
322
|
+
return random.uniform(ceiling / 2, ceiling)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _retry_delay(response: httpx.Response, attempt: int) -> float | None:
|
|
326
|
+
"""How long to wait before retrying, or ``None`` to give up now.
|
|
327
|
+
|
|
328
|
+
A rate-limited response carries the delay the API wants; anything longer
|
|
329
|
+
than the cap is reported to the caller instead of blocking the thread.
|
|
330
|
+
"""
|
|
331
|
+
if response.status_code != 429:
|
|
332
|
+
return _backoff(attempt)
|
|
333
|
+
|
|
334
|
+
retry_after = _retry_after_seconds(response)
|
|
335
|
+
if retry_after is None:
|
|
336
|
+
return _backoff(attempt)
|
|
337
|
+
if retry_after > _MAX_RETRY_DELAY:
|
|
338
|
+
return None
|
|
339
|
+
return retry_after
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _retry_after_seconds(response: httpx.Response) -> float | None:
|
|
343
|
+
raw = response.headers.get("Retry-After")
|
|
344
|
+
if not raw:
|
|
345
|
+
return None
|
|
346
|
+
try:
|
|
347
|
+
return max(0.0, float(raw.strip()))
|
|
348
|
+
except ValueError:
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _decode(response: httpx.Response) -> Any:
|
|
353
|
+
if response.status_code == 204 or not response.content:
|
|
354
|
+
return None
|
|
355
|
+
try:
|
|
356
|
+
return response.json()
|
|
357
|
+
except ValueError as exc:
|
|
358
|
+
raise errors.AutosignlyError(
|
|
359
|
+
f"Expected JSON from {response.request.url}, got {response.headers.get('content-type')}",
|
|
360
|
+
status_code=response.status_code,
|
|
361
|
+
) from exc
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _to_error(response: httpx.Response) -> errors.AutosignlyError:
|
|
365
|
+
error_type = error_id = info = None
|
|
366
|
+
try:
|
|
367
|
+
body = response.json()
|
|
368
|
+
if isinstance(body, dict):
|
|
369
|
+
error_type = body.get("errorType")
|
|
370
|
+
error_id = body.get("errorId")
|
|
371
|
+
info = body.get("info")
|
|
372
|
+
except ValueError:
|
|
373
|
+
info = response.text[:200] or None
|
|
374
|
+
|
|
375
|
+
status = response.status_code
|
|
376
|
+
message = info or f"Request failed with status {status}"
|
|
377
|
+
kwargs = {"status_code": status, "error_type": error_type, "error_id": error_id}
|
|
378
|
+
|
|
379
|
+
if status == 401:
|
|
380
|
+
return errors.AuthenticationError(message, **kwargs)
|
|
381
|
+
if status == 403:
|
|
382
|
+
return errors.PermissionDeniedError(message, **kwargs)
|
|
383
|
+
if status == 404:
|
|
384
|
+
return errors.NotFoundError(message, **kwargs)
|
|
385
|
+
if status == 429:
|
|
386
|
+
return errors.RateLimitError(message, retry_after=_retry_after_seconds(response), **kwargs)
|
|
387
|
+
if status >= 500:
|
|
388
|
+
return errors.ServerError(message, **kwargs)
|
|
389
|
+
return errors.ValidationError(message, **kwargs)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _to_page(payload: Any, factory: Any) -> Page[Any]:
|
|
393
|
+
content = [factory(item) for item in (payload or {}).get("content") or []]
|
|
394
|
+
info = (payload or {}).get("page") or {}
|
|
395
|
+
return Page(
|
|
396
|
+
content=content,
|
|
397
|
+
number=info.get("number", 0),
|
|
398
|
+
size=info.get("size", len(content)),
|
|
399
|
+
total_elements=info.get("totalElements", len(content)),
|
|
400
|
+
total_pages=info.get("totalPages", 1 if content else 0),
|
|
401
|
+
)
|
autosignly/errors.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Exceptions raised by the Autosignly client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AutosignlyError(Exception):
|
|
7
|
+
"""Base class for every error raised by this library."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
*,
|
|
13
|
+
status_code: int | None = None,
|
|
14
|
+
error_type: str | None = None,
|
|
15
|
+
error_id: str | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.message = message
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.error_type = error_type
|
|
21
|
+
self.error_id = error_id
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
parts = [self.message]
|
|
25
|
+
if self.error_type:
|
|
26
|
+
parts.append(f"type={self.error_type}")
|
|
27
|
+
if self.error_id:
|
|
28
|
+
parts.append(f"errorId={self.error_id}")
|
|
29
|
+
return " ".join(parts)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthenticationError(AutosignlyError):
|
|
33
|
+
"""The API key or secret was rejected."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PermissionDeniedError(AutosignlyError):
|
|
37
|
+
"""The credentials are valid but do not grant access to this resource."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotFoundError(AutosignlyError):
|
|
41
|
+
"""The requested resource does not exist."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ValidationError(AutosignlyError):
|
|
45
|
+
"""The request was rejected as invalid."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RateLimitError(AutosignlyError):
|
|
49
|
+
"""Too many requests were sent in a short period.
|
|
50
|
+
|
|
51
|
+
``retry_after`` carries the delay the API asked for, in seconds, when it
|
|
52
|
+
provided one.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str, *, retry_after: float | None = None, **kwargs) -> None:
|
|
56
|
+
super().__init__(message, **kwargs)
|
|
57
|
+
self.retry_after = retry_after
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ServerError(AutosignlyError):
|
|
61
|
+
"""The API failed to process the request."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ConnectionError(AutosignlyError):
|
|
65
|
+
"""The API could not be reached."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class InvalidSignatureError(AutosignlyError):
|
|
69
|
+
"""A webhook signature did not match the payload."""
|
autosignly/models.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Data types returned by and passed to the Autosignly API.
|
|
2
|
+
|
|
3
|
+
Status and type values are plain strings rather than enums on purpose: the API
|
|
4
|
+
may gain new values over time, and a client that raises on an unknown value
|
|
5
|
+
would break on a server-side addition. The classes below list the values known
|
|
6
|
+
at the time of release, for convenience and autocompletion.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Generic, Iterator, TypeVar
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SignatureType:
|
|
18
|
+
"""Level of the electronic signature requested for a document or signer."""
|
|
19
|
+
|
|
20
|
+
QES = "QES"
|
|
21
|
+
AES = "AES"
|
|
22
|
+
SES = "SES"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VerificationMethod:
|
|
26
|
+
"""How an advanced signature verifies the signer's identity."""
|
|
27
|
+
|
|
28
|
+
SMS = "SMS"
|
|
29
|
+
WK = "WK"
|
|
30
|
+
BIOMETRIC = "BIOMETRIC"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SigningMode:
|
|
34
|
+
"""Whether a document still needs signatures."""
|
|
35
|
+
|
|
36
|
+
REQUIRES_SIGNATURE = "REQUIRES_SIGNATURE"
|
|
37
|
+
ALREADY_SIGNED = "ALREADY_SIGNED"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DocumentStatus:
|
|
41
|
+
"""Lifecycle of a document."""
|
|
42
|
+
|
|
43
|
+
GENERATED = "GENERATED"
|
|
44
|
+
SIGNERS_ASSIGNED = "SIGNERS_ASSIGNED"
|
|
45
|
+
WAITING_FOR_SIGNATURE = "WAITING_FOR_SIGNATURE"
|
|
46
|
+
SIGNING_IN_PROGRESS = "SIGNING_IN_PROGRESS"
|
|
47
|
+
SIGNED = "SIGNED"
|
|
48
|
+
CANCELLED = "CANCELLED"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SigningStatus:
|
|
52
|
+
"""State of an individual signer within a signing request."""
|
|
53
|
+
|
|
54
|
+
SENT = "SENT"
|
|
55
|
+
AWAITING_SIGNATURE = "AWAITING_SIGNATURE"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class Signer:
|
|
60
|
+
"""A person asked to sign a document."""
|
|
61
|
+
|
|
62
|
+
first_name: str
|
|
63
|
+
last_name: str
|
|
64
|
+
email: str
|
|
65
|
+
country: str
|
|
66
|
+
phone_number: str | None = None
|
|
67
|
+
locale: str | None = None
|
|
68
|
+
order: int | None = None
|
|
69
|
+
signature_type: str | None = None
|
|
70
|
+
signature_verification_method: str | None = None
|
|
71
|
+
|
|
72
|
+
def to_payload(self) -> dict[str, Any]:
|
|
73
|
+
payload = {
|
|
74
|
+
"firstName": self.first_name,
|
|
75
|
+
"lastName": self.last_name,
|
|
76
|
+
"email": self.email,
|
|
77
|
+
"country": self.country,
|
|
78
|
+
"phoneNumber": self.phone_number,
|
|
79
|
+
"locale": self.locale,
|
|
80
|
+
"order": self.order,
|
|
81
|
+
"signatureType": self.signature_type,
|
|
82
|
+
"signatureVerificationMethod": self.signature_verification_method,
|
|
83
|
+
}
|
|
84
|
+
return {key: value for key, value in payload.items() if value is not None}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class Tag:
|
|
89
|
+
"""A company tag, used to group documents and templates."""
|
|
90
|
+
|
|
91
|
+
id: str
|
|
92
|
+
name: str
|
|
93
|
+
color: str | None = None
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_payload(cls, payload: dict[str, Any]) -> "Tag":
|
|
97
|
+
return cls(id=payload["id"], name=payload["name"], color=payload.get("color"))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(slots=True)
|
|
101
|
+
class SignerStatus:
|
|
102
|
+
"""Where a signer stands, and the link they were given."""
|
|
103
|
+
|
|
104
|
+
email: str
|
|
105
|
+
status: str | None = None
|
|
106
|
+
sign_url: str | None = None
|
|
107
|
+
expires_at: str | None = None
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_payload(cls, payload: dict[str, Any]) -> "SignerStatus":
|
|
111
|
+
return cls(
|
|
112
|
+
email=payload.get("email", ""),
|
|
113
|
+
status=payload.get("status"),
|
|
114
|
+
sign_url=payload.get("signUrl"),
|
|
115
|
+
expires_at=payload.get("expiresAt"),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(slots=True)
|
|
120
|
+
class SignerDetails:
|
|
121
|
+
"""A signer as stored on a document."""
|
|
122
|
+
|
|
123
|
+
email: str
|
|
124
|
+
first_name: str | None = None
|
|
125
|
+
last_name: str | None = None
|
|
126
|
+
phone_number: str | None = None
|
|
127
|
+
country: str | None = None
|
|
128
|
+
locale: str | None = None
|
|
129
|
+
signature_type: str | None = None
|
|
130
|
+
signature_verification_method: str | None = None
|
|
131
|
+
signing_order: int | None = None
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_payload(cls, payload: dict[str, Any]) -> "SignerDetails":
|
|
135
|
+
return cls(
|
|
136
|
+
email=payload.get("email", ""),
|
|
137
|
+
first_name=payload.get("firstName"),
|
|
138
|
+
last_name=payload.get("lastName"),
|
|
139
|
+
phone_number=payload.get("phoneNumber"),
|
|
140
|
+
country=payload.get("country"),
|
|
141
|
+
locale=payload.get("locale"),
|
|
142
|
+
signature_type=payload.get("signatureType"),
|
|
143
|
+
signature_verification_method=payload.get("signatureVerificationMethod"),
|
|
144
|
+
signing_order=payload.get("signingOrder"),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(slots=True)
|
|
149
|
+
class Document:
|
|
150
|
+
"""Full details of a document, including its signers and a link to its file.
|
|
151
|
+
|
|
152
|
+
``file_url`` is short-lived. Fetch the document again to obtain a fresh link
|
|
153
|
+
rather than storing it. A document that is still being signed can be
|
|
154
|
+
downloaded as well; it then carries only the signatures collected so far.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
id: str
|
|
158
|
+
name: str | None = None
|
|
159
|
+
company_id: str | None = None
|
|
160
|
+
status: str | None = None
|
|
161
|
+
signing_mode: str | None = None
|
|
162
|
+
signers: list[SignerDetails] = field(default_factory=list)
|
|
163
|
+
tags: list[Tag] = field(default_factory=list)
|
|
164
|
+
file_url: str | None = None
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_payload(cls, payload: dict[str, Any]) -> "Document":
|
|
168
|
+
return cls(
|
|
169
|
+
id=payload["id"],
|
|
170
|
+
name=payload.get("name"),
|
|
171
|
+
company_id=payload.get("companyId"),
|
|
172
|
+
status=payload.get("status"),
|
|
173
|
+
signing_mode=payload.get("signingMode"),
|
|
174
|
+
signers=[SignerDetails.from_payload(s) for s in payload.get("signerResponses") or []],
|
|
175
|
+
tags=[Tag.from_payload(t) for t in payload.get("tags") or []],
|
|
176
|
+
file_url=payload.get("fileUrl"),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(slots=True)
|
|
181
|
+
class DocumentSummary:
|
|
182
|
+
"""A document as it appears in a list."""
|
|
183
|
+
|
|
184
|
+
id: str
|
|
185
|
+
name: str | None = None
|
|
186
|
+
status: str | None = None
|
|
187
|
+
signing_mode: str | None = None
|
|
188
|
+
created_at: str | None = None
|
|
189
|
+
tags: list[Tag] = field(default_factory=list)
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def from_payload(cls, payload: dict[str, Any]) -> "DocumentSummary":
|
|
193
|
+
return cls(
|
|
194
|
+
id=payload["id"],
|
|
195
|
+
name=payload.get("name"),
|
|
196
|
+
status=payload.get("status"),
|
|
197
|
+
signing_mode=payload.get("signingMode"),
|
|
198
|
+
created_at=payload.get("createdAt"),
|
|
199
|
+
tags=[Tag.from_payload(t) for t in payload.get("tags") or []],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(slots=True)
|
|
204
|
+
class SigningRequestResult:
|
|
205
|
+
"""Outcome of sending a document for signature.
|
|
206
|
+
|
|
207
|
+
Only the first signer receives a link immediately; the others are e-mailed
|
|
208
|
+
their link when their turn comes.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
document_id: str
|
|
212
|
+
status: str | None = None
|
|
213
|
+
signers: list[SignerStatus] = field(default_factory=list)
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def from_payload(cls, payload: dict[str, Any]) -> "SigningRequestResult":
|
|
217
|
+
return cls(
|
|
218
|
+
document_id=payload.get("documentId", ""),
|
|
219
|
+
status=payload.get("status"),
|
|
220
|
+
signers=[SignerStatus.from_payload(s) for s in payload.get("signers") or []],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(slots=True)
|
|
225
|
+
class Page(Generic[T]):
|
|
226
|
+
"""One page of a paged listing."""
|
|
227
|
+
|
|
228
|
+
content: list[T]
|
|
229
|
+
number: int = 0
|
|
230
|
+
size: int = 0
|
|
231
|
+
total_elements: int = 0
|
|
232
|
+
total_pages: int = 0
|
|
233
|
+
|
|
234
|
+
def __iter__(self) -> Iterator[T]:
|
|
235
|
+
return iter(self.content)
|
|
236
|
+
|
|
237
|
+
def __len__(self) -> int:
|
|
238
|
+
return len(self.content)
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def has_next(self) -> bool:
|
|
242
|
+
return self.number + 1 < self.total_pages
|
autosignly/webhooks.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Verification of webhook deliveries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
|
|
8
|
+
from .errors import InvalidSignatureError
|
|
9
|
+
|
|
10
|
+
SIGNATURE_HEADER = "X-Webhook-Signature"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def compute_signature(payload: bytes, secret: str) -> str:
|
|
14
|
+
"""Return the hex digest Autosignly sends for this payload."""
|
|
15
|
+
return hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_valid(payload: bytes, signature_header: str, secret: str) -> bool:
|
|
19
|
+
"""Check a delivery without raising.
|
|
20
|
+
|
|
21
|
+
``payload`` must be the raw request body exactly as received. Parsing and
|
|
22
|
+
re-serialising the JSON changes the bytes and invalidates the signature.
|
|
23
|
+
"""
|
|
24
|
+
if not signature_header or not secret:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
expected = compute_signature(payload, secret)
|
|
28
|
+
for candidate in signature_header.split(","):
|
|
29
|
+
candidate = candidate.strip()
|
|
30
|
+
_, _, digest = candidate.rpartition("=")
|
|
31
|
+
if digest and hmac.compare_digest(digest, expected):
|
|
32
|
+
return True
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def verify(payload: bytes, signature_header: str, secret: str) -> None:
|
|
37
|
+
"""Check a delivery and raise :class:`InvalidSignatureError` if it fails."""
|
|
38
|
+
if not is_valid(payload, signature_header, secret):
|
|
39
|
+
raise InvalidSignatureError("Webhook signature does not match the payload")
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: autosignly
|
|
3
|
+
Version: 0.1.0.dev0
|
|
4
|
+
Summary: Python client for the Autosignly API - eIDAS electronic signatures and document workflows
|
|
5
|
+
Project-URL: Homepage, https://autosignly.eu
|
|
6
|
+
Project-URL: Documentation, https://docs.16it.eu/docs/intro/
|
|
7
|
+
Project-URL: Source, https://github.com/16it-pl/autosignly-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/16it-pl/autosignly-sdk/issues
|
|
9
|
+
Author: 16it
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: autosignly,eidas,electronic-signature,esignature,pades,pdf
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx<1,>=0.27
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# autosignly
|
|
22
|
+
|
|
23
|
+
Python client for the [Autosignly](https://autosignly.eu) API - eIDAS electronic signatures and
|
|
24
|
+
document workflows.
|
|
25
|
+
|
|
26
|
+
> **Not published yet.** This package is being built. Install from source for now.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install autosignly
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.10 or newer.
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from autosignly import AutosignlyClient, Signer
|
|
40
|
+
|
|
41
|
+
with AutosignlyClient(api_key="api_key_...", api_secret="api_sct_...") as client:
|
|
42
|
+
document_id = client.upload_and_sign(
|
|
43
|
+
pdf=open("contract.pdf", "rb").read(),
|
|
44
|
+
document_name="Consulting agreement",
|
|
45
|
+
signers=[
|
|
46
|
+
Signer(
|
|
47
|
+
first_name="Anna",
|
|
48
|
+
last_name="Nowak",
|
|
49
|
+
email="anna@example.com",
|
|
50
|
+
country="PL",
|
|
51
|
+
)
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
print(document_id)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The key and secret decide which environment you are working in. Every environment, production or
|
|
58
|
+
sandbox, has its own pair, so pointing a script at the sandbox is a matter of swapping credentials.
|
|
59
|
+
|
|
60
|
+
The secret must stay on your server. It must never be shipped to a browser or a mobile app.
|
|
61
|
+
|
|
62
|
+
## Reading documents
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
document = client.get_document(document_id)
|
|
66
|
+
print(document.status, [s.email for s in document.signers])
|
|
67
|
+
|
|
68
|
+
for summary in client.iter_documents(status="SIGNED"):
|
|
69
|
+
print(summary.id, summary.name)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Downloading the file
|
|
73
|
+
|
|
74
|
+
A document carries a short-lived link to its file. The link expires, so fetch the document again
|
|
75
|
+
for a fresh one rather than storing it.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
document = client.get_document(document_id)
|
|
79
|
+
print(document.file_url)
|
|
80
|
+
|
|
81
|
+
pdf = client.download_document(document_id)
|
|
82
|
+
open("signed.pdf", "wb").write(pdf)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A document that is still being signed can be downloaded as well - it then carries only the
|
|
86
|
+
signatures collected so far.
|
|
87
|
+
|
|
88
|
+
## Tags
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
tag = client.create_tag("contracts")
|
|
92
|
+
client.set_document_tags(document_id, tag_ids=[tag.id], names=["2026"])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Setting tags replaces the whole set: tags left out are removed, and names that do not exist yet are
|
|
96
|
+
added to the company tag pool.
|
|
97
|
+
|
|
98
|
+
## Verifying webhooks
|
|
99
|
+
|
|
100
|
+
Autosignly signs every webhook delivery. Check the signature against the raw request body, before
|
|
101
|
+
parsing it - re-serialising the JSON changes the bytes and the signature will not match.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from autosignly import webhooks
|
|
105
|
+
|
|
106
|
+
webhooks.verify(request.body, request.headers["X-Webhook-Signature"], webhook_key)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`verify` raises `InvalidSignatureError` on a mismatch; `webhooks.is_valid(...)` returns a boolean
|
|
110
|
+
instead.
|
|
111
|
+
|
|
112
|
+
## Errors
|
|
113
|
+
|
|
114
|
+
Every failure raises a subclass of `AutosignlyError` carrying the HTTP status and the error type
|
|
115
|
+
returned by the API.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from autosignly import AutosignlyError, NotFoundError
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
client.get_document("does-not-exist")
|
|
122
|
+
except NotFoundError:
|
|
123
|
+
...
|
|
124
|
+
except AutosignlyError as error:
|
|
125
|
+
print(error.status_code, error.error_type, error.error_id)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Connection problems and server errors are retried automatically, with an exponential backoff and
|
|
129
|
+
jitter. Client errors are not retried, since repeating a rejected request cannot change its outcome.
|
|
130
|
+
|
|
131
|
+
Rate limits are retried too, honouring the delay the API asks for. When that delay is longer than a
|
|
132
|
+
minute the call fails instead of blocking your thread, and `RateLimitError.retry_after` tells you
|
|
133
|
+
how long to wait.
|
|
134
|
+
|
|
135
|
+
The client does not implement a circuit breaker. It runs inside your process, on calls you asked
|
|
136
|
+
for, so refusing to even attempt one would be surprising - and your own infrastructure is the right
|
|
137
|
+
place for that policy. Pass your own `http_client` if you want to add one.
|
|
138
|
+
|
|
139
|
+
## Links
|
|
140
|
+
|
|
141
|
+
- Website: <https://autosignly.eu>
|
|
142
|
+
- API documentation: <https://docs.16it.eu/docs/intro/>
|
|
143
|
+
- Source and issues: <https://github.com/16it-pl/autosignly-sdk>
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
Apache-2.0
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
autosignly/__init__.py,sha256=FLT8__jVHRx4RilIAmJ8EY8KSjtb4CQUtKAnZgbiGKM,1614
|
|
2
|
+
autosignly/_version.py,sha256=qbhMh4mg2JMoCFflKwIRvApU63RR8AxvYq1OT__8sgI,27
|
|
3
|
+
autosignly/client.py,sha256=S0PvHTpXiyabaQW8jZ9NRqeUQaVdkTwjVtByLzNqE_c,13966
|
|
4
|
+
autosignly/errors.py,sha256=QyKj7iS74hamgkIi1En5BJvYZNLov4SA7_mzqq4P68Q,1852
|
|
5
|
+
autosignly/models.py,sha256=4QgBeui05933WIy39ACXlT-zaPKkbCJKSyPzW7tEtrw,7116
|
|
6
|
+
autosignly/webhooks.py,sha256=B7uEu8RYFqiIp4mxq43xkA5JCbBKYpA8WdONq72Kn5o,1330
|
|
7
|
+
autosignly-0.1.0.dev0.dist-info/METADATA,sha256=Ye4BDMnNVdtAGYjI3RJp3K4b2q_-QfWD4Gf2AlXFLJo,4642
|
|
8
|
+
autosignly-0.1.0.dev0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
9
|
+
autosignly-0.1.0.dev0.dist-info/RECORD,,
|