coiled 1.118.4.dev6__py3-none-any.whl → 1.129.3.dev10__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.
coiled/filestore.py ADDED
@@ -0,0 +1,458 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ from rich.align import Align
10
+ from rich.console import Group
11
+ from rich.prompt import Confirm
12
+ from rich.status import Status
13
+
14
+ import coiled
15
+ from coiled.cli.curl import sync_request
16
+ from coiled.exceptions import CoiledException
17
+
18
+
19
+ def wait_until_complete(cluster_id, wait_for_output=True, wait_for_input=False):
20
+ done = False
21
+ attachments = None
22
+ timeout_at = time.monotonic() + 30
23
+ while not done and time.monotonic() < timeout_at:
24
+ attachments = FilestoreManager.get_cluster_attachments(cluster_id)
25
+ if not attachments:
26
+ return None
27
+
28
+ done = all(
29
+ a["complete"] for a in attachments if ((wait_for_output and a["output"]) or (wait_for_input and a["input"]))
30
+ )
31
+ if not done:
32
+ time.sleep(2)
33
+ return attachments
34
+
35
+
36
+ def list_files_ui(fs, name_includes=None):
37
+ blobs = FilestoreManager.get_download_list_with_urls(fs["id"])
38
+
39
+ if name_includes:
40
+ blobs = [blob for blob in blobs if name_includes in blob["relative_path"]]
41
+
42
+ for blob in blobs:
43
+ print(blob["relative_path"])
44
+
45
+
46
+ def download_from_filestore_with_ui(fs, into=".", name_includes=None):
47
+ if fs:
48
+ # TODO (possible enhancement) if "has files" flag is set then make sure we do see files to download?
49
+ blobs = FilestoreManager.get_download_list_with_urls(fs["id"])
50
+
51
+ if name_includes:
52
+ blobs = [blob for blob in blobs if name_includes in blob["relative_path"]]
53
+
54
+ total_bytes = sum(blob["size"] for blob in blobs)
55
+
56
+ size_label = "Bytes"
57
+ size_scale = 1
58
+
59
+ if total_bytes > 10_000_000:
60
+ size_label = "Mb"
61
+ size_scale = 1_000_000
62
+ elif total_bytes > 10_000:
63
+ size_label = "Kb"
64
+ size_scale = 1_000
65
+
66
+ def progress_title(f=None):
67
+ return Group(
68
+ Align.left(Status(f"Downloading from cloud storage: [green]{fs['name']}[green]", spinner="dots")),
69
+ Align.left(f"Local directory: [green]{into}[/green]"),
70
+ Align.left(f"Currently downloading: [blue]{f or ''}[/blue]"),
71
+ )
72
+
73
+ with coiled.utils.SimpleRichProgressPanel.from_defaults(title=progress_title()) as progress:
74
+ done_files = 0
75
+ done_bytes = 0
76
+
77
+ progress.update_progress([
78
+ {"label": "Files", "total": len(blobs), "completed": done_files},
79
+ {
80
+ "label": size_label,
81
+ "total": total_bytes / size_scale if size_scale > 1 else total_bytes,
82
+ "completed": done_bytes / size_scale if size_scale > 1 else done_bytes,
83
+ },
84
+ ])
85
+
86
+ for blob in blobs:
87
+ progress.update_title(progress_title(blob["key"]))
88
+
89
+ FilestoreManager.download_from_signed_url(
90
+ local_path=os.path.join(into, blob["relative_path"]),
91
+ url=blob["url"],
92
+ )
93
+
94
+ done_files += 1
95
+ done_bytes += blob["size"]
96
+
97
+ progress.update_progress([
98
+ {"label": "Files", "total": len(blobs), "completed": done_files},
99
+ {
100
+ "label": size_label,
101
+ "total": total_bytes / size_scale if size_scale > 1 else total_bytes,
102
+ "completed": done_bytes / size_scale if size_scale > 1 else done_bytes,
103
+ },
104
+ ])
105
+
106
+ progress.update_title(
107
+ Group(
108
+ Align.left(f"Downloaded from cloud storage: [green]{fs['name']}[green]"),
109
+ Align.left(f"Local directory: [green]{into}[/green]"),
110
+ )
111
+ )
112
+
113
+
114
+ def upload_to_filestore_with_ui(fs, local_dir, file_buffers=None):
115
+ # TODO (future enhancement) send write status
116
+ # this is tricky because status is stored on the "attachment" object, which might not exist yet
117
+ # because we want to be able to upload files before cluster has been created
118
+ # FilestoreManager.post_fs_write_status(fs["id"], "start")
119
+
120
+ def progress_title(f=None):
121
+ return Group(
122
+ Align.left(Status(f"Uploading to cloud storage: [green]{fs['name']}[green]", spinner="dots")),
123
+ Align.left(f"Currently uploading: [blue]{f or ''}[/blue]"),
124
+ )
125
+
126
+ files = []
127
+ total_bytes = None
128
+
129
+ if fs:
130
+ if local_dir:
131
+ files_from_path, total_bytes = FilestoreManager.get_files_for_upload(local_dir)
132
+ files.extend(files_from_path)
133
+ if file_buffers:
134
+ files.extend(file_buffers)
135
+
136
+ if files:
137
+ size_label = "Bytes"
138
+ size_scale = 1
139
+
140
+ if total_bytes and total_bytes > 10_000_000:
141
+ size_label = "Mb"
142
+ size_scale = 1_000_000
143
+ elif total_bytes and total_bytes > 10_000:
144
+ size_label = "Kb"
145
+ size_scale = 1_000
146
+
147
+ with coiled.utils.SimpleRichProgressPanel.from_defaults(title=progress_title()) as progress:
148
+ done_files = 0
149
+ done_bytes = 0
150
+
151
+ progress.update_progress([
152
+ {"label": "Files", "total": len(files), "completed": done_files},
153
+ {
154
+ "label": size_label,
155
+ "total": total_bytes / size_scale if size_scale > 1 else total_bytes,
156
+ "completed": done_bytes / size_scale if size_scale > 1 else done_bytes,
157
+ }
158
+ if total_bytes
159
+ else {},
160
+ ])
161
+
162
+ # files_for_upload is type list[dict] where each dict has "relative_path" key
163
+ upload_info = FilestoreManager.get_signed_upload_urls(fs["id"], files_for_upload=files)
164
+
165
+ upload_urls = upload_info.get("urls")
166
+ existing_blobs = upload_info.get("existing")
167
+
168
+ for file in files:
169
+ relative_path = file.get("relative_path")
170
+ local_path = file.get("local_path")
171
+ buffer = file.get("buffer")
172
+ if local_path:
173
+ size = file.get("size")
174
+ skip_upload = False
175
+ existing_blob_info = existing_blobs.get(relative_path)
176
+ if existing_blob_info:
177
+ modified = os.path.getmtime(local_path)
178
+ if size == existing_blob_info["size"] and modified < existing_blob_info["modified"]:
179
+ skip_upload = True
180
+
181
+ if not skip_upload:
182
+ progress.batch_title = progress_title(local_path)
183
+ progress.refresh()
184
+
185
+ FilestoreManager.upload_to_signed_url(local_path, upload_urls[relative_path])
186
+
187
+ done_bytes += size
188
+
189
+ elif buffer:
190
+ FilestoreManager.upload_bytes_to_signed_url(buffer, upload_urls[relative_path])
191
+
192
+ done_files += 1
193
+
194
+ progress.update_progress([
195
+ {"label": "Files", "total": len(files), "completed": done_files},
196
+ {
197
+ "label": size_label,
198
+ "total": total_bytes / size_scale if size_scale > 1 else total_bytes,
199
+ "completed": done_bytes / size_scale if size_scale > 1 else done_bytes,
200
+ }
201
+ if total_bytes
202
+ else {},
203
+ ])
204
+
205
+ progress.update_title(Align.left(f"Uploaded to cloud storage: [green]{fs['name']}[green]"))
206
+
207
+ # TODO (future enhancement) send write status
208
+ # FilestoreManager.post_fs_write_status(fs["id"], "finish", {"complete": True, "file_count": len(files)})
209
+
210
+ return len(files)
211
+
212
+
213
+ def upload_bytes_to_fs(fs, files):
214
+ def progress_title(f=None):
215
+ return Group(
216
+ Align.left(Status(f"Uploading to cloud storage: [green]{fs['name']}[green]", spinner="dots")),
217
+ Align.left(f"Currently uploading: [blue]{f or ''}[/blue]"),
218
+ )
219
+
220
+ if fs and files:
221
+ with coiled.utils.SimpleRichProgressPanel.from_defaults(title=progress_title()) as progress:
222
+ done_files = 0
223
+
224
+ progress.update_progress([
225
+ {"label": "Files", "total": len(files), "completed": done_files},
226
+ ])
227
+
228
+ upload_info = FilestoreManager.get_signed_upload_urls(fs["id"], files_for_upload=files)
229
+
230
+ upload_urls = upload_info.get("urls")
231
+ existing_blobs = upload_info.get("existing")
232
+
233
+ for file in files:
234
+ local_path = file.get("local_path")
235
+ relative_path = file.get("relative_path")
236
+ size = file.get("size")
237
+ skip_upload = False
238
+ existing_blob_info = existing_blobs.get(relative_path)
239
+ if existing_blob_info:
240
+ modified = os.path.getmtime(local_path)
241
+ if size == existing_blob_info["size"] and modified < existing_blob_info["modified"]:
242
+ skip_upload = True
243
+
244
+ if not skip_upload:
245
+ progress.batch_title = progress_title(local_path)
246
+ progress.refresh()
247
+
248
+ FilestoreManager.upload_to_signed_url(local_path, upload_urls[relative_path])
249
+
250
+ done_files += 1
251
+
252
+ progress.update_progress([
253
+ {"label": "Files", "total": len(files), "completed": done_files},
254
+ ])
255
+
256
+ progress.update_title(Align.left(f"Uploaded to cloud storage: [green]{fs['name']}[green]"))
257
+
258
+ # TODO (future enhancement) send write status
259
+ # FilestoreManager.post_fs_write_status(fs["id"], "finish", {"complete": True, "file_count": len(files)})
260
+
261
+ return len(files)
262
+
263
+
264
+ def clear_filestores_with_ui(filestores):
265
+ seen = set()
266
+ # see if user wants to delete files from cloud storage now that job is done and results are downloaded
267
+ # TODO (possible feature enhancement)
268
+ # distinguish filestores created for this specific job from "named" filestores made explicitly?
269
+ for fs in filestores:
270
+ if fs["id"] in seen:
271
+ continue
272
+ seen.add(fs["id"])
273
+ if Confirm.ask(f"Clear cloud storage for [green]{fs['name']}[/green]?", default=True):
274
+ FilestoreManager.clear_fs(fs["id"])
275
+
276
+
277
+ class FilestoreManagerWithoutHttp:
278
+ # code duplicated between coiled_agent.py and coiled client package
279
+ http2 = False
280
+
281
+ @staticmethod
282
+ def make_req(api_path, post=False, data=None):
283
+ raise NotImplementedError()
284
+
285
+ @classmethod
286
+ def get_filestore(cls, name=None):
287
+ if name:
288
+ return cls.make_req(f"/api/v2/filestore/name/{name}").get("filestores")
289
+
290
+ @classmethod
291
+ def get_or_create_filestores(cls, names, workspace, region):
292
+ return cls.make_req(
293
+ "/api/v2/filestore/list", post=True, data={"names": names, "workspace": workspace, "region": region}
294
+ ).get("filestores")
295
+
296
+ @classmethod
297
+ def get_cluster_attachments(cls, cluster_id):
298
+ return cls.make_req(f"/api/v2/filestore/cluster/{cluster_id}").get("attachments")
299
+
300
+ @classmethod
301
+ def get_vm_attachments(cls, vm_role=""):
302
+ return cls.make_req(f"/api/v2/filestore/vm/{vm_role}").get("attachments")
303
+
304
+ @classmethod
305
+ def get_signed_upload_urls(cls, fs_id, files_for_upload):
306
+ paths = [f["relative_path"] for f in files_for_upload] # relative paths
307
+ return cls.make_req(f"/api/v2/filestore/fs/{fs_id}/signed-urls/upload", post=True, data={"paths": paths})
308
+
309
+ @classmethod
310
+ def get_download_list_with_urls(cls, fs_id):
311
+ return cls.make_req(f"/api/v2/filestore/fs/{fs_id}/download-with-urls").get("blobs_with_urls")
312
+
313
+ @classmethod
314
+ def attach_filestores_to_cluster(cls, cluster_id, attachments):
315
+ return cls.make_req(
316
+ "/api/v2/filestore/attach",
317
+ post=True,
318
+ data={
319
+ "cluster_id": cluster_id,
320
+ "attachments": attachments,
321
+ },
322
+ )
323
+
324
+ @classmethod
325
+ def post_fs_write_status(cls, fs_id, action: str, data: dict | None = None):
326
+ # this endpoint uses cluster auth to determine the filestore
327
+ cls.make_req(f"/api/v2/filestore/fs/{fs_id}/status/{action}", post=True, data=data)
328
+
329
+ @classmethod
330
+ def clear_fs(cls, fs_id):
331
+ cls.make_req(f"/api/v2/filestore/fs/{fs_id}/clear", post=True)
332
+
333
+ @staticmethod
334
+ def get_files_for_upload(local_dir):
335
+ files = []
336
+ total_bytes = 0
337
+
338
+ # if we're given a specific file path instead of directory, then mark that file for upload
339
+ if os.path.isfile(local_dir):
340
+ local_path = local_dir
341
+ local_dir = os.path.dirname(local_path)
342
+ relative_path = Path(os.path.relpath(local_path, local_dir)).as_posix()
343
+ size = os.path.getsize(local_path)
344
+
345
+ files.append({"local_path": local_path, "relative_path": relative_path, "size": size})
346
+ total_bytes += size
347
+
348
+ return files, total_bytes
349
+
350
+ ignore_before_ts = 0
351
+ if os.path.exists(os.path.join(local_dir, ".ignore-before")):
352
+ ignore_before_ts = os.path.getmtime(os.path.join(local_dir, ".ignore-before"))
353
+
354
+ for parent_dir, _, children in os.walk(local_dir):
355
+ ignore_file_list = set()
356
+
357
+ if ".ignore-list" in children:
358
+ with open(os.path.join(parent_dir, ".ignore-list")) as f:
359
+ ignore_file_list = set(f.read().split("\n"))
360
+
361
+ for child in children:
362
+ local_path = os.path.join(parent_dir, child)
363
+
364
+ # we use .ignore-before file so that if we're using a directory which already had files
365
+ # (e.g., we're using same directory for inputs and outputs)
366
+ # then we'll only upload new or modified files, not prior unmodified files
367
+ if (
368
+ child.startswith(".ignore")
369
+ or child in ignore_file_list
370
+ or (ignore_before_ts and os.path.getmtime(local_path) < ignore_before_ts)
371
+ ):
372
+ continue
373
+
374
+ relative_path = Path(os.path.relpath(local_path, local_dir)).as_posix()
375
+ size = os.path.getsize(local_path)
376
+
377
+ files.append({"local_path": local_path, "relative_path": relative_path, "size": size})
378
+ total_bytes += size
379
+ return files, total_bytes
380
+
381
+ @classmethod
382
+ def upload_to_signed_url(cls, local_path: str, url: str):
383
+ with open(local_path, "rb") as f:
384
+ buffer = io.BytesIO(f.read())
385
+ cls.upload_bytes_to_signed_url(buffer=buffer, url=url)
386
+
387
+ @classmethod
388
+ def upload_bytes_to_signed_url(cls, buffer: io.BytesIO, url: str):
389
+ buffer.seek(0)
390
+ num_bytes = len(buffer.getvalue())
391
+ with httpx.Client(http2=cls.http2) as client:
392
+ headers = {"Content-Type": "binary/octet-stream", "Content-Length": str(num_bytes)}
393
+ if "blob.core.windows.net" in url:
394
+ headers["x-ms-blob-type"] = "BlockBlob"
395
+ # TODO error handling
396
+ client.put(
397
+ url,
398
+ # content must be set to an iterable of bytes, rather than a
399
+ # bytes object (like file.read()) because files >2GB need
400
+ # to be sent in chunks to avoid an OverflowError in the
401
+ # Python stdlib ssl module, and httpx will not chunk up a
402
+ # bytes object automatically.
403
+ content=buffer,
404
+ timeout=60,
405
+ headers=headers,
406
+ )
407
+
408
+ @classmethod
409
+ def download_from_signed_url(cls, local_path, url, max_retries=3, verbose=False):
410
+ # TODO (performance enhancement) check if file already exists, skip if match, warn if not
411
+ os.makedirs(os.path.dirname(local_path), exist_ok=True)
412
+
413
+ if verbose:
414
+ print(f"Downloading file from signed URL: {url} to {local_path}")
415
+
416
+ with httpx.Client(http2=cls.http2) as client:
417
+ for attempt in range(max_retries):
418
+ try:
419
+ with client.stream("GET", url, timeout=60) as response:
420
+ response.raise_for_status()
421
+ with open(local_path, "wb") as f:
422
+ for chunk in response.iter_bytes(chunk_size=8192):
423
+ f.write(chunk)
424
+ return # Success, exit function
425
+ except (httpx.RemoteProtocolError, httpx.ReadTimeout, httpx.ConnectError) as e:
426
+ if attempt < max_retries - 1:
427
+ wait_time = 2**attempt # Exponential backoff: 1s, 2s, 4s
428
+ if verbose:
429
+ print(
430
+ f"Download failed (attempt {attempt + 1}/{max_retries}): {e}. "
431
+ f"Retrying in {wait_time}s..."
432
+ )
433
+ time.sleep(wait_time)
434
+ else:
435
+ if verbose:
436
+ print(f"Download failed after {max_retries} attempts: {e}")
437
+ raise
438
+
439
+
440
+ class FilestoreManager(FilestoreManagerWithoutHttp):
441
+ http2 = True
442
+
443
+ @staticmethod
444
+ def make_req(api_path, post=False, data=None):
445
+ workspace = (data or {}).get("workspace")
446
+ with coiled.Cloud(workspace=workspace) as cloud:
447
+ url = f"{cloud.server}{api_path}"
448
+ response = sync_request(
449
+ cloud=cloud,
450
+ url=url,
451
+ method="post" if post else "get",
452
+ json=True,
453
+ data=data,
454
+ json_output=True,
455
+ )
456
+ if isinstance(response, dict) and response.get("error"):
457
+ raise CoiledException(f"\n\n{response['error']}")
458
+ return response
coiled/plugins.py CHANGED
@@ -29,9 +29,11 @@ class DaskSchedulerWriteFiles(SchedulerPlugin):
29
29
  target_dir = os.path.abspath(os.path.expanduser(target_dir))
30
30
  if not os.path.exists(target_dir):
31
31
  try:
32
+ os.makedirs(os.path.dirname(target_dir), exist_ok=True)
32
33
  os.symlink(source_dir, target_dir)
33
34
  except Exception:
34
35
  logger.exception(f"Error creating symlink from {source_dir} to {target_dir}")
36
+
35
37
  if self._symlink_dirs == {"/mount": "./mount"}:
36
38
  timeout = parse_timedelta(dask.config.get("coiled.mount-bucket.timeout", "30 s"))
37
39
  await wait_for_bucket_mounting(timeout=timeout, logger=logger)
@@ -58,6 +60,7 @@ class DaskWorkerWriteFiles(WorkerPlugin):
58
60
  target_dir = os.path.abspath(os.path.expanduser(target_dir))
59
61
  if not os.path.exists(target_dir):
60
62
  try:
63
+ os.makedirs(os.path.dirname(target_dir), exist_ok=True)
61
64
  os.symlink(source_dir, target_dir)
62
65
  except Exception:
63
66
  logger.exception(f"Error creating symlink from {source_dir} to {target_dir}")
coiled/pypi_conda_map.py CHANGED
@@ -33,6 +33,8 @@ PYPI_TO_CONDA = {
33
33
  "datadotworld": "datadotworld-py",
34
34
  "datalad_container": "datalad-container",
35
35
  "delegator.py": "delegator",
36
+ "dftd3": "dftd3-python",
37
+ "dftd4": "dftd4-python",
36
38
  "dials_data": "dials-data",
37
39
  "docker": "docker-py",
38
40
  "duckdb": "python-duckdb",
@@ -78,6 +80,7 @@ PYPI_TO_CONDA = {
78
80
  "kubernetes": "python-kubernetes",
79
81
  "libaio": "python-libaio",
80
82
  "libnacl": "libnacl-python-bindings",
83
+ "lmdb": "python-lmdb",
81
84
  "matplotlib": "matplotlib-base",
82
85
  "md_toc": "md-toc",
83
86
  "message_ix": "message-ix",
@@ -126,14 +129,18 @@ PYPI_TO_CONDA = {
126
129
  "symengine": "python-symengine",
127
130
  "systemd-python": "python-systemd",
128
131
  "tables": "pytables",
132
+ "tblite": "tblite-python",
129
133
  "termstyle": "python-termstyle",
130
134
  "torch": "pytorch",
135
+ "torch-cluster": "pytorch_cluster",
136
+ "torch-sparse": "pytorch_sparse",
131
137
  "torch_geometric": "pytorch_geometric",
132
138
  "trino": "trino-python-client",
133
139
  "typing": "typing",
134
140
  "typing-extensions": "typing_extensions",
135
141
  "useDAVE": "dave",
136
142
  "vsts": "vsts-python-api",
143
+ "xtb": "xtb-python",
137
144
  "xxhash": "python-xxhash",
138
145
  }
139
146
 
@@ -157,6 +164,8 @@ CONDA_TO_PYPI = {
157
164
  "datamatrix": "python-datamatrix",
158
165
  "dave": "useDAVE",
159
166
  "delegator": "delegator.py",
167
+ "dftd3-python": "dftd3",
168
+ "dftd4-python": "dftd4",
160
169
  "dials-data": "dials_data",
161
170
  "docker-py": "docker",
162
171
  "dye-score": "dye_score",
@@ -240,6 +249,7 @@ CONDA_TO_PYPI = {
240
249
  "python-kaleido": "kaleido",
241
250
  "python-kubernetes": "kubernetes",
242
251
  "python-libaio": "libaio",
252
+ "python-lmdb": "lmdb",
243
253
  "python-mss": "mss",
244
254
  "python-neo": "neo",
245
255
  "python-node-semver": "node-semver",
@@ -250,7 +260,9 @@ CONDA_TO_PYPI = {
250
260
  "python-termstyle": "termstyle",
251
261
  "python-xxhash": "xxhash",
252
262
  "pytorch": "torch",
263
+ "pytorch_cluster": "torch-cluster",
253
264
  "pytorch_geometric": "torch_geometric",
265
+ "pytorch_sparse": "torch-sparse",
254
266
  "pywin32-on-windows": "pywin32",
255
267
  "qdatamatrix": "python-qdatamatrix",
256
268
  "qnotifications": "python-qnotifications",
@@ -265,8 +277,10 @@ CONDA_TO_PYPI = {
265
277
  "setuptools-scm": "setuptools_scm",
266
278
  "spherical_functions": "spherical-functions",
267
279
  "sphinx_rtd_theme": "sphinx-rtd-theme",
280
+ "tblite-python": "tblite",
268
281
  "trino-python-client": "trino",
269
282
  "typing": "typing",
270
283
  "typing_extensions": "typing-extensions",
271
284
  "vsts-python-api": "vsts",
285
+ "xtb-python": "xtb",
272
286
  }