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.
Files changed (99) hide show
  1. toil/batchSystems/abstractBatchSystem.py +13 -5
  2. toil/batchSystems/abstractGridEngineBatchSystem.py +17 -5
  3. toil/batchSystems/kubernetes.py +13 -2
  4. toil/batchSystems/mesos/batchSystem.py +33 -2
  5. toil/batchSystems/registry.py +15 -118
  6. toil/batchSystems/slurm.py +191 -16
  7. toil/common.py +20 -1
  8. toil/cwl/cwltoil.py +97 -119
  9. toil/cwl/utils.py +103 -3
  10. toil/fileStores/__init__.py +1 -1
  11. toil/fileStores/abstractFileStore.py +5 -2
  12. toil/fileStores/cachingFileStore.py +1 -1
  13. toil/job.py +30 -14
  14. toil/jobStores/abstractJobStore.py +35 -255
  15. toil/jobStores/aws/jobStore.py +864 -1964
  16. toil/jobStores/aws/utils.py +24 -270
  17. toil/jobStores/fileJobStore.py +2 -1
  18. toil/jobStores/googleJobStore.py +32 -13
  19. toil/jobStores/utils.py +0 -327
  20. toil/leader.py +27 -22
  21. toil/lib/accelerators.py +1 -1
  22. toil/lib/aws/config.py +22 -0
  23. toil/lib/aws/s3.py +477 -9
  24. toil/lib/aws/utils.py +22 -33
  25. toil/lib/checksum.py +88 -0
  26. toil/lib/conversions.py +33 -31
  27. toil/lib/directory.py +217 -0
  28. toil/lib/ec2.py +97 -29
  29. toil/lib/exceptions.py +2 -1
  30. toil/lib/expando.py +2 -2
  31. toil/lib/generatedEC2Lists.py +138 -19
  32. toil/lib/io.py +33 -2
  33. toil/lib/memoize.py +21 -7
  34. toil/lib/misc.py +1 -1
  35. toil/lib/pipes.py +385 -0
  36. toil/lib/plugins.py +106 -0
  37. toil/lib/retry.py +1 -1
  38. toil/lib/threading.py +1 -1
  39. toil/lib/url.py +320 -0
  40. toil/lib/web.py +4 -5
  41. toil/options/cwl.py +13 -1
  42. toil/options/runner.py +17 -10
  43. toil/options/wdl.py +12 -1
  44. toil/provisioners/__init__.py +5 -2
  45. toil/provisioners/aws/__init__.py +43 -36
  46. toil/provisioners/aws/awsProvisioner.py +47 -15
  47. toil/provisioners/node.py +60 -12
  48. toil/resource.py +3 -13
  49. toil/server/app.py +12 -6
  50. toil/server/cli/wes_cwl_runner.py +2 -2
  51. toil/server/wes/abstract_backend.py +21 -43
  52. toil/server/wes/toil_backend.py +2 -2
  53. toil/test/__init__.py +16 -18
  54. toil/test/batchSystems/batchSystemTest.py +2 -9
  55. toil/test/batchSystems/batch_system_plugin_test.py +7 -0
  56. toil/test/batchSystems/test_slurm.py +103 -14
  57. toil/test/cwl/cwlTest.py +181 -8
  58. toil/test/cwl/staging_cat.cwl +27 -0
  59. toil/test/cwl/staging_make_file.cwl +25 -0
  60. toil/test/cwl/staging_workflow.cwl +43 -0
  61. toil/test/cwl/zero_default.cwl +61 -0
  62. toil/test/docs/scripts/tutorial_staging.py +17 -8
  63. toil/test/docs/scriptsTest.py +2 -1
  64. toil/test/jobStores/jobStoreTest.py +23 -133
  65. toil/test/lib/aws/test_iam.py +7 -7
  66. toil/test/lib/aws/test_s3.py +30 -33
  67. toil/test/lib/aws/test_utils.py +9 -9
  68. toil/test/lib/test_url.py +69 -0
  69. toil/test/lib/url_plugin_test.py +105 -0
  70. toil/test/provisioners/aws/awsProvisionerTest.py +60 -7
  71. toil/test/provisioners/clusterTest.py +15 -2
  72. toil/test/provisioners/gceProvisionerTest.py +1 -1
  73. toil/test/server/serverTest.py +78 -36
  74. toil/test/src/autoDeploymentTest.py +2 -3
  75. toil/test/src/fileStoreTest.py +89 -87
  76. toil/test/utils/ABCWorkflowDebug/ABC.txt +1 -0
  77. toil/test/utils/ABCWorkflowDebug/debugWorkflow.py +4 -4
  78. toil/test/utils/toilKillTest.py +35 -28
  79. toil/test/wdl/md5sum/md5sum-gs.json +1 -1
  80. toil/test/wdl/md5sum/md5sum.json +1 -1
  81. toil/test/wdl/testfiles/read_file.wdl +18 -0
  82. toil/test/wdl/testfiles/url_to_optional_file.wdl +2 -1
  83. toil/test/wdl/wdltoil_test.py +171 -162
  84. toil/test/wdl/wdltoil_test_kubernetes.py +9 -0
  85. toil/utils/toilDebugFile.py +6 -3
  86. toil/utils/toilSshCluster.py +23 -0
  87. toil/utils/toilStats.py +17 -2
  88. toil/utils/toilUpdateEC2Instances.py +1 -0
  89. toil/version.py +10 -10
  90. toil/wdl/wdltoil.py +1179 -825
  91. toil/worker.py +16 -8
  92. {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/METADATA +32 -32
  93. {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/RECORD +97 -85
  94. {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/WHEEL +1 -1
  95. toil/lib/iterables.py +0 -112
  96. toil/test/docs/scripts/stagingExampleFiles/in.txt +0 -1
  97. {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/entry_points.txt +0 -0
  98. {toil-8.2.0.dist-info → toil-9.1.0.dist-info}/licenses/LICENSE +0 -0
  99. {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" in kwargs:
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
- userDataBytes: bytes = b""
999
- if isinstance(userData, str):
1000
- # Spot-market provisioning requires bytes for user data.
1001
- userDataBytes = userData.encode("utf-8")
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": userDataBytes,
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": userDataBytes,
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["SpotInstanceRequestId"] is not None)
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["SpotInstanceRequestId"] is not None,
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 request["InstanceId"] in idsToCancel
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
- If the node has a launch time, this function returns a floating point value
74
- between 0 and 1.0 representing how far we are into the
75
- current billing cycle for the given instance. If the return value is .25, we are one
76
- quarter into the billing cycle, with three quarters remaining before we will be charged
77
- again for that instance.
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 -> 1.0 representing percentage of pre-paid time left in cycle.
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
- if self.launchTime:
84
- now = datetime.datetime.utcnow()
85
- delta = now - self.launchTime
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 %s from %s or invoke the user script as a module via "
624
- "'PYTHONPATH=\"%s\" %s -m %s.%s'."
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/", options={"swagger_ui": args.swagger_ui}
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.app.add_url_rule(
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.app.add_url_rule(
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.app.add_url_rule("/engine/v1/status", view_func=backend.get_health)
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.app.add_url_rule("/", view_func=backend.get_homepage)
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.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 wes_reponse as wes_response
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='safe', pure=True)
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 key, ls in connexion.request.files.lists():
242
- try:
243
- for value in ls:
244
- # uploaded files that are required to execute the workflow
245
- if key == "workflow_attachment":
246
- # guard against maliciously constructed filenames
247
- dest = os.path.join(temp_dir, self.secure_path(value.filename))
248
- if not os.path.isdir(os.path.dirname(dest)):
249
- os.makedirs(os.path.dirname(dest))
250
- self.log_for_run(
251
- run_id, f"Staging attachment '{value.filename}' to '{dest}'"
252
- )
253
- value.save(dest)
254
- has_attachments = True
255
- body[key] = (
256
- f"file://{temp_dir}" # Reference to temp working dir.
257
- )
258
-
259
- elif key in (
260
- "workflow_params",
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:
@@ -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(concat(command, args))
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 and SDB access"
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
- from google.cloud import storage # type: ignore[import-untyped]
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[import-not-found]
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 = list(
1253
- concat(
1254
- "docker",
1255
- "run",
1256
- "--entrypoint=" + self._entryPoint(),
1257
- "--net=host",
1258
- "-i",
1259
- "--name=" + self.containerName,
1260
- ["--volume=%s:%s" % mount for mount in self.mounts.items()],
1261
- image,
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
- restore_batch_system_plugin_state(self.__state)
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