simrun 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.
simrun/cli.py ADDED
@@ -0,0 +1,350 @@
1
+ """The command line of simrun: submit, follow and compare runs.
2
+
3
+ Every command prints the detail of a refusal of the service to stderr and exits 1;
4
+ `--json` prints machine-readable JSON instead of tables.
5
+ """
6
+
7
+ import json
8
+ import signal
9
+ import sys
10
+ import threading
11
+ from collections.abc import Callable, Sequence
12
+ from functools import wraps
13
+ from pathlib import Path
14
+ from typing import Annotated, Any, NoReturn
15
+
16
+ import click
17
+ import httpx
18
+ import typer
19
+ from pydantic import BaseModel
20
+ from rich.console import Console
21
+ from rich.table import Column, Table
22
+
23
+ from simrun.client import Client, ClientError
24
+ from simrun.edit import run_edit
25
+ from simrun.service.app import RunSummary
26
+ from simrun.service.logs import DEFAULT_TAIL_LINES
27
+ from simrun.store import RunState
28
+ from simrun.workflow import SplitMode
29
+
30
+ app = typer.Typer(help="Submit and follow simulation workflows.", no_args_is_help=True)
31
+ console = Console()
32
+
33
+ AS_JSON = Annotated[bool, typer.Option("--json", help="Print JSON.")]
34
+ WORKFLOW = Annotated[Path, typer.Argument(help="The workflow YAML file.")]
35
+ SOURCE = Annotated[
36
+ list[str] | None,
37
+ typer.Option("--source", help="A source as NAME=PATH, the model first."),
38
+ ]
39
+ GROUP = Annotated[str | None, typer.Option(help="Group of the run, e.g. a study.")]
40
+ SINGLE_SERVER = Annotated[
41
+ bool, typer.Option("--single-server", help="Place the pieces on one server.")
42
+ ]
43
+ RESERVE = Annotated[str | None, typer.Option(help="A node to reserve for the run.")]
44
+
45
+ EXIT_FAILED = 1
46
+ EXIT_TIMEOUT = 2
47
+
48
+
49
+ def make_client() -> Client:
50
+ """The client of the environment (`SIMRUN_URL`, `SIMRUN_TOKEN`, `SIMRUN_STORE`)."""
51
+ return Client.from_env()
52
+
53
+
54
+ def _fail(message: str) -> NoReturn:
55
+ typer.echo(message, err=True)
56
+ raise typer.Exit(EXIT_FAILED)
57
+
58
+
59
+ def _guarded[**P, R](function: Callable[P, R]) -> Callable[P, R]:
60
+ @wraps(function)
61
+ def run(*args: P.args, **kwargs: P.kwargs) -> R:
62
+ try:
63
+ return function(*args, **kwargs)
64
+ except typer.Exit, click.exceptions.ClickException:
65
+ raise
66
+ except ClientError as error:
67
+ _fail(error.detail)
68
+ except (ValueError, RuntimeError, OSError) as error:
69
+ _fail(str(error))
70
+ except httpx.TransportError as error:
71
+ _fail(f"the service is not reachable: {error}")
72
+
73
+ return run
74
+
75
+
76
+ def _json(data: Any) -> None:
77
+ def plain(value: Any) -> Any:
78
+ if isinstance(value, BaseModel):
79
+ return value.model_dump(mode="json")
80
+ if isinstance(value, dict):
81
+ return {k: plain(v) for k, v in value.items()}
82
+ if isinstance(value, list):
83
+ return [plain(v) for v in value]
84
+ return value
85
+
86
+ typer.echo(json.dumps(plain(data), indent=2))
87
+
88
+
89
+ def _sources(items: Sequence[str] | None) -> dict[str, Path]:
90
+ sources: dict[str, Path] = {}
91
+ for item in items or []:
92
+ name, sep, path = item.partition("=")
93
+ if not sep or not name or not path:
94
+ _fail(f"--source must be NAME=PATH, got {item!r}")
95
+ if name in sources:
96
+ _fail(f"duplicate source {name!r}")
97
+ sources[name] = Path(path).expanduser()
98
+ return sources
99
+
100
+
101
+ def _runs_table(runs: Sequence[RunSummary]) -> Table:
102
+ table = Table(
103
+ Column("run", no_wrap=True, overflow="ignore"),
104
+ "state",
105
+ "group",
106
+ "by",
107
+ "agent",
108
+ "priority",
109
+ "created",
110
+ )
111
+ for r in runs:
112
+ table.add_row(
113
+ r.id,
114
+ r.state.value,
115
+ r.group or "",
116
+ r.submitted_by,
117
+ r.agent or "",
118
+ r.priority.value,
119
+ f"{r.created:%Y-%m-%d %H:%M}",
120
+ )
121
+ return table
122
+
123
+
124
+ @app.command()
125
+ @_guarded
126
+ def submit(
127
+ workflow: WORKFLOW,
128
+ source: SOURCE = None,
129
+ group: GROUP = None,
130
+ single_server: SINGLE_SERVER = False,
131
+ reserve: RESERVE = None,
132
+ as_json: AS_JSON = False,
133
+ ) -> None:
134
+ """Snapshot the sources and submit a workflow."""
135
+ split = SplitMode.SINGLE_SERVER if single_server else None
136
+ summary = make_client().submit(workflow, _sources(source), group, split, reserve)
137
+ if as_json:
138
+ _json(summary)
139
+ else:
140
+ console.print(f"submitted [bold]{summary.id}[/bold] ({summary.state.value})")
141
+
142
+
143
+ @app.command()
144
+ @_guarded
145
+ def wait(
146
+ run: Annotated[str, typer.Argument(help="The run id.")],
147
+ timeout: Annotated[float | None, typer.Option(help="Seconds to wait.")] = None,
148
+ as_json: AS_JSON = False,
149
+ ) -> None:
150
+ """Wait for a run to end; exit 0 if it succeeded, 1 if not, 2 on timeout."""
151
+ summary = make_client().wait(run, timeout)
152
+ if as_json:
153
+ _json(summary)
154
+ else:
155
+ console.print(f"{summary.id}: {summary.state.value}")
156
+ if summary.message:
157
+ console.print(summary.message)
158
+ if summary.state == RunState.SUCCEEDED:
159
+ return
160
+ ended = summary.state in (RunState.FAILED, RunState.CANCELLED)
161
+ raise typer.Exit(EXIT_FAILED if ended else EXIT_TIMEOUT)
162
+
163
+
164
+ @app.command()
165
+ @_guarded
166
+ def status(
167
+ run: Annotated[str, typer.Argument(help="The run id.")],
168
+ as_json: AS_JSON = False,
169
+ ) -> None:
170
+ """Show the state of a run and its tasks."""
171
+ client = make_client()
172
+ record = client.status(run)
173
+ progress = client.progress(run)
174
+ if as_json:
175
+ _json({"run": record, "progress": progress})
176
+ return
177
+ console.print(_runs_table([RunSummary.of(record)]))
178
+ if record.message:
179
+ console.print(record.message)
180
+ table = Table("task", "state", "done", "total")
181
+ for task_id, state in record.tasks.items():
182
+ counts = progress[task_id].counts if task_id in progress else None
183
+ done = "" if counts is None else str(counts.done)
184
+ total = "" if counts is None else str(counts.total)
185
+ table.add_row(task_id, state.value, done, total)
186
+ console.print(table)
187
+
188
+
189
+ @app.command("list")
190
+ @_guarded
191
+ def list_(
192
+ group: GROUP = None,
193
+ agent: Annotated[str | None, typer.Option(help="Only runs of this agent.")] = None,
194
+ state: Annotated[RunState | None, typer.Option(help="Only this state.")] = None,
195
+ owner: Annotated[
196
+ str | None, typer.Option(help="Only runs of this owner and their agents.")
197
+ ] = None,
198
+ as_json: AS_JSON = False,
199
+ ) -> None:
200
+ """List runs."""
201
+ runs = make_client().list_runs(group, agent, state, owner)
202
+ if as_json:
203
+ _json(runs)
204
+ else:
205
+ console.print(_runs_table(runs))
206
+
207
+
208
+ @app.command()
209
+ @_guarded
210
+ def compare(
211
+ runs: Annotated[list[str], typer.Argument(help="The run ids.")],
212
+ as_json: AS_JSON = False,
213
+ ) -> None:
214
+ """Compare the provenance and the summaries of runs."""
215
+ result = make_client().compare(runs)
216
+ if as_json:
217
+ _json(result)
218
+ return
219
+ table = Table(*result.columns)
220
+ for row in result.rows:
221
+ table.add_row(
222
+ *("" if row.get(c) is None else str(row[c]) for c in result.columns)
223
+ )
224
+ console.print(table)
225
+
226
+
227
+ @app.command()
228
+ @_guarded
229
+ def cancel(
230
+ run: Annotated[str, typer.Argument(help="The run id.")],
231
+ as_json: AS_JSON = False,
232
+ ) -> None:
233
+ """Cancel a run."""
234
+ summary = make_client().cancel(run)
235
+ if as_json:
236
+ _json(summary)
237
+ else:
238
+ console.print(f"{summary.id}: {summary.state.value}")
239
+
240
+
241
+ @app.command()
242
+ @_guarded
243
+ def logs(
244
+ run: Annotated[str, typer.Argument(help="The run id.")],
245
+ task: Annotated[str, typer.Argument(help="The task id.")],
246
+ tail: Annotated[
247
+ int, typer.Option(help="The last lines of every pod, 0 for all of them.")
248
+ ] = DEFAULT_TAIL_LINES,
249
+ ) -> None:
250
+ """Print the logs of the pods of a task."""
251
+ sys.stdout.write(make_client().logs(run, task, tail))
252
+
253
+
254
+ @app.command()
255
+ @_guarded
256
+ def estimate(
257
+ workflow: WORKFLOW,
258
+ source: SOURCE = None,
259
+ single_server: SINGLE_SERVER = False,
260
+ reserve: RESERVE = None,
261
+ as_json: AS_JSON = False,
262
+ ) -> None:
263
+ """Show the plan and the expected duration of every task of a submission."""
264
+ split = SplitMode.SINGLE_SERVER if single_server else None
265
+ plans = make_client().estimate(workflow, _sources(source), None, split, reserve)
266
+ if as_json:
267
+ _json(plans)
268
+ return
269
+ table = Table(
270
+ "task", "pods", "cores", "memory GiB", "units", "seconds", "resources"
271
+ )
272
+ for task_id, e in plans.items():
273
+ table.add_row(
274
+ task_id,
275
+ str(e.plan.pods),
276
+ str(e.plan.shape.cores),
277
+ f"{e.plan.shape.memory_gb:g}",
278
+ "" if e.plan.units is None else str(e.plan.units),
279
+ "" if e.seconds is None else f"{e.seconds:.0f}",
280
+ e.resources.source,
281
+ )
282
+ console.print(table)
283
+
284
+
285
+ @app.command()
286
+ @_guarded
287
+ def cleanup(
288
+ older_than: Annotated[float, typer.Option(help="Days since the run ended.")] = 30,
289
+ group: GROUP = None,
290
+ failed: Annotated[bool, typer.Option(help="Only failed runs.")] = False,
291
+ dry_run: Annotated[bool, typer.Option(help="Remove nothing.")] = False,
292
+ as_json: AS_JSON = False,
293
+ ) -> None:
294
+ """Remove ended runs and the snapshots nothing refers to."""
295
+ result = make_client().cleanup(older_than, group, failed, dry_run)
296
+ if as_json:
297
+ _json(result)
298
+ return
299
+ verb = "would remove" if result.dry_run else "removed"
300
+ console.print(
301
+ f"{verb} {len(result.runs)} runs, {len(result.snapshots)} snapshots, "
302
+ f"{result.bytes_freed / 2**30:.2f} GiB"
303
+ )
304
+ for run in result.runs:
305
+ console.print(f" {run}")
306
+
307
+
308
+ @app.command()
309
+ @_guarded
310
+ def edit(workflow: WORKFLOW, source: SOURCE = None, group: GROUP = None) -> None:
311
+ """Serve the Editor of the web app for a workflow file until stopped.
312
+
313
+ Saves of the browser are written into the file, a run snapshots the sources
314
+ again. A missing file is a new workflow. Ctrl-C closes the session and exits,
315
+ a second Ctrl-C exits without waiting for the close.
316
+ """
317
+ stop = threading.Event()
318
+
319
+ def first_signal(*_: object) -> None:
320
+ # the loop ends and closes the session; a second Ctrl-C interrupts at once
321
+ stop.set()
322
+ signal.signal(signal.SIGINT, signal.default_int_handler)
323
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
324
+
325
+ for number in (signal.SIGINT, signal.SIGTERM):
326
+ signal.signal(number, first_signal)
327
+ try:
328
+ run_edit(
329
+ make_client(), workflow, _sources(source), group, should_stop=stop.is_set
330
+ )
331
+ except KeyboardInterrupt:
332
+ console.print("Stopped, the session shows not connected within 30 s")
333
+
334
+
335
+ @app.command()
336
+ @_guarded
337
+ def usage(as_json: AS_JSON = False) -> None:
338
+ """Show the bytes of the store per group and run."""
339
+ result = make_client().usage()
340
+ if as_json:
341
+ _json(result)
342
+ return
343
+ table = Table("group", "GiB")
344
+ for group, size in sorted(result.groups.items(), key=lambda kv: -kv[1]):
345
+ table.add_row(group or "(none)", f"{size / 2**30:.2f}")
346
+ console.print(table)
347
+ runs = Table("run", "GiB")
348
+ for run, size in sorted(result.runs.items(), key=lambda kv: -kv[1])[:20]:
349
+ runs.add_row(run, f"{size / 2**30:.2f}")
350
+ console.print(runs)