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.
- {accli-2.0.5 → accli-2.0.7}/PKG-INFO +1 -1
- {accli-2.0.5 → accli-2.0.7}/accli/AcceleratorTaskDispatcher.py +38 -7
- {accli-2.0.5 → accli-2.0.7}/accli/_version.py +1 -1
- {accli-2.0.5 → accli-2.0.7}/accli/cli.py +104 -49
- accli-2.0.7/accli/nfs_watcher.py +224 -0
- accli-2.0.7/accli/refresh_explorer.ps1 +60 -0
- {accli-2.0.5 → accli-2.0.7}/accli.egg-info/PKG-INFO +1 -1
- {accli-2.0.5 → accli-2.0.7}/accli.egg-info/SOURCES.txt +1 -1
- accli-2.0.5/accli/refresh_explorer.ps1 +0 -12
- accli-2.0.5/accli/windows_refresher.py +0 -159
- {accli-2.0.5 → accli-2.0.7}/LICENSE +0 -0
- {accli-2.0.5 → accli-2.0.7}/README.md +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/AcceleratorJobProjectService.py +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/AcceleratorTerminalCliProjectService.py +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/CsvRegionalTimeseriesValidator.py +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/__init__.py +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/__main__.py +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/cleanup-nfs.ps1 +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/common.py +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/gui.py +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/mount_downloader.py +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/setup-nfs.ps1 +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli/token.py +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli.egg-info/dependency_links.txt +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli.egg-info/entry_points.txt +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli.egg-info/requires.txt +0 -0
- {accli-2.0.5 → accli-2.0.7}/accli.egg-info/top_level.txt +0 -0
- {accli-2.0.5 → accli-2.0.7}/pyproject.toml +0 -0
- {accli-2.0.5 → accli-2.0.7}/setup.cfg +0 -0
|
@@ -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
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
614
|
-
if not
|
|
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(
|
|
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("
|
|
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
|
|
647
|
-
"""
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
|
1082
|
-
print(f"[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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
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.
|
|
1113
|
-
with open(log_file_path, "r", encoding="utf-8") as lf:
|
|
1114
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
|
1310
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|