feat: --retry 自动补传 + --verify 完成后校验
This commit is contained in:
1 parent
a9fe6ff068
commit
7ab32bad24
3 files changed
+103
-9
No files matched your search
@@ -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` 全量校验(无输出=源目标完全一致),适合读卡器
|
||||
> 掉卡频繁的场景。
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user