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.
@@ -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"
@@ -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()