156 lines
4.0 KiB
Python
156 lines
4.0 KiB
Python
|
#! /usr/bin/env python3
|
||
|
# -*- coding: utf-8 -*-
|
||
|
import argparse
|
||
|
import re
|
||
|
from pathlib import Path
|
||
|
from typing import Generator
|
||
|
from typing import List
|
||
|
from typing import Optional
|
||
|
from typing import Tuple
|
||
|
|
||
|
|
||
|
num_matcher = re.compile(r"([0-9]+)")
|
||
|
|
||
|
|
||
|
def extract_numbers(s: str) -> str:
|
||
|
number_match = num_matcher.search(s)
|
||
|
if number_match:
|
||
|
return number_match.group(1)
|
||
|
return ""
|
||
|
|
||
|
|
||
|
def calc_pad_length(files: List[Path]) -> int:
|
||
|
max_len = 0
|
||
|
for f in files:
|
||
|
numbers = extract_numbers(f.name)
|
||
|
max_len = max(len(numbers), max_len)
|
||
|
return max_len
|
||
|
|
||
|
|
||
|
def parse_args(sys_args: Optional[List[str]] = None) -> argparse.Namespace:
|
||
|
parser = argparse.ArgumentParser(
|
||
|
description="Pads numbers in file names so they consistently align and sort"
|
||
|
)
|
||
|
parser.add_argument("files", nargs="+", metavar="file", help="Files to be renamed")
|
||
|
parser.add_argument(
|
||
|
"-l",
|
||
|
"--length",
|
||
|
type=int,
|
||
|
help="Length of numbers after padding (default: auto)",
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"-f",
|
||
|
"--force",
|
||
|
action="store_true",
|
||
|
help="Force rename, even if file at destination exists",
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"-v", "--verbose", action="store_true", help="Print all actions"
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"-d",
|
||
|
"--dry-run",
|
||
|
action="store_true",
|
||
|
help="Print actions only without modifying any file. Implies --verbose",
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"-i",
|
||
|
"--ignore",
|
||
|
metavar="REGEX",
|
||
|
help="Regular expression used to ignore files matching the name",
|
||
|
)
|
||
|
parser.add_argument(
|
||
|
"--ignore-files",
|
||
|
nargs="+",
|
||
|
metavar="IGNOREFILE",
|
||
|
help="Files to ignore for renaming. Must add -- before positional arguments",
|
||
|
)
|
||
|
args = parser.parse_args(sys_args)
|
||
|
|
||
|
# Dry run implies verbose
|
||
|
if args.dry_run:
|
||
|
args.verbose = True
|
||
|
|
||
|
return args
|
||
|
|
||
|
|
||
|
def get_files(
|
||
|
filenames: List[str],
|
||
|
ignore_filenames: List[str],
|
||
|
ignore_pattern: str,
|
||
|
) -> List[Path]:
|
||
|
# Compile ignore pattern, if provided
|
||
|
ignore_matcher: Optional[re.Pattern] = None
|
||
|
if ignore_pattern:
|
||
|
ignore_matcher = re.compile(ignore_pattern)
|
||
|
|
||
|
p = Path(".")
|
||
|
files: List[Path] = []
|
||
|
for f in filenames:
|
||
|
if ignore_filenames and f in ignore_filenames:
|
||
|
continue
|
||
|
if ignore_matcher and ignore_matcher.match(f):
|
||
|
continue
|
||
|
files.append(p / f)
|
||
|
|
||
|
return files
|
||
|
|
||
|
|
||
|
def pad_files(
|
||
|
files: List[Path],
|
||
|
pad_len: int,
|
||
|
verbose=False,
|
||
|
) -> Generator[Tuple[Path, Path], None, None]:
|
||
|
for f in files:
|
||
|
numbers = extract_numbers(f.name)
|
||
|
if len(numbers) == pad_len:
|
||
|
if verbose:
|
||
|
print(f"{f.name} is already padded.")
|
||
|
continue
|
||
|
|
||
|
# Pad number and get destination path
|
||
|
new_numbers = numbers.zfill(pad_len)
|
||
|
new_name = num_matcher.sub(new_numbers, f.name, count=1)
|
||
|
new_file = f.parent / new_name
|
||
|
|
||
|
if f == new_file:
|
||
|
if verbose:
|
||
|
print(f"{f.name} already matches destination.")
|
||
|
continue
|
||
|
|
||
|
yield f, new_file
|
||
|
|
||
|
|
||
|
def main(sys_args: Optional[List[str]] = None) -> int:
|
||
|
args = parse_args(sys_args)
|
||
|
|
||
|
# Build list of files to act on
|
||
|
files = get_files(args.files, args.ignore_files, args.ignore)
|
||
|
|
||
|
pad_len = args.length
|
||
|
if pad_len is None:
|
||
|
pad_len = calc_pad_length(files)
|
||
|
|
||
|
if args.verbose:
|
||
|
print(f"Padding to {pad_len}")
|
||
|
|
||
|
status = 0
|
||
|
for f, new_file in pad_files(files, pad_len, args.verbose):
|
||
|
# Possibly rename unless exists or forced
|
||
|
if not new_file.exists() or args.force:
|
||
|
if args.verbose:
|
||
|
print(f"Rename {f.name} to {new_file.name}")
|
||
|
if not args.dry_run:
|
||
|
f.rename(new_file)
|
||
|
else:
|
||
|
print(
|
||
|
f"Could not rename {f.name} to {new_file.name}. Destination file exists."
|
||
|
)
|
||
|
status = 1
|
||
|
|
||
|
return status
|
||
|
|
||
|
|
||
|
if __name__ == "__main__":
|
||
|
exit(main())
|