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 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
@@ -0,0 +1,4 @@
1
+ from rexs.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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()