#!/usr/bin/env python3 """Cancel each archive transfer returned by p4 pull -l (Python 3.8+). Uses the existing P4 environment/configuration and login ticket. No P4Python dependency is needed. This cancels queued transfers; it does not retry them. """ import argparse import re import shlex import subprocess import sys # The depot path can contain spaces. Identify its end from the archive revision # and the following type/status/action/digest fields, not whitespace alone. RECORD = re.compile( r"^(?P//.+?)\s+(?P[0-9]+\.[0-9]+)" r"\s+\S+\s+\S+\s+\S+\s+[0-9a-fA-F]{32}(?:\s|$)" ) def parse_listing(output): """Validate the entire snapshot before issuing any cancellation commands.""" entries = [] seen = set() for number, line in enumerate(output.splitlines(), 1): if not line.strip(): continue match = RECORD.match(line) if not match: raise ValueError( f"Unrecognized p4 pull -l output on line {number}: {line[:160]!r}" ) entry = (match.group("file"), match.group("revision")) if entry not in seen: seen.add(entry) entries.append(entry) return entries def run_p4(command, timeout): return subprocess.run( command, capture_output=True, text=True, timeout=timeout, check=False ) def positive_timeout(value): value = int(value) if value < 1: raise argparse.ArgumentTypeError("timeout must be at least one second") return value def main(argv=None): parser = argparse.ArgumentParser( description=( "Run p4 pull -l, then p4 pull -d -f FILE -r ARCHIVE_REVISION for " "each listed transfer. All listed statuses are included." ), epilog=( "Example: python3 cancel_pull_transfers.py --dry-run. " "Remove --dry-run to cancel the transfers. Uses your current " "P4PORT, P4USER, P4CONFIG and login ticket unless overridden below." ), ) parser.add_argument( "--dry-run", action="store_true", help="list commands without cancelling transfers", ) parser.add_argument( "--p4", default="p4", help="p4 executable path (default: p4 on PATH)" ) parser.add_argument( "-p", "--port", help="override P4PORT for both listing and cancellation" ) parser.add_argument( "-u", "--user", help="override P4USER for both listing and cancellation" ) parser.add_argument( "--timeout", type=positive_timeout, default=120, help="timeout per p4 command, in seconds (default: 120)", ) args = parser.parse_args(argv) command = [args.p4] if args.port: command.extend(["-p", args.port]) if args.user: command.extend(["-u", args.user]) try: listing = run_p4(command + ["pull", "-l"], args.timeout) if listing.returncode: print("p4 pull -l failed; no transfers cancelled.", file=sys.stderr) print(listing.stderr or listing.stdout, file=sys.stderr, end="") return 1 entries = parse_listing(listing.stdout) except (OSError, subprocess.TimeoutExpired, ValueError) as exc: print( f"Cannot read transfer list; no transfers cancelled: {exc}", file=sys.stderr, ) return 1 if listing.stderr: print(listing.stderr, file=sys.stderr, end="") if not entries: print("No transfers listed.") return 0 failed = 0 for filename, revision in entries: cancel = command + ["pull", "-d", "-f", filename, "-r", revision] print(shlex.join(cancel), flush=True) if args.dry_run: continue try: result = run_p4(cancel, args.timeout) if result.stdout: print(result.stdout, end="") if result.stderr: print(result.stderr, file=sys.stderr, end="") if result.returncode: failed += 1 print( f"Cancellation failed for {filename} ({revision})", file=sys.stderr, ) except (OSError, subprocess.TimeoutExpired) as exc: failed += 1 print( f"Cancellation failed for {filename} ({revision}): {exc}", file=sys.stderr, ) if args.dry_run: print(f"Dry run: {len(entries)} transfer(s); no transfers cancelled.") else: print(f"{len(entries) - failed} cancellation(s) succeeded; {failed} failed.") return 1 if failed else 0 if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: print( "Interrupted; some preceding cancellations may have completed.", file=sys.stderr, ) sys.exit(130)