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.
- pageindex_extract-0.1.0/.gitignore +6 -0
- pageindex_extract-0.1.0/LICENSE +21 -0
- pageindex_extract-0.1.0/PKG-INFO +149 -0
- pageindex_extract-0.1.0/README.md +128 -0
- pageindex_extract-0.1.0/pyproject.toml +33 -0
- pageindex_extract-0.1.0/src/pageindex_extract/__init__.py +9 -0
- pageindex_extract-0.1.0/src/pageindex_extract/_client.py +304 -0
- pageindex_extract-0.1.0/src/pageindex_extract/_version.py +1 -0
- pageindex_extract-0.1.0/src/pageindex_extract/py.typed +0 -0
- pageindex_extract-0.1.0/tests/test_client.py +188 -0
- pageindex_extract-0.1.0/tests/test_live.py +31 -0
|
@@ -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"
|