【Python】必要なファイルだけ複製する:shutilで作業フォルダーをコピー

PythonのTopに戻る

解析用の複製から不要なファイルを除く

元データを残したまま試行用の作業フォルダーを作りたいなら、shutil.copytree()で複製できる。すべてを無条件にコピーすると、一時ファイルやキャッシュ、リンク先の大量データまで混ざることがある。除外規則とコピー先の扱いを決め、元のフォルダーを変更しない状態で作業用の複製を作る。

以下は.tmpで終わる項目、__pycache__、シンボリックリンクを除外する例である。コピー先は存在していないことを条件とし、既存フォルダーへの混在や上書きを認めない。例の入力と出力はすべてTemporaryDirectoryに作るので、実ファイルの複製や削除は行わない。

ignore関数で除外する名前を返す

example.py

from pathlib import Path
from tempfile import TemporaryDirectory
import shutil


def ignore_work_files(directory, names):
    return {
        name for name in names
        if name == "__pycache__"
        or name.endswith(".tmp")
        or (Path(directory) / name).is_symlink()
    }


with TemporaryDirectory() as temporary:
    root = Path(temporary)
    source, destination = root / "source", root / "work"
    (source / "nested").mkdir(parents=True)
    (source / "__pycache__").mkdir()
    for name, content in {"data.csv": "x\n1\n", "scratch.tmp": "temporary", "nested/note.txt": "note"}.items():
        (source / name).write_text(content, encoding="utf-8")
    (root / "outside.txt").write_text("not part of source", encoding="utf-8")
    (source / "linked.txt").symlink_to(root / "outside.txt")
    shutil.copytree(source, destination, ignore=ignore_work_files, copy_function=shutil.copy2)
    copied = sorted(path.relative_to(destination).as_posix() for path in destination.rglob("*") if path.is_file())
    assert copied == ["data.csv", "nested/note.txt"]
    assert not (destination / "linked.txt").exists()
    (destination / "data.csv").write_text("changed\n", encoding="utf-8")
    assert (source / "data.csv").read_text() == "x\n1\n"
    try:
        shutil.copytree(source, destination, ignore=ignore_work_files)
    except FileExistsError:
        print("existing destination rejected")
    else:
        raise AssertionError("existing destination was accepted")
    print(copied)

実行結果

existing destination rejected
['data.csv', 'nested/note.txt']

除外関数はフォルダーごとに呼ばれる

ignore関数は、その時点のフォルダー名と中にある項目名の一覧を受け取り、除外したい名前の集合を返す。ここで返すのは完全なパスではなく、そのフォルダー内の名前である。__pycache__というディレクトリ自体を除外すれば、その中へ降りてコピーしない。

name.endswith(".tmp")は大文字小文字を区別するため、.TMPは除外されない。拡張子を大文字小文字なしで判断したいならcasefold()を使うなど、規則を明示する。除外するつもりで元のフォルダーから削除する必要はない。コピーする項目を選ぶ処理と、元データを整理する処理は分けておく方が安全である。

copytree()の既定では既存のコピー先を受け入れない。dirs_exist_ok=Trueを使うと既存ディレクトリへ混ぜられるが、同名ファイルを上書きする可能性があるため、この例では使わない。前回の作業結果があるなら別名のコピー先を用意し、意図しない混在を避ける。

シンボリックリンクとメタデータ

copytree()は設定によってシンボリックリンク先の内容をコピーしたり、リンクを保持したりできる。ここでは分かりやすくすべてのリンクをignoreで除外した。例のlinked.txtはsourceの外を指しているが、コピー先へ内容は入らない。このテストはLinuxで実行したもので、Windowsのリンク作成権限などは別途確認が必要である。

この除外判定と実際のコピーの間に項目が差し替わる可能性までは防いでいない。信頼できる、処理中に変更されない入力フォルダーを前提にする。第三者が同時に変更できるディレクトリを境界の外へ出ないよう複製する用途では、別の安全設計が必要になる。

copy2()は内容に加えて日時や権限などのメタデータの保存を試みるが、所有者、ACL、拡張属性などを含むすべてを完全に複製するとは限らない。OS・ファイルシステム・権限によって範囲が異なる。復元要件の厳しいバックアップをcopytreeとcopy2だけで代替できると考えず、必要な属性を実際に確認する。

コピーの成功と完全性を確認する

例では一覧が期待した2ファイルだけであることと、コピー先を変更しても元の内容が変わらないことを確認した。重要な複製では、件数だけでなくサイズやハッシュも照合すると内容の確認になる。ただしコピー中に元のファイルが変更されれば、フォルダー全体が同じ時点の状態になるとは限らない。

途中で容量不足や読み込みエラーが起きると、コピー先に一部だけ残る場合がある。既存データと混ぜない作りにしておけば、その不完全なコピー先を特定しやすい。成功が確認できるまで解析の入力として使わず、エラー内容と残ったファイルを見てから再実行する。勝手に全体を消す後始末を加える場合には、削除対象が今回作成した領域だけかを必ず確かめる。

実行環境と関連情報

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

関連:作業用ファイルを残さない:TemporaryDirectoryで一時領域を管理する / 大量ファイルの名前を安全に変える:事前確認と衝突検出

PythonのTopに戻る