image2ppt 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.
- image2ppt-0.1.0/.gitignore +27 -0
- image2ppt-0.1.0/LICENSE +21 -0
- image2ppt-0.1.0/PKG-INFO +144 -0
- image2ppt-0.1.0/README.md +112 -0
- image2ppt-0.1.0/pyproject.toml +62 -0
- image2ppt-0.1.0/src/image2ppt/__init__.py +50 -0
- image2ppt-0.1.0/src/image2ppt/_compress.py +72 -0
- image2ppt-0.1.0/src/image2ppt/client.py +280 -0
- image2ppt-0.1.0/src/image2ppt/errors.py +155 -0
- image2ppt-0.1.0/src/image2ppt/models.py +62 -0
- image2ppt-0.1.0/src/image2ppt/py.typed +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
|
|
14
|
+
# Node / TypeScript
|
|
15
|
+
node_modules/
|
|
16
|
+
typescript/dist/
|
|
17
|
+
*.tsbuildinfo
|
|
18
|
+
npm-debug.log*
|
|
19
|
+
|
|
20
|
+
# Editors / OS
|
|
21
|
+
.DS_Store
|
|
22
|
+
.idea/
|
|
23
|
+
.vscode/
|
|
24
|
+
|
|
25
|
+
# Local artifacts
|
|
26
|
+
*.pptx
|
|
27
|
+
out.pptx
|
image2ppt-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 image2ppt
|
|
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.
|
image2ppt-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: image2ppt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the image2ppt API — convert images and PDFs into editable PowerPoint (.pptx).
|
|
5
|
+
Project-URL: Homepage, https://image2ppt.com
|
|
6
|
+
Project-URL: Documentation, https://image2ppt.com/docs/api
|
|
7
|
+
Project-URL: Repository, https://github.com/shrektan/image2ppt-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/shrektan/image2ppt-sdk/issues
|
|
9
|
+
Author: image2ppt
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,image-to-pptx,image2ppt,ocr,pdf,powerpoint,pptx,presentation,sdk,slides
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Multimedia :: Graphics :: Presentation
|
|
24
|
+
Classifier: Topic :: Office/Business :: Office Suites
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: pillow>=9.0
|
|
28
|
+
Requires-Dist: requests>=2.25
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# image2ppt — Python client
|
|
34
|
+
|
|
35
|
+
Official Python client for the [image2ppt](https://image2ppt.com) API. Turn a batch of images or PDF pages into one **editable** PowerPoint (`.pptx`).
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install image2ppt
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.9+. Depends on `requests` and `Pillow` (Pillow powers optional client-side image pre-compression — see below).
|
|
44
|
+
|
|
45
|
+
## Get an API key
|
|
46
|
+
|
|
47
|
+
Sign in at [image2ppt.com](https://image2ppt.com), open **Developer / API** from the account menu, and create a key (looks like `i2p_live_xxxx`). It's shown in full **once** — save it. API access is available to accounts with credits.
|
|
48
|
+
|
|
49
|
+
> **Server-side only.** Keep your key on your backend. Never embed it in a browser, mobile app, or anything a user can inspect.
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
One shot — submit, wait, download:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from image2ppt import Image2PPTClient
|
|
57
|
+
|
|
58
|
+
client = Image2PPTClient(api_key="i2p_live_your_key")
|
|
59
|
+
|
|
60
|
+
job = client.convert(
|
|
61
|
+
["slide1.png", "slide2.png", "report.pdf"],
|
|
62
|
+
dest_path="out.pptx",
|
|
63
|
+
locale="zh-CN", # optional: "zh-CN" (default) or "en"
|
|
64
|
+
aspect_ratio="16:9", # optional: "auto" (default) / "16:9" / "4:3"
|
|
65
|
+
)
|
|
66
|
+
print("done — credits used:", job.credits_used, "refunded:", job.credits_refunded)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Step by step, if you want to control polling:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
job = client.submit(["slide1.png"], aspect_ratio="4:3")
|
|
73
|
+
print("job:", job.job_id, "credits reserved:", job.credits_reserved)
|
|
74
|
+
|
|
75
|
+
job = client.wait(job.job_id, poll_interval=5, timeout=1800)
|
|
76
|
+
client.download(job.job_id, "out.pptx")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Check your balance:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
info = client.account()
|
|
83
|
+
print(info["email"], "credits:", info["credits"])
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## How it works
|
|
87
|
+
|
|
88
|
+
- **Async.** `submit` returns a job id immediately; conversion runs in the background. A single page typically takes ~2 minutes; 90% of jobs finish within 3.
|
|
89
|
+
- **One job = one PPTX.** All files in a submission are merged into a single deck, in upload order.
|
|
90
|
+
- **Billed per page.** 1 page = 1 credit, reserved at submit and settled on completion. If some pages fail but others succeed, the job still `completed`s with the good pages and the failed pages' credits are refunded (`credits_refunded`).
|
|
91
|
+
- **Limits.** Each file ≤ 35MB; total ≤ 50 pages per job (images count as 1, PDFs as their page count).
|
|
92
|
+
- **Client-side pre-compression.** Images are compressed to the server's spec before upload (≤2000px, ≤1MB, JPEG), so the server's own pass is a no-op and you send fewer bytes. PDFs are uploaded as-is and rendered server-side.
|
|
93
|
+
|
|
94
|
+
## Rate limits
|
|
95
|
+
|
|
96
|
+
Per account (all keys share the budget): ≤ 10 concurrent jobs, ≤ 60 pages/minute submitted. Over the limit returns `429` with a `Retry-After` hint. `wait()` handles 429 backoff for you automatically. If you call `submit()` directly, catch `RateLimitedError` and honor `retry_after`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import time
|
|
100
|
+
from image2ppt import RateLimitedError
|
|
101
|
+
|
|
102
|
+
while True:
|
|
103
|
+
try:
|
|
104
|
+
job = client.submit(paths)
|
|
105
|
+
break
|
|
106
|
+
except RateLimitedError as e:
|
|
107
|
+
time.sleep(e.retry_after or 5)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Errors
|
|
111
|
+
|
|
112
|
+
Every exception subclasses `Image2PPTError` and carries `status_code`, `code`, and `message`. Branch on `code`, not `message`.
|
|
113
|
+
|
|
114
|
+
| Exception | HTTP | code |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `AuthenticationError` | 401 / 403 | `INVALID_API_KEY`, `API_KEY_REQUIRED`, `ACCOUNT_DELETED` |
|
|
117
|
+
| `InvalidFileError` | 400 | `INVALID_FILE` |
|
|
118
|
+
| `TooManySlidesError` | 400 | `TOO_MANY_SLIDES` |
|
|
119
|
+
| `InsufficientCreditsError` | 402 | `INSUFFICIENT_CREDITS` |
|
|
120
|
+
| `RateLimitedError` | 429 | `RATE_LIMITED` (has `retry_after`) |
|
|
121
|
+
| `JobNotFoundError` | 404 | `JOB_NOT_FOUND` |
|
|
122
|
+
| `NotReadyError` | 409 | `NOT_READY` |
|
|
123
|
+
| `OutputExpiredError` | 410 | `OUTPUT_EXPIRED` |
|
|
124
|
+
| `JobFailedError` | — | job's `error.code` (raised by `wait()`; `e.job` is the snapshot) |
|
|
125
|
+
| `Image2PPTTimeoutError` | — | — (`wait()` exceeded its `timeout`; job may still be running) |
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from image2ppt import Image2PPTError, JobFailedError
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
job = client.convert(paths, "out.pptx")
|
|
132
|
+
except JobFailedError as e:
|
|
133
|
+
print("conversion failed:", e.code, e.message)
|
|
134
|
+
except Image2PPTError as e:
|
|
135
|
+
print("request error:", e.status_code, e.code, e.message)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Full API reference
|
|
139
|
+
|
|
140
|
+
See [../docs/api.md](../docs/api.md) for the complete HTTP contract (endpoints, fields, error codes).
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# image2ppt — Python client
|
|
2
|
+
|
|
3
|
+
Official Python client for the [image2ppt](https://image2ppt.com) API. Turn a batch of images or PDF pages into one **editable** PowerPoint (`.pptx`).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install image2ppt
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.9+. Depends on `requests` and `Pillow` (Pillow powers optional client-side image pre-compression — see below).
|
|
12
|
+
|
|
13
|
+
## Get an API key
|
|
14
|
+
|
|
15
|
+
Sign in at [image2ppt.com](https://image2ppt.com), open **Developer / API** from the account menu, and create a key (looks like `i2p_live_xxxx`). It's shown in full **once** — save it. API access is available to accounts with credits.
|
|
16
|
+
|
|
17
|
+
> **Server-side only.** Keep your key on your backend. Never embed it in a browser, mobile app, or anything a user can inspect.
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
One shot — submit, wait, download:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from image2ppt import Image2PPTClient
|
|
25
|
+
|
|
26
|
+
client = Image2PPTClient(api_key="i2p_live_your_key")
|
|
27
|
+
|
|
28
|
+
job = client.convert(
|
|
29
|
+
["slide1.png", "slide2.png", "report.pdf"],
|
|
30
|
+
dest_path="out.pptx",
|
|
31
|
+
locale="zh-CN", # optional: "zh-CN" (default) or "en"
|
|
32
|
+
aspect_ratio="16:9", # optional: "auto" (default) / "16:9" / "4:3"
|
|
33
|
+
)
|
|
34
|
+
print("done — credits used:", job.credits_used, "refunded:", job.credits_refunded)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Step by step, if you want to control polling:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
job = client.submit(["slide1.png"], aspect_ratio="4:3")
|
|
41
|
+
print("job:", job.job_id, "credits reserved:", job.credits_reserved)
|
|
42
|
+
|
|
43
|
+
job = client.wait(job.job_id, poll_interval=5, timeout=1800)
|
|
44
|
+
client.download(job.job_id, "out.pptx")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Check your balance:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
info = client.account()
|
|
51
|
+
print(info["email"], "credits:", info["credits"])
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## How it works
|
|
55
|
+
|
|
56
|
+
- **Async.** `submit` returns a job id immediately; conversion runs in the background. A single page typically takes ~2 minutes; 90% of jobs finish within 3.
|
|
57
|
+
- **One job = one PPTX.** All files in a submission are merged into a single deck, in upload order.
|
|
58
|
+
- **Billed per page.** 1 page = 1 credit, reserved at submit and settled on completion. If some pages fail but others succeed, the job still `completed`s with the good pages and the failed pages' credits are refunded (`credits_refunded`).
|
|
59
|
+
- **Limits.** Each file ≤ 35MB; total ≤ 50 pages per job (images count as 1, PDFs as their page count).
|
|
60
|
+
- **Client-side pre-compression.** Images are compressed to the server's spec before upload (≤2000px, ≤1MB, JPEG), so the server's own pass is a no-op and you send fewer bytes. PDFs are uploaded as-is and rendered server-side.
|
|
61
|
+
|
|
62
|
+
## Rate limits
|
|
63
|
+
|
|
64
|
+
Per account (all keys share the budget): ≤ 10 concurrent jobs, ≤ 60 pages/minute submitted. Over the limit returns `429` with a `Retry-After` hint. `wait()` handles 429 backoff for you automatically. If you call `submit()` directly, catch `RateLimitedError` and honor `retry_after`:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import time
|
|
68
|
+
from image2ppt import RateLimitedError
|
|
69
|
+
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
job = client.submit(paths)
|
|
73
|
+
break
|
|
74
|
+
except RateLimitedError as e:
|
|
75
|
+
time.sleep(e.retry_after or 5)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Errors
|
|
79
|
+
|
|
80
|
+
Every exception subclasses `Image2PPTError` and carries `status_code`, `code`, and `message`. Branch on `code`, not `message`.
|
|
81
|
+
|
|
82
|
+
| Exception | HTTP | code |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `AuthenticationError` | 401 / 403 | `INVALID_API_KEY`, `API_KEY_REQUIRED`, `ACCOUNT_DELETED` |
|
|
85
|
+
| `InvalidFileError` | 400 | `INVALID_FILE` |
|
|
86
|
+
| `TooManySlidesError` | 400 | `TOO_MANY_SLIDES` |
|
|
87
|
+
| `InsufficientCreditsError` | 402 | `INSUFFICIENT_CREDITS` |
|
|
88
|
+
| `RateLimitedError` | 429 | `RATE_LIMITED` (has `retry_after`) |
|
|
89
|
+
| `JobNotFoundError` | 404 | `JOB_NOT_FOUND` |
|
|
90
|
+
| `NotReadyError` | 409 | `NOT_READY` |
|
|
91
|
+
| `OutputExpiredError` | 410 | `OUTPUT_EXPIRED` |
|
|
92
|
+
| `JobFailedError` | — | job's `error.code` (raised by `wait()`; `e.job` is the snapshot) |
|
|
93
|
+
| `Image2PPTTimeoutError` | — | — (`wait()` exceeded its `timeout`; job may still be running) |
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from image2ppt import Image2PPTError, JobFailedError
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
job = client.convert(paths, "out.pptx")
|
|
100
|
+
except JobFailedError as e:
|
|
101
|
+
print("conversion failed:", e.code, e.message)
|
|
102
|
+
except Image2PPTError as e:
|
|
103
|
+
print("request error:", e.status_code, e.code, e.message)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Full API reference
|
|
107
|
+
|
|
108
|
+
See [../docs/api.md](../docs/api.md) for the complete HTTP contract (endpoints, fields, error codes).
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "image2ppt"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the image2ppt API — convert images and PDFs into editable PowerPoint (.pptx)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "image2ppt" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"image2ppt",
|
|
16
|
+
"pptx",
|
|
17
|
+
"powerpoint",
|
|
18
|
+
"presentation",
|
|
19
|
+
"slides",
|
|
20
|
+
"pdf",
|
|
21
|
+
"ocr",
|
|
22
|
+
"image-to-pptx",
|
|
23
|
+
"api",
|
|
24
|
+
"sdk",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.9",
|
|
33
|
+
"Programming Language :: Python :: 3.10",
|
|
34
|
+
"Programming Language :: Python :: 3.11",
|
|
35
|
+
"Programming Language :: Python :: 3.12",
|
|
36
|
+
"Programming Language :: Python :: 3.13",
|
|
37
|
+
"Topic :: Office/Business :: Office Suites",
|
|
38
|
+
"Topic :: Multimedia :: Graphics :: Presentation",
|
|
39
|
+
"Typing :: Typed",
|
|
40
|
+
]
|
|
41
|
+
dependencies = [
|
|
42
|
+
"requests>=2.25",
|
|
43
|
+
"Pillow>=9.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://image2ppt.com"
|
|
48
|
+
Documentation = "https://image2ppt.com/docs/api"
|
|
49
|
+
Repository = "https://github.com/shrektan/image2ppt-sdk"
|
|
50
|
+
Issues = "https://github.com/shrektan/image2ppt-sdk/issues"
|
|
51
|
+
|
|
52
|
+
[project.optional-dependencies]
|
|
53
|
+
dev = ["pytest>=7"]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/image2ppt"]
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.sdist]
|
|
59
|
+
include = ["src", "README.md", "LICENSE"]
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Official Python client for the image2ppt API.
|
|
2
|
+
|
|
3
|
+
Convert images and PDFs into editable PowerPoint (.pptx) decks.
|
|
4
|
+
|
|
5
|
+
from image2ppt import Image2PPTClient
|
|
6
|
+
|
|
7
|
+
client = Image2PPTClient(api_key="i2p_live_...")
|
|
8
|
+
job = client.convert(["slide1.png", "report.pdf"], dest_path="out.pptx")
|
|
9
|
+
print("credits used:", job.credits_used)
|
|
10
|
+
|
|
11
|
+
See https://github.com/shrektan/image2ppt-sdk for docs and examples.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .client import DEFAULT_BASE_URL, Image2PPTClient
|
|
17
|
+
from .errors import (
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
Image2PPTError,
|
|
20
|
+
Image2PPTTimeoutError,
|
|
21
|
+
InsufficientCreditsError,
|
|
22
|
+
InvalidFileError,
|
|
23
|
+
JobFailedError,
|
|
24
|
+
JobNotFoundError,
|
|
25
|
+
NotReadyError,
|
|
26
|
+
OutputExpiredError,
|
|
27
|
+
RateLimitedError,
|
|
28
|
+
TooManySlidesError,
|
|
29
|
+
)
|
|
30
|
+
from .models import Job
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"Image2PPTClient",
|
|
36
|
+
"Job",
|
|
37
|
+
"DEFAULT_BASE_URL",
|
|
38
|
+
"Image2PPTError",
|
|
39
|
+
"AuthenticationError",
|
|
40
|
+
"InvalidFileError",
|
|
41
|
+
"TooManySlidesError",
|
|
42
|
+
"InsufficientCreditsError",
|
|
43
|
+
"RateLimitedError",
|
|
44
|
+
"JobNotFoundError",
|
|
45
|
+
"NotReadyError",
|
|
46
|
+
"OutputExpiredError",
|
|
47
|
+
"JobFailedError",
|
|
48
|
+
"Image2PPTTimeoutError",
|
|
49
|
+
"__version__",
|
|
50
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Client-side image compression, matched to the server's upload pipeline.
|
|
2
|
+
|
|
3
|
+
The server runs the same compression on every upload (``compressImageForUpload``).
|
|
4
|
+
By pre-compressing to the same spec, the server's pass becomes a passthrough — one
|
|
5
|
+
less redundant compute, fewer bytes on the wire. These constants must stay in sync
|
|
6
|
+
with the server; changing one means changing both.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
|
|
13
|
+
from PIL import Image
|
|
14
|
+
|
|
15
|
+
_UPLOAD_TARGET_BYTES = 1024 * 1024
|
|
16
|
+
_UPLOAD_MAX_DIM = 2000
|
|
17
|
+
_UPLOAD_QUALITY_LADDER = (90, 85, 80)
|
|
18
|
+
# Only PNG / JPEG pass through as-is; WebP / GIF are transcoded to JPEG even when
|
|
19
|
+
# small (matching the server, which transcodes anything that isn't PNG/JPEG first).
|
|
20
|
+
_PASSTHROUGH_MIMES = frozenset({"image/png", "image/jpeg"})
|
|
21
|
+
IMAGE_MIMES = frozenset({"image/png", "image/jpeg", "image/webp", "image/gif"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def compress_image_for_upload(raw: bytes, mime: str) -> "tuple[bytes, str]":
|
|
25
|
+
"""Compress an image to the server's spec; return ``(bytes, mime)``.
|
|
26
|
+
|
|
27
|
+
Rules mirror the server's ``compressImageForUpload``:
|
|
28
|
+
- PNG/JPEG with longest edge <= 2000px and <= 1MB -> returned as-is (passthrough).
|
|
29
|
+
- Otherwise: fit inside 2000x2000 (shrink only), flatten transparency onto
|
|
30
|
+
white, JPEG at quality 90 -> 85 -> 80 until <= 1MB or the ladder bottoms out.
|
|
31
|
+
- Fallback: if compression somehow yields a larger file (already-low-quality
|
|
32
|
+
sources do this) -> return the original, never "blurrier AND bigger".
|
|
33
|
+
|
|
34
|
+
Only images go through here; PDFs are uploaded as-is and rendered server-side.
|
|
35
|
+
"""
|
|
36
|
+
with Image.open(io.BytesIO(raw)) as img:
|
|
37
|
+
img.load() # animated GIF / WebP: first frame only (Pillow default)
|
|
38
|
+
width, height = img.size
|
|
39
|
+
within_budget = (
|
|
40
|
+
len(raw) <= _UPLOAD_TARGET_BYTES and max(width, height) <= _UPLOAD_MAX_DIM
|
|
41
|
+
)
|
|
42
|
+
if within_budget and mime in _PASSTHROUGH_MIMES:
|
|
43
|
+
return raw, mime
|
|
44
|
+
|
|
45
|
+
scaled = img.copy()
|
|
46
|
+
# thumbnail = fit inside, no enlargement (server's fit:inside + withoutEnlargement).
|
|
47
|
+
if max(width, height) > _UPLOAD_MAX_DIM:
|
|
48
|
+
scaled.thumbnail((_UPLOAD_MAX_DIM, _UPLOAD_MAX_DIM), Image.LANCZOS)
|
|
49
|
+
|
|
50
|
+
# Flatten onto white, dropping alpha (server's .flatten({background:'#ffffff'})).
|
|
51
|
+
has_alpha = scaled.mode in ("RGBA", "LA") or (
|
|
52
|
+
scaled.mode == "P" and "transparency" in scaled.info
|
|
53
|
+
)
|
|
54
|
+
if has_alpha:
|
|
55
|
+
rgba = scaled.convert("RGBA")
|
|
56
|
+
flattened = Image.new("RGB", rgba.size, (255, 255, 255))
|
|
57
|
+
flattened.paste(rgba, mask=rgba.split()[-1])
|
|
58
|
+
scaled = flattened
|
|
59
|
+
else:
|
|
60
|
+
scaled = scaled.convert("RGB")
|
|
61
|
+
|
|
62
|
+
compressed = None
|
|
63
|
+
for quality in _UPLOAD_QUALITY_LADDER:
|
|
64
|
+
buffer = io.BytesIO()
|
|
65
|
+
scaled.save(buffer, format="JPEG", quality=quality)
|
|
66
|
+
compressed = buffer.getvalue()
|
|
67
|
+
if len(compressed) <= _UPLOAD_TARGET_BYTES:
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
if compressed is None or len(compressed) >= len(raw):
|
|
71
|
+
return raw, mime
|
|
72
|
+
return compressed, "image/jpeg"
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""The image2ppt API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from ._compress import IMAGE_MIMES, compress_image_for_upload
|
|
13
|
+
from .errors import (
|
|
14
|
+
Image2PPTError,
|
|
15
|
+
Image2PPTTimeoutError,
|
|
16
|
+
JobFailedError,
|
|
17
|
+
RateLimitedError,
|
|
18
|
+
exception_for,
|
|
19
|
+
)
|
|
20
|
+
from .models import Job
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://image2ppt.com"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Image2PPTClient:
|
|
26
|
+
"""Client for the image2ppt API.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
api_key: Your API key (looks like ``i2p_live_...``), created on the
|
|
30
|
+
Developer / API page.
|
|
31
|
+
base_url: Service base URL, defaults to ``https://image2ppt.com``.
|
|
32
|
+
timeout: Per-HTTP-request timeout in seconds (not the whole-job wait).
|
|
33
|
+
session: Optional ``requests.Session`` to inject (for testing or pooling).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
#: Supported input extensions -> MIME type (for labeling multipart uploads).
|
|
37
|
+
_MIME_BY_EXT = {
|
|
38
|
+
".png": "image/png",
|
|
39
|
+
".jpg": "image/jpeg",
|
|
40
|
+
".jpeg": "image/jpeg",
|
|
41
|
+
".webp": "image/webp",
|
|
42
|
+
".gif": "image/gif",
|
|
43
|
+
".pdf": "application/pdf",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
api_key: str,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
*,
|
|
51
|
+
timeout: float = 60.0,
|
|
52
|
+
session: Optional[requests.Session] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if not api_key:
|
|
55
|
+
raise ValueError("api_key must not be empty")
|
|
56
|
+
self.base_url = base_url.rstrip("/")
|
|
57
|
+
self.timeout = timeout
|
|
58
|
+
self._session = session or requests.Session()
|
|
59
|
+
self._session.headers.update({"Authorization": f"Bearer {api_key}"})
|
|
60
|
+
|
|
61
|
+
# ----- public methods ---------------------------------------------- #
|
|
62
|
+
def submit(
|
|
63
|
+
self,
|
|
64
|
+
paths: Sequence[str],
|
|
65
|
+
*,
|
|
66
|
+
locale: Optional[str] = None,
|
|
67
|
+
aspect_ratio: Optional[str] = None,
|
|
68
|
+
) -> Job:
|
|
69
|
+
"""Submit a batch of files and create a conversion job.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
paths: Local file paths (one or more). Supports png/jpeg/webp/gif/pdf,
|
|
73
|
+
each file <= 35MB. An image is 1 page, a PDF is its page count;
|
|
74
|
+
the total must be <= 50 pages.
|
|
75
|
+
locale: ``zh-CN`` (default) or ``en``.
|
|
76
|
+
aspect_ratio: ``auto`` (default) / ``16:9`` / ``4:3``.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A ``Job`` with ``status`` ``pending``, plus ``slide_count`` and
|
|
80
|
+
``credits_reserved`` (credits locked at submit time).
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
AuthenticationError, InvalidFileError, TooManySlidesError,
|
|
84
|
+
InsufficientCreditsError, RateLimitedError.
|
|
85
|
+
"""
|
|
86
|
+
paths = list(paths)
|
|
87
|
+
if not paths:
|
|
88
|
+
raise ValueError("at least one file is required")
|
|
89
|
+
|
|
90
|
+
data: Dict[str, str] = {}
|
|
91
|
+
if locale is not None:
|
|
92
|
+
data["locale"] = locale
|
|
93
|
+
if aspect_ratio is not None:
|
|
94
|
+
data["aspectRatio"] = aspect_ratio
|
|
95
|
+
|
|
96
|
+
opened = []
|
|
97
|
+
multipart = []
|
|
98
|
+
try:
|
|
99
|
+
for path in paths:
|
|
100
|
+
filename = os.path.basename(path)
|
|
101
|
+
mime = self._guess_mime(filename)
|
|
102
|
+
if mime in IMAGE_MIMES:
|
|
103
|
+
# Images: pre-compress to the server spec so its pass is a passthrough.
|
|
104
|
+
with open(path, "rb") as fh:
|
|
105
|
+
raw = fh.read()
|
|
106
|
+
payload, out_mime = compress_image_for_upload(raw, mime)
|
|
107
|
+
if out_mime == "image/jpeg" and not filename.lower().endswith(
|
|
108
|
+
(".jpg", ".jpeg")
|
|
109
|
+
):
|
|
110
|
+
# Compressed to JPEG: align the extension so name matches content.
|
|
111
|
+
filename = os.path.splitext(filename)[0] + ".jpg"
|
|
112
|
+
multipart.append(("files", (filename, payload, out_mime)))
|
|
113
|
+
else:
|
|
114
|
+
# PDFs and other non-images: upload as-is (streamed), render server-side.
|
|
115
|
+
handle = open(path, "rb")
|
|
116
|
+
opened.append(handle)
|
|
117
|
+
multipart.append(("files", (filename, handle, mime)))
|
|
118
|
+
resp = self._session.post(
|
|
119
|
+
f"{self.base_url}/api/v1/jobs",
|
|
120
|
+
files=multipart,
|
|
121
|
+
data=data,
|
|
122
|
+
timeout=self.timeout,
|
|
123
|
+
)
|
|
124
|
+
finally:
|
|
125
|
+
for handle in opened:
|
|
126
|
+
handle.close()
|
|
127
|
+
|
|
128
|
+
return Job.from_dict(self._parse_json(resp))
|
|
129
|
+
|
|
130
|
+
def get_job(self, job_id: str) -> Job:
|
|
131
|
+
"""Fetch the current job state as a ``Job`` snapshot. Raises JobNotFoundError."""
|
|
132
|
+
resp = self._session.get(
|
|
133
|
+
f"{self.base_url}/api/v1/jobs/{job_id}",
|
|
134
|
+
timeout=self.timeout,
|
|
135
|
+
)
|
|
136
|
+
return Job.from_dict(self._parse_json(resp))
|
|
137
|
+
|
|
138
|
+
def wait(
|
|
139
|
+
self,
|
|
140
|
+
job_id: str,
|
|
141
|
+
*,
|
|
142
|
+
poll_interval: float = 5.0,
|
|
143
|
+
timeout: float = 1800.0,
|
|
144
|
+
) -> Job:
|
|
145
|
+
"""Poll until the job reaches a terminal state; return the completed ``Job``.
|
|
146
|
+
|
|
147
|
+
The poll interval starts at ``poll_interval`` and backs off to 15s max. On a
|
|
148
|
+
429 it waits the ``Retry-After`` seconds before continuing. A failed job
|
|
149
|
+
raises JobFailedError; exceeding ``timeout`` raises Image2PPTTimeoutError
|
|
150
|
+
(the job itself may still be running).
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
job_id: The job id.
|
|
154
|
+
poll_interval: Initial poll interval in seconds (default 5).
|
|
155
|
+
timeout: Overall wait cap in seconds (default 1800 = 30 min).
|
|
156
|
+
"""
|
|
157
|
+
deadline = time.monotonic() + timeout
|
|
158
|
+
interval = poll_interval
|
|
159
|
+
while True:
|
|
160
|
+
try:
|
|
161
|
+
job = self.get_job(job_id)
|
|
162
|
+
except RateLimitedError as exc:
|
|
163
|
+
sleep_for = exc.retry_after if exc.retry_after is not None else interval
|
|
164
|
+
self._sleep_until(deadline, sleep_for, job_id)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
if job.is_completed:
|
|
168
|
+
return job
|
|
169
|
+
if job.is_failed:
|
|
170
|
+
err = job.error or {}
|
|
171
|
+
raise JobFailedError(
|
|
172
|
+
err.get("message") or "conversion failed",
|
|
173
|
+
code=err.get("code"),
|
|
174
|
+
job=job,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
self._sleep_until(deadline, interval, job_id)
|
|
178
|
+
interval = min(interval * 1.5, 15.0)
|
|
179
|
+
|
|
180
|
+
def download(self, job_id: str, dest_path: str) -> str:
|
|
181
|
+
"""Stream a completed job's PPTX to ``dest_path``; return that path.
|
|
182
|
+
|
|
183
|
+
Raises NotReadyError (409) if the job isn't done, JobNotFoundError (404) if
|
|
184
|
+
it doesn't exist, OutputExpiredError (410) if the deliverable was reaped.
|
|
185
|
+
"""
|
|
186
|
+
resp = self._session.get(
|
|
187
|
+
f"{self.base_url}/api/v1/jobs/{job_id}/download",
|
|
188
|
+
stream=True,
|
|
189
|
+
timeout=self.timeout,
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
if not resp.ok:
|
|
193
|
+
self._raise_for_error(resp)
|
|
194
|
+
with open(dest_path, "wb") as out:
|
|
195
|
+
for chunk in resp.iter_content(chunk_size=65536):
|
|
196
|
+
if chunk:
|
|
197
|
+
out.write(chunk)
|
|
198
|
+
finally:
|
|
199
|
+
resp.close()
|
|
200
|
+
return dest_path
|
|
201
|
+
|
|
202
|
+
def convert(
|
|
203
|
+
self,
|
|
204
|
+
paths: Sequence[str],
|
|
205
|
+
dest_path: str,
|
|
206
|
+
*,
|
|
207
|
+
locale: Optional[str] = None,
|
|
208
|
+
aspect_ratio: Optional[str] = None,
|
|
209
|
+
poll_interval: float = 5.0,
|
|
210
|
+
timeout: float = 1800.0,
|
|
211
|
+
) -> Job:
|
|
212
|
+
"""One-shot: submit -> wait for completion -> download to ``dest_path``.
|
|
213
|
+
|
|
214
|
+
Arguments mirror ``submit`` and ``wait``. For the synchronous
|
|
215
|
+
"give me a batch of images, hand me back a PPTX" case.
|
|
216
|
+
"""
|
|
217
|
+
job = self.submit(paths, locale=locale, aspect_ratio=aspect_ratio)
|
|
218
|
+
completed = self.wait(job.job_id, poll_interval=poll_interval, timeout=timeout)
|
|
219
|
+
self.download(completed.job_id, dest_path)
|
|
220
|
+
return completed
|
|
221
|
+
|
|
222
|
+
def account(self) -> Dict[str, Any]:
|
|
223
|
+
"""Return account info: ``{"email": ..., "credits": available_credits}``."""
|
|
224
|
+
resp = self._session.get(
|
|
225
|
+
f"{self.base_url}/api/v1/account",
|
|
226
|
+
timeout=self.timeout,
|
|
227
|
+
)
|
|
228
|
+
return self._parse_json(resp)
|
|
229
|
+
|
|
230
|
+
# ----- internal helpers -------------------------------------------- #
|
|
231
|
+
def _guess_mime(self, filename: str) -> str:
|
|
232
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
233
|
+
if ext in self._MIME_BY_EXT:
|
|
234
|
+
return self._MIME_BY_EXT[ext]
|
|
235
|
+
guessed, _ = mimetypes.guess_type(filename)
|
|
236
|
+
return guessed or "application/octet-stream"
|
|
237
|
+
|
|
238
|
+
def _sleep_until(self, deadline: float, seconds: float, job_id: str) -> None:
|
|
239
|
+
"""Sleep ``seconds``, but never past ``deadline``; raise TimeoutError if past."""
|
|
240
|
+
remaining = deadline - time.monotonic()
|
|
241
|
+
if remaining <= 0:
|
|
242
|
+
raise Image2PPTTimeoutError(f"timed out waiting for job {job_id}", job_id=job_id)
|
|
243
|
+
time.sleep(min(seconds, remaining))
|
|
244
|
+
|
|
245
|
+
def _parse_json(self, resp: requests.Response) -> Dict[str, Any]:
|
|
246
|
+
"""Return the JSON body on 2xx; otherwise raise the mapped exception."""
|
|
247
|
+
if not resp.ok:
|
|
248
|
+
self._raise_for_error(resp)
|
|
249
|
+
return resp.json()
|
|
250
|
+
|
|
251
|
+
def _raise_for_error(self, resp: requests.Response) -> None:
|
|
252
|
+
"""Parse the ``{"error": {code, message}}`` envelope and raise the mapped error."""
|
|
253
|
+
code: Optional[str] = None
|
|
254
|
+
message: Optional[str] = None
|
|
255
|
+
try:
|
|
256
|
+
body = resp.json()
|
|
257
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
258
|
+
if isinstance(err, dict):
|
|
259
|
+
code = err.get("code")
|
|
260
|
+
message = err.get("message")
|
|
261
|
+
except ValueError:
|
|
262
|
+
pass # non-JSON error body (e.g. a gateway HTML page): fall back to status text
|
|
263
|
+
message = message or f"request failed (HTTP {resp.status_code})"
|
|
264
|
+
|
|
265
|
+
raise exception_for(
|
|
266
|
+
status_code=resp.status_code,
|
|
267
|
+
code=code,
|
|
268
|
+
message=message,
|
|
269
|
+
retry_after=self._parse_retry_after(resp.headers.get("Retry-After")),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
274
|
+
"""Parse the Retry-After header as seconds (contract: integer seconds)."""
|
|
275
|
+
if not value:
|
|
276
|
+
return None
|
|
277
|
+
try:
|
|
278
|
+
return float(value)
|
|
279
|
+
except (TypeError, ValueError):
|
|
280
|
+
return None
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Exception hierarchy for the image2ppt client.
|
|
2
|
+
|
|
3
|
+
Every error carries the HTTP ``status_code``, the server error ``code`` (from the
|
|
4
|
+
``{"error": {"code", "message"}}`` envelope), and a human-readable ``message``.
|
|
5
|
+
Branch on ``code``, not ``message`` — messages may be reworded.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Image2PPTError(Exception):
|
|
14
|
+
"""Base class for all client errors."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
*,
|
|
20
|
+
status_code: Optional[int] = None,
|
|
21
|
+
code: Optional[str] = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
self.code = code
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
parts = []
|
|
30
|
+
if self.status_code is not None:
|
|
31
|
+
parts.append(f"HTTP {self.status_code}")
|
|
32
|
+
if self.code:
|
|
33
|
+
parts.append(self.code)
|
|
34
|
+
prefix = " ".join(parts)
|
|
35
|
+
return f"[{prefix}] {self.message}" if prefix else self.message
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthenticationError(Image2PPTError):
|
|
39
|
+
"""API key is missing, invalid, or the account is gone (401 / 403)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InvalidFileError(Image2PPTError):
|
|
43
|
+
"""A file was rejected: unsupported format or over the 35MB per-file limit (400)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TooManySlidesError(Image2PPTError):
|
|
47
|
+
"""The submission exceeds the 50-page-per-job limit (400 TOO_MANY_SLIDES)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InsufficientCreditsError(Image2PPTError):
|
|
51
|
+
"""Not enough available credits to cover the submission (402)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RateLimitedError(Image2PPTError):
|
|
55
|
+
"""Rate limited (429 RATE_LIMITED).
|
|
56
|
+
|
|
57
|
+
``retry_after`` is the server-suggested wait in seconds (from the
|
|
58
|
+
``Retry-After`` header); retry after that long.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
message: str,
|
|
64
|
+
*,
|
|
65
|
+
status_code: Optional[int] = None,
|
|
66
|
+
code: Optional[str] = None,
|
|
67
|
+
retry_after: Optional[float] = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
super().__init__(message, status_code=status_code, code=code)
|
|
70
|
+
self.retry_after = retry_after
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class JobNotFoundError(Image2PPTError):
|
|
74
|
+
"""The job id doesn't exist, or isn't owned by this key's account (404)."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NotReadyError(Image2PPTError):
|
|
78
|
+
"""The job hasn't finished yet, so the deliverable can't be downloaded (409)."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OutputExpiredError(Image2PPTError):
|
|
82
|
+
"""The job finished, but its PPTX passed the retention window and was reaped (410)."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class JobFailedError(Image2PPTError):
|
|
86
|
+
"""The job ended in failure (raised by ``wait`` when it polls status=failed).
|
|
87
|
+
|
|
88
|
+
``job`` is the failure snapshot; ``code`` / ``message`` come from its ``error`` field.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
message: str,
|
|
94
|
+
*,
|
|
95
|
+
code: Optional[str] = None,
|
|
96
|
+
job: Optional[Any] = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
super().__init__(message, code=code)
|
|
99
|
+
self.job = job
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Image2PPTTimeoutError(Image2PPTError):
|
|
103
|
+
"""``wait`` exceeded its ``timeout`` before the job reached a terminal state.
|
|
104
|
+
|
|
105
|
+
This does not mean the job failed — it may still be running. Re-``wait`` on the
|
|
106
|
+
``job_id`` later. (The prefix avoids shadowing the builtin ``TimeoutError``.)
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, message: str, *, job_id: Optional[str] = None) -> None:
|
|
110
|
+
super().__init__(message)
|
|
111
|
+
self.job_id = job_id
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Server error code -> exception class. Unlisted codes fall back to the status-code
|
|
115
|
+
# map, then to the base class.
|
|
116
|
+
_CODE_TO_EXC: Dict[str, type] = {
|
|
117
|
+
"INVALID_API_KEY": AuthenticationError,
|
|
118
|
+
"API_KEY_REQUIRED": AuthenticationError,
|
|
119
|
+
"ACCOUNT_DELETED": AuthenticationError,
|
|
120
|
+
"INVALID_FILE": InvalidFileError,
|
|
121
|
+
"INVALID_PDF": InvalidFileError,
|
|
122
|
+
"TOO_MANY_SLIDES": TooManySlidesError,
|
|
123
|
+
"INSUFFICIENT_CREDITS": InsufficientCreditsError,
|
|
124
|
+
"RATE_LIMITED": RateLimitedError,
|
|
125
|
+
"JOB_NOT_FOUND": JobNotFoundError,
|
|
126
|
+
"NOT_READY": NotReadyError,
|
|
127
|
+
"OUTPUT_EXPIRED": OutputExpiredError,
|
|
128
|
+
}
|
|
129
|
+
_STATUS_TO_EXC: Dict[int, type] = {
|
|
130
|
+
401: AuthenticationError,
|
|
131
|
+
402: InsufficientCreditsError,
|
|
132
|
+
404: JobNotFoundError,
|
|
133
|
+
409: NotReadyError,
|
|
134
|
+
410: OutputExpiredError,
|
|
135
|
+
429: RateLimitedError,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def exception_for(
|
|
140
|
+
*,
|
|
141
|
+
status_code: int,
|
|
142
|
+
code: Optional[str],
|
|
143
|
+
message: str,
|
|
144
|
+
retry_after: Optional[float] = None,
|
|
145
|
+
) -> Image2PPTError:
|
|
146
|
+
"""Build the mapped exception for an error envelope."""
|
|
147
|
+
if status_code == 429:
|
|
148
|
+
return RateLimitedError(
|
|
149
|
+
message,
|
|
150
|
+
status_code=429,
|
|
151
|
+
code=code or "RATE_LIMITED",
|
|
152
|
+
retry_after=retry_after,
|
|
153
|
+
)
|
|
154
|
+
exc_cls = _CODE_TO_EXC.get(code or "") or _STATUS_TO_EXC.get(status_code, Image2PPTError)
|
|
155
|
+
return exc_cls(message, status_code=status_code, code=code)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Data models returned by the client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Job:
|
|
11
|
+
"""A snapshot of a conversion job's state.
|
|
12
|
+
|
|
13
|
+
Which fields are populated depends on the source: a ``submit`` response only
|
|
14
|
+
carries ``credits_reserved``; a ``get_job`` response carries
|
|
15
|
+
``credits_used`` / ``credits_refunded`` / ``download_url`` and friends.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
job_id: str
|
|
19
|
+
status: str # pending | processing | completed | failed
|
|
20
|
+
slide_count: Optional[int] = None
|
|
21
|
+
progress: Optional[int] = None # 0-100
|
|
22
|
+
credits_reserved: Optional[int] = None # submit response: credits locked
|
|
23
|
+
credits_used: Optional[int] = None # settled: credits actually charged
|
|
24
|
+
credits_refunded: Optional[int] = None # partial success: refunded failed pages
|
|
25
|
+
created_at: Optional[str] = None
|
|
26
|
+
completed_at: Optional[str] = None
|
|
27
|
+
download_url: Optional[str] = None # completed only; relative path
|
|
28
|
+
error: Optional[Dict[str, Any]] = None # failed only; {code, message}
|
|
29
|
+
raw: Optional[Dict[str, Any]] = None # raw response body, for forward-compat fields
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_completed(self) -> bool:
|
|
33
|
+
"""Whether the job finished successfully (deliverable downloadable)."""
|
|
34
|
+
return self.status == "completed"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_failed(self) -> bool:
|
|
38
|
+
"""Whether the job failed."""
|
|
39
|
+
return self.status == "failed"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_terminal(self) -> bool:
|
|
43
|
+
"""Whether the job reached a terminal state (completed or failed)."""
|
|
44
|
+
return self.status in ("completed", "failed")
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Job":
|
|
48
|
+
"""Build a Job from server JSON; handles both submit and status shapes."""
|
|
49
|
+
return cls(
|
|
50
|
+
job_id=data["jobId"],
|
|
51
|
+
status=data["status"],
|
|
52
|
+
slide_count=data.get("slideCount"),
|
|
53
|
+
progress=data.get("progress"),
|
|
54
|
+
credits_reserved=data.get("creditsReserved"),
|
|
55
|
+
credits_used=data.get("creditsUsed"),
|
|
56
|
+
credits_refunded=data.get("creditsRefunded"),
|
|
57
|
+
created_at=data.get("createdAt"),
|
|
58
|
+
completed_at=data.get("completedAt"),
|
|
59
|
+
download_url=data.get("downloadUrl"),
|
|
60
|
+
error=data.get("error"),
|
|
61
|
+
raw=data,
|
|
62
|
+
)
|
|
File without changes
|