hypercli-sdk 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- c3/__init__.py +57 -0
- c3/billing.py +72 -0
- c3/client.py +60 -0
- c3/config.py +70 -0
- c3/files.py +386 -0
- c3/http.py +217 -0
- c3/instances.py +211 -0
- c3/job/__init__.py +24 -0
- c3/job/base.py +249 -0
- c3/job/comfyui.py +1469 -0
- c3/jobs.py +285 -0
- c3/logs.py +273 -0
- c3/renders.py +339 -0
- c3/user.py +37 -0
- hypercli_sdk-0.4.2.dist-info/METADATA +141 -0
- hypercli_sdk-0.4.2.dist-info/RECORD +17 -0
- hypercli_sdk-0.4.2.dist-info/WHEEL +4 -0
c3/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""C3 SDK - Python client for HyperCLI API"""
|
|
2
|
+
from .client import C3
|
|
3
|
+
from .config import configure, GHCR_IMAGES, COMFYUI_IMAGE
|
|
4
|
+
from .http import APIError, AsyncHTTPClient
|
|
5
|
+
from .instances import GPUType, GPUConfig, Region, GPUPricing, PricingTier
|
|
6
|
+
from .jobs import Job, JobMetrics, GPUMetrics, find_job, find_by_id, find_by_hostname, find_by_ip
|
|
7
|
+
from .renders import Render, RenderStatus
|
|
8
|
+
from .files import File, AsyncFiles
|
|
9
|
+
from .job import BaseJob, ComfyUIJob, apply_params, apply_graph_modes, find_node, find_nodes, load_template, graph_to_api, DEFAULT_OBJECT_INFO
|
|
10
|
+
from .logs import LogStream, stream_logs, fetch_logs
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.1"
|
|
13
|
+
__all__ = [
|
|
14
|
+
"C3",
|
|
15
|
+
"configure",
|
|
16
|
+
"APIError",
|
|
17
|
+
# Images
|
|
18
|
+
"GHCR_IMAGES",
|
|
19
|
+
"COMFYUI_IMAGE",
|
|
20
|
+
# Instance types
|
|
21
|
+
"GPUType",
|
|
22
|
+
"GPUConfig",
|
|
23
|
+
"Region",
|
|
24
|
+
"GPUPricing",
|
|
25
|
+
"PricingTier",
|
|
26
|
+
# Jobs API
|
|
27
|
+
"Job",
|
|
28
|
+
"JobMetrics",
|
|
29
|
+
"GPUMetrics",
|
|
30
|
+
# Renders API
|
|
31
|
+
"Render",
|
|
32
|
+
"RenderStatus",
|
|
33
|
+
# Files API
|
|
34
|
+
"File",
|
|
35
|
+
"AsyncFiles",
|
|
36
|
+
"AsyncHTTPClient",
|
|
37
|
+
# Job lookup utils
|
|
38
|
+
"find_job",
|
|
39
|
+
"find_by_id",
|
|
40
|
+
"find_by_hostname",
|
|
41
|
+
"find_by_ip",
|
|
42
|
+
# Job helpers
|
|
43
|
+
"BaseJob",
|
|
44
|
+
"ComfyUIJob",
|
|
45
|
+
# Workflow utils
|
|
46
|
+
"apply_params",
|
|
47
|
+
"apply_graph_modes",
|
|
48
|
+
"find_node",
|
|
49
|
+
"find_nodes",
|
|
50
|
+
"load_template",
|
|
51
|
+
"graph_to_api",
|
|
52
|
+
"DEFAULT_OBJECT_INFO",
|
|
53
|
+
# Log streaming
|
|
54
|
+
"LogStream",
|
|
55
|
+
"stream_logs",
|
|
56
|
+
"fetch_logs",
|
|
57
|
+
]
|
c3/billing.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Billing API"""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .http import HTTPClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Balance:
|
|
11
|
+
total: str
|
|
12
|
+
rewards: str
|
|
13
|
+
paid: str
|
|
14
|
+
available: str
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_dict(cls, data: dict) -> "Balance":
|
|
18
|
+
return cls(
|
|
19
|
+
total=data.get("total_balance", "0"),
|
|
20
|
+
rewards=data.get("rewards_balance", "0"),
|
|
21
|
+
paid=data.get("balance", "0"),
|
|
22
|
+
available=data.get("available_balance", "0"),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Transaction:
|
|
28
|
+
id: str
|
|
29
|
+
user_id: str
|
|
30
|
+
amount: int
|
|
31
|
+
amount_usd: float
|
|
32
|
+
transaction_type: str
|
|
33
|
+
status: str
|
|
34
|
+
rewards: bool
|
|
35
|
+
job_id: str | None
|
|
36
|
+
created_at: str
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_dict(cls, data: dict) -> "Transaction":
|
|
40
|
+
return cls(
|
|
41
|
+
id=data.get("id", ""),
|
|
42
|
+
user_id=data.get("user_id", ""),
|
|
43
|
+
amount=data.get("amount", 0),
|
|
44
|
+
amount_usd=data.get("amount_usd", 0),
|
|
45
|
+
transaction_type=data.get("transaction_type", ""),
|
|
46
|
+
status=data.get("status", ""),
|
|
47
|
+
rewards=data.get("rewards", False),
|
|
48
|
+
job_id=data.get("job_id"),
|
|
49
|
+
created_at=data.get("created_at", ""),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Billing:
|
|
54
|
+
"""Billing API wrapper"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, http: "HTTPClient"):
|
|
57
|
+
self._http = http
|
|
58
|
+
|
|
59
|
+
def balance(self) -> Balance:
|
|
60
|
+
"""Get account balance"""
|
|
61
|
+
data = self._http.get("/api/balance")
|
|
62
|
+
return Balance.from_dict(data)
|
|
63
|
+
|
|
64
|
+
def transactions(self, limit: int = 50, page: int = 1) -> list[Transaction]:
|
|
65
|
+
"""List transactions"""
|
|
66
|
+
data = self._http.get("/api/tx", params={"page": page, "page_size": limit})
|
|
67
|
+
return [Transaction.from_dict(tx) for tx in data.get("transactions", [])]
|
|
68
|
+
|
|
69
|
+
def get_transaction(self, transaction_id: str) -> Transaction:
|
|
70
|
+
"""Get a specific transaction"""
|
|
71
|
+
data = self._http.get(f"/api/tx/{transaction_id}")
|
|
72
|
+
return Transaction.from_dict(data)
|
c3/client.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Main C3 client"""
|
|
2
|
+
from .config import get_api_key, get_api_url
|
|
3
|
+
from .http import HTTPClient
|
|
4
|
+
from .billing import Billing
|
|
5
|
+
from .jobs import Jobs
|
|
6
|
+
from .user import UserAPI
|
|
7
|
+
from .instances import Instances
|
|
8
|
+
from .renders import Renders
|
|
9
|
+
from .files import Files
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class C3:
|
|
13
|
+
"""
|
|
14
|
+
C3 API Client
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
from c3 import C3
|
|
18
|
+
|
|
19
|
+
c3 = C3() # Uses C3_API_KEY from env or ~/.c3/config
|
|
20
|
+
# or
|
|
21
|
+
c3 = C3(api_key="your_key")
|
|
22
|
+
|
|
23
|
+
# Billing
|
|
24
|
+
balance = c3.billing.balance()
|
|
25
|
+
print(f"Balance: ${balance.total}")
|
|
26
|
+
|
|
27
|
+
# Jobs
|
|
28
|
+
job = c3.jobs.create(
|
|
29
|
+
image="nvidia/cuda:12.0",
|
|
30
|
+
gpu_type="l40s",
|
|
31
|
+
command="python train.py"
|
|
32
|
+
)
|
|
33
|
+
print(f"Job: {job.job_id}")
|
|
34
|
+
|
|
35
|
+
# User
|
|
36
|
+
user = c3.user.get()
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, api_key: str = None, api_url: str = None):
|
|
40
|
+
self._api_key = api_key or get_api_key()
|
|
41
|
+
if not self._api_key:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"API key required. Set C3_API_KEY env var, "
|
|
44
|
+
"create ~/.c3/config, or pass api_key parameter."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self._api_url = api_url or get_api_url()
|
|
48
|
+
self._http = HTTPClient(self._api_url, self._api_key)
|
|
49
|
+
|
|
50
|
+
# API namespaces
|
|
51
|
+
self.billing = Billing(self._http)
|
|
52
|
+
self.jobs = Jobs(self._http)
|
|
53
|
+
self.user = UserAPI(self._http)
|
|
54
|
+
self.instances = Instances(self._http)
|
|
55
|
+
self.renders = Renders(self._http)
|
|
56
|
+
self.files = Files(self._http)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def api_url(self) -> str:
|
|
60
|
+
return self._api_url
|
c3/config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Configuration handling"""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
CONFIG_DIR = Path.home() / ".c3"
|
|
7
|
+
CONFIG_FILE = CONFIG_DIR / "config"
|
|
8
|
+
|
|
9
|
+
DEFAULT_API_URL = "https://api.hypercli.com"
|
|
10
|
+
DEFAULT_WS_URL = "wss://api.hypercli.com"
|
|
11
|
+
WS_LOGS_PATH = "/orchestra/ws/logs" # WebSocket path for job logs: {WS_URL}{WS_LOGS_PATH}/{job_key}
|
|
12
|
+
|
|
13
|
+
# GHCR images
|
|
14
|
+
GHCR_IMAGES = "ghcr.io/hypercliai/images"
|
|
15
|
+
COMFYUI_IMAGE = f"{GHCR_IMAGES}/comfyui"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_config_file() -> dict:
|
|
19
|
+
"""Load config from ~/.c3/config"""
|
|
20
|
+
config = {}
|
|
21
|
+
if CONFIG_FILE.exists():
|
|
22
|
+
for line in CONFIG_FILE.read_text().splitlines():
|
|
23
|
+
line = line.strip()
|
|
24
|
+
if line and not line.startswith("#") and "=" in line:
|
|
25
|
+
key, value = line.split("=", 1)
|
|
26
|
+
config[key.strip()] = value.strip()
|
|
27
|
+
return config
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_config_value(key: str, default: str = None) -> Optional[str]:
|
|
31
|
+
"""Get config value: env var > config file > default"""
|
|
32
|
+
env_val = os.getenv(key)
|
|
33
|
+
if env_val:
|
|
34
|
+
return env_val
|
|
35
|
+
config = _load_config_file()
|
|
36
|
+
return config.get(key, default)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_api_key() -> Optional[str]:
|
|
40
|
+
"""Get API key from env or config file"""
|
|
41
|
+
return get_config_value("C3_API_KEY")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_api_url() -> str:
|
|
45
|
+
"""Get API URL"""
|
|
46
|
+
return get_config_value("C3_API_URL", DEFAULT_API_URL)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_ws_url() -> str:
|
|
50
|
+
"""Get WebSocket URL"""
|
|
51
|
+
ws = get_config_value("C3_WS_URL")
|
|
52
|
+
if ws:
|
|
53
|
+
return ws
|
|
54
|
+
# Derive from API URL
|
|
55
|
+
api = get_api_url()
|
|
56
|
+
return api.replace("https://", "wss://").replace("http://", "ws://")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def configure(api_key: str, api_url: str = None):
|
|
60
|
+
"""Save configuration to ~/.c3/config"""
|
|
61
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
config = _load_config_file()
|
|
64
|
+
config["C3_API_KEY"] = api_key
|
|
65
|
+
if api_url:
|
|
66
|
+
config["C3_API_URL"] = api_url
|
|
67
|
+
|
|
68
|
+
lines = [f"{k}={v}" for k, v in config.items()]
|
|
69
|
+
CONFIG_FILE.write_text("\n".join(lines) + "\n")
|
|
70
|
+
CONFIG_FILE.chmod(0o600)
|
c3/files.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Files API"""
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import asyncio
|
|
5
|
+
import mimetypes
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .http import HTTPClient, AsyncHTTPClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class File:
|
|
15
|
+
"""Uploaded file metadata.
|
|
16
|
+
|
|
17
|
+
The `url` field is an internal reference (s3://...) that can only be used
|
|
18
|
+
within the HyperCLI platform (e.g., as image_url in render calls).
|
|
19
|
+
It cannot be used for direct downloads or sharing.
|
|
20
|
+
"""
|
|
21
|
+
id: str
|
|
22
|
+
user_id: str
|
|
23
|
+
filename: str
|
|
24
|
+
content_type: str
|
|
25
|
+
file_size: int
|
|
26
|
+
url: str # Internal S3 reference - only valid for use in C3 renders
|
|
27
|
+
state: str | None = None # processing, done, failed (for async uploads)
|
|
28
|
+
error: str | None = None # Error message if state=failed
|
|
29
|
+
created_at: str | None = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, data: dict) -> "File":
|
|
33
|
+
return cls(
|
|
34
|
+
id=data.get("id", ""),
|
|
35
|
+
user_id=data.get("user_id", ""),
|
|
36
|
+
filename=data.get("filename", ""),
|
|
37
|
+
content_type=data.get("content_type", ""),
|
|
38
|
+
file_size=data.get("file_size", 0),
|
|
39
|
+
url=data.get("url", ""),
|
|
40
|
+
state=data.get("state"),
|
|
41
|
+
error=data.get("error"),
|
|
42
|
+
created_at=data.get("created_at"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_ready(self) -> bool:
|
|
47
|
+
"""Check if file upload is complete and ready to use."""
|
|
48
|
+
return self.state == "done"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_failed(self) -> bool:
|
|
52
|
+
"""Check if file upload failed."""
|
|
53
|
+
return self.state == "failed"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_processing(self) -> bool:
|
|
57
|
+
"""Check if file upload is still processing."""
|
|
58
|
+
return self.state == "processing"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Files:
|
|
62
|
+
"""Files API wrapper for uploading assets"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, http: "HTTPClient"):
|
|
65
|
+
self._http = http
|
|
66
|
+
|
|
67
|
+
def upload(self, file_path: str) -> File:
|
|
68
|
+
"""Upload a file for use in renders.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
file_path: Path to local file (image, audio, or video)
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
File object with id and internal url for use in render calls.
|
|
75
|
+
The url is an S3 reference that only works within C3.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
file = c3.files.upload("./my_image.png")
|
|
79
|
+
render = c3.renders.image_to_video("dancing", file.url)
|
|
80
|
+
"""
|
|
81
|
+
# Read file
|
|
82
|
+
with open(file_path, "rb") as f:
|
|
83
|
+
content = f.read()
|
|
84
|
+
|
|
85
|
+
filename = os.path.basename(file_path)
|
|
86
|
+
|
|
87
|
+
# Guess content type
|
|
88
|
+
content_type, _ = mimetypes.guess_type(file_path)
|
|
89
|
+
if not content_type:
|
|
90
|
+
content_type = "application/octet-stream"
|
|
91
|
+
|
|
92
|
+
# Upload via multipart
|
|
93
|
+
files = {"file": (filename, content, content_type)}
|
|
94
|
+
data = self._http.post_multipart("/api/files/multi", files=files)
|
|
95
|
+
return File.from_dict(data)
|
|
96
|
+
|
|
97
|
+
def upload_bytes(self, content: bytes, filename: str, content_type: str) -> File:
|
|
98
|
+
"""Upload file bytes directly.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
content: File bytes
|
|
102
|
+
filename: Filename to use
|
|
103
|
+
content_type: MIME type (e.g., "image/png", "audio/mp3")
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
File object with id and url for use in render calls
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
file = c3.files.upload_bytes(image_bytes, "image.png", "image/png")
|
|
110
|
+
"""
|
|
111
|
+
files = {"file": (filename, content, content_type)}
|
|
112
|
+
data = self._http.post_multipart("/api/files/multi", files=files)
|
|
113
|
+
return File.from_dict(data)
|
|
114
|
+
|
|
115
|
+
def upload_url(self, url: str, path: str | None = None) -> File:
|
|
116
|
+
"""Upload a file from a URL (async backend processing).
|
|
117
|
+
|
|
118
|
+
The backend downloads the file from the URL and uploads it to storage.
|
|
119
|
+
Returns immediately with state=processing. Use wait_ready() or poll get().
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
url: The URL to download the file from
|
|
123
|
+
path: Optional path prefix for organizing files
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
File object with id and state=processing.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
file = c3.files.upload_url("https://example.com/image.png")
|
|
130
|
+
file = c3.files.wait_ready(file.id) # Wait for completion
|
|
131
|
+
"""
|
|
132
|
+
payload = {"url": url}
|
|
133
|
+
if path:
|
|
134
|
+
payload["path"] = path
|
|
135
|
+
data = self._http.post("/api/files/url", json=payload)
|
|
136
|
+
return File.from_dict(data)
|
|
137
|
+
|
|
138
|
+
def upload_b64(
|
|
139
|
+
self,
|
|
140
|
+
data: str,
|
|
141
|
+
filename: str,
|
|
142
|
+
content_type: str | None = None,
|
|
143
|
+
path: str | None = None,
|
|
144
|
+
) -> File:
|
|
145
|
+
"""Upload a file from base64-encoded data (async backend processing).
|
|
146
|
+
|
|
147
|
+
The backend decodes the base64 data and uploads it to storage.
|
|
148
|
+
Returns immediately with state=processing. Use wait_ready() or poll get().
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
data: Base64-encoded file content
|
|
152
|
+
filename: Filename to use (e.g., "image.jpg")
|
|
153
|
+
content_type: MIME type (auto-detected from filename if not provided)
|
|
154
|
+
path: Optional path prefix for organizing files
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
File object with id and state=processing.
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
import base64
|
|
161
|
+
b64_data = base64.b64encode(image_bytes).decode()
|
|
162
|
+
file = c3.files.upload_b64(b64_data, "image.png", "image/png")
|
|
163
|
+
file = c3.files.wait_ready(file.id)
|
|
164
|
+
"""
|
|
165
|
+
payload = {"data": data, "filename": filename}
|
|
166
|
+
if content_type:
|
|
167
|
+
payload["content_type"] = content_type
|
|
168
|
+
if path:
|
|
169
|
+
payload["path"] = path
|
|
170
|
+
result = self._http.post("/api/files/b64", json=payload)
|
|
171
|
+
return File.from_dict(result)
|
|
172
|
+
|
|
173
|
+
def get(self, file_id: str) -> File:
|
|
174
|
+
"""Get file metadata and URL.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
file_id: The file ID
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
File object with url for use in render calls
|
|
181
|
+
"""
|
|
182
|
+
data = self._http.get(f"/api/files/{file_id}")
|
|
183
|
+
return File.from_dict(data)
|
|
184
|
+
|
|
185
|
+
def delete(self, file_id: str) -> dict:
|
|
186
|
+
"""Delete an uploaded file.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
file_id: The file ID to delete
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
{"status": "deleted", "id": "..."}
|
|
193
|
+
"""
|
|
194
|
+
return self._http.delete(f"/api/files/{file_id}")
|
|
195
|
+
|
|
196
|
+
def wait_ready(
|
|
197
|
+
self,
|
|
198
|
+
file_id: str,
|
|
199
|
+
timeout: float = 60.0,
|
|
200
|
+
poll_interval: float = 1.0,
|
|
201
|
+
) -> File:
|
|
202
|
+
"""Wait for an async upload to complete.
|
|
203
|
+
|
|
204
|
+
Polls the file status until it's done or failed.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
file_id: The file ID to wait for
|
|
208
|
+
timeout: Maximum time to wait in seconds
|
|
209
|
+
poll_interval: Time between polls in seconds
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
File object with final state (done or failed)
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
TimeoutError: If file doesn't complete within timeout
|
|
216
|
+
ValueError: If file upload failed
|
|
217
|
+
|
|
218
|
+
Example:
|
|
219
|
+
file = c3.files.upload_url("https://example.com/image.png")
|
|
220
|
+
file = c3.files.wait_ready(file.id, timeout=30)
|
|
221
|
+
print(f"Ready: {file.url}")
|
|
222
|
+
"""
|
|
223
|
+
start = time.time()
|
|
224
|
+
while time.time() - start < timeout:
|
|
225
|
+
file = self.get(file_id)
|
|
226
|
+
if file.is_ready:
|
|
227
|
+
return file
|
|
228
|
+
if file.is_failed:
|
|
229
|
+
raise ValueError(f"File upload failed: {file.error}")
|
|
230
|
+
time.sleep(poll_interval)
|
|
231
|
+
raise TimeoutError(f"File {file_id} did not complete within {timeout}s")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class AsyncFiles:
|
|
235
|
+
"""Async Files API wrapper for uploading assets in async contexts.
|
|
236
|
+
|
|
237
|
+
Use this in async applications like Telegram bots, web servers, etc.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
def __init__(self, http: "AsyncHTTPClient"):
|
|
241
|
+
self._http = http
|
|
242
|
+
|
|
243
|
+
async def upload_bytes(
|
|
244
|
+
self,
|
|
245
|
+
content: bytes,
|
|
246
|
+
filename: str,
|
|
247
|
+
content_type: str,
|
|
248
|
+
path: str | None = None,
|
|
249
|
+
) -> File:
|
|
250
|
+
"""Upload file bytes directly.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
content: File bytes
|
|
254
|
+
filename: Filename to use
|
|
255
|
+
content_type: MIME type (e.g., "image/png", "audio/mp3")
|
|
256
|
+
path: Optional path prefix for organizing files (e.g., "telegram/12345")
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
File object with id and url for use in render calls
|
|
260
|
+
|
|
261
|
+
Example:
|
|
262
|
+
file = await files.upload_bytes(image_bytes, "photo.jpg", "image/jpeg")
|
|
263
|
+
"""
|
|
264
|
+
files = {"file": (filename, content, content_type)}
|
|
265
|
+
params = {"path": path} if path else None
|
|
266
|
+
data = await self._http.post_multipart("/api/files/multi", files=files, params=params)
|
|
267
|
+
return File.from_dict(data)
|
|
268
|
+
|
|
269
|
+
async def upload_url(self, url: str, path: str | None = None) -> File:
|
|
270
|
+
"""Upload a file from a URL (async backend processing).
|
|
271
|
+
|
|
272
|
+
The backend downloads the file from the URL and uploads it to storage.
|
|
273
|
+
Returns immediately with state=processing. Use wait_ready() or poll get().
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
url: The URL to download the file from
|
|
277
|
+
path: Optional path prefix for organizing files
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
File object with id and state=processing.
|
|
281
|
+
|
|
282
|
+
Example:
|
|
283
|
+
file = await files.upload_url("https://example.com/image.png")
|
|
284
|
+
file = await files.wait_ready(file.id)
|
|
285
|
+
"""
|
|
286
|
+
payload = {"url": url}
|
|
287
|
+
if path:
|
|
288
|
+
payload["path"] = path
|
|
289
|
+
data = await self._http.post("/api/files/url", json=payload)
|
|
290
|
+
return File.from_dict(data)
|
|
291
|
+
|
|
292
|
+
async def upload_b64(
|
|
293
|
+
self,
|
|
294
|
+
data: str,
|
|
295
|
+
filename: str,
|
|
296
|
+
content_type: str | None = None,
|
|
297
|
+
path: str | None = None,
|
|
298
|
+
) -> File:
|
|
299
|
+
"""Upload a file from base64-encoded data (async backend processing).
|
|
300
|
+
|
|
301
|
+
The backend decodes the base64 data and uploads it to storage.
|
|
302
|
+
Returns immediately with state=processing. Use wait_ready() or poll get().
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
data: Base64-encoded file content
|
|
306
|
+
filename: Filename to use (e.g., "image.jpg")
|
|
307
|
+
content_type: MIME type (auto-detected from filename if not provided)
|
|
308
|
+
path: Optional path prefix for organizing files
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
File object with id and state=processing.
|
|
312
|
+
|
|
313
|
+
Example:
|
|
314
|
+
import base64
|
|
315
|
+
b64_data = base64.b64encode(image_bytes).decode()
|
|
316
|
+
file = await files.upload_b64(b64_data, "image.png", "image/png")
|
|
317
|
+
file = await files.wait_ready(file.id)
|
|
318
|
+
"""
|
|
319
|
+
payload = {"data": data, "filename": filename}
|
|
320
|
+
if content_type:
|
|
321
|
+
payload["content_type"] = content_type
|
|
322
|
+
if path:
|
|
323
|
+
payload["path"] = path
|
|
324
|
+
result = await self._http.post("/api/files/b64", json=payload)
|
|
325
|
+
return File.from_dict(result)
|
|
326
|
+
|
|
327
|
+
async def get(self, file_id: str) -> File:
|
|
328
|
+
"""Get file metadata and URL.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
file_id: The file ID
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
File object with url for use in render calls
|
|
335
|
+
"""
|
|
336
|
+
data = await self._http.get(f"/api/files/{file_id}")
|
|
337
|
+
return File.from_dict(data)
|
|
338
|
+
|
|
339
|
+
async def delete(self, file_id: str) -> dict:
|
|
340
|
+
"""Delete an uploaded file.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
file_id: The file ID to delete
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
{"status": "deleted", "id": "..."}
|
|
347
|
+
"""
|
|
348
|
+
return await self._http.delete(f"/api/files/{file_id}")
|
|
349
|
+
|
|
350
|
+
async def wait_ready(
|
|
351
|
+
self,
|
|
352
|
+
file_id: str,
|
|
353
|
+
timeout: float = 60.0,
|
|
354
|
+
poll_interval: float = 1.0,
|
|
355
|
+
) -> File:
|
|
356
|
+
"""Wait for an async upload to complete.
|
|
357
|
+
|
|
358
|
+
Polls the file status until it's done or failed.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
file_id: The file ID to wait for
|
|
362
|
+
timeout: Maximum time to wait in seconds
|
|
363
|
+
poll_interval: Time between polls in seconds
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
File object with final state (done or failed)
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
TimeoutError: If file doesn't complete within timeout
|
|
370
|
+
ValueError: If file upload failed
|
|
371
|
+
|
|
372
|
+
Example:
|
|
373
|
+
file = await files.upload_url("https://example.com/image.png")
|
|
374
|
+
file = await files.wait_ready(file.id, timeout=30)
|
|
375
|
+
print(f"Ready: {file.url}")
|
|
376
|
+
"""
|
|
377
|
+
import time
|
|
378
|
+
start = time.time()
|
|
379
|
+
while time.time() - start < timeout:
|
|
380
|
+
file = await self.get(file_id)
|
|
381
|
+
if file.is_ready:
|
|
382
|
+
return file
|
|
383
|
+
if file.is_failed:
|
|
384
|
+
raise ValueError(f"File upload failed: {file.error}")
|
|
385
|
+
await asyncio.sleep(poll_interval)
|
|
386
|
+
raise TimeoutError(f"File {file_id} did not complete within {timeout}s")
|