podstack 1.2.0__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.
- podstack/__init__.py +222 -0
- podstack/annotations.py +725 -0
- podstack/client.py +322 -0
- podstack/exceptions.py +125 -0
- podstack/execution.py +291 -0
- podstack/gpu_runner.py +1141 -0
- podstack/models.py +274 -0
- podstack/notebook.py +410 -0
- podstack/registry/__init__.py +402 -0
- podstack/registry/client.py +957 -0
- podstack/registry/exceptions.py +107 -0
- podstack/registry/experiment.py +227 -0
- podstack/registry/model.py +273 -0
- podstack/registry/model_utils.py +231 -0
- podstack-1.2.0.dist-info/METADATA +299 -0
- podstack-1.2.0.dist-info/RECORD +27 -0
- podstack-1.2.0.dist-info/WHEEL +5 -0
- podstack-1.2.0.dist-info/licenses/LICENSE +21 -0
- podstack-1.2.0.dist-info/top_level.txt +2 -0
- podstack_gpu/__init__.py +126 -0
- podstack_gpu/app.py +675 -0
- podstack_gpu/exceptions.py +35 -0
- podstack_gpu/image.py +325 -0
- podstack_gpu/runner.py +746 -0
- podstack_gpu/secret.py +189 -0
- podstack_gpu/utils.py +203 -0
- podstack_gpu/volume.py +198 -0
podstack/client.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Podstack Client
|
|
3
|
+
|
|
4
|
+
Main client for interacting with the Podstack API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Optional, Dict, Any, List
|
|
10
|
+
from urllib.parse import urljoin
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
PodstackError,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
ConnectionError
|
|
20
|
+
)
|
|
21
|
+
from .notebook import NotebooksAPI, Notebook
|
|
22
|
+
from .execution import ExecutionsAPI, Execution
|
|
23
|
+
from .models import (
|
|
24
|
+
GPUInfo,
|
|
25
|
+
GPUType,
|
|
26
|
+
Project,
|
|
27
|
+
WalletBalance,
|
|
28
|
+
UsageSummary,
|
|
29
|
+
Webhook
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Client:
|
|
34
|
+
"""
|
|
35
|
+
Podstack API Client.
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
# Async context manager (recommended)
|
|
39
|
+
async with Client(api_key="your-api-key") as client:
|
|
40
|
+
notebook = await client.notebooks.create(name="experiment", gpu_type="A100")
|
|
41
|
+
result = await notebook.execute("print('Hello GPU!')")
|
|
42
|
+
|
|
43
|
+
# Manual lifecycle
|
|
44
|
+
client = Client(api_key="your-api-key")
|
|
45
|
+
await client.connect()
|
|
46
|
+
# ... use client ...
|
|
47
|
+
await client.close()
|
|
48
|
+
|
|
49
|
+
# Sync wrapper for simple scripts
|
|
50
|
+
client = Client(api_key="your-api-key")
|
|
51
|
+
notebook = client.sync_create_notebook(name="experiment", gpu_type="A100")
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
DEFAULT_BASE_URL = "https://api.podstack.io/v1"
|
|
55
|
+
DEFAULT_TIMEOUT = 30.0
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
api_key: str = None,
|
|
60
|
+
base_url: str = None,
|
|
61
|
+
timeout: float = None,
|
|
62
|
+
max_retries: int = 3
|
|
63
|
+
):
|
|
64
|
+
"""
|
|
65
|
+
Initialize the Podstack client.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
api_key: API key for authentication. If not provided, uses PODSTACK_API_KEY env var.
|
|
69
|
+
base_url: Base URL for the API. Defaults to https://api.podstack.io/v1
|
|
70
|
+
timeout: Request timeout in seconds. Defaults to 30.
|
|
71
|
+
max_retries: Maximum number of retries for failed requests.
|
|
72
|
+
"""
|
|
73
|
+
self.api_key = api_key or os.environ.get("PODSTACK_API_KEY")
|
|
74
|
+
if not self.api_key:
|
|
75
|
+
raise AuthenticationError("API key is required. Provide api_key or set PODSTACK_API_KEY env var.")
|
|
76
|
+
|
|
77
|
+
self.base_url = base_url or os.environ.get("PODSTACK_BASE_URL", self.DEFAULT_BASE_URL)
|
|
78
|
+
self.timeout = timeout or self.DEFAULT_TIMEOUT
|
|
79
|
+
self.max_retries = max_retries
|
|
80
|
+
|
|
81
|
+
self._http_client: Optional[httpx.AsyncClient] = None
|
|
82
|
+
|
|
83
|
+
# API resources
|
|
84
|
+
self.notebooks = NotebooksAPI(self)
|
|
85
|
+
self.executions = ExecutionsAPI(self)
|
|
86
|
+
|
|
87
|
+
async def connect(self):
|
|
88
|
+
"""Initialize HTTP client"""
|
|
89
|
+
if self._http_client is None:
|
|
90
|
+
self._http_client = httpx.AsyncClient(
|
|
91
|
+
base_url=self.base_url,
|
|
92
|
+
timeout=self.timeout,
|
|
93
|
+
headers={
|
|
94
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
"User-Agent": "podstack-python/1.2.0"
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
async def close(self):
|
|
101
|
+
"""Close HTTP client"""
|
|
102
|
+
if self._http_client:
|
|
103
|
+
await self._http_client.aclose()
|
|
104
|
+
self._http_client = None
|
|
105
|
+
|
|
106
|
+
async def __aenter__(self) -> "Client":
|
|
107
|
+
await self.connect()
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
111
|
+
await self.close()
|
|
112
|
+
|
|
113
|
+
async def _request(
|
|
114
|
+
self,
|
|
115
|
+
method: str,
|
|
116
|
+
path: str,
|
|
117
|
+
params: Dict[str, Any] = None,
|
|
118
|
+
json: Dict[str, Any] = None,
|
|
119
|
+
retry_count: int = 0
|
|
120
|
+
) -> Dict[str, Any]:
|
|
121
|
+
"""
|
|
122
|
+
Make an API request.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
method: HTTP method
|
|
126
|
+
path: API path
|
|
127
|
+
params: Query parameters
|
|
128
|
+
json: JSON body
|
|
129
|
+
retry_count: Current retry count
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Response data as dict
|
|
133
|
+
"""
|
|
134
|
+
if self._http_client is None:
|
|
135
|
+
await self.connect()
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
response = await self._http_client.request(
|
|
139
|
+
method=method,
|
|
140
|
+
url=path,
|
|
141
|
+
params=params,
|
|
142
|
+
json=json
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Handle errors
|
|
146
|
+
if response.status_code == 401:
|
|
147
|
+
raise AuthenticationError()
|
|
148
|
+
elif response.status_code == 404:
|
|
149
|
+
raise NotFoundError("Resource", path)
|
|
150
|
+
elif response.status_code == 429:
|
|
151
|
+
retry_after = response.headers.get("Retry-After")
|
|
152
|
+
if retry_count < self.max_retries:
|
|
153
|
+
await asyncio.sleep(int(retry_after or 1))
|
|
154
|
+
return await self._request(method, path, params, json, retry_count + 1)
|
|
155
|
+
raise RateLimitError(int(retry_after) if retry_after else None)
|
|
156
|
+
elif response.status_code == 400:
|
|
157
|
+
error_data = response.json().get("error", {})
|
|
158
|
+
raise ValidationError(
|
|
159
|
+
error_data.get("message", "Invalid request"),
|
|
160
|
+
error_data.get("field")
|
|
161
|
+
)
|
|
162
|
+
elif response.status_code >= 500:
|
|
163
|
+
if retry_count < self.max_retries:
|
|
164
|
+
await asyncio.sleep(2 ** retry_count)
|
|
165
|
+
return await self._request(method, path, params, json, retry_count + 1)
|
|
166
|
+
raise PodstackError(
|
|
167
|
+
f"Server error: {response.status_code}",
|
|
168
|
+
code="server_error"
|
|
169
|
+
)
|
|
170
|
+
elif response.status_code >= 400:
|
|
171
|
+
error_data = response.json().get("error", {})
|
|
172
|
+
raise PodstackError(
|
|
173
|
+
error_data.get("message", f"Request failed: {response.status_code}"),
|
|
174
|
+
code=error_data.get("code")
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Return JSON response
|
|
178
|
+
if response.status_code == 204:
|
|
179
|
+
return {}
|
|
180
|
+
return response.json()
|
|
181
|
+
|
|
182
|
+
except httpx.ConnectError as e:
|
|
183
|
+
if retry_count < self.max_retries:
|
|
184
|
+
await asyncio.sleep(2 ** retry_count)
|
|
185
|
+
return await self._request(method, path, params, json, retry_count + 1)
|
|
186
|
+
raise ConnectionError(f"Failed to connect: {e}")
|
|
187
|
+
except httpx.TimeoutException:
|
|
188
|
+
if retry_count < self.max_retries:
|
|
189
|
+
await asyncio.sleep(2 ** retry_count)
|
|
190
|
+
return await self._request(method, path, params, json, retry_count + 1)
|
|
191
|
+
raise ConnectionError("Request timed out")
|
|
192
|
+
|
|
193
|
+
# GPU methods
|
|
194
|
+
async def list_gpus(self) -> List[GPUInfo]:
|
|
195
|
+
"""List available GPU types"""
|
|
196
|
+
data = await self._request("GET", "/gpus")
|
|
197
|
+
return [GPUInfo.from_dict(g) for g in data.get("gpus", [])]
|
|
198
|
+
|
|
199
|
+
async def get_gpu_availability(self, gpu_type: str) -> Dict[str, Any]:
|
|
200
|
+
"""Get availability for a specific GPU type"""
|
|
201
|
+
data = await self._request("GET", f"/gpus/{gpu_type}")
|
|
202
|
+
return data
|
|
203
|
+
|
|
204
|
+
# Project methods
|
|
205
|
+
async def create_project(self, name: str, description: str = None) -> Project:
|
|
206
|
+
"""Create a new project"""
|
|
207
|
+
data = await self._request("POST", "/projects", json={
|
|
208
|
+
"name": name,
|
|
209
|
+
"description": description
|
|
210
|
+
})
|
|
211
|
+
return Project.from_dict(data)
|
|
212
|
+
|
|
213
|
+
async def list_projects(self, limit: int = 20, offset: int = 0) -> List[Project]:
|
|
214
|
+
"""List projects"""
|
|
215
|
+
data = await self._request("GET", "/projects", params={
|
|
216
|
+
"limit": limit,
|
|
217
|
+
"offset": offset
|
|
218
|
+
})
|
|
219
|
+
return [Project.from_dict(p) for p in data.get("projects", [])]
|
|
220
|
+
|
|
221
|
+
async def get_project(self, project_id: str) -> Project:
|
|
222
|
+
"""Get a project by ID"""
|
|
223
|
+
data = await self._request("GET", f"/projects/{project_id}")
|
|
224
|
+
return Project.from_dict(data)
|
|
225
|
+
|
|
226
|
+
async def delete_project(self, project_id: str):
|
|
227
|
+
"""Delete a project"""
|
|
228
|
+
await self._request("DELETE", f"/projects/{project_id}")
|
|
229
|
+
|
|
230
|
+
# Billing methods
|
|
231
|
+
async def get_wallet_balance(self) -> WalletBalance:
|
|
232
|
+
"""Get wallet balance"""
|
|
233
|
+
data = await self._request("GET", "/billing/wallet")
|
|
234
|
+
return WalletBalance.from_dict(data)
|
|
235
|
+
|
|
236
|
+
async def get_usage(
|
|
237
|
+
self,
|
|
238
|
+
start_date: str = None,
|
|
239
|
+
end_date: str = None,
|
|
240
|
+
group_by: str = "day"
|
|
241
|
+
) -> UsageSummary:
|
|
242
|
+
"""
|
|
243
|
+
Get usage summary.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
start_date: Start date (ISO 8601)
|
|
247
|
+
end_date: End date (ISO 8601)
|
|
248
|
+
group_by: Grouping (day, week, month)
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
UsageSummary object
|
|
252
|
+
"""
|
|
253
|
+
params = {"group_by": group_by}
|
|
254
|
+
if start_date:
|
|
255
|
+
params["start_date"] = start_date
|
|
256
|
+
if end_date:
|
|
257
|
+
params["end_date"] = end_date
|
|
258
|
+
|
|
259
|
+
data = await self._request("GET", "/billing/usage", params=params)
|
|
260
|
+
return UsageSummary.from_dict(data)
|
|
261
|
+
|
|
262
|
+
# Webhook methods
|
|
263
|
+
async def create_webhook(self, url: str, events: List[str]) -> Webhook:
|
|
264
|
+
"""
|
|
265
|
+
Create a webhook.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
url: Webhook URL
|
|
269
|
+
events: List of events to subscribe to
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Webhook object
|
|
273
|
+
"""
|
|
274
|
+
data = await self._request("POST", "/webhooks", json={
|
|
275
|
+
"url": url,
|
|
276
|
+
"events": events
|
|
277
|
+
})
|
|
278
|
+
return Webhook.from_dict(data)
|
|
279
|
+
|
|
280
|
+
async def list_webhooks(self) -> List[Webhook]:
|
|
281
|
+
"""List webhooks"""
|
|
282
|
+
data = await self._request("GET", "/webhooks")
|
|
283
|
+
return [Webhook.from_dict(w) for w in data.get("webhooks", [])]
|
|
284
|
+
|
|
285
|
+
async def delete_webhook(self, webhook_id: str):
|
|
286
|
+
"""Delete a webhook"""
|
|
287
|
+
await self._request("DELETE", f"/webhooks/{webhook_id}")
|
|
288
|
+
|
|
289
|
+
# Sync wrappers for simple scripts
|
|
290
|
+
def _run_async(self, coro):
|
|
291
|
+
"""Run async coroutine in sync context"""
|
|
292
|
+
try:
|
|
293
|
+
loop = asyncio.get_event_loop()
|
|
294
|
+
except RuntimeError:
|
|
295
|
+
loop = asyncio.new_event_loop()
|
|
296
|
+
asyncio.set_event_loop(loop)
|
|
297
|
+
|
|
298
|
+
return loop.run_until_complete(coro)
|
|
299
|
+
|
|
300
|
+
def sync_create_notebook(self, **kwargs) -> Notebook:
|
|
301
|
+
"""Sync wrapper for notebooks.create()"""
|
|
302
|
+
return self._run_async(self.notebooks.create(**kwargs))
|
|
303
|
+
|
|
304
|
+
def sync_get_notebook(self, notebook_id: str) -> Notebook:
|
|
305
|
+
"""Sync wrapper for notebooks.get()"""
|
|
306
|
+
return self._run_async(self.notebooks.get(notebook_id))
|
|
307
|
+
|
|
308
|
+
def sync_list_notebooks(self, **kwargs) -> List[Notebook]:
|
|
309
|
+
"""Sync wrapper for notebooks.list()"""
|
|
310
|
+
return self._run_async(self.notebooks.list(**kwargs))
|
|
311
|
+
|
|
312
|
+
def sync_run(self, code: str, **kwargs) -> Execution:
|
|
313
|
+
"""Sync wrapper for executions.run()"""
|
|
314
|
+
return self._run_async(self.executions.run(code, **kwargs))
|
|
315
|
+
|
|
316
|
+
def sync_list_gpus(self) -> List[GPUInfo]:
|
|
317
|
+
"""Sync wrapper for list_gpus()"""
|
|
318
|
+
return self._run_async(self.list_gpus())
|
|
319
|
+
|
|
320
|
+
def sync_get_wallet_balance(self) -> WalletBalance:
|
|
321
|
+
"""Sync wrapper for get_wallet_balance()"""
|
|
322
|
+
return self._run_async(self.get_wallet_balance())
|
podstack/exceptions.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Podstack Exception Classes
|
|
3
|
+
|
|
4
|
+
Custom exceptions for handling various error conditions in the Podstack SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PodstackError(Exception):
|
|
9
|
+
"""Base exception for all Podstack errors"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, code: str = None, details: dict = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message = message
|
|
14
|
+
self.code = code
|
|
15
|
+
self.details = details or {}
|
|
16
|
+
|
|
17
|
+
def __str__(self):
|
|
18
|
+
if self.code:
|
|
19
|
+
return f"[{self.code}] {self.message}"
|
|
20
|
+
return self.message
|
|
21
|
+
|
|
22
|
+
def __repr__(self):
|
|
23
|
+
return f"{self.__class__.__name__}(message={self.message!r}, code={self.code!r})"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AuthenticationError(PodstackError):
|
|
27
|
+
"""Raised when API authentication fails"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str = "Invalid or missing API key"):
|
|
30
|
+
super().__init__(message, code="authentication_error")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotFoundError(PodstackError):
|
|
34
|
+
"""Raised when a requested resource is not found"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, resource_type: str, resource_id: str):
|
|
37
|
+
message = f"{resource_type} '{resource_id}' not found"
|
|
38
|
+
super().__init__(message, code="not_found")
|
|
39
|
+
self.resource_type = resource_type
|
|
40
|
+
self.resource_id = resource_id
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimitError(PodstackError):
|
|
44
|
+
"""Raised when API rate limit is exceeded"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, retry_after: int = None):
|
|
47
|
+
message = "Rate limit exceeded"
|
|
48
|
+
if retry_after:
|
|
49
|
+
message += f". Retry after {retry_after} seconds"
|
|
50
|
+
super().__init__(message, code="rate_limit_exceeded")
|
|
51
|
+
self.retry_after = retry_after
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GPUNotAvailableError(PodstackError):
|
|
55
|
+
"""Raised when the requested GPU type is not available"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, gpu_type: str, available_types: list = None):
|
|
58
|
+
message = f"GPU type '{gpu_type}' is not available"
|
|
59
|
+
super().__init__(message, code="gpu_not_available")
|
|
60
|
+
self.gpu_type = gpu_type
|
|
61
|
+
self.available_types = available_types or []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ExecutionTimeoutError(PodstackError):
|
|
65
|
+
"""Raised when code execution times out"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, execution_id: str, timeout_seconds: int):
|
|
68
|
+
message = f"Execution '{execution_id}' timed out after {timeout_seconds} seconds"
|
|
69
|
+
super().__init__(message, code="execution_timeout")
|
|
70
|
+
self.execution_id = execution_id
|
|
71
|
+
self.timeout_seconds = timeout_seconds
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ValidationError(PodstackError):
|
|
75
|
+
"""Raised when request validation fails"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, message: str, field: str = None):
|
|
78
|
+
super().__init__(message, code="validation_error")
|
|
79
|
+
self.field = field
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class QuotaExceededError(PodstackError):
|
|
83
|
+
"""Raised when account quota is exceeded"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, quota_type: str, limit: int, current: int):
|
|
86
|
+
message = f"{quota_type} quota exceeded: {current}/{limit}"
|
|
87
|
+
super().__init__(message, code="quota_exceeded")
|
|
88
|
+
self.quota_type = quota_type
|
|
89
|
+
self.limit = limit
|
|
90
|
+
self.current = current
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class InsufficientBalanceError(PodstackError):
|
|
94
|
+
"""Raised when wallet balance is insufficient"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, required: float, available: float):
|
|
97
|
+
message = f"Insufficient balance: required ${required:.2f}, available ${available:.2f}"
|
|
98
|
+
super().__init__(message, code="insufficient_balance")
|
|
99
|
+
self.required = required
|
|
100
|
+
self.available = available
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class NotebookStateError(PodstackError):
|
|
104
|
+
"""Raised when notebook is in an invalid state for the operation"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, notebook_id: str, current_state: str, required_state: str):
|
|
107
|
+
message = f"Notebook '{notebook_id}' is {current_state}, must be {required_state}"
|
|
108
|
+
super().__init__(message, code="invalid_notebook_state")
|
|
109
|
+
self.notebook_id = notebook_id
|
|
110
|
+
self.current_state = current_state
|
|
111
|
+
self.required_state = required_state
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ConnectionError(PodstackError):
|
|
115
|
+
"""Raised when connection to the API fails"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, message: str = "Failed to connect to Podstack API"):
|
|
118
|
+
super().__init__(message, code="connection_error")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class WebSocketError(PodstackError):
|
|
122
|
+
"""Raised when WebSocket connection fails"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, message: str = "WebSocket connection error"):
|
|
125
|
+
super().__init__(message, code="websocket_error")
|