fastocr-sdk 0.1.0__py3-none-any.whl
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.
- fastocr_sdk/__init__.py +53 -0
- fastocr_sdk/_core.py +318 -0
- fastocr_sdk/_version.py +1 -0
- fastocr_sdk/async_client.py +290 -0
- fastocr_sdk/client.py +289 -0
- fastocr_sdk/errors.py +145 -0
- fastocr_sdk/models.py +140 -0
- fastocr_sdk/py.typed +0 -0
- fastocr_sdk-0.1.0.dist-info/METADATA +285 -0
- fastocr_sdk-0.1.0.dist-info/RECORD +12 -0
- fastocr_sdk-0.1.0.dist-info/WHEEL +4 -0
- fastocr_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
fastocr_sdk/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""FastOCR Python SDK for the Document OCR API."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .async_client import AsyncFastOCR
|
|
5
|
+
from .client import FastOCR
|
|
6
|
+
from .errors import (
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
BadRequestError,
|
|
10
|
+
DocumentConflictError,
|
|
11
|
+
DocumentFailedError,
|
|
12
|
+
DownloadError,
|
|
13
|
+
FastOCRError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PartialResultError,
|
|
16
|
+
PollingTimeoutError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ServerError,
|
|
19
|
+
UploadError,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
DocumentCreateResponse,
|
|
23
|
+
DocumentJob,
|
|
24
|
+
DocumentListResponse,
|
|
25
|
+
DocumentOutputs,
|
|
26
|
+
DocumentOutputUrl,
|
|
27
|
+
JobError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
"FastOCR",
|
|
33
|
+
"AsyncFastOCR",
|
|
34
|
+
"DocumentJob",
|
|
35
|
+
"DocumentCreateResponse",
|
|
36
|
+
"DocumentOutputs",
|
|
37
|
+
"DocumentOutputUrl",
|
|
38
|
+
"DocumentListResponse",
|
|
39
|
+
"JobError",
|
|
40
|
+
"FastOCRError",
|
|
41
|
+
"APIConnectionError",
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"BadRequestError",
|
|
44
|
+
"DocumentConflictError",
|
|
45
|
+
"DocumentFailedError",
|
|
46
|
+
"DownloadError",
|
|
47
|
+
"NotFoundError",
|
|
48
|
+
"PartialResultError",
|
|
49
|
+
"PollingTimeoutError",
|
|
50
|
+
"RateLimitError",
|
|
51
|
+
"ServerError",
|
|
52
|
+
"UploadError",
|
|
53
|
+
]
|
fastocr_sdk/_core.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Logic shared by the sync and async clients. No I/O happens here."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, AsyncIterator, BinaryIO, Dict, FrozenSet, Iterator, Optional, Tuple, Union
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .errors import (
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
BadRequestError,
|
|
17
|
+
DocumentConflictError,
|
|
18
|
+
DocumentFailedError,
|
|
19
|
+
DownloadError,
|
|
20
|
+
FastOCRError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
PartialResultError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
ServerError,
|
|
25
|
+
UploadError,
|
|
26
|
+
)
|
|
27
|
+
from .models import DocumentJob, JobError
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://api.fastocr.org"
|
|
30
|
+
DEFAULT_MAX_RETRIES = 2
|
|
31
|
+
MAX_RETRY_DELAY = 60.0
|
|
32
|
+
BACKOFF_BASE = 0.5
|
|
33
|
+
THROTTLE_BACKOFF_BASE = 1.0
|
|
34
|
+
BACKOFF_CAP = 8.0
|
|
35
|
+
CHUNK_SIZE = 1024 * 1024
|
|
36
|
+
TRANSFER_TIMEOUT = httpx.Timeout(120.0, connect=10.0)
|
|
37
|
+
|
|
38
|
+
API_RETRY_STATUSES: FrozenSet[int] = frozenset({429, 502, 503, 504})
|
|
39
|
+
TRANSFER_RETRY_STATUSES: FrozenSet[int] = frozenset({500, 502, 503, 504})
|
|
40
|
+
|
|
41
|
+
FileInput = Union[str, Path, bytes, BinaryIO]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_api_key(api_key: Optional[str]) -> str:
|
|
45
|
+
key = (api_key or os.environ.get("FASTOCR_API_KEY") or "").strip()
|
|
46
|
+
if not key:
|
|
47
|
+
raise AuthenticationError("API key must be provided or set via FASTOCR_API_KEY environment variable")
|
|
48
|
+
return key
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_base_url(base_url: Optional[str]) -> str:
|
|
52
|
+
return (base_url or os.environ.get("FASTOCR_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def default_headers(api_key: str) -> Dict[str, str]:
|
|
56
|
+
return {
|
|
57
|
+
"Authorization": f"Bearer {api_key}",
|
|
58
|
+
"User-Agent": f"fastocr-python/{__version__}",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def new_idempotency_key() -> str:
|
|
63
|
+
return str(uuid.uuid4())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_pdf_filename(filename: str) -> None:
|
|
67
|
+
if not filename.lower().endswith(".pdf"):
|
|
68
|
+
raise ValueError("FastOCR Document API accepts PDF files only (.pdf)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_output_format(file_format: str) -> None:
|
|
72
|
+
if file_format not in ("text", "pdf"):
|
|
73
|
+
raise ValueError("file_format must be either 'text' or 'pdf'")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def create_payload(
|
|
77
|
+
filename: str, size_bytes: int, external_id: Optional[str], sha256: Optional[str]
|
|
78
|
+
) -> Dict[str, Any]:
|
|
79
|
+
check_pdf_filename(filename)
|
|
80
|
+
payload: Dict[str, Any] = {"filename": filename, "size_bytes": size_bytes}
|
|
81
|
+
if external_id is not None:
|
|
82
|
+
payload["external_id"] = external_id
|
|
83
|
+
if sha256 is not None:
|
|
84
|
+
payload["sha256"] = sha256
|
|
85
|
+
return payload
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def list_params(
|
|
89
|
+
limit: Optional[int], status: Optional[str], cursor: Optional[str], updated_since: Optional[str]
|
|
90
|
+
) -> Dict[str, Any]:
|
|
91
|
+
values = {"limit": limit, "status": status, "cursor": cursor, "updated_since": updated_since}
|
|
92
|
+
return {name: value for name, value in values.items() if value is not None}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def document_path(document_id: str, suffix: str = "") -> str:
|
|
96
|
+
return f"/v1/documents/{quote(document_id, safe='')}{suffix}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_json(response: httpx.Response) -> Dict[str, Any]:
|
|
100
|
+
if not response.content:
|
|
101
|
+
return {}
|
|
102
|
+
try:
|
|
103
|
+
body = response.json()
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
raise ServerError(
|
|
106
|
+
"FastOCR returned a response that is not valid JSON",
|
|
107
|
+
status_code=response.status_code,
|
|
108
|
+
) from exc
|
|
109
|
+
if not isinstance(body, dict):
|
|
110
|
+
raise ServerError(
|
|
111
|
+
"FastOCR returned a response that is not a JSON object",
|
|
112
|
+
status_code=response.status_code,
|
|
113
|
+
)
|
|
114
|
+
return body
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
118
|
+
try:
|
|
119
|
+
seconds = float(value) if value is not None else None
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
return seconds if seconds is not None and seconds >= 0 else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def error_from_response(response: httpx.Response) -> FastOCRError:
|
|
126
|
+
"""Build the right exception from a non-2xx API response.
|
|
127
|
+
|
|
128
|
+
Handlers send {"error": {type, code, message, request_id}}. API Gateway sends
|
|
129
|
+
{"message": ...} (an `error` object may be added next to it).
|
|
130
|
+
"""
|
|
131
|
+
status = response.status_code
|
|
132
|
+
try:
|
|
133
|
+
body = response.json()
|
|
134
|
+
except ValueError:
|
|
135
|
+
body = None
|
|
136
|
+
details = body if isinstance(body, dict) else {}
|
|
137
|
+
nested = details.get("error")
|
|
138
|
+
fields = nested if isinstance(nested, dict) else {}
|
|
139
|
+
message = (
|
|
140
|
+
fields.get("message")
|
|
141
|
+
or details.get("message")
|
|
142
|
+
or (nested if isinstance(nested, str) else None)
|
|
143
|
+
or (response.text[:200].strip() if body is None else "")
|
|
144
|
+
or f"HTTP {status}"
|
|
145
|
+
)
|
|
146
|
+
options: Dict[str, Any] = {
|
|
147
|
+
"status_code": status,
|
|
148
|
+
"code": fields.get("code") or details.get("code"),
|
|
149
|
+
"type": fields.get("type"),
|
|
150
|
+
"request_id": fields.get("request_id") or response.headers.get("x-amzn-requestid"),
|
|
151
|
+
"details": details,
|
|
152
|
+
}
|
|
153
|
+
message = str(message)
|
|
154
|
+
if status == 429:
|
|
155
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
156
|
+
return RateLimitError(message, retry_after=retry_after, **options)
|
|
157
|
+
if status in (401, 403):
|
|
158
|
+
return AuthenticationError(message, **options)
|
|
159
|
+
error_class = {400: BadRequestError, 404: NotFoundError, 409: DocumentConflictError}.get(status)
|
|
160
|
+
if error_class is None:
|
|
161
|
+
error_class = ServerError if status >= 500 else FastOCRError
|
|
162
|
+
return error_class(message, **options)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def transfer_error(error_class: type, action: str, response: httpx.Response) -> FastOCRError:
|
|
166
|
+
code = re.search(r"<Code>([^<]+)</Code>", response.text[:2000])
|
|
167
|
+
detail = f" ({code.group(1)})" if code else ""
|
|
168
|
+
return error_class(
|
|
169
|
+
f"Failed to {action}: status {response.status_code}{detail}",
|
|
170
|
+
status_code=response.status_code,
|
|
171
|
+
code=code.group(1) if code else None,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def upload_error_from_response(response: httpx.Response) -> FastOCRError:
|
|
176
|
+
return transfer_error(UploadError, "upload the document to the presigned URL", response)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def download_error_from_response(response: httpx.Response) -> FastOCRError:
|
|
180
|
+
return transfer_error(DownloadError, "download the result from the presigned URL", response)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def retry_delay(
|
|
184
|
+
error: FastOCRError, attempt: int, max_retries: int, statuses: FrozenSet[int]
|
|
185
|
+
) -> Optional[float]:
|
|
186
|
+
"""Seconds to wait before retrying, or None if this error must be raised."""
|
|
187
|
+
if attempt >= max_retries:
|
|
188
|
+
return None
|
|
189
|
+
if error.status_code is not None and error.status_code not in statuses:
|
|
190
|
+
return None
|
|
191
|
+
retry_after = getattr(error, "retry_after", None)
|
|
192
|
+
if retry_after is not None:
|
|
193
|
+
return retry_after if retry_after <= MAX_RETRY_DELAY else None
|
|
194
|
+
base = THROTTLE_BACKOFF_BASE if error.status_code == 429 else BACKOFF_BASE
|
|
195
|
+
ceiling = min(BACKOFF_CAP, base * 2 ** attempt)
|
|
196
|
+
return ceiling / 2 + random.uniform(0, ceiling / 2)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def upload_headers(size: int, sha256: Optional[str]) -> Dict[str, str]:
|
|
200
|
+
# S3 rejects chunked uploads, so Content-Length is always explicit.
|
|
201
|
+
headers = {"Content-Type": "application/pdf", "Content-Length": str(size)}
|
|
202
|
+
if sha256 is not None:
|
|
203
|
+
headers["x-amz-checksum-sha256"] = sha256
|
|
204
|
+
return headers
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def failed_error(job: DocumentJob) -> DocumentFailedError:
|
|
208
|
+
error = job.error or JobError(code="processing_failed", message="Document processing failed", retryable=True)
|
|
209
|
+
return DocumentFailedError(
|
|
210
|
+
error.message or "Document processing failed",
|
|
211
|
+
document_id=job.id,
|
|
212
|
+
code=error.code,
|
|
213
|
+
retryable=error.retryable,
|
|
214
|
+
type=error.type or None,
|
|
215
|
+
details=job.raw.get("error") if isinstance(job.raw.get("error"), dict) else None,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def partial_error(job: DocumentJob) -> PartialResultError:
|
|
220
|
+
done = job.pages_billed if job.pages_billed is not None else job.pages_processed
|
|
221
|
+
scope = "only partly processed"
|
|
222
|
+
if done is not None:
|
|
223
|
+
scope = f"only {done}" + (f" of {job.pages_total}" if job.pages_total is not None else "") + " pages were processed"
|
|
224
|
+
return PartialResultError(
|
|
225
|
+
f"Document {job.id} is partial: {scope} because the account ran out of pages. "
|
|
226
|
+
"Use documents.get_text() for the partial text, or pass allow_partial=True.",
|
|
227
|
+
document_id=job.id,
|
|
228
|
+
pages_processed=job.pages_processed,
|
|
229
|
+
pages_billed=job.pages_billed,
|
|
230
|
+
pages_total=job.pages_total,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class UploadSource:
|
|
235
|
+
"""A file to upload: its size, and a fresh body for every attempt.
|
|
236
|
+
|
|
237
|
+
Paths and seekable streams are read in chunks, never loaded whole. A stream
|
|
238
|
+
that cannot seek has no known size, so it is read into memory.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(self, file: FileInput):
|
|
242
|
+
self._data: Optional[bytes] = None
|
|
243
|
+
self._path: Optional[Path] = None
|
|
244
|
+
self._stream: Optional[BinaryIO] = None
|
|
245
|
+
self._start = 0
|
|
246
|
+
if isinstance(file, (str, Path)):
|
|
247
|
+
self._path = Path(file)
|
|
248
|
+
self.size = self._path.stat().st_size
|
|
249
|
+
elif isinstance(file, (bytes, bytearray)):
|
|
250
|
+
self._data = bytes(file)
|
|
251
|
+
self.size = len(self._data)
|
|
252
|
+
elif hasattr(file, "read"):
|
|
253
|
+
self._init_stream(file)
|
|
254
|
+
else:
|
|
255
|
+
raise TypeError("Expected file path, bytes, or file-like object")
|
|
256
|
+
|
|
257
|
+
def _init_stream(self, stream: BinaryIO) -> None:
|
|
258
|
+
if getattr(stream, "seekable", lambda: False)():
|
|
259
|
+
self._stream = stream
|
|
260
|
+
self._start = stream.tell()
|
|
261
|
+
self.size = stream.seek(0, os.SEEK_END) - self._start
|
|
262
|
+
stream.seek(self._start)
|
|
263
|
+
else:
|
|
264
|
+
self._data = stream.read()
|
|
265
|
+
self.size = len(self._data)
|
|
266
|
+
|
|
267
|
+
def _open(self) -> Tuple[BinaryIO, bool]:
|
|
268
|
+
if self._path is not None:
|
|
269
|
+
return open(self._path, "rb"), True
|
|
270
|
+
assert self._stream is not None
|
|
271
|
+
self._stream.seek(self._start)
|
|
272
|
+
return self._stream, False
|
|
273
|
+
|
|
274
|
+
def sync_body(self) -> Union[bytes, Iterator[bytes]]:
|
|
275
|
+
if self._data is not None:
|
|
276
|
+
return self._data
|
|
277
|
+
return self._read_sync()
|
|
278
|
+
|
|
279
|
+
def async_body(self) -> Union[bytes, AsyncIterator[bytes]]:
|
|
280
|
+
if self._data is not None:
|
|
281
|
+
return self._data
|
|
282
|
+
return self._read_async()
|
|
283
|
+
|
|
284
|
+
def _read_sync(self) -> Iterator[bytes]:
|
|
285
|
+
source, owned = self._open()
|
|
286
|
+
try:
|
|
287
|
+
remaining = self.size
|
|
288
|
+
while remaining > 0:
|
|
289
|
+
chunk = source.read(min(CHUNK_SIZE, remaining))
|
|
290
|
+
if not chunk:
|
|
291
|
+
return
|
|
292
|
+
remaining -= len(chunk)
|
|
293
|
+
yield chunk
|
|
294
|
+
finally:
|
|
295
|
+
if owned:
|
|
296
|
+
source.close()
|
|
297
|
+
|
|
298
|
+
async def _read_async(self) -> AsyncIterator[bytes]:
|
|
299
|
+
source, owned = self._open()
|
|
300
|
+
try:
|
|
301
|
+
remaining = self.size
|
|
302
|
+
while remaining > 0:
|
|
303
|
+
chunk = await asyncio.to_thread(source.read, min(CHUNK_SIZE, remaining))
|
|
304
|
+
if not chunk:
|
|
305
|
+
return
|
|
306
|
+
remaining -= len(chunk)
|
|
307
|
+
yield chunk
|
|
308
|
+
finally:
|
|
309
|
+
if owned:
|
|
310
|
+
source.close()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def filename_for(file: FileInput, filename: Optional[str]) -> str:
|
|
314
|
+
if filename:
|
|
315
|
+
return filename
|
|
316
|
+
if isinstance(file, (str, Path)):
|
|
317
|
+
return Path(file).name
|
|
318
|
+
return "document.pdf"
|
fastocr_sdk/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Asynchronous FastOCR client. Behaves exactly like the sync client."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, BinaryIO, Awaitable, Callable, Dict, FrozenSet, Optional, TypeVar, Union
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from . import _core
|
|
11
|
+
from .errors import (
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
DownloadError,
|
|
14
|
+
FastOCRError,
|
|
15
|
+
PollingTimeoutError,
|
|
16
|
+
UploadError,
|
|
17
|
+
)
|
|
18
|
+
from .models import (
|
|
19
|
+
DocumentCreateResponse,
|
|
20
|
+
DocumentJob,
|
|
21
|
+
DocumentListResponse,
|
|
22
|
+
DocumentOutputUrl,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncDocumentsResource:
|
|
29
|
+
"""Document OCR operations (`/v1/documents`)."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, client: "AsyncFastOCR"):
|
|
32
|
+
self._client = client
|
|
33
|
+
|
|
34
|
+
async def create(
|
|
35
|
+
self,
|
|
36
|
+
filename: str,
|
|
37
|
+
size_bytes: int,
|
|
38
|
+
idempotency_key: Optional[str] = None,
|
|
39
|
+
external_id: Optional[str] = None,
|
|
40
|
+
sha256: Optional[str] = None,
|
|
41
|
+
) -> DocumentCreateResponse:
|
|
42
|
+
"""Create a document and get a presigned upload URL.
|
|
43
|
+
|
|
44
|
+
The same `idempotency_key` is sent on every retry of this call, so a retry
|
|
45
|
+
can never create a second document.
|
|
46
|
+
"""
|
|
47
|
+
payload = _core.create_payload(filename, size_bytes, external_id, sha256)
|
|
48
|
+
headers = {"Idempotency-Key": idempotency_key or _core.new_idempotency_key()}
|
|
49
|
+
resp = await self._client._request("POST", "/v1/documents", json=payload, headers=headers)
|
|
50
|
+
return DocumentCreateResponse.from_dict(resp)
|
|
51
|
+
|
|
52
|
+
async def upload(
|
|
53
|
+
self,
|
|
54
|
+
upload_url: str,
|
|
55
|
+
file: Union[str, Path, bytes, BinaryIO],
|
|
56
|
+
sha256: Optional[str] = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""PUT the file to the presigned URL. Paths and seekable streams are streamed from disk.
|
|
59
|
+
|
|
60
|
+
`sha256` (base64 of the raw 32-byte digest) is only needed if you passed it to `create`;
|
|
61
|
+
S3 checks it on upload.
|
|
62
|
+
"""
|
|
63
|
+
await self._upload_source(upload_url, _core.UploadSource(file), sha256)
|
|
64
|
+
|
|
65
|
+
async def _upload_source(self, upload_url: str, source: _core.UploadSource, sha256: Optional[str]) -> None:
|
|
66
|
+
headers = _core.upload_headers(source.size, sha256)
|
|
67
|
+
|
|
68
|
+
async def attempt() -> None:
|
|
69
|
+
try:
|
|
70
|
+
async with httpx.AsyncClient(timeout=_core.TRANSFER_TIMEOUT) as uploader:
|
|
71
|
+
res = await uploader.put(upload_url, content=source.async_body(), headers=headers)
|
|
72
|
+
except httpx.RequestError as exc:
|
|
73
|
+
raise UploadError(f"Network error while uploading the document: {exc}") from exc
|
|
74
|
+
if not res.is_success:
|
|
75
|
+
raise _core.upload_error_from_response(res)
|
|
76
|
+
|
|
77
|
+
await self._client._retry(attempt, _core.TRANSFER_RETRY_STATUSES)
|
|
78
|
+
|
|
79
|
+
async def start(self, document_id: str) -> Dict[str, Any]:
|
|
80
|
+
"""Start OCR on an uploaded document, or run a `buy_pages` failure again after adding pages."""
|
|
81
|
+
return await self._client._request("POST", _core.document_path(document_id, "/start"))
|
|
82
|
+
|
|
83
|
+
async def get(self, document_id: str) -> DocumentJob:
|
|
84
|
+
resp = await self._client._request("GET", _core.document_path(document_id))
|
|
85
|
+
return DocumentJob.from_dict(resp)
|
|
86
|
+
|
|
87
|
+
async def list(
|
|
88
|
+
self,
|
|
89
|
+
limit: Optional[int] = None,
|
|
90
|
+
status: Optional[str] = None,
|
|
91
|
+
cursor: Optional[str] = None,
|
|
92
|
+
updated_since: Optional[str] = None,
|
|
93
|
+
) -> DocumentListResponse:
|
|
94
|
+
params = _core.list_params(limit, status, cursor, updated_since)
|
|
95
|
+
resp = await self._client._request("GET", "/v1/documents", params=params)
|
|
96
|
+
return DocumentListResponse.from_dict(resp)
|
|
97
|
+
|
|
98
|
+
async def delete(self, document_id: str) -> Dict[str, Any]:
|
|
99
|
+
"""Delete a document and its files. Cleanup is asynchronous."""
|
|
100
|
+
return await self._client._request("DELETE", _core.document_path(document_id))
|
|
101
|
+
|
|
102
|
+
async def get_output_url(self, document_id: str, file_format: str = "text") -> DocumentOutputUrl:
|
|
103
|
+
"""Get a presigned download URL (valid one hour) for `text` or `pdf`."""
|
|
104
|
+
_core.check_output_format(file_format)
|
|
105
|
+
resp = await self._client._request(
|
|
106
|
+
"GET", _core.document_path(document_id, "/output"), params={"format": file_format}
|
|
107
|
+
)
|
|
108
|
+
return DocumentOutputUrl.from_dict(resp)
|
|
109
|
+
|
|
110
|
+
async def get_text(self, document_id: str) -> str:
|
|
111
|
+
"""Download the extracted text."""
|
|
112
|
+
url = (await self.get_output_url(document_id, "text")).url
|
|
113
|
+
|
|
114
|
+
async def attempt() -> bytes:
|
|
115
|
+
try:
|
|
116
|
+
async with httpx.AsyncClient(timeout=_core.TRANSFER_TIMEOUT) as downloader:
|
|
117
|
+
res = await downloader.get(url)
|
|
118
|
+
except httpx.RequestError as exc:
|
|
119
|
+
raise DownloadError(f"Network error while downloading the text: {exc}") from exc
|
|
120
|
+
if not res.is_success:
|
|
121
|
+
raise _core.download_error_from_response(res)
|
|
122
|
+
return res.content
|
|
123
|
+
|
|
124
|
+
return (await self._client._retry(attempt, _core.TRANSFER_RETRY_STATUSES)).decode("utf-8")
|
|
125
|
+
|
|
126
|
+
async def download_searchable_pdf(self, document_id: str, destination: Union[str, Path, BinaryIO]) -> None:
|
|
127
|
+
"""Download the searchable PDF to a path or a writable binary stream.
|
|
128
|
+
|
|
129
|
+
A path is written atomically and retried on transient failures. A stream is
|
|
130
|
+
tried once, because a partial write cannot be undone.
|
|
131
|
+
"""
|
|
132
|
+
url = (await self.get_output_url(document_id, "pdf")).url
|
|
133
|
+
if isinstance(destination, (str, Path)):
|
|
134
|
+
await self._download_to_path(url, Path(destination))
|
|
135
|
+
else:
|
|
136
|
+
await self._stream_pdf(url, destination)
|
|
137
|
+
|
|
138
|
+
async def _stream_pdf(self, url: str, sink: BinaryIO) -> None:
|
|
139
|
+
try:
|
|
140
|
+
async with httpx.AsyncClient(timeout=_core.TRANSFER_TIMEOUT) as downloader:
|
|
141
|
+
async with downloader.stream("GET", url) as response:
|
|
142
|
+
if not response.is_success:
|
|
143
|
+
await response.aread()
|
|
144
|
+
raise _core.download_error_from_response(response)
|
|
145
|
+
async for chunk in response.aiter_bytes():
|
|
146
|
+
sink.write(chunk)
|
|
147
|
+
except httpx.RequestError as exc:
|
|
148
|
+
raise DownloadError(f"Network error while downloading the PDF: {exc}") from exc
|
|
149
|
+
|
|
150
|
+
async def _download_to_path(self, url: str, destination: Path) -> None:
|
|
151
|
+
partial = destination.with_name(destination.name + ".part")
|
|
152
|
+
|
|
153
|
+
async def attempt() -> None:
|
|
154
|
+
with open(partial, "wb") as sink:
|
|
155
|
+
await self._stream_pdf(url, sink)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
await self._client._retry(attempt, _core.TRANSFER_RETRY_STATUSES)
|
|
159
|
+
os.replace(partial, destination)
|
|
160
|
+
finally:
|
|
161
|
+
partial.unlink(missing_ok=True)
|
|
162
|
+
|
|
163
|
+
async def wait_for_completion(
|
|
164
|
+
self,
|
|
165
|
+
document_id: str,
|
|
166
|
+
timeout: float = 300.0,
|
|
167
|
+
poll_interval: float = 2.0,
|
|
168
|
+
) -> DocumentJob:
|
|
169
|
+
"""Poll until the document is `completed` or `partial`.
|
|
170
|
+
|
|
171
|
+
Raises DocumentFailedError if it fails and PollingTimeoutError on timeout.
|
|
172
|
+
An unrecognized status counts as still processing.
|
|
173
|
+
"""
|
|
174
|
+
deadline = time.monotonic() + timeout
|
|
175
|
+
while True:
|
|
176
|
+
job = await self.get(document_id)
|
|
177
|
+
if job.is_completed:
|
|
178
|
+
return job
|
|
179
|
+
if job.is_failed:
|
|
180
|
+
raise _core.failed_error(job)
|
|
181
|
+
if time.monotonic() >= deadline:
|
|
182
|
+
raise PollingTimeoutError(
|
|
183
|
+
f"Document {document_id} did not finish processing within {timeout} seconds",
|
|
184
|
+
document_id=document_id,
|
|
185
|
+
last_status=job.status,
|
|
186
|
+
)
|
|
187
|
+
await self._client._sleep(poll_interval)
|
|
188
|
+
|
|
189
|
+
async def process(
|
|
190
|
+
self,
|
|
191
|
+
file: Union[str, Path, bytes, BinaryIO],
|
|
192
|
+
filename: Optional[str] = None,
|
|
193
|
+
idempotency_key: Optional[str] = None,
|
|
194
|
+
external_id: Optional[str] = None,
|
|
195
|
+
sha256: Optional[str] = None,
|
|
196
|
+
timeout: float = 300.0,
|
|
197
|
+
poll_interval: float = 2.0,
|
|
198
|
+
) -> DocumentJob:
|
|
199
|
+
"""Create, upload, start, and wait. Returns the finished job.
|
|
200
|
+
|
|
201
|
+
One idempotency key covers the whole call. Pass your own to make a repeated
|
|
202
|
+
`process()` for the same file safe: it finds the same document and, if that
|
|
203
|
+
document was already uploaded or started, just waits for it.
|
|
204
|
+
"""
|
|
205
|
+
source = _core.UploadSource(file)
|
|
206
|
+
name = _core.filename_for(file, filename)
|
|
207
|
+
key = idempotency_key or _core.new_idempotency_key()
|
|
208
|
+
created = await self.create(name, source.size, idempotency_key=key, external_id=external_id, sha256=sha256)
|
|
209
|
+
if idempotency_key and (await self.get(created.id)).status != "awaiting_upload":
|
|
210
|
+
return await self.wait_for_completion(created.id, timeout=timeout, poll_interval=poll_interval)
|
|
211
|
+
await self._upload_source(created.upload_url, source, sha256)
|
|
212
|
+
await self.start(created.id)
|
|
213
|
+
return await self.wait_for_completion(created.id, timeout=timeout, poll_interval=poll_interval)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class AsyncFastOCR:
|
|
217
|
+
"""Asynchronous FastOCR API client.
|
|
218
|
+
|
|
219
|
+
`max_retries` bounds retries of connection errors, 429 (honoring Retry-After)
|
|
220
|
+
and 502/503/504. Other errors are raised immediately.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
api_key: Optional[str] = None,
|
|
226
|
+
base_url: Optional[str] = None,
|
|
227
|
+
timeout: float = 30.0,
|
|
228
|
+
max_retries: int = _core.DEFAULT_MAX_RETRIES,
|
|
229
|
+
):
|
|
230
|
+
self.api_key = _core.resolve_api_key(api_key)
|
|
231
|
+
self.base_url = _core.resolve_base_url(base_url)
|
|
232
|
+
self.timeout = timeout
|
|
233
|
+
self.max_retries = max_retries
|
|
234
|
+
self._sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
|
|
235
|
+
self._http = httpx.AsyncClient(
|
|
236
|
+
base_url=self.base_url,
|
|
237
|
+
timeout=timeout,
|
|
238
|
+
headers=_core.default_headers(self.api_key),
|
|
239
|
+
)
|
|
240
|
+
self.documents = AsyncDocumentsResource(self)
|
|
241
|
+
|
|
242
|
+
async def extract_text(
|
|
243
|
+
self,
|
|
244
|
+
file: Union[str, Path, bytes, BinaryIO],
|
|
245
|
+
timeout: float = 300.0,
|
|
246
|
+
allow_partial: bool = False,
|
|
247
|
+
) -> str:
|
|
248
|
+
"""Process a PDF and return its text.
|
|
249
|
+
|
|
250
|
+
Raises PartialResultError if the account ran out of pages partway through, so
|
|
251
|
+
truncated text is never returned silently. Pass `allow_partial=True` to get the
|
|
252
|
+
text of the processed pages instead.
|
|
253
|
+
"""
|
|
254
|
+
job = await self.documents.process(file, timeout=timeout)
|
|
255
|
+
if job.is_partial and not allow_partial:
|
|
256
|
+
raise _core.partial_error(job)
|
|
257
|
+
return await self.documents.get_text(job.id)
|
|
258
|
+
|
|
259
|
+
async def _retry(self, attempt: Callable[[], Awaitable[T]], statuses: FrozenSet[int]) -> T:
|
|
260
|
+
retries = 0
|
|
261
|
+
while True:
|
|
262
|
+
try:
|
|
263
|
+
return await attempt()
|
|
264
|
+
except FastOCRError as error:
|
|
265
|
+
delay = _core.retry_delay(error, retries, self.max_retries, statuses)
|
|
266
|
+
if delay is None:
|
|
267
|
+
raise
|
|
268
|
+
retries += 1
|
|
269
|
+
await self._sleep(delay)
|
|
270
|
+
|
|
271
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
|
|
272
|
+
async def attempt() -> Dict[str, Any]:
|
|
273
|
+
try:
|
|
274
|
+
res = await self._http.request(method, path, **kwargs)
|
|
275
|
+
except httpx.RequestError as exc:
|
|
276
|
+
raise APIConnectionError(f"Network request to FastOCR API failed: {exc}") from exc
|
|
277
|
+
if res.is_success:
|
|
278
|
+
return _core.parse_json(res)
|
|
279
|
+
raise _core.error_from_response(res)
|
|
280
|
+
|
|
281
|
+
return await self._retry(attempt, _core.API_RETRY_STATUSES)
|
|
282
|
+
|
|
283
|
+
async def close(self) -> None:
|
|
284
|
+
await self._http.aclose()
|
|
285
|
+
|
|
286
|
+
async def __aenter__(self) -> "AsyncFastOCR":
|
|
287
|
+
return self
|
|
288
|
+
|
|
289
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
290
|
+
await self.close()
|