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 +38 -0
- etchv/client.py +589 -0
- etchv/py.typed +0 -0
- etchv_sdk-1.0.0.dist-info/METADATA +117 -0
- etchv_sdk-1.0.0.dist-info/RECORD +7 -0
- etchv_sdk-1.0.0.dist-info/WHEEL +4 -0
- etchv_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
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,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.
|