boreholeai 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.
- boreholeai-0.1.0/.gitignore +7 -0
- boreholeai-0.1.0/LICENSE +21 -0
- boreholeai-0.1.0/PKG-INFO +119 -0
- boreholeai-0.1.0/README.md +98 -0
- boreholeai-0.1.0/examples/basic_usage.py +25 -0
- boreholeai-0.1.0/pyproject.toml +32 -0
- boreholeai-0.1.0/src/boreholeai/__init__.py +26 -0
- boreholeai-0.1.0/src/boreholeai/_api.py +86 -0
- boreholeai-0.1.0/src/boreholeai/_files.py +47 -0
- boreholeai-0.1.0/src/boreholeai/_types.py +25 -0
- boreholeai-0.1.0/src/boreholeai/_version.py +1 -0
- boreholeai-0.1.0/src/boreholeai/client.py +164 -0
- boreholeai-0.1.0/src/boreholeai/exceptions.py +29 -0
boreholeai-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BoreholeAI
|
|
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,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: boreholeai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the BoreholeAI API — digitise borehole logs programmatically
|
|
5
|
+
Project-URL: Homepage, https://boreholeai.com
|
|
6
|
+
Project-URL: Documentation, https://boreholeai.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/boreholeai/boreholeai-python
|
|
8
|
+
Author-email: BoreholeAI <support@boreholeai.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,borehole,boreholeai,digitisation,geotechnical
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: httpx<1,>=0.24
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# BoreholeAI Python SDK
|
|
23
|
+
|
|
24
|
+
Python SDK for the [BoreholeAI](https://boreholeai.com) API. Digitise borehole logs programmatically — upload PDFs or images, get structured ground profiles, test data, and annotated PDFs.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install boreholeai
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from boreholeai import BoreholeAI
|
|
36
|
+
|
|
37
|
+
client = BoreholeAI(api_key="bhai_your_api_key_here")
|
|
38
|
+
|
|
39
|
+
# Process a single borehole log
|
|
40
|
+
result = client.process_documents("BH01.pdf", output_dir="./results")
|
|
41
|
+
|
|
42
|
+
print(f"Pages processed: {result.num_pages}")
|
|
43
|
+
for f in result.files:
|
|
44
|
+
print(f" {f.filename}")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Folder Processing
|
|
48
|
+
|
|
49
|
+
Pass a directory path to process multiple files together. Results are merged into a single ground profile Excel, test data Excel, and AGS file, with one annotated PDF per input file.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
result = client.process_documents("./borehole_logs/", output_dir="./results")
|
|
53
|
+
|
|
54
|
+
# Output:
|
|
55
|
+
# Borehole_ground_profile.xlsx (merged from all input files)
|
|
56
|
+
# Borehole_test_data.xlsx (merged from all input files)
|
|
57
|
+
# Borehole_ags4.ags (merged from all input files)
|
|
58
|
+
# BH01_annotated.pdf (one per input file)
|
|
59
|
+
# BH02_annotated.pdf
|
|
60
|
+
# BH03_annotated.pdf
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Supported File Types
|
|
64
|
+
|
|
65
|
+
- PDF (`.pdf`)
|
|
66
|
+
- PNG (`.png`)
|
|
67
|
+
- JPEG (`.jpg`, `.jpeg`)
|
|
68
|
+
- TIFF (`.tif`, `.tiff`)
|
|
69
|
+
- WebP (`.webp`)
|
|
70
|
+
|
|
71
|
+
## API Key
|
|
72
|
+
|
|
73
|
+
Get your API key from the [BoreholeAI dashboard](https://boreholeai.com/app/settings/api-keys).
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
# Pass directly
|
|
77
|
+
client = BoreholeAI(api_key="bhai_xxx")
|
|
78
|
+
|
|
79
|
+
# Or use for local development / testing
|
|
80
|
+
client = BoreholeAI(api_key="bhai_xxx", base_url="http://localhost:8000")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Error Handling
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from boreholeai import BoreholeAI, InsufficientCreditsError, AuthenticationError
|
|
87
|
+
|
|
88
|
+
client = BoreholeAI(api_key="bhai_xxx")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
result = client.process_documents("borehole.pdf")
|
|
92
|
+
except AuthenticationError:
|
|
93
|
+
print("Invalid API key")
|
|
94
|
+
except InsufficientCreditsError:
|
|
95
|
+
print("Not enough credits — buy more at boreholeai.com")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Response
|
|
99
|
+
|
|
100
|
+
`process_documents()` returns a `JobResult`:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
@dataclass
|
|
104
|
+
class JobResult:
|
|
105
|
+
job_id: str # Unique job identifier
|
|
106
|
+
status: str # "completed"
|
|
107
|
+
num_pages: int # Total pages processed
|
|
108
|
+
credits_used: int # Credits consumed
|
|
109
|
+
files: list[FileResult] # Downloaded result files
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class FileResult:
|
|
113
|
+
filename: str # e.g. "Borehole_ground_profile.xlsx"
|
|
114
|
+
path: Path # Local path where file was saved
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# BoreholeAI Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for the [BoreholeAI](https://boreholeai.com) API. Digitise borehole logs programmatically — upload PDFs or images, get structured ground profiles, test data, and annotated PDFs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install boreholeai
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from boreholeai import BoreholeAI
|
|
15
|
+
|
|
16
|
+
client = BoreholeAI(api_key="bhai_your_api_key_here")
|
|
17
|
+
|
|
18
|
+
# Process a single borehole log
|
|
19
|
+
result = client.process_documents("BH01.pdf", output_dir="./results")
|
|
20
|
+
|
|
21
|
+
print(f"Pages processed: {result.num_pages}")
|
|
22
|
+
for f in result.files:
|
|
23
|
+
print(f" {f.filename}")
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Folder Processing
|
|
27
|
+
|
|
28
|
+
Pass a directory path to process multiple files together. Results are merged into a single ground profile Excel, test data Excel, and AGS file, with one annotated PDF per input file.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
result = client.process_documents("./borehole_logs/", output_dir="./results")
|
|
32
|
+
|
|
33
|
+
# Output:
|
|
34
|
+
# Borehole_ground_profile.xlsx (merged from all input files)
|
|
35
|
+
# Borehole_test_data.xlsx (merged from all input files)
|
|
36
|
+
# Borehole_ags4.ags (merged from all input files)
|
|
37
|
+
# BH01_annotated.pdf (one per input file)
|
|
38
|
+
# BH02_annotated.pdf
|
|
39
|
+
# BH03_annotated.pdf
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Supported File Types
|
|
43
|
+
|
|
44
|
+
- PDF (`.pdf`)
|
|
45
|
+
- PNG (`.png`)
|
|
46
|
+
- JPEG (`.jpg`, `.jpeg`)
|
|
47
|
+
- TIFF (`.tif`, `.tiff`)
|
|
48
|
+
- WebP (`.webp`)
|
|
49
|
+
|
|
50
|
+
## API Key
|
|
51
|
+
|
|
52
|
+
Get your API key from the [BoreholeAI dashboard](https://boreholeai.com/app/settings/api-keys).
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
# Pass directly
|
|
56
|
+
client = BoreholeAI(api_key="bhai_xxx")
|
|
57
|
+
|
|
58
|
+
# Or use for local development / testing
|
|
59
|
+
client = BoreholeAI(api_key="bhai_xxx", base_url="http://localhost:8000")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Error Handling
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from boreholeai import BoreholeAI, InsufficientCreditsError, AuthenticationError
|
|
66
|
+
|
|
67
|
+
client = BoreholeAI(api_key="bhai_xxx")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
result = client.process_documents("borehole.pdf")
|
|
71
|
+
except AuthenticationError:
|
|
72
|
+
print("Invalid API key")
|
|
73
|
+
except InsufficientCreditsError:
|
|
74
|
+
print("Not enough credits — buy more at boreholeai.com")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Response
|
|
78
|
+
|
|
79
|
+
`process_documents()` returns a `JobResult`:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
@dataclass
|
|
83
|
+
class JobResult:
|
|
84
|
+
job_id: str # Unique job identifier
|
|
85
|
+
status: str # "completed"
|
|
86
|
+
num_pages: int # Total pages processed
|
|
87
|
+
credits_used: int # Credits consumed
|
|
88
|
+
files: list[FileResult] # Downloaded result files
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class FileResult:
|
|
92
|
+
filename: str # e.g. "Borehole_ground_profile.xlsx"
|
|
93
|
+
path: Path # Local path where file was saved
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Basic usage example for the BoreholeAI Python SDK."""
|
|
2
|
+
|
|
3
|
+
from boreholeai import BoreholeAI
|
|
4
|
+
|
|
5
|
+
# Initialize client with your API key
|
|
6
|
+
client = BoreholeAI(api_key="bhai_your_api_key_here")
|
|
7
|
+
|
|
8
|
+
# Process a single PDF
|
|
9
|
+
result = client.process_documents("path/to/borehole.pdf", output_dir="./results")
|
|
10
|
+
|
|
11
|
+
print(f"Job ID: {result.job_id}")
|
|
12
|
+
print(f"Pages processed: {result.num_pages}")
|
|
13
|
+
print(f"Credits used: {result.credits_used}")
|
|
14
|
+
print(f"Output files:")
|
|
15
|
+
for f in result.files:
|
|
16
|
+
print(f" {f.filename} → {f.path}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Process a folder of PDFs and images (merged output)
|
|
20
|
+
result = client.process_documents("path/to/logs/", output_dir="./results")
|
|
21
|
+
|
|
22
|
+
print(f"\nBatch job ID: {result.job_id}")
|
|
23
|
+
print(f"Total pages: {result.num_pages}")
|
|
24
|
+
for f in result.files:
|
|
25
|
+
print(f" {f.filename} → {f.path}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "boreholeai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for the BoreholeAI API — digitise borehole logs programmatically"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "BoreholeAI", email = "support@boreholeai.com" }]
|
|
13
|
+
keywords = ["boreholeai", "geotechnical", "borehole", "digitisation", "api"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Scientific/Engineering",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"httpx>=0.24,<1",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://boreholeai.com"
|
|
28
|
+
Documentation = "https://boreholeai.com/docs"
|
|
29
|
+
Repository = "https://github.com/boreholeai/boreholeai-python"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/boreholeai"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""BoreholeAI Python SDK — digitise borehole logs programmatically."""
|
|
2
|
+
|
|
3
|
+
from boreholeai._types import FileResult, JobResult
|
|
4
|
+
from boreholeai._version import __version__
|
|
5
|
+
from boreholeai.client import BoreholeAI
|
|
6
|
+
from boreholeai.exceptions import (
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
BoreholeAIError,
|
|
9
|
+
InsufficientCreditsError,
|
|
10
|
+
JobFailedError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
ServerError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BoreholeAI",
|
|
17
|
+
"__version__",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"BoreholeAIError",
|
|
20
|
+
"FileResult",
|
|
21
|
+
"InsufficientCreditsError",
|
|
22
|
+
"JobFailedError",
|
|
23
|
+
"JobResult",
|
|
24
|
+
"RateLimitError",
|
|
25
|
+
"ServerError",
|
|
26
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Low-level HTTP calls to the BoreholeAI worker API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from boreholeai.exceptions import (
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
BoreholeAIError,
|
|
13
|
+
InsufficientCreditsError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://boreholeai-worker-7b5nhwzhwq-uc.a.run.app"
|
|
19
|
+
DEFAULT_TIMEOUT = 120.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIClient:
|
|
23
|
+
"""Thin wrapper around httpx for authenticated requests to the worker."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, api_key: str, base_url: str, timeout: float):
|
|
26
|
+
self._client = httpx.Client(
|
|
27
|
+
base_url=base_url,
|
|
28
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
29
|
+
timeout=timeout,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def close(self) -> None:
|
|
33
|
+
self._client.close()
|
|
34
|
+
|
|
35
|
+
def create_job(self, file_paths: list[Path]) -> dict[str, Any]:
|
|
36
|
+
"""POST /v1/jobs — upload files and create a job.
|
|
37
|
+
|
|
38
|
+
Returns {"job_id": ..., "num_pages": ..., "credits_remaining": ...}.
|
|
39
|
+
"""
|
|
40
|
+
files = [
|
|
41
|
+
("files", (p.name, p.read_bytes()))
|
|
42
|
+
for p in file_paths
|
|
43
|
+
]
|
|
44
|
+
resp = self._client.post("/v1/jobs", files=files)
|
|
45
|
+
_raise_for_status(resp)
|
|
46
|
+
return resp.json()
|
|
47
|
+
|
|
48
|
+
def get_job(self, job_id: str) -> dict[str, Any]:
|
|
49
|
+
"""GET /v1/jobs/{id} — poll job status."""
|
|
50
|
+
resp = self._client.get(f"/v1/jobs/{job_id}")
|
|
51
|
+
_raise_for_status(resp)
|
|
52
|
+
return resp.json()
|
|
53
|
+
|
|
54
|
+
def get_results(self, job_id: str) -> dict[str, Any]:
|
|
55
|
+
"""GET /v1/jobs/{id}/results — signed download URLs."""
|
|
56
|
+
resp = self._client.get(f"/v1/jobs/{job_id}/results")
|
|
57
|
+
_raise_for_status(resp)
|
|
58
|
+
return resp.json()
|
|
59
|
+
|
|
60
|
+
def download_file(self, url: str) -> bytes:
|
|
61
|
+
"""Download a file from a signed URL."""
|
|
62
|
+
resp = httpx.get(url, timeout=self._client.timeout)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
return resp.content
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _raise_for_status(resp: httpx.Response) -> None:
|
|
68
|
+
"""Convert HTTP errors to SDK exceptions."""
|
|
69
|
+
if resp.is_success:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
detail = ""
|
|
73
|
+
try:
|
|
74
|
+
detail = resp.json().get("detail", "")
|
|
75
|
+
except Exception:
|
|
76
|
+
detail = resp.text
|
|
77
|
+
|
|
78
|
+
if resp.status_code == 401:
|
|
79
|
+
raise AuthenticationError(detail or "Invalid API key", 401)
|
|
80
|
+
if resp.status_code == 402:
|
|
81
|
+
raise InsufficientCreditsError(detail or "Insufficient credits", 402)
|
|
82
|
+
if resp.status_code == 429:
|
|
83
|
+
raise RateLimitError(detail or "Rate limited", 429)
|
|
84
|
+
if resp.status_code >= 500:
|
|
85
|
+
raise ServerError(detail or "Server error", resp.status_code)
|
|
86
|
+
raise BoreholeAIError(detail or f"HTTP {resp.status_code}", resp.status_code)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""File scanning and validation utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def collect_files(input_path: str | Path) -> list[Path]:
|
|
11
|
+
"""Collect valid files from a path (single file or directory).
|
|
12
|
+
|
|
13
|
+
Returns a sorted list of Paths with supported extensions.
|
|
14
|
+
Raises FileNotFoundError if the path doesn't exist.
|
|
15
|
+
Raises ValueError if no supported files are found.
|
|
16
|
+
"""
|
|
17
|
+
path = Path(input_path).resolve()
|
|
18
|
+
|
|
19
|
+
if not path.exists():
|
|
20
|
+
raise FileNotFoundError(f"Path not found: {path}")
|
|
21
|
+
|
|
22
|
+
if path.is_file():
|
|
23
|
+
_validate_extension(path)
|
|
24
|
+
return [path]
|
|
25
|
+
|
|
26
|
+
if path.is_dir():
|
|
27
|
+
files = sorted(
|
|
28
|
+
f for f in path.iterdir()
|
|
29
|
+
if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS
|
|
30
|
+
)
|
|
31
|
+
if not files:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"No supported files found in {path}. "
|
|
34
|
+
f"Accepted: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
|
35
|
+
)
|
|
36
|
+
return files
|
|
37
|
+
|
|
38
|
+
raise ValueError(f"Path is neither a file nor a directory: {path}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_extension(path: Path) -> None:
|
|
42
|
+
"""Raise ValueError if the file extension is not supported."""
|
|
43
|
+
if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Unsupported file type: {path.suffix}. "
|
|
46
|
+
f"Accepted: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
|
47
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Response types for the BoreholeAI SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class FileResult:
|
|
11
|
+
"""A single downloaded result file."""
|
|
12
|
+
|
|
13
|
+
filename: str
|
|
14
|
+
path: Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class JobResult:
|
|
19
|
+
"""Result of processing a single job (one or more input files)."""
|
|
20
|
+
|
|
21
|
+
job_id: str
|
|
22
|
+
status: str
|
|
23
|
+
num_pages: int
|
|
24
|
+
credits_used: int
|
|
25
|
+
files: list[FileResult] = field(default_factory=list)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""BoreholeAI Python SDK client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from boreholeai._api import APIClient, DEFAULT_BASE_URL, DEFAULT_TIMEOUT
|
|
10
|
+
from boreholeai._files import collect_files
|
|
11
|
+
from boreholeai._types import FileResult, JobResult
|
|
12
|
+
from boreholeai.exceptions import JobFailedError
|
|
13
|
+
|
|
14
|
+
# Polling configuration
|
|
15
|
+
_POLL_INITIAL_INTERVAL = 2.0 # seconds
|
|
16
|
+
_POLL_MAX_INTERVAL = 10.0 # seconds
|
|
17
|
+
_POLL_BACKOFF_FACTOR = 1.5
|
|
18
|
+
|
|
19
|
+
# Default output directory
|
|
20
|
+
_DEFAULT_OUTPUT_DIR = "./boreholeai_output"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BoreholeAI:
|
|
24
|
+
"""Client for the BoreholeAI API.
|
|
25
|
+
|
|
26
|
+
Usage::
|
|
27
|
+
|
|
28
|
+
from boreholeai import BoreholeAI
|
|
29
|
+
|
|
30
|
+
client = BoreholeAI(api_key="bhai_xxx")
|
|
31
|
+
|
|
32
|
+
# Single file
|
|
33
|
+
results = client.process_documents("borehole.pdf")
|
|
34
|
+
|
|
35
|
+
# Folder — all PDFs + images processed together, merged output
|
|
36
|
+
results = client.process_documents("./logs/", output_dir="./results")
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
api_key: str,
|
|
42
|
+
*,
|
|
43
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
44
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
45
|
+
):
|
|
46
|
+
if not api_key:
|
|
47
|
+
raise ValueError("api_key is required")
|
|
48
|
+
self._api = APIClient(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
"""Close the underlying HTTP client."""
|
|
52
|
+
self._api.close()
|
|
53
|
+
|
|
54
|
+
def __enter__(self) -> BoreholeAI:
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *args: object) -> None:
|
|
58
|
+
self.close()
|
|
59
|
+
|
|
60
|
+
def process_documents(
|
|
61
|
+
self,
|
|
62
|
+
input_path: str | Path,
|
|
63
|
+
*,
|
|
64
|
+
output_dir: str | Path = _DEFAULT_OUTPUT_DIR,
|
|
65
|
+
) -> JobResult:
|
|
66
|
+
"""Upload files, process them, and download results.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
input_path: Path to a single file or a directory of files.
|
|
70
|
+
Supported formats: PDF, PNG, JPG, JPEG, TIF, TIFF, WebP.
|
|
71
|
+
output_dir: Directory to save result files. Created if it doesn't exist.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
JobResult with job metadata and list of downloaded files.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
FileNotFoundError: If input_path doesn't exist.
|
|
78
|
+
ValueError: If no supported files found.
|
|
79
|
+
AuthenticationError: If API key is invalid.
|
|
80
|
+
InsufficientCreditsError: If not enough credits.
|
|
81
|
+
JobFailedError: If processing fails on the server.
|
|
82
|
+
"""
|
|
83
|
+
files = collect_files(input_path)
|
|
84
|
+
out = Path(output_dir).resolve()
|
|
85
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
# Upload
|
|
88
|
+
total_files = len(files)
|
|
89
|
+
_print_status(f"Uploading {total_files} file(s)...")
|
|
90
|
+
job_data = self._api.create_job(files)
|
|
91
|
+
job_id = job_data["job_id"]
|
|
92
|
+
num_pages = job_data["num_pages"]
|
|
93
|
+
_print_status(
|
|
94
|
+
f"Job {job_id[:8]}... created — "
|
|
95
|
+
f"{num_pages} page(s), {total_files} file(s)"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Poll
|
|
99
|
+
result = self._poll_until_done(job_id)
|
|
100
|
+
|
|
101
|
+
# Download results
|
|
102
|
+
downloaded = self._download_results(job_id, out)
|
|
103
|
+
|
|
104
|
+
return JobResult(
|
|
105
|
+
job_id=job_id,
|
|
106
|
+
status=result["status"],
|
|
107
|
+
num_pages=num_pages,
|
|
108
|
+
credits_used=num_pages,
|
|
109
|
+
files=downloaded,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def _poll_until_done(self, job_id: str) -> dict:
|
|
113
|
+
"""Poll GET /v1/jobs/{id} until status is completed or failed."""
|
|
114
|
+
interval = _POLL_INITIAL_INTERVAL
|
|
115
|
+
|
|
116
|
+
while True:
|
|
117
|
+
data = self._api.get_job(job_id)
|
|
118
|
+
status = data["status"]
|
|
119
|
+
|
|
120
|
+
if status == "completed":
|
|
121
|
+
_print_status("Processing complete!")
|
|
122
|
+
return data
|
|
123
|
+
|
|
124
|
+
if status == "failed":
|
|
125
|
+
error = data.get("error_message", "Unknown error")
|
|
126
|
+
raise JobFailedError(f"Job {job_id} failed: {error}")
|
|
127
|
+
|
|
128
|
+
# Show progress if available
|
|
129
|
+
progress = data.get("progress") or {}
|
|
130
|
+
pages_done = progress.get("pages_done", 0)
|
|
131
|
+
pages_total = progress.get("pages_total", "?")
|
|
132
|
+
_print_status(
|
|
133
|
+
f"Processing... {pages_done}/{pages_total} pages "
|
|
134
|
+
f"[{status}]"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
time.sleep(interval)
|
|
138
|
+
interval = min(interval * _POLL_BACKOFF_FACTOR, _POLL_MAX_INTERVAL)
|
|
139
|
+
|
|
140
|
+
def _download_results(self, job_id: str, output_dir: Path) -> list[FileResult]:
|
|
141
|
+
"""Download all result files to output_dir."""
|
|
142
|
+
results_data = self._api.get_results(job_id)
|
|
143
|
+
downloaded: list[FileResult] = []
|
|
144
|
+
|
|
145
|
+
for file_info in results_data.get("files", []):
|
|
146
|
+
filename = file_info["filename"]
|
|
147
|
+
url = file_info["url"]
|
|
148
|
+
|
|
149
|
+
_print_status(f"Downloading {filename}...")
|
|
150
|
+
content = self._api.download_file(url)
|
|
151
|
+
|
|
152
|
+
dest = output_dir / filename
|
|
153
|
+
dest.write_bytes(content)
|
|
154
|
+
downloaded.append(FileResult(filename=filename, path=dest))
|
|
155
|
+
|
|
156
|
+
_print_status(
|
|
157
|
+
f"Downloaded {len(downloaded)} file(s) to {output_dir}"
|
|
158
|
+
)
|
|
159
|
+
return downloaded
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _print_status(message: str) -> None:
|
|
163
|
+
"""Print a status message to stderr (doesn't interfere with stdout piping)."""
|
|
164
|
+
print(f" {message}", file=sys.stderr, flush=True)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""BoreholeAI SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BoreholeAIError(Exception):
|
|
5
|
+
"""Base exception for all SDK errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthenticationError(BoreholeAIError):
|
|
13
|
+
"""Invalid or missing API key (401)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InsufficientCreditsError(BoreholeAIError):
|
|
17
|
+
"""Not enough credits to process the request (402)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RateLimitError(BoreholeAIError):
|
|
21
|
+
"""Too many requests (429)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ServerError(BoreholeAIError):
|
|
25
|
+
"""Worker returned 5xx."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class JobFailedError(BoreholeAIError):
|
|
29
|
+
"""Job processing failed on the server."""
|