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
toil/lib/checksum.py ADDED
@@ -0,0 +1,88 @@
1
+ # Copyright (C) 2015-2021 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
+ import logging
15
+ import hashlib
16
+
17
+ from io import BytesIO
18
+ from typing import BinaryIO, Union, List, TYPE_CHECKING
19
+
20
+ from toil.lib.aws.config import S3_PART_SIZE
21
+
22
+ if TYPE_CHECKING:
23
+ # mypy complaint: https://github.com/python/typeshed/issues/2928
24
+ from hashlib import _Hash
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class ChecksumError(Exception):
31
+ """Raised when a download does not contain the correct data."""
32
+
33
+
34
+ class Etag:
35
+ """A hasher for s3 etags."""
36
+ def __init__(self, chunk_size: int) -> None:
37
+ self.etag_bytes: int = 0
38
+ self.etag_parts: List[bytes] = []
39
+ self.etag_hasher: "_Hash" = hashlib.md5()
40
+ self.chunk_size: int = chunk_size
41
+
42
+ def update(self, chunk: bytes) -> None:
43
+ if self.etag_bytes + len(chunk) > self.chunk_size:
44
+ chunk_head = chunk[:self.chunk_size - self.etag_bytes]
45
+ chunk_tail = chunk[self.chunk_size - self.etag_bytes:]
46
+ self.etag_hasher.update(chunk_head)
47
+ self.etag_parts.append(self.etag_hasher.digest())
48
+ self.etag_hasher = hashlib.md5()
49
+ self.etag_hasher.update(chunk_tail)
50
+ self.etag_bytes = len(chunk_tail)
51
+ else:
52
+ self.etag_hasher.update(chunk)
53
+ self.etag_bytes += len(chunk)
54
+
55
+ def hexdigest(self) -> str:
56
+ if self.etag_bytes:
57
+ self.etag_parts.append(self.etag_hasher.digest())
58
+ self.etag_bytes = 0
59
+ if len(self.etag_parts) > 1:
60
+ etag = hashlib.md5(b"".join(self.etag_parts)).hexdigest()
61
+ return f'{etag}-{len(self.etag_parts)}'
62
+ else:
63
+ return self.etag_hasher.hexdigest()
64
+
65
+
66
+ hashers = {'sha1': hashlib.sha1(),
67
+ 'sha256': hashlib.sha256(),
68
+ 'etag': Etag(chunk_size=S3_PART_SIZE)}
69
+
70
+
71
+ def compute_checksum_for_file(local_file_path: str, algorithm: str = 'sha1') -> str:
72
+ with open(local_file_path, 'rb') as fh:
73
+ checksum_result = compute_checksum_for_content(fh, algorithm=algorithm)
74
+ return checksum_result
75
+
76
+
77
+ def compute_checksum_for_content(fh: Union[BinaryIO, BytesIO], algorithm: str = 'sha1') -> str:
78
+ """
79
+ Note: Chunk size matters for s3 etags, and must be the same to get the same hash from the same object.
80
+ Therefore this buffer is not modifiable throughout Toil.
81
+ """
82
+ hasher: "_Hash" = hashers[algorithm] # type: ignore
83
+ contents = fh.read(S3_PART_SIZE)
84
+ while contents != b'':
85
+ hasher.update(contents)
86
+ contents = fh.read(S3_PART_SIZE)
87
+
88
+ return f'{algorithm}${hasher.hexdigest()}'
toil/lib/conversions.py CHANGED
@@ -2,40 +2,28 @@
2
2
  Conversion utilities for mapping memory, disk, core declarations from strings to numbers and vice versa.
3
3
  Also contains general conversion functions
4
4
  """
5
-
6
5
  import math
7
- from typing import Optional, SupportsInt, Union
6
+ import urllib.parse
7
+
8
+ from typing import Optional, SupportsInt, Union, List
9
+
10
+ KIB = 1024
11
+ MIB = 1024 ** 2
12
+ GIB = 1024 ** 3
13
+ TIB = 1024 ** 4
14
+ PIB = 1024 ** 5
15
+ EIB = 1024 ** 6
16
+
17
+ KB = 1000
18
+ MB = 1000 ** 2
19
+ GB = 1000 ** 3
20
+ TB = 1000 ** 4
21
+ PB = 1000 ** 5
22
+ EB = 1000 ** 6
8
23
 
9
24
  # See https://en.wikipedia.org/wiki/Binary_prefix
10
- BINARY_PREFIXES = [
11
- "ki",
12
- "mi",
13
- "gi",
14
- "ti",
15
- "pi",
16
- "ei",
17
- "kib",
18
- "mib",
19
- "gib",
20
- "tib",
21
- "pib",
22
- "eib",
23
- ]
24
- DECIMAL_PREFIXES = [
25
- "b",
26
- "k",
27
- "m",
28
- "g",
29
- "t",
30
- "p",
31
- "e",
32
- "kb",
33
- "mb",
34
- "gb",
35
- "tb",
36
- "pb",
37
- "eb",
38
- ]
25
+ BINARY_PREFIXES = ['ki', 'mi', 'gi', 'ti', 'pi', 'ei', 'kib', 'mib', 'gib', 'tib', 'pib', 'eib']
26
+ DECIMAL_PREFIXES = ['b', 'k', 'm', 'g', 't', 'p', 'e', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb']
39
27
  VALID_PREFIXES = BINARY_PREFIXES + DECIMAL_PREFIXES
40
28
 
41
29
 
@@ -185,3 +173,17 @@ def strtobool(val: str) -> bool:
185
173
  def opt_strtobool(b: Optional[str]) -> Optional[bool]:
186
174
  """Convert an optional string representation of bool to None or bool"""
187
175
  return b if b is None else strtobool(b)
176
+
177
+
178
+ def modify_url(url: str, remove: List[str]) -> str:
179
+ """
180
+ Given a valid URL string, split out the params, remove any offending
181
+ params in 'remove', and return the cleaned URL.
182
+ """
183
+ scheme, netloc, path, query, fragment = urllib.parse.urlsplit(url)
184
+ params = urllib.parse.parse_qs(query)
185
+ for param_key in remove:
186
+ if param_key in params:
187
+ del params[param_key]
188
+ query = urllib.parse.urlencode(params, doseq=True)
189
+ return urllib.parse.urlunsplit((scheme, netloc, path, query, fragment))
toil/lib/directory.py ADDED
@@ -0,0 +1,217 @@
1
+ # Copyright (C) 2015-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
+ import json
16
+ import base64
17
+
18
+ from urllib.parse import quote, unquote
19
+
20
+ from typing import Iterator, Optional, Union
21
+
22
+ TOIL_DIR_URI_SCHEME = "toildir:"
23
+
24
+
25
+ DirectoryContents = dict[str, Union[str, "DirectoryContents"]]
26
+
27
+
28
+ def check_directory_dict_invariants(contents: DirectoryContents) -> None:
29
+ """
30
+ Make sure a directory structure dict makes sense. Throws an error
31
+ otherwise.
32
+
33
+ Currently just checks to make sure no empty-string keys exist.
34
+ """
35
+
36
+ for name, item in contents.items():
37
+ if name == "":
38
+ raise RuntimeError(
39
+ "Found nameless entry in directory: " + json.dumps(contents, indent=2)
40
+ )
41
+ if isinstance(item, dict):
42
+ check_directory_dict_invariants(item)
43
+
44
+
45
+ def decode_directory(
46
+ dir_path: str,
47
+ ) -> tuple[DirectoryContents, Optional[str], str, Optional[str], Optional[str]]:
48
+ """
49
+ Decode a directory from a "toildir:" path to a directory (or a file in it).
50
+
51
+ :returns: the decoded directory dict, the remaining part of the path (which
52
+ may be None), an identifier string for the directory (which is the
53
+ stored name URI if one was provided), and the name URI and source task
54
+ info.
55
+ """
56
+ if not dir_path.startswith(TOIL_DIR_URI_SCHEME):
57
+ raise RuntimeError(f"Cannot decode non-directory path: {dir_path}")
58
+
59
+ # We will decode the directory and then look inside it
60
+
61
+ # Since this was encoded by upload_directory we know the
62
+ # next pieces are encoded source URL, encoded source task, and JSON
63
+ # describing the directory structure, and it can't contain any slashes.
64
+ #
65
+ # So split on slash to separate all that from the path components within
66
+ # the directory to whatever we're trying to get.
67
+ parts = dir_path[len(TOIL_DIR_URI_SCHEME) :].split("/", 1)
68
+
69
+ # Before the first slash is the encoded data describing the directory contents
70
+ encoded_name, encoded_source, dir_data = parts[0].split(":")
71
+ # Decode the name and source, replacing empty string with None again.
72
+ name: Optional[str] = unquote(encoded_name) or None
73
+ source: Optional[str] = unquote(encoded_source) or None
74
+
75
+ # We need the unique key identifying this directory, which is where it came
76
+ # from if stored, or the encoded data itself otherwise.
77
+ # TODO: Is this too complicated?
78
+ directory_identifier = name if name is not None else dir_data
79
+
80
+ # Decode what to download
81
+ contents = json.loads(
82
+ base64.urlsafe_b64decode(dir_data.encode("utf-8")).decode("utf-8")
83
+ )
84
+
85
+ check_directory_dict_invariants(contents)
86
+
87
+ if len(parts) == 1 or parts[1] == "/":
88
+ # We didn't have any subdirectory
89
+ return contents, None, directory_identifier, name, source
90
+ else:
91
+ # We have a path below this
92
+ return contents, parts[1], directory_identifier, name, source
93
+
94
+ def encode_directory(contents: DirectoryContents, name: Optional[str] = None, source: Optional[str] = None) -> str:
95
+ """
96
+ Encode a directory from a "toildir:" path to a directory (or a file in it).
97
+
98
+ :param contents: the directory dict, which is a dict from name to URI for a
99
+ file or dict for a subdirectory.
100
+ :param name: the path or URI the directory belongs at, including its
101
+ basename. May not be empty if set.
102
+ :param source: the name of a workflow component that uploaded the
103
+ directory. May not be empty if set.
104
+ """
105
+
106
+ check_directory_dict_invariants(contents)
107
+
108
+ parts = [
109
+ TOIL_DIR_URI_SCHEME[:-1],
110
+ quote(name or "", safe=""),
111
+ quote(source or "", safe=""),
112
+ base64.urlsafe_b64encode(
113
+ json.dumps(contents).encode("utf-8")
114
+ ).decode("utf-8"),
115
+ ]
116
+
117
+ return ":".join(parts)
118
+
119
+
120
+ def directory_item_exists(dir_path: str) -> bool:
121
+ """
122
+ Checks that a URL to a Toil directory or thing in it actually exists.
123
+
124
+ Assumes that all the pointed-to URLs exist; just checks tha tthe thing is
125
+ actually in the encoded directory structure.
126
+ """
127
+
128
+ try:
129
+ get_directory_item(dir_path)
130
+ except FileNotFoundError:
131
+ return False
132
+ return True
133
+
134
+ def get_directory_contents_item(contents: DirectoryContents, remaining_path: Optional[str]) -> Union[DirectoryContents, str]:
135
+ """
136
+ Get a subdirectory or file from a decoded directory and remaining path.
137
+ """
138
+
139
+ if remaining_path is None:
140
+ return contents
141
+
142
+ here: Union[str, DirectoryContents] = contents
143
+ for part in remaining_path.split("/"):
144
+ if not isinstance(here, dict):
145
+ # We're trying to go inside a file
146
+ raise FileNotFoundError(remaining_path)
147
+ if part not in here:
148
+ # We've hit a nonexistent path component
149
+ raise FileNotFoundError(remaining_path)
150
+ here = here[part]
151
+ # If we get here we successfully looked up the thing in the structure
152
+ return here
153
+
154
+ def get_directory_item(dir_path: str) -> Union[DirectoryContents, str]:
155
+ """
156
+ Get a subdirectory or file from a URL pointing to or into a toildir: directory.
157
+ """
158
+
159
+ contents, remaining_path, _, _, _ = decode_directory(dir_path)
160
+
161
+ try:
162
+ return get_directory_contents_item(contents, remaining_path)
163
+ except FileNotFoundError:
164
+ # Rewrite file not found to be about the full thing we went to look up.
165
+ raise FileNotFoundError(dir_path)
166
+
167
+ def directory_contents_items(contents: DirectoryContents) -> Iterator[tuple[str, Union[str, None]]]:
168
+ """
169
+ Yield each file or directory under the given contents, including itself.
170
+
171
+ Yields parent items before children.
172
+
173
+ Yields each item as a str path from the root (possibly empty), and either a
174
+ str value for files or a None for directories.
175
+
176
+ The path won't have trailing slashes.
177
+ """
178
+
179
+ # Yield the thing itself
180
+ yield ("", None)
181
+
182
+ for k, v in contents.items():
183
+ if isinstance(v, str):
184
+ # Yield a file
185
+ yield (k, v)
186
+ else:
187
+ # Recurse on the directory
188
+ for child_path, child_value in directory_contents_items(v):
189
+ yield (f"{k}/{child_path}", child_value)
190
+
191
+ def directory_items(dir_path: str) -> Iterator[tuple[str, Union[str, None]]]:
192
+ """
193
+ Yield each file or directory under the given path, including itself.
194
+
195
+ Yields parent items before children.
196
+
197
+ Yields each item as a str path from the root (possibly empty), and either a
198
+ str value for files or a None for directories.
199
+
200
+ The path won't have trailing slashes.
201
+ """
202
+
203
+ item = get_directory_item(dir_path)
204
+
205
+ if isinstance(item, str):
206
+ # Only one item and it's this file
207
+ yield ("", item)
208
+ else:
209
+ # It's a directory in there.
210
+ yield from directory_contents_items(item)
211
+
212
+
213
+
214
+
215
+
216
+
217
+
toil/lib/ec2.py CHANGED
@@ -1,11 +1,19 @@
1
1
  import logging
2
2
  import time
3
- from base64 import b64encode
3
+ from base64 import b64encode, b64decode
4
+ import binascii
4
5
  from collections.abc import Generator, Iterable, Mapping
5
- from typing import TYPE_CHECKING, Any, Callable, Optional, Union
6
+ from typing import (
7
+ TYPE_CHECKING,
8
+ Any,
9
+ Callable,
10
+ Literal,
11
+ Optional,
12
+ Union,
13
+ )
6
14
 
7
15
  from toil.lib.aws.session import establish_boto3_session
8
- from toil.lib.aws.utils import flatten_tags
16
+ from toil.lib.aws.utils import flatten_tags, boto3_pager
9
17
  from toil.lib.exceptions import panic
10
18
  from toil.lib.retry import (
11
19
  ErrorCondition,
@@ -29,6 +37,19 @@ a_short_time = 5
29
37
  a_long_time = 60 * 60
30
38
  logger = logging.getLogger(__name__)
31
39
 
40
+ def is_base64(value: str) -> bool:
41
+ """
42
+ Return True if value is base64-decodeable, and False otherwise.
43
+ """
44
+ try:
45
+ b64decode(
46
+ value.encode("utf-8"),
47
+ validate=True
48
+ )
49
+ return True
50
+ except binascii.Error:
51
+ return False
52
+
32
53
 
33
54
  class UserError(RuntimeError):
34
55
  def __init__(self, message=None, cause=None):
@@ -129,7 +150,7 @@ def wait_instances_running(
129
150
  elif i["State"]["Name"] == "running":
130
151
  if i["InstanceId"] in running_ids:
131
152
  raise RuntimeError(
132
- "An instance was already added to the list of running instance IDs. Maybe there is a duplicate."
153
+ f"Instance {i['InstanceId']} was already added to the list of running instance IDs. Maybe there is a duplicate."
133
154
  )
134
155
  running_ids.add(i["InstanceId"])
135
156
  yield i
@@ -151,12 +172,15 @@ def wait_instances_running(
151
172
  time.sleep(seconds)
152
173
  for attempt in retry_ec2():
153
174
  with attempt:
154
- described_instances = boto3_ec2.describe_instances(
175
+ # describe_instances weirdly really describes reservations
176
+ reservations = boto3_pager(
177
+ boto3_ec2.describe_instances,
178
+ "Reservations",
155
179
  InstanceIds=list(pending_ids)
156
180
  )
157
181
  instances = [
158
182
  instance
159
- for reservation in described_instances["Reservations"]
183
+ for reservation in reservations
160
184
  for instance in reservation["Instances"]
161
185
  ]
162
186
 
@@ -184,6 +208,9 @@ def wait_spot_requests_active(
184
208
 
185
209
  if timeout is not None:
186
210
  timeout = time.time() + timeout
211
+
212
+ # These hold spot instance request IDs.
213
+ # Not to be confused with instance IDs.
187
214
  active_ids = set()
188
215
  other_ids = set()
189
216
  open_ids = None
@@ -201,34 +228,37 @@ def wait_spot_requests_active(
201
228
  batch = []
202
229
  for r in requests:
203
230
  r: "SpotInstanceRequestTypeDef" # pycharm thinks it is a string
231
+ assert isinstance(r, dict), f"Found garbage posing as a spot request: {r}"
204
232
  if r["State"] == "open":
205
- open_ids.add(r["InstanceId"])
206
- if r["Status"] == "pending-evaluation":
207
- eval_ids.add(r["InstanceId"])
208
- elif r["Status"] == "pending-fulfillment":
209
- fulfill_ids.add(r["InstanceId"])
233
+ open_ids.add(r["SpotInstanceRequestId"])
234
+ if r["Status"]["Code"] == "pending-evaluation":
235
+ eval_ids.add(r["SpotInstanceRequestId"])
236
+ elif r["Status"]["Code"] == "pending-fulfillment":
237
+ fulfill_ids.add(r["SpotInstanceRequestId"])
210
238
  else:
211
239
  logger.info(
212
240
  "Request %s entered status %s indicating that it will not be "
213
- "fulfilled anytime soon.",
214
- r["InstanceId"],
215
- r["Status"],
241
+ "fulfilled anytime soon. (Message: %s)",
242
+ r["SpotInstanceRequestId"],
243
+ r["Status"]["Code"],
244
+ r["Status"].get("Message"),
216
245
  )
217
246
  elif r["State"] == "active":
218
- if r["InstanceId"] in active_ids:
247
+ if r["SpotInstanceRequestId"] in active_ids:
219
248
  raise RuntimeError(
220
249
  "A request was already added to the list of active requests. Maybe there are duplicate requests."
221
250
  )
222
- active_ids.add(r["InstanceId"])
251
+ active_ids.add(r["SpotInstanceRequestId"])
223
252
  batch.append(r)
224
253
  else:
225
- if r["InstanceId"] in other_ids:
254
+ if r["SpotInstanceRequestId"] in other_ids:
226
255
  raise RuntimeError(
227
256
  "A request was already added to the list of other IDs. Maybe there are duplicate requests."
228
257
  )
229
- other_ids.add(r["InstanceId"])
258
+ other_ids.add(r["SpotInstanceRequestId"])
230
259
  batch.append(r)
231
260
  if batch:
261
+ logger.debug("Found %d new active/other spot requests", len(batch))
232
262
  yield batch
233
263
  logger.info(
234
264
  "%i spot requests(s) are open (%i of which are pending evaluation and %i "
@@ -247,8 +277,10 @@ def wait_spot_requests_active(
247
277
  time.sleep(sleep_time)
248
278
  for attempt in retry_ec2(retry_while=spot_request_not_found):
249
279
  with attempt:
250
- requests = boto3_ec2.describe_spot_instance_requests(
251
- SpotInstanceRequestIds=list(open_ids)
280
+ requests = boto3_pager(
281
+ boto3_ec2.describe_spot_instance_requests,
282
+ "SpotInstanceRequests",
283
+ SpotInstanceRequestIds=list(open_ids),
252
284
  )
253
285
  except BaseException:
254
286
  if open_ids:
@@ -264,14 +296,20 @@ def create_spot_instances(
264
296
  boto3_ec2: "EC2Client",
265
297
  price,
266
298
  image_id,
267
- spec,
299
+ spec: dict[Literal["LaunchSpecification"], dict[str, Any]],
268
300
  num_instances=1,
269
301
  timeout=None,
270
302
  tentative=False,
271
- tags=None,
303
+ tags: dict[str, str] = None,
272
304
  ) -> Generator["DescribeInstancesResultTypeDef", None, None]:
273
305
  """
274
306
  Create instances on the spot market.
307
+
308
+ The "UserData" field in "LaunchSpecification" in spec MUST ALREADY BE
309
+ base64-encoded. It will NOT be automatically encoded.
310
+
311
+ :param tags: Dict from tag key to tag value of tags to apply to the
312
+ request.
275
313
  """
276
314
 
277
315
  def spotRequestNotFound(e):
@@ -280,20 +318,27 @@ def create_spot_instances(
280
318
  spec["LaunchSpecification"].update(
281
319
  {"ImageId": image_id}
282
320
  ) # boto3 image id is in the launch specification
321
+
322
+ user_data = spec["LaunchSpecification"].get("UserData", "")
323
+ assert is_base64(user_data), f"Spot user data needs to be base64-encoded: {user_data}"
324
+
283
325
  for attempt in retry_ec2(
284
326
  retry_for=a_long_time, retry_while=inconsistencies_detected
285
327
  ):
286
328
  with attempt:
287
329
  requests_dict = boto3_ec2.request_spot_instances(
288
- SpotPrice=price, InstanceCount=num_instances, **spec
330
+ SpotPrice=str(price), InstanceCount=num_instances, **spec
289
331
  )
290
332
  requests = requests_dict["SpotInstanceRequests"]
291
333
 
334
+ assert isinstance(requests, list)
335
+
292
336
  if tags is not None:
337
+ flat_tags = flatten_tags(tags)
293
338
  for requestID in (request["SpotInstanceRequestId"] for request in requests):
294
339
  for attempt in retry_ec2(retry_while=spotRequestNotFound):
295
340
  with attempt:
296
- boto3_ec2.create_tags(Resources=[requestID], Tags=tags)
341
+ boto3_ec2.create_tags(Resources=[requestID], Tags=flat_tags)
297
342
 
298
343
  num_active, num_other = 0, 0
299
344
  # noinspection PyUnboundLocalVariable,PyTypeChecker
@@ -310,7 +355,7 @@ def create_spot_instances(
310
355
  else:
311
356
  logger.info(
312
357
  "Request %s in unexpected state %s.",
313
- request["InstanceId"],
358
+ request["SpotInstanceRequestId"],
314
359
  request["State"],
315
360
  )
316
361
  num_other += 1
@@ -324,7 +369,14 @@ def create_spot_instances(
324
369
  boto3_ec2.modify_instance_metadata_options(
325
370
  InstanceId=instance_id, HttpPutResponseHopLimit=3
326
371
  )
327
- yield boto3_ec2.describe_instances(InstanceIds=instance_ids)
372
+ # We can't use the normal boto3_pager here because we're weirdly
373
+ # specced as yielding the pages ourselves.
374
+ # TODO: Change this to just yield instance descriptions instead.
375
+ page = boto3_ec2.describe_instances(InstanceIds=instance_ids)
376
+ while page.get("NextToken") is not None:
377
+ yield page
378
+ page = boto3_ec2.describe_instances(InstanceIds=instance_ids, NextToken=page["NextToken"])
379
+ yield page
328
380
  if not num_active:
329
381
  message = "None of the spot requests entered the active state"
330
382
  if tentative:
@@ -335,6 +387,9 @@ def create_spot_instances(
335
387
  logger.warning("%i request(s) entered a state other than active.", num_other)
336
388
 
337
389
 
390
+ # TODO: Get rid of this and use create_instances instead.
391
+ # Right now we need it because we have code that needs an InstanceTypeDef for
392
+ # either a spot or an ondemand instance.
338
393
  def create_ondemand_instances(
339
394
  boto3_ec2: "EC2Client",
340
395
  image_id: str,
@@ -344,7 +399,19 @@ def create_ondemand_instances(
344
399
  """
345
400
  Requests the RunInstances EC2 API call but accounts for the race between recently created
346
401
  instance profiles, IAM roles and an instance creation that refers to them.
402
+
403
+ The "UserData" field in spec MUST NOT be base64 encoded; it will be
404
+ base64-encoded by boto3 automatically. See
405
+ <https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/run_instances.html>.
406
+
407
+ Replaced by create_instances.
347
408
  """
409
+
410
+ user_data: str = spec.get("UserData", "")
411
+ if user_data:
412
+ # Hope any real user data contains some characters not allowed in base64
413
+ assert not is_base64(user_data), f"On-demand user data needs to not be base64-encoded: {user_data}"
414
+
348
415
  instance_type = spec["InstanceType"]
349
416
  logger.info("Creating %s instance(s) ... ", instance_type)
350
417
  boto_instance_list = []
@@ -434,12 +501,13 @@ def create_instances(
434
501
  Not to be confused with "run_instances" (same input args; returns a dictionary):
435
502
  https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2.html#EC2.Client.run_instances
436
503
 
437
- Tags, if given, are applied to the instances, and all volumes.
504
+ :param user_data: non-base64-encoded user data to control instance startup.
505
+ :param tags: if given, these tags are applied to the instances, and all volumes.
438
506
  """
439
507
  logger.info("Creating %s instance(s) ... ", instance_type)
440
508
 
441
- if isinstance(user_data, str):
442
- user_data = user_data.encode("utf-8")
509
+ if isinstance(user_data, bytes):
510
+ user_data = user_data.decode("utf-8")
443
511
 
444
512
  request = {
445
513
  "ImageId": image_id,
toil/lib/exceptions.py CHANGED
@@ -18,6 +18,7 @@ import sys
18
18
  from typing import Optional
19
19
  import logging
20
20
  from urllib.parse import ParseResult
21
+ from types import TracebackType
21
22
 
22
23
 
23
24
  # TODO: isn't this built in to Python 3 now?
@@ -56,7 +57,7 @@ class panic:
56
57
  raise_(exc_type, exc_value, traceback)
57
58
 
58
59
 
59
- def raise_(exc_type, exc_value, traceback) -> None:
60
+ def raise_(exc_type: Optional[type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]) -> None:
60
61
  if exc_value is not None:
61
62
  exc = exc_value
62
63
  else:
toil/lib/expando.py CHANGED
@@ -67,10 +67,10 @@ class Expando(dict):
67
67
  ...
68
68
  KeyError: 'foo'
69
69
 
70
- >>> del o.foo
70
+ >>> del o.foo # doctest: +IGNORE_EXCEPTION_DETAIL
71
71
  Traceback (most recent call last):
72
72
  ...
73
- AttributeError: foo
73
+ AttributeError: 'Expando' object has no attribute 'foo'
74
74
 
75
75
  And copied:
76
76