easycontract 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.
- easycontract/__init__.py +40 -0
- easycontract/client.py +212 -0
- easycontract/errors.py +80 -0
- easycontract/resources.py +653 -0
- easycontract/webhooks.py +72 -0
- easycontract-0.1.0.dist-info/METADATA +176 -0
- easycontract-0.1.0.dist-info/RECORD +9 -0
- easycontract-0.1.0.dist-info/WHEEL +4 -0
- easycontract-0.1.0.dist-info/licenses/LICENSE.txt +18 -0
easycontract/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""easycontract — SDK oficial de Python.
|
|
2
|
+
|
|
3
|
+
from easycontract import EasyContract
|
|
4
|
+
|
|
5
|
+
client = EasyContract(api_key="ec_test_...")
|
|
6
|
+
envelope = client.envelopes.create(title="Contrato", retention_period_months=72)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from . import webhooks
|
|
12
|
+
from .client import EasyContract
|
|
13
|
+
from .errors import (
|
|
14
|
+
APIError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
EasyContractError,
|
|
18
|
+
ForbiddenError,
|
|
19
|
+
InvalidRequestError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
ServerError,
|
|
23
|
+
SignatureVerificationError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"EasyContract",
|
|
28
|
+
"EasyContractError",
|
|
29
|
+
"APIError",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"ForbiddenError",
|
|
32
|
+
"NotFoundError",
|
|
33
|
+
"InvalidRequestError",
|
|
34
|
+
"ConflictError",
|
|
35
|
+
"RateLimitError",
|
|
36
|
+
"ServerError",
|
|
37
|
+
"SignatureVerificationError",
|
|
38
|
+
"webhooks",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
easycontract/client.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""The easycontract API client (transport layer).
|
|
2
|
+
|
|
3
|
+
- Auth via X-Api-Key (ec_live_… / ec_test_… — test keys see only test data).
|
|
4
|
+
- Automatic Idempotency-Key on every POST (override or disable per call).
|
|
5
|
+
- Retries with backoff on 429 (honoring Retry-After) and on 5xx for GETs.
|
|
6
|
+
- RFC 7807 errors mapped to a typed exception hierarchy (errors.py).
|
|
7
|
+
|
|
8
|
+
File objects passed in `files` are re-wound before every retry (a retry
|
|
9
|
+
re-uploads the full document, not an empty body) and are closed once the
|
|
10
|
+
request finishes, whatever the outcome. A non-seekable stream (pipe, socket)
|
|
11
|
+
is sent once and never retried: an empty re-upload would be worse than the
|
|
12
|
+
error.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .errors import APIError, error_from_response
|
|
25
|
+
from .resources import (
|
|
26
|
+
Envelopes,
|
|
27
|
+
Events,
|
|
28
|
+
Invitations,
|
|
29
|
+
Members,
|
|
30
|
+
Organization,
|
|
31
|
+
Templates,
|
|
32
|
+
Webhooks,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
DEFAULT_BASE_URL = "https://api.easycontract.online/api/v1"
|
|
36
|
+
DEFAULT_TIMEOUT = 30
|
|
37
|
+
DEFAULT_MAX_RETRIES = 3
|
|
38
|
+
_MAX_RETRY_SLEEP = 30
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _retry_after_seconds(response) -> int:
|
|
42
|
+
"""Parse a delta-seconds Retry-After header; fall back to 1 for anything
|
|
43
|
+
else (HTTP-date form, garbage, missing)."""
|
|
44
|
+
try:
|
|
45
|
+
return max(0, int(response.headers.get("Retry-After", "")))
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _file_handles(files):
|
|
51
|
+
"""Yield the file objects inside a requests-style `files` mapping."""
|
|
52
|
+
if not files:
|
|
53
|
+
return
|
|
54
|
+
for value in files.values():
|
|
55
|
+
candidate = value[1] if isinstance(value, tuple) and len(value) > 1 else value
|
|
56
|
+
if hasattr(candidate, "read"):
|
|
57
|
+
yield candidate
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _rewind(handle) -> bool:
|
|
61
|
+
"""Put a file object back at its start. False if it cannot be rewound."""
|
|
62
|
+
try:
|
|
63
|
+
if hasattr(handle, "seekable") and not handle.seekable():
|
|
64
|
+
return False
|
|
65
|
+
handle.seek(0)
|
|
66
|
+
except (AttributeError, OSError, ValueError):
|
|
67
|
+
return False
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EasyContract:
|
|
72
|
+
"""Entry point:
|
|
73
|
+
|
|
74
|
+
>>> client = EasyContract(api_key="ec_test_...", base_url="http://localhost:8000/api/v1")
|
|
75
|
+
>>> envelope = client.envelopes.create(title="Contrato", retention_period_months=72)
|
|
76
|
+
|
|
77
|
+
Usable as a context manager to release the connection pool:
|
|
78
|
+
|
|
79
|
+
>>> with EasyContract(api_key="ec_test_...") as client:
|
|
80
|
+
... client.whoami()
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
api_key: str,
|
|
86
|
+
*,
|
|
87
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
88
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
89
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
90
|
+
):
|
|
91
|
+
if not api_key:
|
|
92
|
+
raise ValueError("api_key is required")
|
|
93
|
+
self.base_url = base_url.rstrip("/")
|
|
94
|
+
self.timeout = timeout
|
|
95
|
+
self.max_retries = max_retries
|
|
96
|
+
self._session = requests.Session()
|
|
97
|
+
self._session.headers.update(
|
|
98
|
+
{
|
|
99
|
+
"X-Api-Key": api_key,
|
|
100
|
+
"User-Agent": f"easycontract-python/{__version__}",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
self.envelopes = Envelopes(self)
|
|
105
|
+
self.templates = Templates(self)
|
|
106
|
+
self.webhooks = Webhooks(self)
|
|
107
|
+
self.events = Events(self)
|
|
108
|
+
self.organization = Organization(self)
|
|
109
|
+
self.members = Members(self)
|
|
110
|
+
self.invitations = Invitations(self)
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------ core
|
|
113
|
+
|
|
114
|
+
def close(self) -> None:
|
|
115
|
+
"""Close the underlying HTTP session and release its connections."""
|
|
116
|
+
self._session.close()
|
|
117
|
+
|
|
118
|
+
def __enter__(self) -> EasyContract:
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *exc_info) -> None:
|
|
122
|
+
self.close()
|
|
123
|
+
|
|
124
|
+
def request(
|
|
125
|
+
self,
|
|
126
|
+
method: str,
|
|
127
|
+
path: str,
|
|
128
|
+
*,
|
|
129
|
+
json: dict | None = None,
|
|
130
|
+
params: dict | None = None,
|
|
131
|
+
files: dict | None = None,
|
|
132
|
+
data: dict | None = None,
|
|
133
|
+
idempotency_key: str | bool | None = None,
|
|
134
|
+
raw: bool = False,
|
|
135
|
+
):
|
|
136
|
+
url = f"{self.base_url}{path}"
|
|
137
|
+
headers = {}
|
|
138
|
+
if method == "POST" and idempotency_key is not False:
|
|
139
|
+
if idempotency_key is None or idempotency_key is True or idempotency_key == "":
|
|
140
|
+
idempotency_key = str(uuid.uuid4()) # "generate one for me"
|
|
141
|
+
headers["Idempotency-Key"] = str(idempotency_key) # e.g. a uuid.UUID
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
response = self._send_with_retries(
|
|
145
|
+
method, url, json=json, params=params, files=files, data=data, headers=headers
|
|
146
|
+
)
|
|
147
|
+
finally:
|
|
148
|
+
for handle in _file_handles(files):
|
|
149
|
+
with contextlib.suppress(Exception):
|
|
150
|
+
handle.close()
|
|
151
|
+
|
|
152
|
+
if response.status_code >= 400:
|
|
153
|
+
try:
|
|
154
|
+
payload = response.json()
|
|
155
|
+
except ValueError:
|
|
156
|
+
payload = {"title": response.reason, "detail": response.text[:300]}
|
|
157
|
+
retry_after = None
|
|
158
|
+
if response.headers.get("Retry-After") is not None:
|
|
159
|
+
try:
|
|
160
|
+
retry_after = max(0, int(response.headers["Retry-After"]))
|
|
161
|
+
except ValueError:
|
|
162
|
+
retry_after = None
|
|
163
|
+
raise error_from_response(
|
|
164
|
+
response.status_code,
|
|
165
|
+
payload,
|
|
166
|
+
retry_after=retry_after,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if raw:
|
|
170
|
+
return response.content
|
|
171
|
+
if response.status_code == 204 or not response.content:
|
|
172
|
+
return None
|
|
173
|
+
try:
|
|
174
|
+
return response.json()
|
|
175
|
+
except ValueError as exc:
|
|
176
|
+
raise APIError(
|
|
177
|
+
status=response.status_code,
|
|
178
|
+
code="invalid_response",
|
|
179
|
+
title="Non-JSON response",
|
|
180
|
+
detail="The API returned a body that is not valid JSON",
|
|
181
|
+
) from exc
|
|
182
|
+
|
|
183
|
+
def _send_with_retries(self, method: str, url: str, *, headers: dict, **kwargs):
|
|
184
|
+
"""GETs and 429s are retried with backoff (bounded by Retry-After).
|
|
185
|
+
|
|
186
|
+
File uploads are re-wound before every retry so it re-sends the whole
|
|
187
|
+
document; if any upload cannot be rewound the request is not retried.
|
|
188
|
+
"""
|
|
189
|
+
handles = list(_file_handles(kwargs.get("files")))
|
|
190
|
+
attempt = 0
|
|
191
|
+
while True:
|
|
192
|
+
attempt += 1
|
|
193
|
+
response = self._session.request(
|
|
194
|
+
method,
|
|
195
|
+
url,
|
|
196
|
+
headers=headers,
|
|
197
|
+
timeout=self.timeout,
|
|
198
|
+
**kwargs,
|
|
199
|
+
)
|
|
200
|
+
retryable_429 = response.status_code == 429
|
|
201
|
+
retryable_5xx = response.status_code >= 500 and method == "GET"
|
|
202
|
+
if not (retryable_429 or retryable_5xx) or attempt >= self.max_retries:
|
|
203
|
+
return response
|
|
204
|
+
if not all(_rewind(handle) for handle in handles):
|
|
205
|
+
return response
|
|
206
|
+
time.sleep(min(_retry_after_seconds(response), _MAX_RETRY_SLEEP))
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------- shortcuts
|
|
209
|
+
|
|
210
|
+
def whoami(self) -> dict:
|
|
211
|
+
"""GET /whoami/ — identifies the key's organization and mode."""
|
|
212
|
+
return self.request("GET", "/whoami/")
|
easycontract/errors.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Error hierarchy mapped from the API's RFC 7807 responses.
|
|
2
|
+
|
|
3
|
+
Every error carries the stable machine-readable `code` documented in the
|
|
4
|
+
OpenAPI spec, plus `status`, `title` and `detail`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EasyContractError(Exception):
|
|
11
|
+
"""Base for everything raised by this SDK."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIError(EasyContractError):
|
|
15
|
+
def __init__(self, *, status: int, code: str = "", title: str = "", detail: str = ""):
|
|
16
|
+
self.status = status
|
|
17
|
+
self.code = code
|
|
18
|
+
self.title = title
|
|
19
|
+
self.detail = detail
|
|
20
|
+
super().__init__(f"[{status}] {code or title}: {detail or title}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthenticationError(APIError):
|
|
24
|
+
"""401 — missing or invalid API key."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ForbiddenError(APIError):
|
|
28
|
+
"""403 — plan quota/feature or membership rule (see .code)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotFoundError(APIError):
|
|
32
|
+
"""404 — no such resource in this organization (or wrong mode)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InvalidRequestError(APIError):
|
|
36
|
+
"""400 — validation failed (see .detail)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConflictError(APIError):
|
|
40
|
+
"""409 — invalid lifecycle state for the operation."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimitError(APIError):
|
|
44
|
+
"""429 — plan rate limit exceeded. .retry_after seconds when available."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, *, retry_after: int | None = None, **kwargs):
|
|
47
|
+
super().__init__(**kwargs)
|
|
48
|
+
self.retry_after = retry_after
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ServerError(APIError):
|
|
52
|
+
"""5xx — easycontract-side failure."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SignatureVerificationError(EasyContractError):
|
|
56
|
+
"""Webhook payload signature could not be verified."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def error_from_response(status: int, payload, retry_after: int | None = None) -> APIError:
|
|
60
|
+
if not isinstance(payload, dict):
|
|
61
|
+
payload = {"detail": str(payload)[:300]} if payload else {}
|
|
62
|
+
kwargs = {
|
|
63
|
+
"status": status,
|
|
64
|
+
"code": str(payload.get("code") or ""),
|
|
65
|
+
"title": str(payload.get("title") or ""),
|
|
66
|
+
"detail": str(payload.get("detail") or ""),
|
|
67
|
+
}
|
|
68
|
+
if status == 401:
|
|
69
|
+
return AuthenticationError(**kwargs)
|
|
70
|
+
if status == 403:
|
|
71
|
+
return ForbiddenError(**kwargs)
|
|
72
|
+
if status == 404:
|
|
73
|
+
return NotFoundError(**kwargs)
|
|
74
|
+
if status == 409:
|
|
75
|
+
return ConflictError(**kwargs)
|
|
76
|
+
if status == 429:
|
|
77
|
+
return RateLimitError(retry_after=retry_after, **kwargs)
|
|
78
|
+
if status >= 500:
|
|
79
|
+
return ServerError(**kwargs)
|
|
80
|
+
return InvalidRequestError(**kwargs)
|
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""Resource namespaces — a 1:1 mirror of docs/api/openapi.yaml.
|
|
2
|
+
|
|
3
|
+
OPERATIONS maps every spec operation to its SDK method; CI in the main repo
|
|
4
|
+
asserts full coverage, so the SDK cannot drift from the API contract.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .errors import EasyContractError
|
|
13
|
+
|
|
14
|
+
# (HTTP method, spec path) → dotted SDK method. CI-enforced coverage.
|
|
15
|
+
OPERATIONS: dict[tuple[str, str], str] = {
|
|
16
|
+
("GET", "/whoami/"): "client.whoami",
|
|
17
|
+
("GET", "/organization/"): "client.organization.get",
|
|
18
|
+
("GET", "/members/"): "client.members.list",
|
|
19
|
+
("PATCH", "/members/{membership_id}/"): "client.members.change_role",
|
|
20
|
+
("DELETE", "/members/{membership_id}/"): "client.members.remove",
|
|
21
|
+
("GET", "/invitations/"): "client.invitations.list",
|
|
22
|
+
("POST", "/invitations/"): "client.invitations.create",
|
|
23
|
+
("DELETE", "/invitations/{invite_id}/"): "client.invitations.cancel",
|
|
24
|
+
("GET", "/envelopes/"): "client.envelopes.list",
|
|
25
|
+
("POST", "/envelopes/"): "client.envelopes.create",
|
|
26
|
+
("GET", "/envelopes/{envelope_id}/"): "client.envelopes.get",
|
|
27
|
+
("POST", "/envelopes/{envelope_id}/documents/"): "client.envelopes.add_document",
|
|
28
|
+
("POST", "/envelopes/{envelope_id}/signers/"): "client.envelopes.add_signer",
|
|
29
|
+
("POST", "/envelopes/{envelope_id}/send/"): "client.envelopes.send",
|
|
30
|
+
(
|
|
31
|
+
"POST",
|
|
32
|
+
"/envelopes/{envelope_id}/signers/{signer_id}/remind/",
|
|
33
|
+
): "client.envelopes.remind_signer",
|
|
34
|
+
("POST", "/envelopes/{envelope_id}/void/"): "client.envelopes.void",
|
|
35
|
+
("GET", "/envelopes/{envelope_id}/certificate/"): "client.envelopes.download_certificate",
|
|
36
|
+
("GET", "/envelopes/{envelope_id}/evidence/"): "client.envelopes.download_evidence",
|
|
37
|
+
("GET", "/templates/"): "client.templates.list",
|
|
38
|
+
("POST", "/templates/"): "client.templates.create",
|
|
39
|
+
("GET", "/templates/catalog/"): "client.templates.catalog",
|
|
40
|
+
("POST", "/templates/catalog/{catalog_key}/clone/"): "client.templates.clone_from_catalog",
|
|
41
|
+
("GET", "/templates/{template_id}/"): "client.templates.get",
|
|
42
|
+
("PATCH", "/templates/{template_id}/"): "client.templates.update",
|
|
43
|
+
("DELETE", "/templates/{template_id}/"): "client.templates.delete",
|
|
44
|
+
("POST", "/templates/{template_id}/documents/"): "client.templates.add_document",
|
|
45
|
+
(
|
|
46
|
+
"GET",
|
|
47
|
+
"/templates/{template_id}/documents/{document_id}/",
|
|
48
|
+
): "client.templates.get_document",
|
|
49
|
+
(
|
|
50
|
+
"PATCH",
|
|
51
|
+
"/templates/{template_id}/documents/{document_id}/",
|
|
52
|
+
): "client.templates.update_document",
|
|
53
|
+
(
|
|
54
|
+
"DELETE",
|
|
55
|
+
"/templates/{template_id}/documents/{document_id}/",
|
|
56
|
+
): "client.templates.delete_document",
|
|
57
|
+
(
|
|
58
|
+
"GET",
|
|
59
|
+
"/templates/{template_id}/documents/{document_id}/docx/",
|
|
60
|
+
): "client.templates.download_docx",
|
|
61
|
+
(
|
|
62
|
+
"POST",
|
|
63
|
+
"/templates/{template_id}/documents/{document_id}/docx/",
|
|
64
|
+
): "client.templates.upload_docx",
|
|
65
|
+
(
|
|
66
|
+
"GET",
|
|
67
|
+
"/templates/{template_id}/documents/{document_id}/translations/{language}/",
|
|
68
|
+
): "client.templates.get_translation",
|
|
69
|
+
(
|
|
70
|
+
"PUT",
|
|
71
|
+
"/templates/{template_id}/documents/{document_id}/translations/{language}/",
|
|
72
|
+
): "client.templates.put_translation",
|
|
73
|
+
(
|
|
74
|
+
"DELETE",
|
|
75
|
+
"/templates/{template_id}/documents/{document_id}/translations/{language}/",
|
|
76
|
+
): "client.templates.delete_translation",
|
|
77
|
+
("POST", "/templates/{template_id}/preview/"): "client.templates.preview",
|
|
78
|
+
("POST", "/templates/{template_id}/duplicate/"): "client.templates.duplicate",
|
|
79
|
+
("POST", "/templates/{template_id}/roles/"): "client.templates.add_role",
|
|
80
|
+
("POST", "/templates/{template_id}/fields/"): "client.templates.add_field",
|
|
81
|
+
("POST", "/templates/{template_id}/envelopes/"): "client.templates.create_envelope",
|
|
82
|
+
("GET", "/events/"): "client.events.list",
|
|
83
|
+
("GET", "/webhooks/"): "client.webhooks.list",
|
|
84
|
+
("POST", "/webhooks/"): "client.webhooks.create",
|
|
85
|
+
("DELETE", "/webhooks/{endpoint_id}/"): "client.webhooks.delete",
|
|
86
|
+
("POST", "/webhooks/deliveries/{delivery_id}/redeliver/"): "client.webhooks.redeliver",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _file_tuple(file, name: str | None, content_type: str = "application/pdf"):
|
|
94
|
+
"""Accept a path, bytes, or a file-like object."""
|
|
95
|
+
if isinstance(file, (str, Path)):
|
|
96
|
+
path = Path(file)
|
|
97
|
+
return (name or path.name, path.open("rb"), content_type)
|
|
98
|
+
if isinstance(file, bytes):
|
|
99
|
+
return (name or "document.pdf", io.BytesIO(file), content_type)
|
|
100
|
+
return (name or getattr(file, "name", "document.pdf"), file, content_type)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _Resource:
|
|
104
|
+
def __init__(self, client):
|
|
105
|
+
self._client = client
|
|
106
|
+
|
|
107
|
+
def _auto_paging(self, path: str, **params):
|
|
108
|
+
"""Iterate every item across cursor pages. Guards against a server
|
|
109
|
+
that keeps advertising more pages without advancing the cursor
|
|
110
|
+
(would loop forever)."""
|
|
111
|
+
cursor = None
|
|
112
|
+
seen = set()
|
|
113
|
+
while True:
|
|
114
|
+
page = self._client.request(
|
|
115
|
+
"GET", path, params={**params, **({"cursor": cursor} if cursor else {})}
|
|
116
|
+
)
|
|
117
|
+
if not isinstance(page, dict) or not isinstance(page.get("data"), list):
|
|
118
|
+
raise EasyContractError("malformed page: missing 'data' list")
|
|
119
|
+
yield from page["data"]
|
|
120
|
+
if not page.get("has_more"):
|
|
121
|
+
return
|
|
122
|
+
cursor = page.get("next_cursor")
|
|
123
|
+
if cursor is None or cursor in seen:
|
|
124
|
+
raise EasyContractError("pagination cursor did not advance")
|
|
125
|
+
seen.add(cursor)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Envelopes(_Resource):
|
|
129
|
+
def create(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
title: str,
|
|
133
|
+
retention_period_months: int,
|
|
134
|
+
signing_order: str = "parallel",
|
|
135
|
+
metadata: dict | None = None,
|
|
136
|
+
idempotency_key: str | None = None,
|
|
137
|
+
) -> dict:
|
|
138
|
+
return self._client.request(
|
|
139
|
+
"POST",
|
|
140
|
+
"/envelopes/",
|
|
141
|
+
json={
|
|
142
|
+
"title": title,
|
|
143
|
+
"retention_period_months": retention_period_months,
|
|
144
|
+
"signing_order": signing_order,
|
|
145
|
+
"metadata": metadata or {},
|
|
146
|
+
},
|
|
147
|
+
idempotency_key=idempotency_key,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def list(
|
|
151
|
+
self, *, status: str | None = None, limit: int | None = None, cursor: str | None = None
|
|
152
|
+
) -> dict:
|
|
153
|
+
params = {
|
|
154
|
+
k: v
|
|
155
|
+
for k, v in {
|
|
156
|
+
"status": status,
|
|
157
|
+
"limit": limit,
|
|
158
|
+
"cursor": cursor,
|
|
159
|
+
}.items()
|
|
160
|
+
if v is not None
|
|
161
|
+
}
|
|
162
|
+
return self._client.request("GET", "/envelopes/", params=params)
|
|
163
|
+
|
|
164
|
+
def auto_paging_iter(self, *, status: str | None = None):
|
|
165
|
+
params = {"status": status} if status else {}
|
|
166
|
+
return self._auto_paging("/envelopes/", **params)
|
|
167
|
+
|
|
168
|
+
def get(self, envelope_id: str, *, expand: list[str] | None = None) -> dict:
|
|
169
|
+
params = {"expand": ",".join(expand)} if expand else None
|
|
170
|
+
return self._client.request("GET", f"/envelopes/{envelope_id}/", params=params)
|
|
171
|
+
|
|
172
|
+
def add_document(
|
|
173
|
+
self,
|
|
174
|
+
envelope_id: str,
|
|
175
|
+
*,
|
|
176
|
+
file,
|
|
177
|
+
name: str | None = None,
|
|
178
|
+
content_type: str = "application/pdf",
|
|
179
|
+
) -> dict:
|
|
180
|
+
return self._client.request(
|
|
181
|
+
"POST",
|
|
182
|
+
f"/envelopes/{envelope_id}/documents/",
|
|
183
|
+
files={"file": _file_tuple(file, name, content_type)},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def add_signer(
|
|
187
|
+
self,
|
|
188
|
+
envelope_id: str,
|
|
189
|
+
*,
|
|
190
|
+
full_name: str,
|
|
191
|
+
email: str,
|
|
192
|
+
phone: str = "",
|
|
193
|
+
order: int | None = None,
|
|
194
|
+
auth_methods: list[str] | None = None,
|
|
195
|
+
idempotency_key: str | None = None,
|
|
196
|
+
) -> dict:
|
|
197
|
+
body = {"full_name": full_name, "email": email, "phone": phone}
|
|
198
|
+
if order is not None:
|
|
199
|
+
body["order"] = order
|
|
200
|
+
if auth_methods is not None:
|
|
201
|
+
body["auth_methods"] = auth_methods
|
|
202
|
+
return self._client.request(
|
|
203
|
+
"POST",
|
|
204
|
+
f"/envelopes/{envelope_id}/signers/",
|
|
205
|
+
json=body,
|
|
206
|
+
idempotency_key=idempotency_key,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def send(
|
|
210
|
+
self,
|
|
211
|
+
envelope_id: str,
|
|
212
|
+
*,
|
|
213
|
+
expires_in_days: int | None = None,
|
|
214
|
+
deliver_emails: bool = True,
|
|
215
|
+
idempotency_key: str | None = None,
|
|
216
|
+
) -> dict:
|
|
217
|
+
body: dict = {"deliver_emails": deliver_emails}
|
|
218
|
+
if expires_in_days is not None:
|
|
219
|
+
body["expires_in_days"] = expires_in_days
|
|
220
|
+
return self._client.request(
|
|
221
|
+
"POST",
|
|
222
|
+
f"/envelopes/{envelope_id}/send/",
|
|
223
|
+
json=body,
|
|
224
|
+
idempotency_key=idempotency_key,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def remind_signer(self, envelope_id: str, signer_id: str) -> dict:
|
|
228
|
+
"""Re-send the signing link (a FRESH one; previous links die)."""
|
|
229
|
+
return self._client.request("POST", f"/envelopes/{envelope_id}/signers/{signer_id}/remind/")
|
|
230
|
+
|
|
231
|
+
def void(self, envelope_id: str, *, reason: str = "") -> dict:
|
|
232
|
+
return self._client.request(
|
|
233
|
+
"POST", f"/envelopes/{envelope_id}/void/", json={"reason": reason}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def download_certificate(self, envelope_id: str, *, to: str | Path | None = None) -> bytes:
|
|
237
|
+
content = self._client.request("GET", f"/envelopes/{envelope_id}/certificate/", raw=True)
|
|
238
|
+
if to is not None:
|
|
239
|
+
Path(to).write_bytes(content)
|
|
240
|
+
return content
|
|
241
|
+
|
|
242
|
+
def download_evidence(self, envelope_id: str, *, to: str | Path | None = None) -> bytes:
|
|
243
|
+
"""The full evidence ZIP — what a tenant would take to court."""
|
|
244
|
+
content = self._client.request("GET", f"/envelopes/{envelope_id}/evidence/", raw=True)
|
|
245
|
+
if to is not None:
|
|
246
|
+
Path(to).write_bytes(content)
|
|
247
|
+
return content
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class Templates(_Resource):
|
|
251
|
+
def create(
|
|
252
|
+
self,
|
|
253
|
+
*,
|
|
254
|
+
name: str,
|
|
255
|
+
retention_period_months: int,
|
|
256
|
+
description: str = "",
|
|
257
|
+
signing_order: str = "parallel",
|
|
258
|
+
metadata: dict | None = None,
|
|
259
|
+
variables: list[dict] | None = None,
|
|
260
|
+
idempotency_key: str | None = None,
|
|
261
|
+
) -> dict:
|
|
262
|
+
return self._client.request(
|
|
263
|
+
"POST",
|
|
264
|
+
"/templates/",
|
|
265
|
+
json={
|
|
266
|
+
"name": name,
|
|
267
|
+
"retention_period_months": retention_period_months,
|
|
268
|
+
"description": description,
|
|
269
|
+
"signing_order": signing_order,
|
|
270
|
+
"metadata": metadata or {},
|
|
271
|
+
"variables": variables or [],
|
|
272
|
+
},
|
|
273
|
+
idempotency_key=idempotency_key,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def update(self, template_id: str, **changes) -> dict:
|
|
277
|
+
"""PATCH settings: name, description, signing_order,
|
|
278
|
+
retention_period_months, variables (schema list), metadata."""
|
|
279
|
+
return self._client.request("PATCH", f"/templates/{template_id}/", json=changes)
|
|
280
|
+
|
|
281
|
+
def catalog(self, *, language: str | None = None) -> dict:
|
|
282
|
+
"""Starter templates shipped with the platform (house drafts)."""
|
|
283
|
+
params = {"language": language} if language else None
|
|
284
|
+
return self._client.request("GET", "/templates/catalog/", params=params)
|
|
285
|
+
|
|
286
|
+
def clone_from_catalog(
|
|
287
|
+
self, catalog_key: str, *, name: str = "", idempotency_key: str | None = None
|
|
288
|
+
) -> dict:
|
|
289
|
+
return self._client.request(
|
|
290
|
+
"POST",
|
|
291
|
+
f"/templates/catalog/{catalog_key}/clone/",
|
|
292
|
+
json={"name": name},
|
|
293
|
+
idempotency_key=idempotency_key,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def duplicate(
|
|
297
|
+
self, template_id: str, *, name: str = "", idempotency_key: str | None = None
|
|
298
|
+
) -> dict:
|
|
299
|
+
return self._client.request(
|
|
300
|
+
"POST",
|
|
301
|
+
f"/templates/{template_id}/duplicate/",
|
|
302
|
+
json={"name": name},
|
|
303
|
+
idempotency_key=idempotency_key,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def add_authored_document(
|
|
307
|
+
self,
|
|
308
|
+
template_id: str,
|
|
309
|
+
*,
|
|
310
|
+
name: str,
|
|
311
|
+
body_html: str,
|
|
312
|
+
header_text: str | None = None,
|
|
313
|
+
footer_text: str | None = None,
|
|
314
|
+
page_numbers: bool | None = None,
|
|
315
|
+
) -> dict:
|
|
316
|
+
"""Add an AUTHORED document (rich-text HTML with {{ tokens }}) that is
|
|
317
|
+
rendered to PDF at instantiation; signature fields are the
|
|
318
|
+
{{ signature:role }} / {{ date:role }} tokens in the text. Page
|
|
319
|
+
header/footer are plain text with tokens ({{ page }}/{{ pages }} too)."""
|
|
320
|
+
body: dict = {"name": name, "body_html": body_html}
|
|
321
|
+
for key, value in (
|
|
322
|
+
("header_text", header_text),
|
|
323
|
+
("footer_text", footer_text),
|
|
324
|
+
("page_numbers", page_numbers),
|
|
325
|
+
):
|
|
326
|
+
if value is not None:
|
|
327
|
+
body[key] = value
|
|
328
|
+
return self._client.request("POST", f"/templates/{template_id}/documents/", json=body)
|
|
329
|
+
|
|
330
|
+
def get_document(self, template_id: str, document_id: str) -> dict:
|
|
331
|
+
return self._client.request("GET", f"/templates/{template_id}/documents/{document_id}/")
|
|
332
|
+
|
|
333
|
+
def update_document(
|
|
334
|
+
self,
|
|
335
|
+
template_id: str,
|
|
336
|
+
document_id: str,
|
|
337
|
+
*,
|
|
338
|
+
name: str | None = None,
|
|
339
|
+
body_html: str | None = None,
|
|
340
|
+
header_text: str | None = None,
|
|
341
|
+
footer_text: str | None = None,
|
|
342
|
+
page_numbers: bool | None = None,
|
|
343
|
+
) -> dict:
|
|
344
|
+
body = {
|
|
345
|
+
k: v
|
|
346
|
+
for k, v in {
|
|
347
|
+
"name": name,
|
|
348
|
+
"body_html": body_html,
|
|
349
|
+
"header_text": header_text,
|
|
350
|
+
"footer_text": footer_text,
|
|
351
|
+
"page_numbers": page_numbers,
|
|
352
|
+
}.items()
|
|
353
|
+
if v is not None
|
|
354
|
+
}
|
|
355
|
+
return self._client.request(
|
|
356
|
+
"PATCH", f"/templates/{template_id}/documents/{document_id}/", json=body
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def delete_document(self, template_id: str, document_id: str) -> None:
|
|
360
|
+
self._client.request("DELETE", f"/templates/{template_id}/documents/{document_id}/")
|
|
361
|
+
|
|
362
|
+
def get_translation(self, template_id: str, document_id: str, language: str) -> dict:
|
|
363
|
+
return self._client.request(
|
|
364
|
+
"GET", f"/templates/{template_id}/documents/{document_id}/translations/{language}/"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def put_translation(
|
|
368
|
+
self,
|
|
369
|
+
template_id: str,
|
|
370
|
+
document_id: str,
|
|
371
|
+
language: str,
|
|
372
|
+
*,
|
|
373
|
+
body_html: str | None = None,
|
|
374
|
+
header_text: str | None = None,
|
|
375
|
+
footer_text: str | None = None,
|
|
376
|
+
) -> dict:
|
|
377
|
+
"""Create or replace a language version (always a DRAFT). Approval is
|
|
378
|
+
a human act done in the panel; only approved, fresh translations can
|
|
379
|
+
be used when instantiating (`language`)."""
|
|
380
|
+
body = {
|
|
381
|
+
k: v
|
|
382
|
+
for k, v in {
|
|
383
|
+
"body_html": body_html,
|
|
384
|
+
"header_text": header_text,
|
|
385
|
+
"footer_text": footer_text,
|
|
386
|
+
}.items()
|
|
387
|
+
if v is not None
|
|
388
|
+
}
|
|
389
|
+
return self._client.request(
|
|
390
|
+
"PUT",
|
|
391
|
+
f"/templates/{template_id}/documents/{document_id}/translations/{language}/",
|
|
392
|
+
json=body,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def delete_translation(self, template_id: str, document_id: str, language: str) -> None:
|
|
396
|
+
self._client.request(
|
|
397
|
+
"DELETE",
|
|
398
|
+
f"/templates/{template_id}/documents/{document_id}/translations/{language}/",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def download_docx(
|
|
402
|
+
self, template_id: str, document_id: str, *, to: str | Path | None = None
|
|
403
|
+
) -> bytes:
|
|
404
|
+
"""Editable DOCX of an authored document (edit in Word, upload back)."""
|
|
405
|
+
content = self._client.request(
|
|
406
|
+
"GET", f"/templates/{template_id}/documents/{document_id}/docx/", raw=True
|
|
407
|
+
)
|
|
408
|
+
if to is not None:
|
|
409
|
+
Path(to).write_bytes(content)
|
|
410
|
+
return content
|
|
411
|
+
|
|
412
|
+
def upload_docx(
|
|
413
|
+
self, template_id: str, document_id: str, *, file, name: str | None = None
|
|
414
|
+
) -> dict:
|
|
415
|
+
"""Replace an authored document's text with the uploaded DOCX."""
|
|
416
|
+
return self._client.request(
|
|
417
|
+
"POST",
|
|
418
|
+
f"/templates/{template_id}/documents/{document_id}/docx/",
|
|
419
|
+
files={"file": _file_tuple(file, name or "document.docx", DOCX_MIME)},
|
|
420
|
+
data={"name": name} if name else None,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def preview(
|
|
424
|
+
self,
|
|
425
|
+
template_id: str,
|
|
426
|
+
*,
|
|
427
|
+
document_id: str | None = None,
|
|
428
|
+
variables: dict | None = None,
|
|
429
|
+
signers: dict[str, dict] | None = None,
|
|
430
|
+
title: str = "",
|
|
431
|
+
language: str | None = None,
|
|
432
|
+
to: str | Path | None = None,
|
|
433
|
+
) -> bytes:
|
|
434
|
+
"""PDF preview of the authored documents (sample values where
|
|
435
|
+
none are given). Nothing is persisted."""
|
|
436
|
+
body: dict = {"variables": variables or {}, "signers": signers or {}, "title": title}
|
|
437
|
+
if document_id:
|
|
438
|
+
body["document_id"] = document_id
|
|
439
|
+
if language:
|
|
440
|
+
body["language"] = language
|
|
441
|
+
content = self._client.request(
|
|
442
|
+
"POST", f"/templates/{template_id}/preview/", json=body, raw=True
|
|
443
|
+
)
|
|
444
|
+
if to is not None:
|
|
445
|
+
Path(to).write_bytes(content)
|
|
446
|
+
return content
|
|
447
|
+
|
|
448
|
+
def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict:
|
|
449
|
+
params = {k: v for k, v in {"limit": limit, "cursor": cursor}.items() if v}
|
|
450
|
+
return self._client.request("GET", "/templates/", params=params)
|
|
451
|
+
|
|
452
|
+
def get(self, template_id: str, *, expand: list[str] | None = None) -> dict:
|
|
453
|
+
params = {"expand": ",".join(expand)} if expand else None
|
|
454
|
+
return self._client.request("GET", f"/templates/{template_id}/", params=params)
|
|
455
|
+
|
|
456
|
+
def delete(self, template_id: str) -> None:
|
|
457
|
+
self._client.request("DELETE", f"/templates/{template_id}/")
|
|
458
|
+
|
|
459
|
+
def add_document(
|
|
460
|
+
self,
|
|
461
|
+
template_id: str,
|
|
462
|
+
*,
|
|
463
|
+
file,
|
|
464
|
+
name: str | None = None,
|
|
465
|
+
content_type: str = "application/pdf",
|
|
466
|
+
) -> dict:
|
|
467
|
+
return self._client.request(
|
|
468
|
+
"POST",
|
|
469
|
+
f"/templates/{template_id}/documents/",
|
|
470
|
+
files={"file": _file_tuple(file, name, content_type)},
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def add_role(
|
|
474
|
+
self,
|
|
475
|
+
template_id: str,
|
|
476
|
+
*,
|
|
477
|
+
key: str,
|
|
478
|
+
label: str = "",
|
|
479
|
+
order: int | None = None,
|
|
480
|
+
auth_methods: list[str] | None = None,
|
|
481
|
+
) -> dict:
|
|
482
|
+
body: dict = {"key": key, "label": label}
|
|
483
|
+
if order is not None:
|
|
484
|
+
body["order"] = order
|
|
485
|
+
if auth_methods is not None:
|
|
486
|
+
body["auth_methods"] = auth_methods
|
|
487
|
+
return self._client.request("POST", f"/templates/{template_id}/roles/", json=body)
|
|
488
|
+
|
|
489
|
+
def add_field(
|
|
490
|
+
self,
|
|
491
|
+
template_id: str,
|
|
492
|
+
*,
|
|
493
|
+
document_id: str,
|
|
494
|
+
role: str,
|
|
495
|
+
kind: str = "signature",
|
|
496
|
+
page: int = 1,
|
|
497
|
+
x: float = 0,
|
|
498
|
+
y: float = 0,
|
|
499
|
+
width: float = 160,
|
|
500
|
+
height: float = 50,
|
|
501
|
+
anchor_text: str = "",
|
|
502
|
+
anchor_offset_x: float = 0,
|
|
503
|
+
anchor_offset_y: float = -55,
|
|
504
|
+
) -> dict:
|
|
505
|
+
return self._client.request(
|
|
506
|
+
"POST",
|
|
507
|
+
f"/templates/{template_id}/fields/",
|
|
508
|
+
json={
|
|
509
|
+
"document_id": document_id,
|
|
510
|
+
"role": role,
|
|
511
|
+
"kind": kind,
|
|
512
|
+
"page": page,
|
|
513
|
+
"x": x,
|
|
514
|
+
"y": y,
|
|
515
|
+
"width": width,
|
|
516
|
+
"height": height,
|
|
517
|
+
"anchor_text": anchor_text,
|
|
518
|
+
"anchor_offset_x": anchor_offset_x,
|
|
519
|
+
"anchor_offset_y": anchor_offset_y,
|
|
520
|
+
},
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
def create_envelope(
|
|
524
|
+
self,
|
|
525
|
+
template_id: str,
|
|
526
|
+
*,
|
|
527
|
+
signers: dict[str, dict],
|
|
528
|
+
title: str = "",
|
|
529
|
+
metadata: dict | None = None,
|
|
530
|
+
variables: dict | None = None,
|
|
531
|
+
send: bool = False,
|
|
532
|
+
expires_in_days: int | None = None,
|
|
533
|
+
deliver_emails: bool = True,
|
|
534
|
+
language: str | None = None,
|
|
535
|
+
idempotency_key: str | None = None,
|
|
536
|
+
) -> dict:
|
|
537
|
+
"""The one-call automation flow (§10.4): instantiate and optionally
|
|
538
|
+
send, getting each signer's sign_url back for embedded flows.
|
|
539
|
+
`variables` fills the tokens of authored documents; `language` picks
|
|
540
|
+
the source or an approved translation."""
|
|
541
|
+
body: dict = {
|
|
542
|
+
"signers": signers,
|
|
543
|
+
"title": title,
|
|
544
|
+
"metadata": metadata or {},
|
|
545
|
+
"variables": variables or {},
|
|
546
|
+
"send": send,
|
|
547
|
+
"deliver_emails": deliver_emails,
|
|
548
|
+
}
|
|
549
|
+
if language is not None:
|
|
550
|
+
body["language"] = language
|
|
551
|
+
if expires_in_days is not None:
|
|
552
|
+
body["expires_in_days"] = expires_in_days
|
|
553
|
+
return self._client.request(
|
|
554
|
+
"POST",
|
|
555
|
+
f"/templates/{template_id}/envelopes/",
|
|
556
|
+
json=body,
|
|
557
|
+
idempotency_key=idempotency_key,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class Webhooks(_Resource):
|
|
562
|
+
def create(
|
|
563
|
+
self, *, url: str, event_types: list[str] | None = None, description: str = ""
|
|
564
|
+
) -> dict:
|
|
565
|
+
"""The response includes `secret` (whsec_…) ONCE — store it now."""
|
|
566
|
+
return self._client.request(
|
|
567
|
+
"POST",
|
|
568
|
+
"/webhooks/",
|
|
569
|
+
json={"url": url, "event_types": event_types or [], "description": description},
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict:
|
|
573
|
+
params = {k: v for k, v in {"limit": limit, "cursor": cursor}.items() if v}
|
|
574
|
+
return self._client.request("GET", "/webhooks/", params=params)
|
|
575
|
+
|
|
576
|
+
def delete(self, endpoint_id: str) -> None:
|
|
577
|
+
self._client.request("DELETE", f"/webhooks/{endpoint_id}/")
|
|
578
|
+
|
|
579
|
+
def redeliver(self, delivery_id: str) -> dict:
|
|
580
|
+
return self._client.request("POST", f"/webhooks/deliveries/{delivery_id}/redeliver/")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
class Events(_Resource):
|
|
584
|
+
"""The consultable event log — reconciliation source when a webhook is
|
|
585
|
+
lost (poll `list` and compare against what you received)."""
|
|
586
|
+
|
|
587
|
+
def list(
|
|
588
|
+
self,
|
|
589
|
+
*,
|
|
590
|
+
type: str | None = None,
|
|
591
|
+
envelope_id: str | None = None,
|
|
592
|
+
limit: int | None = None,
|
|
593
|
+
cursor: str | None = None,
|
|
594
|
+
) -> dict:
|
|
595
|
+
params = {
|
|
596
|
+
k: v
|
|
597
|
+
for k, v in {
|
|
598
|
+
"type": type,
|
|
599
|
+
"envelope_id": envelope_id,
|
|
600
|
+
"limit": limit,
|
|
601
|
+
"cursor": cursor,
|
|
602
|
+
}.items()
|
|
603
|
+
if v is not None
|
|
604
|
+
}
|
|
605
|
+
return self._client.request("GET", "/events/", params=params)
|
|
606
|
+
|
|
607
|
+
def auto_paging_iter(self, *, type: str | None = None):
|
|
608
|
+
params = {"type": type} if type else {}
|
|
609
|
+
return self._auto_paging("/events/", **params)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class Organization(_Resource):
|
|
613
|
+
def get(self) -> dict:
|
|
614
|
+
"""Org info including plan limits and features."""
|
|
615
|
+
return self._client.request("GET", "/organization/")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
class Members(_Resource):
|
|
619
|
+
def list(self) -> dict:
|
|
620
|
+
return self._client.request("GET", "/members/")
|
|
621
|
+
|
|
622
|
+
def change_role(self, membership_id: str, *, role: str) -> dict:
|
|
623
|
+
return self._client.request("PATCH", f"/members/{membership_id}/", json={"role": role})
|
|
624
|
+
|
|
625
|
+
def remove(self, membership_id: str) -> None:
|
|
626
|
+
self._client.request("DELETE", f"/members/{membership_id}/")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class Invitations(_Resource):
|
|
630
|
+
def create(self, *, email: str, role: str = "member", deliver_email: bool = True) -> dict:
|
|
631
|
+
"""The response includes `accept_url` ONCE, at creation."""
|
|
632
|
+
return self._client.request(
|
|
633
|
+
"POST",
|
|
634
|
+
"/invitations/",
|
|
635
|
+
json={"email": email, "role": role, "deliver_email": deliver_email},
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
def list(
|
|
639
|
+
self, *, status: str | None = None, limit: int | None = None, cursor: str | None = None
|
|
640
|
+
) -> dict:
|
|
641
|
+
params = {
|
|
642
|
+
k: v
|
|
643
|
+
for k, v in {
|
|
644
|
+
"status": status,
|
|
645
|
+
"limit": limit,
|
|
646
|
+
"cursor": cursor,
|
|
647
|
+
}.items()
|
|
648
|
+
if v is not None
|
|
649
|
+
}
|
|
650
|
+
return self._client.request("GET", "/invitations/", params=params)
|
|
651
|
+
|
|
652
|
+
def cancel(self, invite_id: str) -> None:
|
|
653
|
+
self._client.request("DELETE", f"/invitations/{invite_id}/")
|
easycontract/webhooks.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Receiver-side webhook signature verification.
|
|
2
|
+
|
|
3
|
+
easycontract signs every delivery with
|
|
4
|
+
|
|
5
|
+
X-EasyContract-Signature: t=<unix>,v1=<hex>
|
|
6
|
+
|
|
7
|
+
where v1 = HMAC-SHA256(secret, f"{t}.{body}"). Verify with your endpoint's
|
|
8
|
+
whsec_… secret (shown once at creation):
|
|
9
|
+
|
|
10
|
+
event = easycontract.webhooks.verify_signature(
|
|
11
|
+
payload=request.body, header=request.headers["X-EasyContract-Signature"],
|
|
12
|
+
secret=WEBHOOK_SECRET,
|
|
13
|
+
)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac
|
|
20
|
+
import json
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from .errors import SignatureVerificationError
|
|
24
|
+
|
|
25
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def verify_signature(
|
|
29
|
+
*,
|
|
30
|
+
payload: bytes | str,
|
|
31
|
+
header: str,
|
|
32
|
+
secret: bytes | str,
|
|
33
|
+
tolerance: int | None = DEFAULT_TOLERANCE_SECONDS,
|
|
34
|
+
) -> dict:
|
|
35
|
+
"""Verify a delivery and return the parsed event. Raises
|
|
36
|
+
SignatureVerificationError on ANY problem (never a raw parse/decode
|
|
37
|
+
exception).
|
|
38
|
+
|
|
39
|
+
`tolerance` is the allowed clock skew in seconds. Pass None to skip the
|
|
40
|
+
timestamp check entirely (not recommended: it disables replay
|
|
41
|
+
protection).
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(header, str):
|
|
44
|
+
raise SignatureVerificationError("malformed signature header")
|
|
45
|
+
|
|
46
|
+
# Verify over the RAW bytes: nothing is decoded or parsed before the HMAC
|
|
47
|
+
# check passes, so hostile input can only ever produce our own exception.
|
|
48
|
+
if isinstance(payload, (bytes, bytearray)):
|
|
49
|
+
body = bytes(payload)
|
|
50
|
+
elif isinstance(payload, str):
|
|
51
|
+
body = payload.encode("utf-8")
|
|
52
|
+
else:
|
|
53
|
+
raise SignatureVerificationError("payload must be bytes or str")
|
|
54
|
+
secret_bytes = secret if isinstance(secret, (bytes, bytearray)) else str(secret).encode("utf-8")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
parts = dict(part.split("=", 1) for part in header.split(","))
|
|
58
|
+
timestamp = int(parts["t"])
|
|
59
|
+
signature = parts["v1"]
|
|
60
|
+
except (ValueError, KeyError) as exc:
|
|
61
|
+
raise SignatureVerificationError("malformed signature header") from exc
|
|
62
|
+
|
|
63
|
+
if tolerance is not None and abs(time.time() - timestamp) > tolerance:
|
|
64
|
+
raise SignatureVerificationError("timestamp outside tolerance (possible replay)")
|
|
65
|
+
|
|
66
|
+
expected = hmac.new(secret_bytes, b"%d." % timestamp + body, hashlib.sha256).hexdigest()
|
|
67
|
+
if not hmac.compare_digest(expected.encode(), signature.encode()):
|
|
68
|
+
raise SignatureVerificationError("signature mismatch")
|
|
69
|
+
try:
|
|
70
|
+
return json.loads(body.decode("utf-8"))
|
|
71
|
+
except ValueError as exc: # UnicodeDecodeError is a ValueError too
|
|
72
|
+
raise SignatureVerificationError("payload is not valid JSON") from exc
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: easycontract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK oficial de Python para el API de easycontract (firma electrónica eIDAS)
|
|
5
|
+
Project-URL: Documentation, https://easycontract.online/docs/api
|
|
6
|
+
Author-email: Román Ramírez <roman@easycontract.online>
|
|
7
|
+
License-Expression: LicenseRef-easycontract-proprietary
|
|
8
|
+
License-File: LICENSE.txt
|
|
9
|
+
Keywords: audit-trail,contract,e-signature,eidas,esign,webhooks
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: requests<3,>=2.31
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# easycontract — SDK de Python
|
|
25
|
+
|
|
26
|
+
SDK oficial del API de firma electrónica de easycontract (SES/AdES bajo eIDAS,
|
|
27
|
+
con paquete de evidencias defendible en litigio).
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install easycontract
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from easycontract import EasyContract
|
|
35
|
+
|
|
36
|
+
client = EasyContract(api_key="ec_test_...") # ec_live_... en producción
|
|
37
|
+
print(client.whoami())
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- **Modo test de primera clase**: una clave `ec_test_…` opera sobre datos
|
|
41
|
+
aislados (TSA/PAdES simulados). Nadie debería firmar un documento real para
|
|
42
|
+
probar la integración.
|
|
43
|
+
- **Idempotencia automática**: todo POST lleva `Idempotency-Key` (uuid4);
|
|
44
|
+
pásala tú misma para reintentos seguros de tu lado.
|
|
45
|
+
- **Reintentos**: 429 (respetando `Retry-After`) y 5xx en GETs.
|
|
46
|
+
- **Errores tipados** con el `code` estable del API:
|
|
47
|
+
`AuthenticationError`, `RateLimitError`, `ForbiddenError` (cuota/feature/
|
|
48
|
+
regla de membresía), `InvalidRequestError`, `NotFoundError`,
|
|
49
|
+
`ConflictError`, `ServerError`.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Receta 1 — Enviar un envelope desde plantilla (el flujo RootedCON)
|
|
54
|
+
|
|
55
|
+
Una sola llamada: cubre los roles, envía, y recibe los enlaces de firma.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
result = client.templates.create_envelope(
|
|
59
|
+
template_id,
|
|
60
|
+
signers={"ponente": {"full_name": "Ada Lovelace", "email": "ada@example.com"}},
|
|
61
|
+
title="Contrato ponencia RootedCON 2027 — Ada Lovelace",
|
|
62
|
+
send=True, # congela hashes, plan y retención, y acuña enlaces
|
|
63
|
+
deliver_emails=True, # easycontract envía los emails con los enlaces
|
|
64
|
+
)
|
|
65
|
+
envelope_id = result["envelope"]["id"]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Creación de la plantilla (una vez):
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
template = client.templates.create(name="Contrato ponente", retention_period_months=72)
|
|
72
|
+
doc = client.templates.add_document(template["id"], file="contrato-ponente.pdf")
|
|
73
|
+
client.templates.add_role(template["id"], key="ponente", label="Ponente")
|
|
74
|
+
client.templates.add_field(
|
|
75
|
+
template["id"],
|
|
76
|
+
document_id=doc["id"],
|
|
77
|
+
role="ponente",
|
|
78
|
+
anchor_text="Fdo. el ponente:", # la firma se estampa donde el PDF lo dice
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Receta 2 — Firma embebida (embedded signing)
|
|
83
|
+
|
|
84
|
+
Con `deliver_emails=False` recibes el `sign_url` de un solo uso por firmante y
|
|
85
|
+
lo integras en tu propia aplicación (redirect o iframe):
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
result = client.templates.create_envelope(
|
|
89
|
+
template_id,
|
|
90
|
+
signers={...},
|
|
91
|
+
send=True,
|
|
92
|
+
deliver_emails=False,
|
|
93
|
+
)
|
|
94
|
+
sign_url = result["signers"][0]["sign_url"]
|
|
95
|
+
# → redirige al ponente a sign_url sin salir de tu flujo
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
El enlace es personal, caduca (14 días por defecto, `expires_in_days` para
|
|
99
|
+
cambiarlo) y queda invalidado al firmar o rechazar.
|
|
100
|
+
|
|
101
|
+
## Receta 3 — Webhooks vs. polling
|
|
102
|
+
|
|
103
|
+
**Webhooks** (recomendado): registra un endpoint y verifica CADA entrega.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
endpoint = client.webhooks.create(url="https://miapp.com/hooks/easycontract")
|
|
107
|
+
WEBHOOK_SECRET = endpoint["secret"] # whsec_… — se muestra UNA sola vez
|
|
108
|
+
|
|
109
|
+
# En tu receptor (Django/Flask/FastAPI):
|
|
110
|
+
from easycontract import webhooks
|
|
111
|
+
|
|
112
|
+
event = webhooks.verify_signature(
|
|
113
|
+
payload=request.body,
|
|
114
|
+
header=request.headers["X-EasyContract-Signature"],
|
|
115
|
+
secret=WEBHOOK_SECRET,
|
|
116
|
+
)
|
|
117
|
+
if event["type"] == "envelope.completed":
|
|
118
|
+
...
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Reconciliación / polling**: los webhooks son la proyección de un event log
|
|
122
|
+
consultable; si pierdes una entrega, reconcilia:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
for event in client.events.auto_paging_iter(type="envelope.completed"):
|
|
126
|
+
...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Receta 4 — Descargar el paquete de evidencias
|
|
130
|
+
|
|
131
|
+
El ZIP completo (documentos, certificado, audit trail JSON verificable
|
|
132
|
+
offline, sellos TSA) — lo que un tenant llevaría a un juzgado:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
client.envelopes.download_evidence(envelope_id, to="evidencias.zip")
|
|
136
|
+
client.envelopes.download_certificate(envelope_id, to="certificado.pdf")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Receta 5 — Gestión de plantillas
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
for template in client.templates.list()["data"]:
|
|
143
|
+
print(template["name"])
|
|
144
|
+
|
|
145
|
+
detail = client.templates.get(template_id, expand=["documents", "roles", "fields"])
|
|
146
|
+
client.templates.delete(old_template_id)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Envelope manual (sin plantilla)
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
envelope = client.envelopes.create(
|
|
155
|
+
title="NDA",
|
|
156
|
+
retention_period_months=60, # retención SIEMPRE explícita
|
|
157
|
+
)
|
|
158
|
+
client.envelopes.add_document(envelope["id"], file="nda.pdf")
|
|
159
|
+
client.envelopes.add_signer(
|
|
160
|
+
envelope["id"],
|
|
161
|
+
full_name="Grace Hopper",
|
|
162
|
+
email="grace@example.com",
|
|
163
|
+
auth_methods=["email_link", "sms_otp"],
|
|
164
|
+
phone="+34600111222", # OTP: plan Pro+
|
|
165
|
+
)
|
|
166
|
+
sent = client.envelopes.send(envelope["id"])
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Organización, miembros e invitaciones
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
org = client.organization.get() # plan, límites y features del plan
|
|
173
|
+
members = client.members.list()
|
|
174
|
+
invite = client.invitations.create(email="colega@example.com", role="member")
|
|
175
|
+
# invite["accept_url"] — entrégalo tú o deja que easycontract lo envíe por email
|
|
176
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
easycontract/__init__.py,sha256=QUYWwECa8TWCBaNQXg1LWZIiLG4TZkUZJqg_TWe__Ds,835
|
|
2
|
+
easycontract/client.py,sha256=geTUooai3vrsLCBKOJFi_AuPCI1MwmsTo04EtFqTUXM,7102
|
|
3
|
+
easycontract/errors.py,sha256=6YTbJ5hlQKYCFLINznyqT0zlieyjTF82eaYxkOSkomY,2388
|
|
4
|
+
easycontract/resources.py,sha256=Ahl5IhmH1deWturaJxg4Rckf6gXN9oNV1xAfi3ZO5KI,23944
|
|
5
|
+
easycontract/webhooks.py,sha256=f03kjRFJu14eIdKn4iV4p1aYQSX-dOT8XiPaIKeSJLU,2557
|
|
6
|
+
easycontract-0.1.0.dist-info/METADATA,sha256=AmY_Q3w7a0UQSsttbr5V73-DVuyaoV-fptEiNw9wK-s,5740
|
|
7
|
+
easycontract-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
easycontract-0.1.0.dist-info/licenses/LICENSE.txt,sha256=7L_OK7dZHSR7Rw4Q8MmAgBEFl_Gfm51ji4-TNWRXjD0,874
|
|
9
|
+
easycontract-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
easycontract Python SDK
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Román Ramírez — roman@easycontract.online
|
|
4
|
+
|
|
5
|
+
Proprietary. All rights reserved.
|
|
6
|
+
|
|
7
|
+
This software and its documentation are proprietary to easycontract. No
|
|
8
|
+
rights are granted to use, modify, reproduce, distribute, or create
|
|
9
|
+
derivative works of this software, except as expressly agreed in writing
|
|
10
|
+
with easycontract (for example, under an applicable customer agreement).
|
|
11
|
+
|
|
12
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
13
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
14
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
15
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
16
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
17
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
18
|
+
DEALINGS IN THE SOFTWARE.
|