arker 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.
arker/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Arker — single-file Python client for the Arker virtual computer
2
+ platform. See README.md for the quickstart, `arker/computer.py` for
3
+ the implementation, and `docs/sync-api.md` for wire-level details."""
4
+ from .computer import Arker, Computer, Sync, RunResult, VmSummary, VmList, ArkerError
5
+
6
+ __all__ = ["Arker", "Computer", "Sync", "RunResult", "VmSummary", "VmList", "ArkerError"]
7
+ __version__ = "0.1.0"
arker/computer.py ADDED
@@ -0,0 +1,319 @@
1
+ """Arker SDK — single-file Python client.
2
+
3
+ Quickstart:
4
+
5
+ from arker import Arker
6
+ arker = Arker(api_key="ark_live_...")
7
+ vm = arker.vm("arkuntu").fork(name="hello") # fresh VM from base image
8
+ result = vm.run("echo hi") # result.stdout: bytes, result.exit_code: int
9
+
10
+ vm.sync.write_file("/home/user/data.bin", b"...")
11
+ blob = vm.sync.read_file("/home/user/data.bin") # → bytes
12
+
13
+ child = vm.fork(name="branch") # branch off this VM
14
+ child.delete(); vm.delete()
15
+
16
+ # List your VMs (paginated):
17
+ page = arker.list(limit=10)
18
+ for summary in page.items:
19
+ print(summary.vm_id, summary.name, summary.created_at)
20
+
21
+ # Re-open an existing VM by ID (no network call):
22
+ same_vm = arker.vm("01ABC...XYZ_d8c0")
23
+
24
+ Errors raise `ArkerError(code, message, status)`.
25
+ """
26
+ from __future__ import annotations
27
+ import base64, dataclasses, json, os, secrets, time, urllib.error, urllib.parse, urllib.request
28
+ from typing import Any
29
+
30
+ DEFAULT_BASE_URL = "https://aws-us-west-2.burst.arker.ai"
31
+ LIST_BASE_URL = "https://arker.ai" # `list` is served from a different host than the rest;
32
+ # used regardless of the client's base_url.
33
+ SOURCE_ALIASES = {"arkuntu": "01KQBYKEV5WJ7YB010603T1DCT_d8c0"} # public base images
34
+ CHUNK_SIZE = 4 * 1024 * 1024 # files above this go through a direct upload
35
+ PRESIGN_EXPIRES = 900 # signed-URL lifetime (s)
36
+ RETRYABLE_HTTP = {429, 502, 503, 504}
37
+ # Substring matches in `error.message` that mark a transient failure
38
+ # the server expects clients to retry. Matched verbatim against the
39
+ # message returned in the error envelope.
40
+ _TRANSIENT_HINTS = ("503", "Service Unavailable", "throttle", "SlowDown", "ThrottlingException")
41
+ MAX_ATTEMPTS = 4
42
+ BACKOFF_S = 0.2
43
+ ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
44
+
45
+
46
+ class ArkerError(Exception):
47
+ def __init__(self, code: str, message: str, status: int) -> None:
48
+ super().__init__(f"{code}: {message}")
49
+ self.code, self.message, self.status = code, message, status
50
+
51
+
52
+ @dataclasses.dataclass
53
+ class RunResult:
54
+ stdout: bytes; stderr: bytes; exit_code: int
55
+ duration_ms: float; session_id: str; cwd: str
56
+
57
+
58
+ @dataclasses.dataclass
59
+ class VmSummary:
60
+ """One row from `Arker.list()`. `created_at` is ISO 8601 UTC."""
61
+ vm_id: str
62
+ name: str | None
63
+ base_image: str
64
+ region: str
65
+ created_at: str
66
+
67
+
68
+ @dataclasses.dataclass
69
+ class VmList:
70
+ items: list[VmSummary]
71
+ total: int # total matching the query, ignoring limit/offset
72
+ def __iter__(self): return iter(self.items)
73
+ def __len__(self): return len(self.items)
74
+
75
+
76
+ def _ulid() -> str:
77
+ """26-char Crockford-base32 ULID; not strictly monotonic, but unique
78
+ enough — the server's `If-None-Match: *` catches any duplicates."""
79
+ raw = ((int(time.time() * 1000) & ((1 << 48) - 1)) << 80) | secrets.randbits(80)
80
+ out = []
81
+ for _ in range(26):
82
+ out.append(ULID_ALPHABET[raw & 31]); raw >>= 5
83
+ return "".join(reversed(out))
84
+
85
+
86
+ def _looks_like_vm_id(s: str) -> bool:
87
+ """ULID-shape check: 26 Crockford chars, optionally `_<region>` suffix
88
+ of 1+ alphanumeric chars (e.g. `_uswe`, `_d8c0`). Used by `fork()`
89
+ to dispatch by-id vs by-ref."""
90
+ if "_" in s:
91
+ head, _, tail = s.partition("_")
92
+ if not tail or not all(c.isalnum() for c in tail):
93
+ return False
94
+ else:
95
+ head = s
96
+ if len(head) != 26:
97
+ return False
98
+ return all(c in ULID_ALPHABET for c in head.upper())
99
+
100
+
101
+ def _decode_stream(text: Any, encoding: Any) -> bytes:
102
+ s = text or ""
103
+ if encoding == "base64":
104
+ try: return base64.b64decode(s)
105
+ except Exception: pass
106
+ return (s if isinstance(s, str) else "").encode("utf-8", "replace")
107
+
108
+
109
+ def _is_transient(err: dict | None) -> bool:
110
+ if not err or err.get("code") != "internal":
111
+ return False
112
+ return any(h in (err.get("message") or "") for h in _TRANSIENT_HINTS)
113
+
114
+
115
+ def _http(method: str, url: str, headers: dict, body: bytes | None) -> tuple[int, bytes]:
116
+ """Single-shot urllib request. Retry policy lives in `Arker._request`."""
117
+ req = urllib.request.Request(url, method=method, headers=headers, data=body)
118
+ try:
119
+ resp = urllib.request.urlopen(req, timeout=120)
120
+ return resp.status, resp.read()
121
+ except urllib.error.HTTPError as e:
122
+ return e.code, e.read()
123
+
124
+
125
+ class Arker:
126
+ """Top-level client. Default `base_url` points at the production
127
+ regional endpoint; override per-client or via the `ARKER_BASE_URL`
128
+ env var to use a different region or a self-hosted deployment."""
129
+ def __init__(self, api_key: str, base_url: str | None = None) -> None:
130
+ if not api_key: raise ValueError("api_key is required")
131
+ self._api_key = api_key
132
+ self._base_url = (base_url or os.environ.get("ARKER_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
133
+
134
+ def _request(self, method: str, path: str, body: dict | None = None,
135
+ *, base_url: str | None = None) -> dict:
136
+ """One retry budget covering both transient HTTP statuses and
137
+ envelope-level transient errors. `base_url=` overrides the client
138
+ default for routes that live on a different host (e.g. `list`)."""
139
+ headers = {"authorization": f"Bearer {self._api_key}", "content-type": "application/json"}
140
+ data = json.dumps(body).encode() if body is not None else None
141
+ url = (base_url.rstrip("/") if base_url else self._base_url) + path
142
+ last_status, last_err, last_payload = 0, None, None
143
+ for attempt in range(MAX_ATTEMPTS):
144
+ status, raw = _http(method, url, headers, data)
145
+ try: payload = json.loads(raw) if raw else {}
146
+ except json.JSONDecodeError: payload = None
147
+ last_status, last_payload = status, payload
148
+ envelope_err = (payload.get("error") if isinstance(payload, dict) and payload.get("ok") is False else None)
149
+ last_err = envelope_err
150
+ if status in RETRYABLE_HTTP or _is_transient(envelope_err):
151
+ if attempt == MAX_ATTEMPTS - 1: break
152
+ time.sleep(BACKOFF_S * (2 ** attempt) + secrets.randbelow(50) / 1000.0)
153
+ continue
154
+ if envelope_err is not None:
155
+ raise ArkerError(envelope_err.get("code", "internal"), envelope_err.get("message", ""), status)
156
+ if status >= 400:
157
+ raise ArkerError("internal", str(payload)[:200], status)
158
+ return payload # type: ignore[return-value]
159
+ if last_err is not None:
160
+ raise ArkerError(last_err.get("code", "internal"), last_err.get("message", ""), last_status)
161
+ raise ArkerError("internal", str(last_payload)[:200], last_status)
162
+
163
+ def vm(self, vm_id: str) -> "Computer":
164
+ """Open a handle to a VM by ULID *or* by template name (e.g.
165
+ `"arkuntu"`). Names resolve on `.fork()` only — you can't
166
+ `.run()` against a name handle. No network call here."""
167
+ return Computer(self, vm_id)
168
+
169
+ def list(self, *, limit: int = 25, offset: int = 0,
170
+ q: str | None = None, sort: str | None = None) -> VmList:
171
+ """List VMs in the caller's organization. Always hits
172
+ `https://arker.ai` regardless of the client's `base_url` — list
173
+ data is served from a global host rather than the regional one.
174
+
175
+ Args:
176
+ limit: 1–100, default 25.
177
+ offset: ≥0, default 0.
178
+ q: substring filter on VM name.
179
+ sort: `created_at` | `-created_at` | `region` | `-region`.
180
+ Default `-created_at` (newest first).
181
+ """
182
+ params: dict[str, str] = {}
183
+ if limit != 25: params["limit"] = str(limit)
184
+ if offset != 0: params["offset"] = str(offset)
185
+ if q is not None: params["q"] = q
186
+ if sort is not None: params["sort"] = sort
187
+ path = "/api/v1/vms/list"
188
+ if params:
189
+ path += "?" + urllib.parse.urlencode(params)
190
+ r = self._request("GET", path, base_url=LIST_BASE_URL)
191
+ items = [VmSummary(
192
+ vm_id = i["vm_id"],
193
+ name = i.get("name"),
194
+ base_image = i["base_image"],
195
+ region = i["region"],
196
+ created_at = i["created_at"],
197
+ ) for i in r.get("items", [])]
198
+ return VmList(items=items, total=int(r.get("total", 0)))
199
+
200
+
201
+ class Computer:
202
+ """A handle on one VM. Methods: `.delete()`, `.fork(...)`, `.run(...)`,
203
+ `.sync.read_file(path)`, `.sync.write_file(path, data)`."""
204
+ def __init__(self, client: Arker, vm_id: str) -> None:
205
+ self._client, self.id = client, vm_id
206
+ self._default_session: str | None = None
207
+ self.sync = Sync(self)
208
+
209
+ def delete(self) -> None:
210
+ self._client._request("DELETE", f"/api/v1/vms/{self.id}")
211
+
212
+ def fork(self, *, name: str | None = None, is_public: bool = False,
213
+ region: str | None = None) -> "Computer":
214
+ """Branch off this VM. Aliases like `"arkuntu"` resolve to a
215
+ ULID client-side via `SOURCE_ALIASES`; the request then uses
216
+ the by-id endpoint so it works on the default `base_url`."""
217
+ body: dict[str, Any] = {"is_public": is_public}
218
+ if name is not None: body["name"] = name
219
+ if region is not None: body["region"] = region
220
+ resolved = SOURCE_ALIASES.get(self.id, self.id)
221
+ if _looks_like_vm_id(resolved):
222
+ r = self._client._request("POST", f"/api/v1/vms/{resolved}/fork", body)
223
+ else:
224
+ # Unknown name; let the global host resolve it.
225
+ body["from"] = self.id
226
+ r = self._client._request("POST", "/api/v1/vms/fork", body, base_url=LIST_BASE_URL)
227
+ if not r.get("vm_id"):
228
+ raise ArkerError("internal", "fork response missing vm_id", 200)
229
+ return Computer(self._client, r["vm_id"])
230
+
231
+ def run(self, command: str, *, session_id: str | int | None = None,
232
+ timeout: int | None = None) -> RunResult:
233
+ body: dict[str, Any] = {"command": command,
234
+ "session_id": session_id if session_id is not None else (self._default_session or 0)}
235
+ if timeout is not None: body["timeout"] = timeout
236
+ r = self._client._request("POST", f"/api/v1/vms/{self.id}/run", body)
237
+ return RunResult(
238
+ stdout=_decode_stream(r.get("stdout"), r.get("stdout_encoding")),
239
+ stderr=_decode_stream(r.get("stderr"), r.get("stderr_encoding")),
240
+ exit_code=int(r.get("exit_code", 0)),
241
+ duration_ms=float(r.get("duration_ms", 0.0)),
242
+ session_id=str(r.get("session_id", "")),
243
+ cwd=str(r.get("cwd", "")),
244
+ )
245
+
246
+
247
+ class Sync:
248
+ """File I/O for one VM. Routes through `/api/v1/vms/{id}/sync`. Hides
249
+ chunk-vs-presigned write strategy and inline-vs-presigned reads."""
250
+ def __init__(self, vm: Computer) -> None:
251
+ self._vm, self._client = vm, vm._client
252
+
253
+ def _path(self) -> str: return f"/api/v1/vms/{self._vm.id}/sync"
254
+
255
+ def read_file(self, path: str) -> bytes:
256
+ r = self._client._request("POST", self._path(), {"op": "read", "path": path})
257
+ if "content" in r:
258
+ c = r["content"]
259
+ return base64.b64decode(c) if r.get("encoding") == "base64" else c.encode("utf-8")
260
+ url = r.get("presigned_url")
261
+ if not url: raise ArkerError("internal", "read response missing content/presigned_url", 200)
262
+ with urllib.request.urlopen(url, timeout=300) as resp:
263
+ return resp.read()
264
+
265
+ def write_file(self, path: str, data: bytes | str) -> None:
266
+ if isinstance(data, str): data = data.encode("utf-8")
267
+ if not data: raise ArkerError("bad_request", "write_file: empty data", 400)
268
+ if len(data) <= CHUNK_SIZE:
269
+ self._fast_path(path, data)
270
+ else:
271
+ self._presigned(path, data)
272
+
273
+ def _fast_path(self, path: str, data: bytes) -> None:
274
+ """Single-chunk write for payloads ≤ CHUNK_SIZE: one round-trip,
275
+ bytes carried inline."""
276
+ size = len(data)
277
+ entry = {"path": path, "size": size, "upload_id": _ulid(),
278
+ "start": 0, "end": size,
279
+ "content": base64.b64encode(data).decode("ascii")}
280
+ result = self._send_one(entry)
281
+ if not (result.get("complete") and result.get("written")):
282
+ raise ArkerError("internal", "fast-path write returned without complete+written", 200)
283
+
284
+ def _presigned(self, path: str, data: bytes) -> None:
285
+ """> CHUNK_SIZE: request a signed upload URL, PUT bytes directly,
286
+ then commit. Bytes never pass through the API layer."""
287
+ size = len(data)
288
+ # Step 1 — request the signed URL.
289
+ e1 = self._send_one({"path": path, "size": size, "presigned": True})
290
+ url, upload_id = e1["presigned_url"], e1["upload_id"]
291
+ # Step 2 — direct PUT to the upload URL, retrying transient HTTP statuses.
292
+ for attempt in range(MAX_ATTEMPTS):
293
+ try:
294
+ req = urllib.request.Request(url, method="PUT", data=data)
295
+ with urllib.request.urlopen(req, timeout=600) as resp:
296
+ if resp.status >= 400:
297
+ raise urllib.error.HTTPError(url, resp.status, "fail", {}, None)
298
+ break
299
+ except urllib.error.HTTPError as e:
300
+ if e.code not in RETRYABLE_HTTP or attempt == MAX_ATTEMPTS - 1:
301
+ raise ArkerError("internal", f"upload PUT failed: {e.code}", e.code)
302
+ time.sleep(BACKOFF_S * (2 ** attempt))
303
+ # Step 3 — commit.
304
+ self._send_one({"path": path, "size": size, "upload_id": upload_id})
305
+
306
+ def _send_one(self, entry: dict) -> dict:
307
+ """POST a single-entry write request, retrying transient
308
+ envelope-level errors (HTTP 200 with `error.code:"internal"` and
309
+ a throttling-style hint in the message)."""
310
+ last_err = None
311
+ for attempt in range(MAX_ATTEMPTS):
312
+ r = self._client._request("POST", self._path(), {"op": "write", "writes": [entry]})
313
+ result = (r.get("results") or [{}])[0]
314
+ err = result.get("error")
315
+ if not err: return result
316
+ last_err = err
317
+ if not _is_transient(err) or attempt == MAX_ATTEMPTS - 1: break
318
+ time.sleep(BACKOFF_S * (2 ** attempt) + secrets.randbelow(50) / 1000.0)
319
+ raise ArkerError(last_err.get("code", "internal"), last_err.get("message", ""), 200)
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: arker
3
+ Version: 0.1.0
4
+ Summary: Python client for the Arker virtual computer platform.
5
+ Project-URL: Homepage, https://arker.ai
6
+ Project-URL: Documentation, https://arker.ai/docs
7
+ Project-URL: Source, https://github.com/ArkerHQ/arker-python-sdk
8
+ Project-URL: Issues, https://github.com/ArkerHQ/arker-python-sdk/issues
9
+ Author-email: Arker <support@arker.ai>
10
+ License-Expression: Apache-2.0
11
+ Keywords: agent,arker,code-execution,sandbox,vm
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Requires-Python: >=3.10
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=8.0; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Arker — Python SDK
27
+
28
+ Single-file Python client for the [Arker](https://arker.ai) virtual computer
29
+ platform. Spawn isolated Linux sandboxes, run shell / Python / Node code in
30
+ them, read and write files. Zero runtime dependencies (stdlib `urllib`).
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install arker
36
+ ```
37
+
38
+ Or, while in alpha:
39
+
40
+ ```bash
41
+ pip install git+https://github.com/ArkerHQ/arker-python-sdk@v0.1.0
42
+ ```
43
+
44
+ ## Quickstart
45
+
46
+ ```python
47
+ from arker import Arker, ArkerError
48
+
49
+ arker = Arker(api_key="ark_live_...")
50
+ vm = arker.vm("arkuntu").fork(name="hello") # fresh VM from base image
51
+ result = vm.run("python3 -c 'print(2+2)'")
52
+ print(result.stdout.decode()) # → "4\n"
53
+
54
+ vm.sync.write_file("/home/user/data.csv", b"a,b\n1,2\n")
55
+ data = vm.sync.read_file("/home/user/data.csv") # → b"a,b\n1,2\n"
56
+
57
+ child = vm.fork(name="branch") # constant-time copy-on-write
58
+ child.delete()
59
+ vm.delete()
60
+ ```
61
+
62
+ List your VMs:
63
+
64
+ ```python
65
+ page = arker.list(limit=10, sort="-created_at")
66
+ print(f"{page.total} total")
67
+ for summary in page: # iterable; also page.items
68
+ print(summary.vm_id, summary.name, summary.region, summary.created_at)
69
+ ```
70
+
71
+ ## API
72
+
73
+ ```
74
+ Arker(api_key, base_url=None)
75
+ .vm(vm_id) -> Computer # open handle (no network call)
76
+ .list(*, limit=25, offset=0, q=None, sort=None) -> VmList
77
+
78
+ Computer
79
+ .id, .delete()
80
+ .fork(*, name=, is_public=, region=) -> Computer
81
+ .run(command, *, session_id=, timeout=) -> RunResult
82
+ .sync.read_file(path) -> bytes
83
+ .sync.write_file(path, data: bytes | str)
84
+
85
+ RunResult: stdout, stderr (bytes), exit_code, duration_ms, session_id, cwd
86
+ VmSummary: vm_id, name, base_image, region, created_at (ISO 8601)
87
+ VmList: items (list[VmSummary]), total (int); iterable, len()-able
88
+
89
+ ArkerError(code, message, status) # one exception type for everything
90
+ ```
91
+
92
+ ### Routing
93
+
94
+ `fork`, `run`, `sync`, and `delete` use the regional endpoint set on the
95
+ client (default `https://aws-us-west-2.burst.arker.ai`).
96
+
97
+ `list` always goes through `https://arker.ai` regardless of `base_url`,
98
+ because list data is served from a global host rather than a regional
99
+ one.
100
+
101
+ Public base-image names like `"arkuntu"` resolve to a ULID **client-side**
102
+ (see `SOURCE_ALIASES` in `computer.py`), so `arker.vm("arkuntu").fork()`
103
+ works on the default endpoint with no extra round-trip. Override
104
+ `base_url` or set `ARKER_BASE_URL` to point at a different region or a
105
+ self-hosted deployment.
106
+
107
+ ### Errors
108
+
109
+ Every server-side error becomes an `ArkerError`:
110
+
111
+ ```python
112
+ try:
113
+ vm.sync.read_file("/home/user/missing")
114
+ except ArkerError as err:
115
+ print(err.code) # "not_found"
116
+ print(err.message) # "file not found: /home/user/missing"
117
+ print(err.status) # 404
118
+ ```
119
+
120
+ `code` is a stable enum: `bad_request`, `unauthorized`, `payment_required`,
121
+ `forbidden`, `not_found`, `conflict`, `payload_too_large`, `internal`,
122
+ `not_implemented`, `vm_busy`, `unsupported_*`, `command_not_found`.
123
+
124
+ ### What the SDK does for you
125
+
126
+ Hidden behind these six methods:
127
+
128
+ - **Write strategy**: files up to 100 MB. Small payloads go in one call;
129
+ larger ones use a direct upload path so the bytes don't traverse the
130
+ API layer. `write_file` returns once the bytes are durably stored.
131
+ - **Read coalescing**: `read_file` always returns raw `bytes`, regardless
132
+ of whether the server inlined the content or returned a signed URL.
133
+ - **Idempotent retry**: transient errors are retried with exponential
134
+ backoff. Writes are server-side idempotent on `upload_id`, so retries
135
+ never produce duplicates.
136
+ - **Path validation**: only `/home/user/...` paths accepted; `..` rejected.
137
+
138
+ ## Demo / smoke test
139
+
140
+ Run the full surface against a live deployment:
141
+
142
+ ```bash
143
+ ARKER_API_KEY=ark_live_... python tests/demo.py
144
+ ```
145
+
146
+ It exercises every method (`list`, `vm`, `fork`, `run`, `sync.write_file`,
147
+ `sync.read_file`, error path, child fork, `delete`) and prints what each
148
+ call hits on the wire — useful as living documentation.
149
+
150
+ ## License
151
+
152
+ Apache-2.0.
@@ -0,0 +1,5 @@
1
+ arker/__init__.py,sha256=jUTYP75ZphwFqsdjoOWPHYFIATQfD-8L1vfQH30VD38,407
2
+ arker/computer.py,sha256=8WrAgvg40alYfkyl6v3m9sMrRIrSITQKD_QMb0IkfP0,14566
3
+ arker-0.1.0.dist-info/METADATA,sha256=5CXxqPym6N_majmxGONdUf_-9esxLuN3lCEX9G6RfGI,5087
4
+ arker-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
5
+ arker-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any