Macで、uWSGIをインストールしたらエラーになった

MacPython環境にuWSGIをインストールしようとしたところ、

# Xcodeのバージョン確認
$ xcodebuild -version
Xcode 8.1
Build version 8B62

# pipでインストール
$ pip install uwsgi
...
  *** uWSGI linking ***
...
  ld: file not found: /usr/lib/system/libsystem_symptoms.dylib for architecture x86_64
  clang: error: linker command failed with exit code 1 (use -v to see invocation)
  *** error linking uWSGI ***
...

というエラーになったため、メモを残します。

 

環境

 

対応

uWSGIのGitHubにIssueがありました。
Error in pip install uwsgi in macos x · Issue #1364 · unbit/uwsgi

 
そこに記載されていた通り実行したところ、

# Issueの手順に従う
$ brew unlink libxml2
$ brew uninstall libxml2
$ brew install --with-python libxml2
$ brew link libxml2 --force

# 再度インストール
$ pip install uwsgi
...
Successfully installed uwsgi-2.0.14

インストールできました*1

*1:Issueの手順のうち「brew link libxmls2 --force」に、「libxml"s"2」と余計なsがあるので注意

Pythonで、Cookieを扱うWSGIフレームワークを自作してみた

前回、書籍「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」をもとに、PythonでURLルーティング機能だけがあるWSGIフレームワークを自作しました。

gihyo.jp

 
今回は、以下のRFC6265やWebサイトを参考に、前回のWSGIフレームワークCookieを扱える機能を追加してみます。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit

 

Webサーバへ機能追加

前回までと同様、自作WSGIフレームワークは、WSGI準拠の自作Webサーバ上で動作させます。

ただ、前回までの自作WebサーバではCookieを扱うことができないため、機能を追加します。

 

Cookieを扱えるようにする

UAからCookieを含むHTTPリクエストを自作Webサーバへ送信したところ、

GET / HTTP/1.1
...
Cookie: visit=1

というリクエストラインが取得できました。
Cookieとセッション管理 - Qiita

 
RFC6265にて、UAからサーバへはCookieヘッダをいくつ送信可能なのかを調べたところ、

UA は HTTP リクエストを生成する際に、複数の Cookie ヘッダを添付してはならない。

5.4. Cookie ヘッダ | RFC 6265 — HTTP State Management Mechanism (日本語訳)

との記載がありました。一つだけと考えて良さそうです。

 
続いて、WSGIサーバからWSGIフレームワークCookieを渡す方法を探したところ、接頭辞HTTPを持つ変数をenviron辞書へ設定すれば良さそうでした。
PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation

 
そこで

# "Cookie: "を削除
HTTP_HEADER_COOKIE = 'Cookie:'
request_cookies = [line.decode('utf-8').replace(HTTP_HEADER_COOKIE, '').lstrip() for line in byte_request_lines if line.decode('utf-8').find(HTTP_HEADER_COOKIE) == 0]

# environ辞書へ設定
env['HTTP_COOKIE']   = request_cookies[0]

のように実装しました。

 

サーバで生成するDateヘッダに、現在時刻をセットする

今まで、自作Webサーバでは固定値をセットしていました。

今回、CookieのMax-Age属性を試すときに分かりやすくするため、以下を参考に現在時刻を設定するように修正します。
http - RFC 1123 Date Representation in Python? - Stack Overflow

 
以上でWSGIサーバの機能追加は完了です。

 

WSGIフレームワークへ機能追加

自作WSGIフレームワークに対し、Cookieを扱う機能を追加します。

PythonCookieを簡単に扱う方法を調べたところ、標準ライブラリにあるhttp.cookies.SimpleCookieを使うのが良さそうでした。
21.23. http.cookies — HTTPの状態管理 — Python 3.5.2 ドキュメント

 
文字列からSimpleCookieオブジェクトを生成する方法を調べるためにソースコードを読んでみました。

SimpleCookieクラスの__init__()メソッドに文字列を渡すと内部ではload()メソッドが呼ばれることから、文字列からSimpleCookieオブジェクトを生成できていました。
cpython/cookies.py - GitHub

 
そのため、自作WSGIフレームワークでは

def __call__(self, environ, start_response):
    self.cookie = SimpleCookie(environ['HTTP_COOKIE']) if 'HTTP_COOKIE' in environ else None
# ...

として、SimpleCookieオブジェクトを保持することにしました。

 

WSGIアプリへ機能追加

Cookieの挙動確認

機能を追加する前に、Cookieの挙動を確認します。

 

Cookieの属性について

RFC6265にて、

クッキーの属性は、 UA からサーバへは返されないことに注意。 特に,サーバは、単独の Cookie ヘッダからは,クッキーが[ いつ失効するのか?/ どのホストに対して有効なのか?/ どのパスに対して有効なのか?/ Secure や HttpOnly 属性を伴って設定されたものかどうか? ]を決定できない。

4.2.2. 意味論 | RFC 6265 — HTTP State Management Mechanism (日本語訳)

と記載されているため、その挙動を確認します。

 
cookie_test_set_property_only_once.py

# python server.py cookie_test_set_property_only_once:app
from http.cookies import SimpleCookie
from no1_1_cookie_framework import MyWSGIFramework

def cookie(environ, start_response):
    visit_in_html = 1
    headers = [('Content-Type', 'text/plain'),]

    if app.cookie:
        visit_in_html = int(app.cookie['http_only'].value) + 1
    else:
        http_only = SimpleCookie('http_only=1')
        http_only['http_only']['httponly'] = True
        headers.append(('Set-Cookie', http_only.output(header='')))
    
    start_response('200 OK', headers)
    return ["Hello, No.{}".format(visit_in_html).encode('utf-8')]


# URLルーティング設定
app = MyWSGIFramework([
    ('/', cookie),
])

というWSGIアプリを作成・実行し、ヘッダを確認してみたところ、

# 一回目
## Request Headers
GET / HTTP/1.1
Host: localhost:8888
...

## Response Headers
HTTP/1.1 200 OK
Content-Type: text/plain
Set-Cookie: http_only=1; HttpOnly
Date: Wed, 28 Sep 2016 08:06:36 GMT
Server: MyWSGIServer 0.3


#------------------------------
# 二回目
## Request Headers
GET / HTTP/1.1
Host: localhost:8888
...
Cookie: http_only=1

## Response Headers
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Wed, 28 Sep 2016 08:06:53 GMT
Server: MyWSGIServer 0.3

のように、二回目のRequest HeaderのCookieヘッダにはHttpOnly属性が含まれていませんでした。

 

CookieのMax-Age属性について

Max-Age属性ではCookieが失効するまでの秒数を指定できるため、この挙動も確認してみます。

 
cookie_test_set_max_age.py

# python server.py cookie_test_set_max_age:app
from http.cookies import SimpleCookie
from no1_1_cookie_framework import MyWSGIFramework

def cookie(environ, start_response):
    visit_in_html = 1
    headers = [('Content-Type', 'text/plain'),]

    if app.cookie:
        visit_in_html = int(app.cookie['http_only'].value) + 1
    else:
        http_only = SimpleCookie('http_only=1')
        http_only['http_only']['max-age'] = 5
        headers.append(('Set-Cookie', http_only.output(header='')))
    
    start_response('200 OK', headers)
    return ["Hello, No.{}".format(visit_in_html).encode('utf-8')]


# URLルーティング設定
app = MyWSGIFramework([
    ('/', cookie),
])

というWSGIアプリを作成し、指定した秒数以上(今回は5秒以上)待ってから二回目のリクエストを送ったところ、

# 一回目
## Request Headers
GET / HTTP/1.1
Host: localhost:8888
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: ja,en-US;q=0.8,en;q=0.6

## Response Headers
HTTP/1.1 200 OK
Content-Type: text/plain
Set-Cookie: http_only=1; Max-Age=5
Date: Wed, 28 Sep 2016 08:39:58 GMT
Server: MyWSGIServer 0.3


#------------------------------
# 二回目
## Request Headers
GET / HTTP/1.1
Host: localhost:8888
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: ja,en-US;q=0.8,en;q=0.6

## Response Headers
HTTP/1.1 200 OK
Content-Type: text/plain
Set-Cookie: http_only=1; Max-Age=5
Date: Wed, 28 Sep 2016 08:40:51 GMT
Server: MyWSGIServer 0.3

と、二回目のRequest HeadersにCookieヘッダが含まれていませんでした。

 

複数のSet-Cookieヘッダを作成する機能を追加

RFC6265より、複数のCookieがある場合、その分Set-Cookie応答ヘッダを用意する必要があります。

Set-Cookie 応答ヘッダは[ ヘッダ名 "Set-Cookie", 文字 ":", 1個のクッキー ]の並びからなる。 各クッキーは、1個の name-value-pair ([ 名前 cookie-name, 値 cookie-value ]の組)から開始され,ゼロ個以上の属性([ 名前 attribute-name, 値 attribute-value ]の組)が後続する。

4.1.1. 構文 | RFC 6265 — HTTP State Management Mechanism (日本語訳)

 
そこで、複数のCookieがあるapp.cookie(SimpleCookieオブジェクト)の値を、output()メソッドを使って確認してみます。
BaseCookie.output() | 21.23. http.cookies — HTTPの状態管理 — Python 3.5.2 ドキュメント

print('-'*10)
print('app_cookie:\n{}\n'.format(app.cookie.output(header='')))
print('-'*10)

結果は、

----------
app_cookie:
 hoge=fuga
 visit=2; HttpOnly

----------

となり、各Cookieが「CRLF + 半角スペース」で連結された状態で取得できました。

 
今回、HTTPレスポンスヘッダはheaders.append(('Set-Cookie', '<Cookie名>=<Cookie値>'))という形で作成するため、

server_cookies = app.cookie.output(header='').split('\r\n')
for sc in server_cookies:
    headers.append(('Set-Cookie', sc))

としました。

 

Cookieの処理をWSGIアプリからWSGIフレームワークへ移動

今までの実装では、Cookieの処理はWSGIアプリ側で行われていました。

これではWSGIアプリを作成するごとにCookieの処理も実装しなければならないため、WSGIフレームワーク側へと移動してみます。

 
WSGIフレームワークでは

の3つのメソッドを用意します。

def set_cookie(self, key, value, **kwargs):
    self.cookie[key] = value
    for k, v in kwargs.items():
        # max-age属性は引数名「max_age」として設定されてくる前提
        # Pythonではハイフンが引数名で使えないため
        self.cookie[key][k.replace('_', '-')] = v

def get_cookie(self, key):
    return self.cookie.get(key, None)

def generate_cookie_header(self):
    # HTTPリクエストヘッダのCookieに複数のCookieが含まれる場合、Cookie名ごとにSet-Cookieヘッダを生成する
    headers = []
    response_cookies = self.cookie.output(header='').split('\r\n')
    for cookie in response_cookies:
        headers.append(('Set-Cookie', cookie))

    return headers

 
それを使用するWSGIアプリは

def cookie(environ, start_response):
    visit_in_html = 1
    headers = [('Content-Type', 'text/plain'),]

    if app.cookie:
        v = app.get_cookie('visit')
        if v:
            visit_in_html = int(v.value) + 1
            app.set_cookie('visit', visit)
        else:
            app.set_cookie('visit', 1)
        app.set_cookie('fuga', 'fugafuga', httponly=True)

    else:
        app.set_cookie('visit', 1)

        # Cookie値の他、max-age属性もセットしてみる
        app.set_cookie('hoge', 'hogehoge', max_age=10)

        # Cookie値の他、httponly属性もセットしてみる
        app.set_cookie('fuga', 'fugafuga', httponly=True)

    # ここまでで作成したCookieをCookieヘッダに設定する
    # リストにリストを追加するので、append()ではなくextend()を使う
    headers.extend(app.generate_cookie_header())

    start_response('200 OK', headers)
    return ["Hello, No.{}".format(visit_in_html).encode('utf-8')]

として、Cookie処理をWSGIフレームワーク側へと移動しました。

 

ソースコード

GitHubに上げました。cookieディレクトリの下が今回のソースコードになります。
thinkAmi-sandbox/wsgi_framework-sample

Pythonで、URLルーティング機能だけがあるWSGIフレームワークを自作してみた

以前、

を自作しました。

これらの自作により、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」の第3章以降もPythonで書く見通しが立ちました。

gihyo.jp

 
第3章以降では、URLルーティングやクッキー・セッションなどの機能が登場します。

ただ、それらの機能を

のどちらの形で実装するか悩みました。

それでも、せっかくなので、後者のWSGIフレームワークを自作してみようと考えました。

そこで今回、URLルーティング機能だけがあるWSGIフレームワークを自作してみました。

なお、学習用途で作ったため、セキュリティ面は考えていませんのであしからず。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit

 
なお、WSGI準拠のWebサーバは自作したものを使うため、server.pyとして保存しておきます。
wsgi_webserver-sample/multi_response_wsgi_server.py at master · thinkAmi-sandbox/wsgi_webserver-sample

   

復習:単純なWSGIアプリケーション

まずは復習として、単純なWSGIアプリケーションを作ってみます。

no1_base_app.py

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
return [b"Hello, world."]

 
動作確認として、

(env) >python server.py no1_base_app:application

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、ブラウザ上に 「Hello, world」 が表示されました。

URLルーティングは実装していないため、http://localhost:8888/hogeのようなURLでも、「Hello, world」が表示されます。

 

URLルーティング機能を作る(関数版)

続いて、URLルーティング機能を持ったアプリを作ります。

ただ、最初からデコレータを使ってURLルーティング機能を実装すると、自分の理解が追いつかないかもしれないと考えました。

そこで、その代わりとなる方法を調べてみたところ、以下の記事がありました。
Serving Static Files and Routing with WSGI

この記事の方法が分かりやすかったため、no2_router_separately_by_func.pyとして実装してみます。

 
まずはURLルーティングのためのタプルのリストを作ります。

routes = [
    ('/', get),
    ('/hoge', hoge),
]

その内容は

  • タプルは2つの要素を持つ
    • ひとつ目の要素は、対象のURLパス
    • ふたつ目の要素は、対象のURLパスにアクセスした時に呼ばれる関数

です。

 
続いて、WSGIサーバから呼ばれる関数を用意します。

def application(environ, start_response):
    for path, func in routes:
        if path == environ['PATH_INFO']:
            return func(environ, start_response)

    return not_found(environ, start_response)

タプルを順次読み込み、リクエストのパス(environ['PATH_INFO'])と一致した場合に、タプルで指定した関数を呼び出しています。

 
あとは、application関数から呼び出される関数を

def get(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world."]

def hoge(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"hoge"]

def not_found(environ, start_response):
    start_response('404 Not Found', [('Content-Type', 'text/plain')])
    return [b"Not found."]

と用意して完成です。

 
動作確認として、

(env) >python server.py no2_router_separately_by_func:application 

を実行し、ブラウザでアクセスすると以下のようになります。

アクセス先のURL 表示内容
http://localhost:8888/ Hello, world
http://localhost:8888/hoge hoge
http://localhost:8888/fuga Not found.

 

URLルーティング機能を作る(クラス版)

URLルーティング機能を、関数版からクラス版へと変更してみます。

no3_router_separately_by_class.py

class MyWSGIApplication(object):
    def __init__(self):
        self.routes = [
            ('/', self.get),
            ('/hoge', self.hoge),
        ]

    def get(self, environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"Hello, world by class."]

    def hoge(self, environ, start_response):
        start_response('200 OK', [('Content-Type', 'text/plain')])
        return [b"hoge by class"]

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found by class."]


    def __call__(self, environ, start_response):
        for path, method in self.routes:
            if path == environ['PATH_INFO']:
                return method(environ, start_response)

        return self.not_found(environ, start_response)

app = MyWSGIApplication()

 
動作確認として、

(env) >python server.py no3_router_separately_by_class:app

として、ブラウザでアクセスしても、末尾に「 by class.」が付くだけで、それ以外の動作は変わっていません。

 

フレームワーク

次は

の2つに機能を分割して、それぞれを実装します。

 
WSGIフレームワークno3_router_separately_by_class.pyからURLルーティング機能を移植します。

no4_1_framework.py

class MyWSGIFramework(object):
    def __init__(self, routes):
        self.routes = routes

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found by framework."]


    def __call__(self, environ, start_response):
        for path, method in self.routes:
            if environ['PATH_INFO'] == path:
                return method(environ, start_response)
        return self.not_found(environ, start_response)

 
WSGIアプリはURLルーティング用のタプルのリストと、URLルーティングされた時に呼ばれる関数だけになります。

no4_2_app.py

from no4_1_framework import MyWSGIFramework

def get(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world by framework."]

def hoge(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"hoge by framework"]


app = MyWSGIFramework([
    ('/', get),
    ('/hoge', hoge),
])

 
動作確認として、

(env) >python server.py no4_2_app:app

として、ブラウザでアクセスしても、末尾に「by framework」が付くだけで、それ以外の動作は変わっていません。

 

HTMLテンプレートやCSS、画像の配信

最後に、Jinja2を使ってHTMLテンプレートやCSS・画像を表示できるようにします。

CSSや画像の出力については定型的な処理のため、今回はフレームワーク側で処理します。

画像ファイルについては、自作WSGIサーバではsocket.sendfile()を使って送信しています。そのため、画像ファイルをファイルライクオブジェクトの形にして自作WSGIサーバに渡す必要があります。

今回は、バイナリモードでファイルを読み込み、io.BytesIOにてバイナリストリームとすることで、ファイルライクオブジェクトの形にしています*1
16.2. io — ストリームを扱うコアツール — Python 3.5.1 ドキュメント

fullpath = './{}'.format(environ['PATH_INFO'])
with open(fullpath, "rb") as f:
    r = f.read()

binary_stream = io.BytesIO(r)

# 変数content_typeには、ファイルの種類により'text/css'か'image/png'が設定済
start_response('200 OK', content_type)

return binary_stream

 
それを受けて、自作WSGIサーバ(server.py)では、

if 'image' in str_response:
    # 画像データの場合
    self.connection.sendall(str_response.encode('utf-8'))
    self.connection.sendfile(byte_response_body)

else:
    # 画像データ以外の場合
    for byte_body in byte_response_body:
        str_response += byte_body.decode('utf-8')
    self.connection.sendall(str_response.encode('utf-8'))

のような形で、クライアントへデータを送信しています。

なお、PEP3333にはファイルの扱いに関しての記述もありましたが、今回は置いておきます。
PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation

 
ソースコード全体ですが、WSGIフレームワーク

no5_1_framework.py

import re
import io

class MyWSGIFramework(object):
    def __init__(self, routes, css_dir=None, img_dir=None):
        self.routes = routes

        # 静的ファイル向けのディレクトリ設定
        self.static_dir = '/static'
        css_dir = css_dir if css_dir else "/css/"
        self.css_dir = '{static}{css}'.format(static=self.static_dir, css=css_dir)
        img_dir = img_dir if img_dir else "/images/"
        self.img_dir = '{static}{img}'.format(static=self.static_dir, img=img_dir)

    def static(self, environ, start_response):
        content_type = []
        if re.match(self.css_dir, environ['PATH_INFO']):
            content_type.append(('Content-Type', 'text/css'))
        elif re.match(self.img_dir, environ['PATH_INFO']):
            content_type.append(('Content-Type', 'image/png'))
        else:
            return self.not_found(environ, start_response)

        try:
            fullpath = './{}'.format(environ['PATH_INFO'])
            with open(fullpath, "rb") as f:
                r = f.read()
            binary_stream = io.BytesIO(r)
        except:
            start_response('500 Internal Server Error', [('Content-Type', 'text/plain')])
            return [b"Internal server error"]

        start_response('200 OK', content_type)
        return binary_stream

    def not_found(self, environ, start_response):
        start_response('404 Not Found', [('Content-Type', 'text/plain')])
        return [b"Not found."]


    def __call__(self, environ, start_response):
        if re.match(self.static_dir, environ['PATH_INFO']):
            return self.static(environ, start_response) 

        for path, method in self.routes:
            if environ['PATH_INFO'] == path:
                return method(environ, start_response)
        return self.not_found(environ, start_response)

となりました。

また、WSGIアプリは、

no5_2_app.py

# python server.py no5_2_app:app
from no5_1_framework import MyWSGIFramework
from jinja2 import Environment, FileSystemLoader

def get(environ, start_response):
# (省略)

def hoge(environ, start_response):
# (省略)

def index(environ, start_response):
    jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
    template = jinja2_env.get_template('index.html')
    html = template.render({'messages': ['hoge', 'fuga', 'piyo']})

    start_response('200 OK', [('Content-Type', 'text/html')])
    return [html.encode('utf-8')]


app = MyWSGIFramework([
    ('/', get),
    ('/hoge', hoge),
    ('/index', index),
])

となりました。

 
動作確認として、

(env) >python server.py no5_2_app:app

として、ブラウザでhttp://localhost:8888/indexへとアクセスすると、HTMLテンプレートやCSS、画像が表示されました。

 

ソースコード

GitHubに上げました。routing_onlyディレクトリの下が、今回のサンプルです。
wsgi_framework-sample/routing_only at master · thinkAmi-sandbox/wsgi_framework-sample

 

参考

 
もし、FlaskやBottleのようにデコレータを使ったURLルーティング機能を実装する場合には、以下のBottleのソースコード解説も参考になるかもしれません。
Python Bottleのソースを読む ルータ編 - TAISA BLOG

 
2016/9/28 追記 ここから

PyConJP2016にてWSGIフレームワークの作り方に関する発表がありました。
#pyconjp でWeb(WSGI)フレームワークの作り方について話してきました - c-bata web

以前kobinのソースコードを読んだとき、ルーティング・リクエスト・レスポンスについて「なぜこのような形で実装したのか」をつみきれないところがありました。

上記の資料ではそれに対する回答が記載されていたため、スッキリするとともにとても参考になりました。ありがとうございました。

2016/9/28 追記 ここまで

*1:この辺りの説明が正しいかどうか分からないため、誤りがあればご指摘いただけるとありがたいです

Pythonで、自作したWSGI準拠のWebサーバ上で、自作のWSGIアプリを動かしてみた

前回WSGI準拠のWebサーバを自作しました。

その時はWSGIアプリケーションフレームワークとしてBottleを使いましたが、今回はWSGIアプリケーションも自作してみます。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit
  • Jinja2 2.8

 
WSGIアプリケーションのテンプレートエンジンはどうしようかと考えました。ただ、以下を読むと、自作に手間がかかりそうでした。
みんなのPython Webアプリ編 - 標準モジュールを使ったテンプレートエンジン | TRIVIAL TECHNOLOGIES 4 @ats のイクメン日記

そこで今回は、Django・Flask・Bottleなどに対応しているJinja2をテンプレートエンジンとして使うことにしました。
Welcome to Jinja2 — Jinja2 Documentation (2.8-dev)

 
また、自作したWSGI準拠のWebサーバは以下を流用します。
wsgi_webserver-sample/multi_response_wsgi_server.py at master · thinkAmi-sandbox/wsgi_webserver-sample

 

最も単純なWSGIプリケーション

関数一つで動作します。
第1回 WSGIの概要:WSGIとPythonでスマートなWebアプリケーション開発を|gihyo.jp … 技術評論社

Python3版の場合、戻り値はバイト列とする必要があるため、

hello_wsgi_app_by_function.py

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world."]

とします。

 
動作確認として

(env) >python multi_response_wsgi_server.py hello_wsgi_app_by_function:application

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、ブラウザ上に 「Hello, world」 が表示されました。

 

Jinja2テンプレートを使う(関数版)

Jinja2テンプレートエンジンを使う方法は、以下が参考になりました。

 
Jinja2をインストールします。

(env) >pip install jinja2

 
Jinja2向けのテンプレートファイルを用意します。

templates/hello.html

<html>
    <head>
        <meta charset="UTF-8">
        <title>hello</title>
    </head>
    <body>
        <h1>Hello, world part2</h1>
    </body>
</html>

 
Jinja2テンプレートエンジンをWSGIアプリケーションへと組み込みます。

jinja2_wsgi_app_by_function.py

from jinja2 import Environment, FileSystemLoader

def application(environ, start_response):
    jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
    template = jinja2_env.get_template('hello.html')
    html = template.render()

    start_response('200 OK', [('Content-Type', 'text/html')])
    return [html.encode('utf-8')]

 
動作確認として

(env) >python multi_response_wsgi_server.py jinja2_wsgi_app_by_function:application

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、ブラウザ上に 「Hello, world part2」 が表示されました。

 

Jinja2テンプレートを使う(クラス版)

次はクラス形式で書いてみます。

PEP3333によると、__call__メソッドを持つインスタンスを用意すれば良さそうでした。
Specification Overview | PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation

jinja2_wsgi_app_by_class.py

from jinja2 import Environment, FileSystemLoader

class MyWSGIApplication(object):
    def __call__(self, environ, start_response):
        jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
        template = jinja2_env.get_template('hello.html')
        html = template.render()

        start_response('200 OK', [('Content-Type', 'text/html')])
        return [html.encode('utf-8')]

app = MyWSGIApplication()

 
動作確認として

(env) >python multi_response_wsgi_server.py jinja2_wsgi_app_by_function:application

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、ブラウザ上に 「Hello, world part2」 が表示されました。

 

クエリパラメータをテンプレートへ反映する

GETの動作は確認できたので、今度はGETのクエリパラメータを扱ってみます。

GETのクエリパラメータを扱いやすくする方法を探したところ、cgi.FieldStorageを使うのが良さそうでした。
Python(WSGI)でGET、POSTを処理する方法.-蔦箱(ツタハコ)

 
templates/query.html

<html>
    <head>
        <meta charset="UTF-8">
        <title>GETテスト</title>
    </head>
    <body>
        <h1>GETテスト</h1>
        <p>{{ title }} : {{ comment }}</p>
    </body>
</html>

 
get_query_wsgi_app_by_class.py

import cgi
from jinja2 import Environment, FileSystemLoader

class MyWSGIApplication(object):
    def __call__(self, environ, start_response):
        if environ['REQUEST_METHOD'].upper() == "GET":
            jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
            template = jinja2_env.get_template('get.html')

            fs = cgi.FieldStorage(
                environ=environ,
                keep_blank_values=True,
            )
            html = template.render(title=fs['title'].value, comment=fs['comment'].value)

            start_response('200 OK', [('Content-Type', 'text/html')])
            return [html.encode('utf-8')]


app = MyWSGIApplication()

 
ただ、これだけではクエリパラメータがWSGIアプリケーションに渡らないため、WSGIサーバも修正します。

クライアントからのリクエストにて、ステータスラインにクエリパラメータが含まれます(例:GET /?title=aaa&comment=bbb HTTP/1.1)。

ただ、ステータスラインからクエリパラメータだけを抜き出す良い方法が思い浮かばなかったため、

multi_response_wsgi_server.py

def get_environ(self, byte_request):
#...
    request_method, path, request_version = str_request_line_without_crlf.split()

    fullpath = "http://{server}:{port}{path}".format(
        server=self.server_name,
        port=str(self.server_port),
        path=path,
    )
    parsed_fullpath = urlparse(fullpath)

    env = {}
#...
    env['PATH_INFO']         = path                  # /
    env['QUERY_STRING']      = parsed_fullpath.query # query parameter

#...
    return env

と、一度URLへと変換してからurllib.parse.urlparse()で抜き出してenv['QUERY_STRING']へと設定しました。

 
動作確認として

(env) >python multi_response_wsgi_server.py get_query_wsgi_app_by_class:app

を実行し、http://localhost:8888/?title=aaa&comment=bbbへアクセスすると

GETテスト

aaa : bbb

と、クエリパラメータの値が表示されました。

 

POSTを扱う

POSTを扱うWSGIアプリケーションとして、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」の「3.3.3. Tomcatで掲示板を作る」の掲示板アプリケーションを作ります。

このアプリケーションの流れは、

  1. POST
  2. 301リダイレクト
  3. GET

でした。

301リダイレクトをするには、レスポンスヘッダにLocationヘッダを追加します。

bbs_wsgi_app.app

import datetime
import cgi
import io
from jinja2 import Environment, FileSystemLoader

class Message(object):
    def __init__(self, title, handle, message):
        self.title = title
        self.handle = handle
        self.message = message
        self.created_at = datetime.datetime.now()


class MyWSGIApplication(object):
    def __init__(self):
        self.messages = []

    # https://knzm.readthedocs.io/en/latest/pep-3333-ja.html#the-application-framework-side
    def __call__(self, environ, start_response):
        if environ['REQUEST_METHOD'].upper() == "POST":
            # POSTヘッダとボディが一緒に格納されている
            # cgi.FieldStorageで使うために、
            #  ・リクエストをデコード
            #  ・ヘッダとボディを分離
            #  ・ボディをエンコード
            #  ・ボディをio.BytesIOに渡す
            # を行う
            decoded = environ['wsgi.input'].read().decode('utf-8')
            header_body_list = decoded.split('\r\n')
            body = header_body_list[-1]
            encoded_body = body.encode('utf-8')

            # http://docs.python.jp/3/library/io.html#io.BytesIO
            with io.BytesIO(encoded_body) as bytes_body:
                fs = cgi.FieldStorage(
                    fp=bytes_body,
                    environ=environ,
                    keep_blank_values=True,
                )
            # 念のためFieldStorageの内容を確認
            print('-'*20 + '\nFieldStorage:{}\n'.format(fs) + '-'*20)

            self.messages.append(Message(
                title=fs['title'].value,
                handle=fs['handle'].value,
                message=fs['message'].value,
            ))

            # リダイレクトはLocationヘッダをつけてあげれば、ブラウザがうまいことやってくれる
            location = "{scheme}://{name}:{port}/".format(
                scheme = environ['wsgi.url_scheme'],
                name = environ['SERVER_NAME'],
                port = environ['SERVER_PORT'],
            )
            start_response('301 Moved Permanently', [('Location', location)])
            # 適当な値を返しておく
            return [b'1']

        else:
            jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
            template = jinja2_env.get_template('bbs.html')
            html = template.render({'messages': self.messages})
            start_response('200 OK', [('Content-Type', 'text/html')])
            return [html.encode('utf-8')]


app = MyWSGIApplication()

 
動作確認として

(env) >python multi_response_wsgi_server.py bbs_wsgi_app:app

を実行し、ブラウザでhttp://localhost:8888/へアクセスすると、掲示板アプリが再現できました。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/wsgi_application-sample

 

その他参考

クラス宣言について

いろいろなコードのクラス宣言を見ると、明示的なobjectの継承があったりなかったりしました。

どちらのほうが良いか調べてみましたところstackoverflowに情報がありました。
Python class inherits object - Stack Overflow

自分はPython2系で書く機会もあるため、今後も明示的なobject継承で書くことにしました。

PythonでWSGI準拠のWebサーバを自作し、その上でBottleを動かしてみた

以前、「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」を参考に、PythonでWebサーバを書きました。
「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」が良かったのでPythonで書いてみた - メモ的な思考的な

gihyo.jp

 
この時は3章以降は読むだけにしましたが、やはり3章以降もPythonで実装してみたいと考えました。

3章以降はJava + Tomcatを使っていたため、Pythonの場合はWSGIを使って作るのが良さそうです。
java - What is the Python equivalent of Tomcat? - Stack Overflow

 
まずはWSGIの仕様を知ろうと、PEP3333の日本語訳を中心に読んでみました。

 
次に、WSGI準拠Webサーバをイチから作成する方法を調べてみたところ、以下の記事を見つけました(以下、「参考記事」と表記)。
Let’s Build A Web Server. Part 2. - Ruslan's Blog

図などで詳しく説明されていたため、だいたいの流れは理解できました。ありがとうございました。

 
上記を元にWSGI準拠のWebサーバを実装してみたため、その時の内容をメモしておきます*1

なお、誤りなどがあれば、ご指摘いただけるとありがたいです。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit
  • bottle 0.12.9

 
今回は、WSGI準拠の自作Webサーバ上で、WSGIアプリケーションフレームワークのBottleを使ったアプリを動かします。
Bottle: Python Web Framework — Bottle 0.13-dev documentation

Bottleアプリの内容は、

bottle_app.py

@route('/')
def index():
    # Bottleで設定するレスポンスヘッダを確認
    print("----Bottle's response_header----")
    print(bottle.response.headerlist)
    return template('index.html')

@route('/static/css/<filename>')
def static_css(filename):
    return static_file(filename, root=STATIC_DIR + '/css/')

@route('/static/images/<filename>')
def static_css(filename):
    return static_file(filename, root=STATIC_DIR + '/images/')

app = bottle.default_app()

 
views/index.html

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>テスト</title>
        <link rel="stylesheet" href="/static/css/default.css">
    </head>
   <body>
       <h1>テストタイトル</h1>
       <p>テストページです</p>
       <p>今日のシナノゴールド</p>
       <img src="/static/images/shinanogold.png">
   </body>
</html>

 
static/css/default.css

h1 {
    color: red;
}

です。

 

1つのレスポンスを返すWSGI Webサーバの作成

まずは、単純に1つのレスポンスを返すWSGI Webサーバを作成します。

前回作成した以下のWebサーバをベースにします。
syakyo_create_web_server/modoki01_1_5_3.py at master · thinkAmi-sandbox/syakyo_create_web_server

 
WSGI準拠とするため、

  • WSGI環境変数の作成
  • WSGIアプリケーションから呼び出されるコールバック関数の作成
  • WSGI環境変数とコールバック関数を引数とした、WSGIアプリケーションの呼び出し

の3点を追加します。

 

WSGI環境変数の作成

PEP3333では、このあたりに書いてあります。

今回は、

env = {}
env['wsgi.version']      = (1, 0)   # WSGIのバージョン:決め打ち
env['wsgi.url_scheme']   = 'http'   # urlのスキーム(http/https)
# HTTP リクエスト本体のバイト列を読み出すことができる入力ストリーム 
env['wsgi.input']        = io.BytesIO(byte_request)
env['wsgi.errors']       = sys.stderr
env['wsgi.multithread']  = False
env['wsgi.multiprocess'] = False
env['wsgi.run_once']     = False
env['REQUEST_METHOD']    = request_method    # GET
env['PATH_INFO']         = path              # /
env['SERVER_NAME']       = server_name       # FQDN
env['SERVER_PORT']       = str(port)         # 8888

としました。

 

WSGIアプリケーションから呼び出されるコールバック関数の作成

PEP3333では、このあたりに書いてあります。

コールバック関数で必要な引数は、

  • status
    • 文字列で、 200 OK などが入ってくる
  • response_headers
    • 文字列で、 [('Content-Length', '398'), ('Content-Type', 'text/html; charset=UTF-8')] などが入ってくる
  • exc_info=None
    • エラーがなければ、基本はNone

の3つです。引数名は任意ですが、今回はPEP3333に合わせます。

 
コールバック関数の中では、ステータスコードやレスポンスヘッダがWSGIアプリケーションから渡されます。

ただ、

  • コールバック関数の戻り値として、それらをWebサーバへ渡すことができない
  • コールバック関数の中以外では、それらを取得できない

という制約があります。

そのため、参考記事ではインスタンス変数を使ってWebサーバへそれらを渡していました。

ただ、今回は関数による実装のため、

def start_response(status, response_headers, exc_info=None):
    # 任意の内容の、WSGIサーバで追加するレスポンスヘッダ
    server_headers = [
        ('Date', 'Sat, 16 Jul 2016 00:00:00 JST'),
        ('Server', 'HenaWSGIServer 0.1'),
    ]

    global headers_set
    headers_set = [status, response_headers + server_headers]

と、グローバル変数へと保存しました。

 

WSGI環境変数とコールバック関数を引数とした、WSGIアプリケーションの呼び出し

上記で作成した環境変数とコールバック関数をWSGIアプリケーションに渡します。

byte_response_body = application(env, start_response)

 

ブラウザへのレスポンスについて

以前作ったWebサーバでは、ステータスライン・レスポンスヘッダ・レスポンスボディを1行ずつ返していました。

今回も同じように1行ずつ返したところ、

Content-Length: 398

Content-Type: text/html; charset=UTF-8

Date: Sat, 16 Jul 2016 00:00:00 JST

Server: HenaWSGIServer 0.1


<!DOCTYPE html>
<html lang="ja">
...

のようなテキストがブラウザに表示されてしまい、正しいレスポンスとは認識されませんでした。

 
そこで

# ステータスラインからレスポンスボディまで、一括で送信する
# ステータスライン
str_response = 'HTTP/1.1 {status}\r\n'.format(status=status)

# レスポンスヘッダ
for header in response_headers:
    str_response += '{0}: {1}\r\n'.format(*header)

# レスポンスヘッダとレスポンスボディを分ける、改行コード
str_response += '\r\n'

# レスポンスボディ
for byte_body in byte_response_body:
    # WSGIアプリからもらったレスポンスボディはバイト列
    # レスポンスヘッダなどと結合するため、一度文字列へとデコードする
    str_response += byte_body.decode('utf-8')

# クライアントへ送信
# バイト列で送信する必要があるため、エンコードしてから送信
connection.sendall(str_response.encode('utf-8'))

のように、ステータスラインやレスポンスヘッダ、レスポンスボディを連結して、一括して返すようにしました。

 

動作確認

この時点のソースコードは以下の通りです。
wsgi_webserver-sample/single_response_wsgi_server.py at master · thinkAmi-sandbox/wsgi_webserver-sample

 
動作確認として、コマンドプロンプトより

>python single_response_wsgi_server.py bottle_app:app

と実行し、ブラウザで確認したところ

f:id:thinkAmi:20160718170207p:plain

と表示されました。

1つのレスポンスしか処理できないため、CSSや画像の部分は対応できていません。

 

普通にWebページを表示するWebサーバの作成

今度はCSSや画像も含めたWebページを表示できる、WSGI準拠のWebサーバを作ります。

前回作成した以下のWebサーバのように、threading.Threadにてマルチスレッド化することで、複数のリクエストとレスポンスを扱えるようにします。
syakyo_create_web_server/modoki02_main_2_6.py at master · thinkAmi-sandbox/syakyo_create_web_server

また、せっかくなので、クラス形式でWebサーバを作ってみます。

 

コールバック関数のstatusやresponse_headersの扱いについて

1つのレスポンスを処理するバージョンでは、グローバル変数を使ってstatusresponse_headersを保存していました。

ただ、今回はマルチスレッド化するためグローバル変数が使えません。また、参考記事のようなクラス構成でインスタンス変数を使った場合、スレッド間でインスタンス変数を上書きしてしまいます。

スレッド固有のデータを保持するthreading.local()を使うことも考えましたが、うまい使い方が思い浮かびませんでした。
Python の threadling.local (スレッドローカル) を試してみる | CUBE SUGAR STORAGE

そんな中、WSGIのリファレンス実装であるwsgirefモジュールの構成を見たところ、serverとhandlerのクラスが分かれていました。これならマルチスレッド化できそうです。
cpython/Lib/wsgiref at master · python/cpython

 
そこで、

  • メインスレッドのサーバクラス(MyWSGIServer)でsocket.accept()
  • データを受信したら、別スレッドのハンドラクラス(MyWSGIHandler)でリクエストとレスポンスを処理

の形としました。

 
念のため、MyWSGIHandlerクラスのオブジェクトについて、

while True:
    client_connection, client_address = self.listen_socket.accept()
    handler = MyWSGIHandler(client_connection, client_address, self.application, self.server_name, self.server_port)

    # 念のため、インスタンスの識別値を確認
    print("handler object id: {}".format(id(handler)))

    # スレッドで画像ファイルとかも受け取れるようにする
    thread = threading.Thread(target=handler.handle_one_request)
    thread.start()

のように、オブジェクト識別値を確認してみました。

結果は

handler object id: 29123088
handler object id: 30610064
handler object id: 59447632
handler object id: 59448240

となり、リクエストごとにオブジェクトが生成されていました。

 

WSGI環境変数について

WSGI環境変数のうち、

  • wsgi.multithread
  • wsgi.multiprocess
  • wsgi.run_once

の値をどうするか悩みましたが、今回は

env['wsgi.multithread']  = True  # マルチスレッドだからTrueでいいのかな 
env['wsgi.multiprocess'] = False # マルチプロセスではないのでFalse 
env['wsgi.run_once']     = True  # 複数回呼ばれそうなので、True

としました。

正しくないような気もしますので、ご存じの方がいればご指摘ください。

 

画像のレスポンスについて

上記の内容でWSGI準拠サーバを作成し、ブラウザで確認したところ、

f:id:thinkAmi:20160718171706p:plain

となりました。画像のレスポンスがうまくいっていないようです。

コンソールを確認したところ、

UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position 0: invalid start byte

というエラーが出ていました。

レスポンスボディを結合する際の

str_response += byte_body.decode('utf-8')

で発生したようです。

 
画像なのでバイナリデータなのかなと思い、Bottleからのレスポンスボディを見たところ、

<bottle.WSGIFileWrapper object at 0x03305B30>

のようなBottleオブジェクトでした。

 
ファイルオブジェクトをsocketで送信する方法を探そうと公式ドキュメントを見たところ、Python3.5より、socket.sendfile()が追加されていました。
18.1. socket — 低水準ネットワークインターフェース — Python 3.5.1 ドキュメント

Bottleでの使用例も探してみたところ、

yield connection.sendall(response.render_headers())

res = yield connection.sendfile(self.filelike, 0, self.blocksize)

https://github.com/j4cbo/chiral/blob/master/chiral/web/httpd.py#L146

のような形で、レスポンスヘッダの送信後に画像データを送れば良さそうでした。

 

動作確認

コマンドプロンプトより、

>python single_response_wsgi_server.py bottle_app:app

と実行し、ブラウザで確認したところ、

f:id:thinkAmi:20160718172248p:plain

画像も表示されていました。

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/wsgi_webserver-sample

こちらにも残しておきます。

import socket
import io
import sys
import threading

class MyWSGIHandler(object):
    def __init__(self, client_connection, client_address, application, server_name, server_port):
        self.connection = client_connection
        self.address = client_address
        self.application = application
        self.server_name = server_name
        self.server_port = server_port

        # コールバック関数で取得できるステータスコード・レスポンスヘッダの保存先
        self.headers_set = []


    def handle_one_request(self):
        request_data = self.connection.recv(1024)
        env = self.get_environ(request_data)
        byte_response_body = self.application(env, self.start_response)
        self.finish_response(byte_response_body)


    def get_environ(self, byte_request):
        byte_request_line = byte_request.splitlines()[0]
        str_request_line = byte_request_line.decode('utf-8')
        str_request_line_without_crlf = str_request_line.rstrip('\r\n')
        request_method, path, request_version = str_request_line_without_crlf.split()

        env = {}
        env['wsgi.version']      = (1, 0)
        env['wsgi.url_scheme']   = 'http'
        env['wsgi.input']        = io.BytesIO(byte_request) 
        env['wsgi.errors']       = sys.stderr
        env['wsgi.multithread']  = True  # マルチスレッドだからTrueでいいのかな 
        env['wsgi.multiprocess'] = False # マルチプロセスではないのでFalse 
        env['wsgi.run_once']     = True  # 複数回呼ばれそうなので、True
        env['REQUEST_METHOD']    = request_method        # GET
        env['PATH_INFO']         = path                  # /
        env['SERVER_NAME']       = self.server_name      # FQDN
        env['SERVER_PORT']       = str(self.server_port) # 8888

        return env


    def start_response(self, status, response_headers, exc_info=None):
        server_headers = [
            ('Date', 'Sat, 16 Jul 2016 00:00:00 JST'),
            ('Server', 'MyWSGIServer 0.2'),
        ]
        self.headers_set = [status, response_headers + server_headers]


    def finish_response(self, byte_response_body):
        try:
            status, response_headers = self.headers_set

            # ステータスライン
            str_response = 'HTTP/1.1 {status}\r\n'.format(status=status)

            # レスポンスヘッダ
            for header in response_headers:
                str_response += '{0}: {1}\r\n'.format(*header)

            # レスポンスヘッダとレスポンスボディを分ける、改行コード
            str_response += '\r\n'

            # レスポンスボディ
            print(byte_response_body)
            # 画像データのレスポンスがあった場合のオブジェクト
            # => <bottle.WSGIFileWrapper object at 0x03305B30>
            # これをそのままdecodeするとエラーになる
            # => UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position 0: invalid start byte

            if 'image' in str_response:
                # 画像データの場合
                # レスポンスヘッダを送信した後、sendfile()で画像データを送信する
                self.connection.sendall(str_response.encode('utf-8'))
                self.connection.sendfile(byte_response_body)

            else:
                # 画像データ以外の場合
                for byte_body in byte_response_body:
                    # WSGIアプリからもらったレスポンスボディはバイト列
                    # レスポンスヘッダなどと結合するため、一度文字列へとデコードする
                    str_response += byte_body.decode('utf-8')

                # クライアントへ送信
                # バイト列で送信する必要があるため、エンコードしてから送信
                self.connection.sendall(str_response.encode('utf-8'))

        finally:
            self.connection.close()


class MyWSGIServer(object):
    def __init__(self, ip_address, port, wsgi_app):
        self.listen_socket = socket.socket(
            socket.AF_INET,
            socket.SOCK_STREAM
        )
        self.listen_socket.bind((ip_address, port))
        self.listen_socket.listen(1)
        self.application = wsgi_app
        self.server_name = socket.getfqdn(ip_address)
        self.server_port = port
    
    def serve_forever(self):
        while True:
            client_connection, client_address = self.listen_socket.accept()
            handler = MyWSGIHandler(client_connection, client_address, 
                self.application, self.server_name, self.server_port)

            # 念のため、オブジェクト識別値を確認
            print("handler object id: {}".format(id(handler)))

            # スレッドで画像ファイルとかも受け取れるようにする
            thread = threading.Thread(target=handler.handle_one_request)
            thread.start()


def make_server(ip_address, port, wsgi_app):
    server = MyWSGIServer(ip_address, port, wsgi_app)
    return server


if __name__ == '__main__':
    if len(sys.argv) < 2:
        sys.exit('"module:callable"の形でWSGIアプリケーションを指定してください')

    app_path = sys.argv[1]
    module, application = app_path.split(':')
    module = __import__(module)
    wsgi_app = getattr(module, application)

    # WSGIサーバの起動
    httpd = make_server('', 8888, wsgi_app)
    print('MyWSGIServer: ホスト{address}、ポート{port}にて起動しました\n'.format(
        address=httpd.server_name, port=httpd.server_port))
    httpd.serve_forever()

 

その他参考

*1:もっとも、WSGI準拠とはいいつつ、BottleでGETが動く最低限のものしか実装していませんが...

Visual Studio CodeでPython + Djangoを書いて、py.testを実行してみた

それなりの規模のDjangoアプリを書く場合、PyCharmなどのIDEを使っています。

ただ、諸般の事情によりIDEが使えないことも考えて、Visual Studio Code(以下VS Code)のPython拡張を試してみました。
Python with Visual Studio Code - Visual Studio Code

 
なお、今回は上記の公式ドキュメント例にもある通り、DonJayamanneさんのPython support for Visual Studio Codeを使います。
DonJayamanne/pythonVSCode: Python support for Visual Studio Code

 
目次

 

環境

 

環境準備

Python support for Visual Studio Codeのインストール

コマンドプロンプトからDjango用のディレクトリを用意し、そのディレクトリでVS Codeを起動します。

# ディレクトリ作成と移動
D:\Sandbox>mkdir vscode_django
D:\Sandbox>cd vscode_django

# カレントディレクトリとして VS Codeを起動 (末尾のドットを忘れずに)
D:\Sandbox\vscode_django>code .

 
VS Codeが起動したら、

  • Ctrl + Alt + P(もしくはF1)でコマンドパレットを起動
  • ei(Extensions: Install Extension) と入力して、拡張機能:拡張機能のインストールを選択
  • Python (DonJayamanne)を選択し、インストール
  • インストール後、VS Codeを再起動

と、Pythonを設定します。

 

Djangoのインストールと確認

今回はVS Code上のターミナルで実行します。ただ、現時点のバージョンではコピー&ペーストができません(↑・↓で入力履歴の再表示は可能)。
ターミナルを統合した「Visual Studio Code」インサイダー版、6月から日毎リリースに - 窓の杜

  • 表示 > Toggle Integrated Terminal (もしくはCtrl + @) を選択
  • 画面の下にターミナルが開く
  • ターミナルに以下を入力
# virtualenv環境を作成
D:\Sandbox\vscode_django>virtualenv -p c:\python35-32\python.exe env
D:\Sandbox\vscode_django>env\Scripts\activate

# pipでインストール
(env) D:\Sandbox\vscode_django>pip install django
...
Successfully installed django-1.9.7

 
続いて、Djangoのインストール先を確認するため、以下のPythonスクリプトを作成します。
Python Tips:ライブラリ・モジュールの場所を調べたい - Life with Python

django_checker.py

import django
print(django.__file__)

 
Debug Viewに切り替えます(Ctrl + Shift + D)。

  • 実行ボタンを押す(F5)
  • 環境の選択を求められるので、Pythonを選択
  • 情報バーにPlease set up the launch configuration file for your applicationが、エディタにlaunch.jsonが表示
  • launch.jsonはデフォルトのまま、django_checker.pyファイルを選択
  • 実行環境でPythonが選択されていることを確認の上、実行ボタンを押す(F5)
  • ファイアウォールのブロック表示がでたら、必要な内容をチェックしてアクセスを許可
  • デバッグコンソールに以下が表示
Traceback (most recent call last):
  File "d:\Sandbox\vscode_django\django_checker.py", line 1, in <module>
    import django
ImportError: No module named 'django'

Djangoが認識されていないことから、virtualenvのPythonインタプリタは使われていないようです。

 
公式ドキュメントによると、対応方法としては

  • settings.jsonpython.pythonPathを指定する
  • virtualenv環境をactivateしてから、VS Codeを起動する
  • launch.jsonpythonPathを指定する
    • 今回の場合、"pythonPath": "${workspaceRoot}/env/Scripts/python.exe",
    • ただし、この場合はデバッグ時だけ有効で、エディタのインテリセンスなどが効かない

の3つが挙げられていました。
Python Path and Version · DonJayamanne/pythonVSCode Wiki

エディタのインテリセンスは効かせたいこと、グローバルな設定は避けたいことから、今回はvirtualenv環境をactivateしてから、VS Codeを起動する方法をとります。

 
そのため、いったん VS Codeを閉じてから、以下のようにVS Codeを起動します。

# virtualenvをon
D:\Sandbox\vscode_django>env\Scripts\activate

# VS Codeの起動
(env) D:\Sandbox\vscode_django>code .

 
再度、django_checker.pyファイルを選択し、デバッグ実行(F5)します。デバッグコンソールには

D:\Sandbox\vscode_django\env\lib\site-packages\django\__init__.py

と表示され、virtualenvのPythonインタプリタで実行されるようになりました。

 

Djangoアプリの作成

It worked!の確認

VS Code ターミナルを起動し(Ctrl + @)、Djangoアプリを作成します。

(env) D:\Sandbox\vscode_django>django-admin startproject myproject .
(env) D:\Sandbox\vscode_django>python manage.py startapp myapp

また、myproject/settings.pyINSTALLED_APPSに、上記作成のアプリmyappを追加します。

 
Djangoアプリができたため、

  • Debug viewに切り替える(Ctrl + Shift + D)
  • Djangoを選んでから、実行ボタンを押す(F5)
  • manage.pyの一行目のところで停止するが、そのまま続行(F5)
  • Debug Consoleに開発サーバの起動が表示
  • ブラウザでhttp://localhost:8000/へとアクセスすると、It worked!が表示されます。

を行い、動作を確認します。

 
なお、該当するlaunch.jsonの内容は

{
    "name": "Django",
    "type": "python",
    "request": "launch",
    "stopOnEntry": true,
    "program": "${workspaceRoot}/manage.py",
    "args": [
        "runserver",
        "--noreload"
    ],
    "debugOptions": [
        "WaitOnAbnormalExit",
        "WaitOnNormalExit",
        "RedirectOutput",
        "DjangoDebugging"
    ]
},

でした。

--noreloadがあることから、オートリロードされないのがデフォルトのようです。もし、オートリロードしたい場合はその部分を削除します。

 

Djangoアプリへ機能を追加

Hello worldを表示するよう、機能を追加します。

myproject/urls.py

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^myapp/', include('myapp.urls', 'my')),  # 追加
]

 
myapp/urls.py

from django.conf.urls import url
from django.views.generic.base import TemplateView

urlpatterns = [
    url(r'^$', 
        TemplateView.as_view(template_name='hello.html'),
        name='hello'),
]

 
myapp/templates/hello.html

<h1>Hello world!</h1>

 
完成したため、

  • Djangoを選択して実行ボタンを押す(F5)
  • http://localhost:8000/myapp/へアクセスすると、Hello world!が表示

にて動作を確認します。

 

py.testの設定と実行

以前、Djangoアプリをpytest-djangoでテストしたことがあったため、同じように作業します。
Djangoアプリについて、pytest-djangoを使ってテストしてみた - メモ的な思考的な

 
ターミナルにてインストールします。

(env) D:\Sandbox\vscode_django>pip install pytest pytest-django

 
pytest.iniを書きます。

pytest.ini

[pytest]
DJANGO_SETTINGS_MODULE=myproject.settings
norecursedirs=env

 
URL解決のテストを書きます。

py.testのデフォルトでは、test_*.pyというファイル名であればテストファイルとみなされます。そのため、Djangoが自動生成したmyapp/tests.pyはテストの対象とはなりません。
My tests are not being found. Why not? | FAQ — pytest-django documentation

そこで、myapp/tests/ ディレクトリを作成し、その中にテストファイルtest_urls.pyを作成することにします。

 
まずはエラーとなるよう、テストコードを書きます。

myapp/tests/test_urls.py

# myapp.urls.py
# urlpatterns = [
#    url(r'^$', TemplateView.as_view(template_name='hello.html'), name='hello'),]

# テストコード
class URL解決テスト(TestCase):
    def test_helloのパスが404とならないこと(self):
        try:
            resolve('/mysite/hello')
        except Resolver404:
            pytest.fail('raise error')

 
ターミナルから実行します。

(env) D:\Sandbox\vscode_django>py.test

...
myapp\tests\test_urls.py:11: Failed

エラーになりました。また、一部が文字化けしています。

 
テストが通るように修正します。

myapp.urls.py

urlpatterns = [
    url(r'^hello$', 
        TemplateView.as_view(template_name='hello.html'),
        name='hello'),
]

 
もう一度テストします。

(env) D:\Sandbox\vscode_django>py.test
...
========================== 1 passed =...

テストが通りました。

 
以上より、VS Codeを使ってPython + Djangoを書いて、py.testを実行することができました。

 

IDE機能

IDE機能もありました。

 

インテリセンス

f:id:thinkAmi:20160705220750p:plain

 

メソッドの引数の表示

f:id:thinkAmi:20160705220802p:plain

 

コードナビゲーション
  • Go To Definition(定義へ移動)
  • Peek Definition(定義をここに表示)
  • Find All References(すべての参照の検索)

f:id:thinkAmi:20160705220812p:plain

 

linter設定

Linting · DonJayamanne/pythonVSCode Wiki

 
他にもいろいろとありました。

なお、現時点では、タブはまだprerelease Insiders buildのため、今後に期待します。
Tabbed editor support in Visual Studio Code

2016/7/9 追記

先日リリースされた1.3.0にてタブ機能が追加されました。

2016/7/9 追記おわり

 

ソースコード

GitHubにあげておきました。
thinkAmi-sandbox/vscode_django-sample

なお、デバッグ時のみvirtualenv環境が使われるのを確認するために、pythonPathを追加した.vscode/launch.jsonも入れてあります。

 

その他参考

 
なお、公式ドキュメントではlaunch.jsonに関する説明が見当たりませんでした。そのため、以下を参考にしました。
特集:VS Code早分かりガイド:Visual Studio CodeでNode.jsアプリをデバッグする (2/4) - @IT

「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」が良かったのでPythonで書いてみた

Pythonでhttp.serverを使っているうちに、せっかくならもう少し下のレイヤについても知りたくなりました。

何か良い資料がないかを探したところ、本当の基礎からのWebアプリケーション入門――Webサーバを作ってみようというページを見つけ、さらに書籍「Webサーバを作りながら学ぶ 基礎からのWebアプリケーション開発入門」発売されます - プログラミング言語を作る日記にて、書籍として発売されたのを知りました。

 
自分はEPUB/PDFセットをGihyo Digital Publishingで買って読みました。

gihyo.jp

 
感想としては、

  • Webサーバを作るのに必要な用語が、1つずつ丁寧に詳しく解説されている
  • どうしてこう書くのかという理由が書かれており、出典としてRFCを示している
  • チュートリアル的にWebサーバを作っていくので、理解しやすい
    • 知りたかったソケットプログラミングから始まっていたのも良かった
  • Apacheの設定にも一部ふれられていた

など、まさに自分が知りたいポイントについて書かれていたため、とてもためになりました。ありがとうございました。

 
書籍のソースコードJavaだったのですが、別の言語で実装すればさらに理解が深まるだろうと考え、第1章・第2章のWebサーバ作成をPythonで書いてみました。なお、仕様の一部は簡略化しました。

Python版のソースコードGitHubに置いておきます*1
thinkAmi-sandbox/syakyo_create_web_server

 
第3章以降はTomcatのようなものを作っていたため、現時点では読むだけにしました。もし、第3章以降もPythonで書くとしたら、WSGIで実装する形になるのでしょうか。
java - What is the Python equivalent of Tomcat? - Stack Overflow

 
以下、Pythonで書いた時に悩んだことをメモしておきます。

 
目次

 

環境

  • Windows10 x64
  • Python 3.5.2 32bit

 

1.3.2 TCPサーバ/クライアントのプログラム

ソケットを使ったプログラムの流れとしては、書籍中の他、以下のページが参考になりました。
サーバプログラム

Pythonでソケットを使うには、標準ライブラリのsocketを使います。
18.1. socket — 低水準ネットワークインターフェース — Python 3.5.1 ドキュメント

Pythonでのソケット通信については、以下が参考になりました。
プログラミングと慶應通信 : Pythonによるsocket programming入門

なお、Python版では終了マークとして"0"を送信するは実装しませんでした。

 

ソケットを使った送受信

Pythonでソケットを使ってデータを送受信する際、そのデータはバイト列とする必要があります。

 
そのため、

# 送信
socket.send(data.encode("utf-8"))

# 受信
data = socket.recv(1024)
data.decode("utf-8")

のように、送信前にエンコード・受信後にデコードしました。

ただ、今回エンコード・デコード用文字コードとしてutf-8を使いましたが、本来どの文字コードを使うべきかは分かりませんでした。

 

ファイルの入出力

以下が参考になりました。
ファイル - Dive Into Python 3 日本語版

 

1.5.3 1つのHTMLファイルを返す

HTTPレスポンスヘッダのDateを出力する

Pythonではどのようにやるのがいいのかを探したところ、stackoverflowに回答がありました。
http - RFC 1123 Date Representation in Python? - Stack Overflow

いくつか方法がありましたが、email.utilsライブラリは今でも機能追加されていることから、email.utils.formatdate(usegmt=True)を使うことにしました。

 

1.5.4 普通にWebページを表示できるようにする

Pythonのマルチスレッドについて

今回は標準ライブラリのthreadingを使います。
17.1. threading — スレッドベースの並列処理 — Python 3.5.1 ドキュメント

PythonのThreadやProcessについては、以下が参考になりました。
python - Should I use fork or threads? - Stack Overflow

 

accept()中の停止について

Ctrl+Cでは停止できなかったため、Breakキーで強制停止します。
Python socket accept in the main thread prevents quitting - Stack Overflow

 

レスポンスボディの送信について

Java版とは異なり、テキストデータと画像データを同じ方法で返すやり方が分からなかったため、

if "text" in content_type:
    with open(DOCUMENT_ROOT + path, encoding="utf-8") as f:
        r = f.read()
        write_line(sock, r)
elif "image" in content_type:
    with open(DOCUMENT_ROOT + path, mode="rb") as f: 
        r = f.read()
        # 画像の場合、読み込んだバイナリに改行コードを加えてはいけないので、そのまま送る
        sock.send(r)

のように、Content-Typeを見て処理分岐する形にしました。

 

2.6 Modoki/0.2のソースコード

エンコードされた日本語URLのデコード

Python3のため、urllib.parse.unquote()を使ってデコードしました。

 

Windowsでのos.path.join()

Windowsの場合、os.path.join()を使うと

DOCUMENT_ROOT = "D:/Sandbox/syakyo_create_web_server/src/static"
path = "/index.html"
print(os.path.join(DOCUMENT_ROOT, path))
# => D:/index.html

のように、相対パスが返ってきます。
os.path.join() | 11.2. os.path — 共通のパス名操作 — Python 3.5.1 ドキュメント

 
今回は絶対パスが欲しかったため、stackoverflowを参考にして、

docroot_drive = DOCUMENT_ROOT[0:2]
docroot_path = DOCUMENT_ROOT[2:]
full_path = os.path.join(docroot_drive, os.sep, docroot_path + path)
print("fullpath: {}".format(full_path))
#=> D:/Sandbox/syakyo_create_web_server/src/static/index.html

としました。
Python os.path.join on Windows - Stack Overflow

 

301リダイレクト時のChromeの挙動について

書籍とは異なりChromeで動作確認をしていたのですが、301リダイレクト時にキャッシュされているような挙動がありました。

調べてみると、やはりキャッシュされているようでした。
chromeは、301リダイレクトをキャッシュしている - too_youngの日記  
 

htmlの文字コード設定について

書籍のままだとChromeで文字化けしたため、<meta charset="UTF-8">タグを追加しました。

 

ソースコード

再掲となりますが、以下となります。
thinkAmi-sandbox/syakyo_create_web_server

*1:理解のためのコメントをたくさん書いてありますが、あまり気にしないでください...