accli 2.0.5__tar.gz → 2.0.7__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 (29) hide show
  1. {accli-2.0.5 → accli-2.0.7}/PKG-INFO +1 -1
  2. {accli-2.0.5 → accli-2.0.7}/accli/AcceleratorTaskDispatcher.py +38 -7
  3. {accli-2.0.5 → accli-2.0.7}/accli/_version.py +1 -1
  4. {accli-2.0.5 → accli-2.0.7}/accli/cli.py +104 -49
  5. accli-2.0.7/accli/nfs_watcher.py +224 -0
  6. accli-2.0.7/accli/refresh_explorer.ps1 +60 -0
  7. {accli-2.0.5 → accli-2.0.7}/accli.egg-info/PKG-INFO +1 -1
  8. {accli-2.0.5 → accli-2.0.7}/accli.egg-info/SOURCES.txt +1 -1
  9. accli-2.0.5/accli/refresh_explorer.ps1 +0 -12
  10. accli-2.0.5/accli/windows_refresher.py +0 -159
  11. {accli-2.0.5 → accli-2.0.7}/LICENSE +0 -0
  12. {accli-2.0.5 → accli-2.0.7}/README.md +0 -0
  13. {accli-2.0.5 → accli-2.0.7}/accli/AcceleratorJobProjectService.py +0 -0
  14. {accli-2.0.5 → accli-2.0.7}/accli/AcceleratorTerminalCliProjectService.py +0 -0
  15. {accli-2.0.5 → accli-2.0.7}/accli/CsvRegionalTimeseriesValidator.py +0 -0
  16. {accli-2.0.5 → accli-2.0.7}/accli/__init__.py +0 -0
  17. {accli-2.0.5 → accli-2.0.7}/accli/__main__.py +0 -0
  18. {accli-2.0.5 → accli-2.0.7}/accli/cleanup-nfs.ps1 +0 -0
  19. {accli-2.0.5 → accli-2.0.7}/accli/common.py +0 -0
  20. {accli-2.0.5 → accli-2.0.7}/accli/gui.py +0 -0
  21. {accli-2.0.5 → accli-2.0.7}/accli/mount_downloader.py +0 -0
  22. {accli-2.0.5 → accli-2.0.7}/accli/setup-nfs.ps1 +0 -0
  23. {accli-2.0.5 → accli-2.0.7}/accli/token.py +0 -0
  24. {accli-2.0.5 → accli-2.0.7}/accli.egg-info/dependency_links.txt +0 -0
  25. {accli-2.0.5 → accli-2.0.7}/accli.egg-info/entry_points.txt +0 -0
  26. {accli-2.0.5 → accli-2.0.7}/accli.egg-info/requires.txt +0 -0
  27. {accli-2.0.5 → accli-2.0.7}/accli.egg-info/top_level.txt +0 -0
  28. {accli-2.0.5 → accli-2.0.7}/pyproject.toml +0 -0
  29. {accli-2.0.5 → accli-2.0.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: accli
3
- Version: 2.0.5
3
+ Version: 2.0.7
4
4
  Summary: IIASA Accelerator Client
5
5
  Author-email: Wrufesh S <wrufesh@gmail.com>
6
6
  License: The MIT License (MIT)
@@ -3,11 +3,12 @@ import zipfile
3
3
  import hashlib
4
4
  import shutil
5
5
  import tempfile
6
+ import fnmatch
6
7
  from typing import Optional, Dict, List
7
8
 
8
9
  import requests
9
10
  from functools import lru_cache
10
- from pydantic import BaseModel, model_validator
11
+ from pydantic import BaseModel, model_validator, Field
11
12
  from accli.AcceleratorTerminalCliProjectService import AcceleratorTerminalCliProjectService
12
13
  from accli.token import (
13
14
  get_token, get_server_url,
@@ -48,23 +49,38 @@ def get_file_sha1(file_path):
48
49
  return sha1_hash.hexdigest()
49
50
 
50
51
 
51
- def copy_tree(src, dst):
52
+ def copy_tree(src, dst, include=None, exclude=None, root_src=None):
52
53
  """Recursively copy from src to dst, excluding .git folders."""
54
+ if root_src is None:
55
+ root_src = src
53
56
  for item in os.listdir(src):
54
57
  src_path = os.path.join(src, item)
55
58
  dst_path = os.path.join(dst, item)
59
+ rel_path = os.path.relpath(src_path, root_src)
56
60
 
57
61
  if os.path.isdir(src_path):
58
62
  if item == '.git':
59
63
  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
60
68
  os.makedirs(dst_path, exist_ok=True)
61
- copy_tree(src_path, dst_path)
69
+ copy_tree(src_path, dst_path, include, exclude, root_src)
62
70
  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
63
79
  shutil.copy2(src_path, dst_path)
64
80
 
65
81
 
66
82
  @lru_cache(maxsize=None)
67
- def push_folder_job(directory):
83
+ def push_folder_job(directory, include=None, exclude=None):
68
84
  server_url = get_server_url()
69
85
  project_slug = get_project_slug()
70
86
  _, access_token, _ = exchange_refresh_token(project_slug)
@@ -76,7 +92,7 @@ def push_folder_job(directory):
76
92
  )
77
93
 
78
94
  repo_dir = tempfile.mkdtemp()
79
- copy_tree(directory, repo_dir)
95
+ copy_tree(directory, repo_dir, include, exclude)
80
96
 
81
97
  if os.path.isfile(f'{repo_dir}/wkube.py'):
82
98
  os.remove(f'{repo_dir}/wkube.py')
@@ -152,7 +168,9 @@ class WKubeTaskMeta(BaseModel):
152
168
  class WKubeTaskKwargs(BaseModel):
153
169
  docker_image: Optional[str] = None
154
170
 
155
- job_folder: str = './'
171
+ job_folder: str = Field('./', description="Path to the directory containing job files. Must be a valid existing directory.")
172
+ include: Optional[str] = Field(None, description="Comma-separated wildcard patterns to include (e.g. '*.py, data/*')")
173
+ exclude: Optional[str] = Field(None, description="Comma-separated wildcard patterns to exclude (e.g. '*.txt, .venv/*')")
156
174
 
157
175
  repo_url: Optional[str] = None # required when docker image is not present
158
176
  repo_branch: Optional[str] = None # required when docker image is not present
@@ -172,6 +190,10 @@ class WKubeTaskKwargs(BaseModel):
172
190
  result = super().model_dump(*args, **kwargs)
173
191
  if 'job_folder' in result:
174
192
  del result['job_folder']
193
+ if 'include' in result:
194
+ del result['include']
195
+ if 'exclude' in result:
196
+ del result['exclude']
175
197
  return result
176
198
 
177
199
  @model_validator(mode="before")
@@ -179,10 +201,19 @@ class WKubeTaskKwargs(BaseModel):
179
201
  def validate_root(cls, values):
180
202
  if not values.get('docker_image'):
181
203
  job_folder = values.get('job_folder', './')
204
+ include = values.get('include')
205
+ exclude = values.get('exclude')
206
+
207
+ if not os.path.isdir(job_folder):
208
+ raise ValueError(
209
+ f"Invalid job_folder: '{job_folder}' does not exist or is not a directory. "
210
+ f"Help: job_folder must be a valid path. You can also use 'include' and 'exclude' "
211
+ f"with comma-separated wildcards (e.g., include='*.py, data/*', exclude='*.txt, .venv/*')."
212
+ )
182
213
 
183
214
  if not (values.get('repo_url') and values.get('repo_branch')):
184
215
  remote_url, branch_name = push_folder_job(
185
- os.path.abspath(job_folder)
216
+ os.path.abspath(job_folder), include, exclude
186
217
  )
187
218
  values['repo_url'] = remote_url
188
219
  values['repo_branch'] = branch_name
@@ -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.5"
3
+ VERSION = "v2.0.7"
@@ -2,6 +2,7 @@ import glob
2
2
  import importlib.util
3
3
  import os
4
4
  import re
5
+ import time
5
6
  import warnings
6
7
  import hashlib
7
8
  import json
@@ -588,7 +589,7 @@ mount_app = typer.Typer(
588
589
  app.add_typer(mount_app, name="mount")
589
590
 
590
591
 
591
- def find_available_windows_drive(preferred: str = "W") -> str:
592
+ def find_available_windows_drive(preferred: str) -> str:
592
593
  """Finds an available Windows drive letter, starting with preferred, then checking others."""
593
594
  import os
594
595
  preferred = preferred.upper()
@@ -604,14 +605,56 @@ def find_available_windows_drive(preferred: str = "W") -> str:
604
605
  raise RuntimeError("No available Windows drive letters found.")
605
606
 
606
607
 
607
- def start_windows_explorer_refresher(mount_point: str, interval: int):
608
+ def validate_project_slug(server_url: str, project_slug: str) -> None:
609
+ """
610
+ Validates that project_slug exists on the backend before launching the mount daemon.
611
+ Exchanges the stored refresh token for a cas_token, then calls
612
+ GET /api/v1/aterm-cli/{slug}/ (authenticated via XetCASAuthorizationService).
613
+ - 200 → project exists and user is authorized; proceed with mount
614
+ - 403 / 404 → project not found or no access → exit with clear message
615
+ """
616
+ import requests as _requests
617
+ from accli.token import exchange_refresh_token
618
+
619
+ try:
620
+ cas_token, _access_token, _expires = exchange_refresh_token(project_slug)
621
+ except SystemExit:
622
+ raise
623
+ except Exception as e:
624
+ print(f"[bold red]ERROR: Could not obtain access token for project '{project_slug}': {e}[/bold red]")
625
+ raise typer.Exit(1)
626
+
627
+ verify_ssl = not bool(os.environ.get("ACCLI_DEBUG"))
628
+ url = f"{server_url.rstrip('/')}/api/v1/aterm-cli/{project_slug}/"
629
+ try:
630
+ resp = _requests.get(
631
+ url,
632
+ headers={"Authorization": f"Bearer {cas_token}"},
633
+ verify=verify_ssl,
634
+ timeout=10,
635
+ )
636
+ except _requests.exceptions.RequestException as e:
637
+ print(f"[bold red]ERROR: Could not reach backend to validate project slug: {e}[/bold red]")
638
+ raise typer.Exit(1)
639
+
640
+ if resp.status_code in (403, 404):
641
+ print(f"[bold red]ERROR: Project '{project_slug}' not found or you do not have access.[/bold red]")
642
+ projects_url = f"{server_url.rstrip('/')}/projects/"
643
+ print(f"[yellow]Hint: Check the project slug in the web GUI: [link={projects_url}]{projects_url}[/link][/yellow]")
644
+ raise typer.Exit(1)
645
+ elif not resp.ok:
646
+ print(f"[bold red]ERROR: Unexpected server response ({resp.status_code}) when validating project slug.[/bold red]")
647
+ raise typer.Exit(1)
648
+
649
+
650
+ def start_nfs_watcher(mount_point: str, interval: int):
608
651
  """Starts a lightweight background watcher in the user session that notifies Explorer when remote changes occur."""
609
652
  import sys
610
653
  import subprocess
611
654
  from pathlib import Path
612
655
 
613
- refresher_script = Path(__file__).parent / "windows_refresher.py"
614
- if not refresher_script.is_file():
656
+ watcher_script = Path(__file__).parent / "nfs_watcher.py"
657
+ if not watcher_script.is_file():
615
658
  return
616
659
 
617
660
  # Locate Python interpreter (prefer pythonw to run completely windowless if available)
@@ -627,13 +670,13 @@ def start_windows_explorer_refresher(mount_point: str, interval: int):
627
670
  # DETACHED_PROCESS (0x00000008) | CREATE_NEW_PROCESS_GROUP (0x00000200) | CREATE_NO_WINDOW (0x08000000)
628
671
  creationflags = 0x08000008 | 0x00000200
629
672
  proc = subprocess.Popen(
630
- [executable, str(refresher_script), str(mount_point), str(interval)],
673
+ [executable, str(watcher_script), str(mount_point), str(interval)],
631
674
  creationflags=creationflags,
632
675
  stdout=subprocess.DEVNULL,
633
676
  stderr=subprocess.DEVNULL,
634
677
  close_fds=True
635
678
  )
636
- pid_file = Path("C:/ProgramData/accli/refresher.pid")
679
+ pid_file = Path.home() / ".accli" / "nfs_watcher.pid"
637
680
  try:
638
681
  pid_file.parent.mkdir(parents=True, exist_ok=True)
639
682
  pid_file.write_text(str(proc.pid), encoding="utf-8")
@@ -643,16 +686,33 @@ def start_windows_explorer_refresher(mount_point: str, interval: int):
643
686
  pass
644
687
 
645
688
 
646
- def stop_windows_explorer_refresher():
647
- """Stops any active background Explorer watcher process."""
689
+ def stop_nfs_watcher(mount_point: str):
690
+ """Signals and stops any active background NFS watcher process."""
691
+ import os
648
692
  import subprocess
649
693
  from pathlib import Path
694
+ import ctypes
695
+ kernel32 = ctypes.windll.kernel32
650
696
 
651
- pid_file = Path("C:/ProgramData/accli/refresher.pid")
697
+ # 1. Graceful cooperative shutdown via Windows Named Event
698
+ try:
699
+ EVENT_MODIFY_STATE = 0x0002
700
+ drive = Path(mount_point).drive.upper().rstrip(":")
701
+ evt_name = f"Local\\accli_watcher_{drive}_stop" if drive else "Local\\accli_watcher_stop"
702
+ h_evt = kernel32.OpenEventW(EVENT_MODIFY_STATE, False, evt_name)
703
+ if h_evt:
704
+ kernel32.SetEvent(h_evt)
705
+ kernel32.CloseHandle(h_evt)
706
+ time.sleep(0.1)
707
+ except Exception:
708
+ pass
709
+
710
+ # 2. Clean up user-scoped PID file
711
+ pid_file = Path.home() / ".accli" / "nfs_watcher.pid"
652
712
  if pid_file.is_file():
653
713
  try:
654
714
  pid_str = pid_file.read_text(encoding="utf-8").strip()
655
- if pid_str:
715
+ if pid_str and pid_str != str(os.getpid()):
656
716
  subprocess.run(["taskkill", "/F", "/PID", pid_str], capture_output=True)
657
717
  except Exception:
658
718
  pass
@@ -660,18 +720,6 @@ def stop_windows_explorer_refresher():
660
720
  pid_file.unlink()
661
721
  except Exception:
662
722
  pass
663
-
664
- # Also clean up any lingering windows_refresher.py processes
665
- try:
666
- cleanup_cmd = [
667
- "powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command",
668
- "Get-CimInstance Win32_Process -Filter \"name='python.exe' or name='pythonw.exe'\" | "
669
- "Where-Object { $_.CommandLine -like '*windows_refresher.py*' } | "
670
- "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
671
- ]
672
- subprocess.run(cleanup_cmd, capture_output=True)
673
- except Exception:
674
- pass
675
723
 
676
724
 
677
725
  def enable_windows_nfs_features():
@@ -873,6 +921,10 @@ def mount_start(
873
921
  print("[bold red]ERROR: Mode must be either 'bucket' or 'repo'.[/bold red]")
874
922
  raise typer.Exit(1)
875
923
 
924
+ # Validate project slug against backend before doing any heavy work
925
+ print(f"[cyan]Validating project '{project_slug}'...[/cyan]")
926
+ validate_project_slug(server_url, project_slug)
927
+
876
928
  # Ensure binaries are downloaded and cached
877
929
  try:
878
930
  mount_downloader.ensure_binaries(version=binary_version, use_fuse=fuse)
@@ -1053,20 +1105,24 @@ def mount_start(
1053
1105
 
1054
1106
  run_res = subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True, text=True)
1055
1107
  if run_res.returncode == 0:
1056
- import time
1057
1108
  print("[cyan]Waiting for elevated NFS daemon to initialize...[/cyan]")
1058
1109
  time.sleep(2.0)
1059
1110
 
1060
1111
  # Map the network drive in the CURRENT user session (so it is visible in Explorer)
1061
1112
  mount_cmd = [
1062
1113
  "C:\\Windows\\System32\\mount.exe",
1063
- "-o", "nolock,anon,mtype=hard,rsize=32,wsize=32,timeout=60",
1114
+ "-o", "mtype=hard",
1115
+ "-o", "timeout=10",
1116
+ "-o", "rsize=32",
1117
+ "-o", "wsize=32",
1118
+ "-o", "anon",
1119
+ "-o", "nolock",
1064
1120
  "\\\\127.0.0.1\\!",
1065
1121
  str(mount_point_abs)
1066
1122
  ]
1067
1123
  mount_res = subprocess.run(mount_cmd, capture_output=True, text=True)
1068
1124
  if mount_res.returncode == 0:
1069
- start_windows_explorer_refresher(str(mount_point_abs), interval=30)
1125
+ start_nfs_watcher(str(mount_point_abs), interval=30)
1070
1126
  print(f"[bold green][OK] NFS mount successfully mapped at [white]{mount_point_abs}[/white]![/bold green]")
1071
1127
  print("[cyan]Use 'umount' or 'accli mount stop' to unmount the drive.[/cyan]")
1072
1128
  return
@@ -1078,8 +1134,8 @@ def mount_start(
1078
1134
  raise typer.Exit(1)
1079
1135
  else:
1080
1136
  print("[bold red][ERROR] Failed to execute Scheduled Task 'accli-mount-nfs'.[/bold red]")
1081
- if run_res.stderr:
1082
- print(f"[red]{run_res.stderr.strip()}[/red]")
1137
+ if trigger_res.stderr:
1138
+ print(f"[red]{trigger_res.stderr.strip()}[/red]")
1083
1139
  print("[yellow]Falling back to UAC elevation...[/yellow]")
1084
1140
  has_task = False
1085
1141
  except Exception as e:
@@ -1089,30 +1145,26 @@ def mount_start(
1089
1145
  print("[yellow]Falling back to UAC elevation...[/yellow]")
1090
1146
  has_task = False
1091
1147
 
1148
+ # Fallback or standard spawn path
1092
1149
  try:
1093
1150
  if is_admin:
1094
- # Already running as Administrator - spawn decoupled background process directly
1095
- creationflags = 0x00000008
1096
1151
  process = subprocess.Popen(
1097
1152
  args,
1098
1153
  env=env,
1099
- creationflags=creationflags,
1100
- stdout=log_file,
1101
- stderr=subprocess.STDOUT,
1102
- close_fds=True
1154
+ stdout=subprocess.DEVNULL,
1155
+ stderr=subprocess.DEVNULL,
1156
+ creationflags=subprocess.CREATE_NO_WINDOW
1103
1157
  )
1104
-
1105
- # Startup verification (1-second boot poll)
1106
- import time
1158
+ print(f"[cyan]Waiting for NFS daemon to initialize...[/cyan]")
1107
1159
  time.sleep(1.0)
1160
+
1161
+ # Check if process crashed on boot
1108
1162
  if process.poll() is not None:
1109
- # Process has already terminated!
1110
- print(f"[bold red][ERROR] Failed to start Windows NFS mount process (exit code {process.returncode}).[/bold red]")
1163
+ print(f"[bold red][ERROR] NFS daemon failed to start (exit code {process.returncode}).[/bold red]")
1111
1164
  try:
1112
- if log_file_path.is_file():
1113
- with open(log_file_path, "r", encoding="utf-8") as lf:
1114
- lines = lf.readlines()
1115
- last_lines = "".join(lines[-10:])
1165
+ if Path(log_file_path).exists():
1166
+ with open(log_file_path, "r", encoding="utf-8", errors="ignore") as lf:
1167
+ last_lines = "".join(lf.readlines()[-15:])
1116
1168
  print("[red]Recent log output:[/red]")
1117
1169
  print(f"[dim]{last_lines.strip()}[/dim]")
1118
1170
  except Exception:
@@ -1120,7 +1172,7 @@ def mount_start(
1120
1172
  print(f"[yellow]Please check the log file at {log_file_path} for more details.[/yellow]")
1121
1173
  raise typer.Exit(1)
1122
1174
 
1123
- start_windows_explorer_refresher(str(mount_point_abs), interval=30)
1175
+ start_nfs_watcher(str(mount_point_abs), interval=30)
1124
1176
  print(f"[bold green][OK] NFS mount process spawned successfully (PID: {process.pid}).[/bold green]")
1125
1177
  print(f"[cyan]Use 'umount' or 'accli mount stop' to unmount the drive.[/cyan]")
1126
1178
  else:
@@ -1156,19 +1208,23 @@ def mount_start(
1156
1208
  raise typer.Exit(1)
1157
1209
 
1158
1210
  print("[cyan]Waiting for elevated NFS daemon to initialize...[/cyan]")
1159
- import time
1160
1211
  time.sleep(2.0)
1161
1212
 
1162
1213
  # Map the network drive in the CURRENT user session (so it is visible in Explorer)
1163
1214
  mount_cmd = [
1164
1215
  "C:\\Windows\\System32\\mount.exe",
1165
- "-o", "nolock,anon,mtype=hard,rsize=32,wsize=32,timeout=60",
1216
+ "-o", "mtype=hard",
1217
+ "-o", "timeout=10",
1218
+ "-o", "rsize=32",
1219
+ "-o", "wsize=32",
1220
+ "-o", "anon",
1221
+ "-o", "nolock",
1166
1222
  "\\\\127.0.0.1\\!",
1167
1223
  str(mount_point_abs)
1168
1224
  ]
1169
1225
  mount_res = subprocess.run(mount_cmd, capture_output=True, text=True)
1170
1226
  if mount_res.returncode == 0:
1171
- start_windows_explorer_refresher(str(mount_point_abs), interval=30)
1227
+ start_nfs_watcher(str(mount_point_abs), interval=30)
1172
1228
  print(f"[bold green][OK] NFS mount successfully mapped at [white]{mount_point_abs}[/white]![/bold green]")
1173
1229
  print("[cyan]Use 'umount' or 'accli mount stop' to unmount the drive.[/cyan]")
1174
1230
  else:
@@ -1306,8 +1362,8 @@ def mount_stop(
1306
1362
  except Exception:
1307
1363
  is_admin = False
1308
1364
 
1309
- # 0. Stop Explorer background refresher
1310
- stop_windows_explorer_refresher()
1365
+ # 0. Stop background NFS watcher
1366
+ stop_nfs_watcher(str(mount_point_abs))
1311
1367
 
1312
1368
  # 1. Unmap the network drive in the user session
1313
1369
  res = subprocess.run(["C:\\Windows\\System32\\umount.exe", "-f", str(mount_point_abs)], capture_output=True, text=True)
@@ -1329,7 +1385,6 @@ def mount_stop(
1329
1385
  print("[cyan]Triggering elevated NFS daemon termination via Task Scheduler...[/cyan]")
1330
1386
  subprocess.run(["schtasks", "/run", "/tn", "accli-umount-nfs"], capture_output=True)
1331
1387
  # Poll for up to 5 seconds to ensure hf-mount-nfs.exe has fully terminated
1332
- import time
1333
1388
  for _ in range(10):
1334
1389
  time.sleep(0.5)
1335
1390
  task_check = subprocess.run(["tasklist", "/FI", "IMAGENAME eq hf-mount-nfs.exe"], capture_output=True, text=True)
@@ -0,0 +1,224 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ import ctypes
5
+ import subprocess
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+
9
+ # Windows Shell Notification constants
10
+ SHCNE_UPDATEDIR = 0x00001000
11
+ SHCNF_PATHW = 0x0005
12
+
13
+ # Win32 Synchronization constants
14
+ SYNCHRONIZE = 0x00100000
15
+ WAIT_OBJECT_0 = 0x00000000
16
+ WAIT_TIMEOUT = 0x00000102
17
+ ERROR_ALREADY_EXISTS = 183
18
+ EVENT_MODIFY_STATE = 0x0002
19
+
20
+ kernel32 = ctypes.windll.kernel32
21
+
22
+
23
+ def log(msg: str):
24
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
25
+ line = f"[{timestamp}] [Watcher] {msg}"
26
+ print(line, flush=True)
27
+ try:
28
+ log_file = Path.home() / ".accli" / "nfs_watcher.log"
29
+ log_file.parent.mkdir(parents=True, exist_ok=True)
30
+ with open(log_file, "a", encoding="utf-8") as f:
31
+ f.write(line + "\n")
32
+ except Exception:
33
+ pass
34
+
35
+
36
+ def get_sync_names(mount_point: str):
37
+ """Returns unique Named Mutex and Event names based on mount drive."""
38
+ drive = Path(mount_point).drive.upper().rstrip(":")
39
+ tag = drive if drive else "DEFAULT"
40
+ return f"Local\\accli_watcher_{tag}_mutex", f"Local\\accli_watcher_{tag}_stop"
41
+
42
+
43
+ def get_open_explorer_paths(script_path: Path, mount_point: str) -> list[str]:
44
+ """Queries open Windows Explorer windows to get paths currently viewed under mount_point."""
45
+ if not script_path.is_file():
46
+ return []
47
+ try:
48
+ res = subprocess.run(
49
+ [
50
+ "powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
51
+ "-File", str(script_path), mount_point, "ListOpenPaths"
52
+ ],
53
+ capture_output=True,
54
+ text=True,
55
+ creationflags=0x08000000 # CREATE_NO_WINDOW
56
+ )
57
+ if res.returncode == 0 and res.stdout:
58
+ return [p.strip() for p in res.stdout.splitlines() if p.strip()]
59
+ except Exception as e:
60
+ log(f"Error querying open explorer paths: {e}")
61
+ return []
62
+
63
+
64
+ def notify_explorer(script_path: Path, mount_point: str, path_str: str):
65
+ """Notifies Windows Shell and refreshes Explorer windows for path_str."""
66
+ log(f"Broadcasting SHChangeNotify on '{path_str}'...")
67
+ try:
68
+ ctypes.windll.shell32.SHChangeNotify(
69
+ SHCNE_UPDATEDIR,
70
+ SHCNF_PATHW,
71
+ path_str,
72
+ None
73
+ )
74
+ except Exception as e:
75
+ log(f"SHChangeNotify error: {e}")
76
+
77
+ if script_path.is_file():
78
+ try:
79
+ subprocess.run(
80
+ [
81
+ "powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
82
+ "-File", str(script_path), mount_point, "Refresh", path_str
83
+ ],
84
+ capture_output=True,
85
+ text=True,
86
+ creationflags=0x08000000 # CREATE_NO_WINDOW
87
+ )
88
+ except Exception as e:
89
+ log(f"COM refresh error: {e}")
90
+
91
+
92
+ def scan_directory_state(dir_path: Path):
93
+ """
94
+ Fast snapshot of a single directory using os.scandir().
95
+ Avoids recursive network traversal. Returns dict of entry_name -> (is_dir, size, mtime_ns).
96
+ """
97
+ state = {}
98
+ try:
99
+ if not dir_path.exists():
100
+ return None
101
+ with os.scandir(dir_path) as it:
102
+ for entry in it:
103
+ try:
104
+ st = entry.stat(follow_symlinks=False)
105
+ is_dir = entry.is_dir(follow_symlinks=False)
106
+ state[entry.name] = (is_dir, st.st_size if not is_dir else -1, st.st_mtime_ns)
107
+ except (OSError, PermissionError):
108
+ continue
109
+ return state
110
+ except Exception:
111
+ return None
112
+
113
+
114
+ def describe_changes(old_state, new_state):
115
+ old_keys = set(old_state.keys())
116
+ new_keys = set(new_state.keys())
117
+ added = new_keys - old_keys
118
+ removed = old_keys - new_keys
119
+ modified = {k for k in (old_keys & new_keys) if old_state[k] != new_state[k]}
120
+
121
+ parts = []
122
+ if added:
123
+ parts.append(f"Added: {list(added)[:5]}")
124
+ if removed:
125
+ parts.append(f"Removed: {list(removed)[:5]}")
126
+ if modified:
127
+ parts.append(f"Modified: {list(modified)[:5]}")
128
+ return "; ".join(parts)
129
+
130
+
131
+ def run_watcher(mount_point: str, interval: float):
132
+ """
133
+ Optimized NFS watcher:
134
+ - Waits on a Named Event for instant, 0-CPU sleeping and clean shutdown.
135
+ - Inspects only directories currently viewed in Windows Explorer (View-Scoped).
136
+ - Uses shallow os.scandir() to avoid recursive network tree walking.
137
+ """
138
+ drive_root = mount_point.rstrip("\\/") + "\\"
139
+ script_path = Path(__file__).parent / "refresh_explorer.ps1"
140
+ interval_ms = max(500, int(interval * 1000))
141
+
142
+ mutex_name, stop_event_name = get_sync_names(mount_point)
143
+
144
+ # 1. Single-instance protection via Named Mutex
145
+ mutex = kernel32.CreateMutexW(None, True, mutex_name)
146
+ if kernel32.GetLastError() == ERROR_ALREADY_EXISTS:
147
+ log(f"Another watcher is already active for {drive_root} ({mutex_name}). Exiting.")
148
+ sys.exit(0)
149
+
150
+ # 2. Named Event for clean cooperative shutdown
151
+ stop_event = kernel32.CreateEventW(None, True, False, stop_event_name)
152
+
153
+ # 3. Write user-scoped fallback PID file
154
+ pid_file = Path.home() / ".accli" / "nfs_watcher.pid"
155
+ try:
156
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
157
+ pid_file.write_text(str(os.getpid()), encoding="utf-8")
158
+ except Exception:
159
+ pass
160
+
161
+ log(f"Starting view-scoped NFS watcher on '{drive_root}' (poll interval: {interval}s)...")
162
+
163
+ cached_states = {}
164
+
165
+ try:
166
+ while True:
167
+ # Sleep via WaitForSingleObject: 0% CPU, immediate wakeup on stop signal
168
+ wait_res = kernel32.WaitForSingleObject(stop_event, interval_ms)
169
+ if wait_res == WAIT_OBJECT_0:
170
+ log("Stop event signaled. Gracefully exiting watcher.")
171
+ break
172
+
173
+ # If drive is unmounted or inaccessible, idle until available
174
+ if not Path(drive_root).exists():
175
+ continue
176
+
177
+ # Query which folders under mount_point are currently open in Explorer
178
+ open_dirs = get_open_explorer_paths(script_path, mount_point)
179
+
180
+ # If no Explorer window is viewing the mount, zero network I/O needed
181
+ if not open_dirs:
182
+ if cached_states:
183
+ cached_states.clear()
184
+ continue
185
+
186
+ # Check each active directory
187
+ for dir_str in open_dirs:
188
+ dpath = Path(dir_str)
189
+ current_state = scan_directory_state(dpath)
190
+ if current_state is None:
191
+ continue
192
+
193
+ last_state = cached_states.get(dir_str)
194
+ if last_state is not None:
195
+ if current_state != last_state:
196
+ diff_summary = describe_changes(last_state, current_state)
197
+ log(f"Change detected in '{dir_str}' -> {diff_summary}")
198
+ notify_explorer(script_path, mount_point, dir_str)
199
+ cached_states[dir_str] = current_state
200
+ else:
201
+ cached_states[dir_str] = current_state
202
+
203
+ finally:
204
+ # Cleanup
205
+ try:
206
+ if pid_file.is_file():
207
+ pid_file.unlink()
208
+ except Exception:
209
+ pass
210
+ if stop_event:
211
+ kernel32.CloseHandle(stop_event)
212
+ if mutex:
213
+ kernel32.CloseHandle(mutex)
214
+ log("Watcher terminated.")
215
+
216
+
217
+ if __name__ == "__main__":
218
+ if len(sys.argv) < 3:
219
+ print("Usage: python nfs_watcher.py <mount_point> <interval_seconds>")
220
+ sys.exit(1)
221
+ mp = sys.argv[1]
222
+ poll_int = float(sys.argv[2])
223
+ run_watcher(mp, poll_int)
224
+
@@ -0,0 +1,60 @@
1
+ param(
2
+ [Parameter(Position=0)]
3
+ [string]$MountPoint,
4
+ [Parameter(Position=1)]
5
+ [ValidateSet("ListOpenPaths", "Refresh")]
6
+ [string]$Action,
7
+ [Parameter(Position=2)]
8
+ [string]$TargetFolder
9
+ )
10
+
11
+ $ErrorActionPreference = "SilentlyContinue"
12
+ $mountClean = $MountPoint.TrimEnd("\/")
13
+
14
+ $shell = New-Object -ComObject Shell.Application
15
+
16
+ if ($Action -eq "ListOpenPaths") {
17
+ $foundPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
18
+ foreach ($win in $shell.Windows()) {
19
+ try {
20
+ $p = $win.Document.Folder.Self.Path
21
+ if (-not $p) {
22
+ $u = [System.Uri]$win.LocationURL
23
+ if ($u.IsFile) { $p = $u.LocalPath }
24
+ }
25
+ if ($p) {
26
+ $pClean = $p.TrimEnd("\/")
27
+ if ($pClean -eq $mountClean -or $pClean.StartsWith($mountClean + "\", [System.StringComparison]::OrdinalIgnoreCase)) {
28
+ [void]$foundPaths.Add($pClean)
29
+ }
30
+ }
31
+ } catch {}
32
+ }
33
+ foreach ($path in $foundPaths) {
34
+ Write-Output $path
35
+ }
36
+ }
37
+ else {
38
+ $targetClean = if ($TargetFolder) { $TargetFolder.TrimEnd("\/") } else { "" }
39
+ foreach ($win in $shell.Windows()) {
40
+ try {
41
+ $winPath = $win.Document.Folder.Self.Path
42
+ if (-not $winPath) {
43
+ $u = [System.Uri]$win.LocationURL
44
+ if ($u.IsFile) { $winPath = $u.LocalPath }
45
+ }
46
+ if ($winPath) {
47
+ $winClean = $winPath.TrimEnd("\/")
48
+ if ($targetClean) {
49
+ if ($winClean -eq $targetClean -or $winClean.StartsWith($targetClean + "\", [System.StringComparison]::OrdinalIgnoreCase)) {
50
+ $win.Refresh()
51
+ }
52
+ } else {
53
+ if ($winClean -eq $mountClean -or $winClean.StartsWith($mountClean + "\", [System.StringComparison]::OrdinalIgnoreCase)) {
54
+ $win.Refresh()
55
+ }
56
+ }
57
+ }
58
+ } catch {}
59
+ }
60
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: accli
3
- Version: 2.0.5
3
+ Version: 2.0.7
4
4
  Summary: IIASA Accelerator Client
5
5
  Author-email: Wrufesh S <wrufesh@gmail.com>
6
6
  License: The MIT License (MIT)
@@ -13,10 +13,10 @@ accli/cli.py
13
13
  accli/common.py
14
14
  accli/gui.py
15
15
  accli/mount_downloader.py
16
+ accli/nfs_watcher.py
16
17
  accli/refresh_explorer.ps1
17
18
  accli/setup-nfs.ps1
18
19
  accli/token.py
19
- accli/windows_refresher.py
20
20
  accli.egg-info/PKG-INFO
21
21
  accli.egg-info/SOURCES.txt
22
22
  accli.egg-info/dependency_links.txt
@@ -1,12 +0,0 @@
1
- $ErrorActionPreference = "SilentlyContinue"
2
- $mountPoint = $args[0]
3
- if (-not $mountPoint) { $mountPoint = "W:" }
4
- $mountClean = $mountPoint.TrimEnd("\/")
5
-
6
- $shell = New-Object -ComObject Shell.Application
7
- foreach ($win in $shell.Windows()) {
8
- $url = $win.LocationURL
9
- if ($url -and ($url -like "*$mountClean*" -or $win.Path -like "*$mountClean*")) {
10
- $win.Refresh()
11
- }
12
- }
@@ -1,159 +0,0 @@
1
- import os
2
- import sys
3
- import time
4
- import ctypes
5
- import subprocess
6
- from pathlib import Path
7
- from datetime import datetime
8
-
9
- # Windows Shell Notification constants
10
- SHCNE_UPDATEDIR = 0x00001000
11
- SHCNF_PATHW = 0x0005
12
-
13
-
14
- def log(msg: str):
15
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
16
- print(f"[{timestamp}] [Watcher] {msg}", flush=True)
17
-
18
-
19
- def notify_explorer(path_str: str):
20
- """Notifies Windows Shell / Explorer to refresh views for path_str."""
21
- log(f"Broadcasting SHChangeNotify on '{path_str}'...")
22
- try:
23
- ctypes.windll.shell32.SHChangeNotify(
24
- SHCNE_UPDATEDIR,
25
- SHCNF_PATHW,
26
- path_str,
27
- None
28
- )
29
- except Exception as e:
30
- log(f"SHChangeNotify error: {e}")
31
-
32
- try:
33
- script_path = Path(__file__).parent / "refresh_explorer.ps1"
34
- if script_path.is_file():
35
- log(f"Executing COM window refresh for '{path_str}'...")
36
- res = subprocess.run(
37
- [
38
- "powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
39
- "-File", str(script_path), path_str
40
- ],
41
- capture_output=True,
42
- text=True,
43
- creationflags=0x08000000 # CREATE_NO_WINDOW
44
- )
45
- if res.stdout and res.stdout.strip():
46
- log(f"COM output: {res.stdout.strip()}")
47
- if res.stderr and res.stderr.strip():
48
- log(f"COM error: {res.stderr.strip()}")
49
- log("Explorer windows refreshed.")
50
- except Exception as e:
51
- log(f"COM refresh error: {e}")
52
-
53
-
54
- def scan_directory_state(root_path: Path):
55
- """
56
- Fast snapshot of directory state: mapping of relative_path -> (size, mtime_ns).
57
- Returns None if root_path is not accessible or unmounted.
58
- """
59
- state = {}
60
- try:
61
- if not root_path.exists():
62
- return None
63
-
64
- for root, dirs, files in os.walk(root_path):
65
- for name in files:
66
- full_path = os.path.join(root, name)
67
- try:
68
- st = os.stat(full_path)
69
- rel = os.path.relpath(full_path, root_path)
70
- state[rel] = (st.st_size, st.st_mtime_ns)
71
- except (OSError, PermissionError):
72
- continue
73
- for name in dirs:
74
- full_path = os.path.join(root, name)
75
- try:
76
- st = os.stat(full_path)
77
- rel = os.path.relpath(full_path, root_path)
78
- state[rel] = (-1, st.st_mtime_ns)
79
- except (OSError, PermissionError):
80
- continue
81
- return state
82
- except Exception:
83
- return None
84
-
85
-
86
- def describe_changes(old_state, new_state):
87
- old_keys = set(old_state.keys())
88
- new_keys = set(new_state.keys())
89
- added = new_keys - old_keys
90
- removed = old_keys - new_keys
91
- modified = {k for k in (old_keys & new_keys) if old_state[k] != new_state[k]}
92
-
93
- parts = []
94
- if added:
95
- parts.append(f"Added: {list(added)[:5]}")
96
- if removed:
97
- parts.append(f"Removed: {list(removed)[:5]}")
98
- if modified:
99
- parts.append(f"Modified: {list(modified)[:5]}")
100
- return "; ".join(parts)
101
-
102
-
103
- def run_watcher(mount_point: str, interval: float):
104
- """
105
- Periodically inspects mount_point for remote additions/deletions/modifications.
106
- Triggers Windows Shell & open Explorer window refreshes whenever changes are detected.
107
- """
108
- drive_root = mount_point.rstrip("\\/") + "\\"
109
- root_path = Path(drive_root)
110
-
111
- log(f"Starting watcher on '{drive_root}' (poll interval: {interval}s)...")
112
-
113
- # Baseline snapshot
114
- try:
115
- last_state = scan_directory_state(root_path)
116
- except Exception as e:
117
- last_state = None
118
- log(f"Initial scan error: {e}")
119
-
120
- if last_state is not None:
121
- log(f"Initial baseline snapshot indexed {len(last_state)} items on '{drive_root}'.")
122
- else:
123
- log(f"Warning: Could not read '{drive_root}'. Waiting for drive to become available...")
124
-
125
- while True:
126
- try:
127
- time.sleep(interval)
128
- except (KeyboardInterrupt, SystemExit):
129
- log("Watcher stopped.")
130
- break
131
- except Exception as e:
132
- log(f"Sleep interrupted: {e}")
133
- continue
134
-
135
- try:
136
- current_state = scan_directory_state(root_path)
137
- if current_state is None:
138
- continue
139
-
140
- if last_state is not None:
141
- if current_state != last_state:
142
- diff_summary = describe_changes(last_state, current_state)
143
- log(f"Change detected on '{drive_root}' -> {diff_summary}")
144
- notify_explorer(drive_root)
145
- last_state = current_state
146
- else:
147
- last_state = current_state
148
- log(f"Baseline established: {len(last_state)} items found.")
149
- except Exception as e:
150
- log(f"Loop error: {e}")
151
-
152
-
153
- if __name__ == "__main__":
154
- if len(sys.argv) < 3:
155
- print("Usage: python windows_refresher.py <mount_point> <interval_seconds>")
156
- sys.exit(1)
157
- mp = sys.argv[1]
158
- poll_int = float(sys.argv[2])
159
- run_watcher(mp, poll_int)
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
File without changes