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.
@@ -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}/")
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.