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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thresh-sdk
3
- Version: 0.2.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "thresh-sdk"
3
- version = "0.2.0"
3
+ version = "0.3.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
 
@@ -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 with an extraction spec. Returns the queued document.
132
+ """Upload a PDF. Returns the queued document.
132
133
 
133
- tables entries are {"name": ..., "columns": [...]}.
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
- 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")
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={"spec": json.dumps(spec)},
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={"spec": json.dumps(spec)},
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 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,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
+ ]
@@ -237,7 +237,7 @@ wheels = [
237
237
 
238
238
  [[package]]
239
239
  name = "thresh-sdk"
240
- version = "0.1.0"
240
+ version = "0.3.0"
241
241
  source = { editable = "." }
242
242
  dependencies = [
243
243
  { name = "httpx" },
File without changes