runtime-sdk 0.4.49__py3-none-win_amd64.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.
runtime_sdk/config.py ADDED
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import json
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ from contextlib import contextmanager
10
+ from dataclasses import asdict, dataclass, field
11
+ from pathlib import Path
12
+ from typing import Iterator, TextIO
13
+
14
+
15
+ DEFAULT_BASE_URL = "https://api.runruntime.dev"
16
+
17
+
18
+ class RuntimeConfigError(RuntimeError):
19
+ pass
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class WorkOSSession:
24
+ access_token: str
25
+ refresh_token: str
26
+ organization_id: str
27
+ session_id: str
28
+
29
+ def __post_init__(self) -> None:
30
+ for field_name in (
31
+ "access_token",
32
+ "refresh_token",
33
+ "organization_id",
34
+ "session_id",
35
+ ):
36
+ value = getattr(self, field_name, None)
37
+ if not isinstance(value, str) or not value.strip():
38
+ raise ValueError(f"session {field_name} is required")
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class RuntimeConfig:
43
+ base_url: str = DEFAULT_BASE_URL
44
+ configured_base_url: str | None = None
45
+ session: WorkOSSession | None = None
46
+ api_key: str | None = None
47
+ _tracks_saved_credentials: bool = field(
48
+ default=False, init=False, repr=False, compare=False
49
+ )
50
+
51
+ def __post_init__(self) -> None:
52
+ if self.session is not None and self.api_key is not None:
53
+ raise ValueError("config cannot contain both a WorkOS session and an API key")
54
+
55
+
56
+ def config_path() -> Path:
57
+ root = os.environ.get("XDG_CONFIG_HOME")
58
+ if root:
59
+ return Path(root) / "runtime" / "config.json"
60
+ if sys.platform == "win32":
61
+ root = os.environ.get("APPDATA")
62
+ if root:
63
+ return Path(root) / "Runtime" / "config.json"
64
+ return Path.home() / ".config" / "runtime" / "config.json"
65
+
66
+
67
+ def load_config() -> RuntimeConfig:
68
+ path = config_path()
69
+ if not path.exists():
70
+ config = RuntimeConfig()
71
+ config._tracks_saved_credentials = True
72
+ return config
73
+
74
+ try:
75
+ data = json.loads(path.read_text(encoding="utf-8"))
76
+ except OSError as exc:
77
+ raise RuntimeConfigError(f"failed to read config: {exc}") from exc
78
+ except ValueError as exc:
79
+ raise RuntimeConfigError("config file contains invalid JSON") from exc
80
+ if not isinstance(data, dict):
81
+ raise RuntimeConfigError("config file must contain a JSON object")
82
+
83
+ configured_base_url = data.get("base_url") or None
84
+ session = data.get("session")
85
+ try:
86
+ config = RuntimeConfig(
87
+ base_url=configured_base_url or DEFAULT_BASE_URL,
88
+ configured_base_url=configured_base_url,
89
+ session=WorkOSSession(**session) if session is not None else None,
90
+ api_key=_optional_string(data.get("api_key")),
91
+ )
92
+ config._tracks_saved_credentials = True
93
+ return config
94
+ except (TypeError, ValueError) as exc:
95
+ raise RuntimeConfigError(f"config file has an invalid shape: {exc}") from exc
96
+
97
+
98
+ def save_config(config: RuntimeConfig) -> None:
99
+ with config_lock():
100
+ _save_config_unlocked(config)
101
+ config._tracks_saved_credentials = True
102
+
103
+
104
+ @contextmanager
105
+ def config_lock() -> Iterator[None]:
106
+ path = config_path()
107
+ try:
108
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
109
+ _set_private_permissions(path.parent, 0o700)
110
+ with path.with_suffix(".lock").open("a+", encoding="utf-8") as lock:
111
+ _set_private_permissions(Path(lock.name), 0o600)
112
+ with _exclusive_file_lock(lock):
113
+ yield
114
+ except OSError as exc:
115
+ raise RuntimeConfigError(f"failed to lock config: {exc}") from exc
116
+
117
+
118
+ def _save_config_unlocked(config: RuntimeConfig) -> None:
119
+ path = config_path()
120
+ payload: dict[str, object] = {
121
+ "session": asdict(config.session) if config.session else None,
122
+ "api_key": config.api_key,
123
+ }
124
+ if config.configured_base_url is not None:
125
+ payload["base_url"] = config.configured_base_url
126
+
127
+ temporary_path: str | None = None
128
+ try:
129
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
130
+ _set_private_permissions(path.parent, 0o700)
131
+ fd, temporary_path = tempfile.mkstemp(prefix=".config-", dir=path.parent)
132
+ if sys.platform != "win32":
133
+ os.fchmod(fd, 0o600)
134
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
135
+ json.dump(payload, file, indent=2)
136
+ file.write("\n")
137
+ file.flush()
138
+ os.fsync(file.fileno())
139
+ os.replace(temporary_path, path)
140
+ temporary_path = None
141
+ if sys.platform != "win32":
142
+ directory_fd = os.open(path.parent, os.O_RDONLY)
143
+ try:
144
+ os.fsync(directory_fd)
145
+ finally:
146
+ os.close(directory_fd)
147
+ except OSError as exc:
148
+ raise RuntimeConfigError(f"failed to write config: {exc}") from exc
149
+ finally:
150
+ if temporary_path is not None:
151
+ try:
152
+ os.unlink(temporary_path)
153
+ except FileNotFoundError:
154
+ pass
155
+
156
+
157
+ @contextmanager
158
+ def _exclusive_file_lock(lock: TextIO) -> Iterator[None]:
159
+ if sys.platform != "win32":
160
+ import fcntl
161
+
162
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
163
+ try:
164
+ yield
165
+ finally:
166
+ fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
167
+ return
168
+
169
+ import msvcrt
170
+
171
+ lock.seek(0, os.SEEK_END)
172
+ if lock.tell() == 0:
173
+ lock.write("\0")
174
+ lock.flush()
175
+ lock.seek(0)
176
+ while True:
177
+ try:
178
+ msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
179
+ break
180
+ except OSError as exc:
181
+ if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
182
+ raise
183
+ time.sleep(0.05)
184
+ try:
185
+ yield
186
+ finally:
187
+ lock.seek(0)
188
+ msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1)
189
+
190
+
191
+ def _set_private_permissions(path: Path, mode: int) -> None:
192
+ if sys.platform != "win32":
193
+ os.chmod(path, mode)
194
+
195
+
196
+ def _optional_string(value: object) -> str | None:
197
+ if value is None:
198
+ return None
199
+ if not isinstance(value, str) or not value.strip():
200
+ raise ValueError("api_key must be a non-empty string")
201
+ return value
runtime_sdk/console.py ADDED
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import sys
6
+ import time
7
+ from collections.abc import Callable, Iterator
8
+
9
+ from runtime_sdk.client import RuntimeAPIError
10
+
11
+
12
+ @contextlib.contextmanager
13
+ def raw_terminal_input(fd: int) -> Iterator[Callable[[float], bytes | None]]:
14
+ if sys.platform == "win32":
15
+ with _windows_raw_terminal_input(fd) as read:
16
+ yield read
17
+ return
18
+ with _posix_raw_terminal_input(fd) as read:
19
+ yield read
20
+
21
+
22
+ @contextlib.contextmanager
23
+ def _posix_raw_terminal_input(
24
+ fd: int,
25
+ ) -> Iterator[Callable[[float], bytes | None]]:
26
+ try:
27
+ import select
28
+ import termios
29
+ import tty
30
+ except ModuleNotFoundError as exc:
31
+ raise RuntimeAPIError(
32
+ "interactive console is not supported on this platform"
33
+ ) from exc
34
+
35
+ old_settings = termios.tcgetattr(fd)
36
+ tty.setraw(fd)
37
+ try:
38
+ def read(timeout: float) -> bytes | None:
39
+ readable, _, _ = select.select([fd], [], [], timeout)
40
+ return os.read(fd, 1024) if fd in readable else None
41
+
42
+ yield read
43
+ finally:
44
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
45
+
46
+
47
+ @contextlib.contextmanager
48
+ def _windows_raw_terminal_input(
49
+ fd: int,
50
+ ) -> Iterator[Callable[[float], bytes | None]]:
51
+ import ctypes
52
+ import msvcrt
53
+ from ctypes import wintypes
54
+
55
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
56
+ kernel32.GetConsoleMode.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
57
+ kernel32.GetConsoleMode.restype = wintypes.BOOL
58
+ kernel32.SetConsoleMode.argtypes = [wintypes.HANDLE, wintypes.DWORD]
59
+ kernel32.SetConsoleMode.restype = wintypes.BOOL
60
+ input_handle = msvcrt.get_osfhandle(fd)
61
+ output_handle = msvcrt.get_osfhandle(sys.stdout.fileno())
62
+ input_mode = wintypes.DWORD()
63
+ output_mode = wintypes.DWORD()
64
+ if not kernel32.GetConsoleMode(input_handle, ctypes.byref(input_mode)):
65
+ raise RuntimeAPIError("failed to read Windows console mode")
66
+ if not kernel32.GetConsoleMode(output_handle, ctypes.byref(output_mode)):
67
+ raise RuntimeAPIError("failed to read Windows console output mode")
68
+
69
+ enable_virtual_terminal_input = 0x0200
70
+ enable_virtual_terminal_processing = 0x0004
71
+ cooked_input_flags = 0x0001 | 0x0002 | 0x0004
72
+ raw_input_mode = (input_mode.value & ~cooked_input_flags) | enable_virtual_terminal_input
73
+ raw_output_mode = output_mode.value | enable_virtual_terminal_processing
74
+ if not kernel32.SetConsoleMode(input_handle, raw_input_mode):
75
+ raise RuntimeAPIError("failed to enable Windows console input")
76
+ if not kernel32.SetConsoleMode(output_handle, raw_output_mode):
77
+ kernel32.SetConsoleMode(input_handle, input_mode.value)
78
+ raise RuntimeAPIError("failed to enable Windows terminal output")
79
+
80
+ try:
81
+ yield lambda timeout: _read_windows_input(msvcrt, timeout)
82
+ finally:
83
+ kernel32.SetConsoleMode(input_handle, input_mode.value)
84
+ kernel32.SetConsoleMode(output_handle, output_mode.value)
85
+
86
+
87
+ _WINDOWS_SPECIAL_KEYS = {
88
+ "H": b"\x1b[A",
89
+ "P": b"\x1b[B",
90
+ "M": b"\x1b[C",
91
+ "K": b"\x1b[D",
92
+ "G": b"\x1b[H",
93
+ "O": b"\x1b[F",
94
+ "R": b"\x1b[2~",
95
+ "S": b"\x1b[3~",
96
+ "I": b"\x1b[5~",
97
+ "Q": b"\x1b[6~",
98
+ }
99
+
100
+
101
+ def _read_windows_input(msvcrt: object, timeout: float) -> bytes | None:
102
+ deadline = time.monotonic() + timeout
103
+ while not msvcrt.kbhit():
104
+ if time.monotonic() >= deadline:
105
+ return None
106
+ time.sleep(min(0.01, max(0.0, deadline - time.monotonic())))
107
+
108
+ data = bytearray()
109
+ while msvcrt.kbhit():
110
+ char = msvcrt.getwch()
111
+ if char in ("\0", "\xe0"):
112
+ data.extend(_WINDOWS_SPECIAL_KEYS.get(msvcrt.getwch(), b""))
113
+ continue
114
+ if "\ud800" <= char <= "\udbff" and msvcrt.kbhit():
115
+ char += msvcrt.getwch()
116
+ char = char.encode("utf-16", errors="surrogatepass").decode("utf-16")
117
+ data.extend(char.encode("utf-8", errors="replace"))
118
+ return bytes(data)
runtime_sdk/files.py ADDED
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import tarfile
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from runtime_sdk import computers, output as cli_output
10
+ from runtime_sdk.auth import authenticated_client
11
+ from runtime_sdk.client import RuntimeAPIError, RuntimeClient
12
+ from runtime_sdk.config import RuntimeConfig
13
+
14
+
15
+ def handle_files(config: RuntimeConfig, args: Any) -> int:
16
+ action = str(getattr(args, "files_action", "") or "")
17
+ if action == "read":
18
+ return handle_files_read(config, args.id, args.path, args.output)
19
+ if action == "write":
20
+ return handle_files_write(config, args)
21
+ if action in ("list", "ls"):
22
+ return handle_files_list(config, args.id, args.path)
23
+ if action == "stat":
24
+ return handle_files_stat(config, args.id, args.path)
25
+ if action == "mkdir":
26
+ return handle_files_mkdir(config, args.id, args.path, args.parents, args.mode)
27
+ if action in ("rm", "delete"):
28
+ return handle_files_rm(config, args.id, args.path, args.recursive)
29
+ if action == "upload":
30
+ return handle_files_upload(config, args.id, args.local, args.remote)
31
+ if action == "download":
32
+ return handle_files_download(config, args.id, args.remote, args.local)
33
+ return cli_output.report_error("unknown files command")
34
+
35
+
36
+ def _resolve_file_target(
37
+ config: RuntimeConfig, computer_id: str | None, path: str | None
38
+ ) -> tuple[RuntimeClient, str, str] | int:
39
+ if (err := cli_output.require_auth(config)) is not None:
40
+ return err
41
+ if not computer_id:
42
+ return cli_output.report_error("computer id is required")
43
+ if not path:
44
+ return cli_output.report_error("path is required")
45
+ client = authenticated_client(config)
46
+ resolved_id = computers.resolve_computer_ref(client, computer_id)
47
+ return client, resolved_id, path
48
+
49
+
50
+ def handle_files_read(
51
+ config: RuntimeConfig, computer_id: str | None, path: str | None, output: str | None
52
+ ) -> int:
53
+ resolved = _resolve_file_target(config, computer_id, path)
54
+ if isinstance(resolved, int):
55
+ return resolved
56
+ client, resolved_id, resolved_path = resolved
57
+ data = client.read_file(resolved_id, resolved_path)
58
+ if output:
59
+ try:
60
+ Path(output).write_bytes(data)
61
+ except OSError as exc:
62
+ return cli_output.report_error(f"failed to write output file: {exc}")
63
+ return cli_output.report_success(
64
+ {"path": resolved_path, "output": output, "size": len(data)}
65
+ )
66
+ stdout_buffer = getattr(sys.stdout, "buffer", None)
67
+ if stdout_buffer is None:
68
+ sys.stdout.write(data.decode(errors="replace"))
69
+ sys.stdout.flush()
70
+ return 0
71
+ stdout_buffer.write(data)
72
+ stdout_buffer.flush()
73
+ return 0
74
+
75
+
76
+ def handle_files_write(config: RuntimeConfig, args: Any) -> int:
77
+ input_path = getattr(args, "input", None)
78
+ content = getattr(args, "content", None)
79
+ if input_path and content is not None:
80
+ return cli_output.report_error("pass either --input or --content, not both")
81
+ resolved = _resolve_file_target(config, args.id, args.path)
82
+ if isinstance(resolved, int):
83
+ return resolved
84
+ client, resolved_id, resolved_path = resolved
85
+ if input_path:
86
+ try:
87
+ data = Path(input_path).read_bytes()
88
+ except OSError as exc:
89
+ return cli_output.report_error(f"failed to read input file: {exc}")
90
+ elif content is not None:
91
+ data = content.encode()
92
+ else:
93
+ stdin_buffer = getattr(sys.stdin, "buffer", None)
94
+ data = (
95
+ stdin_buffer.read()
96
+ if stdin_buffer is not None
97
+ else sys.stdin.read().encode()
98
+ )
99
+ result = client.write_file(
100
+ resolved_id, resolved_path, data, uid=args.uid, gid=args.gid, mode=args.mode
101
+ )
102
+ return cli_output.report_success(result)
103
+
104
+
105
+ def handle_files_list(
106
+ config: RuntimeConfig, computer_id: str | None, path: str | None
107
+ ) -> int:
108
+ resolved = _resolve_file_target(config, computer_id, path or "/home/runtime")
109
+ if isinstance(resolved, int):
110
+ return resolved
111
+ client, resolved_id, resolved_path = resolved
112
+ return cli_output.report_success(client.list_files(resolved_id, resolved_path))
113
+
114
+
115
+ def handle_files_stat(
116
+ config: RuntimeConfig, computer_id: str | None, path: str | None
117
+ ) -> int:
118
+ resolved = _resolve_file_target(config, computer_id, path)
119
+ if isinstance(resolved, int):
120
+ return resolved
121
+ client, resolved_id, resolved_path = resolved
122
+ return cli_output.report_success(client.stat_file(resolved_id, resolved_path))
123
+
124
+
125
+ def handle_files_mkdir(
126
+ config: RuntimeConfig,
127
+ computer_id: str | None,
128
+ path: str | None,
129
+ parents: bool,
130
+ mode: str | None,
131
+ ) -> int:
132
+ resolved = _resolve_file_target(config, computer_id, path)
133
+ if isinstance(resolved, int):
134
+ return resolved
135
+ client, resolved_id, resolved_path = resolved
136
+ result = client.mkdir(resolved_id, resolved_path, recursive=parents, mode=mode)
137
+ return cli_output.report_success(result)
138
+
139
+
140
+ def handle_files_rm(
141
+ config: RuntimeConfig, computer_id: str | None, path: str | None, recursive: bool
142
+ ) -> int:
143
+ resolved = _resolve_file_target(config, computer_id, path)
144
+ if isinstance(resolved, int):
145
+ return resolved
146
+ client, resolved_id, resolved_path = resolved
147
+ result = client.delete_file(resolved_id, resolved_path, recursive=recursive)
148
+ return cli_output.report_success(result)
149
+
150
+
151
+ def handle_files_upload(
152
+ config: RuntimeConfig,
153
+ computer_id: str | None,
154
+ local: str | None,
155
+ remote: str | None,
156
+ ) -> int:
157
+ if not local:
158
+ return cli_output.report_error("local path is required")
159
+ local_path = Path(local)
160
+ if not local_path.exists():
161
+ return cli_output.report_error(f"local path does not exist: {local}")
162
+ resolved = _resolve_file_target(config, computer_id, remote)
163
+ if isinstance(resolved, int):
164
+ return resolved
165
+ client, resolved_id, remote_path = resolved
166
+ with tempfile.TemporaryFile() as archive:
167
+ try:
168
+ with tarfile.open(fileobj=archive, mode="w") as tar:
169
+ if local_path.is_dir():
170
+ for child in sorted(local_path.rglob("*")):
171
+ tar.add(
172
+ child,
173
+ arcname=str(child.relative_to(local_path)),
174
+ recursive=False,
175
+ )
176
+ else:
177
+ tar.add(local_path, arcname=local_path.name, recursive=False)
178
+ except (OSError, tarfile.TarError) as exc:
179
+ return cli_output.report_error(f"failed to read local path: {exc}")
180
+ archive.seek(0)
181
+ result = client.upload_files(resolved_id, remote_path, archive)
182
+ return cli_output.report_success(result)
183
+
184
+
185
+ def handle_files_download(
186
+ config: RuntimeConfig,
187
+ computer_id: str | None,
188
+ remote: str | None,
189
+ local: str | None,
190
+ ) -> int:
191
+ local_dir = Path(local or ".")
192
+ resolved = _resolve_file_target(config, computer_id, remote)
193
+ if isinstance(resolved, int):
194
+ return resolved
195
+ client, resolved_id, remote_path = resolved
196
+ try:
197
+ local_dir.mkdir(parents=True, exist_ok=True)
198
+ with tempfile.TemporaryFile() as archive:
199
+ client.download_files_to(resolved_id, remote_path, archive)
200
+ archive.seek(0)
201
+ with tarfile.open(fileobj=archive, mode="r") as tar:
202
+ members = tar.getmembers()
203
+ for member in members:
204
+ target = safe_local_extract_path(local_dir, member.name)
205
+ if member.isdir():
206
+ target.mkdir(parents=True, exist_ok=True)
207
+ continue
208
+ if not member.isfile():
209
+ return cli_output.report_error(
210
+ f"unsupported archive entry: {member.name}"
211
+ )
212
+ target.parent.mkdir(parents=True, exist_ok=True)
213
+ source = tar.extractfile(member)
214
+ if source is None:
215
+ return cli_output.report_error(
216
+ f"missing archive data: {member.name}"
217
+ )
218
+ target.write_bytes(source.read())
219
+ except (OSError, tarfile.TarError) as exc:
220
+ return cli_output.report_error(f"failed to write downloaded files: {exc}")
221
+ return cli_output.report_success(
222
+ {"path": remote_path, "output": str(local_dir), "entries": len(members)}
223
+ )
224
+
225
+
226
+ def safe_local_extract_path(root: Path, name: str) -> Path:
227
+ if not name or Path(name).is_absolute():
228
+ raise RuntimeAPIError(f"unsafe archive entry: {name}")
229
+ target = (root / name).resolve()
230
+ root_resolved = root.resolve()
231
+ if target != root_resolved and root_resolved not in target.parents:
232
+ raise RuntimeAPIError(f"unsafe archive entry: {name}")
233
+ return target