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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any