indream-client 0.1.1__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.
- indream/__init__.py +15 -0
- indream/async_client.py +204 -0
- indream/client.py +117 -0
- indream/errors.py +69 -0
- indream/resources/__init__.py +1 -0
- indream/resources/editor.py +42 -0
- indream/resources/exports.py +99 -0
- indream/types.py +68 -0
- indream/webhooks.py +85 -0
- indream_client-0.1.1.dist-info/METADATA +52 -0
- indream_client-0.1.1.dist-info/RECORD +13 -0
- indream_client-0.1.1.dist-info/WHEEL +4 -0
- indream_client-0.1.1.dist-info/licenses/LICENSE +21 -0
indream/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from indream.async_client import AsyncIndreamClient
|
|
2
|
+
from indream.client import IndreamClient
|
|
3
|
+
from indream.errors import APIError, AuthError, RateLimitError, ValidationError
|
|
4
|
+
from indream.webhooks import verify_export_webhook_request, verify_export_webhook_signature
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"IndreamClient",
|
|
8
|
+
"AsyncIndreamClient",
|
|
9
|
+
"APIError",
|
|
10
|
+
"AuthError",
|
|
11
|
+
"RateLimitError",
|
|
12
|
+
"ValidationError",
|
|
13
|
+
"verify_export_webhook_signature",
|
|
14
|
+
"verify_export_webhook_request",
|
|
15
|
+
]
|
indream/async_client.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from indream.errors import APIError, Problem, create_api_error
|
|
10
|
+
from indream.resources.editor import AsyncEditorResource
|
|
11
|
+
|
|
12
|
+
TERMINAL_STATUSES = {"COMPLETED", "FAILED", "CANCELED"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncExportsResource:
|
|
16
|
+
def __init__(self, request: Any, poll_interval: float) -> None:
|
|
17
|
+
self._request = request
|
|
18
|
+
self._poll_interval = poll_interval
|
|
19
|
+
|
|
20
|
+
async def create(
|
|
21
|
+
self,
|
|
22
|
+
payload: dict[str, Any],
|
|
23
|
+
idempotency_key: str | None = None,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
data = await self._request(
|
|
26
|
+
"POST",
|
|
27
|
+
"/v1/exports",
|
|
28
|
+
json=payload,
|
|
29
|
+
idempotency_key=idempotency_key,
|
|
30
|
+
)
|
|
31
|
+
if not isinstance(data, dict):
|
|
32
|
+
raise TypeError("Unexpected response payload")
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
async def get(self, task_id: str) -> dict[str, Any]:
|
|
36
|
+
data = await self._request("GET", f"/v1/exports/{task_id}")
|
|
37
|
+
if not isinstance(data, dict):
|
|
38
|
+
raise TypeError("Unexpected response payload")
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
async def list(
|
|
42
|
+
self,
|
|
43
|
+
page_size: int | None = None,
|
|
44
|
+
page_cursor: str | None = None,
|
|
45
|
+
created_by_api_key_id: str | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
query: list[str] = []
|
|
48
|
+
if page_size is not None:
|
|
49
|
+
query.append(f"pageSize={page_size}")
|
|
50
|
+
if page_cursor is not None:
|
|
51
|
+
query.append(f"pageCursor={page_cursor}")
|
|
52
|
+
if created_by_api_key_id is not None:
|
|
53
|
+
query.append(f"createdByApiKeyId={created_by_api_key_id}")
|
|
54
|
+
|
|
55
|
+
suffix = f"?{'&'.join(query)}" if query else ""
|
|
56
|
+
envelope = await self._request("GET", f"/v1/exports{suffix}", unwrap_data=False)
|
|
57
|
+
|
|
58
|
+
# Keep the response envelope so pagination cursor metadata stays available.
|
|
59
|
+
if not isinstance(envelope, dict):
|
|
60
|
+
raise TypeError("Unexpected response payload")
|
|
61
|
+
|
|
62
|
+
raw_items = envelope.get("data")
|
|
63
|
+
if not isinstance(raw_items, list):
|
|
64
|
+
raise TypeError("Unexpected response payload")
|
|
65
|
+
|
|
66
|
+
meta = envelope.get("meta")
|
|
67
|
+
if not isinstance(meta, dict):
|
|
68
|
+
raise TypeError("Unexpected response payload")
|
|
69
|
+
|
|
70
|
+
next_page_cursor = meta.get("nextPageCursor")
|
|
71
|
+
if next_page_cursor is not None and not isinstance(next_page_cursor, str):
|
|
72
|
+
raise TypeError("Unexpected response payload")
|
|
73
|
+
|
|
74
|
+
items = [item for item in raw_items if isinstance(item, dict)]
|
|
75
|
+
return {"items": items, "nextPageCursor": next_page_cursor}
|
|
76
|
+
|
|
77
|
+
async def wait(
|
|
78
|
+
self,
|
|
79
|
+
task_id: str,
|
|
80
|
+
timeout: float = 600,
|
|
81
|
+
poll_interval: float | None = None,
|
|
82
|
+
) -> dict[str, Any]:
|
|
83
|
+
interval = poll_interval if poll_interval is not None else self._poll_interval
|
|
84
|
+
loop = asyncio.get_running_loop()
|
|
85
|
+
started = loop.time()
|
|
86
|
+
|
|
87
|
+
while True:
|
|
88
|
+
if loop.time() - started > timeout:
|
|
89
|
+
raise TimeoutError(f"wait timeout after {timeout} seconds")
|
|
90
|
+
|
|
91
|
+
task = await self.get(task_id)
|
|
92
|
+
status = task.get("status")
|
|
93
|
+
if status in TERMINAL_STATUSES:
|
|
94
|
+
if status in {"FAILED", "CANCELED"}:
|
|
95
|
+
raise APIError(
|
|
96
|
+
Problem(
|
|
97
|
+
type="TASK_TERMINAL_FAILURE",
|
|
98
|
+
title="Task failed",
|
|
99
|
+
status=422,
|
|
100
|
+
detail=task.get("error") or f"Task ended with status {status}",
|
|
101
|
+
error_code="TASK_TERMINAL_FAILURE",
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return task
|
|
105
|
+
|
|
106
|
+
await asyncio.sleep(interval)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AsyncIndreamClient:
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
api_key: str,
|
|
113
|
+
*,
|
|
114
|
+
base_url: str = "https://api.indream.ai",
|
|
115
|
+
timeout: float = 60,
|
|
116
|
+
max_retries: int = 2,
|
|
117
|
+
poll_interval: float = 2,
|
|
118
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
if not api_key:
|
|
121
|
+
raise ValueError("api_key is required")
|
|
122
|
+
|
|
123
|
+
self._api_key = api_key
|
|
124
|
+
self._base_url = base_url.rstrip("/")
|
|
125
|
+
self._timeout = timeout
|
|
126
|
+
self._max_retries = max_retries
|
|
127
|
+
self._poll_interval = poll_interval
|
|
128
|
+
self._client = httpx.AsyncClient(
|
|
129
|
+
base_url=self._base_url,
|
|
130
|
+
timeout=self._timeout,
|
|
131
|
+
transport=transport,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self.exports = AsyncExportsResource(self._request, self._poll_interval)
|
|
135
|
+
self.editor = AsyncEditorResource(self._request)
|
|
136
|
+
|
|
137
|
+
async def aclose(self) -> None:
|
|
138
|
+
await self._client.aclose()
|
|
139
|
+
|
|
140
|
+
async def _request(
|
|
141
|
+
self,
|
|
142
|
+
method: str,
|
|
143
|
+
path: str,
|
|
144
|
+
*,
|
|
145
|
+
json: dict[str, Any] | None = None,
|
|
146
|
+
idempotency_key: str | None = None,
|
|
147
|
+
skip_retry: bool = False,
|
|
148
|
+
unwrap_data: bool = True,
|
|
149
|
+
) -> Any:
|
|
150
|
+
attempt = 0
|
|
151
|
+
|
|
152
|
+
while True:
|
|
153
|
+
try:
|
|
154
|
+
headers = {
|
|
155
|
+
"x-api-key": self._api_key,
|
|
156
|
+
"Accept": "application/json",
|
|
157
|
+
}
|
|
158
|
+
if idempotency_key:
|
|
159
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
160
|
+
|
|
161
|
+
response = await self._client.request(method, path, json=json, headers=headers)
|
|
162
|
+
payload = response.json() if response.content else {}
|
|
163
|
+
|
|
164
|
+
if response.status_code >= 400:
|
|
165
|
+
raise create_api_error(response.status_code, payload)
|
|
166
|
+
|
|
167
|
+
if not isinstance(payload, dict) or "data" not in payload:
|
|
168
|
+
raise create_api_error(response.status_code, payload)
|
|
169
|
+
|
|
170
|
+
if "meta" not in payload:
|
|
171
|
+
raise create_api_error(response.status_code, payload)
|
|
172
|
+
|
|
173
|
+
if not unwrap_data:
|
|
174
|
+
return payload
|
|
175
|
+
|
|
176
|
+
data = payload["data"]
|
|
177
|
+
if not isinstance(data, dict):
|
|
178
|
+
raise create_api_error(response.status_code, payload)
|
|
179
|
+
|
|
180
|
+
return data
|
|
181
|
+
except APIError as error:
|
|
182
|
+
if skip_retry:
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
if not self._should_retry(error.status) or attempt >= self._max_retries:
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
await self._sleep_with_backoff(attempt)
|
|
189
|
+
attempt += 1
|
|
190
|
+
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout):
|
|
191
|
+
if skip_retry or attempt >= self._max_retries:
|
|
192
|
+
raise
|
|
193
|
+
await self._sleep_with_backoff(attempt)
|
|
194
|
+
attempt += 1
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _should_retry(status: int) -> bool:
|
|
198
|
+
return status == 429 or status >= 500
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
async def _sleep_with_backoff(attempt: int) -> None:
|
|
202
|
+
base = min(3.0, 0.3 * (2**attempt))
|
|
203
|
+
jitter = random.random() * 0.1
|
|
204
|
+
await asyncio.sleep(base + jitter)
|
indream/client.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from indream.errors import APIError, create_api_error
|
|
10
|
+
from indream.resources.editor import EditorResource
|
|
11
|
+
from indream.resources.exports import ExportsResource
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IndreamClient:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
api_key: str,
|
|
18
|
+
*,
|
|
19
|
+
base_url: str = "https://api.indream.ai",
|
|
20
|
+
timeout: float = 60,
|
|
21
|
+
max_retries: int = 2,
|
|
22
|
+
poll_interval: float = 2,
|
|
23
|
+
transport: httpx.BaseTransport | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
if not api_key:
|
|
26
|
+
raise ValueError("api_key is required")
|
|
27
|
+
|
|
28
|
+
self._api_key = api_key
|
|
29
|
+
self._base_url = base_url.rstrip("/")
|
|
30
|
+
self._timeout = timeout
|
|
31
|
+
self._max_retries = max_retries
|
|
32
|
+
self._poll_interval = poll_interval
|
|
33
|
+
self._client = httpx.Client(
|
|
34
|
+
base_url=self._base_url,
|
|
35
|
+
timeout=self._timeout,
|
|
36
|
+
transport=transport,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
self.exports = ExportsResource(self._request, self._poll_interval)
|
|
40
|
+
self.editor = EditorResource(self._request)
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
self._client.close()
|
|
44
|
+
|
|
45
|
+
def __enter__(self) -> IndreamClient:
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, *_: object) -> None:
|
|
49
|
+
self.close()
|
|
50
|
+
|
|
51
|
+
def _request(
|
|
52
|
+
self,
|
|
53
|
+
method: str,
|
|
54
|
+
path: str,
|
|
55
|
+
*,
|
|
56
|
+
json: dict[str, Any] | None = None,
|
|
57
|
+
idempotency_key: str | None = None,
|
|
58
|
+
skip_retry: bool = False,
|
|
59
|
+
unwrap_data: bool = True,
|
|
60
|
+
) -> Any:
|
|
61
|
+
attempt = 0
|
|
62
|
+
|
|
63
|
+
while True:
|
|
64
|
+
try:
|
|
65
|
+
headers = {
|
|
66
|
+
"x-api-key": self._api_key,
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
}
|
|
69
|
+
if idempotency_key:
|
|
70
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
71
|
+
|
|
72
|
+
response = self._client.request(method, path, json=json, headers=headers)
|
|
73
|
+
payload = response.json() if response.content else {}
|
|
74
|
+
|
|
75
|
+
if response.status_code >= 400:
|
|
76
|
+
raise create_api_error(response.status_code, payload)
|
|
77
|
+
|
|
78
|
+
if not isinstance(payload, dict) or "data" not in payload:
|
|
79
|
+
raise create_api_error(response.status_code, payload)
|
|
80
|
+
|
|
81
|
+
if "meta" not in payload:
|
|
82
|
+
raise create_api_error(response.status_code, payload)
|
|
83
|
+
|
|
84
|
+
if not unwrap_data:
|
|
85
|
+
return payload
|
|
86
|
+
|
|
87
|
+
data = payload["data"]
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
raise create_api_error(response.status_code, payload)
|
|
90
|
+
|
|
91
|
+
return data
|
|
92
|
+
except APIError as error:
|
|
93
|
+
if skip_retry:
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
if not self._should_retry(error.status) or attempt >= self._max_retries:
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
self._sleep_with_backoff(attempt)
|
|
100
|
+
attempt += 1
|
|
101
|
+
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout):
|
|
102
|
+
if skip_retry or attempt >= self._max_retries:
|
|
103
|
+
raise
|
|
104
|
+
self._sleep_with_backoff(attempt)
|
|
105
|
+
attempt += 1
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _should_retry(status: int) -> bool:
|
|
109
|
+
return status == 429 or status >= 500
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _sleep_with_backoff(attempt: int) -> None:
|
|
113
|
+
# Use exponential backoff with jitter to reduce retry bursts
|
|
114
|
+
# under shared throttling windows.
|
|
115
|
+
base = min(3.0, 0.3 * (2**attempt))
|
|
116
|
+
jitter = random.random() * 0.1
|
|
117
|
+
time.sleep(base + jitter)
|
indream/errors.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Problem:
|
|
9
|
+
type: str
|
|
10
|
+
title: str
|
|
11
|
+
status: int
|
|
12
|
+
detail: str
|
|
13
|
+
error_code: str | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(Exception):
|
|
17
|
+
def __init__(self, problem: Problem):
|
|
18
|
+
super().__init__(problem.detail)
|
|
19
|
+
self.problem = problem
|
|
20
|
+
self.status = problem.status
|
|
21
|
+
self.type = problem.type
|
|
22
|
+
self.error_code = problem.error_code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthError(APIError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ValidationError(APIError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RateLimitError(APIError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_problem(status: int, payload: Any) -> Problem:
|
|
38
|
+
if isinstance(payload, dict):
|
|
39
|
+
maybe_type = payload.get("type")
|
|
40
|
+
maybe_title = payload.get("title")
|
|
41
|
+
if isinstance(maybe_type, str) and isinstance(maybe_title, str):
|
|
42
|
+
return Problem(
|
|
43
|
+
type=maybe_type,
|
|
44
|
+
title=maybe_title,
|
|
45
|
+
status=int(payload.get("status") or status),
|
|
46
|
+
detail=str(payload.get("detail") or maybe_title),
|
|
47
|
+
error_code=payload.get("errorCode"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return Problem(
|
|
51
|
+
type="INTERNAL_ERROR",
|
|
52
|
+
title="Internal server error",
|
|
53
|
+
status=status,
|
|
54
|
+
detail="Unexpected API response",
|
|
55
|
+
error_code="SDK_UNEXPECTED_RESPONSE",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_api_error(status: int, payload: Any) -> APIError:
|
|
60
|
+
problem = parse_problem(status, payload)
|
|
61
|
+
|
|
62
|
+
if status in (401, 403):
|
|
63
|
+
return AuthError(problem)
|
|
64
|
+
if status in (400, 422):
|
|
65
|
+
return ValidationError(problem)
|
|
66
|
+
if status == 429:
|
|
67
|
+
return RateLimitError(problem)
|
|
68
|
+
|
|
69
|
+
return APIError(problem)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# resources package
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from indream.types import EditorCapabilities, EditorValidationResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EditorResource:
|
|
10
|
+
def __init__(self, request: Callable[..., dict[str, Any]]) -> None:
|
|
11
|
+
self._request = request
|
|
12
|
+
|
|
13
|
+
def capabilities(self) -> EditorCapabilities:
|
|
14
|
+
data = self._request("GET", "/v1/editor/capabilities")
|
|
15
|
+
return EditorCapabilities.model_validate(data)
|
|
16
|
+
|
|
17
|
+
def validate(self, editor_state: dict[str, Any]) -> EditorValidationResult:
|
|
18
|
+
data = self._request(
|
|
19
|
+
"POST",
|
|
20
|
+
"/v1/editor/validate",
|
|
21
|
+
json={"editorState": editor_state},
|
|
22
|
+
skip_retry=True,
|
|
23
|
+
)
|
|
24
|
+
return EditorValidationResult.model_validate(data)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AsyncEditorResource:
|
|
28
|
+
def __init__(self, request: Callable[..., Any]) -> None:
|
|
29
|
+
self._request = request
|
|
30
|
+
|
|
31
|
+
async def capabilities(self) -> EditorCapabilities:
|
|
32
|
+
data = await self._request("GET", "/v1/editor/capabilities")
|
|
33
|
+
return EditorCapabilities.model_validate(data)
|
|
34
|
+
|
|
35
|
+
async def validate(self, editor_state: dict[str, Any]) -> EditorValidationResult:
|
|
36
|
+
data = await self._request(
|
|
37
|
+
"POST",
|
|
38
|
+
"/v1/editor/validate",
|
|
39
|
+
json={"editorState": editor_state},
|
|
40
|
+
skip_retry=True,
|
|
41
|
+
)
|
|
42
|
+
return EditorValidationResult.model_validate(data)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from indream.errors import APIError, Problem
|
|
8
|
+
from indream.types import ExportCreateResponse, ExportTask, ExportTaskListResponse
|
|
9
|
+
|
|
10
|
+
TERMINAL_STATUSES = {"COMPLETED", "FAILED", "CANCELED"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExportsResource:
|
|
14
|
+
def __init__(self, request: Callable[..., Any], poll_interval: float) -> None:
|
|
15
|
+
self._request = request
|
|
16
|
+
self._poll_interval = poll_interval
|
|
17
|
+
|
|
18
|
+
def create(
|
|
19
|
+
self,
|
|
20
|
+
payload: dict[str, Any],
|
|
21
|
+
idempotency_key: str | None = None,
|
|
22
|
+
) -> ExportCreateResponse:
|
|
23
|
+
data = self._request(
|
|
24
|
+
"POST",
|
|
25
|
+
"/v1/exports",
|
|
26
|
+
json=payload,
|
|
27
|
+
idempotency_key=idempotency_key,
|
|
28
|
+
)
|
|
29
|
+
return ExportCreateResponse.model_validate(data)
|
|
30
|
+
|
|
31
|
+
def get(self, task_id: str) -> ExportTask:
|
|
32
|
+
data = self._request("GET", f"/v1/exports/{task_id}")
|
|
33
|
+
return ExportTask.model_validate(data)
|
|
34
|
+
|
|
35
|
+
def list(
|
|
36
|
+
self,
|
|
37
|
+
page_size: int | None = None,
|
|
38
|
+
page_cursor: str | None = None,
|
|
39
|
+
created_by_api_key_id: str | None = None,
|
|
40
|
+
) -> ExportTaskListResponse:
|
|
41
|
+
query: list[str] = []
|
|
42
|
+
if page_size is not None:
|
|
43
|
+
query.append(f"pageSize={page_size}")
|
|
44
|
+
if page_cursor is not None:
|
|
45
|
+
query.append(f"pageCursor={page_cursor}")
|
|
46
|
+
if created_by_api_key_id is not None:
|
|
47
|
+
query.append(f"createdByApiKeyId={created_by_api_key_id}")
|
|
48
|
+
|
|
49
|
+
suffix = f"?{'&'.join(query)}" if query else ""
|
|
50
|
+
envelope = self._request("GET", f"/v1/exports{suffix}", unwrap_data=False)
|
|
51
|
+
|
|
52
|
+
# Keep envelope meta for pagination cursor handling.
|
|
53
|
+
if not isinstance(envelope, dict):
|
|
54
|
+
raise TypeError("Unexpected response payload")
|
|
55
|
+
|
|
56
|
+
raw_items = envelope.get("data")
|
|
57
|
+
if not isinstance(raw_items, list):
|
|
58
|
+
raise TypeError("Unexpected response payload")
|
|
59
|
+
|
|
60
|
+
meta = envelope.get("meta")
|
|
61
|
+
if not isinstance(meta, dict):
|
|
62
|
+
raise TypeError("Unexpected response payload")
|
|
63
|
+
|
|
64
|
+
next_page_cursor = meta.get("nextPageCursor")
|
|
65
|
+
if next_page_cursor is not None and not isinstance(next_page_cursor, str):
|
|
66
|
+
raise TypeError("Unexpected response payload")
|
|
67
|
+
|
|
68
|
+
items = [ExportTask.model_validate(item) for item in raw_items]
|
|
69
|
+
return ExportTaskListResponse(items=items, nextPageCursor=next_page_cursor)
|
|
70
|
+
|
|
71
|
+
def wait(
|
|
72
|
+
self,
|
|
73
|
+
task_id: str,
|
|
74
|
+
timeout: float = 600,
|
|
75
|
+
poll_interval: float | None = None,
|
|
76
|
+
) -> ExportTask:
|
|
77
|
+
interval = poll_interval if poll_interval is not None else self._poll_interval
|
|
78
|
+
started = time.time()
|
|
79
|
+
|
|
80
|
+
# Polling follows the server task state machine and stops only on terminal states.
|
|
81
|
+
while True:
|
|
82
|
+
if time.time() - started > timeout:
|
|
83
|
+
raise TimeoutError(f"wait timeout after {timeout} seconds")
|
|
84
|
+
|
|
85
|
+
task = self.get(task_id)
|
|
86
|
+
if task.status in TERMINAL_STATUSES:
|
|
87
|
+
if task.status in {"FAILED", "CANCELED"}:
|
|
88
|
+
raise APIError(
|
|
89
|
+
Problem(
|
|
90
|
+
type="TASK_TERMINAL_FAILURE",
|
|
91
|
+
title="Task failed",
|
|
92
|
+
status=422,
|
|
93
|
+
detail=task.error or f"Task ended with status {task.status}",
|
|
94
|
+
error_code="TASK_TERMINAL_FAILURE",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return task
|
|
98
|
+
|
|
99
|
+
time.sleep(interval)
|
indream/types.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
TaskStatus = Literal["PENDING", "PROCESSING", "COMPLETED", "FAILED", "PAUSED", "CANCELED"]
|
|
8
|
+
WebhookEventType = Literal["EXPORT_STARTED", "EXPORT_COMPLETED", "EXPORT_FAILED"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExportCreateResponse(BaseModel):
|
|
12
|
+
task_id: str = Field(alias="taskId")
|
|
13
|
+
created_at: str = Field(alias="createdAt")
|
|
14
|
+
duration_seconds: float = Field(alias="durationSeconds")
|
|
15
|
+
billed_standard_seconds: int = Field(alias="billedStandardSeconds")
|
|
16
|
+
charged_credits: str = Field(alias="chargedCredits")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExportTask(BaseModel):
|
|
20
|
+
task_id: str = Field(alias="taskId")
|
|
21
|
+
created_by_api_key_id: str | None = Field(alias="createdByApiKeyId")
|
|
22
|
+
client_task_id: str | None = Field(alias="clientTaskId")
|
|
23
|
+
status: TaskStatus
|
|
24
|
+
progress: float
|
|
25
|
+
error: str | None
|
|
26
|
+
output_url: str | None = Field(alias="outputUrl")
|
|
27
|
+
duration_seconds: float = Field(alias="durationSeconds")
|
|
28
|
+
billed_standard_seconds: int = Field(alias="billedStandardSeconds")
|
|
29
|
+
charged_credits: str = Field(alias="chargedCredits")
|
|
30
|
+
callback_url: str | None = Field(alias="callbackUrl")
|
|
31
|
+
created_at: str = Field(alias="createdAt")
|
|
32
|
+
completed_at: str | None = Field(alias="completedAt")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExportTaskListResponse(BaseModel):
|
|
36
|
+
items: list[ExportTask]
|
|
37
|
+
next_page_cursor: str | None = Field(alias="nextPageCursor")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExportWebhookEvent(BaseModel):
|
|
41
|
+
event_type: WebhookEventType = Field(alias="eventType")
|
|
42
|
+
occurred_at: str = Field(alias="occurredAt")
|
|
43
|
+
task: ExportTask
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EditorCapabilities(BaseModel):
|
|
47
|
+
version: str
|
|
48
|
+
animations: list[str]
|
|
49
|
+
transitions: list[str]
|
|
50
|
+
transition_presets: list[dict[str, Any]] = Field(alias="transitionPresets")
|
|
51
|
+
effects: list[str]
|
|
52
|
+
effect_presets: list[dict[str, Any]] = Field(alias="effectPresets")
|
|
53
|
+
filters: list[str]
|
|
54
|
+
filter_presets: list[dict[str, Any]] = Field(alias="filterPresets")
|
|
55
|
+
shapes: list[str]
|
|
56
|
+
background_presets: dict[str, Any] = Field(alias="backgroundPresets")
|
|
57
|
+
illustrations: list[str]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EditorValidationError(BaseModel):
|
|
61
|
+
code: str
|
|
62
|
+
path: str
|
|
63
|
+
message: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class EditorValidationResult(BaseModel):
|
|
67
|
+
valid: bool
|
|
68
|
+
errors: list[EditorValidationError]
|
indream/webhooks.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
|
|
8
|
+
INDREAM_WEBHOOK_TIMESTAMP_HEADER = "x-indream-timestamp"
|
|
9
|
+
INDREAM_WEBHOOK_SIGNATURE_HEADER = "x-indream-signature"
|
|
10
|
+
DEFAULT_WEBHOOK_MAX_SKEW_SECONDS = 300
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve_header_value(headers: Mapping[str, object], target_key: str) -> str | None:
|
|
14
|
+
lower_key = target_key.lower()
|
|
15
|
+
for key, value in headers.items():
|
|
16
|
+
if key.lower() != lower_key:
|
|
17
|
+
continue
|
|
18
|
+
if isinstance(value, list | tuple):
|
|
19
|
+
value = value[0] if value else None
|
|
20
|
+
if isinstance(value, bytes):
|
|
21
|
+
try:
|
|
22
|
+
value = value.decode("utf-8")
|
|
23
|
+
except UnicodeDecodeError:
|
|
24
|
+
return None
|
|
25
|
+
if not isinstance(value, str):
|
|
26
|
+
return None
|
|
27
|
+
normalized = value.strip()
|
|
28
|
+
return normalized or None
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def verify_export_webhook_signature(
|
|
33
|
+
*,
|
|
34
|
+
webhook_secret: str,
|
|
35
|
+
timestamp: str,
|
|
36
|
+
raw_body: str,
|
|
37
|
+
signature: str,
|
|
38
|
+
) -> bool:
|
|
39
|
+
if not webhook_secret or not timestamp or not signature:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
normalized_signature = signature.strip().lower()
|
|
43
|
+
if len(normalized_signature) != 64:
|
|
44
|
+
return False
|
|
45
|
+
if any(ch not in "0123456789abcdef" for ch in normalized_signature):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
payload = f"{timestamp}.{raw_body}".encode()
|
|
49
|
+
expected = hmac.new(webhook_secret.encode(), payload, sha256).hexdigest()
|
|
50
|
+
return hmac.compare_digest(expected, normalized_signature)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def verify_export_webhook_request(
|
|
54
|
+
*,
|
|
55
|
+
webhook_secret: str,
|
|
56
|
+
raw_body: str,
|
|
57
|
+
headers: Mapping[str, object],
|
|
58
|
+
max_skew_seconds: int = DEFAULT_WEBHOOK_MAX_SKEW_SECONDS,
|
|
59
|
+
now_timestamp_seconds: int | None = None,
|
|
60
|
+
) -> bool:
|
|
61
|
+
timestamp = _resolve_header_value(headers, INDREAM_WEBHOOK_TIMESTAMP_HEADER)
|
|
62
|
+
signature = _resolve_header_value(headers, INDREAM_WEBHOOK_SIGNATURE_HEADER)
|
|
63
|
+
if timestamp is None or signature is None:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
if not timestamp.isdigit():
|
|
67
|
+
return False
|
|
68
|
+
timestamp_seconds = int(timestamp)
|
|
69
|
+
|
|
70
|
+
if max_skew_seconds < 0:
|
|
71
|
+
return False
|
|
72
|
+
current_timestamp = int(time.time()) if now_timestamp_seconds is None else now_timestamp_seconds
|
|
73
|
+
if current_timestamp < 0:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
# Enforce a bounded timestamp window to reduce replay risk.
|
|
77
|
+
if abs(current_timestamp - timestamp_seconds) > max_skew_seconds:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
return verify_export_webhook_signature(
|
|
81
|
+
webhook_secret=webhook_secret,
|
|
82
|
+
timestamp=timestamp,
|
|
83
|
+
raw_body=raw_body,
|
|
84
|
+
signature=signature,
|
|
85
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: indream-client
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Official Python client for Indream Open API
|
|
5
|
+
Project-URL: Homepage, https://github.com/indreamai/indream-client-python
|
|
6
|
+
Project-URL: Repository, https://github.com/indreamai/indream-client-python
|
|
7
|
+
Project-URL: Issues, https://github.com/indreamai/indream-client-python/issues
|
|
8
|
+
Author: Indream
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx<1.0.0,>=0.27.0
|
|
13
|
+
Requires-Dist: pydantic<3.0.0,>=2.9.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: mypy<2.0.0,>=1.13.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest<9.0.0,>=8.3.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff<0.10.0,>=0.8.0; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# indream-client
|
|
21
|
+
|
|
22
|
+
Official Python client for the Indream Open API.
|
|
23
|
+
|
|
24
|
+
- API docs: https://docs.indream.ai
|
|
25
|
+
- Supports Python 3.10+
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install indream-client
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from indream import IndreamClient
|
|
37
|
+
|
|
38
|
+
client = IndreamClient(api_key="YOUR_INDREAM_API_KEY")
|
|
39
|
+
|
|
40
|
+
created = client.exports.create(
|
|
41
|
+
{
|
|
42
|
+
"editorState": editor_state,
|
|
43
|
+
"ratio": "9:16",
|
|
44
|
+
"scale": 0.6,
|
|
45
|
+
"fps": 30,
|
|
46
|
+
"format": "mp4",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
task = client.exports.wait(created.task_id)
|
|
51
|
+
print(task.status, task.output_url, task.duration_seconds, task.billed_standard_seconds)
|
|
52
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
indream/__init__.py,sha256=l8e59vIHORfwNPSshQdKoTBqET8jyiez_VYO-G1XT7U,481
|
|
2
|
+
indream/async_client.py,sha256=z8n7AASc9wxXtq-z88Ap60wrnRTOgm52Rz3lTtS1ac4,6903
|
|
3
|
+
indream/client.py,sha256=JuUvlzoBHdKIBPjX6UuguWt8PDfqgjQ8O69PMmDgI40,3616
|
|
4
|
+
indream/errors.py,sha256=1YxBbotJCcARQZ2o1suUtnodJtUu-vi0pk8zAqQeD0k,1679
|
|
5
|
+
indream/types.py,sha256=dD_ACdqgPaCs_DxEyqPL58muZI7R0QlOkEN1kHF-Pa0,2273
|
|
6
|
+
indream/webhooks.py,sha256=WnoWPmdvkjD__IRvGN7v0lgcvIJImlAuu4tP5kK72Qo,2629
|
|
7
|
+
indream/resources/__init__.py,sha256=CRcYDTGDql0LqcJyWH1zHmglD3W6L8_Sm3w48XgzNJs,20
|
|
8
|
+
indream/resources/editor.py,sha256=YuYaI3yFOLXUaM10vpK8gNP_R3l41Dq3VBZKlDizQ6k,1402
|
|
9
|
+
indream/resources/exports.py,sha256=nNS1JaWXdbgwxAp_WIXIBelVWpIFt72MlEpgMlCNmQk,3562
|
|
10
|
+
indream_client-0.1.1.dist-info/METADATA,sha256=NhuBkagdJQ0q64qF4Bpd0NqziRUSmqIHu4RIKCPMHgo,1320
|
|
11
|
+
indream_client-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
indream_client-0.1.1.dist-info/licenses/LICENSE,sha256=_GA8zJOuSL-VraB_nHnM0BtI4B4ZMVhqc74HgBe-rL4,1064
|
|
13
|
+
indream_client-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Indream
|
|
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.
|