zappyquote 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zappy
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,69 @@
1
+ Metadata-Version: 2.1
2
+ Name: zappyquote
3
+ Version: 0.2.0
4
+ Summary: Official Zappy API client for Python - a thin wrapper over the documented Zappy API v1.
5
+ Author: Zappy
6
+ License: MIT
7
+ Project-URL: Homepage, https://zappyquote.com/developers/
8
+ Project-URL: Documentation, https://zappyquote.com/developers/
9
+ Project-URL: Issues, https://zappyquote.com/contact.html
10
+ Keywords: zappy,zappyquote,ai,image-generation,api,sdk
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+
18
+ # zappyquote
19
+
20
+ The official Python client for the Zappy API v1 - a thin wrapper over the documented HTTP API
21
+ (https://zappyquote.com/developers/). The server stays authoritative for pricing, caps, idempotency and ownership.
22
+
23
+ Version 0.2.0 · contract `openapi-1.0.0@9dff9fdfbcd8c1bd` · Python 3.9+ · standard library only · MIT
24
+
25
+ ```bash
26
+ # official download (always available):
27
+ curl -O https://zappyquote.com/dl/zappyquote.py # then: import zappyquote
28
+ # PyPI, ONLY once https://zappyquote.com/developers/ lists zappyquote as published by Zappy:
29
+ # pip install zappyquote
30
+ ```
31
+
32
+ ```python
33
+ from zappyquote import Zappy, ZappyError, new_idempotency_key
34
+
35
+ zappy = Zappy() # reads ZAPPY_API_KEY; or Zappy(api_key=...)
36
+ caps = zappy.capabilities()["capabilities"] # free
37
+ quote = zappy.quote("zappy-image-pro") # free, reserves nothing
38
+
39
+ # BILLABLE. max_credits is a hard ceiling; the idempotency key makes a replay return the same job, never a second charge.
40
+ key = new_idempotency_key("order-123")
41
+ r = zappy.create_image("a lighthouse at dusk", max_credits=quote["credits_quoted"], idempotency_key=key)
42
+ job = r if r["status"] == "succeeded" else zappy.wait_for_job(r["job_id"]) # 202 => poll (free)
43
+ asset = zappy.get_asset(job["assets"][0]["asset_id"]) # short-lived signed URL on Zappy's host
44
+ ```
45
+
46
+ Every response is a `dict` with `.meta` (`request_id`, `rate_limit`, `retry_after`, `status`). Failures raise a
47
+ `ZappyError` subclass with `code`, `status`, `request_id`, `retry_after`: `ZappyAuthError`, `ZappyRateLimitError`,
48
+ `ZappySpendError`, `ZappyNotFoundError`, `ZappyUnavailableError`, `ZappyTransportError`. Messages never contain a credential.
49
+
50
+ ```python
51
+ from zappyquote import verify_webhook_signature
52
+ ok = verify_webhook_signature(secret, request.headers["Zappy-Signature"], request.get_data()) # exact raw bytes
53
+ ```
54
+
55
+ `pkce_pair()` / `authorize_url(...)` build the OAuth authorization request for an app you registered in Zappy; consent
56
+ and token exchange stay on Zappy's authorization server. Paid calls are never retried by this package.
57
+
58
+ ## Name
59
+
60
+ The distribution and the import are both `zappyquote` (`import zappyquote`). The shorter name `zappy` is used on PyPI by an unrelated project whose distribution also installs a top-level `zappy` package; sharing that name would let either install silently replace the other. Version 0.2.0 is the first version under this name (0.1.0 was only ever a direct download, `/dl/zappy.py`, and is retired).
61
+
62
+ ### Migrating from the retired `/dl/zappy.py`
63
+
64
+ 1. Download `https://zappyquote.com/dl/zappyquote.py` next to your code.
65
+ 2. Change `import zappy` / `from zappy import ...` to `import zappyquote` / `from zappyquote import ...` (the API is unchanged).
66
+ 3. Delete your project-local `zappy.py` once you have confirmed it is the Zappy SDK (its docstring starts with
67
+ "zappy - the official Python client for the Zappy API v1"). Left in place, it would shadow any other `zappy` package.
68
+
69
+ Registry packages are official only if https://zappyquote.com/developers/ lists them as published; until then use the download, and never install a look-alike name.
@@ -0,0 +1,52 @@
1
+ # zappyquote
2
+
3
+ The official Python client for the Zappy API v1 - a thin wrapper over the documented HTTP API
4
+ (https://zappyquote.com/developers/). The server stays authoritative for pricing, caps, idempotency and ownership.
5
+
6
+ Version 0.2.0 · contract `openapi-1.0.0@9dff9fdfbcd8c1bd` · Python 3.9+ · standard library only · MIT
7
+
8
+ ```bash
9
+ # official download (always available):
10
+ curl -O https://zappyquote.com/dl/zappyquote.py # then: import zappyquote
11
+ # PyPI, ONLY once https://zappyquote.com/developers/ lists zappyquote as published by Zappy:
12
+ # pip install zappyquote
13
+ ```
14
+
15
+ ```python
16
+ from zappyquote import Zappy, ZappyError, new_idempotency_key
17
+
18
+ zappy = Zappy() # reads ZAPPY_API_KEY; or Zappy(api_key=...)
19
+ caps = zappy.capabilities()["capabilities"] # free
20
+ quote = zappy.quote("zappy-image-pro") # free, reserves nothing
21
+
22
+ # BILLABLE. max_credits is a hard ceiling; the idempotency key makes a replay return the same job, never a second charge.
23
+ key = new_idempotency_key("order-123")
24
+ r = zappy.create_image("a lighthouse at dusk", max_credits=quote["credits_quoted"], idempotency_key=key)
25
+ job = r if r["status"] == "succeeded" else zappy.wait_for_job(r["job_id"]) # 202 => poll (free)
26
+ asset = zappy.get_asset(job["assets"][0]["asset_id"]) # short-lived signed URL on Zappy's host
27
+ ```
28
+
29
+ Every response is a `dict` with `.meta` (`request_id`, `rate_limit`, `retry_after`, `status`). Failures raise a
30
+ `ZappyError` subclass with `code`, `status`, `request_id`, `retry_after`: `ZappyAuthError`, `ZappyRateLimitError`,
31
+ `ZappySpendError`, `ZappyNotFoundError`, `ZappyUnavailableError`, `ZappyTransportError`. Messages never contain a credential.
32
+
33
+ ```python
34
+ from zappyquote import verify_webhook_signature
35
+ ok = verify_webhook_signature(secret, request.headers["Zappy-Signature"], request.get_data()) # exact raw bytes
36
+ ```
37
+
38
+ `pkce_pair()` / `authorize_url(...)` build the OAuth authorization request for an app you registered in Zappy; consent
39
+ and token exchange stay on Zappy's authorization server. Paid calls are never retried by this package.
40
+
41
+ ## Name
42
+
43
+ The distribution and the import are both `zappyquote` (`import zappyquote`). The shorter name `zappy` is used on PyPI by an unrelated project whose distribution also installs a top-level `zappy` package; sharing that name would let either install silently replace the other. Version 0.2.0 is the first version under this name (0.1.0 was only ever a direct download, `/dl/zappy.py`, and is retired).
44
+
45
+ ### Migrating from the retired `/dl/zappy.py`
46
+
47
+ 1. Download `https://zappyquote.com/dl/zappyquote.py` next to your code.
48
+ 2. Change `import zappy` / `from zappy import ...` to `import zappyquote` / `from zappyquote import ...` (the API is unchanged).
49
+ 3. Delete your project-local `zappy.py` once you have confirmed it is the Zappy SDK (its docstring starts with
50
+ "zappy - the official Python client for the Zappy API v1"). Left in place, it would shadow any other `zappy` package.
51
+
52
+ Registry packages are official only if https://zappyquote.com/developers/ lists them as published; until then use the download, and never install a look-alike name.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "zappyquote"
7
+ version = "0.2.0"
8
+ description = "Official Zappy API client for Python - a thin wrapper over the documented Zappy API v1."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Zappy" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = []
14
+ keywords = ["zappy", "zappyquote", "ai", "image-generation", "api", "sdk"]
15
+ classifiers = [
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://zappyquote.com/developers/"
23
+ Documentation = "https://zappyquote.com/developers/"
24
+ Issues = "https://zappyquote.com/contact.html"
25
+
26
+ [tool.setuptools]
27
+ packages = ["zappyquote"]
28
+ include-package-data = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,339 @@
1
+ """zappyquote - the official Python client for the Zappy API v1 (distribution `zappyquote`, import `zappyquote`).
2
+
3
+ Named after the zappyquote.com domain and the npm @zappyquote scope. Not `zappy`: that top-level name belongs to an unrelated
4
+ PyPI project (lasersonlab/zappy), and two distributions shipping the same top-level package overwrite each other on install.
5
+
6
+ A THIN WRAPPER over the documented HTTP API (https://zappyquote.com/developers/). It holds no business rule:
7
+ pricing, quotas, ceilings, idempotency and ownership are decided by the server; this module only carries the
8
+ request there and maps the public error envelope into typed exceptions. Standard library only (urllib), Python >= 3.9.
9
+
10
+ Safety properties:
11
+ - the credential comes from the constructor or ZAPPY_API_KEY, is never logged, and is redacted from every error;
12
+ - paid calls (create_image, edit_image) are NEVER retried on their own; replay with the same idempotency_key
13
+ returns the same job, never a second charge;
14
+ - every mutating call sends an Idempotency-Key (yours, or one this client generates and returns to you);
15
+ - rate-limit metadata (RateLimit-*, Retry-After) and X-Request-Id are exposed on every result and error.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import hmac
22
+ import json
23
+ import os
24
+ import re
25
+ import secrets
26
+ import time
27
+ import urllib.error
28
+ import urllib.parse
29
+ import urllib.request
30
+ from dataclasses import dataclass
31
+ from typing import Any, Callable, Dict, Optional
32
+
33
+ __version__ = "0.2.0"
34
+ SDK_VERSION = __version__
35
+ #: sha256 prefix of the public_html/api/openapi.json this client was written against (see DEVELOPER_DISTRIBUTION.md)
36
+ CONTRACT = "openapi-1.0.0@9dff9fdfbcd8c1bd"
37
+ DEFAULT_BASE_URL = "https://zappyquote.com/api/v1"
38
+
39
+ _KEY_RE = re.compile(r"(zp_live_|zat_|zrt_|zcs_)[A-Za-z0-9_-]+") # no leading boundary: an embedded credential is still a credential
40
+
41
+
42
+ def redact(s: Any) -> str:
43
+ """Replace any Zappy credential shape in a string with a redacted marker."""
44
+ return _KEY_RE.sub(lambda m: m.group(1) + "…redacted", str(s))
45
+
46
+
47
+ def new_idempotency_key(prefix: str = "zq") -> str:
48
+ """A stable idempotency key for ONE intended operation. Keep it with the operation; reuse it to replay safely."""
49
+ p = re.sub(r"[^A-Za-z0-9._:-]", "", prefix)[:20] or "zq"
50
+ return f"{p}-{int(time.time() * 1000):x}-{secrets.token_urlsafe(9)}"
51
+
52
+
53
+ @dataclass
54
+ class RateLimit:
55
+ limit: Optional[int]
56
+ remaining: Optional[int]
57
+ reset: Optional[int]
58
+
59
+
60
+ @dataclass
61
+ class ResponseMeta:
62
+ request_id: Optional[str]
63
+ rate_limit: Optional[RateLimit]
64
+ retry_after: Optional[int]
65
+ status: int = 0
66
+
67
+
68
+ class ZappyError(Exception):
69
+ """Base error. `code` is the public error code, `status` the HTTP status, `meta` the response metadata."""
70
+
71
+ def __init__(self, message: str, code: str = "error", status: int = 0, meta: Optional[ResponseMeta] = None, body: Any = None):
72
+ super().__init__(redact(message))
73
+ self.code = code
74
+ self.status = status
75
+ self.meta = meta
76
+ self.body = body
77
+ self.idempotency_key: Optional[str] = None
78
+
79
+ @property
80
+ def request_id(self) -> Optional[str]:
81
+ return self.meta.request_id if self.meta else None
82
+
83
+ @property
84
+ def retry_after(self) -> Optional[int]:
85
+ return self.meta.retry_after if self.meta else None
86
+
87
+
88
+ class ZappyAuthError(ZappyError): ...
89
+ class ZappyRateLimitError(ZappyError): ...
90
+ class ZappySpendError(ZappyError): ...
91
+ class ZappyNotFoundError(ZappyError): ...
92
+ class ZappyUnavailableError(ZappyError): ...
93
+ class ZappyTransportError(ZappyError): ...
94
+
95
+
96
+ _SPEND_CODES = {"insufficient_credits", "daily_cap_reached", "request_exceeds_key_ceiling", "request_exceeds_ceiling", "spend_check_unavailable", "idempotency_key_required", "idempotency_conflict"}
97
+
98
+
99
+ def _error_for(status: int, body: Any, meta: ResponseMeta) -> ZappyError:
100
+ err = body.get("error") if isinstance(body, dict) else None
101
+ if not isinstance(err, dict):
102
+ err = {}
103
+ code = str(err.get("code") or {429: "rate_limited", 401: "unauthorized", 404: "not_found"}.get(status, "error"))
104
+ message = str(err.get("message") or f"Zappy API answered HTTP {status}")
105
+ kw = dict(code=code, status=status, meta=meta, body=body)
106
+ if status == 401 or code == "unauthorized" or (status == 403 and code == "insufficient_scope"):
107
+ return ZappyAuthError(message, **kw)
108
+ if status == 429 and code == "rate_limited":
109
+ return ZappyRateLimitError(message, **kw)
110
+ if code in _SPEND_CODES:
111
+ return ZappySpendError(message, **kw)
112
+ if status == 404:
113
+ return ZappyNotFoundError(message, **kw)
114
+ if status in (502, 503) or code in ("not_available", "pilot_closed"):
115
+ return ZappyUnavailableError(message, **kw)
116
+ return ZappyError(message, **kw)
117
+
118
+
119
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
120
+ """F5-SEC-001: a redirect is NEVER followed, so the credential can only ever reach the configured base URL."""
121
+
122
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
123
+ return None
124
+
125
+
126
+ _OPENER = urllib.request.build_opener(_NoRedirect())
127
+
128
+
129
+ class Response(dict):
130
+ """The decoded JSON body, plus `.meta` (request id, rate limit, retry-after, status)."""
131
+ meta: ResponseMeta
132
+
133
+
134
+ class Zappy:
135
+ def __init__(self, api_key: Optional[str] = None, *, access_token: Optional[str] = None, base_url: Optional[str] = None,
136
+ client_name: str = "zappy-sdk-python", client_type: str = "sdk", client_version: Optional[str] = None,
137
+ timeout: float = 60.0, opener: Optional[Callable[..., Any]] = None):
138
+ token = access_token or api_key or os.environ.get("ZAPPY_API_KEY", "")
139
+ if not token:
140
+ raise ZappyAuthError("No credential: pass api_key= or set ZAPPY_API_KEY. Create a key in Zappy under Account -> Developer.", code="not_configured")
141
+ self.__token = token
142
+ self.base_url = (base_url or os.environ.get("ZAPPY_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
143
+ self.client_type = "cli" if client_type == "cli" else "sdk"
144
+ self.client_name = re.sub(r"[^a-z0-9._-]", "", client_name.lower())[:64] or "zappy-sdk-python"
145
+ self.client_version = client_version if client_version and re.fullmatch(r"[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", client_version) else SDK_VERSION
146
+ self.timeout = timeout
147
+ self._open = opener or _OPENER.open
148
+ self.last_meta: Optional[ResponseMeta] = None
149
+
150
+ def __repr__(self) -> str: # never the credential
151
+ return f"Zappy(base_url={self.base_url!r}, credential='redacted')"
152
+
153
+ # ---- transport ----
154
+ def _request(self, method: str, path: str, *, body: Optional[dict] = None, idempotency_key: Optional[str] = None, query: Optional[dict] = None) -> Response:
155
+ url = self.base_url + "/" + path.lstrip("/")
156
+ if query:
157
+ q = {k: str(v) for k, v in query.items() if v is not None}
158
+ if q:
159
+ url += "?" + urllib.parse.urlencode(q)
160
+ headers = {"Authorization": "Bearer " + self.__token, "Accept": "application/json",
161
+ "X-Zappy-Client-Type": self.client_type, "X-Zappy-Client-Name": self.client_name, "X-Zappy-Client-Version": self.client_version}
162
+ data = None
163
+ if body is not None:
164
+ headers["Content-Type"] = "application/json"
165
+ data = json.dumps(body).encode("utf-8")
166
+ if idempotency_key:
167
+ headers["Idempotency-Key"] = idempotency_key
168
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
169
+ try:
170
+ resp = self._open(req, timeout=self.timeout)
171
+ status = resp.status
172
+ raw = resp.read()
173
+ hdrs = resp.headers
174
+ except urllib.error.HTTPError as e:
175
+ status = e.code
176
+ hdrs = e.headers
177
+ try:
178
+ raw = e.read()
179
+ except Exception as rx: # an interrupted error body is a transport failure, typed like any other
180
+ raise ZappyTransportError("Could not read the Zappy API response: " + rx.__class__.__name__, code="unreachable", status=status, meta=self._meta(hdrs, status)) from None
181
+ if 300 <= status < 400:
182
+ raise ZappyTransportError(f"Zappy API answered with a redirect (HTTP {status}), which this client never follows", code="bad_response", status=status, meta=self._meta(hdrs, status)) from None
183
+ except Exception as e: # network, timeout, DNS
184
+ raise ZappyTransportError("Could not reach the Zappy API: " + (e.__class__.__name__ + ": " + str(e)), code="unreachable") from None
185
+ try:
186
+ meta = self._meta(hdrs, status)
187
+ except Exception:
188
+ meta = ResponseMeta(request_id=None, rate_limit=None, retry_after=None, status=status)
189
+ self.last_meta = meta
190
+ try:
191
+ parsed = json.loads(raw.decode("utf-8")) if raw else None
192
+ except Exception:
193
+ parsed = None
194
+ if status >= 400 or not isinstance(parsed, dict) or parsed.get("ok") is False:
195
+ if not isinstance(parsed, dict):
196
+ raise ZappyTransportError(f"Zappy returned an unreadable response (HTTP {status})", code="bad_response", status=status, meta=meta)
197
+ raise _error_for(status, parsed, meta)
198
+ out = Response(parsed)
199
+ out.meta = meta
200
+ return out
201
+
202
+ @staticmethod
203
+ def _meta(h: Any, status: int) -> ResponseMeta:
204
+ def n(k: str) -> Optional[int]:
205
+ v = h.get(k) if h is not None else None
206
+ try:
207
+ return int(v) if v not in (None, "") else None
208
+ except ValueError:
209
+ return None
210
+ limit, remaining, reset = n("RateLimit-Limit"), n("RateLimit-Remaining"), n("RateLimit-Reset")
211
+ rl = None if limit is None and remaining is None else RateLimit(limit, remaining, reset)
212
+ return ResponseMeta(request_id=h.get("X-Request-Id") if h is not None else None, rate_limit=rl, retry_after=n("Retry-After"), status=status)
213
+
214
+ # ---- read (free) ----
215
+ def capabilities(self) -> Response:
216
+ return self._request("GET", "capabilities")
217
+
218
+ def balance(self) -> Response:
219
+ return self._request("GET", "balance")
220
+
221
+ def usage(self) -> Response:
222
+ return self._request("GET", "usage")
223
+
224
+ def quote(self, capability: str, duration: Optional[int] = None) -> Response:
225
+ body: Dict[str, Any] = {"capability": capability}
226
+ if duration:
227
+ body["duration"] = int(duration)
228
+ return self._request("POST", "quote", body=body)
229
+
230
+ def get_job(self, job_id: str) -> Response:
231
+ return self._request("GET", "jobs/" + urllib.parse.quote(str(job_id), safe=""))
232
+
233
+ def get_asset(self, asset_id: str) -> Response:
234
+ return self._request("GET", "assets/" + urllib.parse.quote(str(asset_id), safe=""))
235
+
236
+ def list_creations(self, limit: Optional[int] = None, cursor: Optional[str] = None) -> Response:
237
+ return self._request("GET", "creations", query={"limit": limit, "cursor": cursor})
238
+
239
+ def wait_for_job(self, job_id: str, *, interval: float = 3.0, timeout: float = 600.0, on_poll: Optional[Callable[[dict], None]] = None) -> Response:
240
+ """Poll (free) until the job is terminal. Honours Retry-After. Returns the final job record."""
241
+ start = time.time()
242
+ while True:
243
+ j = self.get_job(job_id)
244
+ if on_poll:
245
+ on_poll(j)
246
+ st = str(j.get("status") or "")
247
+ if st in ("succeeded", "failed"):
248
+ return j
249
+ if time.time() - start > timeout:
250
+ raise ZappyError(f"Timed out waiting for job {job_id} (still {st or 'processing'}; keep polling get_job)", code="wait_timeout")
251
+ ra = j.meta.retry_after if j.meta else None
252
+ time.sleep(ra if ra else interval)
253
+
254
+ # ---- write (SPENDS CREDITS; never auto-retried) ----
255
+ def create_image(self, prompt: str, *, max_credits: int, aspect_ratio: Optional[str] = None, idempotency_key: Optional[str] = None,
256
+ on_key: Optional[Callable[[str], None]] = None) -> Response:
257
+ """SPENDS CREDITS. Returns the API response with `.idempotency_key` set to the key used. `on_key` receives the key BEFORE
258
+ dispatch (persist it); any exception raised carries `.idempotency_key` too, so a lost response can be replayed safely."""
259
+ if not prompt:
260
+ raise ZappyError("prompt is required", code="prompt_required")
261
+ if not (isinstance(max_credits, int) and max_credits > 0):
262
+ raise ZappyError("max_credits is required: a paid call needs a hard credit ceiling", code="max_credits_required")
263
+ key = idempotency_key or new_idempotency_key("img")
264
+ body: Dict[str, Any] = {"prompt": prompt, "max_credits": int(max_credits)}
265
+ if aspect_ratio:
266
+ body["aspect_ratio"] = aspect_ratio
267
+ return self._paid("images", body, key, on_key)
268
+
269
+ def edit_image(self, source_asset_id: str, prompt: str, *, max_credits: int, aspect_ratio: Optional[str] = None, idempotency_key: Optional[str] = None,
270
+ on_key: Optional[Callable[[str], None]] = None) -> Response:
271
+ """SPENDS CREDITS."""
272
+ if not source_asset_id or not prompt:
273
+ raise ZappyError("source_asset_id and prompt are required", code="source_required")
274
+ if not (isinstance(max_credits, int) and max_credits > 0):
275
+ raise ZappyError("max_credits is required: a paid call needs a hard credit ceiling", code="max_credits_required")
276
+ key = idempotency_key or new_idempotency_key("edit")
277
+ body: Dict[str, Any] = {"source_asset_id": source_asset_id, "prompt": prompt, "max_credits": int(max_credits)}
278
+ if aspect_ratio:
279
+ body["aspect_ratio"] = aspect_ratio
280
+ return self._paid("images/edit", body, key, on_key)
281
+
282
+ def _paid(self, path: str, body: dict, key: str, on_key: Optional[Callable[[str], None]]) -> Response:
283
+ """F5-BILL-001: the key is handed out BEFORE dispatch and attached to every outcome, including the exception."""
284
+ if on_key:
285
+ on_key(key)
286
+ try:
287
+ r = self._request("POST", path, body=body, idempotency_key=key)
288
+ except ZappyError as e:
289
+ e.idempotency_key = key
290
+ raise
291
+ except Exception as ex: # anything else escaping the dispatch boundary still carries the key
292
+ err = ZappyTransportError("Paid request failed before a response could be read: " + ex.__class__.__name__ + ": " + str(ex), code="unreachable")
293
+ err.idempotency_key = key
294
+ raise err from None
295
+ r.idempotency_key = key # type: ignore[attr-defined]
296
+ return r
297
+
298
+
299
+ # ---- webhooks (zq.webhook.v1) ----
300
+ def verify_webhook_signature(secret: str, header: str, raw_body: bytes, *, now: Optional[int] = None, window_seconds: int = 300) -> bool:
301
+ """Verify a Zappy webhook delivery (Zappy-Signature: t=..,v1=..) over the exact raw body. Constant-time compare."""
302
+ if not isinstance(secret, str) or not secret or not isinstance(header, str):
303
+ return False
304
+ t = None
305
+ sigs = []
306
+ for part in header.split(","):
307
+ if "=" not in part:
308
+ continue
309
+ k, v = part.split("=", 1)
310
+ k, v = k.strip(), v.strip()
311
+ if k == "t" and v.isdigit():
312
+ t = int(v)
313
+ if k == "v1" and re.fullmatch(r"[a-f0-9]{64}", v):
314
+ sigs.append(v)
315
+ if t is None or not sigs or abs((now if now is not None else int(time.time())) - t) > window_seconds:
316
+ return False
317
+ if isinstance(raw_body, str):
318
+ raw_body = raw_body.encode("utf-8")
319
+ want = hmac.new(secret.encode("utf-8"), str(t).encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
320
+ return any(hmac.compare_digest(want, s) for s in sigs)
321
+
322
+
323
+ # ---- OAuth 2.1 PKCE helpers (pure functions; Zappy's authorization server stays the one auth implementation) ----
324
+ def pkce_pair() -> dict:
325
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii")
326
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
327
+ return {"verifier": verifier, "challenge": challenge, "method": "S256"}
328
+
329
+
330
+ def authorize_url(*, client_id: str, redirect_uri: str, scope: str, state: str, challenge: str,
331
+ resource: str = "https://zappyquote.com/api/v1", issuer: str = "https://zappyquote.com") -> str:
332
+ q = urllib.parse.urlencode({"client_id": client_id, "redirect_uri": redirect_uri, "response_type": "code", "scope": scope, "state": state,
333
+ "code_challenge": challenge, "code_challenge_method": "S256", "resource": resource})
334
+ return issuer.rstrip("/") + "/oauth/authorize?" + q
335
+
336
+
337
+ __all__ = ["Zappy", "ZappyError", "ZappyAuthError", "ZappyRateLimitError", "ZappySpendError", "ZappyNotFoundError", "ZappyUnavailableError",
338
+ "ZappyTransportError", "Response", "ResponseMeta", "RateLimit", "redact", "new_idempotency_key", "verify_webhook_signature",
339
+ "pkce_pair", "authorize_url", "SDK_VERSION", "CONTRACT", "DEFAULT_BASE_URL"]
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.1
2
+ Name: zappyquote
3
+ Version: 0.2.0
4
+ Summary: Official Zappy API client for Python - a thin wrapper over the documented Zappy API v1.
5
+ Author: Zappy
6
+ License: MIT
7
+ Project-URL: Homepage, https://zappyquote.com/developers/
8
+ Project-URL: Documentation, https://zappyquote.com/developers/
9
+ Project-URL: Issues, https://zappyquote.com/contact.html
10
+ Keywords: zappy,zappyquote,ai,image-generation,api,sdk
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+
18
+ # zappyquote
19
+
20
+ The official Python client for the Zappy API v1 - a thin wrapper over the documented HTTP API
21
+ (https://zappyquote.com/developers/). The server stays authoritative for pricing, caps, idempotency and ownership.
22
+
23
+ Version 0.2.0 · contract `openapi-1.0.0@9dff9fdfbcd8c1bd` · Python 3.9+ · standard library only · MIT
24
+
25
+ ```bash
26
+ # official download (always available):
27
+ curl -O https://zappyquote.com/dl/zappyquote.py # then: import zappyquote
28
+ # PyPI, ONLY once https://zappyquote.com/developers/ lists zappyquote as published by Zappy:
29
+ # pip install zappyquote
30
+ ```
31
+
32
+ ```python
33
+ from zappyquote import Zappy, ZappyError, new_idempotency_key
34
+
35
+ zappy = Zappy() # reads ZAPPY_API_KEY; or Zappy(api_key=...)
36
+ caps = zappy.capabilities()["capabilities"] # free
37
+ quote = zappy.quote("zappy-image-pro") # free, reserves nothing
38
+
39
+ # BILLABLE. max_credits is a hard ceiling; the idempotency key makes a replay return the same job, never a second charge.
40
+ key = new_idempotency_key("order-123")
41
+ r = zappy.create_image("a lighthouse at dusk", max_credits=quote["credits_quoted"], idempotency_key=key)
42
+ job = r if r["status"] == "succeeded" else zappy.wait_for_job(r["job_id"]) # 202 => poll (free)
43
+ asset = zappy.get_asset(job["assets"][0]["asset_id"]) # short-lived signed URL on Zappy's host
44
+ ```
45
+
46
+ Every response is a `dict` with `.meta` (`request_id`, `rate_limit`, `retry_after`, `status`). Failures raise a
47
+ `ZappyError` subclass with `code`, `status`, `request_id`, `retry_after`: `ZappyAuthError`, `ZappyRateLimitError`,
48
+ `ZappySpendError`, `ZappyNotFoundError`, `ZappyUnavailableError`, `ZappyTransportError`. Messages never contain a credential.
49
+
50
+ ```python
51
+ from zappyquote import verify_webhook_signature
52
+ ok = verify_webhook_signature(secret, request.headers["Zappy-Signature"], request.get_data()) # exact raw bytes
53
+ ```
54
+
55
+ `pkce_pair()` / `authorize_url(...)` build the OAuth authorization request for an app you registered in Zappy; consent
56
+ and token exchange stay on Zappy's authorization server. Paid calls are never retried by this package.
57
+
58
+ ## Name
59
+
60
+ The distribution and the import are both `zappyquote` (`import zappyquote`). The shorter name `zappy` is used on PyPI by an unrelated project whose distribution also installs a top-level `zappy` package; sharing that name would let either install silently replace the other. Version 0.2.0 is the first version under this name (0.1.0 was only ever a direct download, `/dl/zappy.py`, and is retired).
61
+
62
+ ### Migrating from the retired `/dl/zappy.py`
63
+
64
+ 1. Download `https://zappyquote.com/dl/zappyquote.py` next to your code.
65
+ 2. Change `import zappy` / `from zappy import ...` to `import zappyquote` / `from zappyquote import ...` (the API is unchanged).
66
+ 3. Delete your project-local `zappy.py` once you have confirmed it is the Zappy SDK (its docstring starts with
67
+ "zappy - the official Python client for the Zappy API v1"). Left in place, it would shadow any other `zappy` package.
68
+
69
+ Registry packages are official only if https://zappyquote.com/developers/ lists them as published; until then use the download, and never install a look-alike name.
@@ -0,0 +1,8 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ zappyquote/__init__.py
5
+ zappyquote.egg-info/PKG-INFO
6
+ zappyquote.egg-info/SOURCES.txt
7
+ zappyquote.egg-info/dependency_links.txt
8
+ zappyquote.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ zappyquote