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