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

オープンハードカンファレンス2017 Naganoに参加しました

4/22にギークラボ長野で開かれた「オープンハードカンファレンス2017 Nagano」に参加しました。
オープンハードカンファレンス2017 Nagano – OSHWC

 
物理的な不器用さ*1から、相変わらず物理レイヤーについて学ぶことがあまりできていません…

ただ、以前GDG信州で色々なガジェットを見た時の経験から、モノを見るだけでも得られるものはあるだろうと思い、参加しました。

実際、色々と得るものがありました。

 

会場にて

セミナーを聞いたり、色々な展示の作者にいろいろな技術背景をうかがえたりと、貴重な機会でした。

特に、「ハードウェアなどのツールは色々と出揃っているので、あとはその存在を知ればいい」旨の話を聞き、最近眠っているRaspberry Piなどで何か作ろうかと感じました。

 
展示では、セミナーで紹介されたものの他、

  • IchigoLatteでJavaScriptが動き、Shellやviが動く様子
  • Raspberry Pi Zeroの小ささ
  • 零式電子弦の実機と実演、LEDテープ

などを見ていました。

 
他に、キッズスペースもあり、子どもたちがそちらで集中して熱心にモノづくりしていたのが印象に残りました。

 

セミナー 

スマートフォンで操作するRaspberry Pi 高音質オーディオプレイヤー(試作品) (三石さん)

フィルターレス型DAC変換の電子回路基板とオリジナル電源回路を工作した時の技術的な内容についてのお話でした。

質疑応答や展示での様子から情熱が伝わってきました。

楽しめる世界をより広くするためにも、やはり自分に不足している前提知識をいろいろと身につけたいなと感じました。

 

Raspberry PiでLEDが光るまで (佐藤さん)

LEDが光るという目に見える世界の裏側で、どのようなものが動いているのかのお話でした。

高いレイヤーから低いレイヤーへとお話が移っていったので、理解しやすい内容でした。このあたりの学習にはRaspberry Piを使うと良いようです。

 

GDG信州の紹介(石丸さん)

GDG信州 & GCPUG(ジーシーパグ)信州の紹介と、今後のイベント予定、ガジェット紹介と今後のガジェットについてのお話でした。

予定として、5/17と6/10あたりは空けておくと良いようです。

 

現実とバーチャルを繋ぐ実演 (ギークラボ長野)

Oculus & Raspberry Pi & Pepper & MESH & NFCを組み合わせて、ゲーム仕立ての実演&解説でした。新作ムービーも用意されていました。

それぞれのガジェットで何ができるのかが分かりました。画面に連動してPepperが電磁砲(?)の動きをしていたのにも驚きました。

 

 
最後になりましたが、企画・開催・運営をされたみなさま、ありがとうございました。

バタバタと帰宅したため、片付け*2を十分手伝えずにすみません…

*1:ハンダ付けが鬼門

*2:特に粘土系

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

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

 
今回は

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

でした。

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

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

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

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

 
印象に残った内容は、

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

でした。

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

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

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

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

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

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

 
目次

 

環境

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

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

 

Werkzeugとは

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

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

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

 

GETのテスト

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

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

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

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

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

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

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

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

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

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

 

POSTのテスト

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

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

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

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

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

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

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

 

Cookieのテスト

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

 

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

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

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

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

 

Cookieの取り出し方について

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

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

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

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

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

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

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

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

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

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

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

ただ、RFC6265を見ると、

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

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

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

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

 

Cookie値の変換について

次にCookie値を見てみると、

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

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

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

 

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

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

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

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

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

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

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

 

ソースコード

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

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

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

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

 

環境

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

 

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

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

仕様は

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

です。

from bottle import run, get, redirect, response

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

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

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

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

 

/にアクセスした時

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

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

 

/redirectへアクセスした時

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

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

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

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

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

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

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

 

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

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

 
そこで、reqeusts.Responseを使った

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

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

 

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

テストはパスしました。

 

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

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

 

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

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

 

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

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

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

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

 

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

テストがパスしました。

 

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

テストがパスしました。

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

 

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

テストがパスしました。

 

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

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

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

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

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

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

ソースコード

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

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

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

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

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

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

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

例えば、\oooは、

8 進数値 ooo を持つ文字

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

として使えます。

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

>>> print('\141')
a

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

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

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

 
目次

 

環境

 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

と変わりませんでした。

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

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

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

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

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

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

 

方法

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

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

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

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

 

codecs.getdecoderを使う例

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

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

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

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

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

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

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

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

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

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

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

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

assert stripped == 'あ'

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

 

ast.literal_eval()を使う例

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

なお、stackoverflowのコメントでは

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

Fred Nurk Jul 29 ‘11 at 2:29

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

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

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

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

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

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

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

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

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

assert decoded == 'あ'

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

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

 

その他

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

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

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

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

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

 

escaped_literal = '\343\201\202'

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

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

「あ」を得られました。

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

 

ソースコード

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

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

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

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

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

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

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

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

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

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

 

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