取引先から届いたZIPを開いて、中のCSVを取り出して、集計する。そんな作業を毎月くり返していませんか。
逆に、作ったレポートをフォルダごとZIPにまとめて送る作業もよくあります。1回なら右クリックで済みますが、毎週となると地味に時間を取られます。
こうした作業は、Python標準ライブラリのzipfileで自動化できます。pip installは要りません。
今回はZIPの作成、中身の確認、展開に加えて、初心者がつまずきやすい圧縮の設定と文字化け、そして安全な展開の書き方まで順番に見ていきます。
zipfileは、ZIPを読み書きするための標準ライブラリ¶
ZIPは、複数のファイルを1つにまとめて、サイズを小さくするための形式です。メールの添付やダウンロード配布でおなじみですね。
zipfileは、このZIPをPythonから作ったり、読んだり、追記したりするためのモジュールです。import zipfileと書けば、すぐに使えます。
公式ドキュメントによると、4GiBを超える大きなZIPにも対応しています。一方で、暗号化されたZIPの展開はできても、暗号化したZIPを新しく作ることはできません。
ちなみに、フォルダを丸ごとZIPにするだけなら、shutilのmake_archiveのほうが手軽です。こちらで紹介しています。【関連記事】Pythonのshutilとは?ファイルのコピーや移動、zip圧縮を自動化する基本を初心者向けに解説
それでもzipfileを使うのは、ZIPの中身を1ファイルずつ細かく扱えるからです。
まず覚えたい4つのモード¶
zipfileの中心はZipFileクラスです。ファイルを開くときと同じように、モードを指定して開きます。
モードは次の4つです。ファイルの読み書きで使うopen()とよく似ています。
| モード | 意味 | 既存のZIPがあるとき |
|---|---|---|
"r" |
読み込み(省略時はこれ) | そのまま読む |
"w" |
新しく書き込む | 中身を消して作り直す |
"a" |
追記する | 末尾にファイルを足す |
"x" |
新しく作るときだけ書き込む | FileExistsErrorになる |
いちばん気をつけたいのは"w"です。同じ名前のZIPがあると、警告なしに中身が消えます。
うっかり上書きを防ぎたい場面では、"x"を選ぶと安心です。モードの考え方そのものに自信がない方は、先にこちらを読んでおくと理解が早くなります。【関連記事】Pythonのファイル読み書きとは?open()とwith文の基本を初心者向けに解説
ZIPを作るときは、圧縮方式の指定を忘れない¶
それでは、実際にZIPを作ってみましょう。例として、売上のCSVとメモが入ったreportフォルダを、まるごとZIPにします。
フォルダの中を順番にたどるために、pathlibのrglobを使っています。
import zipfile
from pathlib import Path
report = Path("report")
with zipfile.ZipFile("report.zip", "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in sorted(report.rglob("*")):
# ZIPの中での名前を report/〜 の相対パスにそろえる
zf.write(path, arcname=path.relative_to(report.parent))
with zipfile.ZipFile("report.zip") as zf:
for info in zf.infolist():
print(info.filename, info.file_size, info.compress_size)
手元のPython 3.11で動かすと、次のように表示されました。左から、ZIPの中での名前、元のサイズ、圧縮後のサイズです。
report/sales.csv 32012 107
report/メモ.txt 29 34
CSVは32012バイトが107バイトまで縮みました。同じ行をくり返しただけのテストデータなので極端に縮んでいますが、圧縮が効いていることはわかります。
with文で開いているので、ブロックを抜けると自動で閉じられます。公式ドキュメントには、閉じ忘れると必要な情報が書き込まれないと明記されているので、withで書くのが基本です。
compressionを省略すると、圧縮されない¶
ここが、初心者がいちばん引っかかるポイントです。compressionを省略すると、既定値のZIP_STOREDになります。
STOREDは、そのまま保存するという意味です。つまりファイルを1つにまとめるだけで、サイズはまったく小さくなりません。
実際にcompressionを付けずに同じCSVを入れてみると、圧縮後のサイズも32012バイトのままでした。
私は10年ほどエンジニアとして開発に関わってきましたが、まさにこれで恥をかいたことがあります。ログを毎晩ZIPにしてサーバーの容量を節約するバッチを作ったのに、何か月たっても容量が減らない。原因はZIP_DEFLATEDの書き忘れで、まとめただけのファイルが毎晩増えていました。
選べる圧縮方式は、次のとおりです。迷ったらZIP_DEFLATEDを選べば、ほとんどの環境で開けます。
| 定数 | 方式 | ひとこと |
|---|---|---|
ZIP_STORED |
圧縮しない | 既定値。まとめるだけ |
ZIP_DEFLATED |
Deflate | もっとも一般的。迷ったらこれ |
ZIP_BZIP2 |
bzip2 | 縮みやすいが対応ツールが少なめ |
ZIP_LZMA |
LZMA | 同上 |
ZIP_ZSTANDARD |
Zstandard | Python 3.14で追加 |
DEFLATED以外の方式は、古いツールや古いPythonでは開けないことがあると、公式ドキュメントでも注意されています。相手の環境がわからないときは、DEFLATEDにしておくのが無難です。
arcnameで、ZIPの中の名前を決める¶
write()の第2引数arcnameは、ZIPの中でどんな名前にするかを決めるものです。
これを省略すると、渡したパスがそのまま使われます。たとえば/home/user/work/report/sales.csvを渡すと、展開したときに深いフォルダが何段もできてしまいます。
受け取った人が開いたときの見え方を想像して、arcnameを決めましょう。
展開せずに、中身を確認して読む¶
ZIPを受け取ったとき、いきなり全部を展開する必要はありません。まずは何が入っているのかを確認しましょう。
ファイル名の一覧はnamelist()、サイズなどの詳しい情報はinfolist()で取れます。
with zipfile.ZipFile("report.zip") as zf:
print(zf.namelist())
print(zf.read("report/メモ.txt").decode("utf-8"))
実行すると、次のように表示されます。read()はバイト列を返すので、文字列にするにはdecode()が必要です。
['report/sales.csv', 'report/メモ.txt']
9月の売上メモです。
CSVをZIPに入れたまま集計する¶
大きなCSVの場合、展開してから読むと、ディスクに同じデータがもう1つできてしまいます。open()を使えば、ZIPの中のファイルを直接読めます。
ただしopen()で得られるのはバイト列の流れです。文字列として読むために、io.TextIOWrapperで包みます。
import csv
import io
import zipfile
with zipfile.ZipFile("report.zip") as zf:
with zf.open("report/sales.csv") as f:
reader = csv.DictReader(io.TextIOWrapper(f, encoding="utf-8"))
total = sum(int(row["amount"]) for row in reader)
print(total) # 2400000
展開用の一時フォルダを用意する必要も、後片付けをする必要もありません。CSVの読み方そのものは、こちらで詳しく解説しています。【関連記事】Pythonのcsvモジュールとは?表データの読み書きと文字化けの防ぎ方を初心者向けに解説
Windowsで作られたZIPが文字化けしたら¶
ZIPの中のファイル名が、見たこともない記号の列になっている。日本語の環境では、これがかなりの頻度で起こります。
原因は、ファイル名の文字コードです。ZIPの規格では、ファイル名は昔ながらのIBMの文字コードか、UTF-8のどちらかだと決まっています。
ところが古いWindowsのツールの中には、日本語のファイル名をShift_JIS系のcp932で書き込むものがあります。zipfileはそれを規格どおりに読もうとして、文字化けしてしまうのです。
Python 3.11からは、metadata_encodingで読み込み時の文字コードを指定できるようになりました。
with zipfile.ZipFile("from_windows.zip", metadata_encoding="cp932") as zf:
print(zf.namelist())
この指定は読み込みモードのときだけ使えます。また、ZIPの中にUTF-8だという印が付いている場合は、そちらが優先されます。
逆に、zipfileで日本語名のファイルを書き込むと、自動でUTF-8が使われます。文字コードがそもそもよくわからないという方は、こちらで基本を整理できます。【関連記事】Pythonの文字コードとは?文字化けとUnicodeDecodeErrorの直し方を初心者向けに解説
信頼できないZIPは、そのまま展開しない¶
全部を展開するなら、extractall()を呼ぶだけです。とても便利ですが、ここには落とし穴があります。
公式ドキュメントには、信頼できない相手から受け取ったZIPを、確認せずに展開してはいけないとはっきり書かれています。どんな危険があるのでしょうか。
ZIPには、2種類の危険が潜んでいる¶
1つめは、ファイル名に../を含めて、展開先の外へファイルを書き込もうとするZIPです。zipfileは..を取り除くなどの対策をしてくれますが、公式ドキュメントの表現も、防ごうと試みる、にとどまっています。
2つめは、ZIP爆弾と呼ばれるものです。ZIPとしては数KBしかないのに、展開すると巨大なサイズに膨れ上がり、ディスクを埋め尽くします。
さらに、同じZIPを2回展開すると、既存のファイルを確認なしに上書きする点も覚えておきましょう。
展開する前に、名前とサイズを確かめる¶
そこで、展開の前にチェックを挟む関数を作ってみます。ファイル数、展開後の合計サイズ、展開先の外に出ないかの3点を確かめています。
import zipfile
from pathlib import Path
MAX_FILES = 1000
MAX_TOTAL_SIZE = 100 * 1024 * 1024 # 展開後の合計は100MBまで
def safe_extract(zip_path, dest):
dest = Path(dest).resolve()
with zipfile.ZipFile(zip_path) as zf:
infos = zf.infolist()
if len(infos) > MAX_FILES:
raise ValueError(f"ファイル数が多すぎます: {len(infos)}")
total = sum(info.file_size for info in infos)
if total > MAX_TOTAL_SIZE:
raise ValueError(f"展開後のサイズが大きすぎます: {total:,} バイト")
for info in infos:
target = (dest / info.filename).resolve()
if not target.is_relative_to(dest):
raise ValueError(f"危険なパスが含まれています: {info.filename}")
zf.extractall(dest)
return [info.filename for info in infos]
試しに、../../etc/evil.txtという名前のファイルを入れたZIPを渡してみました。すると展開する前に止まり、次のメッセージが出ます。
危険なパスが含まれています: ../../etc/evil.txt
is_relative_to()は、パスが指定したフォルダの中にあるかを調べるpathlibのメソッドで、Python 3.9から使えます。パスの扱いに慣れていない方は、こちらもどうぞ。【関連記事】Pythonのpathlibとは?ファイルパス操作を初心者向けに解説
なお、file_sizeはZIPの中に書かれた申告上の値です。完全な防御ではなく、最初の関門と考えてください。
私自身、Webアプリでユーザーがアップロードしたファイルを扱う仕事をしたとき、レビューでいちばん指摘されたのがこの展開処理でした。便利な1行ほど、何を信用しているのかを一度立ち止まって考える価値があります。
コマンドラインからも使える¶
ちょっと中身を見たいだけなら、Pythonのコードを書く必要すらありません。python -m zipfileで、ZIPの操作ができます。
主なオプションを表にまとめました。
| オプション | できること | 例 |
|---|---|---|
-l |
中身の一覧を表示 | python -m zipfile -l report.zip |
-c |
ZIPを作る | python -m zipfile -c out.zip report/ |
-e |
指定フォルダへ展開 | python -m zipfile -e report.zip out/ |
-t |
ZIPが壊れていないか検査 | python -m zipfile -t report.zip |
OSを問わず同じコマンドで動くのがうれしいところです。
コードの中で壊れていないかを調べたいときは、testzip()を使います。問題がなければNoneが返り、壊れたファイルがあれば最初の1つの名前が返ります。
zipfileでつまずきやすいポイントのまとめ¶
ZIPを作るときは、compression=zipfile.ZIP_DEFLATEDを忘れないこと。省略すると、まとめるだけで小さくなりません。
読むときは、いきなり展開せずにnamelist()で中身を確認すること。CSVならopen()とio.TextIOWrapperで、展開せずに読めます。
文字化けしたら、metadata_encoding="cp932"を試すこと。そして信頼できないZIPは、名前とサイズを確かめてから展開することです。
ZIPまわりの作業は、毎月のように発生するわりに、手作業のまま放置されがちです。まずは今週のどれか1つを、zipfileで置き換えてみてください。
ここまでお読みいただきありがとうございました。