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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thresh-sdk
3
- Version: 0.2.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "thresh-sdk"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Official Python client for the Thresh document extraction API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -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, Any, Optional, Union
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 with an extraction spec. Returns the queued document.
134
+ """Upload a PDF. Returns the queued document.
132
135
 
133
- tables entries are {"name": ..., "columns": [...]}.
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
- 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")
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={"spec": json.dumps(spec)},
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={"spec": json.dumps(spec)},
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 test_upload_requires_nonempty_spec(tmp_path):
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
- with pytest.raises(ValueError):
49
- make_client(lambda r: httpx.Response(500)).upload(pdf)
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"}
@@ -237,7 +237,7 @@ wheels = [
237
237
 
238
238
  [[package]]
239
239
  name = "thresh-sdk"
240
- version = "0.1.0"
240
+ version = "0.4.0"
241
241
  source = { editable = "." }
242
242
  dependencies = [
243
243
  { name = "httpx" },
File without changes