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/edit.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""The command `simrun edit`: the machine with the code behind the Editor.
|
|
2
|
+
|
|
3
|
+
It opens an edit session in the service, writes the saves of the browser into the
|
|
4
|
+
workflow file, runs the workflow with fresh snapshots of the working trees, and
|
|
5
|
+
sends the file and the trees to the session when they change.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import hashlib
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import shutil
|
|
16
|
+
import socket
|
|
17
|
+
import subprocess
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Callable, Generator
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
import typer
|
|
25
|
+
|
|
26
|
+
from simrun.client import (
|
|
27
|
+
BACKOFF_MAX_S,
|
|
28
|
+
BACKOFF_START_S,
|
|
29
|
+
RETRY_STATUS,
|
|
30
|
+
Client,
|
|
31
|
+
ClientError,
|
|
32
|
+
)
|
|
33
|
+
from simrun.service.edits import (
|
|
34
|
+
EditRequest,
|
|
35
|
+
EditResult,
|
|
36
|
+
EditSession,
|
|
37
|
+
EditState,
|
|
38
|
+
OpenEdit,
|
|
39
|
+
TreeState,
|
|
40
|
+
)
|
|
41
|
+
from simrun.snapshot import snapshot
|
|
42
|
+
from simrun.workflow import Workflow, WorkflowError, load_workflow, workflow_yaml
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
EDIT_WATCH_S = 2.0
|
|
47
|
+
# how long the close of a session may take when the command stops
|
|
48
|
+
EDIT_CLOSE_TIMEOUT_S = 5.0
|
|
49
|
+
CONFLICT = "the file changed on disk"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _git(repo: Path, *args: str) -> str:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
["git", "-C", str(repo), *args], capture_output=True, text=True
|
|
55
|
+
)
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
raise RuntimeError(f"git {' '.join(args)} in {repo}: {result.stderr.strip()}")
|
|
58
|
+
return result.stdout
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def tree_state(name: str, repo: Path) -> TreeState:
|
|
62
|
+
"""The live state of a working tree: branch, commit and changed files.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
name: Name of the source.
|
|
66
|
+
repo: The working tree.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The state, with no branch for a detached HEAD.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
RuntimeError: When git fails in the tree.
|
|
73
|
+
"""
|
|
74
|
+
branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").strip()
|
|
75
|
+
changed = [
|
|
76
|
+
line.strip()
|
|
77
|
+
for line in _git(repo, "status", "--porcelain").splitlines()
|
|
78
|
+
if line.strip()
|
|
79
|
+
]
|
|
80
|
+
return TreeState(
|
|
81
|
+
name=name,
|
|
82
|
+
branch=None if branch == "HEAD" else branch,
|
|
83
|
+
commit=_git(repo, "rev-parse", "HEAD").strip(),
|
|
84
|
+
changed_files=changed,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def file_hash(path: Path) -> str:
|
|
89
|
+
"""The sha256 of the bytes of a file, empty for a file which does not exist."""
|
|
90
|
+
try:
|
|
91
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
92
|
+
except FileNotFoundError:
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def file_state(path: Path) -> tuple[Workflow, str]:
|
|
97
|
+
"""The workflow in a file and the sha256 of its bytes.
|
|
98
|
+
|
|
99
|
+
A missing file is a new workflow named by the file stem, with no tasks, and
|
|
100
|
+
the empty hash.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
path: The workflow file.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The workflow and the hash of the file bytes.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
WorkflowError: When the file is not a valid workflow.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
data = path.read_bytes()
|
|
113
|
+
except FileNotFoundError:
|
|
114
|
+
return Workflow(name=path.stem, tasks=[]), ""
|
|
115
|
+
return load_workflow(path), hashlib.sha256(data).hexdigest()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Editor:
|
|
119
|
+
"""One edit session of one workflow file."""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
client: Client,
|
|
124
|
+
path: Path,
|
|
125
|
+
sources: dict[str, Path],
|
|
126
|
+
group: str | None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Create the editor, the session is opened by `open`.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
client: The client of the service.
|
|
132
|
+
path: The workflow file.
|
|
133
|
+
sources: Working trees by source name.
|
|
134
|
+
group: Group of the runs, when the request has none.
|
|
135
|
+
"""
|
|
136
|
+
self.client = client
|
|
137
|
+
self.path = path.expanduser().resolve()
|
|
138
|
+
self.sources = sources
|
|
139
|
+
self.group = group
|
|
140
|
+
self.session_id = ""
|
|
141
|
+
self._sent: EditState | None = None
|
|
142
|
+
self._last: tuple[Workflow, str] | None = None
|
|
143
|
+
# the results of handled requests which are not posted yet, by request id
|
|
144
|
+
self._results: dict[str, EditResult] = {}
|
|
145
|
+
# the requests handled which the service may still list: never handled again
|
|
146
|
+
self._handled: set[str] = set()
|
|
147
|
+
|
|
148
|
+
def _trees(self) -> list[TreeState]:
|
|
149
|
+
"""The state of the trees; the last one sent when git fails in one."""
|
|
150
|
+
try:
|
|
151
|
+
return [tree_state(name, repo) for name, repo in self.sources.items()]
|
|
152
|
+
except RuntimeError as error:
|
|
153
|
+
logger.warning("the state of the trees cannot be read: %s", error)
|
|
154
|
+
return self._sent.tree if self._sent is not None else []
|
|
155
|
+
|
|
156
|
+
def open(self) -> EditSession:
|
|
157
|
+
"""Snapshot the sources and open the session."""
|
|
158
|
+
workflow, digest = file_state(self.path)
|
|
159
|
+
provenance = [
|
|
160
|
+
snapshot(repo, self.client.store, name)
|
|
161
|
+
for name, repo in self.sources.items()
|
|
162
|
+
]
|
|
163
|
+
trees = self._trees()
|
|
164
|
+
session = self.client.open_edit(
|
|
165
|
+
OpenEdit(
|
|
166
|
+
host=socket.gethostname(),
|
|
167
|
+
path=str(self.path),
|
|
168
|
+
workflow=workflow,
|
|
169
|
+
file_sha256=digest,
|
|
170
|
+
sources=provenance,
|
|
171
|
+
tree=trees,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
self.session_id = session.id
|
|
175
|
+
self._sent = EditState(workflow=workflow, file_sha256=digest, tree=trees)
|
|
176
|
+
self._last = (workflow, digest)
|
|
177
|
+
return session
|
|
178
|
+
|
|
179
|
+
def _send(self, state: EditState) -> None:
|
|
180
|
+
self.client.edit_state(self.session_id, state)
|
|
181
|
+
self._sent = state.model_copy(update={"sources": None})
|
|
182
|
+
self._last = (state.workflow, state.file_sha256)
|
|
183
|
+
|
|
184
|
+
def step(self, timeout: float) -> None:
|
|
185
|
+
"""Poll once for requests and answer them, then watch the file and trees.
|
|
186
|
+
|
|
187
|
+
A request is handled once: its result is kept until it is posted, so that
|
|
188
|
+
a post which failed is posted again by the next step, and its id is kept
|
|
189
|
+
while the service lists it, so that a request whose result was refused is
|
|
190
|
+
not done twice.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
timeout: Seconds to wait for a request, at most `EDIT_WATCH_S`: the
|
|
194
|
+
poll is the interval at which the file and trees are watched.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
httpx.TransportError: When the service is not reachable.
|
|
198
|
+
ClientError: When the service refuses the poll or a post of a result
|
|
199
|
+
with a status of `RETRY_STATUS`.
|
|
200
|
+
"""
|
|
201
|
+
self._post_results()
|
|
202
|
+
poll = min(timeout, EDIT_WATCH_S)
|
|
203
|
+
requests = self.client.edit_requests(self.session_id, poll)
|
|
204
|
+
for request in requests:
|
|
205
|
+
if request.id in self._handled:
|
|
206
|
+
continue
|
|
207
|
+
self._handled.add(request.id)
|
|
208
|
+
with self._heartbeat():
|
|
209
|
+
self._results[request.id] = self.handle(request)
|
|
210
|
+
self._post_results()
|
|
211
|
+
# a handled request the service no longer lists is answered or dropped
|
|
212
|
+
self._handled &= {r.id for r in requests} | set(self._results)
|
|
213
|
+
self._watch()
|
|
214
|
+
|
|
215
|
+
def _post_results(self) -> None:
|
|
216
|
+
"""Post the results not posted yet; a request gone (404) is skipped."""
|
|
217
|
+
for request_id, result in list(self._results.items()):
|
|
218
|
+
try:
|
|
219
|
+
self.client.edit_result(self.session_id, request_id, result)
|
|
220
|
+
except ClientError as error:
|
|
221
|
+
if error.status in RETRY_STATUS:
|
|
222
|
+
raise
|
|
223
|
+
if error.status == 404:
|
|
224
|
+
logger.warning(
|
|
225
|
+
"the request %s was no longer waited for: %s",
|
|
226
|
+
request_id,
|
|
227
|
+
error.detail,
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
logger.warning(
|
|
231
|
+
"the result of request %s was refused: %s", request_id, error
|
|
232
|
+
)
|
|
233
|
+
del self._results[request_id]
|
|
234
|
+
|
|
235
|
+
@contextlib.contextmanager
|
|
236
|
+
def _heartbeat(self) -> Generator[None]:
|
|
237
|
+
"""Poll without waiting every `EDIT_WATCH_S` while a request is handled.
|
|
238
|
+
|
|
239
|
+
The service takes a poll as the sign that the command is alive, and a run
|
|
240
|
+
(plans, snapshots, the submission) may take longer than `EDIT_CONNECTED_S`.
|
|
241
|
+
The polls of the heartbeat only keep the session connected: a request they
|
|
242
|
+
bring is answered by the next step.
|
|
243
|
+
"""
|
|
244
|
+
done = threading.Event()
|
|
245
|
+
|
|
246
|
+
def beat() -> None:
|
|
247
|
+
while not done.wait(EDIT_WATCH_S):
|
|
248
|
+
try:
|
|
249
|
+
self.client.edit_requests(self.session_id, 0)
|
|
250
|
+
except (httpx.TransportError, ClientError) as error:
|
|
251
|
+
logger.warning("the heartbeat of the session failed: %s", error)
|
|
252
|
+
|
|
253
|
+
thread = threading.Thread(target=beat, name="edit-heartbeat", daemon=True)
|
|
254
|
+
thread.start()
|
|
255
|
+
interrupted = False
|
|
256
|
+
try:
|
|
257
|
+
yield
|
|
258
|
+
except KeyboardInterrupt:
|
|
259
|
+
interrupted = True # a second Ctrl-C does not wait for a poll in flight
|
|
260
|
+
raise
|
|
261
|
+
finally:
|
|
262
|
+
done.set()
|
|
263
|
+
if not interrupted:
|
|
264
|
+
thread.join(EDIT_WATCH_S)
|
|
265
|
+
|
|
266
|
+
def _watch(self) -> None:
|
|
267
|
+
"""Send the file and the trees when they changed since they were sent.
|
|
268
|
+
|
|
269
|
+
A file which is not a valid workflow keeps the workflow and the hash last
|
|
270
|
+
sent, the trees are sent still; a refusal of the service is a warning, the
|
|
271
|
+
next step sends again.
|
|
272
|
+
"""
|
|
273
|
+
if self._last is None:
|
|
274
|
+
return
|
|
275
|
+
try:
|
|
276
|
+
workflow, digest = file_state(self.path)
|
|
277
|
+
except WorkflowError as error:
|
|
278
|
+
logger.warning("the workflow file cannot be read yet: %s", error)
|
|
279
|
+
workflow, digest = self._last
|
|
280
|
+
state = EditState(workflow=workflow, file_sha256=digest, tree=self._trees())
|
|
281
|
+
if state == self._sent:
|
|
282
|
+
return
|
|
283
|
+
try:
|
|
284
|
+
self._send(state)
|
|
285
|
+
except ClientError as error:
|
|
286
|
+
logger.warning("the state could not be sent: %s", error)
|
|
287
|
+
|
|
288
|
+
def handle(self, request: EditRequest) -> EditResult:
|
|
289
|
+
"""Do one request of the browser: save the file or run the workflow."""
|
|
290
|
+
if request.kind == "save":
|
|
291
|
+
return self._save(request)
|
|
292
|
+
return self._run(request)
|
|
293
|
+
|
|
294
|
+
def _save(self, request: EditRequest) -> EditResult:
|
|
295
|
+
if not request.force and request.base != file_hash(self.path):
|
|
296
|
+
return EditResult(ok=False, status=409, detail=CONFLICT)
|
|
297
|
+
data = workflow_yaml(request.workflow).encode()
|
|
298
|
+
tmp = self.path.parent / f".{self.path.name}.{secrets.token_hex(4)}.tmp"
|
|
299
|
+
try:
|
|
300
|
+
tmp.write_bytes(data)
|
|
301
|
+
with contextlib.suppress(FileNotFoundError):
|
|
302
|
+
shutil.copymode(self.path, tmp)
|
|
303
|
+
os.replace(tmp, self.path)
|
|
304
|
+
except OSError as error:
|
|
305
|
+
tmp.unlink(missing_ok=True)
|
|
306
|
+
return EditResult(ok=False, status=422, detail=str(error))
|
|
307
|
+
return EditResult(ok=True, file_sha256=hashlib.sha256(data).hexdigest())
|
|
308
|
+
|
|
309
|
+
def _run(self, request: EditRequest) -> EditResult:
|
|
310
|
+
try:
|
|
311
|
+
summary = self.client.submit(
|
|
312
|
+
request.workflow,
|
|
313
|
+
self.sources,
|
|
314
|
+
request.group or self.group,
|
|
315
|
+
request.split,
|
|
316
|
+
request.reserve,
|
|
317
|
+
)
|
|
318
|
+
except ClientError as error:
|
|
319
|
+
return EditResult(ok=False, status=error.status, detail=error.detail)
|
|
320
|
+
except (ValueError, RuntimeError, OSError) as error:
|
|
321
|
+
return EditResult(ok=False, status=422, detail=str(error))
|
|
322
|
+
try:
|
|
323
|
+
sources = self.client.status(summary.id).sources
|
|
324
|
+
if self._last is not None:
|
|
325
|
+
workflow, digest = self._last
|
|
326
|
+
self._send(
|
|
327
|
+
EditState(
|
|
328
|
+
workflow=workflow,
|
|
329
|
+
file_sha256=digest,
|
|
330
|
+
tree=self._trees(),
|
|
331
|
+
sources=sources,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
except ClientError, OSError, httpx.TransportError, RuntimeError:
|
|
335
|
+
logger.warning("the sources of run %s could not be sent", summary.id)
|
|
336
|
+
return EditResult(ok=True, run=summary)
|
|
337
|
+
|
|
338
|
+
def close(self) -> None:
|
|
339
|
+
"""Close the session, best effort: a failure is logged.
|
|
340
|
+
|
|
341
|
+
The close waits at most `EDIT_CLOSE_TIMEOUT_S`; a session which was not
|
|
342
|
+
closed shows not connected once it was not polled for `EDIT_CONNECTED_S`.
|
|
343
|
+
"""
|
|
344
|
+
if not self.session_id:
|
|
345
|
+
return
|
|
346
|
+
try:
|
|
347
|
+
self.client.close_edit(self.session_id, EDIT_CLOSE_TIMEOUT_S)
|
|
348
|
+
except (httpx.TransportError, ClientError) as error:
|
|
349
|
+
logger.warning("the session could not be closed: %s", error)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _pause(seconds: float, should_stop: Callable[[], bool]) -> None:
|
|
353
|
+
"""Sleep, in slices, so that a stop is noticed promptly."""
|
|
354
|
+
end = time.monotonic() + seconds
|
|
355
|
+
while not should_stop() and (left := end - time.monotonic()) > 0:
|
|
356
|
+
time.sleep(min(0.2, left))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def run_edit(
|
|
360
|
+
client: Client,
|
|
361
|
+
path: Path,
|
|
362
|
+
sources: dict[str, Path],
|
|
363
|
+
group: str | None,
|
|
364
|
+
*,
|
|
365
|
+
should_stop: Callable[[], bool] = lambda: False,
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Open a session, print its link and serve it until stopped.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
client: The client of the service.
|
|
371
|
+
path: The workflow file.
|
|
372
|
+
sources: Working trees by source name.
|
|
373
|
+
group: Group of the runs.
|
|
374
|
+
should_stop: Checked after every step, true ends the loop.
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
KeyboardInterrupt: A second Ctrl-C: it ends the command at once, without
|
|
378
|
+
closing the session, which shows not connected after
|
|
379
|
+
`EDIT_CONNECTED_S`.
|
|
380
|
+
"""
|
|
381
|
+
editor = Editor(client, path, sources, group)
|
|
382
|
+
session = editor.open()
|
|
383
|
+
typer.echo(f"Editing {session.path} on {session.host}")
|
|
384
|
+
typer.echo(f"Open {client.url}/ui/#/edit/{session.id}")
|
|
385
|
+
backoff = BACKOFF_START_S
|
|
386
|
+
interrupted = False
|
|
387
|
+
try:
|
|
388
|
+
while not should_stop():
|
|
389
|
+
try:
|
|
390
|
+
editor.step(EDIT_WATCH_S)
|
|
391
|
+
backoff = BACKOFF_START_S
|
|
392
|
+
continue
|
|
393
|
+
except httpx.TransportError as error:
|
|
394
|
+
logger.warning("the service is not reachable: %s", error)
|
|
395
|
+
except ClientError as error:
|
|
396
|
+
# a proxy or a restarting service; any other refusal ends the command
|
|
397
|
+
if error.status not in RETRY_STATUS:
|
|
398
|
+
raise
|
|
399
|
+
logger.warning("the service is not available: %s", error)
|
|
400
|
+
typer.echo(f"The service is not reachable, retrying in {backoff:.0f} s")
|
|
401
|
+
_pause(backoff, should_stop)
|
|
402
|
+
backoff = min(backoff * 2, BACKOFF_MAX_S)
|
|
403
|
+
except KeyboardInterrupt:
|
|
404
|
+
interrupted = True
|
|
405
|
+
raise
|
|
406
|
+
finally:
|
|
407
|
+
if not interrupted:
|
|
408
|
+
editor.close()
|