aimage-sdk 0.1.0__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.

Potentially problematic release.


This version of aimage-sdk might be problematic. Click here for more details.

@@ -0,0 +1,67 @@
1
+ # Dependencies
2
+ node_modules/
3
+ .pnpm-store/
4
+
5
+ # Build outputs
6
+ dist/
7
+ .next/
8
+ .turbo/
9
+ out/
10
+ next-env.d.ts
11
+ storybook-static/
12
+
13
+ # Python
14
+ __pycache__/
15
+ *.pyc
16
+ *.pyo
17
+ .venv/
18
+ venv/
19
+ *.egg-info/
20
+
21
+ # Terraform
22
+ .terraform/
23
+ *.tfstate
24
+ *.tfstate.backup
25
+ *.tfplan
26
+ tfplan-*
27
+
28
+ # Environment
29
+ .env
30
+ .env.*
31
+ !.env.example
32
+
33
+ # IDE
34
+ .idea/
35
+ .vscode/
36
+ *.swp
37
+ *.swo
38
+
39
+ # OS
40
+ .DS_Store
41
+ Thumbs.db
42
+
43
+ # Test & coverage
44
+ coverage/
45
+ .vitest/
46
+
47
+ # Lint cache
48
+ .eslintcache
49
+
50
+ # Misc
51
+ *.log
52
+ *.tsbuildinfo
53
+
54
+
55
+ # Rust
56
+ /target
57
+ infra/tfplan
58
+
59
+ # Local-only artifacts
60
+ .claude/scheduled_tasks.lock
61
+ .claude/settings.local.json
62
+ .playwright-mcp/
63
+ insights-*.png
64
+ tsup.config.bundled_*.mjs
65
+ audit/
66
+ .bdd-logs/
67
+ .bdd-snapshots/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AI-Mage Inc.
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.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: aimage-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the AI-Mage Search public API
5
+ Project-URL: Documentation, https://api.search.ai-mage.jp/docs
6
+ Project-URL: Repository, https://github.com/aimagexyz/aimage-monorepo
7
+ Author: AI-Mage
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: httpx<1,>=0.28
17
+ Description-Content-Type: text/markdown
18
+
19
+ # aimage-sdk
20
+
21
+ Python SDK for the AI-Mage Search public API (`/v0`). Python 3.12+, `httpx`
22
+ is the only dependency.
23
+
24
+ ## Install
25
+
26
+ The package is not yet published to PyPI. Install from the repository:
27
+
28
+ ```sh
29
+ uv add "aimage-sdk @ file:///path/to/aimage-monorepo/sdks/python"
30
+ # or: pip install /path/to/aimage-monorepo/sdks/python
31
+ ```
32
+
33
+ ## Quickstart
34
+
35
+ ```python
36
+ import os
37
+
38
+ from aimage_sdk import AimageClient
39
+
40
+ client = AimageClient(
41
+ api_key=os.environ["AIMAGE_API_KEY"], # "aimage_api_..."
42
+ base_url="https://api.search.ai-mage.jp", # API server origin; the client calls {base_url}/v0/...
43
+ )
44
+
45
+ # Discover accessible projects.
46
+ me = client.me()
47
+ project_id = me["projects"][0]["id"]
48
+
49
+ # Upload a video: mints a presigned URL, PUTs the bytes, registers the video.
50
+ video = client.upload_video(
51
+ project_id=project_id,
52
+ file="./episode-01.mp4", # str | pathlib.Path | bytes (bytes need file_name=)
53
+ name="Episode 1",
54
+ season=1,
55
+ episode=1,
56
+ on_progress=print,
57
+ )
58
+
59
+ # Wait until clip extraction and AI tagging finish.
60
+ processed = client.wait_for_processing(video["id"], poll_interval_s=10, timeout_s=1800)
61
+ if processed["processing_status"] == "failed":
62
+ raise RuntimeError(processed["processing_error"])
63
+
64
+ # Search clips with a natural-language query.
65
+ result = client.search_clips(project_id=project_id, query="two characters arguing in the rain")
66
+ for clip in result["clips"]:
67
+ print(clip["start_time"], clip["subtitle"], clip["tags"], clip["characters"])
68
+ ```
69
+
70
+ ## API surface
71
+
72
+ - `ping()` / `me()`
73
+ - `mint_upload_url(project_id=, file_name=, content_type=)`
74
+ - `register_video(project_id=, name=, file_name=|s3_key=, season=, episode=, auto_process=)`
75
+ - `get_video(video_id)` / `list_videos(project_id=, page=, page_size=)` / `delete_video(video_id)`
76
+ - `search_clips(project_id=, query=, page=, page_size=)`
77
+ - `upload_video(project_id=, file=, name=, ...)` — full 3-step upload orchestration
78
+ - `wait_for_processing(video_id, poll_interval_s=, timeout_s=)`
79
+
80
+ Failed API calls raise `AimageApiError` with `code`, `message`, and HTTP
81
+ `status` parsed from the `{"error": {"code", "message"}}` envelope.
82
+
83
+ ## Development
84
+
85
+ ```sh
86
+ cd sdks/python
87
+ uv sync
88
+ uv run pytest
89
+ uv run ruff check .
90
+ ```
@@ -0,0 +1,72 @@
1
+ # aimage-sdk
2
+
3
+ Python SDK for the AI-Mage Search public API (`/v0`). Python 3.12+, `httpx`
4
+ is the only dependency.
5
+
6
+ ## Install
7
+
8
+ The package is not yet published to PyPI. Install from the repository:
9
+
10
+ ```sh
11
+ uv add "aimage-sdk @ file:///path/to/aimage-monorepo/sdks/python"
12
+ # or: pip install /path/to/aimage-monorepo/sdks/python
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```python
18
+ import os
19
+
20
+ from aimage_sdk import AimageClient
21
+
22
+ client = AimageClient(
23
+ api_key=os.environ["AIMAGE_API_KEY"], # "aimage_api_..."
24
+ base_url="https://api.search.ai-mage.jp", # API server origin; the client calls {base_url}/v0/...
25
+ )
26
+
27
+ # Discover accessible projects.
28
+ me = client.me()
29
+ project_id = me["projects"][0]["id"]
30
+
31
+ # Upload a video: mints a presigned URL, PUTs the bytes, registers the video.
32
+ video = client.upload_video(
33
+ project_id=project_id,
34
+ file="./episode-01.mp4", # str | pathlib.Path | bytes (bytes need file_name=)
35
+ name="Episode 1",
36
+ season=1,
37
+ episode=1,
38
+ on_progress=print,
39
+ )
40
+
41
+ # Wait until clip extraction and AI tagging finish.
42
+ processed = client.wait_for_processing(video["id"], poll_interval_s=10, timeout_s=1800)
43
+ if processed["processing_status"] == "failed":
44
+ raise RuntimeError(processed["processing_error"])
45
+
46
+ # Search clips with a natural-language query.
47
+ result = client.search_clips(project_id=project_id, query="two characters arguing in the rain")
48
+ for clip in result["clips"]:
49
+ print(clip["start_time"], clip["subtitle"], clip["tags"], clip["characters"])
50
+ ```
51
+
52
+ ## API surface
53
+
54
+ - `ping()` / `me()`
55
+ - `mint_upload_url(project_id=, file_name=, content_type=)`
56
+ - `register_video(project_id=, name=, file_name=|s3_key=, season=, episode=, auto_process=)`
57
+ - `get_video(video_id)` / `list_videos(project_id=, page=, page_size=)` / `delete_video(video_id)`
58
+ - `search_clips(project_id=, query=, page=, page_size=)`
59
+ - `upload_video(project_id=, file=, name=, ...)` — full 3-step upload orchestration
60
+ - `wait_for_processing(video_id, poll_interval_s=, timeout_s=)`
61
+
62
+ Failed API calls raise `AimageApiError` with `code`, `message`, and HTTP
63
+ `status` parsed from the `{"error": {"code", "message"}}` envelope.
64
+
65
+ ## Development
66
+
67
+ ```sh
68
+ cd sdks/python
69
+ uv sync
70
+ uv run pytest
71
+ uv run ruff check .
72
+ ```
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "aimage-sdk"
3
+ version = "0.1.0"
4
+ description = "Python SDK for the AI-Mage Search public API"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "AI-Mage" }]
9
+ requires-python = ">=3.12"
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Typing :: Typed",
16
+ ]
17
+ dependencies = [
18
+ "httpx>=0.28,<1",
19
+ ]
20
+
21
+ [project.urls]
22
+ Documentation = "https://api.search.ai-mage.jp/docs"
23
+ Repository = "https://github.com/aimagexyz/aimage-monorepo"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "pytest>=9,<10",
28
+ "ruff>=0.7,<1",
29
+ ]
30
+
31
+ [tool.uv]
32
+ default-groups = ["dev"]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py312"
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "B", "UP"]
@@ -0,0 +1,34 @@
1
+ """Python SDK for the AI-Mage Search public API."""
2
+
3
+ from .client import AimageClient, ProgressStage
4
+ from .errors import AimageApiError
5
+ from .types import (
6
+ Clip,
7
+ DeleteVideoResponse,
8
+ MeProject,
9
+ MeResponse,
10
+ PingResponse,
11
+ ProcessingStatus,
12
+ ProjectRole,
13
+ SearchClipsResponse,
14
+ UploadUrlResponse,
15
+ Video,
16
+ VideoListResponse,
17
+ )
18
+
19
+ __all__ = [
20
+ "AimageApiError",
21
+ "AimageClient",
22
+ "Clip",
23
+ "DeleteVideoResponse",
24
+ "MeProject",
25
+ "MeResponse",
26
+ "PingResponse",
27
+ "ProcessingStatus",
28
+ "ProgressStage",
29
+ "ProjectRole",
30
+ "SearchClipsResponse",
31
+ "UploadUrlResponse",
32
+ "Video",
33
+ "VideoListResponse",
34
+ ]
@@ -0,0 +1,236 @@
1
+ import mimetypes
2
+ import time
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+ from typing import Any, Literal, cast
6
+
7
+ import httpx
8
+
9
+ from .errors import AimageApiError
10
+ from .types import (
11
+ DeleteVideoResponse,
12
+ MeResponse,
13
+ PingResponse,
14
+ SearchClipsResponse,
15
+ UploadUrlResponse,
16
+ Video,
17
+ VideoListResponse,
18
+ )
19
+
20
+ ProgressStage = Literal["minting_upload_url", "uploading", "registering", "done"]
21
+
22
+
23
+ class AimageClient:
24
+ """Thin client for the AI-Mage Search public API (/v0), httpx-based.
25
+
26
+ ``base_url`` is the API server origin, e.g. ``https://api.search.ai-mage.jp``;
27
+ the client calls ``{base_url}/v0/...``. ``transport`` exists for testing
28
+ with ``httpx.MockTransport``.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ api_key: str,
34
+ base_url: str,
35
+ *,
36
+ timeout: float = 30.0,
37
+ transport: httpx.BaseTransport | None = None,
38
+ ) -> None:
39
+ self._api_key = api_key
40
+ self._base_url = base_url.rstrip("/")
41
+ # No default Authorization header: presigned S3 PUTs go through the
42
+ # same client and S3 rejects requests carrying both auth mechanisms.
43
+ self._client = httpx.Client(timeout=timeout, transport=transport)
44
+
45
+ def close(self) -> None:
46
+ self._client.close()
47
+
48
+ def __enter__(self) -> "AimageClient":
49
+ return self
50
+
51
+ def __exit__(self, *exc_info: object) -> None:
52
+ self.close()
53
+
54
+ # -- thin endpoint wrappers ------------------------------------------------
55
+
56
+ def ping(self) -> PingResponse:
57
+ return cast(PingResponse, self._request("GET", "/ping"))
58
+
59
+ def me(self) -> MeResponse:
60
+ return cast(MeResponse, self._request("GET", "/me"))
61
+
62
+ def mint_upload_url(
63
+ self, *, project_id: str, file_name: str, content_type: str
64
+ ) -> UploadUrlResponse:
65
+ body = {"project_id": project_id, "file_name": file_name, "content_type": content_type}
66
+ return cast(UploadUrlResponse, self._request("POST", "/videos/upload-url", json=body))
67
+
68
+ def register_video(
69
+ self,
70
+ *,
71
+ project_id: str,
72
+ name: str,
73
+ file_name: str | None = None,
74
+ s3_key: str | None = None,
75
+ season: int | None = None,
76
+ episode: int | None = None,
77
+ auto_process: bool = True,
78
+ ) -> Video:
79
+ if (file_name is None) == (s3_key is None):
80
+ raise ValueError("Provide exactly one of file_name or s3_key.")
81
+ body: dict[str, Any] = {
82
+ "project_id": project_id,
83
+ "name": name,
84
+ "auto_process": auto_process,
85
+ }
86
+ if file_name is not None:
87
+ body["file_name"] = file_name
88
+ if s3_key is not None:
89
+ body["s3_key"] = s3_key
90
+ if season is not None:
91
+ body["season"] = season
92
+ if episode is not None:
93
+ body["episode"] = episode
94
+ return cast(Video, self._request("POST", "/videos", json=body))
95
+
96
+ def get_video(self, video_id: str) -> Video:
97
+ return cast(Video, self._request("GET", f"/videos/{video_id}"))
98
+
99
+ def list_videos(
100
+ self, *, project_id: str, page: int = 1, page_size: int = 20
101
+ ) -> VideoListResponse:
102
+ params = {"project_id": project_id, "page": page, "page_size": page_size}
103
+ return cast(VideoListResponse, self._request("GET", "/videos", params=params))
104
+
105
+ def delete_video(self, video_id: str) -> DeleteVideoResponse:
106
+ return cast(DeleteVideoResponse, self._request("DELETE", f"/videos/{video_id}"))
107
+
108
+ def search_clips(
109
+ self, *, project_id: str, query: str, page: int = 1, page_size: int = 20
110
+ ) -> SearchClipsResponse:
111
+ body = {"project_id": project_id, "query": query, "page": page, "page_size": page_size}
112
+ return cast(SearchClipsResponse, self._request("POST", "/search/clips", json=body))
113
+
114
+ # -- high-level orchestration ----------------------------------------------
115
+
116
+ def upload_video(
117
+ self,
118
+ *,
119
+ project_id: str,
120
+ file: str | Path | bytes,
121
+ name: str,
122
+ file_name: str | None = None,
123
+ content_type: str | None = None,
124
+ season: int | None = None,
125
+ episode: int | None = None,
126
+ auto_process: bool = True,
127
+ on_progress: Callable[[ProgressStage], None] | None = None,
128
+ ) -> Video:
129
+ """Full upload orchestration: mint a presigned URL, PUT the file bytes
130
+ with the exact Content-Type, then register the video via its s3_key.
131
+ """
132
+ if isinstance(file, bytes):
133
+ if file_name is None:
134
+ raise ValueError("file_name is required when uploading bytes.")
135
+ data = file
136
+ else:
137
+ path = Path(file)
138
+ file_name = file_name or path.name
139
+ data = path.read_bytes()
140
+
141
+ if content_type is None:
142
+ content_type, _ = mimetypes.guess_type(file_name)
143
+ if content_type is None:
144
+ raise ValueError(
145
+ f'Cannot infer content_type from file name "{file_name}". Pass it explicitly.'
146
+ )
147
+
148
+ if on_progress:
149
+ on_progress("minting_upload_url")
150
+ minted = self.mint_upload_url(
151
+ project_id=project_id, file_name=file_name, content_type=content_type
152
+ )
153
+
154
+ if on_progress:
155
+ on_progress("uploading")
156
+ upload_response = self._client.put(
157
+ minted["upload_url"], content=data, headers={"Content-Type": content_type}
158
+ )
159
+ if upload_response.is_error:
160
+ raise AimageApiError(
161
+ code="UPLOAD_FAILED",
162
+ message=f"Upload to presigned URL failed with status {upload_response.status_code}",
163
+ status=upload_response.status_code,
164
+ )
165
+
166
+ if on_progress:
167
+ on_progress("registering")
168
+ video = self.register_video(
169
+ project_id=project_id,
170
+ name=name,
171
+ s3_key=minted["s3_key"],
172
+ season=season,
173
+ episode=episode,
174
+ auto_process=auto_process,
175
+ )
176
+
177
+ if on_progress:
178
+ on_progress("done")
179
+ return video
180
+
181
+ def wait_for_processing(
182
+ self,
183
+ video_id: str,
184
+ *,
185
+ poll_interval_s: float = 5.0,
186
+ timeout_s: float = 600.0,
187
+ ) -> Video:
188
+ """Polls GET /videos/{id} until processing reaches a terminal state and
189
+ returns the final video — check processing_status ('completed' or
190
+ 'failed') and processing_error yourself. Raises TimeoutError on timeout.
191
+ """
192
+ deadline = time.monotonic() + timeout_s
193
+ while True:
194
+ video = self.get_video(video_id)
195
+ if video["processing_status"] in ("completed", "failed"):
196
+ return video
197
+ if time.monotonic() + poll_interval_s >= deadline:
198
+ raise TimeoutError(
199
+ f"Timed out waiting for video {video_id} to finish processing "
200
+ f"after {timeout_s}s"
201
+ )
202
+ time.sleep(poll_interval_s)
203
+
204
+ # -- internals ---------------------------------------------------------------
205
+
206
+ def _request(
207
+ self,
208
+ method: str,
209
+ path: str,
210
+ *,
211
+ params: dict[str, Any] | None = None,
212
+ json: dict[str, Any] | None = None,
213
+ ) -> Any:
214
+ response = self._client.request(
215
+ method,
216
+ f"{self._base_url}/v0{path}",
217
+ params=params,
218
+ json=json,
219
+ headers={"Authorization": f"Bearer {self._api_key}"},
220
+ )
221
+ if response.is_error:
222
+ raise _to_api_error(response)
223
+ return response.json()
224
+
225
+
226
+ def _to_api_error(response: httpx.Response) -> AimageApiError:
227
+ code = "UNKNOWN"
228
+ message = f"Request failed with status {response.status_code}"
229
+ try:
230
+ envelope = response.json().get("error")
231
+ if isinstance(envelope, dict):
232
+ code = str(envelope.get("code", code))
233
+ message = str(envelope.get("message", message))
234
+ except ValueError:
235
+ pass
236
+ return AimageApiError(code=code, message=message, status=response.status_code)
@@ -0,0 +1,12 @@
1
+ class AimageApiError(Exception):
2
+ """Raised when the AI-Mage Search API responds with a non-2xx status.
3
+
4
+ ``code`` and ``message`` come from the API error envelope
5
+ ``{"error": {"code", "message"}}``; ``status`` is the HTTP status code.
6
+ """
7
+
8
+ def __init__(self, code: str, message: str, status: int) -> None:
9
+ super().__init__(message)
10
+ self.code = code
11
+ self.message = message
12
+ self.status = status
@@ -0,0 +1,84 @@
1
+ """Wire-format types of the AI-Mage Search public API (/v0).
2
+
3
+ Field names are snake_case to match the HTTP payloads exactly; the source of
4
+ truth is packages/api-contract/src/schemas/public-api.ts in the monorepo.
5
+ """
6
+
7
+ from typing import Literal, TypedDict
8
+
9
+ ProcessingStatus = Literal["pending", "queued", "processing", "completed", "failed"]
10
+
11
+ ProjectRole = Literal["viewer", "admin"]
12
+
13
+
14
+ class Video(TypedDict):
15
+ id: str
16
+ project_id: str
17
+ name: str
18
+ season: int | None
19
+ episode: int | None
20
+ duration: int
21
+ width: int
22
+ height: int
23
+ processing_status: ProcessingStatus
24
+ processing_error: str | None
25
+ created_at: str
26
+
27
+
28
+ class Clip(TypedDict):
29
+ clip_id: str
30
+ video_id: str
31
+ project_id: str
32
+ start_time: float
33
+ end_time: float
34
+ subtitle: str | None
35
+ similarity: float
36
+ thumbnail_url: str
37
+ episode_title: str | None
38
+ season: int | None
39
+ episode: int | None
40
+ tags: list[str]
41
+ characters: list[str]
42
+
43
+
44
+ class UploadUrlResponse(TypedDict):
45
+ upload_url: str
46
+ s3_key: str
47
+ expires_at: str
48
+
49
+
50
+ class VideoListResponse(TypedDict):
51
+ videos: list[Video]
52
+ total: int
53
+ page: int
54
+ page_size: int
55
+
56
+
57
+ class DeleteVideoResponse(TypedDict):
58
+ deleted: Literal[True]
59
+ id: str
60
+
61
+
62
+ class SearchClipsResponse(TypedDict):
63
+ clips: list[Clip]
64
+ total_available: int
65
+ page: int
66
+ page_size: int
67
+
68
+
69
+ class MeProject(TypedDict):
70
+ id: str
71
+ name: str
72
+ role: ProjectRole
73
+
74
+
75
+ class MeResponse(TypedDict):
76
+ key_name: str
77
+ key_preview: str
78
+ projects: list[MeProject]
79
+
80
+
81
+ class PingResponse(TypedDict):
82
+ status: Literal["ok"]
83
+ version: str
84
+ time: str
@@ -0,0 +1,222 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+
6
+ from aimage_sdk import AimageApiError, AimageClient
7
+
8
+ BASE_URL = "https://api.test"
9
+ PROJECT_ID = "22222222-2222-2222-2222-222222222222"
10
+ VIDEO_ID = "11111111-1111-1111-1111-111111111111"
11
+
12
+ SAMPLE_VIDEO = {
13
+ "id": VIDEO_ID,
14
+ "project_id": PROJECT_ID,
15
+ "name": "Episode 1",
16
+ "season": 1,
17
+ "episode": 1,
18
+ "duration": 0,
19
+ "width": 0,
20
+ "height": 0,
21
+ "processing_status": "queued",
22
+ "processing_error": None,
23
+ "created_at": "2026-06-11T00:00:00.000Z",
24
+ }
25
+
26
+
27
+ def make_client(handler) -> AimageClient:
28
+ return AimageClient("aimage_api_test", BASE_URL, transport=httpx.MockTransport(handler))
29
+
30
+
31
+ def test_sends_bearer_token_and_parses_json() -> None:
32
+ seen: list[httpx.Request] = []
33
+
34
+ def handler(request: httpx.Request) -> httpx.Response:
35
+ seen.append(request)
36
+ return httpx.Response(
37
+ 200, json={"status": "ok", "version": "0.1.0", "time": "2026-06-11T00:00:00.000Z"}
38
+ )
39
+
40
+ with make_client(handler) as client:
41
+ result = client.ping()
42
+
43
+ assert result["status"] == "ok"
44
+ assert seen[0].url == f"{BASE_URL}/v0/ping"
45
+ assert seen[0].headers["authorization"] == "Bearer aimage_api_test"
46
+
47
+
48
+ def test_list_videos_sends_query_params() -> None:
49
+ seen: list[httpx.Request] = []
50
+
51
+ def handler(request: httpx.Request) -> httpx.Response:
52
+ seen.append(request)
53
+ return httpx.Response(200, json={"videos": [], "total": 0, "page": 2, "page_size": 50})
54
+
55
+ with make_client(handler) as client:
56
+ client.list_videos(project_id=PROJECT_ID, page=2, page_size=50)
57
+
58
+ params = dict(seen[0].url.params)
59
+ assert seen[0].url.path == "/v0/videos"
60
+ assert params == {"project_id": PROJECT_ID, "page": "2", "page_size": "50"}
61
+
62
+
63
+ def test_error_envelope_is_parsed() -> None:
64
+ def handler(request: httpx.Request) -> httpx.Response:
65
+ return httpx.Response(
66
+ 404, json={"error": {"code": "NOT_FOUND", "message": "Video not found"}}
67
+ )
68
+
69
+ with make_client(handler) as client:
70
+ with pytest.raises(AimageApiError) as exc_info:
71
+ client.get_video(VIDEO_ID)
72
+
73
+ assert exc_info.value.code == "NOT_FOUND"
74
+ assert exc_info.value.message == "Video not found"
75
+ assert exc_info.value.status == 404
76
+
77
+
78
+ def test_non_json_error_falls_back_to_unknown() -> None:
79
+ def handler(request: httpx.Request) -> httpx.Response:
80
+ return httpx.Response(500, text="oops")
81
+
82
+ with make_client(handler) as client:
83
+ with pytest.raises(AimageApiError) as exc_info:
84
+ client.ping()
85
+
86
+ assert exc_info.value.code == "UNKNOWN"
87
+ assert exc_info.value.status == 500
88
+
89
+
90
+ def test_register_video_requires_exactly_one_source() -> None:
91
+ def handler(request: httpx.Request) -> httpx.Response:
92
+ raise AssertionError("no request expected")
93
+
94
+ with make_client(handler) as client:
95
+ with pytest.raises(ValueError):
96
+ client.register_video(project_id=PROJECT_ID, name="Episode 1")
97
+ with pytest.raises(ValueError):
98
+ client.register_video(
99
+ project_id=PROJECT_ID, name="Episode 1", file_name="a.mp4", s3_key="k"
100
+ )
101
+
102
+
103
+ def test_upload_video_orchestration() -> None:
104
+ upload_url = "https://uploads.test/bucket/videos/abc.mp4?signature=xyz"
105
+ s3_key = "videos/22222222/abc.mp4"
106
+ seen: list[httpx.Request] = []
107
+
108
+ def handler(request: httpx.Request) -> httpx.Response:
109
+ seen.append(request)
110
+ if request.url.path == "/v0/videos/upload-url":
111
+ return httpx.Response(
112
+ 200,
113
+ json={
114
+ "upload_url": upload_url,
115
+ "s3_key": s3_key,
116
+ "expires_at": "2026-06-11T01:00:00.000Z",
117
+ },
118
+ )
119
+ if request.url.host == "uploads.test":
120
+ return httpx.Response(200)
121
+ if request.url.path == "/v0/videos":
122
+ return httpx.Response(200, json=SAMPLE_VIDEO)
123
+ raise AssertionError(f"unexpected request: {request.url}")
124
+
125
+ stages: list[str] = []
126
+ with make_client(handler) as client:
127
+ video = client.upload_video(
128
+ project_id=PROJECT_ID,
129
+ file=b"video-bytes",
130
+ file_name="episode-01.mp4",
131
+ name="Episode 1",
132
+ season=1,
133
+ episode=1,
134
+ on_progress=stages.append,
135
+ )
136
+
137
+ assert video == SAMPLE_VIDEO
138
+ assert stages == ["minting_upload_url", "uploading", "registering", "done"]
139
+ assert [str(r.url) for r in seen] == [
140
+ f"{BASE_URL}/v0/videos/upload-url",
141
+ upload_url,
142
+ f"{BASE_URL}/v0/videos",
143
+ ]
144
+
145
+ mint_body = json.loads(seen[0].content)
146
+ assert mint_body == {
147
+ "project_id": PROJECT_ID,
148
+ "file_name": "episode-01.mp4",
149
+ "content_type": "video/mp4",
150
+ }
151
+
152
+ put = seen[1]
153
+ assert put.method == "PUT"
154
+ assert put.headers["content-type"] == "video/mp4"
155
+ assert "authorization" not in put.headers
156
+ assert put.content == b"video-bytes"
157
+
158
+ register_body = json.loads(seen[2].content)
159
+ assert register_body == {
160
+ "project_id": PROJECT_ID,
161
+ "name": "Episode 1",
162
+ "s3_key": s3_key,
163
+ "season": 1,
164
+ "episode": 1,
165
+ "auto_process": True,
166
+ }
167
+
168
+
169
+ def test_upload_video_raises_on_failed_put() -> None:
170
+ def handler(request: httpx.Request) -> httpx.Response:
171
+ if request.url.path == "/v0/videos/upload-url":
172
+ return httpx.Response(
173
+ 200,
174
+ json={
175
+ "upload_url": "https://uploads.test/k?sig=1",
176
+ "s3_key": "k",
177
+ "expires_at": "2026-06-11T01:00:00.000Z",
178
+ },
179
+ )
180
+ return httpx.Response(403, text="denied")
181
+
182
+ with make_client(handler) as client:
183
+ with pytest.raises(AimageApiError) as exc_info:
184
+ client.upload_video(
185
+ project_id=PROJECT_ID,
186
+ file=b"video-bytes",
187
+ file_name="episode-01.mp4",
188
+ name="Episode 1",
189
+ )
190
+
191
+ assert exc_info.value.code == "UPLOAD_FAILED"
192
+ assert exc_info.value.status == 403
193
+
194
+
195
+ def test_upload_video_requires_file_name_for_bytes() -> None:
196
+ def handler(request: httpx.Request) -> httpx.Response:
197
+ raise AssertionError("no request expected")
198
+
199
+ with make_client(handler) as client:
200
+ with pytest.raises(ValueError):
201
+ client.upload_video(project_id=PROJECT_ID, file=b"x", name="Episode 1")
202
+
203
+
204
+ def test_wait_for_processing_polls_until_terminal() -> None:
205
+ statuses = iter(["processing", "processing", "completed"])
206
+
207
+ def handler(request: httpx.Request) -> httpx.Response:
208
+ return httpx.Response(200, json={**SAMPLE_VIDEO, "processing_status": next(statuses)})
209
+
210
+ with make_client(handler) as client:
211
+ video = client.wait_for_processing(VIDEO_ID, poll_interval_s=0)
212
+
213
+ assert video["processing_status"] == "completed"
214
+
215
+
216
+ def test_wait_for_processing_times_out() -> None:
217
+ def handler(request: httpx.Request) -> httpx.Response:
218
+ return httpx.Response(200, json={**SAMPLE_VIDEO, "processing_status": "processing"})
219
+
220
+ with make_client(handler) as client:
221
+ with pytest.raises(TimeoutError):
222
+ client.wait_for_processing(VIDEO_ID, poll_interval_s=0, timeout_s=0)
@@ -0,0 +1,189 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "aimage-sdk"
7
+ version = "0.1.0"
8
+ source = { editable = "." }
9
+ dependencies = [
10
+ { name = "httpx" },
11
+ ]
12
+
13
+ [package.dev-dependencies]
14
+ dev = [
15
+ { name = "pytest" },
16
+ { name = "ruff" },
17
+ ]
18
+
19
+ [package.metadata]
20
+ requires-dist = [{ name = "httpx", specifier = ">=0.28,<1" }]
21
+
22
+ [package.metadata.requires-dev]
23
+ dev = [
24
+ { name = "pytest", specifier = ">=9,<10" },
25
+ { name = "ruff", specifier = ">=0.7,<1" },
26
+ ]
27
+
28
+ [[package]]
29
+ name = "anyio"
30
+ version = "4.13.0"
31
+ source = { registry = "https://pypi.org/simple" }
32
+ dependencies = [
33
+ { name = "idna" },
34
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
35
+ ]
36
+ sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
37
+ wheels = [
38
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
39
+ ]
40
+
41
+ [[package]]
42
+ name = "certifi"
43
+ version = "2026.5.20"
44
+ source = { registry = "https://pypi.org/simple" }
45
+ sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
46
+ wheels = [
47
+ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
48
+ ]
49
+
50
+ [[package]]
51
+ name = "colorama"
52
+ version = "0.4.6"
53
+ source = { registry = "https://pypi.org/simple" }
54
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
55
+ wheels = [
56
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
57
+ ]
58
+
59
+ [[package]]
60
+ name = "h11"
61
+ version = "0.16.0"
62
+ source = { registry = "https://pypi.org/simple" }
63
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
64
+ wheels = [
65
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
66
+ ]
67
+
68
+ [[package]]
69
+ name = "httpcore"
70
+ version = "1.0.9"
71
+ source = { registry = "https://pypi.org/simple" }
72
+ dependencies = [
73
+ { name = "certifi" },
74
+ { name = "h11" },
75
+ ]
76
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
77
+ wheels = [
78
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
79
+ ]
80
+
81
+ [[package]]
82
+ name = "httpx"
83
+ version = "0.28.1"
84
+ source = { registry = "https://pypi.org/simple" }
85
+ dependencies = [
86
+ { name = "anyio" },
87
+ { name = "certifi" },
88
+ { name = "httpcore" },
89
+ { name = "idna" },
90
+ ]
91
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
92
+ wheels = [
93
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
94
+ ]
95
+
96
+ [[package]]
97
+ name = "idna"
98
+ version = "3.18"
99
+ source = { registry = "https://pypi.org/simple" }
100
+ sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
101
+ wheels = [
102
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
103
+ ]
104
+
105
+ [[package]]
106
+ name = "iniconfig"
107
+ version = "2.3.0"
108
+ source = { registry = "https://pypi.org/simple" }
109
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
110
+ wheels = [
111
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
112
+ ]
113
+
114
+ [[package]]
115
+ name = "packaging"
116
+ version = "26.2"
117
+ source = { registry = "https://pypi.org/simple" }
118
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
119
+ wheels = [
120
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
121
+ ]
122
+
123
+ [[package]]
124
+ name = "pluggy"
125
+ version = "1.6.0"
126
+ source = { registry = "https://pypi.org/simple" }
127
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
128
+ wheels = [
129
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
130
+ ]
131
+
132
+ [[package]]
133
+ name = "pygments"
134
+ version = "2.20.0"
135
+ source = { registry = "https://pypi.org/simple" }
136
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
137
+ wheels = [
138
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
139
+ ]
140
+
141
+ [[package]]
142
+ name = "pytest"
143
+ version = "9.0.3"
144
+ source = { registry = "https://pypi.org/simple" }
145
+ dependencies = [
146
+ { name = "colorama", marker = "sys_platform == 'win32'" },
147
+ { name = "iniconfig" },
148
+ { name = "packaging" },
149
+ { name = "pluggy" },
150
+ { name = "pygments" },
151
+ ]
152
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
153
+ wheels = [
154
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
155
+ ]
156
+
157
+ [[package]]
158
+ name = "ruff"
159
+ version = "0.15.16"
160
+ source = { registry = "https://pypi.org/simple" }
161
+ sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" }
162
+ wheels = [
163
+ { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" },
164
+ { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" },
165
+ { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" },
166
+ { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" },
167
+ { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" },
168
+ { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" },
169
+ { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" },
170
+ { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" },
171
+ { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" },
172
+ { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" },
173
+ { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" },
174
+ { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" },
175
+ { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" },
176
+ { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" },
177
+ { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" },
178
+ { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" },
179
+ { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
180
+ ]
181
+
182
+ [[package]]
183
+ name = "typing-extensions"
184
+ version = "4.15.0"
185
+ source = { registry = "https://pypi.org/simple" }
186
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
187
+ wheels = [
188
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
189
+ ]