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/__init__.py +3 -0
- simrun/argo.py +449 -0
- simrun/build_constraints.txt +20 -0
- simrun/cgroup.py +77 -0
- simrun/cli.py +350 -0
- simrun/client.py +407 -0
- simrun/config.py +63 -0
- simrun/cwl.py +287 -0
- simrun/demo.py +106 -0
- simrun/edit.py +408 -0
- simrun/environment.py +370 -0
- simrun/ledger.py +286 -0
- simrun/mcp.py +161 -0
- simrun/planner.py +303 -0
- simrun/plugins.py +149 -0
- simrun/plugins_builtin.py +33 -0
- simrun/podspec.py +166 -0
- simrun/resources.py +270 -0
- simrun/service/__init__.py +1 -0
- simrun/service/app.py +1721 -0
- simrun/service/argo_backend.py +842 -0
- simrun/service/auth.py +93 -0
- simrun/service/backend.py +170 -0
- simrun/service/compare.py +77 -0
- simrun/service/controller.py +788 -0
- simrun/service/edits.py +406 -0
- simrun/service/index.py +292 -0
- simrun/service/logs.py +91 -0
- simrun/service/submission.py +349 -0
- simrun/snapshot.py +196 -0
- simrun/store.py +190 -0
- simrun/testing.py +263 -0
- simrun/worker.py +1097 -0
- simrun/workflow.py +188 -0
- simrun-0.1.0.dist-info/METADATA +107 -0
- simrun-0.1.0.dist-info/RECORD +39 -0
- simrun-0.1.0.dist-info/WHEEL +4 -0
- simrun-0.1.0.dist-info/entry_points.txt +10 -0
- simrun-0.1.0.dist-info/licenses/LICENSE +7 -0
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)
|