parserbee 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,43 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - name: Check the tag matches the package version
16
+ run: |
17
+ version=$(python -c "import re;print(re.search(r'__version__ = \"(.+)\"', open('src/parserbee/__init__.py').read()).group(1))")
18
+ if [ "v$version" != "${GITHUB_REF_NAME}" ]; then
19
+ echo "Tag ${GITHUB_REF_NAME} does not match __version__ $version"
20
+ exit 1
21
+ fi
22
+ - run: pip install -e ".[dev]" build
23
+ - run: pytest -q
24
+ - run: python -m build
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: dist
28
+ path: dist/
29
+
30
+ publish:
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ environment:
34
+ name: pypi
35
+ url: https://pypi.org/p/parserbee
36
+ permissions:
37
+ id-token: write
38
+ steps:
39
+ - uses: actions/download-artifact@v4
40
+ with:
41
+ name: dist
42
+ path: dist/
43
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,20 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e ".[dev]"
20
+ - run: pytest -q
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .coverage
11
+ htmlcov/
12
+ .env
13
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ParserBee
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,145 @@
1
+ Metadata-Version: 2.5
2
+ Name: parserbee
3
+ Version: 0.1.0
4
+ Summary: Python client for the ParserBee document extraction API
5
+ Project-URL: Homepage, https://parserbee.com
6
+ Project-URL: Documentation, https://app.parserbee.com/docs
7
+ Project-URL: Source, https://github.com/ParserBee/parserbee-python
8
+ Author-email: ParserBee <support@parserbee.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: data extraction,document extraction,invoice,ocr,parserbee,pdf
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: requests>=2.28
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # parserbee-python
24
+
25
+ Python client for the [ParserBee](https://parserbee.com) API. Send a PDF, scan or phone photo of an invoice, purchase order, receipt or bank statement, and get back structured JSON that matches your Parser Template.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install parserbee
31
+ ```
32
+
33
+ Requires Python 3.9 or later.
34
+
35
+ ## Before you start
36
+
37
+ 1. Create a free account at [app.parserbee.com](https://app.parserbee.com/create-account). New accounts get 25 free pages, no credit card.
38
+ 2. Create a Parser Template: upload one sample document and the AI builds the fields for you, or pick a ready-made one from the [template library](https://parserbee.com/templates/).
39
+ 3. Copy your API key from **Settings** (it starts with `pk_`) and the template ID from the template page.
40
+
41
+ ```bash
42
+ export PARSERBEE_API_KEY=pk_your_api_key
43
+ ```
44
+
45
+ ## Quick start
46
+
47
+ ```python
48
+ from parserbee import ParserBee
49
+
50
+ client = ParserBee() # reads PARSERBEE_API_KEY
51
+
52
+ result = client.extract("your_template_id", "invoice.pdf")
53
+ print(result["data"])
54
+ # {'invoice_number': 'INV-2026-001', 'vendor_name': 'Acme Corp', 'total_amount': 1500.0, ...}
55
+ ```
56
+
57
+ Extract from a public link instead of a local file:
58
+
59
+ ```python
60
+ result = client.extract("your_template_id", file_url="https://example.com/invoice.pdf")
61
+ ```
62
+
63
+ An open binary file works too:
64
+
65
+ ```python
66
+ with open("receipt.jpg", "rb") as f:
67
+ result = client.extract("your_template_id", f)
68
+ ```
69
+
70
+ ## The response
71
+
72
+ `extract()` returns the API response as a dict:
73
+
74
+ | Key | What it holds |
75
+ |---|---|
76
+ | `data` | The extracted fields, shaped like your Parser Template |
77
+ | `request_id` | The request ID (also in Activity in the dashboard) |
78
+ | `credits_remaining` | Page credits left after this call |
79
+ | `usage` | `pages_processed`, `credits_charged`, `doc_size_bytes` |
80
+ | `processing_time_ms` | Time taken |
81
+
82
+ Each extraction costs 1 page credit per page processed, with a minimum of 1.
83
+
84
+ ## Other methods
85
+
86
+ ```python
87
+ client.list_templates() # every Parser Template in the account
88
+ client.get_template("tpl_id") # one template with its fields
89
+ client.get_credits() # balance, plan and subscription status
90
+ client.list_activity() # the last 10 extractions
91
+ client.get_activity("req_id") # one extraction, with its data
92
+ ```
93
+
94
+ ## Errors
95
+
96
+ Every error is a subclass of `parserbee.ParserBeeError`, with `.code` and `.status`:
97
+
98
+ | Exception | When |
99
+ |---|---|
100
+ | `InvalidRequestError` | 400: missing field, or a file type that is not accepted |
101
+ | `AuthenticationError` | 401: missing or invalid API key |
102
+ | `InsufficientCreditsError` | 402: no page credits left (`.credits_remaining`) |
103
+ | `PermissionDeniedError` | 403: no active subscription |
104
+ | `NotFoundError` | 404: template or request not found |
105
+ | `APIError` | Any other API error |
106
+ | `APIConnectionError` | The request did not reach the API |
107
+
108
+ ```python
109
+ from parserbee import InsufficientCreditsError
110
+
111
+ try:
112
+ client.extract("your_template_id", "invoice.pdf")
113
+ except InsufficientCreditsError:
114
+ print("Top up page credits in the dashboard")
115
+ ```
116
+
117
+ ## Examples
118
+
119
+ The [examples](https://github.com/ParserBee/parserbee-python/tree/main/examples) folder has scripts you can run as they are:
120
+
121
+ - `extract_invoice.py`: one file, printed as JSON
122
+ - `extract_from_url.py`: a document that is already online
123
+ - `folder_to_csv.py`: every PDF and image in a folder, one CSV row each
124
+ - `check_credits.py`: balance and template IDs
125
+
126
+ ## Supported files
127
+
128
+ PDF, PNG, JPG, WebP and HEIC, up to 50 MB. The API also reads DOC, PPT, PPTX, XLSX, CSV, TXT, RTF, ODT, HTML and EML; see the [API reference](https://app.parserbee.com/docs) for details. Scans and phone photos work, and there is no need to run OCR first. DOCX is not accepted: save it as PDF first.
129
+
130
+ ## No code needed?
131
+
132
+ If you only need the data in a spreadsheet, you do not need this library. [Data Lab](https://parserbee.com/tools/bank-statement-converter/#data-lab) in the dashboard takes a batch of documents and exports one combined Excel, CSV or Google Sheets file. To try a single invoice in the browser, use the free [invoice parser](https://parserbee.com/tools/invoice-parser/).
133
+
134
+ Choosing a tool for invoices? Our comparison of [invoice OCR software](https://parserbee.com/blog/best-invoice-ocr-software/) covers 10 options, tested on real invoices, scans and phone photos.
135
+
136
+ ## Links
137
+
138
+ - [ParserBee](https://parserbee.com)
139
+ - [API reference](https://app.parserbee.com/docs)
140
+ - [Pricing](https://parserbee.com/pricing/)
141
+ - Support: support@parserbee.com
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,123 @@
1
+ # parserbee-python
2
+
3
+ Python client for the [ParserBee](https://parserbee.com) API. Send a PDF, scan or phone photo of an invoice, purchase order, receipt or bank statement, and get back structured JSON that matches your Parser Template.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install parserbee
9
+ ```
10
+
11
+ Requires Python 3.9 or later.
12
+
13
+ ## Before you start
14
+
15
+ 1. Create a free account at [app.parserbee.com](https://app.parserbee.com/create-account). New accounts get 25 free pages, no credit card.
16
+ 2. Create a Parser Template: upload one sample document and the AI builds the fields for you, or pick a ready-made one from the [template library](https://parserbee.com/templates/).
17
+ 3. Copy your API key from **Settings** (it starts with `pk_`) and the template ID from the template page.
18
+
19
+ ```bash
20
+ export PARSERBEE_API_KEY=pk_your_api_key
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```python
26
+ from parserbee import ParserBee
27
+
28
+ client = ParserBee() # reads PARSERBEE_API_KEY
29
+
30
+ result = client.extract("your_template_id", "invoice.pdf")
31
+ print(result["data"])
32
+ # {'invoice_number': 'INV-2026-001', 'vendor_name': 'Acme Corp', 'total_amount': 1500.0, ...}
33
+ ```
34
+
35
+ Extract from a public link instead of a local file:
36
+
37
+ ```python
38
+ result = client.extract("your_template_id", file_url="https://example.com/invoice.pdf")
39
+ ```
40
+
41
+ An open binary file works too:
42
+
43
+ ```python
44
+ with open("receipt.jpg", "rb") as f:
45
+ result = client.extract("your_template_id", f)
46
+ ```
47
+
48
+ ## The response
49
+
50
+ `extract()` returns the API response as a dict:
51
+
52
+ | Key | What it holds |
53
+ |---|---|
54
+ | `data` | The extracted fields, shaped like your Parser Template |
55
+ | `request_id` | The request ID (also in Activity in the dashboard) |
56
+ | `credits_remaining` | Page credits left after this call |
57
+ | `usage` | `pages_processed`, `credits_charged`, `doc_size_bytes` |
58
+ | `processing_time_ms` | Time taken |
59
+
60
+ Each extraction costs 1 page credit per page processed, with a minimum of 1.
61
+
62
+ ## Other methods
63
+
64
+ ```python
65
+ client.list_templates() # every Parser Template in the account
66
+ client.get_template("tpl_id") # one template with its fields
67
+ client.get_credits() # balance, plan and subscription status
68
+ client.list_activity() # the last 10 extractions
69
+ client.get_activity("req_id") # one extraction, with its data
70
+ ```
71
+
72
+ ## Errors
73
+
74
+ Every error is a subclass of `parserbee.ParserBeeError`, with `.code` and `.status`:
75
+
76
+ | Exception | When |
77
+ |---|---|
78
+ | `InvalidRequestError` | 400: missing field, or a file type that is not accepted |
79
+ | `AuthenticationError` | 401: missing or invalid API key |
80
+ | `InsufficientCreditsError` | 402: no page credits left (`.credits_remaining`) |
81
+ | `PermissionDeniedError` | 403: no active subscription |
82
+ | `NotFoundError` | 404: template or request not found |
83
+ | `APIError` | Any other API error |
84
+ | `APIConnectionError` | The request did not reach the API |
85
+
86
+ ```python
87
+ from parserbee import InsufficientCreditsError
88
+
89
+ try:
90
+ client.extract("your_template_id", "invoice.pdf")
91
+ except InsufficientCreditsError:
92
+ print("Top up page credits in the dashboard")
93
+ ```
94
+
95
+ ## Examples
96
+
97
+ The [examples](https://github.com/ParserBee/parserbee-python/tree/main/examples) folder has scripts you can run as they are:
98
+
99
+ - `extract_invoice.py`: one file, printed as JSON
100
+ - `extract_from_url.py`: a document that is already online
101
+ - `folder_to_csv.py`: every PDF and image in a folder, one CSV row each
102
+ - `check_credits.py`: balance and template IDs
103
+
104
+ ## Supported files
105
+
106
+ PDF, PNG, JPG, WebP and HEIC, up to 50 MB. The API also reads DOC, PPT, PPTX, XLSX, CSV, TXT, RTF, ODT, HTML and EML; see the [API reference](https://app.parserbee.com/docs) for details. Scans and phone photos work, and there is no need to run OCR first. DOCX is not accepted: save it as PDF first.
107
+
108
+ ## No code needed?
109
+
110
+ If you only need the data in a spreadsheet, you do not need this library. [Data Lab](https://parserbee.com/tools/bank-statement-converter/#data-lab) in the dashboard takes a batch of documents and exports one combined Excel, CSV or Google Sheets file. To try a single invoice in the browser, use the free [invoice parser](https://parserbee.com/tools/invoice-parser/).
111
+
112
+ Choosing a tool for invoices? Our comparison of [invoice OCR software](https://parserbee.com/blog/best-invoice-ocr-software/) covers 10 options, tested on real invoices, scans and phone photos.
113
+
114
+ ## Links
115
+
116
+ - [ParserBee](https://parserbee.com)
117
+ - [API reference](https://app.parserbee.com/docs)
118
+ - [Pricing](https://parserbee.com/pricing/)
119
+ - Support: support@parserbee.com
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,18 @@
1
+ """Print the page credit balance and the Parser Templates in the account."""
2
+
3
+ from parserbee import ParserBee
4
+
5
+
6
+ def main() -> None:
7
+ with ParserBee() as client:
8
+ credits = client.get_credits()
9
+ print(f"Plan: {credits['subscription_plan']} ({credits['subscription_status']})")
10
+ print(f"Page credits: {credits['credits_remaining']} of {credits['credits_total']}")
11
+
12
+ print("\nParser Templates:")
13
+ for template in client.list_templates():
14
+ print(f" {template['id']} {template['name']}")
15
+
16
+
17
+ if __name__ == "__main__":
18
+ main()
@@ -0,0 +1,26 @@
1
+ """Extract data from a document that is already online.
2
+
3
+ The link must open without a login.
4
+
5
+ Usage:
6
+ python examples/extract_from_url.py <template_id> https://example.com/invoice.pdf
7
+ """
8
+
9
+ import json
10
+ import sys
11
+
12
+ from parserbee import ParserBee
13
+
14
+
15
+ def main() -> None:
16
+ if len(sys.argv) != 3:
17
+ sys.exit("Usage: python extract_from_url.py <template_id> <url>")
18
+
19
+ with ParserBee() as client:
20
+ result = client.extract(sys.argv[1], file_url=sys.argv[2])
21
+
22
+ print(json.dumps(result["data"], indent=2))
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
@@ -0,0 +1,28 @@
1
+ """Extract the fields of one invoice and print them.
2
+
3
+ Usage:
4
+ export PARSERBEE_API_KEY=pk_...
5
+ python examples/extract_invoice.py <template_id> invoice.pdf
6
+ """
7
+
8
+ import json
9
+ import sys
10
+
11
+ from parserbee import ParserBee
12
+
13
+
14
+ def main() -> None:
15
+ if len(sys.argv) != 3:
16
+ sys.exit("Usage: python extract_invoice.py <template_id> <file>")
17
+ template_id, path = sys.argv[1], sys.argv[2]
18
+
19
+ with ParserBee() as client:
20
+ result = client.extract(template_id, path)
21
+
22
+ print(json.dumps(result["data"], indent=2))
23
+ usage = result.get("usage") or {}
24
+ print(f"\nPages: {usage.get('pages_processed')}, credits left: {result.get('credits_remaining')}")
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
@@ -0,0 +1,61 @@
1
+ """Extract every document in a folder and write one CSV row per document.
2
+
3
+ Top-level fields become columns. Lists and groups (such as invoice line
4
+ items) are written as JSON text in their column.
5
+
6
+ Usage:
7
+ python examples/folder_to_csv.py <template_id> ./invoices output.csv
8
+ """
9
+
10
+ import csv
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from parserbee import InsufficientCreditsError, ParserBee, ParserBeeError
16
+
17
+ EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".webp", ".heic"}
18
+
19
+
20
+ def cell(value):
21
+ if isinstance(value, (dict, list)):
22
+ return json.dumps(value, ensure_ascii=False)
23
+ return value
24
+
25
+
26
+ def main() -> None:
27
+ if len(sys.argv) != 4:
28
+ sys.exit("Usage: python folder_to_csv.py <template_id> <folder> <output.csv>")
29
+ template_id, folder, output = sys.argv[1], Path(sys.argv[2]), Path(sys.argv[3])
30
+
31
+ files = sorted(p for p in folder.iterdir() if p.suffix.lower() in EXTENSIONS)
32
+ if not files:
33
+ sys.exit(f"No PDF or image files in {folder}")
34
+
35
+ rows = []
36
+ with ParserBee() as client:
37
+ for path in files:
38
+ try:
39
+ data = client.extract(template_id, path)["data"]
40
+ except InsufficientCreditsError:
41
+ print("Out of page credits; stopping.")
42
+ break
43
+ except ParserBeeError as exc:
44
+ print(f"{path.name}: failed ({exc})")
45
+ continue
46
+ rows.append({"source_file": path.name, **{k: cell(v) for k, v in data.items()}})
47
+ print(f"{path.name}: done")
48
+
49
+ if not rows:
50
+ sys.exit("Nothing was extracted.")
51
+
52
+ columns = list(dict.fromkeys(key for row in rows for key in row))
53
+ with output.open("w", newline="", encoding="utf-8") as handle:
54
+ writer = csv.DictWriter(handle, fieldnames=columns)
55
+ writer.writeheader()
56
+ writer.writerows(rows)
57
+ print(f"Wrote {len(rows)} rows to {output}")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "parserbee"
7
+ dynamic = ["version"]
8
+ description = "Python client for the ParserBee document extraction API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "ParserBee", email = "support@parserbee.com" }]
13
+ keywords = ["parserbee", "ocr", "invoice", "pdf", "document extraction", "data extraction"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Office/Business :: Financial :: Accounting",
20
+ ]
21
+ dependencies = ["requests>=2.28"]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=7"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://parserbee.com"
28
+ Documentation = "https://app.parserbee.com/docs"
29
+ Source = "https://github.com/ParserBee/parserbee-python"
30
+
31
+ [tool.hatch.version]
32
+ path = "src/parserbee/__init__.py"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/parserbee"]
@@ -0,0 +1,27 @@
1
+ """Python client for the ParserBee document extraction API."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .client import ParserBee # noqa: E402
6
+ from .errors import ( # noqa: E402
7
+ APIConnectionError,
8
+ APIError,
9
+ AuthenticationError,
10
+ InsufficientCreditsError,
11
+ InvalidRequestError,
12
+ NotFoundError,
13
+ ParserBeeError,
14
+ PermissionDeniedError,
15
+ )
16
+
17
+ __all__ = [
18
+ "ParserBee",
19
+ "ParserBeeError",
20
+ "APIError",
21
+ "APIConnectionError",
22
+ "AuthenticationError",
23
+ "InsufficientCreditsError",
24
+ "InvalidRequestError",
25
+ "NotFoundError",
26
+ "PermissionDeniedError",
27
+ ]
@@ -0,0 +1,149 @@
1
+ """HTTP client for the ParserBee API."""
2
+
3
+ import mimetypes
4
+ import os
5
+ from pathlib import Path
6
+ from typing import IO, Any, Dict, List, Optional, Union
7
+ from urllib.parse import quote
8
+
9
+ import requests
10
+
11
+ from . import __version__
12
+ from .errors import APIConnectionError, APIError, error_for_status
13
+
14
+ DEFAULT_BASE_URL = "https://app.parserbee.com"
15
+ DEFAULT_TIMEOUT = 120.0
16
+
17
+ FileInput = Union[str, os.PathLike, IO[bytes]]
18
+
19
+
20
+ class ParserBee:
21
+ """Client for the ParserBee REST API.
22
+
23
+ The API key is read from the ``api_key`` argument or, if that is not
24
+ given, from the ``PARSERBEE_API_KEY`` environment variable. Generate a
25
+ key from Settings in the ParserBee dashboard; it starts with ``pk_``.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ api_key: Optional[str] = None,
31
+ *,
32
+ base_url: str = DEFAULT_BASE_URL,
33
+ timeout: float = DEFAULT_TIMEOUT,
34
+ session: Optional[requests.Session] = None,
35
+ ) -> None:
36
+ api_key = api_key or os.environ.get("PARSERBEE_API_KEY")
37
+ if not api_key:
38
+ raise ValueError(
39
+ "No API key given. Pass api_key=... or set PARSERBEE_API_KEY."
40
+ )
41
+ self.api_key = api_key
42
+ self.base_url = base_url.rstrip("/")
43
+ self.timeout = timeout
44
+ self._session = session or requests.Session()
45
+ self._session.headers.update(
46
+ {
47
+ "x-api-key": api_key,
48
+ "User-Agent": f"parserbee-python/{__version__}",
49
+ "Accept": "application/json",
50
+ }
51
+ )
52
+
53
+ def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
54
+ url = f"{self.base_url}{path}"
55
+ kwargs.setdefault("timeout", self.timeout)
56
+ try:
57
+ response = self._session.request(method, url, **kwargs)
58
+ except requests.RequestException as exc:
59
+ raise APIConnectionError(f"Could not reach the ParserBee API: {exc}") from exc
60
+
61
+ try:
62
+ body = response.json()
63
+ except ValueError:
64
+ body = {}
65
+
66
+ if response.status_code >= 400:
67
+ raise error_for_status(response.status_code, body)
68
+ if not isinstance(body, dict):
69
+ raise APIError(
70
+ "Unexpected response from the ParserBee API",
71
+ status=response.status_code,
72
+ )
73
+ return body
74
+
75
+ def extract(
76
+ self,
77
+ template_id: str,
78
+ file: Optional[FileInput] = None,
79
+ *,
80
+ file_url: Optional[str] = None,
81
+ ) -> Dict[str, Any]:
82
+ """Extract structured data from a document with a Parser Template.
83
+
84
+ Pass either ``file`` (a path or an open binary file) or ``file_url``
85
+ (a public link to the document). Each extraction costs 1 page credit
86
+ per page processed.
87
+
88
+ Returns the full API response. The extracted fields are in
89
+ ``result["data"]``; ``result["usage"]`` holds the page count and the
90
+ page credits charged.
91
+ """
92
+ if not template_id:
93
+ raise ValueError("template_id is required")
94
+ if (file is None) == (file_url is None):
95
+ raise ValueError("Pass exactly one of file or file_url")
96
+
97
+ if file_url is not None:
98
+ return self._request(
99
+ "POST",
100
+ "/api/v1/extract",
101
+ json={"template_id": template_id, "file_url": file_url},
102
+ )
103
+
104
+ if isinstance(file, (str, os.PathLike)):
105
+ path = Path(file)
106
+ with path.open("rb") as handle:
107
+ return self._upload(template_id, handle, path.name)
108
+
109
+ name = Path(getattr(file, "name", "document")).name
110
+ return self._upload(template_id, file, name)
111
+
112
+ def _upload(self, template_id: str, handle: IO[bytes], name: str) -> Dict[str, Any]:
113
+ content_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
114
+ return self._request(
115
+ "POST",
116
+ "/api/v1/extract",
117
+ data={"template_id": template_id},
118
+ files={"file": (name, handle, content_type)},
119
+ )
120
+
121
+ def list_templates(self) -> List[Dict[str, Any]]:
122
+ """Return every Parser Template in the account, newest first."""
123
+ return self._request("GET", "/api/v1/templates").get("items", [])
124
+
125
+ def get_template(self, template_id: str) -> Dict[str, Any]:
126
+ """Return one Parser Template, with its fields."""
127
+ return self._request("GET", f"/api/v1/templates/{quote(template_id, safe='')}")
128
+
129
+ def get_credits(self) -> Dict[str, Any]:
130
+ """Return the page credit balance and the subscription plan."""
131
+ return self._request("GET", "/api/v1/credits")
132
+
133
+ def list_activity(self) -> List[Dict[str, Any]]:
134
+ """Return the last 10 extraction requests, newest first."""
135
+ return self._request("GET", "/api/v1/activity").get("items", [])
136
+
137
+ def get_activity(self, request_id: str) -> Dict[str, Any]:
138
+ """Return one extraction request, including the extracted data."""
139
+ return self._request("GET", f"/api/v1/activity/{quote(request_id, safe='')}")
140
+
141
+ def close(self) -> None:
142
+ """Close the underlying HTTP session."""
143
+ self._session.close()
144
+
145
+ def __enter__(self) -> "ParserBee":
146
+ return self
147
+
148
+ def __exit__(self, *exc: Any) -> None:
149
+ self.close()
@@ -0,0 +1,77 @@
1
+ """Exceptions raised by the ParserBee client."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+
6
+ class ParserBeeError(Exception):
7
+ """Base class for every error the client raises."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ *,
13
+ code: Optional[str] = None,
14
+ status: Optional[int] = None,
15
+ body: Optional[Dict[str, Any]] = None,
16
+ ) -> None:
17
+ super().__init__(message)
18
+ self.message = message
19
+ self.code = code
20
+ self.status = status
21
+ self.body = body or {}
22
+
23
+ def __str__(self) -> str:
24
+ parts = [self.message]
25
+ if self.code:
26
+ parts.append(f"(code: {self.code})")
27
+ if self.status:
28
+ parts.append(f"[HTTP {self.status}]")
29
+ return " ".join(parts)
30
+
31
+
32
+ class AuthenticationError(ParserBeeError):
33
+ """The API key is missing or invalid (HTTP 401)."""
34
+
35
+
36
+ class InsufficientCreditsError(ParserBeeError):
37
+ """The account has no page credits left (HTTP 402)."""
38
+
39
+ @property
40
+ def credits_remaining(self) -> Optional[int]:
41
+ return self.body.get("credits_remaining")
42
+
43
+
44
+ class PermissionDeniedError(ParserBeeError):
45
+ """No active subscription, or the account is locked (HTTP 403)."""
46
+
47
+
48
+ class NotFoundError(ParserBeeError):
49
+ """The template or activity record does not exist (HTTP 404)."""
50
+
51
+
52
+ class InvalidRequestError(ParserBeeError):
53
+ """The request is missing a field or the file is not accepted (HTTP 400)."""
54
+
55
+
56
+ class APIError(ParserBeeError):
57
+ """Any other error returned by the API, including server errors."""
58
+
59
+
60
+ class APIConnectionError(ParserBeeError):
61
+ """The request did not reach the API (network error or timeout)."""
62
+
63
+
64
+ _STATUS_ERRORS = {
65
+ 400: InvalidRequestError,
66
+ 401: AuthenticationError,
67
+ 402: InsufficientCreditsError,
68
+ 403: PermissionDeniedError,
69
+ 404: NotFoundError,
70
+ }
71
+
72
+
73
+ def error_for_status(status: int, body: Dict[str, Any]) -> ParserBeeError:
74
+ """Build the right exception for an error response."""
75
+ cls = _STATUS_ERRORS.get(status, APIError)
76
+ message = body.get("error") or f"ParserBee API returned HTTP {status}"
77
+ return cls(message, code=body.get("code"), status=status, body=body)
@@ -0,0 +1,163 @@
1
+ import io
2
+
3
+ import pytest
4
+ import requests
5
+
6
+ from parserbee import (
7
+ APIConnectionError,
8
+ AuthenticationError,
9
+ InsufficientCreditsError,
10
+ InvalidRequestError,
11
+ NotFoundError,
12
+ ParserBee,
13
+ )
14
+
15
+
16
+ class FakeResponse:
17
+ def __init__(self, status_code, body):
18
+ self.status_code = status_code
19
+ self._body = body
20
+
21
+ def json(self):
22
+ if self._body is None:
23
+ raise ValueError("no json")
24
+ return self._body
25
+
26
+
27
+ class FakeSession:
28
+ def __init__(self, response=None, error=None):
29
+ self.headers = {}
30
+ self.calls = []
31
+ self.response = response or FakeResponse(200, {})
32
+ self.error = error
33
+
34
+ def request(self, method, url, **kwargs):
35
+ self.calls.append((method, url, kwargs))
36
+ if self.error:
37
+ raise self.error
38
+ return self.response
39
+
40
+ def close(self):
41
+ pass
42
+
43
+
44
+ def make_client(response=None, error=None):
45
+ session = FakeSession(response, error)
46
+ return ParserBee("pk_test", session=session), session
47
+
48
+
49
+ def test_api_key_from_environment(monkeypatch):
50
+ monkeypatch.setenv("PARSERBEE_API_KEY", "pk_env")
51
+ client = ParserBee(session=FakeSession())
52
+ assert client.api_key == "pk_env"
53
+
54
+
55
+ def test_missing_api_key_raises(monkeypatch):
56
+ monkeypatch.delenv("PARSERBEE_API_KEY", raising=False)
57
+ with pytest.raises(ValueError):
58
+ ParserBee(session=FakeSession())
59
+
60
+
61
+ def test_sends_api_key_header():
62
+ client, session = make_client()
63
+ assert session.headers["x-api-key"] == "pk_test"
64
+ assert session.headers["User-Agent"].startswith("parserbee-python/")
65
+
66
+
67
+ def test_extract_uploads_file_from_path(tmp_path):
68
+ pdf = tmp_path / "invoice.pdf"
69
+ pdf.write_bytes(b"%PDF-1.4")
70
+ body = {"success": True, "data": {"invoice_number": "INV-001"}}
71
+ client, session = make_client(FakeResponse(200, body))
72
+
73
+ result = client.extract("tpl_123", pdf)
74
+
75
+ method, url, kwargs = session.calls[0]
76
+ assert (method, url) == ("POST", "https://app.parserbee.com/api/v1/extract")
77
+ assert kwargs["data"] == {"template_id": "tpl_123"}
78
+ name, _, content_type = kwargs["files"]["file"]
79
+ assert (name, content_type) == ("invoice.pdf", "application/pdf")
80
+ assert result["data"]["invoice_number"] == "INV-001"
81
+
82
+
83
+ def test_extract_accepts_open_file():
84
+ handle = io.BytesIO(b"\x89PNG")
85
+ handle.name = "/tmp/receipt.png"
86
+ client, session = make_client(FakeResponse(200, {"success": True}))
87
+
88
+ client.extract("tpl_123", handle)
89
+
90
+ name, _, content_type = session.calls[0][2]["files"]["file"]
91
+ assert (name, content_type) == ("receipt.png", "image/png")
92
+
93
+
94
+ def test_extract_from_url_sends_json():
95
+ client, session = make_client(FakeResponse(200, {"success": True}))
96
+
97
+ client.extract("tpl_123", file_url="https://example.com/invoice.pdf")
98
+
99
+ kwargs = session.calls[0][2]
100
+ assert kwargs["json"] == {"template_id": "tpl_123", "file_url": "https://example.com/invoice.pdf"}
101
+ assert "files" not in kwargs
102
+
103
+
104
+ @pytest.mark.parametrize("args", [{}, {"file": "a.pdf", "file_url": "https://x.y/a.pdf"}])
105
+ def test_extract_needs_exactly_one_source(args):
106
+ client, _ = make_client()
107
+ with pytest.raises(ValueError):
108
+ client.extract("tpl_123", **args)
109
+
110
+
111
+ def test_list_templates_returns_items():
112
+ client, session = make_client(FakeResponse(200, {"items": [{"id": "tpl_1"}], "total": 1}))
113
+ assert client.list_templates() == [{"id": "tpl_1"}]
114
+ assert session.calls[0][1].endswith("/api/v1/templates")
115
+
116
+
117
+ def test_get_template_escapes_id():
118
+ client, session = make_client(FakeResponse(200, {"id": "a/b"}))
119
+ client.get_template("a/b")
120
+ assert session.calls[0][1].endswith("/api/v1/templates/a%2Fb")
121
+
122
+
123
+ def test_get_credits():
124
+ client, _ = make_client(FakeResponse(200, {"credits_remaining": 42}))
125
+ assert client.get_credits()["credits_remaining"] == 42
126
+
127
+
128
+ def test_activity_endpoints():
129
+ client, session = make_client(FakeResponse(200, {"items": [], "total": 0}))
130
+ assert client.list_activity() == []
131
+ client.get_activity("req_1")
132
+ assert session.calls[1][1].endswith("/api/v1/activity/req_1")
133
+
134
+
135
+ @pytest.mark.parametrize(
136
+ "status,exc",
137
+ [
138
+ (400, InvalidRequestError),
139
+ (401, AuthenticationError),
140
+ (402, InsufficientCreditsError),
141
+ (404, NotFoundError),
142
+ ],
143
+ )
144
+ def test_error_statuses_map_to_exceptions(status, exc):
145
+ client, _ = make_client(FakeResponse(status, {"error": "nope", "code": "SOME_CODE"}))
146
+ with pytest.raises(exc) as info:
147
+ client.get_credits()
148
+ assert info.value.code == "SOME_CODE"
149
+ assert info.value.status == status
150
+
151
+
152
+ def test_insufficient_credits_exposes_balance():
153
+ body = {"error": "Insufficient page credits", "code": "INSUFFICIENT_CREDITS", "credits_remaining": 0}
154
+ client, _ = make_client(FakeResponse(402, body))
155
+ with pytest.raises(InsufficientCreditsError) as info:
156
+ client.extract("tpl_123", file_url="https://example.com/a.pdf")
157
+ assert info.value.credits_remaining == 0
158
+
159
+
160
+ def test_network_error_is_wrapped():
161
+ client, _ = make_client(error=requests.ConnectionError("boom"))
162
+ with pytest.raises(APIConnectionError):
163
+ client.get_credits()