🔒

[Python, Windows] open 中のファイルを削除・移動できない問題への対処

に公開

Windows では open 中のファイルを消せない

次のコードで、 input で処理を止めている間に text.txt を削除してみましょう。

with open("./test.txt", "w") as f:
    # ここで test.txt を消す
    input()

Linux ではファイルを削除できますが、 Windows ではエラーになります。

ファイルの移動も同様です。
この問題をなんとかしましょう。

Windows は使用中ファイルの削除を禁止していない

実は、 Windows でも使用中のファイルを消せる場合があります。

Windows でファイルを開く際に使う CreateFileW のドキュメントを見てみましょう。引数 dwShareMode について、次のように書かれています。

[in] dwShareMode

ファイルまたはデバイスの要求された共有モード。読み取り、書き込み、両方、削除、これらすべて、またはなしを指定できます (次の表を参照)。 属性または拡張属性へのアクセス要求は、このフラグの影響を受けません。

dwShareMode に適切なフラグを渡せば、使用中のファイルを削除・移動できるのです。

Python の open は削除許可フラグをセットしない

なぜ Python で open 中のファイルは消せないのでしょうか。
CPython における open の実装を見てみましょう (該当コード)。

fileio.c
#ifdef MS_WINDOWS
    self->fd = _wopen(widename, flags, 0666);
#else
    self->fd = open(name, flags, 0666);
#endif

_wopen が呼ばれています。
では、 _wopen の定義[1]を見てみましょう。

corecrt_wio.h
inline int __CRTDECL _wopen(
    _In_z_ wchar_t const* _FileName,
    _In_   int            _OFlag,
    _In_   int            _PMode = 0
    )
{
    int _FileHandle;
    // Last parameter passed as 0 because we don't want to validate pmode from _open
    errno_t const _Result = _wsopen_dispatch(_FileName, _OFlag, _SH_DENYNO, _PMode, &_FileHandle, 0);
    return _Result ? -1 : _FileHandle;
}

_SH_DENYNO が目につきます。これは共有モード変数と呼ばれています。CreateFileWdwShareMode では、読み・書き・削除を設定できましたが、こちらはどうでしょうか。

残念なことに、共有モード変数では、読み・書きの設定しかできません。 _SH_DENYNO の場合、読み・書きは許可されますが、削除は許可されません[2]

つまり、 CPython の提供する open では、削除を許可するフラグがセットされないのです。

ではどうすればよいのか

CreateFileW 関数に適切なフラグを渡せば削除を許可できます。ならば、 open の実装をそのように上書きしてしまえばよいのです[3]

ありがたいことに、 open 関数には opener という引数があります。 opener に、「pathflags を受け取ってファイルディスクリプタを返す処理」を渡すと、 open がそれを使ってファイルを開いてくれます。これを使いましょう。

具体的には次のようにします。
(フラグの設定やエラー処理等に問題がありましたら、コメントいただけると幸いです。)

import sys

if sys.platform == "win32":
    import os
    import ctypes
    import builtins

    #----------------------------------
    # Win32API の関数を使う準備
    #----------------------------------

    from ctypes.wintypes import HANDLE, LPCWSTR, DWORD
    from ctypes import c_void_p, c_bool, POINTER
    import msvcrt

    GENERIC_READ = 0x80000000
    GENERIC_WRITE = 0x40000000

    FILE_SHARE_READ = 0x00000001
    FILE_SHARE_WRITE = 0x00000002
    FILE_SHARE_DELETE = 0x00000004

    CREATE_NEW = 1
    CREATE_ALWAYS = 2
    OPEN_EXISTING = 3
    OPEN_ALWAYS = 4

    FILE_ATTRIBUTE_NORMAL = 0x00000080

    CreateFileW = ctypes.windll.kernel32.CreateFileW
    CreateFileW.argtypes = [LPCWSTR, DWORD, DWORD, c_void_p, DWORD, DWORD, HANDLE]
    CreateFileW.restype = HANDLE
    INVALID_HANDLE_VALUE = HANDLE(-1).value

    CloseHandle = ctypes.windll.kernel32.CloseHandle
    CloseHandle.argtypes = [HANDLE]
    CloseHandle.restype = c_bool

    #----------------------------------
    # CreateFileW でファイルを開く関数 
    #----------------------------------

    def custom_opener(path, flags):
        match flags & 0b11:
            case os.O_RDONLY: # 0
                access = GENERIC_READ
            case os.O_WRONLY: # 1
                access = GENERIC_WRITE
            case os.O_RDWR: # 2
                access = GENERIC_READ | GENERIC_WRITE
            case _:
                raise OSError(f'Unexpected flags: {flags}')

        if flags & os.O_CREAT:
            if flags & os.O_TRUNC: # w 
                create = CREATE_ALWAYS
            elif flags & os.O_APPEND: # a
                create = OPEN_ALWAYS
            elif flags & os.O_EXCL: # x
                create = CREATE_NEW
            else:
                raise OSError(f'Unexpected flags: {flags}')
        else: # r
            create = OPEN_EXISTING

        handle = CreateFileW(
            path,
            access,
            FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
            None,
            create,
            FILE_ATTRIBUTE_NORMAL,
            None,
        )
        if handle == INVALID_HANDLE_VALUE:
            raise ctypes.WinError()

        # この fd は _close で閉じなくてはならない。
        # handle に対して CloseHandle を呼んではならない。
        # CPython の builtin.close は _close (のエイリアスである close) で閉じてくれる。
        # - https://learn.microsoft.com/ja-jp/cpp/c-runtime-library/reference/posix-close?view=msvc-170        
        # - https://github.com/python/cpython/blob/31c9f3ced293492b38e784c17c4befe425da5dab/Modules/_io/fileio.c#L128 
        fd = msvcrt.open_osfhandle(handle, flags)
        if fd == -1:
            if CloseHandle(handle) != 0:
                raise ctypes.WinError()
            raise OSError('Failed to convert handle to fd.')

        # Append の場合はファイル末尾にシークする
        if flags & os.O_APPEND:
            os.lseek(fd, 0, os.SEEK_END)

        return fd

    #----------------------------------
    # open の実装を上書き
    #----------------------------------

    original_open = builtins.open

    def patched_open(file, mode="r", buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=custom_opener):
        return original_open(file, mode, buffering, encoding, errors, newline, closefd, opener)

    builtins.open = patched_open

if __name__ == "__main__":
    with open("test.txt", "w") as f:
        # test.txt を削除・移動できる
        input()

これで、 open 中のファイルを削除・移動できるようになりました。

まとめ

Windows 環境において、 Python で open 中のファイルを削除・移動できない問題と、その対処法を紹介しました。移植等で挙動の違いに悩んでいる人の助けになればと思います。

脚注
  1. Visual Studio の定義ジャンプを使うと見ることができます。 ↩︎

  2. このことはドキュメントには明記されていませんが、 Micorosoft のブログ で言及されています。 ↩︎

  3. モンキーパッチと呼ばれる手法です。 ↩︎

Discussion