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.
@@ -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())