thresh-sdk 0.2.0__tar.gz → 0.4.0__tar.gz
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_sdk-0.2.0 → thresh_sdk-0.4.0}/PKG-INFO +5 -1
- {thresh_sdk-0.2.0 → thresh_sdk-0.4.0}/README.md +4 -0
- {thresh_sdk-0.2.0 → thresh_sdk-0.4.0}/pyproject.toml +1 -1
- {thresh_sdk-0.2.0 → thresh_sdk-0.4.0}/src/thresh/__init__.py +56 -12
- {thresh_sdk-0.2.0 → thresh_sdk-0.4.0}/tests/test_sdk.py +80 -3
- {thresh_sdk-0.2.0 → thresh_sdk-0.4.0}/uv.lock +1 -1
- {thresh_sdk-0.2.0 → thresh_sdk-0.4.0}/.gitignore +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: thresh-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Official Python client for the Thresh document extraction API
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Requires-Dist: httpx>=0.27
|
|
@@ -30,6 +30,10 @@ result.answer("Is the contract contingent upon financing?") # "Yes"
|
|
|
30
30
|
|
|
31
31
|
- `upload(...)` accepts a path or a binary file object; pass `idempotency_key=`
|
|
32
32
|
to make retries safe.
|
|
33
|
+
- No schema? `client.upload("contract.pdf")` runs auto-extraction: the engine
|
|
34
|
+
discovers and names the fields and tables itself. Refine what it found, then
|
|
35
|
+
`client.create_template("sale-contract", fields=[...])` and reuse it with
|
|
36
|
+
`client.upload("next.pdf", template="sale-contract")`.
|
|
33
37
|
- `wait(id, timeout=300)` polls until `completed` or `failed` (failed results
|
|
34
38
|
return with `.error` set — no exception).
|
|
35
39
|
- `verify_webhook_signature(secret, header, body)` validates
|
|
@@ -22,6 +22,10 @@ result.answer("Is the contract contingent upon financing?") # "Yes"
|
|
|
22
22
|
|
|
23
23
|
- `upload(...)` accepts a path or a binary file object; pass `idempotency_key=`
|
|
24
24
|
to make retries safe.
|
|
25
|
+
- No schema? `client.upload("contract.pdf")` runs auto-extraction: the engine
|
|
26
|
+
discovers and names the fields and tables itself. Refine what it found, then
|
|
27
|
+
`client.create_template("sale-contract", fields=[...])` and reuse it with
|
|
28
|
+
`client.upload("next.pdf", template="sale-contract")`.
|
|
25
29
|
- `wait(id, timeout=300)` polls until `completed` or `failed` (failed results
|
|
26
30
|
return with `.error` set — no exception).
|
|
27
31
|
- `verify_webhook_signature(secret, header, body)` validates
|
|
@@ -17,7 +17,7 @@ import json
|
|
|
17
17
|
import time
|
|
18
18
|
from dataclasses import dataclass, field
|
|
19
19
|
from pathlib import Path
|
|
20
|
-
from typing import IO,
|
|
20
|
+
from typing import IO, Optional, Union
|
|
21
21
|
|
|
22
22
|
import httpx
|
|
23
23
|
|
|
@@ -62,6 +62,8 @@ class Result:
|
|
|
62
62
|
id: str
|
|
63
63
|
status: str
|
|
64
64
|
fields: dict = field(default_factory=dict)
|
|
65
|
+
# per-field provenance: name -> {"page": int|None, "confidence": "high"|"low"}
|
|
66
|
+
field_meta: dict = field(default_factory=dict)
|
|
65
67
|
tables: dict = field(default_factory=dict)
|
|
66
68
|
answers: list = field(default_factory=list)
|
|
67
69
|
error: Optional[str] = None
|
|
@@ -126,19 +128,27 @@ class Thresh:
|
|
|
126
128
|
fields: Optional[list] = None,
|
|
127
129
|
tables: Optional[list] = None,
|
|
128
130
|
questions: Optional[list] = None,
|
|
131
|
+
template: Optional[str] = None,
|
|
129
132
|
idempotency_key: Optional[str] = None,
|
|
130
133
|
) -> Document:
|
|
131
|
-
"""Upload a PDF
|
|
134
|
+
"""Upload a PDF. Returns the queued document.
|
|
132
135
|
|
|
133
|
-
|
|
136
|
+
Three modes: pass fields/tables/questions for a spec'd extraction,
|
|
137
|
+
template= to reuse a saved template by name, or neither to let the
|
|
138
|
+
engine discover the schema (auto-extraction). tables entries are
|
|
139
|
+
{"name": ..., "columns": [...]}.
|
|
134
140
|
"""
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
has_spec = bool(fields or tables or questions)
|
|
142
|
+
if has_spec and template:
|
|
143
|
+
raise ValueError("pass a spec or template=, not both")
|
|
144
|
+
data: dict[str, str] = {}
|
|
145
|
+
if has_spec:
|
|
146
|
+
data["spec"] = json.dumps(
|
|
147
|
+
{"fields": fields or [], "tables": tables or [], "questions": questions or []}
|
|
148
|
+
)
|
|
149
|
+
elif template:
|
|
150
|
+
data["template"] = template
|
|
151
|
+
# neither: auto-extraction discovers the schema
|
|
142
152
|
|
|
143
153
|
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
144
154
|
if isinstance(file, (str, Path)):
|
|
@@ -147,14 +157,14 @@ class Thresh:
|
|
|
147
157
|
res = self._http.post(
|
|
148
158
|
"/v1/documents",
|
|
149
159
|
files={"file": (path.name, fh, "application/pdf")},
|
|
150
|
-
data=
|
|
160
|
+
data=data,
|
|
151
161
|
headers=headers,
|
|
152
162
|
)
|
|
153
163
|
else:
|
|
154
164
|
res = self._http.post(
|
|
155
165
|
"/v1/documents",
|
|
156
166
|
files={"file": ("document.pdf", file, "application/pdf")},
|
|
157
|
-
data=
|
|
167
|
+
data=data,
|
|
158
168
|
headers=headers,
|
|
159
169
|
)
|
|
160
170
|
_raise_for(res)
|
|
@@ -166,6 +176,39 @@ class Thresh:
|
|
|
166
176
|
created_at=body.get("created_at"),
|
|
167
177
|
)
|
|
168
178
|
|
|
179
|
+
def create_template(
|
|
180
|
+
self,
|
|
181
|
+
name: str,
|
|
182
|
+
*,
|
|
183
|
+
fields: Optional[list] = None,
|
|
184
|
+
tables: Optional[list] = None,
|
|
185
|
+
questions: Optional[list] = None,
|
|
186
|
+
) -> dict:
|
|
187
|
+
"""Save an extraction spec as a named template for reuse via upload(template=...)."""
|
|
188
|
+
res = self._http.post(
|
|
189
|
+
"/v1/templates",
|
|
190
|
+
json={
|
|
191
|
+
"name": name,
|
|
192
|
+
"spec": {
|
|
193
|
+
"fields": fields or [],
|
|
194
|
+
"tables": tables or [],
|
|
195
|
+
"questions": questions or [],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
_raise_for(res)
|
|
200
|
+
return res.json()
|
|
201
|
+
|
|
202
|
+
def templates(self) -> list:
|
|
203
|
+
"""List this workspace's saved templates."""
|
|
204
|
+
res = self._http.get("/v1/templates")
|
|
205
|
+
_raise_for(res)
|
|
206
|
+
return res.json()["data"]
|
|
207
|
+
|
|
208
|
+
def delete_template(self, template_id: str) -> None:
|
|
209
|
+
res = self._http.delete(f"/v1/templates/{template_id}")
|
|
210
|
+
_raise_for(res)
|
|
211
|
+
|
|
169
212
|
def result(self, document_id: str) -> Result:
|
|
170
213
|
"""Current result snapshot; status tells you if it's done."""
|
|
171
214
|
res = self._http.get(f"/v1/documents/{document_id}/result")
|
|
@@ -177,6 +220,7 @@ class Thresh:
|
|
|
177
220
|
id=body["id"],
|
|
178
221
|
status=body["status"],
|
|
179
222
|
fields=extracted.get("fields", {}),
|
|
223
|
+
field_meta=extracted.get("field_meta", {}),
|
|
180
224
|
tables=extracted.get("tables", {}),
|
|
181
225
|
answers=extracted.get("answers", []),
|
|
182
226
|
error=body.get("error"),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import io
|
|
2
3
|
import time
|
|
3
4
|
|
|
@@ -42,11 +43,21 @@ def test_upload_sends_spec_and_parses(tmp_path):
|
|
|
42
43
|
assert seen == {"auth": "Bearer th_live_test", "idem": "abc", "has_spec": True}
|
|
43
44
|
|
|
44
45
|
|
|
45
|
-
def
|
|
46
|
+
def test_upload_without_spec_is_auto(tmp_path):
|
|
46
47
|
pdf = tmp_path / "c.pdf"
|
|
47
48
|
pdf.write_bytes(b"%PDF")
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
seen = {}
|
|
50
|
+
|
|
51
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
52
|
+
seen["body"] = request.read()
|
|
53
|
+
return httpx.Response(
|
|
54
|
+
202, json={"id": "d1", "object": "document", "filename": "c.pdf",
|
|
55
|
+
"status": "queued", "created_at": "2026-07-17T00:00:00"}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
doc = make_client(handler).upload(pdf)
|
|
59
|
+
assert doc.id == "d1"
|
|
60
|
+
assert b'name="spec"' not in seen["body"]
|
|
50
61
|
|
|
51
62
|
|
|
52
63
|
def test_upload_accepts_file_object():
|
|
@@ -127,3 +138,69 @@ def test_webhook_signature_roundtrip():
|
|
|
127
138
|
assert not verify_webhook_signature("whsec_s", header, b'{"a":2}')
|
|
128
139
|
assert not verify_webhook_signature("whsec_other", header, body)
|
|
129
140
|
assert not verify_webhook_signature("whsec_s", "junk", body)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_upload_auto_and_template_modes():
|
|
144
|
+
seen = {}
|
|
145
|
+
|
|
146
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
147
|
+
seen["content_type"] = request.headers.get("content-type", "")
|
|
148
|
+
seen["body"] = request.read()
|
|
149
|
+
return httpx.Response(
|
|
150
|
+
202, json={"id": "d1", "object": "document", "filename": "f", "status": "queued",
|
|
151
|
+
"created_at": "2026-07-17T00:00:00"}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
client = Thresh(api_key="th_live_x", transport=httpx.MockTransport(handler))
|
|
155
|
+
client.upload(io.BytesIO(b"%PDF")) # auto: no spec, no template form fields
|
|
156
|
+
assert b'name="spec"' not in seen["body"] and b'name="template"' not in seen["body"]
|
|
157
|
+
|
|
158
|
+
client.upload(io.BytesIO(b"%PDF"), template="contract")
|
|
159
|
+
assert b'name="template"' in seen["body"] and b"contract" in seen["body"]
|
|
160
|
+
assert b'name="spec"' not in seen["body"]
|
|
161
|
+
|
|
162
|
+
with pytest.raises(ValueError):
|
|
163
|
+
client.upload(io.BytesIO(b"%PDF"), fields=["x"], template="contract")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_template_crud_helpers():
|
|
167
|
+
calls = []
|
|
168
|
+
|
|
169
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
170
|
+
calls.append((request.method, request.url.path))
|
|
171
|
+
if request.method == "POST":
|
|
172
|
+
body = json.loads(request.read())
|
|
173
|
+
assert body["name"] == "contract" and body["spec"]["fields"] == ["price"]
|
|
174
|
+
return httpx.Response(201, json={"id": "t1", "name": "contract", "spec": body["spec"]})
|
|
175
|
+
if request.method == "GET":
|
|
176
|
+
return httpx.Response(200, json={"data": [{"id": "t1", "name": "contract"}]})
|
|
177
|
+
return httpx.Response(204)
|
|
178
|
+
|
|
179
|
+
client = Thresh(api_key="th_live_x", transport=httpx.MockTransport(handler))
|
|
180
|
+
created = client.create_template("contract", fields=["price"])
|
|
181
|
+
assert created["id"] == "t1"
|
|
182
|
+
assert client.templates()[0]["name"] == "contract"
|
|
183
|
+
client.delete_template("t1")
|
|
184
|
+
assert calls == [
|
|
185
|
+
("POST", "/v1/templates"),
|
|
186
|
+
("GET", "/v1/templates"),
|
|
187
|
+
("DELETE", "/v1/templates/t1"),
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_result_carries_field_meta():
|
|
192
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
193
|
+
return httpx.Response(200, json={
|
|
194
|
+
"id": "d1", "object": "document", "filename": "f", "status": "completed",
|
|
195
|
+
"created_at": "2026-07-19T00:00:00",
|
|
196
|
+
"result": {
|
|
197
|
+
"fields": {"price": "100"},
|
|
198
|
+
"field_meta": {"price": {"page": 3, "confidence": "low"}},
|
|
199
|
+
"tables": {}, "answers": [],
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
client = Thresh(api_key="th_live_x", transport=httpx.MockTransport(handler))
|
|
204
|
+
result = client.result("d1")
|
|
205
|
+
assert result.fields["price"] == "100"
|
|
206
|
+
assert result.field_meta["price"] == {"page": 3, "confidence": "low"}
|
|
File without changes
|