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 +91 -0
- exportflow/_client.py +614 -0
- exportflow/_errors.py +140 -0
- exportflow/_models.py +305 -0
- exportflow/_version.py +11 -0
- exportflow/py.typed +0 -0
- exportflow/webhooks.py +87 -0
- exportflow-1.0.0.dist-info/METADATA +222 -0
- exportflow-1.0.0.dist-info/RECORD +10 -0
- exportflow-1.0.0.dist-info/WHEEL +4 -0
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
|
+
)
|