特定のディレクトリ直下にあるファイルの名前を連番に変更したいとき、意外にうまくいかないケースがあります。
具体的には、不定長さの文字列として数字が入っているとソートが効かない場合。
例えばWindowsで考えなしにファイルを作った時、Explorerではこう↓表示されるのですが、単純にos.listdir()
でリスト化してソートすると、期待通りになりません。
まあキーを指定していないので当然です。Pythonが悪いわけではありません。
1 2 3 4 5 6 7 8 9 | >>> for item in sorted([x for x in os.listdir("C:/path/to/dir")]): ... print(item) ... 新しいテキスト ドキュメント (10).txt 新しいテキスト ドキュメント (11).txt 新しいテキスト ドキュメント (2).txt 新しいテキスト ドキュメント (3).txt 新しいテキスト ドキュメント (9).txt 新しいテキスト ドキュメント.txt |
今回はそんなWindowsが命名したファイル名をちゃんとソートするスクリプトを書いてみました。
まずはサンプルから。環境はWindows10 + Python3.6です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | import os import re import shutil import traceback def rename_files(dir_path, base_name="file"): # extract number from file name def _get_number(file_name): nums = re.findall(r'[0-9]+', file_name) if nums != []: return int(nums[-1]) else: return -1 # get file names files = sorted([x for x in os.listdir(dir_path) if os.path.isfile(os.path.join(dir_path, x))]) # sort file path by included number files.sort(key=_get_number) # rename with serial number for i in range(len(files)): renamed = "{}-{:04d}.{}".format(base_name, i, files[i].split(".")[-1]) # debug print print("{}\t-[{}]-> \t{}".format(files[i], _get_number(files[i]), renamed)) # change file name shutil.move(os.path.join( dir_path, files[i]), os.path.join(dir_path, renamed)) if __name__ == "__main__": source_dir = "C:/path/to/dir" try: rename_files(source_dir) except: print(traceback.format_exc()) |
実行結果はこんな感じ。数字部分の長さに関わらず、ちゃんと解釈できていますね。
1 2 3 4 5 6 7 8 | > python \serialnum.py 新しいテキスト ドキュメント.txt -[-1]-> file-0000.txt 新しいテキスト ドキュメント (2).txt -[2]-> file-0001.txt 新しいテキスト ドキュメント (3).txt -[3]-> file-0002.txt 新しいテキスト ドキュメント (9).txt -[9]-> file-0003.txt 新しいテキスト ドキュメント (10).txt -[10]-> file-0004.txt 新しいテキスト ドキュメント (11).txt -[11]-> file-0005.txt |
要はファイル名に含まれている数字を整数型として取り出し、それをキーにしてソートすればいいのです。
以下、サンプルのポイントです。
ファイルの取得
os.listdir()
を使って、対象ディレクトリ内にあるファイル名から成るリストを作成します。
指定されたディレクトリが存在しなければ例外を吐きますが、そのディレクトリにファイルが何もなければ例外を吐かずに終了します。
1 2 3 | # get file names files = sorted([x for x in os.listdir(dir_path) if os.path.isfile(os.path.join(dir_path, x))]) |
ここで一旦ソートしているのは、os.listdir()
の出力はどうやら厳密に順序が保証されていない(不定)らしいためです。
数字の抽出
正規表現を使ってファイル名の文字列から数字を検索します。
ここで書いているのは、文字列に含まれる最後部の数字が順序を表していると想定して、検索結果から最後の要素のみをキャストして返す関数です。これで数字が複数個所あっても大丈夫。
1 2 3 4 5 6 7 | # extract number from file name def _get_number(file_name): nums = re.findall(r'[0-9]+', file_name) if nums != []: return int(nums[-1]) else: return -1 |
また、数字が見つからなかった場合は負数(-1)を返します。これで数字を含まないファイルがあった場合、数字付きファイル名よりも前に来るようにソートされます。後に持ってきたい場合は充分大きな正数にするやり方が考えられます。
別のファイル名で試してもちゃんと解釈できていますね。
1 2 3 4 5 6 7 8 9 | > python \serialnum.py file-a.txt -[-1]-> file-0000.txt file-02_1.txt -[1]-> file-0001.txt file-02_2.txt -[2]-> file-0002.txt file-02_10.txt -[10]-> file-0003.txt file-02_11.txt -[11]-> file-0004.txt file-02_21.txt -[21]-> file-0005.txt file-02_101.txt -[101]-> file-0006.txt |
関数をキーにしたソート
ソート方法を与える関数をkey
オプションに渡します。
1 2 | # sort file path by included number files.sort(key=_get_number) |
他のルールでソートしたい場合も同様に書けます。例えばファイルの最終変更時刻(os.path.getmtime()
)でソートしたい場合はこんな感じ。
1 2 | files = [os.path.join(dir_path, x) for x in files] files.sort(key=os.path.getmtime) |
簡単な関数を渡すならラムダ式でもいいですね。
ちなみにリストをソートするやり方として、sorted([])
と[].sort()
の両方を使っていますが、ここではリストをインプレース(コピーを作成せず)でソートしています。
もとのリストを保存する必要がない場合は、たぶんこちらの方法がより高速です。ただし、返り値はリストではないので、例えばfiles = files.sort(key=...)
とするとまずいことになります。
ゼロパディング
名前の長さを調節するため、桁数を指定してパディングします。フォーマットメソッドの04d
のところですね。
1 | renamed = "{}-{:04d}.{}".format(base_name, i, files[i].split(".")[-1]) |
ファイル名の変更
ここではshutil.move()
を使ってファイル名を変更しています。
1 2 | shutil.move(os.path.join( dir_path, files[i]), os.path.join(dir_path, renamed)) |