mappa-conduit 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.3
2
+ Name: mappa-conduit
3
+ Version: 0.1.2
4
+ Summary: Official Python SDK for the Conduit API
5
+ Author: drsh4dow
6
+ Author-email: drsh4dow <daniel.morettiv@gmail.com>
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Conduit Python SDK
12
+
13
+ Official Python SDK for the Conduit API.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install mappa-conduit
19
+ ```
20
+
21
+ ## Quickstart
22
+
23
+ ```python
24
+ from conduit import Conduit
25
+
26
+ conduit = Conduit(api_key="sk_...")
27
+
28
+ receipt = conduit.reports.create(
29
+ source={"path": "./call.mp3"},
30
+ output={"template": "general_report"},
31
+ target={"strategy": "dominant"},
32
+ webhook={"url": "https://your-app.com/webhooks/conduit"},
33
+ )
34
+
35
+ print(receipt.job_id)
36
+ ```
@@ -0,0 +1,26 @@
1
+ # Conduit Python SDK
2
+
3
+ Official Python SDK for the Conduit API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install mappa-conduit
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from conduit import Conduit
15
+
16
+ conduit = Conduit(api_key="sk_...")
17
+
18
+ receipt = conduit.reports.create(
19
+ source={"path": "./call.mp3"},
20
+ output={"template": "general_report"},
21
+ target={"strategy": "dominant"},
22
+ webhook={"url": "https://your-app.com/webhooks/conduit"},
23
+ )
24
+
25
+ print(receipt.job_id)
26
+ ```
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "mappa-conduit"
3
+ version = "0.1.2"
4
+ description = "Official Python SDK for the Conduit API"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "drsh4dow", email = "daniel.morettiv@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "httpx>=0.28.1",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["uv_build>=0.10.9,<0.11.0"]
16
+ build-backend = "uv_build"
17
+
18
+ [tool.uv.build-backend]
19
+ module-name = "conduit"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pyright>=1.1.408",
24
+ "pytest>=9.0.2",
25
+ "ruff>=0.15.6",
26
+ ]
27
+
28
+ [tool.pyright]
29
+ pythonVersion = "3.12"
30
+ typeCheckingMode = "strict"
31
+ include = ["src", "tests"]
32
+ reportCallInDefaultInitializer = "error"
33
+ reportImplicitOverride = "error"
34
+ reportImportCycles = "error"
35
+ reportImplicitStringConcatenation = "error"
36
+ reportMissingTypeArgument = "error"
37
+ reportMissingTypeStubs = "error"
38
+ reportPrivateUsage = "error"
39
+ reportPropertyTypeMismatch = "error"
40
+ reportUnnecessaryTypeIgnoreComment = "error"
41
+ reportUnknownArgumentType = "error"
42
+ reportUnknownLambdaType = "error"
43
+ reportUnknownMemberType = "error"
44
+ reportUnknownParameterType = "error"
45
+ reportUnknownVariableType = "error"
46
+ reportUnnecessaryCast = "error"
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
50
+
51
+ [tool.ruff]
52
+ target-version = "py312"
53
+
54
+ [tool.ruff.lint]
55
+ select = ["ALL"]
56
+ ignore = ["A001", "COM812", "EM101", "EM102", "PLR0913", "PLR2004", "TRY003"]
57
+
58
+ [tool.ruff.lint.per-file-ignores]
59
+ "tests/*.py" = ["S105"]
@@ -0,0 +1,87 @@
1
+ """Official Python SDK for the Conduit API."""
2
+
3
+ from .client import (
4
+ Conduit,
5
+ MatchingJobReceipt,
6
+ MatchingRunHandle,
7
+ ReportJobReceipt,
8
+ ReportRunHandle,
9
+ )
10
+ from .errors import (
11
+ ApiError,
12
+ AuthError,
13
+ ConduitError,
14
+ InitializationError,
15
+ InsufficientCreditsError,
16
+ InvalidSourceError,
17
+ JobCanceledError,
18
+ JobFailedError,
19
+ RateLimitError,
20
+ RemoteFetchError,
21
+ RemoteFetchTimeoutError,
22
+ RemoteFetchTooLargeError,
23
+ RequestAbortedError,
24
+ SourceError,
25
+ StreamError,
26
+ UnsupportedRuntimeError,
27
+ ValidationError,
28
+ WebhookVerificationError,
29
+ )
30
+ from .errors import (
31
+ TimeoutError as SDKTimeoutError,
32
+ )
33
+ from .models import (
34
+ Entity,
35
+ FileDeleteReceipt,
36
+ Job,
37
+ JobEvent,
38
+ ListEntitiesResponse,
39
+ ListFilesResponse,
40
+ MatchingAnalysisResponse,
41
+ MediaFile,
42
+ MediaObject,
43
+ Report,
44
+ RetentionLockResult,
45
+ WebhookEvent,
46
+ )
47
+
48
+ TimeoutError = SDKTimeoutError
49
+
50
+ __all__ = [
51
+ "ApiError",
52
+ "AuthError",
53
+ "Conduit",
54
+ "ConduitError",
55
+ "Entity",
56
+ "FileDeleteReceipt",
57
+ "InitializationError",
58
+ "InsufficientCreditsError",
59
+ "InvalidSourceError",
60
+ "Job",
61
+ "JobCanceledError",
62
+ "JobEvent",
63
+ "JobFailedError",
64
+ "ListEntitiesResponse",
65
+ "ListFilesResponse",
66
+ "MatchingAnalysisResponse",
67
+ "MatchingJobReceipt",
68
+ "MatchingRunHandle",
69
+ "MediaFile",
70
+ "MediaObject",
71
+ "RateLimitError",
72
+ "RemoteFetchError",
73
+ "RemoteFetchTimeoutError",
74
+ "RemoteFetchTooLargeError",
75
+ "Report",
76
+ "ReportJobReceipt",
77
+ "ReportRunHandle",
78
+ "RequestAbortedError",
79
+ "RetentionLockResult",
80
+ "SourceError",
81
+ "StreamError",
82
+ "TimeoutError",
83
+ "UnsupportedRuntimeError",
84
+ "ValidationError",
85
+ "WebhookEvent",
86
+ "WebhookVerificationError",
87
+ ]
@@ -0,0 +1,308 @@
1
+ """HTTP transport helpers for the Conduit Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import time
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from email.utils import parsedate_to_datetime
10
+ from typing import TYPE_CHECKING, cast
11
+ from uuid import uuid4
12
+
13
+ import httpx
14
+
15
+ from .errors import (
16
+ ApiError,
17
+ AuthError,
18
+ ConduitError,
19
+ InsufficientCreditsError,
20
+ RateLimitError,
21
+ ValidationError,
22
+ )
23
+ from .errors import (
24
+ TimeoutError as ConduitTimeoutError,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Mapping
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class TransportResponse:
33
+ """Normalized HTTP response payload."""
34
+
35
+ data: object
36
+ status: int
37
+ request_id: str | None
38
+ headers: httpx.Headers
39
+
40
+
41
+ class Transport:
42
+ """Small authenticated transport with retry support."""
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ api_key: str,
48
+ base_url: str,
49
+ timeout_ms: int,
50
+ max_retries: int,
51
+ user_agent: str | None = None,
52
+ ) -> None:
53
+ """Initialize the transport."""
54
+ self._api_key = api_key
55
+ self._timeout_ms = timeout_ms
56
+ self._max_retries = max_retries
57
+ self._user_agent = user_agent
58
+ self._client = httpx.Client(
59
+ base_url=base_url.rstrip("/"),
60
+ follow_redirects=False,
61
+ timeout=timeout_ms / 1000,
62
+ )
63
+
64
+ def close(self) -> None:
65
+ """Close the underlying HTTP client."""
66
+ self._client.close()
67
+
68
+ def request(
69
+ self,
70
+ method: str,
71
+ path: str,
72
+ *,
73
+ json_body: object | None = None,
74
+ data: Mapping[str, str] | None = None,
75
+ files: Mapping[str, tuple[str, bytes, str | None]] | None = None,
76
+ query: Mapping[str, str] | None = None,
77
+ headers: Mapping[str, str] | None = None,
78
+ request_id: str | None = None,
79
+ idempotency_key: str | None = None,
80
+ retryable: bool = False,
81
+ timeout_ms: int | None = None,
82
+ ) -> TransportResponse:
83
+ """Issue an authenticated API request."""
84
+ resolved_request_id = request_id or f"req_{uuid4().hex}"
85
+ attempts = self._max_retries + 1 if retryable else 1
86
+
87
+ for attempt in range(1, attempts + 1):
88
+ try:
89
+ response = self._client.request(
90
+ method,
91
+ path,
92
+ params=query,
93
+ json=json_body,
94
+ data=data,
95
+ files=files,
96
+ headers=self._headers(
97
+ request_id=resolved_request_id,
98
+ idempotency_key=idempotency_key,
99
+ headers=headers,
100
+ ),
101
+ timeout=(timeout_ms or self._timeout_ms) / 1000,
102
+ )
103
+ server_request_id = response.headers.get(
104
+ "x-request-id", resolved_request_id
105
+ )
106
+ if response.is_success:
107
+ return TransportResponse(
108
+ data=_read_response_data(response),
109
+ status=response.status_code,
110
+ request_id=server_request_id,
111
+ headers=response.headers,
112
+ )
113
+
114
+ error = _coerce_api_error(response, server_request_id)
115
+ if attempt < attempts and _should_retry_error(error):
116
+ _sleep(_retry_after_ms(error, attempt))
117
+ continue
118
+ raise error
119
+ except httpx.TimeoutException as exc:
120
+ error = ConduitTimeoutError(
121
+ f"Request timed out after {timeout_ms or self._timeout_ms}ms",
122
+ code="timeout",
123
+ request_id=resolved_request_id,
124
+ cause=exc,
125
+ )
126
+ if attempt < attempts:
127
+ _sleep(_backoff_ms(attempt))
128
+ continue
129
+ raise error from exc
130
+ except httpx.HTTPError as exc:
131
+ error = ConduitError(
132
+ "Request failed",
133
+ code="transport_error",
134
+ request_id=resolved_request_id,
135
+ cause=exc,
136
+ )
137
+ if attempt < attempts:
138
+ _sleep(_backoff_ms(attempt))
139
+ continue
140
+ raise error from exc
141
+
142
+ raise ConduitError("Unexpected transport exit", code="transport_error")
143
+
144
+ def _headers(
145
+ self,
146
+ *,
147
+ request_id: str,
148
+ idempotency_key: str | None,
149
+ headers: Mapping[str, str] | None,
150
+ ) -> dict[str, str]:
151
+ values = {
152
+ "Mappa-Api-Key": self._api_key,
153
+ "X-Request-Id": request_id,
154
+ }
155
+ if self._user_agent:
156
+ values["User-Agent"] = self._user_agent
157
+ if idempotency_key:
158
+ values["Idempotency-Key"] = idempotency_key
159
+ if headers:
160
+ values.update(headers)
161
+ return values
162
+
163
+
164
+ def _read_response_data(response: httpx.Response) -> object:
165
+ content_type = response.headers.get("content-type", "")
166
+ if "application/json" in content_type:
167
+ return response.json()
168
+ if response.content:
169
+ return response.text
170
+ return None
171
+
172
+
173
+ def _coerce_api_error(response: httpx.Response, request_id: str | None) -> ApiError:
174
+ payload = _read_error_body(response)
175
+ message, code, details = _read_error_fields(payload)
176
+
177
+ if response.status_code in {401, 403}:
178
+ return AuthError(
179
+ message,
180
+ status=response.status_code,
181
+ code=code,
182
+ request_id=request_id,
183
+ details=details,
184
+ )
185
+ if response.status_code == 402:
186
+ required, available = _read_credit_details(details)
187
+ return InsufficientCreditsError(
188
+ message,
189
+ status=response.status_code,
190
+ code=code,
191
+ request_id=request_id,
192
+ details=details,
193
+ required=required,
194
+ available=available,
195
+ )
196
+ if response.status_code == 422:
197
+ return ValidationError(
198
+ message,
199
+ status=response.status_code,
200
+ code=code,
201
+ request_id=request_id,
202
+ details=details,
203
+ )
204
+ if response.status_code == 429:
205
+ return RateLimitError(
206
+ message,
207
+ status=response.status_code,
208
+ code=code,
209
+ request_id=request_id,
210
+ details=details,
211
+ retry_after_ms=_read_retry_after_ms(response.headers.get("retry-after")),
212
+ )
213
+ return ApiError(
214
+ message,
215
+ status=response.status_code,
216
+ code=code,
217
+ request_id=request_id,
218
+ details=details,
219
+ )
220
+
221
+
222
+ def _read_error_body(response: httpx.Response) -> object | None:
223
+ try:
224
+ return response.json()
225
+ except ValueError:
226
+ if response.text:
227
+ return {"message": response.text}
228
+ return None
229
+
230
+
231
+ def _read_error_fields(payload: object | None) -> tuple[str, str, object | None]:
232
+ default_message = "Request failed"
233
+ default_code = "api_error"
234
+ if not isinstance(payload, dict):
235
+ return default_message, default_code, payload
236
+
237
+ payload_map = cast("dict[str, object]", payload)
238
+
239
+ error = payload_map.get("error")
240
+ if isinstance(error, dict):
241
+ error_map = cast("dict[str, object]", error)
242
+ code = error_map.get("code")
243
+ message = error_map.get("message")
244
+ return (
245
+ message if isinstance(message, str) else default_message,
246
+ code if isinstance(code, str) else default_code,
247
+ error_map.get("details"),
248
+ )
249
+
250
+ code = payload_map.get("code")
251
+ message = payload_map.get("message")
252
+ return (
253
+ message if isinstance(message, str) else default_message,
254
+ code if isinstance(code, str) else default_code,
255
+ payload_map,
256
+ )
257
+
258
+
259
+ def _read_credit_details(details: object | None) -> tuple[int, int]:
260
+ if not isinstance(details, dict):
261
+ return 0, 0
262
+ details_map = cast("dict[str, object]", details)
263
+ required = details_map.get("required")
264
+ available = details_map.get("available")
265
+ return (
266
+ int(required) if isinstance(required, int | float) else 0,
267
+ int(available) if isinstance(available, int | float) else 0,
268
+ )
269
+
270
+
271
+ def _read_retry_after_ms(value: str | None) -> int | None:
272
+ if value is None:
273
+ return None
274
+ if value.isdigit():
275
+ return int(value) * 1000
276
+ try:
277
+ retry_at = parsedate_to_datetime(value)
278
+ except (TypeError, ValueError):
279
+ return None
280
+ now = datetime.now(tz=UTC)
281
+ return max(0, int((retry_at - now).total_seconds() * 1000))
282
+
283
+
284
+ def _should_retry_error(error: Exception) -> bool:
285
+ if isinstance(error, RateLimitError):
286
+ return True
287
+ if isinstance(error, ApiError):
288
+ return error.status >= 500
289
+ return False
290
+
291
+
292
+ def _retry_after_ms(error: Exception, attempt: int) -> int:
293
+ if isinstance(error, RateLimitError) and error.retry_after_ms is not None:
294
+ return error.retry_after_ms
295
+ return _backoff_ms(attempt)
296
+
297
+
298
+ def _backoff_ms(attempt: int) -> int:
299
+ base = min(500 * (2**attempt), 4000)
300
+ jitter = secrets.randbelow(max(base // 2, 1))
301
+ return base + jitter
302
+
303
+
304
+ def _sleep(duration_ms: int) -> None:
305
+ time.sleep(duration_ms / 1000)
306
+
307
+
308
+ __all__ = ["Transport", "TransportResponse"]