0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Pythonのshutil.make_archiveはスレッドセーフではない?

Last updated at Posted at 2020-04-08

OS: Windows10(64-bit)
Python: 3.6.8(64-bit)

タイトルの通り

PythonでZip圧縮する際によく使用するのがshutilモジュールのmake_archive。
結論から言うと、root_dir引数を指定するとスレッドセーフではなくなります。

例えば、以下のディレクトリ構成でuser1とuser2をZip圧縮する場合、
dir.PNG

simple(sync).py
import shutil
import os

def make_zip(user):
    user_dir = os.path.join('workDir', user)
    shutil.make_archive(user_dir, "zip", root_dir=user_dir)

if __name__ == '__main__':
    make_zip('user1')
    make_zip('user2')

上のコードを実行するとworkDirディレクトリにuser1.zipとuser2.zipが作成されます。
問題なく動きます。

マルチスレッドで動かしてみる

simple(thread).py
import shutil
import os
import threading

def make_zip(user):
    user_dir = os.path.join('workDir', user)
    shutil.make_archive(user_dir, "zip", root_dir=user_dir)

if __name__ == '__main__':
    # 各スレッドを作成
    thread_1 = threading.Thread(target=lambda: make_zip('user1'))
    thread_2 = threading.Thread(target=lambda: make_zip('user2'))

    # 各スレッドを開始
    thread_1.start()
    thread_2.start()

    # 各スレッドが終了するまで待機
    thread_1.join()
    thread_2.join()

上のコードを実行すると例外が送出されます。何故?

FileNotFoundError: [WinError 3] 指定されたパスが見つかりません。: 'workDir\\user2'

make_archiveが一時的にカレントディレクトリを変更して処理を行っているからです。

shutil.py(抜粋)
    save_cwd = os.getcwd()
    if root_dir is not None:
        if logger is not None:
            logger.debug("changing into '%s'", root_dir)
        base_name = os.path.abspath(base_name)
        if not dry_run:
            os.chdir(root_dir)

       ~ 中略 ~

    try:
        filename = func(base_name, base_dir, **kwargs)
    finally:
        if root_dir is not None:
            if logger is not None:
                logger.debug("changing back to '%s'", save_cwd)
            os.chdir(save_cwd)
  1. 最初のmake_archiveでカレントディレクトリを'./workDir/user1'に変更
  2. 次のmake_archiveで'./workDir/user2'に変更しようとするが、既にカレントディレクトリが'./workDir/user1'になっているので'./workDir/user1/workDir/user2'に変更しようとしてしまいパスが見つからず例外送出

root_dir引数を絶対パスで指定したらいいんじゃない?

相対パスではなく絶対パスにすれば例外が出なくなるのでは?と思い試してみる

simple_abs(thread).py
import shutil
import os
import threading

def make_zip(cwd, user):
    # 絶対パスになるように指定
    work_dir = os.path.join(cwd, 'workDir', user)
    shutil.make_archive(work_dir, "zip", root_dir=work_dir)

if __name__ == '__main__':
    # カレントディレクトリを保持
    cwd = os.getcwd()

    # 各スレッドを作成
    thread_1 = threading.Thread(target=lambda: make_zip(cwd, 'user1'))
    thread_2 = threading.Thread(target=lambda: make_zip(cwd, 'user2'))

    # 各スレッドを開始
    thread_1.start()
    thread_2.start()

    # 各スレッドが終了するまで待機
    thread_1.join()
    thread_2.join()

    print(cwd, '', os.getcwd(), 'になりました')
D:\Python\zip_test が D:\Python\zip_test\workDir\user1 になりました

Zipファイルは作成されますがuser2.zipの中にuser1ディレクトリのファイルが含まれることがありました。
また、処理後にカレントディレクトリが正しく戻りません。
※Webサーバーなんかだと急にhtmlファイルを参照できなくなったりしてビビります。

解決方法

shutilモジュールの代わりにzipfileモジュールを使います。

zipfile.py
import zipfile
import os

def make_zipfile(input_path, output_path):
    input_path = os.path.abspath(input_path)

    # input_pathが存在しない場合の処理
    if not os.path.exists(input_path):
        raise FileNotFoundError('指定されたファイル、ディレクトリが存在しませんでした')

    # output_pathが存在しない場合は作成
    os.makedirs(os.path.dirname(output_path), exist_ok=True)

    with zipfile.ZipFile(file=output_path, mode='w') as z:
        # input_pathがファイルだった場合の処理
        if os.path.isfile(input_path):
            z.write(
                filename=input_path,
                arcname=os.path.basename(input_path)
            )
        # input_pathがディレクトリだった場合の処理
        elif os.path.isdir(input_path):
            def _nest(_path):
                for x in os.listdir(_path):
                    y = os.path.join(_path, x)
                    z.write(
                        filename=y,
                        arcname=y.replace(input_path, '')
                    )
                    # ディレクトリの場合は再帰
                    if os.path.isdir(y):
                        _nest(y)

            _nest(input_path)

if __name__ == '__main__':
    make_zipfile(
        input_path=os.path.join('workDir', 'user1'),
        output_path=os.path.join('workDir', 'user1.zip')
    )
    make_zipfile(
        input_path=os.path.join('workDir', 'user2'),
        output_path=os.path.join('workDir', 'user2.zip')
    )

shutil.make_archive()と同じ引数を受け取り、同じ動作をさせる場合

make_zipfile.py
import zipfile
import os

def make_zipfile(base_name, format, root_dir='.', base_dir=''):
    if format != 'zip':
        raise ValueError("unknown archive format '%s'" % format)
    filename = os.path.abspath(base_name + '.' + format)

    # 出力ディレクトリが存在しない場合は作成
    os.makedirs(os.path.dirname(filename), exist_ok=True)

    root_dir = os.path.abspath(root_dir)
    with zipfile.ZipFile(file=filename, mode='w', compression=zipfile.ZIP_DEFLATED) as z:
        def _nest(_path):
            for x in os.listdir(_path):
                y = os.path.join(_path, x)
                z.write(
                    filename=y,
                    arcname=y.replace(root_dir, '')
                )
                # ディレクトリの場合は再帰
                if os.path.isdir(y):
                    _nest(y)

        _nest(os.path.join(root_dir, base_dir))
    return filename

if __name__ == '__main__':
    # shutil.make_archive()と同じ結果になる
    make_zipfile(
        os.path.join('workDir', 'user1'),
        'zip',
        root_dir='workDir',
        base_dir='user1'
    )
0
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?