Pythonでprintを卒業してログ出力をいい感じにする

Pythonである程度しっかり開発することになり、ログ出力について学習しました。2時間くらいで終わらせようと甘く見ていたら、理解に1日以上かけてしまいました。そのくらい`print`の次ステップは単純ではなかったです。
記事[「ログ出力のための print と import logging はやめてほしい」](https://qiita.com/amedama/items/b856b2f30c2f38665701)を読み、全てはないですがエッセンスは理解できたと考えています。
他にも以下の記事は結構読みました。

- [Python公式 - Logging HOWTO](https://docs.python.org/ja/3/howto/logging.html)
- [Good logging practice in Python](https://fangpenlin.com/posts/2012/08/26/good-logging-practice-in-python/)
- [ロギング — The Hitchhiker's Guide to Python - Python ヒッチハイク・ガイド](https://python-guideja.readthedocs.io/ja/latest/writing/logging.html)

[GitHub](https://github.com/YoheiFukuhara/py-logging/tree/master/py-logging)に今回のコードをのせています。

# はじめに - `print`でもいい場合と卒業する場合
`print`は簡単にメッセージ出力できるのが良い点です。loggingパッケージを知った今でも、Jupyterなどではprintを使おうと考えていますし、開発・検証中は`print`は大きな武器です。
以下の点に当てはまる場合、`print`を卒業してloggingパッケージを使うのが適切かと考えています。

- 自動的にタイムスタンプや出力元プログラム位置等の情報を付加したい
- ログ内容の統一性を持たせたい
- ログの重要度を識別したい
- コンソールとログファイルに同じログを出力したい

loggingパッケージを使うことで、以下のようにログ出力のコマンドを少し書くだけで

```python
logger.info('Process Start!')
logger.debug('debug')
logger.info('info')
logger.warning('warning')
logger.error('error')
logger.info('Process End!')
```

タイムスタンプや出力元プログラム位置など、以下のようなログ出力をすることが出来ます:point_up:
ディープラーニングのような時間がかかる処理時に、タイムスタンプを出してくれるのは後で確認するときに嬉しいです。

```terminal
2019-07-02 23:29:26,518 __main__:25 main [INFO]: Process Start!
2019-07-02 23:29:26,519 __main__:26 main [DEBUG]: debug
2019-07-02 23:29:26,519 __main__:27 main [INFO]: info
2019-07-02 23:29:26,519 __main__:28 main [WARNING]: warning
2019-07-02 23:29:26,519 __main__:29 main [ERROR]: error
2019-07-02 23:29:26,519 __main__:30 main [INFO]: Process End!
```

そして、コンソールに出力するだけでなく、ファイルにも同じ/違った内容を書き込んでくれます。


# loggingパッケージの基本
まずは、私が学んだloggingパッケージ基本の3項目です。

1. 間違えにくいように`from logging import getLogger`を使う(propagate系注意点あり)
2. `Logger`インスタンスを使う。ルートを汚さない。
3. 環境設定ファイル(json/YAML)で設定する(propagate系注意点あり, iniは古い)
str.format/stringTemplateにする

## 1と2 : `from logging import getLogger`を使い、`Logger`インスタンスを使う

まずimportは以下のように書きます。`logger.info('message')`と`logging.info('message')`が似ているので、間違ってloggingを使わないように必要なモジュールのみをimportさせます。

```python:いい例(import)
from logging import getLogger
logger = getLogger(__name__)
logger.info('message')
```

```python:悪い例(import)
import logging
logging.info('message')
```

そもそもloggingでなくloggerを使う理由なのですが、loggingを使ってしまうとルートを使った出力となってしまうからです。詳しくは記事[「ログ出力のための print と import logging はやめてほしい」](https://qiita.com/amedama/items/b856b2f30c2f38665701)を参照ください(詳しすぎるほど書いてあります)。

## 3. 環境設定ファイルを使う
### 3.1. 環境設定ファイル(JSON)
こんなjsonファイルを作って、loggingの出力方法を定義しておきます。**"disable_existing_loggers"の値は"false"にすることをおすすめします。** 理由は記事[「Python: ロギング設定をファイルから読み込むときの注意点」](https://blog.amedama.jp/entry/2018/04/12/233903)

```json:log_config.json
{
    "version": 1,
    "disable_existing_loggers": false,
    "formatters": {
        "simple": {
            "format": "%(asctime)s %(name)s:%(lineno)s %(funcName)s [%(levelname)s]: %(message)s"
        }
    },

    "handlers": {
        "consoleHandler": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "simple",
            "stream": "ext://sys.stdout"
        },
        "fileHandler": {
            "class": "logging.FileHandler",
            "level": "INFO",
            "formatter": "simple",
            "filename": "to be replaced"
        }
    },

    "loggers": {
        "__main__": {
            "level": "DEBUG",
            "handlers": ["consoleHandler", "fileHandler"],
            "propagate": false
        },
        "same_hierarchy": {
            "level": "DEBUG",
            "handlers": ["consoleHandler", "fileHandler"],
            "propagate": false
        },
        "lower.sub": {
            "level": "DEBUG",
            "handlers": ["consoleHandler", "fileHandler"],
            "propagate": false
        }
    },

    "root": {
        "level": "INFO"
    }
}
```

環境設定ファイルの定義方法は[公式 - 環境設定辞書スキーマ](https://docs.python.org/ja/3/library/logging.config.html#configuration-dictionary-schema)に詳しく書かれています。
ファイル内で`ext://sys.stderr`や`INFO`といった定義をしています。それらは
[公式 - 外部オブジェクトへのアクセス](https://docs.python.org/ja/3/library/logging.config.html#access-to-external-objects)や[公式 - 内部オブジェクトへのアクセス](https://docs.python.org/ja/3/library/logging.config.html#access-to-internal-objects)に詳しく記載しています。
また、`formatters`の`format`部分で使える属性は[Python公式 - LogRecord 属性](https://docs.python.org/ja/3/library/logging.html#logrecord-attributes)を参照ください。

### 3.2. 環境設定ファイル(JSON)をPythonで読み込む
あとは、`dictConfig`関数を使って読み込むだけです。

```python:環境設定ファイル(json/YAML)を使った例
import json
from logging import getLogger, config

with open('../log_config.json', 'r') as f:
    log_conf = json.load(f)

config.dictConfig(log_conf)

# ここからはいつもどおり
logger = getLogger(__name__)
```

### 3.3. 環境設定ファイル(JSON)のPythonでの編集
JSONファイルの定義に対してpython上で条件分岐や変数での編集も可能です。


```python
import json

with open('../log_config.json', 'r') as f:
    log_conf = json.load(f)

# ファイル名をタイムスタンプで作成
log_conf["handlers"]["fileHandler"]["filename"] = \
    '../data/logs/{}.logs'.format(datetime.utcnow().strftime("%Y%m%d%H%M%S"))

# パラメータが設定されていればレベルをINFOからDEBUGに置換
if ARGS.verbose:
    log_conf["handlers"]["fileHandler"]["level"] = DEBUG

# logging設定
config.dictConfig(log_conf)

```

### 3.4. コード方式にすると・・・
環境設定ファイル方式でなく、コード方式で書くと大雑把に以下のようになります。コード方式は開発者によって書き方の差が大きいので、環境設定ファイル方式の方が若干優れていると考えています。

```python:コード方式で設定した例
from logging import getLogger, Formatter, StreamHandler, FileHandler, DEBUG, INFO

def get_module_logger(module, verbose):
    logger = getLogger(module)
    logger = _set_handler(logger, StreamHandler(), False)
    logger = _set_handler(logger, FileHandler(LOG_DIR), verbose)
    logger.setLevel(DEBUG)
    logger.propagate = False
    return logger


def _set_handler(logger, handler, verbose):
    if verbose:
        handler.setLevel(DEBUG)
    else:
        handler.setLevel(INFO)
    handler.setFormatter(Formatter('%(asctime)s %(name)s:%(lineno)s %(funcName)s [%(levelname)s]: %(message)s'))
    logger.addHandler(handler)
    return logger
```

### 3.5. 環境設定ファイル(YAML)方式について
YAML形式も可能です。YAML形式の方がJSONより読みやすいのですが、YAMLを読み込む標準パッケージがなく、[PyYAMLパッケージ](https://pypi.org/project/PyYAML/)などを追加インストールしないといけないため、私はJSONで定義しています。 

### 3.6. 環境設定ファイル(configparser)方式について
また、`fileConfig`関数を使ったconfigparser形式もあるのですが、[Python公式 - 環境設定ファイルの書式](https://docs.python.org/ja/3/library/logging.config.html#configuration-file-format)に以下の記載があるとおり、拡張性という点で劣っています。

> 注釈 fileConfig() API は dictConfig() API よりも古く、ロギングのある種の側面についてカバーする機能に欠けています。たとえば fileConfig() では数値レベルを超えたメッセージを単に拾うフィルタリングを行う Filter オブジェクトを構成出来ません。 Filter のインスタンスをロギングの設定において持つ必要があるならば、 dictConfig() を使う必要があるでしょう。設定の機能における将来の拡張は dictConfig() に対して行われることに注意してください。ですから、そうするのが便利であるときに新しい API に乗り換えるのは良い考えです。


# loggingパッケージ使用で個人的にやっていること
方式の優越や必須とかではなく、現時点で私が「これがいいだろう」と考え実践していることです。
## 1. `Logger`インスタンスは\_\_name\_\_にしなくてもいい
[公式 - ロガーオブジェクト](https://docs.python.org/ja/3/library/logging.html#logger-objects)に以下の記述があります。

> 推奨される構築方法 logging.getLogger(\_\_name\_\_) を使用してロガーをモジュール単位で構成すれば、Python パッケージ階層と同一になります。これは、モジュールの中では \_\_name\_\_ が Python パッケージ名前空間におけるモジュール名だからです。

しかし、私は大規模なプログラムを開発するわけではないので、モジュール単位に準備すると環境設定ファイルがやや複雑になってしまいます。そのため、メインのモジュールで`logging.getLogger(__name__)`をして、他モジュールに渡しています。

## 2. StreamHandlerとFileHandlerの併用
基本的に[SteramHandler](https://docs.python.org/ja/3/library/logging.handlers.html#streamhandler)と[FileHandler](https://docs.python.org/ja/3/library/logging.handlers.html#filehandler)を併用しています。使ったことはないですが、ファイルローテーションをするハンドラもあるので、より複雑なことが必要になったら使う予定です。

```json:環境設定ファイルのハンドラ部分抜粋
    "handlers": {
        "consoleHandler": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "simple",
            "stream": "ext://sys.stdout"
        },
        "fileHandler": {
            "class": "logging.FileHandler",
            "level": "INFO",
            "formatter": "simple",
            "filename": "to be replaced"
        }
    },
```

## 3. パラメータでレベル指定
FileHandlerはargparseパッケージを使ってパラメータを受け取って、値の内容によってDEBUG/INFOレベルのログを出しています。argparseパッケージについては以前、記事[「openCVの顔検出でパラメータを指定して手っ取り早く検出精度を高める」](https://qiita.com/FukuharaYohei/items/116932920c99a5b73b32#%E3%83%91%E3%83%A9%E3%83%A1%E3%83%BC%E3%82%BF%E3%81%AE%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%82%A4%E3%83%AAargparse)で書きましたので参考にしてください。

`__main__`のあたりで引数を定義し、受け取ります(`import argparse`は省略)。

```python
if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument(
        '-v', '--verbose', action='store_true',
        help='Log level DEBUG for FileHandler'
    )

    ARGS, _ = parser.parse_known_args()
```

で、環境設定ファイル(JSON)を読み込み、上書きをして設定。

```python
    with open('../log_config.json', 'r') as f:
        log_conf = json.load(f)

    # パラメータが設定されていればレベルをINFOからDEBUGに置換
    if ARGS.verbose:
        log_conf["handlers"]["fileHandler"]["level"] = DEBUG

    # logging設定
    config.dictConfig(log_conf)
```

# ログ出力方法
## 1. 基本出力
ログレベルを指定して出力するだけです。`formatter`を定義していれば、定義内容に従って出力されます。

```python
logger.debug('debug')
logger.info('info')
logger.warning('warning')
logger.error('error')
```

## 2. 変数を使用した出力
`print`と同じように`format`を使って変数値を表示します。

```python
test1 = '値1'
test2 = '値2'
logger.info('変数:{}'.format(test1))
logger.info('変数:{0}, {1}'.format(test1, test2))
```

実行結果。

```terminal
変数:値1
変数:値1, 値2
```

%も使って以下のように書いても同じ結果です。

```python
logger.info('変数:%s', test1)
```

しかし、 `%`を使った書式は後方互換性の維持のためと[「Python公式 - 固有の書式化スタイルをアプリケーション全体で使う」](https://docs.python.org/ja/3/howto/logging-cookbook.html#using-particular-formatting-styles-throughout-your-application)に書いてあり、あえて使う意味はなさそうです。
> Python 3.2 では、Formatter クラスが style という名前のオプションのキーワード引数を受け取ります。このデフォルト値は後方互換性を維持するために % となっていますが、 { か $ を指定することで、str.format() か string.Template でサポートされているのと同じ書式化のアプローチを採れます。