thresh-sdk 0.2.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.
- thresh/__init__.py +217 -0
- thresh_sdk-0.2.0.dist-info/METADATA +40 -0
- thresh_sdk-0.2.0.dist-info/RECORD +4 -0
- thresh_sdk-0.2.0.dist-info/WHEEL +4 -0
thresh/__init__.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Thresh — documents in, schema-conformant JSON out.
|
|
2
|
+
|
|
3
|
+
from thresh import Thresh
|
|
4
|
+
|
|
5
|
+
client = Thresh(api_key="th_live_...")
|
|
6
|
+
doc = client.upload("contract.pdf", fields=["purchase_price", "closing_date"],
|
|
7
|
+
questions=["Is the contract contingent upon financing?"])
|
|
8
|
+
result = client.wait(doc.id)
|
|
9
|
+
print(result.fields["purchase_price"])
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import IO, Any, Optional, Union
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
__all__ = ["Thresh", "Document", "Result", "ThreshError", "verify_webhook_signature"]
|
|
25
|
+
|
|
26
|
+
# KEEP IN SYNC with the deployed domain (#50 changes this; bump the SDK version)
|
|
27
|
+
DEFAULT_BASE_URL = "https://usethresh.com"
|
|
28
|
+
DEFAULT_TIMEOUT_S = 30.0
|
|
29
|
+
DEFAULT_POLL_INTERVAL_S = 4.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ThreshError(Exception):
|
|
33
|
+
"""API error with the envelope's type, message, and request id."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, status: int, error_type: str, message: str, request_id: Optional[str]):
|
|
36
|
+
super().__init__(f"{error_type}: {message} (status {status}, request {request_id})")
|
|
37
|
+
self.status = status
|
|
38
|
+
self.error_type = error_type
|
|
39
|
+
self.request_id = request_id
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ExtractionTimeout(ThreshError):
|
|
43
|
+
def __init__(self, document_id: str, waited_s: float):
|
|
44
|
+
Exception.__init__( # bypass ThreshError.__init__: no HTTP envelope here
|
|
45
|
+
self, f"document {document_id} still processing after {waited_s:.0f}s"
|
|
46
|
+
)
|
|
47
|
+
self.status = 0
|
|
48
|
+
self.error_type = "timeout"
|
|
49
|
+
self.request_id = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Document:
|
|
54
|
+
id: str
|
|
55
|
+
filename: str
|
|
56
|
+
status: str
|
|
57
|
+
created_at: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Result:
|
|
62
|
+
id: str
|
|
63
|
+
status: str
|
|
64
|
+
fields: dict = field(default_factory=dict)
|
|
65
|
+
tables: dict = field(default_factory=dict)
|
|
66
|
+
answers: list = field(default_factory=list)
|
|
67
|
+
error: Optional[str] = None
|
|
68
|
+
cost_usd: Optional[float] = None
|
|
69
|
+
latency_s: Optional[float] = None
|
|
70
|
+
|
|
71
|
+
def answer(self, question: str) -> Optional[str]:
|
|
72
|
+
"""The answer for an exact question string, if present."""
|
|
73
|
+
for pair in self.answers:
|
|
74
|
+
if pair.get("question") == question:
|
|
75
|
+
return pair.get("answer")
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _raise_for(res: httpx.Response) -> None:
|
|
80
|
+
if res.is_success:
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
err = res.json().get("error", {})
|
|
84
|
+
except ValueError:
|
|
85
|
+
err = {}
|
|
86
|
+
raise ThreshError(
|
|
87
|
+
res.status_code,
|
|
88
|
+
err.get("type", "api_error"),
|
|
89
|
+
err.get("message", res.text[:200] or res.reason_phrase),
|
|
90
|
+
err.get("request_id"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Thresh:
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
api_key: str,
|
|
98
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
99
|
+
timeout: float = DEFAULT_TIMEOUT_S,
|
|
100
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
101
|
+
):
|
|
102
|
+
if not api_key.startswith("th_"):
|
|
103
|
+
raise ValueError("api_key should look like th_live_...")
|
|
104
|
+
self._http = httpx.Client(
|
|
105
|
+
base_url=base_url.rstrip("/"),
|
|
106
|
+
timeout=timeout,
|
|
107
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
108
|
+
transport=transport,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
self._http.close()
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> "Thresh":
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, *exc: object) -> None:
|
|
118
|
+
self.close()
|
|
119
|
+
|
|
120
|
+
# -- documents -----------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def upload(
|
|
123
|
+
self,
|
|
124
|
+
file: Union[str, Path, IO[bytes]],
|
|
125
|
+
*,
|
|
126
|
+
fields: Optional[list] = None,
|
|
127
|
+
tables: Optional[list] = None,
|
|
128
|
+
questions: Optional[list] = None,
|
|
129
|
+
idempotency_key: Optional[str] = None,
|
|
130
|
+
) -> Document:
|
|
131
|
+
"""Upload a PDF with an extraction spec. Returns the queued document.
|
|
132
|
+
|
|
133
|
+
tables entries are {"name": ..., "columns": [...]}.
|
|
134
|
+
"""
|
|
135
|
+
spec: dict[str, Any] = {
|
|
136
|
+
"fields": fields or [],
|
|
137
|
+
"tables": tables or [],
|
|
138
|
+
"questions": questions or [],
|
|
139
|
+
}
|
|
140
|
+
if not (spec["fields"] or spec["tables"] or spec["questions"]):
|
|
141
|
+
raise ValueError("request at least one field, table, or question")
|
|
142
|
+
|
|
143
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
144
|
+
if isinstance(file, (str, Path)):
|
|
145
|
+
path = Path(file)
|
|
146
|
+
with path.open("rb") as fh:
|
|
147
|
+
res = self._http.post(
|
|
148
|
+
"/v1/documents",
|
|
149
|
+
files={"file": (path.name, fh, "application/pdf")},
|
|
150
|
+
data={"spec": json.dumps(spec)},
|
|
151
|
+
headers=headers,
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
res = self._http.post(
|
|
155
|
+
"/v1/documents",
|
|
156
|
+
files={"file": ("document.pdf", file, "application/pdf")},
|
|
157
|
+
data={"spec": json.dumps(spec)},
|
|
158
|
+
headers=headers,
|
|
159
|
+
)
|
|
160
|
+
_raise_for(res)
|
|
161
|
+
body = res.json()
|
|
162
|
+
return Document(
|
|
163
|
+
id=body["id"],
|
|
164
|
+
filename=body["filename"],
|
|
165
|
+
status=body["status"],
|
|
166
|
+
created_at=body.get("created_at"),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def result(self, document_id: str) -> Result:
|
|
170
|
+
"""Current result snapshot; status tells you if it's done."""
|
|
171
|
+
res = self._http.get(f"/v1/documents/{document_id}/result")
|
|
172
|
+
if res.status_code not in (200, 202):
|
|
173
|
+
_raise_for(res)
|
|
174
|
+
body = res.json()
|
|
175
|
+
extracted = body.get("result") or {}
|
|
176
|
+
return Result(
|
|
177
|
+
id=body["id"],
|
|
178
|
+
status=body["status"],
|
|
179
|
+
fields=extracted.get("fields", {}),
|
|
180
|
+
tables=extracted.get("tables", {}),
|
|
181
|
+
answers=extracted.get("answers", []),
|
|
182
|
+
error=body.get("error"),
|
|
183
|
+
cost_usd=body.get("cost_usd"),
|
|
184
|
+
latency_s=body.get("latency_s"),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def wait(
|
|
188
|
+
self,
|
|
189
|
+
document_id: str,
|
|
190
|
+
timeout: float = 300.0,
|
|
191
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_S,
|
|
192
|
+
) -> Result:
|
|
193
|
+
"""Poll until the document completes or fails."""
|
|
194
|
+
deadline = time.monotonic() + timeout
|
|
195
|
+
while True:
|
|
196
|
+
snapshot = self.result(document_id)
|
|
197
|
+
if snapshot.status in ("completed", "failed"):
|
|
198
|
+
return snapshot
|
|
199
|
+
if time.monotonic() >= deadline:
|
|
200
|
+
raise ExtractionTimeout(document_id, timeout)
|
|
201
|
+
time.sleep(poll_interval)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def verify_webhook_signature(
|
|
205
|
+
secret: str, header: str, body: bytes, tolerance_s: int = 300
|
|
206
|
+
) -> bool:
|
|
207
|
+
"""Verify a Thresh-Signature header: t=<unix>,v1=<hmac-sha256(secret, t.body)>."""
|
|
208
|
+
try:
|
|
209
|
+
parts = dict(kv.split("=", 1) for kv in header.split(","))
|
|
210
|
+
t = int(parts["t"])
|
|
211
|
+
expected = parts["v1"]
|
|
212
|
+
except (KeyError, ValueError):
|
|
213
|
+
return False
|
|
214
|
+
if abs(time.time() - t) > tolerance_s:
|
|
215
|
+
return False
|
|
216
|
+
mac = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
|
|
217
|
+
return hmac.compare_digest(mac, expected)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thresh-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python client for the Thresh document extraction API
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# thresh-sdk
|
|
10
|
+
|
|
11
|
+
Official Python client for the [Thresh](https://github.com/Fillnull/thresh)
|
|
12
|
+
document extraction API — documents in, schema-conformant JSON out.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from thresh import Thresh
|
|
16
|
+
|
|
17
|
+
client = Thresh(api_key="th_live_...")
|
|
18
|
+
doc = client.upload(
|
|
19
|
+
"contract.pdf",
|
|
20
|
+
fields=["purchase_price", "closing_date", "buyer_name"],
|
|
21
|
+
tables=[{"name": "monthly_charges", "columns": ["item", "qty", "mrc"]}],
|
|
22
|
+
questions=["Is the contract contingent upon financing?"],
|
|
23
|
+
)
|
|
24
|
+
result = client.wait(doc.id)
|
|
25
|
+
|
|
26
|
+
result.fields["purchase_price"] # "399900"
|
|
27
|
+
result.tables["monthly_charges"] # [{"item": ..., "qty": ..., "mrc": ...}, ...]
|
|
28
|
+
result.answer("Is the contract contingent upon financing?") # "Yes"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `upload(...)` accepts a path or a binary file object; pass `idempotency_key=`
|
|
32
|
+
to make retries safe.
|
|
33
|
+
- `wait(id, timeout=300)` polls until `completed` or `failed` (failed results
|
|
34
|
+
return with `.error` set — no exception).
|
|
35
|
+
- `verify_webhook_signature(secret, header, body)` validates
|
|
36
|
+
`Thresh-Signature` headers on webhook deliveries.
|
|
37
|
+
- Errors raise `ThreshError` with `.status`, `.error_type`, and `.request_id`.
|
|
38
|
+
|
|
39
|
+
Missing values come back as the string `"null"`; checkbox states as
|
|
40
|
+
`"true"`/`"false"`.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
thresh/__init__.py,sha256=rbHhOvqnodbzrQiq1l_4jFEEtsSBK6cX4N-qtv9p7Qs,7013
|
|
2
|
+
thresh_sdk-0.2.0.dist-info/METADATA,sha256=MqkdcT5El8pqApdhgPSAC5U_AJWd4fVfFgWkePLPPlg,1445
|
|
3
|
+
thresh_sdk-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
4
|
+
thresh_sdk-0.2.0.dist-info/RECORD,,
|