カテゴリー: ソフトウェア

  • Python の CGI で reCAPTCHA v2 を使う

    ちょっと前までは人間でも読解困難な文字を読ませていた CAPTCHA ですが、Google の reCAPTCHA v2 では人間である可能性が高い場合は「私はロボットではありません」というチェックを入れるだけとなって、かなりユーザへの負担が軽減されました。

    既にクリックすら必要ない reCAPTCHA v3 のベータテストが行われているので、もしかしたらすぐに v3 へ移行してしまうかもしれませんが、現時点ではまだベータ版ということで v2 を選んでいます。

    reCAPTCHA v2 を自分のサイトに導入するには、クライアントに表示されるページとサーバの両方にコードの追加が必要となります(実体は両方ともサーバにあるファイルですね)。

    流れとしては下記の通りです。

    1. reCAPTHA のページで登録を行う
    2. ユーザに表示する form にコードを埋め込む(JavaScript の読み込みと HTML)
    3. form からの値を Google に投げて判別するコードをサーバ側に追加する

    reCAPTCHA の登録

    https://www.google.com/recaptcha/ を開き、”My reCAPTCHA” から登録を行います。余談ですが右下に reCAPTCHA のマークが表示されているので、このページ自体も reCAPTCHA で保護されていますね。

    reCAPTCHA トップ画面
    reCAPTCHA トップ画面

    “Register a new site” から新しくサイトを登録します。自分の区別しやすい名前とドメイン名を入力するだけです。

    reCAPTCHA register

    登録したサイトを開くと設定の仕方が載っているので、これを参考にソースやコードを書き換えます。癖で secret にモザイクかけてしまいましたが、HTML に書かれるから隠す意味はありませんね。

    reCAPTCHA secret

    クライアントに表示される HTML の変更

    2 点だけです。

    head 要素に JavaScript の読み込みを追加

    <script src='https://www.google.com/recaptcha/api.js'></script>

    form 要素に div 要素を追加する

    <div class="g-recaptcha" data-sitekey="XXXXXXXXYOURSECRETXXXXXXXXX"></div>

    これが reCAPTCHA v2 のチェックボックスになります。

    ここまでで次のような表示が現れたら OK です(ID: の入力欄は関係ありません)。

    reCAPTCHA form

    サーバ側 Python コードの修正

    今回は昔ながらの手法で POST された form を読み取る CGI を対象としています。

    使うモジュールは次の通り。

    import cgi
    import urllib, urllib2
    import json

    cgi は form の値を読み取るために、urllib, urllib2 は Google の API にアクセスするために、json は API の返答を読み取るために使います。

    後は通常の form のように ‘g-recaptcha-response’ の値を取得して、’https://www.google.com/recaptcha/api/siteverify’ に対して secret と ‘g-recaptcha-response’ の値を POST します。

    # form から g-recaptcha-response の値を取得
    form = cgi.FieldStorage()
    response = form.getfirst('g-recaptcha-response', '')
    
    # API で値を検証する
    url = 'https://www.google.com/recaptcha/api/siteverify'
    secret = 'XXXXXXXXYOURSECRETXXXXXXXXX'
    params = urllib.urlencode({'secret': secret, 'response': response})
    req = urllib2.Request(url, params)
    res = json.loads(urllib2.urlopen(req).read())
    
    # 検証結果
    if res['success']:
        # ここに reCAPTCHA 成功時の処理を書く
        print('Passed reCAPTCHA')
    else:
        # ここに reCAPTCHA 失敗時の処理を書く
        print('Failed to pass reCAPTCHA')

    response は JSON で帰ってきますが、簡単に使うだけなら ‘success’ の値だけを取得すれば OK です。response の JSON は次のような内容を含んでいます(https://developers.google.com/recaptcha/docs/verify)。

    {
      "success": true|false,
      "challenge_ts": timestamp,  // timestamp of the challenge load (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
      "hostname": string,         // the hostname of the site where the reCAPTCHA was solved
      "error-codes": [...]        // optional
    }

    これだけなので、bot 対策に悩まされているなら試してみる価値はあります。

  • Python の bcrypt 実装色々

    Python の bcrypt 実装にはいくつかあって、PyPI で検索すると単体で使うもの以外にも Flask 用、Django 用など様々なものがあります。

    単体で使うには現在でもメンテが活発な bcrypt が良いと思いますが、導入する環境によっては libffi に依存する関係で pip install 中にビルドが通りません。環境を整えれば解決する話(apt なら install libffi-dev で OK)なのですが、共用サーバなのもあって他のもので解決できそうであればその方が良いと判断しました。

    ここで注意しなければいけないのが、上に挙げた 3 つの bcrypt 実装は全て import bcrypt して使うことになっています。これはつまり、異なるライブラリなのに import する名前が bcrypt で同じなので、今どのライブラリを使っているのかが判断できなくて予想外の問題を起す可能性があるということです。

    実際にこんな投稿がありました。

    import bcrypt する場合は開発環境と運用環境の違いに十分注意しないと、一見動いているように見えてもこのようにハマる可能性が高いです。

    bcrypt 3.1.4 から py-bcrypt 0.4 へ

    gensalt() の引数の違い

    試しに同じソースを使って、環境を bcrypt から py-bcrypt に変更してみました。

    <type 'exceptions.TypeError'>: gensalt() got an unexpected keyword argument 'rounds' 
     args = ("gensalt() got an unexpected keyword argument 'rounds'",) 
     message = "gensalt() got an unexpected keyword argument 'rounds'"

    gensalt() の引数が違うようです。

    bcrypt の src/bcrypt/__init__.py を読んでみると、rounds と prefix があります。

    def gensalt(rounds=12, prefix=b"2b"):
        if prefix not in (b"2a", b"2b"):
        raise ValueError("Supported prefixes are b'2a' or b'2b'")
    
        if rounds < 4 or rounds > 31:
        raise ValueError("Invalid rounds")
    
        salt = os.urandom(16)
        output = _bcrypt.ffi.new("char[]", 30)
        _bcrypt.lib.encode_base64(output, salt, len(salt))
    
        return (
            b"$" + prefix + b"$" + ("%2.2u" % rounds).encode("ascii") + b"$" +
            _bcrypt.ffi.string(output)
        )

    一方の py-bcrypt の bcrypt/__init__.py にある gensalt() の実装を見てみると、log_rounds だけです。

    def gensalt(log_rounds = 12):
        """Generate a random text salt for use with hashpw(). "log_rounds"
        defines the complexity of the hashing, increasing the cost as
        2**log_rounds."""
        return encode_salt(os.urandom(16), min(max(log_rounds, 4), 31))

    以上から、bcrypt から py-bcrypt に移行するときは prefix をなくして rounds を log_rounds に直せば OK です。逆の場合は 2a を指定したいときだけ prefix = b’2a’ を与えれば良いです。py-bcrypt は 2a 固定になっていました。

    hashpw() と checkpw()

    hashpw() と checkpw() は特に変更することなく、正常に使えているようです。

  • さくらのレンタルサーバーで pip できない(SSLError)

    TL; DR

    古い OpenSSL が使われるのが原因です。Python ビルド時に新しい OpenSSL を指定すれば解決します。

    $ pyenv uninstall 3.6.5
    $ CPPFLAGS="-I/usr/local/ssl/include" LDFLAGS="-L/usr/local/ssl/lib" pyenv install 3.6.5

    経緯

    さくらのレンタルサーバーはプラン スタンダード以上で SSH が使えるので、pyenv を入れたり pip を入れたりできます。pyenv を使って Python の他のバージョンをインストールするときに注意しないといけないのが、さくらでは Python 導入時のビルドで古い OpenSSL が使われてしまうことです。これの影響で pip したときに次のような SSL のエラーを吐きます。

    $ pip install bcrypt
    Collecting bcrypt
     Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLError(1, '[SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version (_ssl.c:833)'),)': /simple/bcrypt/
     Retrying (Retry(total=3, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLError(1, '[SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version (_ssl.c:833)'),)': /simple/bcrypt/
     Retrying (Retry(total=2, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLError(1, '[SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version (_ssl.c:833)'),)': /simple/bcrypt/
     Retrying (Retry(total=1, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLError(1, '[SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version (_ssl.c:833)'),)': /simple/bcrypt/
     Retrying (Retry(total=0, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLError(1, '[SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version (_ssl.c:833)'),)': /simple/bcrypt/
     Could not fetch URL https://pypi.python.org/simple/bcrypt/: There was a problem confirming the ssl certificate: HTTPSConnectionPool(host='pypi.python.org', port=443): Max retries exceeded with url: /simple/bcrypt/ (Caused by SSLError(SSLError(1, '[SSL: TLSV1_ALERT_PROTOCOL_VERSION] tlsv1 alert protocol version (_ssl.c:833)'),)) - skipping
     Could not find a version that satisfies the requirement bcrypt (from versions: )
    No matching distribution found for bcrypt

    OpenSSL のバージョンが古いのかな?とバージョンを確認しても最新版です。

    $ openssl version
    OpenSSL 1.0.2o 27 Mar 2018

    ですが pyenv install 3.6.5 した Python 上で確認してみると

    $ python
    Python 3.6.5 (default, Jun 26 2018, 10:35:21)
    [GCC 4.2.1 20070831 patched [FreeBSD]] on freebsd9
    Type "help", "copyright", "credits" or "license" for more information.
    >>> import ssl
    >>> ssl.OPENSSL_VERSION
    'OpenSSL 0.9.8zf 19 Mar 2015'

    思いっきり 0.9.8zf と出ていますね。「Python – さくらレンタルサーバーでpip installができません(123028)|teratail」によると、普通にビルドすると最新版の OpenSSL ではなくて、古い方の OpenSSL をリンクしてしまうとのこと。

    さくらレンタルサーバでは普通にpythonをbuildすると古いopensslにつながってしまうようです。
    /usr/local/sslに新しいのが入ってるみたいなので、

    ./configure CPPFLAGS="-I/usr/local/ssl/include" LDFLAGS="-L/usr/local/ssl/lib"

    でpythonを作るとよさそうです。

    ということで一旦インストールした Python を uninstall して、OpenSSL のパスを指定して再度インストールし直します。

    $ pyenv uninstall 3.6.5
    $ CPPFLAGS="-I/usr/local/ssl/include" LDFLAGS="-L/usr/local/ssl/lib" pyenv install 3.6.5
    Downloading Python-3.6.5.tgz...
    -> https://www.python.org/ftp/python/3.6.5/Python-3.6.5.tgz
    Installing Python-3.6.5...
    Installed Python-3.6.5 to /home/USERNAME/.pyenv/versions/3.6.5

    無事インストールできたようなので、OpenSSL のバージョンを確認してみます。

    $ python
    Python 3.6.5 (default, Jun 26 2018, 10:35:21)
    [GCC 4.2.1 20070831 patched [FreeBSD]] on freebsd9
    Type "help", "copyright", "credits" or "license" for more information.
    >>> import ssl
    >>> ssl.OPENSSL_VERSION
    'OpenSSL 1.0.2o 27 Mar 2018'

    大丈夫そうですね。pip の更新を兼ねて pip を試してみます。

    $ pip install -U pip
    Collecting pip
     Downloading https://files.pythonhosted.org/packages/0f/74/ecd13431bcc456ed390b44c8a6e917c1820365cbebcb6a8974d1cd045ab4/pip-10.0.1-py2.py3-none-any.whl (1.3MB)
     100% |################################| 1.3MB 890kB/s
    Installing collected packages: pip
     Found existing installation: pip 9.0.3
     Uninstalling pip-9.0.3:
     Successfully uninstalled pip-9.0.3
    Successfully installed pip-10.0.1

    ばっちり動きました。

    ですが……今回の目的だった bcrypt のインストールは、libffi がなくて結局できず。py-bcrypt なら pip 一発なのでソースコードを py-bcrypt に書き替えた方が早そう。ちょっと不完全燃焼です。

    参考

  • Python 3 と ReportLab で PDF ファイルを生成する

    Python から PDF ファイルを作成できる ReportLab を試してみます。

    インストール

    ReportLab のインストールは pip で一発ですが

    $ python -V
    Python 3.6.5
    $ pip install reportlab
    ...<省略>...
     The headers or library files could not be found for jpeg,
     a required dependency when compiling Pillow from source.

    と Pillow のインストール時に JPEG のヘッダ類がないと怒られるので、libjpeg-dev を apt でインストールしておきます。

    $ sudo apt install libjpeg-dev
    $ pip install reportlab
    Collecting reportlab
     Using cached https://files.pythonhosted.org/packages/87/f9/53b34c58d3735a6df7d5c542bf4de60d699cfa6035e113ca08b3ecdcca3f/reportlab-3.4.0.tar.gz
    Collecting pillow>=2.4.0 (from reportlab)
     Using cached https://files.pythonhosted.org/packages/89/b8/2f49bf71cbd0e9485bb36f72d438421b69b7356180695ae10bd4fd3066f5/Pillow-5.1.0.tar.gz
    Requirement already satisfied: pip>=1.4.1 in /home/pi/.pyenv/versions/3.6.5/lib/python3.6/site-packages (from reportlab) (10.0.1)
    Requirement already satisfied: setuptools>=2.2 in /home/pi/.pyenv/versions/3.6.5/lib/python3.6/site-packages (from reportlab) (39.0.1)
    Installing collected packages: pillow, reportlab
     Running setup.py install for pillow ... done
     Running setup.py install for reportlab ... done
    Successfully installed pillow-5.1.0 reportlab-3.4.0

    PDF を生成する

    公式のユーザガイド(PDF)にあるサンプルコードを実行してみます。

    from reportlab.pdfgen import canvas
    
    def hello(c):
        c.drawString(100,100,"Hello World")
    
    c = canvas.Canvas("hello.pdf")
    hello(c)
    c.showPage()
    c.save()

    hello.pdf が生成されているはずなので開いて確認します。

    ReportLab PDF hello
    ReportLab PDF hello

    簡単ですね。ただし、このままだと日本語を出力しようとしても四角(■)で埋められるだけでした。エンコーディングも関係ないようだったのでフォントを指定する必要があるようです。

    座標系はページ左下を起点としています。先の例では左下から (100, 100) の座標に文字列を描画しています。

    日本語フォント

    こちらを参考にしました。

    ReportLab には予め HeiseiMin-W3, HeiseiKakuGo-W5 が用意されているそうです。ユーザガイドの 3.6 Asian Font Support に Asian Language Packs として記載されていますね。

    Japanese, Traditional Chinese (Taiwan/Hong Kong), Simplified Chinese (mainland China) and Korean are all supported and our software knows about the following fonts:

    • chs = Chinese Simplified (mainland): ‘STSong-Light’
    • cht = Chinese Traditional (Taiwan): ‘MSung-Light’, ‘MHei-Medium’
    • kor = Korean: ‘HYSMyeongJoStd-Medium’,’HYGothic-Medium’
    • jpn = Japanese: ‘HeiseiMin-W3’, ‘HeiseiKakuGo-W5’

    これらを参考に日本語を入れてみたのが次のソースになります。ファイルは UTF-8n で保存しています。

    from reportlab.pdfgen import canvas
    from reportlab.lib.pagesizes import A4, portrait
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.cidfonts import UnicodeCIDFont
    
    c = canvas.Canvas('sample.pdf', pagesize=portrait(A4))
    
    pdfmetrics.registerFont(UnicodeCIDFont('HeiseiKakuGo-W5'))
    
    c.setFont('HeiseiKakuGo-W5', 24)
    w, h = A4
    c.drawCentredString(w / 2, h / 2, '日本語PDFのサンプルです。')
    
    c.showPage()
    c.save()
    ReportLab PDF 日本語
    ReportLab PDF 日本語

    特に問題なく表示されていますね。

    TrueType フォント

    この他にも自分で TrueType フォントを用意して指定することもできます。試しに Windows 10 の游ゴシックを使ってみました。

    from reportlab.pdfgen import canvas
    from reportlab.lib.pagesizes import A4, portrait
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    
    c = canvas.Canvas('sample.pdf', pagesize=portrait(A4))
    
    pdfmetrics.registerFont(TTFont('Yu Gothic Light', 'YuGothL.ttc'))
    
    c.setFont('Yu Gothic Light', 24)
    w, h = A4
    c.drawCentredString(w / 2, h / 2, '日本語PDFのサンプルです。')
    
    c.showPage()
    c.save()
    ReportLab PDF 日本語TTF
    ReportLab PDF 日本語TTF

    ばっちりです。*.ttf だけでなく *.ttc でも大丈夫でした。

    描画メソッド

    直線

    • canvas.line(x1, y1, x2, y2)
    • canvas.lines(linelist)

    図形

    • canvas.grid(xlist, ylist)
    • canvas.bezier(x1, y1, x2, y2, x3, y3, x4, y4)
    • canvas.arc(x1, y1, x2, y2)
    • canvas.rect(x, y, width, height, stroke=1, fill=0)
    • canvas.ellipse(x1, y1, x2, y2, stroke=1, fill=0)
    • canvas.wedge(x1, y1, x2, y2, startAng, extent, stroke=1, fill=0)
    • canvas.circle(x_cen, y_cen, r, stroke=1, fill=0)
    • canvas.roundRect(x, y, width, height, radius, stroke=1, fill=0)

    一行文字列(改行なし文字列)

    • canvas.drawString(x, y, text)
    • canvas.drawRightString(x, y, text)
    • canvas.drawCentredString(x, y, text)

    テキストオブジェクト

    ユーザガイドには次のように記載されています。

    For the dedicated presentation of text in a PDF document, use a text object. The text object interface provides detailed control of text layout parameters not available directly at the canvas level. In addition, it results in smaller PDF that will render faster than many separate calls to the drawString methods.

    テキストが多いなら、一回一回 drawString() を呼び出すよりもこちらを使えってことらしいです。が、いざ使ってみると canvas に beginText() なんてメソッドはないと言われて使えず。

    textobject = canvas.beginText(x, y)
    canvas.drawText(textobject)
    • textobject.setTextOrigin(x, y)
    • textobject.setTextTransform(a, b, c, d, e, f)
    • textobject.moveCursor(dx, dy) # from start of current LINE
    • (x, y) = textobject.getCursor()
    • x = textobject.getX(); y = textobject.getY()
    • textobject.setFont(psfontname, size, leading = None)
    • textobject.textOut(text)
    • textobject.textLine(text=”)
    • textobject.textLines(stuff, trim=1)

    着色

    • canvas.setFillColorCMYK(c, m, y, k)
    • canvas.setStrikeColorCMYK(c, m, y, k)
    • canvas.setFillColorRGB(r, g, b)
    • canvas.setStrokeColorRGB(r, g, b)
    • canvas.setFillColor(acolor)
    • canvas.setStrokeColor(acolor)
    • canvas.setFillGray(gray)
    • canvas.setStrokeGray(gray)

    フォント

    • canvas.setFont(psfontname, size, leading = None)

    Platypus

    ここまでは低水準(low-level)の描画メソッドばかりでした。これらを駆使して複雑な文書を作れないこともないですが、大変な労力を要します。例えば改行や改頁といった処理も自分で行わないといけません。

    ReportLab には Platypus というテンプレートエンジンが用意されいて、一般的な文書の構成であればわりと簡単にページの構成を作ることができます。

    次はユーザガイドに載っている Platypus の最も簡単なサンプルを少しだけ整形したものです。

    from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
    from reportlab.lib.styles import getSampleStyleSheet
    from reportlab.rl_config import defaultPageSize
    from reportlab.lib.units import inch
    PAGE_HEIGHT=defaultPageSize[1]; PAGE_WIDTH=defaultPageSize[0]
    styles = getSampleStyleSheet()
    
    Title = "Hello world"
    pageinfo = "platypus example"
    def myFirstPage(canvas, doc):
        canvas.saveState()
        canvas.setFont('Times-Bold',16)
        canvas.drawCentredString(PAGE_WIDTH / 2.0, PAGE_HEIGHT - 108, Title)
        canvas.setFont('Times-Roman',9)
        canvas.drawString(inch, 0.75 * inch, "First Page / %s" % pageinfo)
        canvas.restoreState()
    
    def myLaterPages(canvas, doc):
        canvas.saveState()
        canvas.setFont('Times-Roman',9)
        canvas.drawString(inch, 0.75 * inch, "Page %d %s" % (doc.page, pageinfo))
        canvas.restoreState()
    
    doc = SimpleDocTemplate("phello.pdf")
    Story = [Spacer(1, 2 * inch)]
    style = styles["Normal"]
    for i in range(100):
        bogustext = ("This is Paragraph number %s. " % i) * 20
        p = Paragraph(bogustext, style)
        Story.append(p)
        Story.append(Spacer(1, 0.2 * inch))
    doc.build(Story, onFirstPage=myFirstPage, onLaterPages=myLaterPages)

    これによって生成された phello.pdf を表示すると、次のようになっています。

    ReportLab Platypus Sample 1/2
    ReportLab Platypus Sample 1/2
    ReportLab Platypus Sample 2/2
    ReportLab Platypus Sample 2/2

    <執筆中>

    参考文献

  • Debian 上の Python 3.6.5 で pyodbc を使う

    $ python -V
    Python 3.6.5
    $ sudo apt install unixodbc-dev
    $ pip install pyodbc

    unixodbc-dev を入れておかないと pip でのインストール中にこけます。

  • Python 3 + pyodbc + unixODBC + FreeTDS の日本語でハマる

    TL;DR

    connect = pyodbc.connect('DSN=SQLServer;UID=user;PWD=password;')
    connect.setencoding('utf-8')
    connect.cursor.execute('SELECT 列 FROM テーブル WHERE 番号 = 5;')

    connect.setencoding() でエンコーディングを指定したら解決しました。

    経緯

    古い基幹システムに SQL Server 2008 が使われており、テーブル名やカラム名が全て日本語で構成されていた関係で、Linux 上の Python から接続するのにひと手間かかっていました。DB 自体は UTF-8 で処理されているので、Python 2 からはクエリを query.encode('utf-8') とすることでうまく処理できていました。さらっと書いていますが、Python にまだ慣れていないときに開発していたので、かなり苦労して辿りついた結論です。

    今回は Bottle を使うに当って Python 3 に移行する関係から DB 接続周りを一新する必要があったのが事の始まりです。DB への接続自体は Python 2 と同じで特に問題もなかったのですが、いざ SELECT 分を execute してみると次のようなエラーを吐きました。

    pyodbc.ProgrammingError: ('42S22', "[42S22] [FreeTDS][SQL Server]Invalid column name 'F'. (207) (SQLExecDirectW)")

    このエラーには見覚えがあり、さらにカラム名は日本語で指定したはずなのに見慣れない文字になっていることから、日本語のエンコーディング周りの問題だろうということはすぐにわかりました。

    まず試してみたのが Python 2 と同じ手法でクエリを encode(‘utf-8’) する方法です。これの結果は

    TypeError: The first argument to execute must be a string or unicode query.

    ユニコードでよこせと怒られてしまいました。なら最初はユニコードで渡してるからそれで良いのではないか。

    次に疑ったのはファイルの文字コードを他のエンコーディングで保存していないかです。でもソースコード冒頭には # -*- coding: utf-8 -*- と記述した上で、間違いなく UTF-8N で保存しています。試しに Shift JIS で保存すると、次のエラーで実行すらできません。

    SyntaxError: (unicode error) 'utf-8' codec can't decode byte 0x8f in position 0: invalid start byte

    調べていても、そもそも Linux の Python から Windows Server の SQL Server を扱う事例自体が少なすぎて、情報があまり得られません。そこで、pyodbc のソースを読んでみようと思ったら、そのものずばりの情報がありました。

    記事冒頭に書いた、DB に connect() した後 connection.setencoding('utf-8') するだけです。これで無事に日本語のテーブル名、列名を処理できました。

    たった一文のことですが、情報量が少ないとこれに辿りついて気づくまでが本当に大変です。

  • 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)