relayctl 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.
relayctl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import TypedDict
10
+
11
+
12
+ class ShellEnvEntryPayload(TypedDict):
13
+ name: str
14
+ value: str
15
+
16
+
17
+ class StagedPathPayload(TypedDict):
18
+ slot: int
19
+ path: str
20
+
21
+
22
+ class _RemoteRunnerManifestOptionalPayload(TypedDict, total=False):
23
+ mode: str
24
+ shell_text: str
25
+ shell_env: list[ShellEnvEntryPayload]
26
+ pullback_report_path: str
27
+ staged_paths: list[StagedPathPayload]
28
+
29
+
30
+ class RemoteRunnerManifestPayload(_RemoteRunnerManifestOptionalPayload):
31
+ argv: list[str]
32
+ cwd: str
33
+
34
+
35
+ class PullbackStatePayload(TypedDict):
36
+ slot: int
37
+ changed: bool
38
+ exists: bool
39
+ is_dir: bool
40
+
41
+
42
+ class PullbackReportPayload(TypedDict):
43
+ staged: list[PullbackStatePayload]
44
+
45
+
46
+ REMOTE_RUNNER_TIMEOUT_SECONDS = 3600.0
47
+
48
+
49
+ def _expand_home(value: str) -> str:
50
+ if value.startswith("~/"):
51
+ return os.path.expanduser(value)
52
+ return value
53
+
54
+
55
+ def main(argv: list[str] | None = None) -> int:
56
+ args = sys.argv[1:] if argv is None else argv
57
+ if len(args) != 1:
58
+ raise SystemExit("Usage: remote-runner.py <manifest-path>")
59
+
60
+ manifest_path = Path(args[0]).expanduser()
61
+ try:
62
+ manifest = _parse_remote_runner_manifest(
63
+ json.loads(manifest_path.read_text(encoding="utf-8"))
64
+ )
65
+ except ValueError as exc:
66
+ raise SystemExit(str(exc)) from exc
67
+
68
+ manifest_cwd = _expand_home(manifest["cwd"])
69
+ mode = manifest.get("mode", "argv")
70
+ shell_text = manifest.get("shell_text")
71
+
72
+ report_path_raw = manifest.get("pullback_report_path")
73
+ report_path = (
74
+ Path(_expand_home(report_path_raw)) if report_path_raw is not None else None
75
+ )
76
+
77
+ staged_paths = [
78
+ (entry["slot"], Path(_expand_home(entry["path"])))
79
+ for entry in manifest.get("staged_paths", [])
80
+ ]
81
+
82
+ shell_env = [
83
+ (entry["name"], _expand_home(entry["value"]))
84
+ for entry in manifest.get("shell_env", [])
85
+ ]
86
+
87
+ staged_before = {slot: _snapshot_any(path) for slot, path in staged_paths}
88
+
89
+ completed = _run_manifest_command(
90
+ mode=mode,
91
+ manifest_cwd=manifest_cwd,
92
+ shell_text=shell_text,
93
+ shell_env=shell_env,
94
+ argv=tuple(manifest["argv"]),
95
+ )
96
+
97
+ if report_path is not None:
98
+ staged_entries: list[PullbackStatePayload] = []
99
+ for slot, stage_path in staged_paths:
100
+ after_snapshot = _snapshot_any(stage_path)
101
+ before_snapshot = staged_before.get(slot, {"kind": "missing"})
102
+ staged_entries.append(
103
+ PullbackStatePayload(
104
+ slot=slot,
105
+ changed=before_snapshot != after_snapshot,
106
+ exists=after_snapshot.get("kind") != "missing",
107
+ is_dir=after_snapshot.get("kind") == "dir",
108
+ )
109
+ )
110
+ report_payload = PullbackReportPayload(staged=staged_entries)
111
+ report_path.parent.mkdir(parents=True, exist_ok=True)
112
+ report_path.write_text(
113
+ json.dumps(report_payload, sort_keys=True) + "\n",
114
+ encoding="utf-8",
115
+ )
116
+
117
+ return completed.returncode
118
+
119
+
120
+ def _parse_remote_runner_manifest(payload: object) -> RemoteRunnerManifestPayload:
121
+ if not isinstance(payload, dict):
122
+ raise ValueError("remote runner manifest payload must be an object")
123
+ cwd = payload.get("cwd")
124
+ if not isinstance(cwd, str):
125
+ raise ValueError("Invalid manifest cwd payload")
126
+
127
+ mode = payload.get("mode", "argv")
128
+ if not isinstance(mode, str):
129
+ raise ValueError("Invalid manifest mode payload")
130
+
131
+ shell_text = payload.get("shell_text")
132
+ if shell_text is not None and not isinstance(shell_text, str):
133
+ raise ValueError("Invalid shell manifest payload")
134
+
135
+ argv = payload.get("argv", [])
136
+ if not isinstance(argv, list) or not all(isinstance(token, str) for token in argv):
137
+ raise ValueError("Invalid argv manifest payload")
138
+
139
+ shell_env_raw = payload.get("shell_env", [])
140
+ if not isinstance(shell_env_raw, list):
141
+ raise ValueError("Invalid shell environment payload")
142
+ shell_env: list[ShellEnvEntryPayload] = []
143
+ for entry in shell_env_raw:
144
+ if not isinstance(entry, dict):
145
+ raise ValueError("Invalid shell environment payload")
146
+ name = entry.get("name")
147
+ value = entry.get("value")
148
+ if not isinstance(name, str) or not isinstance(value, str):
149
+ raise ValueError("Invalid shell environment payload")
150
+ shell_env.append(ShellEnvEntryPayload(name=name, value=value))
151
+
152
+ pullback_report_path = payload.get("pullback_report_path")
153
+ if pullback_report_path is not None and not isinstance(pullback_report_path, str):
154
+ raise ValueError("Invalid pullback report payload")
155
+
156
+ staged_paths_raw = payload.get("staged_paths", [])
157
+ if not isinstance(staged_paths_raw, list):
158
+ raise ValueError("Invalid staged paths payload")
159
+ staged_paths: list[StagedPathPayload] = []
160
+ for entry in staged_paths_raw:
161
+ if not isinstance(entry, dict):
162
+ raise ValueError("Invalid staged paths payload")
163
+ slot = entry.get("slot")
164
+ path = entry.get("path")
165
+ if type(slot) is not int or not isinstance(path, str):
166
+ raise ValueError("Invalid staged paths payload")
167
+ staged_paths.append(StagedPathPayload(slot=slot, path=path))
168
+
169
+ manifest = RemoteRunnerManifestPayload(argv=list(argv), cwd=cwd)
170
+ if mode != "argv":
171
+ manifest["mode"] = mode
172
+ if shell_text is not None:
173
+ manifest["shell_text"] = shell_text
174
+ if shell_env:
175
+ manifest["shell_env"] = shell_env
176
+ if pullback_report_path is not None:
177
+ manifest["pullback_report_path"] = pullback_report_path
178
+ if staged_paths:
179
+ manifest["staged_paths"] = staged_paths
180
+ return manifest
181
+
182
+
183
+ def _run_manifest_command(
184
+ *,
185
+ mode: object,
186
+ manifest_cwd: str,
187
+ shell_text: object,
188
+ shell_env: list[tuple[str, str]],
189
+ argv: tuple[str, ...],
190
+ ) -> subprocess.CompletedProcess[bytes] | subprocess.CompletedProcess[str]:
191
+ env = os.environ.copy()
192
+ for name, value in shell_env:
193
+ env[name] = value
194
+
195
+ if mode == "shell":
196
+ if not isinstance(shell_text, str):
197
+ raise SystemExit("Invalid shell manifest payload")
198
+ return subprocess.run(
199
+ ["/bin/sh", "-c", shell_text],
200
+ cwd=manifest_cwd,
201
+ env=env,
202
+ check=False,
203
+ timeout=REMOTE_RUNNER_TIMEOUT_SECONDS,
204
+ )
205
+
206
+ manifest_argv = [_expand_home(token) for token in argv]
207
+ return subprocess.run(
208
+ manifest_argv,
209
+ cwd=manifest_cwd,
210
+ env=env,
211
+ check=False,
212
+ timeout=REMOTE_RUNNER_TIMEOUT_SECONDS,
213
+ )
214
+
215
+
216
+ def _snapshot_any(path: Path) -> dict[str, object]:
217
+ if not path.exists():
218
+ return {"kind": "missing"}
219
+ if path.is_file():
220
+ return {"kind": "file", "digest": _hash_file(path)}
221
+ if path.is_dir():
222
+ return {"kind": "dir", "entries": _snapshot_tree(path)}
223
+ return {"kind": "other"}
224
+
225
+
226
+ def _snapshot_tree(path: Path | None) -> dict[str, str]:
227
+ if path is None or not path.exists() or not path.is_dir():
228
+ return {}
229
+
230
+ snapshot: dict[str, str] = {}
231
+ for child in sorted(path.rglob("*"), key=lambda value: value.as_posix()):
232
+ rel = child.relative_to(path).as_posix()
233
+ if child.is_file():
234
+ snapshot[rel] = f"file:{_hash_file(child)}"
235
+ elif child.is_dir():
236
+ snapshot[rel] = "dir"
237
+ else:
238
+ snapshot[rel] = "other"
239
+ return snapshot
240
+
241
+
242
+ def _hash_file(path: Path) -> str:
243
+ digest = hashlib.sha256()
244
+ with path.open("rb") as handle:
245
+ while True:
246
+ chunk = handle.read(8192)
247
+ if not chunk:
248
+ break
249
+ digest.update(chunk)
250
+ return digest.hexdigest()
251
+
252
+
253
+ if __name__ == "__main__":
254
+ raise SystemExit(main())
relayctl/argv_paths.py ADDED
@@ -0,0 +1,300 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ from pathlib import Path, PurePosixPath
6
+ import re
7
+
8
+ from relayctl.root import logical_absolute_path, logical_current_directory
9
+ from relayctl.workspace import RemoteLayout
10
+
11
+
12
+ class PathRewriteError(ValueError):
13
+ pass
14
+
15
+
16
+ class SymlinkEscapeError(PathRewriteError):
17
+ pass
18
+
19
+
20
+ class ExplicitStagePathError(PathRewriteError):
21
+ pass
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ClassifiedPathToken:
26
+ original: str
27
+ kind: str
28
+ local_path: Path | None = None
29
+ relative_path: PurePosixPath | None = None
30
+ rewritten_token: str | None = None
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ExternalStagePath:
35
+ slot: int
36
+ local_path: Path
37
+ remote_path: PurePosixPath
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RewrittenArgv:
42
+ argv: tuple[str, ...]
43
+ staged_paths: tuple[ExternalStagePath, ...]
44
+
45
+
46
+ def _plan_stage_path(
47
+ local_path: Path,
48
+ *,
49
+ layout: RemoteLayout,
50
+ planned: list[ExternalStagePath],
51
+ stage_slots: dict[Path, ExternalStagePath],
52
+ ) -> ExternalStagePath:
53
+ stage_path = stage_slots.get(local_path)
54
+ if stage_path is not None:
55
+ return stage_path
56
+
57
+ slot = len(planned) + 1
58
+ stage_path = ExternalStagePath(
59
+ slot=slot,
60
+ local_path=local_path,
61
+ remote_path=layout.run_stage_dir / str(slot) / _stage_basename(local_path),
62
+ )
63
+ stage_slots[local_path] = stage_path
64
+ planned.append(stage_path)
65
+ return stage_path
66
+
67
+
68
+ def classify_path_token(
69
+ token: str,
70
+ *,
71
+ local_root: Path,
72
+ cwd: Path | None = None,
73
+ ) -> ClassifiedPathToken:
74
+ if _is_attached_or_value_form(token):
75
+ return ClassifiedPathToken(original=token, kind="unchanged")
76
+
77
+ label, labeled_path = _split_path_label(token)
78
+ if label not in {None, "local", "remote"}:
79
+ raise PathRewriteError(
80
+ f"Unsupported path label '{label}:' in token: {token}. "
81
+ "Use local:PATH or remote:PATH"
82
+ )
83
+
84
+ if label == "remote":
85
+ return ClassifiedPathToken(
86
+ original=token,
87
+ kind="remote_literal",
88
+ rewritten_token=labeled_path,
89
+ )
90
+
91
+ local_token = labeled_path if label == "local" else token
92
+
93
+ base_cwd = (
94
+ logical_current_directory() if cwd is None else logical_absolute_path(cwd)
95
+ )
96
+ candidate = Path(local_token).expanduser()
97
+ if not candidate.is_absolute():
98
+ candidate = base_cwd / candidate
99
+
100
+ absolute_candidate = _lexical_normalize(
101
+ logical_absolute_path(candidate, base=base_cwd)
102
+ )
103
+ logical_root = _lexical_normalize(logical_absolute_path(local_root, base=base_cwd))
104
+
105
+ if not absolute_candidate.exists():
106
+ logical_relative = _relative_to(absolute_candidate, logical_root)
107
+ if logical_relative is None:
108
+ raise PathRewriteError(
109
+ f"Absolute local path does not exist: {local_token}. "
110
+ f"If this path already exists on the remote host, use remote:{local_token}"
111
+ )
112
+ return ClassifiedPathToken(
113
+ original=token,
114
+ kind="in_root",
115
+ local_path=absolute_candidate,
116
+ relative_path=PurePosixPath(*logical_relative.parts),
117
+ )
118
+
119
+ resolved_root = logical_root.resolve(strict=True)
120
+ resolved_candidate = absolute_candidate.resolve(strict=True)
121
+
122
+ logical_relative = _relative_to(absolute_candidate, logical_root)
123
+ if logical_relative is not None:
124
+ resolved_relative = _relative_to(resolved_candidate, resolved_root)
125
+ if resolved_relative is None:
126
+ raise SymlinkEscapeError(
127
+ f"Path escapes the project root through a symlink: {absolute_candidate}"
128
+ )
129
+ return ClassifiedPathToken(
130
+ original=token,
131
+ kind="in_root",
132
+ local_path=absolute_candidate,
133
+ relative_path=PurePosixPath(*resolved_relative.parts),
134
+ )
135
+
136
+ resolved_relative = _relative_to(resolved_candidate, resolved_root)
137
+ if resolved_relative is not None:
138
+ return ClassifiedPathToken(
139
+ original=token,
140
+ kind="in_root",
141
+ local_path=absolute_candidate,
142
+ relative_path=PurePosixPath(*resolved_relative.parts),
143
+ )
144
+
145
+ return ClassifiedPathToken(
146
+ original=token,
147
+ kind="external",
148
+ local_path=absolute_candidate,
149
+ )
150
+
151
+
152
+ def rewrite_argv_path_tokens(
153
+ argv: tuple[str, ...] | list[str],
154
+ *,
155
+ local_root: Path,
156
+ layout: RemoteLayout,
157
+ cwd: Path | None = None,
158
+ ) -> RewrittenArgv:
159
+ rewritten: list[str] = []
160
+ staged_paths: list[ExternalStagePath] = []
161
+ stage_slots: dict[Path, ExternalStagePath] = {}
162
+
163
+ for token in argv:
164
+ if not _looks_like_explicit_path_token(token):
165
+ rewritten.append(token)
166
+ continue
167
+
168
+ classification = classify_path_token(token, local_root=local_root, cwd=cwd)
169
+ if classification.kind == "in_root":
170
+ if classification.relative_path is None:
171
+ raise PathRewriteError(
172
+ f"Internal path classification bug for in-root token: {token}"
173
+ )
174
+ rewritten.append(str(layout.mirror_dir / classification.relative_path))
175
+ continue
176
+
177
+ if classification.kind == "external":
178
+ if classification.local_path is None:
179
+ raise PathRewriteError(
180
+ f"Internal path classification bug for external token: {token}"
181
+ )
182
+ stage_path = _plan_stage_path(
183
+ classification.local_path,
184
+ layout=layout,
185
+ planned=staged_paths,
186
+ stage_slots=stage_slots,
187
+ )
188
+ rewritten.append(str(stage_path.remote_path))
189
+ continue
190
+
191
+ if classification.kind == "remote_literal":
192
+ if classification.rewritten_token is None:
193
+ raise PathRewriteError(
194
+ f"Internal path classification bug for remote literal token: {token}"
195
+ )
196
+ rewritten.append(classification.rewritten_token)
197
+ continue
198
+
199
+ rewritten.append(token)
200
+
201
+ return RewrittenArgv(argv=tuple(rewritten), staged_paths=tuple(staged_paths))
202
+
203
+
204
+ def plan_explicit_stage_paths(
205
+ stage_paths: tuple[str, ...] | list[str],
206
+ *,
207
+ local_root: Path,
208
+ layout: RemoteLayout,
209
+ cwd: Path | None = None,
210
+ ) -> tuple[ExternalStagePath, ...]:
211
+ base_cwd = (
212
+ logical_current_directory() if cwd is None else logical_absolute_path(cwd)
213
+ )
214
+ logical_root = logical_absolute_path(local_root, base=base_cwd)
215
+ resolved_root = logical_root.resolve(strict=True)
216
+
217
+ planned: list[ExternalStagePath] = []
218
+ stage_slots: dict[Path, ExternalStagePath] = {}
219
+
220
+ for raw_path in stage_paths:
221
+ local_path = logical_absolute_path(raw_path, base=base_cwd)
222
+ if not local_path.exists():
223
+ raise ExplicitStagePathError(
224
+ f"Explicit stage path does not exist: {raw_path}"
225
+ )
226
+ if not local_path.is_file() and not local_path.is_dir():
227
+ raise ExplicitStagePathError(
228
+ f"Explicit stage path must be a file or directory: {raw_path}"
229
+ )
230
+
231
+ resolved_local = local_path.resolve(strict=True)
232
+ if _relative_to(resolved_local, resolved_root) is not None:
233
+ raise ExplicitStagePathError(
234
+ f"Explicit stage path is already inside the mirrored project root: {raw_path}"
235
+ )
236
+
237
+ _plan_stage_path(
238
+ local_path,
239
+ layout=layout,
240
+ planned=planned,
241
+ stage_slots=stage_slots,
242
+ )
243
+
244
+ return tuple(planned)
245
+
246
+
247
+ def _is_attached_or_value_form(token: str) -> bool:
248
+ if token.startswith("-"):
249
+ return True
250
+
251
+ if "=" not in token:
252
+ return False
253
+
254
+ return not Path(token).expanduser().is_absolute()
255
+
256
+
257
+ _LABELLED_PATH_RE = re.compile(r"^(?P<label>[A-Za-z][A-Za-z0-9_-]*):(?P<path>.+)$")
258
+ _PLAIN_FILENAME_RE = re.compile(
259
+ r"^(?=.*[A-Za-z])[A-Za-z0-9][A-Za-z0-9._-]*\.[A-Za-z0-9._-]+$"
260
+ )
261
+
262
+
263
+ def _split_path_label(token: str) -> tuple[str | None, str]:
264
+ match = _LABELLED_PATH_RE.match(token)
265
+ if match is None:
266
+ return None, token
267
+ return match.group("label"), match.group("path")
268
+
269
+
270
+ def _looks_like_explicit_path_token(token: str) -> bool:
271
+ label, _ = _split_path_label(token)
272
+ if label is not None:
273
+ return True
274
+
275
+ candidate = Path(token).expanduser()
276
+ return (
277
+ candidate.is_absolute()
278
+ or token.startswith((".", "~"))
279
+ or "/" in token
280
+ or _PLAIN_FILENAME_RE.match(token) is not None
281
+ )
282
+
283
+
284
+ def _lexical_normalize(path: Path) -> Path:
285
+ return Path(os.path.normpath(str(path)))
286
+
287
+
288
+ def _relative_to(path: Path, root: Path) -> Path | None:
289
+ try:
290
+ return path.relative_to(root)
291
+ except ValueError:
292
+ return None
293
+
294
+
295
+ def _stage_basename(path: Path) -> str:
296
+ if path.name:
297
+ return path.name
298
+
299
+ anchor = path.anchor.replace("/", "").replace("\\", "")
300
+ return anchor or "root"