LowercaseDepotPaths.py #2

  • //
  • cic/
  • dev/
  • src/
  • LowercaseDepotPaths.py
  • View
  • Commits
  • Open Download .zip Download (11 KB)
#!/usr/bin/env python3
# LowercaseDepotPaths.py
# This script recursively renames files and directories whose basenames contain uppercase characters to their lowercase equivalents.
# This is necessary for Perforce depot paths which have been converted from case sensitive to insensitive.

from __future__ import annotations

import argparse
import os
import subprocess
import sys
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class RenameOp:
    src: Path
    dst: Path
    depth: int
    is_dir: bool
    merge: bool = False  # merge src files into existing leaf dst dir then rmdir src


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Recursively rename files and directories whose basenames contain "
            "uppercase characters to their lowercase equivalents."
        )
    )
    parser.add_argument(
        "target_dir",
        nargs="?",
        default=".",
        help="Directory tree to scan (default: current directory).",
    )
    parser.add_argument(
        "-n",
        "--dry-run",
        action="store_true",
        help="Show planned renames without applying them.",
    )
    parser.add_argument(
        "-j",
        "--jobs",
        type=int,
        default=max(1, min(32, (os.cpu_count() or 1))),
        help="Number of worker threads for independent parent directories.",
    )
    parser.add_argument(
        "-s",
        "--serial",
        action="store_true",
        help="Force serial execution.",
    )
    parser.add_argument(
        "-q",
        "--quiet",
        action="store_true",
        help="Only print errors and the final summary.",
    )
    parser.add_argument(
        "--remove-empty-only",
        action="store_true",
        help="Only remove empty directory trees; skip lowercase renaming.",
    )
    return parser.parse_args()


def has_uppercase(name: str) -> bool:
    return any(char.isupper() for char in name)


def collect_ops(root: Path) -> list[RenameOp]:
    ops: list[RenameOp] = []

    for current_root, dirnames, filenames in os.walk(root, topdown=False):
        current_path = Path(current_root)

        for filename in filenames:
            lower_name = filename.lower()
            if lower_name != filename and has_uppercase(filename):
                src = current_path / filename
                ops.append(
                    RenameOp(
                        src=src,
                        dst=current_path / lower_name,
                        depth=len(src.relative_to(root).parts),
                        is_dir=False,
                    )
                )

        for dirname in dirnames:
            lower_name = dirname.lower()
            if lower_name != dirname and has_uppercase(dirname):
                src = current_path / dirname
                ops.append(
                    RenameOp(
                        src=src,
                        dst=current_path / lower_name,
                        depth=len(src.relative_to(root).parts),
                        is_dir=True,
                    )
                )

    return ops


def same_path_entry(src: Path, dst: Path) -> bool:
    try:
        return dst.exists() and src.samefile(dst)
    except FileNotFoundError:
        return False


def file_collision(src: Path, dst: Path) -> Path | None:
    source_files: dict[tuple[str, ...], Path] = {}
    for current_root, _, filenames in os.walk(src):
        current_path = Path(current_root)
        for filename in filenames:
            source_file = current_path / filename
            relative_parts = source_file.relative_to(src).parts
            normalized_parts = tuple(part.lower() for part in relative_parts)
            other_source = source_files.get(normalized_parts)
            if other_source is not None:
                return other_source
            source_files[normalized_parts] = source_file

    for current_root, _, filenames in os.walk(dst):
        current_path = Path(current_root)
        for filename in filenames:
            relative_parts = (current_path / filename).relative_to(dst).parts
            normalized_parts = tuple(part.lower() for part in relative_parts)
            source_file = source_files.get(normalized_parts)
            if source_file is not None:
                return source_file

    return None


def validate_ops(ops: list[RenameOp]) -> tuple[list[str], list[str], list[RenameOp]]:
    warnings: list[str] = []
    errors: list[str] = []
    result: list[RenameOp] = []
    destinations: dict[Path, Path] = {}

    for op in ops:
        other_src = destinations.get(op.dst)
        if other_src is not None and other_src != op.src:
            warnings.append(f"Collision: {op.src} and {other_src} both map to {op.dst}")
            continue

        destinations[op.dst] = op.src

        if op.dst.exists() and not same_path_entry(op.src, op.dst):
            if op.is_dir:
                collision = file_collision(op.src, op.dst)
                if collision is not None:
                    warnings.append(
                        f"Collision: {collision} conflicts with a file in {op.dst}"
                    )
                    continue
                result.append(RenameOp(
                    src=op.src, dst=op.dst,
                    depth=op.depth, is_dir=True, merge=True,
                ))
                continue
            errors.append(f"Target already exists: {op.src} -> {op.dst}")
            result.append(op)
            continue

        result.append(op)

    return warnings, errors, result


def print_op(prefix: str, op: RenameOp) -> None:
    kind = "dir " if op.is_dir else "file"
    print(f"{prefix} [{kind}] {op.src} -> {op.dst}")


def remove_empty_dirs_recursive(root: Path, quiet: bool) -> int:
    """Remove all empty directory trees under root, depth-first. Returns count removed."""
    removed = 0
    # Iterate multiple times until no more empty dirs are found (handles nested empties).
    while True:
        dirs_this_pass = 0
        for empty_dir in sorted(root.rglob("*"), key=lambda p: len(p.parts), reverse=True):
            if empty_dir.is_dir() and empty_dir != root:
                try:
                    empty_dir.rmdir()
                    dirs_this_pass += 1
                    removed += 1
                    if not quiet:
                        print(f"Removed    [dir ] {empty_dir}")
                except OSError:
                    pass
        if dirs_this_pass == 0:
            break
    return removed


def merge_dir_into(src: Path, dst: Path, dry_run: bool, quiet: bool) -> int:
    """Merge src tree into dst using rsync, then rmdir src. Returns number of entries moved."""
    if dry_run:
        moved = sum(1 for _ in src.rglob("*"))
        if not quiet:
            print(f"Would merge [dir ] {src} -> {dst}")
        return moved

    try:
        result = subprocess.run(
            ["rsync", "--remove-source-files", "-a", str(src) + "/", str(dst) + "/"],
            capture_output=True,
            text=True,
            check=False,
        )
        if result.returncode != 0:
            raise OSError(f"rsync failed: {result.stderr}")
    except FileNotFoundError:
        raise OSError("rsync not found; cannot perform merge operation")

    moved = 0
    for entry in dst.rglob("*"):
        lower_name = entry.name.lower()
        if lower_name != entry.name:
            lower_path = entry.parent / lower_name
            try:
                os.rename(entry, lower_path)
                moved += 1
                if not quiet:
                    kind = "[dir ]" if entry.is_dir() else "[file]"
                    print(f"Merged    {kind} {entry} -> {lower_path}")
            except OSError as exc:
                print(f"Warning: could not lowercase {entry}: {exc}", file=sys.stderr)
        else:
            moved += 1

    # Remove src and any now-empty parent directories depth-first (bottom-up).
    try:
        for empty_dir in sorted(src.rglob("*"), key=lambda p: len(p.parts), reverse=True):
            if empty_dir.is_dir():
                try:
                    empty_dir.rmdir()
                    if not quiet:
                        print(f"Removed    [dir ] {empty_dir}")
                except OSError:
                    pass
        src.rmdir()
        if not quiet:
            print(f"Removed    [dir ] {src}")
    except OSError as exc:
        print(f"Warning: could not remove {src}: {exc}", file=sys.stderr)

    return moved


def apply_group(ops: list[RenameOp], dry_run: bool, quiet: bool) -> int:
    renamed = 0

    for op in ops:
        if op.merge:
            if not quiet:
                print_op("Would merge" if dry_run else "Merging   ", op)
            renamed += merge_dir_into(op.src, op.dst, dry_run=dry_run, quiet=quiet)
            continue

        if dry_run:
            if not quiet:
                print_op("Would rename", op)
            renamed += 1
            continue

        os.rename(op.src, op.dst)
        renamed += 1
        if not quiet:
            print_op("Renamed    ", op)

    return renamed


def apply_ops(ops: list[RenameOp], dry_run: bool, quiet: bool, jobs: int) -> int:
    total = 0
    by_depth: dict[int, list[RenameOp]] = defaultdict(list)

    for op in ops:
        by_depth[op.depth].append(op)

    for depth in sorted(by_depth, reverse=True):
        by_parent: dict[Path, list[RenameOp]] = defaultdict(list)
        for op in by_depth[depth]:
            by_parent[op.src.parent].append(op)

        groups = list(by_parent.values())
        if jobs == 1 or len(groups) == 1:
            for group in groups:
                total += apply_group(group, dry_run=dry_run, quiet=quiet)
            continue

        with ThreadPoolExecutor(max_workers=jobs) as executor:
            futures = [
                executor.submit(apply_group, group, dry_run=dry_run, quiet=quiet)
                for group in groups
            ]
            for future in futures:
                total += future.result()

    return total


def main() -> int:
    args = parse_args()
    root = Path(args.target_dir).resolve()

    if not root.is_dir():
        print(f"Target is not a directory: {root}", file=sys.stderr)
        return 1

    if args.jobs < 1:
        print("--jobs must be a positive integer", file=sys.stderr)
        return 1

    # Handle --remove-empty-only mode.
    if args.remove_empty_only:
        removed = remove_empty_dirs_recursive(root, quiet=args.quiet)
        print(f"Done. Removed {removed} empty director{'y' if removed == 1 else 'ies'}.")
        return 0

    jobs = 1 if args.serial else args.jobs
    ops = collect_ops(root)

    if not ops:
        print("Done. No uppercase file or directory names found.")
        return 0

    warnings, errors, ops = validate_ops(ops)
    for warning in warnings:
        print(warning, file=sys.stderr)

    if errors:
        for error in errors:
            print(error, file=sys.stderr)
        return 1

    try:
        renamed = apply_ops(ops, dry_run=args.dry_run, quiet=args.quiet, jobs=jobs)
    except OSError as exc:
        print(f"Rename failed: {exc}", file=sys.stderr)
        return 1

    mode = "Would rename" if args.dry_run else "Renamed"
    print(f"Done. {mode} {renamed} path(s).")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
# Change User Description Committed
#2 33219 C. Thomas Tyler chmod +x
#1 33211 Robert Cowham Updated with latest versons of files