[Python] Bottleでzipをストリーミングダウンロード

適当なキーワードでパッケージを探したら本当にあった

Ruby にziplineという gem がある。ファイルのリストを渡すと zip に固めてストリーミングダウンロードしてくれるという Rails 向けの gem だ。今回同じことを Python で行いたく、似たような機能のパッケージ探してみた。

すると全く同じ名前のパッケージを見つけたけど、これziplineアルゴリズムトレードライブラリだ!

しょうがないから google で「python zip stream」あたりのキーワードで適当に検索を掛けてみると、ドンピシャなパッケージpython-zipstreamが一発ヒットしてビビる。
これは使えそうだ

ダミーファイル作成

まずダウンロードテスト用のダミーファイルを作る

dd if=/dev/random of=dummy1.txt bs=1M count=100
for i in {1..10} ; do dd if=/dev/random of=dummy${i}.txt bs=1M count=100; done

参考:Bash でいろいろループする - Qiita

これでdummy1.txtdummy10.txtまで 100MB のファイルが 10 個作成された。

zip をストリーミングダウンロード

先に成功したコードを挙げておく。
これで開発環境の場合はhttp://localhost:3030/zipにアクセスすると、dummy1.txtdummy10.txtを zip したfiles.zipがストリーミングでダウンロードされる。

import zipstream
from pathlib import Path

@route('/zip')
def zip():
""" download zip with streaming """
path = Path('<temp files dir>')

def generator():
zip = zipstream.ZipFile(mode='w', compression=zipstream.ZIP_DEFLATED)
zip.write((path / 'dummy1.txt').as_posix(), arcname='1.txt')
zip.write((path / 'dummy2.txt').as_posix(), arcname='2.txt')
zip.write((path / 'dummy3.txt').as_posix(), arcname='3.txt')
zip.write((path / 'dummy4.txt').as_posix(), arcname='4.txt')
zip.write((path / 'dummy5.txt').as_posix(), arcname='5.txt')
zip.write((path / 'dummy6.txt').as_posix(), arcname='6.txt')
zip.write((path / 'dummy7.txt').as_posix(), arcname='7.txt')
zip.write((path / 'dummy8.txt').as_posix(), arcname='8.txt')
zip.write((path / 'dummy9.txt').as_posix(), arcname='9.txt')
for chunk in zip:
yield chunk

zip_filename = 'files.zip'
response.content_type = 'application/zip'
response.set_header('Content-Disposition', f'attachment; filename="{zip_filename}"')
response.body = generator()
return response

本家のREADMEを読んだ感じ、使い方がよく理解できなかったので軽く解説を入れてみる。

zip = zipstream.ZipFile(mode='w', compression=zipstream.ZIP_DEFLATED)

まずは ZipFile のインスタンスを作る。zipstream.ZipFilezipfile.ZipFileを継承したラッパークラスなので、共通のインターフェースになっている。ここを読むと分かりやすいかもしれない。

mode='w'を指定しているが、ここはデフォルトも'w'だし、'w'が含まれないとRuntimeErrorになる。
compressionはデフォルトZIP_STOREDなのでデフォルトだと圧縮されない。取り合えず圧縮したいレベルならZIP_DEFLATEDでお K。

  • ZIP_STORED : 圧縮無しでファイルをまとめるだけ
  • ZIP_DEFLATED : 通常の ZIP 圧縮、zlibモジュールが必要
  • ZIP_BZIP2 : BZIP2 での圧縮を行う、bz2モジュールが必要
  • ZIP_LZMA : LZMA での圧縮を行う、lzmaモジュールが必要
zip.write((path / 'dummy1.txt').as_posix(), arcname='1.txt')

ZipFile インスタンスにファイルを渡していく。ここもzipfile.ZipFile#writeとインターフェースは同じで、write('ファイルパス', arcname='アーカイブされるファイル名', compress_type='個別の圧縮指定')になる。
ここでディレクトリを渡してしまうと空のディレクトリが入ってしまうので注意が必要。

write_iter(arcname, iterable, compress_type)メソッドではファイルパスではなくイテレータを渡すこともできるようだ。

.as_posix()してるのはファイルパスとしてpathlibを受け付けないから…残念!

for chunk in zip:
yield chunk

zipstream.ZipFileはイテレータで圧縮済みデータを返してくれる。
具体的にはwriteしたファイルのリストからファイルを順次読み出し、1024 * 8バイト読んで圧縮して都度データを返す。

zip_filename = 'files.zip'
response.content_type = 'application/zip'
response.set_header('Content-Disposition', f'attachment; filename="{zip_filename}"')
response.body = generator()
return response

最後はbottleresponse部分だ。Content-Type'application/zip'を指定する。Content-Dispositionにはファイルをアタッチすることで即ダウンロードする動きをしてくれる。response.bodyはジェネレータを受けることができるので、generator()関数をそのままセットするとストリーム処理するようになってくれる、ありがたい。

Content-Length は?

Content-Lengthを指定しないとダウンロードの残り時間表示が「不明」になってしまうが、圧縮後のサイズが事前に分からないのでここはしょうがない。
試しにContent-Lengthに適当な値を設定してみたら、ダウンロードが強制中断になってしまった。

実行環境

  • Python 3.6.3
  • bottle 0.12.13
  • zipstream 1.1.4