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 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")