tanvo 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.
@@ -0,0 +1,20 @@
1
+ # Publishes to PyPI when a version tag (v0.1.0, …) is pushed. Uses PyPI trusted publishing: no token is stored here.
2
+ # One-time setup on pypi.org: add a trusted publisher for this repository, workflow "publish.yml", environment "pypi".
3
+ name: publish
4
+ on:
5
+ push:
6
+ tags: ["v*"]
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.13"
18
+ - run: python -m unittest discover -s tests
19
+ - run: python -m pip install build && python -m build
20
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,14 @@
1
+ name: test
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python: ["3.9", "3.11", "3.13"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: ${{ matrix.python }}
14
+ - run: python -m unittest discover -s tests -v
tanvo-0.1.0/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
tanvo-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tanvo
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.
tanvo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.5
2
+ Name: tanvo
3
+ Version: 0.1.0
4
+ Summary: Python client for Tanvo: AI images, video and songs on Nano Banana, Seedream, GPT Image, Veo, Kling, Seedance and Suno, plus 80 ready-made photo apps. No dependencies; the house image engine works without an API key.
5
+ Project-URL: Homepage, https://tanvo.ai/?utm_source=pypi&utm_medium=referral
6
+ Project-URL: Documentation, https://github.com/tanvoai/tanvo-python#readme
7
+ Project-URL: Repository, https://github.com/tanvoai/tanvo-python
8
+ Project-URL: Issues, https://github.com/tanvoai/tanvo-python/issues
9
+ Author-email: Tanvo <support@tanvo.ai>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai image generator,ai music,ai video generator,api,image to video,kling,nano banana,seedance,seedream,suno,tanvo,text to image,text to video,veo
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Multimedia :: Graphics
18
+ Classifier: Topic :: Multimedia :: Video
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Tanvo for Python
25
+
26
+ [![PyPI](https://img.shields.io/pypi/v/tanvo)](https://pypi.org/project/tanvo/) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
27
+
28
+ The Python client for [Tanvo](https://tanvo.ai/?utm_source=github&utm_medium=referral), an AI image, video and music studio. Generate images on Nano Banana, Seedream, GPT Image and Qwen, video on Veo, Kling, Seedance and Wan, and songs on Suno, or run one of 80 ready-made photo apps (pet portraits, figurines, old photo restoration, trend videos) on your own photos.
29
+
30
+ - No dependencies: only the standard library. Python 3.9+.
31
+ - **Works without an API key** on the free tier (house image engine, watermarked).
32
+ - Local files are uploaded for you; results download with one call.
33
+ - Retries that never double-charge (every submit carries an idempotency key).
34
+
35
+ ```bash
36
+ pip install tanvo
37
+ ```
38
+
39
+ ## Quick start
40
+
41
+ ```python
42
+ import tanvo
43
+
44
+ client = tanvo.Client() # reads TANVO_API_KEY; without one it runs on the free tier
45
+ image = client.generate_image("a paper lantern floating over a misty lake at dawn", aspect="16:9")
46
+ print(image.url)
47
+ client.download(image, "out/")
48
+ ```
49
+
50
+ Create a key under [Settings → API keys](https://tanvo.ai/settings/apikeys?utm_source=github&utm_medium=referral) to use every model, get clean output and have runs land in your history.
51
+
52
+ ```python
53
+ client = tanvo.Client("sk_...") # or export TANVO_API_KEY=sk_...
54
+ ```
55
+
56
+ ## Ready-made apps
57
+
58
+ Each app has preset looks: a tuned prompt, model and settings with a real example output.
59
+
60
+ ```python
61
+ for app in client.apps(kind="image")[:5]:
62
+ print(app.slug, "-", app.name, [look.id for look in app.looks])
63
+
64
+ portrait = client.generate_from_app(
65
+ "ai-pet-portrait-generator",
66
+ look="royal",
67
+ photos=["rex.jpg"], # local path, https URL or bytes, in the order of app.inputs
68
+ details="Rex, a very good boy",
69
+ )
70
+ client.download(portrait, "out/")
71
+ ```
72
+
73
+ ## Images
74
+
75
+ ```python
76
+ # Text to image on any model (see client.models("image") for ids, options and prices)
77
+ img = client.generate_image("isometric cutaway of a tiny ramen shop", model="nano-banana-2", resolution="2K")
78
+
79
+ # Edit or combine photos: describe what to change and what to keep
80
+ edit = client.generate_image(
81
+ "change the jacket to dark green; keep the face, pose and background exactly as they are",
82
+ model="nano-banana-2",
83
+ images=["portrait.jpg"],
84
+ )
85
+ ```
86
+
87
+ ## Video
88
+
89
+ Video takes one to several minutes, so `generate_video` returns at once by default.
90
+
91
+ ```python
92
+ job = client.generate_video(
93
+ "slow dolly-in on a lighthouse in a storm, waves crashing, cinematic",
94
+ model="kling-3-0", aspect="16:9", resolution="720p", duration=5,
95
+ )
96
+ clip = client.wait(job.id) # or pass wait=True above
97
+ client.download(clip, "out/")
98
+
99
+ # Image to video, with an optional end frame on models that support it
100
+ client.generate_video("the cat turns its head and blinks", model="seedance-2-5", image="cat.jpg", wait=True)
101
+ ```
102
+
103
+ ## Songs
104
+
105
+ Two takes per run, each an MP3 with cover art, a title and the lyrics as sung.
106
+
107
+ ```python
108
+ song = client.generate_music(
109
+ "an upbeat birthday song for my sister Ana: surfing, terrible puns, a big singalong chorus",
110
+ style="acoustic pop, hand claps, warm female vocal",
111
+ )
112
+ for take in song.outputs:
113
+ print(take.title, take.seconds, take.url, take.cover)
114
+
115
+ client.generate_music("[Verse]\n...\n[Chorus]\n...", mode="Lyrics", title="Ana Rides Again", vocal="f")
116
+ client.generate_music("mellow lo-fi study beat, vinyl crackle, 75 bpm", mode="Instrumental")
117
+ ```
118
+
119
+ ## Runs, history and webhooks
120
+
121
+ ```python
122
+ g = client.get("cm...") # one run
123
+ items, next_before = client.list(limit=20)
124
+
125
+ # Skip polling: have the result POSTed to you when it finishes
126
+ client.generate_video("...", model="veo-3-1", webhook_url="https://example.com/hooks/tanvo")
127
+
128
+ # In your web handler, verify with the secret from Settings → Webhooks (pass the raw body)
129
+ generation = tanvo.verify_webhook(secret, request.headers, request.get_data())
130
+ ```
131
+
132
+ Webhooks can arrive more than once; de-duplicate on the `webhook-id` header.
133
+
134
+ ## Errors
135
+
136
+ | Exception | When | What to do |
137
+ |---|---|---|
138
+ | `InvalidRequest` | Bad option, refused prompt, file too large | Fix the input |
139
+ | `AuthError` | Missing or invalid key | Check the key |
140
+ | `QuotaError` | Out of credits, free tier limit, `busy` | Top up, add a key, or wait (`wait_for_slot=True` waits for a free slot) |
141
+ | `ServiceError` | Upstream model or network failure | Already retried twice with the same request key; try again later |
142
+
143
+ All carry `.code` (stable string), `.message` and `.status`. Failed runs are refunded automatically.
144
+
145
+ ## Settings
146
+
147
+ | | |
148
+ |---|---|
149
+ | `TANVO_API_KEY` | API key (or `Client(api_key=...)`) |
150
+ | `TANVO_BASE_URL` | Another deployment (default `https://tanvo.ai`) |
151
+
152
+ ## Also from Tanvo
153
+
154
+ - [tanvo-mcp](https://github.com/tanvoai/tanvo-mcp): the same apps and models inside Claude, Cursor and other MCP clients.
155
+ - [tanvo-js](https://github.com/tanvoai/tanvo-js): the TypeScript / JavaScript client.
156
+
157
+ ## License
158
+
159
+ MIT
tanvo-0.1.0/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # Tanvo for Python
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/tanvo)](https://pypi.org/project/tanvo/) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
4
+
5
+ The Python client for [Tanvo](https://tanvo.ai/?utm_source=github&utm_medium=referral), an AI image, video and music studio. Generate images on Nano Banana, Seedream, GPT Image and Qwen, video on Veo, Kling, Seedance and Wan, and songs on Suno, or run one of 80 ready-made photo apps (pet portraits, figurines, old photo restoration, trend videos) on your own photos.
6
+
7
+ - No dependencies: only the standard library. Python 3.9+.
8
+ - **Works without an API key** on the free tier (house image engine, watermarked).
9
+ - Local files are uploaded for you; results download with one call.
10
+ - Retries that never double-charge (every submit carries an idempotency key).
11
+
12
+ ```bash
13
+ pip install tanvo
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```python
19
+ import tanvo
20
+
21
+ client = tanvo.Client() # reads TANVO_API_KEY; without one it runs on the free tier
22
+ image = client.generate_image("a paper lantern floating over a misty lake at dawn", aspect="16:9")
23
+ print(image.url)
24
+ client.download(image, "out/")
25
+ ```
26
+
27
+ Create a key under [Settings → API keys](https://tanvo.ai/settings/apikeys?utm_source=github&utm_medium=referral) to use every model, get clean output and have runs land in your history.
28
+
29
+ ```python
30
+ client = tanvo.Client("sk_...") # or export TANVO_API_KEY=sk_...
31
+ ```
32
+
33
+ ## Ready-made apps
34
+
35
+ Each app has preset looks: a tuned prompt, model and settings with a real example output.
36
+
37
+ ```python
38
+ for app in client.apps(kind="image")[:5]:
39
+ print(app.slug, "-", app.name, [look.id for look in app.looks])
40
+
41
+ portrait = client.generate_from_app(
42
+ "ai-pet-portrait-generator",
43
+ look="royal",
44
+ photos=["rex.jpg"], # local path, https URL or bytes, in the order of app.inputs
45
+ details="Rex, a very good boy",
46
+ )
47
+ client.download(portrait, "out/")
48
+ ```
49
+
50
+ ## Images
51
+
52
+ ```python
53
+ # Text to image on any model (see client.models("image") for ids, options and prices)
54
+ img = client.generate_image("isometric cutaway of a tiny ramen shop", model="nano-banana-2", resolution="2K")
55
+
56
+ # Edit or combine photos: describe what to change and what to keep
57
+ edit = client.generate_image(
58
+ "change the jacket to dark green; keep the face, pose and background exactly as they are",
59
+ model="nano-banana-2",
60
+ images=["portrait.jpg"],
61
+ )
62
+ ```
63
+
64
+ ## Video
65
+
66
+ Video takes one to several minutes, so `generate_video` returns at once by default.
67
+
68
+ ```python
69
+ job = client.generate_video(
70
+ "slow dolly-in on a lighthouse in a storm, waves crashing, cinematic",
71
+ model="kling-3-0", aspect="16:9", resolution="720p", duration=5,
72
+ )
73
+ clip = client.wait(job.id) # or pass wait=True above
74
+ client.download(clip, "out/")
75
+
76
+ # Image to video, with an optional end frame on models that support it
77
+ client.generate_video("the cat turns its head and blinks", model="seedance-2-5", image="cat.jpg", wait=True)
78
+ ```
79
+
80
+ ## Songs
81
+
82
+ Two takes per run, each an MP3 with cover art, a title and the lyrics as sung.
83
+
84
+ ```python
85
+ song = client.generate_music(
86
+ "an upbeat birthday song for my sister Ana: surfing, terrible puns, a big singalong chorus",
87
+ style="acoustic pop, hand claps, warm female vocal",
88
+ )
89
+ for take in song.outputs:
90
+ print(take.title, take.seconds, take.url, take.cover)
91
+
92
+ client.generate_music("[Verse]\n...\n[Chorus]\n...", mode="Lyrics", title="Ana Rides Again", vocal="f")
93
+ client.generate_music("mellow lo-fi study beat, vinyl crackle, 75 bpm", mode="Instrumental")
94
+ ```
95
+
96
+ ## Runs, history and webhooks
97
+
98
+ ```python
99
+ g = client.get("cm...") # one run
100
+ items, next_before = client.list(limit=20)
101
+
102
+ # Skip polling: have the result POSTed to you when it finishes
103
+ client.generate_video("...", model="veo-3-1", webhook_url="https://example.com/hooks/tanvo")
104
+
105
+ # In your web handler, verify with the secret from Settings → Webhooks (pass the raw body)
106
+ generation = tanvo.verify_webhook(secret, request.headers, request.get_data())
107
+ ```
108
+
109
+ Webhooks can arrive more than once; de-duplicate on the `webhook-id` header.
110
+
111
+ ## Errors
112
+
113
+ | Exception | When | What to do |
114
+ |---|---|---|
115
+ | `InvalidRequest` | Bad option, refused prompt, file too large | Fix the input |
116
+ | `AuthError` | Missing or invalid key | Check the key |
117
+ | `QuotaError` | Out of credits, free tier limit, `busy` | Top up, add a key, or wait (`wait_for_slot=True` waits for a free slot) |
118
+ | `ServiceError` | Upstream model or network failure | Already retried twice with the same request key; try again later |
119
+
120
+ All carry `.code` (stable string), `.message` and `.status`. Failed runs are refunded automatically.
121
+
122
+ ## Settings
123
+
124
+ | | |
125
+ |---|---|
126
+ | `TANVO_API_KEY` | API key (or `Client(api_key=...)`) |
127
+ | `TANVO_BASE_URL` | Another deployment (default `https://tanvo.ai`) |
128
+
129
+ ## Also from Tanvo
130
+
131
+ - [tanvo-mcp](https://github.com/tanvoai/tanvo-mcp): the same apps and models inside Claude, Cursor and other MCP clients.
132
+ - [tanvo-js](https://github.com/tanvoai/tanvo-js): the TypeScript / JavaScript client.
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,11 @@
1
+ """python examples/pet_portrait.py rex.jpg: a royal portrait of your pet (needs TANVO_API_KEY)."""
2
+ import sys
3
+
4
+ import tanvo
5
+
6
+ client = tanvo.Client()
7
+ app = client.app("ai-pet-portrait-generator")
8
+ print("Looks:", ", ".join(f"{l.id} ({l.name})" for l in app.looks))
9
+ result = client.generate_from_app(app.slug, look="royal", photos=[sys.argv[1]])
10
+ print(result.status, result.url)
11
+ print("saved to", client.download(result, "out/"))
@@ -0,0 +1,7 @@
1
+ """python examples/quickstart.py: one free image, no API key needed."""
2
+ import tanvo
3
+
4
+ client = tanvo.Client()
5
+ image = client.generate_image("a paper lantern floating over a misty lake at dawn", aspect="16:9")
6
+ print(image.status, image.url)
7
+ print("saved to", client.download(image, "out/"))
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tanvo"
7
+ version = "0.1.0"
8
+ description = "Python client for Tanvo: AI images, video and songs on Nano Banana, Seedream, GPT Image, Veo, Kling, Seedance and Suno, plus 80 ready-made photo apps. No dependencies; the house image engine works without an API key."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "Tanvo", email = "support@tanvo.ai" }]
14
+ keywords = ["tanvo", "ai image generator", "ai video generator", "text to image", "image to video", "text to video", "ai music", "nano banana", "seedream", "kling", "veo", "seedance", "suno", "api"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Multimedia :: Graphics",
21
+ "Topic :: Multimedia :: Video",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Homepage = "https://tanvo.ai/?utm_source=pypi&utm_medium=referral"
29
+ Documentation = "https://github.com/tanvoai/tanvo-python#readme"
30
+ Repository = "https://github.com/tanvoai/tanvo-python"
31
+ Issues = "https://github.com/tanvoai/tanvo-python/issues"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/tanvo"]
@@ -0,0 +1,12 @@
1
+ """Tanvo: AI images, video and songs from Python. https://tanvo.ai"""
2
+
3
+ from ._client import DEFAULT_BASE_URL, Client, __version__
4
+ from ._errors import AuthError, InvalidRequest, QuotaError, ServiceError, TanvoError, WebhookVerificationError
5
+ from ._types import Account, App, AppInput, Generation, Look, Model, Output
6
+ from ._webhook import verify_webhook
7
+
8
+ __all__ = [
9
+ "Client", "DEFAULT_BASE_URL", "__version__", "verify_webhook",
10
+ "TanvoError", "InvalidRequest", "AuthError", "QuotaError", "ServiceError", "WebhookVerificationError",
11
+ "Account", "App", "AppInput", "Generation", "Look", "Model", "Output",
12
+ ]
@@ -0,0 +1,266 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import mimetypes
5
+ import os
6
+ import time
7
+ import uuid
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
13
+
14
+ from ._errors import ServiceError, TanvoError, error_for
15
+ from ._types import Account, App, Generation, Model
16
+ from ._webhook import verify_webhook
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ DEFAULT_BASE_URL = "https://tanvo.ai"
21
+ ImageInput = Union[str, os.PathLike, bytes]
22
+ _MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".mp4": "video/mp4", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4"}
23
+
24
+
25
+ class Client:
26
+ """Tanvo API client.
27
+
28
+ With no key (argument or TANVO_API_KEY) it runs on the anonymous free tier: the house image engine only,
29
+ watermarked, about one image per client. Create a key at https://tanvo.ai/settings/apikeys for everything else.
30
+ """
31
+
32
+ def __init__(self, api_key: Optional[str] = None, *, anon_id: Optional[str] = None, base_url: Optional[str] = None, timeout: float = 60.0):
33
+ self.api_key = api_key or os.environ.get("TANVO_API_KEY") or None
34
+ self.anon_id = None if self.api_key else (anon_id or f"py-{uuid.uuid4()}")
35
+ self.base_url = (base_url or os.environ.get("TANVO_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
36
+ self.timeout = timeout
37
+ self._models: Optional[List[Model]] = None
38
+ self._apps: Optional[List[App]] = None
39
+
40
+ # ─── HTTP ──────────────────────────────────────────────────────────────
41
+
42
+ def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None, *, retries: int = 0) -> Dict[str, Any]:
43
+ url = f"{self.base_url}/api/v1{path}"
44
+ headers = {"Content-Type": "application/json", "Accept": "application/json", "User-Agent": f"tanvo-python/{__version__}"}
45
+ if self.api_key:
46
+ headers["Authorization"] = f"Bearer {self.api_key}"
47
+ else:
48
+ headers["x-anon-id"] = self.anon_id or ""
49
+ data = json.dumps(body).encode() if body is not None else None
50
+ delays = [1, 3]
51
+ rate_limited_once = False
52
+ attempt = 0
53
+ while True:
54
+ try:
55
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
56
+ with urllib.request.urlopen(req, timeout=self.timeout) as res:
57
+ raw = res.read()
58
+ return json.loads(raw) if raw else {}
59
+ except urllib.error.HTTPError as e:
60
+ err = self._error(e)
61
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as e:
62
+ err = ServiceError("network", f"Could not reach {self.base_url}: {getattr(e, 'reason', e)}", 0)
63
+ if err.code == "rate_limited" and not rate_limited_once:
64
+ rate_limited_once = True
65
+ time.sleep(min(err.retry_after or 5, 60))
66
+ continue
67
+ if isinstance(err, ServiceError) and attempt < retries:
68
+ time.sleep(delays[min(attempt, len(delays) - 1)])
69
+ attempt += 1
70
+ continue
71
+ raise err
72
+
73
+ def _error(self, e: urllib.error.HTTPError) -> TanvoError:
74
+ try:
75
+ payload = json.loads(e.read() or b"{}")
76
+ except ValueError:
77
+ payload = {}
78
+ retry = e.headers.get("Retry-After") if e.headers else None
79
+ return error_for(payload.get("error") or f"http_{e.code}", payload.get("message") or f"HTTP {e.code}", e.code, payload.get("issues"), float(retry) if retry and retry.isdigit() else None, has_key=bool(self.api_key))
80
+
81
+ # ─── Catalogue and account ─────────────────────────────────────────────
82
+
83
+ def models(self, kind: Optional[str] = None) -> List[Model]:
84
+ """Every model with its options, defaults and credit cost at defaults. Cached on the client."""
85
+ if self._models is None:
86
+ self._models = [Model.from_dict(m) for m in self._request("GET", "/models", retries=2)["models"]]
87
+ return [m for m in self._models if kind is None or m.kind == kind]
88
+
89
+ def model(self, model_id: str) -> Model:
90
+ for m in self.models():
91
+ if m.id == model_id:
92
+ return m
93
+ raise KeyError(f"Unknown model {model_id!r}. See client.models().")
94
+
95
+ def apps(self, kind: Optional[str] = None) -> List[App]:
96
+ """Ready-made apps (pet portraits, figurines, trend videos, songs…) with their preset looks. Cached on the client."""
97
+ if self._apps is None:
98
+ self._apps = [App.from_dict(a) for a in self._request("GET", "/apps", retries=2)["apps"]]
99
+ return [a for a in self._apps if kind is None or a.kind == kind]
100
+
101
+ def app(self, slug: str) -> App:
102
+ for a in self.apps():
103
+ if a.slug == slug:
104
+ return a
105
+ raise KeyError(f"Unknown app {slug!r}. See client.apps().")
106
+
107
+ def me(self) -> Account:
108
+ d = self._request("GET", "/me", retries=2)
109
+ return Account(tier=d.get("tier", ""), credits=d.get("credits", 0), watermarked=bool(d.get("watermarked")), concurrency=d.get("concurrency", 1), email=d.get("email"))
110
+
111
+ # ─── Generation ────────────────────────────────────────────────────────
112
+
113
+ def _options(self, model_id: str, given: Dict[str, Any]) -> Dict[str, Any]:
114
+ """The model's defaults, overridden by whatever the caller set."""
115
+ try:
116
+ base = dict(self.model(model_id).defaults)
117
+ except KeyError:
118
+ base = {}
119
+ base.update({k: v for k, v in given.items() if v is not None})
120
+ return base
121
+
122
+ def _submit(self, body: Dict[str, Any], wait: bool, timeout: float, wait_for_slot: bool) -> Generation:
123
+ body = {k: v for k, v in body.items() if v not in (None, [], "")}
124
+ body.setdefault("requestKey", str(uuid.uuid4())) # makes the retries below safe: a repeat returns the first run
125
+ deadline = time.monotonic() + timeout
126
+ while True:
127
+ try:
128
+ g = Generation.from_dict(self._request("POST", "/generations", body, retries=2)["generation"])
129
+ break
130
+ except TanvoError as e:
131
+ if e.code == "busy" and wait_for_slot and time.monotonic() + 5 < deadline:
132
+ time.sleep(5)
133
+ continue
134
+ raise
135
+ if wait:
136
+ return self.wait(g.id, timeout=max(1.0, deadline - time.monotonic()), every=6 if g.kind == "video" else 4)
137
+ return g
138
+
139
+ def generate_image(
140
+ self, prompt: str, *, model: str = "studio-image-v1", aspect: Optional[str] = None, resolution: Optional[str] = None, format: Optional[str] = None,
141
+ images: Sequence[ImageInput] = (), negative_prompt: Optional[str] = None, wait: bool = True, timeout: float = 180, request_key: Optional[str] = None,
142
+ webhook_url: Optional[str] = None, wait_for_slot: bool = False,
143
+ ) -> Generation:
144
+ """Text to image, or an edit / combination when `images` are given (https URLs, local paths or bytes)."""
145
+ return self._submit({
146
+ "kind": "image", "model": model, "prompt": prompt, "negativePrompt": negative_prompt,
147
+ "options": self._options(model, {"aspect": aspect, "resolution": resolution, "format": format}),
148
+ "imageUrls": [self._ref(i) for i in images], "requestKey": request_key, "webhookUrl": webhook_url,
149
+ }, wait, timeout, wait_for_slot)
150
+
151
+ def generate_video(
152
+ self, prompt: str, *, model: str = "studio-video-v1", aspect: Optional[str] = None, resolution: Optional[str] = None, duration: Optional[int] = None,
153
+ audio: Optional[bool] = None, image: Optional[ImageInput] = None, end_image: Optional[ImageInput] = None, negative_prompt: Optional[str] = None,
154
+ wait: bool = False, timeout: float = 600, request_key: Optional[str] = None, webhook_url: Optional[str] = None, wait_for_slot: bool = False,
155
+ ) -> Generation:
156
+ """Text to video, or image to video from `image` (optionally ending on `end_image`). Returns at once unless wait=True."""
157
+ return self._submit({
158
+ "kind": "video", "model": model, "prompt": prompt, "negativePrompt": negative_prompt,
159
+ "options": self._options(model, {"aspect": aspect, "resolution": resolution, "duration": duration, "audio": audio}),
160
+ "imageUrls": [self._ref(image)] if image is not None else [], "endImageUrl": self._ref(end_image) if end_image is not None else None,
161
+ "requestKey": request_key, "webhookUrl": webhook_url,
162
+ }, wait, timeout, wait_for_slot)
163
+
164
+ def generate_music(
165
+ self, prompt: str, *, mode: str = "Describe", style: Optional[str] = None, title: Optional[str] = None, vocal: Optional[str] = None,
166
+ model: str = "suno-v6", wait: bool = True, timeout: float = 300, request_key: Optional[str] = None, webhook_url: Optional[str] = None,
167
+ wait_for_slot: bool = False,
168
+ ) -> Generation:
169
+ """Two takes of a song. mode: "Describe" (prompt describes the song), "Lyrics" (prompt is your lyrics) or "Instrumental"."""
170
+ return self._submit({
171
+ "kind": "music", "model": model, "prompt": prompt,
172
+ "options": {k: v for k, v in {"tier": mode, "resolution": "song", "style": style, "title": title, "vocal": vocal}.items() if v is not None},
173
+ "requestKey": request_key, "webhookUrl": webhook_url,
174
+ }, wait, timeout, wait_for_slot)
175
+
176
+ def generate_from_app(
177
+ self, app: str, *, look: Optional[str] = None, photos: Sequence[ImageInput] = (), details: Optional[str] = None, wait: Optional[bool] = None,
178
+ timeout: Optional[float] = None, request_key: Optional[str] = None, webhook_url: Optional[str] = None, wait_for_slot: bool = False,
179
+ ) -> Generation:
180
+ """Runs one of an app's preset looks on your photos (in the order of app.inputs), e.g.
181
+ client.generate_from_app("ai-pet-portrait-generator", look="royal", photos=["rex.jpg"])."""
182
+ a = self.app(app)
183
+ l = a.look(look)
184
+ needed = sum(1 for i in a.inputs if i.min > 0)
185
+ if len(photos) < needed:
186
+ raise ValueError(f"{a.name} needs {needed} photo(s): {', '.join(i.label for i in a.inputs if i.min > 0)}.")
187
+ prompt = f"{l.prompt}\n\nThis one is for: {details.strip()}." if details else l.prompt
188
+ is_video = a.kind == "video"
189
+ return self._submit({
190
+ "kind": a.kind, "model": l.model, "prompt": prompt, "options": self._options(l.model, dict(l.options)),
191
+ "imageUrls": [self._ref(p) for p in photos] if a.kind != "music" else [], "requestKey": request_key, "webhookUrl": webhook_url,
192
+ }, (not is_video) if wait is None else wait, timeout or (600 if is_video else 300 if a.kind == "music" else 180), wait_for_slot)
193
+
194
+ # ─── Runs ──────────────────────────────────────────────────────────────
195
+
196
+ def get(self, generation_id: str) -> Generation:
197
+ return Generation.from_dict(self._request("GET", f"/generations/{urllib.parse.quote(generation_id)}", retries=2)["generation"])
198
+
199
+ def wait(self, generation_id: str, timeout: float = 600, every: float = 4) -> Generation:
200
+ """Polls until the run finishes. On timeout returns the last state seen (still running) instead of raising."""
201
+ deadline = time.monotonic() + timeout
202
+ time.sleep(min(2, timeout))
203
+ last: Optional[Generation] = None
204
+ while True:
205
+ try:
206
+ last = self.get(generation_id)
207
+ if last.done:
208
+ return last
209
+ except ServiceError:
210
+ pass # one failed read is not a failed run
211
+ if time.monotonic() + every > deadline:
212
+ return last if last is not None else self.get(generation_id)
213
+ time.sleep(every)
214
+
215
+ def list(self, limit: int = 20, before: Optional[int] = None) -> Tuple[List[Generation], Optional[int]]:
216
+ q = urllib.parse.urlencode({k: v for k, v in {"limit": limit, "before": before}.items() if v is not None})
217
+ d = self._request("GET", f"/generations?{q}", retries=2)
218
+ return [Generation.from_dict(g) for g in d.get("generations", [])], d.get("next_before")
219
+
220
+ # ─── Files ─────────────────────────────────────────────────────────────
221
+
222
+ def upload(self, file: Union[str, os.PathLike, bytes], mime: Optional[str] = None) -> str:
223
+ """Uploads a local file (path or bytes) and returns a URL usable in images= / image=."""
224
+ if isinstance(file, bytes):
225
+ data = file
226
+ if not mime:
227
+ raise ValueError("Pass mime= when uploading bytes, e.g. mime='image/png'.")
228
+ else:
229
+ p = Path(file).expanduser()
230
+ data = p.read_bytes()
231
+ mime = mime or _MIME.get(p.suffix.lower()) or mimetypes.guess_type(p.name)[0]
232
+ if not mime:
233
+ raise ValueError(f"Cannot tell the file type of {p.name}; pass mime=.")
234
+ slot = self._request("POST", "/uploads", {"mime": mime, "bytes": len(data)})
235
+ req = urllib.request.Request(slot["upload_url"], data=data, method="PUT", headers={"Content-Type": mime})
236
+ try:
237
+ with urllib.request.urlopen(req, timeout=max(self.timeout, 120)):
238
+ pass
239
+ except urllib.error.HTTPError as e:
240
+ raise ServiceError("upload_failed", f"Upload failed with HTTP {e.code}.", e.code) from None
241
+ return slot["url"]
242
+
243
+ def _ref(self, ref: ImageInput) -> str:
244
+ if isinstance(ref, str) and ref.startswith("https://"):
245
+ return ref
246
+ if isinstance(ref, str) and ref.startswith("http://") and not self.base_url.startswith("http://"):
247
+ raise ValueError(f"Only https URLs can be fetched by the service: {ref}")
248
+ if isinstance(ref, bytes):
249
+ return self.upload(ref, mime="image/png" if ref[:8] == b"\x89PNG\r\n\x1a\n" else "image/jpeg" if ref[:3] == b"\xff\xd8\xff" else "image/webp")
250
+ return self.upload(ref)
251
+
252
+ def download(self, generation: Union[Generation, str], path: Union[str, os.PathLike] = ".", index: int = 0) -> str:
253
+ """Saves one output to `path` (a folder or a file name) and returns the file path."""
254
+ g = self.get(generation) if isinstance(generation, str) else generation
255
+ if not g.outputs:
256
+ raise ValueError(f"Generation {g.id} has no outputs (status {g.status}).")
257
+ url = g.outputs[index].url
258
+ target = Path(path).expanduser()
259
+ if target.is_dir() or str(path).endswith(("/", os.sep)):
260
+ target.mkdir(parents=True, exist_ok=True)
261
+ target = target / f"tanvo-{g.id}-{index}{Path(urllib.parse.urlparse(url).path).suffix or '.bin'}"
262
+ with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": f"tanvo-python/{__version__}"}), timeout=max(self.timeout, 120)) as res:
263
+ target.write_bytes(res.read())
264
+ return str(target)
265
+
266
+ verify_webhook = staticmethod(verify_webhook)
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Optional
4
+
5
+ KEYS_URL = "https://tanvo.ai/settings/apikeys"
6
+
7
+
8
+ class TanvoError(Exception):
9
+ """Base class. `code` is the API's stable error string, `status` the HTTP status (0 for network errors)."""
10
+
11
+ def __init__(self, code: str, message: str, status: int = 0, issues: Optional[List[str]] = None, retry_after: Optional[float] = None):
12
+ super().__init__(message)
13
+ self.code = code
14
+ self.message = message
15
+ self.status = status
16
+ self.issues = issues or []
17
+ self.retry_after = retry_after
18
+
19
+ def __str__(self) -> str:
20
+ extra = f" ({'; '.join(self.issues)})" if self.issues else ""
21
+ return f"{self.code}: {self.message}{extra}"
22
+
23
+
24
+ class InvalidRequest(TanvoError):
25
+ """Bad parameters, a prompt the moderator refused, a file too large, or a record that does not exist. Do not retry."""
26
+
27
+
28
+ class AuthError(TanvoError):
29
+ """No identity, or an invalid / revoked API key."""
30
+
31
+
32
+ class QuotaError(TanvoError):
33
+ """Out of credits, the free tier does not cover this, or a limit was hit. `busy` and `rate_limited` clear by waiting."""
34
+
35
+
36
+ class ServiceError(TanvoError):
37
+ """The service or an upstream model failed. Safe to retry with the same request_key."""
38
+
39
+
40
+ class WebhookVerificationError(TanvoError):
41
+ """A webhook's signature or timestamp did not check out."""
42
+
43
+
44
+ _KINDS = {
45
+ InvalidRequest: {"invalid", "rejected", "too_large", "not_found"},
46
+ AuthError: {"unauthorized", "invalid_api_key", "unknown_anon_id"},
47
+ QuotaError: {"credits", "allowance", "needs_api_key", "anon_ip_daily", "trial_closed", "rate_limited", "limit", "busy"},
48
+ }
49
+ NEEDS_KEY = {"needs_api_key", "allowance", "anon_ip_daily", "trial_closed"}
50
+
51
+
52
+ def error_for(code: str, message: str, status: int, issues: Optional[List[str]] = None, retry_after: Optional[float] = None, has_key: bool = True) -> TanvoError:
53
+ cls = next((c for c, codes in _KINDS.items() if code in codes), ServiceError)
54
+ if code in NEEDS_KEY and not has_key:
55
+ message = f"{message} Get a key at {KEYS_URL} (then pass api_key= or set TANVO_API_KEY)."
56
+ return cls(code, message, status, issues, retry_after)
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ TERMINAL = {"SUCCEEDED", "FAILED", "CANCELED"}
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Output:
11
+ url: str
12
+ mime: str
13
+ # Songs only.
14
+ cover: Optional[str] = None
15
+ title: Optional[str] = None
16
+ lyrics: Optional[str] = None
17
+ seconds: Optional[float] = None
18
+
19
+ @classmethod
20
+ def from_dict(cls, d: Dict[str, Any]) -> "Output":
21
+ return cls(url=d["url"], mime=d.get("mime", ""), cover=d.get("cover"), title=d.get("title"), lyrics=d.get("lyrics"), seconds=d.get("seconds"))
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Generation:
26
+ id: str
27
+ kind: str
28
+ model: str
29
+ prompt: str
30
+ options: Dict[str, Any]
31
+ cost: int
32
+ status: str
33
+ error: Optional[str]
34
+ created_at: int
35
+ outputs: List[Output] = field(default_factory=list)
36
+
37
+ @classmethod
38
+ def from_dict(cls, d: Dict[str, Any]) -> "Generation":
39
+ return cls(
40
+ id=d["id"],
41
+ kind=d.get("kind", ""),
42
+ model=d.get("model", ""),
43
+ prompt=d.get("prompt", ""),
44
+ options=d.get("options") or {},
45
+ cost=d.get("cost", 0),
46
+ status=d.get("status", ""),
47
+ error=d.get("error"),
48
+ created_at=d.get("createdAt", 0),
49
+ outputs=[Output.from_dict(o) for o in d.get("outputs") or []],
50
+ )
51
+
52
+ @property
53
+ def done(self) -> bool:
54
+ return self.status in TERMINAL
55
+
56
+ @property
57
+ def succeeded(self) -> bool:
58
+ return self.status == "SUCCEEDED"
59
+
60
+ @property
61
+ def url(self) -> Optional[str]:
62
+ """The first output's URL, or None while running or after a failure."""
63
+ return self.outputs[0].url if self.outputs else None
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Model:
68
+ id: str
69
+ kind: str
70
+ name: str
71
+ free_tier: bool
72
+ inputs: List[str]
73
+ options: Dict[str, Any]
74
+ defaults: Dict[str, Any]
75
+ credits_at_defaults: int
76
+ prompt_max_chars: int
77
+ raw: Dict[str, Any] = field(repr=False, default_factory=dict)
78
+
79
+ @classmethod
80
+ def from_dict(cls, d: Dict[str, Any]) -> "Model":
81
+ return cls(
82
+ id=d["id"], kind=d.get("kind", ""), name=d.get("name", ""), free_tier=bool(d.get("free_tier")), inputs=d.get("inputs") or [],
83
+ options=d.get("options") or {}, defaults=d.get("defaults") or {}, credits_at_defaults=d.get("credits_at_defaults", 0),
84
+ prompt_max_chars=d.get("prompt_max_chars", 0), raw=d,
85
+ )
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Look:
90
+ id: str
91
+ name: str
92
+ prompt: str
93
+ model: str
94
+ options: Dict[str, Any]
95
+ example: Optional[str]
96
+ url: str
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class AppInput:
101
+ id: str
102
+ label: str
103
+ hint: str
104
+ min: int
105
+ max: int
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class App:
110
+ slug: str
111
+ kind: str
112
+ name: str
113
+ description: str
114
+ category: Optional[str]
115
+ model: str
116
+ url: str
117
+ inputs: List[AppInput]
118
+ looks: List[Look]
119
+
120
+ @classmethod
121
+ def from_dict(cls, d: Dict[str, Any]) -> "App":
122
+ return cls(
123
+ slug=d["slug"], kind=d.get("kind", ""), name=d.get("name", ""), description=d.get("description", ""), category=d.get("category"),
124
+ model=d.get("model", ""), url=d.get("url", ""),
125
+ inputs=[AppInput(id=i["id"], label=i.get("label", ""), hint=i.get("hint", ""), min=i.get("min", 1), max=i.get("max", 1)) for i in d.get("inputs") or []],
126
+ looks=[
127
+ Look(id=l["id"], name=l.get("name", ""), prompt=l.get("prompt", ""), model=l.get("model", ""), options=l.get("options") or {},
128
+ example=(l.get("example") or {}).get("video") or (l.get("example") or {}).get("url"), url=l.get("url", ""))
129
+ for l in d.get("looks") or []
130
+ ],
131
+ )
132
+
133
+ def look(self, look_id: Optional[str] = None) -> Look:
134
+ if look_id is None:
135
+ return self.looks[0]
136
+ for l in self.looks:
137
+ if l.id == look_id:
138
+ return l
139
+ raise KeyError(f"{self.slug} has no look {look_id!r}; looks: {', '.join(l.id for l in self.looks)}")
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class Account:
144
+ tier: str
145
+ credits: int
146
+ watermarked: bool
147
+ concurrency: int
148
+ email: Optional[str] = None
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import time
8
+ from typing import Mapping, Optional, Union
9
+
10
+ from ._errors import WebhookVerificationError
11
+ from ._types import Generation
12
+
13
+
14
+ def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
15
+ for k, v in headers.items():
16
+ if k.lower() == name:
17
+ return v
18
+ return None
19
+
20
+
21
+ def verify_webhook(secret: str, headers: Mapping[str, str], body: Union[bytes, str], tolerance: int = 300) -> Generation:
22
+ """Checks a completion webhook (Standard Webhooks) and returns the Generation it carries.
23
+
24
+ Pass the raw request body exactly as received, not re-serialised JSON. Raises WebhookVerificationError when the
25
+ signature does not match or the timestamp is more than `tolerance` seconds away. The same `webhook-id` can arrive
26
+ more than once (retries, manual resends): de-duplicate on it.
27
+ """
28
+ raw = body.encode() if isinstance(body, str) else body
29
+ msg_id, ts, sig = _header(headers, "webhook-id"), _header(headers, "webhook-timestamp"), _header(headers, "webhook-signature")
30
+ if not msg_id or not ts or not sig:
31
+ raise WebhookVerificationError("missing_headers", "webhook-id, webhook-timestamp and webhook-signature are required.")
32
+ try:
33
+ sent = int(ts)
34
+ except ValueError:
35
+ raise WebhookVerificationError("bad_timestamp", "webhook-timestamp is not a number.") from None
36
+ if abs(time.time() - sent) > tolerance:
37
+ raise WebhookVerificationError("stale", f"The webhook timestamp is more than {tolerance} s from now.")
38
+ key = base64.b64decode(secret[len("whsec_"):] if secret.startswith("whsec_") else secret)
39
+ expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw, hashlib.sha256).digest()).decode()
40
+ candidates = [part.split(",", 1)[1] for part in sig.split() if part.startswith("v1,")]
41
+ if not any(hmac.compare_digest(expected, c) for c in candidates):
42
+ raise WebhookVerificationError("bad_signature", "The webhook signature does not match. Check the secret under Settings → Webhooks.")
43
+ return Generation.from_dict(json.loads(raw))
File without changes
@@ -0,0 +1,328 @@
1
+ """Offline tests against a fake API server (standard library only): python -m unittest discover -s tests"""
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import sys
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ import unittest
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from unittest import mock
15
+
16
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
17
+
18
+ import tanvo # noqa: E402
19
+ from tanvo import AuthError, Client, InvalidRequest, QuotaError, ServiceError, WebhookVerificationError, verify_webhook # noqa: E402
20
+
21
+ PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 32
22
+ MODELS = [
23
+ {"id": "studio-image-v1", "kind": "image", "name": "Tanvo Image v1", "free_tier": True, "inputs": ["text", "image"], "options": {}, "defaults": {"aspect": "1:1", "resolution": "1K", "format": "PNG"}, "credits_at_defaults": 5, "prompt_max_chars": 2000},
24
+ {"id": "seedream-4", "kind": "image", "name": "Seedream 4.0", "free_tier": False, "inputs": ["text", "image"], "options": {}, "defaults": {"aspect": "1:1", "resolution": "2K"}, "credits_at_defaults": 10, "prompt_max_chars": 5000},
25
+ {"id": "studio-video-v1", "kind": "video", "name": "Tanvo Video v1", "free_tier": True, "inputs": ["text", "image"], "options": {}, "defaults": {"aspect": "16:9", "resolution": "480p", "duration": 5}, "credits_at_defaults": 20, "prompt_max_chars": 2000},
26
+ ]
27
+ APPS = [{
28
+ "slug": "ai-pet-portrait-generator", "kind": "image", "name": "Pet Portrait Generator", "description": "A painted portrait.", "category": "Pets", "model": "seedream-4",
29
+ "url": "https://tanvo.ai/image/ai-pet-portrait-generator", "inputs": [{"id": "in1", "label": "Pet photo", "hint": "", "accept": "image/*", "min": 1, "max": 1}],
30
+ "looks": [{"id": "renaissance", "name": "Renaissance oil", "prompt": "Paint the pet as a Renaissance oil portrait.", "model": "seedream-4", "options": {}, "example": {"url": "https://x/e.webp", "kind": "image"}, "url": "https://tanvo.ai/image/ai-pet-portrait-generator?look=renaissance"},
31
+ {"id": "royal", "name": "Royal monarch", "prompt": "Paint the pet as a monarch.", "model": "seedream-4", "options": {"aspect": "3:4"}, "example": None, "url": "https://tanvo.ai/image/ai-pet-portrait-generator?look=royal"}],
32
+ }]
33
+
34
+
35
+ class Fake:
36
+ """State shared with the handler: what was asked, and which failures to inject next."""
37
+
38
+ def __init__(self):
39
+ self.reset()
40
+
41
+ def reset(self):
42
+ self.requests = []
43
+ self.runs = {}
44
+ self.by_key = {}
45
+ self.fail_next = [] # (status, error, extra_headers)
46
+ self.polls_until_done = 1
47
+ self.uploaded = {}
48
+
49
+
50
+ FAKE = Fake()
51
+
52
+
53
+ class Handler(BaseHTTPRequestHandler):
54
+ def log_message(self, *a):
55
+ pass
56
+
57
+ def _send(self, status, payload, headers=None):
58
+ body = json.dumps(payload).encode()
59
+ self.send_response(status)
60
+ self.send_header("Content-Type", "application/json")
61
+ for k, v in (headers or {}).items():
62
+ self.send_header(k, v)
63
+ self.send_header("Content-Length", str(len(body)))
64
+ self.end_headers()
65
+ self.wfile.write(body)
66
+
67
+ def _body(self):
68
+ n = int(self.headers.get("Content-Length") or 0)
69
+ return self.rfile.read(n) if n else b""
70
+
71
+ def _handle(self, method):
72
+ raw = self._body()
73
+ FAKE.requests.append((method, self.path, dict(self.headers), raw))
74
+ if self.path.startswith("/put/"):
75
+ FAKE.uploaded[self.path] = (self.headers.get("Content-Type"), raw)
76
+ self.send_response(200)
77
+ self.send_header("Content-Length", "0")
78
+ self.end_headers()
79
+ return
80
+ if self.path.startswith("/files/"):
81
+ self.send_response(200)
82
+ self.send_header("Content-Length", str(len(PNG)))
83
+ self.end_headers()
84
+ self.wfile.write(PNG)
85
+ return
86
+ if FAKE.fail_next:
87
+ status, error, headers = FAKE.fail_next.pop(0)
88
+ return self._send(status, {"error": error, "message": f"injected {error}"}, headers)
89
+ auth = self.headers.get("Authorization")
90
+ if auth and auth != "Bearer sk_good":
91
+ return self._send(401, {"error": "invalid_api_key", "message": "That API key is not valid."})
92
+ path = self.path.split("?")[0]
93
+ if method == "GET" and path == "/api/v1/models":
94
+ return self._send(200, {"models": MODELS})
95
+ if method == "GET" and path == "/api/v1/apps":
96
+ return self._send(200, {"apps": APPS})
97
+ if method == "GET" and path == "/api/v1/me":
98
+ return self._send(200, {"tier": "paid" if auth else "anonymous", "credits": 900 if auth else 5, "watermarked": not auth, "concurrency": 3 if auth else 1})
99
+ if method == "POST" and path == "/api/v1/generations":
100
+ b = json.loads(raw)
101
+ if not auth and b["model"] != "studio-image-v1":
102
+ return self._send(403, {"error": "needs_api_key", "message": "The free tier covers the house image engine only."})
103
+ if b["options"].get("resolution") == "8K":
104
+ return self._send(400, {"error": "invalid", "message": "That model or setting is not available.", "issues": ["options.resolution: 8K"]})
105
+ key = b.get("requestKey")
106
+ if key in FAKE.by_key:
107
+ return self._send(202, {"generation": FAKE.runs[FAKE.by_key[key]]["g"]})
108
+ gid = f"gen{len(FAKE.runs) + 1}"
109
+ g = {"id": gid, "kind": b["kind"], "model": b["model"], "prompt": b["prompt"], "options": b["options"], "cost": 5, "status": "PROCESSING", "error": None, "createdAt": 1790000000000 + len(FAKE.runs), "outputs": []}
110
+ FAKE.runs[gid] = {"g": g, "polls": 0, "body": b}
111
+ FAKE.by_key[key] = gid
112
+ return self._send(202, {"generation": g})
113
+ if method == "GET" and path.startswith("/api/v1/generations/"):
114
+ r = FAKE.runs.get(path.rsplit("/", 1)[1])
115
+ if not r:
116
+ return self._send(404, {"error": "not_found", "message": "No such generation."})
117
+ r["polls"] += 1
118
+ if r["polls"] >= FAKE.polls_until_done:
119
+ host = f"http://127.0.0.1:{self.server.server_address[1]}"
120
+ r["g"] = {**r["g"], "status": "SUCCEEDED", "outputs": [{"url": f"{host}/files/{r['g']['id']}/0.png", "mime": "image/png"}]}
121
+ return self._send(200, {"generation": r["g"]})
122
+ if method == "GET" and path == "/api/v1/generations":
123
+ gs = sorted((r["g"] for r in FAKE.runs.values()), key=lambda g: -g["createdAt"])[:2]
124
+ return self._send(200, {"generations": gs, "next_before": gs[-1]["createdAt"] if gs else None})
125
+ if method == "POST" and path == "/api/v1/uploads":
126
+ b = json.loads(raw)
127
+ host = f"http://127.0.0.1:{self.server.server_address[1]}"
128
+ return self._send(200, {"upload_url": f"{host}/put/{len(FAKE.uploaded)}", "url": f"https://media.tanvo.ai/uploads/u/{len(FAKE.uploaded)}.png", "expires_in": 300, "_mime": b["mime"]})
129
+ self._send(404, {"error": "not_found", "message": self.path})
130
+
131
+ def do_GET(self):
132
+ self._handle("GET")
133
+
134
+ def do_POST(self):
135
+ self._handle("POST")
136
+
137
+ def do_PUT(self):
138
+ self._handle("PUT")
139
+
140
+
141
+ class ClientTest(unittest.TestCase):
142
+ @classmethod
143
+ def setUpClass(cls):
144
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
145
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
146
+ cls.base = f"http://127.0.0.1:{cls.server.server_address[1]}"
147
+
148
+ @classmethod
149
+ def tearDownClass(cls):
150
+ cls.server.shutdown()
151
+
152
+ def setUp(self):
153
+ FAKE.reset()
154
+ # Polling and retry delays are real sleeps; make them instant.
155
+ self.sleep = mock.patch("tanvo._client.time.sleep", lambda s: None)
156
+ self.sleep.start()
157
+ os.environ.pop("TANVO_API_KEY", None)
158
+
159
+ def tearDown(self):
160
+ self.sleep.stop()
161
+
162
+ def client(self, key=None):
163
+ return Client(key, base_url=self.base)
164
+
165
+ def test_anonymous_free_image(self):
166
+ c = self.client()
167
+ self.assertEqual(c.me().tier, "anonymous")
168
+ g = c.generate_image("a cat")
169
+ self.assertTrue(g.succeeded)
170
+ self.assertEqual(g.cost, 5)
171
+ post = next(r for r in FAKE.requests if r[0] == "POST" and r[1] == "/api/v1/generations")
172
+ sent = json.loads(post[3])
173
+ self.assertEqual(sent["options"], {"aspect": "1:1", "resolution": "1K", "format": "PNG"}) # model defaults filled in
174
+ self.assertTrue(sent["requestKey"])
175
+ headers = post[2]
176
+ self.assertTrue(headers["X-Anon-Id"].startswith("py-"))
177
+ self.assertTrue(headers["User-Agent"].startswith("tanvo-python/"))
178
+
179
+ def test_free_tier_refuses_other_models_with_a_hint(self):
180
+ with self.assertRaises(QuotaError) as e:
181
+ self.client().generate_video("a fox")
182
+ self.assertEqual(e.exception.code, "needs_api_key")
183
+ self.assertIn("settings/apikeys", str(e.exception))
184
+
185
+ def test_key_options_override_defaults(self):
186
+ c = self.client("sk_good")
187
+ g = c.generate_image("a cat", model="seedream-4", aspect="16:9")
188
+ self.assertTrue(g.succeeded)
189
+ self.assertEqual(json.loads(FAKE.requests[-2][3])["options"], {"aspect": "16:9", "resolution": "2K"})
190
+ self.assertEqual(FAKE.requests[-1][2]["Authorization"], "Bearer sk_good")
191
+
192
+ def test_request_key_is_idempotent(self):
193
+ c = self.client("sk_good")
194
+ a = c.generate_image("a cat", request_key="job-1", wait=False)
195
+ b = c.generate_image("a cat", request_key="job-1", wait=False)
196
+ self.assertEqual(a.id, b.id)
197
+ self.assertEqual(len(FAKE.runs), 1)
198
+
199
+ def test_video_returns_at_once_then_wait(self):
200
+ c = self.client("sk_good")
201
+ FAKE.polls_until_done = 3
202
+ g = c.generate_video("a fox in snow")
203
+ self.assertEqual(g.status, "PROCESSING")
204
+ self.assertTrue(c.wait(g.id, timeout=60).succeeded)
205
+
206
+ def test_wait_timeout_returns_last_state(self):
207
+ c = self.client("sk_good")
208
+ FAKE.polls_until_done = 10_000
209
+ g = c.generate_video("a fox")
210
+ clock = iter(range(0, 10_000, 30))
211
+ with mock.patch("tanvo._client.time.monotonic", lambda: next(clock)):
212
+ last = c.wait(g.id, timeout=60)
213
+ self.assertEqual(last.status, "PROCESSING")
214
+
215
+ def test_errors_map_to_classes(self):
216
+ with self.assertRaises(AuthError) as e:
217
+ self.client("sk_bad").me()
218
+ self.assertEqual(e.exception.code, "invalid_api_key")
219
+ with self.assertRaises(InvalidRequest) as e:
220
+ self.client("sk_good").generate_image("x", resolution="8K")
221
+ self.assertEqual(e.exception.issues, ["options.resolution: 8K"])
222
+
223
+ def test_service_errors_retry_with_the_same_request_key(self):
224
+ c = self.client("sk_good")
225
+ c.models()
226
+ FAKE.fail_next = [(502, "provider", None), (500, "server_error", None)]
227
+ g = c.generate_image("a cat", wait=False)
228
+ posts = [json.loads(r[3])["requestKey"] for r in FAKE.requests if r[0] == "POST" and r[1] == "/api/v1/generations"]
229
+ self.assertEqual(len(posts), 3)
230
+ self.assertEqual(len(set(posts)), 1)
231
+ self.assertEqual(g.status, "PROCESSING")
232
+
233
+ def test_service_error_gives_up_after_two_retries(self):
234
+ c = self.client("sk_good")
235
+ c.models()
236
+ FAKE.fail_next = [(502, "provider", None)] * 3
237
+ with self.assertRaises(ServiceError):
238
+ c.generate_image("a cat", wait=False)
239
+
240
+ def test_rate_limit_waits_once(self):
241
+ c = self.client("sk_good")
242
+ c.models()
243
+ FAKE.fail_next = [(429, "rate_limited", {"Retry-After": "2"})]
244
+ self.assertEqual(c.me().tier, "paid")
245
+
246
+ def test_busy_waits_for_a_slot_when_asked(self):
247
+ c = self.client("sk_good")
248
+ c.models()
249
+ FAKE.fail_next = [(409, "busy", None)]
250
+ with self.assertRaises(QuotaError):
251
+ c.generate_image("a cat", wait=False)
252
+ FAKE.fail_next = [(409, "busy", None)]
253
+ self.assertEqual(c.generate_image("a cat", wait=False, wait_for_slot=True).status, "PROCESSING")
254
+
255
+ def test_local_images_are_uploaded(self):
256
+ c = self.client("sk_good")
257
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
258
+ f.write(PNG)
259
+ try:
260
+ g = c.generate_image("make it winter", model="seedream-4", images=[f.name, "https://example.com/a.jpg", PNG], wait=False)
261
+ finally:
262
+ os.unlink(f.name)
263
+ sent = json.loads(next(r[3] for r in FAKE.requests if r[1] == "/api/v1/generations"))
264
+ self.assertEqual(sent["imageUrls"][1], "https://example.com/a.jpg")
265
+ self.assertTrue(all(u.startswith("https://media.tanvo.ai/uploads/") for u in (sent["imageUrls"][0], sent["imageUrls"][2])))
266
+ self.assertEqual([m for m, _ in FAKE.uploaded.values()], ["image/png", "image/png"])
267
+ self.assertEqual(g.status, "PROCESSING")
268
+
269
+ def test_generate_from_app_uses_the_look(self):
270
+ c = self.client("sk_good")
271
+ with self.assertRaises(ValueError):
272
+ c.generate_from_app("ai-pet-portrait-generator", look="royal")
273
+ g = c.generate_from_app("ai-pet-portrait-generator", look="royal", photos=["https://example.com/rex.jpg"], details="Rex, a very good boy")
274
+ self.assertTrue(g.succeeded)
275
+ sent = json.loads(next(r[3] for r in FAKE.requests if r[1] == "/api/v1/generations"))
276
+ self.assertEqual(sent["model"], "seedream-4")
277
+ self.assertEqual(sent["options"], {"aspect": "3:4", "resolution": "2K"})
278
+ self.assertTrue(sent["prompt"].startswith("Paint the pet as a monarch.") and sent["prompt"].endswith("This one is for: Rex, a very good boy."))
279
+ with self.assertRaises(KeyError):
280
+ c.app("ai-pet-portrait-generator").look("nope")
281
+
282
+ def test_list_and_download(self):
283
+ c = self.client("sk_good")
284
+ for i in range(3):
285
+ c.generate_image(f"cat {i}", wait=False)
286
+ items, nxt = c.list(limit=2)
287
+ self.assertEqual(len(items), 2)
288
+ self.assertEqual(nxt, items[-1].created_at)
289
+ g = c.wait(items[0].id)
290
+ with tempfile.TemporaryDirectory() as d:
291
+ path = c.download(g, d)
292
+ with open(path, "rb") as f:
293
+ self.assertEqual(f.read(), PNG)
294
+
295
+ def test_network_error_is_a_service_error(self):
296
+ with self.assertRaises(ServiceError) as e:
297
+ Client("sk_good", base_url="http://127.0.0.1:9").me()
298
+ self.assertEqual(e.exception.code, "network")
299
+
300
+
301
+ class WebhookTest(unittest.TestCase):
302
+ secret = "whsec_" + base64.b64encode(b"k" * 24).decode()
303
+ body = json.dumps({"id": "gen1", "kind": "image", "model": "studio-image-v1", "prompt": "p", "options": {}, "cost": 5, "status": "SUCCEEDED", "error": None, "createdAt": 1, "outputs": [{"url": "https://x/0.png", "mime": "image/png"}]})
304
+
305
+ def sign(self, ts, body=None):
306
+ mac = hmac.new(b"k" * 24, f"msg_1.{ts}.{body or self.body}".encode(), hashlib.sha256).digest()
307
+ return {"webhook-id": "msg_1", "webhook-timestamp": str(ts), "webhook-signature": f"v1,{base64.b64encode(mac).decode()}"}
308
+
309
+ def test_valid(self):
310
+ g = verify_webhook(self.secret, self.sign(int(time.time())), self.body.encode())
311
+ self.assertEqual((g.id, g.url), ("gen1", "https://x/0.png"))
312
+ self.assertEqual(Client.verify_webhook(self.secret, {k.title(): v for k, v in self.sign(int(time.time())).items()}, self.body).id, "gen1")
313
+
314
+ def test_tampered_or_stale(self):
315
+ h = self.sign(int(time.time()))
316
+ with self.assertRaises(WebhookVerificationError):
317
+ verify_webhook(self.secret, h, self.body.replace("gen1", "gen2"))
318
+ with self.assertRaises(WebhookVerificationError):
319
+ verify_webhook(self.secret, self.sign(int(time.time()) - 600), self.body)
320
+ with self.assertRaises(WebhookVerificationError):
321
+ verify_webhook(self.secret, {}, self.body)
322
+
323
+ def test_version(self):
324
+ self.assertRegex(tanvo.__version__, r"^\d+\.\d+\.\d+$")
325
+
326
+
327
+ if __name__ == "__main__":
328
+ unittest.main()