CREATIVEWITY / SCRIPT SOURCEPython 3.9+ / standard library / MIT

Readable source

#!/usr/bin/env python3
"""Plan or apply a collision-safe numbered rename. Python 3.9+, no dependencies."""
import argparse,json,uuid
from pathlib import Path
def plan(folder,pattern,prefix,start,digits):
    files=sorted((p for p in folder.glob(pattern) if p.is_file() and not p.is_symlink()),key=lambda p:p.name.casefold())
    pairs=[(p,folder/(prefix+str(start+i).zfill(digits)+p.suffix.lower())) for i,p in enumerate(files)]
    sources={p.resolve() for p in files};seen=set()
    for src,dst in pairs:
        key=dst.name.casefold()
        if key in seen or (dst.exists() and dst.resolve() not in sources): raise ValueError('Destination collision: '+dst.name)
        if '/' in dst.name or '\\' in dst.name: raise ValueError('Invalid destination name')
        seen.add(key)
    return pairs
def apply(pairs):
    states=[]
    try:
        for src,dst in pairs:
            temp=src.with_name('.cw-rename-'+uuid.uuid4().hex+src.suffix)
            src.rename(temp);states.append([src,dst,temp,'temp'])
        for row in states:
            src,dst,temp,_=row
            if dst.exists(): raise ValueError('A new collision appeared: '+str(dst))
            temp.rename(dst);row[3]='target'
    except Exception:
        for row in reversed(states):
            if row[3]=='target':row[1].rename(row[2]);row[3]='temp'
        for src,dst,temp,state in reversed(states):temp.rename(src)
        raise
def main():
    p=argparse.ArgumentParser(description=__doc__);p.add_argument('folder',type=Path);p.add_argument('--pattern',default='*');p.add_argument('--prefix',default='asset-');p.add_argument('--start',type=int,default=1);p.add_argument('--digits',type=int,default=3);p.add_argument('--apply',action='store_true');a=p.parse_args()
    if not a.folder.is_dir():p.error('Folder does not exist.')
    if any(c in a.prefix for c in '/\\') or a.start<0 or not 1<=a.digits<=12:p.error('Invalid prefix, start or digits.')
    try:
        pairs=plan(a.folder.resolve(),a.pattern,a.prefix,a.start,a.digits)
        print(json.dumps({'mode':'apply' if a.apply else 'dry-run','renames':[{'from':s.name,'to':d.name} for s,d in pairs]},indent=2))
        if a.apply:apply(pairs)
    except (ValueError,OSError) as e:p.error(str(e))
if __name__=='__main__':main()