preflight-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.
- preflight_sdk/__init__.py +45 -0
- preflight_sdk/_version.py +4 -0
- preflight_sdk/client.py +514 -0
- preflight_sdk/errors.py +85 -0
- preflight_sdk/py.typed +0 -0
- preflight_sdk/types.py +209 -0
- preflight_sdk/webhooks.py +78 -0
- preflight_sdk-1.0.0.dist-info/METADATA +148 -0
- preflight_sdk-1.0.0.dist-info/RECORD +12 -0
- preflight_sdk-1.0.0.dist-info/WHEEL +5 -0
- preflight_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- preflight_sdk-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from ._version import API_VERSION, SDK_VERSION
|
|
2
|
+
from .client import UNAVAILABLE, Preflight, new_idempotency_key, parse_retry_after, unavailable_decision
|
|
3
|
+
from .errors import (
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
ConflictError,
|
|
6
|
+
InvalidRequestError,
|
|
7
|
+
NetworkError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
PermissionDeniedError,
|
|
10
|
+
PreflightError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
RequestTimeoutError,
|
|
13
|
+
ServerError,
|
|
14
|
+
)
|
|
15
|
+
from .webhooks import verify_webhook
|
|
16
|
+
from .types import (
|
|
17
|
+
BatchJob,
|
|
18
|
+
BatchResult,
|
|
19
|
+
CheckRequest,
|
|
20
|
+
ConsentRequest,
|
|
21
|
+
Decision,
|
|
22
|
+
Evidence,
|
|
23
|
+
EvidencePdf,
|
|
24
|
+
ImportOptions,
|
|
25
|
+
ListKind,
|
|
26
|
+
NewList,
|
|
27
|
+
Obligation,
|
|
28
|
+
OptOutRequest,
|
|
29
|
+
Outcome,
|
|
30
|
+
OutcomeReceipt,
|
|
31
|
+
Reason,
|
|
32
|
+
ReasonCode,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"UNAVAILABLE", "Preflight", "PreflightError", "unavailable_decision",
|
|
37
|
+
"BatchJob", "BatchResult", "CheckRequest", "ConsentRequest", "Decision", "Evidence", "EvidencePdf", "ImportOptions", "ListKind", "NewList", "Obligation",
|
|
38
|
+
"OptOutRequest", "Outcome", "OutcomeReceipt", "Reason", "ReasonCode",
|
|
39
|
+
]
|
|
40
|
+
__all__ += ["verify_webhook"]
|
|
41
|
+
__all__ += [
|
|
42
|
+
"API_VERSION", "SDK_VERSION", "new_idempotency_key", "parse_retry_after",
|
|
43
|
+
"AuthenticationError", "ConflictError", "InvalidRequestError", "NetworkError", "NotFoundError",
|
|
44
|
+
"PermissionDeniedError", "RateLimitError", "RequestTimeoutError", "ServerError",
|
|
45
|
+
]
|
preflight_sdk/client.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import email.utils
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
from ._version import SDK_VERSION
|
|
15
|
+
from .errors import NetworkError, PreflightError, RequestTimeoutError, error_for_status
|
|
16
|
+
from .types import (
|
|
17
|
+
BatchJob,
|
|
18
|
+
CheckRequest,
|
|
19
|
+
ConsentRequest,
|
|
20
|
+
Decision,
|
|
21
|
+
Evidence,
|
|
22
|
+
EvidencePdf,
|
|
23
|
+
ImportOptions,
|
|
24
|
+
NewList,
|
|
25
|
+
OptOutRequest,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
UNAVAILABLE = "preflight_unavailable"
|
|
29
|
+
OUTCOMES = ("placed", "not_placed", "failed")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
33
|
+
"""Refuse every redirect: urllib would copy Authorization to the new location, even on another host. The 30x
|
|
34
|
+
then surfaces as an HTTPError (a 4xx-class failure, so check() fails closed and nothing is retried)."""
|
|
35
|
+
|
|
36
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _seg(s: str) -> str:
|
|
44
|
+
"""One path segment, percent-encoded."""
|
|
45
|
+
return urllib.parse.quote(s, safe="")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _query(q: Dict[str, Any]) -> str:
|
|
49
|
+
"""`?a=1&b=2` from the values that are not None (booleans as true/false), or "" when there are none."""
|
|
50
|
+
pairs = [(k, str(v).lower() if isinstance(v, bool) else str(v)) for k, v in q.items() if v is not None]
|
|
51
|
+
return "?" + urllib.parse.urlencode(pairs) if pairs else ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_ERROR_BODY_CAP = 64 * 1024 # an error body only carries {"error": {"code", "message", "request_id"}}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_body(resp: Any, deadline: float, cap: Optional[int] = None) -> bytes:
|
|
58
|
+
"""Read a response body in chunks, raising TimeoutError once the monotonic deadline passes; stop at `cap`."""
|
|
59
|
+
chunks: List[bytes] = []
|
|
60
|
+
n = 0
|
|
61
|
+
while cap is None or n < cap:
|
|
62
|
+
if time.monotonic() >= deadline:
|
|
63
|
+
raise TimeoutError("deadline exceeded while reading the response")
|
|
64
|
+
chunk = resp.read1(65536)
|
|
65
|
+
if not chunk:
|
|
66
|
+
break
|
|
67
|
+
chunks.append(chunk)
|
|
68
|
+
n += len(chunk)
|
|
69
|
+
return b"".join(chunks)[:cap]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def unavailable_decision(message: str, status: Optional[int] = None, idempotency_key: Optional[str] = None) -> Decision:
|
|
73
|
+
"""The decision to act on when there is no answer: do not dial. There is no fail-open mode."""
|
|
74
|
+
reason: Dict[str, Any] = {"code": UNAVAILABLE, "message": message}
|
|
75
|
+
if status is not None:
|
|
76
|
+
reason["status"] = status
|
|
77
|
+
if idempotency_key is not None:
|
|
78
|
+
reason["idempotency_key"] = idempotency_key
|
|
79
|
+
return {"decision": "block", "reasons": [reason], "obligations": [], "warnings": []} # type: ignore[return-value]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def new_idempotency_key() -> str:
|
|
83
|
+
"""A fresh Idempotency-Key. Every write already gets one per call; pass your own (`idempotency_key=`) when you
|
|
84
|
+
retry a failed call yourself, so a write that landed before its answer was lost is replayed, not recorded twice."""
|
|
85
|
+
return str(uuid.uuid4())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_retry_after(value: Optional[str], now: Optional[float] = None) -> Optional[float]:
|
|
89
|
+
"""Seconds to wait from a Retry-After header (delay-seconds or an HTTP date), or None when absent or unreadable."""
|
|
90
|
+
if value is None:
|
|
91
|
+
return None
|
|
92
|
+
v = value.strip()
|
|
93
|
+
if v.isascii() and v.isdigit() and len(v) <= 9:
|
|
94
|
+
return float(int(v))
|
|
95
|
+
try:
|
|
96
|
+
at = email.utils.parsedate_to_datetime(v)
|
|
97
|
+
except (TypeError, ValueError, IndexError):
|
|
98
|
+
return None
|
|
99
|
+
if at is None or at.tzinfo is None:
|
|
100
|
+
return None
|
|
101
|
+
return max(0.0, at.timestamp() - (time.time() if now is None else now))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _retryable(e: PreflightError) -> bool:
|
|
105
|
+
"""Whether a failed attempt may be repeated: no response, a timeout, a 429 or a 5xx."""
|
|
106
|
+
return e.status is None or e.status == 429 or e.status >= 500
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _Retry:
|
|
110
|
+
"""How many times, and how patiently, a call is retried. Backoff before retry n (from 0) is uniform in
|
|
111
|
+
[0, min(max_delay, base * 2**n)) ("full jitter"); a Retry-After longer than `max_delay` ends the call."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, retries: int, base: float, max_delay: float):
|
|
114
|
+
self.retries = retries
|
|
115
|
+
self.base = base
|
|
116
|
+
self.max_delay = max_delay
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Preflight:
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
base_url: str,
|
|
123
|
+
api_key: str,
|
|
124
|
+
*,
|
|
125
|
+
check_timeout: float = 0.3,
|
|
126
|
+
request_timeout: float = 10.0,
|
|
127
|
+
throw_on_error: bool = False,
|
|
128
|
+
max_retries: int = 2,
|
|
129
|
+
check_max_retries: int = 1,
|
|
130
|
+
retry_base_delay: float = 0.2,
|
|
131
|
+
max_retry_delay: float = 10.0,
|
|
132
|
+
):
|
|
133
|
+
"""Timeouts and delays are in seconds; timeouts are per attempt. `check_timeout` defaults to 300 ms.
|
|
134
|
+
|
|
135
|
+
A network error, timeout, 429 or 5xx is retried: `max_retries` times for every call except check() and the
|
|
136
|
+
creates the server does not deduplicate (never retried), `check_max_retries` times for check(). Retry n waits
|
|
137
|
+
a random time up to `retry_base_delay * 2**n`, or the server's Retry-After, at most `max_retry_delay`; a
|
|
138
|
+
longer Retry-After raises at once. check() never waits longer than its timeout."""
|
|
139
|
+
self._base = base_url.rstrip("/")
|
|
140
|
+
self._key = api_key
|
|
141
|
+
self._check_timeout = check_timeout
|
|
142
|
+
self._timeout = request_timeout
|
|
143
|
+
self._throw = throw_on_error
|
|
144
|
+
self._retry = _Retry(max_retries, retry_base_delay, max_retry_delay)
|
|
145
|
+
# check() sits in front of a dial: a Retry-After (whole seconds) ends it at once with the fail-closed decision.
|
|
146
|
+
self._check_retry = _Retry(check_max_retries, min(retry_base_delay, 0.025), check_timeout)
|
|
147
|
+
|
|
148
|
+
def _attempt(
|
|
149
|
+
self, method: str, path: str, data: Optional[bytes], headers: Dict[str, str], timeout: float
|
|
150
|
+
) -> "tuple[bytes, int, Any]":
|
|
151
|
+
"""One attempt with a total deadline. `urlopen(timeout=)` only bounds each socket operation, so a server
|
|
152
|
+
that drips bytes could outlast it: the attempt runs in a daemon thread that we stop waiting for at the
|
|
153
|
+
deadline (the body is read in chunks against the same monotonic deadline, so the thread ends soon after)."""
|
|
154
|
+
deadline = time.monotonic() + timeout
|
|
155
|
+
box: Dict[str, Any] = {}
|
|
156
|
+
|
|
157
|
+
def run() -> None:
|
|
158
|
+
try:
|
|
159
|
+
req = urllib.request.Request(self._base + path, data=data, headers=headers, method=method)
|
|
160
|
+
with _OPENER.open(req, timeout=timeout) as resp:
|
|
161
|
+
box["ok"] = (_read_body(resp, deadline), resp.status, resp.headers)
|
|
162
|
+
except urllib.error.HTTPError as e:
|
|
163
|
+
# A non-2xx (or refused 30x): read its body here, under the same deadline and capped, not in the
|
|
164
|
+
# caller's thread where only the per-socket timeout would bound a server that drips its error page.
|
|
165
|
+
try:
|
|
166
|
+
e.preflight_body = _read_body(e, deadline, _ERROR_BODY_CAP) # type: ignore[attr-defined]
|
|
167
|
+
except Exception: # a failure while reading the error body is still a server-side failure
|
|
168
|
+
e.preflight_body = b"" # type: ignore[attr-defined]
|
|
169
|
+
finally:
|
|
170
|
+
e.close()
|
|
171
|
+
box["err"] = e
|
|
172
|
+
except BaseException as e: # handed back to the caller's thread
|
|
173
|
+
box["err"] = e
|
|
174
|
+
|
|
175
|
+
t = threading.Thread(target=run, daemon=True)
|
|
176
|
+
t.start()
|
|
177
|
+
t.join(max(0.0, deadline - time.monotonic()))
|
|
178
|
+
if t.is_alive():
|
|
179
|
+
raise TimeoutError(f"no complete response within {timeout}s")
|
|
180
|
+
if "err" in box:
|
|
181
|
+
raise box["err"]
|
|
182
|
+
return box["ok"]
|
|
183
|
+
|
|
184
|
+
def _request(
|
|
185
|
+
self,
|
|
186
|
+
method: str,
|
|
187
|
+
path: str,
|
|
188
|
+
body: Any,
|
|
189
|
+
timeout: float,
|
|
190
|
+
idempotency_key: Optional[str],
|
|
191
|
+
*,
|
|
192
|
+
content_type: Optional[str] = None,
|
|
193
|
+
binary: bool = False,
|
|
194
|
+
retry: Optional[_Retry] = None,
|
|
195
|
+
) -> Any:
|
|
196
|
+
"""A network error, a timeout, a 429 or a 5xx is retried (up to the policy's count, with jittered
|
|
197
|
+
exponential backoff or the server's Retry-After); any other status is not. Every attempt of a POST sends the
|
|
198
|
+
same Idempotency-Key, and the server replays a key it has already answered, so a retry never records a
|
|
199
|
+
write twice. Returns parsed JSON or raises PreflightError.
|
|
200
|
+
|
|
201
|
+
`content_type` sends `body` (bytes or str) as is instead of as JSON; `binary` returns (bytes, headers)
|
|
202
|
+
instead of parsing JSON."""
|
|
203
|
+
headers = {"Authorization": f"Bearer {self._key}", "User-Agent": f"preflight-sdk-python/{SDK_VERSION}"}
|
|
204
|
+
data = None
|
|
205
|
+
if body is not None:
|
|
206
|
+
if content_type:
|
|
207
|
+
data = body.encode() if isinstance(body, str) else bytes(body)
|
|
208
|
+
else:
|
|
209
|
+
data = json.dumps(body).encode()
|
|
210
|
+
headers["Content-Type"] = content_type or "application/json"
|
|
211
|
+
if method == "POST":
|
|
212
|
+
headers["Idempotency-Key"] = idempotency_key or new_idempotency_key()
|
|
213
|
+
policy = retry or self._retry
|
|
214
|
+
retries = policy.retries
|
|
215
|
+
attempt = 0
|
|
216
|
+
while True:
|
|
217
|
+
try:
|
|
218
|
+
return self._one(method, path, data, headers, timeout, binary)
|
|
219
|
+
except PreflightError as e:
|
|
220
|
+
err = e
|
|
221
|
+
if attempt >= retries or not _retryable(err):
|
|
222
|
+
raise err
|
|
223
|
+
if err.retry_after is not None:
|
|
224
|
+
if err.retry_after > policy.max_delay:
|
|
225
|
+
raise err # the caller decides whether to wait that long
|
|
226
|
+
wait = err.retry_after
|
|
227
|
+
else:
|
|
228
|
+
wait = random.uniform(0, min(policy.max_delay, policy.base * 2**attempt))
|
|
229
|
+
time.sleep(wait)
|
|
230
|
+
attempt += 1
|
|
231
|
+
|
|
232
|
+
def _one(
|
|
233
|
+
self, method: str, path: str, data: Optional[bytes], headers: Dict[str, str], timeout: float, binary: bool
|
|
234
|
+
) -> Any:
|
|
235
|
+
"""One attempt: the response, or a PreflightError subclass saying why not."""
|
|
236
|
+
try:
|
|
237
|
+
raw, status, resp_headers = self._attempt(method, path, data, headers, timeout)
|
|
238
|
+
except urllib.error.HTTPError as e:
|
|
239
|
+
text = getattr(e, "preflight_body", b"").decode(errors="replace")
|
|
240
|
+
try:
|
|
241
|
+
parsed = json.loads(text)
|
|
242
|
+
except ValueError:
|
|
243
|
+
parsed = None
|
|
244
|
+
err = parsed.get("error") if isinstance(parsed, dict) else None
|
|
245
|
+
err = err if isinstance(err, dict) else {}
|
|
246
|
+
retry_after = parse_retry_after(e.headers.get("Retry-After") if e.headers is not None else None)
|
|
247
|
+
raise error_for_status(
|
|
248
|
+
e.code, err.get("message") or f"preflight {e.code}", err.get("code"), parsed, retry_after
|
|
249
|
+
) from None
|
|
250
|
+
except Exception as e: # URLError, timeouts, http.client.HTTPException, bad encodings
|
|
251
|
+
reason = getattr(e, "reason", None)
|
|
252
|
+
if isinstance(e, TimeoutError) or isinstance(reason, TimeoutError):
|
|
253
|
+
raise RequestTimeoutError(f"no complete response within {timeout}s: {e!r}") from None
|
|
254
|
+
raise NetworkError(f"network error: {e!r}") from None
|
|
255
|
+
if binary:
|
|
256
|
+
return raw, resp_headers
|
|
257
|
+
try:
|
|
258
|
+
return json.loads(raw.decode())
|
|
259
|
+
except ValueError:
|
|
260
|
+
raise PreflightError("unreadable response body", status) from None
|
|
261
|
+
|
|
262
|
+
def check(
|
|
263
|
+
self,
|
|
264
|
+
req: CheckRequest,
|
|
265
|
+
*,
|
|
266
|
+
idempotency_key: Optional[str] = None,
|
|
267
|
+
timeout: Optional[float] = None,
|
|
268
|
+
throw_on_error: Optional[bool] = None,
|
|
269
|
+
) -> Decision:
|
|
270
|
+
"""Ask before every call or text. Never raises by default: on a timeout, network error, 429, 5xx (after one
|
|
271
|
+
quick retry, never a wait longer than the timeout) or 4xx it returns {"decision": "block", "reasons": [{"code": "preflight_unavailable"}]}. Treat that
|
|
272
|
+
as do-not-dial. With throw_on_error=True it raises PreflightError instead. There is no fail-open mode."""
|
|
273
|
+
idempotency_key = idempotency_key or new_idempotency_key()
|
|
274
|
+
try:
|
|
275
|
+
d = self._request(
|
|
276
|
+
"POST",
|
|
277
|
+
"/v1/check",
|
|
278
|
+
dict(req),
|
|
279
|
+
self._check_timeout if timeout is None else timeout,
|
|
280
|
+
idempotency_key,
|
|
281
|
+
retry=self._check_retry,
|
|
282
|
+
)
|
|
283
|
+
if (
|
|
284
|
+
not isinstance(d, dict)
|
|
285
|
+
or d.get("decision") not in ("allow", "defer", "block")
|
|
286
|
+
or not isinstance(d.get("reasons"), list)
|
|
287
|
+
):
|
|
288
|
+
raise PreflightError("malformed decision in response", 200)
|
|
289
|
+
return d
|
|
290
|
+
except Exception as e: # never let a check() call raise unless asked to: fail closed
|
|
291
|
+
pe = e if isinstance(e, PreflightError) else PreflightError(f"unexpected error: {e!r}")
|
|
292
|
+
if self._throw if throw_on_error is None else throw_on_error:
|
|
293
|
+
if pe is e:
|
|
294
|
+
raise
|
|
295
|
+
raise pe from e
|
|
296
|
+
return unavailable_decision(str(pe), pe.status, idempotency_key)
|
|
297
|
+
|
|
298
|
+
def release_unplaced(
|
|
299
|
+
self, req: CheckRequest, unavailable: Decision, *, timeout: Optional[float] = None
|
|
300
|
+
) -> Optional[Dict[str, Any]]:
|
|
301
|
+
"""After check() returned the preflight_unavailable block, the server may still have recorded the check as
|
|
302
|
+
an allowed attempt that you never dialed. This replays the check under the key the block carries (the server
|
|
303
|
+
answers with the stored decision while the key is within its 5 minute window) and, if that was an allowed,
|
|
304
|
+
committed check, reports "not_placed" so it stops counting toward frequency and volume caps. Pass the same
|
|
305
|
+
request and the block decision. Returns the receipt, or None when there was nothing to release (no key on
|
|
306
|
+
the decision, or the check was not an allow). Raises PreflightError on any failure: retry it."""
|
|
307
|
+
key = next((r.get("idempotency_key") for r in unavailable["reasons"] if r.get("code") == UNAVAILABLE), None)
|
|
308
|
+
if key is None:
|
|
309
|
+
return None
|
|
310
|
+
d = self._request("POST", "/v1/check", dict(req), self._t(timeout), key)
|
|
311
|
+
if not isinstance(d, dict) or d.get("decision") != "allow" or d.get("committed") is not True or not isinstance(d.get("check_id"), str):
|
|
312
|
+
return None
|
|
313
|
+
return self.report_outcome(d["check_id"], "not_placed", timeout=timeout)
|
|
314
|
+
|
|
315
|
+
def record_consent(
|
|
316
|
+
self, req: ConsentRequest, *, idempotency_key: Optional[str] = None, timeout: Optional[float] = None
|
|
317
|
+
) -> Dict[str, Any]:
|
|
318
|
+
"""Record consent where you collect it. Raises PreflightError on any failure."""
|
|
319
|
+
return self._request(
|
|
320
|
+
"POST", "/v1/consents", dict(req), self._timeout if timeout is None else timeout, idempotency_key
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def record_opt_out(
|
|
324
|
+
self, req: OptOutRequest, *, idempotency_key: Optional[str] = None, timeout: Optional[float] = None
|
|
325
|
+
) -> Dict[str, Any]:
|
|
326
|
+
"""Send every opt-out here. Raises PreflightError on any failure: retry it until it succeeds."""
|
|
327
|
+
return self._request(
|
|
328
|
+
"POST", "/v1/opt-outs", dict(req), self._timeout if timeout is None else timeout, idempotency_key
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def report_outcome(
|
|
332
|
+
self,
|
|
333
|
+
check_id: str,
|
|
334
|
+
outcome: str,
|
|
335
|
+
provider_ref: Optional[str] = None,
|
|
336
|
+
*,
|
|
337
|
+
idempotency_key: Optional[str] = None,
|
|
338
|
+
timeout: Optional[float] = None,
|
|
339
|
+
) -> Dict[str, Any]:
|
|
340
|
+
"""Report what happened to an allowed, committed check once the call or text ends: "placed", "not_placed"
|
|
341
|
+
(never sent) or "failed" (sent to the provider, never reached the line). Only "placed" keeps counting as an
|
|
342
|
+
attempt. Raises PreflightError on any failure, including a bad `outcome`: retry it until it succeeds."""
|
|
343
|
+
if outcome not in OUTCOMES:
|
|
344
|
+
raise PreflightError(f"outcome must be one of {', '.join(OUTCOMES)}", None, "invalid_outcome")
|
|
345
|
+
body: Dict[str, Any] = {"outcome": outcome}
|
|
346
|
+
if provider_ref is not None:
|
|
347
|
+
body["provider_ref"] = provider_ref
|
|
348
|
+
path = f"/v1/checks/{urllib.parse.quote(check_id, safe='')}/outcome"
|
|
349
|
+
return self._request("POST", path, body, self._timeout if timeout is None else timeout, idempotency_key)
|
|
350
|
+
|
|
351
|
+
def get_evidence(self, phone: str, *, timeout: Optional[float] = None) -> Evidence:
|
|
352
|
+
"""Everything Preflight holds about one number."""
|
|
353
|
+
path = "/v1/evidence?" + urllib.parse.urlencode({"phone": phone})
|
|
354
|
+
return self._request("GET", path, None, self._timeout if timeout is None else timeout, None)
|
|
355
|
+
|
|
356
|
+
def _t(self, timeout: Optional[float]) -> float:
|
|
357
|
+
return self._timeout if timeout is None else timeout
|
|
358
|
+
|
|
359
|
+
def revoke_consent(
|
|
360
|
+
self,
|
|
361
|
+
consent_id: str,
|
|
362
|
+
reason: Optional[str] = None,
|
|
363
|
+
*,
|
|
364
|
+
idempotency_key: Optional[str] = None,
|
|
365
|
+
timeout: Optional[float] = None,
|
|
366
|
+
) -> Dict[str, Any]:
|
|
367
|
+
"""Revoke a consent (idempotent: revoking again keeps the first revocation). Raises PreflightError on any
|
|
368
|
+
failure."""
|
|
369
|
+
body: Dict[str, Any] = {} if reason is None else {"reason": reason}
|
|
370
|
+
return self._request("POST", f"/v1/consents/{_seg(consent_id)}/revoke", body, self._t(timeout), idempotency_key)
|
|
371
|
+
|
|
372
|
+
def get_consent_capture(self, capture_id: str, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
373
|
+
"""A consent capture's certificate (from the consent widget), pending, claimed or expired."""
|
|
374
|
+
return self._request("GET", f"/v1/consent-captures/{_seg(capture_id)}", None, self._t(timeout), None)
|
|
375
|
+
|
|
376
|
+
def claim_consent_capture(
|
|
377
|
+
self, capture_id: str, phone: str, *, idempotency_key: Optional[str] = None, timeout: Optional[float] = None
|
|
378
|
+
) -> Dict[str, Any]:
|
|
379
|
+
"""Claim a widget capture as consent. `phone` must be the phone from the form submission you accepted; the
|
|
380
|
+
server refuses the claim (409) if it is not the capture's. Raises PreflightError on any failure."""
|
|
381
|
+
path = f"/v1/consent-captures/{_seg(capture_id)}/claim"
|
|
382
|
+
return self._request("POST", path, {"phone": phone}, self._t(timeout), idempotency_key)
|
|
383
|
+
|
|
384
|
+
def create_list(
|
|
385
|
+
self, list_: NewList, *, idempotency_key: Optional[str] = None, timeout: Optional[float] = None
|
|
386
|
+
) -> Dict[str, Any]:
|
|
387
|
+
"""Create a list (national_dnc, state_dnc, litigator, reassigned or custom). Retried with the same
|
|
388
|
+
Idempotency-Key, so a retry never creates a second list."""
|
|
389
|
+
return self._request("POST", "/v1/lists", dict(list_), self._t(timeout), idempotency_key)
|
|
390
|
+
|
|
391
|
+
def _all_pages(self, path: str, timeout: Optional[float]) -> Dict[str, Any]:
|
|
392
|
+
"""Every row of a list endpoint, following next_cursor page by page: {"data": [...]}."""
|
|
393
|
+
data: list = []
|
|
394
|
+
cursor: Optional[str] = None
|
|
395
|
+
while True:
|
|
396
|
+
q = _query({"limit": 1000, "cursor": cursor})
|
|
397
|
+
page = self._request("GET", path + q, None, self._t(timeout), None)
|
|
398
|
+
data.extend(page.get("data") or [])
|
|
399
|
+
cursor = page.get("next_cursor")
|
|
400
|
+
if not cursor:
|
|
401
|
+
return {"data": data}
|
|
402
|
+
|
|
403
|
+
def list_lists(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
404
|
+
"""The workspace's lists with entry counts and freshness, every page: {"data": [...]}."""
|
|
405
|
+
return self._all_pages("/v1/lists", timeout)
|
|
406
|
+
|
|
407
|
+
def import_list_entries(
|
|
408
|
+
self,
|
|
409
|
+
list_id: str,
|
|
410
|
+
text: "str | bytes",
|
|
411
|
+
*,
|
|
412
|
+
options: Optional[ImportOptions] = None,
|
|
413
|
+
idempotency_key: Optional[str] = None,
|
|
414
|
+
timeout: Optional[float] = None,
|
|
415
|
+
) -> Dict[str, Any]:
|
|
416
|
+
"""Import entries into a list: delimited text, one entry per line, sent as is (see the README for the
|
|
417
|
+
format and options). A large file needs a larger `timeout` than the default. Raises PreflightError on any
|
|
418
|
+
failure, including a refused replace. Retried with the same Idempotency-Key: the server answers a repeat of
|
|
419
|
+
the same file and options with the first import's result instead of importing again."""
|
|
420
|
+
path = f"/v1/lists/{_seg(list_id)}/entries" + _query(dict(options or {}))
|
|
421
|
+
return self._request("POST", path, text, self._t(timeout), idempotency_key, content_type="text/plain")
|
|
422
|
+
|
|
423
|
+
def get_check_evidence(self, check_id: str, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
424
|
+
"""The evidence bundle for one check, with its SHA-256 and the audit chain status."""
|
|
425
|
+
return self._request("GET", f"/v1/checks/{_seg(check_id)}/evidence", None, self._t(timeout), None)
|
|
426
|
+
|
|
427
|
+
def get_check_evidence_pdf(self, check_id: str, *, timeout: Optional[float] = None) -> EvidencePdf:
|
|
428
|
+
"""The same evidence as a PDF, with the bundle's SHA-256 from the response header."""
|
|
429
|
+
path = f"/v1/checks/{_seg(check_id)}/evidence.pdf"
|
|
430
|
+
pdf, headers = self._request("GET", path, None, self._t(timeout), None, binary=True)
|
|
431
|
+
return {"pdf": pdf, "bundle_sha256": headers.get("X-Preflight-Evidence-Sha256")}
|
|
432
|
+
|
|
433
|
+
def get_rule_changes(self, since: Optional[str] = None, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
434
|
+
"""Changes to the rule data since a rules version (YYYY-MM-DD.N) or a date, oldest first."""
|
|
435
|
+
return self._request("GET", "/v1/rules/changes" + _query({"since": since}), None, self._t(timeout), None)
|
|
436
|
+
|
|
437
|
+
def get_rule_impact(
|
|
438
|
+
self,
|
|
439
|
+
*,
|
|
440
|
+
since: Optional[str] = None,
|
|
441
|
+
until: Optional[str] = None,
|
|
442
|
+
cursor: Optional[str] = None,
|
|
443
|
+
limit: Optional[int] = None,
|
|
444
|
+
timeout: Optional[float] = None,
|
|
445
|
+
) -> Dict[str, Any]:
|
|
446
|
+
"""Your recent checks re-evaluated under the deployed rules (last 30 days, paged by next_cursor)."""
|
|
447
|
+
q = _query({"since": since, "until": until, "cursor": cursor, "limit": limit})
|
|
448
|
+
return self._request("GET", "/v1/rules/impact" + q, None, self._t(timeout), None)
|
|
449
|
+
|
|
450
|
+
def create_webhook(
|
|
451
|
+
self,
|
|
452
|
+
url: str,
|
|
453
|
+
events: Optional[Sequence[str]] = None,
|
|
454
|
+
*,
|
|
455
|
+
idempotency_key: Optional[str] = None,
|
|
456
|
+
timeout: Optional[float] = None,
|
|
457
|
+
) -> Dict[str, Any]:
|
|
458
|
+
"""Register a webhook endpoint. The response carries its signing secret. Retried with the same
|
|
459
|
+
Idempotency-Key: the server replays the first response (secret included), so a retry never makes a second
|
|
460
|
+
endpoint."""
|
|
461
|
+
body: Dict[str, Any] = {"url": url}
|
|
462
|
+
if events is not None:
|
|
463
|
+
body["events"] = list(events)
|
|
464
|
+
return self._request("POST", "/v1/webhooks", body, self._t(timeout), idempotency_key)
|
|
465
|
+
|
|
466
|
+
def list_webhooks(self, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
467
|
+
"""The workspace's webhook endpoints with pending and failed delivery counts, every page: {"data": [...]}."""
|
|
468
|
+
return self._all_pages("/v1/webhooks", timeout)
|
|
469
|
+
|
|
470
|
+
def delete_webhook(self, webhook_id: str, *, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
471
|
+
"""Disable a webhook endpoint and cancel its pending deliveries (deleting again changes nothing)."""
|
|
472
|
+
return self._request("DELETE", f"/v1/webhooks/{_seg(webhook_id)}", None, self._t(timeout), None)
|
|
473
|
+
|
|
474
|
+
def batch(
|
|
475
|
+
self,
|
|
476
|
+
items: Sequence[CheckRequest],
|
|
477
|
+
*,
|
|
478
|
+
idempotency_key: Optional[str] = None,
|
|
479
|
+
timeout: Optional[float] = None,
|
|
480
|
+
poll_interval: float = 1.0,
|
|
481
|
+
max_wait: float = 600.0,
|
|
482
|
+
page_size: int = 1000,
|
|
483
|
+
) -> BatchJob:
|
|
484
|
+
"""Screen a list (screening only: items must not set commit=True). Submits the job, polls until it is
|
|
485
|
+
done and returns every result in item order. Raises PreflightError if the job fails, polling runs out of
|
|
486
|
+
time or a call fails: no answer means do not dial. An item the server could not evaluate comes back as
|
|
487
|
+
{"error": {...}}."""
|
|
488
|
+
t = self._timeout if timeout is None else timeout
|
|
489
|
+
job_id = self._request("POST", "/v1/checks/batch", {"items": list(items)}, t, idempotency_key)["job_id"]
|
|
490
|
+
path = f"/v1/checks/batch/{_seg(job_id)}"
|
|
491
|
+
deadline = time.monotonic() + max_wait
|
|
492
|
+
while True:
|
|
493
|
+
s = self._request("GET", f"{path}?limit=1", None, t, None)
|
|
494
|
+
if s.get("status") == "done":
|
|
495
|
+
break
|
|
496
|
+
if s.get("status") == "failed":
|
|
497
|
+
raise PreflightError(f"batch job {job_id} failed: {s.get('error') or 'unknown'}", None, "batch_failed", s)
|
|
498
|
+
if time.monotonic() >= deadline:
|
|
499
|
+
raise PreflightError(f"batch job {job_id} not done in time", None, "batch_timeout")
|
|
500
|
+
time.sleep(poll_interval)
|
|
501
|
+
results: List[Any] = []
|
|
502
|
+
cursor: Optional[str] = None
|
|
503
|
+
last: Dict[str, Any] = {}
|
|
504
|
+
while True:
|
|
505
|
+
q = f"limit={page_size}" + (f"&cursor={urllib.parse.quote(cursor, safe='')}" if cursor else "")
|
|
506
|
+
last = self._request("GET", f"{path}?{q}", None, t, None)
|
|
507
|
+
results.extend(last["results"])
|
|
508
|
+
cursor = last.get("next_cursor")
|
|
509
|
+
if not cursor:
|
|
510
|
+
break
|
|
511
|
+
last["results"] = results
|
|
512
|
+
last.pop("next_cursor", None)
|
|
513
|
+
last.pop("next_offset", None)
|
|
514
|
+
return last # type: ignore[return-value]
|
preflight_sdk/errors.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Every failure is a ``PreflightError``; the subclasses say why, so callers can branch with ``except`` instead of
|
|
2
|
+
reading status codes. Statuses with no subclass of their own (a refused redirect, 413, 421) are plain
|
|
3
|
+
``PreflightError``."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PreflightError(Exception):
|
|
10
|
+
"""`status` is the HTTP status, or None for a network error or timeout. `code` is the server's error code.
|
|
11
|
+
`retry_after` is the server's Retry-After in seconds (429, 503), when it sent one."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
status: Optional[int] = None,
|
|
17
|
+
code: Optional[str] = None,
|
|
18
|
+
body: Any = None,
|
|
19
|
+
retry_after: Optional[float] = None,
|
|
20
|
+
):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status = status
|
|
23
|
+
self.code = code
|
|
24
|
+
self.body = body
|
|
25
|
+
self.retry_after = retry_after
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def request_id(self) -> Optional[str]:
|
|
29
|
+
"""The server's id for the failed request (`error.request_id`, also the x-request-id header): quote it to
|
|
30
|
+
support. None when there was no server response."""
|
|
31
|
+
err = self.body.get("error") if isinstance(self.body, dict) else None
|
|
32
|
+
rid = err.get("request_id") if isinstance(err, dict) else None
|
|
33
|
+
return rid if isinstance(rid, str) else None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvalidRequestError(PreflightError):
|
|
37
|
+
"""400 or 422: the request is malformed or refused as sent (including a reused Idempotency-Key)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthenticationError(PreflightError):
|
|
41
|
+
"""401: the API key is missing, wrong or revoked."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PermissionDeniedError(PreflightError):
|
|
45
|
+
"""403: the key is valid but may not do this (for example a read-only key on a write)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NotFoundError(PreflightError):
|
|
49
|
+
"""404: no such resource in this workspace."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConflictError(PreflightError):
|
|
53
|
+
"""409: the request conflicts with what is recorded (for example downgrading a placed outcome)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RateLimitError(PreflightError):
|
|
57
|
+
"""429: slow down; `retry_after` says for how long."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ServerError(PreflightError):
|
|
61
|
+
"""5xx: the server failed; safe to retry later with the same Idempotency-Key."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NetworkError(PreflightError):
|
|
65
|
+
"""No HTTP response: DNS, connection refused or reset, TLS."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RequestTimeoutError(PreflightError):
|
|
69
|
+
"""No complete response within the per-attempt timeout."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def error_for_status(
|
|
73
|
+
status: int, message: str, code: Optional[str] = None, body: Any = None, retry_after: Optional[float] = None
|
|
74
|
+
) -> PreflightError:
|
|
75
|
+
"""The subclass for an HTTP status."""
|
|
76
|
+
cls = {
|
|
77
|
+
400: InvalidRequestError,
|
|
78
|
+
401: AuthenticationError,
|
|
79
|
+
403: PermissionDeniedError,
|
|
80
|
+
404: NotFoundError,
|
|
81
|
+
409: ConflictError,
|
|
82
|
+
422: InvalidRequestError,
|
|
83
|
+
429: RateLimitError,
|
|
84
|
+
}.get(status, ServerError if status >= 500 else PreflightError)
|
|
85
|
+
return cls(message, status, code, body, retry_after)
|
preflight_sdk/py.typed
ADDED
|
File without changes
|
preflight_sdk/types.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Request and response types. Field names match the API's JSON (snake_case), so dicts pass straight through."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
|
|
5
|
+
|
|
6
|
+
Channel = Literal["voice", "sms"]
|
|
7
|
+
Purpose = Literal["marketing", "informational", "emergency"]
|
|
8
|
+
Method = Literal["ai_voice", "prerecorded", "autodialed", "manual", "ai_text", "ai_agent", "ai_prerecorded"]
|
|
9
|
+
ConsentKind = Literal["express_written", "express", "ebr_purchase", "ebr_inquiry"]
|
|
10
|
+
Verdict = Literal["allow", "defer", "block"]
|
|
11
|
+
Outcome = Literal["placed", "not_placed", "failed"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _CheckRequired(TypedDict):
|
|
15
|
+
to: str
|
|
16
|
+
channel: Channel
|
|
17
|
+
purpose: Purpose
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CheckRequest(_CheckRequired, total=False):
|
|
21
|
+
method: Method
|
|
22
|
+
seller: str
|
|
23
|
+
subject: str
|
|
24
|
+
recipient_state: str
|
|
25
|
+
at: str # ISO 8601; what-if time, only with commit=False (committed: within 5 min of server time)
|
|
26
|
+
commit: bool # batch items must leave it false or unset
|
|
27
|
+
line_type: Literal["wireless", "residential", "unknown"] # only "residential" changes a verdict; a workspace with caller_attributes.tax_exempt_nonprofit true gets the 3-in-30 limit for any non-emergency purpose
|
|
28
|
+
existing_debt_or_contract: bool # contact is primarily about an existing, uncompleted debt or contract; lifts Florida's do-not-call list only (Fla. Stat. 501.059(1)(k)2)
|
|
29
|
+
existing_customer: bool # person called is an NJ "existing customer" (open payment obligation on merchandise, or a written contract with performance outstanding); lifts only the NJ mobile sales-call ban (N.J.A.C. 13:45D-1.3)
|
|
30
|
+
health_care_message: bool # HIPAA health-care message to a residential line; needs caller_attributes.hipaa_covered_entity
|
|
31
|
+
message_text: str # exact SMS text (max 2000 chars); with free_to_end_user the 160-character limit and STOP wording are checked
|
|
32
|
+
free_to_end_user: dict # {"category": "package"|"inmate"|"financial"|"healthcare", "event_id", "conditions": [...], "signature_required"}; 47 CFR 64.1200(a)(9), needs the workspace's caller_attributes.free_to_end_user_<category> and a current free_to_end_user_attestations entry; the conditions are your representation, not verified
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Every code a reason can carry (README "Reason codes"). New codes may appear: keep a default branch.
|
|
36
|
+
ReasonCode = Literal[
|
|
37
|
+
"opted_out",
|
|
38
|
+
"consent_missing",
|
|
39
|
+
"national_dnc",
|
|
40
|
+
"state_dnc",
|
|
41
|
+
"state_ban",
|
|
42
|
+
"state_volume_cap",
|
|
43
|
+
"state_registration_missing",
|
|
44
|
+
"known_litigator",
|
|
45
|
+
"suppressed",
|
|
46
|
+
"dnc_list_missing",
|
|
47
|
+
"dnc_list_stale",
|
|
48
|
+
"dnc_list_not_covering",
|
|
49
|
+
"state_dnc_list_missing",
|
|
50
|
+
"litigator_list_missing",
|
|
51
|
+
"litigator_list_stale",
|
|
52
|
+
"unknown_area_code",
|
|
53
|
+
"non_geographic_number",
|
|
54
|
+
"unsupported_jurisdiction",
|
|
55
|
+
"emergency_disabled",
|
|
56
|
+
"emergency_subject_not_allowed",
|
|
57
|
+
"emergency_rule",
|
|
58
|
+
"no_allowed_time",
|
|
59
|
+
"outside_federal_window",
|
|
60
|
+
"outside_state_window",
|
|
61
|
+
"window_closing",
|
|
62
|
+
"state_holiday",
|
|
63
|
+
"state_no_calls_today",
|
|
64
|
+
"frequency_cap",
|
|
65
|
+
"rnd_unavailable",
|
|
66
|
+
"preflight_unavailable",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Reason(TypedDict, total=False):
|
|
71
|
+
code: str # a ReasonCode today; typed as str so a code added to the server later still type-checks
|
|
72
|
+
message: str
|
|
73
|
+
rule: str
|
|
74
|
+
source: str
|
|
75
|
+
verified: bool
|
|
76
|
+
status: int # only on the SDK's own preflight_unavailable reason, when the server answered with a 4xx
|
|
77
|
+
idempotency_key: str # only on the SDK's own preflight_unavailable reason: the key the check was sent under; see release_unplaced()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Obligation(TypedDict, total=False):
|
|
81
|
+
code: str
|
|
82
|
+
message: str
|
|
83
|
+
source: str
|
|
84
|
+
state: str
|
|
85
|
+
required: bool
|
|
86
|
+
trigger: str
|
|
87
|
+
timing: str
|
|
88
|
+
text: str
|
|
89
|
+
conditions: str
|
|
90
|
+
verified: bool
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SourceText(TypedDict):
|
|
94
|
+
url: str
|
|
95
|
+
sha256: str
|
|
96
|
+
fetched_at: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RulesReview(TypedDict):
|
|
100
|
+
reviewed_by: str
|
|
101
|
+
reviewed_on: str
|
|
102
|
+
scope: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Decision(TypedDict, total=False):
|
|
106
|
+
decision: Verdict
|
|
107
|
+
reasons: List[Reason]
|
|
108
|
+
next_allowed_at: str
|
|
109
|
+
obligations: List[Obligation]
|
|
110
|
+
jurisdiction: Dict[str, Any]
|
|
111
|
+
warnings: List[str]
|
|
112
|
+
consent_ids: List[str]
|
|
113
|
+
check_id: str
|
|
114
|
+
committed: bool
|
|
115
|
+
evaluated_at: str
|
|
116
|
+
reference_snapshot: Optional[Dict[str, Any]]
|
|
117
|
+
rules_version: str
|
|
118
|
+
rules_in_force: List[str]
|
|
119
|
+
source_texts: List[SourceText]
|
|
120
|
+
rules_review: RulesReview
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _ConsentRequired(TypedDict):
|
|
124
|
+
phone: str
|
|
125
|
+
kind: ConsentKind
|
|
126
|
+
source: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ConsentRequest(_ConsentRequired, total=False):
|
|
130
|
+
seller: str
|
|
131
|
+
channels: List[Channel]
|
|
132
|
+
purposes: List[Purpose]
|
|
133
|
+
obtained_at: str
|
|
134
|
+
wording: str # required for express_written: the exact wording shown to the person
|
|
135
|
+
ip: str
|
|
136
|
+
user_agent: str
|
|
137
|
+
evidence: Any
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _OptOutRequired(TypedDict):
|
|
141
|
+
phone: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class OptOutRequest(_OptOutRequired, total=False):
|
|
145
|
+
channel: Channel # omit to opt out on every channel
|
|
146
|
+
source: str
|
|
147
|
+
raw: str
|
|
148
|
+
received_at: str
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# A batch item the server could not evaluate is {"error": {"code", "message"}}: treat it as do-not-dial.
|
|
152
|
+
BatchResult = Union[Decision, Dict[str, Any]]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class BatchJob(TypedDict):
|
|
156
|
+
job_id: str
|
|
157
|
+
status: str
|
|
158
|
+
total: int
|
|
159
|
+
processed: int
|
|
160
|
+
counts: Dict[str, int]
|
|
161
|
+
results: List[BatchResult] # in item order
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# The server's JSON is passed through as-is.
|
|
165
|
+
Evidence = Dict[str, Any]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class OutcomeReceipt(TypedDict):
|
|
169
|
+
check_id: str
|
|
170
|
+
outcome: Outcome
|
|
171
|
+
outcome_at: str
|
|
172
|
+
provider_ref: Optional[str]
|
|
173
|
+
counts_as_attempt: bool # True only for "placed"
|
|
174
|
+
changed: bool # False when the same outcome was already recorded
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
ListKind = Literal["national_dnc", "state_dnc", "litigator", "reassigned", "custom"]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _NewListRequired(TypedDict):
|
|
181
|
+
kind: ListKind
|
|
182
|
+
name: str
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class NewList(_NewListRequired, total=False):
|
|
186
|
+
state: str # two-letter state, required for state_dnc
|
|
187
|
+
source: str
|
|
188
|
+
area_codes: List[str]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class ImportOptions(TypedDict, total=False):
|
|
192
|
+
"""Query options of POST /v1/lists/{id}/entries; see the README's import table."""
|
|
193
|
+
|
|
194
|
+
mode: Literal["merge", "replace"]
|
|
195
|
+
delimiter: Literal["comma", "tab", "pipe", "semicolon"]
|
|
196
|
+
header: bool
|
|
197
|
+
phone_col: str
|
|
198
|
+
area_code_col: str
|
|
199
|
+
registered_col: str
|
|
200
|
+
disconnected_col: str
|
|
201
|
+
edition_date: str # YYYY-MM-DD
|
|
202
|
+
source: str
|
|
203
|
+
area_codes: str # comma-separated, e.g. "303,720"
|
|
204
|
+
force: bool
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class EvidencePdf(TypedDict):
|
|
208
|
+
pdf: bytes
|
|
209
|
+
bundle_sha256: Optional[str] # from the X-Preflight-Evidence-Sha256 header
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Verifies the ``preflight-signature`` header on webhook deliveries (#280).
|
|
2
|
+
|
|
3
|
+
Scheme (src/webhooks.rs ``sign``)::
|
|
4
|
+
|
|
5
|
+
preflight-signature: t=<unix seconds>,v1=<hex(HMAC-SHA256(secret, "<t>.<raw body>"))>
|
|
6
|
+
|
|
7
|
+
The key is the endpoint secret exactly as returned by ``create_webhook`` (including ``whsec_``), as UTF-8 bytes.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from typing import Optional, Union
|
|
17
|
+
|
|
18
|
+
_HEX64 = re.compile(r"[0-9a-f]{64}")
|
|
19
|
+
_DIGITS = re.compile(r"[0-9]{1,15}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_webhook(
|
|
23
|
+
raw_body: Union[bytes, bytearray, memoryview, str],
|
|
24
|
+
header: Optional[str],
|
|
25
|
+
secret: str,
|
|
26
|
+
tolerance: float = 300,
|
|
27
|
+
now: Optional[float] = None,
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""True only when ``header`` carries a ``v1`` signature of ``raw_body`` under ``secret`` and its ``t`` is within
|
|
30
|
+
``tolerance`` seconds of ``now`` (default ``time.time()``) in either direction.
|
|
31
|
+
|
|
32
|
+
Every other case returns False (fail closed): missing or malformed header, non-numeric, stale or future
|
|
33
|
+
timestamp, wrong signature. Pass the body exactly as received (``request.get_data()``, ``await request.body()``)
|
|
34
|
+
or its undecoded text; a parsed dict does not match and raises ``TypeError``, as do an empty secret and a bad
|
|
35
|
+
tolerance, because those are programming errors. Any one matching ``v1`` value is accepted.
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(raw_body, str):
|
|
38
|
+
body = raw_body.encode("utf-8")
|
|
39
|
+
elif isinstance(raw_body, (bytes, bytearray, memoryview)):
|
|
40
|
+
body = bytes(raw_body)
|
|
41
|
+
else:
|
|
42
|
+
raise TypeError("verify_webhook needs the raw request body (bytes or text), not a parsed object")
|
|
43
|
+
if not isinstance(secret, str) or not secret:
|
|
44
|
+
raise TypeError("verify_webhook needs the endpoint secret")
|
|
45
|
+
if isinstance(tolerance, bool) or not isinstance(tolerance, (int, float)) or not (0 <= tolerance < math.inf):
|
|
46
|
+
raise TypeError("tolerance must be a non-negative number")
|
|
47
|
+
if not isinstance(header, str):
|
|
48
|
+
return False
|
|
49
|
+
current = time.time() if now is None else now
|
|
50
|
+
if not isinstance(current, (int, float)) or not math.isfinite(current):
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
t: Optional[str] = None
|
|
54
|
+
sigs = []
|
|
55
|
+
for part in header.split(","):
|
|
56
|
+
key, eq, value = part.partition("=")
|
|
57
|
+
if not eq:
|
|
58
|
+
return False
|
|
59
|
+
key, value = key.strip(), value.strip()
|
|
60
|
+
if key == "t":
|
|
61
|
+
if t is not None: # two timestamps: ambiguous, refuse
|
|
62
|
+
return False
|
|
63
|
+
t = value
|
|
64
|
+
elif key == "v1":
|
|
65
|
+
sigs.append(value)
|
|
66
|
+
# other schemes are ignored
|
|
67
|
+
if t is None or not _DIGITS.fullmatch(t) or not sigs:
|
|
68
|
+
return False
|
|
69
|
+
if abs(current - int(t)) > tolerance:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
expected = hmac.new(secret.encode("utf-8"), t.encode("ascii") + b"." + body, hashlib.sha256).hexdigest()
|
|
73
|
+
ok = False
|
|
74
|
+
for sig in sigs:
|
|
75
|
+
# Compare every candidate (no early exit); compare_digest is constant time for equal-length inputs.
|
|
76
|
+
if _HEX64.fullmatch(sig) and hmac.compare_digest(sig.encode("ascii"), expected.encode("ascii")):
|
|
77
|
+
ok = True
|
|
78
|
+
return ok
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: preflight-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Thin fail-closed client for the Preflight pre-dial API. Standard library only.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://www.preflightapi.com/docs/
|
|
7
|
+
Project-URL: Documentation, https://www.preflightapi.com/docs/
|
|
8
|
+
Project-URL: Support, https://www.preflightapi.com/support.html
|
|
9
|
+
Project-URL: Source, https://github.com/palfi-andras/preflight
|
|
10
|
+
Keywords: preflight,tcpa,dnc,opt-out,consent,voice,sms
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Communications :: Telephony
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# Preflight Python SDK
|
|
23
|
+
|
|
24
|
+
A thin client for the Preflight pre-dial API. Standard library only (Python 3.9+), no dependencies. Typed (`py.typed`).
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install preflight-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Preflight applies encoded rules and keeps evidence. This SDK does not make anyone compliant and does not replace
|
|
31
|
+
counsel.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from preflight_sdk import Preflight
|
|
35
|
+
|
|
36
|
+
pf = Preflight("https://api.preflightapi.com", os.environ["PREFLIGHT_KEY"])
|
|
37
|
+
|
|
38
|
+
d = pf.check({"to": "(702) 555-0142", "channel": "voice", "purpose": "marketing", "method": "ai_voice"})
|
|
39
|
+
if d["decision"] == "allow":
|
|
40
|
+
place_call() # d["obligations"] belong in your opening script
|
|
41
|
+
elif d["decision"] == "defer":
|
|
42
|
+
reschedule(d["next_allowed_at"])
|
|
43
|
+
else:
|
|
44
|
+
drop_lead(d["reasons"]) # includes "preflight_unavailable"
|
|
45
|
+
|
|
46
|
+
pf.record_opt_out({"phone": "+12065550142", "channel": "voice", "source": "in_call", "raw": "stop calling me"})
|
|
47
|
+
pf.record_consent({"phone": "+17025550142", "kind": "express_written", "source": "https://acme.example/quote", "wording": "..."})
|
|
48
|
+
job = pf.batch(items) # polls until done; job["results"] is in item order
|
|
49
|
+
pf.report_outcome(check_id, "placed", "CA123") # after the call or text ends: placed | not_placed | failed
|
|
50
|
+
ev = pf.get_evidence("+17025550142")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Account and data calls, each one request to the endpoint of the same name in the [API reference](https://www.preflightapi.com/docs/api.html):
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
pf.revoke_consent(consent_id, "asked to stop")
|
|
57
|
+
pf.get_consent_capture(capture_id) # consent widget certificate
|
|
58
|
+
pf.claim_consent_capture(capture_id, "+17025550142") # 409 unless it is the capture's phone
|
|
59
|
+
lst = pf.create_list({"kind": "litigator", "name": "Vendor feed"})
|
|
60
|
+
pf.import_list_entries(lst["id"], file_text, options={"mode": "replace", "edition_date": "2026-10-01"}, timeout=120)
|
|
61
|
+
pf.list_lists()
|
|
62
|
+
pf.get_check_evidence(check_id)
|
|
63
|
+
r = pf.get_check_evidence_pdf(check_id) # {"pdf": bytes, "bundle_sha256": ...}
|
|
64
|
+
pf.get_rule_changes("2026-10-14.3") # or a date, or None for all
|
|
65
|
+
pf.get_rule_impact(limit=1000) # follow next_cursor with cursor=
|
|
66
|
+
hook = pf.create_webhook("https://acme.example/preflight", ["opt_out.created"]) # secret shown once
|
|
67
|
+
pf.list_webhooks()
|
|
68
|
+
pf.delete_webhook(hook["id"])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Verifying webhooks
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from preflight_sdk import verify_webhook
|
|
75
|
+
|
|
76
|
+
# raw body exactly as received: request.get_data() (Flask), await request.body() (Starlette), never a parsed dict
|
|
77
|
+
if not verify_webhook(request.get_data(), request.headers.get("preflight-signature"), os.environ["PREFLIGHT_WEBHOOK_SECRET"]):
|
|
78
|
+
abort(400)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Returns `True` only when a `v1` value in `preflight-signature` is the HMAC-SHA256 of `<t>.<raw body>` under the
|
|
82
|
+
endpoint secret (the whole `whsec_...` string from `create_webhook`) and `t` is within `tolerance` seconds (default
|
|
83
|
+
300) of now in either direction. Anything else (missing or malformed header, non-numeric, stale or future `t`, wrong
|
|
84
|
+
signature) returns `False`; it never raises for bad input from the network. It raises `TypeError` for programming
|
|
85
|
+
errors: a body that is not bytes or str (for example a parsed dict), an empty secret, a negative tolerance. The
|
|
86
|
+
compare is constant time (`hmac.compare_digest`), and any matching `v1` is accepted (for secret rotation).
|
|
87
|
+
`verify_webhook(raw_body, header, secret, tolerance=300, now=None)`; `now` is Unix seconds, for tests.
|
|
88
|
+
Receiver guide: [Webhooks guide](https://www.preflightapi.com/docs/).
|
|
89
|
+
|
|
90
|
+
## Fail-closed behaviour
|
|
91
|
+
|
|
92
|
+
- `check()` defaults to a **300 ms** timeout per attempt. On a timeout, a network error, a 429, a 5xx (after one
|
|
93
|
+
quick retry) or any 4xx it **returns** `{"decision": "block", "reasons": [{"code": "preflight_unavailable", ...}]}` instead of
|
|
94
|
+
raising. Treat it like any other `block`: do not dial or text. A 4xx also carries its HTTP `status` in the reason.
|
|
95
|
+
- A timeout can leave a check the server already recorded as an allowed attempt that you never dialed; it keeps
|
|
96
|
+
counting toward the frequency and volume caps. The block's reason carries the `idempotency_key` the check was sent
|
|
97
|
+
under. Call `pf.release_unplaced(req, d)` (same request, the block decision) within 5 minutes: it replays the key and,
|
|
98
|
+
if the stored check was an allow, reports `not_placed`. Over-counting is the safe direction, so skipping this only
|
|
99
|
+
costs reachable leads. Never use it for a call you placed.
|
|
100
|
+
- There is no fail-open option. The only alternative is `throw_on_error=True` (on the client or per call), which
|
|
101
|
+
raises `PreflightError` (with `status`, `code`, `body`, `retry_after`) so you can handle the failure yourself. Even then, no
|
|
102
|
+
answer means do not dial.
|
|
103
|
+
- Retries: a network error, timeout, 429 or 5xx is retried; any other status is not. `check()` gets one quick retry
|
|
104
|
+
(`check_max_retries`, default 1) and never sleeps longer than its timeout, so a `Retry-After` longer than that ends
|
|
105
|
+
it at once with the block decision: worst case about two timeouts. Every other call gets `max_retries` (default 2)
|
|
106
|
+
with jittered exponential backoff (`retry_base_delay`, default 0.2 s) or the server's `Retry-After` when it sends
|
|
107
|
+
one, up to `max_retry_delay` (default 10 s); a longer `Retry-After` raises `RateLimitError` (or `ServerError` for
|
|
108
|
+
a 503) with `retry_after` so you can schedule the retry.
|
|
109
|
+
- A retry never records a write twice: every attempt of a POST sends the **same** `Idempotency-Key`, and the server
|
|
110
|
+
replays a key it has already answered instead of running the request again. The key is a UUID generated
|
|
111
|
+
per call; when you retry a failed `record_opt_out` (or any write) yourself, pass `idempotency_key=` (your inbound
|
|
112
|
+
message id, or `new_idempotency_key()` kept for the job) so your retries are replays too. The server keeps keys
|
|
113
|
+
for 24 hours: a retry later than that runs again (for an opt-out, a second opt-out event: it still blocks, but the
|
|
114
|
+
audit trail shows it twice).
|
|
115
|
+
- Every method other than `check()` always raises `PreflightError` on failure (default timeout 10 s): swallowing a
|
|
116
|
+
failed opt-out or revocation would be unsafe, so retry it until it succeeds.
|
|
117
|
+
- `import_list_entries` and `create_list` retry like any write; a replayed import returns the first import's counts
|
|
118
|
+
and does not apply the file again.
|
|
119
|
+
- `create_webhook` retries like any write; a replay returns the same signing secret, so a retry never makes a second
|
|
120
|
+
endpoint.
|
|
121
|
+
- `batch()` is screening only (items must not set `commit`). It raises if the job fails or polling exceeds
|
|
122
|
+
`max_wait`; an item the server could not evaluate comes back as `{"error": {...}}`: treat it as do-not-dial.
|
|
123
|
+
|
|
124
|
+
The per-attempt timeout is a total deadline (not per socket read), covering an error response's body too, so a server that drips bytes cannot outlast it. The SDK never follows redirects (a 30x fails closed without a retry and the API key is never sent elsewhere), and `check()` rejects a response whose `reasons` is not a list.
|
|
125
|
+
|
|
126
|
+
## Errors
|
|
127
|
+
|
|
128
|
+
Every failure is a `PreflightError`; the subclass says why: `InvalidRequestError` (400, 422), `AuthenticationError`
|
|
129
|
+
(401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429),
|
|
130
|
+
`ServerError` (5xx), `NetworkError` (no response) and `RequestTimeoutError`. Other statuses (a refused redirect, 413,
|
|
131
|
+
421) are a plain `PreflightError`.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from preflight_sdk import AuthenticationError, PreflightError
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
pf.record_opt_out(opt_out, idempotency_key=inbound_message_id)
|
|
138
|
+
except AuthenticationError:
|
|
139
|
+
alert_ops("Preflight key rejected")
|
|
140
|
+
except PreflightError as e:
|
|
141
|
+
requeue(opt_out, delay=e.retry_after) # keep the same key on the next try
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Requests send `User-Agent: preflight-sdk-python/<SDK_VERSION>`. `SDK_VERSION` and `API_VERSION` (the `/v1` API this
|
|
145
|
+
SDK speaks) are exported.
|
|
146
|
+
|
|
147
|
+
Request and response types (`TypedDict`) are in `preflight_sdk.types`.
|
|
148
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
preflight_sdk/__init__.py,sha256=DKTC5Wl_tIb3O4rTH8DB-v7acSWtvypWROMYOf2uQTM,1336
|
|
2
|
+
preflight_sdk/_version.py,sha256=_PY6d1b1O7YeLRCErRzOn60IKqRXzy14pEJAhflkK2I,225
|
|
3
|
+
preflight_sdk/client.py,sha256=K_sht-trve5DkFd0TJzD_gm6dak00nviBm7Z9Mx_53c,25079
|
|
4
|
+
preflight_sdk/errors.py,sha256=EK7vNchh5C-d775xGDk2O9iyC5XvCr8RUnRCrgBoeb8,2957
|
|
5
|
+
preflight_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
preflight_sdk/types.py,sha256=sgnmCZoArAATPjd4IaCQey6yxiWKjY7hqe_XZuTl4F4,6511
|
|
7
|
+
preflight_sdk/webhooks.py,sha256=Zps2I10rS6VpgZPOHmPYx-FZ7FEfPxHvsGzjPiHwipo,3142
|
|
8
|
+
preflight_sdk-1.0.0.dist-info/licenses/LICENSE,sha256=P6ChiR3AXZTZj_O0VVHfZpD_s66vqdh-je564oqI1A4,1074
|
|
9
|
+
preflight_sdk-1.0.0.dist-info/METADATA,sha256=n_VrWbWgegdELCHDLXxcRai0tyqWjY0rcE8rIDjS6-M,8770
|
|
10
|
+
preflight_sdk-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
11
|
+
preflight_sdk-1.0.0.dist-info/top_level.txt,sha256=iQplzLkBdOzJn85UlQoMvWYs-7T9m8r2OAHWWgt_s68,14
|
|
12
|
+
preflight_sdk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 IBUR Ventures LLC
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
preflight_sdk
|