【python】logを綺麗にコンソールとファイル出力する自作logger

プログラミング

はじめに

この記事では、pythonでコンソールとファイルの両方にログを出力する方法を記載します。

また、コンソールでのログ出力の際は print() のそれよりも綺麗に表示されるようにします。例えばエラー時の際は下図のように表示されることを目指します。

方針としては、rich ライブラリRichHandler と、RotatingFileHandler を使用して自作loggerを作成します。

この記事で使用したソースコードは次の Github で管理しています。

GitHub - Joichiro433/Blog-logger
Contribute to Joichiro433/Blog-logger development by creating an account on GitHub.

richライブラリを使用した自作logger

今回は rich ライブラリが必要になるので、pip インストールします。

$ pip install rich

さっそくですが、自作loggerである logger.py は下記のように実装しました。

from __future__ import annotations
import os
from pathlib import Path
from datetime import datetime
import logging
from logging import Formatter, handlers, getLogger, Handler
import inspect

from rich.logging import RichHandler


LOG_FILE_NAME = f'app_{datetime.strftime(datetime.now(), "%Y%m%d")}.log'  # logファイルの名前


class Logger:
    """
    Attributes
    ----------
    log_level : str
        追跡するログレベル, e.g. 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'
    save_log_dir : Path
        ログを書き出すディレクトリ

    Methods
    -------
    debug
        debugレベルログを出力
    info
        infoレベルログを出力
    warn
        warnレベルログを出力
    error
        errorレベルログを出力
    remove_oldlog
        ログディレクトリにmax_log_num以上logファイルがある場合、最も古いlogファイルを消去
    """
    def __init__(
            self,
            *,
            log_level: str = 'INFO',
            save_log_dir: str = 'logs'):
        self.log_dir: Path = Path(save_log_dir)
        self.log_backupcount: int = 3
        if not os.path.exists(self.log_dir):  # logs/ディレクトリが無ければ作成
            os.makedirs(self.log_dir)
            self._create_log_gitignore()
        log_file_path: Path = self.log_dir/LOG_FILE_NAME

        stdout_formatter: Formatter = Formatter(fmt='%(message)s')
        file_formatter: Formatter = Formatter(
            fmt='%(asctime)s.%(msecs)03d %(levelname)7s %(message)s [%(name)s:%(lineno)d]',
            datefmt='%Y/%m/%d %H:%M:%S')

        stdout_handler: Handler = RichHandler(rich_tracebacks=True)
        stdout_handler.setFormatter(fmt=stdout_formatter)
        file_handler: Handler = handlers.RotatingFileHandler(
            filename=log_file_path,
            encoding='UTF-8',
            maxBytes=2**24,  # 16MB
            backupCount=self.log_backupcount)
        file_handler.setFormatter(fmt=file_formatter)
        
        logging.basicConfig(level=log_level, handlers=[stdout_handler, file_handler])
        caller_func_name: str = inspect.stack()[1].filename.split('/')[-1]  # 呼び出し元関数名
        self.logger = getLogger(name=caller_func_name)

    def debug(self, msg: str) -> None:
        self.logger.debug(msg, stacklevel=2)

    def info(self, msg: str) -> None:
        self.logger.info(msg, stacklevel=2)

    def warn(self, msg: str) -> None:
        self.logger.warning(msg, stacklevel=2)

    def error(self, msg: str, *, exc_info: bool = True) -> None:
        self.logger.error(msg, exc_info=exc_info, stacklevel=2)

    def critical(self, msg: str) -> None:
        self.logger.critical(msg, stacklevel=2)

    def remove_oldlog(self, *, max_num_log: int = 100) -> None:
        """ログディレクトリにmax_log_num以上logファイルがある場合、最も古いlogファイルを消去

        Parameters
        ----------
        max_log_num : int, optional
            logファイルの最大件数, by default 100
        """
        logs: list[str] = list(self.log_dir.glob('*.log'))
        if len(logs) > max_num_log:
            # log_name_pairs: [(logname_yyyymmdd.log, datetime(yyyy, mm, dd)), ...]
            log_name_pairs: list[tuple[str, datetime]]  = [(log, datetime.strptime(log[-12:-4], '%Y%m%d')) for log in logs]
            log_name_pairs = sorted(log_name_pairs, key=lambda s: s[1])
            remove_log_path: str = log_name_pairs[0][0]  # 最も古いlogファイル
            os.remove(remove_log_path)
            self.info(f'remove {remove_log_path}')
            # ローテーションされたlogファイル (logname_yyyymmdd.log.1) 等がある場合、それらも削除する
            for i in range(1, self.log_backupcount+1):
                remove_rotating_log_path: str = remove_log_path + f'.{i}'
                if os.path.exists(remove_rotating_log_path):
                    os.remove(remove_rotating_log_path)
                    self.info(f'removed {remove_rotating_log_path}')

    def _create_log_gitignore(self) -> None:
        """logs/ ディレクトリに .gitignore を作成"""
        content: str = (
            '*\n'
            '!.gitignore\n'
        )
        with open(self.log_dir/'.gitignore', 'w') as f:
            f.write(content)

自作loggerを使ってみる

実装した logger.py を使ってみます。

ファイルの階層構造は以下のようになっています。logs/ ディレクトリは存在しなくても大丈夫です。存在しない場合は、プログラム実行時に自動で作成されます。

.
├── logger.py
├── main.py
└── logs/

次の main.py で 自作logger を使用してみます。

import time

from logger import Logger


logger = Logger()
logger.remove_oldlog()


def main():
    logger.info('sample log')

    sample_array = [1, 2, 3, 4, 5]
    logger.info(sample_array)

    time.sleep(1)

    sample_dict = {
            'key1': 'value1',
            'key2': 1e10,
            'sub_key3': [1, 2, 3, 4, 5],
        }
    logger.info(sample_dict)

    print(5 / 0)  # エラーを発生させる


if __name__ == '__main__':
    try:
        logger.info('##### Begin #####')
        main()

    except Exception as e:
        logger.error(e)

    finally:
        logger.info('###### End ######')

main.py を実行すると下図のようにログがコンソール出力されます。

また、同時に logs/ ディレクトリにログファイルが出力されます。


このようにpythonでコンソールとファイルの両方に綺麗にログを出力することができました。

タイトルとURLをコピーしました