#!/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<file>//.+?)\s+(?P<revision>[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)