materials-commons-cli 2.2.0__tar.gz → 2.2.2__tar.gz

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 (71) hide show
  1. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/PKG-INFO +1 -1
  2. materials_commons_cli-2.2.2/materials_commons/cli/__init__.py +1 -0
  3. materials_commons_cli-2.2.2/materials_commons/cli/desktop/__init__.py +2 -0
  4. materials_commons_cli-2.2.2/materials_commons/cli/desktop/command_handlers.py +369 -0
  5. materials_commons_cli-2.2.2/materials_commons/cli/desktop/downloader/file_download_manager.py +143 -0
  6. materials_commons_cli-2.2.2/materials_commons/cli/desktop/downloader/file_downloader.py +287 -0
  7. materials_commons_cli-2.2.2/materials_commons/cli/desktop/local_rest_server.py +100 -0
  8. materials_commons_cli-2.2.2/materials_commons/cli/desktop/projects.py +88 -0
  9. materials_commons_cli-2.2.2/materials_commons/cli/desktop/uploader/__init__.py +0 -0
  10. materials_commons_cli-2.2.2/materials_commons/cli/desktop/uploader/api.py +178 -0
  11. materials_commons_cli-2.2.2/materials_commons/cli/desktop/uploader/file_transfer_manager.py +212 -0
  12. materials_commons_cli-2.2.2/materials_commons/cli/desktop/uploader/file_uploader.py +439 -0
  13. materials_commons_cli-2.2.2/materials_commons/cli/desktop/websocket_server.py +242 -0
  14. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/parser.py +8 -4
  15. materials_commons_cli-2.2.2/materials_commons/cli/subcommands/__init__.py +0 -0
  16. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons_cli.egg-info/PKG-INFO +1 -1
  17. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons_cli.egg-info/SOURCES.txt +12 -0
  18. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/setup.py +1 -1
  19. materials_commons_cli-2.2.0/materials_commons/cli/__init__.py +0 -1
  20. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/LICENSE +0 -0
  21. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/README.md +0 -0
  22. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/LICENSE.txt +0 -0
  23. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/cloned_project.py +0 -0
  24. {materials_commons_cli-2.2.0/materials_commons/cli/subcommands → materials_commons_cli-2.2.2/materials_commons/cli/desktop/downloader}/__init__.py +0 -0
  25. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/exceptions.py +0 -0
  26. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/file_functions.py +0 -0
  27. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/functions.py +0 -0
  28. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/globus.py +0 -0
  29. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/list_objects.py +0 -0
  30. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/print_formatter.py +0 -0
  31. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/sqltable.py +0 -0
  32. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/clone.py +0 -0
  33. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/config.py +0 -0
  34. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/dataset.py +0 -0
  35. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/down.py +0 -0
  36. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/expt.py +0 -0
  37. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/fetch.py +0 -0
  38. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/globus.py +0 -0
  39. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/init.py +0 -0
  40. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/ls.py +0 -0
  41. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/mkdir.py +0 -0
  42. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/mv.py +0 -0
  43. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/proj.py +0 -0
  44. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/remote.py +0 -0
  45. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/rm.py +0 -0
  46. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/server.py +0 -0
  47. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/up.py +0 -0
  48. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/subcommands/versions.py +0 -0
  49. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/tmp_functions.py +0 -0
  50. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/tree_functions.py +0 -0
  51. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/treedb.py +0 -0
  52. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons/cli/user_config.py +0 -0
  53. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons_cli.egg-info/dependency_links.txt +0 -0
  54. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons_cli.egg-info/entry_points.txt +0 -0
  55. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons_cli.egg-info/not-zip-safe +0 -0
  56. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons_cli.egg-info/requires.txt +0 -0
  57. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/materials_commons_cli.egg-info/top_level.txt +0 -0
  58. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/setup.cfg +0 -0
  59. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_api.py +0 -0
  60. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_functions.py +0 -0
  61. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_mc_dataset.py +0 -0
  62. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_mc_down.py +0 -0
  63. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_mc_mkdir.py +0 -0
  64. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_mc_mv.py +0 -0
  65. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_mc_proj.py +0 -0
  66. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_mc_rm.py +0 -0
  67. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_mc_up.py +0 -0
  68. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_treecompare_no_cache.py +0 -0
  69. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_treecompare_with_cache.py +0 -0
  70. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_treedb.py +0 -0
  71. {materials_commons_cli-2.2.0 → materials_commons_cli-2.2.2}/test/test_user_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: materials_commons_cli
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: Materials Commons CLI
5
5
  Home-page: https://materials-commons.github.io/materials-commons-cli/html/index.html
6
6
  Author: Materials Commons development team
@@ -0,0 +1 @@
1
+ __version__ = '2.2.2'
@@ -0,0 +1,2 @@
1
+ from .projects import *
2
+ from .local_rest_server import LocalRestServer
@@ -0,0 +1,369 @@
1
+ import asyncio
2
+ from typing import Dict, Any, Callable, Awaitable
3
+ from materials_commons.cli import desktop
4
+ import os
5
+ import signal
6
+ import logging
7
+ from pathlib import Path
8
+ from datetime import datetime
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ CommandHandler = Callable[[Any, Dict[str, Any]], Awaitable[None]]
13
+
14
+
15
+ def register_handlers() -> Dict[str, CommandHandler]:
16
+ return {
17
+ "sync": handle_sync,
18
+ "refresh_cache": handle_refresh_cache,
19
+ "SHUTDOWN": handle_shutdown,
20
+ "LIST_DIRECTORY": handle_list_directory,
21
+ "LIST_PROJECTS": handle_list_projects,
22
+
23
+ # Upload commands
24
+ "UPLOAD_FILE": handle_upload_file,
25
+ "UPLOAD_DIRECTORY": handle_upload_directory,
26
+ "CANCEL_UPLOAD": handle_cancel_upload,
27
+ "UPLOAD_PAUSE": handle_pause_upload,
28
+ "UPLOAD_RESUME": handle_resume_upload,
29
+
30
+ # Download commands
31
+ "DOWNLOAD_FILE": handle_download_file,
32
+ "PAUSE_DOWNLOAD": handle_pause_download,
33
+ "RESUME_DOWNLOAD": handle_resume_download,
34
+ "CANCEL_DOWNLOAD": handle_cancel_download,
35
+ }
36
+
37
+ async def handle_sync(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
38
+ print(f"[handler] sync -> {cmd}")
39
+
40
+
41
+ async def handle_refresh_cache(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
42
+ print(f"[handler] refresh_cache -> {cmd}")
43
+
44
+
45
+ async def handle_shutdown(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
46
+ print(f"[handler] shutdown -> {cmd}")
47
+ os.kill(os.getpid(), signal.SIGINT)
48
+
49
+
50
+ async def handle_list_directory(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
51
+ print(f"[handler] list_directory -> {cmd}")
52
+ payload = cmd.get("payload") or {}
53
+ request_id = payload.get("request_id")
54
+ dir_path = payload.get("directory_path")
55
+ response_payload = {"files": [], "request_id": request_id}
56
+ if dir_path:
57
+ files = []
58
+ for entry in Path(dir_path).iterdir():
59
+ try:
60
+ stat = entry.stat()
61
+ files.append({
62
+ "name": entry.name,
63
+ "path": entry.as_posix(),
64
+ "type": "directory" if entry.is_dir() else "file",
65
+ "size": stat.st_size,
66
+ "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat() + "Z",
67
+ "ctime": datetime.fromtimestamp(stat.st_ctime).isoformat() + "Z"
68
+ })
69
+ except (OSError, PermissionError) as e:
70
+ logger.warning(f"Could not stat {entry}: {e}")
71
+ response_payload["files"] = files
72
+ await queue.put({"command": "LIST_DIRECTORY", "payload": response_payload})
73
+
74
+ async def handle_list_projects(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
75
+ print(f"[handler] list_projects -> {cmd}")
76
+ payload = cmd.get("payload") or {}
77
+ projects = desktop.list_local_projects()
78
+ response_payload = {
79
+ "projects": projects,
80
+ "request_id": payload.get("request_id"),
81
+ }
82
+ print(f"[handler] list_projects returning -> {response_payload}")
83
+ await queue.put({"command": "LIST_PROJECTS", "payload": response_payload})
84
+
85
+
86
+ async def handle_upload_file(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
87
+ """
88
+ Handler for UPLOAD_FILE command from the server. It will receive a request that
89
+ contains the project_id and project_path. It will optionally receive the file_path
90
+ if the file being requested is not in the project. Optionally, the server can send
91
+ a chunk_size to use for uploading the file.
92
+
93
+ Expected payload:
94
+ {
95
+ "project_path": "/path/to/file/in/project", # This assumes / for project root, client will resolve to file_path
96
+ "file_path": "path/to/file/in/project/file.csv", # Optional
97
+ "project_id": 1047,
98
+ "chunk_size": 1048576 # Optional
99
+ }
100
+ """
101
+ # print(f"[handler] upload_file_request -> {cmd}")
102
+ payload = cmd.get("payload") or {}
103
+ project_path = payload.get("project_path")
104
+ file_path = payload.get("file_path")
105
+ project_id = payload.get("project_id")
106
+ chunk_size = payload.get("chunk_size", 1024 * 1024)
107
+
108
+ # We must have a project_id
109
+ if not project_id:
110
+ logger.error("Missing required field project_id in UPLOAD_FILE command")
111
+ return
112
+
113
+ # We also must have a project_path
114
+ if not project_path:
115
+ logger.error("Missing required field project_path in UPLOAD_FILE command")
116
+ return
117
+
118
+ # The server may send us just a project_path. If that happens, then we need to resolve it to a file_path
119
+ # based on the local project's path. The project_path the server sends us is the path on the MC server.
120
+ # All MC server projects start with /, so we need to resolve to the local client path to the project.
121
+ # To do this, we get the local path for the project, then join project_path and the local project's path
122
+ # to get the full local path. Note that project_path may start with a /, so we strip it off before joining.
123
+ if not file_path:
124
+ p = desktop.get_local_project_by_id(project_id)
125
+ if not p:
126
+ logger.error(f"Project {project_id} not found in local projects")
127
+ return
128
+ file_path = os.path.join(p["project_dir_path"], project_path.lstrip("/"))
129
+
130
+ # print(f"Resolved file_path: {file_path}")
131
+
132
+ # This will be set by the listener
133
+ file_manager = cmd.get("_file_manager")
134
+ if not file_manager:
135
+ logger.error("File transfer manager not available")
136
+ return
137
+
138
+ logger.info(f"Received upload request for: {file_path}")
139
+
140
+ try:
141
+ transfer_id = await file_manager.upload_file(
142
+ file_path=file_path,
143
+ project_id=project_id,
144
+ project_path=project_path,
145
+ chunk_size=chunk_size,
146
+ progress_callback=lambda sent, total: logger.debug(f"{file_path}: {sent}/{total}")
147
+ )
148
+ logger.info(f"Queued upload: {file_path} (transfer_id: {transfer_id})")
149
+ except Exception as e:
150
+ logger.error(f"Failed to queue upload: {e}")
151
+
152
+
153
+ async def handle_upload_directory(queue: asyncio.Queue, cmd: Dict[str, Any]):
154
+ """
155
+ Handler for UPLOAD_DIRECTORY command from the server.
156
+
157
+ Expected payload:
158
+ {
159
+ "project_path": "/path/to/file/in/project", # This assumes / for project root, client will resolve to file_path
160
+ "directory_path": "/local/path/to/data",
161
+ "project_id": 1047,
162
+ "recursive": true,
163
+ "chunk_size": 1048576 // optional
164
+ }
165
+ """
166
+ payload = cmd.get("payload") or {}
167
+ mc_project_path = payload.get("mc_project_path")
168
+ local_directory_path = payload.get("local_directory_path")
169
+ project_id = payload.get("project_id")
170
+ recursive = payload.get("recursive", True)
171
+ chunk_size = payload.get("chunk_size", 1024 * 1024)
172
+
173
+ if not project_id:
174
+ logger.error("Missing required field project_id in UPLOAD_DIRECTORY command")
175
+ return
176
+
177
+ if not mc_project_path:
178
+ logger.error("Missing required field mc_project_path in UPLOAD_DIRECTORY command")
179
+ return
180
+
181
+ p = desktop.get_local_project_by_id(project_id)
182
+ if not p:
183
+ logger.error(f"Project {project_id} not found in local projects")
184
+ return
185
+
186
+ if not local_directory_path:
187
+ local_directory_path = os.path.join(p["project_dir_path"], mc_project_path.lstrip("/"))
188
+
189
+ file_manager = cmd.get("_file_manager")
190
+ if not file_manager:
191
+ logger.error("File transfer manager not available")
192
+ return
193
+
194
+ # print(f"Calling upload_directory with recursive={recursive}")
195
+ # print(f" dest_dir = {mc_project_path}")
196
+ # print(f" dir_path = {local_directory_path}")
197
+ # print(f" local_project_root = {p['project_dir_path']}")
198
+ try:
199
+ await file_manager.upload_directory(
200
+ dest_dir=mc_project_path,
201
+ dir_path=local_directory_path,
202
+ project_id=project_id,
203
+ recursive=recursive,
204
+ chunk_size=chunk_size,
205
+ )
206
+ logger.info(f"Queued upload of directory: {local_directory_path}")
207
+ except Exception as e:
208
+ logger.error(f"Failed to queue upload of directory: {e}")
209
+ # try:
210
+ # transfer_ids = await file_manager.upload_directory(
211
+ # dir_path=directory_path,
212
+ # project_id=project_id,
213
+ # directory_id=directory_id,
214
+ # recursive=recursive,
215
+ # progress_callback=lambda fname, sent, total: logger.debug(f"{fname}: {sent}/{total}")
216
+ # )
217
+ # logger.info(f"Queued {len(transfer_ids)} files from {directory_path}")
218
+ # except Exception as e:
219
+ # logger.error(f"Failed to queue directory upload: {e}")
220
+
221
+
222
+ async def handle_cancel_upload(queue: asyncio.Queue, cmd: Dict[str, Any]):
223
+ """
224
+ Handler for CANCEL_UPLOAD command from the server.
225
+
226
+ Expected payload:
227
+ {
228
+ "transfer_id": "uuid-here"
229
+ }
230
+ """
231
+ payload = cmd.get("payload", {})
232
+ transfer_id = payload.get("transfer_id")
233
+
234
+ if not transfer_id:
235
+ logger.error("Missing transfer_id in CANCEL_UPLOAD command")
236
+ return
237
+
238
+ file_manager = cmd.get("_file_manager")
239
+ if not file_manager:
240
+ logger.error("File transfer manager not available")
241
+ return
242
+
243
+ logger.info(f"Received cancel request for transfer: {transfer_id}")
244
+ file_manager.cancel_upload(transfer_id)
245
+
246
+
247
+ async def handle_pause_upload(queue: asyncio.Queue, cmd: Dict[str, Any]):
248
+ """
249
+ Handler for PAUSE_UPLOAD command from the server.
250
+
251
+ Expected payload:
252
+ {
253
+ "transfer_id": "uuid-here"
254
+ }
255
+ """
256
+ payload = cmd.get("payload", {})
257
+ transfer_id = payload.get("transfer_id")
258
+
259
+ if not transfer_id:
260
+ logger.error("Missing transfer_id in PAUSE_UPLOAD command")
261
+ return
262
+
263
+ file_manager = cmd.get("_file_manager")
264
+ if not file_manager:
265
+ return
266
+
267
+ logger.info(f"Received pause request for transfer: {transfer_id}")
268
+ file_manager.pause_upload(transfer_id)
269
+
270
+
271
+ async def handle_resume_upload(queue: asyncio.Queue, cmd: Dict[str, Any]):
272
+ """
273
+ Handler for RESUME_UPLOAD command from the server.
274
+
275
+ Expected payload:
276
+ {
277
+ "transfer_id": "uuid-here"
278
+ }
279
+ """
280
+ payload = cmd.get("payload", {})
281
+ transfer_id = payload.get("transfer_id")
282
+
283
+ if not transfer_id:
284
+ logger.error("Missing transfer_id in RESUME_UPLOAD command")
285
+ return
286
+
287
+ file_manager = cmd.get("_file_manager")
288
+ if not file_manager:
289
+ return
290
+
291
+ logger.info(f"Received resume request for transfer: {transfer_id}")
292
+ file_manager.resume_upload(transfer_id)
293
+
294
+ async def handle_download_file(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
295
+ """
296
+ Handler for DOWNLOAD_FILE command from the server.
297
+
298
+ Expected payload:
299
+ {
300
+ "project_path": "/path/to/download/file/to/in/project", # Optional
301
+ "file_path": "/full/path/to/download/file/to, # Optional, but one of project_path or file_path must be specified
302
+ "project_id": 1047, # materials commons project id
303
+ "file_id": 159681, # materials commons file id
304
+ "size": 54323 # Size in bytes
305
+ "checksum": "md5-checksum-here" # md5 checksum of file
306
+ }
307
+ """
308
+ # print(f"[handler] download_file -> {cmd}")
309
+ payload = cmd.get("payload") or {}
310
+ project_path = payload.get("project_path")
311
+ file_path = payload.get("file_path")
312
+ project_id = payload.get("project_id")
313
+ file_id = payload.get("file_id")
314
+ size = payload.get("size")
315
+ checksum = payload.get("checksum")
316
+
317
+ # We must have a project_id
318
+ if not project_id:
319
+ logger.error("Missing required field project_id in DOWNLOAD_FILE command")
320
+ return
321
+
322
+ # We also must have a file_id
323
+ if not file_id:
324
+ logger.error("Missing required field file_id in DOWNLOAD_FILE command")
325
+ return
326
+
327
+ # we must have a file_path or project_path
328
+ if not file_path and not project_path:
329
+ logger.error("Missing required field file_path or project_path in DOWNLOAD_FILE command")
330
+ return
331
+
332
+ # We must have a size
333
+ if not size:
334
+ logger.error("Missing required field size in DOWNLOAD_FILE command")
335
+ return
336
+
337
+ # We must have a checksum
338
+ if not checksum:
339
+ logger.error("Missing required field checksum in DOWNLOAD_FILE command")
340
+
341
+ if project_path:
342
+ # If we have a project path, then we need to translate it to a
343
+ # local file path
344
+ p = desktop.get_local_project_by_id(project_id)
345
+ if not p:
346
+ logger.error(f"Project {project_id} not found in local projects")
347
+ return
348
+ file_path = os.path.join(p["project_dir_path"], project_path.lstrip("/"))
349
+
350
+ # This will be set by the listener in websocket_server
351
+ file_manager = cmd.get("_file_manager")
352
+ if not file_manager:
353
+ logger.error("File transfer manager not available")
354
+
355
+ try:
356
+ # print(f"I would download to {file_path} with size {size} and checksum {checksum}, file_id {file_id}")
357
+ await file_manager.download_file(project_id, file_id, file_path, size, checksum)
358
+ logger.info(f"Queued download of file: {file_path}")
359
+ except Exception as e:
360
+ logger.error(f"Failed to queue download of file: {e}")
361
+
362
+ async def handle_pause_download(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
363
+ print(f"[handler] pause_download -> {cmd}")
364
+
365
+ async def handle_resume_download(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
366
+ print(f"[handler] resume_download -> {cmd}")
367
+
368
+ async def handle_cancel_download(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
369
+ print(f"[handler] cancel_download -> {cmd}")
@@ -0,0 +1,143 @@
1
+ import asyncio
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Dict, Any, Optional, Callable
5
+
6
+ from materials_commons.cli.desktop.downloader.file_downloader import FileDownloader, logger
7
+
8
+
9
+ class FileDownloadManager:
10
+ """Manages multiple concurrent file downloads"""
11
+
12
+ def __init__(self, send_queue: asyncio.Queue, client_id: str, mcurl: str, apitoken: str, max_concurrent: int = 3):
13
+ self.send_queue = send_queue
14
+ self.client_id = client_id
15
+ self.mcurl = mcurl
16
+ self.apitoken = apitoken
17
+ self.max_concurrent = max_concurrent
18
+
19
+ # Active downloads indexed by transfer_id
20
+ self.active_downloads: Dict[str, FileDownloader] = {}
21
+ self.download_queue: asyncio.Queue = asyncio.Queue()
22
+ self.results: Dict[str, bool] = {}
23
+ self._workers_running = False
24
+
25
+ async def start_workers(self):
26
+ """Start background workers to process download queue"""
27
+ self._workers_running = True
28
+ workers = [
29
+ asyncio.create_task(self._download_worker(i))
30
+ for i in range(self.max_concurrent)
31
+ ]
32
+ return workers
33
+
34
+ async def _download_worker(self, worker_id: int):
35
+ """Worker that processes downloads from queue"""
36
+ logger.info(f"Download worker {worker_id} started")
37
+
38
+ while self._workers_running:
39
+ try:
40
+ # Get download from queue with timeout
41
+ downloader = await asyncio.wait_for(
42
+ self.download_queue.get(),
43
+ timeout=1.0
44
+ )
45
+ except asyncio.TimeoutError:
46
+ continue
47
+
48
+ try:
49
+ logger.info(f"Worker {worker_id} starting download: {downloader.file_path.name}")
50
+ self.active_downloads[downloader.transfer_id] = downloader
51
+
52
+ success = await downloader.download()
53
+ self.results[downloader.transfer_id] = success
54
+
55
+ del self.active_downloads[downloader.transfer_id]
56
+
57
+ if success:
58
+ logger.info(f"Worker {worker_id} completed: {downloader.file_path.name}")
59
+ else:
60
+ logger.error(f"Worker {worker_id} failed: {downloader.file_path.name}")
61
+
62
+ except Exception as e:
63
+ logger.error(f"Worker {worker_id} error: {e}")
64
+ self.results[downloader.transfer_id] = False
65
+ if downloader.transfer_id in self.active_downloads:
66
+ del self.active_downloads[downloader.transfer_id]
67
+
68
+ finally:
69
+ self.download_queue.task_done()
70
+
71
+ async def download_file(
72
+ self,
73
+ project_id: int,
74
+ file_id: int,
75
+ file_path: str,
76
+ expected_size: Optional[int] = None,
77
+ expected_checksum: Optional[str] = None,
78
+ chunk_size: int = 1024 * 1024,
79
+ progress_callback: Optional[Callable[[int, int], None]] = None
80
+ ) -> str:
81
+ """
82
+ Queue a file for download. Returns transfer_id.
83
+ """
84
+ downloader = FileDownloader(
85
+ send_queue=self.send_queue,
86
+ file_id=file_id,
87
+ file_path=file_path,
88
+ base_url=self.mcurl,
89
+ apitoken=self.apitoken,
90
+ project_id=project_id,
91
+ client_id=self.client_id,
92
+ expected_size=expected_size,
93
+ expected_checksum=expected_checksum,
94
+ chunk_size=chunk_size,
95
+ progress_callback=progress_callback
96
+ )
97
+
98
+ await self.download_queue.put(downloader)
99
+ logger.info(f"Queued download: {file_path} (transfer_id: {downloader.transfer_id})")
100
+
101
+ return downloader.transfer_id
102
+
103
+ def pause_download(self, transfer_id: str):
104
+ """Pause an active download"""
105
+ if transfer_id in self.active_downloads:
106
+ self.active_downloads[transfer_id].pause()
107
+ logger.info(f"Paused download: {transfer_id}")
108
+
109
+ def resume_download(self, transfer_id: str):
110
+ """Resume a paused download"""
111
+ if transfer_id in self.active_downloads:
112
+ self.active_downloads[transfer_id].resume_pause()
113
+ logger.info(f"Resumed download: {transfer_id}")
114
+
115
+ def cancel_download(self, transfer_id: str):
116
+ """Cancel an active download"""
117
+ if transfer_id in self.active_downloads:
118
+ self.active_downloads[transfer_id].cancel()
119
+ logger.info(f"Cancelled download: {transfer_id}")
120
+
121
+ async def wait_all(self):
122
+ """Wait for all queued downloads to complete"""
123
+ await self.download_queue.join()
124
+
125
+ def get_active_downloads(self) -> list[Dict[str, Any]]:
126
+ """Get list of currently active downloads"""
127
+ return [
128
+ {
129
+ "transfer_id": downloader.transfer_id,
130
+ "file_name": downloader.file_path.name,
131
+ "bytes_received": downloader.bytes_received,
132
+ "file_size": downloader.expected_size,
133
+ "progress_pct": (
134
+ (downloader.bytes_received / downloader.expected_size) * 100
135
+ if downloader.expected_size else 0
136
+ )
137
+ }
138
+ for downloader in self.active_downloads.values()
139
+ ]
140
+
141
+ def stop_workers(self):
142
+ """Stop all worker tasks"""
143
+ self._workers_running = False