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.
@@ -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}
@@ -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()