#!/bin/python3 """ Parallel rsync launcher. Requires rsync on PATH (e.g. via MSYS/Cygwin on Windows). Runs multiple rsync invocations in parallel using either top-level entries or balanced recursive file chunks. """ # Usage examples: # # Basic sync (recursive-files mode, auto worker count): # python pyrsync.py /src/dir user@host:/dest/ # # Auto-tune workers based on file size and local vs remote destination: # python pyrsync.py /src/dir user@host:/dest/ --auto-tune # # Limit to a specific number of parallel workers: # python pyrsync.py /src/dir /local/dest/ -w 8 # # Use top-level mode (one rsync process per immediate source entry): # python pyrsync.py /src/dir /local/dest/ --mode top-level # # Dry run (show what would be transferred without doing it): # python pyrsync.py /src/dir user@host:/dest/ --dry-run # # Verbose output (shows command templates, task list preview, and elapsed time): # python pyrsync.py /src/dir /local/dest/ -v # # Show rsync output for all tasks: # python pyrsync.py /src/dir /local/dest/ --show-rsync-output # # Show rsync output only when a task fails: # python pyrsync.py /src/dir user@host:/dest/ --show-rsync-output-on-failure # # Full-featured example (auto-tune, verbose, show failures): # python pyrsync.py /src/dir user@host:/dest/ --auto-tune -v --show-rsync-output-on-failure # # Override rsync flags entirely: # python pyrsync.py /src/dir /local/dest/ -r "-az --checksum" import argparse import os import re import shlex import subprocess import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from typing import List, Sequence, Tuple # DEFAULT_RSYNC_ARGS = ["-av", "--partial", "--compress", "--info=progress2"] DEFAULT_RSYNC_ARGS = ["-a", "--partial", "--info=stats2"] def build_file_list(src: str) -> List[Path]: p = Path(src) if p.is_dir(): # sync each top-level entry separately (files and immediate subdirs) return [x for x in p.iterdir()] # allow glob patterns if any(ch in src for ch in ["*", "?"]): return list(Path(".").glob(src)) # single file return [p] def build_recursive_file_list(src_dir: Path) -> List[Path]: """Return all files under src_dir as relative paths for --files-from.""" return [p.relative_to(src_dir) for p in src_dir.rglob("*") if p.is_file()] def get_sized_files(src_dir: Path, rel_files: Sequence[Path]) -> List[Tuple[int, Path]]: """Return (size_bytes, rel_path) tuples with best-effort size lookup.""" sized_files: List[Tuple[int, Path]] = [] for rel_path in rel_files: abs_path = src_dir / rel_path try: size = abs_path.stat().st_size except OSError: size = 0 sized_files.append((size, rel_path)) return sized_files def partition_by_size(sized_files: Sequence[Tuple[int, Path]], workers: int) -> List[List[Path]]: """Greedy size-balanced partitioning to reduce worker skew.""" buckets: List[List[Path]] = [[] for _ in range(workers)] bucket_sizes = [0] * workers # Largest files first gives better load balancing across workers. sorted_files = sorted(sized_files, key=lambda x: x[0], reverse=True) for size, rel_path in sorted_files: idx = min(range(workers), key=lambda i: bucket_sizes[i]) buckets[idx].append(rel_path) bucket_sizes[idx] += size return [b for b in buckets if b] def detect_remote_destination(dest: str) -> bool: """Heuristic detection of rsync remote destinations.""" if dest.startswith("rsync://"): return True if "::" in dest: return True if re.match(r"^[a-zA-Z]:[\\/]", dest): return False if re.match(r"^\\\\", dest): return False return bool(re.match(r"^[^:/\\]+@?[^:/\\]*:[^/].*", dest)) def format_bytes(num_bytes: float) -> str: units = ["B", "KB", "MB", "GB", "TB"] value = float(num_bytes) for unit in units: if value < 1024.0 or unit == units[-1]: return f"{value:.1f}{unit}" value /= 1024.0 return f"{num_bytes:.1f}B" def auto_tune_workers( total_units: int, avg_size_bytes: float, is_remote: bool, cpu_count: int, configured_workers: int, ) -> int: """Pick worker count from workload size profile and destination type.""" cpu = max(1, cpu_count) if avg_size_bytes < 1 * 1024 * 1024: base = min(32, cpu * 4) elif avg_size_bytes < 32 * 1024 * 1024: base = min(16, cpu * 2) elif avg_size_bytes < 256 * 1024 * 1024: base = min(8, cpu) else: base = min(4, max(2, cpu // 2)) if is_remote: base = max(2, min(base, 8)) else: base = min(base + 2, 24) tuned = max(1, min(base, total_units)) return min(tuned, configured_workers) def maybe_print_task_output(label: str, out: str, err: str, show_output: bool, show_on_fail_only: bool, code: int) -> None: if not show_output: return if show_on_fail_only and code == 0: return if out.strip(): print(f"--- stdout: {label} ---") print(out.rstrip()) if err.strip(): print(f"--- stderr: {label} ---", file=sys.stderr) print(err.rstrip(), file=sys.stderr) def run_rsync(item: Path, dest: str, rsync_args: List[str], dry_run: bool) -> Tuple[Path, int, str, str]: cmd = ["rsync", *rsync_args] if dry_run: cmd.append("--dry-run") # preserve relative path by sending the item name, not its parent cmd += [str(item), dest] try: proc = subprocess.run(cmd, capture_output=True, text=True) return item, proc.returncode, proc.stdout, proc.stderr except FileNotFoundError: return item, 127, "", "rsync not found on PATH" def run_rsync_files_from( src_dir: Path, rel_files: Sequence[Path], dest: str, rsync_args: List[str], dry_run: bool, ) -> Tuple[str, int, str, str]: cmd = ["rsync", *rsync_args] if dry_run: cmd.append("--dry-run") cmd += ["--from0", "--files-from=-", f"{src_dir}{os.sep}", dest] payload = "\0".join(str(p) for p in rel_files) + "\0" try: proc = subprocess.run(cmd, input=payload, capture_output=True, text=True) label = f"chunk({len(rel_files)} files)" return label, proc.returncode, proc.stdout, proc.stderr except FileNotFoundError: return "chunk(0 files)", 127, "", "rsync not found on PATH" def main(): ap = argparse.ArgumentParser(description="Transfer files using rsync in parallel.") ap.add_argument("src", help="Source file, directory or glob pattern") ap.add_argument("dest", help="Rsync destination (local path or user@host:/path)") ap.add_argument( "-w", "--workers", type=int, default=min(8, (os.cpu_count() or 4)), help="Parallel rsync workers (default: min(8, CPU count))", ) ap.add_argument( "--mode", choices=["top-level", "recursive-files"], default="recursive-files", help=( "top-level: one rsync per immediate src entry; " "recursive-files: split full file tree into balanced rsync chunks" ), ) ap.add_argument("-r", "--rsync-args", default=" ".join(DEFAULT_RSYNC_ARGS), help="Additional rsync args (quoted). Default: -a --partial --info=stats2") ap.add_argument( "--auto-tune", action="store_true", help=( "Auto-tune worker count from average file size and destination type. " "Uses --workers as an upper bound." ), ) ap.add_argument( "-v", "--verbose", action="store_true", help="Show detailed progress, including task start/completion and rsync command lines.", ) ap.add_argument( "--show-rsync-output", action="store_true", help="Print rsync stdout/stderr for each task (can be noisy).", ) ap.add_argument( "--show-rsync-output-on-failure", action="store_true", help="Print rsync stdout/stderr only for failed tasks.", ) ap.add_argument("--dry-run", action="store_true", help="Don't transfer, just show what would happen") args = ap.parse_args() if args.workers < 1: print("--workers must be >= 1", file=sys.stderr) sys.exit(2) rsync_args = shlex.split(args.rsync_args) results = [] started_at = time.perf_counter() if args.show_rsync_output and args.show_rsync_output_on_failure: print( "Both --show-rsync-output and --show-rsync-output-on-failure set; " "showing output for all tasks.", file=sys.stderr, ) show_output = args.show_rsync_output or args.show_rsync_output_on_failure show_on_fail_only = args.show_rsync_output_on_failure and not args.show_rsync_output src_path = Path(args.src) is_remote_dest = detect_remote_destination(args.dest) cpu_count = os.cpu_count() or 4 if args.mode == "recursive-files" and src_path.is_dir(): rel_files = build_recursive_file_list(src_path) if not rel_files: print("No files found for:", args.src, file=sys.stderr) sys.exit(2) sized_files = get_sized_files(src_path, rel_files) avg_size = sum(size for size, _ in sized_files) / max(1, len(sized_files)) if args.auto_tune: workers = auto_tune_workers(len(rel_files), avg_size, is_remote_dest, cpu_count, args.workers) dest_kind = "remote" if is_remote_dest else "local" print( f"Auto-tuned workers={workers} ({dest_kind} dest, avg file {format_bytes(avg_size)})" ) else: workers = min(args.workers, len(rel_files)) chunks = partition_by_size(sized_files, workers) print(f"Starting {len(chunks)} rsync chunk task(s) with {workers} worker(s)...") if args.verbose: cmd_preview = ["rsync", *rsync_args] if args.dry_run: cmd_preview.append("--dry-run") cmd_preview += ["--from0", "--files-from=-", f"{src_path}{os.sep}", args.dest] print("Command template:", " ".join(shlex.quote(part) for part in cmd_preview)) with ThreadPoolExecutor(max_workers=workers) as ex: futures = { ex.submit(run_rsync_files_from, src_path, chunk, args.dest, rsync_args, args.dry_run): chunk for chunk in chunks } for fut in as_completed(futures): label, code, out, err = fut.result() results.append((label, code, out, err)) status = "OK" if code == 0 else f"ERR({code})" print(f"[{status}] {label}") maybe_print_task_output(label, out, err, show_output, show_on_fail_only, code) else: items = build_file_list(args.src) if not items: print("No files found for:", args.src, file=sys.stderr) sys.exit(2) size_samples = [] for item in items: try: if item.is_file(): size_samples.append(item.stat().st_size) else: size_samples.append(64 * 1024 * 1024) except OSError: size_samples.append(0) avg_size = sum(size_samples) / max(1, len(size_samples)) if args.auto_tune: workers = auto_tune_workers(len(items), avg_size, is_remote_dest, cpu_count, args.workers) dest_kind = "remote" if is_remote_dest else "local" print( f"Auto-tuned workers={workers} ({dest_kind} dest, avg unit {format_bytes(avg_size)})" ) else: workers = min(args.workers, len(items)) print(f"Starting {len(items)} rsync task(s) with {workers} worker(s)...") if args.verbose: print("Task list preview:") preview_limit = 10 for idx, item in enumerate(items[:preview_limit], start=1): print(f" {idx}. {item}") if len(items) > preview_limit: print(f" ... and {len(items) - preview_limit} more") with ThreadPoolExecutor(max_workers=workers) as ex: futures = {} for item in items: if args.verbose: cmd_preview = ["rsync", *rsync_args] if args.dry_run: cmd_preview.append("--dry-run") cmd_preview += [str(item), args.dest] print("Scheduling:", " ".join(shlex.quote(part) for part in cmd_preview)) futures[ex.submit(run_rsync, item, args.dest, rsync_args, args.dry_run)] = item for fut in as_completed(futures): item, code, out, err = fut.result() results.append((str(item), code, out, err)) status = "OK" if code == 0 else f"ERR({code})" print(f"[{status}] {item}") maybe_print_task_output(str(item), out, err, show_output, show_on_fail_only, code) # summary failed = [r for r in results if r[1] != 0] elapsed = time.perf_counter() - started_at print(f"\nCompleted: {len(results)} tasks, {len(failed)} failed in {elapsed:.2f}s.") if failed: print("Failures detail:") for label, code, out, err in failed: print(f"--- {label} (exit {code}) ---") if out: print(out.strip()) if err: print(err.strip()) sys.exit(1 if failed else 0) if __name__ == "__main__": main()