toil 8.2.0__py3-none-any.whl → 9.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.
- toil/batchSystems/abstractBatchSystem.py +13 -5
- toil/batchSystems/abstractGridEngineBatchSystem.py +17 -5
- toil/batchSystems/kubernetes.py +13 -2
- toil/batchSystems/mesos/batchSystem.py +33 -2
- toil/batchSystems/registry.py +15 -118
- toil/batchSystems/slurm.py +191 -16
- toil/common.py +20 -1
- toil/cwl/cwltoil.py +97 -119
- toil/cwl/utils.py +103 -3
- toil/fileStores/__init__.py +1 -1
- toil/fileStores/abstractFileStore.py +5 -2
- toil/fileStores/cachingFileStore.py +1 -1
- toil/job.py +30 -14
- toil/jobStores/abstractJobStore.py +35 -255
- toil/jobStores/aws/jobStore.py +864 -1964
- toil/jobStores/aws/utils.py +24 -270
- toil/jobStores/fileJobStore.py +2 -1
- toil/jobStores/googleJobStore.py +32 -13
- toil/jobStores/utils.py +0 -327
- toil/leader.py +27 -22
- toil/lib/accelerators.py +1 -1
- toil/lib/aws/config.py +22 -0
- toil/lib/aws/s3.py +477 -9
- toil/lib/aws/utils.py +22 -33
- toil/lib/checksum.py +88 -0
- toil/lib/conversions.py +33 -31
- toil/lib/directory.py +217 -0
- toil/lib/ec2.py +97 -29
- toil/lib/exceptions.py +2 -1
- toil/lib/expando.py +2 -2
- toil/lib/generatedEC2Lists.py +138 -19
- toil/lib/io.py +33 -2
- toil/lib/memoize.py +21 -7
- toil/lib/misc.py +1 -1
- toil/lib/pipes.py +385 -0
- toil/lib/plugins.py +106 -0
- toil/lib/retry.py +1 -1
- toil/lib/threading.py +1 -1
- toil/lib/url.py +320 -0
- toil/lib/web.py +4 -5
- toil/options/cwl.py +13 -1
- toil/options/runner.py +17 -10
- toil/options/wdl.py +12 -1
- toil/provisioners/__init__.py +5 -2
- toil/provisioners/aws/__init__.py +43 -36
- toil/provisioners/aws/awsProvisioner.py +47 -15
- toil/provisioners/node.py +60 -12
- toil/resource.py +3 -13
- toil/server/app.py +12 -6
- toil/server/cli/wes_cwl_runner.py +2 -2
- toil/server/wes/abstract_backend.py +21 -43
- toil/server/wes/toil_backend.py +2 -2
- toil/test/__init__.py +16 -18
- toil/test/batchSystems/batchSystemTest.py +2 -9
- toil/test/batchSystems/batch_system_plugin_test.py +7 -0
- toil/test/batchSystems/test_slurm.py +103 -14
- toil/test/cwl/cwlTest.py +181 -8
- toil/test/cwl/staging_cat.cwl +27 -0
- toil/test/cwl/staging_make_file.cwl +25 -0
- toil/test/cwl/staging_workflow.cwl +43 -0
- toil/test/cwl/zero_default.cwl +61 -0
- toil/test/docs/scripts/tutorial_staging.py +17 -8
- toil/test/docs/scriptsTest.py +2 -1
- toil/test/jobStores/jobStoreTest.py +23 -133
- toil/test/lib/aws/test_iam.py +7 -7
- toil/test/lib/aws/test_s3.py +30 -33
- toil/test/lib/aws/test_utils.py +9 -9
- toil/test/lib/test_url.py +69 -0
- toil/test/lib/url_plugin_test.py +105 -0
- toil/test/provisioners/aws/awsProvisionerTest.py +60 -7
- toil/test/provisioners/clusterTest.py +15 -2
- toil/test/provisioners/gceProvisionerTest.py +1 -1
- toil/test/server/serverTest.py +78 -36
- toil/test/src/autoDeploymentTest.py +2 -3
- toil/test/src/fileStoreTest.py +89 -87
- toil/test/utils/ABCWorkflowDebug/ABC.txt +1 -0
- toil/test/utils/ABCWorkflowDebug/debugWorkflow.py +4 -4
- toil/test/utils/toilKillTest.py +35 -28
- toil/test/wdl/md5sum/md5sum-gs.json +1 -1
- toil/test/wdl/md5sum/md5sum.json +1 -1
- toil/test/wdl/testfiles/read_file.wdl +18 -0
- toil/test/wdl/testfiles/url_to_optional_file.wdl +2 -1
- toil/test/wdl/wdltoil_test.py +171 -162
- toil/test/wdl/wdltoil_test_kubernetes.py +9 -0
- toil/utils/toilDebugFile.py +6 -3
- toil/utils/toilSshCluster.py +23 -0
- toil/utils/toilStats.py +17 -2
- toil/utils/toilUpdateEC2Instances.py +1 -0
- toil/version.py +10 -10
- toil/wdl/wdltoil.py +1179 -825
- toil/worker.py +16 -8
- {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/METADATA +32 -32
- {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/RECORD +97 -85
- {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/WHEEL +1 -1
- toil/lib/iterables.py +0 -112
- toil/test/docs/scripts/stagingExampleFiles/in.txt +0 -1
- {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/entry_points.txt +0 -0
- {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/licenses/LICENSE +0 -0
- {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/top_level.txt +0 -0
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import base64
|
|
16
17
|
import json
|
|
17
18
|
import logging
|
|
18
19
|
import os
|
|
@@ -25,7 +26,7 @@ import uuid
|
|
|
25
26
|
from collections.abc import Collection, Iterable
|
|
26
27
|
from functools import wraps
|
|
27
28
|
from shlex import quote
|
|
28
|
-
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
|
|
29
|
+
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar, cast
|
|
29
30
|
|
|
30
31
|
# We need these to exist as attributes we can get off of the boto object
|
|
31
32
|
from botocore.exceptions import ClientError
|
|
@@ -109,6 +110,11 @@ _INSTANCE_PROFILE_ROLE_NAME = "toil"
|
|
|
109
110
|
_TAG_KEY_TOIL_NODE_TYPE = "ToilNodeType"
|
|
110
111
|
# The tag that specifies the cluster name on all nodes
|
|
111
112
|
_TAG_KEY_TOIL_CLUSTER_NAME = "clusterName"
|
|
113
|
+
# The tag we use to store the SSH key name.
|
|
114
|
+
# TODO: Get rid of this once
|
|
115
|
+
# <https://github.com/adamchainz/ec2-metadata/pull/562> is merged and we can
|
|
116
|
+
# get the SSH key name from the instance metadata.
|
|
117
|
+
_TAG_KEY_TOIL_SSH_KEY = "sshKeyName"
|
|
112
118
|
# How much storage on the root volume is expected to go to overhead and be
|
|
113
119
|
# unavailable to jobs when the node comes up?
|
|
114
120
|
# TODO: measure
|
|
@@ -309,11 +315,28 @@ class AWSProvisioner(AbstractProvisioner):
|
|
|
309
315
|
for tag in instance["Tags"]:
|
|
310
316
|
if tag.get("Key") == "Name":
|
|
311
317
|
self.clusterName = tag["Value"]
|
|
318
|
+
elif tag.get("Key") == _TAG_KEY_TOIL_SSH_KEY:
|
|
319
|
+
# If we can't get an SSH key from the instance metadata, we
|
|
320
|
+
# might be able to use this one from the tags.
|
|
321
|
+
self._keyName = tag["Value"]
|
|
312
322
|
# Determine what subnet we, the leader, are in
|
|
313
323
|
self._leader_subnet = instance["SubnetId"]
|
|
314
324
|
# Determine where to deploy workers.
|
|
315
325
|
self._worker_subnets_by_zone = self._get_good_subnets_like(self._leader_subnet)
|
|
316
326
|
|
|
327
|
+
# Find the SSH key name to use to start instances
|
|
328
|
+
if hasattr(ec2_metadata, 'public_keys') and isinstance(ec2_metadata.public_keys, dict):
|
|
329
|
+
key_names = list(ec2_metadata.public_keys.keys())
|
|
330
|
+
if len(key_names) > 0 and isinstance(key_names[0], str):
|
|
331
|
+
# We have a key name from the EC2 metadata. This should always
|
|
332
|
+
# be the case once
|
|
333
|
+
# <https://github.com/adamchainz/ec2-metadata/pull/562> is
|
|
334
|
+
# merged. Override anything from the tags.
|
|
335
|
+
self._keyName = key_names[0]
|
|
336
|
+
|
|
337
|
+
if not hasattr(self, '_keyName'):
|
|
338
|
+
raise RuntimeError("Unable to determine the SSH key name the cluster is using")
|
|
339
|
+
|
|
317
340
|
self._leaderPrivateIP = ec2_metadata.private_ipv4 # this is PRIVATE IP
|
|
318
341
|
self._tags = {
|
|
319
342
|
k: v
|
|
@@ -427,7 +450,7 @@ class AWSProvisioner(AbstractProvisioner):
|
|
|
427
450
|
:return: None
|
|
428
451
|
"""
|
|
429
452
|
|
|
430
|
-
if "network"
|
|
453
|
+
if kwargs.get("network") is not None:
|
|
431
454
|
logger.warning(
|
|
432
455
|
"AWS provisioner does not support a network parameter. Ignoring %s!",
|
|
433
456
|
kwargs["network"],
|
|
@@ -495,6 +518,7 @@ class AWSProvisioner(AbstractProvisioner):
|
|
|
495
518
|
# Make tags for the leader specifically
|
|
496
519
|
leader_tags = dict(self._tags)
|
|
497
520
|
leader_tags[_TAG_KEY_TOIL_NODE_TYPE] = "leader"
|
|
521
|
+
leader_tags[_TAG_KEY_TOIL_SSH_KEY] = self._keyName
|
|
498
522
|
logger.debug("Launching leader with tags: %s", leader_tags)
|
|
499
523
|
|
|
500
524
|
instances: list[Instance] = create_instances(
|
|
@@ -995,28 +1019,29 @@ class AWSProvisioner(AbstractProvisioner):
|
|
|
995
1019
|
userData: str = self._getIgnitionUserData(
|
|
996
1020
|
"worker", keyPath, preemptible, self._architecture
|
|
997
1021
|
)
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
spot_kwargs = {
|
|
1004
|
-
"KeyName": self._keyName,
|
|
1022
|
+
# Boto 3 demands we base64 the user data ourselves *only* for spot
|
|
1023
|
+
# instances, and still wants a str.
|
|
1024
|
+
spot_user_data = base64.b64encode(
|
|
1025
|
+
userData.encode("utf-8")
|
|
1026
|
+
).decode("utf-8")
|
|
1027
|
+
spot_kwargs: dict[Literal["LaunchSpecification"], dict[str, Any]] = {
|
|
1005
1028
|
"LaunchSpecification": {
|
|
1029
|
+
"KeyName": self._keyName,
|
|
1006
1030
|
"SecurityGroupIds": self._getSecurityGroupIDs(),
|
|
1007
1031
|
"InstanceType": type_info.name,
|
|
1008
|
-
"UserData":
|
|
1032
|
+
"UserData": spot_user_data,
|
|
1009
1033
|
"BlockDeviceMappings": bdm,
|
|
1010
1034
|
"IamInstanceProfile": {"Arn": self._leaderProfileArn},
|
|
1011
1035
|
"Placement": {"AvailabilityZone": zone},
|
|
1012
1036
|
"SubnetId": subnet_id,
|
|
1013
1037
|
},
|
|
1014
1038
|
}
|
|
1039
|
+
|
|
1015
1040
|
on_demand_kwargs = {
|
|
1016
1041
|
"KeyName": self._keyName,
|
|
1017
1042
|
"SecurityGroupIds": self._getSecurityGroupIDs(),
|
|
1018
1043
|
"InstanceType": type_info.name,
|
|
1019
|
-
"UserData":
|
|
1044
|
+
"UserData": userData,
|
|
1020
1045
|
"BlockDeviceMappings": bdm,
|
|
1021
1046
|
"IamInstanceProfile": {"Arn": self._leaderProfileArn},
|
|
1022
1047
|
"Placement": {"AvailabilityZone": zone},
|
|
@@ -1032,6 +1057,10 @@ class AWSProvisioner(AbstractProvisioner):
|
|
|
1032
1057
|
# every request in this method
|
|
1033
1058
|
if not preemptible:
|
|
1034
1059
|
logger.debug("Launching %s non-preemptible nodes", numNodes)
|
|
1060
|
+
# TODO: Use create_instances() instead, which requires
|
|
1061
|
+
# refactoring both ondemand and spot sides here to use
|
|
1062
|
+
# mypy_boto3_ec2.service_resource.Instance objects instead
|
|
1063
|
+
# of mypy_boto3_ec2.type_defs.InstanceTypeDef
|
|
1035
1064
|
instancesLaunched = create_ondemand_instances(
|
|
1036
1065
|
boto3_ec2=boto3_ec2,
|
|
1037
1066
|
image_id=self._discoverAMI(),
|
|
@@ -1059,7 +1088,6 @@ class AWSProvisioner(AbstractProvisioner):
|
|
|
1059
1088
|
reservation
|
|
1060
1089
|
for subdict in generatedInstancesLaunched
|
|
1061
1090
|
for reservation in subdict["Reservations"]
|
|
1062
|
-
for key, value in subdict.items()
|
|
1063
1091
|
]
|
|
1064
1092
|
# get a flattened list of all requested instances, as before instancesLaunched is a dict of reservations which is a dict of instance requests
|
|
1065
1093
|
instancesLaunched = [
|
|
@@ -1144,7 +1172,7 @@ class AWSProvisioner(AbstractProvisioner):
|
|
|
1144
1172
|
workerInstances = [
|
|
1145
1173
|
i
|
|
1146
1174
|
for i in workerInstances
|
|
1147
|
-
if preemptible == (i
|
|
1175
|
+
if preemptible == (i.get("SpotInstanceRequestId") is not None)
|
|
1148
1176
|
]
|
|
1149
1177
|
logger.debug(
|
|
1150
1178
|
"%spreemptible workers found in cluster: %s",
|
|
@@ -1161,7 +1189,7 @@ class AWSProvisioner(AbstractProvisioner):
|
|
|
1161
1189
|
name=i["InstanceId"],
|
|
1162
1190
|
launchTime=i["LaunchTime"],
|
|
1163
1191
|
nodeType=i["InstanceType"],
|
|
1164
|
-
preemptible=i
|
|
1192
|
+
preemptible=i.get("SpotInstanceRequestId") is not None,
|
|
1165
1193
|
tags=collapse_tags(i["Tags"]),
|
|
1166
1194
|
)
|
|
1167
1195
|
for i in workerInstances
|
|
@@ -1509,11 +1537,15 @@ class AWSProvisioner(AbstractProvisioner):
|
|
|
1509
1537
|
tags: list[TagDescriptionTypeDef] = ec2.describe_tags(Filters=[tag_filter])[
|
|
1510
1538
|
"Tags"
|
|
1511
1539
|
]
|
|
1540
|
+
# TODO: Does this reference instance or spot request? Or can it be either?
|
|
1512
1541
|
idsToCancel = [tag["ResourceId"] for tag in tags]
|
|
1513
1542
|
return [
|
|
1514
1543
|
request["SpotInstanceRequestId"]
|
|
1515
1544
|
for request in requests
|
|
1516
|
-
if
|
|
1545
|
+
if (
|
|
1546
|
+
request.get("InstanceId") in idsToCancel
|
|
1547
|
+
or request["SpotInstanceRequestId"] in idsToCancel
|
|
1548
|
+
)
|
|
1517
1549
|
]
|
|
1518
1550
|
|
|
1519
1551
|
def _createSecurityGroups(self) -> list[str]:
|
toil/provisioners/node.py
CHANGED
|
@@ -41,6 +41,21 @@ class Node:
|
|
|
41
41
|
tags: Optional[dict[str, str]] = None,
|
|
42
42
|
use_private_ip: Optional[bool] = None,
|
|
43
43
|
) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Create a new node.
|
|
46
|
+
|
|
47
|
+
:param launchTime: Time when the node was launched. If a naive
|
|
48
|
+
datetime, or a string without timezone information, is assumed to
|
|
49
|
+
be in UTC.
|
|
50
|
+
|
|
51
|
+
:raises ValueError: If launchTime is an improperly formatted string.
|
|
52
|
+
|
|
53
|
+
>>> node = Node("127.0.0.1", "127.0.0.1", "localhost",
|
|
54
|
+
... "Decembruary Eleventeenth", None, False)
|
|
55
|
+
Traceback (most recent call last):
|
|
56
|
+
...
|
|
57
|
+
ValueError: Invalid isoformat string: 'Decembruary Eleventeenth'
|
|
58
|
+
"""
|
|
44
59
|
self.publicIP = publicIP
|
|
45
60
|
self.privateIP = privateIP
|
|
46
61
|
if use_private_ip:
|
|
@@ -48,13 +63,25 @@ class Node:
|
|
|
48
63
|
else:
|
|
49
64
|
self.effectiveIP = self.publicIP or self.privateIP
|
|
50
65
|
self.name = name
|
|
66
|
+
# Typing should prevent an empty launch time, but just to make sure,
|
|
67
|
+
# check it at runtime.
|
|
68
|
+
assert launchTime is not None, (
|
|
69
|
+
f"Attempted to create a Node {name} without a launch time"
|
|
70
|
+
)
|
|
51
71
|
if isinstance(launchTime, datetime.datetime):
|
|
52
72
|
self.launchTime = launchTime
|
|
53
73
|
else:
|
|
54
74
|
try:
|
|
75
|
+
# Parse an RFC 3339 ISO 8601 UTC datetime
|
|
55
76
|
self.launchTime = parse_iso_utc(launchTime)
|
|
56
77
|
except ValueError:
|
|
78
|
+
# Parse (almost) any ISO 8601 datetime
|
|
57
79
|
self.launchTime = datetime.datetime.fromisoformat(launchTime)
|
|
80
|
+
if self.launchTime.tzinfo is None:
|
|
81
|
+
# Read naive datatimes as in UTC
|
|
82
|
+
self.launchTime = self.launchTime.replace(
|
|
83
|
+
tzinfo=datetime.timezone.utc
|
|
84
|
+
)
|
|
58
85
|
self.nodeType = nodeType
|
|
59
86
|
self.preemptible = preemptible
|
|
60
87
|
self.tags = tags
|
|
@@ -70,22 +97,43 @@ class Node:
|
|
|
70
97
|
|
|
71
98
|
def remainingBillingInterval(self) -> float:
|
|
72
99
|
"""
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
100
|
+
Returns a floating point value between 0 and 1.0 representing how much
|
|
101
|
+
time is left in the current billing cycle for the given instance. If
|
|
102
|
+
the return value is .25, we are three quarters into the billing cycle,
|
|
103
|
+
with one quarters remaining before we will be charged again for that
|
|
104
|
+
instance.
|
|
78
105
|
|
|
79
106
|
Assumes a billing cycle of one hour.
|
|
80
107
|
|
|
81
|
-
:return: Float from 0 ->
|
|
108
|
+
:return: Float from 1.0 -> 0.0 representing fraction of pre-paid time
|
|
109
|
+
remaining in cycle.
|
|
110
|
+
|
|
111
|
+
>>> node = Node("127.0.0.1", "127.0.0.1", "localhost",
|
|
112
|
+
... datetime.datetime.utcnow(), None, False)
|
|
113
|
+
>>> node.remainingBillingInterval() >= 0
|
|
114
|
+
True
|
|
115
|
+
>>> node.remainingBillingInterval() <= 1.0
|
|
116
|
+
True
|
|
117
|
+
>>> node.remainingBillingInterval() > 0.5
|
|
118
|
+
True
|
|
119
|
+
>>> interval1 = node.remainingBillingInterval()
|
|
120
|
+
>>> time.sleep(1)
|
|
121
|
+
>>> interval2 = node.remainingBillingInterval()
|
|
122
|
+
>>> interval2 < interval1
|
|
123
|
+
True
|
|
124
|
+
|
|
125
|
+
>>> node = Node("127.0.0.1", "127.0.0.1", "localhost",
|
|
126
|
+
... datetime.datetime.now(datetime.timezone.utc) -
|
|
127
|
+
... datetime.timedelta(minutes=5), None, False)
|
|
128
|
+
>>> node.remainingBillingInterval() < 0.99
|
|
129
|
+
True
|
|
130
|
+
>>> node.remainingBillingInterval() > 0.9
|
|
131
|
+
True
|
|
132
|
+
|
|
82
133
|
"""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return 1 - delta.total_seconds() / 3600.0 % 1.0
|
|
87
|
-
else:
|
|
88
|
-
return 1
|
|
134
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
135
|
+
delta = now - self.launchTime
|
|
136
|
+
return 1 - delta.total_seconds() / 3600.0 % 1.0
|
|
89
137
|
|
|
90
138
|
def waitForNode(self, role: str, keyName: str = "core") -> None:
|
|
91
139
|
self._waitForSSHPort()
|
toil/resource.py
CHANGED
|
@@ -32,7 +32,6 @@ from zipfile import ZipFile
|
|
|
32
32
|
|
|
33
33
|
from toil import inVirtualEnv
|
|
34
34
|
from toil.lib.io import mkdtemp
|
|
35
|
-
from toil.lib.iterables import concat
|
|
36
35
|
from toil.lib.memoize import strict_bool
|
|
37
36
|
from toil.lib.retry import ErrorCondition, retry
|
|
38
37
|
from toil.version import exactPython
|
|
@@ -619,18 +618,9 @@ class ModuleDescriptor(
|
|
|
619
618
|
initName = self._initModuleName(self.dirPath)
|
|
620
619
|
if initName:
|
|
621
620
|
raise ResourceException(
|
|
622
|
-
"Toil does not support loading a user script from a package directory. You "
|
|
623
|
-
"may want to remove
|
|
624
|
-
"'
|
|
625
|
-
% tuple(
|
|
626
|
-
concat(
|
|
627
|
-
initName,
|
|
628
|
-
self.dirPath,
|
|
629
|
-
exactPython,
|
|
630
|
-
os.path.split(self.dirPath),
|
|
631
|
-
self.name,
|
|
632
|
-
)
|
|
633
|
-
)
|
|
621
|
+
f"Toil does not support loading a user script from a package directory. You "
|
|
622
|
+
f"may want to remove {initName} from {self.dirPath} or invoke the user script as a module via: "
|
|
623
|
+
f"PYTHONPATH='{self.dirPath}' {exactPython} -m {self.dirPath}.{self.name}"
|
|
634
624
|
)
|
|
635
625
|
return self.dirPath
|
|
636
626
|
|
toil/server/app.py
CHANGED
|
@@ -16,6 +16,7 @@ import logging
|
|
|
16
16
|
import os
|
|
17
17
|
|
|
18
18
|
import connexion # type: ignore
|
|
19
|
+
from connexion.options import SwaggerUIOptions # type: ignore[import-untyped]
|
|
19
20
|
from configargparse import ArgumentParser
|
|
20
21
|
|
|
21
22
|
from toil.lib.aws import get_current_aws_region, running_on_ec2, running_on_ecs
|
|
@@ -133,8 +134,10 @@ def create_app(args: argparse.Namespace) -> "connexion.FlaskApp":
|
|
|
133
134
|
"""
|
|
134
135
|
Create a "connexion.FlaskApp" instance with Toil server configurations.
|
|
135
136
|
"""
|
|
137
|
+
swagger_ui_options = SwaggerUIOptions(swagger_ui=args.swagger_ui)
|
|
138
|
+
|
|
136
139
|
flask_app = connexion.FlaskApp(
|
|
137
|
-
__name__, specification_dir="api_spec/",
|
|
140
|
+
__name__, specification_dir="api_spec/", swagger_ui_options=swagger_ui_options
|
|
138
141
|
)
|
|
139
142
|
|
|
140
143
|
flask_app.app.config["JSON_SORT_KEYS"] = False
|
|
@@ -164,16 +167,16 @@ def create_app(args: argparse.Namespace) -> "connexion.FlaskApp":
|
|
|
164
167
|
if isinstance(backend, ToilBackend):
|
|
165
168
|
# We extend the WES API to allow presenting log data
|
|
166
169
|
base_url = "/toil/wes/v1"
|
|
167
|
-
flask_app.
|
|
170
|
+
flask_app.add_url_rule(
|
|
168
171
|
f"{base_url}/logs/<run_id>/stdout", view_func=backend.get_stdout
|
|
169
172
|
)
|
|
170
|
-
flask_app.
|
|
173
|
+
flask_app.add_url_rule(
|
|
171
174
|
f"{base_url}/logs/<run_id>/stderr", view_func=backend.get_stderr
|
|
172
175
|
)
|
|
173
176
|
# To be a well-behaved AGC engine we can implement the default status check endpoint
|
|
174
|
-
flask_app.
|
|
177
|
+
flask_app.add_url_rule("/engine/v1/status", view_func=backend.get_health)
|
|
175
178
|
# And we can provide lost humans some information on what they are looking at
|
|
176
|
-
flask_app.
|
|
179
|
+
flask_app.add_url_rule("/", view_func=backend.get_homepage)
|
|
177
180
|
|
|
178
181
|
return flask_app
|
|
179
182
|
|
|
@@ -201,9 +204,12 @@ def start_server(args: argparse.Namespace) -> None:
|
|
|
201
204
|
else:
|
|
202
205
|
# start a production WSGI server
|
|
203
206
|
run_app(
|
|
204
|
-
flask_app
|
|
207
|
+
flask_app,
|
|
205
208
|
options={
|
|
206
209
|
"bind": f"{host}:{port}",
|
|
207
210
|
"workers": args.workers,
|
|
211
|
+
# The uvicorn worker class must be specified for gunicorn to work on connexion 3
|
|
212
|
+
# https://github.com/spec-first/connexion/issues/1755#issuecomment-1778522142
|
|
213
|
+
"worker_class": "uvicorn.workers.UvicornWorker"
|
|
208
214
|
},
|
|
209
215
|
)
|
|
@@ -14,7 +14,7 @@ import ruamel.yaml
|
|
|
14
14
|
import schema_salad
|
|
15
15
|
from configargparse import ArgumentParser
|
|
16
16
|
from wes_client.util import WESClient # type: ignore
|
|
17
|
-
from wes_client.util import
|
|
17
|
+
from wes_client.util import wes_response as wes_response
|
|
18
18
|
|
|
19
19
|
from toil.lib.web import web_session
|
|
20
20
|
from toil.wdl.utils import get_version as get_wdl_version
|
|
@@ -144,7 +144,7 @@ class WESClientWithWorkflowEngineParameters(WESClient): # type: ignore
|
|
|
144
144
|
return "3.8"
|
|
145
145
|
elif extension == "cwl":
|
|
146
146
|
with open(workflow_file) as f:
|
|
147
|
-
yaml = ruamel.yaml.YAML(typ=
|
|
147
|
+
yaml = ruamel.yaml.YAML(typ="safe", pure=True)
|
|
148
148
|
return str(yaml.load(f)["cwlVersion"])
|
|
149
149
|
elif extension == "wdl":
|
|
150
150
|
with open(workflow_file) as f:
|
|
@@ -224,7 +224,7 @@ class WESBackend:
|
|
|
224
224
|
)
|
|
225
225
|
|
|
226
226
|
def collect_attachments(
|
|
227
|
-
self, run_id: Optional[str], temp_dir: Optional[str]
|
|
227
|
+
self, args: dict[str, Any], run_id: Optional[str], temp_dir: Optional[str]
|
|
228
228
|
) -> tuple[str, dict[str, Any]]:
|
|
229
229
|
"""
|
|
230
230
|
Collect attachments from the current request by staging uploaded files
|
|
@@ -238,48 +238,26 @@ class WESBackend:
|
|
|
238
238
|
temp_dir = mkdtemp()
|
|
239
239
|
body: dict[str, Any] = {}
|
|
240
240
|
has_attachments = False
|
|
241
|
-
for
|
|
242
|
-
|
|
243
|
-
for
|
|
244
|
-
|
|
245
|
-
if
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
"tags",
|
|
262
|
-
"workflow_engine_parameters",
|
|
263
|
-
):
|
|
264
|
-
content = value.read()
|
|
265
|
-
body[key] = json.loads(content.decode("utf-8"))
|
|
266
|
-
else:
|
|
267
|
-
body[key] = value.read().decode()
|
|
268
|
-
except Exception as e:
|
|
269
|
-
raise MalformedRequestException(f"Error reading parameter '{key}': {e}")
|
|
270
|
-
|
|
271
|
-
for key, ls in connexion.request.form.lists():
|
|
272
|
-
try:
|
|
273
|
-
for value in ls:
|
|
274
|
-
if not value:
|
|
275
|
-
continue
|
|
276
|
-
if key in ("workflow_params", "tags", "workflow_engine_parameters"):
|
|
277
|
-
body[key] = json.loads(value)
|
|
278
|
-
else:
|
|
279
|
-
body[key] = value
|
|
280
|
-
except Exception as e:
|
|
281
|
-
raise MalformedRequestException(f"Error reading parameter '{key}': {e}")
|
|
282
|
-
|
|
241
|
+
for k, v in args.items():
|
|
242
|
+
if k == "workflow_attachment":
|
|
243
|
+
for file in (v or []):
|
|
244
|
+
dest = os.path.join(temp_dir, self.secure_path(file.filename))
|
|
245
|
+
if not os.path.isdir(os.path.dirname(dest)):
|
|
246
|
+
os.makedirs(os.path.dirname(dest))
|
|
247
|
+
self.log_for_run(
|
|
248
|
+
run_id,
|
|
249
|
+
f"Staging attachment '{file.filename}' to '{dest}'",
|
|
250
|
+
)
|
|
251
|
+
file.save(dest)
|
|
252
|
+
has_attachments = True
|
|
253
|
+
body["workflow_attachment"] = (
|
|
254
|
+
"file://%s" % temp_dir
|
|
255
|
+
) # Reference to temp working dir.
|
|
256
|
+
elif k in ("workflow_params", "tags", "workflow_engine_parameters"):
|
|
257
|
+
if v is not None:
|
|
258
|
+
body[k] = json.loads(v)
|
|
259
|
+
else:
|
|
260
|
+
body[k] = v
|
|
283
261
|
if "workflow_url" in body:
|
|
284
262
|
url, ref = urldefrag(body["workflow_url"])
|
|
285
263
|
if ":" not in url:
|
toil/server/wes/toil_backend.py
CHANGED
|
@@ -502,7 +502,7 @@ class ToilBackend(WESBackend):
|
|
|
502
502
|
}
|
|
503
503
|
|
|
504
504
|
@handle_errors
|
|
505
|
-
def run_workflow(self) -> dict[str, str]:
|
|
505
|
+
def run_workflow(self, **args: Any) -> dict[str, str]:
|
|
506
506
|
"""Run a workflow."""
|
|
507
507
|
run_id = self.run_id_prefix + uuid.uuid4().hex
|
|
508
508
|
run = self._get_run(run_id, should_exists=False)
|
|
@@ -514,7 +514,7 @@ class ToilBackend(WESBackend):
|
|
|
514
514
|
# stage the uploaded files to the execution directory, so that we can run the workflow file directly
|
|
515
515
|
temp_dir = run.exec_dir
|
|
516
516
|
try:
|
|
517
|
-
_, request = self.collect_attachments(run_id, temp_dir=temp_dir)
|
|
517
|
+
_, request = self.collect_attachments(args, run_id, temp_dir=temp_dir)
|
|
518
518
|
except ValueError:
|
|
519
519
|
run.clean_up()
|
|
520
520
|
raise
|
toil/test/__init__.py
CHANGED
|
@@ -49,7 +49,6 @@ from toil.lib.accelerators import (
|
|
|
49
49
|
have_working_nvidia_smi,
|
|
50
50
|
)
|
|
51
51
|
from toil.lib.io import mkdtemp
|
|
52
|
-
from toil.lib.iterables import concat
|
|
53
52
|
from toil.lib.memoize import memoize
|
|
54
53
|
from toil.lib.threading import ExceptionalThread, cpu_count
|
|
55
54
|
from toil.version import distVersion
|
|
@@ -224,7 +223,7 @@ class ToilTest(unittest.TestCase):
|
|
|
224
223
|
|
|
225
224
|
:return: The output of the process' stdout if capture=True was passed, None otherwise.
|
|
226
225
|
"""
|
|
227
|
-
argl = list(
|
|
226
|
+
argl = [command] + list(args)
|
|
228
227
|
logger.info("Running %r", argl)
|
|
229
228
|
capture = kwargs.pop("capture", False)
|
|
230
229
|
_input = kwargs.pop("input", None)
|
|
@@ -395,6 +394,8 @@ def _aws_s3_avail() -> bool:
|
|
|
395
394
|
boto3_credentials = session.get_credentials()
|
|
396
395
|
except ImportError:
|
|
397
396
|
return False
|
|
397
|
+
except ProxyConnectionError:
|
|
398
|
+
return False
|
|
398
399
|
from toil.lib.aws import running_on_ec2
|
|
399
400
|
|
|
400
401
|
if not (
|
|
@@ -440,7 +441,7 @@ def needs_aws_batch(test_item: MT) -> MT:
|
|
|
440
441
|
test_item
|
|
441
442
|
)
|
|
442
443
|
test_item = needs_env_var(
|
|
443
|
-
"TOIL_AWS_BATCH_JOB_ROLE_ARN", "an IAM role ARN that grants S3
|
|
444
|
+
"TOIL_AWS_BATCH_JOB_ROLE_ARN", "an IAM role ARN that grants S3 access"
|
|
444
445
|
)(test_item)
|
|
445
446
|
try:
|
|
446
447
|
from toil.lib.aws import get_current_aws_region
|
|
@@ -467,7 +468,7 @@ def needs_google_storage(test_item: MT) -> MT:
|
|
|
467
468
|
"""
|
|
468
469
|
test_item = _mark_test("google_storage", needs_online(test_item))
|
|
469
470
|
try:
|
|
470
|
-
|
|
471
|
+
import google.clould.storage # type: ignore[import-untyped]
|
|
471
472
|
except ImportError:
|
|
472
473
|
return unittest.skip(
|
|
473
474
|
"Install Toil with the 'google' extra to include this test."
|
|
@@ -619,7 +620,7 @@ def needs_htcondor(test_item: MT) -> MT:
|
|
|
619
620
|
"""Use a decorator before test classes or methods to run only if the HTCondor is installed."""
|
|
620
621
|
test_item = _mark_test("htcondor", test_item)
|
|
621
622
|
try:
|
|
622
|
-
import htcondor # type: ignore
|
|
623
|
+
import htcondor # type: ignore
|
|
623
624
|
|
|
624
625
|
htcondor.Collector(os.getenv("TOIL_HTCONDOR_COLLECTOR")).query(
|
|
625
626
|
constraint="False"
|
|
@@ -1249,19 +1250,16 @@ class ApplianceTestSupport(ToilTest):
|
|
|
1249
1250
|
with self.lock:
|
|
1250
1251
|
image = applianceSelf()
|
|
1251
1252
|
# Omitting --rm, it's unreliable, see https://github.com/docker/docker/issues/16575
|
|
1252
|
-
args =
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
self._containerCommand(),
|
|
1263
|
-
)
|
|
1264
|
-
)
|
|
1253
|
+
args = [
|
|
1254
|
+
"docker",
|
|
1255
|
+
"run",
|
|
1256
|
+
f"--entrypoint={self._entryPoint()}",
|
|
1257
|
+
"--net=host",
|
|
1258
|
+
"-i",
|
|
1259
|
+
f"--name={self.containerName}"] + \
|
|
1260
|
+
["--volume=%s:%s" % mount for mount in self.mounts.items()] + \
|
|
1261
|
+
[image] + \
|
|
1262
|
+
self._containerCommand()
|
|
1265
1263
|
logger.info("Running %r", args)
|
|
1266
1264
|
self.popen = subprocess.Popen(args)
|
|
1267
1265
|
self.start()
|
|
@@ -42,8 +42,6 @@ from toil.batchSystems.registry import (
|
|
|
42
42
|
add_batch_system_factory,
|
|
43
43
|
get_batch_system,
|
|
44
44
|
get_batch_systems,
|
|
45
|
-
restore_batch_system_plugin_state,
|
|
46
|
-
save_batch_system_plugin_state,
|
|
47
45
|
)
|
|
48
46
|
from toil.batchSystems.singleMachine import SingleMachineBatchSystem
|
|
49
47
|
from toil.common import Config, Toil
|
|
@@ -69,6 +67,7 @@ from toil.test import (
|
|
|
69
67
|
pslow,
|
|
70
68
|
pneeds_mesos,
|
|
71
69
|
)
|
|
70
|
+
from toil.lib.plugins import remove_plugin
|
|
72
71
|
|
|
73
72
|
import pytest
|
|
74
73
|
|
|
@@ -97,15 +96,9 @@ class BatchSystemPluginTest(ToilTest):
|
|
|
97
96
|
Class for testing batch system plugin functionality.
|
|
98
97
|
"""
|
|
99
98
|
|
|
100
|
-
def setUp(self) -> None:
|
|
101
|
-
# Save plugin state so our plugin doesn't stick around after the test
|
|
102
|
-
# (and create duplicate options)
|
|
103
|
-
self.__state = save_batch_system_plugin_state()
|
|
104
|
-
super().setUp()
|
|
105
|
-
|
|
106
99
|
def tearDown(self) -> None:
|
|
107
100
|
# Restore plugin state
|
|
108
|
-
|
|
101
|
+
remove_plugin("batch_system", "testBatchSystem")
|
|
109
102
|
super().tearDown()
|
|
110
103
|
|
|
111
104
|
def test_add_batch_system_factory(self) -> None:
|
|
@@ -26,6 +26,7 @@ from toil.batchSystems.registry import add_batch_system_factory
|
|
|
26
26
|
from toil.common import Toil, addOptions
|
|
27
27
|
from toil.job import JobDescription
|
|
28
28
|
from toil.test import ToilTest
|
|
29
|
+
from toil.lib.plugins import remove_plugin
|
|
29
30
|
|
|
30
31
|
logger = logging.getLogger(__name__)
|
|
31
32
|
|
|
@@ -68,6 +69,11 @@ class FakeBatchSystem(BatchSystemCleanupSupport):
|
|
|
68
69
|
|
|
69
70
|
|
|
70
71
|
class BatchSystemPluginTest(ToilTest):
|
|
72
|
+
def tearDown(self) -> None:
|
|
73
|
+
# Restore plugin state
|
|
74
|
+
remove_plugin("batch_system", "fake")
|
|
75
|
+
super().tearDown()
|
|
76
|
+
|
|
71
77
|
def test_batchsystem_plugin_installable(self):
|
|
72
78
|
"""
|
|
73
79
|
Test that installing a batch system plugin works.
|
|
@@ -76,6 +82,7 @@ class BatchSystemPluginTest(ToilTest):
|
|
|
76
82
|
|
|
77
83
|
def fake_batch_system_factory() -> type[AbstractBatchSystem]:
|
|
78
84
|
return FakeBatchSystem
|
|
85
|
+
|
|
79
86
|
|
|
80
87
|
add_batch_system_factory("fake", fake_batch_system_factory)
|
|
81
88
|
|