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.
- toil/__init__.py +4 -4
- toil/batchSystems/options.py +1 -0
- toil/batchSystems/slurm.py +227 -83
- toil/common.py +161 -45
- toil/cwl/cwltoil.py +31 -10
- toil/job.py +47 -38
- toil/jobStores/aws/jobStore.py +46 -10
- toil/lib/aws/session.py +14 -3
- toil/lib/aws/utils.py +92 -35
- toil/lib/dockstore.py +379 -0
- toil/lib/ec2nodes.py +3 -2
- toil/lib/history.py +1271 -0
- toil/lib/history_submission.py +681 -0
- toil/lib/io.py +22 -1
- toil/lib/misc.py +18 -0
- toil/lib/retry.py +10 -10
- toil/lib/{integration.py → trs.py} +95 -46
- toil/lib/web.py +38 -0
- toil/options/common.py +17 -2
- toil/options/cwl.py +10 -0
- toil/provisioners/gceProvisioner.py +4 -4
- toil/server/cli/wes_cwl_runner.py +3 -3
- toil/server/utils.py +2 -3
- toil/statsAndLogging.py +35 -1
- toil/test/batchSystems/test_slurm.py +172 -2
- toil/test/cwl/conftest.py +39 -0
- toil/test/cwl/cwlTest.py +105 -2
- toil/test/cwl/optional-file.cwl +18 -0
- toil/test/lib/test_history.py +212 -0
- toil/test/lib/test_trs.py +161 -0
- toil/test/wdl/wdltoil_test.py +1 -1
- toil/version.py +10 -10
- toil/wdl/wdltoil.py +23 -9
- toil/worker.py +113 -33
- {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/METADATA +9 -4
- {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/RECORD +40 -34
- {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/WHEEL +1 -1
- toil/test/lib/test_integration.py +0 -104
- {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/LICENSE +0 -0
- {toil-8.0.0.dist-info → toil-8.1.0b1.dist-info}/entry_points.txt +0 -0
- {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
|
+
|