pageindex-extract 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,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ .venv/
5
+ .venv-*/
6
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vectify AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.5
2
+ Name: pageindex-extract
3
+ Version: 0.1.0
4
+ Summary: Python client for Page Extract API by PageIndex: turn a PDF into JSON that fits your schema.
5
+ Project-URL: Homepage, https://extract.pageindex.ai
6
+ Project-URL: Documentation, https://extract.pageindex.ai/docs
7
+ Project-URL: Repository, https://github.com/VectifyAI/pageindex-extract-python
8
+ Project-URL: Issues, https://github.com/VectifyAI/pageindex-extract-python/issues
9
+ Author-email: PageIndex <support@pageindex.ai>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: extract,extraction,json-schema,llm,pageindex,pdf,structured-output
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: httpx>=0.24
19
+ Requires-Dist: pageindex-search>=0.14
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Page Extract Python SDK
23
+
24
+ Python client for [Page Extract](https://extract.pageindex.ai) by PageIndex: turn a PDF into JSON that fits your schema.
25
+
26
+ ```bash
27
+ pip install pageindex-extract
28
+ ```
29
+
30
+ Create an API key in the [console](https://search.pageindex.ai/console/keys). Page Extract and Page Search share one key, one credit balance and one set of documents.
31
+
32
+ ```bash
33
+ export PAGEINDEX_SEARCH_API_KEY=pis_live_...
34
+ ```
35
+
36
+ ## Extract
37
+
38
+ ```python
39
+ from pageindex_extract import PageExtract
40
+
41
+ client = PageExtract()
42
+
43
+ schema = {
44
+ "type": "object",
45
+ "properties": {
46
+ "invoice_number": {"type": "string"},
47
+ "total": {"type": "number", "description": "Amount due, in dollars, without the currency sign"},
48
+ "lines": {
49
+ "type": "array",
50
+ "items": {
51
+ "type": "object",
52
+ "properties": {"item": {"type": "string"}, "amount": {"type": "number"}},
53
+ },
54
+ },
55
+ },
56
+ }
57
+
58
+ job = client.extract("invoice.pdf", schema)
59
+ print(job.result)
60
+ ```
61
+
62
+ `extract` uploads the file (a file uploaded before is not uploaded again), waits for the extraction and returns it. `job.result` is JSON that fits your schema; a field the document does not give is `null`.
63
+
64
+ The schema can also be a Pydantic model:
65
+
66
+ ```python
67
+ from pydantic import BaseModel
68
+
69
+ class Line(BaseModel):
70
+ item: str
71
+ amount: float
72
+
73
+ class Invoice(BaseModel):
74
+ invoice_number: str
75
+ total: float
76
+ lines: list[Line]
77
+
78
+ job = client.extract("invoice.pdf", Invoice)
79
+ invoice = job.parse(Invoice)
80
+ ```
81
+
82
+ ### Options
83
+
84
+ ```python
85
+ job = client.extract(
86
+ "report.pdf",
87
+ schema,
88
+ tier="pro", # "standard" ($0.03 a page) or "pro" ($0.05 a page)
89
+ pages="3-9", # only these pages, and only they are charged; also (3, 9) or 3
90
+ cite_sources=True, # the page each list item is on: job.sources
91
+ system_prompt="Amounts are in thousands of dollars.",
92
+ )
93
+ print(job.usage.pages, job.usage.credits)
94
+ ```
95
+
96
+ `document` can be a path, bytes, an open binary file, an uploaded document, or a document id (`"doc_..."`).
97
+
98
+ You pay only for extractions that complete. A failed extraction raises `PageExtractError` (its `job` holds the job) and costs nothing.
99
+
100
+ ## Jobs
101
+
102
+ Extractions run in the background. To start one and come back for it later:
103
+
104
+ ```python
105
+ job = client.extract("report.pdf", schema, wait=False) # PENDING
106
+ job = client.jobs.wait(job) # COMPLETED, or raises
107
+
108
+ client.jobs.get(job.id) # the job, with its result once COMPLETED
109
+ client.jobs.cancel(job.id) # stop it; nothing is charged
110
+ client.jobs.delete(job.id) # delete it and its result
111
+
112
+ for job in client.jobs.list(): # every extraction, newest first
113
+ print(job.id, job.status)
114
+ ```
115
+
116
+ ## Documents
117
+
118
+ `client.documents` is the Page Search client's, with the same key:
119
+
120
+ ```python
121
+ doc = client.documents.upload("report.pdf")
122
+ job = client.extract(doc, schema)
123
+ ```
124
+
125
+ ## Errors
126
+
127
+ Errors raise `PageExtractError` with `status`, `code`, `message` and `request_id`.
128
+
129
+ | Code | When |
130
+ | --- | --- |
131
+ | `unauthorized` | The API key is missing or not valid. |
132
+ | `insufficient_credit` | Your credit does not cover the pages. Add credit in the console. |
133
+ | `invalid_schema` | The schema is not valid JSON Schema, or its top is not an object. |
134
+ | `schema_too_large` | The schema is larger than 1 MB. |
135
+ | `invalid_target_pages` | `pages` is not within the document. |
136
+ | `document_not_found` | No such document. |
137
+ | `job_not_found` | No such job. |
138
+ | `job_failed` / `job_cancelled` | The extraction failed or was cancelled. Nothing is charged. |
139
+ | `timeout` | `wait` gave up; the job goes on running. |
140
+
141
+ ## Links
142
+
143
+ - [Page Extract](https://extract.pageindex.ai)
144
+ - [API docs](https://extract.pageindex.ai/docs)
145
+ - [Page Search Python SDK](https://github.com/VectifyAI/pageindex-search-python)
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1,128 @@
1
+ # Page Extract Python SDK
2
+
3
+ Python client for [Page Extract](https://extract.pageindex.ai) by PageIndex: turn a PDF into JSON that fits your schema.
4
+
5
+ ```bash
6
+ pip install pageindex-extract
7
+ ```
8
+
9
+ Create an API key in the [console](https://search.pageindex.ai/console/keys). Page Extract and Page Search share one key, one credit balance and one set of documents.
10
+
11
+ ```bash
12
+ export PAGEINDEX_SEARCH_API_KEY=pis_live_...
13
+ ```
14
+
15
+ ## Extract
16
+
17
+ ```python
18
+ from pageindex_extract import PageExtract
19
+
20
+ client = PageExtract()
21
+
22
+ schema = {
23
+ "type": "object",
24
+ "properties": {
25
+ "invoice_number": {"type": "string"},
26
+ "total": {"type": "number", "description": "Amount due, in dollars, without the currency sign"},
27
+ "lines": {
28
+ "type": "array",
29
+ "items": {
30
+ "type": "object",
31
+ "properties": {"item": {"type": "string"}, "amount": {"type": "number"}},
32
+ },
33
+ },
34
+ },
35
+ }
36
+
37
+ job = client.extract("invoice.pdf", schema)
38
+ print(job.result)
39
+ ```
40
+
41
+ `extract` uploads the file (a file uploaded before is not uploaded again), waits for the extraction and returns it. `job.result` is JSON that fits your schema; a field the document does not give is `null`.
42
+
43
+ The schema can also be a Pydantic model:
44
+
45
+ ```python
46
+ from pydantic import BaseModel
47
+
48
+ class Line(BaseModel):
49
+ item: str
50
+ amount: float
51
+
52
+ class Invoice(BaseModel):
53
+ invoice_number: str
54
+ total: float
55
+ lines: list[Line]
56
+
57
+ job = client.extract("invoice.pdf", Invoice)
58
+ invoice = job.parse(Invoice)
59
+ ```
60
+
61
+ ### Options
62
+
63
+ ```python
64
+ job = client.extract(
65
+ "report.pdf",
66
+ schema,
67
+ tier="pro", # "standard" ($0.03 a page) or "pro" ($0.05 a page)
68
+ pages="3-9", # only these pages, and only they are charged; also (3, 9) or 3
69
+ cite_sources=True, # the page each list item is on: job.sources
70
+ system_prompt="Amounts are in thousands of dollars.",
71
+ )
72
+ print(job.usage.pages, job.usage.credits)
73
+ ```
74
+
75
+ `document` can be a path, bytes, an open binary file, an uploaded document, or a document id (`"doc_..."`).
76
+
77
+ You pay only for extractions that complete. A failed extraction raises `PageExtractError` (its `job` holds the job) and costs nothing.
78
+
79
+ ## Jobs
80
+
81
+ Extractions run in the background. To start one and come back for it later:
82
+
83
+ ```python
84
+ job = client.extract("report.pdf", schema, wait=False) # PENDING
85
+ job = client.jobs.wait(job) # COMPLETED, or raises
86
+
87
+ client.jobs.get(job.id) # the job, with its result once COMPLETED
88
+ client.jobs.cancel(job.id) # stop it; nothing is charged
89
+ client.jobs.delete(job.id) # delete it and its result
90
+
91
+ for job in client.jobs.list(): # every extraction, newest first
92
+ print(job.id, job.status)
93
+ ```
94
+
95
+ ## Documents
96
+
97
+ `client.documents` is the Page Search client's, with the same key:
98
+
99
+ ```python
100
+ doc = client.documents.upload("report.pdf")
101
+ job = client.extract(doc, schema)
102
+ ```
103
+
104
+ ## Errors
105
+
106
+ Errors raise `PageExtractError` with `status`, `code`, `message` and `request_id`.
107
+
108
+ | Code | When |
109
+ | --- | --- |
110
+ | `unauthorized` | The API key is missing or not valid. |
111
+ | `insufficient_credit` | Your credit does not cover the pages. Add credit in the console. |
112
+ | `invalid_schema` | The schema is not valid JSON Schema, or its top is not an object. |
113
+ | `schema_too_large` | The schema is larger than 1 MB. |
114
+ | `invalid_target_pages` | `pages` is not within the document. |
115
+ | `document_not_found` | No such document. |
116
+ | `job_not_found` | No such job. |
117
+ | `job_failed` / `job_cancelled` | The extraction failed or was cancelled. Nothing is charged. |
118
+ | `timeout` | `wait` gave up; the job goes on running. |
119
+
120
+ ## Links
121
+
122
+ - [Page Extract](https://extract.pageindex.ai)
123
+ - [API docs](https://extract.pageindex.ai/docs)
124
+ - [Page Search Python SDK](https://github.com/VectifyAI/pageindex-search-python)
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pageindex-extract"
7
+ dynamic = ["version"]
8
+ description = "Python client for Page Extract API by PageIndex: turn a PDF into JSON that fits your schema."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "PageIndex", email = "support@pageindex.ai" }]
14
+ dependencies = ["httpx>=0.24", "pageindex-search>=0.14"]
15
+ keywords = ["pageindex", "extract", "extraction", "pdf", "json-schema", "llm", "structured-output"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ "Intended Audience :: Developers",
20
+ "Topic :: Software Development :: Libraries",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://extract.pageindex.ai"
25
+ Documentation = "https://extract.pageindex.ai/docs"
26
+ Repository = "https://github.com/VectifyAI/pageindex-extract-python"
27
+ Issues = "https://github.com/VectifyAI/pageindex-extract-python/issues"
28
+
29
+ [tool.hatch.version]
30
+ path = "src/pageindex_extract/_version.py"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/pageindex_extract"]
@@ -0,0 +1,9 @@
1
+ """Page Extract API by PageIndex: turn a PDF into JSON that fits your schema."""
2
+
3
+ from pageindex_search import Document
4
+
5
+ from ._client import ExtractJob, JobPage, Jobs, PageExtract, PageExtractError, Usage, schema_of
6
+ from ._version import __version__
7
+
8
+ __all__ = ["PageExtract", "PageExtractError", "ExtractJob", "JobPage", "Jobs", "Usage", "Document", "schema_of",
9
+ "__version__"]
@@ -0,0 +1,304 @@
1
+ """The Page Extract API client: turn a PDF into JSON that fits your schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from dataclasses import dataclass, field, fields
8
+ from pathlib import Path
9
+ from typing import Any, BinaryIO, Iterator, Union
10
+
11
+ import httpx
12
+ from pageindex_search import Document, PageSearch
13
+
14
+ from ._version import __version__
15
+
16
+ DEFAULT_BASE_URL = "https://api.extract.pageindex.ai/v1"
17
+ API_KEY_ENV = "PAGEINDEX_SEARCH_API_KEY" # one key for Page Search and Page Extract
18
+ # Retried, after a short wait that doubles: the server was busy or briefly unreachable.
19
+ RETRY_STATUSES = {429, 500, 502, 503, 504}
20
+ # Starting an extraction is retried only when the request surely did not start one: it was turned away before it
21
+ # reached the service, so a retry cannot run (or charge) the same extraction twice.
22
+ CREATE_RETRY_STATUSES = {429, 502, 503}
23
+ FINISHED = ("COMPLETED", "FAILED", "CANCELLED")
24
+
25
+ FileInput = Union[str, os.PathLike, bytes, BinaryIO]
26
+ DocumentInput = Union[FileInput, Document]
27
+
28
+
29
+ class PageExtractError(Exception):
30
+ """An error the API returned, or an extraction that failed or was cancelled (then job holds it)."""
31
+
32
+ def __init__(self, message: str, *, status: int | None = None, code: str | None = None,
33
+ request_id: str | None = None, job: "ExtractJob | None" = None):
34
+ super().__init__(message)
35
+ self.message = message
36
+ self.status = status
37
+ self.code = code
38
+ self.request_id = request_id
39
+ self.job = job
40
+
41
+ def __str__(self) -> str:
42
+ parts = [f"{self.code}: {self.message}" if self.code else self.message]
43
+ if self.status:
44
+ parts.append(f"(HTTP {self.status})")
45
+ if self.request_id:
46
+ parts.append(f"[{self.request_id}]")
47
+ return " ".join(parts)
48
+
49
+
50
+ def _make(cls, data: dict[str, Any]):
51
+ """A dataclass from a response, ignoring fields this version does not know."""
52
+ known = {f.name for f in fields(cls)}
53
+ return cls(**{k: v for k, v in data.items() if k in known})
54
+
55
+
56
+ @dataclass
57
+ class Usage:
58
+ pages: int # pages extracted
59
+ credits: float # US dollars charged
60
+
61
+
62
+ @dataclass
63
+ class ExtractJob:
64
+ id: str
65
+ status: str # "PENDING", "RUNNING", "COMPLETED", "FAILED" or "CANCELLED"
66
+ document: str # the document's id
67
+ configuration: dict[str, Any] = field(default_factory=dict) # data_schema, tier, target_pages, ...
68
+ created_at: str | None = None
69
+ updated_at: str | None = None
70
+ result: Any = None # the JSON that fits the schema, once COMPLETED
71
+ # With cite_sources: the page each list item is on, by its JSON Pointer ("/rows/0": {"page": 3})
72
+ sources: dict[str, dict[str, Any]] = field(default_factory=dict)
73
+ usage: Usage | None = None # once COMPLETED: what was charged
74
+ error: str | None = None # why it FAILED
75
+
76
+ @property
77
+ def done(self) -> bool:
78
+ return self.status in FINISHED
79
+
80
+ def parse(self, model: Any) -> Any:
81
+ """The result as an instance of a Pydantic model (model.model_validate(result))."""
82
+ if self.status != "COMPLETED":
83
+ raise PageExtractError(f"Extract job {self.id} is {self.status}, not COMPLETED.", job=self)
84
+ return model.model_validate(self.result)
85
+
86
+ @classmethod
87
+ def _from(cls, data: dict[str, Any]) -> "ExtractJob":
88
+ out = _make(cls, data)
89
+ out.result = data.get("extract_result")
90
+ out.sources = (data.get("extract_metadata") or {}).get("field_metadata") or {}
91
+ out.usage = _make(Usage, data["usage"]) if data.get("usage") else None
92
+ out.error = data.get("error_message")
93
+ return out
94
+
95
+
96
+ class JobPage:
97
+ """One page of your extractions, newest first: data and next (the id to pass as after for the page after it).
98
+ Iterating it goes on through the pages after it, so a for loop over client.jobs.list() sees every extraction.
99
+ Listed jobs come without their results: get(id) for one's result."""
100
+
101
+ def __init__(self, jobs: "Jobs", data: list[ExtractJob], next: str | None, limit: int):
102
+ self._jobs, self.data, self.next, self._limit = jobs, data, next, limit
103
+
104
+ @property
105
+ def has_more(self) -> bool:
106
+ return self.next is not None
107
+
108
+ def next_page(self) -> JobPage | None:
109
+ return self._jobs.list(limit=self._limit, after=self.next) if self.next else None
110
+
111
+ def __iter__(self) -> Iterator[ExtractJob]:
112
+ page: JobPage | None = self
113
+ while page is not None:
114
+ yield from page.data
115
+ page = page.next_page()
116
+
117
+
118
+ def schema_of(schema: Any) -> dict[str, Any]:
119
+ """A JSON Schema from a dict, or from a Pydantic model (its model_json_schema())."""
120
+ if isinstance(schema, dict):
121
+ return schema
122
+ if hasattr(schema, "model_json_schema"):
123
+ return schema.model_json_schema()
124
+ raise TypeError("schema is a JSON Schema (a dict) or a Pydantic model")
125
+
126
+
127
+ def _pages(pages: str | int | tuple[int, int] | None) -> str | None:
128
+ """target_pages as the API takes it: "3", or "3-9"."""
129
+ if pages is None or isinstance(pages, str):
130
+ return pages
131
+ if isinstance(pages, int):
132
+ return str(pages)
133
+ first, last = pages
134
+ return f"{first}-{last}"
135
+
136
+
137
+ class Jobs:
138
+ """Your extractions: client.jobs.create(document, schema), .get(id), .list(), .wait(job), .cancel(job),
139
+ .delete(job)."""
140
+
141
+ def __init__(self, client: "PageExtract"):
142
+ self._client = client
143
+
144
+ def create(self, document: Document | str, schema: Any, *, tier: str = "standard",
145
+ pages: str | int | tuple[int, int] | None = None, cite_sources: bool = False,
146
+ system_prompt: str | None = None) -> ExtractJob:
147
+ """Start extracting from an uploaded document (or its id); returns at once, PENDING. client.extract() also
148
+ uploads a file and waits for the result."""
149
+ configuration: dict[str, Any] = {"data_schema": schema_of(schema), "tier": tier}
150
+ if pages is not None:
151
+ configuration["target_pages"] = _pages(pages)
152
+ if cite_sources:
153
+ configuration["cite_sources"] = True
154
+ if system_prompt:
155
+ configuration["system_prompt"] = system_prompt
156
+ body = {"document": _id(document), "configuration": configuration}
157
+ return ExtractJob._from(self._client._request("POST", "/extract", json=body, retry=CREATE_RETRY_STATUSES))
158
+
159
+ def get(self, job: ExtractJob | str) -> ExtractJob:
160
+ return ExtractJob._from(self._client._request("GET", f"/extract/{_id(job)}"))
161
+
162
+ def list(self, *, limit: int = 20, after: str | None = None) -> JobPage:
163
+ """Your extractions, newest first: limit (1 to 100) of them, after the job whose id is after.
164
+
165
+ for job in client.jobs.list(): # every extraction, a page at a time
166
+ """
167
+ params: dict[str, Any] = {"limit": limit}
168
+ if after:
169
+ params["after"] = after
170
+ page = self._client._request("GET", "/extract", params=params)
171
+ return JobPage(self, [ExtractJob._from(j) for j in page["jobs"]], page.get("next"), limit)
172
+
173
+ def wait(self, job: ExtractJob | str, *, timeout: float = 3600.0, interval: float = 3.0) -> ExtractJob:
174
+ """The job once COMPLETED. Raises PageExtractError if it failed, was cancelled, or is not done within
175
+ timeout (it goes on running then: wait again, or cancel it)."""
176
+ current = job if isinstance(job, ExtractJob) else self.get(job)
177
+ deadline = time.monotonic() + timeout
178
+ while not current.done:
179
+ if time.monotonic() > deadline:
180
+ raise PageExtractError(f"Extract job {current.id} is still {current.status} after {timeout:.0f} s.",
181
+ code="timeout", job=current)
182
+ time.sleep(interval)
183
+ current = self.get(current.id)
184
+ if current.status != "COMPLETED":
185
+ message = current.error or f"Extract job {current.id} was {current.status.lower()}."
186
+ raise PageExtractError(message, code=f"job_{current.status.lower()}", job=current)
187
+ return current
188
+
189
+ def cancel(self, job: ExtractJob | str) -> ExtractJob:
190
+ """Stop a job that is PENDING or RUNNING; nothing is charged."""
191
+ return ExtractJob._from(self._client._request("POST", f"/extract/{_id(job)}/cancel"))
192
+
193
+ def delete(self, job: ExtractJob | str) -> None:
194
+ """Delete a job and its result."""
195
+ self._client._request("DELETE", f"/extract/{_id(job)}")
196
+
197
+
198
+ class PageExtract:
199
+ """A client for the Page Extract API.
200
+
201
+ client = PageExtract() # reads PAGEINDEX_SEARCH_API_KEY: the same key as Page Search
202
+ job = client.extract("invoice.pdf", schema) # a JSON Schema, or a Pydantic model
203
+ print(job.result)
204
+
205
+ Documents are Page Search's: client.documents is a Page Search client's, with the same key.
206
+ """
207
+
208
+ def __init__(self, api_key: str | None = None, *, base_url: str | None = None, timeout: float = 120.0,
209
+ max_retries: int = 2, http_client: httpx.Client | None = None, search: PageSearch | None = None):
210
+ api_key = api_key or os.environ.get(API_KEY_ENV)
211
+ if not api_key:
212
+ raise PageExtractError(f"No API key: pass api_key or set {API_KEY_ENV}. "
213
+ "Create one at https://search.pageindex.ai/console/keys.")
214
+ self.base_url = (base_url or os.environ.get("PAGEINDEX_EXTRACT_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
215
+ self.max_retries = max_retries
216
+ self._http = http_client or httpx.Client(timeout=timeout)
217
+ self._headers = {"Authorization": f"Bearer {api_key}", "User-Agent": f"pageindex-extract-python/{__version__}"}
218
+ self.search = search or PageSearch(api_key, timeout=timeout, max_retries=max_retries)
219
+ self.documents = self.search.documents
220
+ self.jobs = Jobs(self)
221
+
222
+ def extract(self, document: DocumentInput, schema: Any, *, tier: str = "standard",
223
+ pages: str | int | tuple[int, int] | None = None, cite_sources: bool = False,
224
+ system_prompt: str | None = None, wait: bool = True, timeout: float = 3600.0) -> ExtractJob:
225
+ """Extract JSON that fits schema (a JSON Schema, or a Pydantic model) from a PDF.
226
+
227
+ document is a file (a path, bytes or an open binary file), uploaded first (a file uploaded before is not
228
+ uploaded again), or an uploaded document or its id ("doc_..."). tier is "standard" or "pro"; pages keeps
229
+ the extraction to some pages ("3-9", (3, 9) or 3), and only they are charged; cite_sources gives the page
230
+ each list item is on (job.sources); system_prompt adds your own instructions.
231
+
232
+ With wait (the default), returns the job once COMPLETED (job.result), or raises PageExtractError if it
233
+ failed; without it, returns the job at once, PENDING: client.jobs.wait(job) for its result.
234
+ """
235
+ doc = self._document(document)
236
+ for _ in range(60): # a document just uploaded elsewhere may still be processing
237
+ try:
238
+ job = self.jobs.create(doc, schema, tier=tier, pages=pages, cite_sources=cite_sources,
239
+ system_prompt=system_prompt)
240
+ break
241
+ except PageExtractError as error:
242
+ if error.code != "document_not_ready":
243
+ raise
244
+ doc = self.documents.wait(_id(doc))
245
+ else:
246
+ raise PageExtractError(f"Document {_id(doc)} is not ready.", code="document_not_ready")
247
+ return self.jobs.wait(job, timeout=timeout) if wait else job
248
+
249
+ def _document(self, document: DocumentInput) -> Document | str:
250
+ """An uploaded document, or an id: a file is uploaded (and waited for)."""
251
+ if isinstance(document, Document):
252
+ return document
253
+ if isinstance(document, str) and not os.path.exists(document) and not document.lower().endswith(".pdf"):
254
+ return document # an id
255
+ if isinstance(document, (str, os.PathLike)) and not Path(document).exists():
256
+ raise FileNotFoundError(str(document))
257
+ return self.documents.upload(document)
258
+
259
+ # Plumbing
260
+
261
+ def _request(self, method: str, path: str, *, retry: set[int] = RETRY_STATUSES, **kwargs: Any) -> Any:
262
+ """The response's JSON. An error raises PageExtractError."""
263
+ for attempt in range(self.max_retries + 1):
264
+ try:
265
+ response = self._http.request(method, self.base_url + path, headers=self._headers, **kwargs)
266
+ except httpx.TransportError as error:
267
+ # Starting a job is retried only when the request surely was not sent.
268
+ unsent = isinstance(error, (httpx.ConnectError, httpx.ConnectTimeout))
269
+ if attempt == self.max_retries or (method == "POST" and path == "/extract" and not unsent):
270
+ raise PageExtractError(f"Could not reach the API: {error}") from error
271
+ else:
272
+ if response.status_code not in retry or attempt == self.max_retries:
273
+ return _json(response)
274
+ time.sleep(0.5 * 2 ** attempt)
275
+ raise AssertionError("unreachable")
276
+
277
+ def close(self) -> None:
278
+ self._http.close()
279
+ self.search.close()
280
+
281
+ def __enter__(self) -> "PageExtract":
282
+ return self
283
+
284
+ def __exit__(self, *exc: object) -> None:
285
+ self.close()
286
+
287
+
288
+ def _id(item: Any) -> str:
289
+ return item if isinstance(item, str) else item.id
290
+
291
+
292
+ def _json(response: httpx.Response) -> dict[str, Any]:
293
+ try:
294
+ data = response.json()
295
+ except ValueError:
296
+ data = None
297
+ if response.is_success and isinstance(data, dict):
298
+ return data
299
+ request_id = response.headers.get("x-request-id")
300
+ error = data.get("error") if isinstance(data, dict) else None
301
+ error = error if isinstance(error, dict) else {}
302
+ raise PageExtractError(error.get("message") or response.text[:200] or response.reason_phrase,
303
+ status=response.status_code, code=error.get("code"),
304
+ request_id=error.get("request_id") or request_id)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,188 @@
1
+ """The client against a stand-in for the API. test_live.py runs it against the real one."""
2
+ import json
3
+
4
+ import httpx
5
+ import pytest
6
+ from pageindex_search import PageSearch
7
+
8
+ from pageindex_extract import ExtractJob, PageExtract, PageExtractError
9
+
10
+ SCHEMA = {"type": "object", "properties": {"total": {"type": "number"}}}
11
+
12
+
13
+ def job(status="PENDING", **extra):
14
+ return {"id": "ext_1", "status": status, "document": "doc_1", "configuration": {"data_schema": SCHEMA},
15
+ "created_at": "2026-11-08T00:00:00+00:00", "updated_at": "2026-11-08T00:00:01+00:00", **extra}
16
+
17
+
18
+ def client(handler, search_handler=None) -> PageExtract:
19
+ search = PageSearch(api_key="pis_live_x", max_retries=0,
20
+ http_client=httpx.Client(transport=httpx.MockTransport(search_handler or handler)))
21
+ return PageExtract(api_key="pis_live_x", max_retries=2, search=search,
22
+ http_client=httpx.Client(transport=httpx.MockTransport(handler)))
23
+
24
+
25
+ @pytest.fixture(autouse=True)
26
+ def no_sleep(monkeypatch):
27
+ monkeypatch.setattr("time.sleep", lambda s: None)
28
+
29
+
30
+ def test_extract_by_id_waits_for_the_result():
31
+ seen = []
32
+
33
+ def handler(request):
34
+ seen.append((request.method, request.url.path))
35
+ if request.method == "POST":
36
+ body = json.loads(request.content)
37
+ assert body == {"document": "doc_1", "configuration": {
38
+ "data_schema": SCHEMA, "tier": "pro", "target_pages": "3-9", "cite_sources": True}}
39
+ assert request.headers["authorization"] == "Bearer pis_live_x"
40
+ return httpx.Response(200, json=job())
41
+ if len(seen) < 4:
42
+ return httpx.Response(200, json=job("RUNNING"))
43
+ return httpx.Response(200, json=job("COMPLETED", extract_result={"total": 12.5},
44
+ extract_metadata={"field_metadata": {"/rows/0": {"page": 3}}},
45
+ usage={"pages": 7, "credits": 0.35}))
46
+
47
+ out = client(handler).extract("doc_1", SCHEMA, tier="pro", pages=(3, 9), cite_sources=True)
48
+ assert out.status == "COMPLETED" and out.result == {"total": 12.5}
49
+ assert out.sources == {"/rows/0": {"page": 3}} and out.usage.pages == 7 and out.usage.credits == 0.35
50
+ assert seen[0] == ("POST", "/v1/extract") and seen[-1] == ("GET", "/v1/extract/ext_1")
51
+
52
+
53
+ def test_failed_job_raises_with_the_job():
54
+ def handler(request):
55
+ if request.method == "POST":
56
+ return httpx.Response(200, json=job())
57
+ return httpx.Response(200, json=job("FAILED", error_message="The document could not be finished."))
58
+
59
+ with pytest.raises(PageExtractError) as error:
60
+ client(handler).extract("doc_1", SCHEMA)
61
+ assert error.value.code == "job_failed" and error.value.job.status == "FAILED"
62
+ assert error.value.message == "The document could not be finished."
63
+
64
+
65
+ def test_no_wait_returns_at_once():
66
+ out = client(lambda request: httpx.Response(200, json=job())).extract("doc_1", SCHEMA, wait=False)
67
+ assert out.status == "PENDING" and not out.done
68
+
69
+
70
+ def test_api_error_raises():
71
+ body = {"error": {"code": "insufficient_credit", "message": "Extracting 9 pages takes $0.45 of credit."}}
72
+ with pytest.raises(PageExtractError) as error:
73
+ client(lambda request: httpx.Response(402, json=body)).jobs.create("doc_1", SCHEMA)
74
+ assert (error.value.status, error.value.code) == (402, "insufficient_credit")
75
+
76
+
77
+ def test_create_is_not_retried_after_it_may_have_started():
78
+ calls = []
79
+
80
+ def handler(request):
81
+ calls.append(1)
82
+ return httpx.Response(500, json={"error": {"code": "error", "message": "boom"}})
83
+
84
+ with pytest.raises(PageExtractError):
85
+ client(handler).jobs.create("doc_1", SCHEMA)
86
+ assert len(calls) == 1 # a 500 may have started the job: retrying could run it twice
87
+
88
+
89
+ def test_create_is_retried_when_turned_away():
90
+ calls = []
91
+
92
+ def handler(request):
93
+ calls.append(1)
94
+ return httpx.Response(503) if len(calls) == 1 else httpx.Response(200, json=job())
95
+
96
+ assert client(handler).jobs.create("doc_1", SCHEMA).id == "ext_1" and len(calls) == 2
97
+
98
+
99
+ def test_get_is_retried():
100
+ calls = []
101
+
102
+ def handler(request):
103
+ calls.append(1)
104
+ return httpx.Response(500) if len(calls) == 1 else httpx.Response(200, json=job("RUNNING"))
105
+
106
+ assert client(handler).jobs.get("ext_1").status == "RUNNING" and len(calls) == 2
107
+
108
+
109
+ def test_file_is_uploaded_then_extracted(tmp_path):
110
+ pdf = tmp_path / "invoice.pdf"
111
+ pdf.write_bytes(b"%PDF-1.4")
112
+
113
+ def search(request):
114
+ assert request.method == "POST" and request.url.path == "/v1/documents"
115
+ return httpx.Response(200, json={"id": "doc_7", "status": "ready", "filename": "invoice.pdf", "pages": 2})
116
+
117
+ def extract(request):
118
+ assert json.loads(request.content)["document"] == "doc_7"
119
+ return httpx.Response(200, json=job())
120
+
121
+ assert client(extract, search).extract(str(pdf), SCHEMA, wait=False).id == "ext_1"
122
+
123
+
124
+ def test_missing_file_raises(tmp_path):
125
+ with pytest.raises(FileNotFoundError):
126
+ client(lambda request: httpx.Response(200, json=job())).extract(str(tmp_path / "nope.pdf"), SCHEMA)
127
+
128
+
129
+ def test_document_not_ready_waits_then_retries():
130
+ calls = []
131
+
132
+ def extract(request):
133
+ calls.append(request.method)
134
+ if request.method == "POST" and calls.count("POST") == 1:
135
+ return httpx.Response(409, json={"error": {"code": "document_not_ready", "message": "processing"}})
136
+ return httpx.Response(200, json=job())
137
+
138
+ def search(request):
139
+ return httpx.Response(200, json={"id": "doc_1", "status": "ready"})
140
+
141
+ assert client(extract, search).extract("doc_1", SCHEMA, wait=False).id == "ext_1" and calls.count("POST") == 2
142
+
143
+
144
+ def test_pydantic_schema_and_parse():
145
+ pydantic = pytest.importorskip("pydantic")
146
+
147
+ class Invoice(pydantic.BaseModel):
148
+ total: float
149
+
150
+ def handler(request):
151
+ if request.method == "POST":
152
+ assert json.loads(request.content)["configuration"]["data_schema"]["properties"]["total"]["type"] == "number"
153
+ return httpx.Response(200, json=job())
154
+ return httpx.Response(200, json=job("COMPLETED", extract_result={"total": 3}))
155
+
156
+ assert client(handler).extract("doc_1", Invoice).parse(Invoice) == Invoice(total=3)
157
+
158
+
159
+ def test_list_pages_through_every_job():
160
+ def handler(request):
161
+ after = request.url.params.get("after")
162
+ if after is None:
163
+ return httpx.Response(200, json={"jobs": [job(id="ext_1"), job(id="ext_2")], "next": "ext_2"})
164
+ return httpx.Response(200, json={"jobs": [job(id="ext_3")], "next": None})
165
+
166
+ page = client(handler).jobs.list(limit=2)
167
+ assert page.has_more and [j.id for j in page] == ["ext_1", "ext_2", "ext_3"]
168
+
169
+
170
+ def test_cancel_and_delete():
171
+ seen = []
172
+
173
+ def handler(request):
174
+ seen.append((request.method, request.url.path))
175
+ if request.method == "DELETE":
176
+ return httpx.Response(200, json={"id": "ext_1", "deleted": True})
177
+ return httpx.Response(200, json=job("CANCELLED"))
178
+
179
+ c = client(handler)
180
+ assert c.jobs.cancel("ext_1").status == "CANCELLED"
181
+ c.jobs.delete(ExtractJob._from(job()))
182
+ assert seen == [("POST", "/v1/extract/ext_1/cancel"), ("DELETE", "/v1/extract/ext_1")]
183
+
184
+
185
+ def test_no_key_raises(monkeypatch):
186
+ monkeypatch.delenv("PAGEINDEX_SEARCH_API_KEY", raising=False)
187
+ with pytest.raises(PageExtractError):
188
+ PageExtract()
@@ -0,0 +1,31 @@
1
+ """Against the real API, when PAGEINDEX_SEARCH_API_KEY and PAGE_EXTRACT_LIVE_PDF are set: one page, standard tier
2
+ (about $0.03). The PDF is uploaded (or found, if uploaded before) and left in place."""
3
+ import os
4
+
5
+ import pytest
6
+
7
+ from pageindex_extract import PageExtract, PageExtractError
8
+
9
+ PDF = os.environ.get("PAGE_EXTRACT_LIVE_PDF")
10
+ pytestmark = pytest.mark.skipif(not (PDF and os.environ.get("PAGEINDEX_SEARCH_API_KEY")),
11
+ reason="set PAGEINDEX_SEARCH_API_KEY and PAGE_EXTRACT_LIVE_PDF")
12
+
13
+ SCHEMA = {"type": "object", "properties": {
14
+ "title": {"type": ["string", "null"], "description": "The document's title, as printed on its first page"},
15
+ "page_count_printed": {"type": ["integer", "null"], "description": "Total pages, if the page says (Page 1 of N)"},
16
+ }}
17
+
18
+
19
+ def test_extract_one_page():
20
+ with PageExtract() as client:
21
+ job = client.extract(PDF, SCHEMA, pages=1)
22
+ assert job.status == "COMPLETED" and set(job.result) == {"title", "page_count_printed"}
23
+ assert job.usage.pages == 1
24
+ assert client.jobs.get(job.id).result == job.result
25
+ assert any(j.id == job.id for j in client.jobs.list(limit=10))
26
+
27
+
28
+ def test_bad_schema_is_refused():
29
+ with PageExtract() as client, pytest.raises(PageExtractError) as error:
30
+ client.jobs.create("doc_does_not_matter", {"type": "string"})
31
+ assert error.value.status == 400 and error.value.code == "invalid_schema"