【Python】保存途中で元ファイルを壊さない:一時ファイルから置き換える

PythonのTopに戻る

完成版を書いてから名前を切り替える

既存ファイルをwモードで開いてから書き込みに失敗すると、元の内容を失うことがある。更新内容を同じフォルダーの一時ファイルへすべて書き、閉じた後でos.replace()により保存先へ切り替えれば、書き込み途中の内容を通常の読者へ見せる時間を減らせる。ここでは信頼できる作業フォルダーにある一つのテキストファイルを更新する。

原子的な置換と、停電しても保存内容が残る永続性は別である。また、複数ファイルをまとめて更新するトランザクションにもならない。この例は一時ディレクトリ内で正常な置換と、置換直前の失敗を確認する。実ファイルを更新する前にはバックアップと復旧方法を用意する。

一時ファイルを同じディレクトリに作る

example.py

from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
from unittest.mock import patch
import os


def replace_text(path, text):
    path = Path(path)
    if path.is_symlink():
        raise ValueError("symlink destination is not supported")
    temporary = None
    try:
        with NamedTemporaryFile(
            mode="w", encoding="utf-8", newline="\n",
            dir=path.parent, prefix=".write-", delete=False,
        ) as stream:
            temporary = Path(stream.name)
            stream.write(text)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)


with TemporaryDirectory() as directory:
    root = Path(directory)
    destination = root / "result.txt"
    destination.write_text("old\n", encoding="utf-8")
    with patch("os.replace", side_effect=OSError("simulated replacement failure")):
        try:
            replace_text(destination, "incomplete update\n")
        except OSError:
            pass
        else:
            raise AssertionError("failure was not raised")
    assert destination.read_text(encoding="utf-8") == "old\n"
    assert list(root.glob(".write-*")) == []
    print("failed update preserved:", destination.read_text().strip())
    replace_text(destination, "new\n")
    assert destination.read_text(encoding="utf-8") == "new\n"
    assert list(root.glob(".write-*")) == []
    print("successful update:", destination.read_text().strip())

実行結果

failed update preserved: old
successful update: new

flush・fsync・close・replaceの順序

write()の直後に、Python側のバッファに残っているデータをflush()で渡し、fsync()でファイルの同期を要求する。その後withを抜けてファイルを閉じ、os.replace()を呼ぶ。開いたまま置換できるかはOSや共有設定にも左右されるため、例では閉じてから切り替える順序にした。

一時ファイルをpath.parentへ作るのは、保存先と同じファイルシステム上での置換を狙うためである。別のボリュームにある一般の一時フォルダーから移そうとすると、置換が失敗することがある。os.replace()は移動先にファイルがあれば置き換える操作なので、「既存ファイルを絶対に上書きしない」目的には使わない。

finallyでは一時ファイルが残っていれば削除する。正常な置換後は一時名が存在しないためmissing_ok=Trueで扱う。例は自分が作った一時名だけを削除し、フォルダー全体の一括削除をしない。なお、一時ファイルの削除自体が権限などで失敗すれば、その例外も無視されない。実運用では元の例外と後始末の失敗の両方を記録できるとよい。

どこまで保護できるか

置換前に失敗すれば、元の保存先の内容を保持できる。例はos.replaceをmockで失敗させ、この状態を確認している。ただしOSがプロセスを強制終了した場合にはfinallyが動かず、一時ファイルが残ることがある。起動時に古い一時ファイルを整理する場合も、名前だけで無差別に削除せず、自分の処理が管理するものかを確認する。

fsync()を呼んだだけで、すべての環境の停電耐性が保証されるわけではない。ディレクトリエントリーの同期やストレージの振る舞いなども関わる。ネットワークファイルシステムの意味もローカルと同一とは限らない。強い永続性が必要な用途では、OS・ファイルシステムに合わせた手順やデータベースの利用を検討する。

一時ファイルを使うため、元ファイルのアクセス権、所有者、拡張属性などをそのまま継承するわけではない。必要なメタデータがあるなら、置換前に適切な権限で設定する設計が必要である。同じ保存先へ複数のプロセスが書けば、後の置換が先の結果を上書きする。原子的な切り替えは競合する更新の調停ではない。

リンクと並行変更の前提

例では保存先がシンボリックリンクなら拒否するが、判定後に他者が差し替える状況までは防げない。親フォルダーも含め、信頼できる場所を使う前提である。外部入力の名前を保存先へ使う場合は、基準フォルダーの外へ出ない検査や、敵対的な並行操作を考慮した別の防御が必要になる。

実行環境と関連情報

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

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

PythonのTopに戻る