【python】loggingでプログラムの動きを可視化する

はじめまして！Jacombと申します。Qiitaには初めて記事を投稿します。今後ともよろしくお願いします。本記事では、プログラムの動きの可視化する方法を論じていこうと思います。

# loggingを使うモチベーション

私はpythonを使って9年近くコードを書いてきました。プログラマとして快感を感じる瞬間は、「コードを実行してそのコードが意図通り動作したとき」と考えています。逆に意図通りに動作しなかった場合であっても、プログラムがどのように動いたかを可視化し、問題がどこにあったのかを確認する、そういった行為もまたパズルを解くかのように面白く飽きない作業です。

プログラムの動きを可視化する方法として、手っ取り早いのは print ですが、コードが完成したらデバッグ情報を出力するための print 行は普通削除します。プログラマにとって有用な情報でも、エンドユーザーにとっては不要なので。一方、例えばシステムテスト中に再現性が低い未知のバグに遭遇してしまった場合、こういったコード中に仕込んだデバッグ情報は問題解決の手掛かりとなるはずなので、残す意義は十分にあります。

この2要素を満たすには、print ではなく logging を使おうというモチベーションになります。つまり、プログラムの動きを見るためのログ出力はDEBUGレベルに設定し、デバッグ時とリリース時でDEBUGレベルのログ出力の可否をスイッチするように設計すれば良いのです。

# どこに何のログを仕込むべきか？

経験的には次の場所に仕込むと、プログラムの動きを把握できます。

- 関数の開始・終了
- 関数でエラー発生時
- if文の分岐前後
- for、while制御構文の前後

また、次の情報を出力します。

- ログを仕込んだ場所
- 関数やブロックの動きを決めている変数

# ログの仕込みを楽にできないか？

ここが本記事を投稿したかった一番の理由です。前述のようにログ出力すべき場所と情報はある程度は決まっていため、ログの仕込みは少し楽をできそうです。ログの仕込みは特定のプロジェクトから完全に切り離された無関係の下位問題であると見なせるため、ログの仕込みの効率を論じることはすべてのプロジェクトの効率を良くすることに繋がると考えています。

それでは、1つ1つどのような「楽」ができるかを確認していきます。

- 関数の開始・終了
- 関数でエラー発生時

これは、pythonのデコレータの機能を使えば簡単に実現できそうです。

- if文の分岐前後
- for、while制御構文の前後

これについては、良いアイディアが思いつきません。ブロック前後(あるいは中)に手動でログを埋め込みます。

- ログを仕込んだ場所

これについては、logging の LogRecord の機能で funcName や lineno といったフォーマットにより実現できそうです。この機能は、logging を呼び出した関数名と行番号をログ出力に含めるというものです。正に探し求めていた機能なわけですが、実はある欠点があります。この欠点については後述します。

- 関数やブロックの動きを決めている変数

関数の動きを決めている変数とは、つまり引数のことです。また、同時に引数の対となる存在として返値も可視化には重要な要素となります。これらはデコレータの実装と合わせて自動的にログ出力に含むことがそうです。

一方、ブロックの動きを決めている変数、つまりブロック制御変数が何であるかは、プログラマならすぐ思い付きますが、機械が自動判断するのは難しいと思います。そのため、これは手動でログ出力に埋め込みます。

それでは方針が決まったので、実際に実装してみましょう。

# logging の funcName や lineno にある欠点を解消する

## 欠点とは

funcName と lineno は logging の呼び出し元の関数名と行番号をログ出力に含めることができる、とても便利な機能ですが、「呼び出し元」という点がポイントです。

実際に実装するにあたって logging モジュールで用意されているクラスや関数をオーバーラップするわけですが、その過程で「呼び出し元」が「ログ出力関数のラッパー」にすり替わってしまい、意図した「呼び出し元」を出力してくれないという問題が生じます。

具体的にコード例を挙げると次のようになります。

```python
import logging

logging.basicConfig(format='%(funcName)s >> %(message)s', level=logging.DEBUG)
def logput(msg):
    # ここに何か便利な処理
    logging.debug(msg)

def main():
    logput('mainを開始')
    # ここでmainの処理
    logput('mainを終了')
    
if __name__ == '__main__':
    main()
```

このコードのログ出力は以下のようになります。

```
logput >> mainを開始
logput >> mainを終了
```

私の意図としては、ログは関数 main に仕込まれているので、「呼び出し元」は「main」であって欲しいです。私が期待したログ出力は以下でした。

```
main >> mainを開始
main >> mainを終了
```

## 意図通りに動かない理由

logging では「呼び出し元」をどのように定義しているのでしょうか？該当するコード部分を以下に示します。

ざっくり説明すると、関数 currentframe で現在のフレーム(コールスタック)を取得し、メソッド findCaller でフレームを遡って logging モジュールよりも上位のフレームを取得します。つまり「呼び出し元」とは、「logging モジュールよりも上位のフレーム」のことを指しているため、先の問題が起きました。


```python
if hasattr(sys, '_getframe'):
    currentframe = lambda: sys._getframe(3)
else: #pragma: no cover
    def currentframe():
        """Return the frame object for the caller's stack frame."""
        try:
            raise Exception
        except Exception:
            return sys.exc_info()[2].tb_frame.f_back

# ----- 中略 ------

_srcfile = os.path.normcase(addLevelName.__code__.co_filename)

# ----- 中略 ------

class Logger(Filterer):

# ----- 中略 ------

    def findCaller(self, stack_info=False):
        """
        Find the stack frame of the caller so that we can note the source
        file name, line number and function name.
        """
        f = currentframe()
        #On some versions of IronPython, currentframe() returns None if
        #IronPython isn't run with -X:Frames.
        if f is not None:
            f = f.f_back
        rv = "(unknown file)", 0, "(unknown function)", None
        while hasattr(f, "f_code"):
            co = f.f_code
            filename = os.path.normcase(co.co_filename)
            if filename == _srcfile:
                f = f.f_back
                continue
            sinfo = None
            if stack_info:
                sio = io.StringIO()
                sio.write('Stack (most recent call last):\n')
                traceback.print_stack(f, file=sio)
                sinfo = sio.getvalue()
                if sinfo[-1] == '\n':
                    sinfo = sinfo[:-1]
                sio.close()
            rv = (co.co_filename, f.f_lineno, co.co_name, sinfo)
            break
        return rv

```

## 解決方法

私が実現したい「呼び出し元」とは、「ログ出力関数のラッパーを呼び出した関数」のことなので、「ログ出力関数のラップ数(=wrapper_depth)」を引数に取るように改変した findCaller を作りました。

```python
def findCaller(srcfiles=[_srcfile], wrapper_depth=0):
    """
    呼出元関数の情報を取得する。
    
    Parameters
    ----------
    srcfiles: list
        srcfilesで定義されたファイルリストよりも上位フレームを呼出元とする。
    wrapper_depth: int
        追加でフレームをいくつ遡るかを指定する。
    """
    f = currentframe().f_back
    
    # srcfilesまで遡る
    while hasattr(f, "f_code"):
        co = f.f_code
        filename = os.path.normcase(co.co_filename)
        if filename in srcfiles:
            f = f.f_back
            continue
        else:
            break
    else:
        return "(Unknown file)", 0, "(Unknown function)"
    
    # wrapper_depthだけ更に遡る
    for _ in range(wrapper_depth):
        f = f.f_back
        if hasattr(f, "f_code"):
            co = f.f_code
            filename = os.path.normcase(co.co_filename)
        else:
            return "(Unknown file)", 0, "(Unknown function)"
    
    return filename, f.f_lineno, co.co_name
```

(本当は、クラス Logger をオーバーラップし、wrapper_depth をインタンス変数として指定できるようにしてメソッド findCaller の動作を変更できると、より汎用性が上げられるのですが、面倒なので今回そこまでは実装していません。)

改変した findCaller を使い動作を確認してみます。

```python
import logging
from utils.logging import findCaller

# 説明のためハンドラをリセット
for handler in logging.root.handlers[:]:
    logging.root.removeHandler(handler)

logging.basicConfig(format='%(message)s', level=logging.DEBUG)
def logput(msg):
    funcName = findCaller(wrapper_depth=1)[2]
    logging.debug('{} >> {}'.format(funcName, msg))

def main():
    logput('mainを開始')
    # ここでmainの処理
    logput('mainを終了')
    
if __name__ == '__main__':
    main()
```

このコードのログ出力は、私が意図した通り、「呼び出し元」が main となります。

```
main >> mainを開始
main >> mainを終了
```

# デコレータで関数の入出力とエラーを監視する

デコレータとは、(ちゃんとした定義はよく知りませんが)その関数の実行前後に処理を挿入できる関数のことです。以下を実現するデコレータを作成します。

- 関数実行前に引数をログ出力
- 関数実行後に返値をログ出力
- 関数エラー発生時にトレースバックをログ出力

コードを書くと次のようになります。

```python
def logging_fcuntion_decorator(level=DEBUG, logger=None):
    """
    関数の入出力をロギングするデコレーター。
    """
    def decorator(func):
        logput_kargs = {'level':level, 'logger':logger, 'func_name':func.__name__}
        @functools.wraps(func)
        def logging_function(*args, **kargs):
            logput('start. args:{}, kargs:{}'.format(args, kargs), **logput_kargs)
            try:
                rv = func(*args, **kargs)
            except:
                logput('error happened.', exc_info=True, **logput_kargs)
                raise
            logput('end. retval:{}'.format(rv), **logput_kargs)
            return rv
        return logging_function
    return decorator
```

# 実際にプログラムの動きが可視化できているか確認する

ここまでの実装で大体やりたいことは終わりました。後は、実際に書いたコードでログ出力し、プログラムの動きが可視化できるか確認します。

```python
from utils.logging import logput as _logput
from utils.logging import logging_fcuntion_decorator as _logging_fcuntion_decorator
from utils.logging import DEBUG

# 引数logger, wrapper_depthをこのスクリプト用に固定した関数を作る。
MYLOGGER = 'sample.logput'
def logput(msg, **kargs):
    return _logput(msg, logger=MYLOGGER, wrapper_depth=1, **kargs)

def logging_fcuntion_decorator(**kargs):
    return _logging_fcuntion_decorator(logger=MYLOGGER, **kargs)

# 実際の使い方
@logging_fcuntion_decorator(level=DEBUG)
def plus(a, b):
    return a + b

@logging_fcuntion_decorator(level=DEBUG)
def minus(a, b):
    return a - b

@logging_fcuntion_decorator(level=DEBUG)
def main1():
    x, y = 20, 10
    logput('x:{}, y:{}'.format(x, y))
    return plus(x, y), minus(a=x, b=y)

@logging_fcuntion_decorator(level=DEBUG)
def main2():
    raise Exception

if __name__ == '__main__':
    try:
        main1()
        main2()
    except:
        pass
```

上記例には表示されていませんが、日時やログレベル等の付加的な情報も出力するようにフォーマッターをセットしています。このコードのログ出力は以下となります。

```
|2019-09-05 16:30:20,509|sample.logput|DEBUG|main1 >> start. args:(), kargs:{}|
|2019-09-05 16:30:20,509|sample.logput|DEBUG|main1 >> x:20, y:10|
|2019-09-05 16:30:20,509|sample.logput|DEBUG|plus >> start. args:(20, 10), kargs:{}|
|2019-09-05 16:30:20,509|sample.logput|DEBUG|plus >> end. retval:30|
|2019-09-05 16:30:20,509|sample.logput|DEBUG|minus >> start. args:(), kargs:{'a': 20, 'b': 10}|
|2019-09-05 16:30:20,509|sample.logput|DEBUG|minus >> end. retval:10|
|2019-09-05 16:30:20,509|sample.logput|DEBUG|main1 >> end. retval:(30, 10)|
|2019-09-05 16:30:20,509|sample.logput|DEBUG|main2 >> start. args:(), kargs:{}|
|2019-09-05 16:30:20,509|sample.logput|DEBUG|main2 >> error happened.|
Traceback (most recent call last):
  File "D:\機械学習\コード\Jacomb\reusable_python_utils\utils\logging.py", line 125, in logging_function
    rv = func(*args, **kargs)
  File "D:\機械学習\コード\Jacomb\reusable_python_utils\samples\logput.py", line 33, in main2
    raise Exception
Exception
```

関数 main1 で行っていることは複雑ではありません。言葉で表現すると、main1 を実行し、main1 が plus と minus を呼び出します。そして、それらの返値を main1 の返値としています。この動作がログ出力から把握できますか？私としてはそれなりに満足いく結果となっています。

また、関数 main2 はわざとエラーを発生させていますが、main2 で発生するエラーは try - except で無視されています。しかし、ログにはしっかりそのトレースバックが記録されているため、エラーに気付かないという事態は防げそうです。

# まとめ

私としては、プログラムの動きをしっかり確認でき、満足できる結果となりました。きっとより大きなプロジェクトでも、これらの手法でプログラムの動きを確認できることでしょう。

今回作成したコードは私の GitHub レポジトリの utils/logging.py にまとめられています。
https://github.com/Jacomb/reusable_python_utils