hopx-ai 0.1.15__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.

Potentially problematic release.


This version of hopx-ai might be problematic. Click here for more details.

hopx_ai/sandbox.py ADDED
@@ -0,0 +1,1447 @@
1
+ """Main Sandbox class - E2B inspired pattern."""
2
+
3
+ from typing import Optional, List, Iterator, Dict, Any
4
+ from datetime import datetime, timedelta
5
+ from dataclasses import dataclass
6
+ import logging
7
+
8
+ # Public API models (enhanced with generated models + convenience)
9
+ from .models import (
10
+ SandboxInfo,
11
+ Template,
12
+ ExecutionResult, # ExecuteResponse + convenience methods
13
+ RichOutput,
14
+ MetricsSnapshot,
15
+ Language,
16
+ )
17
+
18
+ from ._client import HTTPClient
19
+ from ._agent_client import AgentHTTPClient
20
+ from ._utils import remove_none_values
21
+ from .files import Files
22
+ from .commands import Commands
23
+ from .desktop import Desktop
24
+ from .env_vars import EnvironmentVariables
25
+ from .cache import Cache
26
+ from ._ws_client import WebSocketClient
27
+ from .terminal import Terminal
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ @dataclass
33
+ class TokenData:
34
+ """JWT token storage."""
35
+ token: str
36
+ expires_at: datetime
37
+
38
+
39
+ # Global token cache (shared across all Sandbox instances)
40
+ _token_cache: Dict[str, TokenData] = {}
41
+
42
+
43
+ class Sandbox:
44
+ """
45
+ Bunnyshell Sandbox - lightweight VM management.
46
+
47
+ Create and manage sandboxes (microVMs) with a simple, intuitive API.
48
+
49
+ Example:
50
+ >>> from bunnyshell import Sandbox
51
+ >>>
52
+ >>> # Create sandbox
53
+ >>> sandbox = Sandbox.create(template="code-interpreter")
54
+ >>> print(sandbox.get_info().public_host)
55
+ >>>
56
+ >>> # Use and cleanup
57
+ >>> sandbox.kill()
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ sandbox_id: str,
63
+ *,
64
+ api_key: Optional[str] = None,
65
+ base_url: str = "https://api.hopx.dev",
66
+ timeout: int = 60,
67
+ max_retries: int = 3,
68
+ ):
69
+ """
70
+ Initialize Sandbox instance.
71
+
72
+ Note: Prefer using Sandbox.create() or Sandbox.connect() instead of direct instantiation.
73
+
74
+ Args:
75
+ sandbox_id: Sandbox ID
76
+ api_key: API key (or use HOPX_API_KEY env var)
77
+ base_url: API base URL
78
+ timeout: Request timeout in seconds
79
+ max_retries: Maximum number of retries
80
+ """
81
+ self.sandbox_id = sandbox_id
82
+ self._client = HTTPClient(
83
+ api_key=api_key,
84
+ base_url=base_url,
85
+ timeout=timeout,
86
+ max_retries=max_retries,
87
+ )
88
+ self._agent_client: Optional[AgentHTTPClient] = None
89
+ self._ws_client: Optional[WebSocketClient] = None
90
+ self._files: Optional[Files] = None
91
+ self._commands: Optional[Commands] = None
92
+ self._desktop: Optional[Desktop] = None
93
+ self._env: Optional[EnvironmentVariables] = None
94
+ self._cache: Optional[Cache] = None
95
+ self._terminal: Optional[Terminal] = None
96
+
97
+ @property
98
+ def files(self) -> Files:
99
+ """
100
+ File operations resource.
101
+
102
+ Lazy initialization - gets agent URL on first access.
103
+
104
+ Returns:
105
+ Files resource instance
106
+
107
+ Example:
108
+ >>> sandbox = Sandbox.create(template="code-interpreter")
109
+ >>> content = sandbox.files.read('/workspace/data.txt')
110
+ """
111
+ if self._files is None:
112
+ self._ensure_agent_client()
113
+ # WS client is lazy-loaded in Files.watch() - not needed for basic operations
114
+ self._files = Files(self._agent_client, self)
115
+ return self._files
116
+
117
+ @property
118
+ def commands(self) -> Commands:
119
+ """
120
+ Command execution resource.
121
+
122
+ Lazy initialization - gets agent URL on first access.
123
+
124
+ Returns:
125
+ Commands resource instance
126
+
127
+ Example:
128
+ >>> sandbox = Sandbox.create(template="nodejs")
129
+ >>> result = sandbox.commands.run('npm install')
130
+ """
131
+ if self._commands is None:
132
+ self._ensure_agent_client()
133
+ self._commands = Commands(self._agent_client)
134
+ return self._commands
135
+
136
+ @property
137
+ def desktop(self) -> Desktop:
138
+ """
139
+ Desktop automation resource.
140
+
141
+ Lazy initialization - checks desktop availability on first access.
142
+
143
+ Provides methods for:
144
+ - VNC server management
145
+ - Mouse and keyboard control
146
+ - Screenshot capture
147
+ - Screen recording
148
+ - Window management
149
+ - Display configuration
150
+
151
+ Returns:
152
+ Desktop resource instance
153
+
154
+ Raises:
155
+ DesktopNotAvailableError: If template doesn't support desktop automation
156
+
157
+ Example:
158
+ >>> sandbox = Sandbox.create(template="desktop")
159
+ >>>
160
+ >>> # Start VNC
161
+ >>> vnc_info = sandbox.desktop.start_vnc()
162
+ >>> print(f"VNC at: {vnc_info.url}")
163
+ >>>
164
+ >>> # Mouse control
165
+ >>> sandbox.desktop.click(100, 100)
166
+ >>> sandbox.desktop.type("Hello World")
167
+ >>>
168
+ >>> # Screenshot
169
+ >>> img = sandbox.desktop.screenshot()
170
+ >>> with open('screen.png', 'wb') as f:
171
+ ... f.write(img)
172
+ >>>
173
+ >>> # If desktop not available:
174
+ >>> try:
175
+ ... sandbox.desktop.click(100, 100)
176
+ ... except DesktopNotAvailableError as e:
177
+ ... print(e.message)
178
+ ... print(e.install_command)
179
+ """
180
+ if self._desktop is None:
181
+ self._ensure_agent_client()
182
+ self._desktop = Desktop(self._agent_client)
183
+ return self._desktop
184
+
185
+ @property
186
+ def env(self) -> EnvironmentVariables:
187
+ """
188
+ Environment variables resource.
189
+
190
+ Lazy initialization - gets agent URL on first access.
191
+
192
+ Provides methods for:
193
+ - Get all environment variables
194
+ - Set/replace all environment variables
195
+ - Update specific environment variables (merge)
196
+ - Delete environment variables
197
+
198
+ Returns:
199
+ EnvironmentVariables resource instance
200
+
201
+ Example:
202
+ >>> sandbox = Sandbox.create(template="code-interpreter")
203
+ >>>
204
+ >>> # Get all environment variables
205
+ >>> env = sandbox.env.get_all()
206
+ >>> print(env.get("PATH"))
207
+ >>>
208
+ >>> # Set a single variable
209
+ >>> sandbox.env.set("API_KEY", "sk-prod-xyz")
210
+ >>>
211
+ >>> # Update multiple variables (merge)
212
+ >>> sandbox.env.update({
213
+ ... "NODE_ENV": "production",
214
+ ... "DEBUG": "false"
215
+ ... })
216
+ >>>
217
+ >>> # Get a specific variable
218
+ >>> api_key = sandbox.env.get("API_KEY")
219
+ >>>
220
+ >>> # Delete a variable
221
+ >>> sandbox.env.delete("DEBUG")
222
+ """
223
+ if self._env is None:
224
+ self._ensure_agent_client()
225
+ self._env = EnvironmentVariables(self._agent_client)
226
+ return self._env
227
+
228
+ @property
229
+ def cache(self) -> Cache:
230
+ """
231
+ Cache management resource.
232
+
233
+ Lazy initialization - gets agent URL on first access.
234
+
235
+ Provides methods for:
236
+ - Get cache statistics
237
+ - Clear cache
238
+
239
+ Returns:
240
+ Cache resource instance
241
+
242
+ Example:
243
+ >>> sandbox = Sandbox.create(template="code-interpreter")
244
+ >>>
245
+ >>> # Get cache stats
246
+ >>> stats = sandbox.cache.stats()
247
+ >>> print(f"Cache hits: {stats['hits']}")
248
+ >>> print(f"Cache size: {stats['size']} MB")
249
+ >>>
250
+ >>> # Clear cache
251
+ >>> sandbox.cache.clear()
252
+ """
253
+ if self._cache is None:
254
+ self._ensure_agent_client()
255
+ self._cache = Cache(self._agent_client)
256
+ return self._cache
257
+
258
+ @property
259
+ def terminal(self) -> Terminal:
260
+ """
261
+ Interactive terminal resource via WebSocket.
262
+
263
+ Lazy initialization - gets agent URL and WebSocket client on first access.
264
+
265
+ Provides methods for:
266
+ - Connect to interactive terminal
267
+ - Send input to terminal
268
+ - Resize terminal
269
+ - Receive output stream
270
+
271
+ Returns:
272
+ Terminal resource instance
273
+
274
+ Note:
275
+ Requires websockets library: pip install websockets
276
+
277
+ Example:
278
+ >>> import asyncio
279
+ >>>
280
+ >>> async def run_terminal():
281
+ ... sandbox = Sandbox.create(template="code-interpreter")
282
+ ...
283
+ ... # Connect to terminal
284
+ ... async with await sandbox.terminal.connect() as ws:
285
+ ... # Send command
286
+ ... await sandbox.terminal.send_input(ws, "ls -la\\n")
287
+ ...
288
+ ... # Receive output
289
+ ... async for message in sandbox.terminal.iter_output(ws):
290
+ ... if message['type'] == 'output':
291
+ ... print(message['data'], end='')
292
+ ... elif message['type'] == 'exit':
293
+ ... break
294
+ >>>
295
+ >>> asyncio.run(run_terminal())
296
+ """
297
+ if self._terminal is None:
298
+ self._ensure_ws_client()
299
+ self._terminal = Terminal(self._ws_client)
300
+ return self._terminal
301
+
302
+ def _ensure_agent_client(self) -> None:
303
+ """Ensure agent HTTP client is initialized."""
304
+ if self._agent_client is None:
305
+ info = self.get_info()
306
+ agent_url = info.public_host.rstrip('/')
307
+
308
+ # Ensure JWT token is valid
309
+ self._ensure_valid_token()
310
+
311
+ # Get JWT token for agent authentication
312
+ jwt_token = _token_cache.get(self.sandbox_id)
313
+ jwt_token_str = jwt_token.token if jwt_token else None
314
+
315
+ # Create agent client with token refresh callback
316
+ def refresh_token_callback():
317
+ """Callback to refresh token when agent returns 401."""
318
+ self.refresh_token()
319
+ token_data = _token_cache.get(self.sandbox_id)
320
+ return token_data.token if token_data else None
321
+
322
+ self._agent_client = AgentHTTPClient(
323
+ agent_url=agent_url,
324
+ jwt_token=jwt_token_str,
325
+ timeout=60, # Default 60s for agent operations
326
+ max_retries=3,
327
+ token_refresh_callback=refresh_token_callback
328
+ )
329
+ logger.debug(f"Agent client initialized: {agent_url}")
330
+
331
+ # Wait for agent to be ready on first access
332
+ # Agent might need a moment after sandbox creation
333
+ import time
334
+ max_wait = 30 # seconds (increased for reliability)
335
+ retry_delay = 1.5 # seconds between retries
336
+
337
+ for attempt in range(max_wait):
338
+ try:
339
+ # Quick health check with short timeout
340
+ health = self._agent_client.get("/health", operation="agent health check", timeout=5)
341
+ if health.json().get("status") == "healthy":
342
+ logger.debug(f"Agent ready after {attempt * retry_delay:.1f}s")
343
+ break
344
+ except Exception as e:
345
+ if attempt < max_wait - 1:
346
+ time.sleep(retry_delay)
347
+ continue
348
+ # Don't log warning - agent will usually work anyway
349
+ logger.debug(f"Agent health check timeout after {max_wait * retry_delay:.1f}s: {e}")
350
+
351
+ def _ensure_ws_client(self) -> None:
352
+ """Ensure WebSocket client is initialized and agent is ready."""
353
+ if self._ws_client is None:
354
+ # First ensure agent HTTP client is ready (which waits for agent)
355
+ self._ensure_agent_client()
356
+
357
+ info = self.get_info()
358
+ agent_url = info.public_host.rstrip('/')
359
+ self._ws_client = WebSocketClient(agent_url)
360
+ logger.debug(f"WebSocket client initialized: {agent_url}")
361
+
362
+ def refresh_token(self) -> None:
363
+ """
364
+ Refresh JWT token for agent authentication.
365
+ Called automatically when token is about to expire (<1 hour left).
366
+ """
367
+ response = self._client.post(f"/v1/sandboxes/{self.sandbox_id}/token/refresh")
368
+
369
+ if "auth_token" in response and "token_expires_at" in response:
370
+ _token_cache[self.sandbox_id] = TokenData(
371
+ token=response["auth_token"],
372
+ expires_at=datetime.fromisoformat(response["token_expires_at"].replace("Z", "+00:00"))
373
+ )
374
+
375
+ # Update agent client's JWT token if already initialized
376
+ if self._agent_client is not None:
377
+ self._agent_client.update_jwt_token(response["auth_token"])
378
+
379
+ def _ensure_valid_token(self) -> None:
380
+ """
381
+ Ensure JWT token is valid (not expired or expiring soon).
382
+ Auto-refreshes if less than 1 hour remaining.
383
+ """
384
+ token_data = _token_cache.get(self.sandbox_id)
385
+
386
+ if token_data is None:
387
+ # No token yet, try to refresh
388
+ try:
389
+ self.refresh_token()
390
+ except Exception:
391
+ # Token might not be available yet (e.g., old sandbox)
392
+ pass
393
+ return
394
+
395
+ # Check if token expires soon (< 1 hour)
396
+ now = datetime.now(token_data.expires_at.tzinfo)
397
+ hours_left = (token_data.expires_at - now).total_seconds() / 3600
398
+
399
+ if hours_left < 1:
400
+ # Refresh token
401
+ self.refresh_token()
402
+
403
+ def get_token(self) -> str:
404
+ """
405
+ Get current JWT token (for advanced use cases).
406
+ Automatically refreshes if needed.
407
+
408
+ Returns:
409
+ JWT token string
410
+
411
+ Raises:
412
+ HopxError: If no token available
413
+ """
414
+ self._ensure_valid_token()
415
+
416
+ token_data = _token_cache.get(self.sandbox_id)
417
+ if token_data is None:
418
+ from .errors import HopxError
419
+ raise HopxError('No JWT token available for sandbox')
420
+
421
+ return token_data.token
422
+
423
+ # =============================================================================
424
+ # CLASS METHODS (Static - for creating/listing sandboxes)
425
+ # =============================================================================
426
+
427
+ @classmethod
428
+ def create(
429
+ cls,
430
+ template: Optional[str] = None,
431
+ *,
432
+ template_id: Optional[str] = None,
433
+ region: Optional[str] = None,
434
+ timeout_seconds: Optional[int] = None,
435
+ internet_access: Optional[bool] = None,
436
+ env_vars: Optional[Dict[str, str]] = None,
437
+ api_key: Optional[str] = None,
438
+ base_url: str = "https://api.hopx.dev",
439
+ ) -> "Sandbox":
440
+ """
441
+ Create a new sandbox from a template.
442
+
443
+ Resources (vcpu, memory, disk) are ALWAYS loaded from the template.
444
+ You cannot specify custom resources - create a template first with desired resources.
445
+
446
+ Args:
447
+ template: Template name (e.g., "my-python-template")
448
+ template_id: Template ID (alternative to template name)
449
+ region: Preferred region (optional, auto-selected if not specified)
450
+ timeout_seconds: Auto-kill timeout in seconds (optional, default: no timeout)
451
+ internet_access: Enable internet access (optional, default: True)
452
+ env_vars: Environment variables to set in the sandbox (optional)
453
+ api_key: API key (or use HOPX_API_KEY env var)
454
+ base_url: API base URL (default: production)
455
+
456
+ Returns:
457
+ Sandbox instance
458
+
459
+ Raises:
460
+ ValidationError: Invalid parameters
461
+ ResourceLimitError: Insufficient resources
462
+ APIError: API request failed
463
+
464
+ Examples:
465
+ >>> # Create from template ID with timeout
466
+ >>> sandbox = Sandbox.create(
467
+ ... template_id="291",
468
+ ... timeout_seconds=300,
469
+ ... internet_access=True
470
+ ... )
471
+ >>> print(sandbox.get_info().public_host)
472
+
473
+ >>> # Create from template name without internet
474
+ >>> sandbox = Sandbox.create(
475
+ ... template="my-python-template",
476
+ ... env_vars={"DEBUG": "true"},
477
+ ... internet_access=False
478
+ ... )
479
+ """
480
+ # Create HTTP client
481
+ client = HTTPClient(api_key=api_key, base_url=base_url)
482
+
483
+ # Validate parameters
484
+ if template_id:
485
+ # Create from template ID (resources from template)
486
+ # Convert template_id to string if it's an int (API may return int from build)
487
+ data = remove_none_values({
488
+ "template_id": str(template_id),
489
+ "region": region,
490
+ "timeout_seconds": timeout_seconds,
491
+ "internet_access": internet_access,
492
+ "env_vars": env_vars,
493
+ })
494
+ elif template:
495
+ # Create from template name (resources from template)
496
+ data = remove_none_values({
497
+ "template_name": template,
498
+ "region": region,
499
+ "timeout_seconds": timeout_seconds,
500
+ "internet_access": internet_access,
501
+ "env_vars": env_vars,
502
+ })
503
+ else:
504
+ raise ValueError("Either 'template' or 'template_id' must be provided")
505
+
506
+ # Create sandbox via API
507
+ response = client.post("/v1/sandboxes", json=data)
508
+ sandbox_id = response["id"]
509
+
510
+ # ⚠️ NEW: Store JWT token from create response
511
+ if "auth_token" in response and "token_expires_at" in response:
512
+ _token_cache[sandbox_id] = TokenData(
513
+ token=response["auth_token"],
514
+ expires_at=datetime.fromisoformat(response["token_expires_at"].replace("Z", "+00:00"))
515
+ )
516
+
517
+ # Return Sandbox instance
518
+ return cls(
519
+ sandbox_id=sandbox_id,
520
+ api_key=api_key,
521
+ base_url=base_url,
522
+ )
523
+
524
+ @classmethod
525
+ def connect(
526
+ cls,
527
+ sandbox_id: str,
528
+ *,
529
+ api_key: Optional[str] = None,
530
+ base_url: str = "https://api.hopx.dev",
531
+ ) -> "Sandbox":
532
+ """
533
+ Connect to an existing sandbox.
534
+
535
+ NEW JWT Behavior:
536
+ - If VM is paused → resumes it and refreshes JWT token
537
+ - If VM is stopped → raises error (cannot connect to stopped VM)
538
+ - If VM is running/active → refreshes JWT token
539
+ - Stores JWT token for agent authentication
540
+
541
+ Args:
542
+ sandbox_id: Sandbox ID
543
+ api_key: API key (or use HOPX_API_KEY env var)
544
+ base_url: API base URL
545
+
546
+ Returns:
547
+ Sandbox instance
548
+
549
+ Raises:
550
+ NotFoundError: Sandbox not found
551
+ HopxError: If sandbox is stopped or in invalid state
552
+
553
+ Example:
554
+ >>> sandbox = Sandbox.connect("1761048129dsaqav4n")
555
+ >>> info = sandbox.get_info()
556
+ >>> print(info.status)
557
+ """
558
+ # Create instance
559
+ instance = cls(
560
+ sandbox_id=sandbox_id,
561
+ api_key=api_key,
562
+ base_url=base_url,
563
+ )
564
+
565
+ # Get current VM status
566
+ info = instance.get_info()
567
+
568
+ # Handle different VM states
569
+ if info.status == "stopped":
570
+ from .errors import HopxError
571
+ raise HopxError(
572
+ f"Cannot connect to stopped sandbox {sandbox_id}. "
573
+ "Use sandbox.start() to start it first, or create a new sandbox."
574
+ )
575
+
576
+ if info.status == "paused":
577
+ # Resume paused VM
578
+ instance.resume()
579
+
580
+ if info.status not in ("running", "paused"):
581
+ from .errors import HopxError
582
+ raise HopxError(
583
+ f"Cannot connect to sandbox {sandbox_id} with status '{info.status}'. "
584
+ "Expected 'running' or 'paused'."
585
+ )
586
+
587
+ # Refresh JWT token for agent authentication
588
+ instance.refresh_token()
589
+
590
+ return instance
591
+
592
+ @classmethod
593
+ def iter(
594
+ cls,
595
+ *,
596
+ status: Optional[str] = None,
597
+ region: Optional[str] = None,
598
+ api_key: Optional[str] = None,
599
+ base_url: str = "https://api.hopx.dev",
600
+ ) -> Iterator["Sandbox"]:
601
+ """
602
+ Lazy iterator for sandboxes.
603
+
604
+ Yields sandboxes one by one, fetching pages as needed.
605
+ Doesn't load all sandboxes into memory at once.
606
+
607
+ Args:
608
+ status: Filter by status (running, stopped, paused, creating)
609
+ region: Filter by region
610
+ api_key: API key (or use HOPX_API_KEY env var)
611
+ base_url: API base URL
612
+
613
+ Yields:
614
+ Sandbox instances
615
+
616
+ Example:
617
+ >>> # Lazy loading - fetches pages as needed
618
+ >>> for sandbox in Sandbox.iter(status="running"):
619
+ ... print(f"{sandbox.sandbox_id}")
620
+ ... if found:
621
+ ... break # Doesn't fetch remaining pages!
622
+ """
623
+ client = HTTPClient(api_key=api_key, base_url=base_url)
624
+ limit = 100
625
+ has_more = True
626
+ cursor = None
627
+
628
+ while has_more:
629
+ params = {"limit": limit}
630
+ if status:
631
+ params["status"] = status
632
+ if region:
633
+ params["region"] = region
634
+ if cursor:
635
+ params["cursor"] = cursor
636
+
637
+ logger.debug(f"Fetching sandboxes page (cursor: {cursor})")
638
+ response = client.get("/v1/sandboxes", params=params)
639
+
640
+ for item in response.get("data", []):
641
+ yield cls(
642
+ sandbox_id=item["id"],
643
+ api_key=api_key,
644
+ base_url=base_url,
645
+ )
646
+
647
+ has_more = response.get("has_more", False)
648
+ cursor = response.get("next_cursor")
649
+
650
+ if has_more:
651
+ logger.debug(f"More results available, next cursor: {cursor}")
652
+
653
+ @classmethod
654
+ def list(
655
+ cls,
656
+ *,
657
+ status: Optional[str] = None,
658
+ region: Optional[str] = None,
659
+ limit: int = 100,
660
+ api_key: Optional[str] = None,
661
+ base_url: str = "https://api.hopx.dev",
662
+ ) -> List["Sandbox"]:
663
+ """
664
+ List all sandboxes (loads all into memory).
665
+
666
+ For lazy loading (better memory usage), use Sandbox.iter() instead.
667
+
668
+ Args:
669
+ status: Filter by status (running, stopped, paused, creating)
670
+ region: Filter by region
671
+ limit: Maximum number of results (default: 100)
672
+ api_key: API key (or use HOPX_API_KEY env var)
673
+ base_url: API base URL
674
+
675
+ Returns:
676
+ List of Sandbox instances (all loaded into memory)
677
+
678
+ Example:
679
+ >>> # List all running sandboxes (loads all into memory)
680
+ >>> sandboxes = Sandbox.list(status="running")
681
+ >>> for sb in sandboxes:
682
+ ... print(f"{sb.sandbox_id}")
683
+
684
+ >>> # For better memory usage, use iter():
685
+ >>> for sb in Sandbox.iter(status="running"):
686
+ ... print(f"{sb.sandbox_id}")
687
+ """
688
+ client = HTTPClient(api_key=api_key, base_url=base_url)
689
+
690
+ params = remove_none_values({
691
+ "status": status,
692
+ "region": region,
693
+ "limit": limit,
694
+ })
695
+
696
+ response = client.get("/v1/sandboxes", params=params)
697
+ sandboxes_data = response.get("data", [])
698
+
699
+ # Create Sandbox instances
700
+ return [
701
+ cls(
702
+ sandbox_id=sb["id"],
703
+ api_key=api_key,
704
+ base_url=base_url,
705
+ )
706
+ for sb in sandboxes_data
707
+ ]
708
+
709
+ @classmethod
710
+ def list_templates(
711
+ cls,
712
+ *,
713
+ category: Optional[str] = None,
714
+ language: Optional[str] = None,
715
+ api_key: Optional[str] = None,
716
+ base_url: str = "https://api.hopx.dev",
717
+ ) -> List[Template]:
718
+ """
719
+ List available templates.
720
+
721
+ Args:
722
+ category: Filter by category (development, infrastructure, operating-system)
723
+ language: Filter by language (python, nodejs, etc.)
724
+ api_key: API key (or use HOPX_API_KEY env var)
725
+ base_url: API base URL
726
+
727
+ Returns:
728
+ List of Template objects
729
+
730
+ Example:
731
+ >>> templates = Sandbox.list_templates()
732
+ >>> for t in templates:
733
+ ... print(f"{t.name}: {t.display_name}")
734
+
735
+ >>> # Filter by category
736
+ >>> dev_templates = Sandbox.list_templates(category="development")
737
+ """
738
+ client = HTTPClient(api_key=api_key, base_url=base_url)
739
+
740
+ params = remove_none_values({
741
+ "category": category,
742
+ "language": language,
743
+ })
744
+
745
+ response = client.get("/v1/templates", params=params)
746
+ templates_data = response.get("data", [])
747
+
748
+ return [Template(**t) for t in templates_data]
749
+
750
+ @classmethod
751
+ def get_template(
752
+ cls,
753
+ name: str,
754
+ *,
755
+ api_key: Optional[str] = None,
756
+ base_url: str = "https://api.hopx.dev",
757
+ ) -> Template:
758
+ """
759
+ Get template details.
760
+
761
+ Args:
762
+ name: Template name
763
+ api_key: API key (or use HOPX_API_KEY env var)
764
+ base_url: API base URL
765
+
766
+ Returns:
767
+ Template object
768
+
769
+ Raises:
770
+ NotFoundError: Template not found
771
+
772
+ Example:
773
+ >>> template = Sandbox.get_template("code-interpreter")
774
+ >>> print(template.description)
775
+ >>> print(f"Default: {template.default_resources.vcpu} vCPU")
776
+ """
777
+ client = HTTPClient(api_key=api_key, base_url=base_url)
778
+ response = client.get(f"/v1/templates/{name}")
779
+ return Template(**response)
780
+
781
+ # =============================================================================
782
+ # INSTANCE METHODS (for managing individual sandbox)
783
+ # =============================================================================
784
+
785
+ def get_info(self) -> SandboxInfo:
786
+ """
787
+ Get current sandbox information.
788
+
789
+ Returns:
790
+ SandboxInfo with current state
791
+
792
+ Raises:
793
+ NotFoundError: Sandbox not found
794
+
795
+ Example:
796
+ >>> sandbox = Sandbox.create(template="nodejs")
797
+ >>> info = sandbox.get_info()
798
+ >>> print(f"Status: {info.status}")
799
+ >>> print(f"URL: {info.public_host}")
800
+ >>> print(f"Ends at: {info.end_at}")
801
+ """
802
+ response = self._client.get(f"/v1/sandboxes/{self.sandbox_id}")
803
+
804
+ # Parse resources if present
805
+ resources = None
806
+ if response.get("resources"):
807
+ from .models import Resources
808
+ resources = Resources(
809
+ vcpu=response["resources"]["vcpu"],
810
+ memory_mb=response["resources"]["memory_mb"],
811
+ disk_mb=response["resources"]["disk_mb"]
812
+ )
813
+
814
+ return SandboxInfo(
815
+ sandbox_id=response["id"],
816
+ template_id=response.get("template_id"),
817
+ template_name=response.get("template_name"),
818
+ organization_id=response.get("organization_id", ""),
819
+ node_id=response.get("node_id"),
820
+ region=response.get("region"),
821
+ status=response["status"],
822
+ public_host=response.get("public_host") or response.get("direct_url", ""),
823
+ resources=resources,
824
+ created_at=response.get("created_at"),
825
+ started_at=None, # TODO: Add when API provides it
826
+ end_at=None, # TODO: Add when API provides it
827
+ )
828
+
829
+ def get_agent_metrics(self) -> Dict[str, Any]:
830
+ """
831
+ Get real-time agent metrics.
832
+
833
+ Returns agent performance and health metrics including uptime,
834
+ request counts, error counts, and performance statistics.
835
+
836
+ Returns:
837
+ Dict with metrics including:
838
+ - uptime_seconds: Agent uptime
839
+ - total_requests: Total requests count
840
+ - total_errors: Total errors count
841
+ - requests_total: Per-endpoint request counts
842
+ - avg_duration_ms: Average request duration by endpoint
843
+
844
+ Example:
845
+ >>> metrics = sandbox.get_agent_metrics()
846
+ >>> print(f"Uptime: {metrics['uptime_seconds']}s")
847
+ >>> print(f"Total requests: {metrics.get('total_requests', 0)}")
848
+ >>> print(f"Errors: {metrics.get('total_errors', 0)}")
849
+
850
+ Note:
851
+ Requires Agent v3.1.0+
852
+ """
853
+ self._ensure_agent_client()
854
+
855
+ logger.debug("Getting agent metrics")
856
+
857
+ response = self._agent_client.get(
858
+ "/metrics/snapshot",
859
+ operation="get agent metrics"
860
+ )
861
+
862
+ return response.json()
863
+
864
+ def run_code(
865
+ self,
866
+ code: str,
867
+ *,
868
+ language: str = "python",
869
+ timeout: int = 60,
870
+ env: Optional[Dict[str, str]] = None,
871
+ working_dir: str = "/workspace",
872
+ ) -> ExecutionResult:
873
+ """
874
+ Execute code with rich output capture (plots, DataFrames, etc.).
875
+
876
+ This method automatically captures visual outputs like matplotlib plots,
877
+ pandas DataFrames, and plotly charts.
878
+
879
+ Args:
880
+ code: Code to execute
881
+ language: Language (python, javascript, bash, go)
882
+ timeout: Execution timeout in seconds (default: 60)
883
+ env: Optional environment variables for this execution only.
884
+ Priority: Request env > Global env > Agent env
885
+ working_dir: Working directory for execution (default: /workspace)
886
+
887
+ Returns:
888
+ ExecutionResult with stdout, stderr, rich_outputs
889
+
890
+ Raises:
891
+ CodeExecutionError: If execution fails
892
+ TimeoutError: If execution times out
893
+
894
+ Example:
895
+ >>> # Simple code execution
896
+ >>> result = sandbox.run_code('print("Hello, World!")')
897
+ >>> print(result.stdout) # "Hello, World!\n"
898
+ >>>
899
+ >>> # With environment variables
900
+ >>> result = sandbox.run_code(
901
+ ... 'import os; print(os.environ["API_KEY"])',
902
+ ... env={"API_KEY": "sk-test-123", "DEBUG": "true"}
903
+ ... )
904
+ >>>
905
+ >>> # Execute with matplotlib plot
906
+ >>> code = '''
907
+ ... import matplotlib.pyplot as plt
908
+ ... plt.plot([1, 2, 3, 4])
909
+ ... plt.savefig('/workspace/plot.png')
910
+ ... '''
911
+ >>> result = sandbox.run_code(code)
912
+ >>> print(f"Generated {result.rich_count} outputs")
913
+ >>>
914
+ >>> # Check for errors
915
+ >>> result = sandbox.run_code('print(undefined_var)')
916
+ >>> if not result.success:
917
+ ... print(f"Error: {result.stderr}")
918
+ >>>
919
+ >>> # With custom timeout for long-running code
920
+ >>> result = sandbox.run_code(long_code, timeout=300)
921
+ """
922
+ self._ensure_agent_client()
923
+
924
+ logger.debug(f"Executing {language} code ({len(code)} chars)")
925
+
926
+ # Build request payload
927
+ payload = {
928
+ "language": language,
929
+ "code": code,
930
+ "working_dir": working_dir,
931
+ "timeout": timeout
932
+ }
933
+
934
+ # Add optional environment variables
935
+ if env:
936
+ payload["env"] = env
937
+
938
+ # Use /execute endpoint for code execution
939
+ response = self._agent_client.post(
940
+ "/execute",
941
+ json=payload,
942
+ operation="execute code",
943
+ context={"language": language},
944
+ timeout=timeout + 5 # Add buffer to HTTP timeout
945
+ )
946
+
947
+ data = response.json() if response.content else {}
948
+
949
+ # Parse rich outputs
950
+ rich_outputs = []
951
+ if data and isinstance(data, dict):
952
+ rich_outputs_data = data.get("rich_outputs") or []
953
+ for output in rich_outputs_data:
954
+ if output:
955
+ rich_outputs.append(RichOutput(
956
+ type=output.get("type", ""),
957
+ data=output.get("data", {}),
958
+ metadata=output.get("metadata"),
959
+ timestamp=output.get("timestamp")
960
+ ))
961
+
962
+ # Create result
963
+ result = ExecutionResult(
964
+ success=data.get("success", True) if data else False,
965
+ stdout=data.get("stdout", "") if data else "",
966
+ stderr=data.get("stderr", "") if data else "",
967
+ exit_code=data.get("exit_code", 0) if data else 1,
968
+ execution_time=data.get("execution_time", 0.0) if data else 0.0,
969
+ rich_outputs=rich_outputs
970
+ )
971
+
972
+ return result
973
+
974
+ def run_code_async(
975
+ self,
976
+ code: str,
977
+ callback_url: str,
978
+ *,
979
+ language: str = "python",
980
+ timeout: int = 1800,
981
+ env: Optional[Dict[str, str]] = None,
982
+ working_dir: str = "/workspace",
983
+ callback_headers: Optional[Dict[str, str]] = None,
984
+ callback_signature_secret: Optional[str] = None,
985
+ ) -> Dict[str, Any]:
986
+ """
987
+ Execute code asynchronously with webhook callback.
988
+
989
+ For long-running code (>5 minutes). Agent will POST results to callback_url when complete.
990
+
991
+ Args:
992
+ code: Code to execute
993
+ callback_url: URL to POST results to when execution completes
994
+ language: Language (python, javascript, bash, go)
995
+ timeout: Execution timeout in seconds (default: 1800 = 30 min)
996
+ env: Optional environment variables
997
+ working_dir: Working directory (default: /workspace)
998
+ callback_headers: Custom headers to include in callback request
999
+ callback_signature_secret: Secret to sign callback payload (HMAC-SHA256)
1000
+
1001
+ Returns:
1002
+ Dict with execution_id, status, callback_url
1003
+
1004
+ Example:
1005
+ >>> # Start async execution
1006
+ >>> response = sandbox.run_code_async(
1007
+ ... code='import time; time.sleep(600); print("Done!")',
1008
+ ... callback_url='https://app.com/webhooks/ml/training',
1009
+ ... callback_headers={'Authorization': 'Bearer secret'},
1010
+ ... callback_signature_secret='webhook-secret-123'
1011
+ ... )
1012
+ >>> print(f"Execution ID: {response['execution_id']}")
1013
+ >>>
1014
+ >>> # Agent will POST to callback_url when done:
1015
+ >>> # POST https://app.com/webhooks/ml/training
1016
+ >>> # X-HOPX-Signature: sha256=...
1017
+ >>> # X-HOPX-Timestamp: 1698765432
1018
+ >>> # Authorization: Bearer secret
1019
+ >>> # {
1020
+ >>> # "execution_id": "abc123",
1021
+ >>> # "status": "completed",
1022
+ >>> # "stdout": "Done!",
1023
+ >>> # "stderr": "",
1024
+ >>> # "exit_code": 0,
1025
+ >>> # "execution_time": 600.123
1026
+ >>> # }
1027
+ """
1028
+ self._ensure_agent_client()
1029
+
1030
+ logger.debug(f"Starting async {language} execution ({len(code)} chars)")
1031
+
1032
+ # Build request payload
1033
+ payload = {
1034
+ "code": code,
1035
+ "language": language,
1036
+ "timeout": timeout,
1037
+ "working_dir": working_dir,
1038
+ "callback_url": callback_url,
1039
+ }
1040
+
1041
+ if env:
1042
+ payload["env"] = env
1043
+ if callback_headers:
1044
+ payload["callback_headers"] = callback_headers
1045
+ if callback_signature_secret:
1046
+ payload["callback_signature_secret"] = callback_signature_secret
1047
+
1048
+ response = self._agent_client.post(
1049
+ "/execute/async",
1050
+ json=payload,
1051
+ operation="async execute code",
1052
+ context={"language": language},
1053
+ timeout=10 # Quick response
1054
+ )
1055
+
1056
+ return response.json()
1057
+
1058
+ def run_code_background(
1059
+ self,
1060
+ code: str,
1061
+ *,
1062
+ language: str = "python",
1063
+ timeout: int = 300,
1064
+ env: Optional[Dict[str, str]] = None,
1065
+ working_dir: str = "/workspace",
1066
+ name: Optional[str] = None,
1067
+ ) -> Dict[str, Any]:
1068
+ """
1069
+ Execute code in background and return immediately.
1070
+
1071
+ Use list_processes() to check status and kill_process() to terminate.
1072
+
1073
+ Args:
1074
+ code: Code to execute
1075
+ language: Language (python, javascript, bash, go)
1076
+ timeout: Execution timeout in seconds (default: 300 = 5 min)
1077
+ env: Optional environment variables
1078
+ working_dir: Working directory (default: /workspace)
1079
+ name: Optional process name for identification
1080
+
1081
+ Returns:
1082
+ Dict with process_id, execution_id, status
1083
+
1084
+ Example:
1085
+ >>> # Start background execution
1086
+ >>> result = sandbox.run_code_background(
1087
+ ... code='long_running_task()',
1088
+ ... name='ml-training',
1089
+ ... env={"GPU": "enabled"}
1090
+ ... )
1091
+ >>> process_id = result['process_id']
1092
+ >>>
1093
+ >>> # Check status
1094
+ >>> processes = sandbox.list_processes()
1095
+ >>> for p in processes:
1096
+ ... if p['process_id'] == process_id:
1097
+ ... print(f"Status: {p['status']}")
1098
+ >>>
1099
+ >>> # Kill if needed
1100
+ >>> sandbox.kill_process(process_id)
1101
+ """
1102
+ self._ensure_agent_client()
1103
+
1104
+ logger.debug(f"Starting background {language} execution ({len(code)} chars)")
1105
+
1106
+ # Build request payload
1107
+ payload = {
1108
+ "code": code,
1109
+ "language": language,
1110
+ "timeout": timeout,
1111
+ "working_dir": working_dir,
1112
+ }
1113
+
1114
+ if env:
1115
+ payload["env"] = env
1116
+ if name:
1117
+ payload["name"] = name
1118
+
1119
+ response = self._agent_client.post(
1120
+ "/execute/background",
1121
+ json=payload,
1122
+ operation="background execute code",
1123
+ context={"language": language},
1124
+ timeout=10 # Quick response
1125
+ )
1126
+
1127
+ return response.json()
1128
+
1129
+ def list_processes(self) -> List[Dict[str, Any]]:
1130
+ """
1131
+ List all background execution processes.
1132
+
1133
+ Returns:
1134
+ List of process dictionaries with status
1135
+
1136
+ Example:
1137
+ >>> processes = sandbox.list_processes()
1138
+ >>> for p in processes:
1139
+ ... print(f"{p['name']}: {p['status']} (PID: {p['process_id']})")
1140
+ """
1141
+ self._ensure_agent_client()
1142
+
1143
+ response = self._agent_client.get(
1144
+ "/execute/processes",
1145
+ operation="list processes"
1146
+ )
1147
+
1148
+ data = response.json()
1149
+ return data.get("processes", [])
1150
+
1151
+ def kill_process(self, process_id: str) -> Dict[str, Any]:
1152
+ """
1153
+ Kill a background execution process.
1154
+
1155
+ Args:
1156
+ process_id: Process ID to kill
1157
+
1158
+ Returns:
1159
+ Dict with confirmation message
1160
+
1161
+ Example:
1162
+ >>> sandbox.kill_process("proc_abc123")
1163
+ """
1164
+ self._ensure_agent_client()
1165
+
1166
+ response = self._agent_client.post(
1167
+ f"/execute/kill/{process_id}",
1168
+ operation="kill process",
1169
+ context={"process_id": process_id}
1170
+ )
1171
+
1172
+ return response.json()
1173
+
1174
+ def get_metrics_snapshot(self) -> Dict[str, Any]:
1175
+ """
1176
+ Get current system metrics snapshot.
1177
+
1178
+ Returns:
1179
+ Dict with system metrics (CPU, memory, disk), process metrics, cache stats
1180
+
1181
+ Example:
1182
+ >>> metrics = sandbox.get_metrics_snapshot()
1183
+ >>> print(f"CPU: {metrics['system']['cpu']['usage_percent']}%")
1184
+ >>> print(f"Memory: {metrics['system']['memory']['usage_percent']}%")
1185
+ >>> print(f"Processes: {metrics['process']['count']}")
1186
+ >>> print(f"Cache size: {metrics['cache']['size']}")
1187
+ """
1188
+ self._ensure_agent_client()
1189
+
1190
+ response = self._agent_client.get(
1191
+ "/metrics/snapshot",
1192
+ operation="get metrics snapshot"
1193
+ )
1194
+
1195
+ return response.json()
1196
+
1197
+ def run_ipython(
1198
+ self,
1199
+ code: str,
1200
+ *,
1201
+ timeout: int = 60,
1202
+ env: Optional[Dict[str, str]] = None,
1203
+ ) -> ExecutionResult:
1204
+ """
1205
+ Execute code in persistent IPython kernel.
1206
+
1207
+ Variables and state persist across executions.
1208
+ Supports magic commands and interactive features.
1209
+
1210
+ Args:
1211
+ code: Code to execute in IPython
1212
+ timeout: Execution timeout in seconds (default: 60)
1213
+ env: Optional environment variables
1214
+
1215
+ Returns:
1216
+ ExecutionResult with stdout, stderr
1217
+
1218
+ Example:
1219
+ >>> # First execution - define variable
1220
+ >>> sandbox.run_ipython("x = 10")
1221
+ >>>
1222
+ >>> # Second execution - x persists!
1223
+ >>> result = sandbox.run_ipython("print(x)")
1224
+ >>> print(result.stdout) # "10"
1225
+ >>>
1226
+ >>> # Magic commands work
1227
+ >>> sandbox.run_ipython("%timeit sum(range(100))")
1228
+ """
1229
+ self._ensure_agent_client()
1230
+
1231
+ logger.debug(f"Executing IPython code ({len(code)} chars)")
1232
+
1233
+ # Build request payload
1234
+ payload = {
1235
+ "code": code,
1236
+ "timeout": timeout,
1237
+ }
1238
+
1239
+ if env:
1240
+ payload["env"] = env
1241
+
1242
+ response = self._agent_client.post(
1243
+ "/execute/ipython",
1244
+ json=payload,
1245
+ operation="execute ipython",
1246
+ timeout=timeout + 5
1247
+ )
1248
+
1249
+ data = response.json()
1250
+
1251
+ return ExecutionResult(
1252
+ success=data.get("success", True),
1253
+ stdout=data.get("stdout", ""),
1254
+ stderr=data.get("stderr", ""),
1255
+ exit_code=data.get("exit_code", 0),
1256
+ execution_time=data.get("execution_time", 0.0)
1257
+ )
1258
+
1259
+ async def run_code_stream(
1260
+ self,
1261
+ code: str,
1262
+ *,
1263
+ language: str = "python",
1264
+ timeout: int = 60,
1265
+ env: Optional[Dict[str, str]] = None,
1266
+ working_dir: str = "/workspace"
1267
+ ):
1268
+ """
1269
+ Execute code with real-time output streaming via WebSocket.
1270
+
1271
+ Stream stdout/stderr as it's generated (async generator).
1272
+
1273
+ Args:
1274
+ code: Code to execute
1275
+ language: Language (python, javascript, bash, go)
1276
+ timeout: Execution timeout in seconds
1277
+ env: Optional environment variables
1278
+ working_dir: Working directory
1279
+
1280
+ Yields:
1281
+ Message dictionaries:
1282
+ - {"type": "stdout", "data": "...", "timestamp": "..."}
1283
+ - {"type": "stderr", "data": "...", "timestamp": "..."}
1284
+ - {"type": "result", "exit_code": 0, "execution_time": 1.23}
1285
+ - {"type": "complete", "success": True}
1286
+
1287
+ Note:
1288
+ Requires websockets library: pip install websockets
1289
+
1290
+ Example:
1291
+ >>> import asyncio
1292
+ >>>
1293
+ >>> async def stream_execution():
1294
+ ... sandbox = Sandbox.create(template="code-interpreter")
1295
+ ...
1296
+ ... code = '''
1297
+ ... import time
1298
+ ... for i in range(5):
1299
+ ... print(f"Step {i+1}/5")
1300
+ ... time.sleep(1)
1301
+ ... '''
1302
+ ...
1303
+ ... async for message in sandbox.run_code_stream(code):
1304
+ ... if message['type'] == 'stdout':
1305
+ ... print(message['data'], end='')
1306
+ ... elif message['type'] == 'result':
1307
+ ... print(f"\\nExit code: {message['exit_code']}")
1308
+ >>>
1309
+ >>> asyncio.run(stream_execution())
1310
+ """
1311
+ self._ensure_ws_client()
1312
+
1313
+ # Connect to streaming endpoint
1314
+ async with await self._ws_client.connect("/execute/stream") as ws:
1315
+ # Send execution request
1316
+ request = {
1317
+ "type": "execute",
1318
+ "code": code,
1319
+ "language": language,
1320
+ "timeout": timeout,
1321
+ "working_dir": working_dir
1322
+ }
1323
+ if env:
1324
+ request["env"] = env
1325
+
1326
+ await self._ws_client.send_message(ws, request)
1327
+
1328
+ # Stream messages
1329
+ async for message in self._ws_client.iter_messages(ws):
1330
+ yield message
1331
+
1332
+ # Stop on complete
1333
+ if message.get('type') == 'complete':
1334
+ break
1335
+
1336
+ def set_timeout(self, seconds: int) -> None:
1337
+ """
1338
+ Extend sandbox timeout.
1339
+
1340
+ The new timeout will be 'seconds' from now.
1341
+
1342
+ Args:
1343
+ seconds: New timeout duration in seconds from now
1344
+
1345
+ Example:
1346
+ >>> sandbox = Sandbox.create(template="nodejs", timeout=300)
1347
+ >>> # Extend to 10 minutes from now
1348
+ >>> sandbox.set_timeout(600)
1349
+
1350
+ Note:
1351
+ This feature may not be available in all plans.
1352
+ """
1353
+ # TODO: Implement when API supports it
1354
+ # For now, this is a placeholder matching E2B's API
1355
+ raise NotImplementedError(
1356
+ "set_timeout() will be available soon. "
1357
+ "For now, create sandbox with desired timeout: "
1358
+ "Sandbox.create(template='...', timeout=600)"
1359
+ )
1360
+
1361
+ def stop(self) -> None:
1362
+ """
1363
+ Stop the sandbox.
1364
+
1365
+ A stopped sandbox can be started again with start().
1366
+
1367
+ Example:
1368
+ >>> sandbox.stop()
1369
+ >>> # ... do something else ...
1370
+ >>> sandbox.start()
1371
+ """
1372
+ self._client.post(f"/v1/sandboxes/{self.sandbox_id}/stop")
1373
+
1374
+ def start(self) -> None:
1375
+ """
1376
+ Start a stopped sandbox.
1377
+
1378
+ Example:
1379
+ >>> sandbox.start()
1380
+ """
1381
+ self._client.post(f"/v1/sandboxes/{self.sandbox_id}/start")
1382
+
1383
+ def pause(self) -> None:
1384
+ """
1385
+ Pause the sandbox.
1386
+
1387
+ A paused sandbox can be resumed with resume().
1388
+
1389
+ Example:
1390
+ >>> sandbox.pause()
1391
+ >>> # ... do something else ...
1392
+ >>> sandbox.resume()
1393
+ """
1394
+ self._client.post(f"/v1/sandboxes/{self.sandbox_id}/pause")
1395
+
1396
+ def resume(self) -> None:
1397
+ """
1398
+ Resume a paused sandbox.
1399
+
1400
+ Example:
1401
+ >>> sandbox.resume()
1402
+ """
1403
+ self._client.post(f"/v1/sandboxes/{self.sandbox_id}/resume")
1404
+
1405
+ def kill(self) -> None:
1406
+ """
1407
+ Destroy the sandbox immediately.
1408
+
1409
+ This action is irreversible. All data in the sandbox will be lost.
1410
+
1411
+ Example:
1412
+ >>> sandbox = Sandbox.create(template="nodejs")
1413
+ >>> # ... use sandbox ...
1414
+ >>> sandbox.kill() # Clean up
1415
+ """
1416
+ self._client.delete(f"/v1/sandboxes/{self.sandbox_id}")
1417
+
1418
+ # =============================================================================
1419
+ # CONTEXT MANAGER (auto-cleanup)
1420
+ # =============================================================================
1421
+
1422
+ def __enter__(self) -> "Sandbox":
1423
+ """Context manager entry."""
1424
+ return self
1425
+
1426
+ def __exit__(self, *args) -> None:
1427
+ """Context manager exit - auto cleanup."""
1428
+ try:
1429
+ self.kill()
1430
+ except Exception:
1431
+ # Ignore errors on cleanup
1432
+ pass
1433
+
1434
+ # =============================================================================
1435
+ # UTILITY METHODS
1436
+ # =============================================================================
1437
+
1438
+ def __repr__(self) -> str:
1439
+ return f"<Sandbox {self.sandbox_id}>"
1440
+
1441
+ def __str__(self) -> str:
1442
+ try:
1443
+ info = self.get_info()
1444
+ return f"Sandbox(id={self.sandbox_id}, status={info.status}, url={info.public_host})"
1445
+ except Exception:
1446
+ return f"Sandbox(id={self.sandbox_id})"
1447
+