第8回 SQLアンチパターン読書会に参加しました

4/20にギークラボ長野で開かれた「第8回 SQLアンチパターン読書会」に参加しました。

 
今回は

  • 11章 ファントムファイル(幻のファイル)
  • 12章 インデックスショットガン(闇雲インデックス)

でした。

 
読書会は初体験でしたが、

  1. 読み手がある一定の区切りまで音読する
  2. 質問やコメント
  3. 読み手を交代し、1.へ戻る

という流れでした。 集中していたこともあり、あっという間に時間が過ぎました。

また、いろいろなお話を聞けたり、質問に答えていただけたりと、データベース経験値が少ない自分にはためになることばかりでした。

 
印象に残った内容は、

  • 11章ではデータベースと画像ファイルの扱い(ファイルシステムに入れるのか、データベースのBLOB列に入れるのか)
  • 12章では「推測するな計測せよ」でインデックスを作成する

でした。

その他にも、そのために役立つツールがデータベースごとに紹介されていたので、あとで読み返そうと思います。

 
次回は5/11(木)、第Ⅲ部の冒頭からです。
第9回 SQLアンチパターン読書会 - connpass

 
また、データベース関連のイベントとして、5/13(土)には「MySQLユーザ会会 in 長野 2017」が開催されます。
MySQLユーザ会会 in 長野 2017 - connpass

Pythonで、Werkzeug.testを使って、WSGIサーバを起動せずにWSGIアプリのテストをする

以前、WebTestやwsgi-interceptを使ってWSGIアプリのテストをしました。

 
その他のテストツールとして、Werkzeug.testがありましたので、今回試してみます。
Test Utilities — Werkzeug Documentation (0.14)

 
目次

 

環境

  • Mac OS X 10.11.6
  • Python 3.6.1
  • Werkzeug 0.12.1
  • pytest 3.0.7
    • テストランナー

 
また、テスト対象アプリはwsgi-interceptで使った、Bottleの掲示板アプリを流用します。

 

Werkzeugとは

Werkzeugは「ヴェルクツォイク」と読むようです。
Werkzeugの読み方 - Qiita

 
WerkzeugはWSGIユーティリティライブラリで、Flaskなどで使われています。作者はFlaskやJinja2と同じArmin Ronacherさんです。
Welcome | Werkzeug (The Python WSGI Utility Library)

 
また、Werkzeug.testでは、werkzeug.Clientを使って、GETやPOSTなどのテストを書くようです。
Test Utilities — Werkzeug Documentation (0.11)

 

GETのテスト

werkzeug.Clientのコンストラクタではテスト対象のWSGIアプリを、オプションresponse_wrapperでレスポンスの型を指定します。

response_wrapperにはwerkzeug.wrappers.BaseResponsewerkzeug.wrappers.Responseなどが指定できます。何も指定しない場合はタプルでレスポンスが返ってきます。

今回は、BaseResponseに便利なものをMixinしたwerkzeug.wrappers.Responseを指定します。

 
こんな感じでレスポンスを取得します。

sut = Client(app, Response)
actual = sut.get('/')

 
レスポンスはwerkzeug.wrappers.Responseオブジェクトなので、ステータスコード(status_code)・レスポンスヘッダ(headers)・レスポンスボディ(get_data())などの属性やメソッドが使えます。

 
なお、get_data()メソッドの戻り値は、デフォルトではバイト文字列です。Unicodeの文字列で取得するには、引数にas_text=Trueをセットします。

ただし、Content-Typeのcharsetがutf-8以外の場合、以下の通りバグがあり、現時点でもOpenされたままなので注意します。

 
テストコード全体は以下の通りとなり、テストはパスします。

def test_get(self):
    # 戻り値をwerkzeug.wrappers.Response型で取得するようインスタンス化
    sut = Client(app, Response)
    # `/`へGETリクエスト
    actual = sut.get('/')
    # ステータスコードの確認
    assert actual.status_code == 200
    # Content-Typeの確認
    assert actual.headers.get('Content-Type') == 'text/html; charset=UTF-8'
    # レスポンスボディの確認
    body = actual.get_data(as_text=True)
    assert 'テスト掲示板' in body

 

POSTのテスト

werkzeug.Clientにはpost()メソッドもあるため、POSTのテストも行えます。

ソースコードを読むと、post()メソッドはopen()メソッドのラッパーのようです。
https://github.com/pallets/werkzeug/blob/0.12.1/werkzeug/test.py#L798

そのため、open()メソッドの引数が使えそうでした。
open() | Test Utilities — Werkzeug Documentation (0.11)

また、POSTするフォームのデータは引数dataで指定します。Werkzeugのドキュメントでは見当たりませんでしたが、Flaskのドキュメントにありました。
Testing Flask Applications — Flask Documentation (0.12)

 
POST時のリダイレクトの挙動については、リダイレクトを許可する引数follow_redirectsは、デフォルトだとFalse(許可しない)でした。
https://github.com/pallets/werkzeug/blob/0.12.1/werkzeug/test.py#L750

 
以下がリダイレクトありのPOSTのテストコードです。

def test_post_with_redirect(self):
    form = {
        'title': 'タイトル2',
        'handle': 'ハンドル名2',
        'message': 'メッセージ2',
    }
    sut = Client(app, Response)
    actual = sut.post('/', data=form, follow_redirects=True)
    assert actual.status_code == 200
    assert actual.headers['content-type'] == 'text/html; charset=UTF-8'
    body = actual.get_data(as_text=True)
    assert 'テスト掲示板' in body
    assert 'タイトル2' in body
    assert 'ハンドル名2' in body
    assert 'メッセージ2' in body

 

Cookieのテスト

POSTのテストでは確認しなかったため、Cookieもテストしてみます。

 

POST後のCookieのセット先について

まずは、Cookieはどこにセットされるのかを見てみます。

コンストラクタの引数でuse_cookies=True(デフォルト値)の場合、_TestCookieJarオブジェクトがClient.cookie_jarCookieとして設定されるようです。
https://github.com/pallets/werkzeug/blob/0.12.1/werkzeug/test.py#L648

 
_TestCookieJarは標準モジュールのhttp.cookiejar.CookieJarを継承したオブジェクトでした。

 

Cookieの取り出し方について

CookieJarオブジェクトからCookieオブジェクトを取り出すには、

CookieJar オブジェクトは保管されている Cookie オブジェクトをひとつずつ取り出すための、イテレータ(iterator)・プロトコルをサポートしています。

21.24.1. CookieJar および FileCookieJar オブジェクト | 21.24. http.cookiejar — HTTP クライアント用の Cookie 処理 — Python 3.6.1 ドキュメント

とのことで、イテレートすれば良さそうです。

 
また、Cookieオブジェクトの属性を見ると、namevalueなどがありました。これらを使えばCookieの値を取り出せそうです。
21.24.5. Cookieオブジェクト | 21.24. http.cookiejar — HTTP クライアント用の Cookie 処理 — Python 3.6.1 ドキュメント

 
ここまでをまとめると、Cookie値の取り出し方は以下となりました。

# cookie_jarをイテレートして、Cookieオブジェクトを取り出す
for c in client.cookie_jar:
    # Cookieオブジェクトのname属性が、Cookieのキーと一致した場合、取り出し処理を行う
    if c.name == name:
        # Cookieの取り出し処理
return None

 
これでもいいのですが、Pythonっぽく辞書のように条件を指定してイテレータから値を取り出す方法がないかを探したところ、stackoverflowに情報がありました。
python - find first list item that matches criteria - Stack Overflow

組込関数のnext()を使えば良いようです。
next() | 2. 組み込み関数 — Python 3.6.1 ドキュメント

cookie = next(c for c in client.cookie_jar if c.name == name, default=None)
if cookie:
    # Cookieの取り出し処理
return None

 
なお、Cookie名が重複してセットされている場合は、上記のforやnextを使ったロジックだと不適切かもしれません。

ただ、RFC6265を見ると、

サーバは、同じ応答­内に同じ cookie-name の複数の Set-Cookie ヘッダを内包するべきでない。 ( UA がこの場合をどのように扱うかについては、 5.2 節 を見よ。†)

【† と記されているが、この場合の取り扱いは,明快な形では述べられていない。 UA が該当するクッキーを 受信した (あるいは,ヘッダが現れる)順番通りに処理し, かつ そのそれぞれを無視しないと見なすならば、あたかも,そのそれぞれが別々の応答で受信されたかのように処理することになると考えられる。 (例えば name が同じでも, path が異なれば、保管の際には別々に扱われる。) しかしながら、 5.2 節には “UA は Set-Cookie ヘッダを無視してもよい” とも記されているので、この種の重複に際し,最初のものだけが有効にされる実装も考えられなくはない。 】

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

とあったので、Cookie名は重複しないものとして、今回は話を進めます。

 

Cookie値の変換について

次にCookie値を見てみると、

print(cookie.value)
#=> "\343\203\217\343\203\263\343\203\211\343\203\253\345\220\2153"

と、リテラルエスケープシーケンスに見える文字列でした。

そのため、以前見た通り、ast.literal_eval()などでUnicode文字へと変換します。
Python3で、リテラルのエスケープシーケンスに見える非リテラルの文字列を、Unicode文字へと変換する - メモ的な思考的な

 

# Cookie値として、リテラルのエスケープシーケンスに見える文字列がセットされている
print(cookie.value)
#=> "\343\203\217\343\203\263\343\203\211\343\203\253\345\220\2153"

# ast.literal_eval()でUnicode文字化するが、文字コードが合っていないっぽい
after_literal_eval = ast.literal_eval(cookie.value)
print(after_literal_eval)
#=> ãã³ãã«

# 以前見たように、Latin-1でエンコードしてバイト文字列にする
encoded = after_literal_eval.encode('latin1')
print(encoded)
#=> b'\xe3\x83\x8f\xe3\x83\xb3\xe3\x83\x89\xe3\x83\xab\xe5\x90\x8d3'

# バイト文字列をデコードしてUnicode文字列にする
decoded = encoded.decode('utf-8')
print(decoded)
#=> ハンドル名3

 
これでUnicode文字になったたため、後は

assert actual_cookie == 'ハンドル名3'

として検証したところ、テストがパスしました。

 

ソースコード

GitHubに上げました。e.g._werkzeug_testが今回のコードです。
thinkAmi-sandbox/python_werkzeug-sample

Pythonで、RequestのCookieを使ってみた

以前wsgi-interceptを使った時に、PythonのHTTPライブラリとして、Requestsを使いました。
Requests: HTTP for Humans — Requests 2.13.0 documentation

 
使っている中で、RequestのCookieの使い方について迷ったことがあったため、メモを残します。

 

環境

  • Mac OS X 10.11.6
  • Python 3.6.1
  • Requests 2.13.0
  • Bottle 0.12.13
    • Requestsを試すためのアプリ
  • pytest 3.0.7
    • テストランナー

 

Requestsを試すための用意したBottleアプリ

CookieをセットするだけのBottleアプリを用意しました。

仕様は

  • /へアクセス
  • /redirectへアクセス
    • Cookieredirectをセット
    • /ヘリダイレクト

です。

from bottle import run, get, redirect, response

@get('/')
def get_root():
    # Cookieにrootをセット
    response.set_cookie('root', 'foo')
    return 'Hello world'

@get('/redirect')
def get_redirect():
    # Cookieにredirectをセットし、`/`へリダイレクト
    response.set_cookie('redirect', 'bar')
    redirect('/')

if __name__ == "__main__":
    run(host='localhost', debug=True, reloader=True)

 
このBottleアプリが想定した動作をするか、Chromeで挙動を見てみます。

 

/にアクセスした時

レスポンスヘッダを見ると、Cookierootがセットされました。

Server:WSGIServer/0.2 CPython/3.6.1
Set-Cookie:root=foo

 

/redirectへアクセスした時

リダイレクト元の/redirectでのレスポンスヘッダを見ると、Cookieredirectがセットされました。

Location:http://localhost:8080/
Server:WSGIServer/0.2 CPython/3.6.1
Set-Cookie:redirect=bar

 
リダイレクト先の/でのレスポンスヘッダでは、Cookierootがセットされました。

Server:WSGIServer/0.2 CPython/3.6.1
Set-Cookie:root=foo

 
なお、リクエストヘッダを見ると、

Cookie:root=foo; redirect=bar
Host:localhost:8080

と、2つのCookieが設定されていることも確認できました。

 

RequestsのResponseオブジェクトのCookieを使う

ドキュメントを見ると、ResponseオブジェクトにCookieがありました。
Cookies | Quickstart — Requests 2.13.0 documentation

 
そこで、reqeusts.Responseを使った

という3種類テストコードを作成します。

なお、テストを試す時は、上記のBottleアプリを起動した後、テストコードを実行します。

 

/へGET
def test_get(self):
    response = requests.get('http://localhost:8080')
    assert response.status_code == 200
    assert response.cookies.get('root') == 'foo'

テストはパスしました。

 

/redirectへGETし、/へリダイレクト
def test_allow_redirect(self):
    response = requests.get('http://localhost:8080/redirect')
    assert response.status_code == 200
    assert response.cookies.get('root') == 'foo'
    # ここで失敗する
    assert response.cookies.get('redirect') == 'bar'
    #=> AssertionError: assert None == 'bar'

テストが失敗しました。リダイレクト元のCookieは保持しないようです。

 

/redirectへGETし、リダイレクトは行わない
def test_forbid_redirect(self):
    response = requests.get('http://localhost:8080/redirect', allow_redirects=False)
    assert response.status_code == 303
    assert response.cookies.get('root') is None
    assert response.cookies.get('redirect') == 'bar'

リダイレクトしない時は、Cookieが正しくセットされています。

 

reqeusts.SessionオブジェクトのCookieを使う

ResponseオブジェクトのCookieでは、リダイレクトが発生するとCookieがなくなるため、あまり実用的ではないかもしれません。

他を探したところ、SessionオブジェクトにCookieがありました。
Using Python Requests: Sessions, Cookies, and POST - Stack Overflow

 
そこで、requests.Sessionオブジェクトを試してみます。

 

/へGET
def test_get(self):
    session = requests.Session()
    response = session.get('http://localhost:8080')
    assert response.status_code == 200
    # responseとsessionの両方にCookieがセットされる
    assert response.cookies.get('root') == 'foo'
    assert session.cookies.get('root') == 'foo'

テストがパスしました。

 

/redirectへGETし、/へリダイレクト
def test_allow_redirect(self):
    session = requests.Session()
    response = session.get('http://localhost:8080/redirect')
    assert response.status_code == 200
    # Cookie「redirect」はsessionのみセットされる
    assert response.cookies.get('root') == 'foo'
    assert response.cookies.get('redirect') is None
    assert session.cookies.get('root') == 'foo'
    assert session.cookies.get('redirect') == 'bar'

テストがパスしました。

Responseオブジェクトと異なり、Sessionオブジェクトはリダイレクト時にもCookieの値を保持するようです。

 

/redirectへGETし、リダイレクトは行わない
def test_forbid_redirect(self):
    session = requests.Session()
    response = session.get('http://localhost:8080/redirect', allow_redirects=False)
    assert response.status_code == 303
    # responseとsessionの両方にCookieがセットされる
    assert response.cookies.get('root') is None
    assert response.cookies.get('redirect') == 'bar'
    assert session.cookies.get('root') is None
    assert session.cookies.get('redirect') == 'bar'

テストがパスしました。

 

requests.SessionオブジェクトをContext Managerとして使う

Requestsのドキュメントを読むと、requests.SessionはContext Managerとしても使えるようでした。
Session Objects | Advanced Usage — Requests 2.13.0 documentation

そのため、上記のSessionオブジェクトのコードは、以下の通りにも書けます。

def test_get(self):
    with requests.Session() as session:
        response = session.get('http://localhost:8080')
        assert response.status_code == 200
        # responseとsessionの両方にCookieがセットされる
        assert response.cookies.get('root') == 'foo'
        assert session.cookies.get('root') == 'foo'

def test_allow_redirect(self):
    with requests.Session() as session:
        response = session.get('http://localhost:8080/redirect')
        assert response.status_code == 200
        # Cookie「redirect」はsessionのみセットされる
        assert response.cookies.get('root') == 'foo'
        assert response.cookies.get('redirect') is None
        assert session.cookies.get('root') == 'foo'
        assert session.cookies.get('redirect') == 'bar'

def test_forbid_redirect(self):
    with requests.Session() as session:
        response = session.get('http://localhost:8080/redirect', allow_redirects=False)
        assert response.status_code == 303
        # responseとsessionの両方にCookieがセットされる
        assert response.cookies.get('root') is None
        assert response.cookies.get('redirect') == 'bar'
        assert session.cookies.get('root') is None
        assert session.cookies.get('redirect') == 'bar'

ソースコード

GitHubにあげました。e.g._usage_cookieが今回のサンプルです。
thinkAmi-sandbox/python_requests-sample

Python3で、リテラルのエスケープシーケンスに見える非リテラルの文字列を、Unicode文字へと変換する

Python3で、リテラルに改行コードなどを含めたい場合、エスケープシーケンスを使います。
2.4.1. 文字列およびバイト列リテラル | 2. 字句解析 — Python 3.6.1 ドキュメント

 
例えば、「Hello(改行) world」としたい場合、

$ python
Python 3.6.1 (default, Apr  5 2017, 11:58:06) 
>>> print('Hello world')
Hello world
>>> print('Hello\n world')
Hello
 world

と、エスケープシーケンスの\nを使います。

 
また、エスケープシーケンスを使ってUnicode文字を表すこともできます。

例えば、\oooは、

8 進数値 ooo を持つ文字

文字列リテラル中では、エスケープ文字は与えられた値を持つ Unicode 文字を表します。

として使えます。

例えば、「a」というUnicode文字を8進数エスケープシーケンスで表す場合は、

>>> print('\141')
a

と、\141というエスケープシーケンスを使います。

 
そこで、今回の本題の「リテラルのエスケープシーケンスに見える非リテラルの文字列」についてです。

\141リテラルではなくて文字列データとして与えられた場合に、Unicode文字aへ変換する方法をメモします。

 
目次

 

環境

 

調べようと思ったそもそもの経緯

以前、wsgi-interceptを使ってテストを書いたときのことです。
Pythonで、wsgi-interceptを使って、WSGIサーバを起動せずにWSGIアプリのテストをする - メモ的な思考的な

 
上記では触れませんでしたが、CookieのテストのためにCookieの値を調べたところ、

form = {
    'title': 'タイトル',
    'handle': 'あ',
    'message': 'メッセージ',
}
with RequestsInterceptor(self.get_app, host='localhost', port=8081) as url:
    actual = requests.post(url, data=form, allow_redirects=False)

# ライブラリRequestsのCookieオブジェクトから、Cookieの値を取得する
handle = actual.cookies['handle']

# Cookieの値
print(handle)
#=> "\343\201\202"

# Cookieの値の型
print(type(handle))
#=> <class 'str'>

と、Cookieの値()がエスケープシーケンスに見える文字列(8進数3桁表記で\343\201\202)となっていました。

ここで、BottleのCookieFormsDict型に入っていて、latin1でデコードされています。

In Python 3 all strings are unicode, but HTTP is a byte-based wire protocol. The server has to decode the byte strings somehow before they are passed to the application. To be on the safe side, WSGI suggests ISO-8859-1 (aka latin1), a reversible single-byte codec that can be re-encoded with a different encoding later.

Notes | INTRODUCING FORMSDICT| Tutorial — Bottle 0.13-dev documentation

 
そこで、encode & decodeしてみましたが、

encoded = handle.encode('latin1')
print(encoded)
#=> b'"\\343\\201\\202"'

decoded = encoded.decode('utf-8')
print(decoded)
#=> "\343\201\202"

と変わりませんでした。

ダブルクォート(")が邪魔なのかとも思いましたが、

replaced = handle.strip('"').strip()
print(replaced)
#=> \343\201\202
print(type(replaced))
#=> <class 'str'>

encoded = replaced.encode('latin1')
print(encoded)
#=> b'\\343\\201\\202'

decoded = encoded.decode('utf-8')
print(decoded)
#=> \343\201\202

結果は変わりませんでした。

 
そこで、このリテラルのエスケープシーケンスに見える非リテラルの文字列をUnicode文字にする方法を調べることにしました*1

 

方法

stackoverflowに情報がありました。
string formatting - How to convert escaped characters in Python? - Stack Overflow

 
以下の2種類の方法が紹介されていました。

  • codecsモジュールのgetdecoder()
  • astモジュールのliteral_eval()

両方ともPythonの標準モジュールにあったため、今回はそれぞれを試してみます。

 

codecs.getdecoderを使う例

codecsモジュールの公式ドキュメントは以下です。
codecs.getencoder() | 7.2. codecs — codec レジストリと基底クラス — Python 3.6.1 ドキュメント

また、stackoverflowでエンコーディングとして指定しているunicode_escapeの情報は以下です。
7.2. codecs — codec レジストリと基底クラス — Python 3.6.1 ドキュメント

 
codecsモジュールを使って変換してみたところ、

import codecs
decoder_func = codecs.getdecoder('unicode_escape')
print(decoder_func)
#=> <built-in function unicode_escape_decode>

after_codecs_tuple = decoder_func(handle)
print(after_codecs_tuple)
#=> ('"ã\x81\x82"', 14)

after_codecs = after_codecs_tuple[0]
print(after_codecs)
#=> "ã"
print(type(after_codecs))
#=> <class 'str'>

文字化けした文字が返ってきました。

上記で見た通り、BottleではデータをLatin-1でデコードしていました。

 
そのため、latin1でエンコードしてバイト文字列化後、utf-8でデコードして文字列にしてみたところ、

encoded = after_codecs.encode('latin1')
print(encoded)
#=> b'"\xe3\x81\x82"'

decoded = encoded.decode('utf-8')
print(decoded)
#=> "あ"

# 前後のダブルクォートが不要なのでstripする
stripped = decoded.strip('"')
print(stripped)
#=> あ

assert stripped == 'あ'

Cookieの値であるを取得でき、テストをパスしました。

 

ast.literal_eval()を使う例

同じように ast.literal_eval()を使ってみます。
ast.literal_eval() | 32.2. ast — 抽象構文木 — Python 3.6.1 ドキュメント

なお、stackoverflowのコメントでは

literal_eval requires a valid string literal, including begin/end quotes.

Fred Nurk Jul 29 ‘11 at 2:29

http://stackoverflow.com/questions/6867588/how-to-convert-escaped-characters-in-python#comment8170329_6867896

とのことですが、今回のCookieは上記で見た通り、

handle = actual.cookies['handle']
print(handle)
#=> "\343\201\202"

とダブルクォートがついていましたので、そのまま使ってみます。

import ast
after_literal_eval = ast.literal_eval(handle)
print(type(after_literal_eval))
#=> <class 'str'>
print(after_literal_eval)
#=> ã

codecsと同じように文字化けした文字が帰ってきました。

 
そのため、同じようにデコード・エンコードしてみます。

encoded = after_literal_eval.encode('latin1')
print(encoded)
#=> b'\xe3\x81\x82'

decoded = encoded.decode('utf-8')
print(decoded)
#=> あ

assert decoded == 'あ'

こちらも、Cookieの値であるを取得でき、テストをパスしました。

 
以上より、codecs, astのどちらもでエスケープシーケンスのリテラルを、文字列として取得できました。

 

その他

日本語リテラルをエスケープシーケンスで表した時の動作

Cookie値でも使った\343\201\202(Unicode文字「あ」)を、リテラルのエスケープシーケンス(8進数3桁表記)として試してみました。

escaped_literal = '\343\201\202'
print(escaped_literal)
#=> ã

 
Unicode文字「あ」の「Octal Escape Sequence」を与えましたが、「あ」ではなく、文字化けした値が得られました。
あ | hiragana letter a (U+3042) @ Graphemica

 
そこで、以下を参考にlatin1でdecode() & utf-8でencode()してみます。

 

escaped_literal = '\343\201\202'

encoded = escaped_literal.encode('latin1')
print(encoded)
#=> b'\xe3\x81\x82'

decoded = encoded.decode('utf-8')
print(decoded)
#=> あ

「あ」を得られました。

 
なお、他のUnicode文字の8進数表記を知りたい時は、以下のツールが便利でした。
UTF8エンコードをデコードする

 

ソースコード

GitHubに上げました。e.g._escaped_literal_to_strディレクトリ以下が今回のサンプルファイルです。
thinkAmi-sandbox/python_misc_samples

*1:Bottle側で変換する方法があるかもしれませんが、今回は置いておきます

#stapy #glnagano みんなのPython勉強会リモート中継 in GEEKLAB.NAGANO#16 に参加しました

4/12にギークラボ長野で開かれた「みんなのPython勉強会リモート中継 in GEEKLAB.NAGANO#16」に参加しました。
みんなのPython勉強会リモート中継 in GEEKLAB.NAGANO #16 - connpass

「みんなのPython勉強会 #23」の東京会場を中継する形での勉強会でした。

資料などは以下のページにまとまっています。
みんなのPython勉強会#23 - connpass

 
冒頭、長野巡業の振り返りがありました。また開催されるかもしれないとのことでした。

また、今年のPyCon JP 2017のお話もありました。

とのことです。
PyCon JP 2017 in Tokyo | Sep 7th – Sep 9th

 
今回は機械学習やデータ分析などあまり詳しくない分野の内容で、今後のキーワード拾いのために参加しました。そのため、簡潔にメモを残します。

 

 
最後になりましたが、開催・運営・中継作業をしてくださったみなさま、ありがとうございました。

Pythonで、wsgi-interceptを使って、WSGIサーバを起動せずにWSGIアプリのテストをする

以前、WebTestを使ってWSGIアプリのテストを行いました。
Pythonで、WebTestを使って、WSGIサーバを起動せずにWSGIアプリのテストをする - メモ的な思考的な

 
他にもテストツールがないかを探したところ、wsgi-interceptがありました。
cdent/wsgi-intercept: Intercept socket connection to wsgi applications for testing

 
そこで今回はwsgi-interceptを試してみます。

 
目次

 

環境

 

テスト対象のアプリ

Bootleで掲示板を作成しました。仕様のとおりです。

 
ソースコードです。

# -*- coding: utf-8 -*-
import datetime
import pickle
from pathlib import Path
import bottle
from bottle import Bottle, run, redirect, request, response, jinja2_template


class Message(object):
    def __init__(self, title, handle, message):
        self.title = title
        self.handle = handle
        self.message = message
        self.created_at = datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S')

# テストコードで扱えるよう、変数appにインスタンスをセット
app = Bottle()

@app.get('/')
def get_form():
    handle = request.cookies.getunicode('handle', default='')
    messages = read_messages()
    return jinja2_template('bbs.html', handle=handle, messages=messages)

@app.post('/foo')
def post_foo():
    return "Hello World!"


@app.post('/')
def post_form():
    response.set_cookie('handle', request.forms.get('handle'))

    message = Message(
        title=request.forms.getunicode('title'),
        handle=request.forms.getunicode('handle'),
        message=request.forms.getunicode('message'),
    )
    messages = read_messages()
    messages.append(message)
    with open('bbs.pickle', mode='wb') as f:
        pickle.dump(messages, f)

    redirect('/')


@app.get('/delete_cookie')
def delete_cookie():
    response.delete_cookie('handle')
    redirect('/')


def read_messages():
    if Path('bbs.pickle').exists():
        with open('bbs.pickle', mode='rb') as f:
            return pickle.load(f)
    return []


if __name__ == "__main__":
    run(app, host="localhost", port=8080, debug=True, reloader=True)

 
テンプレートです。

<html>
    <head>
        <meta charset="UTF-8">
        <title>テスト掲示板 | bottle app</title>
    </head>
    <body>
        <h1>テスト掲示板</h1>
        <form action="/" method="POST">
            <div>
                <label for="title">タイトル</label>
                <input type="text" name="title" size="60">
            </div>
            <div>
                <label for="handle">ハンドル</label>
                <input type="text" name="handle" value="{{handle}}">
            </div>
            <div>
                <label for="message">メッセージ</label>
                <textarea name="message" rows="4" cols="60"></textarea>
            </div>
            <div>
                <input type="submit">
                <a href="/delete_cookie">Cookie削除</a>
            </div>
        </form>

        <hr>

        {% for m in messages %}
            <p>
                「{{ m.title }}」&nbsp;&nbsp;
                {{ m.handle }} さん&nbsp;&nbsp
                {{ m.created_at }}
            </p>
           <p>{{ m.message }}</p>
          <hr>
        {% endfor %}

    </body>
</html>

 

wsgi-interceptを使ったテストコード

wsgi-interceptでは、ライブラリ

  • urllib2
  • urllib.request
  • httplib
  • http.client
  • httplib2
  • requests
  • urllib3

のいずれかを併用してテストコードを書きます。

今回は扱いやすいrequestsを使ってテストコードを書きます。
Requests: HTTP for Humans — Requests 2.13.0 documentation

 

テストの準備
テンプレートディレクトリを認識させる

wsgi-interceptでBottleをテストする場合、デフォルトではテンプレートディレクトリを認識しません。

そのため、

current_dir = os.path.abspath(os.path.dirname(__file__))
template_dir = os.path.join(current_dir, 'bbs_app/views')
bottle.TEMPLATE_PATH.insert(0, template_dir)

のようにして、テンプレートディレクトリを認識させる必要があります。
Frequently Asked Questions — Bottle 0.13-dev documentation

 

Bottleアプリの指定について

wsgi-interceptでは、from bbs_app.bbs import appしてappを利用しようとしてもうまく動作しません。

そのため、

def get_app(self):
    return app

のように、importしたBottleアプリを返す関数やメソッドを用意し、それをwsgi-interceptを使う時に渡してあげる必要があります。

 

GETのテスト

ドキュメントに従い、以下のテストコードを書きます。

with RequestsInterceptor(self.get_app, host='localhost', port=8080) as url:
    actual = requests.get(url)

assert actual.status_code == 200
assert actual.headers['content-type'] == 'text/html; charset=UTF-8'
assert 'テスト掲示板' in actual.text

 
テストを実行すると、wsgi-interceptがrequest.get()をうまいこと処理し、レスポンス(今回はactual)が返ってきます。

あとは、その値を検証すれば良いです。

 

POSTのテスト(add_wsgi_intercept利用版)

上記ではwithを利用していましたが、ドキュメントにもある通り、add_wsgi_intercept()などを使ってもテストできます。

host = 'localhost'
port = 8081
url = f'http://{host}:{port}'

requests_intercept.install()
add_wsgi_intercept(host, port, self.get_app)

form = {
    'title': 'タイトル1',
    'handle': 'ハンドル名1',
    'message': 'メッセージ1',
}
actual = requests.post(url, data=form)

assert actual.status_code == 200
assert actual.headers['content-type'] == 'text/html; charset=UTF-8'
assert 'タイトル1' in actual.text
assert 'ハンドル名1' in actual.text
assert 'メッセージ1' in actual.text

requests_intercept.uninstall()

install()やuninstall()など、色々と手間がかかるため、withの方が良さそうです。

 

POSTとリダイレクトのテスト(RequestsInterceptor利用版)

Webアプリに対してrequestsを使うときと同様の書き方で、POSTやリダイレクトのテスト・検証が行えます。

form = {
    'title': 'タイトル2',
    'handle': 'ハンドル名2',
    'message': 'メッセージ2',
}
with RequestsInterceptor(self.get_app, host='localhost', port=8082) as url:
    # リダイレクト付
    actual = requests.post(url, data=form)

# リダイレクト先の検証
assert actual.status_code == 200
assert actual.headers['content-type'] == 'text/html; charset=UTF-8'
assert 'タイトル2' in actual.text
assert 'ハンドル名2' in actual.text
assert 'メッセージ2' in actual.text

# リダイレクト元の検証
assert len(actual.history) == 1
history = actual.history[0]
assert history.status_code == 302
assert history.headers['content-type'] == 'text/html; charset=UTF-8'
# bottleでredirect()を使った場合、bodyは''になる
assert history.text == ''

 
また、requestsでは、post()メソッドの引数にてallow_redirects=Falseとすれば、リダイレクトしなくなります。
Developer Interface — Requests 2.13.0 documentation

 
リダイレクトなしの場合のテストコードです。

form = {
    'title': 'タイトル3',
    'handle': 'ハンドル名3',
    'message': 'メッセージ3',
}
with RequestsInterceptor(self.get_app, host='localhost', port=8082) as url:
    # リダイレクトさせない
    actual = requests.post(url, data=form, allow_redirects=False)

assert len(actual.history) == 0
assert actual.status_code == 302
assert actual.headers['content-type'] == 'text/html; charset=UTF-8'
# bottleでredirect()を使った場合、bodyは''になる
assert actual.text == ''

 

その他

Bottleアプリではset_cookie()を使っているため、POST時にhandleを設定しないと、TypeError: Secret key missing for non-string Cookieというエラーになります。
Python Bottle - TypeError: Secret key missing for non-string Cookie. | BitmapCake!

 
wsgi-interceptでもエラーを確認できるのですが、エラー出力内容がPython2とPython3で異なりました。

Python2は上記のエラー出力のみですが、Python3では上記のほか、

  • TypeError: getresponse() got an unexpected keyword argument ‘buffering’
  • TypeError: a bytes-like object is required, not ‘str’
  • wsgi_intercept.WSGIAppError: TypeError(“a bytes-like object is required, not ‘str’”,) at path/to/env/bin/bottle.py:972

が混ざっていました。特に、本当のエラーのSecret key missingが途中に混ざっており、エラー解析しづらい印象でした。

 
そのため、こんな感じでcurlも併用して原因の調査をしました。

curl -XPOST --data 'title=bar&handle=baz' http://localhost:8080/

参考:curl コマンド 使い方メモ - Qiita

 

ソースコード

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

Python + Bottleで、フォームやCookieに日本語を設定したら文字化けした

Python + Bottleで、フォームやCookieに日本語を使ったら文字化けしたため、メモを残します。

目次

 

環境

 

フォームやCookieに設定した値の取得について

フォームやCookieに設定した値は

  • フォームに入力した値:request.forms
  • Cookieに設定した値:request.cookies

という、FormsDict型のオブジェクトとして保存されています。

 
例えば、

<form action="/" method="POST">
    <div>
        <label for="input">入力</label>
        <input type="text" name="input" size="60">
    </div>
    <div>
        <input type="submit">
        <a href="/delete_cookie">Cookie削除</a>
    </div>
</form>

というフォームがあった場合、

from bottle import Bottle, request

app = Bottle()

@app.post('/')
def post_form():
    result = request.forms.get('input')

として値を取得します。

 
また、Cookieの場合は、

response.set_cookie('key', 'value')

Cookieに値を設定し、

cookie_by_get = request.get_cookie('key')

で値を取得します。

 

日本語の文字化けと対応について

ただ、上記のrequest.forms.get()request.get_cookie()では、日本語などのマルチバイト文字の場合に文字化けします。

result = request.forms.get('input')
print(result)
#=> ã

 
BottleのチュートリアルのNoteに、原因の記載があります。

In Python 3 all strings are unicode, but HTTP is a byte-based wire protocol. The server has to decode the byte strings somehow before they are passed to the application. To be on the safe side, WSGI suggests ISO-8859-1 (aka latin1), a reversible single-byte codec that can be re-encoded with a different encoding later. Bottle does that for FormsDict.getunicode() and attribute access, but not for the dict-access methods

request.forms.get(key)request.forms[key]では、latin1でデコードした値となるため、文字化けしているようです。

latin1でのデコードについては、PEP3333(日本語訳)のこのあたりで触れられています。
Unicode の問題 | PEP 3333: Python Web Server Gateway Interface v1.0.1 — knzm.readthedocs.org 2012-12-31 documentation

 
そのため、utf-8でデコードした値を取得するには、

などを使います。
INTRODUCING FORMSDICT | Tutorial — Bottle 0.13-dev documentation

 
フォームの値について、

# 値を取得
form_by_get = request.forms.get('input')
form_by_dict_key = request.forms['input']
form_by_getunicode = request.forms.getunicode('input')
form_by_attr = request.forms.input
form_by_decode = request.forms.decode().get('input')
form_by_getall = request.forms.getall('input')
form_by_getall_first = request.forms.getall('input')[0]
form_by_decode_getall = request.forms.decode().getall('input')
form_by_decode_getall_first = request.forms.decode().getall('input')[0]

# テンプレートへ反映
return jinja2_template(
    'form.html',
    form_by_get=form_by_get,
    form_by_dict_key=form_by_dict_key,
    form_by_getunicode=form_by_getunicode,
    form_by_attr=form_by_attr,
    form_by_decode=form_by_decode,
    form_by_getall=form_by_getall,
    form_by_getall_first=form_by_getall_first,
    form_by_decode_getall=form_by_decode_getall,
    form_by_decode_getall_first=form_by_decode_getall_first)

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

f:id:thinkAmi:20170409170633p:plain:w170

となりました。

 
また、Cookieの場合も、POSTで

response.set_cookie('input', request.forms.get('input'))

と値を設定してから、GETで

cookie_by_get = request.get_cookie('input', '')
cookie_by_dict_key = request.cookies['input'] if request.cookies else ''
cookie_by_getunicode = request.cookies.getunicode('input', default='')
cookie_by_attr = request.cookies.input if request.cookies else ''
cookie_by_decode = request.cookies.decode().get('input', '')

と値を取得してブラウザで表示したところ、

f:id:thinkAmi:20170409170718p:plain:w120

となりました。

 

その他

以前、WebTestのサンプルで、Bottleのフォームを使った時に文字化けしていました。
Pythonで、WebTestを使って、WSGIサーバを起動せずにWSGIアプリのテストをする - メモ的な思考的な

 
当時、文字化けの原因がつかめませんでしたが、POSTされたフォームの値をget()で取得していたのが原因でした。

そのため、上記のサンプルはget()ではなく、getunicode()を使うように書き換えました。

 

ソースコード

GitHubに上げました。e.g._FormsDict_using_multi_byte_stringディレクトリ以下が今回のサンプルです。
thinkAmi-sandbox/Bottle-sample: Bottle (python web framework) sample codes