materials-commons-cli 2.3.0b9__tar.gz → 2.3.0b10__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 (106) hide show
  1. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/PKG-INFO +1 -1
  2. materials_commons_cli-2.3.0b10/materials_commons/cli/__init__.py +1 -0
  3. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/functions.py +9 -4
  4. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/parser.py +3 -0
  5. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/reconcile3.py +51 -9
  6. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/downloader/file_download_manager.py +2 -0
  7. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/downloader/file_downloader.py +59 -3
  8. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/uploader/file_uploader.py +55 -4
  9. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/up.py +1 -0
  10. materials_commons_cli-2.3.0b10/materials_commons/cli/terminal_progress.py +45 -0
  11. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons_cli.egg-info/PKG-INFO +1 -1
  12. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons_cli.egg-info/SOURCES.txt +1 -0
  13. materials_commons_cli-2.3.0b9/materials_commons/cli/__init__.py +0 -1
  14. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/LICENSE +0 -0
  15. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/README.md +0 -0
  16. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/LICENSE.txt +0 -0
  17. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/async_reconciler.py +0 -0
  18. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/config.py +0 -0
  19. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/downloader.py +0 -0
  20. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/filedb.py +0 -0
  21. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/local_project.py +0 -0
  22. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/models.py +0 -0
  23. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/__init__.py +0 -0
  24. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/cloned_project.py +0 -0
  25. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/exceptions.py +0 -0
  26. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/file_functions.py +0 -0
  27. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/globus.py +0 -0
  28. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/list_objects.py +0 -0
  29. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/print_formatter.py +0 -0
  30. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/sqltable.py +0 -0
  31. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/tmp_functions.py +0 -0
  32. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/tree_functions.py +0 -0
  33. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/treedb.py +0 -0
  34. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/old/user_config.py +0 -0
  35. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/reconcile2.py +0 -0
  36. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/requests.py +0 -0
  37. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/run.py +0 -0
  38. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/__init__.py +0 -0
  39. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/command_handlers/__init__.py +0 -0
  40. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/command_handlers/admin_handler_lookup.py +0 -0
  41. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/command_handlers/download_handler_lookup.py +0 -0
  42. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/command_handlers/list_handler_lookup.py +0 -0
  43. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/command_handlers/multi_handler_lookup.py +0 -0
  44. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/command_handlers/protocol.py +0 -0
  45. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/command_handlers/search_find_handler_lookup.py +0 -0
  46. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/command_handlers/upload_handler_lookup.py +0 -0
  47. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/db/__init__.py +0 -0
  48. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/db/db_manager.py +0 -0
  49. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/downloader/__init__.py +0 -0
  50. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/indexer/__init__.py +0 -0
  51. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/indexer/file_index_manager.py +0 -0
  52. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/local_rest_server.py +0 -0
  53. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/ocommand_handlers.py +0 -0
  54. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/project_filedbs.py +0 -0
  55. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/projects.py +0 -0
  56. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/service_container.py +0 -0
  57. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/service_runtime.py +0 -0
  58. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/uploader/__init__.py +0 -0
  59. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/uploader/client_api.py +0 -0
  60. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/uploader/file_upload_manager.py +0 -0
  61. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/server/websocket_server.py +0 -0
  62. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/__init__.py +0 -0
  63. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/clone.py +0 -0
  64. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/config.py +0 -0
  65. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/dataset.py +0 -0
  66. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/down.py +0 -0
  67. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/down2.py +0 -0
  68. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/expt.py +0 -0
  69. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/fetch.py +0 -0
  70. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/globus.py +0 -0
  71. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/init.py +0 -0
  72. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/ls.py +0 -0
  73. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/ls2.py +0 -0
  74. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/mcp.py +0 -0
  75. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/mkdir.py +0 -0
  76. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/mv.py +0 -0
  77. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/proj.py +0 -0
  78. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/remote.py +0 -0
  79. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/rm.py +0 -0
  80. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/runners/__init__.py +0 -0
  81. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/runners/download_globus_runner.py +0 -0
  82. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/runners/download_v1_runner.py +0 -0
  83. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/scan.py +0 -0
  84. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/server.py +0 -0
  85. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/subcommands/versions.py +0 -0
  86. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons/cli/walk.py +0 -0
  87. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons_cli.egg-info/dependency_links.txt +0 -0
  88. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons_cli.egg-info/entry_points.txt +0 -0
  89. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons_cli.egg-info/not-zip-safe +0 -0
  90. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons_cli.egg-info/requires.txt +0 -0
  91. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/materials_commons_cli.egg-info/top_level.txt +0 -0
  92. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/setup.cfg +0 -0
  93. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/setup.py +0 -0
  94. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_api.py +0 -0
  95. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_functions.py +0 -0
  96. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_mc_dataset.py +0 -0
  97. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_mc_down.py +0 -0
  98. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_mc_mkdir.py +0 -0
  99. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_mc_mv.py +0 -0
  100. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_mc_proj.py +0 -0
  101. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_mc_rm.py +0 -0
  102. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_mc_up.py +0 -0
  103. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_treecompare_no_cache.py +0 -0
  104. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_treecompare_with_cache.py +0 -0
  105. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/test/test_treedb.py +0 -0
  106. {materials_commons_cli-2.3.0b9 → materials_commons_cli-2.3.0b10}/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.3.0b9
3
+ Version: 2.3.0b10
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.3.0b10'
@@ -144,28 +144,33 @@ def format_time(time_value, fmt="%Y %b %d %H:%M:%S") -> str:
144
144
  return str(type(time_value))
145
145
 
146
146
 
147
- def checksum(path, chunk_size=8192):
148
- """Generate MD5 checksum for the file at "path"
147
+ def checksum(path, chunk_size=1024*1024, progress_callback=None):
148
+ """Generate MD5 checksum for the file at path
149
149
 
150
150
  Args:
151
151
  path (str): Path to file
152
152
  chunk_size (int): Size of chunks to read (default 8192 bytes)
153
+ progress_callback (callable): Callback function to report progress as progress_callback(bytes_read)
153
154
 
154
155
  Returns:
155
156
  str: MD5 checksum hexdigest
156
157
  """
157
158
  md5 = hashlib.md5()
159
+ bytes_read = 0
158
160
  with open(path, 'rb') as f:
159
161
  while True:
160
162
  chunk = f.read(chunk_size)
161
163
  if not chunk:
162
164
  break
163
165
  md5.update(chunk)
166
+ bytes_read += len(chunk)
167
+ if progress_callback:
168
+ progress_callback(bytes_read)
164
169
  return md5.hexdigest()
165
170
 
166
171
 
167
- async def checksum_async(path, chunk_size=8192):
168
- """Generate MD5 checksum for the file at "path"""
172
+ async def checksum_async(path, chunk_size=1024*1024):
173
+ """Generate MD5 checksum for the file at path"""
169
174
  return await asyncio.to_thread(checksum, path, chunk_size)
170
175
 
171
176
 
@@ -1,5 +1,6 @@
1
1
  import argparse
2
2
  import json
3
+ import logging
3
4
  import os
4
5
  import sys
5
6
  from datetime import datetime, timedelta
@@ -144,6 +145,8 @@ def check_package_version():
144
145
 
145
146
 
146
147
  def main(argv=None, working_dir=None):
148
+ logger = logging.getLogger(__name__)
149
+ logger.setLevel(logging.WARNING)
147
150
  if argv is None:
148
151
  argv = sys.argv
149
152
  if working_dir is None:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import time
4
5
  from dataclasses import replace
5
6
  from typing import Literal, Optional
6
7
 
@@ -9,6 +10,7 @@ from materials_commons.api import models as mcmodel
9
10
  from materials_commons.cli.local_project import LocalProject
10
11
  from materials_commons.cli.models import FileRecord, Observation, FileDecision
11
12
  from materials_commons.cli.old.functions import checksum
13
+ from materials_commons.cli.terminal_progress import TerminalProgress
12
14
 
13
15
  Action = Literal["skip", "upload", "download", "conflict", "adopt", "db_update"]
14
16
  ObservationState = Literal["neither", "local_only", "remote_only", "both"]
@@ -141,12 +143,12 @@ class SingleFileReconciler:
141
143
  if observation.local_entry_matches_record() and observation.file_record.local_checksum:
142
144
  return self._upload(record, "local only")
143
145
  else:
144
- local_checksum = await asyncio.to_thread(checksum, observation.local_entry.path.as_posix())
146
+ local_checksum = await self._checksum_with_feedback(observation)
145
147
  record = replace(record, local_checksum=local_checksum)
146
148
  reason = "local only - update local record"
147
149
  return self._upload(record, reason)
148
150
  else:
149
- local_checksum = await asyncio.to_thread(checksum, observation.local_entry.path.as_posix())
151
+ local_checksum = await self._checksum_with_feedback(observation)
150
152
  record = replace(record, local_checksum=local_checksum)
151
153
  reason = "local only - add local record"
152
154
  return self._upload(record, reason)
@@ -157,13 +159,13 @@ class SingleFileReconciler:
157
159
  if observation.local_entry_matches_record():
158
160
  return self._skip(record, "local entry matches existing record; no remote file to download")
159
161
  else:
160
- local_checksum = await asyncio.to_thread(checksum, observation.local_entry.path.as_posix())
162
+ local_checksum = await self._checksum_with_feedback(observation)
161
163
  record = replace(record, local_checksum=local_checksum)
162
164
  reason = "local only, uploadable - update local record"
163
165
  return self._db_update(record, reason)
164
166
  else:
165
167
  # No record in the project database
166
- local_checksum = await asyncio.to_thread(checksum, observation.local_entry.path.as_posix())
168
+ local_checksum = await self._checksum_with_feedback(observation)
167
169
  record = replace(record, local_checksum=local_checksum)
168
170
  reason = "local only, uploadable - add local record"
169
171
  return self._db_update(record, reason)
@@ -265,14 +267,14 @@ class SingleFileReconciler:
265
267
  # No action needed, local has been uploaded, but remote changed, and we could download it.
266
268
  return self._skip(updated_record, "local matches remote, but remote file differs (downloadable)")
267
269
  # Local entry is different. Compute new checksum and upload.
268
- local_checksum = await asyncio.to_thread(checksum, observation.local_entry.path.as_posix())
270
+ local_checksum = await self._checksum_with_feedback(observation)
269
271
  updated_record = replace(updated_record, local_checksum=local_checksum)
270
272
  return self._upload(updated_record, "local file changed")
271
273
 
272
274
  async def _reconcile_both_sides_upload_no_record(self, observation: Observation,
273
275
  updated_record: FileRecord) -> FileDecision:
274
276
  # No file record. Let's compute the checksum and see if that matches the remote
275
- local_checksum = await asyncio.to_thread(checksum, observation.local_entry.path.as_posix())
277
+ local_checksum = await self._checksum_with_feedback(observation)
276
278
  if local_checksum == observation.remote_entry.checksum:
277
279
  return self._db_update(updated_record, "local matches remote, add file record")
278
280
  updated_record = replace(updated_record, local_checksum=local_checksum)
@@ -304,7 +306,7 @@ class SingleFileReconciler:
304
306
  else:
305
307
  # The file record and the local file don't match. We need to compute the checksum for the
306
308
  # local to determine if the local file has ever been uploaded.
307
- local_checksum = await asyncio.to_thread(checksum, observation.local_entry.path.as_posix())
309
+ local_checksum = await self._checksum_with_feedback(observation)
308
310
  # Check if the local checksum matches the file record checksum
309
311
  if local_checksum == observation.file_record.local_checksum:
310
312
  # The checksum matches, so this file has been uploaded. So we can safely download the remote.
@@ -328,7 +330,7 @@ class SingleFileReconciler:
328
330
  """
329
331
  # There is no file_record; that means we don't know the state of the local file. First, lets compute
330
332
  # the checksum of the local file.
331
- local_checksum = await asyncio.to_thread(checksum, observation.local_entry.path.as_posix())
333
+ local_checksum = await self._checksum_with_feedback(observation)
332
334
  if local_checksum == observation.remote_entry.checksum:
333
335
  # Need to create a file_record
334
336
  return self._db_update(updated_record, "local file matches remote checksum")
@@ -372,7 +374,8 @@ class SingleFileReconciler:
372
374
 
373
375
  # Get previous versions and look for a match
374
376
  try:
375
- previous_versions = await asyncio.to_thread(self._proj.remote.get_file_versions, self._proj.id, remote_file_id)
377
+ previous_versions = await asyncio.to_thread(self._proj.remote.get_file_versions, self._proj.id,
378
+ remote_file_id)
376
379
  except Exception as e:
377
380
  previous_versions = None
378
381
 
@@ -432,3 +435,42 @@ class SingleFileReconciler:
432
435
 
433
436
  def _db_update(self, record: FileRecord, reason: str) -> FileDecision:
434
437
  return self._decision("db_update", record, reason, updated=True)
438
+
439
+ async def _checksum_with_feedback(self, observation: Observation) -> str:
440
+ path = observation.local_entry.path
441
+ total_bytes = observation.local_entry.size
442
+ progress_id = f"checksum:{path.as_posix()}"
443
+ loop = asyncio.get_running_loop()
444
+
445
+ def make_line(bytes_read: int, status: str = "computing checksum") -> str:
446
+ percent = 100.0 if total_bytes == 0 else min((bytes_read / total_bytes) * 100.0, 100.0)
447
+ return (
448
+ f"{path.as_posix()} | "
449
+ f"checksum | "
450
+ f"{bytes_read} / {total_bytes} bytes | "
451
+ f"{percent:6.2f}% | "
452
+ f"{status}"
453
+ )
454
+
455
+ last_render_at = 0.0
456
+
457
+ def progress_callback(bytes_read: int) -> None:
458
+ nonlocal last_render_at
459
+
460
+ now = time.monotonic()
461
+ if now - last_render_at < 1.0 and bytes_read < total_bytes:
462
+ return
463
+
464
+ last_render_at = now
465
+ line = make_line(bytes_read)
466
+ asyncio.run_coroutine_threadsafe(
467
+ TerminalProgress.render(progress_id, line),
468
+ loop,
469
+ )
470
+
471
+ await TerminalProgress.render(progress_id, make_line(0))
472
+
473
+ result = await asyncio.to_thread(checksum, path.as_posix(), 1024 * 1024, progress_callback)
474
+
475
+ await TerminalProgress.render(progress_id, make_line(total_bytes, "checksum complete"))
476
+ return result
@@ -1,9 +1,11 @@
1
1
  import asyncio
2
2
  from typing import Dict, Any, Optional, Callable
3
+ import logging
3
4
 
4
5
  from materials_commons.cli.requests import DownloadRequest
5
6
  from materials_commons.cli.server.downloader.file_downloader import FileDownloader, logger
6
7
 
8
+ logger.setLevel(logging.WARNING)
7
9
 
8
10
  class FileDownloadManager:
9
11
  """Manages multiple concurrent file downloads"""
@@ -13,6 +13,7 @@ import requests
13
13
  from materials_commons.cli.old.functions import checksum
14
14
  from materials_commons.cli.requests import DownloadRequest, DBWriteRequest
15
15
  from materials_commons.cli.server import projects
16
+ from materials_commons.cli.terminal_progress import TerminalProgress
16
17
 
17
18
  logger = logging.getLogger(__name__)
18
19
 
@@ -154,25 +155,35 @@ class FileDownloader:
154
155
 
155
156
  async def _download_with_ranges(self, resume_from: int = 0) -> bool:
156
157
  """Download the file using HTTP Range requests with streaming"""
158
+
159
+ # Get the event loop so we can schedule progress rendering. Since the routine
160
+ # _download_with_ranges_blocking is not async it will need the loop do the
161
+ # progress. We have to get the async loop here in the async routine, and then
162
+ # pass it to the blocking download routine.
163
+ loop = asyncio.get_running_loop()
157
164
  try:
158
- success = await asyncio.to_thread(self._download_with_ranges_blocking, resume_from)
165
+ await self._render_progress(status="starting")
166
+ success = await asyncio.to_thread(self._download_with_ranges_blocking, resume_from, loop)
159
167
 
160
168
  if not success:
161
169
  await self._save_metadata()
170
+ await self._render_progress(status="failed downloading")
171
+ return False
162
172
 
163
173
  return success
164
174
 
165
175
  except requests.exceptions.RequestException as e:
166
176
  logger.error(f"HTTP request failed: {e}")
167
177
  await self._save_metadata()
178
+ await self._render_progress(status=f"failed with HTTP exception: {e}")
168
179
  return False
169
180
  except Exception as e:
170
181
  logger.error(f"Download error: {e}")
171
182
  await self._save_metadata()
183
+ await self._render_progress(status=f"failed with exception: {e}")
172
184
  return False
173
185
 
174
- def _download_with_ranges_blocking(self, resume_from: int = 0) -> bool:
175
- "Blocking download. Called from asyncio.to_thread()"
186
+ def _download_with_ranges_blocking(self, resume_from: int = 0, loop: Optional[asyncio.AbstractEventLoop] = None) -> bool:
176
187
  """Blocking download implementation. Runs in a worker thread."""
177
188
  headers = {
178
189
  'Authorization': f'Bearer {self.apitoken}',
@@ -213,6 +224,26 @@ class FileDownloader:
213
224
 
214
225
  # Open the file in append mode if resuming, write mode otherwise
215
226
  mode = 'ab' if resume_from > 0 else 'wb'
227
+ last_progress_render_at = 0.0
228
+ progress_interval_seconds = 0.25
229
+
230
+ def schedule_progress_render(status: str = "downloading", force: bool = False) -> None:
231
+ nonlocal last_progress_render_at
232
+
233
+ if loop is None:
234
+ return
235
+
236
+ now = time.monotonic()
237
+ if not force and now - last_progress_render_at < progress_interval_seconds:
238
+ return
239
+
240
+ last_progress_render_at = now
241
+ asyncio.run_coroutine_threadsafe(
242
+ self._render_progress(self.bytes_received, self.expected_size, status=status),
243
+ loop,
244
+ )
245
+
246
+ schedule_progress_render(status="downloading", force=True)
216
247
 
217
248
  with open(self.part_file, mode) as f:
218
249
  for chunk in response.iter_content(chunk_size=self.chunk_size):
@@ -222,6 +253,7 @@ class FileDownloader:
222
253
 
223
254
  if self.cancelled:
224
255
  logger.info("Download cancelled")
256
+ schedule_progress_render(status="cancelled", force=True)
225
257
  return False
226
258
 
227
259
  if chunk:
@@ -229,6 +261,8 @@ class FileDownloader:
229
261
  f.write(chunk)
230
262
  self.bytes_received += len(chunk)
231
263
 
264
+ schedule_progress_render()
265
+
232
266
  # Progress callback
233
267
  if self.progress_callback:
234
268
  self.progress_callback(self.bytes_received, self.expected_size)
@@ -237,6 +271,8 @@ class FileDownloader:
237
271
  if self.bytes_received % (self.chunk_size * 10) == 0:
238
272
  self._save_metadata_blocking()
239
273
 
274
+ schedule_progress_render(status="download complete", force=True)
275
+
240
276
  return True
241
277
 
242
278
  finally:
@@ -313,3 +349,23 @@ class FileDownloader:
313
349
  """Cancel the download"""
314
350
  self.cancelled = True
315
351
  logger.info(f"Download cancelled: {self.file_path}")
352
+
353
+ async def _render_progress(self,
354
+ bytes_received: Optional[int] = None,
355
+ total_bytes: Optional[int] = None,
356
+ status: str = "downloading") -> None:
357
+ """Render this download's progress on a stable terminal row."""
358
+ bytes_received = self.bytes_received if bytes_received is None else bytes_received
359
+ total_bytes = self.expected_size if total_bytes is None else total_bytes
360
+
361
+ percent = 100.0 if total_bytes == 0 else min((bytes_received / total_bytes) * 100.0, 100.0)
362
+
363
+ line = (
364
+ f"{self.file_path.as_posix()} | "
365
+ f"download | "
366
+ f"{bytes_received} / {total_bytes} bytes | "
367
+ f"{percent:6.2f}% | "
368
+ f"{status}"
369
+ )
370
+
371
+ await TerminalProgress.render(f"download:{self.transfer_id}", line)
@@ -1,23 +1,32 @@
1
1
  import asyncio
2
2
  import hashlib
3
3
  import logging
4
+ import sys
4
5
  import time
5
6
  import uuid
6
7
  from dataclasses import replace
7
8
  from datetime import datetime, timezone
8
9
  from typing import Optional, Callable, Dict, Any
10
+ from materials_commons.cli.terminal_progress import TerminalProgress, format_bytes
9
11
 
10
12
  import aiofiles
11
13
 
12
- from materials_commons.cli.filedb import FileIndexDB
13
14
  from materials_commons.cli.requests import UploadRequest, DBWriteRequest
14
15
 
15
16
  logger = logging.getLogger(__name__)
17
+ logger.setLevel(logging.WARNING)
16
18
 
17
19
 
18
20
  class FileUploader:
19
21
  """Handles upload of a single file via websocket using queue pattern"""
20
22
 
23
+ # These class variables are used to track progress of file uploads. They are shared across all
24
+ # instances of FileUploader. The lock keeps different writers to the terminal from interfering
25
+ # with each other.
26
+ _progress_lock = asyncio.Lock()
27
+ _progress_rows: Dict[str, int] = {}
28
+ _progress_row_count = 0
29
+
21
30
  def __init__(
22
31
  self,
23
32
  ws_send_queue: asyncio.Queue,
@@ -40,6 +49,8 @@ class FileUploader:
40
49
  self.bytes_sent = 0
41
50
  self.paused = False
42
51
  self.cancelled = False
52
+ self.upload_started_at = time.monotonic()
53
+ self._last_progress_render_at = 0.0
43
54
 
44
55
  # These fields are referenced multiple times. They are pulled out to make code more readable.
45
56
  self.file_path = upload_request.observation.path
@@ -66,20 +77,25 @@ class FileUploader:
66
77
  print(f"Skipping {self.file_path.as_posix()} (empty file)...")
67
78
  return True
68
79
 
80
+ await self._render_progress(status="starting")
81
+
69
82
  # Step 1: Initialize the transfer
70
83
  if not await self._send_transfer_init():
84
+ await self._render_progress(status="failed on initialization")
71
85
  return False
72
86
 
73
87
  # Step 2: Wait for acceptance
74
88
  if not await self._wait_for_acceptance():
75
89
  if self._already_uploaded:
76
90
  logger.info(f"File {self.file_path} already uploaded")
77
- print(f"Uploaded {self.file_path}...")
91
+ await self._render_progress(self.file_size, self.file_size, status="already uploaded")
78
92
  return True
93
+ await self._render_progress(status="failed on acceptance")
79
94
  return False
80
95
 
81
96
  # # Step 3: Send file chunks
82
97
  if not await self._send_chunks2():
98
+ await self._render_progress(status="failed on chunk sending")
83
99
  return False
84
100
 
85
101
  # ### New
@@ -99,19 +115,21 @@ class FileUploader:
99
115
 
100
116
  # Step 4: Send completion
101
117
  if not await self._send_transfer_complete():
118
+ await self._render_progress(status="failed on completion")
102
119
  return False
103
120
 
104
121
  # Step 5: Wait for finalization
105
122
  if not await self._wait_for_finalization():
123
+ await self._render_progress(status="failed on finalization")
106
124
  return False
107
125
 
108
126
  logger.info(f"File upload complete for {self.file_path}")
109
- print(f"Uploaded {self.file_path}...")
127
+ await self._render_progress(self.file_size, self.file_size, status="complete")
110
128
  return True
111
129
 
112
130
  except Exception as e:
113
131
  logger.error(f"Error uploading file {self.file_path}: {e}")
114
- print(f"Error uploading file {self.file_path}: {e}")
132
+ await self._render_progress(status=f"error: {e}")
115
133
  return False
116
134
 
117
135
  async def resume(self, resume_from_byte: int, resume_from_chunk: int) -> bool:
@@ -323,6 +341,8 @@ class FileUploader:
323
341
 
324
342
  self.bytes_sent = bytes_received
325
343
 
344
+ await self._render_progress(self.bytes_sent, self.file_size, status="uploading")
345
+
326
346
  # Progress callback outside lock
327
347
  if self.progress_callback:
328
348
  self.progress_callback(self.bytes_sent, self.file_size)
@@ -389,6 +409,8 @@ class FileUploader:
389
409
  self.bytes_sent = ack_msg["payload"]["bytes_received"]
390
410
  sequence += 1
391
411
 
412
+ await self._render_progress(self.bytes_sent, self.file_size, status="uploading")
413
+
392
414
  # Progress callback
393
415
  if self.progress_callback:
394
416
  self.progress_callback(self.bytes_sent, self.file_size)
@@ -473,6 +495,35 @@ class FileUploader:
473
495
  self.cancelled = True
474
496
  logger.info(f"Upload cancelled: {self.file_path.name}")
475
497
 
498
+ async def _render_progress(self,
499
+ bytes_sent: Optional[int] = None,
500
+ total_bytes: Optional[int] = None,
501
+ status: str = "uploading") -> None:
502
+ """Render this upload's progress on a stable terminal row."""
503
+
504
+ bytes_sent = self.bytes_sent if bytes_sent is None else bytes_sent
505
+ total_bytes = self.file_size if total_bytes is None else total_bytes
506
+
507
+ force_render = status != "uploading" or bytes_sent >= total_bytes
508
+ now = time.monotonic()
509
+ if not force_render and now - self._last_progress_render_at < 0.25:
510
+ return
511
+ self._last_progress_render_at = now
512
+
513
+ percent = 100.0 if total_bytes == 0 else min((bytes_sent / total_bytes) * 100.0, 100.0)
514
+ elapsed = max(time.monotonic() - self.upload_started_at, 0.001)
515
+ rate = bytes_sent / elapsed
516
+
517
+ line = (
518
+ f"{self.file_path.as_posix()} | "
519
+ f"{format_bytes(bytes_sent)} / {format_bytes(total_bytes)} | "
520
+ f"{percent:6.2f}% | "
521
+ f"{format_bytes(rate)}/s | "
522
+ f"{status}"
523
+ )
524
+
525
+ await TerminalProgress.render(self.transfer_id, line)
526
+
476
527
  def _calculate_md5(self, chunk_size=8192) -> str:
477
528
  """Calculate md5 hash of file"""
478
529
  md5_hash = hashlib.md5()
@@ -22,6 +22,7 @@ from materials_commons.cli.subcommands.server import DEFAULT_WS_URL
22
22
  from materials_commons.cli.walk import local_listdir
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
+ logger.setLevel(logging.WARNING)
25
26
 
26
27
 
27
28
  def make_parser():
@@ -0,0 +1,45 @@
1
+ import asyncio
2
+ import sys
3
+ from typing import Dict
4
+
5
+ def format_bytes(value: float) -> str:
6
+ """Format bytes as a human-readable value."""
7
+ units = ("B", "KB", "MB", "GB", "TB")
8
+ size = float(value)
9
+
10
+ for unit in units:
11
+ if size < 1024.0 or unit == units[-1]:
12
+ return f"{size:.1f} {unit}"
13
+ size /= 1024.0
14
+
15
+ return f"{size:.1f} TB"
16
+
17
+ class TerminalProgress:
18
+ _lock = asyncio.Lock()
19
+ _rows: Dict[str, int] = {}
20
+ _row_count = 0
21
+
22
+ @classmethod
23
+ async def render(cls, progress_id: str, line: str):
24
+ if not sys.stdout.isatty():
25
+ return
26
+
27
+ async with cls._lock:
28
+ if progress_id not in cls._rows:
29
+ cls._rows[progress_id] = cls._row_count
30
+ cls._row_count += 1
31
+ print()
32
+
33
+ row = cls._rows[progress_id]
34
+ rows_from_cursor = cls._row_count - row
35
+
36
+ terminal_width = 120
37
+ if len(line) > terminal_width:
38
+ line = line[:terminal_width - 3] + "..."
39
+
40
+ sys.stdout.write("\033[s")
41
+ sys.stdout.write(f"\033[{rows_from_cursor}A")
42
+ sys.stdout.write("\r\033[K")
43
+ sys.stdout.write(line)
44
+ sys.stdout.write("\033[u")
45
+ sys.stdout.flush()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: materials-commons-cli
3
- Version: 2.3.0b9
3
+ Version: 2.3.0b10
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
@@ -15,6 +15,7 @@ materials_commons/cli/reconcile2.py
15
15
  materials_commons/cli/reconcile3.py
16
16
  materials_commons/cli/requests.py
17
17
  materials_commons/cli/run.py
18
+ materials_commons/cli/terminal_progress.py
18
19
  materials_commons/cli/walk.py
19
20
  materials_commons/cli/old/__init__.py
20
21
  materials_commons/cli/old/cloned_project.py
@@ -1 +0,0 @@
1
- __version__ = '2.3.0b9'