mountlet 0.2.0__py3-none-any.whl
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.
- mountlet/__init__.py +5 -0
- mountlet/cli.py +116 -0
- mountlet/config_tools/export_config.py +98 -0
- mountlet/config_tools/import_config.py +164 -0
- mountlet/config_tools/path_config.py +69 -0
- mountlet/config_tools/reconnect_config.py +66 -0
- mountlet/config_tools/setup_wizard.py +225 -0
- mountlet/config_tools/shared.py +289 -0
- mountlet/config_tools/verify_config.py +91 -0
- mountlet/core.py +532 -0
- mountlet/settings.py +237 -0
- mountlet/tray.py +732 -0
- mountlet/tui.py +200 -0
- mountlet-0.2.0.dist-info/METADATA +231 -0
- mountlet-0.2.0.dist-info/RECORD +19 -0
- mountlet-0.2.0.dist-info/WHEEL +5 -0
- mountlet-0.2.0.dist-info/entry_points.txt +2 -0
- mountlet-0.2.0.dist-info/licenses/LICENSE +21 -0
- mountlet-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .. import core
|
|
14
|
+
from ..settings import ensure_default_config_files
|
|
15
|
+
from .shared import (
|
|
16
|
+
app_cache_dir,
|
|
17
|
+
app_config_dir,
|
|
18
|
+
app_config_file,
|
|
19
|
+
app_mounts_file,
|
|
20
|
+
app_state_dir,
|
|
21
|
+
default_config_path,
|
|
22
|
+
ensure_app_directories,
|
|
23
|
+
find_rclone,
|
|
24
|
+
read_remotes,
|
|
25
|
+
verify_remotes,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Readiness:
|
|
31
|
+
ready: bool
|
|
32
|
+
messages: list[str]
|
|
33
|
+
remotes: list[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _status(ok: bool, message: str) -> str:
|
|
37
|
+
return ("OK " if ok else "TODO ") + message
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _fuse_available() -> bool:
|
|
41
|
+
if platform.system() == "Windows":
|
|
42
|
+
return True
|
|
43
|
+
if shutil.which("fusermount3") or shutil.which("fusermount"):
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _print_paths() -> None:
|
|
49
|
+
print("App files:")
|
|
50
|
+
print(f" Settings: {app_config_file()}")
|
|
51
|
+
print(f" Mount settings: {app_mounts_file()}")
|
|
52
|
+
print(f" State: {app_state_dir()}")
|
|
53
|
+
print(f" Cache: {app_cache_dir()}")
|
|
54
|
+
print(f" Mounts: {core.BASE_MOUNT_DIR}")
|
|
55
|
+
print(f" rclone: {default_config_path()}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_readiness() -> Readiness:
|
|
59
|
+
messages: list[str] = []
|
|
60
|
+
ensure_app_directories()
|
|
61
|
+
ensure_default_config_files()
|
|
62
|
+
core.ensure_base_mount_dir()
|
|
63
|
+
Path(core.BASE_MOUNT_DIR).mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
rclone_bin = find_rclone()
|
|
66
|
+
if not rclone_bin:
|
|
67
|
+
messages.append("Install rclone: sudo apt install rclone")
|
|
68
|
+
|
|
69
|
+
if not _fuse_available():
|
|
70
|
+
messages.append("Install FUSE: sudo apt install fuse3")
|
|
71
|
+
|
|
72
|
+
config_path = default_config_path()
|
|
73
|
+
if not config_path.exists():
|
|
74
|
+
messages.append(f"Create an rclone config: {config_path}")
|
|
75
|
+
remotes: list[str] = []
|
|
76
|
+
else:
|
|
77
|
+
remotes = read_remotes(config_path)
|
|
78
|
+
if not remotes:
|
|
79
|
+
messages.append("Add at least one cloud storage connection to rclone.")
|
|
80
|
+
|
|
81
|
+
return Readiness(ready=not messages, messages=messages, remotes=remotes)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ensure_ready_for_menu() -> bool:
|
|
85
|
+
readiness = check_readiness()
|
|
86
|
+
if readiness.ready:
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
print("Mountlet needs a little setup first.")
|
|
90
|
+
print()
|
|
91
|
+
for message in readiness.messages:
|
|
92
|
+
print(f"- {message}")
|
|
93
|
+
print()
|
|
94
|
+
print("Run:")
|
|
95
|
+
print(" mountlet setup")
|
|
96
|
+
if find_rclone():
|
|
97
|
+
print()
|
|
98
|
+
print("If you still need to connect cloud storage, run:")
|
|
99
|
+
print(" mountlet setup --configure-rclone")
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run_rclone_config(rclone_bin: str) -> int:
|
|
104
|
+
print()
|
|
105
|
+
print("Opening rclone setup. Follow the prompts to add a cloud storage remote.")
|
|
106
|
+
result = subprocess.run([rclone_bin, "config"])
|
|
107
|
+
return result.returncode
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _next_steps(rclone_bin: str | None, fuse_ok: bool, remotes: list[str], failures: list[str]) -> list[str]:
|
|
111
|
+
steps: list[str] = []
|
|
112
|
+
if not rclone_bin:
|
|
113
|
+
steps.append("Install rclone: sudo apt install rclone")
|
|
114
|
+
if not fuse_ok:
|
|
115
|
+
steps.append("Install FUSE: sudo apt install fuse3")
|
|
116
|
+
if not remotes:
|
|
117
|
+
if rclone_bin:
|
|
118
|
+
steps.append("Add cloud storage: mountlet setup --configure-rclone")
|
|
119
|
+
else:
|
|
120
|
+
steps.append("After installing rclone, add cloud storage: mountlet setup --configure-rclone")
|
|
121
|
+
if failures:
|
|
122
|
+
steps.append("Reconnect credentials: mountlet reconnect --remote <name>")
|
|
123
|
+
return steps
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def setup_command(args: argparse.Namespace) -> int:
|
|
127
|
+
print("Mountlet setup")
|
|
128
|
+
print()
|
|
129
|
+
|
|
130
|
+
dirs = ensure_app_directories()
|
|
131
|
+
ensure_default_config_files()
|
|
132
|
+
core.ensure_base_mount_dir()
|
|
133
|
+
mount_dir = Path(core.BASE_MOUNT_DIR)
|
|
134
|
+
mount_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
|
|
136
|
+
print(_status(True, "Created user app folders."))
|
|
137
|
+
for path in dirs.values():
|
|
138
|
+
print(f" {path}")
|
|
139
|
+
print(_status(mount_dir.exists(), f"Prepared mount folder: {mount_dir}"))
|
|
140
|
+
|
|
141
|
+
rclone_bin = find_rclone()
|
|
142
|
+
print(
|
|
143
|
+
_status(
|
|
144
|
+
bool(rclone_bin),
|
|
145
|
+
f"Found rclone: {rclone_bin}" if rclone_bin else "Install rclone. On Ubuntu: sudo apt install rclone",
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
fuse_ok = _fuse_available()
|
|
150
|
+
print(
|
|
151
|
+
_status(
|
|
152
|
+
fuse_ok,
|
|
153
|
+
"Found FUSE mount support." if fuse_ok else "Install FUSE. On Ubuntu: sudo apt install fuse3",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
config_path = default_config_path()
|
|
158
|
+
config_exists = config_path.exists()
|
|
159
|
+
remotes = read_remotes(config_path) if config_exists else []
|
|
160
|
+
|
|
161
|
+
if not config_exists:
|
|
162
|
+
print(_status(False, f"No rclone config found at {config_path}"))
|
|
163
|
+
elif remotes:
|
|
164
|
+
print(_status(True, f"Found {len(remotes)} rclone remote(s)."))
|
|
165
|
+
for remote in remotes:
|
|
166
|
+
print(f" {remote}")
|
|
167
|
+
else:
|
|
168
|
+
print(_status(False, f"No remotes found in {config_path}"))
|
|
169
|
+
|
|
170
|
+
if rclone_bin and args.configure_rclone and not remotes:
|
|
171
|
+
if _run_rclone_config(rclone_bin) != 0:
|
|
172
|
+
print("[!] rclone setup did not finish cleanly.")
|
|
173
|
+
return 1
|
|
174
|
+
remotes = read_remotes(config_path)
|
|
175
|
+
if remotes:
|
|
176
|
+
print(_status(True, f"Found {len(remotes)} rclone remote(s) after setup."))
|
|
177
|
+
else:
|
|
178
|
+
print(_status(False, "No remotes were added."))
|
|
179
|
+
|
|
180
|
+
if remotes and not args.skip_verify:
|
|
181
|
+
print()
|
|
182
|
+
print("Checking cloud access...")
|
|
183
|
+
results = verify_remotes(remotes)
|
|
184
|
+
failures = [name for name, (ok, _) in results.items() if not ok]
|
|
185
|
+
else:
|
|
186
|
+
failures = []
|
|
187
|
+
|
|
188
|
+
print()
|
|
189
|
+
_print_paths()
|
|
190
|
+
print()
|
|
191
|
+
|
|
192
|
+
ready = bool(rclone_bin and fuse_ok and remotes and not failures)
|
|
193
|
+
if ready:
|
|
194
|
+
print("Ready. Open the menu with:")
|
|
195
|
+
print(" mountlet")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
print("A few things still need attention before mounting.")
|
|
199
|
+
for number, step in enumerate(_next_steps(rclone_bin, fuse_ok, remotes, failures), start=1):
|
|
200
|
+
print(f" {number}. {step}")
|
|
201
|
+
return 1
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
205
|
+
parser = argparse.ArgumentParser(description="Prepare Mountlet for first use.")
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--configure-rclone",
|
|
208
|
+
action="store_true",
|
|
209
|
+
help="Open rclone's setup flow if no remotes are configured.",
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--skip-verify",
|
|
213
|
+
action="store_true",
|
|
214
|
+
help="Do not check cloud access during setup.",
|
|
215
|
+
)
|
|
216
|
+
return parser
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main(argv: list[str] | None = None) -> int:
|
|
220
|
+
parser = build_parser()
|
|
221
|
+
return setup_command(parser.parse_args(argv))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Tuple
|
|
15
|
+
|
|
16
|
+
APP_NAME = "mountlet"
|
|
17
|
+
LEGACY_APP_NAMES = ("cloud-mount-manager",)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_config_path() -> Path:
|
|
21
|
+
env_config = os.environ.get("RCLONE_CONFIG")
|
|
22
|
+
if env_config:
|
|
23
|
+
return Path(env_config).expanduser()
|
|
24
|
+
if platform.system() == "Windows":
|
|
25
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
26
|
+
return base / "rclone" / "rclone.conf"
|
|
27
|
+
return Path.home() / ".config" / "rclone" / "rclone.conf"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _platform_user_dir_for(kind: str, app_name: str) -> Path:
|
|
31
|
+
system = platform.system()
|
|
32
|
+
if system == "Windows":
|
|
33
|
+
env_name = "APPDATA" if kind == "config" else "LOCALAPPDATA"
|
|
34
|
+
fallback = Path.home() / "AppData" / ("Roaming" if kind == "config" else "Local")
|
|
35
|
+
return Path(os.environ.get(env_name, fallback)) / app_name
|
|
36
|
+
if system == "Darwin":
|
|
37
|
+
if kind == "cache":
|
|
38
|
+
return Path.home() / "Library" / "Caches" / app_name
|
|
39
|
+
return Path.home() / "Library" / "Application Support" / app_name
|
|
40
|
+
|
|
41
|
+
env_names = {
|
|
42
|
+
"config": "XDG_CONFIG_HOME",
|
|
43
|
+
"state": "XDG_STATE_HOME",
|
|
44
|
+
"cache": "XDG_CACHE_HOME",
|
|
45
|
+
}
|
|
46
|
+
defaults = {
|
|
47
|
+
"config": Path.home() / ".config",
|
|
48
|
+
"state": Path.home() / ".local" / "state",
|
|
49
|
+
"cache": Path.home() / ".cache",
|
|
50
|
+
}
|
|
51
|
+
base = Path(os.environ.get(env_names[kind], defaults[kind])).expanduser()
|
|
52
|
+
return base / app_name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _platform_user_dir(kind: str) -> Path:
|
|
56
|
+
return _platform_user_dir_for(kind, APP_NAME)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def app_config_dir() -> Path:
|
|
60
|
+
return _platform_user_dir("config")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def app_state_dir() -> Path:
|
|
64
|
+
return _platform_user_dir("state")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def app_cache_dir() -> Path:
|
|
68
|
+
return _platform_user_dir("cache")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def app_config_file() -> Path:
|
|
72
|
+
return app_config_dir() / "config.toml"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def app_mounts_file() -> Path:
|
|
76
|
+
return app_config_dir() / "mounts.toml"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def legacy_app_config_dirs() -> List[Path]:
|
|
80
|
+
return [_platform_user_dir_for("config", name) for name in LEGACY_APP_NAMES]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ensure_app_directories() -> Dict[str, Path]:
|
|
84
|
+
paths = {
|
|
85
|
+
"config": app_config_dir(),
|
|
86
|
+
"state": app_state_dir(),
|
|
87
|
+
"cache": app_cache_dir(),
|
|
88
|
+
}
|
|
89
|
+
for path in paths.values():
|
|
90
|
+
ensure_dir(path)
|
|
91
|
+
return paths
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ensure_dir(path: Path) -> None:
|
|
95
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def timestamp() -> str:
|
|
99
|
+
return _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def backup_file(path: Path) -> Path:
|
|
103
|
+
backup = path.with_name(f"{path.name}.{timestamp()}.bak")
|
|
104
|
+
shutil.copy2(path, backup)
|
|
105
|
+
return backup
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def apply_permissions(path: Path) -> None:
|
|
109
|
+
if platform.system() != "Windows":
|
|
110
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def copy_file(src: Path, dest: Path, *, backup: bool, dry_run: bool) -> Path | None:
|
|
114
|
+
if not src.exists():
|
|
115
|
+
return None
|
|
116
|
+
if dest.exists() and backup and not dry_run:
|
|
117
|
+
backup_file(dest)
|
|
118
|
+
if dry_run:
|
|
119
|
+
return dest
|
|
120
|
+
shutil.copy2(src, dest)
|
|
121
|
+
apply_permissions(dest)
|
|
122
|
+
return dest
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def find_client_secrets(default_dir: Path) -> List[Path]:
|
|
126
|
+
return sorted(Path(default_dir).glob("client_secret*.json"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_RCLONE_CACHED: str | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def find_rclone() -> str | None:
|
|
133
|
+
global _RCLONE_CACHED
|
|
134
|
+
if _RCLONE_CACHED is not None:
|
|
135
|
+
return _RCLONE_CACHED
|
|
136
|
+
|
|
137
|
+
candidates: List[Path] = []
|
|
138
|
+
env_path = os.environ.get("RCLONE_PATH")
|
|
139
|
+
if env_path:
|
|
140
|
+
candidates.append(Path(env_path).expanduser())
|
|
141
|
+
|
|
142
|
+
exe_name = "rclone.exe" if platform.system() == "Windows" else "rclone"
|
|
143
|
+
which_path = shutil.which(exe_name)
|
|
144
|
+
if which_path:
|
|
145
|
+
candidates.append(Path(which_path))
|
|
146
|
+
|
|
147
|
+
if platform.system() == "Windows":
|
|
148
|
+
candidates.append(Path("C:/Program Files/rclone/rclone.exe"))
|
|
149
|
+
|
|
150
|
+
for candidate in candidates:
|
|
151
|
+
if candidate and candidate.exists():
|
|
152
|
+
_RCLONE_CACHED = str(candidate)
|
|
153
|
+
return _RCLONE_CACHED
|
|
154
|
+
|
|
155
|
+
_RCLONE_CACHED = None
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_rclone_version() -> str:
|
|
160
|
+
binary = find_rclone()
|
|
161
|
+
if not binary:
|
|
162
|
+
return "rclone binary not found. Install rclone or set RCLONE_PATH."
|
|
163
|
+
try:
|
|
164
|
+
output = subprocess.check_output([binary, "version"], stderr=subprocess.STDOUT, text=True)
|
|
165
|
+
return output.strip()
|
|
166
|
+
except subprocess.CalledProcessError as exc:
|
|
167
|
+
return f"rclone version check failed: {exc.output.strip() or exc}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def read_remotes(config_path: Path) -> List[str]:
|
|
171
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
172
|
+
try:
|
|
173
|
+
parser.read(config_path, encoding="utf-8")
|
|
174
|
+
except (OSError, configparser.Error):
|
|
175
|
+
return []
|
|
176
|
+
return [section for section in parser.sections()]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def print_remote_list(remotes: List[str], source: Path, label: str = "Remotes discovered in") -> None:
|
|
180
|
+
if remotes:
|
|
181
|
+
print(f"[i] {label} {source}:")
|
|
182
|
+
for name in remotes:
|
|
183
|
+
print(f" - {name}")
|
|
184
|
+
else:
|
|
185
|
+
print(f"[!] No remotes detected in {source}.")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def resolve_remote_selection(config_path: Path, requested: List[str]) -> Tuple[List[str], List[str]]:
|
|
189
|
+
available = read_remotes(config_path)
|
|
190
|
+
if not requested:
|
|
191
|
+
return available, []
|
|
192
|
+
|
|
193
|
+
available_set = set(available)
|
|
194
|
+
selected: List[str] = []
|
|
195
|
+
missing: List[str] = []
|
|
196
|
+
for raw in requested:
|
|
197
|
+
name = raw.rstrip(":")
|
|
198
|
+
if name in available_set:
|
|
199
|
+
if name not in selected:
|
|
200
|
+
selected.append(name)
|
|
201
|
+
else:
|
|
202
|
+
missing.append(raw)
|
|
203
|
+
return selected, missing
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def verify_remotes(remotes: List[str]) -> Dict[str, Tuple[bool, str]]:
|
|
207
|
+
results: Dict[str, Tuple[bool, str]] = {}
|
|
208
|
+
if not remotes:
|
|
209
|
+
print("[!] No remotes available for verification.")
|
|
210
|
+
return results
|
|
211
|
+
|
|
212
|
+
binary = find_rclone()
|
|
213
|
+
if not binary:
|
|
214
|
+
print("[!] rclone executable not found; cannot verify remotes.")
|
|
215
|
+
for remote in remotes:
|
|
216
|
+
results[remote] = (False, "rclone binary missing")
|
|
217
|
+
return results
|
|
218
|
+
|
|
219
|
+
for remote in remotes:
|
|
220
|
+
result = subprocess.run(
|
|
221
|
+
[binary, "about", f"{remote}:"],
|
|
222
|
+
stdout=subprocess.PIPE,
|
|
223
|
+
stderr=subprocess.PIPE,
|
|
224
|
+
text=True,
|
|
225
|
+
)
|
|
226
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
227
|
+
success = result.returncode == 0
|
|
228
|
+
summary = (
|
|
229
|
+
detail.splitlines()[0]
|
|
230
|
+
if detail
|
|
231
|
+
else ("credentials accepted" if success else f"exit code {result.returncode}")
|
|
232
|
+
)
|
|
233
|
+
if success:
|
|
234
|
+
print(f"[✓] {remote}: {summary}")
|
|
235
|
+
else:
|
|
236
|
+
print(f"[!] {remote}: verification failed ({summary})")
|
|
237
|
+
results[remote] = (success, summary)
|
|
238
|
+
return results
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def reconnect_remotes(remotes: List[str], auto_confirm: bool) -> None:
|
|
242
|
+
if not remotes:
|
|
243
|
+
print("[!] No remotes available for reconnection.")
|
|
244
|
+
return
|
|
245
|
+
binary = find_rclone()
|
|
246
|
+
if not binary:
|
|
247
|
+
print("[!] rclone executable not found; cannot run reconnect.")
|
|
248
|
+
return
|
|
249
|
+
for remote in remotes:
|
|
250
|
+
remote_arg = f"{remote}:"
|
|
251
|
+
print(f"[>] Launching 'rclone config reconnect {remote_arg}' (Ctrl+C to skip)...")
|
|
252
|
+
sys.stdout.flush()
|
|
253
|
+
try:
|
|
254
|
+
cmd = [binary, "config", "reconnect", remote_arg]
|
|
255
|
+
if auto_confirm:
|
|
256
|
+
cmd.append("--auto-confirm")
|
|
257
|
+
result = subprocess.run(cmd, stdin=sys.stdin)
|
|
258
|
+
except KeyboardInterrupt:
|
|
259
|
+
print(f"[-] Reconnect for {remote_arg} skipped by user.")
|
|
260
|
+
continue
|
|
261
|
+
if result.returncode == 0:
|
|
262
|
+
print(f"[✓] {remote}: reconnect completed.")
|
|
263
|
+
else:
|
|
264
|
+
print(f"[!] {remote}: reconnect exited with code {result.returncode}.")
|
|
265
|
+
print(f" Run manually: {' '.join(cmd)}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
__all__ = [
|
|
269
|
+
"APP_NAME",
|
|
270
|
+
"LEGACY_APP_NAMES",
|
|
271
|
+
"default_config_path",
|
|
272
|
+
"app_config_dir",
|
|
273
|
+
"app_state_dir",
|
|
274
|
+
"app_cache_dir",
|
|
275
|
+
"app_config_file",
|
|
276
|
+
"app_mounts_file",
|
|
277
|
+
"legacy_app_config_dirs",
|
|
278
|
+
"ensure_app_directories",
|
|
279
|
+
"ensure_dir",
|
|
280
|
+
"copy_file",
|
|
281
|
+
"find_client_secrets",
|
|
282
|
+
"print_remote_list",
|
|
283
|
+
"run_rclone_version",
|
|
284
|
+
"resolve_remote_selection",
|
|
285
|
+
"verify_remotes",
|
|
286
|
+
"reconnect_remotes",
|
|
287
|
+
"find_rclone",
|
|
288
|
+
"read_remotes",
|
|
289
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""Verify access to rclone remotes, optionally auto-reconnecting failures."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .shared import (
|
|
11
|
+
default_config_path,
|
|
12
|
+
print_remote_list,
|
|
13
|
+
resolve_remote_selection,
|
|
14
|
+
verify_remotes,
|
|
15
|
+
reconnect_remotes,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def verify_command(args: argparse.Namespace) -> int:
|
|
20
|
+
config_path = Path(args.config).expanduser().resolve() if args.config else default_config_path()
|
|
21
|
+
if not config_path.exists():
|
|
22
|
+
print(f"[!] Config file not found: {config_path}")
|
|
23
|
+
return 1
|
|
24
|
+
|
|
25
|
+
selected, missing = resolve_remote_selection(config_path, args.remote)
|
|
26
|
+
for name in missing:
|
|
27
|
+
print(f"[!] Remote not found in config: {name}")
|
|
28
|
+
|
|
29
|
+
if not selected:
|
|
30
|
+
print("[!] No remotes available for verification.")
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
print_remote_list(selected, config_path, label="Verifying remotes from")
|
|
34
|
+
results = verify_remotes(selected)
|
|
35
|
+
if not results:
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
failures = [name for name, (ok, _) in results.items() if not ok]
|
|
39
|
+
|
|
40
|
+
if args.auto_reconnect and failures:
|
|
41
|
+
print("[i] Attempting to reconnect failing remotes...")
|
|
42
|
+
reconnect_remotes(failures, args.reconnect_auto_confirm)
|
|
43
|
+
print_remote_list(failures, config_path, label="Re-checking remotes after reconnect from")
|
|
44
|
+
results.update(verify_remotes(failures))
|
|
45
|
+
failures = [name for name, (ok, _) in results.items() if not ok]
|
|
46
|
+
|
|
47
|
+
if failures:
|
|
48
|
+
for name in failures:
|
|
49
|
+
print(f"[!] {name}: still failing after verification.")
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
print("[✓] All selected remotes verified.")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
57
|
+
parser = argparse.ArgumentParser(description="Verify rclone remotes and optionally auto-reconnect them.")
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--config",
|
|
60
|
+
help="Config file to read remotes from (default: detected rclone path).",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--remote",
|
|
64
|
+
action="append",
|
|
65
|
+
default=[],
|
|
66
|
+
help="Specific remote to verify (repeatable). Defaults to all remotes in the config.",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--auto-reconnect",
|
|
70
|
+
dest="auto_reconnect",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Attempt reconnect for failing remotes after verification.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--no-reconnect-auto-confirm",
|
|
76
|
+
dest="reconnect_auto_confirm",
|
|
77
|
+
action="store_false",
|
|
78
|
+
help="When auto-reconnecting, do not pass --auto-confirm to rclone.",
|
|
79
|
+
)
|
|
80
|
+
parser.set_defaults(auto_reconnect=False, reconnect_auto_confirm=True)
|
|
81
|
+
return parser
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main(argv: list[str] | None = None) -> int:
|
|
85
|
+
parser = build_parser()
|
|
86
|
+
args = parser.parse_args(argv)
|
|
87
|
+
return verify_command(args)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
raise SystemExit(main())
|