zipbox 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zipbox/__init__.py ADDED
@@ -0,0 +1,91 @@
1
+ """Zipbox SDK: drive Zipbox boxes from Python.
2
+
3
+ >>> from zipbox import Zipbox
4
+ >>> zb = Zipbox() # reads ZIPBOX_API_KEY
5
+ >>> box = zb.sandboxes.get_or_create("demo")
6
+ >>> print(box.exec("echo hello").stdout)
7
+ """
8
+
9
+ from zipbox._async_client import (
10
+ AsyncAccount,
11
+ AsyncCommand,
12
+ AsyncCommands,
13
+ AsyncFiles,
14
+ AsyncPreviews,
15
+ AsyncPtyApi,
16
+ AsyncSandbox,
17
+ AsyncSandboxes,
18
+ AsyncZipbox,
19
+ )
20
+ from zipbox._client import (
21
+ Account,
22
+ Command,
23
+ Commands,
24
+ Files,
25
+ Previews,
26
+ PtyApi,
27
+ Sandbox,
28
+ Sandboxes,
29
+ Zipbox,
30
+ )
31
+ from zipbox._core import DEFAULT_API_URL, VERSION, WORKSPACE
32
+ from zipbox._errors import ErrorCode, ZipboxError
33
+ from zipbox._models import (
34
+ CommandInfo,
35
+ CommandState,
36
+ ExecEvent,
37
+ ExecResult,
38
+ FileEntry,
39
+ FileKind,
40
+ Language,
41
+ Preview,
42
+ SandboxDetail,
43
+ SandboxInfo,
44
+ Signal,
45
+ SshInfo,
46
+ Tier,
47
+ )
48
+ from zipbox._pty import AsyncPty, Pty
49
+
50
+ __version__ = VERSION
51
+
52
+ __all__ = [
53
+ "DEFAULT_API_URL",
54
+ "WORKSPACE",
55
+ "Account",
56
+ "AsyncAccount",
57
+ "AsyncCommand",
58
+ "AsyncCommands",
59
+ "AsyncFiles",
60
+ "AsyncPreviews",
61
+ "AsyncPty",
62
+ "AsyncPtyApi",
63
+ "AsyncSandbox",
64
+ "AsyncSandboxes",
65
+ "AsyncZipbox",
66
+ "Command",
67
+ "CommandInfo",
68
+ "CommandState",
69
+ "Commands",
70
+ "ErrorCode",
71
+ "ExecEvent",
72
+ "ExecResult",
73
+ "FileEntry",
74
+ "FileKind",
75
+ "Files",
76
+ "Language",
77
+ "Preview",
78
+ "Previews",
79
+ "Pty",
80
+ "PtyApi",
81
+ "Sandbox",
82
+ "SandboxDetail",
83
+ "SandboxInfo",
84
+ "Sandboxes",
85
+ "Signal",
86
+ "SshInfo",
87
+ "Tier",
88
+ "Zipbox",
89
+ "ZipboxError",
90
+ "__version__",
91
+ ]
@@ -0,0 +1,461 @@
1
+ """The asyncio client: ``AsyncZipbox``. Each method drives the same core operation as
2
+ the blocking client, with async I/O."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from collections.abc import AsyncIterator, Mapping
8
+ from pathlib import Path
9
+ from types import TracebackType
10
+ from typing import Any, TypeVar
11
+
12
+ import httpx
13
+
14
+ from zipbox._core import CommandLine, Core, exec_body, resolve_config
15
+ from zipbox._effects import Op, StreamOp, run_async, stream_async
16
+ from zipbox._models import (
17
+ CommandInfo,
18
+ ExecEvent,
19
+ ExecResult,
20
+ FileEntry,
21
+ Language,
22
+ Preview,
23
+ SandboxDetail,
24
+ SandboxInfo,
25
+ Signal,
26
+ SshInfo,
27
+ Tier,
28
+ )
29
+ from zipbox._pty import AsyncPty, new_state
30
+ from zipbox._transport import HttpxAsyncTransport
31
+
32
+ T = TypeVar("T")
33
+
34
+
35
+ class AsyncZipbox:
36
+ """Asyncio AsyncZipbox client.
37
+
38
+ >>> zb = AsyncZipbox() # reads ZIPBOX_API_KEY
39
+ >>> box = await zb.sandboxes.get_or_create("demo")
40
+ >>> print((await box.exec("uname -a")).stdout)
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ api_key: str | None = None,
46
+ *,
47
+ base_url: str | None = None,
48
+ max_retries: int = 3,
49
+ http_client: httpx.AsyncClient | None = None,
50
+ retry_base_delay_s: float = 0.5,
51
+ retry_max_delay_s: float = 30.0,
52
+ poll_interval_s: float = 1.0,
53
+ poll_max_interval_s: float = 10.0,
54
+ pty_base_delay_s: float = 0.5,
55
+ pty_max_delay_s: float = 30.0,
56
+ ) -> None:
57
+ """Create a client. ``api_key`` defaults to ``$ZIPBOX_API_KEY`` and ``base_url``
58
+ to ``$ZIPBOX_API_URL`` or https://api.tribes.xyz. No call has a default timeout."""
59
+ self._core = Core(
60
+ resolve_config(
61
+ api_key,
62
+ base_url,
63
+ max_retries,
64
+ retry_base_delay_s=retry_base_delay_s,
65
+ retry_max_delay_s=retry_max_delay_s,
66
+ poll_interval_s=poll_interval_s,
67
+ poll_max_interval_s=poll_max_interval_s,
68
+ pty_base_delay_s=pty_base_delay_s,
69
+ pty_max_delay_s=pty_max_delay_s,
70
+ )
71
+ )
72
+ self._transport = HttpxAsyncTransport(http_client)
73
+ self.sandboxes = AsyncSandboxes(self)
74
+ self.account = AsyncAccount(self)
75
+
76
+ async def _run(self, op: Op[T]) -> T:
77
+ return await run_async(self._transport, op)
78
+
79
+ def _stream(self, op: StreamOp) -> AsyncIterator[Any]:
80
+ return stream_async(self._transport, op)
81
+
82
+ def sandbox(self, sandbox_id: str) -> AsyncSandbox:
83
+ """A handle on a box by id. Makes no request."""
84
+ return AsyncSandbox(self, sandbox_id, None)
85
+
86
+ async def aclose(self) -> None:
87
+ """Release HTTP connections."""
88
+ await self._transport.aclose()
89
+
90
+ async def __aenter__(self) -> AsyncZipbox:
91
+ return self
92
+
93
+ async def __aexit__(
94
+ self,
95
+ exc_type: type[BaseException] | None,
96
+ exc: BaseException | None,
97
+ tb: TracebackType | None,
98
+ ) -> None:
99
+ await self.aclose()
100
+
101
+
102
+ class AsyncSandboxes:
103
+ """Box lifecycle: ``zb.sandboxes``."""
104
+
105
+ def __init__(self, client: AsyncZipbox) -> None:
106
+ self._client = client
107
+
108
+ async def create(
109
+ self,
110
+ *,
111
+ name: str | None = None,
112
+ tier: Tier | None = None,
113
+ ssh_public_key: str | None = None,
114
+ ) -> AsyncSandbox:
115
+ """Create a box and wait until it is ready (no deadline)."""
116
+ detail = await self._client._run(
117
+ self._client._core.create(name=name, tier=tier, ssh_public_key=ssh_public_key)
118
+ )
119
+ return AsyncSandbox(self._client, detail.id, detail)
120
+
121
+ async def get_or_create(
122
+ self, name: str, *, tier: Tier | None = None, ssh_public_key: str | None = None
123
+ ) -> AsyncSandbox:
124
+ """Return the live box named ``name`` (starting it if stopped), or create it."""
125
+ detail = await self._client._run(
126
+ self._client._core.get_or_create(name, tier=tier, ssh_public_key=ssh_public_key)
127
+ )
128
+ return AsyncSandbox(self._client, detail.id, detail)
129
+
130
+ async def list(self) -> list[SandboxInfo]:
131
+ """Every box on the account."""
132
+ return await self._client._run(self._client._core.list_sandboxes())
133
+
134
+ async def get(self, sandbox_id: str) -> AsyncSandbox:
135
+ """One box, with its current detail."""
136
+ detail = await self._client._run(self._client._core.get(sandbox_id))
137
+ return AsyncSandbox(self._client, sandbox_id, detail)
138
+
139
+ async def start(self, sandbox_id: str) -> None:
140
+ """Start a stopped box (402 ``insufficient_credits`` when the account is dry)."""
141
+ await self._client._run(self._client._core.start(sandbox_id))
142
+
143
+ async def stop(self, sandbox_id: str) -> None:
144
+ """Stop a box: disk archived, nothing billed while stopped."""
145
+ await self._client._run(self._client._core.lifecycle(sandbox_id, "pause"))
146
+
147
+ async def restart(self, sandbox_id: str) -> None:
148
+ """Hard-restart a running box; files survive."""
149
+ await self._client._run(self._client._core.lifecycle(sandbox_id, "reset"))
150
+
151
+ async def delete(self, sandbox_id: str) -> None:
152
+ """Delete a box (a recoverable copy is kept for 30 days)."""
153
+ await self._client._run(self._client._core.delete(sandbox_id))
154
+
155
+ async def set_tier(self, sandbox_id: str, tier: Tier) -> None:
156
+ """Move a box to another tier."""
157
+ await self._client._run(self._client._core.lifecycle(sandbox_id, "tier", {"tier": tier}))
158
+
159
+
160
+ class AsyncSandbox:
161
+ """A box. Returned by ``create``, ``get``, ``get_or_create`` and ``zb.sandbox(id)``."""
162
+
163
+ def __init__(self, client: AsyncZipbox, sandbox_id: str, info: SandboxDetail | None) -> None:
164
+ self._client = client
165
+ self.id = sandbox_id
166
+ self.info = info
167
+ self.commands = AsyncCommands(client, sandbox_id)
168
+ self.files = AsyncFiles(client, sandbox_id)
169
+ self.pty = AsyncPtyApi(client, sandbox_id)
170
+ self.preview = AsyncPreviews(client, sandbox_id)
171
+
172
+ def __repr__(self) -> str:
173
+ status = self.info.status if self.info else "?"
174
+ return f"AsyncSandbox(id={self.id!r}, status={status!r})"
175
+
176
+ async def refresh(self) -> SandboxDetail:
177
+ """Re-read the box's detail."""
178
+ self.info = await self._client._run(self._client._core.get(self.id))
179
+ return self.info
180
+
181
+ async def exec(
182
+ self,
183
+ cmd: CommandLine,
184
+ *,
185
+ cwd: str | None = None,
186
+ env: Mapping[str, str] | None = None,
187
+ timeout_ms: int | None = None,
188
+ user: str | None = None,
189
+ ) -> ExecResult:
190
+ """Run a command to completion. A string runs under ``bash -lc``; a list is argv.
191
+ A non-zero exit is returned in ``exit_code``, never raised."""
192
+ body = exec_body(cmd, cwd=cwd, env=env, timeout_ms=timeout_ms, user=user)
193
+ return await self._client._run(self._client._core.exec(self.id, body))
194
+
195
+ def exec_stream(
196
+ self,
197
+ cmd: CommandLine,
198
+ *,
199
+ cwd: str | None = None,
200
+ env: Mapping[str, str] | None = None,
201
+ timeout_ms: int | None = None,
202
+ user: str | None = None,
203
+ ) -> AsyncIterator[ExecEvent]:
204
+ """Run a command and yield its events as they happen. Leaving the loop early
205
+ kills the command (use ``exec_background`` for one that should outlive you)."""
206
+ body = exec_body(cmd, cwd=cwd, env=env, timeout_ms=timeout_ms, user=user)
207
+ return self._client._stream(self._client._core.exec_events(self.id, body))
208
+
209
+ async def exec_background(
210
+ self,
211
+ cmd: CommandLine,
212
+ *,
213
+ cwd: str | None = None,
214
+ env: Mapping[str, str] | None = None,
215
+ timeout_ms: int | None = None,
216
+ user: str | None = None,
217
+ stdin: bool = False,
218
+ ) -> AsyncCommand:
219
+ """Start a command that keeps running with nobody attached; returns its handle."""
220
+ body = exec_body(
221
+ cmd, cwd=cwd, env=env, timeout_ms=timeout_ms, user=user, stdin=stdin or None
222
+ )
223
+ info = await self._client._run(self._client._core.exec_background(self.id, body))
224
+ return AsyncCommand(self._client, self.id, info.id, info)
225
+
226
+ async def run_code(self, language: Language, code: str) -> ExecResult:
227
+ """Run a snippet with ``python3``, ``node`` or ``bash``."""
228
+ return await self._client._run(self._client._core.run_code(self.id, language, code))
229
+
230
+ async def logs(self, since: str | None = None, lines: int | None = None) -> str:
231
+ """The box's system journal (``journalctl --since``)."""
232
+ return await self._client._run(self._client._core.logs(self.id, since, lines))
233
+
234
+ async def ssh_info(self) -> SshInfo:
235
+ """The direct IPv6 SSH command for this box."""
236
+ return await self._client._run(self._client._core.ssh_info(self.id))
237
+
238
+ async def screenshot(self) -> bytes:
239
+ """A PNG of the box's desktop."""
240
+ return await self._client._run(self._client._core.screenshot(self.id))
241
+
242
+ async def start(self) -> None:
243
+ """Start this box."""
244
+ await self._client.sandboxes.start(self.id)
245
+
246
+ async def stop(self) -> None:
247
+ """Stop this box."""
248
+ await self._client.sandboxes.stop(self.id)
249
+
250
+ async def restart(self) -> None:
251
+ """Hard-restart this box."""
252
+ await self._client.sandboxes.restart(self.id)
253
+
254
+ async def delete(self) -> None:
255
+ """Delete this box."""
256
+ await self._client.sandboxes.delete(self.id)
257
+
258
+ async def set_tier(self, tier: Tier) -> None:
259
+ """Move this box to another tier."""
260
+ await self._client.sandboxes.set_tier(self.id, tier)
261
+
262
+ async def wait_ready(self) -> SandboxDetail:
263
+ """Block until the box is ready (no deadline)."""
264
+ self.info = await self._client._run(self._client._core.wait_ready(self.id))
265
+ return self.info
266
+
267
+
268
+ class AsyncCommand:
269
+ """A command on a box, usually started with ``exec_background``."""
270
+
271
+ def __init__(
272
+ self, client: AsyncZipbox, sandbox_id: str, command_id: str, info: CommandInfo | None
273
+ ) -> None:
274
+ self._client = client
275
+ self._sandbox_id = sandbox_id
276
+ self.id = command_id
277
+ self.info = info
278
+
279
+ def __repr__(self) -> str:
280
+ state = self.info.state if self.info else "?"
281
+ return f"AsyncCommand(id={self.id!r}, state={state!r})"
282
+
283
+ async def get(self) -> CommandInfo:
284
+ """Re-read the command's record."""
285
+ self.info = await self._client._run(self._client._core.command(self._sandbox_id, self.id))
286
+ return self.info
287
+
288
+ def events(self, since: str | None = None) -> AsyncIterator[ExecEvent]:
289
+ """Replay from ``since`` (an event cursor) and follow until exit."""
290
+ return self._client._stream(
291
+ self._client._core.command_events(self._sandbox_id, self.id, since)
292
+ )
293
+
294
+ async def wait(self) -> ExecResult:
295
+ """Block until the command exits; return its full output."""
296
+ return await self._client._run(self._client._core.wait(self._sandbox_id, self.id))
297
+
298
+ async def stdin(self, data: str | bytes) -> None:
299
+ """Write to the command's stdin (start it with ``stdin=True``)."""
300
+ payload = data.encode() if isinstance(data, str) else data
301
+ await self._client._run(self._client._core.stdin(self._sandbox_id, self.id, payload, False))
302
+
303
+ async def close_stdin(self) -> None:
304
+ """Close the command's stdin."""
305
+ await self._client._run(self._client._core.stdin(self._sandbox_id, self.id, b"", True))
306
+
307
+ async def signal(self, signal: Signal = "TERM") -> None:
308
+ """Send a signal to the command."""
309
+ await self._client._run(self._client._core.signal(self._sandbox_id, self.id, signal))
310
+
311
+
312
+ class AsyncCommands:
313
+ """AsyncCommands on one box: ``box.commands``."""
314
+
315
+ def __init__(self, client: AsyncZipbox, sandbox_id: str) -> None:
316
+ self._client = client
317
+ self._sandbox_id = sandbox_id
318
+
319
+ async def list(self) -> list[CommandInfo]:
320
+ """Running and recently finished commands."""
321
+ return await self._client._run(self._client._core.commands(self._sandbox_id))
322
+
323
+ async def get(self, command_id: str) -> CommandInfo:
324
+ """One command's record."""
325
+ return await self._client._run(self._client._core.command(self._sandbox_id, command_id))
326
+
327
+ def handle(self, command_id: str) -> AsyncCommand:
328
+ """A handle on a command by id. Makes no request."""
329
+ return AsyncCommand(self._client, self._sandbox_id, command_id, None)
330
+
331
+ def events(self, command_id: str, since: str | None = None) -> AsyncIterator[ExecEvent]:
332
+ """Replay from ``since`` and follow until exit."""
333
+ return self.handle(command_id).events(since)
334
+
335
+ async def stdin(self, command_id: str, data: str | bytes) -> None:
336
+ """Write to a command's stdin."""
337
+ await self.handle(command_id).stdin(data)
338
+
339
+ async def close_stdin(self, command_id: str) -> None:
340
+ """Close a command's stdin."""
341
+ await self.handle(command_id).close_stdin()
342
+
343
+ async def signal(self, command_id: str, signal: Signal = "TERM") -> None:
344
+ """Signal a command."""
345
+ await self.handle(command_id).signal(signal)
346
+
347
+
348
+ class AsyncFiles:
349
+ """AsyncFiles on one box: ``box.files``. Relative paths resolve against /root/workspace."""
350
+
351
+ def __init__(self, client: AsyncZipbox, sandbox_id: str) -> None:
352
+ self._client = client
353
+ self._sandbox_id = sandbox_id
354
+
355
+ async def read(self, path: str) -> bytes:
356
+ """A file's bytes (up to 250 MiB)."""
357
+ return await self._client._run(self._client._core.read(self._sandbox_id, path))
358
+
359
+ async def read_text(self, path: str, encoding: str = "utf-8") -> str:
360
+ """A file decoded as text."""
361
+ return (await self.read(path)).decode(encoding)
362
+
363
+ async def write(self, path: str, content: str | bytes) -> None:
364
+ """Write a file atomically (parent directory must exist)."""
365
+ payload = content.encode() if isinstance(content, str) else content
366
+ await self._client._run(self._client._core.write(self._sandbox_id, path, payload))
367
+
368
+ async def list(self, path: str = ".") -> list[FileEntry]:
369
+ """A directory's entries."""
370
+ return await self._client._run(self._client._core.list_files(self._sandbox_id, path))
371
+
372
+ async def stat(self, path: str) -> FileEntry:
373
+ """One entry (``not_found`` when absent)."""
374
+ return await self._client._run(self._client._core.stat(self._sandbox_id, path))
375
+
376
+ async def mkdir(self, path: str) -> None:
377
+ """Create a directory."""
378
+ await self._client._run(self._client._core.mutate(self._sandbox_id, "mkdir", path))
379
+
380
+ async def move(self, src: str, dst: str) -> None:
381
+ """Move or rename."""
382
+ await self._client._run(self._client._core.mutate(self._sandbox_id, "move", src, dst))
383
+
384
+ async def delete(self, path: str) -> None:
385
+ """Delete a file or directory."""
386
+ await self._client._run(self._client._core.mutate(self._sandbox_id, "delete", path))
387
+
388
+ async def upload(self, local_path: str | Path, remote_path: str) -> None:
389
+ """Copy a local file to the box."""
390
+ await self.write(remote_path, await asyncio.to_thread(Path(local_path).read_bytes))
391
+
392
+ async def download(self, remote_path: str, local_path: str | Path) -> None:
393
+ """Copy a file from the box to a local path."""
394
+ data = await self.read(remote_path)
395
+ await asyncio.to_thread(Path(local_path).write_bytes, data)
396
+
397
+
398
+ class AsyncPtyApi:
399
+ """Terminals on one box: ``box.pty``."""
400
+
401
+ def __init__(self, client: AsyncZipbox, sandbox_id: str) -> None:
402
+ self._client = client
403
+ self._sandbox_id = sandbox_id
404
+
405
+ async def create(self, *, cols: int = 80, rows: int = 24) -> AsyncPty:
406
+ """Open a new bash session; returns once the socket is up."""
407
+ return await self.attach(None, cols=cols, rows=rows)
408
+
409
+ async def attach(self, session_id: str | None, *, cols: int = 80, rows: int = 24) -> AsyncPty:
410
+ """Re-attach a session by id (its screen is replayed)."""
411
+ pty = AsyncPty(
412
+ self._client._core,
413
+ self._client._transport,
414
+ self._sandbox_id,
415
+ new_state(session_id, cols, rows),
416
+ )
417
+ return await pty._start()
418
+
419
+
420
+ class AsyncPreviews:
421
+ """Public HTTPS URLs for ports on one box: ``box.preview``."""
422
+
423
+ def __init__(self, client: AsyncZipbox, sandbox_id: str) -> None:
424
+ self._client = client
425
+ self._sandbox_id = sandbox_id
426
+
427
+ async def create(self, port: int, label: str | None = None) -> Preview:
428
+ """Serve ``127.0.0.1:<port>`` at ``https://<label>.<box host>``. Public, no auth."""
429
+ return await self._client._run(self._client._core.preview(self._sandbox_id, port, label))
430
+
431
+ async def remove(self, label: str) -> None:
432
+ """Stop serving a preview."""
433
+ await self._client._run(self._client._core.unpreview(self._sandbox_id, label))
434
+
435
+
436
+ class AsyncAccount:
437
+ """The account: ``zb.account``."""
438
+
439
+ def __init__(self, client: AsyncZipbox) -> None:
440
+ self._client = client
441
+
442
+ async def whoami(self) -> dict[str, Any]:
443
+ """Who the key belongs to: ``{"userId": ..., "apiKey": {...}}``."""
444
+ result: dict[str, Any] = await self._client._run(
445
+ self._client._core.control("GET", "/account/whoami")
446
+ )
447
+ return result
448
+
449
+ async def balance(self) -> dict[str, Any]:
450
+ """Credit balance and auto top-up state."""
451
+ result: dict[str, Any] = await self._client._run(
452
+ self._client._core.control("GET", "/billing/summary")
453
+ )
454
+ return result
455
+
456
+ async def usage(self) -> dict[str, Any]:
457
+ """Recent metered usage."""
458
+ result: dict[str, Any] = await self._client._run(
459
+ self._client._core.control("GET", "/billing/usage")
460
+ )
461
+ return result
zipbox/_backoff.py ADDED
@@ -0,0 +1,36 @@
1
+ """Exponential backoff with a cap and jitter. Never a ceiling on attempts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections.abc import Callable
7
+
8
+
9
+ class Backoff:
10
+ """``next()`` returns the delay before the next attempt; ``reset()`` on success."""
11
+
12
+ def __init__(
13
+ self,
14
+ base_s: float,
15
+ cap_s: float,
16
+ *,
17
+ rng: Callable[[], float] = random.random,
18
+ ) -> None:
19
+ self.base_s = base_s
20
+ self.cap_s = cap_s
21
+ self.attempt = 0
22
+ self._rng = rng
23
+
24
+ def peek(self) -> float:
25
+ """The un-jittered delay the next ``next()`` is based on."""
26
+ return float(min(self.cap_s, self.base_s * (2.0**self.attempt)))
27
+
28
+ def next(self) -> float:
29
+ """Advance the ladder; jitter keeps a fleet of clients from retrying in step."""
30
+ delay = self.peek() * (0.5 + self._rng() / 2)
31
+ self.attempt = min(self.attempt + 1, 32)
32
+ return delay
33
+
34
+ def reset(self) -> None:
35
+ """Back to the first rung. Call only on observed success."""
36
+ self.attempt = 0