pytestのkオプションは、マーカー名でマッチしているものもテスト対象だった

pytestでテストを実行する時、今までは

  • -m オプションは、マーカー(@pytest.marker.xxx)に一致するもの
  • -k オプションは、テストクラス名やテストメソッド名でマッチするもの

を対象のテストコードとして実行するものと考えていました。

 
ただ、最近 -k オプションは、マーカー名でマッチするものもテスト対象になることを知ったため、挙動を調べてみました。

 
目次

 

環境

 

テストコード例

以下のテストコードに対し、 foo という文字列で -m オプションと -k オプションを実行してみます。

import pytest


@pytest.mark.foo
class TestHam:
    """クラスにマーカーあり"""
    def test_spam_method(self):
        assert True

    def test_egg_method(self):
        assert True


class TestHoge:
    @pytest.mark.foo
    def test_spam_method(self):
        """このテストメソッドだけマーカーあり"""
        assert True

    def test_egg_method(self):
        assert True


class TestHogefoo:
    """テストクラス名に foo が含まれる"""
    def test_spam_method(self):
        assert True

    def test_egg_method(self):
        assert True


@pytest.mark.foo
def test_bar_function():
    """テスト関数にマーカーあり"""
    assert True


def test_foo_function():
    """テスト関数に foo が含まれる"""
    assert True


def test_baz_function():
    assert True

 

mオプションの場合

mオプションの場合

  • マーカーがあるテストクラスのテストメソッド:2件
  • テストクラスのうち、マーカーがあるテストメソッド:1件
  • マーカーがあるテスト関数:1件

の合計4テストが実行されると考えていました。

 
実行すると、想定通り、対象は4テストでした。

$ pytest -m foo -v
...
collected 9 items / 5 deselected                                                                                                                                                  

test_k_option_marker.py::TestHam::test_spam_method PASSED
test_k_option_marker.py::TestHam::test_egg_method PASSED
test_k_option_marker.py::TestHoge::test_spam_method PASSED
test_k_option_marker.py::test_bar_function PASSED

==== 4 passed, 5 deselected in 0.03 seconds 

 

kオプションの場合

kオプションの場合、

  • foo が含まれるテストクラスのテストコード:2件
  • foo が含まれるテスト関数:1件

の合計3テストが実行されると考えていました。

 
実行すると、対象は7テストでした。

$ pytest -k foo -v
...
collected 9 items / 2 deselected                                                                                                                                                  

test_k_option_marker.py::TestHam::test_spam_method PASSED
test_k_option_marker.py::TestHam::test_egg_method PASSED
test_k_option_marker.py::TestHoge::test_spam_method PASSED
test_k_option_marker.py::TestHogefoo::test_spam_method PASSED
test_k_option_marker.py::TestHogefoo::test_egg_method PASSED
test_k_option_marker.py::test_bar_function PASSED
test_k_option_marker.py::test_foo_function PASSED

==== 7 passed, 2 deselected in 0.05 seconds

 
よく見ると、mオプションで対象だった

  • マーカーがあるテストクラスのテストメソッド:2件
    • test_k_option_marker.py::TestHam::test_spam_method
    • test_k_option_marker.py::TestHam::test_egg_method
  • テストクラスのうち、マーカーがあるテストメソッド:1件
    • test_k_option_marker.py::TestHoge::test_spam_method
  • マーカーがあるテスト関数:1件
    • test_k_option_marker.py::test_bar_function

も実行されていました。

 
なお、kオプションは部分一致なので、 -k oo としても同じ結果になりました。

$ pytest -k oo -v
...
==== 7 passed, 2 deselected

 
ドキュメントでは -k オプションがマーカーも対象にしているとは明示されていなかったため、新たな発見でした。

 

ソースコード

GitHubにあげました。 e.g._k_option_and_marker ディレクトリ以下が、今回のテストコードです。
https://github.com/thinkAmi-sandbox/python_pytest-sample

Werkzeugでレスポンスボディを後から差し替えてみた

Werkzeugでは werkzeug.wrappers.Response を使ってレスポンスオブジェクトを生成する際、 Response('Hello world') のようにコンストラクタの引数にレスポンスボディを渡します。

ただ、Responseオブジェクト生成後にレスポンスボディを修正する方法を知りたかったため、いろいろ試した時のメモです。

 
目次

 

環境

  • Python 3.6.6
  • Werkzeug 0.14.1

 

コンストラクタでレスポンスボディを指定

以下のWerkzeugアプリの中の index_handler() メソッドにて、コンストラクタでレスポンスボディを指定しています。

ルーティングにいろいろと書かれていますが、後から追加しますので、今は置いておきます。

from werkzeug.exceptions import HTTPException
from werkzeug.routing import Map, Rule
from werkzeug.wrappers import Request, Response


class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/', endpoint='index', methods=['GET']),
            Rule('/response', endpoint='response', methods=['GET']),
            Rule('/data', endpoint='data', methods=['GET']),
            Rule('/set_data', endpoint='set_data', methods=['GET']),
        ])

    def dispatch_request(self, request):
        adapter = self.url_map.bind_to_environ(request.environ)
        try:
            endpoint, values = adapter.match()
            return getattr(self, f'{endpoint}_handler')(request, **values)
        except HTTPException as e:
            return e

    def index_handler(self, request):
        return Response('Hello world')

    def wsgi_app(self, environ, start_response):
        request = Request(environ)
        response = self.dispatch_request(request)
        return response(environ, start_response)

    def __call__(self, environ, start_response):
        return self.wsgi_app(environ, start_response)


if __name__ == '__main__':
    from werkzeug.serving import run_simple
    app = Application()
    run_simple('0.0.0.0', 5000, app, use_debugger=True, use_reloader=True)

 
起動して localhost:5000/curlでアクセスすると

$ curl --include http://localhost:5000/
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 12
Server: Werkzeug/0.14.1 Python/3.6.6
Date: Mon, 15 Oct 2018 13:19:56 GMT

Hello world

Hello worldが返ってきます。

 

response属性で差し替えるのはNG

Werkzeugのドキュメントを見ると response 属性があります。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.response

これを

def response_handler(self, request):
    response = Response('Hello world\n')
    response.response = 'update response body\n'
    return response

のようにして使えば、一見レスポンスを差し替えられそうです。

ただ、実際には

$ curl --include http://localhost:5000/response
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 12
Server: Werkzeug/0.14.1 Python/3.6.6
Date: Mon, 15 Oct 2018 13:22:44 GMT

update respo

と、Content-LengthヘッダがHello worldの時に計算した時のままなため、レスポンスボディが欠けています。

また、サーバのログを見ると

Warning: response iterable was set to a string.  This appears to work but means that the server will send the data to the client char, by char.  This is almost never intended behavior, use response.data to assign strings to the response object.
  _warn_if_string(self.response)

が残されており、response属性を使うのはダメそうです。

 

dataプロパティも不適切

次に、エラーメッセージにある data プロパティを使ってみます。

def data_handler(self, request):
    response = Response('Hello world\n')
    response.data = 'update response body\n'
    return response

 
curlでアクセスしてみます。

$ curl --include http://localhost:5000/data
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 21
Server: Werkzeug/0.14.1 Python/3.6.6
Date: Mon, 15 Oct 2018 13:25:52 GMT

update response body

Content-Lengthが再計算され、レスポンスボディの欠けもありません。

また、サーバのログにも先ほどのエラーは出力されていません。

 
ただ、ドキュメントを見ると、

A descriptor that calls get_data() and set_data(). This should not be used and will eventually get deprecated.

http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.data

とあるため、dataプロパティを使うのもよくなさそうです。

 

set_data() メソッドを使うのが良さそう

最後に set_data() メソッドを使います。

ドキュメントにも

Sets a new string as response. The value set must either by a unicode or bytestring. If a unicode string is set it’s encoded automatically to the charset of the response (utf-8 by default).

http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.set_data

と書かれており、これを使うのが良さそうです。

def set_data_handler(self, request):
    response = Response('Hello world\n')
    response.set_data('update response body\n')
    return response

と実装します。

curlでアクセスしてみます。

$ curl --include http://localhost:5000/set_data
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 21
Server: Werkzeug/0.14.1 Python/3.6.6
Date: Mon, 15 Oct 2018 13:30:48 GMT

update response body

とレスポンスが返り、サーバのログにもエラーが出ていませんでした。

 

ソースコード

GitHubに上げました。 response_body ディレクトリ以下が今回のコードです。
werkzeug-sample/response_body at master · thinkAmi-sandbox/werkzeug-sample

RedisをJSONに差し替えて、Werkzeugの公式チュートリアルを写経してみた

前回・前々回と、Werkzeugをさわってみました。

 
今回は、公式のチュートリアルを写経した時のメモを残します。

公式チュートリアル
Werkzeug Tutorial — Werkzeug Documentation (0.14)

 
目次

 

環境

  • Python 3.6.6
  • Werkzeug 0.14.1

 

感想

WerkzeugにはWSGIまわりのいろいろな機能がありますが、それを組み合わせてみようと思った時、どう組み合わせるのが良いか分かりませんでした。

そんな中、公式チュートリアルでは

などが一通り書かれていて、とても参考になりました。

 
ただ、少し敷居が高いと感じたのは、データストアとしてRedisを使っている部分でした。

Redisをインストールしてもよかったのですが、

と感じました。

 
チュートリアルでのRedisの使われ方を見ると、Pythonのdictでも代替できそうだったので

のどちらかで書こうと考えました。

そこで今回は、データがそのまま見えるJSONで書いてみました。

 
JSONを使った場合のソースコードは、最終的にはこんな感じになりました。

import json
import pathlib
import urllib.parse
from datetime import datetime

from jinja2 import Environment, FileSystemLoader
from werkzeug.exceptions import HTTPException, NotFound
from werkzeug.routing import Map, Rule
from werkzeug.utils import redirect
from werkzeug.wrappers import Request, Response
from werkzeug.wsgi import SharedDataMiddleware


class Shortly:
    """WSGIアプリケーション

    公式チュートリアルではRedisを使っていたが、ここではJSONで代用
    """
    JSON_PATH = pathlib.Path('./shortly.json')

    def __init__(self):
        template_path = pathlib.Path('./templates')
        self.jinja_env = Environment(loader=FileSystemLoader(str(template_path)),
                                     autoescape=True)

        # ルーティング
        self.url_map = Map([
            # endpointの方は、URLの逆引きの時に使う:チュートリアルでは取り扱わない
            Rule('/', endpoint='new_url'),
            Rule('/<short_id>', endpoint='follow_short_link'),
            Rule('/<short_id>+', endpoint='short_link_details'),
        ])

    def dispatch_request(self, request):
        adapter = self.url_map.bind_to_environ(request.environ)
        try:
            endpoint, values = adapter.match()
            # このチュートリアルでは、on_<endpoint>という名称のメソッドを呼び出す
            return getattr(self, f'on_{endpoint}')(request, **values)
        except HTTPException as e:
            # except HTTPException, e はPython2の古い書き方
            return e

    def render_template(self, template_name, **context):
        t = self.jinja_env.get_template(template_name)
        return Response(t.render(context), mimetype='text/html')

    def insert_url(self, url):
        json_data = {}
        if self.JSON_PATH.exists():
            with self.JSON_PATH.open('r') as f:
                json_data = json.load(f)
            short_id = json_data.get(f'reverse-url:{url}')
            if short_id is not None:
                return short_id

        now = datetime.now()
        stamp = datetime.strftime(now, '%Y%m%d%H%M%S')
        short_id = base36_encode(int(stamp))
        json_data[f'url-target:{short_id}'] = url
        json_data[f'reverse-url:{url}'] = short_id
        with self.JSON_PATH.open('w') as f:
            json.dump(json_data, f)
        return short_id

    def on_new_url(self, request):
        error = None
        url = ''
        if request.method == 'POST':
            url = request.form['url']
            if not is_valid_url(url):
                error = 'Please enter a valid URL'
            else:
                short_id = self.insert_url(url)
                return redirect(f'/{short_id}+')
        return self.render_template('new_url.html', error=error, url=url)

    def on_follow_short_link(self, request, short_id):
        with self.JSON_PATH.open('r') as f:
            json_data = json.load(f)
        link_target = json_data.get(f'url-target:{short_id}')
        if link_target is None:
            raise NotFound()
        return redirect(link_target)

    def on_short_link_details(self, request, short_id):
        with self.JSON_PATH.open('r') as f:
            json_data = json.load(f)
        link_target = json_data.get(f'url-target:{short_id}')
        if link_target is None:
            raise NotFound()
        return self.render_template('short_link_details.html',
                                    link_target=link_target,
                                    short_id=short_id,
                                    )

    def wsgi_app(self, environ, start_response):
        request = Request(environ)
        response = self.dispatch_request(request)
        return response(environ, start_response)

    def __call__(self, environ, start_response):
        """WSGIアプリを直接dispatchすることで、wsgi_app()をWSGIミドルウェアっぽく使える"""
        return self.wsgi_app(environ, start_response)


def is_valid_url(url):
    parts = urllib.parse.urlparse(url)
    return parts.scheme in ('http', 'https')


def base36_encode(number):
    assert number >= 0, 'positive integer required'
    if number == 0:
        return '0'
    base36 = []
    while number != 0:
        number, i = divmod(number, 36)
        base36.append('0123456789abcdefghijklmnopqrstuvwxyz'[i])
    return ''.join(reversed(base36))


def create_app(with_static=True):
    app = Shortly()

    if with_static:
        app.wsgi_app = SharedDataMiddleware(
            app.wsgi_app,
            {'/static': str(pathlib.Path('./static'))}
        )
    return app


if __name__ == '__main__':
    from werkzeug.serving import run_simple
    a = create_app()
    run_simple('127.0.0.1', 5000, a, use_debugger=True, use_reloader=True)

 

チュートリアル

Werkzeugのチュートリアルを写経した後ですが、GitHubリポジトリを見るといくつかサンプルが入っていました。
https://github.com/pallets/werkzeug/tree/master/examples

このあたりを眺めれば、他の使用方法がいろいろと分かりそうでした。

 

ソースコード

GitHubに上げました。以下のディレクトリの中に写経したときのものがいろいろと入っています。
syakyo/official_werkzeug at master · thinkAmi-sandbox/syakyo

WerkzeugでJSON等のいろいろなレスポンスを作ってみた

前回、Werkzeugでいろいろと試してみました。
Werkzeugでリクエスト・レスポンス・Cookieを試してみた - メモ的な思考的な

 
今回は、WerkzeugでいろいろなHTTPレスポンスを作ってみました。

 
目次

 

環境

  • Python 3.6.6
  • Werkzeug 0.14.1

 
なお、アプリの構成は前回作成した BaseStructure クラスをベースにしています。

 

特定のHTTPメソッドのみレスポンスを許可

Rule オブジェクトを生成する時の引数 methods にHTTPメソッドを指定することで、そのHTTPメソッドのみレスポンスを返せます。
http://werkzeug.pocoo.org/docs/0.14/routing/#werkzeug.routing.Rule

 

class Application:
    def __init__(self):
        self.url_map = Map([
            ...
            Rule('/get-only', endpoint='get_only', methods=['GET']),
            Rule('/post-only', endpoint='post_only', methods=['POST']),
        ])

    def get_only_handler(self, request):
        return Response('GET Only!\n')

    def post_only_handler(self, request):
        return Response(f'POST Only: {request.form.get("foo")}\n')

 
curlで動作確認します。

GETでアクセスした場合です。

# GETだけレスポンス可能なURL
$ curl --include localhost:5000/get-only
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
...
GET Only!

# POSTだけレスポンス可能なURL
$ curl --include 'localhost:5000/post-only'
HTTP/1.0 405 METHOD NOT ALLOWED
Content-Type: text/html
Allow: POST

 
POSTでアクセスした場合です。

# GETだけレスポンス可能なURL
$ curl -w '\n' --include -X POST 'localhost:5000/get-only' --data 'foo=1'
HTTP/1.0 405 METHOD NOT ALLOWED
Content-Type: text/html
Allow: HEAD, GET

# POSTだけレスポンス可能なURL
$ curl -w '\n' --include -X POST 'localhost:5000/post-only' --data 'foo=1'
HTTP/1.0 200 OK
Content-Type: text/plain; charset=utf-8
...
POST Only: 1

 

JSONレスポンス

レスポンスオブジェクトの content_type引数に application/json をセットすることで、JSONをレスポンスできます。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/json', endpoint='json'),
        ])

    def json_handler(self, request):
        input_data = request.form.get('input')
        result = {
            'foo': 'abc',
            'bar': ['ham', 'spam', 'egg'],
            'result': input_data,
        }
        return Response(json.dumps(result), content_type='application/json')

 
では実際に、データをPOSTしてJSONAjaxで取得・表示するHTMLを用意して試してみます。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>JSON response</title>
</head>
<body>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<p>
    <label for="id_input">POST data: </label>
    <input id="id_input" type="text">
</p>
<button id="id_get_json">Go</button>
<p>foo: <span id="id_foo"></span></p>
bar:
<ul id="id_bar">
</ul>
<p>input data: <span id="id_input_result"></span></p>
<script>
    $(function () {
        $('#id_get_json').on('click', function () {
            $.ajax({
                url: '/json',
                type: 'POST',
                dataType: 'json',
                data: {'input': $('#id_input').val()}
            }).done((data) => {
                $('#id_foo').html(data['foo']);
                data['bar'].forEach((currentValue, index) => {
                    $('#id_bar').append(`<li>No. ${index}: ${currentValue} </li>`)
                });
                $('#id_input_result').html(data['result']);
            }).fail((data) => { console.log('fail!') })
        })
    })
</script>
</body>
</html>

 
ブラウザで見るとこのような感じです。

f:id:thinkAmi:20181002215447p:plain:w200

データを入力し、GoボタンでAjaxのPOSTを実行すると、以下のように表示が切り替わります。

f:id:thinkAmi:20181002215457p:plain:w200

 

ファイルをアップロード

ファイルをアップロードするHTMLフォームがあるとします。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>File Upload</title>
</head>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
    <p><input type="file" name="upload_file"></p>
    <p><input type="submit"></p>
</form>
</body>
</html>

 
Werkzeugでは、Requestオブジェクトの files を使うことで、アップロードされたファイルを取得できます。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.files

以下のコードでは、アップロードされたファイルを uploads ディレクトリに保存します。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/upload', endpoint='upload'),
        ])

    def upload_handler(self, request):
        f = request.files.get('upload_file')
        f.save(str(pathlib.Path(f'./uploads/{f.filename}')))
        return Response('hello upload')

 

ファイルをダウンロード

ファイルをダウンロードするには、 Content-Disposition ヘッダを追加します。

以下ではURLにアクセスすると foo.csv がダウンロードされます。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/download', endpoint='download'),
        ])

    def download_handler(self, request):
        field_names = ['No', 'Name']
        contents = [
            {'No': 1, 'Name': 'Apple'},
            {'No': 2, 'Name': 'Mandarin'},
            {'No': 3, 'Name': 'Grape'},
        ]
        stream = StringIO()
        writer = csv.DictWriter(stream, fieldnames=field_names)

        # CSVヘッダの書込
        writer.writeheader()
        # CSVデータの書込
        writer.writerows(contents)

        # ストリームからデータを取得し、レスポンスとする
        data = stream.getvalue()

        headers = Headers()
        headers.add('Content-Disposition', 'attachment', filename='foo.csv')

        return Response(
            data,
            headers=headers,
        )

参考

 
ただし、上記の方法では日本語名のファイルはダウンロードできません。

日本語ファイルをダウンロードする場合は、

  • Content-Dispositionヘッダで、 filename*=アスタリスク付きでファイル名を指定する
  • ファイル名はパーセントエンコードする
    • RFC5987に基づく方法

が必要になります。

参考

 
なお、以前、Cookieを調べた時に、いろいろなパーセントエンコードについて知りました。
Pythonで、日本語をCookie値へ設定する方法を調べてみた - メモ的な思考的な

RFC5987でのパーセントエンコードを見たところ、

pct-encoded   = "%" HEXDIG HEXDIG
              ; see [RFC3986], Section 2.1

# https://tools.ietf.org/html/rfc5987#page-3

との記述がありました。

RFC3986についてはCookieの時に調べました。RFC3986でのパーセントエンコードPythonで実現するには、 urllib.parse.quote(character, safe='~') が良さそうでした。

 
そこで、

encoded_filename = quote(request.form['filename'], safe='~')
headers.add('Content-Disposition', f"attachment; filename*=UTF-8''{encoded_filename}")

として 一~二.csv というファイル名でダウンロードしてみたところ、

f:id:thinkAmi:20181002222338p:plain:w200

というファイル名でダウンロードされました。

~ という文字がファイル名に使えなかったため、 _ へと変換されたのでしょう。
ファイルをダウンロードさせたいときのいろんな方法(Servlet, Apacheなど) - Qiita

 

静的ファイルっぽく見せかけた時のレスポンス

これはどちらかというとルーティングについてです。

以下は拡張子 html 付きでアクセスした場合でも、動的なレスポンスを返すようなルーティングとなっています。

class Application:
    def __init__(self):
        self.url_map = Map([
            Rule('/extension.html', endpoint='extension'),
        ])

    def extension_handler(self, request):
        return Response('extension request')

 
curlで調べてみます。

$ curl localhost:5000/extension.html
extension request

 

ソースコード

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

various_responses ディレクトリ以下が今回のものであり、Werkzeugアプリは various_response_app.py になります。

Werkzeugでリクエスト・レスポンス・Cookieを試してみた

Flaskでも使われているライブラリ Werkzeug でいろいろと試した時のメモです。

 
公式ドキュメントはこちらです。以下のサンプルでも公式ドキュメントへのリンクを記載しています。
http://werkzeug.pocoo.org/docs/0.14/

 
なお、curlでの動作検証時、以下が参考になりました。
curl コマンド 使い方メモ - Qiita

 
目次

 

環境

  • Python 3.6.6
  • Werkzeug 0.14.1

 

Werkzeugを使ったアプリの構成

今回作成するWerkzeugを使ったアプリは、Werkzeugのチュートリアル Step 2: The Base Structure をベースに、 app.py というファイルに追加していきます。
http://werkzeug.pocoo.org/docs/0.14/tutorial/#step-2-the-base-structure

import os

from werkzeug.wrappers import Request, Response
from werkzeug.wsgi import SharedDataMiddleware


class BaseStructure:
    """Werkzeugのチュートリアルにあった、Werkzeugアプリの基本的な構成
    http://werkzeug.pocoo.org/docs/0.14/tutorial/#step-2-the-base-structure
    """
    def dispatch_request(self, request):
        return Response('Hello world!')

    def wsgi_app(self, environ, start_response):
        request = Request(environ)
        response = self.dispatch_request(request)
        return response(environ, start_response)

    def __call__(self, environ, start_response):
        """WSGIアプリを直接dispatchすることで、wsgi_app()をWSGIミドルウェアっぽく使える"""
        return self.wsgi_app(environ, start_response)


def create_app(with_static=True):
    application = BaseStructure()

    if with_static:
        application.wsgi_app = SharedDataMiddleware(
            application.wsgi_app,
            {'/static': os.path.join(os.path.dirname(__file__), 'static')}
        )
    return application


if __name__ == '__main__':
    from werkzeug.serving import run_simple
    app = create_app()
    run_simple('127.0.0.1', 5000, app, use_debugger=True, use_reloader=True)

 
チュートリアルでは、dispatch_request() にルーティングを記載していました。

ただ、今回はルーティングについてはふれないものの、 dispatch_request() にいろいろと実装してきます。

 

HTTPリクエス

WSGIアプリケーションの引数 environwerkzeug.wrappers.Request に渡してインスタンス化することで、HTTPリクエストデータが扱いやすくなります。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest

request = Request(environ)

 

HTTPメソッド

request.method で取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.method

body.append(f'HTTP method: {request.method}')
# => GET

 

HTTPヘッダ

request.headers で取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.headers

for k, v in request.headers.items():
    body.append(f'Request header: key:{k} / value: {v}')
    # => Request header: key:Host / value: localhost:5000 ...

 

クライアントのIPアドレスなど

request.access_routerequest.remote_addr で取得します。

body.append(f'access_route: {request.access_route}')
# => access_route: ImmutableList(['127.0.0.1'])

body.append(f'remote_addr: {request.remote_addr}')
# => remote_addr: 127.0.0.1

 

クエリストリングの取得

request.argsrequest.values で取得します。

POSTの時にもクエリストリングの値を取得できます。

# request.argsを使う場合
body.append(f'Query String: {request.args}')
# => [GET] $ curl http://localhost:5000?foo=bar の場合
#    ImmutableMultiDict([('foo', 'bar')])
# => [POST] $ curl -w '\n' -X POST 'localhost:5000/?ham=spam' --data 'foo=1&bar=2' の場合
#    ImmutableMultiDict([('ham', 'spam')])

# request.valuesを使う場合
body.append(f'request.values: {request.values}')
# => [GET] $ curl http://localhost:5000?foo=bar の場合
#    CombinedMultiDict([ImmutableMultiDict([('foo', 'bar')]),
#                       ImmutableMultiDict([])

 
なお、POSTの時にもクエリストリングがあるのかどうかは、RFC的な規定は見つけられませんでした。ご存知の方は教えていただけますとありがたいです。
参考:http - Is it valid to combine a form POST with a query string? - Stack Overflow

 

POSTデータの取得

request.formrequest.values で取得します。

なお、GETの時は、request.formは ImmutableMultiDict([]) になるようです。

また、POSTの場合でクエリストリング & POSTデータがある場合は、CombinedMultiDict内のImmutableMultiDictに、両方のエントリが含まれます。

順番は、クエリストリング > POSTデータ、で良さそうです。

# request.formを使う場合
body.append(f'Form: {request.form}')
# => [GET] $ curl http://localhost:5000?foo=bar の場合
#    ImmutableMultiDict([])
# => [POST] $ curl -w '\n' -X POST 'localhost:5000/?ham=spam' --data 'foo=1&bar=2' の場合
#    ImmutableMultiDict([('foo', '1'), ('bar', '2')])

# request.valuesを使う場合
body.append(f'request.values: {request.values}')
# => [POST] $ curl -w '\n' -X POST 'localhost:5000/?ham=spam' --data 'foo=1&bar=2' の場合
#    CombinedMultiDict([ImmutableMultiDict([('ham', 'spam')]),
#                       ImmutableMultiDict([('foo', '1'), ('bar', '2')])
#                     ])

 

WSGI/CGI環境変数の取得

request.environ で取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.environ

結果は dict 型であり、この中に WSGI/CGI の両方の環境変数が含まれるようです。

body.append(f'environ: {type(request.environ)} / {request.environ}')
# => environ: <class 'dict'> / {'wsgi.version': (1, 0), 'wsgi.url_scheme': 'http', 'wsgi.input': <_io.BufferedReader name=6>, 'wsgi.errors': <_io.TextIOWrapper name='<stderr>' mode='w' encoding='UTF-8'>, 'wsgi.multithread': False, 'wsgi.multiprocess': False, 'wsgi.run_once': False, 'werkzeug.server.shutdown': <function WSGIRequestHandler.make_environ.<locals>.shutdown_server at 0x105f13c80>, 'SERVER_SOFTWARE': 'Werkzeug/0.14.1', 'REQUEST_METHOD': 'GET', 'SCRIPT_NAME': '', 'PATH_INFO': '/', 'QUERY_STRING': 'foo=bar', 'REMOTE_ADDR': '127.0.0.1', 'REMOTE_PORT': 55330, 'SERVER_NAME': '0.0.0.0', 'SERVER_PORT': '5000', 'SERVER_PROTOCOL': 'HTTP/1.1', 'HTTP_HOST': 'localhost:5000', 'HTTP_CONNECTION': 'keep-alive', 'HTTP_PRAGMA': 'no-cache', 'HTTP_CACHE_CONTROL': 'no-cache', '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/69.0.3497.100 Safari/537.36', 'HTTP_ACCEPT': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', 'HTTP_ACCEPT_ENCODING': 'gzip, deflate, br', 'HTTP_ACCEPT_LANGUAGE': 'ja,en-US;q=0.9,en;q=0.8', 'HTTP_COOKIE': 'one_time=x; counter=8', 'werkzeug.request': <Request 'http://localhost:5000/?foo=bar' [GET]>}

 

HTTPレスポンス

werkzeug.wrappers.Response を使うことで、HTTPレスポンスデータとなります。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse

# msgは、HTTPレスポンスボディ
response = Response(msg)

 

Content-Typeをセット

response.content_type を使います。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.CommonRequestDescriptorsMixin.content_type

response.content_type = 'application/json'

 

独自HTTPヘッダをセット

独自HTTPヘッダをセットするには

の3つが使えます。

response.headers.add('X-headers-add', 'using add')
response.headers.add_header('X-headers-add_header', 'using add_header')
response.headers['X-headers-key'] = 'using key'
# => X-headers-add: using add
#    X-headers-add_header: using add_header
#    X-headers-key: using key

 

エラーページを表示

abort()raise InternalServerError() などが使えます。

 
なお、例外クラスを継承し、エラーページを差し替えることもできます。
http://werkzeug.pocoo.org/docs/0.14/exceptions/#custom-errors

以下の例では、InternalServerErrorを継承し、HTTP500エラー時の画面の表示を差し替えています。

class MyInternalServerError(InternalServerError):
    def get_body(self, environ=None):
        # werkzeug._compat.text_type()がprotectedなので、使っていいものか...
        return text_type(
            u'<!DOCTYPE html>'
            u'<title>My Internal Server Error</title>'
            u'<h1>Oh, my internal server error!</h1>'
        )

 

リダイレクト

werkzeug.utils.redirect() が使えます。
http://werkzeug.pocoo.org/docs/0.14/utils/#werkzeug.utils.redirect

return redirect('https://www.google.co.jp')

 
なお、URLに / を追加するリダイレクトのための werkzeug.utils.append_slash_redirect も用意されています。
http://werkzeug.pocoo.org/docs/0.14/utils/#werkzeug.utils.append_slash_redirect

 

Werkzeugではリクエスト時のCookieとレスポンス時のCookieが分かれています。

 

リクエスCookieの取得

request.cookies ですべてのCookieを取得します。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.cookies

Cookieのキーを指定して値を取得するには、以下となります。

counter = request.cookies.get('counter', 0)

 

レスポンスCookieの設定

レスポンスオブジェクトを生成した後、 response.set_cookie() にてCookieをセットします。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.set_cookie

response = Response(msg)
response.set_cookie('counter', str(int(counter) + 1))

 
なお、 response.delete_cookie()Cookieを削除できます。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseResponse.delete_cookie

delete_cookie() を呼ぶと

Set-Cookie: one_time=; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/

のようなHTTPレスポンスヘッダが追加されます。

Cookieを削除しない限り、リクエストオブジェクトからレスポンスオブジェクトにCookieを移し替えなくても、次回のリクエスト時も同じキー・値でCookieがやってきます。

 

同じCookieキー名での設定に注意

例えば、

response.set_cookie('same_cookie', '1st', httponly=True)
response.set_cookie('same_cookie', '2nd', secure=True)

のように、同じCookieキー名 (same_cookie) でCookieをセットしたとします。

 
この場合、HTTPレスポンスヘッダは

Set-Cookie: same_cookie=1st; HttpOnly; Path=/
Set-Cookie: same_cookie=2nd; Secure; Path=/

となります。

ただし、リロードするなどして再度アクセスすると、HTTPリクエスト時のCookieヘッダは

Cookie: one_time=x; same_cookie=1st; counter=2

と、片方のものしかセットされていません。

 
CookieのRFC6265を見ると、

(原文)

Servers SHOULD NOT include more than one Set-Cookie header field in the same response with the same cookie-name. (See Section 5.2 for how user agents handle this case.)

https://tools.ietf.org/html/rfc6265#section-4.1

(日本語訳)

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

https://triple-underscore.github.io/http-cookie-ja.html#section-4.1

とあります。

これらより、同じCookieキー名を設定すべきではなく、挙動もおかしくなるため、注意が必要です。

 

その他

開発WSGIサーバを外部アクセス可能にする

werkzeug.serving.run_simple() で開発WSGIサーバを起動できます。
http://werkzeug.pocoo.org/docs/0.14/serving/#werkzeug.serving.run_simple

なお、Flask同様、引数hostに 0.0.0.0 を指定することで、開発WSGIサーバを外部アクセス可能にします。
Flaskのサーバーはデフォルトだと公開されてない - Qiita

 

favicon.ico のリクエストを処理する

Cookie

counter = request.cookies.get('counter', 0)
response.set_cookie('counter', str(int(counter) + 1))

と実装し、ブラウザで表示した後、リロードしたところ、HTTPレスポンスヘッダが

Set-Cookie: counter=3; Path=/

となっていました。counter=2を想定していたのに、counter=3となっていました。

 
原因は、ブラウザでアクセスした時、1回で

の2リクエストが飛んでいたせいでした。

今回のアプリはルーティングがないため、favicon.icoのリクエストでも dispatch_request() が処理されてしまったようです。

 
対応としては、開発環境の場合は werkzeug.wsgi.SharedDataMiddleware が使えます *1

create_app()関数の中で、SharedDataMiddlewareを使います。

なお、Werkzeugのチュートリアルなどではディレクトリを指定していますが、以下のようにファイル自体を指定することも可能です。

def create_app(with_static=True):
    application = Application()

    # WSGIミドルウェアの設定ポイント
    if with_static:
        application.wsgi_app = SharedDataMiddleware(
            application.wsgi_app,
            {'/favicon.ico': str(pathlib.Path('./favicon.ico'))}
        )
    return application

 
次に、 app.py と同じディレクトリの中に favicon.ico を用意します。

以上で、favicon.icoへのアクセスはSharedDataMiddlewareにより処理され、dispatch_request()では処理されなくなります。

 

ソースコード

GitHubに上げました。base_structure ディレクトリ以下が今回のファイルです。
thinkAmi-sandbox/werkzeug-sample

*1:本番環境では何らかの形で静的ファイルを配信するはず

Ubuntu18.04 + mod_python 3.3.1で、リクエスト・レスポンス・Cookieを試してみた

Ubuntu 18.04 に mod_python をセットアップし、いろいろと試した時のメモです。
mod_python - Apache / Python Integration

なお、mod_python

なことに注意します。

 
ドキュメントは以下を参考にしました。

 

目次

 

環境

 
なお、今回のmod_python のコード例は、汎用ハンドラ(generic handler)版で書いています。publisherハンドラ版を使う場合は、記述を省略できるかもしれません。

 
また、ディレクトリ構造などは以下です。

my@my-virtual-machine:/var/www$ tree
.
├── html
│   └── index.html  # デフォルトのファイル
└── mptest
    └── mp
        ├── form.html
        ├── generic_handler.py
        ├── publisher_handler.py
        └── template.html


my@my-virtual-machine:/var/www/mptest/mp$ ls -al
合計 24
drwxr-xr-x 2 root root 4096  9月 24 09:06 .
drwxr-xr-x 3 root root 4096  9月 24 06:26 ..
-rw-r--r-- 1 root root 1238  9月 24 08:11 form.html
-rw-r--r-- 1 root root 3125  9月 24 08:59 generic_handler.py
-rw-r--r-- 1 root root  215  9月 24 06:37 publisher_handler.py
-rw-r--r-- 1 root root  232  9月 24 08:36 template.html

 

mod_pythonHello world する (publisher/汎用ハンドラ)

Ubuntu 18.04 に mod_python をセットアップし、 Hello world するまでの流れです。

以下を参考に、Apacheのセットアップを行っていきます。

なお、Apacheの設定は「動けばいい」くらいの適当さです。

 
Macからsshできるようにします。今回、最小パッケージでインストールしたため、openssh-serverをインストールします。

$ sudo apt install openssh-server

 
Pythonのバージョンを確認したところ、Python自体がなかったため、追加で Python2.7 をインストールします。

# Pythonのバージョン確認
$ python --version

Command 'python' not found, but can be installed with:

sudo apt install python3       
sudo apt install python        
sudo apt install python-minimal

You also have python3 installed, you can run 'python3' instead.

# Python2.7系をインストール
my@my-virtual-machine:~$ sudo apt install python

$ python --version
Python 2.7.15rc1

# Pythonの場所を確認
$ which python
/usr/bin/python

 
mod_python開発のため、PyCharm Communityをインストールします。

PyCharmのドキュメントに従い、snapパッケージでPyCharmをインストールします。

# snapパッケージを使うため、snapdをインストール
$ sudo apt install -y snapd

$ sudo snap install pycharm-community --classic
pycharm-community 2018.2.4 from 'jetbrains' installed

 
PyCharm Communityを起動します。

$ pycharm-community

 
プロジェクトを作成、システムのPythonを認識させておきます。

 
以下を参考に、Apache2とmod_pythonをインストールします。
How to install and configure mod_python in apache 2 server | BHOU STUDIO

$ sudo apt install apache2 libapache2-mod-python

 
ApacheのMPMを確認します。

$ apachectl -V
Server version: Apache/2.4.29 (Ubuntu)
...
Server MPM:     event
  threaded:     yes (fixed thread count)
    forked:     yes (variable process count)

 
event MPMでも動くと思いますが、とりあえず prefork MPM に変えてみます。

# mpm_eventを無効化
$ sudo a2dismod mpm_event

# mpm_preforkを有効化
$ sudo a2enmod mpm_prefork

# Apacheの再起動
$ sudo systemctl restart apache2

# 確認
$ apachectl -V
...
Server MPM:     prefork
  threaded:     no
    forked:     yes (variable process count)

 
Apachemod_pythonの設定を追加します。

$ cd /etc/apache2/sites-available/
$ sudo vi mod_python.conf

 
publisherハンドラを使うように設定します。

mod_python.conf

<VirtualHost *:80>
  ServerName example.com

  DocumentRoot /var/www/mptest/

  <Directory "/var/www/mptest/mp">
    AddHandler mod_python py
    PythonHandler mod_python.publisher
    PythonDebug On
  </Directory>
</VirtualHost>

 
mod_python.conf を有効化するとともに、デフォルトを無効化します。

# mod_pythonを有効化
$ sudo a2ensite mod_python.conf
Enabling site mod_python.
To activate the new configuration, you need to run:
  systemctl reload apache2

# デフォルトを無効化
$ sudo a2dissite 000-default.conf

 
Apacheをリロードします。

$ sudo systemctl reload apache2

 
実際のpublisher_handlerを作成します。

$ cd ./mptest/mp
$ sudo vi publisher_handler.py

 
中身は以下のとおりです。

publisher_handler.py

# /usr/bin/python
# -*- codeing: utf-8 -*-


def index(req):
    return 'publisher'

def hello(req):
    return 'Hello world'

 
念のため、Apacheを再起動します。

sudo systemctl restart apache2

 
動作確認です。まずは ip address show にて、UbuntuIPアドレスを確認しておきます。

$ ip address show
2: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 00:0c:29:5b:ce:9f brd ff:ff:ff:ff:ff:ff
    inet 192.168.69.155/24 brd 192.168.69.255 scope global dynamic noprefixroute ens33
       valid_lft 1717sec preferred_lft 1717sec
    inet6 fe80::f391:9b22:de93:f759/64 scope link noprefixroute 
       valid_lft forever preferred_lft forever

 
別のターミナルを開いて動作確認します。publisherハンドラの動作確認ができました。

$ curl http://192.168.69.156/mp/publisher_handler.py
publisher

$ curl http://192.168.69.156/mp/publisher_handler.py/index
publisher

$ curl http://192.168.69.156/mp/publisher_handler.py/hello
Hello world

 
なお、mod_pythonのハンドラは他にもあります。

 
そこで次は、汎用ハンドラ(generic handler)を使ってみます。

汎用ハンドラ用のPythonスクリプトを作成します。

generic_handler.py

# /usr/bin/python
# -*- codeing: utf-8 -*-
from mod_python import apache


def handler(req):
    req.write('Hello world')
    return apache.OK

 
mod_pythonの設定を変更します。

$ sudo vi /etc/apache2/sites-available/mod_python.conf

 
変更内容は

とします。

mod_python.conf

<VirtualHost *:80>
  ServerName example.com

  DocumentRoot /var/www/mptest/

  <Directory "/var/www/mptest/mp">
    AddHandler mod_python py

    # for generic handler
    PythonHandler generic_handler
    # for publisher handler
    # PythonHandler mod_python.publisher

    PythonDebug On
  </Directory>
</VirtualHost>

 
Apacheを再起動します。

$ sudo systemctl restart apache2

 
別のターミナルで確認します。汎用ハンドラでも動作確認できました。

$ curl http://192.168.69.156/mp/generic_handler.py
Hello world

 
引き続き、汎用ハンドラ使用時のリクエストとレスポンスをみていきます。

 

汎用ハンドラ使用時のリクエストとレスポンス

上記で見た通り、汎用ハンドラは

  • handler() 関数を作成
    • 引数として request オブジェクト (以降 req )が渡される
  • レスポンスボディは、 req.write() を使う
  • 処理を終了する時は、 apache.OK などを使う

な実装です。

 
また、 mod_python では、レスポンスは req オブジェクトに設定します。リクエストオブジェクトとレスポンスオブジェクトが一体化しているようです。

 

リクエスト時のHTTPメソッド

req.method で取得します。

req.write('request.method: {}\n'.format(req.method))
# => GET

 

リクエスト時のHTTPヘッダ

req.headers_in に含まれます。

headers_inは dict like object なので、items()などで取得できます。

for k, v in req.headers_in.items():
    req.write('headers_in:  key -> {} / value -> {}\n'.format(k, v))

# => headers_in:  key -> Host / value -> 192.168.69.156
# => headers_in:  key -> User-Agent / value -> curl/7.43.0
# => headers_in:  key -> Accept / value -> */*

 

クライアントのIPアドレスなど

req.get_remote_host() を使います。

引数に apache.REMOTE_NOLOOKUP を指定するとIPアドレスが取得できます。

他の値は以下の公式ドキュメントに記載されています。
https://mod-python-doc-ja.readthedocs.io/ja/latest/pythonapi.html#apache.request.get_remote_host

req.write('request.get_remote_host(): {}\n'.format(
    req.get_remote_host(apache.REMOTE_NOLOOKUP)))
# => 192.168.69.1

 

リクエストのあったファイル名

req.filename を使います。

req.write('request.filename: {}\n'.format(req.filename))
# => /var/www/mpytest/mp/generic_handler.py

 

クエリストリングの取得

req.args を使います。

req.write('request.args: {}\n'.format(req.args))

 
curlで確認します。

$ curl "http://192.168.69.156/mp/generic_handler.py?foo=1&bar=2&baz=3"
request.args: foo=1&bar=2&baz=3

 
ただ、 req.args だと文字列として取得するため、使い勝手があまり良くなさそうです。

もしクエリストリングをオブジェクトとして取得したい場合は、 req オブジェクトを mod_python.util.FieldStorage へ引数として渡せばよさそうです。
python - mod_python and getting the QUERY_STRING using env_vars() - Stack Overflow

fields = util.FieldStorage(req)
for k, v in fields.items():
    req.write('FieldStorage:  key -> {} / value -> {}\n'.format(k, v))

 
curlで確認します。

$ curl "http://192.168.69.156/mp/generic_handler.py?foo=1&bar=2&baz=3"
...
FieldStorage:  key -> foo / value -> 1
FieldStorage:  key -> bar / value -> 2
FieldStorage:  key -> baz / value -> 3

 
なお、FieldStorageも dict like object です。

そのため、

  • fields.get('psp', None)
  • if '404' in fields

などが使えます。

 

POSTデータの取得

以下のようなフォームがあったとします。

form.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Input Form</title>
</head>
<body>
<form action="/mp/generic_handler.py" method="post" name="myform">
    <!-- input -->
    <label for="id_subject">Subject</label>
    <input type="text" name="subject" id="id_subject">

    <!-- radio button -->
    <p>
        <label for="id_apple">Apple</label>
        <input id="id_apple" type="radio" name="fruit" value="apple">
        <label for="id_mandarin">Mandarin</label>
        <input id="id_mandarin" type="radio" name="fruit" value="mandarin">
    </p>

    <!-- select -->
    <p>
        <label for="id_quantity">Quantity</label>
        <select id="id_quantity" name="quantity">
            <option id="id_select_1" name="select_1" value="one">1</option>
            <option id="id_select_2" name="select_2" value="two">2</option>
        </select>
    </p>

    <!-- checkbox -->
    <p>
        <label for="id_takeout">Takeout?</label>
        <input type="checkbox" id="id_takeout" name="takeout" value="takeout_yes">
    </p>

    <!-- hidden -->
    <input type="hidden" id="id_hidden_value" name="hidden_valude" value="Oh, hidden value">
    
    <p><input type="submit"></p>
</form>

</body>
</html>

 
POSTされたデータを取得するには、FieldStorageを使います。クエリストリングと同じです。

fields = util.FieldStorage(req)

for k, v in fields.items():
    req.write('FieldStorage:  key -> {} / value -> {}\n'.format(k, v))

 
ブラウザからフォームを送信してみます。hiddenも含め、値を取得できています。

# http://192.168.69.156/mp/form.html にアクセスし、入力した結果
request.method: POST
...
FieldStorage:  key -> subject / value -> web subject
FieldStorage:  key -> fruit / value -> mandarin
FieldStorage:  key -> quantity / value -> one
FieldStorage:  key -> takeout / value -> takeout_yes
FieldStorage:  key -> hidden_valude / value -> Oh, hidden value

 

CGI環境変数の取得

CGI環境変数とは以下のようなものです。
CGI Programming 101: Chapter 3: CGI Environment Variables

 
mod_pythonの場合、 req.add_common_vars() 後に、 req.subprocess_env から取得します。

req.add_common_vars()
env = req.subprocess_env

# CGI環境変数 HTTP_HOST を取得
host = env.get('HTTP_HOST')
req.write('subprocess_env(HTTP_HOST): {}\n'.format(host))

 
curlで確認してみます。

$ curl "http://192.168.69.156/mp/generic_handler.py?env1=1"
subprocess_env(HTTP_HOST): 192.168.69.156

 
なお、add_common_vars()を使わないと取得できません。

env = req.subprocess_env
host = env.get('HTTP_HOST')
req.write('subprocess_env(HTTP_HOST): {}\n'.format(host))

 
curl結果です。

$ curl "http://192.168.69.156/mp/generic_handler.py?env2=1"
subprocess_env(HTTP_HOST): None

 
また、 req.add_cgi_vars() もあるようですが、mod_python 3.4.1以降でないと使えないようです。
参考:Issue 2550821: mod_python 3.4.1 - add_cgi_vars() instead of add_common_vars() - Roundup tracker

req.add_cgi_vars()
env = req.subprocess_env
req.write(env.get('HTTP_HOST', 'foo'))

 
curl結果です。mod_pythonエラーが発生しています。

$ curl "http://192.168.69.156/mp/generic_handler.py?env3=1"
MOD_PYTHON ERROR
...
Traceback (most recent call last):
...
  File "/var/www/mptest/mp/generic_handler.py", line 63, in handler
    req.add_cgi_vars()

AttributeError: 'mp_request' object has no attribute 'add_cgi_vars'

 

Cookieの取得・設定

mod_pythonでは、リクエスCookieとレスポンスCookieは区別せず、同一の mod_python.Cookie を使います。

 
Cookie.get_cookiesCookieを取得し、 Cookie.add_cookie()Cookieをセットします。

# Cookieの読込
cookies = Cookie.get_cookies(req)
if 'counter' in cookies:

    # 更新したいキーのCookieを取得
    c = cookies['counter']
    c.value = int(c.value) + 1
    
    # Cookieをセット
    Cookie.add_cookie(req, c)
else:
    Cookie.add_cookie(req, 'counter', '1')

 

レスポンスの content_type を設定

req.content_type に設定します。

# text/plainの場合
req.content_type = 'text/plain'

# text/htmlの場合
req.content_type = 'text/html'

 

独自のHTTPヘッダを追加

req.headers_out に設定します。

req.headers_out['X-My-header'] = 'hello world'

 
curlで確認します。

$ curl --include http://192.168.69.156/mp/generic_handler.py
HTTP/1.1 200 OK
Date: Sun, 23 Sep 2018 22:11:02 GMT
Server: Apache/2.4.29 (Ubuntu)
X-My-header: hello world

 

HTMLテンプレート(PSP)を使用したレスポンス

mod_pythonには、Python Server Pager (PSP)と呼ばれるHTMLテンプレートがあります。

テンプレート記法は以下に記載があります。
https://mod-python-doc-ja.readthedocs.io/ja/latest/pythonapi.html#module-psp

 
以下のテンプレート template.html を用意します。内容は以下のとおりです。

  • Python標準モジュール time を使用して、現在の日付を出力
  • Pythonから渡される変数 query_string を表示

なお、template.htmlは、generic_handler.pyと同じディレクトリに入れておきます。

template.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>PSP template</title>
</head>
<body>
<% import time %>
<p>Now: <%=time.strftime("%Y/%m/%d") %></p>
<p>Query String: <%=query_string %></p>
</body>
</html>

 
Python側では、以下のコードでPSPテンプレートを表示します。

# content_typeを設定し、HTMLとして表示
req.content_type = 'text/html'

# テンプレートを指定してPSPオブジェクトを生成
template = psp.PSP(req, filename='template.html')

# クエリストリング:pspの値をPSPテンプレートへと渡す
template.run({'query_string': fields.get('psp', None)})
return apache.OK

 
curlで確認します。

$ curl "http://192.168.69.156/mp/generic_handler.py?psp=use_template"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>PSP template</title>
</head>
<body>

<p>Now: 2018/09/24</p>
<p>Query String: use_template</p>
</body>
</html>

 

404ページを表示

apache.OKの代わりに、 apache.HTTP_NOT_FOUND を使います。

if '404' in fields:
    return apache.HTTP_NOT_FOUND

 
curlで確認します。

$ curl "http://192.168.69.156/mp/generic_handler.py?404=1"
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>404 Not Found</title>
</head><body>
<h1>Not Found</h1>
<p>The requested URL /mp/generic_handler.py was not found on this server.</p>
<hr>
<address>Apache/2.4.29 (Ubuntu) Server at 192.168.69.156 Port 80</address>
</body></html>

 

エラーページを表示

apache.OKの代わりに、 apache.SERVER_RETURN を使います。

if 'error' in fields:
    return apache.SERVER_RETURN

 
curlで確認します。

$ curl "http://192.168.69.156/mp/generic_handler.py?error=1"

<pre>
MOD_PYTHON ERROR
...

 

リダイレクト

mod_python.util.redirect() を使います。

if 'redirect' in fields:
    util.redirect(req, 'https://www.google.co.jp')

 
curlで確認します。

$ curl "http://192.168.69.156/mp/generic_handler.py?redirect=1"
<p>The document has moved <a href="https://www.google.co.jp">here</a></p>

# リダイレクトを追跡
$ curl -L --include "http://192.168.69.156/mp/generic_handler.py?redirect=1"
HTTP/1.1 302 Found
...
Location: https://www.google.co.jp

HTTP/1.1 200 OK
...
Server: gws
...
Set-Cookie: 1P_JAR=2018-09-23-23; expires=Tue, 23-Oct-2018 23:43:13 GMT; path=/; domain=.google.co.jp

 

req.write()使用上の注意

req.write()でレスポンスボディを設定します。

ただ、req.write()後にHTTPヘッダ系を修正しても反映されません。Cookieも同様ですので、注意が必要です。

if 'note' in fields:
    req.write('set after body\n')
    Cookie.add_cookie(req, 'after_write', 'yes')
    req.headers_out['X-After-Write'] = 'oh'
    return apache.OK

 
curlで確認します。

  • HTTPヘッダ:X-After-Write
  • Cookie:after_write

が設定されていません。

$ curl --include "http://192.168.69.156/mp/generic_handler.py?note=1"
HTTP/1.1 200 OK
Date: Sun, 23 Sep 2018 23:56:38 GMT
Server: Apache/2.4.29 (Ubuntu)
X-My-header: hello world
Cache-Control: no-cache="set-cookie"
Set-Cookie: counter=1
Vary: Accept-Encoding
Transfer-Encoding: chunked
Content-Type: text/plain

set after body

 
試しに、req.write()前に移動してみます。

Cookie.add_cookie(req, 'after_write', 'yes')
req.headers_out['X-After-Write'] = 'oh'
req.write('set after body\n')
return apache.OK

 
curlで確認します。

  • HTTPヘッダ:X-After-Write
  • Cookie:after_write

が設定されています。

$ curl --include "http://192.168.69.156/mp/generic_handler.py?note=1"
HTTP/1.1 200 OK
Date: Sun, 23 Sep 2018 23:59:11 GMT
Server: Apache/2.4.29 (Ubuntu)
X-My-header: hello world
Cache-Control: no-cache="set-cookie"
Set-Cookie: counter=1
Set-Cookie: after_write=yes
X-After-Write: oh
Vary: Accept-Encoding
Transfer-Encoding: chunked
Content-Type: text/plain

set after body

 

ソースコード

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

 

その他参考

#pyconjp PyCon JP 2018に参加しました

9/17(日)・18(月・祝)に、大田区産業プラザ PiOで開催された「PyCon JP 2018」に参加しました。
トップ - PyCon JP 2018

去年と同様、無事に参加できてよかったです。
#pyconjp PyCon JP 2017に参加しました - メモ的な思考的な

動画も既に公開されているため、参加できなかったセッションはまたあとで振り返ろうと思います。
PyConJP - YouTube

以下、簡単な感想のメモです。

 
目次

 

参加したセッション

9/17 基調講演「Argentina in Python: community, dreams, travels and learning」 (Kaufmann Manuel 氏)

今年も会場入りが遅れたこともあり、メイン会場が満席で入れませんでした。サテライト会場で聞いていましたが、それでも情熱が伝わってくるセッションでした。

クルマで南米各地を走って6万km超、従来のメディア(TV、ラジオ、新聞)なども使ってPythonの輪を広げていく姿がとても印象に残りました。

何か情熱が出てきたら、それを大切にしようと思いました。

 

9/17 招待講演「東大松尾研流 実践的AI人材育成法」 (中山 浩太郎 氏)

AIまわりの情報と人材育成についての講演でした。

最近データ解析系をさわっているせいか、話についていけてよかったです。

 

Webアプリケーションの仕組み (Takayuki Shimizukawa 氏)

Webアプリケーションを低レイヤーから知りたいと思い、参加しました。

Webアプリケーションやそのまわりのある技術の紹介や、Webアプリケーションを低レイヤーから検証・実装していく内容でした。

個人的には

も分かってよかったです。

 

Building Maintainable Python Web App using Flask (Wonder Chang 氏)

メンテナンスしやすいコードはどんなふうに書けばよいのか気になったので参加しました。

Maintainability = readability (Hard) + extensibility (Hard) + testability (Easy) ということで、テストが重要と改めて感じました。

テストコードはpytestで書かれ、

  • Wrapper Pattern in Unit Testing
  • Integration Test of Wrapper

などのパターン集の紹介もありました。

あとでスライドを読み返すなどして、パターンを理解しようと思いました。

 

メルカリにおける AI 活用事例 (千葉 竜介 氏)

AIを実際に活用している現場を知りたくて参加しました。

メルカリのTeam AIでの

が印象に残りました。

 

Pythonで解く大学入試数学 (新井 正貴 氏)

Pythonでどうやって解いていくのか気になって参加しました。

Sympyを使うことで、分かりやすい数式へと変換する例を見ることができてよかったです。

また、いろいろ試すのにはJupyter Notebook便利と改めて感じました。

 

9/18 基調講演 「Pythonでやってみた」:広がるプログラミングの愉しみ (磯 蘭水 氏)

2日目の基調講演では、車輪の再実装をおそれないことを学びました。

また、作りたいものをどのような構成でシステムに落としてこんでいくかの説明があり、参考になりました。他の方が頭の中で考えているものを見る機会がなかなかないためです。

あとは、「学習する能力は、知識・経験量に比例する」とのことなので、今後何か気になったことがあれば、手を動かして実装・理解していきたいと感じました。

 

Migrating from Py2 application to Py3: first trial in MonotaRO (増田泰 氏)

仕事でPythonを使っているとPython2系に出会うこともあるため、移行について知りたいと思い参加しました。

Python2から3へ移行する時にトライしたこととやめたこと、どのように移行を進めようとしているのかの説明があり、参考になりました。

 

JVM上で動くPython3処理系cafebabepyの実装詳解 (澁谷 典明 氏)

Pythonのコードをどのように解析しているのか気になり、参加しました。

今まではPythonの標準モジュールastでの解析しかやったことがなかったので、Java + ANTRL v4でもできると知ったのが収穫でした。

また、難しくなりそうな話題をきちんと解説してくださり、処理系実装の理解を深められました。

 

Artisanal Async Adventures (Jonas Obrist 氏)

asnycモジュールを雰囲気で使っていたため、より具体的な内容を知りたいと思い、参加しました。

スライドはほぼなく、ライブコーディングで進みました。async/awaitの利用前後のコードが分かり、ためになりました。

また、英語で分からない部分があっても、コードを見れば分かったのでよかったです。

 

C拡張と共に乗り切るPython 2→3移行術 (末田 卓巳 氏)

今のところC拡張に出会う機会はないのですが、Python2からの移行が気になったので参加しました。

C拡張を移行する時のつらさやその解決方法が説明されていてよかったです。

難しい内容になるのかなと思いましたが、リラックスした雰囲気を作り出していて、理解しやすかったです。

 

料理写真が美味しく撮れる! 開発現場から覗くAI料理カメラの裏側 (森永 雄也 氏)

AI料理カメラが気になって参加しました。

あらかじめタグ付けされたデータがあるとだいぶ楽になりそうな一方、SNS特有のノイズもあるのが大変そうでした。

あとは、学習した時のマシンの紹介もあり、金の弾丸を撃てれば個人でも試せそうでした。

 

その他

同僚のスピーカー

会社の同僚3名がスピーカーとして登壇していました。

 
いろいろ準備している姿を見ていたこともあり、無事にセッションを終えたようで何よりでした。

 

pytestを使ったライブコーディング

翔泳社のブースにて、書籍「テスト駆動Python」の訳者・安井さんによる、pytestを使ったライブコーディングがありました。

pytestの書き方も当然のことながら、

  • vimを使いこなしている姿
  • テストコードの書き方
    • テストコード間の依存性は少なくする
    • テストコードはシンプルにする
      • 後で見返した時に分かりづらくなるのを避ける
      • テストコード用共通ライブラリはなるべく作らない
      • テストで使う値とかも、ベタ書きする
  • 実装が分かりきっているところは、テストを後から書いても良い
  • 日本語話者のみでメンテナンスしていくのであれば、テストメソッド名が日本語だとわかりやすい

など、いろいろと参考になりました。

 

Python Boot Camp

2017年にTAとして2回参加した縁で、Python Boot CampのTシャツをいただきました。ありがたい & 嬉しかったです。

 

 

パーティ

いろいろな方とお話ができてよかったです。世界が狭いと感じる出来事もあり、楽しめました。

特に、Python Boot Campに「Pythonを学びたい」と言って参加された方が、その後いろいろと動かれて、現在はPythonメインで書いていると聞けたことが印象に残っています。

やる気をもって行動することの大事さを、改めて感じました。

 

WiFi環境が良好

会場は広かったのですが、1〜6階を往復しても、同じSSIDでネットワークが途切れることなく利用できました。

あの人数をさばくのにどのような構成になっていたのかが気になりました。

CONBUをはじめとしたNetwork Sponsorのみなさま、ありがとうございました。

 
最後になりましたが、PyCon JP 2018を運営してくださったみなさま、ありがとうございました。