vecsnap 0.1.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.
Files changed (174) hide show
  1. vecsnap-0.1.0/.gitignore +5 -0
  2. vecsnap-0.1.0/PKG-INFO +31 -0
  3. vecsnap-0.1.0/README.md +18 -0
  4. vecsnap-0.1.0/pyproject.toml +31 -0
  5. vecsnap-0.1.0/tests/test_vecsnap.py +82 -0
  6. vecsnap-0.1.0/vecsnap/__init__.py +217 -0
  7. vecsnap-0.1.0/vecsnap/_generated/__init__.py +8 -0
  8. vecsnap-0.1.0/vecsnap/_generated/api/__init__.py +1 -0
  9. vecsnap-0.1.0/vecsnap/_generated/api/account/__init__.py +1 -0
  10. vecsnap-0.1.0/vecsnap/_generated/api/account/get_credits.py +180 -0
  11. vecsnap-0.1.0/vecsnap/_generated/api/account/get_limits.py +180 -0
  12. vecsnap-0.1.0/vecsnap/_generated/api/colors/__init__.py +1 -0
  13. vecsnap-0.1.0/vecsnap/_generated/api/colors/create_conversion_variant.py +250 -0
  14. vecsnap-0.1.0/vecsnap/_generated/api/colors/create_palette.py +209 -0
  15. vecsnap-0.1.0/vecsnap/_generated/api/colors/delete_palette.py +210 -0
  16. vecsnap-0.1.0/vecsnap/_generated/api/colors/get_conversion_paints.py +231 -0
  17. vecsnap-0.1.0/vecsnap/_generated/api/colors/get_conversion_palette.py +323 -0
  18. vecsnap-0.1.0/vecsnap/_generated/api/colors/list_conversion_variants.py +216 -0
  19. vecsnap-0.1.0/vecsnap/_generated/api/colors/list_palettes.py +172 -0
  20. vecsnap-0.1.0/vecsnap/_generated/api/colors/update_palette.py +231 -0
  21. vecsnap-0.1.0/vecsnap/_generated/api/conversions/__init__.py +1 -0
  22. vecsnap-0.1.0/vecsnap/_generated/api/conversions/cancel_conversion.py +222 -0
  23. vecsnap-0.1.0/vecsnap/_generated/api/conversions/create_conversion.py +263 -0
  24. vecsnap-0.1.0/vecsnap/_generated/api/conversions/delete_conversion.py +218 -0
  25. vecsnap-0.1.0/vecsnap/_generated/api/conversions/get_conversion.py +214 -0
  26. vecsnap-0.1.0/vecsnap/_generated/api/conversions/get_conversion_feedback.py +212 -0
  27. vecsnap-0.1.0/vecsnap/_generated/api/conversions/list_conversions.py +254 -0
  28. vecsnap-0.1.0/vecsnap/_generated/api/conversions/put_conversion_feedback.py +237 -0
  29. vecsnap-0.1.0/vecsnap/_generated/api/conversions/unlock_conversion.py +227 -0
  30. vecsnap-0.1.0/vecsnap/_generated/api/conversions/watch_conversion.py +224 -0
  31. vecsnap-0.1.0/vecsnap/_generated/api/images/__init__.py +1 -0
  32. vecsnap-0.1.0/vecsnap/_generated/api/images/create_image_conversion.py +276 -0
  33. vecsnap-0.1.0/vecsnap/_generated/api/images/delete_image.py +218 -0
  34. vecsnap-0.1.0/vecsnap/_generated/api/images/delete_images.py +217 -0
  35. vecsnap-0.1.0/vecsnap/_generated/api/images/erase_image.py +214 -0
  36. vecsnap-0.1.0/vecsnap/_generated/api/images/erase_images.py +221 -0
  37. vecsnap-0.1.0/vecsnap/_generated/api/images/get_image.py +210 -0
  38. vecsnap-0.1.0/vecsnap/_generated/api/images/list_images.py +307 -0
  39. vecsnap-0.1.0/vecsnap/_generated/api/images/rename_image.py +235 -0
  40. vecsnap-0.1.0/vecsnap/_generated/api/images/restore_image.py +210 -0
  41. vecsnap-0.1.0/vecsnap/_generated/api/images/restore_images.py +213 -0
  42. vecsnap-0.1.0/vecsnap/_generated/api/images/unlock_images.py +227 -0
  43. vecsnap-0.1.0/vecsnap/_generated/api/results/__init__.py +1 -0
  44. vecsnap-0.1.0/vecsnap/_generated/api/results/download_conversion.py +327 -0
  45. vecsnap-0.1.0/vecsnap/_generated/api/results/get_conversion_facts.py +227 -0
  46. vecsnap-0.1.0/vecsnap/_generated/api/results/get_conversion_original.py +230 -0
  47. vecsnap-0.1.0/vecsnap/_generated/api/results/get_conversion_shapes.py +252 -0
  48. vecsnap-0.1.0/vecsnap/_generated/api/results/get_conversion_tiles.py +244 -0
  49. vecsnap-0.1.0/vecsnap/_generated/api/results/preview_conversion.py +278 -0
  50. vecsnap-0.1.0/vecsnap/_generated/api/uploads/__init__.py +1 -0
  51. vecsnap-0.1.0/vecsnap/_generated/api/uploads/create_upload.py +221 -0
  52. vecsnap-0.1.0/vecsnap/_generated/api/vectorize/__init__.py +1 -0
  53. vecsnap-0.1.0/vecsnap/_generated/api/vectorize/vectorize.py +298 -0
  54. vecsnap-0.1.0/vecsnap/_generated/client.py +280 -0
  55. vecsnap-0.1.0/vecsnap/_generated/errors.py +16 -0
  56. vecsnap-0.1.0/vecsnap/_generated/models/__init__.py +293 -0
  57. vecsnap-0.1.0/vecsnap/_generated/models/conversion.py +325 -0
  58. vecsnap-0.1.0/vecsnap/_generated/models/conversion_allowance.py +17 -0
  59. vecsnap-0.1.0/vecsnap/_generated/models/conversion_options.py +65 -0
  60. vecsnap-0.1.0/vecsnap/_generated/models/conversion_result.py +107 -0
  61. vecsnap-0.1.0/vecsnap/_generated/models/conversion_status.py +23 -0
  62. vecsnap-0.1.0/vecsnap/_generated/models/create_conversion_body.py +144 -0
  63. vecsnap-0.1.0/vecsnap/_generated/models/create_conversion_body_options.py +46 -0
  64. vecsnap-0.1.0/vecsnap/_generated/models/create_conversion_body_preset.py +15 -0
  65. vecsnap-0.1.0/vecsnap/_generated/models/create_conversion_variant_body.py +53 -0
  66. vecsnap-0.1.0/vecsnap/_generated/models/create_conversion_variant_body_colors.py +48 -0
  67. vecsnap-0.1.0/vecsnap/_generated/models/create_conversion_variant_response_200.py +90 -0
  68. vecsnap-0.1.0/vecsnap/_generated/models/create_conversion_variant_response_200_colors.py +48 -0
  69. vecsnap-0.1.0/vecsnap/_generated/models/create_image_conversion_body.py +73 -0
  70. vecsnap-0.1.0/vecsnap/_generated/models/create_image_conversion_body_options.py +46 -0
  71. vecsnap-0.1.0/vecsnap/_generated/models/create_palette_body.py +51 -0
  72. vecsnap-0.1.0/vecsnap/_generated/models/create_upload_body.py +56 -0
  73. vecsnap-0.1.0/vecsnap/_generated/models/create_upload_body_content_type.py +43 -0
  74. vecsnap-0.1.0/vecsnap/_generated/models/create_upload_response_200.py +94 -0
  75. vecsnap-0.1.0/vecsnap/_generated/models/created_conversion.py +338 -0
  76. vecsnap-0.1.0/vecsnap/_generated/models/created_conversion_allowance.py +17 -0
  77. vecsnap-0.1.0/vecsnap/_generated/models/created_conversion_status.py +23 -0
  78. vecsnap-0.1.0/vecsnap/_generated/models/delete_conversion_response_200.py +68 -0
  79. vecsnap-0.1.0/vecsnap/_generated/models/delete_conversion_response_200_deleted.py +20 -0
  80. vecsnap-0.1.0/vecsnap/_generated/models/delete_images_body.py +52 -0
  81. vecsnap-0.1.0/vecsnap/_generated/models/delete_images_response_200.py +82 -0
  82. vecsnap-0.1.0/vecsnap/_generated/models/delete_images_response_200_outcomes_item.py +78 -0
  83. vecsnap-0.1.0/vecsnap/_generated/models/delete_images_response_200_outcomes_item_outcome.py +24 -0
  84. vecsnap-0.1.0/vecsnap/_generated/models/delete_palette_response_200.py +71 -0
  85. vecsnap-0.1.0/vecsnap/_generated/models/download_conversion_as.py +17 -0
  86. vecsnap-0.1.0/vecsnap/_generated/models/download_conversion_response_200.py +86 -0
  87. vecsnap-0.1.0/vecsnap/_generated/models/download_conversion_size.py +17 -0
  88. vecsnap-0.1.0/vecsnap/_generated/models/erase_image_response_200.py +62 -0
  89. vecsnap-0.1.0/vecsnap/_generated/models/erase_images_body.py +65 -0
  90. vecsnap-0.1.0/vecsnap/_generated/models/erase_images_response_200.py +90 -0
  91. vecsnap-0.1.0/vecsnap/_generated/models/erase_images_response_200_outcomes_item.py +78 -0
  92. vecsnap-0.1.0/vecsnap/_generated/models/erase_images_response_200_outcomes_item_outcome.py +23 -0
  93. vecsnap-0.1.0/vecsnap/_generated/models/error.py +69 -0
  94. vecsnap-0.1.0/vecsnap/_generated/models/error_error.py +81 -0
  95. vecsnap-0.1.0/vecsnap/_generated/models/failure.py +81 -0
  96. vecsnap-0.1.0/vecsnap/_generated/models/format_.py +136 -0
  97. vecsnap-0.1.0/vecsnap/_generated/models/format_background_type_0.py +16 -0
  98. vecsnap-0.1.0/vecsnap/_generated/models/format_profile.py +16 -0
  99. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_facts_response_200.py +110 -0
  100. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_facts_response_200_colors_item.py +93 -0
  101. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_facts_response_200_colors_item_roles_item.py +24 -0
  102. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_facts_response_200_summary.py +150 -0
  103. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_feedback_response_200.py +98 -0
  104. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_feedback_response_200_verdict_type_0.py +125 -0
  105. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_feedback_response_200_verdict_type_0_reasons_item.py +26 -0
  106. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_feedback_response_200_verdict_type_0_vote.py +20 -0
  107. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_paints_response_200.py +136 -0
  108. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_paints_response_200_paints_item.py +70 -0
  109. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_palette_response_200_type_0.py +128 -0
  110. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_palette_response_200_type_0_counts_item_type_0.py +99 -0
  111. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_palette_response_200_type_0_counts_item_type_0_mapping.py +48 -0
  112. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_palette_response_200_type_0_counts_item_type_1.py +70 -0
  113. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_palette_response_200_type_1.py +118 -0
  114. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_palette_response_200_type_1_mapping.py +48 -0
  115. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_shapes_response_200.py +120 -0
  116. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_shapes_response_200_rows_item.py +129 -0
  117. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_tiles_body.py +137 -0
  118. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_tiles_response_200.py +146 -0
  119. vecsnap-0.1.0/vecsnap/_generated/models/get_conversion_tiles_response_200_tiles_item.py +78 -0
  120. vecsnap-0.1.0/vecsnap/_generated/models/get_credits_response_200.py +83 -0
  121. vecsnap-0.1.0/vecsnap/_generated/models/get_credits_response_200_tier.py +16 -0
  122. vecsnap-0.1.0/vecsnap/_generated/models/get_limits_response_200.py +87 -0
  123. vecsnap-0.1.0/vecsnap/_generated/models/image.py +280 -0
  124. vecsnap-0.1.0/vecsnap/_generated/models/image_detail.py +297 -0
  125. vecsnap-0.1.0/vecsnap/_generated/models/image_detail_state.py +17 -0
  126. vecsnap-0.1.0/vecsnap/_generated/models/image_list.py +144 -0
  127. vecsnap-0.1.0/vecsnap/_generated/models/image_state.py +17 -0
  128. vecsnap-0.1.0/vecsnap/_generated/models/image_version.py +354 -0
  129. vecsnap-0.1.0/vecsnap/_generated/models/image_version_allowance.py +17 -0
  130. vecsnap-0.1.0/vecsnap/_generated/models/image_version_status.py +23 -0
  131. vecsnap-0.1.0/vecsnap/_generated/models/library_counts.py +95 -0
  132. vecsnap-0.1.0/vecsnap/_generated/models/link.py +71 -0
  133. vecsnap-0.1.0/vecsnap/_generated/models/list_conversion_variants_response_200.py +120 -0
  134. vecsnap-0.1.0/vecsnap/_generated/models/list_conversion_variants_response_200_data_item.py +93 -0
  135. vecsnap-0.1.0/vecsnap/_generated/models/list_conversion_variants_response_200_data_item_colors.py +48 -0
  136. vecsnap-0.1.0/vecsnap/_generated/models/list_conversions_live.py +16 -0
  137. vecsnap-0.1.0/vecsnap/_generated/models/list_conversions_response_200.py +114 -0
  138. vecsnap-0.1.0/vecsnap/_generated/models/list_images_deleted.py +16 -0
  139. vecsnap-0.1.0/vecsnap/_generated/models/list_images_filter.py +18 -0
  140. vecsnap-0.1.0/vecsnap/_generated/models/list_images_sort.py +17 -0
  141. vecsnap-0.1.0/vecsnap/_generated/models/list_palettes_response_200.py +114 -0
  142. vecsnap-0.1.0/vecsnap/_generated/models/palette.py +104 -0
  143. vecsnap-0.1.0/vecsnap/_generated/models/picture.py +81 -0
  144. vecsnap-0.1.0/vecsnap/_generated/models/picture_of.py +16 -0
  145. vecsnap-0.1.0/vecsnap/_generated/models/preview_conversion_layer.py +15 -0
  146. vecsnap-0.1.0/vecsnap/_generated/models/put_conversion_feedback_body.py +105 -0
  147. vecsnap-0.1.0/vecsnap/_generated/models/put_conversion_feedback_body_reasons_item.py +26 -0
  148. vecsnap-0.1.0/vecsnap/_generated/models/put_conversion_feedback_body_vote.py +18 -0
  149. vecsnap-0.1.0/vecsnap/_generated/models/put_conversion_feedback_response_200.py +72 -0
  150. vecsnap-0.1.0/vecsnap/_generated/models/put_conversion_feedback_response_200_verdict.py +123 -0
  151. vecsnap-0.1.0/vecsnap/_generated/models/put_conversion_feedback_response_200_verdict_reasons_item.py +26 -0
  152. vecsnap-0.1.0/vecsnap/_generated/models/put_conversion_feedback_response_200_verdict_vote.py +20 -0
  153. vecsnap-0.1.0/vecsnap/_generated/models/rename_image_body.py +50 -0
  154. vecsnap-0.1.0/vecsnap/_generated/models/restore_images_body.py +52 -0
  155. vecsnap-0.1.0/vecsnap/_generated/models/restore_images_response_200.py +82 -0
  156. vecsnap-0.1.0/vecsnap/_generated/models/restore_images_response_200_outcomes_item.py +78 -0
  157. vecsnap-0.1.0/vecsnap/_generated/models/restore_images_response_200_outcomes_item_outcome.py +23 -0
  158. vecsnap-0.1.0/vecsnap/_generated/models/unlock_conversion_response_200.py +70 -0
  159. vecsnap-0.1.0/vecsnap/_generated/models/unlock_images_body.py +52 -0
  160. vecsnap-0.1.0/vecsnap/_generated/models/unlock_images_response_200.py +79 -0
  161. vecsnap-0.1.0/vecsnap/_generated/models/update_palette_body.py +51 -0
  162. vecsnap-0.1.0/vecsnap/_generated/models/vectorize_files_body.py +178 -0
  163. vecsnap-0.1.0/vecsnap/_generated/models/vectorize_files_body_options.py +46 -0
  164. vecsnap-0.1.0/vecsnap/_generated/models/vectorize_files_body_preset.py +15 -0
  165. vecsnap-0.1.0/vecsnap/_generated/models/vectorize_json_body.py +119 -0
  166. vecsnap-0.1.0/vecsnap/_generated/models/vectorize_json_body_options.py +46 -0
  167. vecsnap-0.1.0/vecsnap/_generated/models/vectorize_json_body_preset.py +15 -0
  168. vecsnap-0.1.0/vecsnap/_generated/models/vectorized.py +345 -0
  169. vecsnap-0.1.0/vecsnap/_generated/models/vectorized_allowance.py +17 -0
  170. vecsnap-0.1.0/vecsnap/_generated/models/vectorized_files.py +52 -0
  171. vecsnap-0.1.0/vecsnap/_generated/models/vectorized_status.py +23 -0
  172. vecsnap-0.1.0/vecsnap/_generated/models/webhook_event.py +122 -0
  173. vecsnap-0.1.0/vecsnap/_generated/models/webhook_event_type.py +19 -0
  174. vecsnap-0.1.0/vecsnap/_generated/types.py +54 -0
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .ruff_cache/
vecsnap-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.5
2
+ Name: vecsnap
3
+ Version: 0.1.0
4
+ Summary: The VecSnap API from Python: turn an image into a clean SVG in one call.
5
+ Project-URL: Homepage, https://vecsnap.com/docs/sdks/python
6
+ License-Expression: MIT
7
+ Keywords: image,sdk,svg,vecsnap,vector,vectorize
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: attrs>=23
10
+ Requires-Dist: httpx<1,>=0.27
11
+ Requires-Dist: python-dateutil>=2.8
12
+ Description-Content-Type: text/markdown
13
+
14
+ # vecsnap — the VecSnap API from Python
15
+
16
+ Turn an image into a clean SVG in one call.
17
+
18
+ ```python
19
+ from vecsnap import VecSnap
20
+
21
+ vecsnap = VecSnap() # reads VECSNAP_API_KEY: vs_test_… is free (a sample result), vs_live_… converts for real
22
+ result = vecsnap.vectorize(file=open("logo.png", "rb").read(), filename="logo.png")
23
+ open("logo.svg", "wb").write(vecsnap.download(result))
24
+ print(result["result"]["anchorPoints"])
25
+ ```
26
+
27
+ A conversion that outlasts one call is waited for. Every refusal is a `VecSnapError` with its `code`
28
+ (vecsnap.com/docs/errors). Check a webhook your server received with `verify_webhook(raw_body, headers, secret)`.
29
+
30
+ `vecsnap._generated` is generated from the API's document — every operation a key may call — and never edited by hand.
31
+ Docs: https://vecsnap.com/docs/sdks/python
@@ -0,0 +1,18 @@
1
+ # vecsnap — the VecSnap API from Python
2
+
3
+ Turn an image into a clean SVG in one call.
4
+
5
+ ```python
6
+ from vecsnap import VecSnap
7
+
8
+ vecsnap = VecSnap() # reads VECSNAP_API_KEY: vs_test_… is free (a sample result), vs_live_… converts for real
9
+ result = vecsnap.vectorize(file=open("logo.png", "rb").read(), filename="logo.png")
10
+ open("logo.svg", "wb").write(vecsnap.download(result))
11
+ print(result["result"]["anchorPoints"])
12
+ ```
13
+
14
+ A conversion that outlasts one call is waited for. Every refusal is a `VecSnapError` with its `code`
15
+ (vecsnap.com/docs/errors). Check a webhook your server received with `verify_webhook(raw_body, headers, secret)`.
16
+
17
+ `vecsnap._generated` is generated from the API's document — every operation a key may call — and never edited by hand.
18
+ Docs: https://vecsnap.com/docs/sdks/python
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "vecsnap"
3
+ version = "0.1.0"
4
+ description = "The VecSnap API from Python: turn an image into a clean SVG in one call."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.9"
8
+ dependencies = [
9
+ "httpx>=0.27,<1",
10
+ "attrs>=23",
11
+ "python-dateutil>=2.8",
12
+ ]
13
+ keywords = ["vecsnap", "svg", "vectorize", "vector", "image", "sdk"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://vecsnap.com/docs/sdks/python"
17
+
18
+ [dependency-groups]
19
+ dev = ["pytest>=8"]
20
+
21
+ # pytest roots HERE: without its own section it climbs to the repository's `pyproject.toml` and loads the engine's
22
+ # `conftest.py` (the gate's read check refuses the engine files that pulls into the web's `static` unit).
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+
26
+ [build-system]
27
+ requires = ["hatchling>=1.25"]
28
+ build-backend = "hatchling.build"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["vecsnap"]
@@ -0,0 +1,82 @@
1
+ """``vecsnap`` on a fake transport — what the hand-written layer adds: ``vectorize`` waits through a 202 and asks again
2
+ with the same idempotency key, a refusal is a ``VecSnapError``, a webhook is checked by the Standard's own known answer."""
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from vecsnap import VecSnap, VecSnapError, WebhookVerificationError, sign_webhook, verify_webhook
9
+
10
+ VECTORIZED = {"object": "conversion", "id": "c1", "status": "completed", "files": {"web": "https://files.example/web.svg"}}
11
+
12
+
13
+ def client(answers):
14
+ calls = []
15
+
16
+ def handle(request: httpx.Request) -> httpx.Response:
17
+ calls.append(request)
18
+ return answers.pop(0)(request)
19
+
20
+ vecsnap = VecSnap(api_key="vs_test_x", base_url="https://api.example/v1", http=httpx.Client(transport=httpx.MockTransport(handle)))
21
+ return vecsnap, calls
22
+
23
+
24
+ def answer(status, body, headers=None):
25
+ return lambda request: httpx.Response(status, json=body, headers=headers or {})
26
+
27
+
28
+ def test_a_file_goes_as_the_form_with_the_key_and_an_idempotency_key():
29
+ vecsnap, calls = client([answer(200, VECTORIZED)])
30
+ assert vecsnap.vectorize(file=b"png", filename="logo.png", options={"maxColors": 4})["files"]["web"]
31
+ request = calls[0]
32
+ assert request.url == "https://api.example/v1/vectorize"
33
+ assert request.headers["authorization"] == "Bearer vs_test_x"
34
+ assert len(request.headers["idempotency-key"]) == 36
35
+ body = request.read().decode()
36
+ assert 'filename="logo.png"' in body and '{"maxColors": 4}' in body
37
+
38
+
39
+ def test_it_waits_through_a_202_then_asks_each_format_for_its_link(monkeypatch):
40
+ monkeypatch.setattr("time.sleep", lambda s: None)
41
+ processing = {"object": "conversion", "id": "c1", "status": "processing"}
42
+ done = {**processing, "status": "completed", "unlocked": True, "result": {"formats": [{"id": "web"}]}}
43
+ vecsnap, calls = client([
44
+ answer(202, processing),
45
+ answer(429, {"error": {"code": "rate_limited", "message": "slow"}}, {"retry-after": "1"}),
46
+ answer(200, done),
47
+ answer(200, {"url": "https://files.example/web.svg", "expiresIn": 3600}),
48
+ ])
49
+ result = vecsnap.vectorize(url="https://example.com/logo.png")
50
+ assert result["files"] == {"web": "https://files.example/web.svg"}
51
+ assert [f"{c.method} {c.url.path}" for c in calls] == ["POST /v1/vectorize", "GET /v1/conversions/c1", "GET /v1/conversions/c1", "GET /v1/conversions/c1/download"]
52
+
53
+
54
+ def test_a_failure_past_the_202_names_the_conversion_and_the_key(monkeypatch):
55
+ monkeypatch.setattr("time.sleep", lambda s: None)
56
+ vecsnap, _ = client([answer(202, {"object": "conversion", "id": "c1", "status": "processing"})])
57
+ with pytest.raises(VecSnapError) as raised:
58
+ vecsnap.vectorize(input_key="inputs/x.png", idempotency_key="my-key", timeout_seconds=0)
59
+ assert (raised.value.code, raised.value.conversion_id, raised.value.idempotency_key) == ("timeout", "c1", "my-key")
60
+
61
+
62
+ def test_a_refusal_is_a_vecsnap_error_with_its_code():
63
+ vecsnap, _ = client([answer(402, {"error": {"code": "insufficient_credits", "message": "buy credits", "requestId": "req_1"}})])
64
+ with pytest.raises(VecSnapError) as raised:
65
+ vecsnap.vectorize(input_key="inputs/x.png")
66
+ assert (raised.value.code, raised.value.status, raised.value.request_id) == ("insufficient_credits", 402, "req_1")
67
+ with pytest.raises(VecSnapError):
68
+ vecsnap.vectorize()
69
+
70
+
71
+ # standard-webhooks `libraries/python/tests/test_webhooks.py` `test_sign_function` — the specification's own vector.
72
+ SECRET, ID, AT, BODY = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", "msg_p5jXN8AQM9LWM0D4loKWxJek", 1614265330, '{"test": 2432232314}'
73
+
74
+
75
+ def test_webhooks_known_answer_and_refusals():
76
+ assert sign_webhook(BODY, SECRET, ID, AT) == "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="
77
+ headers = {"Webhook-Id": ID, "webhook-timestamp": str(AT), "webhook-signature": "v1,AAAA v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="}
78
+ assert verify_webhook(BODY, headers, SECRET, now=AT) == {"test": 2432232314}
79
+ for body, now, reason in [(BODY + " ", AT, "bad_signature"), (BODY, AT + 301, "stale")]:
80
+ with pytest.raises(WebhookVerificationError) as raised:
81
+ verify_webhook(body, headers, SECRET, now=now)
82
+ assert raised.value.reason == reason
@@ -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