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