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 +3 -0
- relayctl/_remote_runner.py +254 -0
- relayctl/argv_paths.py +300 -0
- relayctl/cli.py +1212 -0
- relayctl/config.py +689 -0
- relayctl/conflict_policy.py +121 -0
- relayctl/feedback.py +75 -0
- relayctl/identity.py +43 -0
- relayctl/locking.py +111 -0
- relayctl/mutagen.py +425 -0
- relayctl/pullback_plan.py +148 -0
- relayctl/remote_execution.py +1337 -0
- relayctl/remote_runner_manifest.py +207 -0
- relayctl/root.py +73 -0
- relayctl/setup_checks.py +422 -0
- relayctl/ssh.py +172 -0
- relayctl/startup_cache.py +289 -0
- relayctl/transport.py +184 -0
- relayctl/workspace.py +127 -0
- relayctl-0.1.0.dist-info/METADATA +258 -0
- relayctl-0.1.0.dist-info/RECORD +23 -0
- relayctl-0.1.0.dist-info/WHEEL +4 -0
- relayctl-0.1.0.dist-info/entry_points.txt +2 -0
relayctl/__init__.py
ADDED
|
@@ -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"
|