no-image

Bottle: Python の Web フレームワークを使う

経緯

普段は閉鎖的なシステム開発が多いため、基本的には標準ライブラリで全て自分で実装するというスタイルを採ってきました。全て自分の実装であるが故にバグや挙動の把握がしやすいという利点はありますが、それ故にバグの温床になっているという事実もあります。何より実装と検証に時間がかかるため、生産性が非常に悪くなります。最近になって生産性向上のためにフレームワークにも手を出そうと思い立ちました。

Python のフレームワークといえば Django が有名どころで、実際に使っていた時期もありました。しかし、高機能であるが故に自分にとっては複雑すぎて長続きしませんでした。今ではもう綺麗さっぱり忘れてしまっています。

10 年近く経っているので事情も変ってきているだろうと調べていると、Bottle という Web フレームワークを見つけました。Flask というのも人気があるようですが、1 ファイルでの実装に魅力を感じて Bottle を選びました。今回はその Bottle について簡単に記します。

簡単な説明

Bottle は bottle.py の 1 ファイルで構成される、軽量で高速な Web フレームワークです。テンプレートエンジンも搭載しており、小規模な開発では扱いやすいフレームワークです。

インストール

公式のインストール方法には最新版の bottle.py を取得するだけのこんな方法が書かれていて、1 ファイルのコンパクトさがよくわかります。

$ wget https://bottlepy.org/bottle.py

pip が使える環境であれば

$ pip install bottle

apt が使える環境であれば

$ sudo apt install python-bottle

とすればよいでしょう。パスや設置のことを考えるとパッケージマネージャでインストールした方が良い思いますが、最新版を使わなければいけない事情があれば直接ダウンロードしましょう。

サンプル

Web サーバを建てて、アクセスすると "Hello world!" を返すだけの簡単なコードです。同様のコードが公式サイトにも載っています。

# -*- coding: utf-8 -*-

from bottle import route, run

@route('/')
def hello():
    return '<p>Hello world!</p>'

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

これを例えば hello.py と保存して、次のように実行します。

$ python sample.py
Bottle v0.12.13 server starting up (using WSGIRefServer())...
Listening on http://0.0.0.0:8080/
Hit Ctrl-C to quit.

別の PC でも良いので Web ブラウザから http://[HOST_ADDRESS]:8080/ にアクセスしてみると、"Hello world!" が表示されると思います。終了するには書かれている通り、 Ctrl + C で Python 自体を終了させます。

Hello world!

Hello world!

実行方法からわかるように、ただの Python スクリプトを実行しているだけなので、ソース冒頭に Python 実行ファイルへのパス(#!/usr/local/bin/python 等)を書いておいて実行権限を付与しておけば、直接実行することも可能です。

#!/usr/local/bin/python
...(略)...
$ chmod +x hello.py
$ ./hello.py
Bottle v0.12.13 server starting up (using WSGIRefServer())...
Listening on http://0.0.0.0:8080/
Hit Ctrl-C to quit.

さきほどの hello.py は次のように記述しても全く同じ動作をします。これはオブジェクト指向な書き方で、どちらの書き方を選ぶかは好みです。名前の衝突を避けるために使われることもあります。

# -*- coding: utf-8 -*-

from bottle import Bottle, run

app = Bottle()

@app.route('/')
def hello():
    return '<p>Hello world!</p>'

run(app, host = '0.0.0.0', port = 8080)

ちなみに @ がついているのは Python のデコレータ(decorator)と呼ばれるもので、関数のラッパーのようなものです。関数定義の前に置くことでその関数をデコレータで包みます。ここでは詳しくは述べません。

ルーティング(request routing)

リクエストに応じて処理を変更したいとき、CGI であれば目的に応じたファイルで分けます。list を表示するのに list.py, contact フォームのために contact.py など。Bottle を使えば 1 ファイルで複数のリクエストに対する処理を書くことができます。

次のサンプルでは、http://[HOST_ADDRESS]:8080/ または http://[HOST_ADDRESS]:8080/ にアクセスすると "Home" が表示され、http://[HOST_ADDRESS]:8080/hello にアクセスすると "Hello!" と表示されます。

# -*- coding: utf-8 -*-

from bottle import route, run

@route('/')
@route('/index')
def index():
    return '<p>Home</p>'

@route('/hello')
def hello():
    return '<p>Hello!</p>'

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

これでページ分けが行えますね。

ここまでは予め用意した内容を出力しているだけですが、実際の Web アプリケーションではリクエストに応じて内容を変化させることがほとんどだと思います。クライアントからサーバへ情報を送るにはいくつか方法があります。例えば GET, POST, Cookie, ... 等ですが、ルーティングの続きとして GET リクエストによるルーティングを試してみます。

次のサンプルは  http://[HOST_ADDRESS]:8080/hello/[YOUR_NAME] にアクセスすると、"Hello, [YOUR_NAME]!" と表示されます。

# -*- coding: utf-8 -*-

from bottle import template, route, run

@route('/hello/<str>')
def hello(str):
    return template('<p>Hello, {{name}}!</p>', name = str)

run(host = '0.0.0.0', port = 8080)
Hello, John!

Hello, John!

@route('/hello/<str>') の <str> がワイルドカードとして扱われ、引数のように値を使うことができます。これによって URI 自体が意味を成すため、リンクを張ったときに内容を把握しやくなります。

ここで [YOUR_NAME] を指定しなかったらどうなるでしょうか?試しにアクセスしてみると Error: 404 Not Found と返ってきます。

404 Not Found

404 Not Found

ここで次のようにデフォルト引数を与えてあげると、/hello/ でアクセスするとデフォルト引数が使われて "Hello, Guest!" と表示されます。

# -*- coding: utf-8 -*-

from bottle import template, route, run

@route('/hello/')
@route('/hello/<str>')
def hello(str = 'Guest'):
    return template('<p>Hello, {{name}}!</p>', name = str)

run(host = '0.0.0.0', port = 8080)
Hello, Guest!

Hello, Guest!

ここで注意する必要があるのは、"/hello/" と "/hello"  をきちんと区別するということです。このサンプルで http://[HOST_ADDRESS]:8080/hello にアクセスしても 404 Not Found となってしまいます。普段から意識していれば問題ありませんが、ディレクトリであっても末尾の '/' を省略する癖がある人は気を付けた方が良いでしょう。

@route() で使うワイルドカードは、値をフィルターで限定することができます。次の例では int は整数に限定され、float は小数を受け付け、path は '/' を含むパス文字列、re は ':' に続く正規表現に一致する文字列を受け付けます。

@route('/item/<id:int>')
@route('/release/<ver:float>')
@route('/static/<path:path>')
@route('/view/<name:re:[a-z]+>')

GET と POST

Bottle が用意しているデコレーターは @route() の他にも @get(), @post(), @put(), @delete(), @patch(), @error() があります。HTML でよく使われる GET と POST に絞って使ってみることにします。

次のサンプルは簡易的なログイン機能を設けたものです。

# -*- coding: utf-8 -*-

from bottle import get, post, request, run

@get('/login')
def login_form():
    return '''<form action="/login" method="post">
        <p><label>ID:</label> <input type="text" name="id" />
        <label>Password:</label> <input type="password" name="password" />
        <input type="submit" value="Login" /></p>
        </form>'''

@post('/login')
def login():
    id = request.forms.get('id')
    pw = request.forms.get('password')
    
    if id == 'foo' and pw == 'bar':
        return '<p>You have successfully logged in.</p>'
    else:
        return '<p>Login failed.</p>'

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

ここで注目していただきたいのは、同じ /login にアクセスしているのに GET と POST では異なる処理が行われているということです。デコレータをうまく使うことによって、簡潔な URI を実現できることがおわかりいただけると思います。

静的ファイルの扱い

Bottle アプリケーションでは、画像や CSS, JavaScript といった静的なファイルを扱う場合にも自分でルーティングする必要があります。公式のチュートリアルを参考に、/static/ 以下へのアクセスは /home/user/static/ 以下のファイルを返すルーティングを実装したのが次になります。

# -*- coding: utf-8 -*-

from bottle import static_file, route, run

@route('/static/<filepath:path>')
def static(filepath):
    return static_file(filepath, '/home/user/static')

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

static_file() の引数は static_file(filename, root, ...) となっていて、root を基準とした filename の内容を返す実装になっています。実装を見てみると root は関数内部で絶対パスへ変換されている(root = os.path.join(os.path.abspath(root), ''))ので相対パスも指定できますが、実行時のディレクトリ(current directory, working directory)に依存するので注意する必要があります。

static_file() の引数に download = True を与えると、そのファイルを Content-Disposition: attachment として出力するので、ダウンロード扱いにできます。このときのファイル名は元ファイルのファイル名となります。download = 'filename.ext' とすればダウンロード時のファイル名を指定できます。

エラーページのルーティング(404 Not Found 等)

@error(404) のように、ステータスコードを含んだデコレータを関数の前に置くだけです。

# -*- coding: utf-8 -*-

from bottle import error, run

@error(404)
def error_404(e):
    return '<h1>404 Not Found</h1><p>{}</p>'.format(e)

run(host = '0.0.0.0', port = 8080)
errorデコレータ

errorデコレータ

これでエラー時に任意のページを表示させることができます。

逆にエラーページに飛ばしたい場合は、import abort した上で abort(401, 'Authorization required') のように呼び出します。

# -*- coding: utf-8 -*-

from bottle import abort, route, run

@route('/admin')
def admin():
    abort(401, 'Authorization required.')

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

 

abort

abort

クライアントからのデータを処理する

Bottle で扱えるクライアントからのデータには、以下のようなものがあります。それぞれ簡単な例を挙げます。

request.headers

HTTP ヘッダを辞書形式で取得できます。下記は headers 内の全ての値を出力します。

# -*- coding: utf-8 -*-

from bottle import route, run, request

@route('/req/headers')
def req_headers():
    ret = '<dl>'
    for k in request.headers:
        ret += '<dt>{}</dt><dd>{}</dd>'.format(k, request.headers.get(k))
    ret += '</dl>'
    return ret

run(host = '0.0.0.0', port = 8080)
request.headers

request.headers

余談ですが、Edge のUser-Agentってこんなに色々な名前が入っているんですね。KHTML から拡張されてきた歴史は知っていましたが、Chrome と Safari だけでなく AppleWebKit の名前まで入っていて、どれが実体なのかぱっと見わけがわかりません。ついでなのでちょっと調べてみると、同じこと思っている方は他にもいるみたいです。

ということでモバイル版はもっとひどいようで、Android は "EdgA" であり、iOSは "EdgiOS" らしいです。Edge ですらなくなってます。

request.cookies

全ての Cookie を辞書で返します。Cookie の名前がわかっているなら request.get_cookie('cookie_name') で直接取得できます。

# -*- coding: utf-8 -*-

from bottle import route, run, request, response

@route('/req/set-cookie')
def set_cookie():
    response.set_cookie('foo', 'bar')
    response.set_cookie('key', 'value')
    return '<p>Set some cookies.</p>'

@route('/req/cookies')
def req_cookies():
    ret = '<dl>'
    for k in request.cookies:
        ret += '<dt>{}</dt><dd>{}</dd>'.format(k, request.cookies.getunicode(k))
        ret += '</dl>'
    return ret

run(host = '0.0.0.0', port = 8080)
request.set_cookie

request.set_cookie

request.cookies

request.cookies

request.query

QUERY_STRING と呼ばれる、URL の ? 以下の部分を扱います。request.query_string は ? 以下を一つの文字列として返します。request.query は key=value 形式の辞書を返します。

# -*- coding: utf-8 -*-

from bottle import route, post, run, request, response

@route('/req/query')
def req_query():
    ret = '<p>{}</p>'.format(request.query_string)
    ret += '<dl>'
    for k in request.query:
        ret += '<dt>{}</dt><dd>{}</dd>'.format(k, request.query.getunicode(k))
        ret += '</dl>'
    return ret

run(host = '0.0.0.0', port = 8080)
request.query

request.query

公式チュートリアルでは id=foo&page=5 のような QUERY_STRING が与えられた場合に、次のように値を取得するサンプルも掲載されています。

id = request.query.id
page = request.query.page

request.forms

一般的な method="POST" な form の値は、request.forms で取得します。これも name=value 形式の辞書になっています。name が予めわかっていれば request.forms.get('name') で取得できます。

# -*- coding: utf-8 -*-

from bottle import route, post, run, request

@route('/req/forms')
def post_form():
    ret = '''<form action="" method="post">
    <p>ID: <input name="id" type="text" /> Password: <input name="pw" type="password" /> <input type="submit" value="送信" /></p>
    </form>'''
    return ret

@post('/req/forms')
def req_forms():
    ret = '<dl>'
    for k in request.forms:
        ret += '<dt>{}</dt><dd>{}</dd>'.format(k, request.forms.getunicode(k))
        ret += '</dl>'
    return ret

run(host = '0.0.0.0', port = 8080)
request.forms

request.forms

request.files

ファイルの受信も簡単に行えます。request.files は input の name を key とし、送られたファイルを持つ辞書になっています。name がわかっていれば直接 get() でファイルの内容を取得できます。

# -*- coding: utf-8 -*-

from bottle import route, post, run, request

@route('/req/files')
def file_form():
    ret = '''<form action="" method="post" enctype="multipart/form-data">
    <p>File1: <input name="file1" type="file" /> File2: <input name="file2" type="file" /> <input type="submit" value="Send" /></p>
    </form>'''
    return ret

@post('/req/files')
def file_form():
    ret = '<dl>'
    for k in request.files:
        f = request.files.get(k)
        ret += '<dt>{}</dt><dd>{}({})</dd>'.format(k, f.filename, f.content_type)
        f.save('/home/user/files')
    ret += '</dl>'
    return ret

run(host = '0.0.0.0', port = 8080)
request.files 選択前

request.files 選択前

request.files 送信後

request.files 送信後

get() で取得できるのは class FileUpload のオブジェクトです。この FileUpload について簡単に書きます。以下、file は class FileUpload のオブジェクトとします。

file.raw_filename で元のファイル名を取得できますが、"may contain unsafe characters" とリファレンスにあるように、ユニコードの特殊文字の扱いを考えなければセキュリティ上の問題になる可能性があります。代りに file.filename で取得できるファイル名は安全なものに処理されているので、そのまま保存に使っても問題ありません。ただし、"ASCII letters, digits, dashes, underscores and dots" の範囲内でしか処理されないため、日本語等は全て省略されてしまいます。元のファイル名は何らかの別の形で残すように処理した方が良いでしょう。

file.save(destination = '/path/to/dir/') とすると、ディレクトリ destination に file.filename という名前で保存されます。事前に destination に書込権限があることを確認してください。

送信側の form に enctype="multipart/form-data" を入れ忘れないように注意しましょう。今まで CGI でファイルを扱ったことがなかったのでこれに気付かず、しばらくハマりました。

 テンプレート(SimpleTemplate Engine)

テンプレートエンジンについて簡単に説明しておくと、予め雛型となるファイルを用意しておいて、変える必要がある部分のみを書き換えてくれるというものです。

例えば HTML であれば、DOCTYPE から始まるヘッダに相当する部分はどのページでもさほど違いはありません。title 要素の値が違うくらいでしょうか。これらを毎回出力するのは非効率的ですし、1 つの変更を適用させるためにすべての出力を書き換える必要ができます。CSS が普及するまで HTML に直接デザインを書き入れてたのと同じくらい非効率です。

Bottle には SimpleTemplate Engine と呼ばれるテンプレートエンジンが用意されています。その名の通りとてもシンプルなエンジンですが、テンプレート内に Python コードを埋め込むことができるため、簡素ながらも柔軟性があります。PHP と似たようなもの、といったら経験者にはわかりやすいでしょうか。

最も簡単な例として、次のようなコードを用意しました。

# -*- coding: utf-8 -*-

from bottle import template, route, run

@route('/<dir>/<name>')
def hello(dir, name):
    return template('<p>Dir: {{DIR}}<br />Name: {{NAME}}</p>', DIR = dir, NAME = name)

run(host = '0.0.0.0', port = 8080)
template

template

{{ と }} で囲まれた部分が置き換え対象となり、第 2 引数以降で与えられる値に置き換えられます。第 2 引数を辞書にすることも可能で、上の例であれば {'DIR' : dir, 'NAME' : name} を第 2 引数にします。

インライン式(Inline expressions)

{{ と }} で囲まれた式を変数のように扱いましたが、Python コードそのものを書くこともできます。次は bool 値が True であれば "TRUE" と、False であれば "FALSE" と表示するコード埋め込んだものです。

# -*- coding: utf-8 -*-

from bottle import template, route, run

@route('/flag')
def home():
    return template('<p>{{"TRUE" if flag else "FALSE"}}</p>', flag = True)

run(host = '0.0.0.0', port = 8080)
template: inline expression

template: inline expression

変数の値を埋め込むとき、XSS 攻撃防止のために HTML の特別文字は全てエスケープされます。

埋め込みコード(Embedded Python code)

'%' から始まる行は Python コードとして処理されます。'%' の前後に空白があっても構いません。また、Python の構文であるインデントも必要ありません。インデントで表していたブロックの範囲は、"% end"と明示するまで続きます。

次のコードは、埋め込みコードを使って 0 から 9 の数字をリストアイテムとして出力します。

# -*- coding: utf-8 -*-

from bottle import template, route, run

@route('/loop')
def loop():
    t = '''<ul>
    % for count in range(10):
        <li>{{count}}</li>
    % end
    </ul>'''
    return template(t)

run(host = '0.0.0.0', port = 8080)
template: 埋め込みコード

template: 埋め込みコード

テンプレートをファイルから読み込む

ここまでは、把握しやすいようにソースコードの中に直接 HTML を書いてテンプレートを記述していましたが、実際は別ファイルとして用意しないとテンプレートの意味がなくなってしまいます。

例として、ページタイトルと本文だけを埋め込めるようにしたテンプレートを作って表示してみます。

Bottle を実行しているディレクトリの下に views ディレクトリを作成し、次のソースを html.tpl として保存します。

<!DOCTYPE html>
<head>
<title>{{TITLE}}</title>
</head>
<body>
{{BODY}}
</body>
</html>

そして次の Python コードを実行します。

# -*- coding: utf-8 -*-

from bottle import template, route, run

@route('/')
def home():
    return template('html.tpl', TITLE = 'Bottle Sample', BODY = '<p>Testing template engine.</p>')

run(host = '0.0.0.0', port = 8080)
template 別ファイル

template 別ファイル

おや、HTML のタグがそのまま表示されてしまいましたね。先程述べたように、XSS 攻撃を防ぐために HTML の特別文字は全てエスケープされます。エスケープを行いたくないときは、{{ と }} の中の変数名に '!' を付けます。

<!DOCTYPE html>
<head>
<title>{{TITLE}}</title>
</head>
<body>
{{!BODY}}
</body>
</html>
template HTML エスケープ無効化

template HTML エスケープ無効化

エスケープを無効にする際には、XSS に十分に注意してください。できるだけ HTML はテンプレート側に記述してしまい、値だけを流し込むのが理想だと思います。

@view デコレータ

先のコードでは template() で直接テンプレートを呼び出していましたが、これをデコレータで指定することもできます。

# -*- coding: utf-8 -*-

from bottle import template, view, route, run

@route('/')
@view('html.tpl')
def home():
    return dict(TITLE = 'Bottle Sample', BODY = '<p>Testing template engine.</p>')

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