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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ imagecompressor