Python: 関数内の関数を他の関数から置換する: replaceFunc

ラベル: , ,
関数内にある関数を他の関数から置換する方法を考えます。まず思いついたのは単純にソースコードのテキストを取得してそれを動的に変更する方法です。

やりたいこと

def targetFunc():
    print("ターゲットの関数")
def newFunc(arg):
    print("置換後の関数で{}を出力。".format(arg))
def replaceFunc():
    pass   
if __name__ == "__main__":
    targetFunc()
このtargetFunc()内のprint()をreplaceFunc()でnewFunc()に置換したいのです。

置換したい関数名=新しい関数、を挿入するだけのはず

def targetFunc():
    print = newFunc
    print("ターゲットの関数")
def newFunc(arg):
    print("置換後の関数で{}を出力。".format(arg))
def replaceFunc():
    pass   
if __name__ == "__main__":
    targetFunc()
targetFunc()内であれば単にprint=newFuncを挿入するだけです。

これをtargetFunc()をいじらずにtargetFunc()の中のprint()のみをどうやって置換するか、です。

compile()の第2引数のfilenameは新たな仮想モジュールを指す


まず参考にしたのはPython Cookbook09.24.parsing_and_analyzing_python_sourceです。

これがめちゃくちゃ苦労しました。

一旦諦めかけましたが、ようやく解決しました。

問題点はcompile()のスコープの理解にありました。

exec(compile(top,'','exec'), temp, temp)

09.24.parsing_and_analyzing_python_sourceの46行目のこのtopにはASTオブジェクトが入っておりcompile()でASTオブジェクトをコンパイルしてexec()で実行できるようにしています。

exec()ではこのコンパイルしたオブジェクトを実行してtemp辞書でその結果を受けています。

topの中に入っている関数オブジェクトはその関数名をキーにしてtemp辞書から取り出せます。

で、なかなかわからなかったのは、その関数オブジェクトから使えるスコープです。

09.24.parsing_and_analyzing_python_sourceでは関数オブジェクトの外から何も引用していないのでこの点では参考にはなりませんでした。

結局あれこれ調べましたがよくわからず、あれこれいじってようやく理解できました。

compile()の第2引数のfilenameは新たな仮想モジュール名を指していました。

なので、exec()を実行しているモジュールでも、compile()を実行しているモジュールでもない新たな仮想モジュールなので、globalスコープをみても何もありません。

ということでcompile()の第1引数のオブジェクト内で引用したいオブジェクトがあるモジュールをimportすることで解決しました。

replaceFunc(ターゲット関数、置換前の関数、置換後の関数)


ReplaceFunc/replacefunc.py at 4af53278ff20b249dae6ed851d64889c8114a768 · p--q/ReplaceFunc

これでターゲット関数(targetFunc())内の置換前の関数(print())が置換後の関数(newFunc())に置換されて返ってきます。

12行目で置換後の関数があるモジュールから新しく挿入する関数をimportしています。

exec(compile(src,'virtual_module','exec'), temp, temp)

17行目にあるtemp辞書をprint()してみると次のようになります。
{'targetFunc': <function targetFunc at 0xb6e296a4>, '__builtins__': {'ImportError': <class 'ImportError'>, 'FutureWarning': <class 'FutureWarning'>, 'IsADirectoryError': <class 'IsADirectoryError'>, 'slice': <class 'slice'>, 'bool': <class 'bool'>, 'bin': <built-in function bin>, 'map': <class 'map'>, 'copyright': Copyright (c) 2001-2015 Python Software Foundation.
All Rights Reserved.

Copyright (c) 2000 BeOpen.com.
All Rights Reserved.

Copyright (c) 1995-2001 Corporation for National Research Initiatives.
All Rights Reserved.

Copyright (c) 1991-1995 Stichting Mathematisch Centrum, Amsterdam.
All Rights Reserved., 'SyntaxWarning': <class 'SyntaxWarning'>, 'UnicodeDecodeError': <class 'UnicodeDecodeError'>, 'BytesWarning': <class 'BytesWarning'>, 'abs': <built-in function abs>, 'UnboundLocalError': <class 'UnboundLocalError'>, 'ImportWarning': <class 'ImportWarning'>, 'enumerate': <class 'enumerate'>, 'AssertionError': <class 'AssertionError'>, 'exec': <built-in function exec>, 'PermissionError': <class 'PermissionError'>, 'eval': <built-in function eval>, 'BaseException': <class 'BaseException'>, 'repr': <built-in function repr>, 'sorted': <built-in function sorted>, 'reversed': <class 'reversed'>, 'ConnectionError': <class 'ConnectionError'>, 'UnicodeError': <class 'UnicodeError'>, '__name__': 'builtins', 'PendingDeprecationWarning': <class 'PendingDeprecationWarning'>, 'quit': Use quit() or Ctrl-D (i.e. EOF) to exit, 'KeyError': <class 'KeyError'>, 'EOFError': <class 'EOFError'>, 'InterruptedError': <class 'InterruptedError'>, 'frozenset': <class 'frozenset'>, 'ConnectionRefusedError': <class 'ConnectionRefusedError'>, 'property': <class 'property'>, 'AttributeError': <class 'AttributeError'>, 'None': None, '__debug__': True, 'divmod': <built-in function divmod>, 'SystemExit': <class 'SystemExit'>, 'IndentationError': <class 'IndentationError'>, 'len': <built-in function len>, 'hasattr': <built-in function hasattr>, 'ord': <built-in function ord>, 'callable': <built-in function callable>, 'chr': <built-in function chr>, 'Warning': <class 'Warning'>, 'open': <built-in function open>, 'credits':     Thanks to CWI, CNRI, BeOpen.com, Zope Corporation and a cast of thousands
    for supporting Python development.  See www.python.org for more information., 'min': <built-in function min>, 'RecursionError': <class 'RecursionError'>, 'next': <built-in function next>, 'sum': <built-in function sum>, 'iter': <built-in function iter>, 'UnicodeEncodeError': <class 'UnicodeEncodeError'>, 'UserWarning': <class 'UserWarning'>, 'object': <class 'object'>, 'IndexError': <class 'IndexError'>, '__spec__': ModuleSpec(name='builtins', loader=<class '_frozen_importlib.BuiltinImporter'>), 'zip': <class 'zip'>, 'EnvironmentError': <class 'OSError'>, 'DeprecationWarning': <class 'DeprecationWarning'>, 'NameError': <class 'NameError'>, 'oct': <built-in function oct>, 'pow': <built-in function pow>, 'BlockingIOError': <class 'BlockingIOError'>, '__import__': <built-in function __import__>, 'staticmethod': <class 'staticmethod'>, 'compile': <built-in function compile>, 'StopIteration': <class 'StopIteration'>, 'FileNotFoundError': <class 'FileNotFoundError'>, 'ArithmeticError': <class 'ArithmeticError'>, 'True': True, 'issubclass': <built-in function issubclass>, 'ValueError': <class 'ValueError'>, 'globals': <built-in function globals>, 'dict': <class 'dict'>, '__build_class__': <built-in function __build_class__>, 'format': <built-in function format>, 'ConnectionResetError': <class 'ConnectionResetError'>, 'type': <class 'type'>, 'license': Type license() to see the full license text, 'GeneratorExit': <class 'GeneratorExit'>, 'TypeError': <class 'TypeError'>, 'float': <class 'float'>, 'getattr': <built-in function getattr>, 'bytearray': <class 'bytearray'>, 'IOError': <class 'OSError'>, 'locals': <built-in function locals>, 'tuple': <class 'tuple'>, 'OSError': <class 'OSError'>, 'filter': <class 'filter'>, 'SyntaxError': <class 'SyntaxError'>, 'OverflowError': <class 'OverflowError'>, 'TimeoutError': <class 'TimeoutError'>, 'round': <built-in function round>, 'hash': <built-in function hash>, 'setattr': <built-in function setattr>, 'hex': <built-in function hex>, 'input': <function input at 0xb6eb2dac>, 'BrokenPipeError': <class 'BrokenPipeError'>, 'FloatingPointError': <class 'FloatingPointError'>, 'KeyboardInterrupt': <class 'KeyboardInterrupt'>, 'StopAsyncIteration': <class 'StopAsyncIteration'>, 'complex': <class 'complex'>, 'str': <class 'str'>, 'bytes': <class 'bytes'>, 'any': <built-in function any>, 'FileExistsError': <class 'FileExistsError'>, 'help': Type help() for interactive help, or help(object) for help about object., 'all': <built-in function all>, 'print': <built-in function print>, 'MemoryError': <class 'MemoryError'>, 'LookupError': <class 'LookupError'>, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, 'Exception': <class 'Exception'>, 'dir': <built-in function dir>, 'list': <class 'list'>, 'memoryview': <class 'memoryview'>, 'RuntimeWarning': <class 'RuntimeWarning'>, 'ZeroDivisionError': <class 'ZeroDivisionError'>, 'ascii': <built-in function ascii>, 'ReferenceError': <class 'ReferenceError'>, 'isinstance': <built-in function isinstance>, '__package__': '', 'Ellipsis': Ellipsis, 'NotADirectoryError': <class 'NotADirectoryError'>, 'ProcessLookupError': <class 'ProcessLookupError'>, 'ConnectionAbortedError': <class 'ConnectionAbortedError'>, 'False': False, 'max': <built-in function max>, 'delattr': <built-in function delattr>, 'UnicodeWarning': <class 'UnicodeWarning'>, 'vars': <built-in function vars>, 'UnicodeTranslateError': <class 'UnicodeTranslateError'>, 'RuntimeError': <class 'RuntimeError'>, 'ChildProcessError': <class 'ChildProcessError'>, 'TabError': <class 'TabError'>, 'id': <built-in function id>, 'set': <class 'set'>, 'ResourceWarning': <class 'ResourceWarning'>, 'SystemError': <class 'SystemError'>, 'classmethod': <class 'classmethod'>, 'int': <class 'int'>, 'range': <class 'range'>, 'exit': Use exit() or Ctrl-D (i.e. EOF) to exit, '__doc__': "Built-in functions, exceptions, and other objects.\n\nNoteworthy: None is the `nil' object; Ellipsis represents `...' in slices.", 'super': <class 'super'>, 'NotImplemented': NotImplemented, 'BufferError': <class 'BufferError'>, 'NotImplementedError': <class 'NotImplementedError'>}}
思ったよりいろいろなものが入っていますが、着目するのは最初のキーでそこにtargetFuncの関数オブジェクトが入っているので、次の行でそのオブジェクトを返しています。
def targetFunc():  # この関数の中の関数を置換する。
    print("ターゲットの関数")
これが次のように変換されます。
def targetFunc():  # この関数の中の関数を置換する。

    from replacefunc import newFunc
    print = newFunc  
    
    print("ターゲットの関数")
3行目と4行目が新たに挿入されたコードです。

replaceFunc()を3階の高階関数にする


replaceFunc()をデコレータ式で使えるように変形します。

replaceFunc()は編集する関数以外にも引数を取るので引数付きデコレータにしないといけません。

引数付きデコレータの例はPython Cookbook09.04.defining_a_decorator_that_takes_argumentsにあります。

Python Cookbookは英語のせいもあって購入してから3年近く本棚に眠っていましたが、読んでみるとGoogle検索ではなかなか見つけ出せない役立つことがたくさん書いてありました。

引数付きデコレータとはつまりデコレータの2階関数を3階にするだけです、、、というわけでもありませんでした。

ReplaceFunc/replacefunc.py at 96bdb9416b261177777d69340b8fcb8d28a515f7 · p--q/ReplaceFunc · GitHub

とりあえず3階にしてみました。

15行目と16行目の"""で挟んだ挿入するコード部分は関数の階層に関係なく、挿入後のインデントと同じにしておかないといけません。

23行目で返している関数は実行形式にしておかないといけませんでした。
if __name__ == "__main__":
    targetFunc = replaceFunc(print, newFunc)(targetFunc)
    targetFunc()
27行目のreplaceFunc(print, newFunc)でdecorateが返ってきて、続いてdecorate(targetFunc)が実行されて、wrapperが返りtargetFuncに代入されています。

次の行のtargetFunc()はwrapper()という意味になるので、temp[func.__name__](*args, **kwargs)が返ってきます。

確かにこう順を追ってみていくと、 最後に返すのはtemp[func.__name__]でなくてtemp[func.__name__](*args, **kwargs)でないといけないとわかります。

つまり(print, newFunc)の括弧でreplaceFuncを実行して、それで返ってきたdecorateを(targetFunc)で実行して、それで返ってきたwrapperを()で実行しています。

wrapper()が返すtemp[func.__name__]を実行する括弧はないので、括弧付きで返さないといけないわけです。

replaceFunc()を引数付きデコレータ式として呼びす方法: 未熟編


ReplaceFunc/replacefunc.py at 490d8d1673e1d7e62220127d061ec61c8a73abd7 · p--q/ReplaceFunc

31行目でreplaceFuncを引数付きデコレータとして使っています。

@のデコレータ式自身をinspect.getsource()で取得してしまうので、14行目で@replaceFunc()がある行番号を取得して、それ以前の行は使わないようにしています。

@replaceFunc()が読みこまれるときにdef replaceFuncがすでに読み込まれていないといけないので、 その後にもってきています。

しかしこのreplaceFunc()はいくつもの問題点があります。

 まず気がついたのは、クロージャで1回しか実行されないはずのwrapper()より前の部分が2回実行されていることです。

この原因はdecorate()の引数のfuncの中でreplacefunc.pyをimportしていることだとわかりました。

@replaceFunc()は読み込まれた時点で実行されるので、最初にreplacefunc.pyを実行した時と、targetFunc()の中からインポートしたときも再度実行されてしまうのでした。

解決法として@replaceFunc()をif __name__ == "__main__":の中にいれました。

ところがそうするとinspect.getsource()で読み込んだときのインデントが変わってしまうのでそれに対応しないといけなくなりました。

つまり、 文字列を注入してコードを動的に変更する方法は常にインデントの問題がつきまとうことがわかりました。

replaceFunc()を引数付きデコレータ式として呼びす方法: 同一モジュール


ReplaceFunc/replacefunc.py at ce99bca2922d641526cf78f28d15e49eba2cff93 · p--q/ReplaceFunc


これでインデントの問題も解決しました。
if 1:
    if 1:
        def targetFunc():  # この関数の中の関数を置換する。
            from replacefunc import newFunc; print = newFunc
            print("ターゲットの関数")
インデントの分だけif 1:を追加しています。

結局Python Cookbookに載っているnamelower.pyのハックをすべて使うことになりました。

同一モジュールで処理する場合はこれで完成です。

次にreplaceFunc()を他のモジュールから呼び出せるようにすることを考えます。
PR

0 件のコメント:

コメントを投稿