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 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
+ ]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.