rangler 0.1.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.
- rangler/__init__.py +6 -0
- rangler/__main__.py +9 -0
- rangler/backup.py +327 -0
- rangler/cli.py +250 -0
- rangler/config.py +286 -0
- rangler/discovery.py +102 -0
- rangler/locking.py +51 -0
- rangler/notify.py +91 -0
- rangler/paths.py +163 -0
- rangler/rclone.py +213 -0
- rangler/runtimestate.py +96 -0
- rangler/schedule.py +230 -0
- rangler/summary.py +75 -0
- rangler/targets.py +106 -0
- rangler-0.1.0.dist-info/METADATA +222 -0
- rangler-0.1.0.dist-info/RECORD +20 -0
- rangler-0.1.0.dist-info/WHEEL +4 -0
- rangler-0.1.0.dist-info/entry_points.txt +2 -0
- rangler-0.1.0.dist-info/licenses/LICENSE +202 -0
- rangler-0.1.0.dist-info/licenses/NOTICE +8 -0
rangler/__init__.py
ADDED
rangler/__main__.py
ADDED
rangler/backup.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Orchestrate a backup run over resolved targets.
|
|
2
|
+
|
|
3
|
+
The pure core - :func:`execute_target` and :func:`run_targets` - runs each target
|
|
4
|
+
through an injected rclone runner and aggregates :class:`TargetResult` records
|
|
5
|
+
into a :class:`RunResult`, continuing past per-target failures. The orchestration
|
|
6
|
+
- :func:`perform_backup` - wires in the real runner, the single-run lock, a
|
|
7
|
+
per-run log file, and the ``last-run.json`` record, confining side effects to one
|
|
8
|
+
place. A missing source yields a ``skipped_missing`` result, never a failure.
|
|
9
|
+
|
|
10
|
+
Author: John Grimes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass, replace
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from rangler.config import Config
|
|
20
|
+
from rangler.locking import run_lock
|
|
21
|
+
from rangler.paths import current_host, run_timestamp
|
|
22
|
+
from rangler.rclone import build_sync_argv, interpret_exit_code, parse_stats, run_rclone
|
|
23
|
+
from rangler.runtimestate import log_path, state_dir, write_last_run
|
|
24
|
+
from rangler.targets import BackupTarget, resolve_targets
|
|
25
|
+
|
|
26
|
+
# Name of the run lock file under the state directory.
|
|
27
|
+
LOCK_NAME = "rangler.lock"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TargetResult:
|
|
32
|
+
"""The outcome of attempting one backup target.
|
|
33
|
+
|
|
34
|
+
:ivar target: the attempted target.
|
|
35
|
+
:ivar status: ``"succeeded"``, ``"skipped_missing"``, or ``"failed"``.
|
|
36
|
+
:ivar files: files transferred (from rclone stats).
|
|
37
|
+
:ivar bytes: bytes transferred.
|
|
38
|
+
:ivar exit_code: rclone exit code, or ``None`` when skipped.
|
|
39
|
+
:ivar message: human-readable detail (error text on failure).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
target: BackupTarget
|
|
43
|
+
status: str
|
|
44
|
+
files: int
|
|
45
|
+
bytes: int
|
|
46
|
+
exit_code: int | None
|
|
47
|
+
message: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class RunResult:
|
|
52
|
+
"""The aggregate outcome of a whole run.
|
|
53
|
+
|
|
54
|
+
:ivar started_at: ISO-8601 run start time.
|
|
55
|
+
:ivar finished_at: ISO-8601 run end time.
|
|
56
|
+
:ivar dry_run: whether this was a dry run.
|
|
57
|
+
:ivar results: per-target outcomes.
|
|
58
|
+
:ivar host: sanitised hostname segment used for destinations.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
started_at: str
|
|
62
|
+
finished_at: str
|
|
63
|
+
dry_run: bool
|
|
64
|
+
results: tuple[TargetResult, ...]
|
|
65
|
+
host: str
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _error_detail(lines: list[str]) -> str:
|
|
69
|
+
"""Extract a concise, human-readable error detail from rclone output.
|
|
70
|
+
|
|
71
|
+
Prefers the ``msg`` of the last JSON log entry (rclone runs with
|
|
72
|
+
``--use-json-log``), falling back to the last non-empty raw line so the
|
|
73
|
+
reported detail reads as a sentence rather than a JSON blob.
|
|
74
|
+
|
|
75
|
+
:param lines: captured output lines.
|
|
76
|
+
:returns: a concise error message, or a generic fallback.
|
|
77
|
+
"""
|
|
78
|
+
for line in reversed(lines):
|
|
79
|
+
if not line.strip():
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
entry = json.loads(line)
|
|
83
|
+
except (json.JSONDecodeError, TypeError):
|
|
84
|
+
return line.strip()
|
|
85
|
+
if isinstance(entry, dict) and entry.get("msg"):
|
|
86
|
+
return str(entry["msg"])
|
|
87
|
+
return line.strip()
|
|
88
|
+
return "rclone reported a failure"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_file(path: Path) -> bool:
|
|
92
|
+
"""Report whether a path is a regular file (following symlinks)."""
|
|
93
|
+
return Path(path).is_file()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def execute_target(
|
|
97
|
+
target: BackupTarget,
|
|
98
|
+
*,
|
|
99
|
+
runner: Callable[..., tuple[int, list[str]]],
|
|
100
|
+
exists: Callable[[Path], bool],
|
|
101
|
+
excludes: tuple[str, ...],
|
|
102
|
+
dry_run: bool,
|
|
103
|
+
is_file: Callable[[Path], bool] = _is_file,
|
|
104
|
+
on_line: Callable[[str], None] | None = None,
|
|
105
|
+
) -> TargetResult:
|
|
106
|
+
"""Run a single target through rclone and classify the outcome.
|
|
107
|
+
|
|
108
|
+
:param target: the target to back up.
|
|
109
|
+
:param runner: callable ``(argv, on_line=...) -> (exit_code, lines)``.
|
|
110
|
+
:param exists: predicate reporting whether the source path exists.
|
|
111
|
+
:param excludes: rclone filter patterns to apply.
|
|
112
|
+
:param dry_run: when true, rclone makes no changes.
|
|
113
|
+
:param is_file: predicate reporting whether the source is a single file.
|
|
114
|
+
:param on_line: optional sink for each rclone output line.
|
|
115
|
+
:returns: the :class:`TargetResult` for this target.
|
|
116
|
+
"""
|
|
117
|
+
if not exists(target.source):
|
|
118
|
+
return TargetResult(
|
|
119
|
+
target=target,
|
|
120
|
+
status="skipped_missing",
|
|
121
|
+
files=0,
|
|
122
|
+
bytes=0,
|
|
123
|
+
exit_code=None,
|
|
124
|
+
message="source does not exist on this machine",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
argv = build_sync_argv(
|
|
128
|
+
target, excludes, dry_run=dry_run, source_is_file=is_file(target.source)
|
|
129
|
+
)
|
|
130
|
+
code, lines = runner(argv, on_line=on_line)
|
|
131
|
+
stats = parse_stats(lines)
|
|
132
|
+
status = interpret_exit_code(code)
|
|
133
|
+
return TargetResult(
|
|
134
|
+
target=target,
|
|
135
|
+
status=status,
|
|
136
|
+
files=stats.transfers,
|
|
137
|
+
bytes=stats.bytes,
|
|
138
|
+
exit_code=code,
|
|
139
|
+
message="" if status == "succeeded" else _error_detail(lines),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_targets(
|
|
144
|
+
targets: tuple[BackupTarget, ...],
|
|
145
|
+
*,
|
|
146
|
+
runner: Callable[..., tuple[int, list[str]]],
|
|
147
|
+
exists: Callable[[Path], bool],
|
|
148
|
+
excludes: tuple[str, ...],
|
|
149
|
+
dry_run: bool,
|
|
150
|
+
started_at: str,
|
|
151
|
+
finished_at: str,
|
|
152
|
+
host: str,
|
|
153
|
+
is_file: Callable[[Path], bool] = _is_file,
|
|
154
|
+
on_start: Callable[[BackupTarget], None] | None = None,
|
|
155
|
+
on_finish: Callable[[TargetResult], None] | None = None,
|
|
156
|
+
on_line: Callable[[str], None] | None = None,
|
|
157
|
+
) -> RunResult:
|
|
158
|
+
"""Run every target in order, aggregating results and continuing on failure.
|
|
159
|
+
|
|
160
|
+
:param targets: the resolved targets to process.
|
|
161
|
+
:param runner: the rclone runner (see :func:`execute_target`).
|
|
162
|
+
:param exists: source-existence predicate.
|
|
163
|
+
:param excludes: rclone filter patterns to apply.
|
|
164
|
+
:param dry_run: whether this is a dry run.
|
|
165
|
+
:param started_at: ISO-8601 run start time.
|
|
166
|
+
:param finished_at: ISO-8601 run end time.
|
|
167
|
+
:param host: the hostname segment for the run.
|
|
168
|
+
:param is_file: predicate reporting whether a source is a single file.
|
|
169
|
+
:param on_start: optional callback invoked with each target before it runs.
|
|
170
|
+
:param on_finish: optional callback invoked with each target's result.
|
|
171
|
+
:param on_line: optional sink for each rclone output line.
|
|
172
|
+
:returns: the aggregated :class:`RunResult`.
|
|
173
|
+
"""
|
|
174
|
+
results: list[TargetResult] = []
|
|
175
|
+
for target in targets:
|
|
176
|
+
if on_start is not None:
|
|
177
|
+
on_start(target)
|
|
178
|
+
result = execute_target(
|
|
179
|
+
target,
|
|
180
|
+
runner=runner,
|
|
181
|
+
exists=exists,
|
|
182
|
+
excludes=excludes,
|
|
183
|
+
dry_run=dry_run,
|
|
184
|
+
is_file=is_file,
|
|
185
|
+
on_line=on_line,
|
|
186
|
+
)
|
|
187
|
+
if on_finish is not None:
|
|
188
|
+
on_finish(result)
|
|
189
|
+
results.append(result)
|
|
190
|
+
|
|
191
|
+
return RunResult(
|
|
192
|
+
started_at=started_at,
|
|
193
|
+
finished_at=finished_at,
|
|
194
|
+
dry_run=dry_run,
|
|
195
|
+
results=tuple(results),
|
|
196
|
+
host=host,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def compact_record(run: RunResult) -> dict:
|
|
201
|
+
"""Build the compact ``last-run.json`` record for a completed run.
|
|
202
|
+
|
|
203
|
+
:param run: the aggregate run result.
|
|
204
|
+
:returns: a JSON-serialisable summary with counts and an ``ok`` verdict.
|
|
205
|
+
"""
|
|
206
|
+
failed = sum(1 for r in run.results if r.status == "failed")
|
|
207
|
+
succeeded = sum(1 for r in run.results if r.status == "succeeded")
|
|
208
|
+
skipped = sum(1 for r in run.results if r.status == "skipped_missing")
|
|
209
|
+
return {
|
|
210
|
+
"started_at": run.started_at,
|
|
211
|
+
"finished_at": run.finished_at,
|
|
212
|
+
"dry_run": run.dry_run,
|
|
213
|
+
"ok": failed == 0,
|
|
214
|
+
"succeeded": succeeded,
|
|
215
|
+
"failed": failed,
|
|
216
|
+
"skipped": skipped,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def perform_backup(
|
|
221
|
+
config: Config,
|
|
222
|
+
*,
|
|
223
|
+
home: Path | None = None,
|
|
224
|
+
dry_run: bool = False,
|
|
225
|
+
host: str | None = None,
|
|
226
|
+
now: datetime | None = None,
|
|
227
|
+
runner: Callable[..., tuple[int, list[str]]] | None = None,
|
|
228
|
+
exists: Callable[[Path], bool] | None = None,
|
|
229
|
+
discovered: tuple[Path, ...] = (),
|
|
230
|
+
echo: Callable[[str], None] | None = None,
|
|
231
|
+
) -> RunResult:
|
|
232
|
+
"""Perform a full backup run: lock, transfer, log, and record.
|
|
233
|
+
|
|
234
|
+
Acquires the single-run lock for the duration, resolves targets, runs each
|
|
235
|
+
through rclone while streaming progress to ``echo`` and a per-run log file,
|
|
236
|
+
writes ``last-run.json``, and returns the result.
|
|
237
|
+
|
|
238
|
+
:param config: the validated configuration.
|
|
239
|
+
:param home: home directory override; defaults to ``Path.home()``.
|
|
240
|
+
:param dry_run: when true, rclone makes no changes to the remote.
|
|
241
|
+
:param host: hostname segment override; defaults to the system hostname.
|
|
242
|
+
:param now: run start time override; defaults to the current UTC time.
|
|
243
|
+
:param runner: rclone runner override; defaults to the real subprocess.
|
|
244
|
+
:param exists: source-existence predicate; defaults to ``Path.exists``.
|
|
245
|
+
:param discovered: discovered ``.local`` directories to include.
|
|
246
|
+
:param echo: optional sink for progress lines (e.g. stderr).
|
|
247
|
+
:returns: the aggregated :class:`RunResult`.
|
|
248
|
+
:raises LockHeld: if another run already holds the lock.
|
|
249
|
+
"""
|
|
250
|
+
resolved_home = home if home is not None else Path.home()
|
|
251
|
+
start_time = now if now is not None else datetime.now(UTC)
|
|
252
|
+
resolved_host = host if host is not None else current_host()
|
|
253
|
+
emit = echo if echo is not None else (lambda _: None)
|
|
254
|
+
source_exists = exists if exists is not None else (lambda p: Path(p).exists())
|
|
255
|
+
actual_runner = (
|
|
256
|
+
runner
|
|
257
|
+
if runner is not None
|
|
258
|
+
else (lambda argv, on_line=None: run_rclone(argv, on_line=on_line))
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
timestamp = run_timestamp(start_time)
|
|
262
|
+
targets = resolve_targets(
|
|
263
|
+
config, resolved_host, resolved_home, timestamp, discovered=discovered
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
logfile = log_path(start_time, resolved_home)
|
|
267
|
+
logfile.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
lock = state_dir(resolved_home) / LOCK_NAME
|
|
269
|
+
|
|
270
|
+
with run_lock(lock), logfile.open("w", encoding="utf-8") as log:
|
|
271
|
+
|
|
272
|
+
def write_log(text: str) -> None:
|
|
273
|
+
log.write(text + "\n")
|
|
274
|
+
|
|
275
|
+
write_log(
|
|
276
|
+
f"rangler run start={start_time.isoformat()} host={resolved_host} "
|
|
277
|
+
f"dry_run={dry_run} targets={len(targets)}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def on_start(target: BackupTarget) -> None:
|
|
281
|
+
line = f"[{target.kind}] {target.source} -> {target.dest}"
|
|
282
|
+
emit(line)
|
|
283
|
+
write_log(line)
|
|
284
|
+
|
|
285
|
+
def on_finish(result: TargetResult) -> None:
|
|
286
|
+
line = f" {result.status}: {result.files} files, {result.bytes} bytes" + (
|
|
287
|
+
f" - {result.message}" if result.message else ""
|
|
288
|
+
)
|
|
289
|
+
emit(line)
|
|
290
|
+
write_log(line)
|
|
291
|
+
|
|
292
|
+
run = run_targets(
|
|
293
|
+
targets,
|
|
294
|
+
runner=actual_runner,
|
|
295
|
+
exists=source_exists,
|
|
296
|
+
excludes=config.excludes,
|
|
297
|
+
dry_run=dry_run,
|
|
298
|
+
started_at=start_time.isoformat(),
|
|
299
|
+
finished_at=start_time.isoformat(),
|
|
300
|
+
host=resolved_host,
|
|
301
|
+
on_start=on_start,
|
|
302
|
+
on_finish=on_finish,
|
|
303
|
+
on_line=write_log,
|
|
304
|
+
)
|
|
305
|
+
# Stamp the true end time now that all transfers have completed.
|
|
306
|
+
run = replace(run, finished_at=datetime.now(UTC).isoformat())
|
|
307
|
+
write_log(f"rangler run finished_at={run.finished_at}")
|
|
308
|
+
|
|
309
|
+
write_last_run(compact_record(run), resolved_home)
|
|
310
|
+
return run
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def note_skipped(home: Path | None = None, now: datetime | None = None) -> Path:
|
|
314
|
+
"""Record that a run was skipped because the lock was already held.
|
|
315
|
+
|
|
316
|
+
:param home: home directory override; defaults to ``Path.home()``.
|
|
317
|
+
:param now: time override; defaults to the current UTC time.
|
|
318
|
+
:returns: the path of the skip log written.
|
|
319
|
+
"""
|
|
320
|
+
moment = now if now is not None else datetime.now(UTC)
|
|
321
|
+
path = log_path(moment, home)
|
|
322
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
323
|
+
path.write_text(
|
|
324
|
+
f"rangler run skipped at {moment.isoformat()}: another run is in progress.\n",
|
|
325
|
+
encoding="utf-8",
|
|
326
|
+
)
|
|
327
|
+
return path
|
rangler/cli.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Click command-line interface for rangler.
|
|
2
|
+
|
|
3
|
+
This is the thin orchestration layer: each command loads and validates
|
|
4
|
+
configuration, composes the pure logic modules, and confines side effects to the
|
|
5
|
+
subprocess and filesystem wrappers. Errors and progress go to stderr; primary
|
|
6
|
+
output goes to stdout. Exit codes follow ``contracts/cli.md``.
|
|
7
|
+
|
|
8
|
+
Author: John Grimes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import warnings
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from rangler import notify as notify_mod
|
|
18
|
+
from rangler import rclone
|
|
19
|
+
from rangler import schedule as schedule_mod
|
|
20
|
+
from rangler.backup import note_skipped, perform_backup
|
|
21
|
+
from rangler.config import (
|
|
22
|
+
Config,
|
|
23
|
+
ConfigError,
|
|
24
|
+
config_path,
|
|
25
|
+
load_config,
|
|
26
|
+
starter_template,
|
|
27
|
+
)
|
|
28
|
+
from rangler.discovery import discover_locals, list_subdirs
|
|
29
|
+
from rangler.locking import LockHeld
|
|
30
|
+
from rangler.paths import current_host, run_timestamp
|
|
31
|
+
from rangler.runtimestate import describe_last_run, log_dir, read_last_run
|
|
32
|
+
from rangler.summary import exit_code, render_summary
|
|
33
|
+
from rangler.targets import resolve_targets
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fail(message: str, code: int) -> None:
|
|
37
|
+
"""Print an actionable error to stderr and exit with the given code.
|
|
38
|
+
|
|
39
|
+
:param message: the human-readable error and remediation.
|
|
40
|
+
:param code: the process exit code to raise.
|
|
41
|
+
:raises SystemExit: always, with ``code``.
|
|
42
|
+
"""
|
|
43
|
+
click.echo(f"Error: {message}", err=True)
|
|
44
|
+
raise SystemExit(code)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_or_exit() -> Config:
|
|
48
|
+
"""Load and validate configuration, exiting 2 on any configuration error.
|
|
49
|
+
|
|
50
|
+
Unknown-key warnings are surfaced to stderr (typo protection) without
|
|
51
|
+
aborting.
|
|
52
|
+
|
|
53
|
+
:returns: the validated :class:`Config`.
|
|
54
|
+
:raises SystemExit: with code 2 if the configuration is missing or invalid.
|
|
55
|
+
"""
|
|
56
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
57
|
+
warnings.simplefilter("always")
|
|
58
|
+
try:
|
|
59
|
+
config = load_config()
|
|
60
|
+
except ConfigError as error:
|
|
61
|
+
_fail(str(error), 2)
|
|
62
|
+
for warning in caught:
|
|
63
|
+
click.echo(f"Warning: {warning.message}", err=True)
|
|
64
|
+
return config
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _require_rclone(config: Config) -> None:
|
|
68
|
+
"""Verify rclone is present and the remote is reachable, or exit 3.
|
|
69
|
+
|
|
70
|
+
:param config: the validated configuration.
|
|
71
|
+
:raises SystemExit: with code 3 if rclone is missing or unreachable.
|
|
72
|
+
"""
|
|
73
|
+
if not rclone.rclone_available():
|
|
74
|
+
_fail(
|
|
75
|
+
"rclone is not installed or not on your PATH. Install it with "
|
|
76
|
+
"`brew install rclone`.",
|
|
77
|
+
3,
|
|
78
|
+
)
|
|
79
|
+
if not rclone.remote_reachable(config.remote_base):
|
|
80
|
+
_fail(
|
|
81
|
+
f"the remote {config.remote_base!r} is not reachable. Configure it "
|
|
82
|
+
f"with `rclone config` and check the name in remote_base.",
|
|
83
|
+
3,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _discover(config: Config, home: Path) -> tuple[Path, ...]:
|
|
88
|
+
"""Discover project ``.local`` directories for the configured scan roots.
|
|
89
|
+
|
|
90
|
+
:param config: the validated configuration.
|
|
91
|
+
:param home: the home directory the scan roots are relative to.
|
|
92
|
+
:returns: the discovered ``.local`` directories.
|
|
93
|
+
"""
|
|
94
|
+
return discover_locals(
|
|
95
|
+
config.scan_roots,
|
|
96
|
+
home,
|
|
97
|
+
config.max_depth,
|
|
98
|
+
config.excludes,
|
|
99
|
+
list_subdirs,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@click.group()
|
|
104
|
+
@click.version_option(package_name="rangler")
|
|
105
|
+
def cli() -> None:
|
|
106
|
+
"""Back up a macOS home directory to an rclone remote."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@cli.command()
|
|
110
|
+
@click.option("--force", is_flag=True, help="Overwrite an existing config file.")
|
|
111
|
+
def init(force: bool) -> None:
|
|
112
|
+
"""Scaffold a starter config.toml when none exists."""
|
|
113
|
+
path = config_path()
|
|
114
|
+
if path.exists() and not force:
|
|
115
|
+
click.echo(f"Configuration already exists at {path}; nothing to do.")
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
path.write_text(starter_template(), encoding="utf-8")
|
|
120
|
+
except OSError as error:
|
|
121
|
+
_fail(f"could not write {path}: {error}", 1)
|
|
122
|
+
click.echo(
|
|
123
|
+
f"Wrote starter configuration to {path}.\n"
|
|
124
|
+
f"Edit it to set your remote and paths, then run `rangler check`."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@cli.command()
|
|
129
|
+
def check() -> None:
|
|
130
|
+
"""Validate configuration and environment; preview the resolved targets."""
|
|
131
|
+
config = _load_or_exit()
|
|
132
|
+
_require_rclone(config)
|
|
133
|
+
|
|
134
|
+
home = Path.home()
|
|
135
|
+
host = current_host()
|
|
136
|
+
timestamp = run_timestamp(datetime.now(UTC))
|
|
137
|
+
discovered = _discover(config, home)
|
|
138
|
+
targets = resolve_targets(config, host, home, timestamp, discovered=discovered)
|
|
139
|
+
|
|
140
|
+
click.echo(
|
|
141
|
+
f"Configuration OK. rclone present and remote reachable. Host segment: {host}."
|
|
142
|
+
)
|
|
143
|
+
click.echo(f"{len(targets)} target(s):")
|
|
144
|
+
for target in targets:
|
|
145
|
+
click.echo(f" [{target.kind}] {target.source} -> {target.dest}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@cli.command()
|
|
149
|
+
@click.option(
|
|
150
|
+
"--dry-run",
|
|
151
|
+
is_flag=True,
|
|
152
|
+
help="Report intended changes without writing to the remote.",
|
|
153
|
+
)
|
|
154
|
+
def run(dry_run: bool) -> None:
|
|
155
|
+
"""Back up all resolved targets, writing a log and a summary."""
|
|
156
|
+
config = _load_or_exit()
|
|
157
|
+
_require_rclone(config)
|
|
158
|
+
|
|
159
|
+
discovered = _discover(config, Path.home())
|
|
160
|
+
try:
|
|
161
|
+
result = perform_backup(
|
|
162
|
+
config,
|
|
163
|
+
dry_run=dry_run,
|
|
164
|
+
discovered=discovered,
|
|
165
|
+
echo=lambda line: click.echo(line, err=True),
|
|
166
|
+
)
|
|
167
|
+
except LockHeld:
|
|
168
|
+
note_skipped()
|
|
169
|
+
click.echo("skipped: already running", err=True)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
click.echo(render_summary(result))
|
|
173
|
+
# Notify per policy at run completion. Scheduled runs reach this same path,
|
|
174
|
+
# since launchd invokes `rangler run`. Dry runs are previews and stay silent.
|
|
175
|
+
if not dry_run:
|
|
176
|
+
notify_mod.notify(result, config.notify)
|
|
177
|
+
code = exit_code(result)
|
|
178
|
+
if code != 0:
|
|
179
|
+
raise SystemExit(code)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@cli.command()
|
|
183
|
+
@click.option(
|
|
184
|
+
"--every",
|
|
185
|
+
default=None,
|
|
186
|
+
help="Interval such as 30m, 6h, or 1d; defaults to the config interval.",
|
|
187
|
+
)
|
|
188
|
+
def schedule(every: str | None) -> None:
|
|
189
|
+
"""Install and load the scheduled backup LaunchAgent."""
|
|
190
|
+
config = _load_or_exit()
|
|
191
|
+
interval = every if every is not None else config.interval
|
|
192
|
+
try:
|
|
193
|
+
seconds = schedule_mod.parse_duration(interval)
|
|
194
|
+
except ValueError as error:
|
|
195
|
+
_fail(str(error), 2)
|
|
196
|
+
|
|
197
|
+
logs = log_dir()
|
|
198
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
plist = schedule_mod.build_plist(
|
|
200
|
+
schedule_mod.default_program_args(),
|
|
201
|
+
seconds,
|
|
202
|
+
str(logs / "launchd.out.log"),
|
|
203
|
+
str(logs / "launchd.err.log"),
|
|
204
|
+
)
|
|
205
|
+
path = schedule_mod.plist_path()
|
|
206
|
+
schedule_mod.write_plist(path, plist)
|
|
207
|
+
|
|
208
|
+
# Replace any previously loaded agent so re-scheduling is idempotent.
|
|
209
|
+
if schedule_mod.loaded():
|
|
210
|
+
schedule_mod.bootout()
|
|
211
|
+
result = schedule_mod.bootstrap(path)
|
|
212
|
+
if result.returncode != 0:
|
|
213
|
+
_fail(
|
|
214
|
+
f"launchctl could not load the agent: "
|
|
215
|
+
f"{result.stderr.strip() or result.returncode}",
|
|
216
|
+
1,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
click.echo(
|
|
220
|
+
f"Scheduled rangler to run every {interval}. "
|
|
221
|
+
f"LaunchAgent {schedule_mod.LABEL} is loaded."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@cli.command()
|
|
226
|
+
def unschedule() -> None:
|
|
227
|
+
"""Unload and remove the scheduled backup LaunchAgent."""
|
|
228
|
+
# Best-effort unload: a not-loaded agent is not an error (idempotent).
|
|
229
|
+
schedule_mod.bootout()
|
|
230
|
+
path = schedule_mod.plist_path()
|
|
231
|
+
try:
|
|
232
|
+
if path.exists():
|
|
233
|
+
path.unlink()
|
|
234
|
+
except OSError as error:
|
|
235
|
+
_fail(f"could not remove {path}: {error}", 1)
|
|
236
|
+
click.echo(f"Unscheduled rangler. LaunchAgent {schedule_mod.LABEL} removed.")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@cli.command()
|
|
240
|
+
def status() -> None:
|
|
241
|
+
"""Report schedule state and the last run's outcome."""
|
|
242
|
+
state = schedule_mod.query_schedule()
|
|
243
|
+
if state.enabled:
|
|
244
|
+
click.echo(
|
|
245
|
+
f"Schedule: active, running every {state.interval} "
|
|
246
|
+
f"(LaunchAgent {state.label})."
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
click.echo("Schedule: inactive.")
|
|
250
|
+
click.echo(f"Last run: {describe_last_run(read_last_run())}.")
|