hotcell 0.1.1__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.
hotcell/__init__.py ADDED
@@ -0,0 +1,796 @@
1
+ """hotcell — Python client for the hotcell self-hostable agent sandbox daemon.
2
+
3
+ Mirrors the TypeScript SDK (and, by extension, the Cloudflare Sandbox surface)
4
+ so existing harnesses port with minimal changes, but points at *your* self-hosted
5
+ daemon instead of the edge. Dependency-free: standard library only.
6
+
7
+ from hotcell import HotcellClient
8
+
9
+ client = HotcellClient(endpoint="http://127.0.0.1:4750")
10
+ sandbox = client.get_sandbox()
11
+ result = sandbox.exec("python3 -c 'print(2+2)'")
12
+ print(result.stdout) # "4\n"
13
+ sandbox.destroy()
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from dataclasses import dataclass
24
+ from typing import Any, Dict, Iterator, List, Optional
25
+
26
+ __all__ = [
27
+ "HotcellClient",
28
+ "Sandbox",
29
+ "Session",
30
+ "CodeContext",
31
+ "HotcellError",
32
+ "ExecResult",
33
+ "ExecEvent",
34
+ "SandboxInfo",
35
+ "FileInfo",
36
+ "SandboxStats",
37
+ "SandboxUsage",
38
+ "CostBreakdown",
39
+ "SandboxMetrics",
40
+ "ProcessHandle",
41
+ "ExposedPort",
42
+ "SessionInfo",
43
+ "CodeOutput",
44
+ "CodeResult",
45
+ "BackupInfo",
46
+ "FileChangeEvent",
47
+ "get_sandbox",
48
+ ]
49
+
50
+ DEFAULT_ENDPOINT = "http://127.0.0.1:4750"
51
+
52
+
53
+ class HotcellError(RuntimeError):
54
+ """Raised when the daemon returns a non-2xx response."""
55
+
56
+ def __init__(self, method: str, path: str, status: int, body: str):
57
+ super().__init__(f"hotcell {method} {path} -> {status}: {body}")
58
+ self.status = status
59
+ self.body = body
60
+
61
+
62
+ # --- data types ------------------------------------------------------------
63
+
64
+
65
+ @dataclass
66
+ class ExecResult:
67
+ stdout: str
68
+ stderr: str
69
+ exit_code: int
70
+ success: bool
71
+
72
+
73
+ @dataclass
74
+ class ExecEvent:
75
+ type: str # "stdout" | "stderr" | "exit"
76
+ data: Optional[str] = None
77
+ exit_code: Optional[int] = None
78
+
79
+
80
+ @dataclass
81
+ class SandboxInfo:
82
+ id: str
83
+ image: str
84
+ status: str # "running" | "paused" | "stopped"
85
+ created_at: str
86
+ labels: Dict[str, str]
87
+ persist: bool
88
+ last_activity_at: str
89
+ sleep_after_ms: int
90
+ limits: Dict[str, Any] # {memoryMb?, cpus?, pidsLimit?}; {} = unlimited
91
+
92
+ @classmethod
93
+ def from_dict(cls, d: Dict[str, Any]) -> "SandboxInfo":
94
+ return cls(
95
+ id=d["id"],
96
+ image=d["image"],
97
+ status=d["status"],
98
+ created_at=d.get("createdAt", ""),
99
+ labels=d.get("labels", {}),
100
+ persist=d.get("persist", True),
101
+ last_activity_at=d.get("lastActivityAt", ""),
102
+ sleep_after_ms=d.get("sleepAfterMs", 0),
103
+ limits=d.get("limits", {}),
104
+ )
105
+
106
+
107
+ @dataclass
108
+ class FileInfo:
109
+ path: str
110
+ name: str
111
+ is_directory: bool
112
+ size: int
113
+ modified_at: str
114
+
115
+ @classmethod
116
+ def from_dict(cls, d: Dict[str, Any]) -> "FileInfo":
117
+ return cls(
118
+ path=d["path"],
119
+ name=d["name"],
120
+ is_directory=d.get("isDirectory", False),
121
+ size=d.get("size", 0),
122
+ modified_at=d.get("modifiedAt", ""),
123
+ )
124
+
125
+
126
+ @dataclass
127
+ class SandboxStats:
128
+ cpu_percent: float
129
+ cpu_total_usage_ns: int
130
+ online_cpus: int
131
+ mem_bytes: int
132
+ mem_limit_bytes: int
133
+ net_rx_bytes: int
134
+ net_tx_bytes: int
135
+ pids: int
136
+ sampled_at: str
137
+
138
+ @classmethod
139
+ def from_dict(cls, d: Dict[str, Any]) -> "SandboxStats":
140
+ return cls(
141
+ cpu_percent=d.get("cpuPercent", 0.0),
142
+ cpu_total_usage_ns=d.get("cpuTotalUsageNs", 0),
143
+ online_cpus=d.get("onlineCpus", 1),
144
+ mem_bytes=d.get("memBytes", 0),
145
+ mem_limit_bytes=d.get("memLimitBytes", 0),
146
+ net_rx_bytes=d.get("netRxBytes", 0),
147
+ net_tx_bytes=d.get("netTxBytes", 0),
148
+ pids=d.get("pids", 0),
149
+ sampled_at=d.get("sampledAt", ""),
150
+ )
151
+
152
+
153
+ @dataclass
154
+ class SandboxUsage:
155
+ cpu_seconds: float
156
+ mem_byte_seconds: float
157
+ egress_bytes: int
158
+ provider_calls: int = 0
159
+ provider_bytes: int = 0
160
+ provider_tokens_in: int = 0
161
+ provider_tokens_out: int = 0
162
+ provider_cost: float = 0.0 # provider-reported LLM cost in USD (OpenRouter usage.cost)
163
+
164
+ @classmethod
165
+ def from_dict(cls, d: Dict[str, Any]) -> "SandboxUsage":
166
+ return cls(
167
+ cpu_seconds=d.get("cpuSeconds", 0.0),
168
+ mem_byte_seconds=d.get("memByteSeconds", 0.0),
169
+ egress_bytes=d.get("egressBytes", 0),
170
+ provider_calls=d.get("providerCalls", 0),
171
+ provider_bytes=d.get("providerBytes", 0),
172
+ provider_tokens_in=d.get("providerTokensIn", 0),
173
+ provider_tokens_out=d.get("providerTokensOut", 0),
174
+ provider_cost=d.get("providerCost", 0.0),
175
+ )
176
+
177
+
178
+ @dataclass
179
+ class CostBreakdown:
180
+ cpu: float
181
+ mem: float
182
+ egress: float
183
+ provider: float # LLM cost (provider-reported, e.g. OpenRouter usage.cost)
184
+ total: float
185
+
186
+ @classmethod
187
+ def from_dict(cls, d: Dict[str, Any]) -> "CostBreakdown":
188
+ return cls(
189
+ cpu=d.get("cpu", 0.0),
190
+ mem=d.get("mem", 0.0),
191
+ egress=d.get("egress", 0.0),
192
+ provider=d.get("provider", 0.0),
193
+ total=d.get("total", 0.0),
194
+ )
195
+
196
+
197
+ @dataclass
198
+ class SandboxMetrics:
199
+ status: str
200
+ live: Optional[SandboxStats]
201
+ usage: SandboxUsage
202
+ cost: CostBreakdown
203
+
204
+ @classmethod
205
+ def from_dict(cls, d: Dict[str, Any]) -> "SandboxMetrics":
206
+ live = d.get("live")
207
+ return cls(
208
+ status=d.get("status", ""),
209
+ live=SandboxStats.from_dict(live) if live else None,
210
+ usage=SandboxUsage.from_dict(d.get("usage", {})),
211
+ cost=CostBreakdown.from_dict(d.get("cost", {})),
212
+ )
213
+
214
+
215
+ @dataclass
216
+ class ProcessHandle:
217
+ proc_id: str
218
+ pid: int
219
+ command: str
220
+ status: str
221
+ exit_code: Optional[int]
222
+ started_at: str
223
+ log_path: str
224
+
225
+ @classmethod
226
+ def from_dict(cls, d: Dict[str, Any]) -> "ProcessHandle":
227
+ return cls(
228
+ proc_id=d["procId"],
229
+ pid=d.get("pid", 0),
230
+ command=d.get("command", ""),
231
+ status=d.get("status", ""),
232
+ exit_code=d.get("exitCode"),
233
+ started_at=d.get("startedAt", ""),
234
+ log_path=d.get("logPath", ""),
235
+ )
236
+
237
+
238
+ @dataclass
239
+ class ExposedPort:
240
+ port: int
241
+ expose_id: str
242
+ token: Optional[str]
243
+ created_at: str
244
+ url: str
245
+
246
+ @classmethod
247
+ def from_dict(cls, d: Dict[str, Any]) -> "ExposedPort":
248
+ return cls(
249
+ port=d["port"],
250
+ expose_id=d.get("exposeId", ""),
251
+ token=d.get("token"),
252
+ created_at=d.get("createdAt", ""),
253
+ url=d.get("url", ""),
254
+ )
255
+
256
+
257
+ @dataclass
258
+ class SessionInfo:
259
+ session_id: str
260
+ cwd: str
261
+ env: Dict[str, str]
262
+ created_at: str
263
+
264
+ @classmethod
265
+ def from_dict(cls, d: Dict[str, Any]) -> "SessionInfo":
266
+ return cls(
267
+ session_id=d["sessionId"],
268
+ cwd=d.get("cwd", ""),
269
+ env=d.get("env", {}),
270
+ created_at=d.get("createdAt", ""),
271
+ )
272
+
273
+
274
+ @dataclass
275
+ class CodeOutput:
276
+ type: str
277
+ text: str
278
+
279
+
280
+ @dataclass
281
+ class CodeResult:
282
+ stdout: str
283
+ stderr: str
284
+ results: List[CodeOutput]
285
+ error: Optional[str]
286
+
287
+ @classmethod
288
+ def from_dict(cls, d: Dict[str, Any]) -> "CodeResult":
289
+ return cls(
290
+ stdout=d.get("stdout", ""),
291
+ stderr=d.get("stderr", ""),
292
+ results=[CodeOutput(type=r.get("type", "text"), text=r.get("text", ""))
293
+ for r in d.get("results", [])],
294
+ error=d.get("error"),
295
+ )
296
+
297
+
298
+ @dataclass
299
+ class BackupInfo:
300
+ backup_id: str
301
+ sandbox_id: str
302
+ created_at: str
303
+ bytes: int
304
+
305
+ @classmethod
306
+ def from_dict(cls, d: Dict[str, Any]) -> "BackupInfo":
307
+ return cls(
308
+ backup_id=d["backupId"],
309
+ sandbox_id=d.get("sandboxId", ""),
310
+ created_at=d.get("createdAt", ""),
311
+ bytes=d.get("bytes", 0),
312
+ )
313
+
314
+
315
+ @dataclass
316
+ class FileChangeEvent:
317
+ type: str # "created" | "modified" | "deleted"
318
+ path: str
319
+
320
+
321
+ # --- client ----------------------------------------------------------------
322
+
323
+
324
+ class HotcellClient:
325
+ """Entry point. Talks to one hotcell daemon over its REST API."""
326
+
327
+ def __init__(self, endpoint: Optional[str] = None,
328
+ api_key: Optional[str] = None):
329
+ self.endpoint = (endpoint or os.environ.get("HOTCELL_ENDPOINT")
330
+ or DEFAULT_ENDPOINT).rstrip("/")
331
+ # API key sent as `Authorization: Bearer <key>`; required when the daemon
332
+ # runs with HOTCELL_API_KEY. Falls back to the HOTCELL_API_KEY env var.
333
+ self.api_key = api_key if api_key is not None else os.environ.get("HOTCELL_API_KEY", "")
334
+
335
+ def get_sandbox(
336
+ self,
337
+ id: Optional[str] = None,
338
+ *,
339
+ image: Optional[str] = None,
340
+ env: Optional[Dict[str, str]] = None,
341
+ labels: Optional[Dict[str, str]] = None,
342
+ persist: Optional[bool] = None,
343
+ sleep_after: Optional[int] = None,
344
+ egress: Optional[bool] = None,
345
+ setup: Optional[List[str]] = None,
346
+ repo: Optional[str] = None,
347
+ repo_ref: Optional[str] = None,
348
+ memory_mb: Optional[float] = None,
349
+ cpus: Optional[float] = None,
350
+ pids_limit: Optional[int] = None,
351
+ ) -> "Sandbox":
352
+ """Attach to a sandbox by id, or (id omitted) provision a fresh one."""
353
+ if id:
354
+ info = self.request("GET", f"/sandboxes/{id}")
355
+ return Sandbox(self, SandboxInfo.from_dict(info))
356
+ body: Dict[str, Any] = {}
357
+ if image is not None:
358
+ body["image"] = image
359
+ if env is not None:
360
+ body["env"] = env
361
+ if labels is not None:
362
+ body["labels"] = labels
363
+ if persist is not None:
364
+ body["persist"] = persist
365
+ if sleep_after is not None:
366
+ body["sleepAfter"] = sleep_after
367
+ if egress is not None:
368
+ body["egress"] = egress
369
+ if setup is not None:
370
+ body["setup"] = setup
371
+ if repo is not None:
372
+ body["repo"] = repo
373
+ if repo_ref is not None:
374
+ body["repoRef"] = repo_ref
375
+ if memory_mb is not None:
376
+ body["memoryMb"] = memory_mb
377
+ if cpus is not None:
378
+ body["cpus"] = cpus
379
+ if pids_limit is not None:
380
+ body["pidsLimit"] = pids_limit
381
+ info = self.request("POST", "/sandboxes", body)
382
+ return Sandbox(self, SandboxInfo.from_dict(info))
383
+
384
+ def list(self) -> List[SandboxInfo]:
385
+ data = self.request("GET", "/sandboxes")
386
+ return [SandboxInfo.from_dict(s) for s in data.get("sandboxes", [])]
387
+
388
+ def health(self) -> Dict[str, Any]:
389
+ return self.request("GET", "/healthz")
390
+
391
+ def info(self) -> Dict[str, Any]:
392
+ """Daemon info: driver, default image, proxy port, cost rates."""
393
+ return self.request("GET", "/info")
394
+
395
+ def list_backups(self) -> List[BackupInfo]:
396
+ data = self.request("GET", "/backups")
397
+ return [BackupInfo.from_dict(b) for b in data.get("backups", [])]
398
+
399
+ def delete_backup(self, backup_id: str) -> None:
400
+ self.request("DELETE", f"/backups/{backup_id}")
401
+
402
+ # -- HTTP plumbing ------------------------------------------------------
403
+
404
+ def request(self, method: str, path: str, body: Any = None) -> Any:
405
+ """Issue a JSON request and return the parsed response (or None)."""
406
+ resp = self._open(method, path, body=body)
407
+ with resp:
408
+ raw = resp.read()
409
+ return json.loads(raw) if raw else None
410
+
411
+ def stream(
412
+ self,
413
+ method: str,
414
+ path: str,
415
+ *,
416
+ body: Any = None,
417
+ params: Optional[Dict[str, str]] = None,
418
+ ) -> Iterator[Dict[str, Any]]:
419
+ """Issue a request and yield decoded Server-Sent-Event JSON frames."""
420
+ resp = self._open(method, path, body=body, params=params)
421
+ with resp:
422
+ for raw in resp:
423
+ line = raw.decode("utf-8").rstrip("\r\n")
424
+ if not line.startswith("data:"):
425
+ continue # skip comments (": ...") and blank separators
426
+ payload = line[5:].strip()
427
+ if payload:
428
+ yield json.loads(payload)
429
+
430
+ def _open(
431
+ self,
432
+ method: str,
433
+ path: str,
434
+ *,
435
+ body: Any = None,
436
+ params: Optional[Dict[str, str]] = None,
437
+ ):
438
+ url = self.endpoint + path
439
+ if params:
440
+ url += "?" + urllib.parse.urlencode(params)
441
+ data = None
442
+ headers = {}
443
+ if self.api_key:
444
+ headers["authorization"] = "Bearer " + self.api_key
445
+ if body is not None:
446
+ data = json.dumps(body).encode("utf-8")
447
+ headers["content-type"] = "application/json"
448
+ req = urllib.request.Request(url, data=data, method=method, headers=headers)
449
+ try:
450
+ return urllib.request.urlopen(req)
451
+ except urllib.error.HTTPError as e:
452
+ detail = e.read().decode("utf-8", "replace")
453
+ raise HotcellError(method, path, e.code, detail) from None
454
+
455
+
456
+ class Sandbox:
457
+ """A single sandbox. Mirrors the TS `Sandbox` class, snake_cased."""
458
+
459
+ def __init__(self, client: HotcellClient, info: SandboxInfo):
460
+ self._client = client
461
+ self._info = info
462
+
463
+ @property
464
+ def id(self) -> str:
465
+ return self._info.id
466
+
467
+ @property
468
+ def status(self) -> str:
469
+ return self._info.status
470
+
471
+ @property
472
+ def info(self) -> SandboxInfo:
473
+ return self._info
474
+
475
+ # -- exec ---------------------------------------------------------------
476
+
477
+ def exec(
478
+ self,
479
+ command: str,
480
+ *,
481
+ cwd: Optional[str] = None,
482
+ env: Optional[Dict[str, str]] = None,
483
+ session_id: Optional[str] = None,
484
+ ) -> ExecResult:
485
+ """Run a command to completion, returning aggregated output."""
486
+ stdout, stderr, exit_code = [], [], 0
487
+ for event in self.exec_stream(command, cwd=cwd, env=env, session_id=session_id):
488
+ if event.type == "stdout":
489
+ stdout.append(event.data or "")
490
+ elif event.type == "stderr":
491
+ stderr.append(event.data or "")
492
+ elif event.type == "exit":
493
+ exit_code = event.exit_code or 0
494
+ return ExecResult("".join(stdout), "".join(stderr), exit_code, exit_code == 0)
495
+
496
+ def exec_stream(
497
+ self,
498
+ command: str,
499
+ *,
500
+ cwd: Optional[str] = None,
501
+ env: Optional[Dict[str, str]] = None,
502
+ session_id: Optional[str] = None,
503
+ ) -> Iterator[ExecEvent]:
504
+ """Run a command, yielding output events as they stream in."""
505
+ body = {"command": command, "cwd": cwd, "env": env, "sessionId": session_id}
506
+ for frame in self._client.stream(
507
+ "POST", f"/sandboxes/{self.id}/exec", body=body
508
+ ):
509
+ yield ExecEvent(
510
+ type=frame["type"],
511
+ data=frame.get("data"),
512
+ exit_code=frame.get("exitCode"),
513
+ )
514
+
515
+ # -- lifecycle ----------------------------------------------------------
516
+
517
+ def stop(self) -> None:
518
+ self._info = SandboxInfo.from_dict(
519
+ self._client.request("POST", f"/sandboxes/{self.id}/stop")
520
+ )
521
+
522
+ def start(self) -> None:
523
+ self._info = SandboxInfo.from_dict(
524
+ self._client.request("POST", f"/sandboxes/{self.id}/start")
525
+ )
526
+
527
+ def destroy(self) -> None:
528
+ self._client.request("DELETE", f"/sandboxes/{self.id}")
529
+
530
+ def metrics(self) -> SandboxMetrics:
531
+ """Live stats, cumulative usage, and cost. Passive (won't keep alive)."""
532
+ return SandboxMetrics.from_dict(
533
+ self._client.request("GET", f"/sandboxes/{self.id}/metrics")
534
+ )
535
+
536
+ def metrics_history(self) -> List[Dict[str, Any]]:
537
+ """Recent live-metrics samples (oldest->newest) for sparklines/history."""
538
+ data = self._client.request("GET", f"/sandboxes/{self.id}/metrics/history")
539
+ return data.get("samples", [])
540
+
541
+ # -- egress credential proxy (LLM gateway) ------------------------------
542
+
543
+ def create_egress_token(self) -> Dict[str, Any]:
544
+ """Mint an egress token; returns {token, providers}. Point the sandbox's
545
+ LLM SDK at a provider baseUrl and use the token in place of the real key."""
546
+ return self._client.request("POST", f"/sandboxes/{self.id}/egress-tokens")
547
+
548
+ def list_egress_tokens(self) -> Dict[str, Any]:
549
+ """List this sandbox's egress tokens and available provider routes."""
550
+ return self._client.request("GET", f"/sandboxes/{self.id}/egress-tokens")
551
+
552
+ def revoke_egress_token(self, token: str) -> None:
553
+ """Revoke a previously minted egress token."""
554
+ self._client.request("DELETE", f"/sandboxes/{self.id}/egress-tokens/{token}")
555
+
556
+ # -- files --------------------------------------------------------------
557
+
558
+ def write_file(self, path: str, content: str, *, mode: Optional[str] = None) -> None:
559
+ self._client.request(
560
+ "POST", f"/sandboxes/{self.id}/files/write",
561
+ {"path": path, "content": content, "mode": mode},
562
+ )
563
+
564
+ def read_file(self, path: str) -> str:
565
+ return self._client.request(
566
+ "POST", f"/sandboxes/{self.id}/files/read", {"path": path}
567
+ )["content"]
568
+
569
+ def mkdir(self, path: str, *, parents: bool = False) -> None:
570
+ self._client.request(
571
+ "POST", f"/sandboxes/{self.id}/files/mkdir",
572
+ {"path": path, "parents": parents},
573
+ )
574
+
575
+ def list_files(self, path: str) -> List[FileInfo]:
576
+ data = self._client.request(
577
+ "POST", f"/sandboxes/{self.id}/files/list", {"path": path}
578
+ )
579
+ return [FileInfo.from_dict(e) for e in data.get("entries", [])]
580
+
581
+ def watch(
582
+ self, path: str = "/workspace", *, interval_ms: Optional[int] = None
583
+ ) -> Iterator[FileChangeEvent]:
584
+ """Watch a path recursively, yielding change events until closed."""
585
+ params = {"path": path}
586
+ if interval_ms:
587
+ params["interval"] = str(interval_ms)
588
+ for frame in self._client.stream(
589
+ "GET", f"/sandboxes/{self.id}/watch", params=params
590
+ ):
591
+ yield FileChangeEvent(type=frame["type"], path=frame["path"])
592
+
593
+ # -- processes ----------------------------------------------------------
594
+
595
+ def start_process(
596
+ self,
597
+ command: str,
598
+ *,
599
+ cwd: Optional[str] = None,
600
+ env: Optional[Dict[str, str]] = None,
601
+ ) -> ProcessHandle:
602
+ return ProcessHandle.from_dict(
603
+ self._client.request(
604
+ "POST", f"/sandboxes/{self.id}/processes",
605
+ {"command": command, "cwd": cwd, "env": env},
606
+ )
607
+ )
608
+
609
+ def list_processes(self) -> List[ProcessHandle]:
610
+ data = self._client.request("GET", f"/sandboxes/{self.id}/processes")
611
+ return [ProcessHandle.from_dict(p) for p in data.get("processes", [])]
612
+
613
+ def kill_process(self, proc_id: str, signal: Optional[str] = None) -> None:
614
+ self._client.request(
615
+ "DELETE", f"/sandboxes/{self.id}/processes/{proc_id}",
616
+ {"signal": signal} if signal else None,
617
+ )
618
+
619
+ def stream_logs(self, proc_id: str, *, follow: bool = False) -> Iterator[str]:
620
+ params = {"follow": "1" if follow else "0"}
621
+ for frame in self._client.stream(
622
+ "GET", f"/sandboxes/{self.id}/processes/{proc_id}/logs", params=params
623
+ ):
624
+ if frame.get("type") == "log":
625
+ yield frame.get("data", "")
626
+ elif frame.get("type") == "end":
627
+ return
628
+
629
+ def wait_for_port(
630
+ self,
631
+ port: int,
632
+ *,
633
+ timeout_ms: Optional[int] = None,
634
+ interval_ms: Optional[int] = None,
635
+ host: Optional[str] = None,
636
+ ) -> bool:
637
+ body: Dict[str, Any] = {"port": port}
638
+ if timeout_ms is not None:
639
+ body["timeoutMs"] = timeout_ms
640
+ if interval_ms is not None:
641
+ body["intervalMs"] = interval_ms
642
+ if host is not None:
643
+ body["host"] = host
644
+ return self._client.request(
645
+ "POST", f"/sandboxes/{self.id}/wait-port", body
646
+ )["ready"]
647
+
648
+ # -- ports --------------------------------------------------------------
649
+
650
+ def expose_port(self, port: int, *, token: Optional[str] = None) -> ExposedPort:
651
+ return ExposedPort.from_dict(
652
+ self._client.request(
653
+ "POST", f"/sandboxes/{self.id}/expose", {"port": port, "token": token}
654
+ )
655
+ )
656
+
657
+ def unexpose_port(self, port: int) -> None:
658
+ self._client.request("DELETE", f"/sandboxes/{self.id}/expose/{port}")
659
+
660
+ def list_exposed_ports(self) -> List[ExposedPort]:
661
+ data = self._client.request("GET", f"/sandboxes/{self.id}/expose")
662
+ return [ExposedPort.from_dict(p) for p in data.get("exposed", [])]
663
+
664
+ # -- env + sessions -----------------------------------------------------
665
+
666
+ def set_env_vars(self, env: Dict[str, str]) -> Dict[str, str]:
667
+ return self._client.request(
668
+ "POST", f"/sandboxes/{self.id}/env", {"env": env}
669
+ )["env"]
670
+
671
+ def get_env_vars(self) -> Dict[str, str]:
672
+ return self._client.request("GET", f"/sandboxes/{self.id}/env")["env"]
673
+
674
+ def create_session(
675
+ self,
676
+ *,
677
+ id: Optional[str] = None,
678
+ cwd: Optional[str] = None,
679
+ env: Optional[Dict[str, str]] = None,
680
+ ) -> "Session":
681
+ info = self._client.request(
682
+ "POST", f"/sandboxes/{self.id}/sessions",
683
+ {"id": id, "cwd": cwd, "env": env},
684
+ )
685
+ return Session(self, SessionInfo.from_dict(info))
686
+
687
+ def list_sessions(self) -> List[SessionInfo]:
688
+ data = self._client.request("GET", f"/sandboxes/{self.id}/sessions")
689
+ return [SessionInfo.from_dict(s) for s in data.get("sessions", [])]
690
+
691
+ # -- backups ------------------------------------------------------------
692
+
693
+ def create_backup(self) -> BackupInfo:
694
+ return BackupInfo.from_dict(
695
+ self._client.request("POST", f"/sandboxes/{self.id}/backups")
696
+ )
697
+
698
+ def restore_backup(self, backup_id: str) -> None:
699
+ self._client.request(
700
+ "POST", f"/sandboxes/{self.id}/restore", {"backupId": backup_id}
701
+ )
702
+
703
+ def list_backups(self) -> List[BackupInfo]:
704
+ data = self._client.request("GET", f"/sandboxes/{self.id}/backups")
705
+ return [BackupInfo.from_dict(b) for b in data.get("backups", [])]
706
+
707
+ # -- code interpreter ---------------------------------------------------
708
+
709
+ def create_code_context(self, *, language: str = "python") -> "CodeContext":
710
+ info = self._client.request(
711
+ "POST", f"/sandboxes/{self.id}/code-contexts", {"language": language}
712
+ )
713
+ return CodeContext(self, info["contextId"], info.get("language", language))
714
+
715
+ def list_code_contexts(self) -> List[Dict[str, Any]]:
716
+ return self._client.request(
717
+ "GET", f"/sandboxes/{self.id}/code-contexts"
718
+ ).get("contexts", [])
719
+
720
+ def run_code(
721
+ self,
722
+ code: str,
723
+ *,
724
+ context: Optional["CodeContext"] = None,
725
+ language: Optional[str] = None,
726
+ timeout_ms: Optional[int] = None,
727
+ ) -> CodeResult:
728
+ return CodeResult.from_dict(
729
+ self._client.request(
730
+ "POST", f"/sandboxes/{self.id}/run-code",
731
+ {
732
+ "code": code,
733
+ "contextId": context.context_id if context else None,
734
+ "language": language,
735
+ "timeoutMs": timeout_ms,
736
+ },
737
+ )
738
+ )
739
+
740
+
741
+ class Session:
742
+ """A persistent cwd + env overlay; `cd` persists across commands."""
743
+
744
+ def __init__(self, sandbox: Sandbox, info: SessionInfo):
745
+ self._sandbox = sandbox
746
+ self._info = info
747
+
748
+ @property
749
+ def session_id(self) -> str:
750
+ return self._info.session_id
751
+
752
+ def exec(self, command: str, **kwargs: Any) -> ExecResult:
753
+ return self._sandbox.exec(command, session_id=self.session_id, **kwargs)
754
+
755
+ def exec_stream(self, command: str, **kwargs: Any) -> Iterator[ExecEvent]:
756
+ return self._sandbox.exec_stream(command, session_id=self.session_id, **kwargs)
757
+
758
+ def set_env_vars(self, env: Dict[str, str]) -> SessionInfo:
759
+ self._info = SessionInfo.from_dict(
760
+ self._sandbox._client.request(
761
+ "POST",
762
+ f"/sandboxes/{self._sandbox.id}/sessions/{self.session_id}/env",
763
+ {"env": env},
764
+ )
765
+ )
766
+ return self._info
767
+
768
+ def destroy(self) -> None:
769
+ self._sandbox._client.request(
770
+ "DELETE", f"/sandboxes/{self._sandbox.id}/sessions/{self.session_id}"
771
+ )
772
+
773
+
774
+ class CodeContext:
775
+ """A long-lived interpreter kernel; variables/imports persist across runs."""
776
+
777
+ def __init__(self, sandbox: Sandbox, context_id: str, language: str):
778
+ self._sandbox = sandbox
779
+ self.context_id = context_id
780
+ self.language = language
781
+
782
+ def run_code(self, code: str, *, timeout_ms: Optional[int] = None) -> CodeResult:
783
+ return self._sandbox.run_code(code, context=self, timeout_ms=timeout_ms)
784
+
785
+ def destroy(self) -> None:
786
+ self._sandbox._client.request(
787
+ "DELETE",
788
+ f"/sandboxes/{self._sandbox.id}/code-contexts/{self.context_id}",
789
+ )
790
+
791
+
792
+ def get_sandbox(
793
+ client: HotcellClient, id: Optional[str] = None, **options: Any
794
+ ) -> Sandbox:
795
+ """Convenience matching the Cloudflare `getSandbox(binding, id)` shape."""
796
+ return client.get_sandbox(id, **options)
hotcell/py.typed ADDED
File without changes
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: hotcell
3
+ Version: 0.1.1
4
+ Summary: Python client for the hotcell self-hostable agent sandbox daemon
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://hotcell.sh
7
+ Project-URL: Repository, https://github.com/sinameraji/hotcell
8
+ Keywords: sandbox,ai-agents,hotcell
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # hotcell (Python SDK)
13
+
14
+ Dependency-free Python client for the [hotcell](https://github.com/sinameraji/hotcell)
15
+ self-hostable agent sandbox daemon. Mirrors the TypeScript SDK surface (snake_cased)
16
+ so the same workflows work from either language.
17
+
18
+ ```python
19
+ from hotcell import HotcellClient
20
+
21
+ client = HotcellClient(endpoint="http://127.0.0.1:4750") # or $HOTCELL_ENDPOINT
22
+ sandbox = client.get_sandbox() # create a fresh sandbox
23
+
24
+ # run a command
25
+ result = sandbox.exec("python3 -c 'print(2 + 2)'")
26
+ print(result.stdout, result.exit_code) # "4\n" 0
27
+
28
+ # files
29
+ sandbox.write_file("/workspace/hi.txt", "hello")
30
+ print(sandbox.read_file("/workspace/hi.txt")) # "hello"
31
+
32
+ # stateful code interpreter
33
+ ctx = sandbox.create_code_context(language="python")
34
+ ctx.run_code("x = 21")
35
+ print(ctx.run_code("x * 2").results[0].text) # "42"
36
+
37
+ # metrics + cost
38
+ m = sandbox.metrics()
39
+ print(m.cost.total, m.usage.cpu_seconds)
40
+
41
+ sandbox.destroy()
42
+ ```
43
+
44
+ Standard library only (`urllib`, `json`) — no third-party dependencies. Requires
45
+ Python ≥ 3.9 and a running hotcell daemon.
46
+
47
+ ## Surface
48
+
49
+ `HotcellClient`: `get_sandbox`, `list`, `health`, `info`, `list_backups`, `delete_backup`.
50
+
51
+ `Sandbox`: `exec`, `exec_stream`, `stop`, `start`, `destroy`, `metrics`,
52
+ `write_file`, `read_file`, `mkdir`, `list_files`, `watch`, `start_process`,
53
+ `list_processes`, `kill_process`, `stream_logs`, `wait_for_port`, `expose_port`,
54
+ `unexpose_port`, `list_exposed_ports`, `set_env_vars`, `get_env_vars`,
55
+ `create_session`, `list_sessions`, `create_backup`, `restore_backup`,
56
+ `list_backups`, `create_code_context`, `list_code_contexts`, `run_code`.
57
+
58
+ `Session`: `exec`, `exec_stream`, `set_env_vars`, `destroy`.
59
+ `CodeContext`: `run_code`, `destroy`.
@@ -0,0 +1,6 @@
1
+ hotcell/__init__.py,sha256=wR3rYGsr20fdkxOjL0shb_s6pkEw3KA1jNxYj9EjoHw,25626
2
+ hotcell/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ hotcell-0.1.1.dist-info/METADATA,sha256=G541yr2YBTpDHJZHxn_AltznUVWcTgrsoOp2eqqPcEw,2111
4
+ hotcell-0.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
5
+ hotcell-0.1.1.dist-info/top_level.txt,sha256=BbRQ6qC_ZsR8YIwv0s2GT6JK-9tiOkmo5qEHPUpKqVc,8
6
+ hotcell-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ hotcell