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