Docker + Alpine3.5 + Apache2.4 + Python3.6で、フォームのデータを標準モジュールcgiで受け取ってみた

前回、フォームのデータをCGIPythonスクリプトsys.stdin.read()os.environを使って受け取りました。
Dockerで、Alpine3.5 + Apache2.4 + Python3.6の環境を作って、フォームのデータをCGIで受け取ってみた - メモ的な思考的な

 
今回は標準モジュールcgiにあるFieldStorage使ってデータを受け取ってみます。

 
目次

 

環境

  • Mac OS X 10.11.6
  • Docker for Mac 17.03.1-ce-mac5
  • Alpine3.5 + Apache2.4.25 + Python 3.6.1

なお、Dockerfileは前回のものを流用します。

HTMLファイルは前回のものを流用し、actionだけを対象のPythonスクリプトに差し替えます。

Dockerまわりのコマンドは以下の通りです。

# Docker上のApacheでCGIとして動かすために、Dockerと共有するローカルファイルのパーミッションを変更
$ chmod 755 cgi_module.py 
$ chmod 755 cgi_file_upload.py
$ chmod 755 cgi_file_upload_with.py

# Dockerイメージのビルド
$ docker image build -t alpine:python3_httpd24_cgi_module .

# Dockerコンテナの起動
$ docker container run -p 8081:80 --name cgi_module -v `pwd`/htdocs/:/usr/local/apache2/htdocs -v `pwd`/cgi/:/usr/local/apache2/cgi-bin/ alpine:python3_httpd24_cgi_module

 

cgi.FieldStorageを使ったフォームデータの受け取り

データの読み込み

cgi.FieldStorageインスタンスを生成することで、標準入力などからデータを読み込めます。

cgi_module.py

#!/usr/bin/python3
import cgi

# HTTPレスポンスヘッダ
print('Content-Type: text/plain;charset=utf-8\n')
print('')

form = cgi.FieldStorage()
print(form)
# => FieldStorage(None, None, [MiniFieldStorage('quantity', '1個'), 
#                              MiniFieldStorage('hidden_valude', '隠しデータ')])

 
また、フォームに入力していない要素も取得したい場合は、

form = cgi.FieldStorage(keep_blank_values=True)
print(form)
# => FieldStorage(None, None, [MiniFieldStorage('subject', ''), 
#                              MiniFieldStorage('quantity', '1個'), 
#                              MiniFieldStorage('hidden_valude', '隠しデータ'), 
#                              MiniFieldStorage('memo', '')])

と、FieldStorageのインスタンス生成時の引数としてkeep_blank_values=Trueを指定します。

ただ、上記を見る限り、未選択のcheckboxやradio button、select multipleは取得できないようです。

 
なお、FieldStorageのインスタンス生成については、公式ドキュメントにある

標準入力または環境変数からフォームの内容を読み出します (どちらから読み出すかは、複数の環境変数の値が CGI 標準に従ってどのように設定されているかで決まります)。インスタンスが標準入力を使うかもしれないので、インスタンス生成を行うのは一度だけにしなければなりません。

21.2. cgi — CGI (ゲートウェイインタフェース規格) のサポート — Python 3.6.1 ドキュメント

という点に注意します。

 
また、FieldStorageインスタンス生成後にsys.stdin.read()を使うと、

form = cgi.FieldStorage()

print('stdin:\n{}'.format(sys.stdin.read()))
# => stdin:

と、何も取得できません。

 
一方(今回のケースだけかもしれませんが)、os.environについては

form = cgi.FieldStorage()

print('os.environ:\n')
for k, v in os.environ.items():
    print('{}: {}'.format(k, v))
# =>
# HTTP_HOST: localhost:8081
# HTTP_CONNECTION: keep-alive
# ...

と、環境変数の値が取得できました。

 

FieldStorageの属性やメソッド

属性やメソッドを使ってみると、

form = cgi.FieldStorage()

print(form.type)
# => application/x-www-form-urlencoded

print(form.headers)
# => {'content-type': 'application/x-www-form-urlencoded', 'content-length': '94'}

print(form.keys())
# => ['quantity', 'memo', 'hidden_valude', 'subject']

となりました。

 

フォームのフィールド値の取得

例えば、「自分で持ち帰る」チェックボックスにチェックを入れた場合、

form = cgi.FieldStorage()

print(form['takeout'])
# => MiniFieldStorage('takeout', '自分で持ち帰る')

print(form['takeout'].value)
# => 自分で持ち帰る

と、辞書の添字アクセスと同じようにして、フィールドの値を取得できます。

 
なお、添字アクセスで存在しないキーを指定した場合、

form = cgi.FieldStorage()

try:
    print(form['foo'])
except:
    print('not found: foo')
# => not found: foo

と、例外が出ます。

 
そのため、getvalue()メソッドを使うことで、

form = cgi.FieldStorage()
print(form.getvalue('foo'))
# => None
print(form.getvalue('foo', 'default_value'))
# => default_value

と、例外を回避したり、デフォルト値を返すことができます。

 

同じnameを持つ複数フィールド値の取得

チェックボックスなど、複数のフィールドで同じnameを持つ場合は

form = cgi.FieldStorage()

print(form.getfirst('purpose'))
# => 贈り物にする

print(form.getlist('purpose'))
# => ['贈り物にする', '自家用にする']

のように、getfirst()で最初のものだけ取得したり、getlist()でリストとして取得します。

 
なお、nameが一つだけの場合にgetlist()メソッドを使うと、

form = cgi.FieldStorage(keep_blank_values=True)

print(form.getlist('takeout'))
# => ['自分で持ち帰る']

と、要素数1のリストとなります。

 

cgi.FieldStorageを使ったアップロードファイルの受け取り

ファイルアップロードができるフォームを

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title></title>
</head>

<body>
    <h1>ファイルアップロードサンプル(一気に読み込み)</h1>
    <form action="/cgi-bin/cgi_file_upload.py" method="POST" enctype="multipart/form-data">
        <!--input text-->
        <label for="id_upload_file">対象ファイル</label>
        <input type="file" id="id_upload_file" name="upload_file">

        <!--submit-->
        <p>
            <input type="submit">
        </p>
    </form>
</body>

</html>

と用意した場合も、cgi.FieldStorageでアップロードしたファイルのデータを扱えます。

 
例えば、

upload_file.txt

あ
a

というファイルを扱う場合、

cgi_file_upload.py

#!/usr/bin/python3
import cgi

# HTTPレスポンスヘッダ
print('Content-Type: text/plain;charset=utf-8\n')
print('')

form = cgi.FieldStorage()
print(form)
# => FieldStorage(None, None, [FieldStorage('upload_file', 'upload_file.txt', b'\xe3\x81\x82\na')])

とすることで、FieldStorageのインスタンスとして取得できます。

 
FieldStorageインスタンスからファイルの中身を一気に読み込む場合、

form = cgi.FieldStorage()

content = form.getvalue('upload_file')

print(content.__class__)
# => <class 'bytes'>

print(content.decode('utf-8'))
# =>
# あ
# a

と、getvalue()メソッドでバイト文字列を取得し、それをdecode()にて文字列にします。

 
また、一気に読み込むのが難しい場合は、一定のバイト数で区切って読み込むこともできます。

cgi_file_upload_with.py

form = cgi.FieldStorage()
upload_file = form['upload_file']

print(upload_file)
# => FieldStorage('upload_file', 'upload_file.txt', b'\xe3\x81\x82\na')

print(dir(upload_file))
# => ['FieldStorageClass', '_FieldStorage__file', '_FieldStorage__write', '__bool__', 
#     '__class__', '__contains__', '__del__', '__delattr__', '__dict__', '__dir__', 
#     '__doc__', '__enter__', '__eq__', '__exit__', '__format__', '__ge__', '__getattr__',
#     '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__',
#     '__iter__', '__le__', '__len__', '__lt__', '__module__', '__ne__', '__new__',
#     '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', 
#     '__subclasshook__', '__weakref__', '_binary_file', 'bufsize', 'bytes_read', 
#     'disposition', 'disposition_options', 'done', 'encoding', 'errors', 'file', 
#     'filename', 'fp', 'getfirst', 'getlist', 'getvalue', 'headers', 'innerboundary',
#     'keep_blank_values', 'keys', 'length', 'limit', 'list', 'make_file', 'name', 
#     'outerboundary', 'qs_on_post', 'read_binary', 'read_lines', 'read_lines_to_eof', 
#     'read_lines_to_outerboundary', 'read_multi', 'read_single', 'read_urlencoded', 
#     'skip_lines', 'strict_parsing', 'type', 'type_options']

print(upload_file.name)
# => upload_file

print(upload_file.filename)
# => upload_file.txt

print(upload_file.file)
#=> <_io.BytesIO object at 0x7fe34bba03b8>

# with文にも対応しているとのことなので、試してみる
# http://docs.python.jp/3/library/cgi.html
with upload_file as f:
    content = b''
    while True:
        # 1バイトずつ読み込む場合
        byte_strings = f.file.read(1)
        print(byte_strings)
        # => b'\xe3', b'\x81', b'\x82', b'\n', b'a', b'' の順に読み込まれる

        if not byte_strings:
            break
        content += byte_strings

print(content.decode('utf-8'))
# あ
# a

 

ソースコード

GitHubに上げました。alpine_apache_python36_cgi_moduleディレクトリの中が今回のものです。
thinkAmi-sandbox/Docker_Apache-sample

Dockerで、Alpine3.5 + Apache2.4 + Python3.6の環境を作って、フォームのデータをCGIで受け取ってみた

以前、DockerでCGIを動かしてみました。
Dockerで、Alpine3.4 + Apache2.4.25 + Python3.6.0の環境を作って、CGIを動かしてみた - メモ的な思考的な

 
今回は、Dockerで、Alpine3.5 + Apache2.4 + Python3.6の環境を作って、フォームのデータをPythonCGIで受け取ってみます。

 
目次

 

環境

  • Mac OS X 10.11.6
  • Docker for Mac 17.03.1-ce-mac5
  • Alpine3.5
    • httpd:2.4.25-alpineをベースに、Python3をAlpineのパッケージapkでインストールしたもの

 

Dockerfile作成

以前は「Python3.6のAlpine版のイメージ + ApacheをセットアップするDockerfile」という構成でした。

ただ、Alpine3.4縛りのせいとはいえ、ApacheをセットアップするDockerfileがほぼコピペだったので、あまり良くないと感じていました。

 
他の方法を探してみたところ、Alpine3.5にapkのPython3をインストールしているDockerfileがありました。
frol/docker-alpine-python3: The smallest Docker image with Python 3.5 (~61MB)

 
そこで、httpd:2.4.25-alpineをベースに、apkのPython3をインストールするDockerfileを作成しました。

なお、Apacheのconfファイルは前回のものを流用します。

Dockerfile

FROM httpd:2.4.25-alpine

RUN apk --update --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/main/ add python3 && \
    python3 -m ensurepip && \
    rm -r /usr/lib/python*/ensurepip && \
    pip3 install --upgrade pip setuptools && \
    rm -r /root/.cache

# ローカルのhttpd.confをコピー
COPY httpd.conf /usr/local/apache2/conf/

 

HTMLフォームの作成

ひと通りのフォーム要素を持つHTMLを用意します。

また、GETだけではなくPOSTも試したかったので、formのmethodだけを変えたHTMLも用意します。

htdocs/form_get_stdin.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title></title>
</head>

<body>
    <h1>フォームサンプル(GET)</h1>
    <form action="/cgi-bin/stdin_environ.py" method="GET">
        <!--input text-->
        <label for="id_subject">件名</label>
        <input type="text" id="id_subject" name="subject">

        <!--radio button-->
        <p>
            <label for="id_apple">リンゴ</label>
            <input id="id_apple" type="radio" name="fruit" value="りんご">
            <label for="id_mandarin">ミカン</label>
            <input id="id_mandarin" type="radio" name="fruit" value="みかん">
            <label for="id_grape">ブドウ</label>
            <input id="id_grape" type="radio" name="fruit" value="ぶどう">
        </p>
        <p>
            <label for="id_big"></label>
            <input id="id_big" type="radio" name="fruit_size" value="大きいもの">
            <label for="id_small"></label>
            <input id="id_small" type="radio" name="fruit_size" value="小さいもの">
        </p>

        <!--select-->
        <p>
            <label for="id_quantity">個数</label>
            <select id="id_quqntity" name="quantity">
                <option id="id_selected1_1" name="select1_1" value="1個">1</option>
                <option id="id_selected1_2" name="select1_2" value="2個">2</option>
                <option id="id_selected1_3" name="select1_3" value="3個">3</option>
            </select>
        </p>

        <!--select multiple-->
        <p>
            <label for="id_accessories">付属品</label>
            <select id="id_accessories" name="accessories" multiple>
                <option id="id_selected2_1" name="select2_1" value="紙袋">紙袋</option>
                <option id="id_selected2_2" name="select2_2" value="容器">容器</option>
                <option id="id_selected2_3" name="select2_3" value="紐"></option>
            </select>
        </p>

        <!--checkbox-->
        <p>
            <label for="id_takeout">持ち帰る</label>
            <input type="checkbox" id="id_takeout" name="takeout" value="自分で持ち帰る">
        </p>
        <p>
            <label for="id_gift">贈り物</label>
            <input type="checkbox" id="id_gift" name="gift" value="贈り物にする">
        </p>

        <!--hidden-->
        <input type="hidden" id="id_hidden_value" name="hidden_valude" value="隠しデータ">

        <!--textare-->
        <label for="id_memo">メモ</label>
        <textarea id="id_memo" name="memo"></textarea>

        <!--submit-->
        <p>
            <input type="submit">
        </p>
    </form>
</body>

</html>

POST用のHTML(htdocs/form_get_stdin.html)は省略します。

 

CGI用のPythonスクリプトを作成

Apacheのドキュメントによると、フォームのデータは環境変数と標準入力(STDIN)に設定されます。
裏で何が起こっているのか? | Apache Tutorial: CGI による動的コンテンツ - Apache HTTP サーバ バージョン 2.4

 
そこで今回は、環境変数と標準入力の値をブラウザへと返すようにしてみます。

なお、Pythonでは、

  • 環境変数の値: os.environ辞書
  • 標準入力の値: sys.stdin.read()

でそれぞれ取得できます。

cgi/stdin_environ.py

#!/usr/bin/python3
# shebangに指定するPython3を以下で確認
# bash-4.3# which python3
# /usr/bin/python3

import os
import sys

# HTTPレスポンスヘッダ
print('Content-Type: text/plain;charset=utf-8\n')
print("\n")

# HTTPレスポンスボディ
# 標準入力
print('-'*20)
print('stdin:\n{}'.format(sys.stdin.read()))

# 環境変数
print('-'*20)
print('os.environ:\n')
for k, v in os.environ.items():
    print('{}: {}'.format(k, v))

 

CGI用のPythonスクリプトパーミッションを変更

今回、HTMLやPythonスクリプトは Dockerの-vオプションを使用して、ホストとコンテナで共有します。

ただ、Dockerfileでは共有した時のパーミッションをうまく設定できませんでした(後述)。

そこで今回は、ホストのPythonスクリプトパーミッションを変更します。これにより、DockerコンテナとPythonスクリプトを共有しても、同じパーミッションになります。

# 変更前のMac上のパーミッション
$ ls -al
-rw-r--r--   1 you  staff  513  5 10 05:46 stdin_environ.py

# 実行可能へと変更
$ chmod 755 stdin_environ.py 

# 変更後のMac上のパーミッション
$ ls -al
-rwxr-xr-x   1 you  staff  513  5 10 05:46 stdin_environ.py

 

Dockerの起動

ローカルとDockerでファイルを共有するため、-vオプションを使用します。

今回はhtmlファイルのディレクトリとCGIPythonディレクトリの2つを共有するため、-vオプションを2つ使ってそれぞれ指定します。
Mounting multiple volumes on a docker container? - Stack Overflow

ホスト コンテナ
htdocs/ /usr/local/apache2/htdocs
cgi/ /usr/local/apache2/cgi-bin/

 
実際に入力する内容は以下の通りです。

# ビルド
$ docker build -t alpine:python3_httpd24_cgi_form .

# 起動
$ docker run -p 8081:80 --name cgi_form -v `pwd`/htdocs/:/usr/local/apache2/htdocs -v `pwd`/cgi/:/usr/local/apache2/cgi-bin/ alpine:python3_httpd24_cgi_form

 
コンテナのパーミッションも確認します。

# コンテナに入る
$ docker exec -it `docker ps | grep cgi_form | awk '{print $1}'` /bin/bash

# カレントディレクトリの確認
bash-4.3# pwd
/usr/local/apache2

# カレントディレクトリのパーミッション
bash-4.3# ls -al
total 40
drwxr-xr-x    1 www-data www-data      4096 Mar  3 21:57 .
drwxr-xr-x    1 root     root          4096 Mar  3 21:57 ..
drwxr-xr-x    2 root     root          4096 Mar  3 21:57 bin
drwxr-xr-x    2 root     root          4096 Mar  3 21:57 build
drwxr-xr-x    4 root     root           136 May  9 20:47 cgi-bin
drwxr-xr-x    1 root     root          4096 May  9 20:53 conf
drwxr-xr-x    3 root     root          4096 Mar  3 21:57 error
drwxr-xr-x    5 root     root           170 May  9 20:46 htdocs
drwxr-xr-x    3 root     root          4096 Mar  3 21:57 icons
drwxr-xr-x    2 root     root          4096 Mar  3 21:57 include
drwxr-xr-x    1 root     root          4096 May  9 20:54 logs
drwxr-xr-x    2 root     root          4096 Mar  3 21:57 modules

# cgi-binディレクトリの中にある「stdin_environ.py」のパーミッションを確認
bash-4.3# cd cgi-bin/

bash-4.3# ls -al
-rwxr-xr-x    1 root     root           513 May  9 20:46 stdin_environ.py

ホストと同じパーミッションが設定されていました。

 

フォームでGET

http://localhost:8081/form_get_stdin.htmlにアクセスし、以下のようにフォームへ入力します。

f:id:thinkAmi:20170510210628p:plain:w300

 
送信ボタンを押したあとの結果は以下の通りです。

環境変数のみ値が設定されています。

--------------------
stdin:

--------------------
os.environ:

HTTP_HOST: localhost:8081
HTTP_CONNECTION: keep-alive
HTTP_UPGRADE_INSECURE_REQUESTS: 1
HTTP_USER_AGENT: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36
HTTP_ACCEPT: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
HTTP_REFERER: http://localhost:8081/form_get_stdin.html
HTTP_ACCEPT_ENCODING: gzip, deflate, sdch, br
HTTP_ACCEPT_LANGUAGE: ja,en-US;q=0.8,en;q=0.6
PATH: /usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SERVER_SIGNATURE: 
SERVER_SOFTWARE: Apache/2.4.25 (Unix)
SERVER_NAME: localhost
SERVER_ADDR: 172.17.0.2
SERVER_PORT: 8081
REMOTE_ADDR: 172.17.0.1
DOCUMENT_ROOT: /usr/local/apache2/htdocs
REQUEST_SCHEME: http
CONTEXT_PREFIX: /cgi-bin/
CONTEXT_DOCUMENT_ROOT: /usr/local/apache2/cgi-bin/
SERVER_ADMIN: you@example.com
SCRIPT_FILENAME: /usr/local/apache2/cgi-bin/stdin_environ.py
REMOTE_PORT: 59532
GATEWAY_INTERFACE: CGI/1.1
SERVER_PROTOCOL: HTTP/1.1
REQUEST_METHOD: GET
QUERY_STRING: subject=%E3%82%BF%E3%82%A4%E3%83%88%E3%83%AB&fruit=%E3%82%8A%E3%82%93%E3%81%94&fruit_size=%E5%B0%8F%E3%81%95%E3%81%84%E3%82%82%E3%81%AE&quantity=2%E5%80%8B&accessories=%E5%AE%B9%E5%99%A8&takeout=%E8%87%AA%E5%88%86%E3%81%A7%E6%8C%81%E3%81%A1%E5%B8%B0%E3%82%8B&gift=%E8%B4%88%E3%82%8A%E7%89%A9%E3%81%AB%E3%81%99%E3%82%8B&hidden_valude=%E9%9A%A0%E3%81%97%E3%83%87%E3%83%BC%E3%82%BF&memo=%E4%B8%80%E8%A1%8C%E7%9B%AE%0D%0A%E4%BA%8C%E8%A1%8C%E7%9B%AE
REQUEST_URI: /cgi-bin/stdin_environ.py?subject=%E3%82%BF%E3%82%A4%E3%83%88%E3%83%AB&fruit=%E3%82%8A%E3%82%93%E3%81%94&fruit_size=%E5%B0%8F%E3%81%95%E3%81%84%E3%82%82%E3%81%AE&quantity=2%E5%80%8B&accessories=%E5%AE%B9%E5%99%A8&takeout=%E8%87%AA%E5%88%86%E3%81%A7%E6%8C%81%E3%81%A1%E5%B8%B0%E3%82%8B&gift=%E8%B4%88%E3%82%8A%E7%89%A9%E3%81%AB%E3%81%99%E3%82%8B&hidden_valude=%E9%9A%A0%E3%81%97%E3%83%87%E3%83%BC%E3%82%BF&memo=%E4%B8%80%E8%A1%8C%E7%9B%AE%0D%0A%E4%BA%8C%E8%A1%8C%E7%9B%AE
SCRIPT_NAME: /cgi-bin/stdin_environ.py

 

フォームでPOST

http://localhost:8081/form_post_stdin.htmlにアクセスし、同じようにフォームに入力し、送信ボタンを押します。

POSTでは環境変数と標準入力に値が設定されています。

--------------------
stdin:
subject=%E3%82%BF%E3%82%A4%E3%83%88%E3%83%AB&fruit=%E3%82%8A%E3%82%93%E3%81%94&fruit_size=%E5%B0%8F%E3%81%95%E3%81%84%E3%82%82%E3%81%AE&quantity=2%E5%80%8B&accessories=%E5%AE%B9%E5%99%A8&takeout=%E8%87%AA%E5%88%86%E3%81%A7%E6%8C%81%E3%81%A1%E5%B8%B0%E3%82%8B&gift=%E8%B4%88%E3%82%8A%E7%89%A9%E3%81%AB%E3%81%99%E3%82%8B&hidden_valude=%E9%9A%A0%E3%81%97%E3%83%87%E3%83%BC%E3%82%BF&memo=%E4%B8%80%E8%A1%8C%E7%9B%AE%0D%0A%E4%BA%8C%E8%A1%8C%E7%9B%AE
--------------------
os.environ:

HTTP_HOST: localhost:8081
HTTP_CONNECTION: keep-alive
CONTENT_LENGTH: 444
HTTP_CACHE_CONTROL: max-age=0
HTTP_ORIGIN: http://localhost:8081
HTTP_UPGRADE_INSECURE_REQUESTS: 1
HTTP_USER_AGENT: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36
CONTENT_TYPE: application/x-www-form-urlencoded
HTTP_ACCEPT: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
HTTP_REFERER: http://localhost:8081/form_post_stdin.html
HTTP_ACCEPT_ENCODING: gzip, deflate, br
HTTP_ACCEPT_LANGUAGE: ja,en-US;q=0.8,en;q=0.6
PATH: /usr/local/apache2/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SERVER_SIGNATURE: 
SERVER_SOFTWARE: Apache/2.4.25 (Unix)
SERVER_NAME: localhost
SERVER_ADDR: 172.17.0.2
SERVER_PORT: 8081
REMOTE_ADDR: 172.17.0.1
DOCUMENT_ROOT: /usr/local/apache2/htdocs
REQUEST_SCHEME: http
CONTEXT_PREFIX: /cgi-bin/
CONTEXT_DOCUMENT_ROOT: /usr/local/apache2/cgi-bin/
SERVER_ADMIN: you@example.com
SCRIPT_FILENAME: /usr/local/apache2/cgi-bin/stdin_environ.py
REMOTE_PORT: 59536
GATEWAY_INTERFACE: CGI/1.1
SERVER_PROTOCOL: HTTP/1.1
REQUEST_METHOD: POST
QUERY_STRING: 
REQUEST_URI: /cgi-bin/stdin_environ.py
SCRIPT_NAME: /cgi-bin/stdin_environ.py

 

その他悩んだこと

Dockerfileの中で、共有ディレクトリのパーミッションを設定する方法

前述のとおり、今回はホストとコンテナの共有ディレクトリのパーミッションは、ホストのパーミッションを変更することで対応しました。

なお、ホストのパーミッションを変更しなかった場合、Dockerのログに以下が出力されるとともに、ブラウザに「Internal Server Error」が表示されました。

[pid 97:tid 140512665647944] (13)Permission denied: AH01241: exec of '/usr/local/apache2/cgi-bin/stdin_environ.py' failed
[pid 12:tid 140512664455856] [client 172.17.0.1:59462] End of script output before headers: stdin_environ.py, referer: http://localhost:8081/form_get_stdin.html

 
以下の方法を試してみましたが、うまくいきませんでした。

 
ちなみに、Alpine3.4では useraddusermodが無いとのことです。
Alpine Linuxでユーザやグループを追加・修正・削除する - 水底

 

ソースコード

GitHubに上げました。alpine_apache_python36_cgi_formディレクトリの中が今回のものです。
thinkAmi-sandbox/Docker_Apache-sample

Pythonで、unittest.mock.patchを使ってデコレータを差し替える

同僚と話している中で、unittest.mock.patchを使ったデコレータの差し替えに関する話題が出ました。

そういえばデコレータは差し替えたことがなかったため、試してみたことをメモします。

なお、「テストファイル群に、デコレータを差し替える/差し替えないものが混在している場合」で使った方法は強引な気がします。そのため、もしより良い方法をご存じであればご指摘ください。

 
目次

 

環境

  • Python 3.6.1
  • pytest 3.0.7
    • テストランナーとして使用

 

用意したデコレータとプロダクションコード

デコレータとは、関数に処理を追加するためのシンタックスシュガー(糖衣構文)です。

以下が参考になりますので、デコレータの詳細は省略します。

 
今回用意するデコレータは、

  • 引数なしのデコレータ (@countup, @countdown)
  • 位置引数ありのデコレータ (@add)
  • 位置引数とキーワード引数ありのデコレータ (@calculate)

です。

また、functools.wrapsを使っていますが、使う意味や使い方などは以下が参考になりました。
[python]デコレータでfunctools.wrap()を使う - logging.info(self)

 
デコレータのソースコードはこんな感じです。

deco/my_decorator.py

from functools import wraps

def countup(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        result = function(*args, **kwargs)
        return result + 1
    return wrapper


def countdown(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        result = function(*args, **kwargs)
        return result - 1
    return wrapper


def add(decorator_arg):
    """デコレータadd(@addとして使う)と、デコレータの引数decorator_arg"""

    def decorate(function):
        """デコレート対象の関数を引数fuctionとして受け取る、関数"""

        @wraps(function)
        def wrapped(*args, **kwargs):
            """デコレート対象の関数の引数をargsやkwargsとして受け取る、関数"""
            # デコレート対象の関数を実行し、結果をresultに入れる
            result = function(*args, **kwargs)
            # resultに対し、デコレータの引数を加算して戻す
            return result + decorator_arg

        # decorate関数は、デコレート対象の関数をラップするwrapped関数を返す
        return wrapped

    # add関数は、decorate関数を返す
    return decorate


def calculate(*decorator_args, **decorator_kwargs):
    u""""""
    def decorate(function):
        @wraps(function)
        def wrapped(*args, **kwargs):
            result = function(*args, **kwargs)
            # 可変長引数で与えられた数を合計する
            summary = sum(decorator_args)
            # キーワード可変長引数に減算指定がある場合は減算、それ以外は加算
            if decorator_kwargs.get('is_decrement'):
                return result - summary
            else:
                return result + summary
        return wrapped
    return decorate

 
これらのデコレータは、以下のようにしてプロダクションコードで使います。

target.py

from deco.my_decorator import countup, countdown, add, calculate

class Target:
    def __init__(self, value=0):
        self.value = value

    @countup
    def execute_count_up(self):
        return self.value

    @countdown
    def execute_count_down(self):
        return self.value

    @add(2)
    def execute_add(self):
        return self.value

    @calculate(1, 2, 3)
    def execute_calculate_increment(self):
        return self.value

    @calculate(1, 2, 3, is_decrement=True)
    def execute_calculate_decrement(self):
        return self.value

 
動作はテストコードで説明すると

test_target.py

from target import Target

class TestCountUpWithoutPatch:
    def test_count_up_decorator(self):
        sut = Target()
        actual = sut.execute_count_up()
        assert actual == 1

    def test_count_down_decorator(self):
        sut = Target()
        actual = sut.execute_count_down()
        assert actual == -1

    def test_add_decorator(self):
        sut = Target()
        actual = sut.execute_add()
        assert actual == 2

    def test_increment_decorator(self):
        sut = Target()
        actual = sut.execute_calculate_increment()
        assert actual == 6

    def test_decrement_decorator(self):
        sut = Target()
        actual = sut.execute_calculate_decrement()
        assert actual == -6

 
であり、テストはすべてパスします。

$ pytest test_target.py 
...

test_target.py .....

5 passed in 0.05 seconds

 

引数なしのデコレータを差し替える

プロダクションコード

@countup
def execute_count_up(self):
    return self.value

について、

  • execute_count_up()メソッドの挙動を確認したい
  • デコレータ@countupは動作させたくない

という条件を満たすテストを書きたいとします。

 
その方法を調べたところ、以下の記事がありました。
Patching decorators | Python Mock Gotchas - Alex Marandon

I hope this silly example convinces you that decorators are applied when the class is created, which happens straight away when we load the module containing the class.

To deal with this you first have to make sure that your decorator is defined in a module separate from the class, otherwise you’ll never get a chance to replace it with a mock before the class definition calls it. Then you need to write your test code so that it patches the decorator before it gets applied to the methods of your class:

とのことです。

そのため、プロダクションコードのimport前に、デコレータを差し替えれば良さそうです。

 
なお、上記の記事ではMockオブジェクトのstart()を使っていましたが、stop()するのがめんどうなので、今回はpatchをwith構文を使うことにします。

 
では、テストコードでデコレータを差し替えてみます。

test_global_patch_count_up.py

from unittest.mock import patch

# withを使ったパッチ
# デコレータはimportした時に確定するため、importだけをwithの中に入れる
with patch('deco.my_decorator.countup', lambda function: function):
    from target import Target

class TestCountUp:
    def test_count_up_decorator(self):
        sut = Target()
        actual = sut.execute_count_up()
        assert actual == 0

デコレータで何も処理しないよう、patchを使って差し替えました。

そのため、デコレータでは1を加算しなくなり、プロダクションコードからは1ではなく0が返ってくるはずです。

 
テストコードを実行してみます。

$ pytest test_global_patch_count_up.py 
...
test_global_patch_count_up.py .

...
1 passed in 0.08 seconds

想定通りの動きとなり、テストをパスしました。

 

引数ありのデコレータを差し替える

次は、引数ありのデコレータを差し替えるケースを考えます。

今回は、位置引数を持つデコレータ@addを差し替えます。

引数なしと同じように

test_global_patch_add.py

from unittest.mock import patch

with patch('deco.my_decorator.add', lambda function: function):
    from target import Target

class TestAdd:
    def test_add_decorator(self):
        sut = Target()
        actual = sut.execute_add()
        assert actual == 0

としてしまうと、

$ pytest test_global_patch_add.py 
...
test_global_patch_add.py:6: in <module>
    from target import Target
target.py:3: in <module>
    class Target:
target.py:15: in Target
    @add(2)
E   TypeError: 'int' object is not callable

 1 error in 0.22 seconds

エラーになります。

 
位置引数を持つデコレータのソースコードを見ると、引数なしのデコレータよりも、引数functionを持つ関数のネストが一段深くなっています。

def add(decorator_arg):
    def decorate(function):
        @wraps(function)
        def wrapped(*args, **kwargs):
            ...
            return result + decorator_arg
        return wrapped
    return decorate

これより、with patch('deco.my_decorator.add', lambda function: function):だと、本来はdecorate関数の引数functionを返すよう差し替えるべきなのに、add関数のdecorator_argを返すようになっていることが分かります。

その結果、callableなオブジェクトの受け取りを想定しているところで、intの2というcallableでないオブジェクトが返っているため、エラーとなっています。

 
そのため、テストコードで

with patch('deco.my_decorator.add', lambda decorator_arg: lambda function: function):
    from target import Target

と差し替えるlambdaを一段深くするように変更したところ、

$ pytest test_global_patch_add.py 
...
test_global_patch_add.py .

1 passed in 0.08 seconds

テストをパスしました。

 

ダミー処理をするデコレータに差し替える

今までは、何も処理しないデコレータへと差し替えました。

ただ、何らかの処理を行うデコレータに差し替えたいこともあります。

その場合は、

test_global_patch_replacement.py

from unittest.mock import patch
from functools import wraps

# 差し替え用のデコレータを用意
def dummy_decorator(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        result = function(*args, **kwargs)
        return result + 9
    return wrapper

# 用意したデコレータへ差し替える(引数無し版と引数あり版)
with patch('deco.my_decorator.countdown', lambda function: dummy_decorator(function)), \
        patch('deco.my_decorator.add', 
              lambda decorator_arg: lambda function: dummy_decorator(function)):
    from target import Target

class TestPatchArgs:
    def test_count_down_decorator(self):
        sut = Target()
        actual = sut.execute_count_down()
        assert actual == 9

    def test_add(self):
        sut = Target()
        actual = sut.execute_add()
        assert actual == 9

と、

  • 何らかの処理を行うデコレータを、デコレータの差し替え前に定義
    • 差し替え前に定義しないと「NameError: name ‘dummy_decorator’ is not defined」エラー
  • 何らかの処理を行うデコレータを返すようなlambdaにしてpatch

することで、

$ pytest test_global_patch_replacement.py 
...
test_global_patch_replacement.py ..

...
2 passed in 0.08 seconds

と、何らかの処理を行うデコレータへと差し替わりました。

 

デコレータの差し替え有無が混在する複数のテストファイルを同時に実行する場合

上記では、

  • デコレータを差し替えないテストコード
    • test_target.py
  • デコレータを差し替えるテストコード
    • test_global_patch_count_up.py

の2種類があります。

そこで、2つのテストファイルを同時に実行してみると、

$ pytest test_target.py test_global_patch_count_up.py 
...

test_target.py .....
test_global_patch_count_up.py F

...
self = <test_global_patch_count_up.TestCountUp object at 0x10b4cb710>

    def test_count_up_decorator(self):
        sut = Target()
        actual = sut.execute_count_up()
>       assert actual == 0
E       assert 1 == 0

test_global_patch_count_up.py:18: AssertionError
 1 failed, 5 passed in 0.13 seconds 

テストが失敗します。

テスト結果を見ると、デコレータを差し替えるテストコードにも関わらず、デコレータが差し替わっていないようです。

通常、複数のテストファイルを一括で実行してテストするため、このままでは使い物になりません。

 
そこでもう一度参考にしたブログを読むと、

I hope this silly example convinces you that decorators are applied when the class is created, which happens straight away when we load the module containing the class.

とあります。

ということは、複数のテストコードファイルでプロダクションコードがimportされた時に、デコレータの挙動が固定されてしまうのかなと考えられました。

 
ここで、Pythonでimportする場合、sys.modulesにモジュールに関するエントリが追加されることを思い出しました。

sys.modulesの挙動を詳しく見ると

新しいモジュールがインポートされたため、それらはsys.modules に追加される。これは、なぜ、同じモジュールを二回インポートする場合、それが非常に素早く行われるかを説明している: Python は、それをすでにロードし、sys.modules の中のモジュールとしてキャッシュしているため、二回目のインポートは単なる辞書への参照のみで済む。

6.4. sys.modules を使う - Dive into Python 5.4. (JAPANESE)

とのことです。

これより、importした時にデコレータの挙動が決まり、あとはそれが使い回されるのかなと考えました。

 
そこで、デコレータを差し替えるテストコードの場合のみ、事前にsys.modulesからエントリを削除してみます。

また、デコレータを差し替えない時のimportを優先させるよう、差し替えはテストメソッドの中で行うことにします。

test_method_patch_count_up.py

from unittest.mock import patch
import sys


class TestCountUpUsingWith:
    def test_count_up_decorator(self):
        # すでにimportされていることを考慮し、
        # そのtargetモジュールを無効化するためにsys.modulesより削除する
        # 未importの場合に例外KeyErrorとならないよう、第二引数にNoneを渡しておく
        sys.modules.pop('target', None)

        # 再度importしてパッチ
        with patch('deco.my_decorator.countup', lambda function: function):
            from target import Target

        # 検証
        sut = Target()
        actual = sut.execute_count_up()
        assert actual == 0

        # 使い終わったのでimportを削除
        sys.modules.pop('target')

 
テストしてみます。

$ pytest test_target.py test_method_patch_count_up.py 
...

test_target.py .....
test_method_patch_count_up.py .

6 passed in 0.12 seconds

想定通りの動きとなり、テストをパスしました。

 
なお、複数のテストケースでデコレータの差し替えを行いたい場合は、pytestのsetup/teardownを使い、

test_pytest_setup_patch_count_up.py

from unittest.mock import patch
import sys


class TestCountUpUsingWith:
    # 必要に応じて、クラスレベルやモジュールレベルにしても良い
    def setup_method(self):
        sys.modules.pop('target', None)

    def teardown_method(self):
        sys.modules.pop('target')

    def test_count_up_decorator(self):
        with patch('deco.my_decorator.countup', lambda function: function):
            from target import Target

        sut = Target()
        actual = sut.execute_count_up()
        assert actual == 0

としても

$ pytest test_target.py test_pytest_setup_patch_count_up.py 
...

test_target.py .....
test_pytest_setup_patch_count_up.py .

6 passed in 0.10 seconds

テストをパスします。

 

ソースコード

GitHubにあげました。e.g._mocking_decoratorディレクトリの中が今回のコードです。
thinkAmi-sandbox/python_mock-sample: Python : usage unittest.mock samples

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側で変換する方法があるかもしれませんが、今回は置いておきます

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