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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any