parsedock 0.1.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.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: parsedock
3
+ Version: 0.1.0
4
+ Summary: Python client for the ParseDock document-parsing API
5
+ Author: ParseDock maintainers
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/luxiang0412/ParseDock
8
+ Project-URL: Documentation, https://github.com/luxiang0412/ParseDock/blob/main/docs/SDK.md
9
+ Project-URL: Changelog, https://github.com/luxiang0412/ParseDock/blob/main/docs/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/luxiang0412/ParseDock/issues
11
+ Keywords: document-parsing,ocr,pdf,docx,xlsx,markdown,document-ir
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Text Processing
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # ParseDock Python SDK
21
+
22
+ Two layers:
23
+
24
+ 1. **Hand-written lifecycle wrapper** — `parsedock_sdk` (no dependencies, stdlib only).
25
+ Encodes retries, backoff, polling, `result_version` handling, SSE and the
26
+ "ack only after your sink succeeded" rule.
27
+ 2. **Generated client** — `../python-generated/` produced from `api/openapi.yaml`
28
+ by OpenAPI Generator (`make sdk`). Use it for typed models of every IR field;
29
+ regenerate after any spec change instead of editing it.
30
+
31
+ ```python
32
+ from parsedock_sdk import ParseDockClient
33
+
34
+ with ParseDockClient("http://localhost:8080", token="…") as c:
35
+ job = c.create_job("invoice.pdf", mode="accurate", idempotency_key="invoice-42")
36
+ final = c.wait(job["job_id"]) # queued → running → completed
37
+ if final["status"] != "completed":
38
+ raise SystemExit(final.get("error_code"))
39
+
40
+ ir, version = c.download(job["job_id"], fmt="json")
41
+
42
+ # Release only after the real consumer accepted the bytes:
43
+ c.download_and_ack(job["job_id"], fmt="markdown",
44
+ sink=lambda b: open("out.md", "wb").write(b))
45
+
46
+ c.wait_released(job["job_id"]) # released / expired
47
+ ```
48
+
49
+ Why `download_and_ack` is not automatic in `download`: an HTTP 200 means the
50
+ bytes left the server, not that you stored them. If your sink raises, the
51
+ exception propagates and the result stays available for a retry until its TTL.
52
+
53
+ Errors are `ParseDockError(status, code, message)` with `retryable` telling you
54
+ whether a retry can help (`429/5xx`, `coordinator_unavailable`).
55
+
56
+ ## Tested in Phase 0
57
+
58
+ `sdk/python/test_sdk.py` runs the lifecycle against a live stack
59
+ (create → wait → download → ack → 410 → released) and asserts that a failing
60
+ sink does **not** release the result. Run it with `make sdk-test`.
@@ -0,0 +1,41 @@
1
+ # ParseDock Python SDK
2
+
3
+ Two layers:
4
+
5
+ 1. **Hand-written lifecycle wrapper** — `parsedock_sdk` (no dependencies, stdlib only).
6
+ Encodes retries, backoff, polling, `result_version` handling, SSE and the
7
+ "ack only after your sink succeeded" rule.
8
+ 2. **Generated client** — `../python-generated/` produced from `api/openapi.yaml`
9
+ by OpenAPI Generator (`make sdk`). Use it for typed models of every IR field;
10
+ regenerate after any spec change instead of editing it.
11
+
12
+ ```python
13
+ from parsedock_sdk import ParseDockClient
14
+
15
+ with ParseDockClient("http://localhost:8080", token="…") as c:
16
+ job = c.create_job("invoice.pdf", mode="accurate", idempotency_key="invoice-42")
17
+ final = c.wait(job["job_id"]) # queued → running → completed
18
+ if final["status"] != "completed":
19
+ raise SystemExit(final.get("error_code"))
20
+
21
+ ir, version = c.download(job["job_id"], fmt="json")
22
+
23
+ # Release only after the real consumer accepted the bytes:
24
+ c.download_and_ack(job["job_id"], fmt="markdown",
25
+ sink=lambda b: open("out.md", "wb").write(b))
26
+
27
+ c.wait_released(job["job_id"]) # released / expired
28
+ ```
29
+
30
+ Why `download_and_ack` is not automatic in `download`: an HTTP 200 means the
31
+ bytes left the server, not that you stored them. If your sink raises, the
32
+ exception propagates and the result stays available for a retry until its TTL.
33
+
34
+ Errors are `ParseDockError(status, code, message)` with `retryable` telling you
35
+ whether a retry can help (`429/5xx`, `coordinator_unavailable`).
36
+
37
+ ## Tested in Phase 0
38
+
39
+ `sdk/python/test_sdk.py` runs the lifecycle against a live stack
40
+ (create → wait → download → ack → 410 → released) and asserts that a failing
41
+ sink does **not** release the result. Run it with `make sdk-test`.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: parsedock
3
+ Version: 0.1.0
4
+ Summary: Python client for the ParseDock document-parsing API
5
+ Author: ParseDock maintainers
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/luxiang0412/ParseDock
8
+ Project-URL: Documentation, https://github.com/luxiang0412/ParseDock/blob/main/docs/SDK.md
9
+ Project-URL: Changelog, https://github.com/luxiang0412/ParseDock/blob/main/docs/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/luxiang0412/ParseDock/issues
11
+ Keywords: document-parsing,ocr,pdf,docx,xlsx,markdown,document-ir
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Topic :: Text Processing
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+
20
+ # ParseDock Python SDK
21
+
22
+ Two layers:
23
+
24
+ 1. **Hand-written lifecycle wrapper** — `parsedock_sdk` (no dependencies, stdlib only).
25
+ Encodes retries, backoff, polling, `result_version` handling, SSE and the
26
+ "ack only after your sink succeeded" rule.
27
+ 2. **Generated client** — `../python-generated/` produced from `api/openapi.yaml`
28
+ by OpenAPI Generator (`make sdk`). Use it for typed models of every IR field;
29
+ regenerate after any spec change instead of editing it.
30
+
31
+ ```python
32
+ from parsedock_sdk import ParseDockClient
33
+
34
+ with ParseDockClient("http://localhost:8080", token="…") as c:
35
+ job = c.create_job("invoice.pdf", mode="accurate", idempotency_key="invoice-42")
36
+ final = c.wait(job["job_id"]) # queued → running → completed
37
+ if final["status"] != "completed":
38
+ raise SystemExit(final.get("error_code"))
39
+
40
+ ir, version = c.download(job["job_id"], fmt="json")
41
+
42
+ # Release only after the real consumer accepted the bytes:
43
+ c.download_and_ack(job["job_id"], fmt="markdown",
44
+ sink=lambda b: open("out.md", "wb").write(b))
45
+
46
+ c.wait_released(job["job_id"]) # released / expired
47
+ ```
48
+
49
+ Why `download_and_ack` is not automatic in `download`: an HTTP 200 means the
50
+ bytes left the server, not that you stored them. If your sink raises, the
51
+ exception propagates and the result stays available for a retry until its TTL.
52
+
53
+ Errors are `ParseDockError(status, code, message)` with `retryable` telling you
54
+ whether a retry can help (`429/5xx`, `coordinator_unavailable`).
55
+
56
+ ## Tested in Phase 0
57
+
58
+ `sdk/python/test_sdk.py` runs the lifecycle against a live stack
59
+ (create → wait → download → ack → 410 → released) and asserts that a failing
60
+ sink does **not** release the result. Run it with `make sdk-test`.
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ parsedock.egg-info/PKG-INFO
4
+ parsedock.egg-info/SOURCES.txt
5
+ parsedock.egg-info/dependency_links.txt
6
+ parsedock.egg-info/entry_points.txt
7
+ parsedock.egg-info/top_level.txt
8
+ parsedock_sdk/__init__.py
9
+ parsedock_sdk/cli.py
10
+ parsedock_sdk/client.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ parsedock = parsedock_sdk.cli:main
@@ -0,0 +1 @@
1
+ parsedock_sdk
@@ -0,0 +1,5 @@
1
+ """ParseDock Python SDK (Phase 0)."""
2
+ from .client import ParseDockClient, ParseDockError
3
+
4
+ __all__ = ["ParseDockClient", "ParseDockError"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,91 @@
1
+ """Command line entry point: `parsedock parse ./file.pdf -o out.md`.
2
+
3
+ Thin wrapper over ParseDockClient so an operator can smoke-test a deployment without
4
+ writing code. Exit codes: 0 success, 1 job failure, 2 usage/connection error.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+ from .client import ParseDockClient, ParseDockError
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ p = argparse.ArgumentParser(prog="parsedock", description="ParseDock client")
19
+ p.add_argument("--version", action="version", version=f"parsedock {__version__}")
20
+ sub = p.add_subparsers(dest="cmd", required=True)
21
+
22
+ parse = sub.add_parser("parse", help="parse a document and write one representation")
23
+ parse.add_argument("file", help="document to parse")
24
+ parse.add_argument("-o", "--out", default="-", help="output path ('-' = stdout)")
25
+ parse.add_argument("--format", default="markdown", choices=["markdown", "text", "json"])
26
+ parse.add_argument("--mode", default="auto", choices=["fast", "accurate", "auto"])
27
+ parse.add_argument("--device", default="auto",
28
+ choices=["auto", "cpu", "gpu_preferred", "gpu_required"])
29
+ parse.add_argument("--api", default="http://127.0.0.1:8080")
30
+ parse.add_argument("--token", default=None, help="API key (default: PARSEDOCK_TOKEN)")
31
+ parse.add_argument("--timeout", type=float, default=300.0)
32
+ parse.add_argument("--keep", action="store_true",
33
+ help="do not ACK (release) the result after downloading it")
34
+ parse.add_argument("--json-status", action="store_true", help="print the job status as JSON")
35
+
36
+ status = sub.add_parser("status", help="show a job")
37
+ status.add_argument("job_id")
38
+ status.add_argument("--api", default="http://127.0.0.1:8080")
39
+ status.add_argument("--token", default=None)
40
+ return p
41
+
42
+
43
+ def main(argv: list[str] | None = None) -> int:
44
+ args = build_parser().parse_args(argv)
45
+ import os
46
+ token = args.token or os.environ.get("PARSEDOCK_TOKEN", "")
47
+ try:
48
+ with ParseDockClient(args.api, token=token) as client:
49
+ if args.cmd == "status":
50
+ print(json.dumps(client.get_job(args.job_id), indent=2))
51
+ return 0
52
+
53
+ job = client.create_job(args.file, mode=args.mode, device=args.device)
54
+ job_id = job["job_id"]
55
+ final = client.wait(job_id, timeout=args.timeout)
56
+ if args.json_status:
57
+ print(json.dumps(final, indent=2), file=sys.stderr)
58
+ # The job view carries both `status` and `processing_status`; accept either so
59
+ # the CLI keeps working if one of them is ever dropped from the schema.
60
+ status = final.get("processing_status") or final.get("status")
61
+ if status != "completed":
62
+ print(f"job {job_id} ended {status}: "
63
+ f"{final.get('error_code')} {final.get('error_message')}", file=sys.stderr)
64
+ return 1
65
+
66
+ data, version = client.download(job_id, fmt=args.format)
67
+ if args.out == "-":
68
+ sys.stdout.buffer.write(data)
69
+ sys.stdout.flush()
70
+ else:
71
+ Path(args.out).write_bytes(data)
72
+ # The job id goes to stderr (stdout carries the document bytes): it is
73
+ # the only handle for a later `parsedock status`, or for releasing a
74
+ # result kept with --keep.
75
+ print(f"job {job_id} ({final.get('engine', 'unknown')}), "
76
+ f"version {version}, retention "
77
+ f"{'active (--keep)' if args.keep else 'releasing'}", file=sys.stderr)
78
+ if not args.keep:
79
+ # ACK only after the bytes are safely written (the SDK's contract).
80
+ client.ack(job_id, version)
81
+ return 0
82
+ except ParseDockError as e:
83
+ print(f"error: {e.code} ({e.status}): {e.message}", file=sys.stderr)
84
+ return 2
85
+ except OSError as e:
86
+ print(f"error: {e}", file=sys.stderr)
87
+ return 2
88
+
89
+
90
+ if __name__ == "__main__":
91
+ sys.exit(main())
@@ -0,0 +1,358 @@
1
+ """ParseDock Python SDK (Phase 0).
2
+
3
+ A small, dependency-free client that encodes the lifecycle rules ParseDock cares
4
+ about:
5
+
6
+ * create a job (multipart upload, idempotency key),
7
+ * poll or stream events until terminal,
8
+ * download a representation and remember its ``result_version``,
9
+ * **ack only after the caller's sink succeeded** (``download_and_ack``),
10
+ * idempotent ``ack`` / ``cancel``.
11
+
12
+ The generated OpenAPI client in ``sdk/python-generated`` is the machine-readable
13
+ surface; this module is the ergonomic wrapper (retries, backoff, lifecycle).
14
+
15
+ from parsedock_sdk import ParseDockClient, ParseDockError
16
+
17
+ with ParseDockClient("http://localhost:8080", token="…") as client:
18
+ job = client.create_job("invoice.pdf", mode="accurate")
19
+ data, version = client.download(job["job_id"], fmt="json")
20
+
21
+ # Option A: explicit control
22
+ client.ack(job["job_id"], version)
23
+
24
+ # Option B: ack only after your own sink succeeded
25
+ client.download_and_ack(job["job_id"], fmt="markdown", sink=open("out.md", "wb").write)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import mimetypes
32
+ import os
33
+ import random
34
+ import time
35
+ import urllib.error
36
+ import urllib.request
37
+ import uuid
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import Any, Callable, Iterator
41
+
42
+ __all__ = ["ParseDockClient", "ParseDockError", "Job", "TERMINAL", "RETENTION_DONE"]
43
+
44
+ TERMINAL = {"completed", "failed", "cancelled"}
45
+ RETENTION_DONE = {"released", "expired"}
46
+ Job = dict[str, Any]
47
+
48
+
49
+ class ParseDockError(Exception):
50
+ """Error returned by the service (stable ``code`` plus HTTP status)."""
51
+
52
+ def __init__(self, status: int, code: str, message: str, retry_after: float | None = None):
53
+ super().__init__(f"{status} {code}: {message}")
54
+ self.status = status
55
+ self.code = code
56
+ self.message = message
57
+ # Seconds the server asked us to wait (`Retry-After`), when it sent one.
58
+ self.retry_after = retry_after
59
+
60
+ # Codes that share a transient HTTP status but will not clear by retrying.
61
+ # `quota_exceeded` is 429 like `rate_limited`, yet the quota only resets when
62
+ # the rolling window passes or an operator changes it: retrying burns the
63
+ # client's backoff budget and still fails.
64
+ _NOT_RETRYABLE = frozenset({"quota_exceeded", "unsupported_type", "encrypted_file",
65
+ "file_too_large", "malformed_file", "invalid_request",
66
+ "page_limit_exceeded", "expanded_too_large", "gone",
67
+ "not_found", "gpu_unavailable", "engine_unavailable"})
68
+
69
+ @property
70
+ def retryable(self) -> bool:
71
+ if self.code in self._NOT_RETRYABLE:
72
+ return False
73
+ return self.status in (429, 500, 502, 503, 504) or self.code in (
74
+ "coordinator_unavailable",
75
+ "insufficient_storage",
76
+ "rate_limited",
77
+ )
78
+
79
+
80
+ @dataclass
81
+ class _Response:
82
+ status: int
83
+ headers: dict[str, str]
84
+ body: bytes
85
+
86
+
87
+ class ParseDockClient:
88
+ def __init__(
89
+ self,
90
+ base_url: str,
91
+ token: str,
92
+ *,
93
+ timeout: float = 120.0,
94
+ max_retries: int = 4,
95
+ user_agent: str = "parsedock-python-sdk/0.1.0",
96
+ ) -> None:
97
+ self.base_url = base_url.rstrip("/")
98
+ self.token = token
99
+ self.timeout = timeout
100
+ self.max_retries = max_retries
101
+ self.user_agent = user_agent
102
+
103
+ # ------------------------------------------------------------------ plumbing
104
+ def __enter__(self) -> "ParseDockClient":
105
+ return self
106
+
107
+ def __exit__(self, *exc: Any) -> None:
108
+ pass
109
+
110
+ def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
111
+ h = {"Authorization": f"Bearer {self.token}", "User-Agent": self.user_agent}
112
+ if extra:
113
+ h.update(extra)
114
+ return h
115
+
116
+ def _request(
117
+ self,
118
+ method: str,
119
+ path: str,
120
+ body: bytes | None = None,
121
+ headers: dict[str, str] | None = None,
122
+ *,
123
+ timeout: float | None = None,
124
+ retry: bool = True,
125
+ ) -> _Response:
126
+ url = self.base_url + path
127
+ attempt = 0
128
+ while True:
129
+ req = urllib.request.Request(url, data=body, method=method,
130
+ headers=self._headers(headers))
131
+ try:
132
+ with urllib.request.urlopen(req, timeout=timeout or self.timeout) as resp:
133
+ return _Response(resp.status, dict(resp.headers), resp.read())
134
+ except urllib.error.HTTPError as e:
135
+ raw = e.read()
136
+ err = self._to_error(e.code, raw, dict(e.headers or {}))
137
+ if retry and err.retryable and attempt < self.max_retries:
138
+ delay = min(2 ** attempt, 8) + random.random() / 4
139
+ if err.retry_after:
140
+ # The server knows when the window resets; do not guess.
141
+ delay = min(err.retry_after, 30)
142
+ time.sleep(delay)
143
+ attempt += 1
144
+ continue
145
+ raise err
146
+ except urllib.error.URLError as e:
147
+ if retry and attempt < self.max_retries:
148
+ time.sleep(min(2 ** attempt, 8) + random.random() / 4)
149
+ attempt += 1
150
+ continue
151
+ raise ParseDockError(502, "coordinator_unavailable", str(e)) from e
152
+
153
+ @staticmethod
154
+ def _to_error(status: int, raw: bytes, headers: dict[str, str] | None = None) -> ParseDockError:
155
+ retry_after = None
156
+ for k, v in (headers or {}).items():
157
+ if k.lower() == "retry-after":
158
+ try:
159
+ retry_after = float(str(v).strip())
160
+ except ValueError:
161
+ retry_after = None
162
+ try:
163
+ payload = json.loads(raw.decode("utf-8", "replace"))
164
+ err = payload.get("error") or {}
165
+ return ParseDockError(status, err.get("code", "unknown"), err.get("message", ""),
166
+ retry_after=retry_after)
167
+ except Exception:
168
+ return ParseDockError(status, "unknown", raw[:300].decode("utf-8", "replace"),
169
+ retry_after=retry_after)
170
+
171
+ @staticmethod
172
+ def _multipart(fields: dict[str, str], path: str) -> tuple[bytes, str]:
173
+ boundary = "----parsedock" + uuid.uuid4().hex
174
+ name = Path(path).name
175
+ ctype = mimetypes.guess_type(name)[0] or "application/octet-stream"
176
+ out = bytearray()
177
+ for k, v in fields.items():
178
+ out += f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n".encode()
179
+ out += (f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{name}\"\r\n"
180
+ f"Content-Type: {ctype}\r\n\r\n").encode()
181
+ with open(path, "rb") as f:
182
+ out += f.read()
183
+ out += b"\r\n" + f"--{boundary}--\r\n".encode()
184
+ return bytes(out), f"multipart/form-data; boundary={boundary}"
185
+
186
+ # ------------------------------------------------------------------ API
187
+ def create_job(
188
+ self,
189
+ path: str | os.PathLike[str],
190
+ *,
191
+ mode: str = "auto",
192
+ device: str = "auto",
193
+ ttl_seconds: int | None = None,
194
+ idempotency_key: str | None = None,
195
+ priority: int | None = None,
196
+ ) -> Job:
197
+ """Upload and enqueue. Returns the job (``status=queued``).
198
+
199
+ A key is always sent: the one you pass, or a generated one. The client
200
+ retries failed requests, and without a key a retried upload whose first
201
+ response was lost would create a second job that nobody releases. The
202
+ generated key is stable for this logical upload (it lives in the multipart
203
+ body built once), so a retry replays instead of duplicating.
204
+ """
205
+ fields = {"mode": mode, "device": device}
206
+ if ttl_seconds is not None:
207
+ fields["ttl_seconds"] = str(ttl_seconds)
208
+ fields["idempotency_key"] = idempotency_key or uuid.uuid4().hex
209
+ body, ctype = self._multipart(fields, str(path))
210
+ q = ""
211
+ if priority is not None:
212
+ q = f"?priority={int(priority)}"
213
+ resp = self._request("POST", "/v1/jobs" + q, body, {"Content-Type": ctype})
214
+ return json.loads(resp.body)
215
+
216
+ def get_job(self, job_id: str) -> Job:
217
+ return json.loads(self._request("GET", f"/v1/jobs/{job_id}").body)
218
+
219
+ def wait(
220
+ self,
221
+ job_id: str,
222
+ *,
223
+ timeout: float = 600.0,
224
+ interval: float = 0.5,
225
+ max_interval: float = 5.0,
226
+ ) -> Job:
227
+ """Poll until the job leaves queued/running. Returns the final job."""
228
+ deadline = time.monotonic() + timeout
229
+ delay = interval
230
+ while True:
231
+ job = self.get_job(job_id)
232
+ if job.get("status") in TERMINAL:
233
+ return job
234
+ if time.monotonic() >= deadline:
235
+ raise TimeoutError(f"job {job_id} still {job.get('status')} after {timeout}s")
236
+ time.sleep(delay)
237
+ delay = min(delay * 1.5, max_interval)
238
+
239
+ def download(self, job_id: str, fmt: str = "markdown") -> tuple[bytes, str]:
240
+ """Download a representation. Returns ``(body, result_version)``.
241
+
242
+ The version must be passed to :meth:`ack`; it identifies exactly these bytes.
243
+ """
244
+ resp = self._request("GET", f"/v1/jobs/{job_id}/result?format={fmt}", timeout=600)
245
+ # Only X-Result-Version identifies the result for ACK. The ETag validates
246
+ # this representation's bytes and differs per format, so it must never be
247
+ # used as the release token (F05).
248
+ version = resp.headers.get("X-Result-Version", "")
249
+ if not version:
250
+ raise ParseDockError(500, "missing_result_version",
251
+ "server did not return X-Result-Version; cannot ACK safely")
252
+ return resp.body, version
253
+
254
+ def download_and_ack(
255
+ self,
256
+ job_id: str,
257
+ *,
258
+ fmt: str = "markdown",
259
+ sink: Callable[[bytes], Any] | None = None,
260
+ ) -> bytes:
261
+ """Download, hand the bytes to ``sink``, and ACK **only if the sink succeeded**.
262
+
263
+ The sink's contract: when it returns, the bytes are *durably* stored. A
264
+ buffered write that is never closed does not satisfy that - use a context
265
+ manager (``with open(path, "wb") as f: f.write(body)``) or an API whose
266
+ return means the write is committed. Raise from the sink if the write
267
+ failed; the exception propagates, the result is not released, and the call
268
+ can be retried.
269
+
270
+ ``sink=None`` ACKs after the bytes are in this process's memory, which is
271
+ only appropriate when the caller itself is the last consumer.
272
+ """
273
+ body, version = self.download(job_id, fmt=fmt)
274
+ if sink is not None:
275
+ sink(body)
276
+ self.ack(job_id, version)
277
+ return body
278
+
279
+ def ack(self, job_id: str, result_version: str, *, action: str = "release",
280
+ idempotency_key: str | None = None) -> dict[str, Any]:
281
+ """Release the result. Idempotent; ``release_pending`` means "accepted"."""
282
+ headers = {"Content-Type": "application/json"}
283
+ if idempotency_key:
284
+ headers["Idempotency-Key"] = idempotency_key
285
+ body = json.dumps({"result_version": result_version, "action": action}).encode()
286
+ return json.loads(self._request("POST", f"/v1/jobs/{job_id}/ack", body, headers,
287
+ retry=True).body)
288
+
289
+ def cancel(self, job_id: str) -> dict[str, Any]:
290
+ """Cancel a queued/running job. Idempotent.
291
+
292
+ A completed job cannot be cancelled and cancel never releases its result:
293
+ the server returns ``409`` and you release with :meth:`ack`. Releasing is
294
+ deliberately a single path so the outcome cannot depend on a race.
295
+ """
296
+ return json.loads(self._request("POST", f"/v1/jobs/{job_id}/cancel", None).body)
297
+
298
+ def parse_sync(
299
+ self,
300
+ path: str | os.PathLike[str],
301
+ *,
302
+ mode: str = "auto",
303
+ device: str = "auto",
304
+ timeout_ms: int = 30000,
305
+ ) -> dict[str, Any]:
306
+ """Bounded synchronous parse. Returns the Document IR;
307
+ if it returns a job instead (``202``), call ``wait()`` yourself."""
308
+ body, ctype = self._multipart({"mode": mode, "device": device}, str(path))
309
+ resp = self._request(
310
+ "POST", f"/v1/parse?timeout_ms={int(timeout_ms)}", body, {"Content-Type": ctype},
311
+ timeout=timeout_ms / 1000 + 30,
312
+ )
313
+ payload = json.loads(resp.body)
314
+ if resp.status == 202:
315
+ return {"async": True, "job": payload}
316
+ return payload
317
+
318
+ def events(self, job_id: str, *, last_event_id: int = 0) -> Iterator[dict[str, Any]]:
319
+ """Yield SSE events until the server closes the stream."""
320
+ headers = self._headers({"Accept": "text/event-stream"})
321
+ if last_event_id:
322
+ headers["Last-Event-ID"] = str(last_event_id)
323
+ req = urllib.request.Request(self.base_url + f"/v1/jobs/{job_id}/events", headers=headers)
324
+ with urllib.request.urlopen(req, timeout=600) as resp:
325
+ current: dict[str, Any] = {}
326
+ for raw in resp:
327
+ line = raw.decode("utf-8", "replace").rstrip("\n")
328
+ if line.startswith("id:"):
329
+ current["event_id"] = int(line[3:].strip())
330
+ elif line.startswith("event:"):
331
+ current["event_type"] = line[6:].strip()
332
+ elif line.startswith("data:"):
333
+ try:
334
+ current["data"] = json.loads(line[5:].strip())
335
+ except json.JSONDecodeError:
336
+ current["data"] = line[5:].strip()
337
+ elif line == "" and current:
338
+ yield current
339
+ if current.get("event_type") == "stream_end":
340
+ return
341
+ current = {}
342
+
343
+ def wait_released(self, job_id: str, *, timeout: float = 60.0) -> Job:
344
+ """Wait until the cleaner finished releasing the artifact.
345
+
346
+ Raises ``TimeoutError`` if retention did not reach a terminal state within
347
+ ``timeout``: returning the still-pending job made callers announce a
348
+ release that had not happened.
349
+ """
350
+ deadline = time.monotonic() + timeout
351
+ while True:
352
+ job = self.get_job(job_id)
353
+ if job.get("retention_status") in RETENTION_DONE:
354
+ return job
355
+ if time.monotonic() >= deadline:
356
+ raise TimeoutError(
357
+ f"job {job_id} still {job.get('retention_status')} after {timeout}s")
358
+ time.sleep(0.5)
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "parsedock"
7
+ version = "0.1.0"
8
+ description = "Python client for the ParseDock document-parsing API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [{name = "ParseDock maintainers"}]
13
+ keywords = ["document-parsing", "ocr", "pdf", "docx", "xlsx", "markdown", "document-ir"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Text Processing",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/luxiang0412/ParseDock"
25
+ Documentation = "https://github.com/luxiang0412/ParseDock/blob/main/docs/SDK.md"
26
+ Changelog = "https://github.com/luxiang0412/ParseDock/blob/main/docs/CHANGELOG.md"
27
+ Issues = "https://github.com/luxiang0412/ParseDock/issues"
28
+
29
+ [project.scripts]
30
+ parsedock = "parsedock_sdk.cli:main"
31
+
32
+ [tool.setuptools]
33
+ packages = ["parsedock_sdk"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+