exportflow 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.
exportflow/__init__.py ADDED
@@ -0,0 +1,91 @@
1
+ """The official Python SDK for the ExportFlow API.
2
+
3
+ from exportflow import ExportFlow
4
+
5
+ client = ExportFlow(api_key="exfk_test_...")
6
+ document = client.documents.upload(file="invoice.pdf")
7
+ document = client.documents.wait(document.id)
8
+ print(client.documents.extraction(document.id).as_dict())
9
+
10
+ Documentation: https://exportflow.app/developers
11
+ """
12
+
13
+ from . import webhooks
14
+ from ._client import DEFAULT_BASE_URL, ExportFlow
15
+ from ._errors import (
16
+ APIConnectionError,
17
+ APIError,
18
+ APITimeoutError,
19
+ AuthenticationError,
20
+ ConflictError,
21
+ ExportFlowError,
22
+ IncompatibleAPIVersionError,
23
+ InvalidRequestError,
24
+ NotFoundError,
25
+ PermissionDeniedError,
26
+ ProcessingFailedError,
27
+ QuotaExceededError,
28
+ RateLimitError,
29
+ SDKVersionUnsupportedError,
30
+ ServerError,
31
+ SignatureVerificationError,
32
+ WaitTimeoutError,
33
+ )
34
+ from ._models import (
35
+ Document,
36
+ ExtractedField,
37
+ Extraction,
38
+ Finding,
39
+ Identity,
40
+ MeterUsage,
41
+ ProcessingAccepted,
42
+ Reading,
43
+ Record,
44
+ RecordField,
45
+ Shipment,
46
+ Usage,
47
+ Verification,
48
+ WebhookEndpoint,
49
+ WebhookEvent,
50
+ )
51
+ from ._version import API_MAJOR_VERSION, __version__
52
+
53
+ __all__ = [
54
+ "API_MAJOR_VERSION",
55
+ "DEFAULT_BASE_URL",
56
+ "APIConnectionError",
57
+ "APIError",
58
+ "APITimeoutError",
59
+ "AuthenticationError",
60
+ "ConflictError",
61
+ "Document",
62
+ "ExportFlow",
63
+ "ExportFlowError",
64
+ "ExtractedField",
65
+ "Extraction",
66
+ "Finding",
67
+ "Identity",
68
+ "IncompatibleAPIVersionError",
69
+ "InvalidRequestError",
70
+ "MeterUsage",
71
+ "NotFoundError",
72
+ "PermissionDeniedError",
73
+ "ProcessingAccepted",
74
+ "ProcessingFailedError",
75
+ "QuotaExceededError",
76
+ "RateLimitError",
77
+ "Reading",
78
+ "Record",
79
+ "RecordField",
80
+ "SDKVersionUnsupportedError",
81
+ "ServerError",
82
+ "Shipment",
83
+ "SignatureVerificationError",
84
+ "Usage",
85
+ "Verification",
86
+ "WaitTimeoutError",
87
+ "WebhookEndpoint",
88
+ "WebhookEvent",
89
+ "__version__",
90
+ "webhooks",
91
+ ]
exportflow/_client.py ADDED
@@ -0,0 +1,614 @@
1
+ """The client.
2
+
3
+ from exportflow import ExportFlow
4
+
5
+ client = ExportFlow(api_key="exfk_test_...")
6
+
7
+ document = client.documents.upload(file="invoice.pdf", document_type="commercial_invoice")
8
+ document = client.documents.wait(document.id)
9
+ fields = client.documents.extraction(document.id).as_dict()
10
+
11
+ Three things the client does so your code does not have to:
12
+
13
+ **Retries that cannot double-charge.** Every request that changes something
14
+ carries an ``Idempotency-Key``. When a request times out or the API answers
15
+ 429 or 5xx, the client waits and sends the *same* request with the *same* key,
16
+ so the work is done once however many times it is sent.
17
+
18
+ **Errors you can branch on.** Every failure is an
19
+ :class:`~exportflow.ExportFlowError`; one the API returned carries its
20
+ documented ``code``.
21
+
22
+ **A version check.** The API states its version on every response. If it is a
23
+ different major version from the one this SDK was built for, the client
24
+ raises rather than misread the response.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import datetime as dt
30
+ import mimetypes
31
+ import os
32
+ import platform
33
+ import random
34
+ import time
35
+ import uuid
36
+ from typing import IO, Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union
37
+
38
+ import httpx
39
+
40
+ from ._errors import (
41
+ APIConnectionError,
42
+ APIError,
43
+ APITimeoutError,
44
+ AuthenticationError,
45
+ ConflictError,
46
+ IncompatibleAPIVersionError,
47
+ InvalidRequestError,
48
+ NotFoundError,
49
+ PermissionDeniedError,
50
+ ProcessingFailedError,
51
+ QuotaExceededError,
52
+ RateLimitError,
53
+ SDKVersionUnsupportedError,
54
+ ServerError,
55
+ WaitTimeoutError,
56
+ )
57
+ from ._models import (
58
+ Document,
59
+ Extraction,
60
+ Identity,
61
+ ProcessingAccepted,
62
+ Record,
63
+ Shipment,
64
+ Usage,
65
+ Verification,
66
+ WebhookEndpoint,
67
+ )
68
+ from ._version import API_MAJOR_VERSION, __version__
69
+
70
+ __all__ = ["DEFAULT_BASE_URL", "ExportFlow"]
71
+
72
+ DEFAULT_BASE_URL = "https://api.exportflow.app"
73
+ API_PREFIX = "/public/v1"
74
+ VERSION_HEADER = "ExportFlow-Api-Version"
75
+ IDEMPOTENCY_HEADER = "Idempotency-Key"
76
+
77
+ FileInput = Union[str, "os.PathLike[str]", bytes, IO[bytes]]
78
+
79
+ _QUOTA_CODES = frozenset({"quota_exceeded", "spend_limit_reached"})
80
+ _NOT_RETRYABLE_429 = _QUOTA_CODES
81
+
82
+
83
+ def _user_agent() -> str:
84
+ return (
85
+ f"exportflow-python/{__version__} "
86
+ f"python/{platform.python_version()} httpx/{httpx.__version__}"
87
+ )
88
+
89
+
90
+ def _read_file(file: FileInput, filename: Optional[str]) -> Tuple[bytes, str]:
91
+ if isinstance(file, (bytes, bytearray)):
92
+ return bytes(file), filename or "document"
93
+ if isinstance(file, (str, os.PathLike)):
94
+ path = os.fspath(file)
95
+ with open(path, "rb") as handle:
96
+ return handle.read(), filename or os.path.basename(path)
97
+ content = file.read()
98
+ name = filename or os.path.basename(str(getattr(file, "name", "") or "document"))
99
+ return content, name
100
+
101
+
102
+ _SIGNATURES: Tuple[Tuple[bytes, str], ...] = (
103
+ (b"%PDF", "application/pdf"),
104
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
105
+ (b"\xff\xd8\xff", "image/jpeg"),
106
+ (b"II*\x00", "image/tiff"),
107
+ (b"MM\x00*", "image/tiff"),
108
+ )
109
+
110
+
111
+ def _media_type(content: bytes, filename: str) -> str:
112
+ """What the file is, from its first bytes; its name is only a fallback.
113
+
114
+ The API checks the declared type against the content and refuses a
115
+ mismatch, so a PDF passed as bytes with no name must still be declared a
116
+ PDF. The content is the evidence; a file's name is whatever somebody
117
+ typed.
118
+ """
119
+ for signature, media_type in _SIGNATURES:
120
+ if content.startswith(signature):
121
+ return media_type
122
+ if content[:4] == b"RIFF" and content[8:12] == b"WEBP":
123
+ return "image/webp"
124
+ return mimetypes.guess_type(filename)[0] or "application/octet-stream"
125
+
126
+
127
+ def _multipart(content: bytes, filename: str, media_type: str) -> Tuple[bytes, str]:
128
+ """One file as multipart/form-data, built once so a retry sends the same bytes."""
129
+ boundary = f"exportflow-{uuid.uuid4().hex}"
130
+ safe_name = filename.replace("\\", "_").replace('"', "_").replace("\r", "").replace("\n", "")
131
+ head = (
132
+ f"--{boundary}\r\n"
133
+ f'Content-Disposition: form-data; name="file"; filename="{safe_name}"\r\n'
134
+ f"Content-Type: {media_type}\r\n\r\n"
135
+ ).encode()
136
+ return head + content + f"\r\n--{boundary}--\r\n".encode(), boundary
137
+
138
+
139
+ class ExportFlow:
140
+ """A client for the ExportFlow API.
141
+
142
+ :param api_key: Your API key. Defaults to the ``EXPORTFLOW_API_KEY``
143
+ environment variable. A key beginning ``exfk_test_`` uses the sandbox;
144
+ ``exfk_live_`` uses production.
145
+ :param base_url: The API's address. Change it only for a private deployment.
146
+ :param timeout: Seconds to wait for one response.
147
+ :param max_retries: How many times to send a request again after a
148
+ connection failure, a 429 or a 5xx. ``0`` disables retries.
149
+ :param http_client: An ``httpx.Client`` to use, for a proxy, custom TLS or tests.
150
+ """
151
+
152
+ def __init__(
153
+ self,
154
+ api_key: Optional[str] = None,
155
+ *,
156
+ base_url: Optional[str] = None,
157
+ timeout: float = 60.0,
158
+ max_retries: int = 2,
159
+ http_client: Optional[httpx.Client] = None,
160
+ ) -> None:
161
+ key = api_key or os.environ.get("EXPORTFLOW_API_KEY")
162
+ if not key:
163
+ raise ValueError(
164
+ "An API key is required: pass api_key=... or set EXPORTFLOW_API_KEY. "
165
+ "Create one in the developer console."
166
+ )
167
+ self._api_key = key
168
+ self._base_url = (
169
+ base_url or os.environ.get("EXPORTFLOW_BASE_URL") or DEFAULT_BASE_URL
170
+ ).rstrip("/")
171
+ self._max_retries = max(int(max_retries), 0)
172
+ self._owns_client = http_client is None
173
+ self._http = http_client or httpx.Client(timeout=timeout)
174
+ self._sleep = time.sleep
175
+
176
+ self.shipments = Shipments(self)
177
+ self.documents = Documents(self)
178
+ self.records = Records(self)
179
+ self.verifications = Verifications(self)
180
+ self.usage = UsageResource(self)
181
+ self.webhook_endpoints = WebhookEndpoints(self)
182
+
183
+ # -- lifecycle ---------------------------------------------------------------------
184
+
185
+ def close(self) -> None:
186
+ if self._owns_client:
187
+ self._http.close()
188
+
189
+ def __enter__(self) -> "ExportFlow":
190
+ return self
191
+
192
+ def __exit__(self, *_: object) -> None:
193
+ self.close()
194
+
195
+ def __repr__(self) -> str:
196
+ # Never the key: a client that prints its key puts it in a log.
197
+ return f"ExportFlow(environment={self.environment!r}, base_url={self._base_url!r})"
198
+
199
+ @property
200
+ def environment(self) -> str:
201
+ """``sandbox`` or ``production``, read from the key's prefix."""
202
+ return "sandbox" if self._api_key.startswith("exfk_test_") else "production"
203
+
204
+ def me(self) -> Identity:
205
+ """Who this key is. A good first call: it proves the key works."""
206
+ return Identity._build(self._request("GET", "/me"))
207
+
208
+ # -- transport ---------------------------------------------------------------------
209
+
210
+ def _request(
211
+ self,
212
+ method: str,
213
+ path: str,
214
+ *,
215
+ params: Optional[Mapping[str, Any]] = None,
216
+ json: Optional[Any] = None,
217
+ content: Optional[bytes] = None,
218
+ content_type: Optional[str] = None,
219
+ idempotency_key: Optional[str] = None,
220
+ ) -> Any:
221
+ headers: Dict[str, str] = {
222
+ "Authorization": f"Bearer {self._api_key}",
223
+ "User-Agent": _user_agent(),
224
+ "Accept": "application/json",
225
+ }
226
+ if method != "GET":
227
+ headers[IDEMPOTENCY_HEADER] = idempotency_key or uuid.uuid4().hex
228
+ if content_type:
229
+ headers["Content-Type"] = content_type
230
+ url = f"{self._base_url}{API_PREFIX}{path}"
231
+ query = {k: v for k, v in (params or {}).items() if v is not None}
232
+
233
+ attempt = 0
234
+ while True:
235
+ try:
236
+ response = self._http.request(
237
+ method, url, params=query or None, json=json, content=content, headers=headers
238
+ )
239
+ except httpx.TimeoutException as error:
240
+ if attempt >= self._max_retries:
241
+ raise APITimeoutError("The ExportFlow API did not answer in time.") from error
242
+ self._wait(attempt, None)
243
+ attempt += 1
244
+ continue
245
+ except httpx.HTTPError as error:
246
+ if attempt >= self._max_retries:
247
+ raise APIConnectionError(
248
+ f"The ExportFlow API could not be reached ({type(error).__name__})."
249
+ ) from error
250
+ self._wait(attempt, None)
251
+ attempt += 1
252
+ continue
253
+
254
+ if response.status_code < 400:
255
+ self._check_version(response)
256
+ if response.status_code == 204 or not response.content:
257
+ return None
258
+ return response.json()
259
+
260
+ error = self._error(response)
261
+ retryable = isinstance(error, ServerError) or (
262
+ isinstance(error, (RateLimitError, ConflictError))
263
+ and error.code in ("rate_limited", "request_in_progress", "idempotency_conflict")
264
+ )
265
+ if not retryable or attempt >= self._max_retries:
266
+ raise error
267
+ self._wait(attempt, getattr(error, "retry_after", None))
268
+ attempt += 1
269
+
270
+ def _wait(self, attempt: int, retry_after: Optional[float]) -> None:
271
+ if retry_after is not None:
272
+ delay = min(max(retry_after, 0.0), 60.0)
273
+ else:
274
+ delay = min(0.5 * (2**attempt), 8.0)
275
+ # Jitter, so many clients that failed together do not retry together.
276
+ self._sleep(delay + random.uniform(0, delay / 4)) # noqa: S311 - not security
277
+
278
+ @staticmethod
279
+ def _check_version(response: httpx.Response) -> None:
280
+ stated = response.headers.get(VERSION_HEADER)
281
+ if not stated:
282
+ return
283
+ try:
284
+ major = int(stated.split(".")[0])
285
+ except ValueError:
286
+ return
287
+ if major != API_MAJOR_VERSION:
288
+ raise IncompatibleAPIVersionError(
289
+ f"The API is version {stated}; this SDK ({__version__}) speaks version "
290
+ f"{API_MAJOR_VERSION}. Install the SDK release for that API version."
291
+ )
292
+
293
+ @staticmethod
294
+ def _error(response: httpx.Response) -> APIError:
295
+ code, message = "error", f"The API answered {response.status_code}."
296
+ try:
297
+ body = response.json()
298
+ if isinstance(body, dict) and isinstance(body.get("error"), dict):
299
+ code = str(body["error"].get("code") or code)
300
+ message = str(body["error"].get("message") or message)
301
+ elif isinstance(body, dict) and "detail" in body:
302
+ # A request the API could not parse at all.
303
+ code, message = "invalid_request", _validation_message(body["detail"])
304
+ except ValueError:
305
+ pass
306
+ request_id = response.headers.get("x-request-id")
307
+ status = response.status_code
308
+ common: Dict[str, Any] = {"status_code": status, "code": code, "request_id": request_id}
309
+ if status == 401:
310
+ return AuthenticationError(message, **common)
311
+ if status == 403:
312
+ return PermissionDeniedError(message, **common)
313
+ if status == 404:
314
+ return NotFoundError(message, **common)
315
+ if status == 409:
316
+ return ConflictError(message, **common)
317
+ if status == 426:
318
+ return SDKVersionUnsupportedError(message, **common)
319
+ if status == 429:
320
+ header = response.headers.get("Retry-After")
321
+ retry_after = float(header) if header and header.replace(".", "", 1).isdigit() else None
322
+ kind = QuotaExceededError if code in _QUOTA_CODES else RateLimitError
323
+ return kind(message, retry_after=retry_after, **common)
324
+ if status >= 500:
325
+ return ServerError(message, **common)
326
+ return InvalidRequestError(message, **common)
327
+
328
+
329
+ def _validation_message(detail: Any) -> str:
330
+ if isinstance(detail, list) and detail:
331
+ first = detail[0]
332
+ if isinstance(first, dict):
333
+ where = ".".join(str(part) for part in first.get("loc", []) if part != "body")
334
+ return f"{where}: {first.get('msg', 'invalid')}" if where else str(first.get("msg"))
335
+ return str(detail)
336
+
337
+
338
+ class _Resource:
339
+ def __init__(self, client: ExportFlow) -> None:
340
+ self._client = client
341
+
342
+
343
+ class Shipments(_Resource):
344
+ """A shipment is a set of documents that are checked against each other."""
345
+
346
+ def create(
347
+ self,
348
+ *,
349
+ reference: Optional[str] = None,
350
+ commodity: Optional[str] = None,
351
+ exporter_name: Optional[str] = None,
352
+ origin_country: Optional[str] = None,
353
+ destination_country: Optional[str] = None,
354
+ destination_port: Optional[str] = None,
355
+ hs_code: Optional[str] = None,
356
+ idempotency_key: Optional[str] = None,
357
+ ) -> Shipment:
358
+ body = {
359
+ "reference": reference,
360
+ "commodity": commodity,
361
+ "exporter_name": exporter_name,
362
+ "origin_country": origin_country,
363
+ "destination_country": destination_country,
364
+ "destination_port": destination_port,
365
+ "hs_code": hs_code,
366
+ }
367
+ return Shipment._build(
368
+ self._client._request(
369
+ "POST",
370
+ "/shipments",
371
+ json={k: v for k, v in body.items() if v is not None},
372
+ idempotency_key=idempotency_key,
373
+ )
374
+ )
375
+
376
+ def get(self, shipment_id: str) -> Shipment:
377
+ return Shipment._build(self._client._request("GET", f"/shipments/{shipment_id}"))
378
+
379
+ def list(self, *, reference: Optional[str] = None, limit: int = 50) -> List[Shipment]:
380
+ """The most recent shipments, newest first."""
381
+ found = self._client._request(
382
+ "GET", "/shipments", params={"reference": reference, "limit": limit}
383
+ )
384
+ return [Shipment._build(item) for item in found["data"]]
385
+
386
+
387
+ class Documents(_Resource):
388
+ def upload(
389
+ self,
390
+ file: FileInput,
391
+ *,
392
+ shipment_id: Optional[str] = None,
393
+ document_type: Optional[str] = None,
394
+ filename: Optional[str] = None,
395
+ idempotency_key: Optional[str] = None,
396
+ ) -> Document:
397
+ """Upload one document; it is scanned, stored and queued for processing.
398
+
399
+ :param file: A path, bytes, or a file opened in binary mode.
400
+ :param shipment_id: The shipment the document belongs to. If omitted, a
401
+ new shipment is created for it; read it from ``document.shipment_id``.
402
+ :param document_type: What you say the document is, for example
403
+ ``commercial_invoice`` or ``bill_of_lading``. Optional: the type is
404
+ detected, and your declaration is kept beside the detected one.
405
+
406
+ Processing is asynchronous. Call :meth:`wait`, or subscribe to the
407
+ ``document.processing.completed`` webhook.
408
+ """
409
+ content, name = _read_file(file, filename)
410
+ key = idempotency_key or uuid.uuid4().hex
411
+ if shipment_id is None:
412
+ # Derived from the upload's key, so a retried upload finds the
413
+ # shipment the first attempt made instead of making another.
414
+ shipment_id = self._client.shipments.create(idempotency_key=f"{key}-shipment").id
415
+ media_type = _media_type(content, name)
416
+ body, boundary = _multipart(content, name, media_type)
417
+ return Document._build(
418
+ self._client._request(
419
+ "POST",
420
+ f"/shipments/{shipment_id}/documents",
421
+ params={"document_type": document_type},
422
+ content=body,
423
+ content_type=f"multipart/form-data; boundary={boundary}",
424
+ idempotency_key=key,
425
+ )
426
+ )
427
+
428
+ def get(self, document_id: str) -> Document:
429
+ """The document and its processing status."""
430
+ return Document._build(self._client._request("GET", f"/documents/{document_id}"))
431
+
432
+ def list(self, shipment_id: str) -> List[Document]:
433
+ found = self._client._request("GET", f"/shipments/{shipment_id}/documents")
434
+ return [Document._build(item) for item in found["data"]]
435
+
436
+ def process(
437
+ self, document_id: str, *, idempotency_key: Optional[str] = None
438
+ ) -> ProcessingAccepted:
439
+ """Process a document again after it failed.
440
+
441
+ A document is processed automatically when it is uploaded, so you do
442
+ not need to call this in the ordinary case. It is safe to call at any
443
+ time: for a document that is being processed or has been processed it
444
+ changes nothing and returns ``queued=False``.
445
+ """
446
+ return ProcessingAccepted._build(
447
+ self._client._request(
448
+ "POST", f"/documents/{document_id}/process", idempotency_key=idempotency_key
449
+ )
450
+ )
451
+
452
+ def extraction(self, document_id: str) -> Extraction:
453
+ """The fields read from the document. Empty until it is ``processed``."""
454
+ return Extraction._build(
455
+ self._client._request("GET", f"/documents/{document_id}/extraction")
456
+ )
457
+
458
+ def wait(
459
+ self,
460
+ document_id: str,
461
+ *,
462
+ timeout: float = 300.0,
463
+ poll_interval: float = 2.0,
464
+ raise_on_failure: bool = True,
465
+ ) -> Document:
466
+ """Poll until the document's processing has finished.
467
+
468
+ Raises :class:`~exportflow.WaitTimeoutError` after ``timeout`` seconds
469
+ (the work continues) and, unless ``raise_on_failure`` is false,
470
+ :class:`~exportflow.ProcessingFailedError` if processing failed.
471
+ """
472
+ deadline = time.monotonic() + timeout
473
+ while True:
474
+ document = self.get(document_id)
475
+ if document.is_finished:
476
+ if document.status == "failed" and raise_on_failure:
477
+ raise ProcessingFailedError(
478
+ document.error or "The document could not be processed.",
479
+ resource_id=document.id,
480
+ )
481
+ return document
482
+ if time.monotonic() >= deadline:
483
+ raise WaitTimeoutError(
484
+ f"Document {document_id} was still '{document.status}' after {timeout:g}s."
485
+ )
486
+ self._client._sleep(poll_interval)
487
+
488
+
489
+ class Records(_Resource):
490
+ """What a shipment's documents say, and what has been confirmed."""
491
+
492
+ def get(self, shipment_id: str) -> Record:
493
+ return Record._build(self._client._request("GET", f"/shipments/{shipment_id}/record"))
494
+
495
+ def confirm(
496
+ self,
497
+ shipment_id: str,
498
+ fields: Union[Mapping[str, Optional[str]], Iterable[Mapping[str, Any]]],
499
+ *,
500
+ idempotency_key: Optional[str] = None,
501
+ ) -> Record:
502
+ """Confirm the values a person has checked against the documents.
503
+
504
+ ``fields`` is either ``{"port_of_discharge": "NHAVA SHEVA"}`` or a list
505
+ of ``{"name", "value" | "absence_reason", "source_extraction_id"}``.
506
+ Verification will not start until the record is confirmed: confirm
507
+ what the documents say, not what they should say.
508
+ """
509
+ if isinstance(fields, Mapping):
510
+ items: List[Dict[str, Any]] = [
511
+ {"name": name, "value": value} for name, value in fields.items()
512
+ ]
513
+ else:
514
+ items = [dict(item) for item in fields]
515
+ return Record._build(
516
+ self._client._request(
517
+ "PUT",
518
+ f"/shipments/{shipment_id}/record",
519
+ json={"fields": items},
520
+ idempotency_key=idempotency_key,
521
+ )
522
+ )
523
+
524
+
525
+ class Verifications(_Resource):
526
+ def create(self, shipment_id: str, *, idempotency_key: Optional[str] = None) -> Verification:
527
+ """Check a shipment's confirmed record against the published rule set.
528
+
529
+ Asynchronous: call :meth:`wait` or subscribe to
530
+ ``verification.completed``. Raises :class:`~exportflow.ConflictError`
531
+ with code ``not_confirmed`` until the record has been confirmed.
532
+ """
533
+ return Verification._build(
534
+ self._client._request(
535
+ "POST", f"/shipments/{shipment_id}/verifications", idempotency_key=idempotency_key
536
+ )
537
+ )
538
+
539
+ def get(self, verification_id: str) -> Verification:
540
+ return Verification._build(
541
+ self._client._request("GET", f"/verifications/{verification_id}")
542
+ )
543
+
544
+ def list(self, shipment_id: str) -> List[Verification]:
545
+ found = self._client._request("GET", f"/shipments/{shipment_id}/verifications")
546
+ return [Verification._build(item) for item in found["data"]]
547
+
548
+ def wait(
549
+ self,
550
+ verification_id: str,
551
+ *,
552
+ timeout: float = 300.0,
553
+ poll_interval: float = 2.0,
554
+ raise_on_failure: bool = True,
555
+ ) -> Verification:
556
+ deadline = time.monotonic() + timeout
557
+ while True:
558
+ verification = self.get(verification_id)
559
+ if verification.is_finished:
560
+ if verification.status == "failed" and raise_on_failure:
561
+ raise ProcessingFailedError(
562
+ verification.failure_reason or "The verification failed.",
563
+ resource_id=verification.id,
564
+ )
565
+ return verification
566
+ if time.monotonic() >= deadline:
567
+ raise WaitTimeoutError(
568
+ f"Verification {verification_id} was still '{verification.status}' "
569
+ f"after {timeout:g}s."
570
+ )
571
+ self._client._sleep(poll_interval)
572
+
573
+
574
+ class UsageResource(_Resource):
575
+ def get(self, period: Optional[Union[str, dt.date]] = None) -> Usage:
576
+ """This application's usage for a month (``"2026-11"``); the current
577
+ month if omitted. Charges are estimates until an invoice is issued."""
578
+ if isinstance(period, dt.date):
579
+ period = period.strftime("%Y-%m")
580
+ return Usage._build(self._client._request("GET", "/usage", params={"period": period}))
581
+
582
+
583
+ class WebhookEndpoints(_Resource):
584
+ def create(
585
+ self,
586
+ url: str,
587
+ *,
588
+ event_types: Optional[Iterable[str]] = None,
589
+ description: Optional[str] = None,
590
+ idempotency_key: Optional[str] = None,
591
+ ) -> WebhookEndpoint:
592
+ """Register an endpoint. The returned ``signing_secret`` is what you
593
+ verify deliveries with; store it."""
594
+ return WebhookEndpoint._build(
595
+ self._client._request(
596
+ "POST",
597
+ "/webhook_endpoints",
598
+ json={
599
+ "url": url,
600
+ "event_types": list(event_types or []),
601
+ "description": description,
602
+ },
603
+ idempotency_key=idempotency_key,
604
+ )
605
+ )
606
+
607
+ def list(self) -> List[WebhookEndpoint]:
608
+ found = self._client._request("GET", "/webhook_endpoints")
609
+ return [WebhookEndpoint._build(item) for item in found["data"]]
610
+
611
+ def delete(self, endpoint_id: str, *, idempotency_key: Optional[str] = None) -> None:
612
+ self._client._request(
613
+ "DELETE", f"/webhook_endpoints/{endpoint_id}", idempotency_key=idempotency_key
614
+ )