Python: replaceFuncをデバッガに対応させる

ラベル: , ,
Pythonの標準ライブラリのunittest.mockpatch()を使うともっとスマートに関数置換ができることがわかりました。置換後の新しい関数をソースに分解しなくてよいのでデバッガでブレークポイントもはれます。

前の関連記事:Python: replaceFuncをモジュールにして、複数関数置換にも対応させる


patch()でビルトインのprint()をパッチする

from functools import wraps
from unittest.mock import patch
def targetFunc():
    print("ターゲットの関数")
def newFunc(arg):
    lst.append("置換後の関数で{}を出力。".format(arg))
def replaceFunc(func):
    @wraps(func)
    def wrapper():
        with patch('builtins.print', side_effect = newFunc): 
            func()
        print(lst)   
    return wrapper
if __name__ == "__main__":
    lst = []
    targetFunc = replaceFunc(targetFunc)
    targetFunc()
同一モジュールでやっているのでbuiltins.printは__main__.printとも書けます。

10行目でビルトインのprint()をnewFuncに置換されたように動作していますが、そうではなくてpatch()によって第一引数のbuiltins.printはMagicMockに置換されます。

side_effectはMagicMockがMockから継承しているオプション引数で、MagicMockがcallableとして呼び出されたときに呼び出すcallableを指定しています。

今回はnewFuncを指定しているのでprint()に代わってnewFunc()が呼び出されるわけです。

newFunc()でprint()を使うとこれもまたMagiMockに置換されてしまって、終わりのない再帰になってmaximum recursion depth exceededと言われてしまいます。

なのでnewFunc()の中ではprint()は使えないのでprint()すべき引数をリストに渡しています。

newFuncはグローバルスコープにあるので、そこで使うリストもまたグローバルスコープに置いています。

replaceFunc()の中だけですべての処理をしてしまいたいのですが、グローバルスコープで定義されたnewFunc自体のスコープを変更したいと思ったのですが、前回やったように一旦ソースにしてコンパイルする方法以外は思いつきませんでした。

ソースにしてコンパイルする方法はデバッガでブレークポイントを設定できなくなるのでその方法はとりたくありません。
from functools import wraps
from unittest.mock import patch
def targetFunc():
    print("ターゲットの関数")
def replaceFunc(func):
    lst = []
    def newFunc(arg):
        lst.append("置換後の関数で{}を出力。".format(arg))
    @wraps(func)
    def wrapper():
        with patch('builtins.print') as print_mock: 
            print_mock.side_effect = newFunc
            func()
        print(lst)   
    return wrapper
if __name__ == "__main__":
    targetFunc = replaceFunc(targetFunc)
    targetFunc()
うーん、結局newFunc()を移動させてスコープを変える方法しか思いつきませんでした。

そもそも置換するprint()をtargetFunc()の中のものだけに限定できればこんなことをしなくて済むわけですが、targetFunc()をいじらずに何とかする方法はわからないです、、、

と思ったら簡単に解決しました。

ReplaceFunc/RepalceFunc/src at 081a3610f82a8591656d0eee5a6ee528edb56222 · p--q/ReplaceFunc

グローバルスコープを別にするだけ、つまり別モジュールにするだけで解決しました。

置換元の関数は__main__.printにしてメインモジュールのものに限定しています。

なので、置換後の関数をメインモジュール以外に置いておけば、終わりのない再帰は生じません。

置換元の関数をbuiltins.printにするとモジュールに限定されないので終わりのない再帰になってしまいます。

複数の関数を置換するようにコンテクストマネジャーのpatch()をExitStackでスタックしています(replacefunc.py)。

contextlib.ExitStackを読んでもExitStackの使い方がよく理解できませんでしたが、python: create a "with" block on several context managers - Stack Overflowをみてそのまま使い方を真似しています。

これで狙い通りの結果が得られるようになりましたが、デバッガにかけるとMagicMockのreturn_valueが延々と入れ子になっています。

MagicMockは特殊なことをしているオブジェクトだからなのかもしれません。

print()をpatch()する代わりに標準出力をpatch()する


ビルトインのprint()を置換したい目的が、その出力をリダイレクトしたい場合はprint()を置換しなくても、標準出力を取得すればよいです。

Python Cookbook14.01.testing_output_sent_to_stdoutに例があります。

patch("sys.stdout", new=StringIO())、でsys.stdoutio.StringIOのインスタンスに置換しています。
from functools import wraps
from unittest.mock import patch
from io import StringIO
def redirectStdout(func):  
    @wraps(func)
    def wrapper(*args, **kwargs):    
        with patch("sys.stdout", new=StringIO()) as fake_out:  # 標準出力をリダイレクト。
            func(*args, **kwargs)
        print("リダイレクトされた出力{}".format(fake_out.getvalue()))
    return wrapper 

@redirectStdout
def targetFunc():  # Replace functions in this function
    print("arg in the taget function")

targetFunc()  # Call the decorated function
これでうまくいきました。

関数を置換するより簡単ですね。

contextlib.redirect_stdout()を使うと引数のオブジェクトに標準出力がリダイレクトされます。
from functools import wraps
from contextlib import redirect_stdout
from io import StringIO
def redirectStdout(func):  
    @wraps(func)
    def wrapper(*args, **kwargs):  
        fake_out = StringIO()  
        with redirect_stdout(fake_out):  # 標準出力をリダイレクト。
            func(*args, **kwargs)
        print("リダイレクトされた出力{}".format(fake_out.getvalue()))
    return wrapper 

@redirectStdout
def targetFunc():  # Replace functions in this function
    print("arg in the taget function")

targetFunc()  # Call the decorated function
patch()ではなくて、redirect_stdout()を使うメリットは29.6.3.1. Reentrant context managersに例がある再入可能(リエントラント) でしょうか?

参考にしたサイト


python: create a "with" block on several context managers - Stack Overflow
コンテクストマネジャーを同時に複数使う方法。
PR

0 件のコメント:

コメントを投稿