【Python】大量ファイルの名前を安全に変える:事前確認と衝突検出

PythonのTopに戻る

名前を変える前に計画を完成させる

大量のファイルを順番にrename()すると、途中で同名が見つかったり、a.txtとb.txtの交換で片方を上書きしたりするおそれがある。最初に旧名と新名の対応をすべて作り、重複・既存名・対象外のパスを検査する。その後、いったん専用の仮置きフォルダーへ移してから最終名へ変えると、名前の交換にも対応できる。

以下は信頼できる単独作業フォルダー向けの小さな例で、既定はdry_run=Trueである。ファイル名を一階層のASCII英数字などに限定し、シンボリックリンクとフォルダーは変更しない。実際に名前を変える呼び出しも、例が作ったTemporaryDirectoryの中だけで行う。複数ファイルの一括変更は原子的ではなく、完全な自動復旧も保証しない。

衝突を調べ、交換は仮置きしてから行う

example.py

from pathlib import Path
from tempfile import TemporaryDirectory, mkdtemp
import re


def rename_batch(root, mapping, *, dry_run=True):
    root = Path(root)
    plan = list(mapping.items())
    for old, new in plan:
        for name in (old, new):
            if not re.fullmatch(r"[A-Za-z0-9_.-]+", name) or name in {".", ".."}:
                raise ValueError("use a simple single-component name")
        source = root / old
        if source.is_symlink() or not source.is_file():
            raise ValueError("source must be a regular non-symlink file")
    target_keys = [new.casefold() for _, new in plan]
    if len(target_keys) != len(set(target_keys)):
        raise ValueError("duplicate destinations")
    sources = {old for old, _ in plan}
    for existing in root.iterdir():
        if existing.name.casefold() in target_keys and existing.name not in sources:
            raise FileExistsError(existing.name)
    if dry_run or not plan:
        return plan
    staging = Path(mkdtemp(prefix=".rename-", dir=root))
    try:
        for index, (old, _) in enumerate(plan):
            (root / old).rename(staging / str(index))
        for index, (_, new) in enumerate(plan):
            (staging / str(index)).rename(root / new)
    except OSError as error:
        raise RuntimeError("partial rename; inspect files and staging directory") from error
    else:
        staging.rmdir()
    return plan


with TemporaryDirectory() as temporary:
    root = Path(temporary)
    (root / "a.txt").write_text("A", encoding="utf-8")
    (root / "b.txt").write_text("B", encoding="utf-8")
    mapping = {"a.txt": "b.txt", "b.txt": "a.txt"}
    print("plan:", rename_batch(root, mapping))
    assert (root / "a.txt").read_text() == "A"
    for invalid in ({"a.txt": "b.txt"}, {"a.txt": "x.txt", "b.txt": "x.txt"}):
        try:
            rename_batch(root, invalid)
        except (ValueError, FileExistsError):
            pass
        else:
            raise AssertionError("collision was not detected")
    rename_batch(root, mapping, dry_run=False)
    assert (root / "a.txt").read_text() == "B"
    assert (root / "b.txt").read_text() == "A"
    assert sorted(path.name for path in root.iterdir()) == ["a.txt", "b.txt"]
    print("after:", (root / "a.txt").read_text(), (root / "b.txt").read_text())

実行結果

plan: [('a.txt', 'b.txt'), ('b.txt', 'a.txt')]
after: B A

既存名と交換対象を区別する

新名が重複していれば二つの入力を一つの名前へ置けないので、処理を始める前に拒否する。大文字小文字を区別しない環境も考え、casefold()した新名でも重複を確認している。ただし、これだけで全OSの予約名やファイルシステムごとの照合規則を完全に再現するものではない。

保存先の名前がすでにあっても、それ自身が今回の移動元なら交換の可能性がある。aからb、bからaの両方が計画にあれば受け入れ、aからbだけなら既存のbを守るため拒否する。計画を逐次実行する前に全件を確認する理由がここにある。

仮置きフォルダーには数字の名前でいったん全ファイルを移す。元の名前がすべて空いた後で最終名へ戻すため、交換や循環した対応でも順番による衝突を避けられる。仮置き先は同じrootの中に作り、別のファイルシステムへの移動を前提にしない。

途中で失敗したら、消さずに状況を確認する

一つ一つのrenameが成功しても、全体の途中で容量・権限・外部変更などにより失敗する可能性がある。例はその場合にRuntimeErrorを送出し、残った仮置きフォルダーを自動削除しない。中にまだ元データがあるかもしれないためである。実運用では計画と進行状況を記録し、残った各ファイルを確認してから復旧する。

自動ロールバックも別のrenameの集まりなので、それ自体が失敗しうる。失敗したからといって無条件で逆順に戻せば必ず元どおり、と説明できる処理ではない。重要なデータでは実行前のバックアップが必要であり、まず少数の複製データで確認する。

他のプロセスが同じフォルダーを変更しないことが前提である。事前検査後に新しいファイルが作られると、OSによってはrenameがそれを置き換える可能性がある。dry-runで問題がなかったことは、後の実行に対するロックではない。共有フォルダーでは作業時間を分けるなど、更新を排他する仕組みを別に設ける。

名前の変更が与える影響

他のプログラムや文書が旧名を参照していれば、ファイルの内容が変わらなくても参照が切れる。解析順を変えたいだけなら、ファイル名を変えずソートキーを使う方が適していることもある。変更が必要な場合は対応表を保存し、変更対象の件数、変わらない名前、既存名との衝突を確認してから実行する。

計画が変わらないことも確かめる

例ではdry-run後にa.txtの内容がAのままであることを調べ、既存名への上書きと新名の重複の二種類を拒否している。実行後には内容が正しく交換されたことだけでなく、仮置きフォルダーが残っていないことも確認する。大量処理へ広げる際は、新名の生成と変更の実行を分離し、生成した計画を人が確認できる形で保存しておくとよい。保存した計画を実行する直前にも、対象の状態が変わっていないか再検査する。

実行環境と関連情報

掲載コードはLinux上のCPython 3.12.14で実行した。OS固有のファイル操作や対話環境の違いは、本文に記した条件に従って扱う。

関連:file2よりfile10が先に来る?番号付きファイルを自然順に並べる / 保存途中で元ファイルを壊さない:一時ファイルから置き換える / 保存先が指定フォルダーの外に出ないようにする:パスの検証

PythonのTopに戻る