feat: --retry 自动补传 + --verify 完成后校验

This commit is contained in:
AskaEth committed 2026-08-22 14:01:00 +08:00
1 parent a9fe6ff068
commit 7ab32bad24
3 files changed
+103 -9

No files matched your search

+9 -4
View File
@@ -51,7 +51,8 @@ quit # 退出
| `--transfers` | 4 | rclone 并行传输数 |
| `--resume` / `--no-resume` | 开 | rsync 断点续传 |
| `--checksum` | 关 | rsync 校验模式 |
| `--verify` | 关 | 完成后校验 |
| `--verify` | 关 | 完成后校验(rsync -c dry-run,0 差异=一致) |
| `--retry` | 0 | 失败自动补传轮数(配合守护自动恢复设备) |
| `--mode` | tui | cli / tui |
| `--keep-logs` | 20 | 日志滚动保留数 |
| `--logdir` | ~/logs | 日志目录 |
@@ -93,10 +94,14 @@ q = 中止传输
- rclone 的 `--ask` 使用原生 `--interactive`(y/n/s);tar 后端不支持 ask/skip
```bash
# CLI 示例: ask 交互
python3 transfer.py --mode cli --policy ask /src/ /dst/
# CLI 示例: ask 交互 + 补传 + 校验
python3 transfer.py --mode cli --policy ask --retry 5 --verify /src/ /dst/
# TUI 输入栏示例
/src /dst --policy ask
/src /dst --policy ask --retry 5 --verify
/src /dst --skip
```
> `--retry` 在传输失败(掉卡等)后自动重跑补传,`--verify` 完成后用
> `rsync -c --dry-run` 全量校验(无输出=源目标完全一致),适合读卡器
> 掉卡频繁的场景。
+25
View File
@@ -282,3 +282,28 @@ def test_dir_size_returns_bytes(tmp_path):
def test_dir_size_missing_returns_zero():
assert transfer.dir_size("/nonexistent/path/xyz") == 0
# ---------- 补传重试 + 校验 ----------
def test_parse_args_retry():
args = transfer.parse_args(["--retry", "5", "s", "d"])
assert args.retry == 5
def test_parse_args_retry_default_zero():
assert transfer.parse_args(["s", "d"]).retry == 0
def test_parse_args_verify_flag():
args = transfer.parse_args(["--verify", "s", "d"])
assert args.verify is True
def test_rsync_verify_cmd():
cmd = transfer.RsyncBackend.build_verify_cmd("a/", "b/", bwlimit="30000")
joined = " ".join(cmd)
assert cmd[0] == "rsync"
assert "--checksum" in joined or "-c" in joined
assert "--dry-run" in joined
assert "--bwlimit=30000" in joined
def test_rsync_verify_cmd_no_bwlimit():
cmd = transfer.RsyncBackend.build_verify_cmd("a/", "b/")
assert "--bwlimit" not in " ".join(cmd)
+69 -5
View File
@@ -118,6 +118,7 @@ class TransferConfig:
verify: bool = False
logdir: str = ""
policy: str = "overwrite"
retry: int = 0
# ---------------------------------------------------------------- 输出解析器
@@ -291,6 +292,17 @@ class RsyncBackend:
return cmd
@staticmethod
def build_verify_cmd(src: str, dst: str, bwlimit: str = "") -> list[str]:
"""校验命令: rsync -c --dry-run, 无输出=一致"""
src = src.rstrip("/") + "/"
cmd = ["rsync", "-a", "-c", "--dry-run", "--out-format=%i|%n"]
if bwlimit:
cmd += [f"--bwlimit={bwlimit}"]
cmd += [src, dst]
return cmd
class RcloneBackend:
name = "rclone"
@@ -415,7 +427,9 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser.add_argument("--checksum", action="store_true",
help="rsync 校验模式 (-c)")
parser.add_argument("--verify", action="store_true",
help="传输完成后校验")
help="传输完成后校验 (-c)")
parser.add_argument("--retry", type=int, default=0,
help="失败自动补传轮数 (默认 0)")
parser.add_argument("--mode", choices=["cli", "tui"], default=None,
help="运行模式 (默认: 有任务=cli, 无任务=tui)")
parser.add_argument("--keep-logs", type=int, default=20,
@@ -559,7 +573,26 @@ class TaskRunner:
# -- 执行
def run(self) -> int:
"""同步执行传输, 返回退出码 (进度由目录轮询驱动, 绕过后端输出缓冲)"""
"""执行传输: 失败自动补传(retry) + 完成后校验(verify)"""
rc = 0
for attempt in range(self.cfg.retry + 1):
if attempt > 0:
self.lm.write(f"=== 补传轮次 {attempt}/{self.cfg.retry} ===")
if self.on_log:
self.on_log(f"[yellow]补传轮次 {attempt}/{self.cfg.retry}[/]")
time.sleep(3) # 等守护恢复设备
rc = self._run_once()
if rc == 0:
break
self.lm.write(f"attempt {attempt + 1} failed rc={rc}")
if self.on_error and attempt < self.cfg.retry:
self.on_error(f"本轮退出码 {rc}, 将自动补传 ({attempt + 1}/{self.cfg.retry})")
if rc == 0 and self.cfg.verify and self.cfg.backend in ("rsync",):
self._verify()
return rc
def _run_once(self) -> int:
"""单次传输, 返回退出码 (进度由目录轮询驱动, 绕过后端输出缓冲)"""
cmd = self.build_command()
self.lm.open_session(f"{self.cfg.backend}_{os.path.basename(self.cfg.source.rstrip('/'))}")
self.lm.write(f"cmd: {' '.join(cmd)}")
@@ -625,6 +658,35 @@ class TaskRunner:
self.on_log(f"已传 {human_size(done)} / {human_size(self.total_bytes)} "
f"({self.stats.pct:.0f}%) {human_speed(self.stats.speed_bps)}")
def _verify(self) -> None:
"""完成后校验: rsync -c --dry-run, 统计差异"""
self.lm.write("verify: 开始校验 (-c dry-run)")
if self.on_log:
self.on_log("[cyan]校验中 (rsync -c --dry-run)...[/]")
cmd = RsyncBackend.build_verify_cmd(self.cfg.source, self.cfg.dest,
self.cfg.bwlimit)
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
except OSError as exc:
self.lm.write_error(f"verify: 启动失败 {exc}")
return
assert proc.stdout is not None
diffs = 0
for line in proc.stdout:
line = line.rstrip()
if not line or line.startswith(("sending", "sent ", "total ")):
continue
diffs += 1
self.lm.write(f"verify diff: {line}")
rc = proc.wait()
self.lm.write(f"verify: 完成 rc={rc} 差异文件={diffs}")
if self.on_log:
if diffs == 0 and rc == 0:
self.on_log("[green]✔ 校验通过: 源与目标完全一致[/]")
else:
self.on_log(f"[red]✘ 校验发现 {diffs} 个差异 (rc={rc})[/]")
def stop(self) -> None:
self._stop.set()
if self.proc and self.proc.poll() is None:
@@ -873,7 +935,7 @@ class TransferApp(App):
source=args.source, dest=args.dest, backend=args.backend,
bwlimit=args.bwlimit, transfers=args.transfers,
resume=args.resume, checksum=args.checksum, verify=args.verify,
logdir=self.logdir, policy=args.policy,
logdir=self.logdir, policy=args.policy, retry=args.retry,
)
if cfg.policy == "ask" and cfg.backend == "rsync":
self._flash("[yellow]预扫描冲突...[/]")
@@ -1035,14 +1097,16 @@ def main() -> int:
backend=args.backend, bwlimit=args.bwlimit,
transfers=args.transfers, resume=args.resume,
checksum=args.checksum, verify=args.verify,
logdir=args.logdir, policy=args.policy)
logdir=args.logdir, policy=args.policy,
retry=args.retry)
TransferApp(preset=cfg, logdir=args.logdir, keep_logs=args.keep_logs).run()
return 0
cfg = TransferConfig(source=args.source, dest=args.dest,
backend=args.backend, bwlimit=args.bwlimit,
transfers=args.transfers, resume=args.resume,
checksum=args.checksum, verify=args.verify,
logdir=args.logdir, policy=args.policy)
logdir=args.logdir, policy=args.policy,
retry=args.retry)
return run_cli(cfg, args)