thresh-sdk 0.2.0__tar.gz → 0.3.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.3.0}/PKG-INFO +5 -1
- {thresh_sdk-0.2.0 → thresh_sdk-0.3.0}/README.md +4 -0
- {thresh_sdk-0.2.0 → thresh_sdk-0.3.0}/pyproject.toml +1 -1
- {thresh_sdk-0.2.0 → thresh_sdk-0.3.0}/src/thresh/__init__.py +53 -12
- {thresh_sdk-0.2.0 → thresh_sdk-0.3.0}/tests/test_sdk.py +62 -3
- {thresh_sdk-0.2.0 → thresh_sdk-0.3.0}/uv.lock +1 -1
- {thresh_sdk-0.2.0 → thresh_sdk-0.3.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.3.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
|
|
|
@@ -126,19 +126,27 @@ class Thresh:
|
|
|
126
126
|
fields: Optional[list] = None,
|
|
127
127
|
tables: Optional[list] = None,
|
|
128
128
|
questions: Optional[list] = None,
|
|
129
|
+
template: Optional[str] = None,
|
|
129
130
|
idempotency_key: Optional[str] = None,
|
|
130
131
|
) -> Document:
|
|
131
|
-
"""Upload a PDF
|
|
132
|
+
"""Upload a PDF. Returns the queued document.
|
|
132
133
|
|
|
133
|
-
|
|
134
|
+
Three modes: pass fields/tables/questions for a spec'd extraction,
|
|
135
|
+
template= to reuse a saved template by name, or neither to let the
|
|
136
|
+
engine discover the schema (auto-extraction). tables entries are
|
|
137
|
+
{"name": ..., "columns": [...]}.
|
|
134
138
|
"""
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
139
|
+
has_spec = bool(fields or tables or questions)
|
|
140
|
+
if has_spec and template:
|
|
141
|
+
raise ValueError("pass a spec or template=, not both")
|
|
142
|
+
data: dict[str, str] = {}
|
|
143
|
+
if has_spec:
|
|
144
|
+
data["spec"] = json.dumps(
|
|
145
|
+
{"fields": fields or [], "tables": tables or [], "questions": questions or []}
|
|
146
|
+
)
|
|
147
|
+
elif template:
|
|
148
|
+
data["template"] = template
|
|
149
|
+
# neither: auto-extraction discovers the schema
|
|
142
150
|
|
|
143
151
|
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
144
152
|
if isinstance(file, (str, Path)):
|
|
@@ -147,14 +155,14 @@ class Thresh:
|
|
|
147
155
|
res = self._http.post(
|
|
148
156
|
"/v1/documents",
|
|
149
157
|
files={"file": (path.name, fh, "application/pdf")},
|
|
150
|
-
data=
|
|
158
|
+
data=data,
|
|
151
159
|
headers=headers,
|
|
152
160
|
)
|
|
153
161
|
else:
|
|
154
162
|
res = self._http.post(
|
|
155
163
|
"/v1/documents",
|
|
156
164
|
files={"file": ("document.pdf", file, "application/pdf")},
|
|
157
|
-
data=
|
|
165
|
+
data=data,
|
|
158
166
|
headers=headers,
|
|
159
167
|
)
|
|
160
168
|
_raise_for(res)
|
|
@@ -166,6 +174,39 @@ class Thresh:
|
|
|
166
174
|
created_at=body.get("created_at"),
|
|
167
175
|
)
|
|
168
176
|
|
|
177
|
+
def create_template(
|
|
178
|
+
self,
|
|
179
|
+
name: str,
|
|
180
|
+
*,
|
|
181
|
+
fields: Optional[list] = None,
|
|
182
|
+
tables: Optional[list] = None,
|
|
183
|
+
questions: Optional[list] = None,
|
|
184
|
+
) -> dict:
|
|
185
|
+
"""Save an extraction spec as a named template for reuse via upload(template=...)."""
|
|
186
|
+
res = self._http.post(
|
|
187
|
+
"/v1/templates",
|
|
188
|
+
json={
|
|
189
|
+
"name": name,
|
|
190
|
+
"spec": {
|
|
191
|
+
"fields": fields or [],
|
|
192
|
+
"tables": tables or [],
|
|
193
|
+
"questions": questions or [],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
_raise_for(res)
|
|
198
|
+
return res.json()
|
|
199
|
+
|
|
200
|
+
def templates(self) -> list:
|
|
201
|
+
"""List this workspace's saved templates."""
|
|
202
|
+
res = self._http.get("/v1/templates")
|
|
203
|
+
_raise_for(res)
|
|
204
|
+
return res.json()["data"]
|
|
205
|
+
|
|
206
|
+
def delete_template(self, template_id: str) -> None:
|
|
207
|
+
res = self._http.delete(f"/v1/templates/{template_id}")
|
|
208
|
+
_raise_for(res)
|
|
209
|
+
|
|
169
210
|
def result(self, document_id: str) -> Result:
|
|
170
211
|
"""Current result snapshot; status tells you if it's done."""
|
|
171
212
|
res = self._http.get(f"/v1/documents/{document_id}/result")
|
|
@@ -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,51 @@ 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
|
+
]
|
|
File without changes
|