materials-commons-cli 2.2.1__tar.gz → 2.2.3__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 (75) hide show
  1. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/PKG-INFO +1 -1
  2. materials_commons_cli-2.2.3/materials_commons/cli/__init__.py +1 -0
  3. materials_commons_cli-2.2.3/materials_commons/cli/filedb.py +110 -0
  4. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/functions.py +13 -0
  5. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/parser.py +10 -4
  6. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/command_handlers.py +5 -5
  7. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/downloader/file_download_manager.py +1 -3
  8. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/downloader/file_downloader.py +2 -2
  9. materials_commons_cli-2.2.3/materials_commons/cli/server/indexer/file_index_manager.py +84 -0
  10. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/local_rest_server.py +3 -3
  11. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/uploader/api.py +2 -2
  12. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/uploader/file_transfer_manager.py +1 -1
  13. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/uploader/file_uploader.py +17 -4
  14. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/websocket_server.py +4 -4
  15. materials_commons_cli-2.2.3/materials_commons/cli/subcommands/__init__.py +0 -0
  16. materials_commons_cli-2.2.3/materials_commons/cli/subcommands/scan.py +83 -0
  17. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/server.py +3 -3
  18. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/up.py +16 -3
  19. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons_cli.egg-info/PKG-INFO +1 -1
  20. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons_cli.egg-info/SOURCES.txt +16 -12
  21. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons_cli.egg-info/requires.txt +2 -1
  22. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/setup.py +2 -1
  23. materials_commons_cli-2.2.1/materials_commons/cli/__init__.py +0 -1
  24. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/LICENSE +0 -0
  25. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/README.md +0 -0
  26. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/LICENSE.txt +0 -0
  27. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/cloned_project.py +0 -0
  28. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/exceptions.py +0 -0
  29. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/file_functions.py +0 -0
  30. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/globus.py +0 -0
  31. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/list_objects.py +0 -0
  32. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/print_formatter.py +0 -0
  33. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/__init__.py +0 -0
  34. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/downloader/__init__.py +0 -0
  35. {materials_commons_cli-2.2.1/materials_commons/cli/desktop/uploader → materials_commons_cli-2.2.3/materials_commons/cli/server/indexer}/__init__.py +0 -0
  36. {materials_commons_cli-2.2.1/materials_commons/cli/desktop → materials_commons_cli-2.2.3/materials_commons/cli/server}/projects.py +0 -0
  37. {materials_commons_cli-2.2.1/materials_commons/cli/subcommands → materials_commons_cli-2.2.3/materials_commons/cli/server/uploader}/__init__.py +0 -0
  38. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/sqltable.py +0 -0
  39. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/clone.py +0 -0
  40. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/config.py +0 -0
  41. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/dataset.py +0 -0
  42. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/down.py +0 -0
  43. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/expt.py +0 -0
  44. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/fetch.py +0 -0
  45. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/globus.py +0 -0
  46. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/init.py +0 -0
  47. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/ls.py +0 -0
  48. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/mkdir.py +0 -0
  49. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/mv.py +0 -0
  50. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/proj.py +0 -0
  51. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/remote.py +0 -0
  52. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/rm.py +0 -0
  53. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/subcommands/versions.py +0 -0
  54. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/tmp_functions.py +0 -0
  55. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/tree_functions.py +0 -0
  56. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/treedb.py +0 -0
  57. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons/cli/user_config.py +0 -0
  58. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons_cli.egg-info/dependency_links.txt +0 -0
  59. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons_cli.egg-info/entry_points.txt +0 -0
  60. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons_cli.egg-info/not-zip-safe +0 -0
  61. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/materials_commons_cli.egg-info/top_level.txt +0 -0
  62. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/setup.cfg +0 -0
  63. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_api.py +0 -0
  64. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_functions.py +0 -0
  65. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_mc_dataset.py +0 -0
  66. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_mc_down.py +0 -0
  67. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_mc_mkdir.py +0 -0
  68. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_mc_mv.py +0 -0
  69. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_mc_proj.py +0 -0
  70. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_mc_rm.py +0 -0
  71. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_mc_up.py +0 -0
  72. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_treecompare_no_cache.py +0 -0
  73. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_treecompare_with_cache.py +0 -0
  74. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/test/test_treedb.py +0 -0
  75. {materials_commons_cli-2.2.1 → materials_commons_cli-2.2.3}/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.1
3
+ Version: 2.2.3
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.3'
@@ -0,0 +1,110 @@
1
+ import hashlib
2
+ import os
3
+ import sqlite3
4
+ import time
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Iterable, Optional, Tuple
8
+
9
+ @dataclass(frozen=True)
10
+ class FileRecord:
11
+ path: str
12
+ size: int
13
+ mtime_ns: int
14
+ ctime_ns: int
15
+ last_seen_ts: int
16
+ checksum: Optional[str] = None
17
+ status: Optional[str] = None
18
+ remote_file_id: Optional[int] = None
19
+ transfer_id: Optional[str] = None
20
+
21
+
22
+ class FileIndexDB:
23
+ def __init__(self, db_path: Path):
24
+ self.db_path = db_path
25
+ self._init_db()
26
+
27
+ def _init_db(self):
28
+ with self._connect() as conn:
29
+ conn.execute(
30
+ """
31
+ CREATE TABLE IF NOT EXISTS files (
32
+ path TEXT PRIMARY KEY,
33
+ size INTEGER NOT NULL,
34
+ mtime_ns INTEGER NOT NULL,
35
+ ctime_ns INTEGER NOT NULL,
36
+ last_seen_ts INTEGER NOT NULL,
37
+ checksum TEXT,
38
+ status TEXT,
39
+ remote_file_id INTEGER,
40
+ transfer_id TEXT
41
+ ) STRICT;
42
+ """
43
+ )
44
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_files_status ON files(status);")
45
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_files_last_seen ON files(last_seen_ts);")
46
+
47
+
48
+ def _connect(self):
49
+ conn = sqlite3.connect(self.db_path)
50
+ conn.row_factory = sqlite3.Row
51
+ conn.execute("PRAGMA journal_mode=WAL;")
52
+ conn.execute("PRAGMA busy_timeout=5000;")
53
+ conn.execute("PRAGMA synchronous=NORMAL;")
54
+ conn.execute("PRAGMA cache_size=100000;")
55
+ conn.execute("PRAGMA temp_store=MEMORY;")
56
+ conn.execute("PRAGMA locking_mode=EXCLUSIVE;")
57
+ conn.execute("PRAGMA foreign_keys=ON;")
58
+ return conn
59
+
60
+ def upsert(self, record: FileRecord):
61
+ with self._connect() as conn:
62
+ conn.execute(
63
+ """
64
+ INSERT INTO files(path, size, mtime_ns, ctime_ns, last_seen_ts, checksum, status, remote_file_id, transfer_id)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
66
+ ON CONFLICT(path) DO UPDATE SET
67
+ size = excluded.size,
68
+ mtime_ns = excluded.mtime_ns,
69
+ ctime_ns = excluded.ctime_ns,
70
+ last_seen_ts = excluded.last_seen_ts,
71
+ checksum = COALESCE(excluded.checksum, files.checksum),
72
+ status = COALESCE(excluded.status, files.status),
73
+ remote_file_id = COALESCE(excluded.remote_file_id, files.remote_file_id),
74
+ transfer_id = COALESCE(excluded.transfer_id, files.transfer_id)
75
+ """,
76
+ (
77
+ record.path,
78
+ record.size,
79
+ record.mtime_ns,
80
+ record.ctime_ns,
81
+ record.last_seen_ts,
82
+ record.checksum,
83
+ record.status,
84
+ record.remote_file_id,
85
+ record.transfer_id,
86
+ )
87
+ )
88
+
89
+ def get_file_by_path(self, path: str) -> Optional[FileRecord]:
90
+ with self._connect() as conn:
91
+ cursor = conn.execute("SELECT * FROM files WHERE path=?", (path,))
92
+ row = cursor.fetchone()
93
+ if row is None:
94
+ return None
95
+ return FileRecord(**row)
96
+
97
+ def walk(root: Path) -> Iterable[Path]:
98
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
99
+ base = Path(dirpath)
100
+ for name in filenames:
101
+ yield base / name
102
+
103
+ def build_file_db_from_scan(db: FileIndexDB, root: Path, compute_checksums: bool = True):
104
+ scan_started = int(time.time())
105
+ count = 0
106
+ for p in walk(root):
107
+ if not p.is_file():
108
+ continue
109
+
110
+
@@ -1,4 +1,6 @@
1
1
  import datetime
2
+
3
+ import aiofiles
2
4
  import dateutil
3
5
  import hashlib
4
6
  import json
@@ -153,6 +155,17 @@ def checksum(path, chunk_size=8192):
153
155
  md5.update(chunk)
154
156
  return md5.hexdigest()
155
157
 
158
+ async def checksum_async(path, chunk_size=8192):
159
+ """Generate MD5 checksum for the file at "path"""
160
+ md5 = hashlib.md5()
161
+ async with aiofiles.open(path, 'rb') as f:
162
+ while True:
163
+ chunk = await f.read(chunk_size)
164
+ if not chunk:
165
+ break
166
+ md5.update(chunk)
167
+ return md5.hexdigest()
168
+
156
169
  def random_name(n=3, max_letters=6, sep='-'):
157
170
  """Generates a random name for "n" words of max "max_letters" length, joined by "sep" """
158
171
  import random
@@ -6,7 +6,7 @@ from datetime import datetime, timedelta
6
6
  from io import StringIO
7
7
 
8
8
  import materials_commons.api as mcapi
9
- import pkg_resources
9
+ from importlib.metadata import PackageNotFoundError, version as pkg_version
10
10
  import requests
11
11
 
12
12
  import materials_commons.cli.functions as clifuncs
@@ -30,6 +30,7 @@ from materials_commons.cli.subcommands.remote import remote_subcommand
30
30
  from materials_commons.cli.subcommands.rm import rm_subcommand
31
31
  from materials_commons.cli.subcommands.up import up_subcommand
32
32
  from materials_commons.cli.subcommands.versions import versions_subcommand
33
+ from materials_commons.cli.subcommands.scan import scan_subcommand
33
34
  from materials_commons.cli.user_config import Config
34
35
  from materials_commons.cli.subcommands.server import server_subcommand
35
36
 
@@ -47,6 +48,7 @@ standard_usage = [
47
48
  {'name': 'fetch', 'desc': 'Remote data fetching and configuration', 'subcommand': fetch_subcommand},
48
49
  {'name': 'up', 'desc': 'Upload files', 'subcommand': up_subcommand},
49
50
  {'name': 'down', 'desc': 'Download files', 'subcommand': down_subcommand},
51
+ # {'name': 'scan', 'desc': 'Scan local directory for files to upload', 'subcommand': scan_subcommand},
50
52
  {'name': 'globus', 'desc': 'Manage Globus uploads and downloads', 'subcommand': globus_subcommand},
51
53
  {'name': 'versions', 'desc': 'List file versions', 'subcommand': versions_subcommand},
52
54
  # {'name': 'proc', 'desc': 'List processes', 'subcommand': ProcSubcommand()},
@@ -74,8 +76,12 @@ def make_parser():
74
76
  description='Materials Commons command line interface',
75
77
  usage=usage_help.getvalue())
76
78
  parser.add_argument('command', help='Subcommand to run')
77
- parser.add_argument('--version', '-v', action='version',
78
- version=pkg_resources.get_distribution('materials-commons-cli').version)
79
+ package_name = 'materials-commons-cli'
80
+ try:
81
+ installed_version = pkg_version(package_name)
82
+ except PackageNotFoundError:
83
+ installed_version = "unknown"
84
+ parser.add_argument('--version', '-v', action='version', version=installed_version)
79
85
 
80
86
  return parser
81
87
 
@@ -113,7 +119,7 @@ def check_package_version():
113
119
 
114
120
  package_name = 'materials-commons-cli'
115
121
  try:
116
- current_version = pkg_resources.get_distribution(package_name).version
122
+ current_version = pkg_version(package_name)
117
123
  response = requests.get(f"https://pypi.org/pypi/{package_name}/json")
118
124
  latest_version = response.json()["info"]["version"]
119
125
 
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
2
  from typing import Dict, Any, Callable, Awaitable
3
- from materials_commons.cli import desktop
3
+ from materials_commons.cli import server
4
4
  import os
5
5
  import signal
6
6
  import logging
@@ -74,7 +74,7 @@ async def handle_list_directory(queue: asyncio.Queue, cmd: Dict[str, Any]) -> No
74
74
  async def handle_list_projects(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
75
75
  print(f"[handler] list_projects -> {cmd}")
76
76
  payload = cmd.get("payload") or {}
77
- projects = desktop.list_local_projects()
77
+ projects = server.list_local_projects()
78
78
  response_payload = {
79
79
  "projects": projects,
80
80
  "request_id": payload.get("request_id"),
@@ -121,7 +121,7 @@ async def handle_upload_file(queue: asyncio.Queue, cmd: Dict[str, Any]) -> None:
121
121
  # To do this, we get the local path for the project, then join project_path and the local project's path
122
122
  # to get the full local path. Note that project_path may start with a /, so we strip it off before joining.
123
123
  if not file_path:
124
- p = desktop.get_local_project_by_id(project_id)
124
+ p = server.get_local_project_by_id(project_id)
125
125
  if not p:
126
126
  logger.error(f"Project {project_id} not found in local projects")
127
127
  return
@@ -178,7 +178,7 @@ async def handle_upload_directory(queue: asyncio.Queue, cmd: Dict[str, Any]):
178
178
  logger.error("Missing required field mc_project_path in UPLOAD_DIRECTORY command")
179
179
  return
180
180
 
181
- p = desktop.get_local_project_by_id(project_id)
181
+ p = server.get_local_project_by_id(project_id)
182
182
  if not p:
183
183
  logger.error(f"Project {project_id} not found in local projects")
184
184
  return
@@ -341,7 +341,7 @@ async def handle_download_file(queue: asyncio.Queue, cmd: Dict[str, Any]) -> Non
341
341
  if project_path:
342
342
  # If we have a project path, then we need to translate it to a
343
343
  # local file path
344
- p = desktop.get_local_project_by_id(project_id)
344
+ p = server.get_local_project_by_id(project_id)
345
345
  if not p:
346
346
  logger.error(f"Project {project_id} not found in local projects")
347
347
  return
@@ -1,9 +1,7 @@
1
1
  import asyncio
2
- import os
3
- from pathlib import Path
4
2
  from typing import Dict, Any, Optional, Callable
5
3
 
6
- from materials_commons.cli.desktop.downloader.file_downloader import FileDownloader, logger
4
+ from materials_commons.cli.server.downloader.file_downloader import FileDownloader, logger
7
5
 
8
6
 
9
7
  class FileDownloadManager:
@@ -1,4 +1,3 @@
1
- # materials_commons/cli/desktop/downloader/file_downloader.py
2
1
  import asyncio
3
2
  import hashlib
4
3
  import json
@@ -6,7 +5,8 @@ import logging
6
5
  import uuid
7
6
  from datetime import datetime, timezone
8
7
  from pathlib import Path
9
- from typing import Optional, Callable, Dict, Any
8
+ from typing import Optional, Callable
9
+
10
10
  import requests
11
11
 
12
12
  logger = logging.getLogger(__name__)
@@ -0,0 +1,84 @@
1
+ import asyncio
2
+ import os
3
+
4
+ from datetime import datetime
5
+ from materials_commons.cli.filedb import FileIndexDB, FileRecord
6
+ from materials_commons.cli.functions import checksum
7
+
8
+
9
+ class FileIndexManager:
10
+ """Manages multiple concurrent file indexers"""
11
+
12
+ def __init__(self, db: FileIndexDB, queue: asyncio.Queue, max_concurrent: int = 3):
13
+ self.db = db
14
+ self._index_queue = queue
15
+ self._workers_running = False
16
+ self._max_concurrent = max_concurrent
17
+ self.db_write_queue = asyncio.Queue()
18
+
19
+ async def start_workers(self):
20
+ """Start background workers to process the index queue"""
21
+ self._workers_running = True
22
+ workers = [
23
+ asyncio.create_task(self._index_file_worker())
24
+ for i in range(self._max_concurrent)
25
+ ]
26
+ db_queue_worker = asyncio.create_task(self._db_queue_worker())
27
+ workers.append(db_queue_worker)
28
+ return workers
29
+
30
+ async def _index_file_worker(self):
31
+ """Worker that processes index requests from queue"""
32
+ print("Index worker started")
33
+ while self._workers_running:
34
+ try:
35
+ (file_path, project_path) = await asyncio.wait_for(self._index_queue.get(), timeout=1.0)
36
+ except asyncio.TimeoutError:
37
+ continue
38
+
39
+ try:
40
+ await self._index(file_path, project_path)
41
+ except Exception as e:
42
+ print(f"Error indexing {file_path}: {e}")
43
+ finally:
44
+ self._index_queue.task_done()
45
+
46
+ async def _index(self, file_path: str, project_path: str):
47
+ """Index a file"""
48
+ print(f"Indexing {file_path} in {project_path}")
49
+ hash = await asyncio.to_thread(checksum, file_path)
50
+ finfo = os.stat(file_path)
51
+ file_record = FileRecord(
52
+ path = file_path,
53
+ size = finfo.st_size,
54
+ mtime_ns = finfo.st_mtime_ns,
55
+ ctime_ns = finfo.st_ctime_ns,
56
+ last_seen_ts = int(datetime.now().timestamp()),
57
+ checksum = hash,
58
+ status = "indexed",
59
+ )
60
+ await self.db_write_queue.put(file_record)
61
+ print(f"Done indexing {file_path} in {project_path} got Hash: {hash}")
62
+ pass
63
+
64
+ async def _db_queue_worker(self):
65
+ """Worker that processes db writes from queue"""
66
+ while self._workers_running:
67
+ try:
68
+ db_entry = await asyncio.wait_for(self.db_write_queue.get(), timeout=1.0)
69
+ print(f"Writing to db: {db_entry}")
70
+ except asyncio.TimeoutError:
71
+ continue
72
+ except asyncio.CancelledError:
73
+ return
74
+
75
+ try:
76
+ self.db.upsert(db_entry)
77
+ except Exception as e:
78
+ pass
79
+ finally:
80
+ self.db_write_queue.task_done()
81
+
82
+ async def stop_workers(self):
83
+ """Stop background workers"""
84
+ self._workers_running = False
@@ -5,7 +5,7 @@ import asyncio
5
5
  import threading
6
6
  from pathlib import Path
7
7
 
8
- from materials_commons.cli import desktop
8
+ from materials_commons.cli import server
9
9
 
10
10
 
11
11
  class LocalRestRequestHandler(BaseHTTPRequestHandler):
@@ -27,7 +27,7 @@ class LocalRestRequestHandler(BaseHTTPRequestHandler):
27
27
 
28
28
  def _refresh_projects(self):
29
29
  try:
30
- projects = desktop.list_local_projects()
30
+ projects = server.list_local_projects()
31
31
  project_ids = [str(p["project_id"]) for p in projects]
32
32
  payload = {"type": "refresh-projects", "project_ids": project_ids}
33
33
 
@@ -54,7 +54,7 @@ class LocalRestRequestHandler(BaseHTTPRequestHandler):
54
54
 
55
55
  class LocalRestServer:
56
56
  """
57
- Local REST server for handling requests from the desktop client. Writes to the server.json state file so
57
+ Local REST server for handling requests from the server client. Writes to the server.json state file so
58
58
  the CLI can find the port.
59
59
  """
60
60
 
@@ -1,8 +1,8 @@
1
1
  import asyncio
2
2
  import logging
3
3
  from typing import List, Optional
4
- from materials_commons.cli.desktop.websocket_server import WebSocketCommandListener
5
- from materials_commons.cli.desktop.command_handlers import register_handlers
4
+ from materials_commons.cli.server.websocket_server import WebSocketCommandListener
5
+ from materials_commons.cli.server.command_handlers import register_handlers
6
6
  from materials_commons.cli.user_config import Config
7
7
  import ssl
8
8
  import websockets
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
5
5
  from pathlib import Path
6
6
  from typing import Dict, Any, Optional, Callable
7
7
 
8
- from materials_commons.cli.desktop.uploader.file_uploader import FileUploader, logger
8
+ from materials_commons.cli.server.uploader.file_uploader import FileUploader, logger
9
9
 
10
10
 
11
11
  class FileTransferManager:
@@ -6,6 +6,8 @@ from datetime import datetime, timezone
6
6
  from pathlib import Path
7
7
  from typing import Optional, Callable, Dict, Any
8
8
 
9
+ import aiofiles
10
+
9
11
  logger = logging.getLogger(__name__)
10
12
 
11
13
 
@@ -43,6 +45,7 @@ class FileUploader:
43
45
  self.in_flight_chunks = 0
44
46
  self.last_acked_chunk = -1
45
47
  self._state_lock = asyncio.Lock()
48
+ self._already_uploaded = False
46
49
 
47
50
  # Response handling
48
51
  self.response_queue: asyncio.Queue = asyncio.Queue()
@@ -59,6 +62,10 @@ class FileUploader:
59
62
 
60
63
  # Step 2: Wait for acceptance
61
64
  if not await self._wait_for_acceptance():
65
+ if self._already_uploaded:
66
+ logger.info(f"File {self.file_path} already uploaded")
67
+ print(f"File {self.file_path} already uploaded...")
68
+ return True
62
69
  return False
63
70
 
64
71
  # # Step 3: Send file chunks
@@ -89,10 +96,12 @@ class FileUploader:
89
96
  return False
90
97
 
91
98
  logger.info(f"File upload complete for {self.file_path}")
99
+ print(f"Uploaded {self.file_path}...")
92
100
  return True
93
101
 
94
102
  except Exception as e:
95
103
  logger.error(f"Error uploading file {self.file_path}: {e}")
104
+ print(f"Error uploading file {self.file_path}: {e}")
96
105
  return False
97
106
 
98
107
  async def resume(self, resume_from_byte: int, resume_from_chunk: int) -> bool:
@@ -161,7 +170,11 @@ class FileUploader:
161
170
 
162
171
  elif msg["command"] == "TRANSFER_REJECT":
163
172
  reason = msg["payload"].get("reason", "unknown")
164
- logger.error(f"Transfer rejected: {reason}")
173
+ if reason == "file already uploaded":
174
+ # TODO: This is a hack to prevent uploading the same file twice. We need a better solution.
175
+ self._already_uploaded = True
176
+ else:
177
+ logger.error(f"Transfer rejected: {reason}")
165
178
  return False
166
179
 
167
180
  logger.error(f"Unexpected response: {msg['command']}")
@@ -189,10 +202,10 @@ class FileUploader:
189
202
  """Send file chunks using sliding window (don't wait for each ACK)"""
190
203
  total_chunks = (self.file_size + self.chunk_size - 1) // self.chunk_size
191
204
 
192
- with open(self.file_path, 'rb') as f:
205
+ async with aiofiles.open(self.file_path, 'rb') as f:
193
206
  # Single seek to start position (for resume)
194
207
  if start_chunk > 0:
195
- f.seek(start_chunk * self.chunk_size)
208
+ await f.seek(start_chunk * self.chunk_size)
196
209
 
197
210
  async with self._state_lock:
198
211
  self.next_chunk_to_send = start_chunk
@@ -227,7 +240,7 @@ class FileUploader:
227
240
  continue
228
241
 
229
242
  # Read the next chunk (outside lock - file I/O can be slow)
230
- chunk = f.read(self.chunk_size)
243
+ chunk = await f.read(self.chunk_size)
231
244
  if not chunk:
232
245
  break
233
246
 
@@ -9,9 +9,9 @@ from typing import Dict, Any, Optional, Callable, Awaitable
9
9
 
10
10
  import websockets
11
11
  from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK, InvalidStatus
12
- from materials_commons.cli import desktop
13
- from materials_commons.cli.desktop.uploader.file_transfer_manager import FileTransferManager
14
- from materials_commons.cli.desktop.downloader.file_download_manager import FileDownloadManager
12
+ from materials_commons.cli import server
13
+ from materials_commons.cli.server.uploader.file_transfer_manager import FileTransferManager
14
+ from materials_commons.cli.server.downloader.file_download_manager import FileDownloadManager
15
15
  from materials_commons.cli.user_config import Config
16
16
 
17
17
  # Type alias for handler functions
@@ -233,7 +233,7 @@ class WebSocketCommandListener:
233
233
  headers["MC-Connection-Type"] = "cli"
234
234
 
235
235
  try:
236
- projects = desktop.list_local_projects()
236
+ projects = server.list_local_projects()
237
237
  headers["MC-Client-Projects"] = ",".join([str(p["project_id"]) for p in projects])
238
238
  except Exception:
239
239
  # Fallback if listing projects fails
@@ -0,0 +1,83 @@
1
+ import argparse
2
+ import asyncio
3
+ import os
4
+
5
+ import materials_commons.cli.functions as clifuncs
6
+ import materials_commons.cli.tree_functions as treefuncs
7
+ from pathlib import Path
8
+ import igittigitt
9
+ from materials_commons.cli.filedb import FileIndexDB
10
+ from materials_commons.cli.server.indexer.file_index_manager import FileIndexManager
11
+
12
+
13
+ def make_parser():
14
+ mc_scan_usage = 'mc scan'
15
+ parser = argparse.ArgumentParser(
16
+ description='Scan a project and build a database of current files and directories. This speeds uploads by storing state on the file.',
17
+ usage=mc_scan_usage,
18
+ prog='mc scan')
19
+
20
+ return parser
21
+
22
+ async def scan_files_async(local_path, mc_path, file_index_queue, ignore_parser):
23
+ """Scan files and directories in a project asynchronously"""
24
+
25
+ loop = asyncio.get_event_loop()
26
+
27
+ def walk_and_enqueue():
28
+
29
+ for root, dirs, files in os.walk(local_path):
30
+ current_dir = os.path.basename(root)
31
+ if current_dir == ".mc":
32
+ continue
33
+ for filename in files:
34
+ if filename == ".DS_Store":
35
+ continue
36
+ if ignore_parser.match(filename):
37
+ continue
38
+ file_path = os.path.join(root, filename)
39
+ # Calculate relative path and construct mc_path
40
+ relative = Path(file_path).relative_to(local_path)
41
+ file_mc_path = str(Path(mc_path) / relative)
42
+ # print(f"Indexing {file_mc_path}, {file_path}")
43
+ asyncio.run_coroutine_threadsafe(file_index_queue.put((file_path, file_mc_path)), loop).result()
44
+
45
+ await asyncio.to_thread(walk_and_enqueue)
46
+
47
+ async def scan_subcommand_async(argv, working_dir):
48
+ """Builds and populates the file index database for project scan; skips ignored directories and files"""
49
+ parser = make_parser()
50
+ parser.parse_args(argv)
51
+
52
+ proj = clifuncs.make_local_project(working_dir)
53
+
54
+ filedb = FileIndexDB(db_path=Path(proj.local_path) / ".mc" / "mc2.sqlite")
55
+
56
+ ignore_parser = igittigitt.IgnoreParser()
57
+ ignore_parser.parse_rule_files(base_dir=proj.local_path, filename=".mcignore",
58
+ add_default_patterns=False)
59
+
60
+ file_index_queue = asyncio.Queue()
61
+ file_index_manager = FileIndexManager(filedb, file_index_queue)
62
+
63
+ workers = await file_index_manager.start_workers()
64
+
65
+ local_abspaths = treefuncs.clipaths_to_local_abspaths(proj.local_path, [proj.local_path], proj.local_path)
66
+ local_abspaths = treefuncs.filter_local_abspaths(proj.local_path, local_abspaths, working_dir)
67
+ mcpaths = treefuncs.clipaths_to_mcpaths(proj.local_path, local_abspaths, working_dir)
68
+ for local_path, mc_path in zip(local_abspaths, mcpaths):
69
+ await scan_files_async(local_path, mc_path, file_index_queue, ignore_parser)
70
+
71
+ # Wait for all tasks to complete
72
+ await file_index_queue.join()
73
+ await file_index_manager.stop_workers()
74
+ print("called stop_workers")
75
+ for worker in workers:
76
+ try:
77
+ await worker
78
+ except asyncio.CancelledError:
79
+ pass
80
+
81
+ def scan_subcommand(argv, working_dir):
82
+ """Runs the scan subcommand asynchronously"""
83
+ asyncio.run(scan_subcommand_async(argv, working_dir))
@@ -4,10 +4,10 @@ import signal
4
4
  import uuid
5
5
  from typing import Awaitable, Callable, Dict, Any
6
6
 
7
- from materials_commons.cli.desktop.command_handlers import register_handlers
8
- from materials_commons.cli.desktop.websocket_server import WebSocketCommandListener
7
+ from materials_commons.cli.server.command_handlers import register_handlers
8
+ from materials_commons.cli.server.websocket_server import WebSocketCommandListener
9
9
  from materials_commons.cli.user_config import Config
10
- from materials_commons.cli.desktop.local_rest_server import LocalRestServer
10
+ from materials_commons.cli.server.local_rest_server import LocalRestServer
11
11
 
12
12
  CommandHandler = Callable[[Dict[str, Any]], Awaitable[None]]
13
13
 
@@ -2,13 +2,15 @@ import argparse
2
2
  import logging
3
3
  import sys
4
4
 
5
+ import igittigitt
6
+
5
7
  import materials_commons.cli.exceptions as cliexcept
6
8
  import materials_commons.cli.functions as clifuncs
7
9
  import materials_commons.cli.globus as cliglobus
8
10
  import materials_commons.cli.tree_functions as treefuncs
9
11
  from materials_commons.cli.treedb import LocalTree, RemoteTree
10
12
  from materials_commons.cli.subcommands.server import DEFAULT_WS_URL
11
- from materials_commons.cli.desktop.uploader.api import ws_upload_synchronous
13
+ from materials_commons.cli.server.uploader.api import ws_upload_synchronous
12
14
  import os
13
15
  from pathlib import Path
14
16
 
@@ -106,11 +108,14 @@ def up_subcommand(argv, working_dir):
106
108
  proj.local_path, args.paths, working_dir)
107
109
 
108
110
  # filter, skipping .mc, those specified by .mcignore
109
- local_abspaths = treefuncs.filter_local_abspaths(
110
- proj.local_path, local_abspaths, working_dir)
111
+ local_abspaths = treefuncs.filter_local_abspaths(proj.local_path, local_abspaths, working_dir)
111
112
 
112
113
  mcpaths = treefuncs.clipaths_to_mcpaths(proj.local_path, local_abspaths, working_dir)
113
114
 
115
+ ignore_parser = igittigitt.IgnoreParser()
116
+ ignore_parser.parse_rule_files(base_dir=proj.local_path, filename=".mcignore",
117
+ add_default_patterns=False)
118
+
114
119
  file_paths = []
115
120
  project_paths = []
116
121
 
@@ -118,7 +123,14 @@ def up_subcommand(argv, working_dir):
118
123
  if treefuncs.os.path.isdir(local_path):
119
124
  if args.recursive:
120
125
  for root, dirs, files in os.walk(local_path):
126
+ current_dir = os.path.basename(root)
127
+ if current_dir == ".mc":
128
+ continue
121
129
  for filename in files:
130
+ if filename == ".DS_Store":
131
+ continue
132
+ # if ignore_parser.match(filename):
133
+ # continue
122
134
  file_path = os.path.join(root, filename)
123
135
 
124
136
  # Calculate relative path and construct mc_path
@@ -126,6 +138,7 @@ def up_subcommand(argv, working_dir):
126
138
  file_mc_path = str(Path(mc_path) / relative)
127
139
  file_paths.append(file_path)
128
140
  project_paths.append(file_mc_path)
141
+ # print(f"Indexing {file_mc_path}, {file_path}")
129
142
  else:
130
143
  print("Skipping directory because --recursive option not specified: " + local_path)
131
144
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: materials-commons-cli
3
- Version: 2.2.1
3
+ Version: 2.2.3
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
@@ -7,6 +7,7 @@ materials_commons/cli/__init__.py
7
7
  materials_commons/cli/cloned_project.py
8
8
  materials_commons/cli/exceptions.py
9
9
  materials_commons/cli/file_functions.py
10
+ materials_commons/cli/filedb.py
10
11
  materials_commons/cli/functions.py
11
12
  materials_commons/cli/globus.py
12
13
  materials_commons/cli/list_objects.py
@@ -17,18 +18,20 @@ materials_commons/cli/tmp_functions.py
17
18
  materials_commons/cli/tree_functions.py
18
19
  materials_commons/cli/treedb.py
19
20
  materials_commons/cli/user_config.py
20
- materials_commons/cli/desktop/__init__.py
21
- materials_commons/cli/desktop/command_handlers.py
22
- materials_commons/cli/desktop/local_rest_server.py
23
- materials_commons/cli/desktop/projects.py
24
- materials_commons/cli/desktop/websocket_server.py
25
- materials_commons/cli/desktop/downloader/__init__.py
26
- materials_commons/cli/desktop/downloader/file_download_manager.py
27
- materials_commons/cli/desktop/downloader/file_downloader.py
28
- materials_commons/cli/desktop/uploader/__init__.py
29
- materials_commons/cli/desktop/uploader/api.py
30
- materials_commons/cli/desktop/uploader/file_transfer_manager.py
31
- materials_commons/cli/desktop/uploader/file_uploader.py
21
+ materials_commons/cli/server/__init__.py
22
+ materials_commons/cli/server/command_handlers.py
23
+ materials_commons/cli/server/local_rest_server.py
24
+ materials_commons/cli/server/projects.py
25
+ materials_commons/cli/server/websocket_server.py
26
+ materials_commons/cli/server/downloader/__init__.py
27
+ materials_commons/cli/server/downloader/file_download_manager.py
28
+ materials_commons/cli/server/downloader/file_downloader.py
29
+ materials_commons/cli/server/indexer/__init__.py
30
+ materials_commons/cli/server/indexer/file_index_manager.py
31
+ materials_commons/cli/server/uploader/__init__.py
32
+ materials_commons/cli/server/uploader/api.py
33
+ materials_commons/cli/server/uploader/file_transfer_manager.py
34
+ materials_commons/cli/server/uploader/file_uploader.py
32
35
  materials_commons/cli/subcommands/__init__.py
33
36
  materials_commons/cli/subcommands/clone.py
34
37
  materials_commons/cli/subcommands/config.py
@@ -44,6 +47,7 @@ materials_commons/cli/subcommands/mv.py
44
47
  materials_commons/cli/subcommands/proj.py
45
48
  materials_commons/cli/subcommands/remote.py
46
49
  materials_commons/cli/subcommands/rm.py
50
+ materials_commons/cli/subcommands/scan.py
47
51
  materials_commons/cli/subcommands/server.py
48
52
  materials_commons/cli/subcommands/up.py
49
53
  materials_commons/cli/subcommands/versions.py
@@ -1,3 +1,5 @@
1
+ websockets
2
+ aiofiles
1
3
  globus-cli
2
4
  globus-sdk
3
5
  igittigitt==2.1.0
@@ -8,4 +10,3 @@ requests
8
10
  setuptools
9
11
  sortedcontainers
10
12
  tabulate
11
- websockets
@@ -21,7 +21,7 @@ setup(
21
21
  "": ["*.txt"]
22
22
  },
23
23
  packages=find_namespace_packages(
24
- include=['materials_commons.cli', 'materials_commons.cli.subcommands', 'materials_commons.cli.desktop', 'materials_commons.cli.desktop.uploader', 'materials_commons.cli.desktop.downloader']
24
+ include=['materials_commons.cli', 'materials_commons.cli.subcommands', 'materials_commons.cli.server', 'materials_commons.cli.server.uploader', 'materials_commons.cli.server.downloader', 'materials_commons.cli.server.indexer']
25
25
  ),
26
26
  entry_points={
27
27
  'console_scripts': ['mc=materials_commons.cli.parser:main']
@@ -50,6 +50,7 @@ setup(
50
50
  keywords='materials science mc materials-commons prisms',
51
51
  install_requires=[
52
52
  "websockets",
53
+ "aiofiles",
53
54
  "globus-cli",
54
55
  "globus-sdk",
55
56
  "igittigitt==2.1.0",
@@ -1 +0,0 @@
1
- __version__ = '2.2.1'