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/_errors.py ADDED
@@ -0,0 +1,140 @@
1
+ """Errors.
2
+
3
+ Every failure the SDK can report is an :class:`ExportFlowError`. An error the
4
+ API answered with carries the API's own ``code`` -- stable, documented, and
5
+ the thing to branch on -- plus the HTTP status and the request id to quote to
6
+ support. The message is for people and may be reworded.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ __all__ = [
14
+ "APIConnectionError",
15
+ "APIError",
16
+ "APITimeoutError",
17
+ "AuthenticationError",
18
+ "ConflictError",
19
+ "ExportFlowError",
20
+ "IncompatibleAPIVersionError",
21
+ "InvalidRequestError",
22
+ "NotFoundError",
23
+ "PermissionDeniedError",
24
+ "ProcessingFailedError",
25
+ "QuotaExceededError",
26
+ "RateLimitError",
27
+ "SDKVersionUnsupportedError",
28
+ "ServerError",
29
+ "SignatureVerificationError",
30
+ "WaitTimeoutError",
31
+ ]
32
+
33
+
34
+ class ExportFlowError(Exception):
35
+ """The base of every error this SDK raises."""
36
+
37
+
38
+ class APIConnectionError(ExportFlowError):
39
+ """The API could not be reached. The request may or may not have arrived;
40
+ repeating it with the same idempotency key is safe."""
41
+
42
+
43
+ class APITimeoutError(APIConnectionError):
44
+ """The API did not answer in time."""
45
+
46
+
47
+ class APIError(ExportFlowError):
48
+ """The API answered with an error."""
49
+
50
+ def __init__(
51
+ self,
52
+ message: str,
53
+ *,
54
+ status_code: int,
55
+ code: str,
56
+ request_id: Optional[str] = None,
57
+ ) -> None:
58
+ super().__init__(message)
59
+ self.message = message
60
+ self.status_code = status_code
61
+ self.code = code
62
+ """Machine-readable and stable, for example ``quota_exceeded``."""
63
+ self.request_id = request_id
64
+ """Quote this to ExportFlow support."""
65
+
66
+ def __str__(self) -> str:
67
+ suffix = f" (request {self.request_id})" if self.request_id else ""
68
+ return f"{self.code}: {self.message}{suffix}"
69
+
70
+
71
+ class AuthenticationError(APIError):
72
+ """401. The API key is missing, wrong, revoked or expired."""
73
+
74
+
75
+ class PermissionDeniedError(APIError):
76
+ """403. The key is valid and may not do this: a missing scope, a suspended
77
+ account, a disabled application or a network that is not allowed."""
78
+
79
+
80
+ class NotFoundError(APIError):
81
+ """404. No such resource in this key's organisation and environment."""
82
+
83
+
84
+ class ConflictError(APIError):
85
+ """409. The request cannot be done in the resource's current state, for
86
+ example verifying a shipment whose record has not been confirmed."""
87
+
88
+
89
+ class InvalidRequestError(APIError):
90
+ """400 or 422. The request itself is wrong; repeating it will not help."""
91
+
92
+
93
+ class RateLimitError(APIError):
94
+ """429. Too many requests. ``retry_after`` says how long to wait."""
95
+
96
+ def __init__(
97
+ self,
98
+ message: str,
99
+ *,
100
+ status_code: int,
101
+ code: str,
102
+ request_id: Optional[str] = None,
103
+ retry_after: Optional[float] = None,
104
+ ) -> None:
105
+ super().__init__(message, status_code=status_code, code=code, request_id=request_id)
106
+ self.retry_after = retry_after
107
+
108
+
109
+ class QuotaExceededError(RateLimitError):
110
+ """429. The account's allowance or spending limit for the period is used
111
+ up. Waiting a few seconds will not help; it resets with the period."""
112
+
113
+
114
+ class ServerError(APIError):
115
+ """5xx. Something failed on ExportFlow's side. Safe to retry."""
116
+
117
+
118
+ class SDKVersionUnsupportedError(APIError):
119
+ """426. This version of the SDK is too old for the API. Upgrade it."""
120
+
121
+
122
+ class IncompatibleAPIVersionError(ExportFlowError):
123
+ """The server speaks a different major version of the API than this SDK."""
124
+
125
+
126
+ class SignatureVerificationError(ExportFlowError):
127
+ """A webhook's signature did not verify. Do not trust the payload."""
128
+
129
+
130
+ class WaitTimeoutError(ExportFlowError):
131
+ """A ``wait`` helper gave up before the work finished. The work continues;
132
+ fetch the resource later or use a webhook."""
133
+
134
+
135
+ class ProcessingFailedError(ExportFlowError):
136
+ """A document's processing or a verification ended in failure."""
137
+
138
+ def __init__(self, message: str, *, resource_id: str) -> None:
139
+ super().__init__(message)
140
+ self.resource_id = resource_id
exportflow/_models.py ADDED
@@ -0,0 +1,305 @@
1
+ """What the API returns, as plain typed objects.
2
+
3
+ Each model is built from the JSON by name and ignores fields it does not know.
4
+ That is deliberate: within a major version the API only ever adds fields, so
5
+ an SDK released today keeps working when the API grows tomorrow. The whole
6
+ response is kept on ``.raw`` for anything newer than this SDK.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import datetime as dt
13
+ from typing import Any, Dict, List, Mapping, Optional, Type, TypeVar
14
+
15
+ __all__ = [
16
+ "Document",
17
+ "ExtractedField",
18
+ "Extraction",
19
+ "Finding",
20
+ "Identity",
21
+ "MeterUsage",
22
+ "ProcessingAccepted",
23
+ "Reading",
24
+ "Record",
25
+ "RecordField",
26
+ "Shipment",
27
+ "Usage",
28
+ "Verification",
29
+ "WebhookEndpoint",
30
+ "WebhookEvent",
31
+ ]
32
+
33
+ T = TypeVar("T", bound="_Model")
34
+
35
+
36
+ def _moment(value: Any) -> Optional[dt.datetime]:
37
+ if not value:
38
+ return None
39
+ return dt.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
40
+
41
+
42
+ def _day(value: Any) -> Optional[dt.date]:
43
+ return dt.date.fromisoformat(str(value)) if value else None
44
+
45
+
46
+ @dataclasses.dataclass(frozen=True)
47
+ class _Model:
48
+ raw: Mapping[str, Any] = dataclasses.field(repr=False, compare=False)
49
+
50
+ @classmethod
51
+ def _build(cls: Type[T], data: Mapping[str, Any]) -> T:
52
+ known = {f.name: f for f in dataclasses.fields(cls) if f.name != "raw"}
53
+ values: Dict[str, Any] = {}
54
+ for name, field in known.items():
55
+ value = data.get(name)
56
+ if name.endswith(("_at",)) or name in ("created_at", "uploaded_at"):
57
+ value = _moment(value)
58
+ elif name in ("period_start", "period_end"):
59
+ value = _day(value)
60
+ if value is None and field.default is not dataclasses.MISSING:
61
+ value = field.default
62
+ elif value is None and field.default_factory is not dataclasses.MISSING: # type: ignore[misc]
63
+ value = field.default_factory() # type: ignore[misc]
64
+ values[name] = value
65
+ return cls(raw=data, **values)
66
+
67
+
68
+ @dataclasses.dataclass(frozen=True)
69
+ class Identity(_Model):
70
+ organisation_id: str = ""
71
+ organisation_name: str = ""
72
+ application_id: str = ""
73
+ application_name: str = ""
74
+ environment: str = ""
75
+ key_prefix: str = ""
76
+ scopes: List[str] = dataclasses.field(default_factory=list)
77
+ api_version: str = ""
78
+
79
+
80
+ @dataclasses.dataclass(frozen=True)
81
+ class Shipment(_Model):
82
+ id: str = ""
83
+ reference: Optional[str] = None
84
+ commodity: Optional[str] = None
85
+ exporter_name: Optional[str] = None
86
+ origin_country: Optional[str] = None
87
+ destination_country: Optional[str] = None
88
+ destination_port: Optional[str] = None
89
+ hs_code: Optional[str] = None
90
+ environment: str = ""
91
+ created_at: Optional[dt.datetime] = None
92
+
93
+
94
+ @dataclasses.dataclass(frozen=True)
95
+ class Document(_Model):
96
+ id: str = ""
97
+ shipment_id: str = ""
98
+ filename: Optional[str] = None
99
+ media_type: str = ""
100
+ sha256: str = ""
101
+ page_count: Optional[int] = None
102
+ document_type: Optional[str] = None
103
+ declared_type: Optional[str] = None
104
+ detected_type: Optional[str] = None
105
+ type_confidence: Optional[float] = None
106
+ status: str = ""
107
+ error: Optional[str] = None
108
+ parent_document_id: Optional[str] = None
109
+ duplicate: bool = False
110
+ uploaded_at: Optional[dt.datetime] = None
111
+
112
+ @property
113
+ def is_finished(self) -> bool:
114
+ """True once processing has ended, successfully or not."""
115
+ return self.status in ("processed", "failed", "split")
116
+
117
+
118
+ @dataclasses.dataclass(frozen=True)
119
+ class ProcessingAccepted(_Model):
120
+ document_id: str = ""
121
+ status: str = ""
122
+ queued: bool = False
123
+
124
+
125
+ @dataclasses.dataclass(frozen=True)
126
+ class ExtractedField(_Model):
127
+ name: str = ""
128
+ value: Optional[str] = None
129
+ unavailable_reason: Optional[str] = None
130
+ confidence: Optional[float] = None
131
+ page: Optional[int] = None
132
+ method: str = ""
133
+ extraction_id: str = ""
134
+
135
+
136
+ @dataclasses.dataclass(frozen=True)
137
+ class Extraction(_Model):
138
+ document_id: str = ""
139
+ status: str = ""
140
+ document_type: Optional[str] = None
141
+ fields: List[ExtractedField] = dataclasses.field(default_factory=list)
142
+
143
+ @classmethod
144
+ def _build(cls, data: Mapping[str, Any]) -> "Extraction":
145
+ built = super()._build(data)
146
+ return dataclasses.replace(
147
+ built, fields=[ExtractedField._build(item) for item in data.get("fields") or []]
148
+ )
149
+
150
+ def value(self, name: str) -> Optional[str]:
151
+ """The value read for ``name``, or None if it was not read."""
152
+ for field in self.fields:
153
+ if field.name == name and field.value is not None:
154
+ return field.value
155
+ return None
156
+
157
+ def as_dict(self) -> Dict[str, Optional[str]]:
158
+ """``{field name: value}`` for every field that has a value."""
159
+ return {f.name: f.value for f in self.fields if f.value is not None}
160
+
161
+
162
+ @dataclasses.dataclass(frozen=True)
163
+ class Reading(_Model):
164
+ document_id: str = ""
165
+ value: Optional[str] = None
166
+ unavailable_reason: Optional[str] = None
167
+ extraction_id: str = ""
168
+
169
+
170
+ @dataclasses.dataclass(frozen=True)
171
+ class RecordField(_Model):
172
+ name: str = ""
173
+ readings: List[Reading] = dataclasses.field(default_factory=list)
174
+ confirmed_value: Optional[str] = None
175
+ confirmed_absence: Optional[str] = None
176
+ confirmed_at: Optional[dt.datetime] = None
177
+
178
+ @classmethod
179
+ def _build(cls, data: Mapping[str, Any]) -> "RecordField":
180
+ built = super()._build(data)
181
+ return dataclasses.replace(
182
+ built, readings=[Reading._build(item) for item in data.get("readings") or []]
183
+ )
184
+
185
+ @property
186
+ def distinct_values(self) -> List[str]:
187
+ """The different values the documents give. More than one is a
188
+ disagreement between documents."""
189
+ return sorted({r.value for r in self.readings if r.value is not None})
190
+
191
+
192
+ @dataclasses.dataclass(frozen=True)
193
+ class Record(_Model):
194
+ shipment_id: str = ""
195
+ confirmed: bool = False
196
+ fields: List[RecordField] = dataclasses.field(default_factory=list)
197
+
198
+ @classmethod
199
+ def _build(cls, data: Mapping[str, Any]) -> "Record":
200
+ built = super()._build(data)
201
+ return dataclasses.replace(
202
+ built, fields=[RecordField._build(item) for item in data.get("fields") or []]
203
+ )
204
+
205
+
206
+ @dataclasses.dataclass(frozen=True)
207
+ class Finding(_Model):
208
+ id: str = ""
209
+ rule: str = ""
210
+ rule_version: int = 0
211
+ severity: str = ""
212
+ document_id: Optional[str] = None
213
+ field: Optional[str] = None
214
+ found: Optional[str] = None
215
+ expected: Optional[str] = None
216
+ message: str = ""
217
+
218
+
219
+ @dataclasses.dataclass(frozen=True)
220
+ class Verification(_Model):
221
+ id: str = ""
222
+ shipment_id: str = ""
223
+ status: str = ""
224
+ rule_set_version: Optional[int] = None
225
+ severity_counts: Dict[str, int] = dataclasses.field(default_factory=dict)
226
+ failure_reason: Optional[str] = None
227
+ created_at: Optional[dt.datetime] = None
228
+ completed_at: Optional[dt.datetime] = None
229
+ coverage: Optional[Dict[str, Any]] = None
230
+ findings: List[Finding] = dataclasses.field(default_factory=list)
231
+
232
+ @classmethod
233
+ def _build(cls, data: Mapping[str, Any]) -> "Verification":
234
+ built = super()._build(data)
235
+ return dataclasses.replace(
236
+ built, findings=[Finding._build(item) for item in data.get("findings") or []]
237
+ )
238
+
239
+ @property
240
+ def is_finished(self) -> bool:
241
+ return self.status in ("complete", "failed")
242
+
243
+
244
+ @dataclasses.dataclass(frozen=True)
245
+ class MeterUsage(_Model):
246
+ meter: str = ""
247
+ name: str = ""
248
+ unit: str = ""
249
+ succeeded: int = 0
250
+ failed: int = 0
251
+ billable_units: int = 0
252
+ estimated_charge_micro: int = 0
253
+ quota: Optional[int] = None
254
+
255
+
256
+ @dataclasses.dataclass(frozen=True)
257
+ class Usage(_Model):
258
+ environment: str = ""
259
+ period_start: Optional[dt.date] = None
260
+ period_end: Optional[dt.date] = None
261
+ currency: str = ""
262
+ plan: str = ""
263
+ meters: List[MeterUsage] = dataclasses.field(default_factory=list)
264
+ estimated_charge_micro: int = 0
265
+
266
+ @classmethod
267
+ def _build(cls, data: Mapping[str, Any]) -> "Usage":
268
+ built = super()._build(data)
269
+ return dataclasses.replace(
270
+ built, meters=[MeterUsage._build(item) for item in data.get("meters") or []]
271
+ )
272
+
273
+ @property
274
+ def estimated_charge(self) -> float:
275
+ """The estimate in whole units of ``currency``, for display."""
276
+ return self.estimated_charge_micro / 1_000_000
277
+
278
+
279
+ @dataclasses.dataclass(frozen=True)
280
+ class WebhookEndpoint(_Model):
281
+ id: str = ""
282
+ application_id: str = ""
283
+ url: str = ""
284
+ description: Optional[str] = None
285
+ event_types: List[str] = dataclasses.field(default_factory=list)
286
+ status: str = ""
287
+ disabled_reason: Optional[str] = None
288
+ created_at: Optional[dt.datetime] = None
289
+ last_success_at: Optional[dt.datetime] = None
290
+ last_failure_at: Optional[dt.datetime] = None
291
+ signing_secret: Optional[str] = None
292
+ """Present only on the endpoint returned by ``create``. Store it."""
293
+
294
+
295
+ @dataclasses.dataclass(frozen=True)
296
+ class WebhookEvent(_Model):
297
+ """A verified webhook delivery."""
298
+
299
+ id: str = ""
300
+ type: str = ""
301
+ created_at: Optional[dt.datetime] = None
302
+ api_version: str = ""
303
+ environment: str = ""
304
+ application_id: Optional[str] = None
305
+ data: Dict[str, Any] = dataclasses.field(default_factory=dict)
exportflow/_version.py ADDED
@@ -0,0 +1,11 @@
1
+ """The SDK's version, and the API version it was built for."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ API_MAJOR_VERSION = 1
6
+ """The major version of the ExportFlow API this SDK speaks (``/public/v1``).
7
+
8
+ The server states its version on every response. A server on a different
9
+ major version is a different contract, and the SDK says so instead of
10
+ guessing at fields that may have changed meaning.
11
+ """
exportflow/py.typed ADDED
File without changes
exportflow/webhooks.py ADDED
@@ -0,0 +1,87 @@
1
+ """Verifying webhooks.
2
+
3
+ ExportFlow signs every delivery with your endpoint's signing secret. Verify
4
+ the signature before you trust the payload: the URL of a webhook endpoint is
5
+ not a secret, and anybody who learns it can post to it.
6
+
7
+ from exportflow import webhooks
8
+
9
+ event = webhooks.construct_event(
10
+ payload=request.body, # the raw bytes, not parsed JSON
11
+ signature=request.headers["ExportFlow-Signature"],
12
+ secret=os.environ["EXPORTFLOW_WEBHOOK_SECRET"],
13
+ )
14
+ if event.type == "document.processing.completed":
15
+ ...
16
+
17
+ Deliveries are at least once: the same event can arrive twice. ``event.id`` is
18
+ the same both times; record the ids you have handled and skip a repeat.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import hmac
25
+ import json
26
+ import time
27
+ from typing import Optional, Union
28
+
29
+ from ._errors import SignatureVerificationError
30
+ from ._models import WebhookEvent
31
+
32
+ __all__ = ["SIGNATURE_HEADER", "TOLERANCE_SECONDS", "construct_event", "sign", "verify"]
33
+
34
+ SIGNATURE_HEADER = "ExportFlow-Signature"
35
+ TOLERANCE_SECONDS = 300
36
+ """A delivery older than this is refused, so a captured one cannot be replayed."""
37
+
38
+
39
+ def _bytes(payload: Union[bytes, str]) -> bytes:
40
+ return payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
41
+
42
+
43
+ def sign(secret: str, payload: Union[bytes, str], *, timestamp: int) -> str:
44
+ """The header ExportFlow would send for this payload. Useful in your tests."""
45
+ mac = hmac.new(
46
+ secret.encode("utf-8"), f"{timestamp}.".encode() + _bytes(payload), hashlib.sha256
47
+ )
48
+ return f"t={timestamp},v1={mac.hexdigest()}"
49
+
50
+
51
+ def verify(
52
+ payload: Union[bytes, str],
53
+ signature: str,
54
+ secret: str,
55
+ *,
56
+ tolerance: int = TOLERANCE_SECONDS,
57
+ now: Optional[float] = None,
58
+ ) -> bool:
59
+ """True if ``signature`` is ExportFlow's signature of ``payload``, made
60
+ within ``tolerance`` seconds of now. Never raises for a bad signature."""
61
+ parts = dict(part.split("=", 1) for part in (signature or "").split(",") if "=" in part)
62
+ try:
63
+ timestamp = int(parts.get("t", ""))
64
+ except ValueError:
65
+ return False
66
+ current = time.time() if now is None else now
67
+ if abs(current - timestamp) > tolerance:
68
+ return False
69
+ expected = sign(secret, payload, timestamp=timestamp).split("v1=", 1)[1]
70
+ return hmac.compare_digest(expected, parts.get("v1", ""))
71
+
72
+
73
+ def construct_event(
74
+ payload: Union[bytes, str],
75
+ signature: str,
76
+ secret: str,
77
+ *,
78
+ tolerance: int = TOLERANCE_SECONDS,
79
+ ) -> WebhookEvent:
80
+ """Verify a delivery and return the event, or raise
81
+ :class:`~exportflow.SignatureVerificationError`."""
82
+ if not verify(payload, signature, secret, tolerance=tolerance):
83
+ raise SignatureVerificationError(
84
+ "The webhook signature did not verify. Check that the payload is the raw "
85
+ "request body and that the secret belongs to this endpoint."
86
+ )
87
+ return WebhookEvent._build(json.loads(_bytes(payload)))