Pythonでフォルダ内のファイル名を一括リネーム!規則に沿った整理術

フォルダ内のファイル名がバラバラで管理がつらい…そんなときに頼れるのが Pythonによる一括リネーム。この記事では中級エンジニア向けに、実務で役立つリネームパターン、安全対策(dry-run / バックアップ / 衝突回避)、コマンドラインツール化の方法まで、実用的な手順とサンプルコードを丁寧に解説します。


想定読者

  • Python(pathlib, re, argparse)が使える中級者
  • 業務で大量のログ・請求書・画像などファイル整理を自動化したい人
  • WordPressサイトなどでファイル名規則を統一したい人

1. 準備と基本方針

  • 標準ライブラリの pathlib, re, argparse, shutil, logging を使う(外部依存は最小限に)。
  • まずは dry-run(実行確認)を必須にする。実際に書き換えるのは --apply フラグが渡されたときだけ。
  • ファイルのタイムスタンプは原則維持する(もし変更するならオプションで)。
  • 名前衝突(同名ファイルが既に存在する)に対する回避ルールを実装する(番号付け、スキップ、上書き選択)。

2. よくあるリネームパターン

  • 一括プレフィックス/サフィックス追加
    例)report_ をすべてのファイルへ追加
  • 文字列置換(単純な検索 → 置換)
    例)draftfinal
  • 正規表現でのマッチ→置換(グループを利用)
    例)^(\d{4})(\d{2})(\d{2})_\1-\2-\3_
  • シーケンス番号付与(ゼロパディング)
    例)img_0001.jpg 形式
  • 日付情報をファイル名に付与(ファイルの最終更新日時を利用)
    例)2025-08-31_invoice.pdf
  • 拡張子の統一(大文字→小文字)
    例).JPG.jpg

3. 安全に実行するための設計

  • dry-run(デフォルト)で「変更前 → 変更後」のプレビューを出す。
  • ログを残す(成功/失敗/スキップ理由)。
  • バックアップオプション:失敗時に元に戻せるよう --backup-folder を作る。
  • 衝突解決:--on-conflict オプション(skip / overwrite / number)を用意。
  • テスト対象のファイル拡張子を絞る(例:.pdf, .jpg)ことで誤適用を防ぐ。
  • Gitなどで管理されているファイルは慎重に扱う(.gitignore 等を確認)。

4. 実践:汎用コマンドラインスクリプト(完全版)

以下は、上記の安全対策・機能を備えた汎用スクリプトのサンプルです。rename_tool.py として保存して使えます。

#!/usr/bin/env python3
"""
rename_tool.py
汎用ファイル一括リネームツール(dry-runがデフォルト)
使い方例:
  python rename_tool.py ./target_dir --pattern "draft" --replace "final" --ext ".pdf,.docx" --apply
  python rename_tool.py ./images --regex "^IMG_(\d+)\.JPG$" --format "img_{num:04d}.jpg" --apply
"""

import argparse
from pathlib import Path
import re
import shutil
import logging
from datetime import datetime

logging.basicConfig(level=logging.INFO, format='%(message)s')
logger = logging.getLogger("rename_tool")


def parse_args():
    p = argparse.ArgumentParser(description="汎用ファイル一括リネームツール")
    p.add_argument("target", type=str, help="対象フォルダ(非再帰にするなら --recursive をオフに)")
    p.add_argument("--recursive", action="store_true", help="サブフォルダも再帰的に処理する")
    p.add_argument("--ext", type=str, default="", help="処理対象の拡張子をカンマ区切りで指定(例: .pdf,.jpg)")
    p.add_argument("--pattern", type=str, help="単純置換の検索パターン(正規表現ではない)")
    p.add_argument("--replace", type=str, help="単純置換の置換文字列")
    p.add_argument("--regex", type=str, help="正規表現パターン(matchで使う)")
    p.add_argument("--format", type=str, help="正規表現のグループや番号を使ったフォーマット。例: 'img_{num:04d}.jpg' or '{g1}_{g2}.txt'")
    p.add_argument("--prefix", type=str, help="全ファイルに付けるプレフィックス")
    p.add_argument("--suffix", type=str, help="全ファイルに付けるサフィックス(拡張子の前)")
    p.add_argument("--date-from", choices=['mtime','ctime','atime'], default=None, help="ファイル日付を名前に使う(mtimeなど)")
    p.add_argument("--apply", action="store_true", help="実際にファイル名を変更するフラグ(指定がないとdry-run)")
    p.add_argument("--backup-folder", type=str, default="", help="適用時にバックアップを保存するフォルダ")
    p.add_argument("--on-conflict", choices=['skip','overwrite','number'], default='number', help="名前衝突時の動作")
    p.add_argument("--lower-ext", action="store_true", help="拡張子を小文字にする")
    return p.parse_args()


def get_candidates(target: Path, recursive: bool, exts):
    if recursive:
        files = target.rglob('*')
    else:
        files = target.iterdir()
    for f in files:
        if not f.is_file():
            continue
        if exts:
            if f.suffix.lower() not in exts:
                continue
        yield f


def build_new_name(file: Path, args, regex_compiled=None, seq_num=None):
    name = file.stem
    ext = file.suffix
    # date
    if args.date_from:
        ts = getattr(file.stat(), {
            'mtime': 'st_mtime',
            'ctime': 'st_ctime',
            'atime': 'st_atime'
        }[args.date_from])
        date_str = datetime.fromtimestamp(ts).strftime('%Y-%m-%d')
    else:
        date_str = None

    # regex
    if regex_compiled:
        m = regex_compiled.match(file.name)
        if m:
            groups = m.groups()
            # If format contains {num} use seq_num
            fmt = args.format or ""
            if '{num' in (fmt or "") and seq_num is not None:
                new_stem = fmt.format(num=seq_num, *groups, **{f"g{i+1}": g for i,g in enumerate(groups)})
            else:
                # allow {g1} {g2} ... or positional
                try:
                    new_stem = fmt.format(*groups, **{f"g{i+1}": g for i,g in enumerate(groups)})
                except Exception:
                    new_stem = name
        else:
            new_stem = name
    elif args.pattern:
        if args.replace is None:
            new_stem = name
        else:
            new_stem = name.replace(args.pattern, args.replace)
    else:
        new_stem = name

    if args.prefix:
        new_stem = f"{args.prefix}{new_stem}"
    if args.suffix:
        new_stem = f"{new_stem}{args.suffix}"
    if date_str:
        new_stem = f"{date_str}_{new_stem}"

    new_ext = ext
    if args.lower_ext:
        new_ext = ext.lower()

    return f"{new_stem}{new_ext}"


def resolve_conflict(target_path: Path, on_conflict: str):
    if not target_path.exists():
        return target_path
    if on_conflict == 'overwrite':
        return target_path
    if on_conflict == 'skip':
        return None
    # number
    parent = target_path.parent
    stem = target_path.stem
    ext = target_path.suffix
    i = 1
    while True:
        candidate = parent / f"{stem}_{i}{ext}"
        if not candidate.exists():
            return candidate
        i += 1


def main():
    args = parse_args()
    target = Path(args.target)
    if not target.exists() or not target.is_dir():
        logger.error("ターゲットが存在しないかディレクトリではありません: %s", args.target)
        return

    exts = {e.strip().lower() for e in args.ext.split(',') if e.strip()} if args.ext else set()
    regex_compiled = re.compile(args.regex) if args.regex else None

    files = list(get_candidates(target, args.recursive, exts))
    if not files:
        logger.info("処理対象ファイルが見つかりません。")
        return

    # Prepare backup
    backup_folder = Path(args.backup_folder) if args.backup_folder else None
    if args.apply and backup_folder:
        backup_folder.mkdir(parents=True, exist_ok=True)

    planned = []
    seq = 1
    for f in files:
        new_name = build_new_name(f, args, regex_compiled, seq_num=seq)
        if new_name == f.name:
            logger.debug("変更なし: %s", f)
            seq += 1
            continue
        planned.append((f, f.parent / new_name))
        seq += 1

    # Show preview
    logger.info("== プレビュー(%s件) ==", len(planned))
    for src, dst in planned:
        logger.info("%s -> %s", src.name, dst.name)

    if not args.apply:
        logger.info("dry-runモードです。実際に変更するには --apply を付けてください。")
        return

    # Apply
    for src, dst in planned:
        resolved = resolve_conflict(dst, args.on_conflict)
        if resolved is None:
            logger.info("スキップ(衝突): %s", dst.name)
            continue
        try:
            if backup_folder:
                shutil.copy2(src, backup_folder / src.name)
            src.rename(resolved)
            logger.info("リネーム: %s -> %s", src.name, resolved.name)
        except Exception as e:
            logger.error("失敗: %s (%s)", src, e)


if __name__ == "__main__":
    main()

5. 使い方例(実行例)

  1. プレフィックス追加(dry-run)
python rename_tool.py ./invoices --prefix "2025_" --ext ".pdf" 
  1. 実際に適用(バックアップを取る)
python rename_tool.py ./invoices --prefix "2025_" --ext ".pdf" --backup-folder ./backup_invoices --apply
  1. 正規表現で連番に(例:IMG_1234.JPGimg_0001.jpg 形式)
python rename_tool.py ./photos --regex "^IMG_(\d+)\.JPG$" --format "img_{num:04d}.jpg" --ext ".JPG" --lower-ext --apply
  1. ファイルの最終更新日を先頭に付ける
python rename_tool.py ./reports --date-from mtime --ext ".xlsx,.pdf" --apply

6. よくあるトラブルと対処法

  • 意図しないファイルも変わってしまった
    --ext で拡張子を限定、--recursive を外して検証。まずは dry-run でプレビュー必須。
  • 名前衝突で上書きされたくない
    --on-conflict number(デフォルト)で _1 のように回避。
  • ファイルのタイムスタンプが変わる
    shutil.copy2 でコピー → 元ファイル削除のフローはタイムスタンプを維持しない場合がある。rename() はタイムスタンプを保持する。
  • Windows のパス長問題
    → 長いパス名が問題なら短いフォルダに移して実行するか、UNC パス等の対応を検討。
  • 画像の撮影日時などEXIFをファイル名に使いたい
    Pillowexifread を使ってEXIFの DateTimeOriginal を取得し build_new_name に組み込む。

7. まとめ&チェックリスト

  • dry-runでまずプレビューを行ったか
  • 拡張子や処理対象を限定しているか(誤適用防止)
  • バックアップを取るオプションを用意しているか
  • 名前衝突の方針(skip/overwrite/number)を決めているか
  • 実行ログを残し、後で復元できるようにしているか
タイトルとURLをコピーしました