vecsnap 0.1.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.
Files changed (172) hide show
  1. vecsnap/__init__.py +217 -0
  2. vecsnap/_generated/__init__.py +8 -0
  3. vecsnap/_generated/api/__init__.py +1 -0
  4. vecsnap/_generated/api/account/__init__.py +1 -0
  5. vecsnap/_generated/api/account/get_credits.py +180 -0
  6. vecsnap/_generated/api/account/get_limits.py +180 -0
  7. vecsnap/_generated/api/colors/__init__.py +1 -0
  8. vecsnap/_generated/api/colors/create_conversion_variant.py +250 -0
  9. vecsnap/_generated/api/colors/create_palette.py +209 -0
  10. vecsnap/_generated/api/colors/delete_palette.py +210 -0
  11. vecsnap/_generated/api/colors/get_conversion_paints.py +231 -0
  12. vecsnap/_generated/api/colors/get_conversion_palette.py +323 -0
  13. vecsnap/_generated/api/colors/list_conversion_variants.py +216 -0
  14. vecsnap/_generated/api/colors/list_palettes.py +172 -0
  15. vecsnap/_generated/api/colors/update_palette.py +231 -0
  16. vecsnap/_generated/api/conversions/__init__.py +1 -0
  17. vecsnap/_generated/api/conversions/cancel_conversion.py +222 -0
  18. vecsnap/_generated/api/conversions/create_conversion.py +263 -0
  19. vecsnap/_generated/api/conversions/delete_conversion.py +218 -0
  20. vecsnap/_generated/api/conversions/get_conversion.py +214 -0
  21. vecsnap/_generated/api/conversions/get_conversion_feedback.py +212 -0
  22. vecsnap/_generated/api/conversions/list_conversions.py +254 -0
  23. vecsnap/_generated/api/conversions/put_conversion_feedback.py +237 -0
  24. vecsnap/_generated/api/conversions/unlock_conversion.py +227 -0
  25. vecsnap/_generated/api/conversions/watch_conversion.py +224 -0
  26. vecsnap/_generated/api/images/__init__.py +1 -0
  27. vecsnap/_generated/api/images/create_image_conversion.py +276 -0
  28. vecsnap/_generated/api/images/delete_image.py +218 -0
  29. vecsnap/_generated/api/images/delete_images.py +217 -0
  30. vecsnap/_generated/api/images/erase_image.py +214 -0
  31. vecsnap/_generated/api/images/erase_images.py +221 -0
  32. vecsnap/_generated/api/images/get_image.py +210 -0
  33. vecsnap/_generated/api/images/list_images.py +307 -0
  34. vecsnap/_generated/api/images/rename_image.py +235 -0
  35. vecsnap/_generated/api/images/restore_image.py +210 -0
  36. vecsnap/_generated/api/images/restore_images.py +213 -0
  37. vecsnap/_generated/api/images/unlock_images.py +227 -0
  38. vecsnap/_generated/api/results/__init__.py +1 -0
  39. vecsnap/_generated/api/results/download_conversion.py +327 -0
  40. vecsnap/_generated/api/results/get_conversion_facts.py +227 -0
  41. vecsnap/_generated/api/results/get_conversion_original.py +230 -0
  42. vecsnap/_generated/api/results/get_conversion_shapes.py +252 -0
  43. vecsnap/_generated/api/results/get_conversion_tiles.py +244 -0
  44. vecsnap/_generated/api/results/preview_conversion.py +278 -0
  45. vecsnap/_generated/api/uploads/__init__.py +1 -0
  46. vecsnap/_generated/api/uploads/create_upload.py +221 -0
  47. vecsnap/_generated/api/vectorize/__init__.py +1 -0
  48. vecsnap/_generated/api/vectorize/vectorize.py +298 -0
  49. vecsnap/_generated/client.py +280 -0
  50. vecsnap/_generated/errors.py +16 -0
  51. vecsnap/_generated/models/__init__.py +293 -0
  52. vecsnap/_generated/models/conversion.py +325 -0
  53. vecsnap/_generated/models/conversion_allowance.py +17 -0
  54. vecsnap/_generated/models/conversion_options.py +65 -0
  55. vecsnap/_generated/models/conversion_result.py +107 -0
  56. vecsnap/_generated/models/conversion_status.py +23 -0
  57. vecsnap/_generated/models/create_conversion_body.py +144 -0
  58. vecsnap/_generated/models/create_conversion_body_options.py +46 -0
  59. vecsnap/_generated/models/create_conversion_body_preset.py +15 -0
  60. vecsnap/_generated/models/create_conversion_variant_body.py +53 -0
  61. vecsnap/_generated/models/create_conversion_variant_body_colors.py +48 -0
  62. vecsnap/_generated/models/create_conversion_variant_response_200.py +90 -0
  63. vecsnap/_generated/models/create_conversion_variant_response_200_colors.py +48 -0
  64. vecsnap/_generated/models/create_image_conversion_body.py +73 -0
  65. vecsnap/_generated/models/create_image_conversion_body_options.py +46 -0
  66. vecsnap/_generated/models/create_palette_body.py +51 -0
  67. vecsnap/_generated/models/create_upload_body.py +56 -0
  68. vecsnap/_generated/models/create_upload_body_content_type.py +43 -0
  69. vecsnap/_generated/models/create_upload_response_200.py +94 -0
  70. vecsnap/_generated/models/created_conversion.py +338 -0
  71. vecsnap/_generated/models/created_conversion_allowance.py +17 -0
  72. vecsnap/_generated/models/created_conversion_status.py +23 -0
  73. vecsnap/_generated/models/delete_conversion_response_200.py +68 -0
  74. vecsnap/_generated/models/delete_conversion_response_200_deleted.py +20 -0
  75. vecsnap/_generated/models/delete_images_body.py +52 -0
  76. vecsnap/_generated/models/delete_images_response_200.py +82 -0
  77. vecsnap/_generated/models/delete_images_response_200_outcomes_item.py +78 -0
  78. vecsnap/_generated/models/delete_images_response_200_outcomes_item_outcome.py +24 -0
  79. vecsnap/_generated/models/delete_palette_response_200.py +71 -0
  80. vecsnap/_generated/models/download_conversion_as.py +17 -0
  81. vecsnap/_generated/models/download_conversion_response_200.py +86 -0
  82. vecsnap/_generated/models/download_conversion_size.py +17 -0
  83. vecsnap/_generated/models/erase_image_response_200.py +62 -0
  84. vecsnap/_generated/models/erase_images_body.py +65 -0
  85. vecsnap/_generated/models/erase_images_response_200.py +90 -0
  86. vecsnap/_generated/models/erase_images_response_200_outcomes_item.py +78 -0
  87. vecsnap/_generated/models/erase_images_response_200_outcomes_item_outcome.py +23 -0
  88. vecsnap/_generated/models/error.py +69 -0
  89. vecsnap/_generated/models/error_error.py +81 -0
  90. vecsnap/_generated/models/failure.py +81 -0
  91. vecsnap/_generated/models/format_.py +136 -0
  92. vecsnap/_generated/models/format_background_type_0.py +16 -0
  93. vecsnap/_generated/models/format_profile.py +16 -0
  94. vecsnap/_generated/models/get_conversion_facts_response_200.py +110 -0
  95. vecsnap/_generated/models/get_conversion_facts_response_200_colors_item.py +93 -0
  96. vecsnap/_generated/models/get_conversion_facts_response_200_colors_item_roles_item.py +24 -0
  97. vecsnap/_generated/models/get_conversion_facts_response_200_summary.py +150 -0
  98. vecsnap/_generated/models/get_conversion_feedback_response_200.py +98 -0
  99. vecsnap/_generated/models/get_conversion_feedback_response_200_verdict_type_0.py +125 -0
  100. vecsnap/_generated/models/get_conversion_feedback_response_200_verdict_type_0_reasons_item.py +26 -0
  101. vecsnap/_generated/models/get_conversion_feedback_response_200_verdict_type_0_vote.py +20 -0
  102. vecsnap/_generated/models/get_conversion_paints_response_200.py +136 -0
  103. vecsnap/_generated/models/get_conversion_paints_response_200_paints_item.py +70 -0
  104. vecsnap/_generated/models/get_conversion_palette_response_200_type_0.py +128 -0
  105. vecsnap/_generated/models/get_conversion_palette_response_200_type_0_counts_item_type_0.py +99 -0
  106. vecsnap/_generated/models/get_conversion_palette_response_200_type_0_counts_item_type_0_mapping.py +48 -0
  107. vecsnap/_generated/models/get_conversion_palette_response_200_type_0_counts_item_type_1.py +70 -0
  108. vecsnap/_generated/models/get_conversion_palette_response_200_type_1.py +118 -0
  109. vecsnap/_generated/models/get_conversion_palette_response_200_type_1_mapping.py +48 -0
  110. vecsnap/_generated/models/get_conversion_shapes_response_200.py +120 -0
  111. vecsnap/_generated/models/get_conversion_shapes_response_200_rows_item.py +129 -0
  112. vecsnap/_generated/models/get_conversion_tiles_body.py +137 -0
  113. vecsnap/_generated/models/get_conversion_tiles_response_200.py +146 -0
  114. vecsnap/_generated/models/get_conversion_tiles_response_200_tiles_item.py +78 -0
  115. vecsnap/_generated/models/get_credits_response_200.py +83 -0
  116. vecsnap/_generated/models/get_credits_response_200_tier.py +16 -0
  117. vecsnap/_generated/models/get_limits_response_200.py +87 -0
  118. vecsnap/_generated/models/image.py +280 -0
  119. vecsnap/_generated/models/image_detail.py +297 -0
  120. vecsnap/_generated/models/image_detail_state.py +17 -0
  121. vecsnap/_generated/models/image_list.py +144 -0
  122. vecsnap/_generated/models/image_state.py +17 -0
  123. vecsnap/_generated/models/image_version.py +354 -0
  124. vecsnap/_generated/models/image_version_allowance.py +17 -0
  125. vecsnap/_generated/models/image_version_status.py +23 -0
  126. vecsnap/_generated/models/library_counts.py +95 -0
  127. vecsnap/_generated/models/link.py +71 -0
  128. vecsnap/_generated/models/list_conversion_variants_response_200.py +120 -0
  129. vecsnap/_generated/models/list_conversion_variants_response_200_data_item.py +93 -0
  130. vecsnap/_generated/models/list_conversion_variants_response_200_data_item_colors.py +48 -0
  131. vecsnap/_generated/models/list_conversions_live.py +16 -0
  132. vecsnap/_generated/models/list_conversions_response_200.py +114 -0
  133. vecsnap/_generated/models/list_images_deleted.py +16 -0
  134. vecsnap/_generated/models/list_images_filter.py +18 -0
  135. vecsnap/_generated/models/list_images_sort.py +17 -0
  136. vecsnap/_generated/models/list_palettes_response_200.py +114 -0
  137. vecsnap/_generated/models/palette.py +104 -0
  138. vecsnap/_generated/models/picture.py +81 -0
  139. vecsnap/_generated/models/picture_of.py +16 -0
  140. vecsnap/_generated/models/preview_conversion_layer.py +15 -0
  141. vecsnap/_generated/models/put_conversion_feedback_body.py +105 -0
  142. vecsnap/_generated/models/put_conversion_feedback_body_reasons_item.py +26 -0
  143. vecsnap/_generated/models/put_conversion_feedback_body_vote.py +18 -0
  144. vecsnap/_generated/models/put_conversion_feedback_response_200.py +72 -0
  145. vecsnap/_generated/models/put_conversion_feedback_response_200_verdict.py +123 -0
  146. vecsnap/_generated/models/put_conversion_feedback_response_200_verdict_reasons_item.py +26 -0
  147. vecsnap/_generated/models/put_conversion_feedback_response_200_verdict_vote.py +20 -0
  148. vecsnap/_generated/models/rename_image_body.py +50 -0
  149. vecsnap/_generated/models/restore_images_body.py +52 -0
  150. vecsnap/_generated/models/restore_images_response_200.py +82 -0
  151. vecsnap/_generated/models/restore_images_response_200_outcomes_item.py +78 -0
  152. vecsnap/_generated/models/restore_images_response_200_outcomes_item_outcome.py +23 -0
  153. vecsnap/_generated/models/unlock_conversion_response_200.py +70 -0
  154. vecsnap/_generated/models/unlock_images_body.py +52 -0
  155. vecsnap/_generated/models/unlock_images_response_200.py +79 -0
  156. vecsnap/_generated/models/update_palette_body.py +51 -0
  157. vecsnap/_generated/models/vectorize_files_body.py +178 -0
  158. vecsnap/_generated/models/vectorize_files_body_options.py +46 -0
  159. vecsnap/_generated/models/vectorize_files_body_preset.py +15 -0
  160. vecsnap/_generated/models/vectorize_json_body.py +119 -0
  161. vecsnap/_generated/models/vectorize_json_body_options.py +46 -0
  162. vecsnap/_generated/models/vectorize_json_body_preset.py +15 -0
  163. vecsnap/_generated/models/vectorized.py +345 -0
  164. vecsnap/_generated/models/vectorized_allowance.py +17 -0
  165. vecsnap/_generated/models/vectorized_files.py +52 -0
  166. vecsnap/_generated/models/vectorized_status.py +23 -0
  167. vecsnap/_generated/models/webhook_event.py +122 -0
  168. vecsnap/_generated/models/webhook_event_type.py +19 -0
  169. vecsnap/_generated/types.py +54 -0
  170. vecsnap-0.1.0.dist-info/METADATA +31 -0
  171. vecsnap-0.1.0.dist-info/RECORD +172 -0
  172. vecsnap-0.1.0.dist-info/WHEEL +4 -0
vecsnap/__init__.py ADDED
@@ -0,0 +1,217 @@
1
+ """``vecsnap`` — the VecSnap API from Python (``developers.md`` § 8; audit ``developer-platform-2026-10-23`` § 13).
2
+
3
+ from vecsnap import VecSnap
4
+ vecsnap = VecSnap() # reads VECSNAP_API_KEY
5
+ result = vecsnap.vectorize(file=open("logo.png", "rb").read(), filename="logo.png")
6
+ open("logo.svg", "wb").write(vecsnap.download(result))
7
+
8
+ Every operation a key may call is GENERATED from the API's own document (``vecsnap._generated``, never edited by hand —
9
+ ``npm --prefix web run gen:clients``) and reachable through ``vecsnap.api`` (its ``Client``, the key set). This module adds
10
+ what generation cannot: ``vectorize`` sends a file as a form and WAITS through a 202, every refusal is a ``VecSnapError``
11
+ with its code, and ``verify_webhook`` checks an event your server received.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import hashlib
17
+ import hmac
18
+ import json
19
+ import os
20
+ import re
21
+ import time
22
+ import uuid
23
+ from typing import Any, Mapping, Optional
24
+
25
+ import httpx
26
+
27
+ from ._generated import AuthenticatedClient
28
+
29
+ __all__ = ["VecSnap", "VecSnapError", "WebhookVerificationError", "verify_webhook", "sign_webhook", "DEFAULT_BASE_URL"]
30
+
31
+ DEFAULT_BASE_URL = "https://api.vecsnap.com/v1"
32
+ ENDED = {"completed", "failed", "expired", "canceled"}
33
+ #: How far ``webhook-timestamp`` may be from now: the reference libraries' five minutes, either way.
34
+ WEBHOOK_TOLERANCE_SECONDS = 5 * 60
35
+
36
+
37
+ class VecSnapError(Exception):
38
+ """A refusal of the API: its ``code`` (branch on it — every code is on vecsnap.com/docs/errors), its HTTP ``status``,
39
+ the ``message`` for a person, the ``request_id`` to quote, and for 429 how many seconds to wait (``retry_after``)."""
40
+
41
+ def __init__(self, code: str, message: str, status: int = 0, request_id: Optional[str] = None, retry_after: Optional[float] = None):
42
+ super().__init__(message)
43
+ self.code = code
44
+ self.message = message
45
+ self.status = status
46
+ self.request_id = request_id
47
+ self.retry_after = retry_after
48
+ #: For a ``vectorize`` that stopped part way: the conversion it made and the key it sent — call again with it.
49
+ self.conversion_id: Optional[str] = None
50
+ self.idempotency_key: Optional[str] = None
51
+
52
+ @classmethod
53
+ def from_response(cls, response: httpx.Response) -> "VecSnapError":
54
+ try:
55
+ retry_after: Optional[float] = float(response.headers["retry-after"])
56
+ except (KeyError, ValueError):
57
+ retry_after = None
58
+ try:
59
+ error = response.json().get("error") or {}
60
+ except ValueError:
61
+ error = {}
62
+ if isinstance(error.get("code"), str):
63
+ return cls(error["code"], str(error.get("message", error["code"])), response.status_code, error.get("requestId") or response.headers.get("x-request-id"), retry_after)
64
+ return cls("unexpected_response", f"the API answered {response.status_code} without an error body", response.status_code, response.headers.get("x-request-id"), retry_after)
65
+
66
+
67
+ class VecSnap:
68
+ """A client for one API key — ``vs_test_…`` (free: a sample result) or ``vs_live_…``."""
69
+
70
+ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None, http: Optional[httpx.Client] = None, timeout: float = 90.0):
71
+ key = api_key or os.environ.get("VECSNAP_API_KEY")
72
+ if not key:
73
+ raise VecSnapError("missing_api_key", "no API key: pass api_key= or set VECSNAP_API_KEY (vecsnap.com/app/developers)")
74
+ # A proxy or a test server: base_url=, else VECSNAP_BASE_URL, else the API.
75
+ self._base_url = (base_url or os.environ.get("VECSNAP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
76
+ self._http = http or httpx.Client(timeout=timeout)
77
+ self._headers = {"authorization": f"Bearer {key}", "accept": "application/json"}
78
+ #: The generated client, the key set: ``from vecsnap._generated.api.conversions import get_conversion``,
79
+ #: then ``get_conversion.sync(id=…, client=vecsnap.api)``.
80
+ self.api = AuthenticatedClient(base_url=self._base_url, token=key, timeout=httpx.Timeout(timeout))
81
+
82
+ def vectorize(
83
+ self,
84
+ *,
85
+ file: Optional[bytes] = None,
86
+ url: Optional[str] = None,
87
+ input_key: Optional[str] = None,
88
+ filename: Optional[str] = None,
89
+ preset: Optional[str] = None,
90
+ options: Optional[Mapping[str, Any]] = None,
91
+ idempotency_key: Optional[str] = None,
92
+ wait: int = 30,
93
+ timeout_seconds: float = 600,
94
+ ) -> dict:
95
+ """Convert one image and wait for its SVG: the conversion and a link to each SVG file (``files``). A conversion that
96
+ outlasts one call (202) is waited for here — read again with backoff, its files then asked for. Raises
97
+ ``VecSnapError`` (``insufficient_credits``, ``timeout``, …) — past the 202 with ``conversion_id`` and
98
+ ``idempotency_key``: call again with that key to pick the same conversion up."""
99
+ if sum(x is not None for x in (file, url, input_key)) != 1:
100
+ raise VecSnapError("invalid_request", 'give exactly one of "file", "url" or "input_key"')
101
+ key = idempotency_key or str(uuid.uuid4())
102
+ sent = dict(file=file, url=url, input_key=input_key, filename=filename, preset=preset, options=options)
103
+ deadline = time.monotonic() + timeout_seconds
104
+ status, body = self._vectorize_once(sent, key, wait)
105
+ if status == 200:
106
+ return body
107
+ # A 202: the conversion exists and finishes on its own. Wait for it — and if this call cannot, say which
108
+ # conversion and which key, so a retry picks the SAME one up (never a second charge).
109
+ conversion_id = body["id"]
110
+ try:
111
+ delay = 1.0
112
+ while True:
113
+ if time.monotonic() + delay > deadline:
114
+ raise VecSnapError("timeout", f"conversion {conversion_id} had not ended within the time given")
115
+ time.sleep(delay)
116
+ delay = min(delay * 1.6, 10.0)
117
+ try:
118
+ conversion = self.conversion(conversion_id)
119
+ except VecSnapError as error:
120
+ if error.status == 429 or error.status >= 500: # wait longer; never give the conversion up for it
121
+ delay = max(delay, error.retry_after or 0)
122
+ continue
123
+ raise
124
+ if conversion["status"] in ENDED:
125
+ # Its files from the conversion itself — never the request sent again, whose URL may be gone.
126
+ return self._with_files(conversion)
127
+ except VecSnapError as error:
128
+ error.conversion_id, error.idempotency_key = conversion_id, key
129
+ raise
130
+
131
+ def conversion(self, conversion_id: str) -> dict:
132
+ """One conversion as it stands. Raises ``VecSnapError`` ``not_found``."""
133
+ response = self._http.get(f"{self._base_url}/conversions/{conversion_id}", headers=self._headers)
134
+ if response.status_code != 200:
135
+ raise VecSnapError.from_response(response)
136
+ return response.json()
137
+
138
+ def _with_files(self, conversion: dict) -> dict:
139
+ """An ended conversion with a link to each of its files (an image not unlocked has none), as ``vectorize`` answers."""
140
+ files: dict = {}
141
+ expires_in = None
142
+ if conversion["status"] == "completed" and conversion.get("unlocked"):
143
+ for fmt in (conversion.get("result") or {}).get("formats") or []:
144
+ response = self._http.get(f"{self._base_url}/conversions/{conversion['id']}/download", headers=self._headers, params={"format": fmt["id"]})
145
+ if response.status_code != 200:
146
+ raise VecSnapError.from_response(response)
147
+ link = response.json()
148
+ files[fmt["id"]] = link["url"]
149
+ expires_in = link["expiresIn"] if expires_in is None else min(expires_in, link["expiresIn"])
150
+ expires_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + (expires_in or 0)))
151
+ return {**conversion, "files": files, "filesExpireAt": expires_at}
152
+
153
+ def download(self, result: Mapping[str, Any], format: str = "web") -> bytes:
154
+ """The bytes of a vectorized image's SVG (``web`` by default; ``design``, …: each of ``files``)."""
155
+ link = (result.get("files") or {}).get(format)
156
+ if not link:
157
+ raise VecSnapError("format_unavailable", f'no "{format}" file: {", ".join(result.get("files") or {}) or "none"} (an image not unlocked has none)')
158
+ response = self._http.get(link)
159
+ if response.status_code != 200:
160
+ raise VecSnapError("download_failed", f"the file answered {response.status_code} (links last until filesExpireAt)", response.status_code)
161
+ return response.content
162
+
163
+ def _vectorize_once(self, sent: Mapping[str, Any], key: str, wait: int) -> tuple[int, dict]:
164
+ headers = {**self._headers, "idempotency-key": key}
165
+ if sent["file"] is not None:
166
+ data = {"wait": str(wait)}
167
+ for field in ("filename", "preset"):
168
+ if sent[field] is not None:
169
+ data[field] = sent[field]
170
+ if sent["options"] is not None:
171
+ data["options"] = json.dumps(dict(sent["options"]))
172
+ files = {"file": (sent["filename"] or "image", sent["file"])}
173
+ response = self._http.post(f"{self._base_url}/vectorize", headers=headers, data=data, files=files)
174
+ else:
175
+ payload = {"wait": wait, "url": sent["url"], "inputKey": sent["input_key"], "filename": sent["filename"], "preset": sent["preset"], "options": sent["options"]}
176
+ response = self._http.post(f"{self._base_url}/vectorize", headers=headers, json={k: v for k, v in payload.items() if v is not None})
177
+ if response.status_code in (200, 202):
178
+ return response.status_code, response.json()
179
+ raise VecSnapError.from_response(response)
180
+
181
+
182
+ class WebhookVerificationError(Exception):
183
+ """Why an event was refused: ``missing_headers``, ``malformed_timestamp``, ``stale``, ``bad_signature`` or ``bad_secret``."""
184
+
185
+ def __init__(self, reason: str, message: str):
186
+ super().__init__(message)
187
+ self.reason = reason
188
+
189
+
190
+ def _secret_key(secret: str) -> bytes:
191
+ match = re.fullmatch(r"(?:v1,)?whsec_([A-Za-z0-9+/]+={0,2})", secret.strip())
192
+ if not match:
193
+ raise WebhookVerificationError("bad_secret", "a webhook secret is whsec_ followed by base64")
194
+ return base64.b64decode(match.group(1))
195
+
196
+
197
+ def sign_webhook(body: str, secret: str, webhook_id: str, timestamp: int) -> str:
198
+ """``v1,<base64>`` of an event signed with ``secret`` — what VecSnap sends; your tests can sign their own with it."""
199
+ mac = hmac.new(_secret_key(secret), f"{webhook_id}.{timestamp}.{body}".encode(), hashlib.sha256).digest()
200
+ return "v1," + base64.b64encode(mac).decode()
201
+
202
+
203
+ def verify_webhook(body: str, headers: Mapping[str, str], secret: str, now: Optional[float] = None) -> dict:
204
+ """The event, once its Standard Webhooks signature and timestamp are checked — or ``WebhookVerificationError``.
205
+ Pass the RAW body, as it arrived. Dedupe on ``event["id"]``: an event may come more than once."""
206
+ lower = {k.lower(): v for k, v in headers.items()}
207
+ webhook_id, timestamp, signatures = lower.get("webhook-id"), lower.get("webhook-timestamp"), lower.get("webhook-signature")
208
+ if not webhook_id or not timestamp or not signatures:
209
+ raise WebhookVerificationError("missing_headers", "webhook-id, webhook-timestamp and webhook-signature are required")
210
+ if not re.fullmatch(r"\d{1,12}", timestamp):
211
+ raise WebhookVerificationError("malformed_timestamp", "webhook-timestamp is not a number of seconds")
212
+ if abs(int(now if now is not None else time.time()) - int(timestamp)) > WEBHOOK_TOLERANCE_SECONDS:
213
+ raise WebhookVerificationError("stale", "webhook-timestamp is more than 5 minutes from now: a replay, or a clock to set")
214
+ expected = sign_webhook(body, secret, webhook_id, int(timestamp))
215
+ if not any(hmac.compare_digest(given, expected) for given in signatures.split(" ")):
216
+ raise WebhookVerificationError("bad_signature", "no webhook-signature matches this secret: a forged event, or another endpoint's secret")
217
+ return json.loads(body)
@@ -0,0 +1,8 @@
1
+ """A client library for accessing VecSnap API"""
2
+
3
+ from .client import AuthenticatedClient, Client
4
+
5
+ __all__ = (
6
+ "AuthenticatedClient",
7
+ "Client",
8
+ )
@@ -0,0 +1 @@
1
+ """Contains methods for accessing the API"""
@@ -0,0 +1 @@
1
+ """Contains endpoint functions for accessing the API"""
@@ -0,0 +1,180 @@
1
+ from http import HTTPStatus
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+ from ... import errors
7
+ from ...client import AuthenticatedClient, Client
8
+ from ...models.error import Error
9
+ from ...models.get_credits_response_200 import GetCreditsResponse200
10
+ from ...types import Response
11
+
12
+
13
+ def _get_kwargs() -> dict[str, Any]:
14
+
15
+ _kwargs: dict[str, Any] = {
16
+ "method": "get",
17
+ "url": "/credits",
18
+ }
19
+
20
+ return _kwargs
21
+
22
+
23
+ def _parse_response(
24
+ *, client: AuthenticatedClient | Client, response: httpx.Response
25
+ ) -> Error | GetCreditsResponse200 | None:
26
+ if response.status_code == 200:
27
+ response_200 = GetCreditsResponse200.from_dict(response.json())
28
+
29
+ return response_200
30
+
31
+ if response.status_code == 401:
32
+ response_401 = Error.from_dict(response.json())
33
+
34
+ return response_401
35
+
36
+ if response.status_code == 403:
37
+ response_403 = Error.from_dict(response.json())
38
+
39
+ return response_403
40
+
41
+ if response.status_code == 409:
42
+ response_409 = Error.from_dict(response.json())
43
+
44
+ return response_409
45
+
46
+ if response.status_code == 422:
47
+ response_422 = Error.from_dict(response.json())
48
+
49
+ return response_422
50
+
51
+ if response.status_code == 429:
52
+ response_429 = Error.from_dict(response.json())
53
+
54
+ return response_429
55
+
56
+ if response.status_code == 500:
57
+ response_500 = Error.from_dict(response.json())
58
+
59
+ return response_500
60
+
61
+ if response.status_code == 503:
62
+ response_503 = Error.from_dict(response.json())
63
+
64
+ return response_503
65
+
66
+ if client.raise_on_unexpected_status:
67
+ raise errors.UnexpectedStatus(response.status_code, response.content)
68
+ else:
69
+ return None
70
+
71
+
72
+ def _build_response(
73
+ *, client: AuthenticatedClient | Client, response: httpx.Response
74
+ ) -> Response[Error | GetCreditsResponse200]:
75
+ return Response(
76
+ status_code=HTTPStatus(response.status_code),
77
+ content=response.content,
78
+ headers=response.headers,
79
+ parsed=_parse_response(client=client, response=response),
80
+ )
81
+
82
+
83
+ def sync_detailed(
84
+ *,
85
+ client: AuthenticatedClient,
86
+ ) -> Response[Error | GetCreditsResponse200]:
87
+ """Retrieve the credit balance
88
+
89
+ Derived from the ledger on every call: there is no balance column, since a cached total drifts under
90
+ partial failure and the customer is the one who finds out. `unlockCost` is what unlocking one image
91
+ costs, published so a client never hardcodes it; `tier` says which caps apply.
92
+
93
+ Raises:
94
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
95
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
96
+
97
+ Returns:
98
+ Response[Error | GetCreditsResponse200]
99
+ """
100
+
101
+ kwargs = _get_kwargs()
102
+
103
+ response = client.get_httpx_client().request(
104
+ **kwargs,
105
+ )
106
+
107
+ return _build_response(client=client, response=response)
108
+
109
+
110
+ def sync(
111
+ *,
112
+ client: AuthenticatedClient,
113
+ ) -> Error | GetCreditsResponse200 | None:
114
+ """Retrieve the credit balance
115
+
116
+ Derived from the ledger on every call: there is no balance column, since a cached total drifts under
117
+ partial failure and the customer is the one who finds out. `unlockCost` is what unlocking one image
118
+ costs, published so a client never hardcodes it; `tier` says which caps apply.
119
+
120
+ Raises:
121
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
122
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
123
+
124
+ Returns:
125
+ Error | GetCreditsResponse200
126
+ """
127
+
128
+ return sync_detailed(
129
+ client=client,
130
+ ).parsed
131
+
132
+
133
+ async def asyncio_detailed(
134
+ *,
135
+ client: AuthenticatedClient,
136
+ ) -> Response[Error | GetCreditsResponse200]:
137
+ """Retrieve the credit balance
138
+
139
+ Derived from the ledger on every call: there is no balance column, since a cached total drifts under
140
+ partial failure and the customer is the one who finds out. `unlockCost` is what unlocking one image
141
+ costs, published so a client never hardcodes it; `tier` says which caps apply.
142
+
143
+ Raises:
144
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
145
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
146
+
147
+ Returns:
148
+ Response[Error | GetCreditsResponse200]
149
+ """
150
+
151
+ kwargs = _get_kwargs()
152
+
153
+ response = await client.get_async_httpx_client().request(**kwargs)
154
+
155
+ return _build_response(client=client, response=response)
156
+
157
+
158
+ async def asyncio(
159
+ *,
160
+ client: AuthenticatedClient,
161
+ ) -> Error | GetCreditsResponse200 | None:
162
+ """Retrieve the credit balance
163
+
164
+ Derived from the ledger on every call: there is no balance column, since a cached total drifts under
165
+ partial failure and the customer is the one who finds out. `unlockCost` is what unlocking one image
166
+ costs, published so a client never hardcodes it; `tier` says which caps apply.
167
+
168
+ Raises:
169
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
170
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
171
+
172
+ Returns:
173
+ Error | GetCreditsResponse200
174
+ """
175
+
176
+ return (
177
+ await asyncio_detailed(
178
+ client=client,
179
+ )
180
+ ).parsed
@@ -0,0 +1,180 @@
1
+ from http import HTTPStatus
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+ from ... import errors
7
+ from ...client import AuthenticatedClient, Client
8
+ from ...models.error import Error
9
+ from ...models.get_limits_response_200 import GetLimitsResponse200
10
+ from ...types import Response
11
+
12
+
13
+ def _get_kwargs() -> dict[str, Any]:
14
+
15
+ _kwargs: dict[str, Any] = {
16
+ "method": "get",
17
+ "url": "/limits",
18
+ }
19
+
20
+ return _kwargs
21
+
22
+
23
+ def _parse_response(
24
+ *, client: AuthenticatedClient | Client, response: httpx.Response
25
+ ) -> Error | GetLimitsResponse200 | None:
26
+ if response.status_code == 200:
27
+ response_200 = GetLimitsResponse200.from_dict(response.json())
28
+
29
+ return response_200
30
+
31
+ if response.status_code == 401:
32
+ response_401 = Error.from_dict(response.json())
33
+
34
+ return response_401
35
+
36
+ if response.status_code == 403:
37
+ response_403 = Error.from_dict(response.json())
38
+
39
+ return response_403
40
+
41
+ if response.status_code == 409:
42
+ response_409 = Error.from_dict(response.json())
43
+
44
+ return response_409
45
+
46
+ if response.status_code == 422:
47
+ response_422 = Error.from_dict(response.json())
48
+
49
+ return response_422
50
+
51
+ if response.status_code == 429:
52
+ response_429 = Error.from_dict(response.json())
53
+
54
+ return response_429
55
+
56
+ if response.status_code == 500:
57
+ response_500 = Error.from_dict(response.json())
58
+
59
+ return response_500
60
+
61
+ if response.status_code == 503:
62
+ response_503 = Error.from_dict(response.json())
63
+
64
+ return response_503
65
+
66
+ if client.raise_on_unexpected_status:
67
+ raise errors.UnexpectedStatus(response.status_code, response.content)
68
+ else:
69
+ return None
70
+
71
+
72
+ def _build_response(
73
+ *, client: AuthenticatedClient | Client, response: httpx.Response
74
+ ) -> Response[Error | GetLimitsResponse200]:
75
+ return Response(
76
+ status_code=HTTPStatus(response.status_code),
77
+ content=response.content,
78
+ headers=response.headers,
79
+ parsed=_parse_response(client=client, response=response),
80
+ )
81
+
82
+
83
+ def sync_detailed(
84
+ *,
85
+ client: AuthenticatedClient,
86
+ ) -> Response[Error | GetLimitsResponse200]:
87
+ """Retrieve today's limits
88
+
89
+ The caps that apply to the caller (a guest, a free account or a customer) and how much of today is
90
+ used: the same count the create refuses by. `usedToday` counts free conversions (waiting ones
91
+ included) and refunded holds; a stopped one never ran and is not counted.
92
+
93
+ Raises:
94
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
95
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
96
+
97
+ Returns:
98
+ Response[Error | GetLimitsResponse200]
99
+ """
100
+
101
+ kwargs = _get_kwargs()
102
+
103
+ response = client.get_httpx_client().request(
104
+ **kwargs,
105
+ )
106
+
107
+ return _build_response(client=client, response=response)
108
+
109
+
110
+ def sync(
111
+ *,
112
+ client: AuthenticatedClient,
113
+ ) -> Error | GetLimitsResponse200 | None:
114
+ """Retrieve today's limits
115
+
116
+ The caps that apply to the caller (a guest, a free account or a customer) and how much of today is
117
+ used: the same count the create refuses by. `usedToday` counts free conversions (waiting ones
118
+ included) and refunded holds; a stopped one never ran and is not counted.
119
+
120
+ Raises:
121
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
122
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
123
+
124
+ Returns:
125
+ Error | GetLimitsResponse200
126
+ """
127
+
128
+ return sync_detailed(
129
+ client=client,
130
+ ).parsed
131
+
132
+
133
+ async def asyncio_detailed(
134
+ *,
135
+ client: AuthenticatedClient,
136
+ ) -> Response[Error | GetLimitsResponse200]:
137
+ """Retrieve today's limits
138
+
139
+ The caps that apply to the caller (a guest, a free account or a customer) and how much of today is
140
+ used: the same count the create refuses by. `usedToday` counts free conversions (waiting ones
141
+ included) and refunded holds; a stopped one never ran and is not counted.
142
+
143
+ Raises:
144
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
145
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
146
+
147
+ Returns:
148
+ Response[Error | GetLimitsResponse200]
149
+ """
150
+
151
+ kwargs = _get_kwargs()
152
+
153
+ response = await client.get_async_httpx_client().request(**kwargs)
154
+
155
+ return _build_response(client=client, response=response)
156
+
157
+
158
+ async def asyncio(
159
+ *,
160
+ client: AuthenticatedClient,
161
+ ) -> Error | GetLimitsResponse200 | None:
162
+ """Retrieve today's limits
163
+
164
+ The caps that apply to the caller (a guest, a free account or a customer) and how much of today is
165
+ used: the same count the create refuses by. `usedToday` counts free conversions (waiting ones
166
+ included) and refunded holds; a stopped one never ran and is not counted.
167
+
168
+ Raises:
169
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
170
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
171
+
172
+ Returns:
173
+ Error | GetLimitsResponse200
174
+ """
175
+
176
+ return (
177
+ await asyncio_detailed(
178
+ client=client,
179
+ )
180
+ ).parsed
@@ -0,0 +1 @@
1
+ """Contains endpoint functions for accessing the API"""