akernel-sdk 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,207 @@
1
+ # Copyright (c) 2026 Ant Group Corporation.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import Any, Literal, overload
16
+
17
+ import yr
18
+
19
+ from .types import (
20
+ YR_GET_DEFAULT_TIMEOUT,
21
+ YR_GET_TIMEOUT_BUFFER,
22
+ CommandInfo,
23
+ CommandResult,
24
+ )
25
+
26
+
27
+ class CommandHandle:
28
+ """Handle for a background process running in the sandbox."""
29
+
30
+ def __init__(self, pid: int, instance: Any) -> None:
31
+ self.pid = pid
32
+ self._instance = instance
33
+
34
+ def wait(self, timeout: int | None = None) -> CommandResult:
35
+ """Wait for the process to finish and return its captured output.
36
+
37
+ Args:
38
+ timeout: Maximum number of seconds to wait. ``None`` waits without
39
+ a deadline.
40
+
41
+ Returns:
42
+ The process output and exit code. A timeout is reported with exit
43
+ code ``-1`` by the sandbox runtime.
44
+ """
45
+
46
+ rpc_timeout = (
47
+ -1
48
+ if timeout is None
49
+ else max(timeout + YR_GET_TIMEOUT_BUFFER, YR_GET_DEFAULT_TIMEOUT)
50
+ )
51
+ result = yr.get(
52
+ self._instance.cmd_wait.invoke(pid=self.pid, timeout=timeout),
53
+ timeout=rpc_timeout,
54
+ )
55
+ return CommandResult(
56
+ stdout=result["stdout"],
57
+ stderr=result["stderr"],
58
+ exit_code=result["exit_code"],
59
+ )
60
+
61
+ def kill(self) -> bool:
62
+ """Terminate the process and return whether it was found."""
63
+
64
+ result = yr.get(self._instance.cmd_kill.invoke(pid=self.pid))
65
+ return result["killed"]
66
+
67
+ def send_stdin(self, data: str, eof: bool = False) -> None:
68
+ """Write *data* to the process's stdin.
69
+
70
+ Args:
71
+ data: Text to write.
72
+ eof: Close stdin after writing so the process observes EOF.
73
+
74
+ Raises:
75
+ RuntimeError: The process was not started with stdin enabled or
76
+ the runtime rejected the write.
77
+ """
78
+ result = yr.get(
79
+ self._instance.cmd_send_stdin.invoke(pid=self.pid, data=data, eof=eof)
80
+ )
81
+ if result.get("error"):
82
+ raise RuntimeError(f"Failed to send stdin: {result['error']}")
83
+
84
+ def close_stdin(self) -> None:
85
+ """Close the process's stdin so it sees EOF."""
86
+ self.send_stdin("", eof=True)
87
+
88
+
89
+ class Commands:
90
+ """Client-side wrapper for command execution on the remote sandbox."""
91
+
92
+ def __init__(self, instance: Any) -> None:
93
+ self._instance = instance
94
+
95
+ @overload
96
+ def run(
97
+ self,
98
+ cmd: str,
99
+ background: Literal[False] = False,
100
+ envs: dict[str, str] | None = None,
101
+ cwd: str | None = None,
102
+ timeout: int = 60,
103
+ stdin: Literal[False] = False,
104
+ ) -> CommandResult: ...
105
+
106
+ @overload
107
+ def run(
108
+ self,
109
+ cmd: str,
110
+ background: Literal[True],
111
+ envs: dict[str, str] | None = None,
112
+ cwd: str | None = None,
113
+ timeout: int = 60,
114
+ stdin: bool = False,
115
+ ) -> CommandHandle: ...
116
+
117
+ def run(
118
+ self,
119
+ cmd: str,
120
+ background: bool = False,
121
+ envs: dict[str, str] | None = None,
122
+ cwd: str | None = None,
123
+ timeout: int = 60,
124
+ stdin: bool = False,
125
+ ) -> CommandResult | CommandHandle:
126
+ """Execute a command in the sandbox.
127
+
128
+ Args:
129
+ cmd: Shell command to execute.
130
+ background: Return a process handle without waiting when true.
131
+ envs: Environment variables for the command.
132
+ cwd: Working directory for the command.
133
+ timeout: Maximum foreground execution time in seconds.
134
+ stdin: Keep stdin open for a background process.
135
+
136
+ Returns:
137
+ A completed result for foreground execution or a process handle
138
+ for background execution.
139
+
140
+ Raises:
141
+ ValueError: ``stdin`` is requested for foreground execution.
142
+ RuntimeError: The runtime cannot start a background process.
143
+ """
144
+ if stdin and not background:
145
+ raise ValueError("stdin=True requires background=True")
146
+
147
+ if background:
148
+ result = yr.get(
149
+ self._instance.cmd_start.invoke(
150
+ cmd=cmd, envs=envs, cwd=cwd, want_stdin=stdin
151
+ )
152
+ )
153
+ if result.get("error"):
154
+ raise RuntimeError(f"Failed to start command: {result['error']}")
155
+ return CommandHandle(result["pid"], self._instance)
156
+
157
+ rpc_timeout = max(timeout + YR_GET_TIMEOUT_BUFFER, YR_GET_DEFAULT_TIMEOUT)
158
+ result = yr.get(
159
+ self._instance.cmd_run.invoke(cmd=cmd, envs=envs, cwd=cwd, timeout=timeout),
160
+ timeout=rpc_timeout,
161
+ )
162
+ return CommandResult(
163
+ stdout=result["stdout"],
164
+ stderr=result["stderr"],
165
+ exit_code=result["exit_code"],
166
+ )
167
+
168
+ def list(self) -> list[CommandInfo]:
169
+ """Return snapshots of processes tracked by this sandbox."""
170
+
171
+ result = yr.get(self._instance.cmd_list.invoke())
172
+ return [
173
+ CommandInfo(
174
+ pid=int(process["pid"]),
175
+ command=str(process["cmd"]),
176
+ running=bool(process["running"]),
177
+ )
178
+ for process in result["processes"]
179
+ ]
180
+
181
+ def kill(self, pid: int) -> bool:
182
+ """Terminate a tracked process and return whether it was found."""
183
+
184
+ result = yr.get(self._instance.cmd_kill.invoke(pid=pid))
185
+ return result["killed"]
186
+
187
+ def send_stdin(self, pid: int, data: str, eof: bool = False) -> None:
188
+ """Write *data* to the stdin of process *pid*.
189
+
190
+ Args:
191
+ pid: Tracked process ID.
192
+ data: Text to write.
193
+ eof: Close stdin after writing so the process observes EOF.
194
+
195
+ Raises:
196
+ RuntimeError: The process was not started with stdin enabled or
197
+ the runtime rejected the write.
198
+ """
199
+ result = yr.get(
200
+ self._instance.cmd_send_stdin.invoke(pid=pid, data=data, eof=eof)
201
+ )
202
+ if result.get("error"):
203
+ raise RuntimeError(f"Failed to send stdin: {result['error']}")
204
+
205
+ def close_stdin(self, pid: int) -> None:
206
+ """Close the stdin of process *pid* so it sees EOF."""
207
+ self.send_stdin(pid, "", eof=True)
@@ -0,0 +1,291 @@
1
+ # Copyright (c) 2026 Ant Group Corporation.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import asyncio
16
+ import os
17
+ import tempfile
18
+ from collections.abc import Coroutine
19
+ from concurrent.futures import ThreadPoolExecutor
20
+ from typing import Any, Literal, TypeVar, overload
21
+
22
+ import yr
23
+
24
+ from ._addresses import exec_endpoint_from_env
25
+ from .types import EntryInfo
26
+
27
+ _T = TypeVar("_T")
28
+
29
+
30
+ def _run_coroutine(coroutine: Coroutine[Any, Any, _T]) -> _T:
31
+ """Run a coroutine from a synchronous API in any caller context."""
32
+
33
+ try:
34
+ asyncio.get_running_loop()
35
+ except RuntimeError:
36
+ return asyncio.run(coroutine)
37
+
38
+ # asyncio.run() cannot execute in a thread that already owns a running
39
+ # event loop. Keep Filesystem's public API synchronous and isolate the
40
+ # underlying async WebSocket helper in a short-lived worker thread.
41
+ with ThreadPoolExecutor(max_workers=1) as executor:
42
+ return executor.submit(asyncio.run, coroutine).result()
43
+
44
+
45
+ class Filesystem:
46
+ """Client-side wrapper for filesystem operations on the remote sandbox."""
47
+
48
+ def __init__(
49
+ self,
50
+ instance: Any,
51
+ instance_id: str | None = None,
52
+ ) -> None:
53
+ self._instance = instance
54
+ self._instance_id = instance_id
55
+
56
+ @overload
57
+ def read(self, path: str, format: Literal["text"] = "text") -> str: ...
58
+
59
+ @overload
60
+ def read(self, path: str, format: Literal["bytes"]) -> bytes: ...
61
+
62
+ def read(self, path: str, format: str = "text") -> str | bytes:
63
+ """Read a file as text or bytes.
64
+
65
+ Args:
66
+ path: Absolute or relative path inside the sandbox.
67
+ format: Return decoded text or raw bytes.
68
+
69
+ Raises:
70
+ ValueError: ``format`` is not ``"text"`` or ``"bytes"``.
71
+ RuntimeError: The sandbox cannot read the file.
72
+ """
73
+
74
+ if format not in ("text", "bytes"):
75
+ raise ValueError("format must be 'text' or 'bytes'")
76
+ binary = format == "bytes"
77
+ result = yr.get(self._instance.fs_read.invoke(path=path, binary=binary))
78
+ if result.get("error"):
79
+ raise RuntimeError(f"Failed to read {path}: {result['error']}")
80
+ data = result["data"]
81
+ if binary:
82
+ return bytes.fromhex(data)
83
+ return data
84
+
85
+ # Binary data beyond this threshold is routed through copy_from_local
86
+ # (tar+WebSocket) instead of RPC hex encoding. Benchmark shows the
87
+ # crossover at ~16 MB where hex-encoded RPC starts to degrade while
88
+ # copy_from_local's fixed connection overhead is amortized.
89
+ _WRITE_SIZE_THRESHOLD = 16 * 1024 * 1024
90
+
91
+ def write(self, path: str, data: str | bytes) -> EntryInfo:
92
+ """Write text or bytes and return metadata for the remote file."""
93
+
94
+ if isinstance(data, bytes):
95
+ if len(data) >= self._WRITE_SIZE_THRESHOLD:
96
+ return self._write_via_copy(path, data)
97
+ binary = True
98
+ send_data = data.hex()
99
+ else:
100
+ binary = False
101
+ send_data = data
102
+ result = yr.get(
103
+ self._instance.fs_write.invoke(path=path, data=send_data, binary=binary)
104
+ )
105
+ if result.get("error"):
106
+ raise RuntimeError(f"Failed to write {path}: {result['error']}")
107
+ return EntryInfo(
108
+ name=result["name"],
109
+ path=result["path"],
110
+ type=result["type"],
111
+ size=result["size"],
112
+ permissions="",
113
+ modified_time=0.0,
114
+ )
115
+
116
+ def _write_via_copy(self, remote_path: str, data: bytes) -> EntryInfo:
117
+ """Write large binary data via copy_from_local to avoid hex bloat."""
118
+ tmp = tempfile.NamedTemporaryFile(delete=False)
119
+ try:
120
+ tmp.write(data)
121
+ tmp.close()
122
+ self.copy_from_local(tmp.name, remote_path)
123
+ finally:
124
+ os.unlink(tmp.name)
125
+ return EntryInfo(
126
+ name=os.path.basename(remote_path),
127
+ path=remote_path,
128
+ type="file",
129
+ size=len(data),
130
+ permissions="",
131
+ modified_time=0.0,
132
+ )
133
+
134
+ def list(self, path: str, depth: int = 1) -> list[EntryInfo]:
135
+ """List entries below a sandbox path up to ``depth`` levels."""
136
+
137
+ result = yr.get(self._instance.fs_list.invoke(path=path, depth=depth))
138
+ if result.get("error"):
139
+ raise RuntimeError(f"Failed to list {path}: {result['error']}")
140
+ return [
141
+ EntryInfo(
142
+ name=e["name"],
143
+ path=e["path"],
144
+ type=e["type"],
145
+ size=e["size"],
146
+ permissions=e["permissions"],
147
+ modified_time=e["modified_time"],
148
+ )
149
+ for e in result["entries"]
150
+ ]
151
+
152
+ def exists(self, path: str) -> bool:
153
+ """Return whether a sandbox path exists."""
154
+
155
+ result = yr.get(self._instance.fs_exists.invoke(path=path))
156
+ return result["exists"]
157
+
158
+ def _get_connection(self) -> tuple[str, str, str, str, bool]:
159
+ # File copy is implemented by the frontend exec WebSocket
160
+ # (/terminal/ws). By default it follows AKERNEL_SERVER_ADDRESS and
161
+ # uses TLS; standalone or custom topologies can override it with
162
+ # AKERNEL_GATEWAY_ADDRESS/YR_GATEWAY_ADDRESS.
163
+ endpoint = exec_endpoint_from_env()
164
+ instance_id = self._instance_id
165
+ if not instance_id:
166
+ raise RuntimeError("Instance ID is not set.")
167
+ from yr.config_manager import ConfigManager
168
+
169
+ token = ConfigManager().auth_token
170
+ return endpoint.host, str(endpoint.port), instance_id, token, endpoint.use_tls
171
+
172
+ def _cp(self, src: str, dst: str, upload: bool) -> None:
173
+ from yr.cli.exec import (
174
+ choose_cp_mode,
175
+ copy_from_remote,
176
+ copy_from_remote_streaming,
177
+ copy_to_remote,
178
+ copy_to_remote_streaming,
179
+ )
180
+
181
+ host, port, instance_id, token, use_ssl = self._get_connection()
182
+ local_path = src if upload else dst
183
+ remote_path = dst if upload else src
184
+
185
+ if upload and not os.path.exists(local_path):
186
+ raise FileNotFoundError(f"Local source path not found: {local_path}")
187
+
188
+ streaming = choose_cp_mode(local_path, remote_path, upload=upload)
189
+
190
+ if upload:
191
+ fn = copy_to_remote_streaming if streaming else copy_to_remote
192
+ _run_coroutine(
193
+ fn(
194
+ host=host,
195
+ port=port,
196
+ instance=instance_id,
197
+ local_path=local_path,
198
+ remote_path=remote_path,
199
+ use_ssl=use_ssl,
200
+ verify_server=False,
201
+ token=token,
202
+ )
203
+ )
204
+ else:
205
+ fn = copy_from_remote_streaming if streaming else copy_from_remote
206
+ _run_coroutine(
207
+ fn(
208
+ host=host,
209
+ port=port,
210
+ instance=instance_id,
211
+ remote_path=remote_path,
212
+ local_path=local_path,
213
+ use_ssl=use_ssl,
214
+ verify_server=False,
215
+ token=token,
216
+ )
217
+ )
218
+
219
+ def copy_from_local(self, local_path: str, remote_path: str) -> None:
220
+ """Copy a local file or directory **into** the sandbox.
221
+
222
+ Args:
223
+ local_path: Absolute or relative path on the **local** machine.
224
+ remote_path: Absolute path inside the **sandbox**.
225
+
226
+ Raises:
227
+ FileNotFoundError: *local_path* does not exist.
228
+ RuntimeError: Server address is not configured.
229
+ """
230
+ self._cp(local_path, remote_path, upload=True)
231
+
232
+ def copy_to_local(self, remote_path: str, local_path: str) -> None:
233
+ """Copy a file or directory **from** the sandbox to the local machine.
234
+
235
+ Args:
236
+ remote_path: Absolute path inside the **sandbox**.
237
+ local_path: Absolute or relative path on the **local** machine.
238
+
239
+ Raises:
240
+ RuntimeError: Server address is not configured.
241
+ """
242
+ self._cp(remote_path, local_path, upload=False)
243
+
244
+ def remove(self, path: str) -> None:
245
+ """Remove a file or directory from the sandbox."""
246
+
247
+ result = yr.get(self._instance.fs_remove.invoke(path=path))
248
+ if result.get("error"):
249
+ raise RuntimeError(f"Failed to remove {path}: {result['error']}")
250
+
251
+ def rename(self, old_path: str, new_path: str) -> EntryInfo:
252
+ """Rename a sandbox path and return its updated metadata."""
253
+
254
+ result = yr.get(
255
+ self._instance.fs_rename.invoke(old_path=old_path, new_path=new_path)
256
+ )
257
+ if result.get("error"):
258
+ raise RuntimeError(
259
+ f"Failed to rename {old_path} -> {new_path}: {result['error']}"
260
+ )
261
+ return EntryInfo(
262
+ name=result["name"],
263
+ path=result["path"],
264
+ type=result["type"],
265
+ size=result["size"],
266
+ permissions=result["permissions"],
267
+ modified_time=result["modified_time"],
268
+ )
269
+
270
+ def make_dir(self, path: str) -> bool:
271
+ """Create a directory and return whether it was newly created."""
272
+
273
+ result = yr.get(self._instance.fs_make_dir.invoke(path=path))
274
+ if result.get("error"):
275
+ raise RuntimeError(f"Failed to make dir {path}: {result['error']}")
276
+ return result["created"]
277
+
278
+ def get_info(self, path: str) -> EntryInfo:
279
+ """Return metadata for a sandbox path."""
280
+
281
+ result = yr.get(self._instance.fs_get_info.invoke(path=path))
282
+ if result.get("error"):
283
+ raise RuntimeError(f"Failed to get info for {path}: {result['error']}")
284
+ return EntryInfo(
285
+ name=result["name"],
286
+ path=result["path"],
287
+ type=result["type"],
288
+ size=result["size"],
289
+ permissions=result["permissions"],
290
+ modified_time=result["modified_time"],
291
+ )