accli 2.0.7__tar.gz → 2.0.13__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 (28) hide show
  1. {accli-2.0.7 → accli-2.0.13}/PKG-INFO +1 -1
  2. {accli-2.0.7 → accli-2.0.13}/accli/AcceleratorTaskDispatcher.py +74 -24
  3. {accli-2.0.7 → accli-2.0.13}/accli/_version.py +1 -1
  4. {accli-2.0.7 → accli-2.0.13}/accli/cli.py +223 -48
  5. {accli-2.0.7 → accli-2.0.13}/accli/gui.py +74 -31
  6. {accli-2.0.7 → accli-2.0.13}/accli/token.py +8 -0
  7. {accli-2.0.7 → accli-2.0.13}/accli.egg-info/PKG-INFO +1 -1
  8. {accli-2.0.7 → accli-2.0.13}/accli.egg-info/SOURCES.txt +2 -1
  9. accli-2.0.13/tests/test_mount_lifecycle.py +138 -0
  10. {accli-2.0.7 → accli-2.0.13}/LICENSE +0 -0
  11. {accli-2.0.7 → accli-2.0.13}/README.md +0 -0
  12. {accli-2.0.7 → accli-2.0.13}/accli/AcceleratorJobProjectService.py +0 -0
  13. {accli-2.0.7 → accli-2.0.13}/accli/AcceleratorTerminalCliProjectService.py +0 -0
  14. {accli-2.0.7 → accli-2.0.13}/accli/CsvRegionalTimeseriesValidator.py +0 -0
  15. {accli-2.0.7 → accli-2.0.13}/accli/__init__.py +0 -0
  16. {accli-2.0.7 → accli-2.0.13}/accli/__main__.py +0 -0
  17. {accli-2.0.7 → accli-2.0.13}/accli/cleanup-nfs.ps1 +0 -0
  18. {accli-2.0.7 → accli-2.0.13}/accli/common.py +0 -0
  19. {accli-2.0.7 → accli-2.0.13}/accli/mount_downloader.py +0 -0
  20. {accli-2.0.7 → accli-2.0.13}/accli/nfs_watcher.py +0 -0
  21. {accli-2.0.7 → accli-2.0.13}/accli/refresh_explorer.ps1 +0 -0
  22. {accli-2.0.7 → accli-2.0.13}/accli/setup-nfs.ps1 +0 -0
  23. {accli-2.0.7 → accli-2.0.13}/accli.egg-info/dependency_links.txt +0 -0
  24. {accli-2.0.7 → accli-2.0.13}/accli.egg-info/entry_points.txt +0 -0
  25. {accli-2.0.7 → accli-2.0.13}/accli.egg-info/requires.txt +0 -0
  26. {accli-2.0.7 → accli-2.0.13}/accli.egg-info/top_level.txt +0 -0
  27. {accli-2.0.7 → accli-2.0.13}/pyproject.toml +0 -0
  28. {accli-2.0.7 → accli-2.0.13}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: accli
3
- Version: 2.0.7
3
+ Version: 2.0.13
4
4
  Summary: IIASA Accelerator Client
5
5
  Author-email: Wrufesh S <wrufesh@gmail.com>
6
6
  License: The MIT License (MIT)
@@ -4,6 +4,9 @@ import hashlib
4
4
  import shutil
5
5
  import tempfile
6
6
  import fnmatch
7
+ import concurrent.futures
8
+ import json
9
+ from pathlib import Path
7
10
  from typing import Optional, Dict, List
8
11
 
9
12
  import requests
@@ -50,9 +53,13 @@ def get_file_sha1(file_path):
50
53
 
51
54
 
52
55
  def copy_tree(src, dst, include=None, exclude=None, root_src=None):
53
- """Recursively copy from src to dst, excluding .git folders."""
56
+ """Recursively copy from src to dst, excluding .git folders. Includes override excludes."""
54
57
  if root_src is None:
55
58
  root_src = src
59
+
60
+ excludes = [e.strip() for e in exclude.split(',') if e.strip()] if exclude else []
61
+ includes = [i.strip() for i in include.split(',') if i.strip()] if include else []
62
+
56
63
  for item in os.listdir(src):
57
64
  src_path = os.path.join(src, item)
58
65
  dst_path = os.path.join(dst, item)
@@ -61,24 +68,54 @@ def copy_tree(src, dst, include=None, exclude=None, root_src=None):
61
68
  if os.path.isdir(src_path):
62
69
  if item == '.git':
63
70
  continue
64
- if exclude:
65
- excludes = [e.strip() for e in exclude.split(',') if e.strip()]
66
- if any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(item, p) for p in excludes):
67
- continue
71
+
72
+ is_excluded = any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(item, p) for p in excludes)
73
+ # Traverse even if excluded as long as includes exist, to find exceptions
74
+ if is_excluded and not includes:
75
+ continue
76
+
68
77
  os.makedirs(dst_path, exist_ok=True)
69
78
  copy_tree(src_path, dst_path, include, exclude, root_src)
70
79
  else:
71
- if exclude:
72
- excludes = [e.strip() for e in exclude.split(',') if e.strip()]
73
- if any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(item, p) for p in excludes):
74
- continue
75
- if include:
76
- includes = [i.strip() for i in include.split(',') if i.strip()]
77
- if not any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(item, p) for p in includes):
78
- continue
80
+ is_excluded = any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(item, p) for p in excludes)
81
+ is_included = any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(item, p) for p in includes)
82
+
83
+ if is_included:
84
+ pass # Included files override exclude
85
+ elif is_excluded:
86
+ continue # Skipped by exclude
87
+ elif includes and not excludes:
88
+ continue # If ONLY includes list is provided, act as whitelist
89
+
79
90
  shutil.copy2(src_path, dst_path)
80
91
 
81
92
 
93
+ def _upload_cas_file_worker(args):
94
+ rel_path, file_path, term_cli_project_service, project_slug = args
95
+ file_hash = get_file_sha1(file_path)
96
+ blob_name = f"cas_{file_hash}.blob"
97
+
98
+ print(f"Checking {rel_path} (hash: {file_hash[:8]})...")
99
+ presigned_push_url = term_cli_project_service.get_jobstore_push_url(
100
+ project_slug, blob_name
101
+ )
102
+ if presigned_push_url:
103
+ print(f"Uploading {rel_path}...")
104
+ with open(file_path, 'rb') as f:
105
+ res = requests.put(
106
+ presigned_push_url,
107
+ data=f,
108
+ verify=False,
109
+ )
110
+ res.raise_for_status()
111
+ print(f"Uploaded {rel_path}.")
112
+ else:
113
+ print(f"Skipped {rel_path} (already in jobstore).")
114
+
115
+ mode = os.stat(file_path).st_mode
116
+ return {"path": rel_path, "hash": file_hash, "mode": mode}
117
+
118
+
82
119
  @lru_cache(maxsize=None)
83
120
  def push_folder_job(directory, include=None, exclude=None):
84
121
  server_url = get_server_url()
@@ -97,19 +134,32 @@ def push_folder_job(directory, include=None, exclude=None):
97
134
  if os.path.isfile(f'{repo_dir}/wkube.py'):
98
135
  os.remove(f'{repo_dir}/wkube.py')
99
136
 
100
- temp_zip_path = f"{repo_dir}/temp.zip"
101
- compress_folder(repo_dir, temp_zip_path)
102
-
103
- sha256_hash = get_file_sha1(temp_zip_path)
104
- final_zip_path = f"{repo_dir}/{sha256_hash}.zip"
105
- os.rename(temp_zip_path, final_zip_path)
106
-
137
+ manifest = {"type": "cas_manifest", "files": []}
138
+
139
+ upload_args = []
140
+ for root, dirs, files in os.walk(repo_dir):
141
+ for file in files:
142
+ file_path = os.path.join(root, file)
143
+ rel_path = Path(file_path).relative_to(repo_dir).as_posix()
144
+ upload_args.append((rel_path, file_path, term_cli_project_service, project_slug))
145
+
146
+ with concurrent.futures.ThreadPoolExecutor(max_workers=30) as executor:
147
+ for result in executor.map(_upload_cas_file_worker, upload_args):
148
+ manifest["files"].append(result)
149
+
150
+ manifest_path = os.path.join(repo_dir, "manifest.json")
151
+ with open(manifest_path, "w") as f:
152
+ json.dump(manifest, f)
153
+
154
+ manifest_hash = get_file_sha1(manifest_path)
155
+ manifest_name = f"manifest_{manifest_hash}.json"
156
+
107
157
  presigned_push_url = term_cli_project_service.get_jobstore_push_url(
108
- project_slug, f"{sha256_hash}.zip"
158
+ project_slug, manifest_name
109
159
  )
110
-
160
+
111
161
  if presigned_push_url:
112
- with open(final_zip_path, 'rb') as f:
162
+ with open(manifest_path, 'rb') as f:
113
163
  res = requests.put(
114
164
  presigned_push_url,
115
165
  data=f,
@@ -118,7 +168,7 @@ def push_folder_job(directory, include=None, exclude=None):
118
168
  res.raise_for_status()
119
169
 
120
170
  shutil.rmtree(repo_dir)
121
- return f"s3accjobstore://{sha256_hash}.zip", sha256_hash
171
+ return f"s3accjobstore://{manifest_name}", manifest_hash
122
172
 
123
173
 
124
174
  class JobDispatchModel(BaseModel):
@@ -1,3 +1,3 @@
1
1
  # Please strictly put double quote to use this info for git tag
2
2
  # Read a developer guide for git tag command with regex
3
- VERSION = "v2.0.7"
3
+ VERSION = "v2.0.13"
@@ -672,6 +672,7 @@ def start_nfs_watcher(mount_point: str, interval: int):
672
672
  proc = subprocess.Popen(
673
673
  [executable, str(watcher_script), str(mount_point), str(interval)],
674
674
  creationflags=creationflags,
675
+ stdin=subprocess.DEVNULL,
675
676
  stdout=subprocess.DEVNULL,
676
677
  stderr=subprocess.DEVNULL,
677
678
  close_fds=True
@@ -722,6 +723,167 @@ def stop_nfs_watcher(mount_point: str):
722
723
  pass
723
724
 
724
725
 
726
+ def cleanup_stuck_windows_mount_processes():
727
+ """Terminate any orphaned mount.exe / umount.exe instances that block Windows NFS client."""
728
+ import subprocess
729
+ try:
730
+ subprocess.run(["taskkill", "/f", "/im", "mount.exe"], capture_output=True)
731
+ except Exception:
732
+ pass
733
+ try:
734
+ subprocess.run(["taskkill", "/f", "/im", "umount.exe"], capture_output=True)
735
+ except Exception:
736
+ pass
737
+
738
+
739
+ def is_nfs_daemon_running() -> bool:
740
+ """Check if hf-mount-nfs.exe is currently running."""
741
+ import subprocess
742
+ try:
743
+ res = subprocess.run(["tasklist", "/FI", "IMAGENAME eq hf-mount-nfs.exe"], capture_output=True, text=True)
744
+ return "hf-mount-nfs.exe" in res.stdout
745
+ except Exception:
746
+ return False
747
+
748
+
749
+ def is_nfs_kernel_driver_active() -> bool:
750
+ """Check if the Windows Client for NFS kernel redirector driver (NfsRdr) is loaded and running."""
751
+ import subprocess
752
+ import shutil
753
+ if not shutil.which("mount.exe") and not os.path.isfile("C:\\Windows\\System32\\mount.exe"):
754
+ return False
755
+ try:
756
+ res = subprocess.run(["sc.exe", "query", "NfsRdr"], capture_output=True, text=True)
757
+ if "RUNNING" in res.stdout:
758
+ return True
759
+ # If stopped, try to start it
760
+ subprocess.run(["sc.exe", "start", "NfsRdr"], capture_output=True)
761
+ check = subprocess.run(["sc.exe", "query", "NfsRdr"], capture_output=True, text=True)
762
+ return "RUNNING" in check.stdout
763
+ except Exception:
764
+ return False
765
+
766
+
767
+ def wait_for_nfs_portmapper(host: str, port: int, timeout: float) -> bool:
768
+ """Poll until the NFS daemon's portmapper is accepting TCP connections and is stable."""
769
+ import socket
770
+ start = time.time()
771
+ consecutive_success = 0
772
+ while time.time() - start < timeout:
773
+ try:
774
+ with socket.create_connection((host, port), timeout=0.5):
775
+ consecutive_success += 1
776
+ if consecutive_success >= 2:
777
+ time.sleep(1.0)
778
+ return True
779
+ except (socket.error, OSError):
780
+ consecutive_success = 0
781
+ time.sleep(0.3)
782
+ return False
783
+
784
+
785
+ def ensure_nfs_daemon_stable(timeout: float) -> bool:
786
+ """
787
+ Ensure the NFS daemon starts and survives any delayed UMNT packet flushed by Windows NFS client.
788
+ If the daemon exits due to UMNT, re-triggers the Task Scheduler task to start a fresh daemon.
789
+ """
790
+ import subprocess
791
+ for attempt in range(3):
792
+ if not wait_for_nfs_portmapper("127.0.0.1", 111, timeout):
793
+ return False
794
+
795
+ # Windows NFS client kernel redirector flushes delayed UMNT within 7-8 seconds of portmapper binding
796
+ exited = False
797
+ for _ in range(16):
798
+ time.sleep(0.5)
799
+ if not is_nfs_daemon_running():
800
+ exited = True
801
+ break
802
+
803
+ if not exited:
804
+ # Daemon remained running and stable without being killed by delayed UMNT
805
+ return True
806
+
807
+ # Daemon consumed the queued UMNT packet and terminated cleanly. Re-trigger task!
808
+ time.sleep(1.0)
809
+ subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True)
810
+
811
+ return wait_for_nfs_portmapper("127.0.0.1", 111, timeout)
812
+
813
+
814
+ def map_windows_network_drive(mount_point_abs, max_attempts: int) -> tuple[bool, str]:
815
+ """
816
+ Attempt to map the NFS share to the requested drive letter using mount.exe.
817
+ Retries with cleanup if Windows NFS client returns a transient error.
818
+ """
819
+ import subprocess
820
+ target_str = str(mount_point_abs).rstrip("\\/")
821
+ if re.match(r"^[a-zA-Z]$", target_str):
822
+ target_str += ":"
823
+
824
+ mount_cmd = [
825
+ "C:\\Windows\\System32\\mount.exe",
826
+ "-o", "mtype=hard",
827
+ "-o", "timeout=10",
828
+ "-o", "rsize=32",
829
+ "-o", "wsize=32",
830
+ "-o", "anon",
831
+ "-o", "nolock",
832
+ "\\\\127.0.0.1\\!",
833
+ target_str
834
+ ]
835
+
836
+ last_err = ""
837
+ drive_root = str(mount_point_abs).rstrip("\\/") + "\\"
838
+ for attempt in range(1, max_attempts + 1):
839
+ cleanup_stuck_windows_mount_processes()
840
+
841
+ # If daemon died before or between attempts, revive it
842
+ if not is_nfs_daemon_running():
843
+ subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True)
844
+ wait_for_nfs_portmapper("127.0.0.1", 111, 10.0)
845
+
846
+ try:
847
+ res = subprocess.run(mount_cmd, capture_output=True, text=True, timeout=15)
848
+ if res.returncode == 0:
849
+ # Confirm drive is actually mounted and directory listing is populated and queryable
850
+ drive_ready = False
851
+ for _ in range(30):
852
+ try:
853
+ if os.path.exists(drive_root):
854
+ entries = os.listdir(drive_root)
855
+ if len(entries) > 0:
856
+ drive_ready = True
857
+ return True, res.stdout.strip()
858
+ except OSError:
859
+ pass
860
+ time.sleep(0.5)
861
+ if not drive_ready:
862
+ last_err = f"mount.exe reported success but drive {drive_root} was empty or not accessible"
863
+ # Clean up the stale mount connection so next attempt can remount fresh
864
+ subprocess.run(["net", "use", target_str, "/delete", "/y"], capture_output=True)
865
+ else:
866
+ last_err = (res.stderr or res.stdout or "").strip()
867
+ if "85" in last_err or "already in use" in last_err.lower():
868
+ # Release stale network drive letter without unmounting the daemon UNC path
869
+ subprocess.run(["net", "use", target_str, "/delete", "/y"], capture_output=True)
870
+ time.sleep(1.0)
871
+ except subprocess.TimeoutExpired:
872
+ last_err = "mount.exe command timed out"
873
+ cleanup_stuck_windows_mount_processes()
874
+ except Exception as e:
875
+ last_err = str(e)
876
+
877
+ if attempt < max_attempts:
878
+ # Check if daemon needs recovery
879
+ if not is_nfs_daemon_running():
880
+ subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True)
881
+ wait_for_nfs_portmapper("127.0.0.1", 111, 3.0)
882
+ time.sleep(1.0 + (attempt * 0.5))
883
+
884
+ return False, last_err
885
+
886
+
725
887
  def enable_windows_nfs_features():
726
888
  """Automatically check and enable Windows Client for NFS features, registry key, and Task Scheduler gateway if needed."""
727
889
  import subprocess
@@ -860,13 +1022,16 @@ def enable_windows_nfs_features():
860
1022
 
861
1023
  print("[bold green][OK] Elevated administrative setup completed successfully![/bold green]")
862
1024
 
863
- # Reboot advisory for registry keys or NFS features
864
- if not reg_ok or not nfs_ok:
865
- print("")
866
- print("[bold yellow][WARNING] IMPORTANT: A Windows system restart is required for the new NFS Client features and registry policies to take effect.[/bold yellow]")
867
- print("[bold yellow]Please restart your computer, then run 'accli mount start' again to mount your drive passwordlessly![/bold yellow]")
868
- print("")
869
- raise typer.Exit(0)
1025
+ # Verify if kernel driver is loaded; if active, restart is NOT required and we continue mounting
1026
+ if is_nfs_kernel_driver_active():
1027
+ print("[bold green][OK] Windows NFS kernel redirector is active. Continuing with mount...[/bold green]")
1028
+ return
1029
+
1030
+ print("")
1031
+ print("[bold yellow][WARNING] IMPORTANT: A Windows system restart is required for the new NFS Client features and registry policies to take effect.[/bold yellow]")
1032
+ print("[bold yellow]Please restart your computer, then run 'accli mount start' again to mount your drive passwordlessly![/bold yellow]")
1033
+ print("")
1034
+ raise typer.Exit(0)
870
1035
 
871
1036
  except Exception as e:
872
1037
  if isinstance(e, typer.Exit):
@@ -909,7 +1074,12 @@ def mount_start(
909
1074
  try:
910
1075
  token = get_token()
911
1076
  server_url = get_server_url()
1077
+ if not token:
1078
+ print("[bold red]ERROR: No token found. Please run 'accli login' first.[/bold red]")
1079
+ raise typer.Exit(1)
912
1080
  except Exception as e:
1081
+ if isinstance(e, typer.Exit):
1082
+ raise e
913
1083
  print(f"[bold red]ERROR: Could not retrieve login details. Please run 'accli login' first. Details: {e}[/bold red]")
914
1084
  raise typer.Exit(1)
915
1085
 
@@ -965,8 +1135,8 @@ def mount_start(
965
1135
  print(f"[bold red]ERROR: {e}[/bold red]")
966
1136
  raise typer.Exit(1)
967
1137
  else:
968
- mount_point_str = str(mount_point)
969
- if re.match(r"^[a-zA-Z]:?\\?$", mount_point_str) or re.match(r"^[a-zA-Z]:/?$", mount_point_str):
1138
+ mount_point_str = str(mount_point).strip()
1139
+ if re.match(r"^[a-zA-Z]:?[\\/]*$", mount_point_str):
970
1140
  drive_letter = mount_point_str[0].upper()
971
1141
  if os.path.exists(f"{drive_letter}:\\"):
972
1142
  print(f"[yellow]Warning: Drive '{drive_letter}:' is already in use.[/yellow]")
@@ -1106,30 +1276,21 @@ def mount_start(
1106
1276
  run_res = subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True, text=True)
1107
1277
  if run_res.returncode == 0:
1108
1278
  print("[cyan]Waiting for elevated NFS daemon to initialize...[/cyan]")
1109
- time.sleep(2.0)
1279
+ if not ensure_nfs_daemon_stable(15.0):
1280
+ print("[bold red][ERROR] Timed out waiting for NFS daemon to bind to 127.0.0.1:111.[/bold red]")
1281
+ raise typer.Exit(1)
1110
1282
 
1111
1283
  # Map the network drive in the CURRENT user session (so it is visible in Explorer)
1112
- mount_cmd = [
1113
- "C:\\Windows\\System32\\mount.exe",
1114
- "-o", "mtype=hard",
1115
- "-o", "timeout=10",
1116
- "-o", "rsize=32",
1117
- "-o", "wsize=32",
1118
- "-o", "anon",
1119
- "-o", "nolock",
1120
- "\\\\127.0.0.1\\!",
1121
- str(mount_point_abs)
1122
- ]
1123
- mount_res = subprocess.run(mount_cmd, capture_output=True, text=True)
1124
- if mount_res.returncode == 0:
1284
+ success, err_msg = map_windows_network_drive(mount_point_abs, 5)
1285
+ if success:
1125
1286
  start_nfs_watcher(str(mount_point_abs), interval=30)
1126
1287
  print(f"[bold green][OK] NFS mount successfully mapped at [white]{mount_point_abs}[/white]![/bold green]")
1127
1288
  print("[cyan]Use 'umount' or 'accli mount stop' to unmount the drive.[/cyan]")
1128
1289
  return
1129
1290
  else:
1130
1291
  print(f"[bold red][ERROR] NFS daemon started, but failed to map drive {mount_point_abs} in user session.[/bold red]")
1131
- if mount_res.stderr:
1132
- print(f"[red]{mount_res.stderr.strip()}[/red]")
1292
+ if err_msg:
1293
+ print(f"[red]{err_msg}[/red]")
1133
1294
  print("[yellow]Tip: Make sure Windows NFS client feature was fully enabled and the PC was restarted.[/yellow]")
1134
1295
  raise typer.Exit(1)
1135
1296
  else:
@@ -1208,29 +1369,20 @@ def mount_start(
1208
1369
  raise typer.Exit(1)
1209
1370
 
1210
1371
  print("[cyan]Waiting for elevated NFS daemon to initialize...[/cyan]")
1211
- time.sleep(2.0)
1372
+ if not wait_for_nfs_portmapper("127.0.0.1", 111, 15.0):
1373
+ print("[bold red][ERROR] Timed out waiting for NFS daemon to bind to 127.0.0.1:111.[/bold red]")
1374
+ raise typer.Exit(1)
1212
1375
 
1213
1376
  # Map the network drive in the CURRENT user session (so it is visible in Explorer)
1214
- mount_cmd = [
1215
- "C:\\Windows\\System32\\mount.exe",
1216
- "-o", "mtype=hard",
1217
- "-o", "timeout=10",
1218
- "-o", "rsize=32",
1219
- "-o", "wsize=32",
1220
- "-o", "anon",
1221
- "-o", "nolock",
1222
- "\\\\127.0.0.1\\!",
1223
- str(mount_point_abs)
1224
- ]
1225
- mount_res = subprocess.run(mount_cmd, capture_output=True, text=True)
1226
- if mount_res.returncode == 0:
1377
+ success, err_msg = map_windows_network_drive(mount_point_abs, 5)
1378
+ if success:
1227
1379
  start_nfs_watcher(str(mount_point_abs), interval=30)
1228
1380
  print(f"[bold green][OK] NFS mount successfully mapped at [white]{mount_point_abs}[/white]![/bold green]")
1229
1381
  print("[cyan]Use 'umount' or 'accli mount stop' to unmount the drive.[/cyan]")
1230
1382
  else:
1231
1383
  print(f"[bold red][ERROR] NFS daemon spawned elevated, but failed to map drive {mount_point_abs} in user session.[/bold red]")
1232
- if mount_res.stderr:
1233
- print(f"[red]{mount_res.stderr.strip()}[/red]")
1384
+ if err_msg:
1385
+ print(f"[red]{err_msg}[/red]")
1234
1386
  raise typer.Exit(1)
1235
1387
  except Exception as e:
1236
1388
  if isinstance(e, typer.Exit):
@@ -1349,10 +1501,17 @@ def mount_stop(
1349
1501
  mount_point_abs = Path.home() / "accelerator" / "mnt" / project_slug
1350
1502
  print(f"[cyan]No mount point specified. Selected default directory: [bold white]{mount_point_abs}[/bold white][/cyan]")
1351
1503
  else:
1352
- mount_point_abs = mount_point.resolve()
1504
+ mount_point_str = str(mount_point).strip()
1505
+ if sys_name == "Windows" and re.match(r"^[a-zA-Z]:?[\\/]*$", mount_point_str):
1506
+ mount_point_abs = Path(f"{mount_point_str[0].upper()}:")
1507
+ else:
1508
+ mount_point_abs = mount_point.resolve()
1353
1509
 
1354
1510
  if sys_name == "Windows":
1355
- print(f"[bold cyan]Stopping mount at '[white]{mount_point_abs}[/white]' on Windows...[/bold cyan]")
1511
+ target_str = str(mount_point_abs).rstrip("\\/")
1512
+ if re.match(r"^[a-zA-Z]$", target_str):
1513
+ target_str += ":"
1514
+ print(f"[bold cyan]Stopping mount at '[white]{target_str}[/white]' on Windows...[/bold cyan]")
1356
1515
  import subprocess
1357
1516
 
1358
1517
  # Determine if current user is admin
@@ -1363,13 +1522,16 @@ def mount_stop(
1363
1522
  is_admin = False
1364
1523
 
1365
1524
  # 0. Stop background NFS watcher
1366
- stop_nfs_watcher(str(mount_point_abs))
1525
+ stop_nfs_watcher(target_str)
1367
1526
 
1368
- # 1. Unmap the network drive in the user session
1369
- res = subprocess.run(["C:\\Windows\\System32\\umount.exe", "-f", str(mount_point_abs)], capture_output=True, text=True)
1527
+ # 1. Unmap the network drive in the user session using net use
1528
+ # (Avoids sending RPC UMNT packet via umount.exe which causes kernel NFS redirector to queue stale UMNTs)
1529
+ res = subprocess.run(["net", "use", target_str, "/delete", "/y"], capture_output=True, text=True)
1370
1530
  if res.returncode != 0:
1371
- # Fallback to force-releasing using net use
1372
- subprocess.run(["net", "use", str(mount_point_abs), "/delete", "/y"], capture_output=True)
1531
+ res = subprocess.run(["C:\\Windows\\System32\\umount.exe", "-f", target_str], capture_output=True, text=True)
1532
+ subprocess.run(["net", "use", "\\\\127.0.0.1\\!", "/delete", "/y"], capture_output=True)
1533
+
1534
+ cleanup_stuck_windows_mount_processes()
1373
1535
 
1374
1536
  # 2. Terminate the background daemon
1375
1537
  has_umount_task = False
@@ -1390,6 +1552,18 @@ def mount_stop(
1390
1552
  task_check = subprocess.run(["tasklist", "/FI", "IMAGENAME eq hf-mount-nfs.exe"], capture_output=True, text=True)
1391
1553
  if "hf-mount-nfs.exe" not in task_check.stdout:
1392
1554
  break
1555
+ cleanup_stuck_windows_mount_processes()
1556
+ for _ in range(10):
1557
+ sched_check = subprocess.run(["schtasks", "/query", "/tn", "accli-mount-nfs"], capture_output=True, text=True)
1558
+ if "Running" not in sched_check.stdout:
1559
+ break
1560
+ time.sleep(0.5)
1561
+
1562
+ # Wait until drive letter is released from Windows namespace
1563
+ for _ in range(10):
1564
+ if not os.path.exists(f"{target_str}\\"):
1565
+ break
1566
+ time.sleep(0.3)
1393
1567
  else:
1394
1568
  # If admin or task scheduler gateway is not registered, stop/kill directly
1395
1569
  if is_admin:
@@ -1425,6 +1599,7 @@ def mount_stop(
1425
1599
 
1426
1600
  if res.returncode == 0 or "successfully" in res.stdout or "successfully" in res.stderr:
1427
1601
  print("[bold green][OK] Mount stopped successfully.[/bold green]")
1602
+ print("[cyan]Note: If mounting again, please allow a few seconds for the Windows NFS network cache to clear.[/cyan]")
1428
1603
  else:
1429
1604
  print("[yellow]Attempted to unmount. Please verify drive status manually using 'umount'.[/yellow]")
1430
1605
  if res.stderr:
@@ -88,7 +88,11 @@ class AccliGuiApp(tk.Tk):
88
88
 
89
89
  # Checkboxes
90
90
  self.style.configure("TCheckbutton", background="#1e1e1e", foreground="#ffffff")
91
- self.style.map("TCheckbutton", background=[("active", "#1e1e1e")])
91
+ self.style.map("TCheckbutton",
92
+ background=[("active", "#1e1e1e")],
93
+ indicatorbackground=[("selected", "#007acc"), ("!selected", "#2d2d2d")],
94
+ indicatorforeground=[("selected", "#ffffff"), ("!selected", "#2d2d2d")]
95
+ )
92
96
 
93
97
  def create_widgets(self):
94
98
  # Top banner
@@ -122,7 +126,7 @@ class AccliGuiApp(tk.Tk):
122
126
  cmd = [sys.executable] + args
123
127
  else:
124
128
  # Script mode
125
- cmd = [sys.executable, "-m", "accli.cli"] + args
129
+ cmd = [sys.executable, "-m", "accli"] + args
126
130
 
127
131
  if elevate and platform.system() != "Windows":
128
132
  import shutil
@@ -162,11 +166,14 @@ class AccliGuiApp(tk.Tk):
162
166
  process.wait()
163
167
 
164
168
  if on_done:
165
- self.after(0, lambda: on_done(process.returncode, "".join(output_lines)))
169
+ ret_code = process.returncode
170
+ out_text = "".join(output_lines)
171
+ self.after(0, lambda: on_done(ret_code, out_text))
166
172
  except Exception as e:
167
- self.append_log(f"ERROR executing command: {e}\n")
173
+ err_text = str(e)
174
+ self.append_log(f"ERROR executing command: {err_text}\n")
168
175
  if on_done:
169
- self.after(0, lambda: on_done(-1, str(e)))
176
+ self.after(0, lambda: on_done(-1, err_text))
170
177
 
171
178
  threading.Thread(target=worker, daemon=True).start()
172
179
 
@@ -196,10 +203,26 @@ class AccliGuiApp(tk.Tk):
196
203
  self.login_btn = ttk.Button(card, text="Authenticate / Login", command=self.action_login)
197
204
  self.login_btn.grid(row=0, column=2, padx=10, pady=5)
198
205
 
206
+ self.debug_mode_val = tk.BooleanVar(value=bool(os.environ.get("ACCLI_DEBUG")))
207
+ self.debug_check = ttk.Checkbutton(
208
+ card,
209
+ text="Debug mode",
210
+ variable=self.debug_mode_val,
211
+ command=self.on_debug_toggle
212
+ )
213
+ self.debug_check.grid(row=1, column=0, columnspan=3, sticky=tk.W, pady=(5, 0))
214
+
199
215
  # Connection status details
200
216
  self.conn_details_txt = tk.Text(auth_tab, height=12, bg="#2d2d2d", fg="#ffffff", insertbackground="white", font=("Courier New", 10), state=tk.DISABLED)
201
217
  self.conn_details_txt.pack(fill=tk.BOTH, expand=True, pady=10)
202
218
 
219
+ def on_debug_toggle(self):
220
+ if self.debug_mode_val.get():
221
+ os.environ["ACCLI_DEBUG"] = "1"
222
+ else:
223
+ os.environ.pop("ACCLI_DEBUG", None)
224
+ self.refresh_login_status()
225
+
203
226
  def refresh_login_status(self):
204
227
  from accli.token import get_db_path
205
228
  from tinydb import TinyDB
@@ -229,13 +252,16 @@ class AccliGuiApp(tk.Tk):
229
252
  self.server_entry.configure(state=tk.NORMAL)
230
253
  self.status_bar_val.set("Session Status: Disconnected")
231
254
 
232
- def on_check_done(code, output):
233
- self.conn_details_txt.configure(state=tk.NORMAL)
234
- self.conn_details_txt.delete("1.0", tk.END)
235
- self.conn_details_txt.insert(tk.END, output)
236
- self.conn_details_txt.configure(state=tk.DISABLED)
237
-
238
- self.run_cli_async(["status"], on_done=on_check_done, log_to_viewer=False)
255
+ details = [
256
+ f"Server URL: {server_url}",
257
+ f"Session Status: {'Connected / Logged In' if logged_in else 'Not Logged In'}",
258
+ f"Token DB Path: {db_path}",
259
+ f"Debug Mode: {'Enabled ($env:ACCLI_DEBUG=\"1\")' if os.environ.get('ACCLI_DEBUG') else 'Disabled'}"
260
+ ]
261
+ self.conn_details_txt.configure(state=tk.NORMAL)
262
+ self.conn_details_txt.delete("1.0", tk.END)
263
+ self.conn_details_txt.insert(tk.END, "\n".join(details) + "\n")
264
+ self.conn_details_txt.configure(state=tk.DISABLED)
239
265
 
240
266
  def action_login(self):
241
267
  if hasattr(self, "is_logged_in_state") and self.is_logged_in_state:
@@ -288,7 +314,7 @@ class AccliGuiApp(tk.Tk):
288
314
  response = requests.post(
289
315
  token_endpoint,
290
316
  json={"device_authorization_code": auth_code.strip()},
291
- verify=True
317
+ verify=(not bool(os.environ.get("ACCLI_DEBUG")))
292
318
  )
293
319
 
294
320
  if response.status_code == 400:
@@ -306,8 +332,9 @@ class AccliGuiApp(tk.Tk):
306
332
  self.after(0, lambda: messagebox.showinfo("Success", "Successfully logged in!"))
307
333
  self.after(0, self.refresh_login_status)
308
334
  except Exception as e:
309
- self.append_log(f"Authentication Failed: {e}\n")
310
- self.after(0, lambda: messagebox.showerror("Error", f"Authentication failed: {e}"))
335
+ err_text = str(e)
336
+ self.append_log(f"Authentication Failed: {err_text}\n")
337
+ self.after(0, lambda msg=err_text: messagebox.showerror("Error", f"Authentication failed: {msg}"))
311
338
  self.after(0, self.refresh_login_status)
312
339
 
313
340
  threading.Thread(target=perform_auth, daemon=True).start()
@@ -422,6 +449,9 @@ class AccliGuiApp(tk.Tk):
422
449
 
423
450
  mount_btn = ttk.Button(btn_frame, text="Start Mount", command=self.action_mount_start)
424
451
  mount_btn.pack(side=tk.LEFT, padx=5)
452
+
453
+ stop_form_btn = ttk.Button(btn_frame, text="Stop Mount", style="Stop.TButton", command=self.action_mount_stop)
454
+ stop_form_btn.pack(side=tk.LEFT, padx=5)
425
455
 
426
456
  # Right Panel (List of mounts)
427
457
  list_frame = ttk.LabelFrame(mounts_tab, text="Active Mounts", padding=10)
@@ -501,20 +531,33 @@ class AccliGuiApp(tk.Tk):
501
531
  self.run_cli_async(args, on_done=on_done, elevate=True)
502
532
 
503
533
  def action_mount_stop(self):
504
- selected = self.mounts_list.get(tk.ACTIVE)
505
- if not selected:
506
- messagebox.showwarning("Warning", "Please select a mount to stop from the list.")
507
- return
508
-
509
- # Parse the mount point from the status line
510
- # E.g. "W: \\\\127.0.0.1\\!" -> "W:"
511
- # Or parse folder paths
512
- parts = selected.split()
513
- if not parts:
514
- return
515
-
516
- mount_point = parts[0]
517
- args = ["mount", "stop", mount_point]
534
+ # 1. Check if user selected an item from the active mounts list
535
+ mount_point = ""
536
+ selected_indices = self.mounts_list.curselection()
537
+ if selected_indices:
538
+ selected = self.mounts_list.get(selected_indices[0])
539
+ parts = selected.split()
540
+ if parts:
541
+ mount_point = parts[0]
542
+ elif self.mounts_list.size() > 0:
543
+ selected = self.mounts_list.get(tk.ACTIVE)
544
+ if selected:
545
+ parts = selected.split()
546
+ if parts:
547
+ mount_point = parts[0]
548
+
549
+ # 2. If nothing selected or list is empty, fallback to the Mount Point / Drive Letter input field
550
+ if not mount_point:
551
+ entered_point = self.mount_point_val.get().strip()
552
+ if entered_point:
553
+ mount_point = entered_point
554
+
555
+ args = ["mount", "stop"]
556
+ if mount_point:
557
+ args.append(mount_point)
558
+ target_desc = mount_point
559
+ else:
560
+ target_desc = "active mount"
518
561
 
519
562
  self.status_bar_val.set("Stopping mount...")
520
563
  self.notebook.select(self.log_tab_idx)
@@ -523,10 +566,10 @@ class AccliGuiApp(tk.Tk):
523
566
  self.refresh_mounts_list()
524
567
  if code == 0:
525
568
  self.status_bar_val.set("Mount Stopped")
526
- messagebox.showinfo("Success", f"Successfully stopped mount at {mount_point}.")
569
+ messagebox.showinfo("Success", f"Successfully stopped mount ({target_desc}).")
527
570
  else:
528
571
  self.status_bar_val.set("Stop Failed")
529
- messagebox.showerror("Error", f"Failed to stop mount at {mount_point}.")
572
+ messagebox.showerror("Error", f"Failed to stop mount ({target_desc}). See logs for details.")
530
573
 
531
574
  self.run_cli_async(args, on_done=on_done, elevate=True)
532
575
 
@@ -3,6 +3,7 @@ import threading
3
3
  import time
4
4
  import requests
5
5
  import typer
6
+ from rich import print
6
7
  from tinydb import TinyDB
7
8
 
8
9
  ACCLI_DEBUG = os.environ.get('ACCLI_DEBUG', False)
@@ -138,6 +139,13 @@ def exchange_refresh_token(project_slug: str) -> tuple[str, str, int]:
138
139
  _cached_expires_at = expires_at
139
140
 
140
141
  return cas_token, access_token, expires_at
142
+ except requests.exceptions.HTTPError as e:
143
+ if e.response is not None and e.response.status_code in (400, 401, 403):
144
+ print("[bold red]ERROR: Your session has expired or the token is invalid.[/bold red]")
145
+ print("[yellow]Please run 'accli login' to authenticate again.[/yellow]")
146
+ raise typer.Exit(1)
147
+ print(f"[bold red]ERROR: Failed to authenticate/exchange refresh token: {e}[/bold red]")
148
+ raise typer.Exit(1)
141
149
  except Exception as e:
142
150
  print(f"[bold red]ERROR: Failed to authenticate/exchange refresh token: {e}[/bold red]")
143
151
  raise typer.Exit(1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: accli
3
- Version: 2.0.7
3
+ Version: 2.0.13
4
4
  Summary: IIASA Accelerator Client
5
5
  Author-email: Wrufesh S <wrufesh@gmail.com>
6
6
  License: The MIT License (MIT)
@@ -22,4 +22,5 @@ accli.egg-info/SOURCES.txt
22
22
  accli.egg-info/dependency_links.txt
23
23
  accli.egg-info/entry_points.txt
24
24
  accli.egg-info/requires.txt
25
- accli.egg-info/top_level.txt
25
+ accli.egg-info/top_level.txt
26
+ tests/test_mount_lifecycle.py
@@ -0,0 +1,138 @@
1
+ """
2
+ Test for mounting lifecycle:
3
+ 1. Ensure clean initial state (mount stop)
4
+ 2. accli mount start <project_slug>
5
+ 3. Capture directory structure (ensure not empty) & read any file
6
+ 4. accli mount stop
7
+ 5. accli mount start <project_slug> again
8
+ 6. Capture directory structure (must not be empty) & read any file
9
+ """
10
+ import os
11
+ import sys
12
+ import time
13
+ import platform
14
+ import subprocess
15
+ import unittest
16
+ from pathlib import Path
17
+
18
+
19
+ class TestMountLifecycle(unittest.TestCase):
20
+ @classmethod
21
+ def setUpClass(cls):
22
+ cls.project_slug = os.environ.get("ACCLI_TEST_PROJECT", "test")
23
+ cls.mount_drive = os.environ.get("ACCLI_TEST_DRIVE", "W:")
24
+ cls.mount_arg = cls.mount_drive.rstrip("\\/")
25
+ if platform.system() == "Windows" and len(cls.mount_arg) == 1 and cls.mount_arg.isalpha():
26
+ cls.mount_arg += ":"
27
+ cls.mount_path = Path(f"{cls.mount_arg}\\") if platform.system() == "Windows" else Path.home() / "accelerator" / "mnt" / cls.project_slug
28
+
29
+ def run_cli(self, args: list, timeout: int):
30
+ cmd = [sys.executable, "-m", "accli"] + args
31
+ print(f"\n[TEST EXEC] {' '.join(cmd)}")
32
+ result = subprocess.run(
33
+ cmd,
34
+ stdin=subprocess.DEVNULL,
35
+ capture_output=True,
36
+ text=True,
37
+ timeout=timeout
38
+ )
39
+ print(f"[EXIT CODE] {result.returncode}")
40
+ if result.stdout:
41
+ print(f"[STDOUT]\n{result.stdout.strip()}")
42
+ if result.stderr:
43
+ print(f"[STDERR]\n{result.stderr.strip()}")
44
+ return result
45
+
46
+ def get_structure(self, target_path: Path):
47
+ """List files and folders at the mounted location and ensure it is not empty."""
48
+ entries = []
49
+ for _ in range(10):
50
+ try:
51
+ if target_path.exists():
52
+ entries = [p.name for p in target_path.iterdir()]
53
+ if entries:
54
+ break
55
+ except OSError:
56
+ pass
57
+ time.sleep(0.5)
58
+
59
+ self.assertTrue(
60
+ target_path.exists(),
61
+ f"Mount path '{target_path}' does not exist or drive is not mapped."
62
+ )
63
+ print(f"[MOUNT STRUCTURE at {target_path}]: {entries}")
64
+ return entries
65
+
66
+ def read_any_file(self, target_path: Path):
67
+ """Locate any readable file under the mounted project path and read its content."""
68
+ search_dirs = [target_path / self.project_slug, target_path]
69
+ for sdir in search_dirs:
70
+ if not sdir.exists():
71
+ continue
72
+ for root, dirs, files in os.walk(sdir):
73
+ for file_name in files:
74
+ candidate = Path(root) / file_name
75
+ try:
76
+ print(f"[TEST READ FILE] Attempting to read: {candidate}")
77
+ with open(candidate, "rb") as fp:
78
+ data = fp.read(1024)
79
+ print(f"[TEST READ FILE] Successfully read {len(data)} bytes from '{candidate.name}'.")
80
+ return candidate, len(data)
81
+ except Exception as e:
82
+ print(f"[TEST READ FILE] Skipping unreadable candidate {candidate}: {e}")
83
+
84
+ self.fail(f"Could not read any file under mounted path '{target_path}'.")
85
+
86
+ def tearDown(self):
87
+ try:
88
+ self.run_cli(["mount", "stop", self.mount_arg], 30)
89
+ except Exception:
90
+ pass
91
+
92
+ def test_mount_start_stop_start_lifecycle(self):
93
+ # Step 0: Ensure clean initial state
94
+ print("\n--- Step 0: Ensure clean initial state ---")
95
+ self.run_cli(["mount", "stop", self.mount_arg], 30)
96
+ time.sleep(2)
97
+
98
+ # Step 1: First mount start
99
+ print(f"\n--- Step 1: Starting mount for '{self.project_slug}' at '{self.mount_arg}' ---")
100
+ start1 = self.run_cli(["mount", "start", self.project_slug, self.mount_arg], 60)
101
+ self.assertEqual(start1.returncode, 0, f"First mount start failed: {start1.stderr or start1.stdout}")
102
+
103
+ # Step 2: Check structure is not empty and try to read any file
104
+ print("\n--- Step 2: Checking structure & reading file after 1st mount ---")
105
+ structure1 = self.get_structure(self.mount_path)
106
+ self.assertGreater(
107
+ len(structure1), 0,
108
+ f"Expected structure to be not empty after 1st mount, got: {structure1}"
109
+ )
110
+ file1, bytes_read1 = self.read_any_file(self.mount_path)
111
+ self.assertIsNotNone(file1)
112
+
113
+ # Step 3: Mount stop (clean)
114
+ print(f"\n--- Step 3: Stopping mount at '{self.mount_arg}' ---")
115
+ stop = self.run_cli(["mount", "stop", self.mount_arg], 30)
116
+ self.assertEqual(stop.returncode, 0, f"Mount stop failed: {stop.stderr or stop.stdout}")
117
+ time.sleep(3)
118
+
119
+ # Step 4: Second mount start
120
+ print(f"\n--- Step 4: Starting mount for '{self.project_slug}' at '{self.mount_arg}' (2nd time) ---")
121
+ start2 = self.run_cli(["mount", "start", self.project_slug, self.mount_arg], 120)
122
+ self.assertEqual(start2.returncode, 0, f"Second mount start failed: {start2.stderr or start2.stdout}")
123
+
124
+ # Step 5: Check structure is not empty and try to read any file after clean & remount
125
+ print("\n--- Step 5: Checking structure & reading file after 2nd mount ---")
126
+ structure2 = self.get_structure(self.mount_path)
127
+ self.assertGreater(
128
+ len(structure2), 0,
129
+ f"Expected structure to be not empty after 2nd mount, got: {structure2}"
130
+ )
131
+ file2, bytes_read2 = self.read_any_file(self.mount_path)
132
+ self.assertIsNotNone(file2)
133
+
134
+ print("\n[SUCCESS] Mount start -> read -> stop -> start -> read lifecycle test completed successfully!")
135
+
136
+
137
+ if __name__ == "__main__":
138
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes