rexs 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.
- rexs/__init__.py +14 -0
- rexs/__main__.py +4 -0
- rexs/auth.py +48 -0
- rexs/beaker.py +47 -0
- rexs/cli.py +363 -0
- rexs/compiler.py +694 -0
- rexs/config.py +136 -0
- rexs/controller.py +258 -0
- rexs/dryrun.py +164 -0
- rexs/errors.py +10 -0
- rexs/links.py +28 -0
- rexs/metrics.py +287 -0
- rexs/resources.py +85 -0
- rexs/server.py +455 -0
- rexs/state.py +412 -0
- rexs/static/rexs-logo.png +0 -0
- rexs/web.py +611 -0
- rexs-0.1.0.dist-info/METADATA +278 -0
- rexs-0.1.0.dist-info/RECORD +22 -0
- rexs-0.1.0.dist-info/WHEEL +5 -0
- rexs-0.1.0.dist-info/entry_points.txt +2 -0
- rexs-0.1.0.dist-info/top_level.txt +1 -0
rexs/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Beaker-to-Slurm compiler for datadev workloads."""
|
|
2
|
+
|
|
3
|
+
from rexs.compiler import CompileResult, compile_experiment
|
|
4
|
+
from rexs.config import SlurmProfile, load_experiment, load_profile
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CompileResult",
|
|
8
|
+
"SlurmProfile",
|
|
9
|
+
"compile_experiment",
|
|
10
|
+
"load_experiment",
|
|
11
|
+
"load_profile",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
rexs/__main__.py
ADDED
rexs/auth.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Persistent, per-account credentials for the local dashboard."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
USERNAME = 'rexs'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def dashboard_password(state_dir: Path) -> str:
|
|
14
|
+
"""Use an environment override or atomically create a private default password."""
|
|
15
|
+
override = os.environ.get('REXS_SERVER_PASSWORD')
|
|
16
|
+
if override is not None:
|
|
17
|
+
if not override.strip():
|
|
18
|
+
raise ValueError('REXS_SERVER_PASSWORD must not be empty')
|
|
19
|
+
return override
|
|
20
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
path = state_dir / 'server.password'
|
|
22
|
+
# Publish only a fully written file, including when two servers start together.
|
|
23
|
+
if not path.exists():
|
|
24
|
+
temporary = None
|
|
25
|
+
try:
|
|
26
|
+
with tempfile.NamedTemporaryFile(mode='w', dir=state_dir, delete=False) as stream:
|
|
27
|
+
temporary = Path(stream.name)
|
|
28
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
29
|
+
stream.write('rexs-' + secrets.token_urlsafe(18) + '\n')
|
|
30
|
+
stream.flush()
|
|
31
|
+
os.fsync(stream.fileno())
|
|
32
|
+
try:
|
|
33
|
+
os.link(temporary, path)
|
|
34
|
+
except FileExistsError:
|
|
35
|
+
pass
|
|
36
|
+
finally:
|
|
37
|
+
if temporary is not None:
|
|
38
|
+
temporary.unlink(missing_ok=True)
|
|
39
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
40
|
+
with os.fdopen(fd, 'r', encoding='utf-8') as stream:
|
|
41
|
+
info = os.fstat(stream.fileno())
|
|
42
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
|
|
43
|
+
raise ValueError('dashboard password must be a regular file owned by this user')
|
|
44
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
45
|
+
password = stream.read().rstrip('\r\n')
|
|
46
|
+
if not password.strip():
|
|
47
|
+
raise ValueError(f'dashboard password file is empty: {path}')
|
|
48
|
+
return password
|
rexs/beaker.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Opt-in adapter for programs that submit Beaker experiment specs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rexs.cli import Rexs
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="beaker", description=__doc__)
|
|
15
|
+
parser.add_argument("--format", choices=("json",), help="Beaker JSON compatibility")
|
|
16
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
experiment = commands.add_parser("experiment")
|
|
18
|
+
actions = experiment.add_subparsers(dest="action", required=True)
|
|
19
|
+
create = actions.add_parser("create")
|
|
20
|
+
create.add_argument("spec")
|
|
21
|
+
create.add_argument("-n", "--name")
|
|
22
|
+
create.add_argument("-w", "--workspace", help="Beaker workspace metadata; Slurm account comes from the Rex profile")
|
|
23
|
+
create.add_argument("--profile", default=os.environ.get("REXS_PROFILE"))
|
|
24
|
+
create.add_argument("--dry-run", action="store_true", default=os.environ.get("REXS_DRY_RUN") == "1")
|
|
25
|
+
stop = actions.add_parser("stop")
|
|
26
|
+
stop.add_argument("identifier")
|
|
27
|
+
stop.add_argument("--dry-run", action="store_true", default=os.environ.get("REXS_DRY_RUN") == "1")
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
if args.action == "stop":
|
|
30
|
+
result = ({"backend": "rexs", "cancelled": False, "id": args.identifier}
|
|
31
|
+
if args.dry_run else Rexs().cancel(args.identifier))
|
|
32
|
+
print(json.dumps(result, indent=2))
|
|
33
|
+
return
|
|
34
|
+
if not args.profile:
|
|
35
|
+
parser.error("set REXS_PROFILE or pass --profile to select the Slurm cluster")
|
|
36
|
+
rex = Rexs()
|
|
37
|
+
if args.dry_run:
|
|
38
|
+
output = str(Path(args.spec).with_suffix(".sbatch"))
|
|
39
|
+
script = rex.render(args.spec, profile=args.profile, name=args.name, output=output)
|
|
40
|
+
result = {"backend": "rexs", "submitted": False, "script": script}
|
|
41
|
+
else:
|
|
42
|
+
result = rex.submit(args.spec, profile=args.profile, name=args.name)
|
|
43
|
+
print(json.dumps(result, indent=2))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
main()
|
rexs/cli.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import fire
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from rexs.compiler import CompileResult, compile_experiment
|
|
17
|
+
from rexs.config import SlurmProfile, load_experiment, load_mapping_file, load_profile, parse_assignments
|
|
18
|
+
from rexs.controller import Controller
|
|
19
|
+
from rexs.dryrun import dry_run_experiments
|
|
20
|
+
from rexs.errors import RexsError
|
|
21
|
+
from rexs.server import serve, start_daemon, stop_daemon
|
|
22
|
+
from rexs.state import StateStore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Rexs:
|
|
26
|
+
"""Reproducible Experiments, eXecuted on Slurm."""
|
|
27
|
+
|
|
28
|
+
def validate(
|
|
29
|
+
self,
|
|
30
|
+
experiment: str,
|
|
31
|
+
profile: str | None = None,
|
|
32
|
+
image_map: str | None = None,
|
|
33
|
+
dataset_map: str | None = None,
|
|
34
|
+
define: Any = None,
|
|
35
|
+
name: str | None = None,
|
|
36
|
+
strict: bool = False,
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
"""Validate and summarize a Beaker v2 experiment without writing or submitting."""
|
|
39
|
+
|
|
40
|
+
_, _, result = _compile_inputs(experiment, profile, image_map, dataset_map, define, name)
|
|
41
|
+
_check_and_report(result, strict)
|
|
42
|
+
return {
|
|
43
|
+
"valid": True,
|
|
44
|
+
"job": result.job_name,
|
|
45
|
+
"nodes": result.nodes,
|
|
46
|
+
"task_replicas": result.tasks,
|
|
47
|
+
"gpus_per_node": result.gpus_per_node,
|
|
48
|
+
"warnings": list(result.warnings),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def dry_run(
|
|
52
|
+
self,
|
|
53
|
+
experiments: Any = "~/datadev/beaker_experiments",
|
|
54
|
+
profile: str | None = None,
|
|
55
|
+
image_map: str | None = None,
|
|
56
|
+
dataset_map: str | None = None,
|
|
57
|
+
define: Any = None,
|
|
58
|
+
recursive: bool = True,
|
|
59
|
+
strict: bool = False,
|
|
60
|
+
output_dir: str | None = None,
|
|
61
|
+
limit: int | None = None,
|
|
62
|
+
details: bool = False,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
"""Offline compile and bash-syntax audit; never invokes Slurm."""
|
|
65
|
+
|
|
66
|
+
report = dry_run_experiments(
|
|
67
|
+
_string_list(experiments),
|
|
68
|
+
profile=load_profile(profile),
|
|
69
|
+
image_map=load_mapping_file(image_map, "image map"),
|
|
70
|
+
dataset_map=load_mapping_file(dataset_map, "dataset map"),
|
|
71
|
+
substitutions=_definitions(define),
|
|
72
|
+
recursive=recursive,
|
|
73
|
+
strict=strict,
|
|
74
|
+
output_dir=output_dir,
|
|
75
|
+
limit=limit,
|
|
76
|
+
)
|
|
77
|
+
if strict and report["failed"]:
|
|
78
|
+
failures = report["failures"]
|
|
79
|
+
sample = "\n".join(f"- {failure['path']}: {failure['error']}" for failure in failures[:10])
|
|
80
|
+
remaining = len(failures) - 10
|
|
81
|
+
suffix = f"\n- ... and {remaining} more" if remaining > 0 else ""
|
|
82
|
+
raise RexsError(f"strict dry run failed {report['failed']} experiment(s):\n{sample}{suffix}")
|
|
83
|
+
if not details:
|
|
84
|
+
report.pop("results")
|
|
85
|
+
return report
|
|
86
|
+
|
|
87
|
+
def render(
|
|
88
|
+
self,
|
|
89
|
+
experiment: str,
|
|
90
|
+
output: str | None = None,
|
|
91
|
+
profile: str | None = None,
|
|
92
|
+
image_map: str | None = None,
|
|
93
|
+
dataset_map: str | None = None,
|
|
94
|
+
define: Any = None,
|
|
95
|
+
name: str | None = None,
|
|
96
|
+
strict: bool = False,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Render an sbatch script to stdout or --output."""
|
|
99
|
+
|
|
100
|
+
_, _, result = _compile_inputs(experiment, profile, image_map, dataset_map, define, name)
|
|
101
|
+
_check_and_report(result, strict)
|
|
102
|
+
if output is None:
|
|
103
|
+
return result.script
|
|
104
|
+
target = Path(output).expanduser()
|
|
105
|
+
_write_script(target, result.script)
|
|
106
|
+
return str(target)
|
|
107
|
+
|
|
108
|
+
def submit(
|
|
109
|
+
self,
|
|
110
|
+
experiment: str,
|
|
111
|
+
output: str | None = None,
|
|
112
|
+
profile: str | None = None,
|
|
113
|
+
image_map: str | None = None,
|
|
114
|
+
dataset_map: str | None = None,
|
|
115
|
+
define: Any = None,
|
|
116
|
+
name: str | None = None,
|
|
117
|
+
strict: bool = False,
|
|
118
|
+
sbatch_args: Any = None,
|
|
119
|
+
db: str | None = None,
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""Compile, persist, submit with sbatch, and register the experiment in SQLite."""
|
|
122
|
+
|
|
123
|
+
spec, slurm_profile, result = _compile_inputs(
|
|
124
|
+
experiment,
|
|
125
|
+
profile,
|
|
126
|
+
image_map,
|
|
127
|
+
dataset_map,
|
|
128
|
+
define,
|
|
129
|
+
name,
|
|
130
|
+
)
|
|
131
|
+
_check_and_report(result, strict)
|
|
132
|
+
store = StateStore(db)
|
|
133
|
+
source = Path(experiment).expanduser().resolve()
|
|
134
|
+
spec_text = source.read_text(encoding="utf-8")
|
|
135
|
+
script_hash = _sha256(result.script)
|
|
136
|
+
target = (
|
|
137
|
+
Path(output).expanduser()
|
|
138
|
+
if output
|
|
139
|
+
else store.path.parent / "artifacts" / f"{_safe(result.job_name)}-{script_hash[:12]}.sbatch"
|
|
140
|
+
)
|
|
141
|
+
_write_script(target, result.script)
|
|
142
|
+
record = store.create_experiment(
|
|
143
|
+
spec_text=spec_text,
|
|
144
|
+
name=result.job_name,
|
|
145
|
+
spec_path=str(source),
|
|
146
|
+
spec_sha256=_sha256(spec_text),
|
|
147
|
+
script_path=str(target.resolve()),
|
|
148
|
+
script_sha256=script_hash,
|
|
149
|
+
run_root=slurm_profile.run_root,
|
|
150
|
+
warnings=result.warnings,
|
|
151
|
+
tasks=spec["tasks"],
|
|
152
|
+
)
|
|
153
|
+
command = ["sbatch", *_string_list(sbatch_args), str(target)]
|
|
154
|
+
try:
|
|
155
|
+
completed = subprocess.run(command, check=True, text=True, capture_output=True)
|
|
156
|
+
job_id = _job_id(completed.stdout)
|
|
157
|
+
except (OSError, subprocess.CalledProcessError, ValueError) as exc:
|
|
158
|
+
detail = _command_error(exc)
|
|
159
|
+
store.record_submission_failure(record.id, detail)
|
|
160
|
+
raise RexsError(detail) from exc
|
|
161
|
+
submitted = store.record_submission(record.id, job_id, completed.stdout)
|
|
162
|
+
return submitted.as_dict()
|
|
163
|
+
|
|
164
|
+
def experiments(
|
|
165
|
+
self,
|
|
166
|
+
status: str | None = None,
|
|
167
|
+
limit: int = 100,
|
|
168
|
+
refresh: bool = True,
|
|
169
|
+
db: str | None = None,
|
|
170
|
+
details: bool = False,
|
|
171
|
+
ids_only: bool = False,
|
|
172
|
+
all: bool = False,
|
|
173
|
+
) -> str | list[dict[str, Any]]:
|
|
174
|
+
"""List queued/running experiments. Use --all for history or --status to filter.
|
|
175
|
+
|
|
176
|
+
Use --details for JSON or --ids-only for IDs.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
controller = Controller(db)
|
|
180
|
+
if refresh:
|
|
181
|
+
controller.refresh()
|
|
182
|
+
records = controller.store.list(
|
|
183
|
+
status=status or (None if all else ("SUBMITTED", "PENDING", "RUNNING")),
|
|
184
|
+
limit=limit,
|
|
185
|
+
)
|
|
186
|
+
if ids_only:
|
|
187
|
+
return "\n".join(item.job_id or item.id for item in records)
|
|
188
|
+
if details:
|
|
189
|
+
return [item.as_dict() for item in records]
|
|
190
|
+
if not records:
|
|
191
|
+
return "No experiments found."
|
|
192
|
+
rows = [("ID", "STATUS", "NAME")]
|
|
193
|
+
rows.extend(
|
|
194
|
+
(item.job_id or item.id, item.status, " ".join(item.name.split())[:60])
|
|
195
|
+
for item in records
|
|
196
|
+
)
|
|
197
|
+
id_width = max(len(row[0]) for row in rows)
|
|
198
|
+
status_width = max(len(row[1]) for row in rows)
|
|
199
|
+
return "\n".join(f"{identifier:<{id_width}} {state:<{status_width}} {name}"
|
|
200
|
+
for identifier, state, name in rows)
|
|
201
|
+
|
|
202
|
+
def show(self, identifier: str, refresh: bool = True, db: str | None = None) -> dict[str, Any]:
|
|
203
|
+
"""Show one experiment by REXS ID or Slurm job ID."""
|
|
204
|
+
|
|
205
|
+
controller = Controller(db)
|
|
206
|
+
if refresh:
|
|
207
|
+
controller.refresh(identifier)
|
|
208
|
+
experiment = controller.store.get(identifier)
|
|
209
|
+
return {
|
|
210
|
+
"experiment": experiment.as_dict(include_spec=True),
|
|
211
|
+
"tasks": [task.as_dict() for task in controller.store.tasks(identifier)],
|
|
212
|
+
"events": controller.store.events(identifier),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def status(self, identifier: str, refresh: bool = True, db: str | None = None) -> dict[str, Any]:
|
|
216
|
+
"""Return the durable status of one tracked experiment."""
|
|
217
|
+
|
|
218
|
+
return self.show(identifier, refresh=refresh, db=db)["experiment"]
|
|
219
|
+
|
|
220
|
+
def refresh(self, identifier: str | None = None, db: str | None = None) -> list[dict[str, Any]]:
|
|
221
|
+
"""Refresh tracked state from squeue and sacct."""
|
|
222
|
+
|
|
223
|
+
return [asdict(item) for item in Controller(db).refresh(identifier)]
|
|
224
|
+
|
|
225
|
+
def logs(
|
|
226
|
+
self,
|
|
227
|
+
identifier: str,
|
|
228
|
+
task: str | None = None,
|
|
229
|
+
replica: int | None = None,
|
|
230
|
+
lines: int = 200,
|
|
231
|
+
follow: bool = False,
|
|
232
|
+
db: str | None = None,
|
|
233
|
+
) -> list[dict[str, object]] | None:
|
|
234
|
+
"""Read or follow per-replica logs for an experiment."""
|
|
235
|
+
|
|
236
|
+
controller = Controller(db)
|
|
237
|
+
records = controller.logs(identifier, task=task, replica=replica, lines=lines)
|
|
238
|
+
if not follow:
|
|
239
|
+
return records
|
|
240
|
+
paths = [str(record["log_path"]) for record in records if record["exists"]]
|
|
241
|
+
if not paths:
|
|
242
|
+
raise RexsError("no matching log files exist yet")
|
|
243
|
+
subprocess.run(["tail", "-n", str(lines), "-F", *paths], check=False)
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def cancel(self, identifier: str, db: str | None = None) -> dict[str, Any]:
|
|
247
|
+
"""Cancel a tracked Slurm experiment and record the transition."""
|
|
248
|
+
|
|
249
|
+
return Controller(db).cancel(identifier).as_dict()
|
|
250
|
+
|
|
251
|
+
def server(
|
|
252
|
+
self,
|
|
253
|
+
host: str = "127.0.0.1",
|
|
254
|
+
port: int = 8765,
|
|
255
|
+
db: str | None = None,
|
|
256
|
+
poll_interval: float = 10.0,
|
|
257
|
+
daemon: bool = False,
|
|
258
|
+
) -> dict[str, Any] | None:
|
|
259
|
+
"""Run the dashboard/API server, optionally detached with --daemon."""
|
|
260
|
+
|
|
261
|
+
if daemon:
|
|
262
|
+
return start_daemon(host=host, port=port, db_path=db, poll_interval=poll_interval)
|
|
263
|
+
serve(host=host, port=port, db_path=db, poll_interval=poll_interval)
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
def server_stop(self, db: str | None = None) -> dict[str, Any]:
|
|
267
|
+
"""Stop the detached server associated with this state database."""
|
|
268
|
+
|
|
269
|
+
return stop_daemon(db)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
273
|
+
try:
|
|
274
|
+
fire.Fire(Rexs(), command=list(argv) if argv is not None else None,
|
|
275
|
+
serialize=lambda value: json.dumps(value, indent=2) if isinstance(value, (dict, list)) else str(value))
|
|
276
|
+
except (RexsError, KeyError, ValueError, OSError, subprocess.CalledProcessError) as exc:
|
|
277
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
278
|
+
raise SystemExit(2) from None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _compile_inputs(
|
|
282
|
+
experiment: str,
|
|
283
|
+
profile_path: str | None,
|
|
284
|
+
image_map_path: str | None,
|
|
285
|
+
dataset_map_path: str | None,
|
|
286
|
+
define: Any,
|
|
287
|
+
name: str | None,
|
|
288
|
+
) -> tuple[dict[str, Any], SlurmProfile, CompileResult]:
|
|
289
|
+
source = Path(experiment).expanduser()
|
|
290
|
+
profile = load_profile(profile_path)
|
|
291
|
+
image_map = load_mapping_file(image_map_path, "image map")
|
|
292
|
+
dataset_map = load_mapping_file(dataset_map_path, "dataset map")
|
|
293
|
+
spec = load_experiment(source, _definitions(define))
|
|
294
|
+
job_name = name or str(spec.get("name") or source.stem)
|
|
295
|
+
result = compile_experiment(
|
|
296
|
+
spec,
|
|
297
|
+
profile=profile,
|
|
298
|
+
job_name=job_name,
|
|
299
|
+
image_map=image_map,
|
|
300
|
+
dataset_map=dataset_map,
|
|
301
|
+
)
|
|
302
|
+
return spec, profile, result
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _definitions(value: Any) -> dict[str, str]:
|
|
306
|
+
if value is None:
|
|
307
|
+
return {}
|
|
308
|
+
if isinstance(value, Mapping):
|
|
309
|
+
return {str(key): str(item) for key, item in value.items()}
|
|
310
|
+
if isinstance(value, str) and value.lstrip().startswith("{"):
|
|
311
|
+
parsed = yaml.safe_load(value)
|
|
312
|
+
if not isinstance(parsed, Mapping):
|
|
313
|
+
raise ValueError("--define mapping must contain key/value pairs")
|
|
314
|
+
return {str(key): str(item) for key, item in parsed.items()}
|
|
315
|
+
return parse_assignments(_string_list(value))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _string_list(value: Any) -> list[str]:
|
|
319
|
+
if value is None:
|
|
320
|
+
return []
|
|
321
|
+
if isinstance(value, str):
|
|
322
|
+
return [value]
|
|
323
|
+
if isinstance(value, (list, tuple)):
|
|
324
|
+
return [str(item) for item in value]
|
|
325
|
+
return [str(value)]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _check_and_report(result: CompileResult, strict: bool) -> None:
|
|
329
|
+
if strict and result.warnings:
|
|
330
|
+
raise RexsError("strict translation refused warnings:\n- " + "\n- ".join(result.warnings))
|
|
331
|
+
for warning in result.warnings:
|
|
332
|
+
print(f"warning: {warning}", file=sys.stderr)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _write_script(path: Path, script: str) -> None:
|
|
336
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
path.write_text(script, encoding="utf-8")
|
|
338
|
+
path.chmod(0o700)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _job_id(output: str) -> str:
|
|
342
|
+
match = re.search(r"Submitted\s+batch\s+job\s+(\d+)", output, re.IGNORECASE)
|
|
343
|
+
if not match:
|
|
344
|
+
raise ValueError(f"could not parse Slurm job ID from sbatch output: {output.strip()!r}")
|
|
345
|
+
return match.group(1)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _command_error(exc: BaseException) -> str:
|
|
349
|
+
if isinstance(exc, subprocess.CalledProcessError):
|
|
350
|
+
return (exc.stderr or exc.stdout or str(exc)).strip()
|
|
351
|
+
return str(exc)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _sha256(value: str) -> str:
|
|
355
|
+
return hashlib.sha256(value.encode()).hexdigest()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _safe(value: str) -> str:
|
|
359
|
+
return re.sub(r"[^A-Za-z0-9_.-]+", "__", value).strip("._-") or "experiment"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
if __name__ == "__main__":
|
|
363
|
+
main()
|