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/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()