mv37-workdir 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.
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: mv37-workdir
3
+ Version: 0.1.0
4
+ Summary: Python SDK for workdir
5
+ Project-URL: Homepage, https://workdir.dev
6
+ Project-URL: Repository, https://github.com/mv37-org/workdir
7
+ Project-URL: Issues, https://github.com/mv37-org/workdir/issues
8
+ Author: mv37
9
+ License-Expression: AGPL-3.0-only
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ # mv37-workdir
20
+
21
+ Python SDK for [workdir](https://workdir.dev).
22
+
23
+ ```bash
24
+ pip install mv37-workdir
25
+ ```
26
+
27
+ ```python
28
+ from workdir import Client
29
+
30
+ workdir = Client("https://api.workdir.dev", api_key="...")
31
+
32
+ box = workdir.sandboxes.create()
33
+ print(box.exec("echo hello").stdout)
34
+ box.delete()
35
+ ```
36
+
37
+ The SDK uses only the Python standard library.
@@ -0,0 +1,5 @@
1
+ workdir/__init__.py,sha256=qTMhHl-vHH6-_vSfn9zOX3ixCche7_BRGnXOjBe7k_4,8312
2
+ workdir/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
3
+ mv37_workdir-0.1.0.dist-info/METADATA,sha256=jGaPC6X4UMfmRY5H8cty1By08aMD_wTDLgLGS7yhkbc,998
4
+ mv37_workdir-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ mv37_workdir-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
workdir/__init__.py ADDED
@@ -0,0 +1,251 @@
1
+ """Minimal Python SDK for workdir.
2
+
3
+ The default path is one call::
4
+
5
+ from workdir import Client
6
+ client = Client("https://api.sandboxes.example.com", api_key="sk_live_...")
7
+ sandbox = client.sandboxes.create() # cheap default path
8
+ print(sandbox.exec("echo ok").stdout)
9
+ sandbox.delete()
10
+
11
+ Heavier sandboxes require explicit options (spec §3.4)::
12
+
13
+ sandbox = client.sandboxes.create(
14
+ image="browser",
15
+ resources={"cpu": 2, "memory_mb": 4096, "disk_gb": 16},
16
+ browser={"enabled": True, "vnc": True, "cdp": True},
17
+ startup={
18
+ "git": {"url": "https://github.com/acme/app.git", "ref": "main", "depth": 1},
19
+ "commands": [{"name": "install", "run": "pnpm install --frozen-lockfile"}],
20
+ "ports": [3000, 6080],
21
+ "ready": {"http": "http://127.0.0.1:3000", "timeout_seconds": 30},
22
+ },
23
+ )
24
+ print(sandbox.urls["vnc"])
25
+
26
+ Opt in to an in-sandbox coding agent (opencode), installed on demand::
27
+
28
+ sandbox = client.sandboxes.create(
29
+ coding_agent={"enabled": True},
30
+ startup={"secrets": ["ANTHROPIC_API_KEY"]},
31
+ )
32
+ sandbox.exec("opencode run 'add a test for utils.py'")
33
+
34
+ Uses only the standard library (urllib), so it has zero dependencies.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import urllib.error
41
+ import urllib.parse
42
+ import urllib.request
43
+ from dataclasses import dataclass
44
+ from typing import Any, Optional
45
+
46
+ __all__ = ["Client", "ExecResult", "Sandbox", "SandboxError"]
47
+
48
+
49
+ class SandboxError(Exception):
50
+ def __init__(self, status: int, code: str, message: str):
51
+ super().__init__(f"[{status} {code}] {message}")
52
+ self.status = status
53
+ self.code = code
54
+ self.message = message
55
+
56
+
57
+ @dataclass
58
+ class ExecResult:
59
+ exit_code: int
60
+ stdout: str
61
+ stderr: str
62
+
63
+
64
+ class _Http:
65
+ def __init__(self, base_url: str, api_key: str, timeout: float = 60.0):
66
+ self.base = base_url.rstrip("/")
67
+ self.key = api_key
68
+ self.timeout = timeout
69
+
70
+ def request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
71
+ url = f"{self.base}{path}"
72
+ data = json.dumps(body).encode() if body is not None else None
73
+ req = urllib.request.Request(url, data=data, method=method)
74
+ req.add_header("Authorization", f"Bearer {self.key}")
75
+ req.add_header("Content-Type", "application/json")
76
+ try:
77
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
78
+ raw = resp.read()
79
+ return json.loads(raw) if raw else {}
80
+ except urllib.error.HTTPError as e:
81
+ raw = e.read()
82
+ try:
83
+ err = json.loads(raw)["error"]
84
+ raise SandboxError(e.code, err.get("code", "error"), err.get("message", "")) from None
85
+ except (ValueError, KeyError):
86
+ raise SandboxError(e.code, "error", raw.decode(errors="replace")) from None
87
+
88
+
89
+ class Sandbox:
90
+ def __init__(self, http: _Http, data: dict):
91
+ self._http = http
92
+ self._data = data
93
+
94
+ @property
95
+ def id(self) -> str:
96
+ return self._data["id"]
97
+
98
+ @property
99
+ def state(self) -> str:
100
+ return self._data["state"]
101
+
102
+ @property
103
+ def boot_path(self) -> str:
104
+ return self._data["boot_path"]
105
+
106
+ @property
107
+ def timings(self) -> dict:
108
+ return self._data.get("timings", {})
109
+
110
+ @property
111
+ def urls(self) -> dict:
112
+ return self._data.get("urls", {})
113
+
114
+ @property
115
+ def price(self) -> dict:
116
+ return self._data.get("price", {})
117
+
118
+ def refresh(self) -> "Sandbox":
119
+ self._data = self._http.request("GET", f"/v1/sandboxes/{self.id}")
120
+ return self
121
+
122
+ def exec(self, cmd: str, cwd: Optional[str] = None, env: Optional[dict] = None,
123
+ background: bool = False) -> ExecResult:
124
+ body = {"cmd": cmd, "background": background}
125
+ if cwd:
126
+ body["cwd"] = cwd
127
+ if env:
128
+ body["env"] = env
129
+ r = self._http.request("POST", f"/v1/sandboxes/{self.id}/exec", body)
130
+ return ExecResult(r["exit_code"], r["stdout"], r["stderr"])
131
+
132
+ def write_file(self, path: str, content: str) -> None:
133
+ self._http.request("PUT", f"/v1/sandboxes/{self.id}/files",
134
+ {"path": path, "content": content})
135
+
136
+ def read_file(self, path: str) -> str:
137
+ q = urllib.parse.urlencode({"path": path})
138
+ r = self._http.request("GET", f"/v1/sandboxes/{self.id}/files?{q}")
139
+ return r["content"]
140
+
141
+ def expose_port(self, port: int) -> str:
142
+ r = self._http.request("POST", f"/v1/sandboxes/{self.id}/ports/{port}/expose")
143
+ return r["url"]
144
+
145
+ def browser(self) -> dict:
146
+ return self._http.request("GET", f"/v1/sandboxes/{self.id}/browser")
147
+
148
+ def snapshot(self) -> dict:
149
+ return self._http.request("POST", f"/v1/sandboxes/{self.id}/snapshot")
150
+
151
+ def pause(self) -> "Sandbox":
152
+ self._data = self._http.request("POST", f"/v1/sandboxes/{self.id}/pause")
153
+ return self
154
+
155
+ def resume(self) -> "Sandbox":
156
+ self._data = self._http.request("POST", f"/v1/sandboxes/{self.id}/resume")
157
+ return self
158
+
159
+ def delete(self) -> None:
160
+ self._http.request("DELETE", f"/v1/sandboxes/{self.id}")
161
+
162
+
163
+ class _Sandboxes:
164
+ def __init__(self, http: _Http):
165
+ self._http = http
166
+
167
+ def create(self, **options) -> Sandbox:
168
+ # `create()` with no args yields the cheapest, fastest default path.
169
+ body = {k: v for k, v in options.items() if v is not None}
170
+ data = self._http.request("POST", "/v1/sandboxes", body)
171
+ return Sandbox(self._http, data)
172
+
173
+ def get(self, sandbox_id: str) -> Sandbox:
174
+ return Sandbox(self._http, self._http.request("GET", f"/v1/sandboxes/{sandbox_id}"))
175
+
176
+ def list(self) -> list[Sandbox]:
177
+ data = self._http.request("GET", "/v1/sandboxes")
178
+ return [Sandbox(self._http, s) for s in data.get("sandboxes", [])]
179
+
180
+
181
+ class _Images:
182
+ def __init__(self, http: _Http):
183
+ self._http = http
184
+
185
+ def create(self, name: str, source: dict, resources_hint: Optional[dict] = None) -> dict:
186
+ body = {"name": name, "source": source}
187
+ if resources_hint:
188
+ body["resources_hint"] = resources_hint
189
+ return self._http.request("POST", "/v1/images", body)
190
+
191
+ def get(self, image_id: str) -> dict:
192
+ return self._http.request("GET", f"/v1/images/{image_id}")
193
+
194
+ def list(self) -> dict:
195
+ return self._http.request("GET", "/v1/images")
196
+
197
+ def delete(self, image_id: str) -> dict:
198
+ return self._http.request("DELETE", f"/v1/images/{image_id}")
199
+
200
+
201
+ class _Nodes:
202
+ def __init__(self, http: _Http):
203
+ self._http = http
204
+
205
+ def list(self) -> dict:
206
+ return self._http.request("GET", "/v1/nodes")
207
+
208
+ def join_token(self) -> dict:
209
+ return self._http.request("POST", "/v1/nodes/join-token")
210
+
211
+ def drain(self, node_id: str) -> dict:
212
+ return self._http.request("POST", f"/v1/nodes/{node_id}/drain")
213
+
214
+
215
+ class _Secrets:
216
+ """Org-scoped secrets. Values are encrypted at rest and never returned."""
217
+
218
+ def __init__(self, http: _Http):
219
+ self._http = http
220
+
221
+ def set(self, name: str, value: str) -> dict:
222
+ return self._http.request("PUT", f"/v1/secrets/{name}", {"value": value})
223
+
224
+ def list(self) -> list[dict]:
225
+ return self._http.request("GET", "/v1/secrets").get("secrets", [])
226
+
227
+ def delete(self, name: str) -> dict:
228
+ return self._http.request("DELETE", f"/v1/secrets/{name}")
229
+
230
+
231
+ class Client:
232
+ def __init__(self, base_url: str, api_key: str, timeout: float = 60.0):
233
+ self._http = _Http(base_url, api_key, timeout)
234
+ self.sandboxes = _Sandboxes(self._http)
235
+ self.images = _Images(self._http)
236
+ self.nodes = _Nodes(self._http)
237
+ self.secrets = _Secrets(self._http)
238
+
239
+ def usage(self) -> dict:
240
+ return self._http.request("GET", "/v1/usage")
241
+
242
+
243
+ if __name__ == "__main__":
244
+ import os
245
+ client = Client(os.environ.get("WORKDIR_URL", "http://127.0.0.1:8080"),
246
+ os.environ["WORKDIR_API_KEY"])
247
+ sb = client.sandboxes.create()
248
+ print("created", sb.id, "boot_path", sb.boot_path, "boot_ms", sb.timings.get("boot_ms"))
249
+ print("echo:", sb.exec("echo ok").stdout.strip())
250
+ sb.delete()
251
+ print("deleted")
workdir/py.typed ADDED
@@ -0,0 +1 @@
1
+