opencomputer-sdk 0.5.4__tar.gz → 0.6.1__tar.gz

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.
Files changed (38) hide show
  1. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/.gitignore +12 -0
  2. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/PKG-INFO +1 -1
  3. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/__init__.py +10 -2
  4. opencomputer_sdk-0.6.1/opencomputer/mounts.py +124 -0
  5. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/sandbox.py +58 -0
  6. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/usage.py +58 -14
  7. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/pyproject.toml +1 -1
  8. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/README.md +0 -0
  9. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/run_all_tests.py +0 -0
  10. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/stream_demo.py +0 -0
  11. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_commands.py +0 -0
  12. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_concurrent.py +0 -0
  13. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_declarative_images.py +0 -0
  14. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_default_template.py +0 -0
  15. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_disk_size.py +0 -0
  16. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_domain_tls.py +0 -0
  17. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_environment.py +0 -0
  18. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_exec.py +0 -0
  19. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_file_ops.py +0 -0
  20. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_large_files.py +0 -0
  21. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_multi_template.py +0 -0
  22. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_python_sdk.py +0 -0
  23. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_reconnect.py +0 -0
  24. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_secret_store_fork.py +0 -0
  25. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_secretstore.py +0 -0
  26. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_shell.py +0 -0
  27. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/examples/test_timeout.py +0 -0
  28. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/agent.py +0 -0
  29. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/commands.py +0 -0
  30. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/exec.py +0 -0
  31. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/filesystem.py +0 -0
  32. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/image.py +0 -0
  33. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/project.py +0 -0
  34. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/pty.py +0 -0
  35. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/shell.py +0 -0
  36. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/snapshot.py +0 -0
  37. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/sse.py +0 -0
  38. {opencomputer_sdk-0.5.4 → opencomputer_sdk-0.6.1}/opencomputer/template.py +0 -0
@@ -19,6 +19,9 @@ Thumbs.db
19
19
  .env.*
20
20
  !.env.example
21
21
 
22
+ # Per-developer dev-box lifecycle state (written by deploy/*/deploy-qemu-dev.sh)
23
+ deploy/**/.qemu-dev-state-*
24
+
22
25
  # Python
23
26
  __pycache__/
24
27
  *.pyc
@@ -29,8 +32,14 @@ __pycache__/
29
32
  # Node
30
33
  node_modules/
31
34
 
35
+ # api-edge Workers Assets — copied from web/dist at deploy time. Source of truth lives in web/.
36
+ cloudflare-workers/api-edge/assets/
37
+
32
38
  # Build artifacts
33
39
  dist/
40
+ # Built dashboard SPA bundle served by the api-edge Worker — produced by
41
+ # `vite build` in web/, copied here by the deploy script. Never check in.
42
+ cloudflare-workers/api-edge/assets/
34
43
 
35
44
  # Compiled binaries
36
45
  bin/
@@ -65,4 +74,7 @@ delme
65
74
  deploy/azure/.dev-env-state-*
66
75
  deploy/azure/.dev-env-secrets*
67
76
  !deploy/azure/.dev-env-secrets.example
77
+ deploy/azure/.dev-vector-token-*
68
78
  /server
79
+ /seed-dev-secrets
80
+ /backfill
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencomputer-sdk
3
- Version: 0.5.4
3
+ Version: 0.6.1
4
4
  Summary: Python SDK for OpenComputer - cloud sandbox platform
5
5
  Project-URL: Homepage, https://github.com/diggerhq/opensandbox
6
6
  Project-URL: Repository, https://github.com/diggerhq/opensandbox
@@ -1,10 +1,11 @@
1
1
  """OpenComputer Python SDK - cloud sandbox platform."""
2
2
 
3
- from opencomputer.sandbox import Sandbox, ScalingLockedError, PlanLimitError
3
+ from opencomputer.sandbox import Sandbox, ScalingLockedError, PlanLimitError, SandboxFamilyLimitError
4
4
  from opencomputer.agent import Agent, AgentEvent, AgentSession, AgentSessionInfo
5
5
  from opencomputer.filesystem import Filesystem
6
6
  from opencomputer.exec import Exec, ProcessResult, ExecSession, ExecSessionInfo
7
7
  from opencomputer.image import Image
8
+ from opencomputer.mounts import Mounts, MountInfo
8
9
  from opencomputer.pty import Pty, PtySession
9
10
  from opencomputer.shell import Shell, ShellBusyError, ShellClosedError
10
11
  from opencomputer.template import Template
@@ -20,6 +21,8 @@ from opencomputer.usage import (
20
21
  UsageBySandboxResponse,
21
22
  UsageByTagResponse,
22
23
  SandboxUsageResponse,
24
+ SandboxUsagePoint,
25
+ SandboxUsageTotals,
23
26
  TagKeyInfo,
24
27
  )
25
28
 
@@ -27,6 +30,7 @@ __all__ = [
27
30
  "Sandbox",
28
31
  "ScalingLockedError",
29
32
  "PlanLimitError",
33
+ "SandboxFamilyLimitError",
30
34
  "Agent",
31
35
  "AgentEvent",
32
36
  "AgentSession",
@@ -37,6 +41,8 @@ __all__ = [
37
41
  "ExecSession",
38
42
  "ExecSessionInfo",
39
43
  "Image",
44
+ "Mounts",
45
+ "MountInfo",
40
46
  "Pty",
41
47
  "PtySession",
42
48
  "Shell",
@@ -54,7 +60,9 @@ __all__ = [
54
60
  "UsageBySandboxResponse",
55
61
  "UsageByTagResponse",
56
62
  "SandboxUsageResponse",
63
+ "SandboxUsagePoint",
64
+ "SandboxUsageTotals",
57
65
  "TagKeyInfo",
58
66
  ]
59
67
 
60
- __version__ = "0.5.4"
68
+ __version__ = "0.6.0"
@@ -0,0 +1,124 @@
1
+ """FUSE-backed remote filesystem mounts inside a sandbox.
2
+
3
+ Mounts use ``rclone mount`` under the hood — one driver covering ~40 backends
4
+ (S3, GCS, Azure Blob, SFTP, WebDAV, Dropbox, etc.). Credentials are passed
5
+ inline, written to a tmpfs file inside the VM (mode 0600), and never persisted
6
+ on the worker. v1 does NOT auto-restore mounts on hibernate/wake — callers
7
+ re-issue ``add(...)`` after a wake if they need the mount back.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ import httpx
16
+
17
+ MountBackend = Literal["s3", "gcs", "azureblob", "sftp", "webdav", "dropbox"]
18
+
19
+
20
+ @dataclass
21
+ class MountInfo:
22
+ """An active mount as tracked by the worker.
23
+
24
+ ``rclone_version`` is the rclone version inside the sandbox captured at
25
+ mount-add time (e.g. ``"v1.65.2"``). rclone is baked into the rootfs, so
26
+ different sandboxes may carry different versions; this lets ops triage
27
+ backend-specific bug reports quickly.
28
+ """
29
+
30
+ path: str
31
+ remote: str
32
+ read_only: bool
33
+ backend: str = ""
34
+ rclone_version: str = ""
35
+
36
+
37
+ @dataclass
38
+ class Mounts:
39
+ """Mount remote filesystems via rclone+FUSE inside the sandbox."""
40
+
41
+ _client: httpx.AsyncClient
42
+ _sandbox_id: str
43
+
44
+ async def add(
45
+ self,
46
+ path: str,
47
+ remote: str,
48
+ backend: MountBackend | None = None,
49
+ creds: dict[str, str] | None = None,
50
+ rclone_config: str | None = None,
51
+ read_only: bool = True,
52
+ mount_options: list[str] | None = None,
53
+ ) -> MountInfo:
54
+ """Mount a remote filesystem at ``path`` inside the sandbox.
55
+
56
+ Args:
57
+ path: Absolute path inside the VM where the remote will be mounted.
58
+ remote: rclone remote spec — ``"<name>:<path>"`` (e.g. ``"s3:my-bucket/prefix"``).
59
+ backend: One of ``s3``, ``gcs``, ``azureblob``, ``sftp``, ``webdav``,
60
+ ``dropbox``. Determines how ``creds`` are templated into the
61
+ rclone config. Omit when passing ``rclone_config`` directly.
62
+ creds: Backend-specific config keys (rclone field names — e.g. for
63
+ S3: ``access_key_id``, ``secret_access_key``, ``region``).
64
+ rclone_config: Raw rclone config string. Overrides ``backend`` and
65
+ ``creds`` — useful for backends not in the typed list or for
66
+ advanced tuning.
67
+ read_only: Default ``True``. Object-store FUSE mounts have
68
+ well-known write footguns; opt in to RW explicitly.
69
+ mount_options: Extra args appended to ``rclone mount`` (e.g.
70
+ ``["--dir-cache-time", "1m"]``).
71
+ """
72
+ body: dict[str, object] = {
73
+ "path": path,
74
+ "remote": remote,
75
+ "readOnly": read_only,
76
+ }
77
+ if backend is not None:
78
+ body["backend"] = backend
79
+ if creds is not None:
80
+ body["creds"] = creds
81
+ if rclone_config is not None:
82
+ body["rcloneConfig"] = rclone_config
83
+ if mount_options is not None:
84
+ body["mountOptions"] = mount_options
85
+
86
+ resp = await self._client.post(
87
+ f"/sandboxes/{self._sandbox_id}/mounts", json=body
88
+ )
89
+ resp.raise_for_status()
90
+ data = resp.json()
91
+ return _mount_from_dict(data)
92
+
93
+ async def list(self) -> list[MountInfo]:
94
+ """List the mounts this worker is tracking for the sandbox.
95
+
96
+ Returns empty after hibernate/wake — re-issue ``add()`` for any mounts
97
+ you need back.
98
+ """
99
+ resp = await self._client.get(f"/sandboxes/{self._sandbox_id}/mounts")
100
+ resp.raise_for_status()
101
+ data = resp.json() or []
102
+ return [_mount_from_dict(entry) for entry in data]
103
+
104
+ async def remove(self, path: str) -> None:
105
+ """Unmount a path previously passed to ``add()``. No-op if not mounted."""
106
+ try:
107
+ resp = await self._client.delete(
108
+ f"/sandboxes/{self._sandbox_id}/mounts", params={"path": path}
109
+ )
110
+ if resp.status_code == 404:
111
+ return
112
+ resp.raise_for_status()
113
+ except httpx.HTTPError:
114
+ raise
115
+
116
+
117
+ def _mount_from_dict(data: dict) -> MountInfo:
118
+ return MountInfo(
119
+ path=data["path"],
120
+ remote=data["remote"],
121
+ backend=data.get("backend", ""),
122
+ read_only=data.get("readOnly", True),
123
+ rclone_version=data.get("rcloneVersion", ""),
124
+ )
@@ -12,6 +12,7 @@ from opencomputer.agent import Agent
12
12
  from opencomputer.exec import Exec
13
13
  from opencomputer.filesystem import Filesystem
14
14
  from opencomputer.image import Image
15
+ from opencomputer.mounts import Mounts
15
16
  from opencomputer.pty import Pty
16
17
  from opencomputer.sse import parse_sse_stream
17
18
 
@@ -33,6 +34,14 @@ class PlanLimitError(Exception):
33
34
  """
34
35
 
35
36
 
37
+ class SandboxFamilyLimitError(Exception):
38
+ """Raised when a resource-changing call is blocked by a sandbox family.
39
+ Kept for compatibility with older API responses.
40
+ """
41
+
42
+ code = "sandbox_family_scale_disabled"
43
+
44
+
36
45
  def _raise_scaling_error(resp: httpx.Response, action: str) -> None:
37
46
  """Inspect a non-OK scaling response and raise the most specific error.
38
47
  Falls back to ``raise_for_status`` so callers still see HTTP details for
@@ -43,6 +52,8 @@ def _raise_scaling_error(resp: httpx.Response, action: str) -> None:
43
52
  body = {}
44
53
  if resp.status_code == 403 and isinstance(body, dict) and body.get("code") == "scaling_locked":
45
54
  raise ScalingLockedError(body.get("error", "scaling is locked on this sandbox"))
55
+ if resp.status_code == 403 and isinstance(body, dict) and body.get("code") == "sandbox_family_scale_disabled":
56
+ raise SandboxFamilyLimitError(body.get("error", "sandbox family does not allow scaling"))
46
57
  if resp.status_code == 402:
47
58
  msg = body.get("error", "plan limit exceeded") if isinstance(body, dict) else "plan limit exceeded"
48
59
  raise PlanLimitError(msg)
@@ -75,7 +86,10 @@ class Sandbox:
75
86
  api_url: str | None = None,
76
87
  envs: dict[str, str] | None = None,
77
88
  metadata: dict[str, str] | None = None,
89
+ burst: bool | None = None,
90
+ sandbox_family: str | None = None,
78
91
  disk_mb: int | None = None,
92
+ memory_mb: int | None = None,
79
93
  secret_store: str | None = None,
80
94
  image: Image | None = None,
81
95
  snapshot: str | None = None,
@@ -90,11 +104,18 @@ class Sandbox:
90
104
  api_url: API URL (or OPENCOMPUTER_API_URL env var).
91
105
  envs: Environment variables to inject. Overrides store secrets.
92
106
  metadata: Custom metadata key-value pairs.
107
+ burst: Create a Burst Sandbox. Disk is preserved across
108
+ infrastructure restarts; processes may restart.
109
+ sandbox_family: Internal/legacy placement family. Prefer
110
+ ``burst=True`` for public API usage.
93
111
  disk_mb: Workspace disk size in MB (default 20480 = 20GB). Any
94
112
  additional GB above 20GB is metered at a per-second rate
95
113
  comparable to EBS gp3. Closed beta: requests above 20GB
96
114
  require the org's ``max_disk_mb`` to be raised. Contact us:
97
115
  https://cal.com/team/digger/opencomputer-founder-chat
116
+ memory_mb: Memory in MB. On a snapshot/checkpoint fork the server
117
+ clamps this to [snapshot memory, 16 GB]; the new sandbox's
118
+ ``memory_mb`` reflects the effective value.
98
119
  secret_store: Secret store name — resolves encrypted secrets
99
120
  and egress allowlist.
100
121
  image: Declarative Image definition. The server builds and caches it as a checkpoint.
@@ -130,8 +151,14 @@ class Sandbox:
130
151
  body["envs"] = envs
131
152
  if metadata:
132
153
  body["metadata"] = metadata
154
+ if burst is not None:
155
+ body["burst"] = burst
156
+ if sandbox_family:
157
+ body["sandboxFamily"] = sandbox_family
133
158
  if disk_mb is not None:
134
159
  body["diskMB"] = disk_mb
160
+ if memory_mb is not None:
161
+ body["memoryMB"] = memory_mb
135
162
  if secret_store:
136
163
  body["secretStore"] = secret_store
137
164
  if image is not None:
@@ -270,6 +297,32 @@ class Sandbox:
270
297
  resp = await self._client.post(f"/sandboxes/{self.sandbox_id}/power-cycle")
271
298
  resp.raise_for_status()
272
299
 
300
+ async def hibernate(self) -> None:
301
+ """Hibernate the sandbox.
302
+
303
+ Snapshots the running VM (RAM + disk) to storage and frees its worker
304
+ slot. The sandbox keeps its ID, disks, env, and secrets; in-memory
305
+ process state is preserved and restored on :meth:`wake`. Compute
306
+ billing stops while hibernated (only storage is metered).
307
+ """
308
+ resp = await self._client.post(f"/sandboxes/{self.sandbox_id}/hibernate")
309
+ resp.raise_for_status()
310
+ self.status = "hibernated"
311
+
312
+ async def wake(self, timeout: int | None = None) -> None:
313
+ """Wake a hibernated sandbox.
314
+
315
+ Restores the VM from its hibernation snapshot on a worker — running
316
+ processes resume where they left off. Optionally set a new idle
317
+ ``timeout`` (seconds; ``0`` = persistent, never auto-hibernate).
318
+ """
319
+ body: dict[str, Any] = {}
320
+ if timeout is not None:
321
+ body["timeout"] = timeout
322
+ resp = await self._client.post(f"/sandboxes/{self.sandbox_id}/wake", json=body)
323
+ resp.raise_for_status()
324
+ self.status = "running"
325
+
273
326
  async def is_running(self) -> bool:
274
327
  """Check if the sandbox is still running."""
275
328
  try:
@@ -482,6 +535,11 @@ class Sandbox:
482
535
  """Access filesystem operations."""
483
536
  return Filesystem(self._ops_client, self.sandbox_id)
484
537
 
538
+ @property
539
+ def mounts(self) -> Mounts:
540
+ """Mount remote filesystems (S3, GCS, SFTP, …) via rclone+FUSE."""
541
+ return Mounts(self._ops_client, self.sandbox_id)
542
+
485
543
  @property
486
544
  def exec(self) -> Exec:
487
545
  """Access session-based command execution."""
@@ -66,19 +66,50 @@ class UsageByTagResponse:
66
66
  next_cursor: str | None
67
67
 
68
68
 
69
+ @dataclass
70
+ class SandboxUsagePoint:
71
+ """One 1-minute bucket of memory usage. Integrals (``*_gb_seconds``,
72
+ ``uptime_seconds``) compose by summation; snapshot scalars
73
+ (``allocated_memory_mb``, ``used_memory_mb_*``) are for charts.
74
+
75
+ v1 is memory only. CPU fields will appear once the server-side
76
+ collector starts populating cgroup cpu.stat.
77
+ """
78
+
79
+ ts: str
80
+ memory_allocated_gb_seconds: float
81
+ memory_used_gb_seconds: float
82
+ uptime_seconds: int
83
+ allocated_memory_mb: int
84
+ used_memory_mb_avg: int
85
+ used_memory_mb_peak: int
86
+
87
+
88
+ @dataclass
89
+ class SandboxUsageTotals:
90
+ """Envelope totals over ``[from_, to)``. Invariant: summing the
91
+ matching field across ``points`` reproduces the value here."""
92
+
93
+ memory_allocated_gb_seconds: float
94
+ memory_used_gb_seconds: float
95
+ uptime_seconds: int
96
+ memory_allocated_peak_mb: int
97
+ memory_used_peak_mb: int
98
+
99
+
69
100
  @dataclass
70
101
  class SandboxUsageResponse:
102
+ """Response for ``GET /sandboxes/:id/usage``. Default window is
103
+ last 1 hour; max 30 days (server returns 400 beyond that).
104
+ ``from_`` and ``to`` accept ISO dates (``YYYY-MM-DD``) or RFC3339
105
+ timestamps."""
106
+
71
107
  sandbox_id: str
72
108
  from_: str
73
109
  to: str
74
- memory_gb_seconds: float
75
- disk_overage_gb_seconds: float
76
- tags: dict[str, str]
77
- tags_last_updated_at: str | None
78
- first_started_at: str | None
79
- last_ended_at: str | None
110
+ totals: SandboxUsageTotals
111
+ points: list[SandboxUsagePoint]
80
112
  alias: str | None = None
81
- status: str | None = None
82
113
 
83
114
 
84
115
  @dataclass
@@ -235,18 +266,31 @@ class Usage:
235
266
  resp = await self._client.get(f"/sandboxes/{sandbox_id}/usage", params=params)
236
267
  resp.raise_for_status()
237
268
  b = resp.json()
269
+ t = b.get("totals") or {}
238
270
  return SandboxUsageResponse(
239
271
  sandbox_id=b["sandboxId"],
240
272
  from_=b["from"],
241
273
  to=b["to"],
242
- memory_gb_seconds=b["memoryGbSeconds"],
243
- disk_overage_gb_seconds=b["diskOverageGbSeconds"],
244
- tags=b.get("tags") or {},
245
- tags_last_updated_at=b.get("tagsLastUpdatedAt"),
246
- first_started_at=b.get("firstStartedAt"),
247
- last_ended_at=b.get("lastEndedAt"),
274
+ totals=SandboxUsageTotals(
275
+ memory_allocated_gb_seconds=t.get("memoryAllocatedGbSeconds", 0.0),
276
+ memory_used_gb_seconds=t.get("memoryUsedGbSeconds", 0.0),
277
+ uptime_seconds=t.get("uptimeSeconds", 0),
278
+ memory_allocated_peak_mb=t.get("memoryAllocatedPeakMb", 0),
279
+ memory_used_peak_mb=t.get("memoryUsedPeakMb", 0),
280
+ ),
281
+ points=[
282
+ SandboxUsagePoint(
283
+ ts=p["ts"],
284
+ memory_allocated_gb_seconds=p["memoryAllocatedGbSeconds"],
285
+ memory_used_gb_seconds=p["memoryUsedGbSeconds"],
286
+ uptime_seconds=p["uptimeSeconds"],
287
+ allocated_memory_mb=p["allocatedMemoryMb"],
288
+ used_memory_mb_avg=p["usedMemoryMbAvg"],
289
+ used_memory_mb_peak=p["usedMemoryMbPeak"],
290
+ )
291
+ for p in b.get("points") or []
292
+ ],
248
293
  alias=b.get("alias"),
249
- status=b.get("status"),
250
294
  )
251
295
 
252
296
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opencomputer-sdk"
7
- version = "0.5.4"
7
+ version = "0.6.1"
8
8
  description = "Python SDK for OpenComputer - cloud sandbox platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"