toil 8.0.0__py3-none-any.whl → 8.1.0b1__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.
Files changed (41) hide show
  1. toil/__init__.py +4 -4
  2. toil/batchSystems/options.py +1 -0
  3. toil/batchSystems/slurm.py +227 -83
  4. toil/common.py +161 -45
  5. toil/cwl/cwltoil.py +31 -10
  6. toil/job.py +47 -38
  7. toil/jobStores/aws/jobStore.py +46 -10
  8. toil/lib/aws/session.py +14 -3
  9. toil/lib/aws/utils.py +92 -35
  10. toil/lib/dockstore.py +379 -0
  11. toil/lib/ec2nodes.py +3 -2
  12. toil/lib/history.py +1271 -0
  13. toil/lib/history_submission.py +681 -0
  14. toil/lib/io.py +22 -1
  15. toil/lib/misc.py +18 -0
  16. toil/lib/retry.py +10 -10
  17. toil/lib/{integration.py → trs.py} +95 -46
  18. toil/lib/web.py +38 -0
  19. toil/options/common.py +17 -2
  20. toil/options/cwl.py +10 -0
  21. toil/provisioners/gceProvisioner.py +4 -4
  22. toil/server/cli/wes_cwl_runner.py +3 -3
  23. toil/server/utils.py +2 -3
  24. toil/statsAndLogging.py +35 -1
  25. toil/test/batchSystems/test_slurm.py +172 -2
  26. toil/test/cwl/conftest.py +39 -0
  27. toil/test/cwl/cwlTest.py +105 -2
  28. toil/test/cwl/optional-file.cwl +18 -0
  29. toil/test/lib/test_history.py +212 -0
  30. toil/test/lib/test_trs.py +161 -0
  31. toil/test/wdl/wdltoil_test.py +1 -1
  32. toil/version.py +10 -10
  33. toil/wdl/wdltoil.py +23 -9
  34. toil/worker.py +113 -33
  35. {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/METADATA +9 -4
  36. {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/RECORD +40 -34
  37. {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/WHEEL +1 -1
  38. toil/test/lib/test_integration.py +0 -104
  39. {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/LICENSE +0 -0
  40. {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/entry_points.txt +0 -0
  41. {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,681 @@
1
+ # Copyright (C) 2025 Regents of the University of California
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """
16
+ Contains logic for generating Toil usage history reports to send back to Toil
17
+ HQ (via Dockstore), and for working out if the user wants to send them.
18
+ """
19
+
20
+ import asyncio
21
+ import collections
22
+ import io
23
+ import json
24
+ import logging
25
+ import multiprocessing
26
+ import os
27
+ import pathlib
28
+ import subprocess
29
+ import sys
30
+ import textwrap
31
+ from typing import Any, Literal, Optional, TypeVar, Union
32
+
33
+ from toil.lib.dockstore import (
34
+ send_metrics,
35
+ get_metrics_url,
36
+ pack_workflow_metrics,
37
+ pack_single_task_metrics,
38
+ pack_workflow_task_set_metrics,
39
+ RunExecution,
40
+ TaskExecutions
41
+ )
42
+ from toil.lib.history import HistoryManager, WorkflowAttemptSummary, JobAttemptSummary
43
+ from toil.lib.misc import unix_seconds_to_local_time
44
+ from toil.lib.trs import parse_trs_spec
45
+ from toil.lib.io import get_toil_home
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ def workflow_execution_id(workflow_attempt: WorkflowAttemptSummary) -> str:
50
+ """
51
+ Get the execution ID for a workflow attempt.
52
+
53
+ Result will follow Dockstore's rules.
54
+
55
+ Deterministic.
56
+ """
57
+
58
+ return workflow_attempt.workflow_id.replace("-", "_") + "_attempt_" + str(workflow_attempt.attempt_number)
59
+
60
+ def workflow_task_set_execution_id(workflow_attempt: WorkflowAttemptSummary) -> str:
61
+ """
62
+ Get the execution ID for a workflow attempt's collection of tasks.
63
+
64
+ Result will follow Dockstore's rules.
65
+
66
+ Deterministic.
67
+ """
68
+
69
+ return workflow_execution_id(workflow_attempt) + "_tasks"
70
+
71
+ def job_execution_id(job_attempt: JobAttemptSummary) -> str:
72
+ """
73
+ Get the execution ID for a job attempt.
74
+
75
+ Result will follow Dockstore's rules.
76
+
77
+ Deterministic.
78
+ """
79
+
80
+ return job_attempt.id.replace("-", "_")
81
+
82
+ def get_parsed_trs_spec(workflow_attempt: WorkflowAttemptSummary) -> tuple[str, str]:
83
+ """
84
+ Get the TRS ID and version of the workflow, or raise an error.
85
+
86
+ :returns: The TRS ID and the TRS version of the wrokflow run.
87
+ :raises: ValueError if the workflow does not have a TRS spec or if the spec
88
+ does not contain a version.
89
+ """
90
+ # If it's submittable the TRS spec will be filled in.
91
+ if workflow_attempt.workflow_trs_spec is None:
92
+ raise ValueError("Workflow cannot be submitted without a TRS spec")
93
+ trs_id, trs_version = parse_trs_spec(workflow_attempt.workflow_trs_spec)
94
+ if trs_version is None:
95
+ raise ValueError("Workflow stored in history with TRS ID but without TRS version")
96
+ return trs_id, trs_version
97
+
98
+ class Submission:
99
+ """
100
+ Class holding a package of information to submit to Dockstore, and the
101
+ information needed to mark the workflows/tasks as submitted if it is accepted.
102
+
103
+ Acceptance is at the TRS id/version combination level within the
104
+ Submission, since we need one request per TRS ID/version combination.
105
+ """
106
+
107
+ def __init__(self) -> None:
108
+ """
109
+ Create a new empty submission.
110
+ """
111
+
112
+ # This collects everything by TRS ID and version, and keeps separate lists for workflows and for task sets.
113
+ # Each thing to be submitted comes along with the reference to what in the database to makr submitted when it goes in.
114
+ self.data: dict[tuple[str, str], tuple[list[tuple[RunExecution, str, int]], list[tuple[TaskExecutions, list[str]]]]] = collections.defaultdict(lambda: ([], []))
115
+
116
+
117
+ def add_workflow_attempt(self, workflow_attempt: WorkflowAttemptSummary) -> None:
118
+ """
119
+ Add a workflow attempt to the submission.
120
+
121
+ May raise an exception if the workflow attempt is not well-formed.
122
+ """
123
+ trs_id, trs_version = get_parsed_trs_spec(workflow_attempt)
124
+
125
+ # Figure out what kind of job store was used.
126
+ job_store_type: Optional[str] = None
127
+ try:
128
+ from toil.common import Toil
129
+ job_store_type = Toil.parseLocator(workflow_attempt.workflow_job_store)[0]
130
+ if job_store_type not in Toil.JOB_STORE_TYPES:
131
+ # Make sure we don't send typo'd job store types in.
132
+ job_store_type = None
133
+ except RuntimeError:
134
+ # Could not parse the stored locator.
135
+ pass
136
+
137
+ # Pack it up
138
+ workflow_metrics = pack_workflow_metrics(
139
+ workflow_execution_id(workflow_attempt),
140
+ workflow_attempt.start_time,
141
+ workflow_attempt.runtime,
142
+ workflow_attempt.succeeded,
143
+ batch_system=workflow_attempt.batch_system,
144
+ caching=workflow_attempt.caching,
145
+ toil_version=workflow_attempt.toil_version,
146
+ python_version=workflow_attempt.python_version,
147
+ platform_system=workflow_attempt.platform_system,
148
+ platform_machine=workflow_attempt.platform_machine,
149
+ job_store_type=job_store_type
150
+ )
151
+
152
+ # Add it to the package
153
+ self.data[(trs_id, trs_version)][0].append((workflow_metrics, workflow_attempt.workflow_id, workflow_attempt.attempt_number))
154
+
155
+ def add_job_attempts(self, workflow_attempt: WorkflowAttemptSummary, job_attempts: list[JobAttemptSummary]) -> None:
156
+ """
157
+ Add the job attempts for a workflow attempt to the submission.
158
+ """
159
+
160
+ trs_id, trs_version = get_parsed_trs_spec(workflow_attempt)
161
+
162
+ # Pack it up
163
+ per_task_metrics = [
164
+ pack_single_task_metrics(
165
+ job_execution_id(job_attempt),
166
+ job_attempt.start_time,
167
+ job_attempt.runtime,
168
+ job_attempt.succeeded,
169
+ job_name=job_attempt.job_name,
170
+ cores=job_attempt.cores,
171
+ cpu_seconds=job_attempt.cpu_seconds,
172
+ memory_bytes=job_attempt.memory_bytes,
173
+ disk_bytes=job_attempt.disk_bytes
174
+ ) for job_attempt in job_attempts
175
+ ]
176
+ task_set_metrics = pack_workflow_task_set_metrics(workflow_task_set_execution_id(workflow_attempt), workflow_attempt.start_time, per_task_metrics)
177
+
178
+ # Add it to the package
179
+ self.data[(trs_id, trs_version)][1].append((task_set_metrics, [job_attempt.id for job_attempt in job_attempts]))
180
+
181
+ def empty(self) -> bool:
182
+ """
183
+ Return True if there is nothing to actually submit.
184
+ """
185
+
186
+ for _, (workflow_metrics, task_metrics) in self.data.items():
187
+ # For each TRS ID and version we might have data for
188
+ if len(workflow_metrics) > 0:
189
+ return False
190
+ if len(task_metrics) > 0:
191
+ # Even if there's no tasks in each, we can report that we ran no tasks
192
+ return False
193
+
194
+ return True
195
+
196
+ def report(self) -> str:
197
+ """
198
+ Compose a multi-line human-readable string report about what will be submitted.
199
+ """
200
+
201
+ stream = io.StringIO()
202
+
203
+ for (trs_id, trs_version), (workflow_metrics, task_metrics) in self.data.items():
204
+ stream.write(f"For workflow {trs_id} version {trs_version}:\n")
205
+ if len(workflow_metrics) > 0:
206
+ stream.write(" Workflow executions:\n")
207
+ for workflow_metric, _, _ in workflow_metrics:
208
+ stream.write(textwrap.indent(json.dumps(workflow_metric, indent=2), ' '))
209
+ if len(task_metrics) > 0:
210
+ stream.write(" Task execution sets:\n")
211
+ for tasks_metric, _ in task_metrics:
212
+ stream.write(textwrap.indent(json.dumps(tasks_metric, indent=2), ' '))
213
+
214
+ return stream.getvalue()
215
+
216
+ def submit(self) -> bool:
217
+ """
218
+ Send in workflow and task information to Dockstore.
219
+
220
+ Assumes the user has approved this already.
221
+
222
+ If submission succeeds for a TRS id/version combination, records this in the history database.
223
+
224
+ Handles errors internally. Will not raise if the submission doesn't go through.
225
+
226
+ :returns: False if there was any error, True if submission was accepted and recorded locally.
227
+ """
228
+
229
+ all_submitted_and_marked = True
230
+
231
+ for (trs_id, trs_version), (workflow_metrics, task_metrics) in self.data.items():
232
+ submitted = False
233
+ try:
234
+ send_metrics(trs_id, trs_version, [item[0] for item in workflow_metrics], [item[0] for item in task_metrics])
235
+ submitted = True
236
+ except:
237
+ logger.exception("Could not submit to Dockstore")
238
+ all_submitted_and_marked = False
239
+ # Ignore failed submissions and keep working on other ones.
240
+
241
+ if submitted:
242
+ for _, workflow_id, attempt_number in workflow_metrics:
243
+ # For each workflow attempt of this TRS ID/version that we successfully submitted, mark it
244
+ try:
245
+ HistoryManager.mark_workflow_attempt_submitted(workflow_id, attempt_number)
246
+ except:
247
+ logger.exception("Could not mark workflow attempt submitted")
248
+ all_submitted_and_marked = False
249
+ # If we can't mark one, keep marking others
250
+ for _, job_attempt_ids in task_metrics:
251
+ # For each colleciton of task attempts of this TRS ID/version that we successfully submitted, mark it
252
+ try:
253
+ HistoryManager.mark_job_attempts_submitted(job_attempt_ids)
254
+ except:
255
+ logger.exception("Could not mark job attempts submitted")
256
+ all_submitted_and_marked = False
257
+ # If we can't mark one set, keep marking others
258
+
259
+ return all_submitted_and_marked
260
+
261
+
262
+ def create_history_submission(batch_size: int = 10, desired_tasks: int = 0) -> Submission:
263
+ """
264
+ Make a package of data about recent workflow runs to send in.
265
+
266
+ Returns a Submission object that remembers how to update the history
267
+ database to record when it is successfully submitted.
268
+
269
+ :param batch_size: Number of workflows to try and submit in one request.
270
+ :param desired_tasks: Number of tasks to try and put into a task submission
271
+ batch. Use 0 to not submit any task information.
272
+ """
273
+
274
+ # Collect together some workflows and some lists of tasks into a submission.
275
+ submission = Submission()
276
+
277
+ # We don't want to submit many large workflows' tasks in one request. So we
278
+ # count how many tasks we've colected so far.
279
+ total_tasks = 0
280
+
281
+ for workflow_attempt in HistoryManager.get_submittable_workflow_attempts(limit=batch_size):
282
+ try:
283
+ submission.add_workflow_attempt(workflow_attempt)
284
+ except:
285
+ logger.exception("Could not compose metrics report for workflow execution")
286
+ # Ignore failed submissions and keep working on other ones.
287
+ # TODO: What happens when they accumulate and fill the whole batch from the database?
288
+
289
+ # TODO: Dockstore limits request size to 60k. See
290
+ # <https://github.com/dockstore/compose_setup/blob/a14cd1d390f4ae1e14a8ba6e36ec06ce5fe2604e/templates/default.nginx_http.shared.conf.template#L15>.
291
+ # Apply that limit to the submission and don't add more attempts if we
292
+ # would go over it.
293
+
294
+ # TODO: Once we have a way to deal with single runs possibly having more
295
+ # than 60k worth of tasks, and with Dockstore turning collections of tasks
296
+ # into extra workflow runs (see
297
+ # <https://ucsc-cgl.atlassian.net/browse/SEAB-6919>), set the default task
298
+ # limit to submit to be nonzero.
299
+
300
+ for workflow_attempt in HistoryManager.get_workflow_attempts_with_submittable_job_attempts(limit=batch_size):
301
+ job_attempts = HistoryManager.get_unsubmitted_job_attempts(workflow_attempt.workflow_id, workflow_attempt.attempt_number)
302
+
303
+ if desired_tasks == 0 or total_tasks + len(job_attempts) > desired_tasks:
304
+ # Don't add any more task sets to the submission
305
+ break
306
+
307
+ try:
308
+ submission.add_job_attempts(workflow_attempt, job_attempts)
309
+
310
+ total_tasks += len(job_attempts)
311
+ except:
312
+ logger.exception("Could not compose metrics report for workflow task set")
313
+ # Ignore failed submissions and keep working on other ones.
314
+ # TODO: What happens when they accumulate and fill the whole batch from the database?
315
+
316
+ return submission
317
+
318
+ def create_current_submission(workflow_id: str, attempt_number: int) -> Submission:
319
+ """
320
+ Make a package of data about the current workflow attempt to send in.
321
+
322
+ Useful if the user wants to submit workflow metrics just this time.
323
+ """
324
+
325
+ submission = Submission()
326
+ try:
327
+ workflow_attempt = HistoryManager.get_workflow_attempt(workflow_id, attempt_number)
328
+ if workflow_attempt is not None:
329
+ if not workflow_attempt.submitted_to_dockstore:
330
+ submission.add_workflow_attempt(workflow_attempt)
331
+ try:
332
+ job_attempts = HistoryManager.get_unsubmitted_job_attempts(workflow_attempt.workflow_id, workflow_attempt.attempt_number)
333
+ submission.add_job_attempts(workflow_attempt, job_attempts)
334
+ except:
335
+ logger.exception("Could not compose metrics report for workflow task set")
336
+ # Keep going with just the workflow.
337
+ except:
338
+ logger.exception("Could not compose metrics report for workflow execution")
339
+ # Keep going with an empty submission.
340
+
341
+ return submission
342
+
343
+ # We have dialog functions that MyPy knows can return strings from a possibly restricted set
344
+ KeyType = TypeVar('KeyType', bound=str)
345
+
346
+ def dialog_tkinter(title: str, text: str, options: dict[KeyType, str], timeout: float) -> Optional[KeyType]:
347
+ """
348
+ Display a dialog with tkinter.
349
+
350
+ Dialog will have the given title, text, and options.
351
+
352
+ Dialog will be displayed by a separate Python process to avoid a Mac dock
353
+ icon sticking around as long as Toil runs.
354
+
355
+ :param options: Dict from machine-readable option key to button text.
356
+ :returns: the key of the selected option, or None if the user declined to
357
+ select an option.
358
+ :raises: an exception if the dialog cannot be displayed.
359
+ """
360
+
361
+ # Multiprocessing queues aren't actually generic, but MyPy requires them to be.
362
+ result_queue: "multiprocessing.Queue[Union[Exception, Optional[KeyType]]]" = multiprocessing.Queue()
363
+ process = multiprocessing.Process(target=display_dialog_tkinter, args=(title, text, options, timeout, result_queue))
364
+ process.start()
365
+ result = result_queue.get()
366
+ process.join()
367
+
368
+ if isinstance(result, Exception):
369
+ raise result
370
+ else:
371
+ return result
372
+
373
+
374
+
375
+ def display_dialog_tkinter(title: str, text: str, options: dict[KeyType, str], timeout: float, result_queue: "multiprocessing.Queue[Union[Exception, Optional[KeyType]]]") -> None:
376
+ """
377
+ Display a dialog with tkinter in the current process.
378
+
379
+ Dialog will have the given title, text, and options.
380
+
381
+ :param options: Dict from machine-readable option key to button text.
382
+
383
+ Sends back either the key of the chosen option, or an exception, via the
384
+ result queue.
385
+ """
386
+
387
+ try:
388
+
389
+ FRAME_PADDING = 10
390
+ BUTTON_FRAME_PADDING = 5
391
+ DEFAULT_LABEL_WRAP = 400
392
+
393
+ import tkinter
394
+ from tkinter import ttk
395
+
396
+ # Get a root window
397
+ root = tkinter.Tk()
398
+ # Set the title
399
+ root.title(title)
400
+
401
+ def close_root() -> None:
402
+ """
403
+ Function to close the dialog window.
404
+ """
405
+ # Hide the window
406
+ root.withdraw()
407
+ # Now we want to exit the main loop. But if we do it right away the window hide never happens and the window stays open but not responding.
408
+ # So we schedule the root to go away.
409
+ root.after(100, root.destroy)
410
+
411
+
412
+ # Make a frame
413
+ frame = ttk.Frame(root, padding=FRAME_PADDING)
414
+ # Put it on a grid in the parent
415
+ frame.grid(row=0, column=0, sticky="nsew")
416
+
417
+ # Lay out a label in the frame's grid
418
+ label = ttk.Label(frame, text=text, wraplength=DEFAULT_LABEL_WRAP)
419
+ label.grid(column=0, row=0, sticky="nsew")
420
+
421
+ # Add a button frame below it so the buttons can be in columns narrower than the label
422
+ button_frame = ttk.Frame(frame, padding=BUTTON_FRAME_PADDING)
423
+ # Put it on a grid in the parent
424
+ button_frame.grid(row=1, column=0, sticky="sw")
425
+
426
+ # Use a list as a mutable slot
427
+ result: list[KeyType] = []
428
+ button_column = 0
429
+ for k, v in options.items():
430
+ # A function in here will capture k by reference. Sneak the current k
431
+ # in through a default argument, which is evaluated at definition time.
432
+ def setter(set_to: KeyType = k) -> None:
433
+ # Record the choice
434
+ result.append(set_to)
435
+ # Close the window.
436
+ close_root()
437
+ ttk.Button(button_frame, text=v, command=setter).grid(column=button_column, row=0)
438
+ button_column += 1
439
+
440
+ # Buttons do not grow as the window resizes.
441
+
442
+ # Say row 0 of the container frame gets all its extra height
443
+ frame.rowconfigure(0, weight=1)
444
+ # Say column 0 of the container frame gets all its extra width
445
+ frame.columnconfigure(0, weight=1)
446
+
447
+ # Say row 0 of the root gets all its extra height
448
+ root.rowconfigure(0, weight=1)
449
+ # Say column 0 of the root gets all its extra width
450
+ root.columnconfigure(0, weight=1)
451
+
452
+ # Make sure the window can't get too small for the content
453
+ def force_fit() -> None:
454
+ """
455
+ Make sure the root window is no smaller than its contents require.
456
+ """
457
+ # Give it a minimum size in inches, and also no smaller than the
458
+ # contents require. We can't use the required width exactly because
459
+ # that would never let us shrink and rewrap the label, so we use the
460
+ # button frame instead and add padding ourselves.
461
+ min_width = max(int(root.winfo_fpixels("2i")), button_frame.winfo_reqwidth() + BUTTON_FRAME_PADDING + FRAME_PADDING)
462
+ min_height = max(int(root.winfo_fpixels("1i")), root.winfo_reqheight())
463
+ root.minsize(min_width, min_height)
464
+
465
+ # TODO: If the window would be too tall for the screen, make it wider for the user?
466
+
467
+ # Rewrap the text as the window size changes
468
+ setattr(label, "last_width", 100)
469
+ # MyPy demands a generic argument here but Python won't be able to handle
470
+ # it until
471
+ # <https://github.com/python/cpython/commit/42a818912bdb367c4ec2b7d58c18db35f55ebe3b>
472
+ # ships.
473
+ def resize_label(event: "tkinter.Event[ttk.Label]") -> None:
474
+ """
475
+ If the label's width has changed, rewrap the text.
476
+
477
+ See <https://stackoverflow.com/a/71599924>
478
+ """
479
+ current_width = label.winfo_width()
480
+ if getattr(label, "last_width") != current_width:
481
+ setattr(label, "last_width", current_width)
482
+ label.config(wraplength=current_width)
483
+ # Update required height calculation based on new width
484
+ label.update_idletasks()
485
+ # Impose minimum height
486
+ force_fit()
487
+ label.bind('<Configure>', resize_label)
488
+
489
+ # Do root sizing
490
+ force_fit()
491
+
492
+ if timeout:
493
+ # If we run out of time, hide the window and move on without a choice.
494
+ root.after(int(timeout * 1000), close_root)
495
+
496
+ # Run the window's main loop
497
+ root.mainloop()
498
+
499
+ if len(result) == 0:
500
+ # User didn't click any buttons. Probably they don't want to deal with
501
+ # this right now.
502
+ result_queue.put(None)
503
+ else:
504
+ result_queue.put(result[0])
505
+ except Exception as e:
506
+ result_queue.put(e)
507
+
508
+ def dialog_tui(title: str, text: str, options: dict[KeyType, str], timeout: float) -> Optional[KeyType]:
509
+ """
510
+ Display a dialog in the terminal.
511
+
512
+ Dialog will have the given title, text, and options.
513
+
514
+ :param options: Dict from machine-readable option key to button text.
515
+ :returns: the key of the selected option, or None if the user declined to
516
+ select an option.
517
+ :raises: an exception if the dialog cannot be displayed.
518
+ """
519
+
520
+ # See
521
+ # <https://python-prompt-toolkit.readthedocs.io/en/master/pages/dialogs.html#button-dialog>
522
+ # for the dialog and
523
+ # <https://python-prompt-toolkit.readthedocs.io/en/master/pages/advanced_topics/input_hooks.html>
524
+ # for how we run concurrently with it.
525
+
526
+ from prompt_toolkit.shortcuts import button_dialog
527
+ from prompt_toolkit.eventloop.inputhook import set_eventloop_with_inputhook
528
+
529
+ # We take button options in the reverse order from how prompt_toolkit does it.
530
+ # TODO: This is not scrollable! What if there's more than 1 screen of text?
531
+ # TODO: Use come kind of ScrollablePane like <https://stackoverflow.com/q/68369073>
532
+ application = button_dialog(
533
+ title=title,
534
+ text=text,
535
+ buttons=[(v, k) for k, v in options.items()],
536
+ )
537
+
538
+ # Make the coroutine to run the dialog
539
+ application_coroutine = application.run_async()
540
+
541
+ async def exit_application_after_timeout() -> None:
542
+ """
543
+ Wait for the timeout to elapse and then close the application.
544
+ """
545
+ await asyncio.sleep(timeout)
546
+ # End the application and make it return None
547
+ application.exit()
548
+
549
+ # Make the coroutine to stop the dialog
550
+ timeout_coroutine = exit_application_after_timeout()
551
+
552
+ # To avoid messing around with a global event loop and instead just race
553
+ # some coroutines, we make our own event loop.
554
+ loop = asyncio.new_event_loop()
555
+
556
+ # Attach the coroutines to it so it tries to advance them
557
+ application_task = loop.create_task(application_coroutine)
558
+ timeout_task = loop.create_task(timeout_coroutine)
559
+
560
+ # This task finishes when the first task in the set finishes.
561
+ race_task = asyncio.wait({application_task, timeout_task}, return_when=asyncio.FIRST_COMPLETED)
562
+ # Run it until then
563
+ loop.run_until_complete(race_task)
564
+ # Maybe the application needs to finish its exit?
565
+ loop.run_until_complete(application_task)
566
+
567
+ # We no longer need the stopping task/coroutine
568
+ timeout_task.cancel()
569
+ # We don't need to await it because we cancel it.
570
+ # It is OK to cancel it if it is finished already.
571
+
572
+ return application_task.result()
573
+
574
+ # Define the dialog form in the abstract
575
+ Decision = Union[Literal["all"], Literal["current"], Literal["no"], Literal["never"]]
576
+
577
+ DIALOG_TITLE = "Publish Workflow Metrics on Dockstore.org?"
578
+
579
+ # We need to fromat the default config path in to the message, but we can't get
580
+ # it until we can close the circular import loop. So it's a placeholder here.
581
+ # We also leave this un-wrapped to let the dialog wrap it if needed.
582
+ DIALOG_TEXT = """
583
+ Would you like to publish execution metrics on Dockstore.org?
584
+
585
+ This includes information like a unique ID for the workflow execution and each job execution, the Tool Registry Service (TRS) ID of the workflow, the names of its jobs, when and for how long they run, how much CPU, memory, and disk they are allocated or use, whether they succeed or fail, the versions of Toil and Python used, the operating system platform and processor type, and which Toil or Toil plugin features are used.
586
+
587
+ Dockstore.org uses this information to prepare reports about how well workflows run in different environments, and what resources they need, in order to help users plan their workflow runs. The Toil developers also consult this information to see which Toil features are the most popular and how popular Toil is overall.
588
+
589
+ Note that publishing is PERMANENT! You WILL NOT be able to recall or un-publish any published metrics!
590
+
591
+ (You can change your choice by editing the "publishWorkflowMetrics" setting in the "{}" file. You can override it once with the "--publishWorkflowMetrics" command line option.)
592
+
593
+ All: Publish for this run and all future AND PAST runs of ALL workflows.
594
+ Yes: Publish for just this run, and ask again next time.
595
+ No: Do not publish anything now, but ask again next time.
596
+ Never: Do not publish anything and stop asking.
597
+ """.strip()
598
+
599
+ DIALOG_OPTIONS: dict[Decision, str] = {
600
+ "all": "All",
601
+ "current": "Yes",
602
+ "no": "No",
603
+ "never": "Never"
604
+ }
605
+
606
+ # Make sure the option texts are short enough; prompt_toolkit can only handle 10 characters.
607
+ for k, v in DIALOG_OPTIONS.items():
608
+ assert len(v) <= 10, f"Label for \"{k}\" dialog option is too long to work on all backends!"
609
+
610
+ # Make sure the options all have unique labels
611
+ assert len(set(DIALOG_OPTIONS.values())) == len(DIALOG_OPTIONS), "Labels for dialog options are not unique!"
612
+
613
+ # How many seconds should we show the dialog for before assuming the user means "no"?
614
+ DIALOG_TIMEOUT = 120
615
+
616
+ def ask_user_about_publishing_metrics() -> Union[Literal["all"], Literal["current"], Literal["no"]]:
617
+ """
618
+ Ask the user to set standing workflow submission consent.
619
+
620
+ If the user makes a persistent decision (always or never), save it to the default Toil config file.
621
+
622
+ :returns: The user's decision about when to publish metrics.
623
+ """
624
+
625
+ from toil.common import update_config, get_default_config_path
626
+
627
+ # Find the default config path to talk about or update
628
+ default_config_path = get_default_config_path()
629
+
630
+ # Actual chatting with the user will fill this in if possible
631
+ decision: Optional[Decision] = None
632
+
633
+ if sys.stdin.isatty() and sys.stderr.isatty():
634
+ # IO is not redirected (except maybe JSON to a file). We might be able to raise the user.
635
+
636
+ # TODO: Get a lock
637
+
638
+ for strategy in [dialog_tkinter, dialog_tui]:
639
+ # TODO: Add a graphical strategy that, unlike tkinter (which needs
640
+ # homebrew python-tk), can work out of the box on Mac! AppleScript
641
+ # and builtin NSDialog can only use 3 buttons. AppleScript choice
642
+ # dialogs can't hold enough text. We don't really want to bring in
643
+ # e.g. QT for this.
644
+ try:
645
+ strategy_decision = strategy(DIALOG_TITLE, DIALOG_TEXT.format(default_config_path), DIALOG_OPTIONS, DIALOG_TIMEOUT)
646
+ if strategy_decision is None:
647
+ # User declined to choose. Treat that as choosing "no".
648
+ logger.warning("User did not make a selection. Assuming \"no\".")
649
+ strategy_decision = "no"
650
+ decision = strategy_decision
651
+ break
652
+ except:
653
+ logger.exception("Could not use %s", strategy)
654
+
655
+ if decision is None:
656
+ # If we think we should be able to reach the user, but we can't, fail and make them tell us via option
657
+ # TODO: Provide a command (or a general toil config editing command) to manage this setting
658
+ logger.critical("Decide whether to publish workflow metrics and pass --publishWorkflowMetrics=[all|current|no]")
659
+ sys.exit(1)
660
+
661
+ if decision is None:
662
+ # We can't reach the user
663
+ decision = "no"
664
+
665
+ result = decision if decision != "never" else "no"
666
+ if decision in ("all", "never"):
667
+ # These are persistent and should save to the config
668
+ update_config(default_config_path, "publishWorkflowMetrics", result)
669
+
670
+ return result
671
+
672
+
673
+
674
+
675
+
676
+
677
+
678
+
679
+
680
+
681
+