etchv-sdk 1.0.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.
etchv/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """Official Python client for the Etchv forensic watermarking API."""
2
+ from .client import (
3
+ __version__,
4
+ AuthenticationError,
5
+ ConflictError,
6
+ DeadlineExceededError,
7
+ DetectionResult,
8
+ DetectionUnit,
9
+ EmbedResult,
10
+ Etchv,
11
+ EtchvError,
12
+ GoneError,
13
+ NotFoundError,
14
+ PaymentRequiredError,
15
+ PermissionDeniedError,
16
+ RateLimitError,
17
+ WebhookVerificationError,
18
+ verify_webhook,
19
+ )
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ "Etchv",
24
+ "EtchvError",
25
+ "AuthenticationError",
26
+ "PaymentRequiredError",
27
+ "PermissionDeniedError",
28
+ "NotFoundError",
29
+ "ConflictError",
30
+ "GoneError",
31
+ "RateLimitError",
32
+ "DeadlineExceededError",
33
+ "EmbedResult",
34
+ "DetectionResult",
35
+ "DetectionUnit",
36
+ "WebhookVerificationError",
37
+ "verify_webhook",
38
+ ]
etchv/client.py ADDED
@@ -0,0 +1,589 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ from collections.abc import Mapping
8
+ from uuid import uuid4
9
+ import math
10
+ import re
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+ from urllib.parse import urlsplit, urlencode
14
+
15
+ import httpx
16
+
17
+ __version__ = "1.0.0"
18
+ USER_AGENT = f"etchv-python/{__version__}"
19
+
20
+
21
+ class EtchvError(RuntimeError):
22
+ """An HTTP or protocol failure returned by the Etchv API.
23
+
24
+ ``status_code`` is the HTTP status (``0`` when the client deadline passed
25
+ before a durable job finished), ``detail`` is the parsed error body and
26
+ ``request_id`` is the ``X-Request-ID`` (or job ID) to quote to support.
27
+ The message never contains the API key or the response body.
28
+ """
29
+
30
+ def __init__(self, status_code: int, detail: Any, request_id: str | None = None):
31
+ message = f"Etchv request failed (HTTP {status_code})"
32
+ if request_id:
33
+ message += f"; request ID {request_id}"
34
+ super().__init__(message)
35
+ self.status_code = status_code
36
+ self.detail = detail
37
+ self.request_id = request_id
38
+
39
+
40
+ class AuthenticationError(EtchvError):
41
+ """HTTP 401: the API key is missing, invalid, revoked or expired."""
42
+
43
+
44
+ class PaymentRequiredError(EtchvError):
45
+ """HTTP 402: billing or credits are unavailable."""
46
+
47
+
48
+ class PermissionDeniedError(EtchvError):
49
+ """HTTP 403: the key lacks a scope, membership role or plan access."""
50
+
51
+
52
+ class NotFoundError(EtchvError):
53
+ """HTTP 404: the resource does not exist in the key's organization."""
54
+
55
+
56
+ class ConflictError(EtchvError):
57
+ """HTTP 409: idempotency conflict, stale version or invalid state."""
58
+
59
+
60
+ class GoneError(EtchvError):
61
+ """HTTP 410: a saved result or file expired or its asset was deleted.
62
+
63
+ Resubmitting the same idempotency key does not charge again.
64
+ """
65
+
66
+
67
+ class RateLimitError(EtchvError):
68
+ """HTTP 429: too many requests."""
69
+
70
+
71
+ class DeadlineExceededError(EtchvError):
72
+ """The client ``timeout`` passed before a durable job finished (status 0).
73
+
74
+ The job may still complete. ``detail`` contains the ``idempotency_key``
75
+ and ``request_id`` (when known) for recovery.
76
+ """
77
+
78
+
79
+ _ERRORS: dict[int, type[EtchvError]] = {
80
+ 401: AuthenticationError, 402: PaymentRequiredError, 403: PermissionDeniedError,
81
+ 404: NotFoundError, 409: ConflictError, 410: GoneError, 429: RateLimitError,
82
+ }
83
+
84
+
85
+ def _error(status_code: int, detail: Any, request_id: str | None) -> EtchvError:
86
+ return _ERRORS.get(status_code, EtchvError)(status_code, detail, request_id)
87
+
88
+
89
+ class WebhookVerificationError(ValueError):
90
+ """A webhook delivery failed signature, timestamp or payload verification."""
91
+
92
+
93
+ def verify_webhook(payload: bytes, headers: Mapping[str, str], signing_secret: str, *,
94
+ tolerance: int = 300, now: float | None = None) -> dict[str, Any]:
95
+ """Verify an Etchv webhook delivery and return the parsed event.
96
+
97
+ ``payload`` must be the raw request body bytes, before any JSON parsing.
98
+ ``signing_secret`` is the endpoint's full ``whsec_…`` secret. The signature
99
+ is ``v1=`` + hex HMAC-SHA256 of ``timestamp + "." + payload``. Deliveries
100
+ whose ``X-Etchv-Timestamp`` is more than ``tolerance`` seconds from ``now``
101
+ are rejected, and the body ``id`` must match ``X-Etchv-Event-ID``.
102
+
103
+ Raises ``WebhookVerificationError`` on any failure.
104
+ """
105
+ if not isinstance(payload, (bytes, bytearray)):
106
+ raise TypeError("payload must be the raw request body bytes")
107
+ if not isinstance(signing_secret, str) or not signing_secret:
108
+ raise ValueError("signing_secret is required")
109
+ lowered = {str(k).lower(): v for k, v in headers.items()}
110
+ timestamp = lowered.get("x-etchv-timestamp", "")
111
+ signature = lowered.get("x-etchv-signature", "")
112
+ if not isinstance(timestamp, str) or not re.fullmatch(r"\d{1,12}", timestamp):
113
+ raise WebhookVerificationError("Missing or invalid webhook timestamp")
114
+ if abs((time.time() if now is None else now) - int(timestamp)) > tolerance:
115
+ raise WebhookVerificationError("Webhook timestamp is outside the tolerance window")
116
+ expected = "v1=" + hmac.new(signing_secret.encode(), timestamp.encode() + b"." + bytes(payload),
117
+ hashlib.sha256).hexdigest()
118
+ if not isinstance(signature, str) or not hmac.compare_digest(expected.encode(), signature.encode()):
119
+ raise WebhookVerificationError("Invalid webhook signature")
120
+ try:
121
+ event = json.loads(payload)
122
+ except ValueError:
123
+ raise WebhookVerificationError("Webhook body is not valid JSON") from None
124
+ if not isinstance(event, dict) or event.get("id") != lowered.get("x-etchv-event-id"):
125
+ raise WebhookVerificationError("Webhook event ID does not match X-Etchv-Event-ID")
126
+ return event
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class EmbedResult:
131
+ """A verified watermarked file. ``image`` holds native image, PDF or video bytes."""
132
+ image: bytes
133
+ watermark_id: str
134
+ request_id: str | None
135
+ content_type: str = "image/png"
136
+ filename: str = "image-watermarked.png"
137
+ asset_id: str | None = None
138
+ source_asset_id: str | None = None
139
+ storage_delivery_id: str | None = None
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class DetectionUnit:
144
+ """Detection for one frame, page or layered composite."""
145
+ index: int
146
+ watermarked: bool
147
+ confidence: float
148
+ watermark_id: str | None
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class DetectionResult:
153
+ """Detection outcome. ``watermark_id`` is set only when every unit agrees."""
154
+ watermarked: bool
155
+ confidence: float
156
+ watermark_id: str | None
157
+ request_id: str | None
158
+ units: tuple[DetectionUnit, ...] = ()
159
+
160
+
161
+ class Etchv:
162
+ """Synchronous Etchv API client authenticated with an ``X-API-Key``.
163
+
164
+ ``timeout`` (seconds) bounds each call, including automatic polling of
165
+ durable jobs. Use as a context manager or call ``close()``.
166
+ """
167
+
168
+ def __init__(self, api_key: str, *, base_url: str = "https://api.etchv.com",
169
+ timeout: float = 120, transport: httpx.BaseTransport | None = None):
170
+ if not isinstance(api_key, str) or not api_key.strip():
171
+ raise ValueError("api_key is required")
172
+ url = urlsplit(base_url)
173
+ if (not url.hostname or url.username or url.password or url.query or url.fragment
174
+ or (url.scheme != "https" and not (url.scheme == "http" and url.hostname in {"localhost", "127.0.0.1", "::1"}))):
175
+ raise ValueError("base_url must use HTTPS (HTTP is allowed for localhost)")
176
+ if not math.isfinite(timeout) or timeout <= 0:
177
+ raise ValueError("timeout must be positive and finite")
178
+ self._timeout = timeout
179
+ self._client = httpx.Client(base_url=base_url.rstrip("/") + "/", timeout=timeout,
180
+ headers={"X-API-Key": api_key, "User-Agent": USER_AGENT}, transport=transport,
181
+ follow_redirects=False)
182
+
183
+ def close(self) -> None:
184
+ """Close the underlying HTTP connection pool."""
185
+ self._client.close()
186
+
187
+ def __enter__(self) -> Etchv:
188
+ return self
189
+
190
+ def __exit__(self, *args: Any) -> None:
191
+ self.close()
192
+
193
+ def _post(self, path: str, image: bytes, filename: str,
194
+ data: dict[str, str] | None, idempotency_key: str | None) -> httpx.Response:
195
+ if not isinstance(image, bytes) or not image or len(image) > 20 * 1024 * 1024:
196
+ raise ValueError("image must contain 1 byte to 20 MB of encoded image bytes")
197
+ headers = {}
198
+ if idempotency_key is not None:
199
+ headers["Idempotency-Key"] = idempotency_key
200
+ durable = path.split("?")[0].endswith("/async") or path.split("?")[0] in ("watermarks/images", "watermarks/documents", "watermarks/videos", "watermarks/videos/detect")
201
+ if durable and not idempotency_key:
202
+ headers["Idempotency-Key"] = uuid4().hex
203
+ return self._request(path, "POST", durable, headers=headers,
204
+ files={"file": (filename, image, "application/octet-stream")}, data=data)
205
+
206
+ def _request(self, path: str, method: str, durable: bool, *, accept: tuple[int, ...] = (),
207
+ **kwargs: Any) -> httpx.Response:
208
+ async_submission = path.split("?")[0].endswith("/async")
209
+ detection_job = path == "watermarks/videos/detect" or "detection-jobs/" in path
210
+ deadline = time.monotonic() + self._timeout
211
+ match = re.search(r"watermarks/(?:detection-)?jobs/(req_[a-f0-9]{64})", path)
212
+ request_id = match.group(1) if match else None
213
+ idempotency_key = kwargs.get("headers", {}).get("Idempotency-Key")
214
+ def pause(seconds: float = 1) -> None:
215
+ time.sleep(min(seconds, max(0, deadline - time.monotonic())))
216
+ while time.monotonic() < deadline:
217
+ try:
218
+ response = self._client.request(method, path, timeout=max(.001, deadline - time.monotonic()), **kwargs)
219
+ except httpx.TransportError:
220
+ if not durable:
221
+ raise
222
+ pause()
223
+ continue
224
+ request_id = response.headers.get("x-request-id") or request_id
225
+ if (response.status_code in (200, 201, 204) or response.status_code in accept
226
+ or (async_submission and response.status_code == 202)):
227
+ return response
228
+ try:
229
+ detail = response.json()
230
+ except ValueError:
231
+ detail = response.text[:1000]
232
+ if durable and response.status_code == 202:
233
+ if not isinstance(detail, dict) or not re.fullmatch(r"req_[a-f0-9]{64}", str(detail.get("request_id", ""))):
234
+ raise EtchvError(202, "Invalid job response", request_id)
235
+ request_id = detail["request_id"]
236
+ path, method, kwargs = f"watermarks/{'detection-jobs' if detection_job else 'jobs'}/{request_id}/result", "GET", {}
237
+ try:
238
+ delay = float(response.headers.get("retry-after", "1"))
239
+ delay = min(5, max(.01, delay)) if math.isfinite(delay) else 1
240
+ except ValueError:
241
+ delay = 1
242
+ pause(delay)
243
+ continue
244
+ if durable and response.status_code in (429, 502, 503, 504) and not (isinstance(detail, dict) and detail.get("status") == "failed"):
245
+ pause()
246
+ continue
247
+ raise _error(response.status_code, detail, request_id)
248
+ raise DeadlineExceededError(0, {"message": "Client deadline exceeded; the job may still complete", "idempotency_key": idempotency_key, "request_id": request_id}, request_id)
249
+
250
+ def _json(self, path: str, method: str = "GET", *, accept: tuple[int, ...] = (), **kwargs: Any) -> Any:
251
+ response = self._request(path, method, False, accept=accept, **kwargs)
252
+ try:
253
+ return response.json()
254
+ except ValueError:
255
+ raise EtchvError(response.status_code, "Invalid JSON response", response.headers.get("x-request-id")) from None
256
+
257
+ # Account
258
+
259
+ def check_api_key(self) -> dict[str, Any]:
260
+ """Non-billable connection check (``GET /auth/api-key``).
261
+
262
+ Returns ``organization_id``, ``key_id`` and ``scopes``. Requires an
263
+ active key but no particular scope.
264
+ """
265
+ return self._json("auth/api-key")
266
+
267
+ # Async jobs
268
+
269
+ @staticmethod
270
+ def _async_path(media: str, detect: bool, webhook_id: str | None) -> str:
271
+ if media not in ("images", "documents", "videos"):
272
+ raise ValueError("media must be images, documents or videos")
273
+ if webhook_id is not None:
274
+ _check_id(webhook_id, r"wh_[a-f0-9]{32}", "webhook ID")
275
+ return f"watermarks/{media}{'/detect' if detect else ''}/async" + (f"?webhook_id={webhook_id}" if webhook_id else "")
276
+
277
+ def submit_embed(self, media: str, file: bytes, data: dict[str, Any], *, filename: str = "file",
278
+ idempotency_key: str | None = None, webhook_id: str | None = None, storage_destination_id: str | None = None, storage_key: str | None = None) -> dict[str, Any]:
279
+ """Submit a background embedding job and return its 202 JSON receipt without polling.
280
+
281
+ ``media`` is ``"images"``, ``"documents"`` or ``"videos"``. Transient
282
+ failures are retried with the same idempotency key (generated when omitted).
283
+ """
284
+ if not isinstance(data, dict) or not data:
285
+ raise ValueError("data must be a non-empty JSON object")
286
+ return self._post(self._storage_path(self._async_path(media, False, webhook_id), storage_destination_id, storage_key), file, filename,
287
+ {"data": json.dumps(data, allow_nan=False)}, idempotency_key).json()
288
+
289
+ def submit_detection(self, media: str, file: bytes, *, filename: str = "file",
290
+ idempotency_key: str | None = None, webhook_id: str | None = None) -> dict[str, Any]:
291
+ """Submit a background detection job and return its 202 JSON receipt without polling."""
292
+ return self._post(self._async_path(media, True, webhook_id), file, filename, None, idempotency_key).json()
293
+
294
+ def get_job(self, request_id: str, *, detect: bool = False) -> dict[str, Any]:
295
+ """Read a job receipt. Set ``detect=True`` for detection jobs."""
296
+ _check_id(request_id, r"req_[a-f0-9]{64}", "request ID")
297
+ return self._json(f"watermarks/{'detection-jobs' if detect else 'jobs'}/{request_id}")
298
+
299
+ def get_embed_result(self, request_id: str) -> EmbedResult:
300
+ """Wait for and download an embedding job's verified file.
301
+
302
+ Polls while the job is pending (HTTP 202). Raises ``GoneError`` (410)
303
+ when the saved result expired or its asset was deleted.
304
+ """
305
+ _check_id(request_id, r"req_[a-f0-9]{64}", "request ID")
306
+ return self._embedding_result(self._request(f"watermarks/jobs/{request_id}/result", "GET", True))
307
+
308
+ def get_detection_result(self, request_id: str) -> DetectionResult:
309
+ """Wait for and return a detection job's result, polling while it is pending (HTTP 202)."""
310
+ _check_id(request_id, r"req_[a-f0-9]{64}", "request ID")
311
+ return self._detection_result(self._request(f"watermarks/detection-jobs/{request_id}/result", "GET", True))
312
+
313
+ # Assets
314
+
315
+ @staticmethod
316
+ def _asset_path(asset_id: str) -> str:
317
+ _check_id(asset_id, r"ast_[a-f0-9]{64}", "asset ID")
318
+ return f"assets/{asset_id}"
319
+
320
+ def list_assets(self, *, limit: int = 25, cursor: str | None = None,
321
+ kind: str | None = None, media_type: str | None = None,
322
+ watermark_id: str | None = None) -> dict[str, Any]:
323
+ """List assets newest first. Returns ``items`` and ``next_cursor``."""
324
+ params = {k: v for k, v in dict(limit=limit, cursor=cursor, kind=kind,
325
+ media_type=media_type, watermark_id=watermark_id).items() if v is not None}
326
+ return self._json("assets", params=params)
327
+
328
+ def get_asset(self, asset_id: str, *, include_metadata: bool = True) -> dict[str, Any]:
329
+ """Read an asset record."""
330
+ params = {} if include_metadata else {"include_metadata": "false"}
331
+ return self._json(self._asset_path(asset_id), params=params)
332
+
333
+ def update_asset(self, asset_id: str, *, version: int, **changes: Any) -> dict[str, Any]:
334
+ """Rename an asset or replace its ``metadata``. ``version`` must be current (409 otherwise)."""
335
+ return self._json(self._asset_path(asset_id), "PATCH", json={**changes, "version": version})
336
+
337
+ def delete_asset(self, asset_id: str) -> None:
338
+ """Delete an asset (requires ``assets:delete`` and owner/admin membership)."""
339
+ self._request(self._asset_path(asset_id), "DELETE", False)
340
+
341
+ def delete_assets(self, asset_ids: list[str]) -> None:
342
+ """Atomically delete 1–50 assets."""
343
+ if isinstance(asset_ids, str) or not 1 <= len(asset_ids) <= 50:
344
+ raise ValueError("Provide 1–50 asset IDs")
345
+ for identifier in asset_ids:
346
+ self._asset_path(identifier)
347
+ self._request("assets/bulk-delete", "POST", False, json={"asset_ids": list(asset_ids)})
348
+
349
+ def download_asset(self, asset_id: str) -> bytes:
350
+ """Download an asset file in its original format. Raises ``GoneError`` when expired."""
351
+ return self._request(self._asset_path(asset_id) + "/content", "GET", False).content
352
+
353
+ # Webhooks
354
+
355
+ @staticmethod
356
+ def _webhook_path(webhook_id: str) -> str:
357
+ _check_id(webhook_id, r"wh_[a-f0-9]{32}", "webhook ID")
358
+ return f"webhooks/{webhook_id}"
359
+
360
+ def list_webhooks(self) -> list[dict[str, Any]]:
361
+ """List webhook endpoints (``webhooks:read``)."""
362
+ return self._json("webhooks")
363
+
364
+ def create_webhook(self, url: str) -> dict[str, Any]:
365
+ """Create a webhook endpoint for a public HTTPS URL (``webhooks:write``, owner/admin).
366
+
367
+ The response includes a one-time ``signing_secret``; store it securely.
368
+ """
369
+ if not isinstance(url, str) or not url.startswith("https://"):
370
+ raise ValueError("url must be a public HTTPS URL")
371
+ return self._json("webhooks", "POST", json={"url": url})
372
+
373
+ def update_webhook(self, webhook_id: str, *, enabled: bool) -> dict[str, Any]:
374
+ """Enable or disable a webhook endpoint."""
375
+ if type(enabled) is not bool:
376
+ raise ValueError("enabled must be a boolean")
377
+ return self._json(self._webhook_path(webhook_id), "PATCH", json={"enabled": enabled})
378
+
379
+ def delete_webhook(self, webhook_id: str) -> None:
380
+ """Permanently delete a webhook endpoint."""
381
+ self._request(self._webhook_path(webhook_id), "DELETE", False)
382
+
383
+ def list_webhook_deliveries(self, webhook_id: str, *, after: str | None = None) -> dict[str, Any]:
384
+ """List up to 50 deliveries. Returns ``data`` and ``next_cursor`` (pass it as ``after``)."""
385
+ params = {}
386
+ if after is not None:
387
+ _check_id(after, r"evt_[a-f0-9]{64}", "delivery cursor")
388
+ params["after"] = after
389
+ return self._json(self._webhook_path(webhook_id) + "/deliveries", params=params)
390
+
391
+ def redeliver_webhook(self, webhook_id: str, event_id: str) -> dict[str, Any]:
392
+ """Queue a delivered, exhausted or cancelled event for redelivery."""
393
+ _check_id(event_id, r"evt_[a-f0-9]{64}", "event ID")
394
+ return self._json(f"{self._webhook_path(webhook_id)}/deliveries/{event_id}/redeliver", "POST", accept=(202,))
395
+
396
+ # Storage
397
+
398
+ @staticmethod
399
+ def _storage_path(path: str, destination: str | None, key: str | None) -> str:
400
+ if key is not None and destination is None: raise ValueError("storage_key requires storage_destination_id")
401
+ if destination is None: return path
402
+ _check_id(destination, r"dst_[a-f0-9]{32}", "storage destination ID")
403
+ values = {"storage_destination_id": destination}
404
+ if key is not None: values["storage_key"] = key
405
+ return path + ("&" if "?" in path else "?") + urlencode(values)
406
+
407
+ @staticmethod
408
+ def _destination_path(destination_id: str) -> str:
409
+ _check_id(destination_id, r"dst_[a-f0-9]{32}", "storage destination ID")
410
+ return f"storage/destinations/{destination_id}"
411
+
412
+ @staticmethod
413
+ def _delivery_path(delivery_id: str) -> str:
414
+ _check_id(delivery_id, r"std_[a-f0-9]{64}", "storage delivery ID")
415
+ return f"storage/deliveries/{delivery_id}"
416
+
417
+ def list_storage_destinations(self) -> list[dict[str, Any]]:
418
+ """List customer storage destinations (``storage:read``)."""
419
+ return self._json("storage/destinations")
420
+
421
+ def create_storage_destination(self, *, name: str, provider: str, bucket: str, **options: Any) -> dict[str, Any]:
422
+ """Create an S3, GCS or Azure destination (``storage:write``, owner/admin).
423
+
424
+ Pass provider fields such as ``region``, ``role_arn``, ``account``,
425
+ ``prefix``, ``visibility`` or ``credentials`` as keyword arguments.
426
+ Credentials are sent once and never returned; verify before use.
427
+ """
428
+ return self._json("storage/destinations", "POST", json={**options, "name": name, "provider": provider, "bucket": bucket})
429
+
430
+ def update_storage_destination(self, destination_id: str, *, enabled: bool | None = None,
431
+ credentials: str | None = None) -> dict[str, Any]:
432
+ """Enable/disable a destination or replace its credentials (which clears verification)."""
433
+ body: dict[str, Any] = {}
434
+ if enabled is not None: body["enabled"] = enabled
435
+ if credentials is not None: body["credentials"] = credentials
436
+ if not body:
437
+ raise ValueError("Provide enabled or credentials")
438
+ return self._json(self._destination_path(destination_id), "PATCH", json=body)
439
+
440
+ def delete_storage_destination(self, destination_id: str) -> None:
441
+ """Delete a destination and its stored credentials."""
442
+ self._request(self._destination_path(destination_id), "DELETE", False)
443
+
444
+ def verify_storage_destination(self, destination_id: str) -> dict[str, Any]:
445
+ """Write and read a connection probe; returns the destination with ``verified_at`` set."""
446
+ return self._json(self._destination_path(destination_id) + "/verify", "POST")
447
+
448
+ def list_storage_deliveries(self, destination_id: str, *, after: str | None = None) -> dict[str, Any]:
449
+ """List up to 50 deliveries. Returns ``items`` and ``next_cursor`` (pass it as ``after``)."""
450
+ params = {}
451
+ if after is not None:
452
+ _check_id(after, r"std_[a-f0-9]{64}", "delivery cursor")
453
+ params["after"] = after
454
+ return self._json(self._destination_path(destination_id) + "/deliveries", params=params)
455
+
456
+ def create_storage_delivery(self, destination_id: str, asset_id: str, *, key: str | None = None) -> dict[str, Any]:
457
+ """Move an Etchv-hosted watermarked asset to a destination; returns the queued delivery."""
458
+ self._asset_path(asset_id)
459
+ body = {"asset_id": asset_id, **({"key": key} if key is not None else {})}
460
+ return self._json(self._destination_path(destination_id) + "/deliveries", "POST", accept=(202,), json=body)
461
+
462
+ def get_storage_delivery(self, identifier: str) -> dict[str, Any]:
463
+ """Read a storage delivery (``storage:read``)."""
464
+ return self._json(self._delivery_path(identifier))
465
+
466
+ def retry_storage_delivery(self, delivery_id: str) -> dict[str, Any]:
467
+ """Retry a failed or cancelled upload without another credit."""
468
+ return self._json(self._delivery_path(delivery_id) + "/retry", "POST", accept=(202,))
469
+
470
+ def download_storage_delivery(self, delivery_id: str) -> bytes:
471
+ """Download a stored object through Etchv (the delivery must be ``stored``)."""
472
+ return self._request(self._delivery_path(delivery_id) + "/content", "GET", False).content
473
+
474
+ # Synchronous watermarking
475
+
476
+ def embed_image(self, image: bytes, data: dict[str, Any], *, filename: str = "image.png",
477
+ idempotency_key: str | None = None, storage_destination_id: str | None = None, storage_key: str | None = None) -> EmbedResult:
478
+ """Watermark an image and wait for the verified file in its original format.
479
+
480
+ Retries transient failures with the same idempotency key and polls
481
+ pending jobs until ``timeout``; raises ``DeadlineExceededError`` then.
482
+ """
483
+ return self._embed_media("images", image, data, filename, idempotency_key, storage_destination_id, storage_key)
484
+
485
+ def embed_document(self, document: bytes, data: dict[str, Any], *, filename: str = "document.pdf",
486
+ idempotency_key: str | None = None, storage_destination_id: str | None = None, storage_key: str | None = None) -> EmbedResult:
487
+ """Watermark a PDF and wait for the verified PDF (see ``embed_image``)."""
488
+ return self._embed_media("documents", document, data, filename, idempotency_key, storage_destination_id, storage_key)
489
+
490
+ def embed_video(self, video: bytes, data: dict[str, Any], *, filename: str = "video.mp4",
491
+ idempotency_key: str | None = None, storage_destination_id: str | None = None, storage_key: str | None = None) -> EmbedResult:
492
+ """Watermark an MP4/MOV video and wait for the verified video (see ``embed_image``)."""
493
+ return self._embed_media("videos", video, data, filename, idempotency_key, storage_destination_id, storage_key)
494
+
495
+ def _embed_media(self, media: str, image: bytes, data: dict[str, Any], filename: str,
496
+ idempotency_key: str | None, destination: str | None, storage_key: str | None) -> EmbedResult:
497
+ if not isinstance(data, dict) or not data:
498
+ raise ValueError("data must be a non-empty JSON object")
499
+ encoded = json.dumps(data, allow_nan=False)
500
+ response = self._post(self._storage_path(f"watermarks/{media}", destination, storage_key), image, filename, {"data": encoded}, idempotency_key)
501
+ return self._embedding_result(response)
502
+
503
+ def _embedding_result(self, response: httpx.Response) -> EmbedResult:
504
+ watermark_id = response.headers.get("x-watermark-id", "")
505
+ content_type = response.headers.get("content-type", "").split(";")[0]
506
+ extension = _image_extension(response.content, content_type)
507
+ if not extension or not _valid_id(watermark_id):
508
+ raise EtchvError(200, "Invalid embedding response", response.headers.get("x-request-id"))
509
+ match = re.search(r'filename="([A-Za-z0-9._-]+)"', response.headers.get("content-disposition", ""))
510
+ filename = match.group(1) if match else f"image-watermarked.{extension}"
511
+ return EmbedResult(response.content, watermark_id, response.headers.get("x-request-id"), content_type, filename, response.headers.get("x-asset-id"), response.headers.get("x-source-asset-id"), response.headers.get("x-storage-delivery-id"))
512
+
513
+ # Synchronous detection
514
+
515
+ def detect_image(self, image: bytes, *, filename: str = "image.png",
516
+ idempotency_key: str | None = None) -> DetectionResult:
517
+ """Detect a watermark in an image. Not retried automatically."""
518
+ return self._detect_media("images", image, filename, idempotency_key)
519
+
520
+ def detect_document(self, document: bytes, *, filename: str = "document.pdf",
521
+ idempotency_key: str | None = None) -> DetectionResult:
522
+ """Detect watermarks page by page in a PDF. Not retried automatically."""
523
+ return self._detect_media("documents", document, filename, idempotency_key)
524
+
525
+ def detect_video(self, video: bytes, *, filename: str = "video.mp4",
526
+ idempotency_key: str | None = None) -> DetectionResult:
527
+ """Detect watermarks frame by frame in a video, polling the durable job."""
528
+ return self._detect_media("videos", video, filename, idempotency_key)
529
+
530
+ def _detect_media(self, media: str, image: bytes, filename: str, idempotency_key: str | None) -> DetectionResult:
531
+ return self._detection_result(self._post(f"watermarks/{media}/detect", image, filename, None, idempotency_key))
532
+
533
+ def _detection_result(self, response: httpx.Response) -> DetectionResult:
534
+ try:
535
+ result = response.json()
536
+ confidence = result["confidence"]
537
+ detected = result["watermarked"]
538
+ identifier = result["watermark_id"]
539
+ if (type(detected) is not bool or type(confidence) not in (float, int)
540
+ or not 0 <= confidence <= 1
541
+ or (detected and not _valid_id(identifier))
542
+ or (not detected and identifier is not None)):
543
+ raise ValueError()
544
+ except (ValueError, KeyError, TypeError):
545
+ raise EtchvError(200, "Invalid detection response", response.headers.get("x-request-id")) from None
546
+ raw_units = result.get("units", [{"index": 0, **result}])
547
+ if not isinstance(raw_units, list) or not raw_units:
548
+ raise EtchvError(200, "Invalid detection units", response.headers.get("x-request-id"))
549
+ units = []
550
+ for index, unit in enumerate(raw_units):
551
+ if (not isinstance(unit, dict) or type(unit.get("index")) is not int or unit.get("index") != index
552
+ or type(unit.get("watermarked")) is not bool
553
+ or type(unit.get("confidence")) not in (int, float)
554
+ or not 0 <= unit["confidence"] <= 1
555
+ or (unit["watermarked"] and not _valid_id(unit.get("watermark_id")))
556
+ or (not unit["watermarked"] and unit.get("watermark_id") is not None)):
557
+ raise EtchvError(200, "Invalid detection units", response.headers.get("x-request-id"))
558
+ units.append(DetectionUnit(index, unit["watermarked"], unit["confidence"], unit.get("watermark_id")))
559
+ return DetectionResult(detected, confidence, identifier, response.headers.get("x-request-id"), tuple(units))
560
+
561
+
562
+ def _check_id(value: Any, pattern: str, label: str) -> None:
563
+ if not isinstance(value, str) or not re.fullmatch(pattern, value):
564
+ raise ValueError(f"Invalid {label}")
565
+
566
+
567
+ def _valid_id(value: Any) -> bool:
568
+ return isinstance(value, str) and re.fullmatch(r"[0-9a-fA-F]{64}", value) is not None
569
+
570
+
571
+ def _image_extension(data: bytes, mime: str) -> str | None:
572
+ if mime in ("video/mp4", "video/quicktime") and data[4:8] == b"ftyp": return "mp4" if mime == "video/mp4" else "mov"
573
+ if mime == "application/pdf" and data.startswith(b"%PDF-"): return "pdf"
574
+ signatures = {
575
+ "image/png": ((b"\x89PNG\r\n\x1a\n",), "png"),
576
+ "image/jpeg": ((b"\xff\xd8\xff",), "jpg"),
577
+ "image/gif": ((b"GIF87a", b"GIF89a"), "gif"),
578
+ "image/tiff": ((b"II*\0", b"MM\0*"), "tiff"),
579
+ "image/bmp": ((b"BM",), "bmp"),
580
+ "image/x-portable-pixmap": ((b"P6", b"P3"), "ppm"),
581
+ }
582
+ if mime == "image/webp" and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
583
+ return "webp"
584
+ if mime == "image/vnd.adobe.photoshop" and data[:4] == b"8BPS":
585
+ return {b"\0\1": "psd", b"\0\2": "psb"}.get(data[4:6])
586
+ if mime in signatures:
587
+ prefixes, extension = signatures[mime]
588
+ if data.startswith(prefixes): return extension
589
+ return None
etchv/py.typed ADDED
File without changes
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.5
2
+ Name: etchv-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the Etchv forensic watermarking API for images, PDFs and videos
5
+ Project-URL: Homepage, https://etchv.com/docs
6
+ Project-URL: Documentation, https://etchv.com/docs/sdks/python
7
+ Project-URL: Repository, https://github.com/etchv-labs/python-sdk
8
+ Project-URL: Issues, https://github.com/etchv-labs/python-sdk/issues
9
+ Author-email: Etchv Labs <hello@etchv.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,detection,etchv,forensic watermarking,image,invisible watermark,pdf,sdk,video,watermark,watermarking
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Multimedia :: Graphics
20
+ Classifier: Topic :: Multimedia :: Video
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: <3.15,>=3.14
25
+ Requires-Dist: httpx<1,>=0.27
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Etchv Python SDK
29
+
30
+ Official server-side Python client for [Etchv](https://etchv.com): embed and
31
+ detect invisible forensic watermarks in images, PDFs and videos.
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ pip install etchv-sdk
37
+ ```
38
+
39
+ Requires Python 3.14. Import it as `etchv`. Fully typed.
40
+
41
+ ## Quickstart
42
+
43
+ Create an API key in the [Etchv dashboard](https://etchv.com/dashboard/api-keys)
44
+ and set `ETCHV_API_KEY` on your server. Embedding needs the `watermarks:embed`
45
+ scope, detection `watermarks:detect`, and both use credits.
46
+
47
+ ```python
48
+ import os
49
+ from pathlib import Path
50
+ from etchv import Etchv
51
+
52
+ with Etchv(os.environ["ETCHV_API_KEY"]) as client:
53
+ result = client.embed_image(Path("photo.jpg").read_bytes(),
54
+ {"recipient": "customer-123"}, filename="photo.jpg")
55
+ Path(result.filename).write_bytes(result.image) # write bytes as-is
56
+
57
+ detection = client.detect_image(result.image)
58
+ print(detection.watermarked, detection.watermark_id, detection.confidence)
59
+ ```
60
+
61
+ Use `embed_document` / `detect_document` for PDFs and `embed_video` /
62
+ `detect_video` for H.264 MP4/MOV. Uploads are limited to 20 MB. Detection
63
+ recovers a SHA-256 digest of your data, not the data itself.
64
+
65
+ ## Async jobs
66
+
67
+ ```python
68
+ job = client.submit_embed("documents", pdf_bytes, {"recipient": "customer-123"},
69
+ filename="report.pdf", idempotency_key="report-001")
70
+ status = client.get_job(job["request_id"]) # ["status"]: queued, running, retrying, succeeded, failed
71
+ result = client.get_embed_result(job["request_id"]) # waits for the file
72
+ ```
73
+
74
+ `submit_detection`, `get_job(..., detect=True)` and `get_detection_result` work
75
+ the same way. Reusing an `idempotency_key` with the same input returns the saved
76
+ result (kept 24 hours) without another charge.
77
+
78
+ ## Also included
79
+
80
+ - API key check: `check_api_key()` (no credits used).
81
+ - Assets: `list_assets`, `get_asset`, `update_asset`, `download_asset`,
82
+ `delete_asset`, `delete_assets`.
83
+ - Webhooks: `create_webhook`, `list_webhooks`, `update_webhook`,
84
+ `delete_webhook`, `list_webhook_deliveries`, `redeliver_webhook`; verify
85
+ deliveries with `verify_webhook(raw_body, headers, signing_secret)`, which
86
+ raises `WebhookVerificationError`.
87
+ - Customer storage (S3, GCS, Azure): `create_storage_destination`,
88
+ `list_storage_destinations`, `update_storage_destination`,
89
+ `verify_storage_destination`, `delete_storage_destination`,
90
+ `create_storage_delivery`, `list_storage_deliveries`, `get_storage_delivery`,
91
+ `retry_storage_delivery`, `download_storage_delivery`.
92
+
93
+ ## Errors
94
+
95
+ API failures raise `EtchvError` or a subclass such as `AuthenticationError`,
96
+ `PermissionDeniedError`, `ConflictError`, `GoneError`, `RateLimitError` or
97
+ `DeadlineExceededError`. Each carries `status_code`, `detail` and `request_id`
98
+ (quote it to support). Messages never include your API key.
99
+
100
+ ```python
101
+ from etchv import EtchvError
102
+
103
+ try:
104
+ client.detect_image(image)
105
+ except EtchvError as error:
106
+ print(error.status_code, error.request_id)
107
+ ```
108
+
109
+ ## Links
110
+
111
+ - Full guide: https://etchv.com/docs/sdks/python
112
+ - API reference: https://etchv.com/docs
113
+ - Support: hello@etchv.com
114
+
115
+ License: MIT (covers this SDK, not the hosted Etchv service).
116
+
117
+ Questions or bug reports: open an issue here or email hello@etchv.com.
@@ -0,0 +1,7 @@
1
+ etchv/__init__.py,sha256=S2R-P027WfhvlN2t3pT0bfXv18TjgieFyOdsoFnLTiw,799
2
+ etchv/client.py,sha256=4pxgtm1TM4L4er5R7JnfPJiEaquD8LD10Qh8_kR_ikk,30271
3
+ etchv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ etchv_sdk-1.0.0.dist-info/METADATA,sha256=-wLwERQjlUKxO128f_LKUm4bkVoSSFzNDKE-QMJblTk,4463
5
+ etchv_sdk-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
6
+ etchv_sdk-1.0.0.dist-info/licenses/LICENSE,sha256=L7Lg0gIxyGmsbs5a2fOcz0r9bLVfX4Rj6ipaVh5P98M,1062
7
+ etchv_sdk-1.0.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Etchv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.