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.
- zappyquote-0.2.0/LICENSE +21 -0
- zappyquote-0.2.0/PKG-INFO +69 -0
- zappyquote-0.2.0/README.md +52 -0
- zappyquote-0.2.0/pyproject.toml +28 -0
- zappyquote-0.2.0/setup.cfg +4 -0
- zappyquote-0.2.0/zappyquote/__init__.py +339 -0
- zappyquote-0.2.0/zappyquote.egg-info/PKG-INFO +69 -0
- zappyquote-0.2.0/zappyquote.egg-info/SOURCES.txt +8 -0
- zappyquote-0.2.0/zappyquote.egg-info/dependency_links.txt +1 -0
- zappyquote-0.2.0/zappyquote.egg-info/top_level.txt +1 -0
zappyquote-0.2.0/LICENSE
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zappyquote
|