graph2table 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.
- graph2table-0.1.0/.gitignore +4 -0
- graph2table-0.1.0/LICENSE +21 -0
- graph2table-0.1.0/PKG-INFO +60 -0
- graph2table-0.1.0/README.md +41 -0
- graph2table-0.1.0/pyproject.toml +29 -0
- graph2table-0.1.0/src/graph2table/__init__.py +14 -0
- graph2table-0.1.0/src/graph2table/client.py +155 -0
- graph2table-0.1.0/tests/test_client.py +79 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 graph2table
|
|
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,60 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: graph2table
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for the graph2table API: send a chart image, get the data table.
|
|
5
|
+
Project-URL: Homepage, https://graph2table.com
|
|
6
|
+
Project-URL: Documentation, https://graph2table.com/docs/api
|
|
7
|
+
Project-URL: Benchmark, https://graph2table.com/blog/chartx-technical-report
|
|
8
|
+
Author: graph2table
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: chart,chart to table,csv,data extraction,graph,plot digitizer
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: requests>=2.28
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# graph2table
|
|
21
|
+
|
|
22
|
+
Python client for the [graph2table](https://graph2table.com) API. Send a chart image, get the data table.
|
|
23
|
+
|
|
24
|
+
graph2table automatically extracts the data table from a chart image.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
pip install graph2table
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from graph2table import convert
|
|
36
|
+
|
|
37
|
+
result = convert("figure.png", api_key="YOUR_API_KEY")
|
|
38
|
+
|
|
39
|
+
result.headers # ['x', 'Series A', 'Series B']
|
|
40
|
+
result.rows[0] # {'x': 0.0, 'Series A': 12.4, 'Series B': 9.1}
|
|
41
|
+
result.to_csv("figure.csv")
|
|
42
|
+
result.to_dataframe() # pandas, if installed
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Get an API key at https://graph2table.com/app/keys. Each call costs 10 credits. Set the `GRAPH2TABLE_API_KEY` environment variable to skip the `api_key` argument.
|
|
46
|
+
|
|
47
|
+
The image can be a file path, raw bytes, or an open file. PNG, JPG and WebP up to 10 MB.
|
|
48
|
+
|
|
49
|
+
To focus the extraction, pass `custom_instructions="Extract only the red series"`.
|
|
50
|
+
|
|
51
|
+
Errors raise `Graph2TableError` with the HTTP status and the API message.
|
|
52
|
+
|
|
53
|
+
## Check the result
|
|
54
|
+
|
|
55
|
+
Open the same image at https://graph2table.com/converter to see every extracted point drawn on the chart, and correct a point before you export.
|
|
56
|
+
|
|
57
|
+
## Links
|
|
58
|
+
|
|
59
|
+
- API reference: https://graph2table.com/docs/api
|
|
60
|
+
- Accuracy and released data: https://graph2table.com/blog/chartx-technical-report
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# graph2table
|
|
2
|
+
|
|
3
|
+
Python client for the [graph2table](https://graph2table.com) API. Send a chart image, get the data table.
|
|
4
|
+
|
|
5
|
+
graph2table automatically extracts the data table from a chart image.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install graph2table
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from graph2table import convert
|
|
17
|
+
|
|
18
|
+
result = convert("figure.png", api_key="YOUR_API_KEY")
|
|
19
|
+
|
|
20
|
+
result.headers # ['x', 'Series A', 'Series B']
|
|
21
|
+
result.rows[0] # {'x': 0.0, 'Series A': 12.4, 'Series B': 9.1}
|
|
22
|
+
result.to_csv("figure.csv")
|
|
23
|
+
result.to_dataframe() # pandas, if installed
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Get an API key at https://graph2table.com/app/keys. Each call costs 10 credits. Set the `GRAPH2TABLE_API_KEY` environment variable to skip the `api_key` argument.
|
|
27
|
+
|
|
28
|
+
The image can be a file path, raw bytes, or an open file. PNG, JPG and WebP up to 10 MB.
|
|
29
|
+
|
|
30
|
+
To focus the extraction, pass `custom_instructions="Extract only the red series"`.
|
|
31
|
+
|
|
32
|
+
Errors raise `Graph2TableError` with the HTTP status and the API message.
|
|
33
|
+
|
|
34
|
+
## Check the result
|
|
35
|
+
|
|
36
|
+
Open the same image at https://graph2table.com/converter to see every extracted point drawn on the chart, and correct a point before you export.
|
|
37
|
+
|
|
38
|
+
## Links
|
|
39
|
+
|
|
40
|
+
- API reference: https://graph2table.com/docs/api
|
|
41
|
+
- Accuracy and released data: https://graph2table.com/blog/chartx-technical-report
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "graph2table"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Client for the graph2table API: send a chart image, get the data table."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "graph2table" }]
|
|
14
|
+
keywords = ["chart", "graph", "plot digitizer", "data extraction", "chart to table", "csv"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Scientific/Engineering",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["requests>=2.28"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://graph2table.com"
|
|
25
|
+
Documentation = "https://graph2table.com/docs/api"
|
|
26
|
+
Benchmark = "https://graph2table.com/blog/chartx-technical-report"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/graph2table"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""graph2table Python client. Send a chart image, get the data table.
|
|
2
|
+
|
|
3
|
+
from graph2table import convert
|
|
4
|
+
|
|
5
|
+
result = convert("figure.png", api_key="YOUR_API_KEY")
|
|
6
|
+
result.headers
|
|
7
|
+
result.rows
|
|
8
|
+
result.to_csv("figure.csv")
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .client import Client, ConversionResult, Graph2TableError, convert
|
|
12
|
+
|
|
13
|
+
__all__ = ["Client", "ConversionResult", "Graph2TableError", "convert"]
|
|
14
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""HTTP client for the graph2table API (POST /api/v1/convert)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import IO, Any, Dict, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://graph2table.com"
|
|
16
|
+
DEFAULT_TIMEOUT = 180
|
|
17
|
+
ENV_API_KEY = "GRAPH2TABLE_API_KEY"
|
|
18
|
+
|
|
19
|
+
ImageInput = Union[str, "os.PathLike[str]", bytes, IO[bytes]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Graph2TableError(Exception):
|
|
23
|
+
"""Raised when the API returns an error response."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status: int, message: str):
|
|
26
|
+
super().__init__(f"graph2table API error {status}: {message}")
|
|
27
|
+
self.status = status
|
|
28
|
+
self.message = message
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ConversionResult:
|
|
33
|
+
"""The extracted table. `rows` are dicts keyed by column header."""
|
|
34
|
+
|
|
35
|
+
headers: List[str]
|
|
36
|
+
rows: List[Dict[str, Any]] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def to_csv(self, path: Optional[Union[str, "os.PathLike[str]"]] = None) -> str:
|
|
39
|
+
"""Return the table as CSV text. If `path` is given, also write it there."""
|
|
40
|
+
buf = io.StringIO()
|
|
41
|
+
writer = csv.DictWriter(buf, fieldnames=self.headers, extrasaction="ignore")
|
|
42
|
+
writer.writeheader()
|
|
43
|
+
for row in self.rows:
|
|
44
|
+
writer.writerow(row)
|
|
45
|
+
text = buf.getvalue()
|
|
46
|
+
if path is not None:
|
|
47
|
+
Path(path).write_text(text, encoding="utf-8", newline="")
|
|
48
|
+
return text
|
|
49
|
+
|
|
50
|
+
def to_dataframe(self): # type: ignore[no-untyped-def]
|
|
51
|
+
"""Return a pandas DataFrame (pandas is an optional dependency)."""
|
|
52
|
+
try:
|
|
53
|
+
import pandas as pd
|
|
54
|
+
except ImportError as exc: # pragma: no cover
|
|
55
|
+
raise ImportError("pandas is required: pip install graph2table[pandas]") from exc
|
|
56
|
+
return pd.DataFrame(self.rows, columns=self.headers)
|
|
57
|
+
|
|
58
|
+
def __len__(self) -> int:
|
|
59
|
+
return len(self.rows)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_data(data: Any) -> ConversionResult:
|
|
63
|
+
"""The API returns `data` as a JSON string holding a list of row objects."""
|
|
64
|
+
rows = json.loads(data) if isinstance(data, str) else data
|
|
65
|
+
if not isinstance(rows, list):
|
|
66
|
+
raise Graph2TableError(200, "Unexpected response shape: data is not a list")
|
|
67
|
+
headers: List[str] = []
|
|
68
|
+
for row in rows:
|
|
69
|
+
for key in row:
|
|
70
|
+
if key not in headers:
|
|
71
|
+
headers.append(key)
|
|
72
|
+
return ConversionResult(headers=headers, rows=rows)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _open_image(image: ImageInput, filename: Optional[str]):
|
|
76
|
+
"""Return (filename, bytes-or-file) for the multipart upload."""
|
|
77
|
+
if isinstance(image, (bytes, bytearray)):
|
|
78
|
+
return filename or "image.png", bytes(image)
|
|
79
|
+
if hasattr(image, "read"):
|
|
80
|
+
name = filename or getattr(image, "name", None) or "image.png"
|
|
81
|
+
return os.path.basename(str(name)), image
|
|
82
|
+
path = Path(image)
|
|
83
|
+
return filename or path.name, path.read_bytes()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Client:
|
|
87
|
+
"""Client for the graph2table API.
|
|
88
|
+
|
|
89
|
+
The API key comes from the `api_key` argument or the GRAPH2TABLE_API_KEY
|
|
90
|
+
environment variable. Create keys at https://graph2table.com/app/keys.
|
|
91
|
+
Every request costs 10 credits.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
api_key: Optional[str] = None,
|
|
97
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
98
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
99
|
+
session: Optional[requests.Session] = None,
|
|
100
|
+
):
|
|
101
|
+
key = api_key or os.environ.get(ENV_API_KEY)
|
|
102
|
+
if not key:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"No API key. Pass api_key=... or set the {ENV_API_KEY} environment variable."
|
|
105
|
+
)
|
|
106
|
+
self.api_key = key
|
|
107
|
+
self.base_url = base_url.rstrip("/")
|
|
108
|
+
self.timeout = timeout
|
|
109
|
+
self.session = session or requests.Session()
|
|
110
|
+
|
|
111
|
+
def convert(
|
|
112
|
+
self,
|
|
113
|
+
image: ImageInput,
|
|
114
|
+
custom_instructions: Optional[str] = None,
|
|
115
|
+
filename: Optional[str] = None,
|
|
116
|
+
) -> ConversionResult:
|
|
117
|
+
"""Extract the data table from one chart image.
|
|
118
|
+
|
|
119
|
+
`image` is a file path, raw bytes, or an open binary file. PNG, JPG and
|
|
120
|
+
WebP up to 10 MB are accepted. `custom_instructions` (500 characters or
|
|
121
|
+
fewer) tells the extractor what to focus on, for example
|
|
122
|
+
"Extract only the red series".
|
|
123
|
+
"""
|
|
124
|
+
name, payload = _open_image(image, filename)
|
|
125
|
+
files = {"file": (name, payload)}
|
|
126
|
+
data = {}
|
|
127
|
+
if custom_instructions:
|
|
128
|
+
data["custom_instructions"] = custom_instructions
|
|
129
|
+
response = self.session.post(
|
|
130
|
+
f"{self.base_url}/api/v1/convert",
|
|
131
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
132
|
+
files=files,
|
|
133
|
+
data=data,
|
|
134
|
+
timeout=self.timeout,
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
body = response.json()
|
|
138
|
+
except ValueError:
|
|
139
|
+
body = {}
|
|
140
|
+
if response.status_code != 200 or not body.get("success"):
|
|
141
|
+
message = body.get("error") if isinstance(body, dict) else None
|
|
142
|
+
raise Graph2TableError(response.status_code, message or response.text[:200])
|
|
143
|
+
return _parse_data(body.get("data"))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def convert(
|
|
147
|
+
image: ImageInput,
|
|
148
|
+
api_key: Optional[str] = None,
|
|
149
|
+
custom_instructions: Optional[str] = None,
|
|
150
|
+
**client_kwargs: Any,
|
|
151
|
+
) -> ConversionResult:
|
|
152
|
+
"""One-call convenience wrapper around `Client.convert`."""
|
|
153
|
+
return Client(api_key=api_key, **client_kwargs).convert(
|
|
154
|
+
image, custom_instructions=custom_instructions
|
|
155
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from graph2table import Client, Graph2TableError, convert
|
|
6
|
+
from graph2table.client import _parse_data
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeResponse:
|
|
10
|
+
def __init__(self, status_code, body, text=""):
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
self._body = body
|
|
13
|
+
self.text = text or json.dumps(body)
|
|
14
|
+
|
|
15
|
+
def json(self):
|
|
16
|
+
return self._body
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeSession:
|
|
20
|
+
def __init__(self, response):
|
|
21
|
+
self.response = response
|
|
22
|
+
self.calls = []
|
|
23
|
+
|
|
24
|
+
def post(self, url, **kwargs):
|
|
25
|
+
self.calls.append((url, kwargs))
|
|
26
|
+
return self.response
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def legacy_data(rows):
|
|
30
|
+
# The API returns `data` as a pretty-printed JSON string of row objects.
|
|
31
|
+
return json.dumps(rows, indent=4)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_convert_parses_legacy_data(tmp_path):
|
|
35
|
+
rows = [{"x": 1, "Series A": 2.5}, {"x": 2, "Series A": 3.0}]
|
|
36
|
+
session = FakeSession(FakeResponse(200, {"success": True, "data": legacy_data(rows)}))
|
|
37
|
+
img = tmp_path / "chart.png"
|
|
38
|
+
img.write_bytes(b"\x89PNG fake")
|
|
39
|
+
|
|
40
|
+
result = Client(api_key="k", session=session).convert(img, custom_instructions="red only")
|
|
41
|
+
|
|
42
|
+
assert result.headers == ["x", "Series A"]
|
|
43
|
+
assert result.rows == rows
|
|
44
|
+
assert len(result) == 2
|
|
45
|
+
url, kwargs = session.calls[0]
|
|
46
|
+
assert url == "https://graph2table.com/api/v1/convert"
|
|
47
|
+
assert kwargs["headers"]["Authorization"] == "Bearer k"
|
|
48
|
+
assert kwargs["files"]["file"][0] == "chart.png"
|
|
49
|
+
assert kwargs["data"] == {"custom_instructions": "red only"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_to_csv_writes_file(tmp_path):
|
|
53
|
+
result = _parse_data(legacy_data([{"x": 1, "y": 2}, {"x": 3, "y": 4}]))
|
|
54
|
+
out = tmp_path / "t.csv"
|
|
55
|
+
text = result.to_csv(out)
|
|
56
|
+
assert text.splitlines() == ["x,y", "1,2", "3,4"]
|
|
57
|
+
assert out.read_text(encoding="utf-8").splitlines() == ["x,y", "1,2", "3,4"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_error_response_raises():
|
|
61
|
+
session = FakeSession(FakeResponse(402, {"error": "Insufficient credits."}))
|
|
62
|
+
with pytest.raises(Graph2TableError) as exc:
|
|
63
|
+
Client(api_key="k", session=session).convert(b"bytes")
|
|
64
|
+
assert exc.value.status == 402
|
|
65
|
+
assert "Insufficient credits" in str(exc.value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_missing_api_key(monkeypatch):
|
|
69
|
+
monkeypatch.delenv("GRAPH2TABLE_API_KEY", raising=False)
|
|
70
|
+
with pytest.raises(ValueError):
|
|
71
|
+
Client()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_env_api_key(monkeypatch):
|
|
75
|
+
monkeypatch.setenv("GRAPH2TABLE_API_KEY", "from-env")
|
|
76
|
+
session = FakeSession(FakeResponse(200, {"success": True, "data": "[]"}))
|
|
77
|
+
result = convert(b"bytes", session=session)
|
|
78
|
+
assert result.rows == []
|
|
79
|
+
assert session.calls[0][1]["headers"]["Authorization"] == "Bearer from-env"
|