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 ADDED
@@ -0,0 +1,6 @@
1
+ """rangler: a macOS home-directory backup tool built on rclone.
2
+
3
+ Author: John Grimes.
4
+ """
5
+
6
+ __version__ = "0.1.0"
rangler/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Entry point for ``python -m rangler``.
2
+
3
+ Author: John Grimes.
4
+ """
5
+
6
+ from rangler.cli import cli
7
+
8
+ if __name__ == "__main__":
9
+ cli()
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())}.")