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.
- aimage_sdk-0.1.0/.gitignore +67 -0
- aimage_sdk-0.1.0/LICENSE +21 -0
- aimage_sdk-0.1.0/PKG-INFO +90 -0
- aimage_sdk-0.1.0/README.md +72 -0
- aimage_sdk-0.1.0/pyproject.toml +43 -0
- aimage_sdk-0.1.0/src/aimage_sdk/__init__.py +34 -0
- aimage_sdk-0.1.0/src/aimage_sdk/client.py +236 -0
- aimage_sdk-0.1.0/src/aimage_sdk/errors.py +12 -0
- aimage_sdk-0.1.0/src/aimage_sdk/types.py +84 -0
- aimage_sdk-0.1.0/tests/test_client.py +222 -0
- aimage_sdk-0.1.0/uv.lock +189 -0
|
@@ -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/
|
aimage_sdk-0.1.0/LICENSE
ADDED
|
@@ -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)
|
aimage_sdk-0.1.0/uv.lock
ADDED
|
@@ -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
|
+
]
|