boxd 0.1.0.dev2__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.
boxd/_sync.py ADDED
@@ -0,0 +1,448 @@
1
+ """Synchronous wrappers for the boxd async API.
2
+
3
+ These thin wrappers run the async methods on a dedicated event loop,
4
+ providing a blocking API that doesn't require async/await.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import TYPE_CHECKING
11
+
12
+ from .exec import ExecResult
13
+ from .types import (
14
+ BoxConfig,
15
+ ConfigResult,
16
+ Domain,
17
+ Network,
18
+ Proxy,
19
+ ResumeResult,
20
+ SuspendResult,
21
+ Template,
22
+ Token,
23
+ TokenInfo,
24
+ WhoamiResult,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from pathlib import Path
29
+
30
+
31
+ class _SyncBase:
32
+ """Mixin that holds a reference to a shared event loop."""
33
+
34
+ _loop: asyncio.AbstractEventLoop
35
+
36
+ def _run(self, coro):
37
+ return self._loop.run_until_complete(coro)
38
+
39
+
40
+ class Box(_SyncBase):
41
+ """A boxd VM (synchronous API).
42
+
43
+ Data attributes (id, name, image, public_ip, status, url, boot_time_ms)
44
+ are forwarded automatically from the underlying async Box.
45
+ """
46
+
47
+ def __init__(self, async_box, loop: asyncio.AbstractEventLoop) -> None:
48
+ from .box import Box as AsyncBox
49
+
50
+ self._async: AsyncBox = async_box
51
+ self._loop = loop
52
+
53
+ def __getattr__(self, name: str):
54
+ # Forward data attributes from the async Box dataclass
55
+ if name in ("id", "name", "image", "public_ip", "status", "url", "boot_time_ms"):
56
+ return getattr(self._async, name)
57
+ raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
58
+
59
+ def __repr__(self) -> str:
60
+ return (
61
+ f"Box(id={self.id!r}, name={self.name!r}, image={self.image!r}, "
62
+ f"public_ip={self.public_ip!r}, status={self.status!r})"
63
+ )
64
+
65
+ # ── Lifecycle ────────────────────────────────────────────────
66
+
67
+ def destroy(self) -> None:
68
+ """Destroy this VM."""
69
+ self._run(self._async.destroy())
70
+
71
+ def reboot(self) -> None:
72
+ """Reboot this VM."""
73
+ self._run(self._async.reboot())
74
+
75
+ def start(self) -> None:
76
+ """Start this VM."""
77
+ self._run(self._async.start())
78
+
79
+ def stop(self) -> None:
80
+ """Stop this VM."""
81
+ self._run(self._async.stop())
82
+
83
+ def suspend(self) -> SuspendResult:
84
+ """Suspend this VM."""
85
+ return self._run(self._async.suspend())
86
+
87
+ def resume(self) -> ResumeResult:
88
+ """Resume this VM from suspension."""
89
+ return self._run(self._async.resume())
90
+
91
+ # ── Exec ─────────────────────────────────────────────────────
92
+
93
+ def exec(
94
+ self,
95
+ *args: str,
96
+ stream: bool = False,
97
+ interactive: bool = False,
98
+ pty: bool = False,
99
+ text: bool = True,
100
+ timeout: float | None = None,
101
+ env: dict[str, str] | None = None,
102
+ ) -> ExecResult | SyncExecProcess:
103
+ """Execute a command in this VM."""
104
+ result = self._run(
105
+ self._async.exec(
106
+ *args,
107
+ stream=stream,
108
+ interactive=interactive,
109
+ pty=pty,
110
+ text=text,
111
+ timeout=timeout,
112
+ env=env,
113
+ )
114
+ )
115
+ if isinstance(result, ExecResult):
116
+ return result
117
+ return SyncExecProcess(result, self._loop)
118
+
119
+ # ── Files ────────────────────────────────────────────────────
120
+
121
+ def write_file(self, source: str | bytes | Path, dest: str) -> None:
122
+ """Write a file to the VM."""
123
+ self._run(self._async.write_file(source, dest))
124
+
125
+ def read_file(self, path: str) -> bytes:
126
+ """Download a file from the VM."""
127
+ return self._run(self._async.read_file(path))
128
+
129
+ # ── Proxies ──────────────────────────────────────────────────
130
+
131
+ def proxies(self) -> list[Proxy]:
132
+ """List proxies for this VM."""
133
+ return self._run(self._async.proxies())
134
+
135
+ def create_proxy(self, name: str, port: int = 0) -> Proxy:
136
+ """Create a new proxy subdomain for this VM."""
137
+ return self._run(self._async.create_proxy(name, port))
138
+
139
+ def delete_proxy(self, name: str) -> None:
140
+ """Delete a proxy subdomain."""
141
+ self._run(self._async.delete_proxy(name))
142
+
143
+ def set_proxy_port(self, port: int, name: str = "") -> None:
144
+ """Change the port for a proxy."""
145
+ self._run(self._async.set_proxy_port(port, name))
146
+
147
+ # ── Logs ─────────────────────────────────────────────────────
148
+
149
+ def stream_logs(self, follow: bool = False):
150
+ """Stream console log chunks from the VM.
151
+
152
+ Args:
153
+ follow: If True, keep the stream open for new chunks.
154
+
155
+ Yields:
156
+ ``bytes`` chunks of raw log data.
157
+ """
158
+ agen = self._async.stream_logs(follow=follow)
159
+ while True:
160
+ try:
161
+ yield self._run(agen.__anext__())
162
+ except StopAsyncIteration:
163
+ return
164
+
165
+
166
+ class SyncExecProcess(_SyncBase):
167
+ """Synchronous handle to a streaming exec process."""
168
+
169
+ def __init__(self, async_process, loop: asyncio.AbstractEventLoop) -> None:
170
+ from .exec import ExecProcess as AsyncExecProcess
171
+
172
+ self._async: AsyncExecProcess = async_process
173
+ self._loop = loop
174
+
175
+ def wait(self) -> int:
176
+ """Wait for the process to exit and return the exit code."""
177
+ return self._run(self._async.wait())
178
+
179
+ def close(self) -> None:
180
+ """Cancel the relay task and close stdin."""
181
+ self._async.close()
182
+
183
+ @property
184
+ def stdin(self):
185
+ """Access the stdin writer (write/write_eof are sync)."""
186
+ return self._async.stdin
187
+
188
+ def read_stdout(self) -> bytes:
189
+ """Read the next stdout chunk, or empty bytes on EOF."""
190
+ return self._run(self._async.stdout.read())
191
+
192
+ def iter_stdout(self):
193
+ """Iterate over stdout chunks until EOF."""
194
+ while True:
195
+ chunk = self.read_stdout()
196
+ if not chunk:
197
+ break
198
+ yield chunk
199
+
200
+
201
+ class BoxService(_SyncBase):
202
+ """Create, list, get, and fork VMs (synchronous API)."""
203
+
204
+ def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
205
+ from .boxes import BoxService as AsyncBoxService
206
+
207
+ self._async: AsyncBoxService = async_service
208
+ self._loop = loop
209
+
210
+ def _wrap(self, async_box) -> Box:
211
+ return Box(async_box, self._loop)
212
+
213
+ def create(
214
+ self,
215
+ name: str = "",
216
+ image: str = "",
217
+ config: BoxConfig | None = None,
218
+ ) -> Box:
219
+ """Create a new VM."""
220
+ return self._wrap(self._run(self._async.create(name, image, config)))
221
+
222
+ def list(self) -> list[Box]:
223
+ """List all VMs."""
224
+ return [self._wrap(b) for b in self._run(self._async.list())]
225
+
226
+ def get(self, vm_id: str) -> Box:
227
+ """Get a VM by ID or name."""
228
+ return self._wrap(self._run(self._async.get(vm_id)))
229
+
230
+ def fork(
231
+ self,
232
+ source: str,
233
+ name: str | None = None,
234
+ config: BoxConfig | None = None,
235
+ ) -> Box:
236
+ """Fork (clone) a VM."""
237
+ return self._wrap(self._run(self._async.fork(source, name, config)))
238
+
239
+
240
+ class TemplateService(_SyncBase):
241
+ """Manage VM templates (synchronous API)."""
242
+
243
+ def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
244
+ from .templates import TemplateService as AsyncTemplateService
245
+
246
+ self._async: AsyncTemplateService = async_service
247
+ self._loop = loop
248
+
249
+ def create(
250
+ self,
251
+ name: str,
252
+ image: str = "",
253
+ config: BoxConfig | None = None,
254
+ ) -> Template:
255
+ """Create a new VM template."""
256
+ return self._run(self._async.create(name, image, config))
257
+
258
+ def list(self) -> list[Template]:
259
+ """List all templates."""
260
+ return self._run(self._async.list())
261
+
262
+ def delete(self, template_id: str) -> None:
263
+ """Delete a template by ID."""
264
+ self._run(self._async.delete(template_id))
265
+
266
+ def create_vm(
267
+ self,
268
+ template: Template | str,
269
+ name: str = "",
270
+ config: BoxConfig | None = None,
271
+ ) -> Box:
272
+ """Create a VM from a template."""
273
+ return Box(self._run(self._async.create_vm(template, name, config)), self._loop)
274
+
275
+
276
+ class DiskHandle(_SyncBase):
277
+ """A handle to a persistent disk (synchronous API).
278
+
279
+ Data attributes (id, name, size_bytes, status) are forwarded
280
+ automatically from the underlying async DiskHandle.
281
+ """
282
+
283
+ def __init__(self, async_handle, loop: asyncio.AbstractEventLoop) -> None:
284
+ from .disks import DiskHandle as AsyncDiskHandle
285
+
286
+ self._async: AsyncDiskHandle = async_handle
287
+ self._loop = loop
288
+
289
+ def __getattr__(self, name: str):
290
+ if name in ("id", "name", "size_bytes", "status"):
291
+ return getattr(self._async, name)
292
+ raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
293
+
294
+ def __repr__(self) -> str:
295
+ return (
296
+ f"DiskHandle(id={self.id!r}, name={self.name!r}, "
297
+ f"size_bytes={self.size_bytes}, status={self.status!r})"
298
+ )
299
+
300
+ def attach(self, box: Box | str, mount_path: str, read_only: bool = False) -> None:
301
+ """Attach this disk to a VM."""
302
+ target = box._async if isinstance(box, Box) else box
303
+ self._run(self._async.attach(target, mount_path, read_only))
304
+
305
+ def detach(self, box: Box | str) -> None:
306
+ """Detach this disk from a VM."""
307
+ target = box._async if isinstance(box, Box) else box
308
+ self._run(self._async.detach(target))
309
+
310
+ def destroy(self) -> None:
311
+ """Permanently destroy this disk."""
312
+ self._run(self._async.destroy())
313
+
314
+
315
+ class DiskService(_SyncBase):
316
+ """Manage persistent disks (synchronous API)."""
317
+
318
+ def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
319
+ from .disks import DiskService as AsyncDiskService
320
+
321
+ self._async: AsyncDiskService = async_service
322
+ self._loop = loop
323
+
324
+ def create(self, name: str, size: str) -> DiskHandle:
325
+ """Create a new persistent disk."""
326
+ return DiskHandle(self._run(self._async.create(name, size)), self._loop)
327
+
328
+ def list(self) -> list[DiskHandle]:
329
+ """List all disks."""
330
+ return [DiskHandle(h, self._loop) for h in self._run(self._async.list())]
331
+
332
+
333
+ class DomainService(_SyncBase):
334
+ """Manage external domains bound to VMs (synchronous API)."""
335
+
336
+ def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
337
+ from .domains import DomainService as AsyncDomainService
338
+
339
+ self._async: AsyncDomainService = async_service
340
+ self._loop = loop
341
+
342
+ def bind(self, domain: str, vm: Box | str) -> None:
343
+ """Bind an external domain to a VM."""
344
+ target = vm._async if isinstance(vm, Box) else vm
345
+ self._run(self._async.bind(domain, target))
346
+
347
+ def unbind(self, domain: str) -> None:
348
+ """Remove the binding for an external domain."""
349
+ self._run(self._async.unbind(domain))
350
+
351
+ def list(self) -> list[Domain]:
352
+ """List bound domains."""
353
+ return self._run(self._async.list())
354
+
355
+
356
+ class NetworkService(_SyncBase):
357
+ """Manage user networks (synchronous API)."""
358
+
359
+ def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
360
+ from .networks import NetworkService as AsyncNetworkService
361
+
362
+ self._async: AsyncNetworkService = async_service
363
+ self._loop = loop
364
+
365
+ def create(self, name: str = "") -> Network:
366
+ """Create a new network."""
367
+ return self._run(self._async.create(name))
368
+
369
+ def list(self) -> list[Network]:
370
+ """List networks."""
371
+ return self._run(self._async.list())
372
+
373
+
374
+ class TokenService(_SyncBase):
375
+ """Manage scoped JWT tokens (synchronous API)."""
376
+
377
+ def __init__(self, async_service, loop: asyncio.AbstractEventLoop) -> None:
378
+ from .tokens import TokenService as AsyncTokenService
379
+
380
+ self._async: AsyncTokenService = async_service
381
+ self._loop = loop
382
+
383
+ def create(self, expires_in: int = 0) -> Token:
384
+ """Create a scoped JWT."""
385
+ return self._run(self._async.create(expires_in))
386
+
387
+ def list(self) -> list[TokenInfo]:
388
+ """List token metadata."""
389
+ return self._run(self._async.list())
390
+
391
+ def revoke(self, jti: str) -> None:
392
+ """Revoke a token by JTI."""
393
+ self._run(self._async.revoke(jti))
394
+
395
+
396
+ class Compute(_SyncBase):
397
+ """Main client for the boxd API (synchronous API).
398
+
399
+ Usage::
400
+
401
+ with Compute(api_key="bxk_...") as c:
402
+ box = c.box.create("my-vm")
403
+ result = box.exec("echo", "hello")
404
+ print(result.stdout)
405
+ """
406
+
407
+ def __init__(
408
+ self,
409
+ *,
410
+ api_key: str | None = None,
411
+ token: str | None = None,
412
+ api_url: str | None = None,
413
+ exchange_url: str | None = None,
414
+ ) -> None:
415
+ from .client import Compute as AsyncCompute
416
+
417
+ self._loop = asyncio.new_event_loop()
418
+ self._async = AsyncCompute(
419
+ api_key=api_key,
420
+ token=token,
421
+ api_url=api_url,
422
+ exchange_url=exchange_url,
423
+ )
424
+ self.box = BoxService(self._async.box, self._loop)
425
+ self.template = TemplateService(self._async.template, self._loop)
426
+ self.disk = DiskService(self._async.disk, self._loop)
427
+ self.domain = DomainService(self._async.domain, self._loop)
428
+ self.network = NetworkService(self._async.network, self._loop)
429
+ self.token = TokenService(self._async.token, self._loop)
430
+
431
+ def whoami(self) -> WhoamiResult:
432
+ """Return information about the authenticated user."""
433
+ return self._run(self._async.whoami())
434
+
435
+ def config(self) -> ConfigResult:
436
+ """Return server configuration."""
437
+ return self._run(self._async.config())
438
+
439
+ def close(self) -> None:
440
+ """Close the gRPC channel and event loop."""
441
+ self._run(self._async.close())
442
+ self._loop.close()
443
+
444
+ def __enter__(self) -> Compute:
445
+ return self
446
+
447
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
448
+ self.close()
boxd/_utils.py ADDED
@@ -0,0 +1,73 @@
1
+ """Internal helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import NamedTuple, TYPE_CHECKING
7
+
8
+ import grpc.aio
9
+
10
+ from .errors import from_grpc_error
11
+
12
+ if TYPE_CHECKING:
13
+ from .client import Compute
14
+
15
+
16
+ class Endpoint(NamedTuple):
17
+ """Resolved gRPC endpoint: bare ``host:port`` and whether to use TLS."""
18
+
19
+ host: str
20
+ use_tls: bool
21
+
22
+
23
+ def resolve_endpoint(url: str) -> Endpoint:
24
+ """Resolve an api_url into a ``(host, use_tls)`` pair.
25
+
26
+ - ``http://...`` → plaintext, scheme stripped
27
+ - ``https://...`` → TLS, scheme stripped
28
+ - bare ``host:port`` → TLS, except ``localhost``/``127.*`` which stay plaintext
29
+
30
+ Production gRPC currently lives at ``boxd.sh:9443`` over plain HTTP/2, so the
31
+ package default uses an explicit ``http://`` scheme. Self-hosted/dev clusters
32
+ can pass ``api_url="http://my-cluster:9443"`` to opt into plaintext.
33
+ """
34
+ if url.startswith("http://"):
35
+ return Endpoint(host=url[len("http://"):], use_tls=False)
36
+ if url.startswith("https://"):
37
+ return Endpoint(host=url[len("https://"):], use_tls=True)
38
+ is_local = url.startswith("localhost") or url.startswith("127.")
39
+ return Endpoint(host=url, use_tls=not is_local)
40
+
41
+
42
+ def parse_size(s: str) -> int:
43
+ """Parse a human-readable size string to bytes.
44
+
45
+ Examples: "8G" -> 8589934592, "512M" -> 536870912, "100G" -> 107374182400
46
+ """
47
+ if not s:
48
+ return 0
49
+ m = re.match(r"^(\d+)\s*([KMGT]?)i?[Bb]?$", s.strip(), re.IGNORECASE)
50
+ if not m:
51
+ raise ValueError(f"invalid size: {s!r}")
52
+ num = int(m.group(1))
53
+ unit = m.group(2).upper()
54
+ multipliers = {"": 1, "K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}
55
+ return num * multipliers[unit]
56
+
57
+
58
+ class GrpcCaller:
59
+ """Mixin for classes that call gRPC methods through a Compute client.
60
+
61
+ Subclasses must have a ``_client`` attribute of type :class:`Compute`.
62
+ """
63
+
64
+ _client: Compute
65
+
66
+ async def _call(self, method_name: str, request):
67
+ """Call a gRPC method on the stub, converting errors."""
68
+ stub = await self._client._ensure_channel()
69
+ method = getattr(stub, method_name)
70
+ try:
71
+ return await method(request)
72
+ except grpc.aio.AioRpcError as e:
73
+ raise from_grpc_error(e) from e
boxd/aio.py ADDED
@@ -0,0 +1,96 @@
1
+ """Async API for the boxd SDK.
2
+
3
+ Usage::
4
+
5
+ from boxd.aio import Compute
6
+
7
+ async with Compute(api_key="bxk_...") as c:
8
+ box = await c.box.create("my-vm")
9
+ result = await box.exec("echo", "hello")
10
+ print(result.stdout)
11
+ """
12
+
13
+ # Re-export async classes under their original names
14
+ from .client import Compute
15
+ from .box import Box
16
+ from .exec import ExecResult, ExecProcess, StreamReader, StreamWriter
17
+ from .boxes import BoxService
18
+ from .templates import TemplateService
19
+ from .disks import DiskService, DiskHandle
20
+ from .domains import DomainService
21
+ from .networks import NetworkService
22
+ from .tokens import TokenService
23
+
24
+ # Types and errors are shared — re-export for convenience
25
+ from .types import (
26
+ BoxConfig,
27
+ ConfigResult,
28
+ Disk,
29
+ DiskAttachment,
30
+ Domain,
31
+ LifecycleConfig,
32
+ Network,
33
+ NetworkConfig,
34
+ Proxy,
35
+ ProxyEntry,
36
+ PtyInfo,
37
+ ResumeResult,
38
+ SuspendResult,
39
+ Template,
40
+ Token,
41
+ TokenInfo,
42
+ VolumeMount,
43
+ WhoamiResult,
44
+ )
45
+ from .errors import (
46
+ BoxdError,
47
+ AuthenticationError,
48
+ NotFoundError,
49
+ QuotaExceededError,
50
+ InvalidArgumentError,
51
+ InternalError,
52
+ TimeoutError,
53
+ ConnectionError,
54
+ )
55
+
56
+ __all__ = [
57
+ "Compute",
58
+ "Box",
59
+ "BoxService",
60
+ "ExecResult",
61
+ "ExecProcess",
62
+ "StreamReader",
63
+ "StreamWriter",
64
+ "TemplateService",
65
+ "DiskService",
66
+ "DiskHandle",
67
+ "DomainService",
68
+ "NetworkService",
69
+ "TokenService",
70
+ "BoxConfig",
71
+ "LifecycleConfig",
72
+ "NetworkConfig",
73
+ "ProxyEntry",
74
+ "VolumeMount",
75
+ "Proxy",
76
+ "Template",
77
+ "Disk",
78
+ "DiskAttachment",
79
+ "Domain",
80
+ "Network",
81
+ "Token",
82
+ "TokenInfo",
83
+ "SuspendResult",
84
+ "ResumeResult",
85
+ "PtyInfo",
86
+ "WhoamiResult",
87
+ "ConfigResult",
88
+ "BoxdError",
89
+ "AuthenticationError",
90
+ "NotFoundError",
91
+ "QuotaExceededError",
92
+ "InvalidArgumentError",
93
+ "InternalError",
94
+ "TimeoutError",
95
+ "ConnectionError",
96
+ ]