martini-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- martini_client/__init__.py +104 -0
- martini_client/_client.py +191 -0
- martini_client/_families.py +95 -0
- martini_client/_http.py +280 -0
- martini_client/_polling.py +101 -0
- martini_client/_version.py +17 -0
- martini_client/_wire.py +142 -0
- martini_client/endpoints.py +104 -0
- martini_client/errors.py +236 -0
- martini_client/py.typed +0 -0
- martini_client/resources/__init__.py +1 -0
- martini_client/resources/assets.py +548 -0
- martini_client/resources/generations.py +1825 -0
- martini_client/resources/workflows.py +362 -0
- martini_client/types/__init__.py +151 -0
- martini_client/types/assets.py +200 -0
- martini_client/types/common.py +45 -0
- martini_client/types/generations.py +153 -0
- martini_client/types/inputs.py +443 -0
- martini_client/types/models.py +171 -0
- martini_client/types/workflows.py +537 -0
- martini_client-0.1.0.dist-info/METADATA +289 -0
- martini_client-0.1.0.dist-info/RECORD +25 -0
- martini_client-0.1.0.dist-info/WHEEL +4 -0
- martini_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The official Python client for the Martini API.
|
|
2
|
+
|
|
3
|
+
Generate images and video, run saved workflows, and upload reusable media — from a script, a backend, or a
|
|
4
|
+
coding agent — with one API key::
|
|
5
|
+
|
|
6
|
+
from martini_client import Martini
|
|
7
|
+
|
|
8
|
+
martini = Martini() # reads MARTINI_API_KEY
|
|
9
|
+
generation = martini.generations.subscribe(
|
|
10
|
+
"bytedance/seedance-2.5/text-to-video",
|
|
11
|
+
input={"prompt": "A moon base at blue hour.", "duration": "auto", "resolution": "720p"},
|
|
12
|
+
)
|
|
13
|
+
print(generation.result()["video"]["url"]) # raises GenerationFailedError if it did not complete
|
|
14
|
+
|
|
15
|
+
Start with ``martini.me()`` to confirm the key, ``martini.models()`` to see what it can call. Everything the
|
|
16
|
+
API answers with a non-2xx status raises a ``MartiniAPIError`` subclass carrying the stable ``code``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from . import types
|
|
20
|
+
from ._client import AsyncMartini, Martini
|
|
21
|
+
from ._http import ConfigurationError
|
|
22
|
+
from ._version import API_VERSION, __version__
|
|
23
|
+
from ._wire import WireShapeError
|
|
24
|
+
from .endpoints import ENDPOINTS, EndpointId
|
|
25
|
+
from .errors import (
|
|
26
|
+
AccessDeniedError,
|
|
27
|
+
AssetUploadError,
|
|
28
|
+
AuthenticationError,
|
|
29
|
+
ConflictError,
|
|
30
|
+
DraftNotStartedError,
|
|
31
|
+
GenerationFailedError,
|
|
32
|
+
GenerationNotSettledError,
|
|
33
|
+
InsufficientOlivesError,
|
|
34
|
+
InvalidRequestError,
|
|
35
|
+
MartiniAPIError,
|
|
36
|
+
MartiniError,
|
|
37
|
+
MartiniTimeoutError,
|
|
38
|
+
MartiniTransportError,
|
|
39
|
+
NotFoundError,
|
|
40
|
+
RateLimitError,
|
|
41
|
+
ServerError,
|
|
42
|
+
StorageUnavailableError,
|
|
43
|
+
WorkflowRunFailedError,
|
|
44
|
+
)
|
|
45
|
+
from .resources.assets import UploadFile
|
|
46
|
+
from .types import (
|
|
47
|
+
Asset,
|
|
48
|
+
CanvasPosition,
|
|
49
|
+
Generation,
|
|
50
|
+
Me,
|
|
51
|
+
Model,
|
|
52
|
+
Models,
|
|
53
|
+
PlacedWorkflow,
|
|
54
|
+
SavedWorkflow,
|
|
55
|
+
Workflow,
|
|
56
|
+
WorkflowList,
|
|
57
|
+
WorkflowResults,
|
|
58
|
+
WorkflowRun,
|
|
59
|
+
WorkflowSettled,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"API_VERSION",
|
|
64
|
+
"ENDPOINTS",
|
|
65
|
+
"AccessDeniedError",
|
|
66
|
+
"Asset",
|
|
67
|
+
"AssetUploadError",
|
|
68
|
+
"AsyncMartini",
|
|
69
|
+
"AuthenticationError",
|
|
70
|
+
"CanvasPosition",
|
|
71
|
+
"ConfigurationError",
|
|
72
|
+
"ConflictError",
|
|
73
|
+
"DraftNotStartedError",
|
|
74
|
+
"EndpointId",
|
|
75
|
+
"Generation",
|
|
76
|
+
"GenerationFailedError",
|
|
77
|
+
"GenerationNotSettledError",
|
|
78
|
+
"InsufficientOlivesError",
|
|
79
|
+
"InvalidRequestError",
|
|
80
|
+
"Martini",
|
|
81
|
+
"MartiniAPIError",
|
|
82
|
+
"MartiniError",
|
|
83
|
+
"MartiniTimeoutError",
|
|
84
|
+
"MartiniTransportError",
|
|
85
|
+
"Me",
|
|
86
|
+
"Model",
|
|
87
|
+
"Models",
|
|
88
|
+
"NotFoundError",
|
|
89
|
+
"PlacedWorkflow",
|
|
90
|
+
"RateLimitError",
|
|
91
|
+
"SavedWorkflow",
|
|
92
|
+
"ServerError",
|
|
93
|
+
"StorageUnavailableError",
|
|
94
|
+
"UploadFile",
|
|
95
|
+
"WireShapeError",
|
|
96
|
+
"Workflow",
|
|
97
|
+
"WorkflowList",
|
|
98
|
+
"WorkflowResults",
|
|
99
|
+
"WorkflowRun",
|
|
100
|
+
"WorkflowRunFailedError",
|
|
101
|
+
"WorkflowSettled",
|
|
102
|
+
"__version__",
|
|
103
|
+
"types",
|
|
104
|
+
]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""``Martini`` and ``AsyncMartini``: the two entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._http import (
|
|
10
|
+
DEFAULT_MAX_RETRIES,
|
|
11
|
+
DEFAULT_TIMEOUT,
|
|
12
|
+
AsyncTransport,
|
|
13
|
+
ClientConfig,
|
|
14
|
+
RequestSpec,
|
|
15
|
+
SyncTransport,
|
|
16
|
+
resolve_api_key,
|
|
17
|
+
resolve_base_url,
|
|
18
|
+
)
|
|
19
|
+
from ._version import API_VERSION
|
|
20
|
+
from .resources.assets import Assets, AsyncAssets
|
|
21
|
+
from .resources.generations import AsyncGenerations, Generations
|
|
22
|
+
from .resources.workflows import AsyncRuns, AsyncWorkflows, Runs, Workflows
|
|
23
|
+
from .types.common import Me
|
|
24
|
+
from .types.models import Models
|
|
25
|
+
|
|
26
|
+
_ME_SPEC = RequestSpec("GET", "/v1/me", retry=True)
|
|
27
|
+
_MODELS_SPEC = RequestSpec("GET", "/v1/models", retry=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _mask(key: str) -> str:
|
|
31
|
+
return f"{key[:9]}…{key[-4:]}" if len(key) > 16 else "…"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_config(
|
|
35
|
+
api_key: str | None,
|
|
36
|
+
base_url: str | None,
|
|
37
|
+
api_version: str,
|
|
38
|
+
timeout: float,
|
|
39
|
+
max_retries: int,
|
|
40
|
+
) -> ClientConfig:
|
|
41
|
+
if max_retries < 0:
|
|
42
|
+
raise ValueError("max_retries must be 0 or more")
|
|
43
|
+
if timeout <= 0:
|
|
44
|
+
raise ValueError("timeout must be positive")
|
|
45
|
+
return ClientConfig(
|
|
46
|
+
api_key=resolve_api_key(api_key),
|
|
47
|
+
base_url=resolve_base_url(base_url),
|
|
48
|
+
api_version=api_version,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
max_retries=max_retries,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Martini:
|
|
55
|
+
"""The synchronous Martini client.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
api_key: Your ``mtn_live_…`` key. Defaults to ``MARTINI_API_KEY``. Server-side only — never ship it in a
|
|
59
|
+
browser bundle or a public config.
|
|
60
|
+
base_url: Defaults to ``MARTINI_API_URL``, then ``https://api.martini.film``. HTTPS is required except
|
|
61
|
+
for localhost.
|
|
62
|
+
api_version: The ``X-Martini-API-Version`` to pin. Defaults to the version this release was built against.
|
|
63
|
+
timeout: Per-request timeout in seconds.
|
|
64
|
+
max_retries: Retries for safe requests (GETs, and submits carrying an ``idempotency_key``) on transport
|
|
65
|
+
errors, 429, and 5xx.
|
|
66
|
+
http_client: Bring your own ``httpx.Client`` (proxies, custom transports). You own its lifetime.
|
|
67
|
+
|
|
68
|
+
Use as a context manager to release the connection pool::
|
|
69
|
+
|
|
70
|
+
with Martini() as martini:
|
|
71
|
+
print(martini.me().organization_name)
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
generations: ``/v1/generations`` — submit, poll, cancel, start drafts.
|
|
75
|
+
workflows: ``/v1/workflows`` — list, get, place, and run workflows.
|
|
76
|
+
runs: ``/v1/runs`` — poll a run and fetch its results.
|
|
77
|
+
assets: upload reusable media.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
api_key: str | None = None,
|
|
83
|
+
*,
|
|
84
|
+
base_url: str | None = None,
|
|
85
|
+
api_version: str = API_VERSION,
|
|
86
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
87
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
88
|
+
http_client: httpx.Client | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
self._config = _build_config(api_key, base_url, api_version, timeout, max_retries)
|
|
91
|
+
self._transport = SyncTransport(self._config, http_client)
|
|
92
|
+
self.generations = Generations(self._transport)
|
|
93
|
+
self.runs = Runs(self._transport)
|
|
94
|
+
self.workflows = Workflows(self._transport, self.runs)
|
|
95
|
+
self.assets = Assets(self._transport)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def base_url(self) -> str:
|
|
99
|
+
"""The API origin this client talks to."""
|
|
100
|
+
return self._config.base_url
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def api_version(self) -> str:
|
|
104
|
+
"""The API version this client pins."""
|
|
105
|
+
return self._config.api_version
|
|
106
|
+
|
|
107
|
+
def me(self) -> Me:
|
|
108
|
+
"""``GET /v1/me``: confirm the key works and see which organization it belongs to."""
|
|
109
|
+
return Me.from_wire(self._transport.request(_ME_SPEC))
|
|
110
|
+
|
|
111
|
+
def models(self) -> Models:
|
|
112
|
+
"""``GET /v1/models``: the endpoint aliases this key can invoke, with your organization's pricing."""
|
|
113
|
+
return Models.from_wire(self._transport.request(_MODELS_SPEC))
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
"""Release the connection pool (no-op for a caller-supplied ``http_client``)."""
|
|
117
|
+
self._transport.close()
|
|
118
|
+
|
|
119
|
+
def __enter__(self) -> Martini:
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def __exit__(
|
|
123
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
124
|
+
) -> None:
|
|
125
|
+
self.close()
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
return f"Martini(base_url={self._config.base_url!r}, api_key={_mask(self._config.api_key)!r})"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class AsyncMartini:
|
|
132
|
+
"""The asynchronous Martini client; same surface as ``Martini`` with ``await``.
|
|
133
|
+
|
|
134
|
+
::
|
|
135
|
+
|
|
136
|
+
async with AsyncMartini() as martini:
|
|
137
|
+
generation = await martini.generations.subscribe("nano-banana-2", input={"prompt": "…"})
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
api_key: str | None = None,
|
|
143
|
+
*,
|
|
144
|
+
base_url: str | None = None,
|
|
145
|
+
api_version: str = API_VERSION,
|
|
146
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
147
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
148
|
+
http_client: httpx.AsyncClient | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
self._config = _build_config(api_key, base_url, api_version, timeout, max_retries)
|
|
151
|
+
self._transport = AsyncTransport(self._config, http_client)
|
|
152
|
+
self.generations = AsyncGenerations(self._transport)
|
|
153
|
+
self.runs = AsyncRuns(self._transport)
|
|
154
|
+
self.workflows = AsyncWorkflows(self._transport, self.runs)
|
|
155
|
+
self.assets = AsyncAssets(self._transport)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def base_url(self) -> str:
|
|
159
|
+
"""The API origin this client talks to."""
|
|
160
|
+
return self._config.base_url
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def api_version(self) -> str:
|
|
164
|
+
"""The API version this client pins."""
|
|
165
|
+
return self._config.api_version
|
|
166
|
+
|
|
167
|
+
async def me(self) -> Me:
|
|
168
|
+
"""``GET /v1/me``. See ``me()``."""
|
|
169
|
+
return Me.from_wire(await self._transport.request(_ME_SPEC))
|
|
170
|
+
|
|
171
|
+
async def models(self) -> Models:
|
|
172
|
+
"""``GET /v1/models``. See ``models()``."""
|
|
173
|
+
return Models.from_wire(await self._transport.request(_MODELS_SPEC))
|
|
174
|
+
|
|
175
|
+
async def aclose(self) -> None:
|
|
176
|
+
"""Release the connection pool (no-op for a caller-supplied ``http_client``)."""
|
|
177
|
+
await self._transport.aclose()
|
|
178
|
+
|
|
179
|
+
async def __aenter__(self) -> AsyncMartini:
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
async def __aexit__(
|
|
183
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
184
|
+
) -> None:
|
|
185
|
+
await self.aclose()
|
|
186
|
+
|
|
187
|
+
def __repr__(self) -> str:
|
|
188
|
+
return f"AsyncMartini(base_url={self._config.base_url!r}, api_key={_mask(self._config.api_key)!r})"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
__all__ = ["AsyncMartini", "Martini"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Which endpoints share an input shape and an output shape.
|
|
2
|
+
|
|
3
|
+
This table drives ``scripts/gen_overloads.py``, which writes the ``@overload`` blocks on
|
|
4
|
+
``generations.submit()``/``subscribe()`` so ``input=`` narrows to the right ``TypedDict`` per endpoint.
|
|
5
|
+
``tests/test_overloads_current.py`` fails when the generated blocks are stale, and the backend's drift guard
|
|
6
|
+
keeps ``endpoints.ENDPOINTS`` equal to the handler registry — so a new alias has to be placed in a family
|
|
7
|
+
here (or get a new one) before it ships.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Family:
|
|
17
|
+
"""Endpoints that accept the same input ``TypedDict`` and produce the same output shape."""
|
|
18
|
+
|
|
19
|
+
endpoints: tuple[str, ...]
|
|
20
|
+
input_type: str
|
|
21
|
+
output_type: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
FAMILIES: tuple[Family, ...] = (
|
|
25
|
+
Family(
|
|
26
|
+
(
|
|
27
|
+
"bytedance/seedance-2.0/text-to-video",
|
|
28
|
+
"bytedance/seedance-2.0/fast/text-to-video",
|
|
29
|
+
"bytedance/seedance-2.0/mini/text-to-video",
|
|
30
|
+
),
|
|
31
|
+
"Seedance20TextToVideoInput",
|
|
32
|
+
"VideoOutput",
|
|
33
|
+
),
|
|
34
|
+
Family(
|
|
35
|
+
(
|
|
36
|
+
"bytedance/seedance-2.0/image-to-video",
|
|
37
|
+
"bytedance/seedance-2.0/fast/image-to-video",
|
|
38
|
+
"bytedance/seedance-2.0/mini/image-to-video",
|
|
39
|
+
),
|
|
40
|
+
"Seedance20ImageToVideoInput",
|
|
41
|
+
"VideoOutput",
|
|
42
|
+
),
|
|
43
|
+
Family(
|
|
44
|
+
(
|
|
45
|
+
"bytedance/seedance-2.0/reference-to-video",
|
|
46
|
+
"bytedance/seedance-2.0/fast/reference-to-video",
|
|
47
|
+
"bytedance/seedance-2.0/mini/reference-to-video",
|
|
48
|
+
),
|
|
49
|
+
"Seedance20ReferenceToVideoInput",
|
|
50
|
+
"VideoOutput",
|
|
51
|
+
),
|
|
52
|
+
Family(("bytedance/seedance-2.5/text-to-video",), "Seedance25TextToVideoInput", "VideoOutput"),
|
|
53
|
+
Family(("bytedance/seedance-2.5/image-to-video",), "Seedance25ImageToVideoInput", "VideoOutput"),
|
|
54
|
+
Family(("bytedance/seedance-2.5/reference-to-video",), "Seedance25ReferenceToVideoInput", "VideoOutput"),
|
|
55
|
+
Family(("xai/grok-imagine-video/v1.5/text-to-video",), "GrokImagine15TextToVideoInput", "VideoOutput"),
|
|
56
|
+
Family(("xai/grok-imagine-video/v1.5/image-to-video",), "GrokImagine15ImageToVideoInput", "VideoOutput"),
|
|
57
|
+
Family(
|
|
58
|
+
("xai/grok-imagine-video/v1.5/reference-to-video",),
|
|
59
|
+
"GrokImagine15ReferenceToVideoInput",
|
|
60
|
+
"VideoOutput",
|
|
61
|
+
),
|
|
62
|
+
Family(("alibaba/wan-3.0/text-to-video",), "Wan3TextToVideoInput", "Wan3Output"),
|
|
63
|
+
Family(("alibaba/wan-3.0/image-to-video",), "Wan3ImageToVideoInput", "Wan3Output"),
|
|
64
|
+
Family(("alibaba/wan-3.0/reference-to-video",), "Wan3ReferenceToVideoInput", "Wan3Output"),
|
|
65
|
+
Family(("alibaba/wan-3.0-prime/text-to-video",), "Wan3PrimeTextToVideoInput", "Wan3Output"),
|
|
66
|
+
Family(("alibaba/wan-3.0-prime/image-to-video",), "Wan3PrimeImageToVideoInput", "Wan3Output"),
|
|
67
|
+
Family(("alibaba/wan-3.0-prime/reference-to-video",), "Wan3PrimeReferenceToVideoInput", "Wan3Output"),
|
|
68
|
+
Family(
|
|
69
|
+
("minimax/h3/text-to-video", "fal-ai/minimax/hailuo-03/text-to-video"),
|
|
70
|
+
"Hailuo03TextToVideoInput",
|
|
71
|
+
"VideoOutput",
|
|
72
|
+
),
|
|
73
|
+
Family(
|
|
74
|
+
("minimax/h3/image-to-video", "fal-ai/minimax/hailuo-03/image-to-video"),
|
|
75
|
+
"Hailuo03ImageToVideoInput",
|
|
76
|
+
"VideoOutput",
|
|
77
|
+
),
|
|
78
|
+
Family(
|
|
79
|
+
("minimax/h3/reference-to-video", "fal-ai/minimax/hailuo-03/reference-to-video"),
|
|
80
|
+
"Hailuo03ReferenceToVideoInput",
|
|
81
|
+
"VideoOutput",
|
|
82
|
+
),
|
|
83
|
+
Family(("minimax/h3-max/text-to-video",), "Hailuo03MaxTextToVideoInput", "VideoOutput"),
|
|
84
|
+
Family(("minimax/h3-max/image-to-video",), "Hailuo03MaxImageToVideoInput", "VideoOutput"),
|
|
85
|
+
Family(("minimax/h3-max/reference-to-video",), "Hailuo03MaxReferenceToVideoInput", "VideoOutput"),
|
|
86
|
+
Family(("minimax/h3-max-turbo/text-to-video",), "Hailuo03MaxTurboTextToVideoInput", "VideoOutput"),
|
|
87
|
+
Family(("minimax/h3-max-turbo/image-to-video",), "Hailuo03MaxTurboImageToVideoInput", "VideoOutput"),
|
|
88
|
+
Family(("nano-banana-2", "fal-ai/nano-banana-2"), "NanoBanana2Input", "ImagesOutput"),
|
|
89
|
+
Family(("nano-banana-2/edit", "fal-ai/nano-banana-2/edit"), "NanoBanana2EditInput", "ImagesOutput"),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def family_endpoints() -> set[str]:
|
|
94
|
+
"""Every endpoint covered by a family."""
|
|
95
|
+
return {endpoint for family in FAMILIES for endpoint in family.endpoints}
|
martini_client/_http.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""The HTTP layer shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Resources describe a call as a ``RequestSpec``; a ``SyncTransport`` or ``AsyncTransport``
|
|
4
|
+
executes it. That is the only place the two clients differ, so behaviour — headers, retries, error mapping —
|
|
5
|
+
is defined exactly once.
|
|
6
|
+
|
|
7
|
+
Retries: a request is retried only when it is safe to. Every ``GET`` is; a ``POST`` is only when the resource
|
|
8
|
+
marked it (a submit carrying an ``idempotency_key``, which the server dedupes). Retried failures are transport
|
|
9
|
+
errors (DNS, connection, read timeout), ``429``, and ``500``/``502``/``503``/``504``, with exponential backoff
|
|
10
|
+
and jitter, honouring ``Retry-After`` when the server sends one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from email.utils import parsedate_to_datetime
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urlsplit
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from ._version import API_VERSION, __version__
|
|
29
|
+
from .errors import MartiniTransportError, api_error_for
|
|
30
|
+
|
|
31
|
+
DEFAULT_BASE_URL = "https://api.martini.film"
|
|
32
|
+
DEFAULT_TIMEOUT = 60.0
|
|
33
|
+
DEFAULT_MAX_RETRIES = 2
|
|
34
|
+
RETRYABLE_STATUSES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
|
|
35
|
+
BACKOFF_INITIAL_SECONDS = 0.5
|
|
36
|
+
BACKOFF_CAP_SECONDS = 8.0
|
|
37
|
+
RETRY_AFTER_CAP_SECONDS = 30.0
|
|
38
|
+
USER_AGENT = (
|
|
39
|
+
f"martini-client-python/{__version__} python/{sys.version_info.major}.{sys.version_info.minor} "
|
|
40
|
+
f"httpx/{httpx.__version__}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConfigurationError(ValueError):
|
|
45
|
+
"""The client was constructed with a value it cannot use (missing key, non-HTTPS base URL, ...)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_base_url(raw: str) -> str:
|
|
49
|
+
"""Validate and normalize the API base URL.
|
|
50
|
+
|
|
51
|
+
HTTPS is required except for ``localhost``/``127.0.0.1`` (a local backend). Credentials, a query string,
|
|
52
|
+
or a fragment are refused rather than silently dropped.
|
|
53
|
+
"""
|
|
54
|
+
base = raw.strip().rstrip("/")
|
|
55
|
+
parts = urlsplit(base)
|
|
56
|
+
if not parts.scheme or not parts.hostname:
|
|
57
|
+
raise ConfigurationError(f"base_url must be an absolute URL, got {raw!r}")
|
|
58
|
+
loopback = parts.hostname in ("localhost", "127.0.0.1")
|
|
59
|
+
if parts.scheme != "https" and not loopback:
|
|
60
|
+
raise ConfigurationError("base_url must use HTTPS (plain HTTP is allowed only for localhost)")
|
|
61
|
+
if parts.username or parts.password or parts.query or parts.fragment:
|
|
62
|
+
raise ConfigurationError("base_url must not include credentials, a query string, or a fragment")
|
|
63
|
+
return base
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_api_key(explicit: str | None) -> str:
|
|
67
|
+
"""The key from the argument or ``MARTINI_API_KEY``; never empty."""
|
|
68
|
+
key = (explicit if explicit is not None else os.environ.get("MARTINI_API_KEY", "")).strip()
|
|
69
|
+
if not key:
|
|
70
|
+
raise ConfigurationError(
|
|
71
|
+
"No API key. Pass api_key=... or set MARTINI_API_KEY. Create a key under Settings → Martini API."
|
|
72
|
+
)
|
|
73
|
+
return key
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_base_url(explicit: str | None) -> str:
|
|
77
|
+
"""The base URL from the argument, ``MARTINI_API_URL``, or the production default."""
|
|
78
|
+
return normalize_base_url(explicit or os.environ.get("MARTINI_API_URL") or DEFAULT_BASE_URL)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class RequestSpec:
|
|
83
|
+
"""One API call, independent of sync/async execution."""
|
|
84
|
+
|
|
85
|
+
method: str
|
|
86
|
+
path: str
|
|
87
|
+
json: Any = None
|
|
88
|
+
params: Mapping[str, str] | None = None
|
|
89
|
+
retry: bool = False
|
|
90
|
+
"""Whether this request may be repeated after a transport error, 429, or 5xx."""
|
|
91
|
+
timeout: float | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class ClientConfig:
|
|
96
|
+
"""Everything a transport needs to talk to one Martini API."""
|
|
97
|
+
|
|
98
|
+
api_key: str
|
|
99
|
+
base_url: str
|
|
100
|
+
api_version: str = API_VERSION
|
|
101
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
102
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
103
|
+
|
|
104
|
+
def headers(self) -> dict[str, str]:
|
|
105
|
+
"""Default request headers."""
|
|
106
|
+
return {
|
|
107
|
+
"Authorization": f"Key {self.api_key}",
|
|
108
|
+
"X-Martini-API-Version": self.api_version,
|
|
109
|
+
"User-Agent": USER_AGENT,
|
|
110
|
+
"Accept": "application/json",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
115
|
+
"""Seconds from a ``Retry-After`` header (delta-seconds or an HTTP date), or ``None``."""
|
|
116
|
+
if not value:
|
|
117
|
+
return None
|
|
118
|
+
text = value.strip()
|
|
119
|
+
if text.isdigit():
|
|
120
|
+
return float(text)
|
|
121
|
+
try:
|
|
122
|
+
when = parsedate_to_datetime(text)
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
return None
|
|
125
|
+
delta: float = when.timestamp() - time.time()
|
|
126
|
+
return max(0.0, delta)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def backoff_seconds(attempt: int, retry_after: float | None, rng: Callable[[], float] = random.random) -> float:
|
|
130
|
+
"""How long to wait before retry number ``attempt`` (1-based)."""
|
|
131
|
+
if retry_after is not None:
|
|
132
|
+
return min(retry_after, RETRY_AFTER_CAP_SECONDS)
|
|
133
|
+
base = min(BACKOFF_INITIAL_SECONDS * (2.0 ** (attempt - 1)), BACKOFF_CAP_SECONDS)
|
|
134
|
+
jitter = 0.8 + 0.4 * rng()
|
|
135
|
+
return base * jitter
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read_json(response: httpx.Response) -> dict[str, Any]:
|
|
139
|
+
"""The JSON body as a dict; a non-JSON body becomes ``{"error": <text>}``."""
|
|
140
|
+
text = response.text
|
|
141
|
+
if not text:
|
|
142
|
+
return {}
|
|
143
|
+
try:
|
|
144
|
+
parsed = response.json()
|
|
145
|
+
except ValueError:
|
|
146
|
+
return {"error": text}
|
|
147
|
+
return parsed if isinstance(parsed, dict) else {"data": parsed}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def raise_for_response(response: httpx.Response) -> dict[str, Any]:
|
|
151
|
+
"""Return the parsed body of a 2xx response; raise the matching ``MartiniAPIError`` otherwise."""
|
|
152
|
+
body = read_json(response)
|
|
153
|
+
if response.is_success:
|
|
154
|
+
return body
|
|
155
|
+
raise api_error_for(
|
|
156
|
+
response.status_code,
|
|
157
|
+
body,
|
|
158
|
+
retry_after=parse_retry_after(response.headers.get("Retry-After")),
|
|
159
|
+
fallback_message=f"Martini API request failed (HTTP {response.status_code})",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _should_retry_status(status: int) -> bool:
|
|
164
|
+
return status in RETRYABLE_STATUSES
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _transport_error(spec: RequestSpec, exc: httpx.TransportError, attempts: int) -> MartiniTransportError:
|
|
168
|
+
return MartiniTransportError(
|
|
169
|
+
f"{spec.method} {spec.path} did not get a response after {attempts} attempt(s): {exc.__class__.__name__}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class SyncTransport:
|
|
174
|
+
"""Executes ``RequestSpec``s over ``httpx.Client``."""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
config: ClientConfig,
|
|
179
|
+
http_client: httpx.Client | None = None,
|
|
180
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
181
|
+
) -> None:
|
|
182
|
+
self.config = config
|
|
183
|
+
self._owns_client = http_client is None
|
|
184
|
+
self._client = http_client or httpx.Client(timeout=config.timeout)
|
|
185
|
+
# Presigned storage PUTs never ride the caller's client: its auth, default headers, or
|
|
186
|
+
# base URL would leak to the storage host. This bare client is always ours to close.
|
|
187
|
+
self._storage = httpx.Client(timeout=config.timeout)
|
|
188
|
+
self.sleep = sleep
|
|
189
|
+
"""Injected for tests; the client's poll loops and retry backoff wait through this."""
|
|
190
|
+
|
|
191
|
+
def request(self, spec: RequestSpec) -> dict[str, Any]:
|
|
192
|
+
"""Run one call, retrying when ``RequestSpec.retry`` allows it; return the parsed 2xx body."""
|
|
193
|
+
attempts = self.config.max_retries + 1 if spec.retry else 1
|
|
194
|
+
for attempt in range(1, attempts + 1):
|
|
195
|
+
try:
|
|
196
|
+
response = self._client.request(
|
|
197
|
+
spec.method,
|
|
198
|
+
f"{self.config.base_url}{spec.path}",
|
|
199
|
+
json=spec.json,
|
|
200
|
+
params=dict(spec.params) if spec.params else None,
|
|
201
|
+
headers=self.config.headers(),
|
|
202
|
+
timeout=spec.timeout if spec.timeout is not None else self.config.timeout,
|
|
203
|
+
)
|
|
204
|
+
except httpx.TransportError as exc:
|
|
205
|
+
if attempt == attempts:
|
|
206
|
+
raise _transport_error(spec, exc, attempts) from exc
|
|
207
|
+
self.sleep(backoff_seconds(attempt, None))
|
|
208
|
+
continue
|
|
209
|
+
if spec.retry and attempt < attempts and _should_retry_status(response.status_code):
|
|
210
|
+
self.sleep(backoff_seconds(attempt, parse_retry_after(response.headers.get("Retry-After"))))
|
|
211
|
+
continue
|
|
212
|
+
return raise_for_response(response)
|
|
213
|
+
raise AssertionError("unreachable")
|
|
214
|
+
|
|
215
|
+
def put_bytes(self, url: str, headers: Mapping[str, str], content: bytes, timeout: float) -> httpx.Response:
|
|
216
|
+
"""PUT raw bytes to a presigned URL. No Martini headers — the URL carries its own authorization."""
|
|
217
|
+
try:
|
|
218
|
+
return self._storage.put(url, content=content, headers=dict(headers), timeout=timeout)
|
|
219
|
+
except httpx.TransportError as exc:
|
|
220
|
+
raise MartiniTransportError(f"PUT to storage failed: {exc.__class__.__name__}") from exc
|
|
221
|
+
|
|
222
|
+
def close(self) -> None:
|
|
223
|
+
"""Close the storage client, and the API client if this transport created it."""
|
|
224
|
+
self._storage.close()
|
|
225
|
+
if self._owns_client:
|
|
226
|
+
self._client.close()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class AsyncTransport:
|
|
230
|
+
"""Executes ``RequestSpec``s over ``httpx.AsyncClient``."""
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
config: ClientConfig,
|
|
235
|
+
http_client: httpx.AsyncClient | None = None,
|
|
236
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
237
|
+
) -> None:
|
|
238
|
+
self.config = config
|
|
239
|
+
self._owns_client = http_client is None
|
|
240
|
+
self._client = http_client or httpx.AsyncClient(timeout=config.timeout)
|
|
241
|
+
self._storage = httpx.AsyncClient(timeout=config.timeout)
|
|
242
|
+
self.sleep = sleep
|
|
243
|
+
"""Injected for tests; the client's poll loops and retry backoff wait through this."""
|
|
244
|
+
|
|
245
|
+
async def request(self, spec: RequestSpec) -> dict[str, Any]:
|
|
246
|
+
"""Run one call, retrying when ``RequestSpec.retry`` allows it; return the parsed 2xx body."""
|
|
247
|
+
attempts = self.config.max_retries + 1 if spec.retry else 1
|
|
248
|
+
for attempt in range(1, attempts + 1):
|
|
249
|
+
try:
|
|
250
|
+
response = await self._client.request(
|
|
251
|
+
spec.method,
|
|
252
|
+
f"{self.config.base_url}{spec.path}",
|
|
253
|
+
json=spec.json,
|
|
254
|
+
params=dict(spec.params) if spec.params else None,
|
|
255
|
+
headers=self.config.headers(),
|
|
256
|
+
timeout=spec.timeout if spec.timeout is not None else self.config.timeout,
|
|
257
|
+
)
|
|
258
|
+
except httpx.TransportError as exc:
|
|
259
|
+
if attempt == attempts:
|
|
260
|
+
raise _transport_error(spec, exc, attempts) from exc
|
|
261
|
+
await self.sleep(backoff_seconds(attempt, None))
|
|
262
|
+
continue
|
|
263
|
+
if spec.retry and attempt < attempts and _should_retry_status(response.status_code):
|
|
264
|
+
await self.sleep(backoff_seconds(attempt, parse_retry_after(response.headers.get("Retry-After"))))
|
|
265
|
+
continue
|
|
266
|
+
return raise_for_response(response)
|
|
267
|
+
raise AssertionError("unreachable")
|
|
268
|
+
|
|
269
|
+
async def put_bytes(self, url: str, headers: Mapping[str, str], content: bytes, timeout: float) -> httpx.Response:
|
|
270
|
+
"""PUT raw bytes to a presigned URL. No Martini headers — the URL carries its own authorization."""
|
|
271
|
+
try:
|
|
272
|
+
return await self._storage.put(url, content=content, headers=dict(headers), timeout=timeout)
|
|
273
|
+
except httpx.TransportError as exc:
|
|
274
|
+
raise MartiniTransportError(f"PUT to storage failed: {exc.__class__.__name__}") from exc
|
|
275
|
+
|
|
276
|
+
async def aclose(self) -> None:
|
|
277
|
+
"""Close the storage client, and the API client if this transport created it."""
|
|
278
|
+
await self._storage.aclose()
|
|
279
|
+
if self._owns_client:
|
|
280
|
+
await self._client.aclose()
|