martini-client 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 (38) hide show
  1. martini_client-0.1.0/.gitignore +8 -0
  2. martini_client-0.1.0/AGENTS.md +40 -0
  3. martini_client-0.1.0/LICENSE +21 -0
  4. martini_client-0.1.0/PKG-INFO +289 -0
  5. martini_client-0.1.0/README.md +265 -0
  6. martini_client-0.1.0/pyproject.toml +81 -0
  7. martini_client-0.1.0/scripts/gen_overloads.py +112 -0
  8. martini_client-0.1.0/src/martini_client/__init__.py +104 -0
  9. martini_client-0.1.0/src/martini_client/_client.py +191 -0
  10. martini_client-0.1.0/src/martini_client/_families.py +95 -0
  11. martini_client-0.1.0/src/martini_client/_http.py +280 -0
  12. martini_client-0.1.0/src/martini_client/_polling.py +101 -0
  13. martini_client-0.1.0/src/martini_client/_version.py +17 -0
  14. martini_client-0.1.0/src/martini_client/_wire.py +142 -0
  15. martini_client-0.1.0/src/martini_client/endpoints.py +104 -0
  16. martini_client-0.1.0/src/martini_client/errors.py +236 -0
  17. martini_client-0.1.0/src/martini_client/py.typed +0 -0
  18. martini_client-0.1.0/src/martini_client/resources/__init__.py +1 -0
  19. martini_client-0.1.0/src/martini_client/resources/assets.py +548 -0
  20. martini_client-0.1.0/src/martini_client/resources/generations.py +1825 -0
  21. martini_client-0.1.0/src/martini_client/resources/workflows.py +362 -0
  22. martini_client-0.1.0/src/martini_client/types/__init__.py +151 -0
  23. martini_client-0.1.0/src/martini_client/types/assets.py +200 -0
  24. martini_client-0.1.0/src/martini_client/types/common.py +45 -0
  25. martini_client-0.1.0/src/martini_client/types/generations.py +153 -0
  26. martini_client-0.1.0/src/martini_client/types/inputs.py +443 -0
  27. martini_client-0.1.0/src/martini_client/types/models.py +171 -0
  28. martini_client-0.1.0/src/martini_client/types/workflows.py +537 -0
  29. martini_client-0.1.0/tests/__init__.py +0 -0
  30. martini_client-0.1.0/tests/conftest.py +204 -0
  31. martini_client-0.1.0/tests/test_assets.py +319 -0
  32. martini_client-0.1.0/tests/test_client.py +151 -0
  33. martini_client-0.1.0/tests/test_errors_and_retries.py +142 -0
  34. martini_client-0.1.0/tests/test_generations.py +212 -0
  35. martini_client-0.1.0/tests/test_golden_v1.py +100 -0
  36. martini_client-0.1.0/tests/test_overloads_and_polling.py +144 -0
  37. martini_client-0.1.0/tests/test_workflows.py +168 -0
  38. martini_client-0.1.0/uv.lock +582 -0
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ .pytest_cache/
@@ -0,0 +1,40 @@
1
+ # `martini-client` (Python) — working notes and release checklist
2
+
3
+ This directory is the independently published PyPI package for the Martini API (generations, workflow runs,
4
+ assets). It is the Python twin of `packages/martini-client` (`@martini-film/client`); both speak the same `/v1`
5
+ contract and both are read as text by the backend's drift guards.
6
+
7
+ ## Working here
8
+
9
+ - Toolchain: `uv`. From this directory: `uv sync`, then `uv run pytest`, `uv run mypy`, `uv run ruff check .`,
10
+ `uv run ruff format .`. All four must be green before handing back (`/verify` runs them when this path changed).
11
+ - Python ≥ 3.10; no runtime dependency beyond `httpx`. Do not add one without a reason the README can state.
12
+ - `src/martini_client/endpoints.py` (`ENDPOINTS`) must equal the backend handler registry —
13
+ `backend/src/falCompat/externalAdapters.test.ts` asserts it. When a model is added, add its alias here **and**
14
+ place it in a family in `src/martini_client/_families.py`, then run `uv run python scripts/gen_overloads.py`
15
+ (the `@overload` blocks on `generations.submit()`/`subscribe()` are generated; `tests/test_overloads_and_polling.py`
16
+ fails when they are stale). Add the new input `TypedDict` in `src/martini_client/types/inputs.py`.
17
+ - Every `/v1/generations` route must be called from this package — `backend/src/apiV1/publicContract.test.ts`
18
+ checks it — and the docs pages cite the version in `pyproject.toml`.
19
+ - `tests/test_golden_v1.py` parses `backend/src/apiV1/fixtures/goldenResponses.v1.json`; when the backend
20
+ regenerates that fixture on purpose, this suite says what the Python side no longer understands.
21
+ - Sync and async share everything except the transport (`_http.py`) and the poll driver (`_polling.py`). Add a
22
+ method by writing its `RequestSpec` builder once and calling it from both classes.
23
+ - Docstrings are what agents and IDE hovers read: plain text, the route in backticks, the errors a caller should
24
+ expect. No Sphinx roles.
25
+
26
+ ## Releasing
27
+
28
+ `pyproject.toml` `project.version` is the single source of truth (`_version.py` reads installed metadata). PyPI
29
+ versions are immutable.
30
+
31
+ 1. Bump `version` in `pyproject.toml` according to semver.
32
+ 2. `uv run pytest && uv run mypy && uv run ruff check .`
33
+ 3. `uv build`, then inspect `dist/` (`python -m zipfile -l dist/*.whl`): `py.typed`, `LICENSE`, no tests.
34
+ 4. Optional dry run: `uv publish --publish-url https://test.pypi.org/legacy/`.
35
+ 5. Publish only after the user explicitly authorizes it and a PyPI token is available: `uv publish`.
36
+ 6. Update the version the docs pages cite (`ApiGenerationDocs.tsx`, `WorkflowsApiDocs.tsx`) in the same PR — the
37
+ drift guard fails otherwise — and do not merge docs that advertise a version that is not on PyPI yet.
38
+
39
+ Do not publish for changes outside this package, test-only changes, or documentation that does not ship in the
40
+ wheel (README does ship).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,289 @@
1
+ Metadata-Version: 2.5
2
+ Name: martini-client
3
+ Version: 0.1.0
4
+ Summary: The official Python client for the Martini API: generations, workflow runs, and asset uploads.
5
+ Project-URL: Homepage, https://www.martini.film
6
+ Project-URL: Documentation, https://www.martini.film/docs/api
7
+ Project-URL: Source, https://github.com/c47-inc/martini/tree/main/packages/martini-python
8
+ Author-email: Martini <support@martini.film>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,generation,martini,sdk,video
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx<1,>=0.27
23
+ Description-Content-Type: text/markdown
24
+
25
+ # `martini-client`
26
+
27
+ The official Python client for the Martini API: generate images and video, run saved workflows, and upload
28
+ reusable media. Sync and async, fully typed, one dependency (`httpx`).
29
+
30
+ ```bash
31
+ pip install martini-client
32
+ ```
33
+
34
+ Requires Python 3.10 or later.
35
+
36
+ ```python
37
+ from martini_client import Martini
38
+
39
+ martini = Martini() # reads MARTINI_API_KEY
40
+
41
+ generation = martini.generations.subscribe(
42
+ "bytedance/seedance-2.5/text-to-video",
43
+ input={
44
+ "prompt": "A practical miniature moon base at blue hour.",
45
+ "duration": "auto",
46
+ "resolution": "720p",
47
+ },
48
+ idempotency_key="shot-42",
49
+ )
50
+
51
+ print(generation.result()["video"]["url"], generation.olive_cost) # raises GenerationFailedError if it failed
52
+ ```
53
+
54
+ `subscribe()` returns whatever the generation settled as — inspect `generation.succeeded`, `.output`, and `.error`
55
+ when you would rather branch than raise; `result()` is the typed accessor (the `concurrent.futures` idiom).
56
+
57
+ ## For coding agents
58
+
59
+ The whole contract in one place. If you are an agent integrating Martini, this section is enough.
60
+
61
+ - **Credentials.** `MARTINI_API_KEY` (`mtn_live_…`) in the environment; the key is a server-side secret. Confirm it
62
+ with `martini.me()`. Point at a staging or local API with `MARTINI_API_URL`.
63
+ - **What you can call.** `martini.models()` lists the endpoint aliases this key can invoke, with pricing; pass
64
+ `.endpoint` values to `generations.submit()`. Hard-coding an alias from `martini_client.ENDPOINTS` also works.
65
+ - **One call to generate.** `martini.generations.subscribe(endpoint, input={...})` submits and polls until the
66
+ generation settles. It **returns** a `Generation` whose `status` is `completed`, `failed`, or `cancelled` — it
67
+ does not raise on failure. Call `generation.result()` for the output (typed; raises `GenerationFailedError`
68
+ with the `error` attached if it failed), or inspect `succeeded` / `output` / `error` to branch instead.
69
+ - **Model input** is a plain dict in the model's own field names (`prompt`, `image_url`, `duration`, ...). The
70
+ server validates strictly: an unknown field is `UNSUPPORTED_INPUT`, a bad value `INVALID_INPUT`. The
71
+ `TypedDict`s in `martini_client.types` spell out every field per endpoint; annotate the dict with one to have
72
+ a type checker catch mistakes before the request.
73
+ - **Always pass `idempotency_key`** for anything you might retry; re-submitting with the same key returns the same
74
+ generation instead of charging twice, and it makes the request safe for the client to retry on transport errors.
75
+ - **Errors** are `MartiniAPIError` subclasses keyed by HTTP status (`RateLimitError`, `InsufficientOlivesError`,
76
+ `NotFoundError`, ...), each carrying the stable `code`. Retry only `RateLimitError` (after `retry_after`
77
+ seconds) and `ServerError`; never retry validation, authentication, or balance errors.
78
+ - **Workflows.** `martini.workflows.subscribe(workflow_id, bins={...}, variables={...})` runs a saved workflow and
79
+ returns `WorkflowSettled(run, results)`; `results.ready` are downloadable outputs with presigned `url`s that
80
+ expire after `expires_in` seconds — fetch them promptly.
81
+ - **Media in.** `martini.assets.upload("file.mp4")` returns an `Asset`; put `asset.url` in `image_url`,
82
+ `video_urls`, and so on. Or pass public HTTPS URLs / `data:` URLs directly in those fields.
83
+
84
+ ## Generations
85
+
86
+ `martini.generations` is the Martini-native generation API (`/v1/generations`). One resource describes a
87
+ generation from draft to terminal state; `output` is on the same object once `status == "completed"`, so you
88
+ poll one thing.
89
+
90
+ ```python
91
+ submitted = martini.generations.submit(
92
+ "nano-banana-2",
93
+ input={"prompt": "A graphite storyboard frame of a desert observatory.", "resolution": "1K"},
94
+ project_id=project_id, # optional: override the key's default project
95
+ idempotency_key="frame-042", # optional but recommended
96
+ )
97
+ current = martini.generations.status(submitted.id) # output is here once completed
98
+ settled = martini.generations.wait(submitted.id) # poll until completed | failed | cancelled
99
+ cancelled = martini.generations.cancel(submitted.id) # 409 REQUEST_ALREADY_TERMINAL once settled
100
+ ```
101
+
102
+ `subscribe()` is `submit()` + `wait()`. Both `wait()` and `subscribe()` accept `poll_interval` (seconds, default 2,
103
+ backing off to 15), `timeout` (default 30 minutes; raises `MartiniTimeoutError`, the generation keeps running on
104
+ Martini), and `on_update` (called with every observation).
105
+
106
+ ### Drafts
107
+
108
+ `mode="draft"` creates an editable canvas node without charging or contacting a provider. Start it later; the
109
+ run uses the draft's latest settings from the Martini canvas.
110
+
111
+ ```python
112
+ draft = martini.generations.submit("nano-banana-2", input={"prompt": "…"}, mode="draft")
113
+ print(draft.status, draft.links.generate) # "draft", "/v1/generations/<id>/generate"
114
+ started = martini.generations.generate(draft.id) # -> pending
115
+ ```
116
+
117
+ A draft never settles on its own; `wait()` on one raises `DraftNotStartedError` pointing you at `generate()`.
118
+
119
+ ### The `Generation` object
120
+
121
+ | Attribute | Meaning |
122
+ | --- | --- |
123
+ | `id` | Public request id; also the canvas asset the generation lands on. |
124
+ | `endpoint` | The alias it was submitted against. |
125
+ | `status` | `draft`, `pending`, `running`, `completed`, `failed`, or `cancelled`. `is_terminal`, `succeeded` are convenience properties. |
126
+ | `output` | The model's output in its fal field names once completed (`output["video"]["url"]`, `output["images"][0]["url"]`); otherwise `None`. URLs are durable Martini storage URLs. `result()` returns it typed, or raises. |
127
+ | `error` | `ErrorDetail(code, message)` when failed or cancelled; otherwise `None`. |
128
+ | `olive_cost` | Snapshotted charge in olives; `None` only while a concurrent idempotent submit is still being priced. |
129
+ | `warnings` | Non-blocking input advisories (unbound reference images). The generation was still accepted. |
130
+ | `martini` | `GenerationPlacement(preview_url, project_id, canvas_id, asset_id)` — open `preview_url` to see it in Martini. |
131
+ | `created_at`, `started_at`, `completed_at` | ISO timestamps. |
132
+
133
+ ### Typed inputs
134
+
135
+ With a literal endpoint, `submit()`/`subscribe()` narrow `input` to that endpoint's `TypedDict` (your editor
136
+ completes the fields) and type the returned `output`. To have `mypy`/`pyright` reject a misspelled field or an
137
+ out-of-range value as well, annotate the input with its `TypedDict` — an unannotated dict literal is accepted
138
+ as a plain mapping, and the server rejects it at submit time instead (`UNSUPPORTED_INPUT`, `INVALID_INPUT`):
139
+
140
+ ```python
141
+ from martini_client.types import Seedance25ReferenceToVideoInput
142
+
143
+ input: Seedance25ReferenceToVideoInput = {
144
+ "prompt": "@Image1 is the lead. Inside @Image2, @Image1 turns to camera. Follow the move from @Video1.",
145
+ "image_urls": [character_url, location_url],
146
+ "video_urls": [camera_guide_url],
147
+ "duration": "8",
148
+ "resolution": "720p",
149
+ }
150
+ generation = martini.generations.subscribe("bytedance/seedance-2.5/reference-to-video", input=input)
151
+ ```
152
+
153
+ Reference arrays are positional: `image_urls[0]` is `@Image1`, `video_urls[0]` is `@Video1`, `audio_urls[0]` is
154
+ `@Audio1`. Bare names do not bind media. Multiple video references and every audio reference must be bound;
155
+ an unbound image is accepted with an advisory in `warnings`.
156
+
157
+ ### Discovering models
158
+
159
+ ```python
160
+ catalog = martini.models()
161
+ for model in catalog.with_capability("generate-video"):
162
+ print(model.endpoint, model.billable_unit, model.model.pricing.display, model.pricing)
163
+ ```
164
+
165
+ Only aliases your key can invoke are listed, with your organization's negotiated multipliers when any.
166
+
167
+ ## Workflows and runs
168
+
169
+ A **saved workflow** is your workspace's reusable, versioned machinery; a **placed workflow** is a copy on a
170
+ canvas. A **run** is one execution.
171
+
172
+ ```python
173
+ workflows = martini.workflows.list() # saved; .list(all=True) adds placed ones
174
+ workflow = workflows.find("Script to Video") # by name (case-insensitive) or id
175
+ print(workflow.inputs.bins, workflow.inputs.variables)
176
+
177
+ script = martini.assets.upload("script.pdf.png") # inputs are assets; upload first
178
+
179
+ settled = martini.workflows.subscribe(
180
+ workflow.id,
181
+ bins={"Script": [script.asset_id]}, # a saved workflow starts from empty bins: pin every input bin
182
+ variables={"Tone": "deadpan"},
183
+ fingerprint=workflow.fingerprint, # refuse to run if the machinery changed since you read it
184
+ idempotency_key="job-7",
185
+ )
186
+ for output in settled.results.ready:
187
+ print(output.filename, output.url) # presigned; valid for output.expires_in seconds
188
+ ```
189
+
190
+ `workflows.run()` starts without waiting; `runs.status(run_id)`, `runs.results(run_id)` (409
191
+ `WORKFLOW_RUN_NOT_SETTLED` until the run settles), and `runs.wait(run_id)` poll an existing run. `subscribe()` and
192
+ `wait()` return a `failed` or `cancelled` run with its `error` rather than raising; `.raise_for_status()` turns
193
+ it into a `WorkflowRunFailedError`. `olive_budget` caps generation spend; omit it to let Martini arm the rail from
194
+ the estimate. Runs started here auto-approve.
195
+
196
+ ## Assets
197
+
198
+ ```python
199
+ asset = martini.assets.upload("reference.mp4") # path, bytes, or a binary file object
200
+ asset = martini.assets.upload(data, filename="ref.png", content_type="image/png")
201
+ asset = martini.assets.upload("ref.png", project_id=..., canvas_id=...) # another project you can edit
202
+ asset = martini.assets.upload("ref.png", wait=False) # return after complete; status queued/processing
203
+ ```
204
+
205
+ `upload()` is prepare → PUT directly to storage → complete → poll until processed. Limits: 10 MB JPEG/PNG/WebP,
206
+ 30 MB MP4, 15 MB MP3/WAV. When direct storage is unavailable it raises `StorageUnavailableError` — it does not
207
+ switch transports on its own; `martini.assets.upload_base64(...)` (same arguments) is the explicit alternative.
208
+ The lower-level steps are exposed too: `prepare_upload(files)`, `complete_upload(asset_ids)`, `get(asset_id)`,
209
+ `wait(asset_id)`.
210
+
211
+ ## Errors
212
+
213
+ Every non-2xx answer raises a subclass of `MartiniAPIError` chosen by HTTP status; branch on the class, then on
214
+ the stable `code` when you need the exact cause.
215
+
216
+ | Class | Status | Typical codes |
217
+ | --- | --- | --- |
218
+ | `InvalidRequestError` | 400, 422 | `INVALID_REQUEST`, `INVALID_INPUT`, `UNSUPPORTED_INPUT`, `INVALID_MEDIA`, `MEDIA_TOO_LARGE`, `WORKFLOW_BIN_REQUIRED`, `WORKFLOW_VARIABLE_NOT_FOUND` |
219
+ | `AuthenticationError` | 401 | `INVALID_API_KEY`, `API_KEY_REVOKED` |
220
+ | `InsufficientOlivesError` | 402 | `INSUFFICIENT_OLIVES`, `SPEND_CAP_EXCEEDED` |
221
+ | `AccessDeniedError` | 403 | `PROJECT_ACCESS_DENIED`, `GENERATION_API_DISABLED`, `WORKFLOWS_NOT_ENABLED` |
222
+ | `NotFoundError` | 404 | `ENDPOINT_NOT_REGISTERED`, `REQUEST_NOT_FOUND`, `CANVAS_NOT_FOUND`, `WORKFLOW_NOT_FOUND`, `WORKFLOW_RUN_NOT_FOUND` |
223
+ | `ConflictError` | 409 | `REQUEST_ALREADY_TERMINAL`, `DRAFT_ALREADY_GENERATING`, `WORKFLOW_RUN_NOT_SETTLED`, `WORKFLOW_CHANGED`, `PROJECT_DOCUMENT_TOO_LARGE` |
224
+ | `RateLimitError` | 429 | `RATE_LIMITED` — `retry_after` carries the server's hint in seconds |
225
+ | `ServerError` | 5xx | `GENERATION_SUBMISSION_FAILED`, `GENERATION_API_ACCESS_UNAVAILABLE` |
226
+
227
+ ```python
228
+ from martini_client import ConflictError, RateLimitError
229
+
230
+ try:
231
+ results = martini.runs.results(run_id)
232
+ except ConflictError as error:
233
+ if error.code == "WORKFLOW_RUN_NOT_SETTLED":
234
+ ... # keep polling
235
+ except RateLimitError as error:
236
+ time.sleep(error.retry_after or 10)
237
+ ```
238
+
239
+ `error.body` holds the full JSON for fields beyond `error`/`code` (the `inputs` a workflow accepts, the current
240
+ `fingerprint`). Outside the API: `MartiniTransportError` (no response after retries), `MartiniTimeoutError`
241
+ (a `wait`/`subscribe` outlasted `timeout`), `AssetUploadError`/`StorageUnavailableError` (uploads),
242
+ `GenerationFailedError`/`WorkflowRunFailedError` (from `raise_for_status()`), `ConfigurationError` (bad
243
+ constructor arguments). All derive from `MartiniError`.
244
+
245
+ `PROJECT_DOCUMENT_TOO_LARGE` (409) is durable, not transient: the project cannot generate until support rebuilds
246
+ its document. Surface the message and stop.
247
+
248
+ ## Configuration
249
+
250
+ ```python
251
+ martini = Martini(
252
+ api_key="mtn_live_…", # default: MARTINI_API_KEY
253
+ base_url="https://api.martini.film", # default: MARTINI_API_URL, then production; HTTPS except localhost
254
+ api_version="2026-08-13", # X-Martini-API-Version; default is the version this release targets
255
+ timeout=60.0, # seconds per request
256
+ max_retries=2, # for GETs and idempotent submits, on transport errors / 429 / 5xx
257
+ http_client=httpx.Client(...), # optional: proxies, custom transports; you own its lifetime
258
+ )
259
+ ```
260
+
261
+ Retries back off exponentially with jitter (0.5 s, 1 s, 2 s, … capped at 8 s) and honour `Retry-After`. A
262
+ `POST` without an `idempotency_key` is never retried automatically — the server could not tell a retry from a
263
+ second request. `with Martini() as martini:` closes the connection pool on exit.
264
+
265
+ ### Async
266
+
267
+ ```python
268
+ from martini_client import AsyncMartini
269
+
270
+ async with AsyncMartini() as martini:
271
+ generation = await martini.generations.subscribe("nano-banana-2", input={"prompt": "…"})
272
+ ```
273
+
274
+ Same methods, same arguments, `await`ed.
275
+
276
+ ## Supported methods
277
+
278
+ - `me()`, `models()`
279
+ - `generations.submit()`, `.status()`, `.cancel()`, `.generate()`, `.wait()`, `.subscribe()`
280
+ - `workflows.list()`, `.get()`, `.create()`, `.run()`, `.subscribe()`
281
+ - `runs.status()`, `.results()`, `.wait()`
282
+ - `assets.upload()`, `.upload_base64()`, `.prepare_upload()`, `.complete_upload()`, `.get()`, `.wait()`
283
+
284
+ Wire reference: [Generation API](https://www.martini.film/docs/api) and
285
+ [Workflows API](https://www.martini.film/docs/workflows). The JavaScript client is
286
+ [`@martini-film/client`](https://www.npmjs.com/package/@martini-film/client).
287
+
288
+ The API key is a server-side secret. Do not embed it in notebooks you share, client-side code, or public
289
+ environment variables.
@@ -0,0 +1,265 @@
1
+ # `martini-client`
2
+
3
+ The official Python client for the Martini API: generate images and video, run saved workflows, and upload
4
+ reusable media. Sync and async, fully typed, one dependency (`httpx`).
5
+
6
+ ```bash
7
+ pip install martini-client
8
+ ```
9
+
10
+ Requires Python 3.10 or later.
11
+
12
+ ```python
13
+ from martini_client import Martini
14
+
15
+ martini = Martini() # reads MARTINI_API_KEY
16
+
17
+ generation = martini.generations.subscribe(
18
+ "bytedance/seedance-2.5/text-to-video",
19
+ input={
20
+ "prompt": "A practical miniature moon base at blue hour.",
21
+ "duration": "auto",
22
+ "resolution": "720p",
23
+ },
24
+ idempotency_key="shot-42",
25
+ )
26
+
27
+ print(generation.result()["video"]["url"], generation.olive_cost) # raises GenerationFailedError if it failed
28
+ ```
29
+
30
+ `subscribe()` returns whatever the generation settled as — inspect `generation.succeeded`, `.output`, and `.error`
31
+ when you would rather branch than raise; `result()` is the typed accessor (the `concurrent.futures` idiom).
32
+
33
+ ## For coding agents
34
+
35
+ The whole contract in one place. If you are an agent integrating Martini, this section is enough.
36
+
37
+ - **Credentials.** `MARTINI_API_KEY` (`mtn_live_…`) in the environment; the key is a server-side secret. Confirm it
38
+ with `martini.me()`. Point at a staging or local API with `MARTINI_API_URL`.
39
+ - **What you can call.** `martini.models()` lists the endpoint aliases this key can invoke, with pricing; pass
40
+ `.endpoint` values to `generations.submit()`. Hard-coding an alias from `martini_client.ENDPOINTS` also works.
41
+ - **One call to generate.** `martini.generations.subscribe(endpoint, input={...})` submits and polls until the
42
+ generation settles. It **returns** a `Generation` whose `status` is `completed`, `failed`, or `cancelled` — it
43
+ does not raise on failure. Call `generation.result()` for the output (typed; raises `GenerationFailedError`
44
+ with the `error` attached if it failed), or inspect `succeeded` / `output` / `error` to branch instead.
45
+ - **Model input** is a plain dict in the model's own field names (`prompt`, `image_url`, `duration`, ...). The
46
+ server validates strictly: an unknown field is `UNSUPPORTED_INPUT`, a bad value `INVALID_INPUT`. The
47
+ `TypedDict`s in `martini_client.types` spell out every field per endpoint; annotate the dict with one to have
48
+ a type checker catch mistakes before the request.
49
+ - **Always pass `idempotency_key`** for anything you might retry; re-submitting with the same key returns the same
50
+ generation instead of charging twice, and it makes the request safe for the client to retry on transport errors.
51
+ - **Errors** are `MartiniAPIError` subclasses keyed by HTTP status (`RateLimitError`, `InsufficientOlivesError`,
52
+ `NotFoundError`, ...), each carrying the stable `code`. Retry only `RateLimitError` (after `retry_after`
53
+ seconds) and `ServerError`; never retry validation, authentication, or balance errors.
54
+ - **Workflows.** `martini.workflows.subscribe(workflow_id, bins={...}, variables={...})` runs a saved workflow and
55
+ returns `WorkflowSettled(run, results)`; `results.ready` are downloadable outputs with presigned `url`s that
56
+ expire after `expires_in` seconds — fetch them promptly.
57
+ - **Media in.** `martini.assets.upload("file.mp4")` returns an `Asset`; put `asset.url` in `image_url`,
58
+ `video_urls`, and so on. Or pass public HTTPS URLs / `data:` URLs directly in those fields.
59
+
60
+ ## Generations
61
+
62
+ `martini.generations` is the Martini-native generation API (`/v1/generations`). One resource describes a
63
+ generation from draft to terminal state; `output` is on the same object once `status == "completed"`, so you
64
+ poll one thing.
65
+
66
+ ```python
67
+ submitted = martini.generations.submit(
68
+ "nano-banana-2",
69
+ input={"prompt": "A graphite storyboard frame of a desert observatory.", "resolution": "1K"},
70
+ project_id=project_id, # optional: override the key's default project
71
+ idempotency_key="frame-042", # optional but recommended
72
+ )
73
+ current = martini.generations.status(submitted.id) # output is here once completed
74
+ settled = martini.generations.wait(submitted.id) # poll until completed | failed | cancelled
75
+ cancelled = martini.generations.cancel(submitted.id) # 409 REQUEST_ALREADY_TERMINAL once settled
76
+ ```
77
+
78
+ `subscribe()` is `submit()` + `wait()`. Both `wait()` and `subscribe()` accept `poll_interval` (seconds, default 2,
79
+ backing off to 15), `timeout` (default 30 minutes; raises `MartiniTimeoutError`, the generation keeps running on
80
+ Martini), and `on_update` (called with every observation).
81
+
82
+ ### Drafts
83
+
84
+ `mode="draft"` creates an editable canvas node without charging or contacting a provider. Start it later; the
85
+ run uses the draft's latest settings from the Martini canvas.
86
+
87
+ ```python
88
+ draft = martini.generations.submit("nano-banana-2", input={"prompt": "…"}, mode="draft")
89
+ print(draft.status, draft.links.generate) # "draft", "/v1/generations/<id>/generate"
90
+ started = martini.generations.generate(draft.id) # -> pending
91
+ ```
92
+
93
+ A draft never settles on its own; `wait()` on one raises `DraftNotStartedError` pointing you at `generate()`.
94
+
95
+ ### The `Generation` object
96
+
97
+ | Attribute | Meaning |
98
+ | --- | --- |
99
+ | `id` | Public request id; also the canvas asset the generation lands on. |
100
+ | `endpoint` | The alias it was submitted against. |
101
+ | `status` | `draft`, `pending`, `running`, `completed`, `failed`, or `cancelled`. `is_terminal`, `succeeded` are convenience properties. |
102
+ | `output` | The model's output in its fal field names once completed (`output["video"]["url"]`, `output["images"][0]["url"]`); otherwise `None`. URLs are durable Martini storage URLs. `result()` returns it typed, or raises. |
103
+ | `error` | `ErrorDetail(code, message)` when failed or cancelled; otherwise `None`. |
104
+ | `olive_cost` | Snapshotted charge in olives; `None` only while a concurrent idempotent submit is still being priced. |
105
+ | `warnings` | Non-blocking input advisories (unbound reference images). The generation was still accepted. |
106
+ | `martini` | `GenerationPlacement(preview_url, project_id, canvas_id, asset_id)` — open `preview_url` to see it in Martini. |
107
+ | `created_at`, `started_at`, `completed_at` | ISO timestamps. |
108
+
109
+ ### Typed inputs
110
+
111
+ With a literal endpoint, `submit()`/`subscribe()` narrow `input` to that endpoint's `TypedDict` (your editor
112
+ completes the fields) and type the returned `output`. To have `mypy`/`pyright` reject a misspelled field or an
113
+ out-of-range value as well, annotate the input with its `TypedDict` — an unannotated dict literal is accepted
114
+ as a plain mapping, and the server rejects it at submit time instead (`UNSUPPORTED_INPUT`, `INVALID_INPUT`):
115
+
116
+ ```python
117
+ from martini_client.types import Seedance25ReferenceToVideoInput
118
+
119
+ input: Seedance25ReferenceToVideoInput = {
120
+ "prompt": "@Image1 is the lead. Inside @Image2, @Image1 turns to camera. Follow the move from @Video1.",
121
+ "image_urls": [character_url, location_url],
122
+ "video_urls": [camera_guide_url],
123
+ "duration": "8",
124
+ "resolution": "720p",
125
+ }
126
+ generation = martini.generations.subscribe("bytedance/seedance-2.5/reference-to-video", input=input)
127
+ ```
128
+
129
+ Reference arrays are positional: `image_urls[0]` is `@Image1`, `video_urls[0]` is `@Video1`, `audio_urls[0]` is
130
+ `@Audio1`. Bare names do not bind media. Multiple video references and every audio reference must be bound;
131
+ an unbound image is accepted with an advisory in `warnings`.
132
+
133
+ ### Discovering models
134
+
135
+ ```python
136
+ catalog = martini.models()
137
+ for model in catalog.with_capability("generate-video"):
138
+ print(model.endpoint, model.billable_unit, model.model.pricing.display, model.pricing)
139
+ ```
140
+
141
+ Only aliases your key can invoke are listed, with your organization's negotiated multipliers when any.
142
+
143
+ ## Workflows and runs
144
+
145
+ A **saved workflow** is your workspace's reusable, versioned machinery; a **placed workflow** is a copy on a
146
+ canvas. A **run** is one execution.
147
+
148
+ ```python
149
+ workflows = martini.workflows.list() # saved; .list(all=True) adds placed ones
150
+ workflow = workflows.find("Script to Video") # by name (case-insensitive) or id
151
+ print(workflow.inputs.bins, workflow.inputs.variables)
152
+
153
+ script = martini.assets.upload("script.pdf.png") # inputs are assets; upload first
154
+
155
+ settled = martini.workflows.subscribe(
156
+ workflow.id,
157
+ bins={"Script": [script.asset_id]}, # a saved workflow starts from empty bins: pin every input bin
158
+ variables={"Tone": "deadpan"},
159
+ fingerprint=workflow.fingerprint, # refuse to run if the machinery changed since you read it
160
+ idempotency_key="job-7",
161
+ )
162
+ for output in settled.results.ready:
163
+ print(output.filename, output.url) # presigned; valid for output.expires_in seconds
164
+ ```
165
+
166
+ `workflows.run()` starts without waiting; `runs.status(run_id)`, `runs.results(run_id)` (409
167
+ `WORKFLOW_RUN_NOT_SETTLED` until the run settles), and `runs.wait(run_id)` poll an existing run. `subscribe()` and
168
+ `wait()` return a `failed` or `cancelled` run with its `error` rather than raising; `.raise_for_status()` turns
169
+ it into a `WorkflowRunFailedError`. `olive_budget` caps generation spend; omit it to let Martini arm the rail from
170
+ the estimate. Runs started here auto-approve.
171
+
172
+ ## Assets
173
+
174
+ ```python
175
+ asset = martini.assets.upload("reference.mp4") # path, bytes, or a binary file object
176
+ asset = martini.assets.upload(data, filename="ref.png", content_type="image/png")
177
+ asset = martini.assets.upload("ref.png", project_id=..., canvas_id=...) # another project you can edit
178
+ asset = martini.assets.upload("ref.png", wait=False) # return after complete; status queued/processing
179
+ ```
180
+
181
+ `upload()` is prepare → PUT directly to storage → complete → poll until processed. Limits: 10 MB JPEG/PNG/WebP,
182
+ 30 MB MP4, 15 MB MP3/WAV. When direct storage is unavailable it raises `StorageUnavailableError` — it does not
183
+ switch transports on its own; `martini.assets.upload_base64(...)` (same arguments) is the explicit alternative.
184
+ The lower-level steps are exposed too: `prepare_upload(files)`, `complete_upload(asset_ids)`, `get(asset_id)`,
185
+ `wait(asset_id)`.
186
+
187
+ ## Errors
188
+
189
+ Every non-2xx answer raises a subclass of `MartiniAPIError` chosen by HTTP status; branch on the class, then on
190
+ the stable `code` when you need the exact cause.
191
+
192
+ | Class | Status | Typical codes |
193
+ | --- | --- | --- |
194
+ | `InvalidRequestError` | 400, 422 | `INVALID_REQUEST`, `INVALID_INPUT`, `UNSUPPORTED_INPUT`, `INVALID_MEDIA`, `MEDIA_TOO_LARGE`, `WORKFLOW_BIN_REQUIRED`, `WORKFLOW_VARIABLE_NOT_FOUND` |
195
+ | `AuthenticationError` | 401 | `INVALID_API_KEY`, `API_KEY_REVOKED` |
196
+ | `InsufficientOlivesError` | 402 | `INSUFFICIENT_OLIVES`, `SPEND_CAP_EXCEEDED` |
197
+ | `AccessDeniedError` | 403 | `PROJECT_ACCESS_DENIED`, `GENERATION_API_DISABLED`, `WORKFLOWS_NOT_ENABLED` |
198
+ | `NotFoundError` | 404 | `ENDPOINT_NOT_REGISTERED`, `REQUEST_NOT_FOUND`, `CANVAS_NOT_FOUND`, `WORKFLOW_NOT_FOUND`, `WORKFLOW_RUN_NOT_FOUND` |
199
+ | `ConflictError` | 409 | `REQUEST_ALREADY_TERMINAL`, `DRAFT_ALREADY_GENERATING`, `WORKFLOW_RUN_NOT_SETTLED`, `WORKFLOW_CHANGED`, `PROJECT_DOCUMENT_TOO_LARGE` |
200
+ | `RateLimitError` | 429 | `RATE_LIMITED` — `retry_after` carries the server's hint in seconds |
201
+ | `ServerError` | 5xx | `GENERATION_SUBMISSION_FAILED`, `GENERATION_API_ACCESS_UNAVAILABLE` |
202
+
203
+ ```python
204
+ from martini_client import ConflictError, RateLimitError
205
+
206
+ try:
207
+ results = martini.runs.results(run_id)
208
+ except ConflictError as error:
209
+ if error.code == "WORKFLOW_RUN_NOT_SETTLED":
210
+ ... # keep polling
211
+ except RateLimitError as error:
212
+ time.sleep(error.retry_after or 10)
213
+ ```
214
+
215
+ `error.body` holds the full JSON for fields beyond `error`/`code` (the `inputs` a workflow accepts, the current
216
+ `fingerprint`). Outside the API: `MartiniTransportError` (no response after retries), `MartiniTimeoutError`
217
+ (a `wait`/`subscribe` outlasted `timeout`), `AssetUploadError`/`StorageUnavailableError` (uploads),
218
+ `GenerationFailedError`/`WorkflowRunFailedError` (from `raise_for_status()`), `ConfigurationError` (bad
219
+ constructor arguments). All derive from `MartiniError`.
220
+
221
+ `PROJECT_DOCUMENT_TOO_LARGE` (409) is durable, not transient: the project cannot generate until support rebuilds
222
+ its document. Surface the message and stop.
223
+
224
+ ## Configuration
225
+
226
+ ```python
227
+ martini = Martini(
228
+ api_key="mtn_live_…", # default: MARTINI_API_KEY
229
+ base_url="https://api.martini.film", # default: MARTINI_API_URL, then production; HTTPS except localhost
230
+ api_version="2026-08-13", # X-Martini-API-Version; default is the version this release targets
231
+ timeout=60.0, # seconds per request
232
+ max_retries=2, # for GETs and idempotent submits, on transport errors / 429 / 5xx
233
+ http_client=httpx.Client(...), # optional: proxies, custom transports; you own its lifetime
234
+ )
235
+ ```
236
+
237
+ Retries back off exponentially with jitter (0.5 s, 1 s, 2 s, … capped at 8 s) and honour `Retry-After`. A
238
+ `POST` without an `idempotency_key` is never retried automatically — the server could not tell a retry from a
239
+ second request. `with Martini() as martini:` closes the connection pool on exit.
240
+
241
+ ### Async
242
+
243
+ ```python
244
+ from martini_client import AsyncMartini
245
+
246
+ async with AsyncMartini() as martini:
247
+ generation = await martini.generations.subscribe("nano-banana-2", input={"prompt": "…"})
248
+ ```
249
+
250
+ Same methods, same arguments, `await`ed.
251
+
252
+ ## Supported methods
253
+
254
+ - `me()`, `models()`
255
+ - `generations.submit()`, `.status()`, `.cancel()`, `.generate()`, `.wait()`, `.subscribe()`
256
+ - `workflows.list()`, `.get()`, `.create()`, `.run()`, `.subscribe()`
257
+ - `runs.status()`, `.results()`, `.wait()`
258
+ - `assets.upload()`, `.upload_base64()`, `.prepare_upload()`, `.complete_upload()`, `.get()`, `.wait()`
259
+
260
+ Wire reference: [Generation API](https://www.martini.film/docs/api) and
261
+ [Workflows API](https://www.martini.film/docs/workflows). The JavaScript client is
262
+ [`@martini-film/client`](https://www.npmjs.com/package/@martini-film/client).
263
+
264
+ The API key is a server-side secret. Do not embed it in notebooks you share, client-side code, or public
265
+ environment variables.