imagecompressor 1.0.0__py3-none-any.whl
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,20 @@
|
|
|
1
|
+
"""Client for the ImageCompressor.si image API.
|
|
2
|
+
|
|
3
|
+
Uses only the Python standard library.
|
|
4
|
+
|
|
5
|
+
from imagecompressor import ImageCompressor
|
|
6
|
+
|
|
7
|
+
client = ImageCompressor("ic_live_...")
|
|
8
|
+
result = client.compress(open("photo.jpg", "rb").read(), format="webp", mode="smart")
|
|
9
|
+
open("photo.webp", "wb").write(result.data)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .client import (
|
|
13
|
+
ImageCompressor,
|
|
14
|
+
ImageCompressorError,
|
|
15
|
+
ImageResult,
|
|
16
|
+
verify_webhook,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = ["ImageCompressor", "ImageCompressorError", "ImageResult", "verify_webhook"]
|
|
20
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""The ImageCompressor API client. Standard library only (urllib)."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional, Union
|
|
13
|
+
|
|
14
|
+
# An image is its bytes, or the address to fetch it from: {"url": "https://..."}.
|
|
15
|
+
ImageInput = Union[bytes, bytearray, Dict[str, str]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ImageCompressorError(Exception):
|
|
19
|
+
"""An error answered by the API, or a failure to reach it (code "network")."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, code, message, status=0, request_id=None, retry_after=None, steps=None):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.code = code
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status = status
|
|
26
|
+
self.request_id = request_id
|
|
27
|
+
self.retry_after = retry_after
|
|
28
|
+
# For a failed pipeline: how far it got.
|
|
29
|
+
self.steps = steps
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ImageResult:
|
|
34
|
+
"""A processed image and the facts the API reported about it."""
|
|
35
|
+
|
|
36
|
+
data: bytes
|
|
37
|
+
content_type: str
|
|
38
|
+
format: str
|
|
39
|
+
width: int
|
|
40
|
+
height: int
|
|
41
|
+
original_bytes: int
|
|
42
|
+
bytes: int
|
|
43
|
+
outcome: str
|
|
44
|
+
warnings: List[str] = field(default_factory=list)
|
|
45
|
+
duration_ms: int = 0
|
|
46
|
+
fingerprint: Optional[str] = None
|
|
47
|
+
similarity: Optional[float] = None
|
|
48
|
+
filename: Optional[str] = None
|
|
49
|
+
request_id: str = ""
|
|
50
|
+
steps: List[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
def save(self, path: str) -> None:
|
|
53
|
+
with open(path, "wb") as handle:
|
|
54
|
+
handle.write(self.data)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _query(options: Dict[str, Any]) -> str:
|
|
58
|
+
"""Options as the API's query string. True/False become true/false; None is left out."""
|
|
59
|
+
pairs = []
|
|
60
|
+
for name, value in options.items():
|
|
61
|
+
if value is None:
|
|
62
|
+
continue
|
|
63
|
+
if isinstance(value, bool):
|
|
64
|
+
value = "true" if value else "false"
|
|
65
|
+
elif isinstance(value, (list, tuple)):
|
|
66
|
+
value = ",".join(str(part) for part in value)
|
|
67
|
+
pairs.append((name, str(value)))
|
|
68
|
+
return ("?" + urllib.parse.urlencode(pairs)) if pairs else ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ImageCompressor:
|
|
72
|
+
"""Calls the API with one API key.
|
|
73
|
+
|
|
74
|
+
Options are keyword arguments named like the API's query parameters,
|
|
75
|
+
for example ``client.compress(data, format="webp", max_width=1600)``.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, api_key: str, base_url: str = "https://imagecompressor.si", max_retries: int = 3, timeout: float = 180.0):
|
|
79
|
+
if not api_key:
|
|
80
|
+
raise ValueError("api_key is required")
|
|
81
|
+
self.api_key = api_key
|
|
82
|
+
self.base_url = base_url.rstrip("/")
|
|
83
|
+
self.max_retries = max_retries
|
|
84
|
+
self.timeout = timeout
|
|
85
|
+
|
|
86
|
+
# ---- One image in, one image out ----
|
|
87
|
+
|
|
88
|
+
def compress(self, image: ImageInput, **options: Any) -> ImageResult:
|
|
89
|
+
"""Compress, convert, resize, crop, rotate, edit or watermark."""
|
|
90
|
+
return self._image("/api/v1/compress", image, options)
|
|
91
|
+
|
|
92
|
+
def convert(self, image: ImageInput, format: str, **options: Any) -> ImageResult:
|
|
93
|
+
return self._image("/api/v1/compress", image, dict(options, format=format))
|
|
94
|
+
|
|
95
|
+
def remove_background(self, image: ImageInput, **options: Any) -> ImageResult:
|
|
96
|
+
return self._image("/api/v1/remove-background", image, options)
|
|
97
|
+
|
|
98
|
+
def id_photo(self, image: ImageInput, **options: Any) -> ImageResult:
|
|
99
|
+
"""Makes an ID or passport photo at an exact size: preset, or width_mm and height_mm, or width
|
|
100
|
+
and height in pixels; dpi, crop, min_bytes, max_bytes, background, remove_background, sheet."""
|
|
101
|
+
return self._image("/api/v1/id-photo", image, options)
|
|
102
|
+
|
|
103
|
+
def upscale(self, image: ImageInput, **options: Any) -> ImageResult:
|
|
104
|
+
"""Doubles the width and height with a super-resolution model."""
|
|
105
|
+
return self._image("/api/v1/upscale", image, options)
|
|
106
|
+
|
|
107
|
+
def pipeline(self, image: ImageInput, pipeline: Optional[dict] = None, preset: Optional[str] = None,
|
|
108
|
+
version: Optional[int] = None, name: Optional[str] = None) -> ImageResult:
|
|
109
|
+
"""Runs a pipeline (inline, or a saved or built-in preset by name) on one image."""
|
|
110
|
+
query = _query({"pipeline": json.dumps(pipeline) if pipeline else None, "preset": preset, "version": version, "name": name})
|
|
111
|
+
return self._read_image(self._send("POST", "/api/v1/pipeline" + query, image=image))
|
|
112
|
+
|
|
113
|
+
def dry_run(self, image: ImageInput, pipeline: Optional[dict] = None, preset: Optional[str] = None,
|
|
114
|
+
version: Optional[int] = None, name: Optional[str] = None) -> dict:
|
|
115
|
+
"""What a pipeline would do to this image, without doing it."""
|
|
116
|
+
query = _query({"pipeline": json.dumps(pipeline) if pipeline else None, "preset": preset, "version": version,
|
|
117
|
+
"name": name, "dry_run": True})
|
|
118
|
+
return self._json("POST", "/api/v1/pipeline" + query, image=image)
|
|
119
|
+
|
|
120
|
+
# ---- One image in, an answer out ----
|
|
121
|
+
|
|
122
|
+
def inspect(self, image: ImageInput) -> dict:
|
|
123
|
+
"""Format, size, metadata, GPS position and fingerprints."""
|
|
124
|
+
return self._json("POST", "/api/v1/inspect", image=image)
|
|
125
|
+
|
|
126
|
+
def ocr(self, image: ImageInput, languages: Optional[List[str]] = None) -> dict:
|
|
127
|
+
"""The text in the image. languages: "eng", "slv" or both."""
|
|
128
|
+
return self._json("POST", "/api/v1/ocr" + _query({"lang": languages}), image=image)
|
|
129
|
+
|
|
130
|
+
# ---- Background jobs ----
|
|
131
|
+
|
|
132
|
+
def create_job(self, inputs: List[Union[str, dict]], pipeline: Optional[dict] = None, preset: Optional[str] = None,
|
|
133
|
+
version: Optional[int] = None, webhook_url: Optional[str] = None,
|
|
134
|
+
idempotency_key: Optional[str] = None) -> dict:
|
|
135
|
+
"""Starts a pipeline over several images. idempotency_key makes the call safe to repeat."""
|
|
136
|
+
body = {"inputs": [{"url": item} if isinstance(item, str) else item for item in inputs]}
|
|
137
|
+
for name, value in (("pipeline", pipeline), ("preset", preset), ("version", version), ("webhook_url", webhook_url)):
|
|
138
|
+
if value is not None:
|
|
139
|
+
body[name] = value
|
|
140
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
141
|
+
return self._json("POST", "/api/v1/jobs", body=body, headers=headers)
|
|
142
|
+
|
|
143
|
+
def get_job(self, job_id: str) -> dict:
|
|
144
|
+
return self._json("GET", "/api/v1/jobs/" + urllib.parse.quote(job_id, safe=""))
|
|
145
|
+
|
|
146
|
+
def cancel_job(self, job_id: str) -> dict:
|
|
147
|
+
return self._json("DELETE", "/api/v1/jobs/" + urllib.parse.quote(job_id, safe=""))
|
|
148
|
+
|
|
149
|
+
def wait_for_job(self, job_id: str, interval: float = 1.0, timeout: float = 600.0) -> dict:
|
|
150
|
+
"""Polls until the job is finished, failed or cancelled."""
|
|
151
|
+
deadline = time.time() + timeout
|
|
152
|
+
while True:
|
|
153
|
+
job = self.get_job(job_id)
|
|
154
|
+
if job["status"] not in ("queued", "running"):
|
|
155
|
+
return job
|
|
156
|
+
if time.time() > deadline:
|
|
157
|
+
raise ImageCompressorError("timeout", "The job did not finish in time.")
|
|
158
|
+
time.sleep(interval)
|
|
159
|
+
|
|
160
|
+
# ---- Saved pipelines ----
|
|
161
|
+
|
|
162
|
+
def list_pipelines(self) -> dict:
|
|
163
|
+
return self._json("GET", "/api/v1/pipelines")
|
|
164
|
+
|
|
165
|
+
def save_pipeline(self, name: str, pipeline: dict) -> dict:
|
|
166
|
+
"""Saving under an existing name adds a version."""
|
|
167
|
+
return self._json("POST", "/api/v1/pipelines", body={"name": name, "pipeline": pipeline})
|
|
168
|
+
|
|
169
|
+
def get_pipeline(self, name: str, version: Optional[int] = None) -> dict:
|
|
170
|
+
return self._json("GET", "/api/v1/pipelines/" + urllib.parse.quote(name, safe="") + _query({"version": version}))
|
|
171
|
+
|
|
172
|
+
def delete_pipeline(self, name: str) -> dict:
|
|
173
|
+
return self._json("DELETE", "/api/v1/pipelines/" + urllib.parse.quote(name, safe=""))
|
|
174
|
+
|
|
175
|
+
# ---- Asset index ----
|
|
176
|
+
|
|
177
|
+
def index_assets(self, project: str, assets: List[dict]) -> dict:
|
|
178
|
+
"""Replaces one project's entries in the asset index. Facts only: no image data is sent."""
|
|
179
|
+
return self._json("POST", "/api/v1/assets", body={"project": project, "assets": assets})
|
|
180
|
+
|
|
181
|
+
def search_assets(self, q: Optional[str] = None, project: Optional[str] = None, limit: Optional[int] = None) -> dict:
|
|
182
|
+
return self._json("GET", "/api/v1/assets" + _query({"q": q, "project": project, "limit": limit}))
|
|
183
|
+
|
|
184
|
+
def duplicate_assets(self) -> dict:
|
|
185
|
+
"""Identical files that appear more than once across your projects."""
|
|
186
|
+
return self._json("GET", "/api/v1/assets/duplicates")
|
|
187
|
+
|
|
188
|
+
def remove_asset_project(self, project: str) -> dict:
|
|
189
|
+
return self._json("DELETE", "/api/v1/assets" + _query({"project": project}))
|
|
190
|
+
|
|
191
|
+
# ---- Website optimizer, account, discovery ----
|
|
192
|
+
|
|
193
|
+
def optimize_site(self, url: str, quality: Optional[int] = None, max_pages: Optional[int] = None) -> dict:
|
|
194
|
+
body = {"url": url}
|
|
195
|
+
if quality is not None:
|
|
196
|
+
body["quality"] = quality
|
|
197
|
+
if max_pages is not None:
|
|
198
|
+
body["max_pages"] = max_pages
|
|
199
|
+
return self._json("POST", "/api/v1/sites/optimize", body=body)
|
|
200
|
+
|
|
201
|
+
def get_site_job(self, job_id: str) -> dict:
|
|
202
|
+
return self._json("GET", "/api/v1/sites/optimize/" + urllib.parse.quote(job_id, safe=""))
|
|
203
|
+
|
|
204
|
+
def usage(self) -> dict:
|
|
205
|
+
return self._json("GET", "/api/v1/usage")
|
|
206
|
+
|
|
207
|
+
def capabilities(self) -> dict:
|
|
208
|
+
return self._json("GET", "/api/v1/capabilities")
|
|
209
|
+
|
|
210
|
+
def download(self, url: str) -> bytes:
|
|
211
|
+
"""Downloads a stored result (a url from a job or an output="json" call)."""
|
|
212
|
+
path = urllib.parse.urlparse(url).path if url.startswith("http") else url
|
|
213
|
+
return self._send("GET", path)["body"]
|
|
214
|
+
|
|
215
|
+
# ---- Plumbing ----
|
|
216
|
+
|
|
217
|
+
def _image(self, path: str, image: ImageInput, options: Dict[str, Any]) -> ImageResult:
|
|
218
|
+
return self._read_image(self._send("POST", path + _query(dict(options, output="binary")), image=image))
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _read_image(response: dict) -> ImageResult:
|
|
222
|
+
headers = response["headers"]
|
|
223
|
+
|
|
224
|
+
def number(name: str) -> int:
|
|
225
|
+
return int(headers.get(name) or 0)
|
|
226
|
+
|
|
227
|
+
disposition = headers.get("content-disposition") or ""
|
|
228
|
+
filename = disposition.split('filename="')[1].split('"')[0] if 'filename="' in disposition else None
|
|
229
|
+
similarity = headers.get("x-ic-similarity")
|
|
230
|
+
return ImageResult(
|
|
231
|
+
data=response["body"],
|
|
232
|
+
content_type=headers.get("content-type") or "application/octet-stream",
|
|
233
|
+
format=headers.get("x-ic-format") or "",
|
|
234
|
+
width=number("x-ic-width"),
|
|
235
|
+
height=number("x-ic-height"),
|
|
236
|
+
original_bytes=number("x-ic-original-bytes"),
|
|
237
|
+
bytes=len(response["body"]),
|
|
238
|
+
outcome=headers.get("x-ic-outcome") or "done",
|
|
239
|
+
warnings=[w for w in (headers.get("x-ic-warnings") or "").split(",") if w],
|
|
240
|
+
duration_ms=number("x-ic-duration-ms"),
|
|
241
|
+
fingerprint=headers.get("x-ic-fingerprint"),
|
|
242
|
+
similarity=float(similarity) if similarity is not None else None,
|
|
243
|
+
filename=filename,
|
|
244
|
+
request_id=headers.get("x-request-id") or "",
|
|
245
|
+
steps=json.loads(headers.get("x-ic-steps") or "[]"),
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _json(self, method: str, path: str, image: Optional[ImageInput] = None, body: Any = None,
|
|
249
|
+
headers: Optional[Dict[str, str]] = None) -> Any:
|
|
250
|
+
return json.loads(self._send(method, path, image=image, body=body, headers=headers)["body"].decode("utf-8"))
|
|
251
|
+
|
|
252
|
+
def _send(self, method: str, path: str, image: Optional[ImageInput] = None, body: Any = None,
|
|
253
|
+
headers: Optional[Dict[str, str]] = None) -> dict:
|
|
254
|
+
request_headers = {"Authorization": "Bearer " + self.api_key, "User-Agent": "imagecompressor-python/1.0.0"}
|
|
255
|
+
request_headers.update(headers or {})
|
|
256
|
+
data = None
|
|
257
|
+
if image is not None:
|
|
258
|
+
if isinstance(image, (bytes, bytearray)):
|
|
259
|
+
data = bytes(image)
|
|
260
|
+
request_headers["Content-Type"] = "application/octet-stream"
|
|
261
|
+
else:
|
|
262
|
+
data = json.dumps({"url": image["url"]}).encode("utf-8")
|
|
263
|
+
request_headers["Content-Type"] = "application/json"
|
|
264
|
+
elif body is not None:
|
|
265
|
+
data = json.dumps(body).encode("utf-8")
|
|
266
|
+
request_headers["Content-Type"] = "application/json"
|
|
267
|
+
|
|
268
|
+
attempt = 0
|
|
269
|
+
while True:
|
|
270
|
+
request = urllib.request.Request(self.base_url + path, data=data, method=method, headers=request_headers)
|
|
271
|
+
try:
|
|
272
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
273
|
+
return {"body": response.read(), "headers": {k.lower(): v for k, v in response.headers.items()}}
|
|
274
|
+
except urllib.error.HTTPError as error:
|
|
275
|
+
error_headers = {k.lower(): v for k, v in error.headers.items()}
|
|
276
|
+
retry_after = int(error_headers.get("retry-after") or 0) or None
|
|
277
|
+
if error.code in (429, 503) and attempt < self.max_retries and retry_after and retry_after <= 60:
|
|
278
|
+
attempt += 1
|
|
279
|
+
time.sleep(retry_after)
|
|
280
|
+
continue
|
|
281
|
+
try:
|
|
282
|
+
parsed = json.loads(error.read().decode("utf-8"))
|
|
283
|
+
except ValueError:
|
|
284
|
+
parsed = {}
|
|
285
|
+
detail = parsed.get("error") or {}
|
|
286
|
+
raise ImageCompressorError(
|
|
287
|
+
detail.get("code", "internal"),
|
|
288
|
+
detail.get("message", "The API answered with status %d." % error.code),
|
|
289
|
+
status=error.code,
|
|
290
|
+
request_id=detail.get("request_id") or error_headers.get("x-request-id"),
|
|
291
|
+
retry_after=retry_after,
|
|
292
|
+
steps=parsed.get("steps"),
|
|
293
|
+
) from None
|
|
294
|
+
except urllib.error.URLError as error:
|
|
295
|
+
raise ImageCompressorError("network", str(error.reason)) from None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def verify_webhook(secret: str, body: Union[str, bytes], headers: Dict[str, str], tolerance: int = 300) -> dict:
|
|
299
|
+
"""Checks that a webhook really came from ImageCompressor and returns its parsed body.
|
|
300
|
+
|
|
301
|
+
secret is the signing secret from your account page (whsec_...), body the
|
|
302
|
+
raw request body exactly as received, headers the request's headers.
|
|
303
|
+
Raises ValueError when the signature does not match or the message is
|
|
304
|
+
older than tolerance seconds. Signatures follow the Standard Webhooks
|
|
305
|
+
specification.
|
|
306
|
+
"""
|
|
307
|
+
lowered = {name.lower(): value for name, value in headers.items()}
|
|
308
|
+
message_id = lowered.get("webhook-id")
|
|
309
|
+
timestamp = lowered.get("webhook-timestamp")
|
|
310
|
+
signatures = lowered.get("webhook-signature")
|
|
311
|
+
if not message_id or not timestamp or not signatures:
|
|
312
|
+
raise ValueError("missing webhook headers")
|
|
313
|
+
if abs(time.time() - int(timestamp)) > tolerance:
|
|
314
|
+
raise ValueError("the message is too old")
|
|
315
|
+
text = body.decode("utf-8") if isinstance(body, bytes) else body
|
|
316
|
+
key = base64.b64decode(secret[len("whsec_"):] if secret.startswith("whsec_") else secret)
|
|
317
|
+
expected = base64.b64encode(
|
|
318
|
+
hmac.new(key, ("%s.%s.%s" % (message_id, timestamp, text)).encode("utf-8"), hashlib.sha256).digest()
|
|
319
|
+
).decode("ascii")
|
|
320
|
+
# The header may carry several space-separated "v1,<signature>" entries.
|
|
321
|
+
for entry in signatures.split(" "):
|
|
322
|
+
version, _, signature = entry.partition(",")
|
|
323
|
+
if version == "v1" and hmac.compare_digest(signature, expected):
|
|
324
|
+
return json.loads(text)
|
|
325
|
+
raise ValueError("the signature does not match")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imagecompressor
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Client for the ImageCompressor.si image API: compress, convert, resize, remove backgrounds, upscale, OCR, pipelines and jobs.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://imagecompressor.si/developers
|
|
7
|
+
Project-URL: Documentation, https://imagecompressor.si/developers
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# imagecompressor (Python)
|
|
12
|
+
|
|
13
|
+
Client for the [ImageCompressor.si](https://imagecompressor.si/developers) image API. Standard library only; Python 3.8+.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
from imagecompressor import ImageCompressor
|
|
18
|
+
|
|
19
|
+
client = ImageCompressor(os.environ["IMAGECOMPRESSOR_API_KEY"])
|
|
20
|
+
|
|
21
|
+
# Compress to WebP, at most 1600 px wide, letting smart mode pick the quality.
|
|
22
|
+
with open("photo.jpg", "rb") as handle:
|
|
23
|
+
result = client.compress(handle.read(), format="webp", mode="smart", max_width=1600)
|
|
24
|
+
result.save("photo.webp")
|
|
25
|
+
print(result.width, result.height, result.bytes, result.warnings)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Options are keyword arguments named like the API's parameters. An image is `bytes`, or `{"url": "https://..."}`.
|
|
29
|
+
|
|
30
|
+
| Method | Does |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `compress(image, **options)` | compress, convert, resize, crop, rotate, edit, watermark |
|
|
33
|
+
| `convert(image, format, **options)` | change format |
|
|
34
|
+
| `remove_background(image, **options)` | cut out the subject |
|
|
35
|
+
| `id_photo(image, preset="35x45")` | an ID or passport photo at an exact size, DPI and KB range; `sheet=True` for a 4×6 print sheet |
|
|
36
|
+
| `upscale(image)` | double the size with the AI upscaler |
|
|
37
|
+
| `inspect(image)` | format, size, metadata, GPS position, fingerprints |
|
|
38
|
+
| `ocr(image, languages=[...])` | the text in the image |
|
|
39
|
+
| `pipeline(image, pipeline=... or preset=...)` / `dry_run(...)` | several steps on one image (Pro plan) |
|
|
40
|
+
| `create_job / get_job / cancel_job / wait_for_job` | a pipeline over many images, in the background (Pro plan) |
|
|
41
|
+
| `list_pipelines / save_pipeline / get_pipeline / delete_pipeline` | saved pipelines |
|
|
42
|
+
| `optimize_site / get_site_job` | the website optimizer |
|
|
43
|
+
| `usage()`, `capabilities()`, `download(url)` | account and discovery |
|
|
44
|
+
|
|
45
|
+
Errors are `ImageCompressorError` with `code`, `status`, `request_id` and, for a failed pipeline, `steps`. A `429` or `503` is retried after the wait the API names.
|
|
46
|
+
|
|
47
|
+
## Webhooks
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from imagecompressor import verify_webhook
|
|
51
|
+
|
|
52
|
+
# body must be the raw request body, exactly as received.
|
|
53
|
+
event = verify_webhook(os.environ["IMAGECOMPRESSOR_WEBHOOK_SECRET"], body, headers)
|
|
54
|
+
if event["type"] == "job.finished":
|
|
55
|
+
print(event["data"]["id"], event["data"]["status"])
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Run the tests with `python -m unittest discover -s tests`.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
imagecompressor/__init__.py,sha256=zuaMdWDoaE43ZjomdmvjjfYVxO61hzD3zEkfRfqUndM,543
|
|
2
|
+
imagecompressor/client.py,sha256=shJ8H1mdz73TxcRw5Wicyt_JwcoIj7c49czY4l46nDc,15361
|
|
3
|
+
imagecompressor-1.0.0.dist-info/METADATA,sha256=DsNWH0Q-gvcvOJX1ooxuIr30s9iyQeuAoZkE7TCwQWM,2640
|
|
4
|
+
imagecompressor-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
imagecompressor-1.0.0.dist-info/top_level.txt,sha256=h8S09EWPVZ-A_EqHhIwNyepIZqGpvg2V2LZjKGQoanc,16
|
|
6
|
+
imagecompressor-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
imagecompressor
|