Help us understand the problem. What is going on with this article?

Python + Bottle でファイルのダウンロードを実装

More than 1 year has passed since last update.

BottleはPythonの軽量Webアプリケーションフレームワークです。
Webアプリでは、ファイルをサーバーサイドで生成してダウンロードさせたりすることがありますよね。
今回はBottleでファイルダウンロードの実装について書いてみます。

静的ファイルディレクトリ

Webアプリケーションでは/static/みたいな画像/CSS/JSファイルを集めたディレクトリを作る事が多いと思いますが、このようなケースではroute関数でリクエストハンドラをいちいち作っていられないので、以下のようにbottlestatic_file関数を利用するのが便利です。

この例ではhttp://localhost:8080/static/hoge.txtにアクセスすると実行時のカレントディレクトリからみた ./static にあるhoge.txtの内容が配信されます。

多くのWebアプリケーションの本番展開では静的ファイルを集めたディレクトリはnginxやapacheから直接配信するのが普通だと思います。ですがbottleで記述されたPythonアプリでも対応しておくことでローカルでの開発時に静的ファイルを読み込む事ができはかどります。(当社比)

# -*- coding: utf-8 -*-
from bottle import route, run, static_file


@route('/static/<file_path:path>')
def static(file_path):
    return static_file(file_path, root='./static')


run(host='localhost', port=8080)

bottleのソースをみたところ、メジャーなファイル形式は拡張子によってmimetypesパッケージによって推測されているようです。画像やcssやzip/gzファイルなどの一般的なファイルは正しいmimetypeがContent-TypeHTTPヘッダに入ってくれそうです.

表示じゃなくてダウンロードされて欲しいんだけど...

static_file関数のdownloadオプションをTrueに設定しましょう。

# -*- coding: utf-8 -*-
from bottle import route, run, static_file


@route('/static/<file_path:path>')
def static(file_path):
    return static_file(file_path, root='./static', download=True)


run(host='localhost', port=8080)

読み込むファイル名とブラウザが保存するファイル名を別にしたい場合はdownloadオプションにブラウザに保存させたいファイル名を設定します。詳しくは「static_file関数の引数・オプション」のセクションで後述します。

パターン1. メモリに乗るような小さなファイル

static_file関数がそのまま使えます。

# -*- coding: utf-8 -*-
from bottle import route, run, static_file


@route('/sample_image.png')
def sample_image():
    return static_file('./my_sample_image.png', root='.')


run(host='localhost', port=8080)

HTTPResponseで実装するとすれば以下のようになるでしょう。
実行時にファイルの内容を編集して配信などのトリッキーなことをする必要がある場合などに使えそうです。
が、すでにあるファイルのダウンロードをさせるだけであれば基本的にはstatic_file関数を使うのが簡単で確実でしょう。
static_file関数を使えばmimetypeの推定やファイルが存在しないときの404レスポンスの作成などの細かいところもおまかせにできます。

# -*- coding: utf-8 -*-
from bottle import route, run, template, response


@route('/sample_image.png')
def sample_image():
    response.content_type = 'image/png'
    with open('./my_sample_image.png', 'rb') as fh:
        content = fh.read()
    response.set_header('Content-Length', str(len(content)))
    return content


run(host='localhost', port=8080)

これでhttp://localhost:8080/sample_image.pngにアクセスすると画像が表示されます。

Bottleから呼び出されるHTTPリクエストに対するハンドラは, ハンドラの返り値がそのままレスポンスのbodyになる性質を利用しています。
HTTPレスポンスヘッダーはbottleパッケージのresponseオブジェクトをそのままいじることで変えています。
個人的にはどちらかというと、以下のような明示的にHTTPResponseオブジェクトを組み立てて返すやり方が好みです。

# -*- coding: utf-8 -*-
from bottle import route, run, template, HTTPResponse


@route('/sample_image.png')
def sample_image():
    with open('./my_sample_image.png', 'rb') as fh:
        content = fh.read()
    resp = HTTPResponse(status=200, body=content)
    resp.content_type = 'image/png'
    resp.set_header('Content-Length', str(len(content)))
    return resp


run(host='localhost', port=8080)

Bottleから呼び出されるHTTPリクエストに対するハンドラは, ハンドラの返り値がHTTPResponseの場合そのHTTPResponseオブジェクトの内容に沿ってレスポンスを返してくれます。
HTTPResponseの詳細についてはBottleのRequest/Responseオブジェクトをマスターに詳しく書いてみましたので、参考にしてみてください。

パターン2. メモリに乗らないような大きなファイル

メモリに乗らないような大きなファイルは少しずつファイルから読み込んでソケットに書き込んで行く必要があります。
このようなケースでもstatic_fileが活用できます。static_fileはファイルのコンテンツがまるごと読み込まれたレスポンスではなく、内部的にファイルを少しずつ読み込んで配信する実装になっています。

調べてみたところ、Bottleのハンドラはジェネレータになった場合、ジェネレータが生成する各要素をbodyの断片としてchunkedでクライアントに送信してくれるという仕様になっているようです。static_fileソースでも_file_iter_rangeというジェネレータ関数によってコンテンツが少しずつ読み込まれてそれがレスポンスになっているようです。

もし自前で実装するのであれば以下のようになるでしょう。

# -*- coding: utf-8 -*-
from bottle import route, run, template, response


@route('/sample_image.png')
def sample_image():
    response.content_type = 'image/png'
    bufsize = 1024  # 1KB
    with open('./my_sample_image.png', 'rb') as fh:
        while True:
            buf = fh.read(bufsize)
            if len(buf) == 0:
                break  # EOF
            yield buf


run(host='localhost', port=8080)

static_fileがあるからこのやり方は覚えなくてもいいのでは?という話もありますが、
データベースからファイルを経由せずにオンザフライで読み込みながらCSVファイル形式に加工しながらクライアントへ送信、というような実装がありえると思っています。というかそういうケースでやり方を調べたのが執筆のきっかけでしたので他の誰かのお役に立てばうれしいです。

HTTPResponseオブジェクトをreturnする形で実装しようと思うとHTTPResponseオブジェクトのbodyにジェネレータを設定します。

# -*- coding: utf-8 -*-
from bottle import route, run, template, HTTPResponse


@route('/sample_image.png')
def sample_image():
    resp = HTTPResponse(status=200)
    resp.content_type = 'image/png'

    def _file_content_iterator():
        bufsize = 1024  # 1KB
        with open('./my_sample_image.png', 'rb') as fh:
            while True:
                buf = fh.read(bufsize)
                if len(buf) == 0:
                    break  # EOF
                yield buf

    resp.body = _file_content_iterator()
    return resp


run(host='localhost', port=8080)

ただこのやり方も、関数内に関数を定義したりと一見わかりにくさがあるのでちょっとした気持ち悪さが残るかもしれません。これはお好みですね。

ジェネレータの実装はPythonを触り始めの方はわかりにくいと思いますが、Pythonのイテレータ/ジェネレータについてPythonのイテレータとジェネレータという記事を書いてみたのでよかったらご覧ください。

インライン表示でなく, ダウンロードさせたい

どちらかというとBottleの話というよりHTTPの話ですが、これはContent-Dispositionヘッダを使うとできます。static_file関数の利用であれば前述のようにdownloadオプションにTrueを設定すればOKです。ここではHTTPResponseを使ったやり際の実装を紹介しておきます。

# -*- coding: utf-8 -*-
from bottle import route, run, template, response


@route('/sample_image.png')
def sample_image():
    response.content_type = 'image/png'
    with open('./my_sample_image.png', 'rb') as fh:
        content = fh.read()
    response.set_header('Content-Length', str(len(content)))
    download_fname = 'hoge.png'
    response.set_header('Content-Disposition', 'attachment; filename="%s"' % download_fname.encode('utf-8'))
    return content


run(host='localhost', port=8080)

これでhttp://localhost:8080/sample_image.pngにアクセスするとhoge.pngというファイル名で画像が保存されます。

static_file関数の引数・オプションの解説

static_file関数のシグネチャは以下のようになっています。

def static_file(filename, root,
                mimetype='auto',
                download=False,
                charset='UTF-8'):
    ....
  • filename: rootにある配信したいコンテンツが含まれるファイルの名前。
  • root: 配信したいコンテンツが含まれるファイルfilenameがあるディレクトリ。
  • mimetype: オプション。デフォルトではmimetypesパッケージにより自動で推測される。
  • download: オプション。保存ダイアログや自動的にダウンロードフォルダに保存するような形でダウンロードさせたい場合Trueまたはクライアントサイドで保存される際の名前を文字列で指定。インラインで表示する形の場合はFalse。デフォルトはFalse
  • charset: オプション。mimetypeがtext/ではじまるまたはapplication/javascriptの場合, Content-TypeHTTPヘッダに ; charset=(charsetの値)が追加される。

参考リンク

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした