From 7ab32bad2461fc77d942d345ffa8bba54a2a74c8 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 22 Aug 2026 14:01:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20--retry=20=E8=87=AA=E5=8A=A8=E8=A1=A5?= =?UTF-8?q?=E4=BC=A0=20+=20--verify=20=E5=AE=8C=E6=88=90=E5=90=8E=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +++++--- tests/test_transfer.py | 25 ++++++++++++++ transfer.py | 74 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f2c5617..84e8bc8 100644 --- a/README.md +++ b/README.md @@ -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` 全量校验(无输出=源目标完全一致),适合读卡器 +> 掉卡频繁的场景。 diff --git a/tests/test_transfer.py b/tests/test_transfer.py index 637b734..9ce005a 100644 --- a/tests/test_transfer.py +++ b/tests/test_transfer.py @@ -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) diff --git a/transfer.py b/transfer.py index 79d764c..4d0e900 100644 --- a/transfer.py +++ b/transfer.py @@ -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)