【Python】毎回Loggingを使うのが面倒だったので簡単にログ出力できるラッパークラス作ってみた

【Python】毎回Loggingを使うのが面倒だったので簡単にログ出力できるラッパークラス作ってみた

私は2年前に、Pythonを使ってGUI上でプログラムを選択して実行するようなタスクプログラムを作成していました。
その時に大変だったのが「マルチスレッド」と「ログ出力」です。

Pythonの場合、ログ出力は大抵 Logging というライブラリを使うと思うのですが、中規模なプログラムだったので色んな場所で呼べる仕様にする必要があったのと、PySimpleGUIというのを使っていたので、画面上にも出力できるようにも工夫しなければなりませんでした。また、万が一のためを考えてログファイルにも転記して残しておきたかったので色々と苦戦しました。

そういった要件を満たすために、独自でLoggingのラッパークラスを作成したのですが、自分的にはかなり使い回しができる良いクラスだと思うのでご紹介したいと思います。

筆者の環境

筆者の環境はこんな感じです。3.7〜3.10のバージョンは動作確認済みです。

環境構築

  • MacOS : Ventura 13.0.1
  • Python : 3.8.5
  • コード紹介

    早速ですが私が作成したLogクラスのコードをご紹介します。

    # -*- coding: utf-8 -*-
    
    # 標準ライブラリ
    import datetime
    import inspect
    import logging
    import os
    from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL
    
    class Log:
        loggers = {}
    
        def __init__(
            self,
            name: str,
            format: str = "%(asctime)s - %(levelname)s - %(name)s: %(message)s",
            level=DEBUG,
        ):
            """
            初期化 フォーマットやlogファイルの設定、
    
            Args:
                name (str): logにつける固有な名前
                format (str, optional): 出力するフォーマット  Defaults to '%(asctime)s - %(levelname)s - %(name)s: %(message)s '.
                level : 表示するレベルを設定 [DEBUG, INFO, WARNING, ERROR, CRITICAL]  Defaults to DEBUG.
            """
            self.name = name
            self.format = format
            self.level = level
    
            # 重複ログ防止のため、nameでのlogが作成済みならば再利用
            if Log.loggers.get(name):
                self.log = Log.loggers.get(name)
                # ハンドラーをいったん削除して再設定する
                self.log.handlers.clear()
    
            else:
                # loggersに格納して、同じlogを生成しないように管理
                self.log = logging.getLogger(self.name)
                self.log.setLevel(self.level)
                Log.loggers[name] = self.log
    
            # logの複数発行を防ぐ
            self.log.propagate = False
    
            # logファイルで使用する日付 ./log/2020年/11月/2020-11-04.logというふうにする
            self.today = datetime.datetime.now()
            self.text_today = datetime.datetime.strftime(self.today, "%Y-%m-%d")
    
            # このクラスの1階層上に logフォルダを作成
            self.log_folder_name = os.path.join(
                os.path.dirname(__file__),
                "../log/" + str(self.today.year) + "年/" + str(self.today.month) + "月",
            )
            self.log_file_name = self.text_today + ".log"
            os.makedirs(self.log_folder_name, exist_ok=True)
    
            # StreamHandlerの設定
            self.sh = logging.StreamHandler()
            self.sh.setLevel(self.level)
    
            # FileHandlerの設定 logファイル場所を指定
            self.fh = logging.FileHandler(
                self.log_folder_name + "/" + self.log_file_name, encoding="utf-8"
            )
            self.fh.setLevel(self.level)
    
            # フォーマットをセットし、logにハンドラーを追加する
            self.formatter = logging.Formatter(self.format)
            self.sh.setFormatter(self.formatter)
            self.fh.setFormatter(self.formatter)
            self.log.addHandler(self.sh)
            self.log.addHandler(self.fh)
    
        def get_log(self):
            """
            log本体を返す
            """
            return self.log
    
        def debug(self, msg, extra=None) -> None:
            """
            log.debugでメッセージを出力する
    
            Args:
                msg : logで表示する文章や変数など
                extra (dict, optional): 付加情報 Defaults to None.
                    例)
                    log.debug("message", {foo: bar})
                    出力 : message [foo=bar]
            """
            try:
                self.log.debug(msg, extra=extra)
            except UnicodeEncodeError:
                self.log.debug(msg.encode("cp932", "ignore"), extra=extra)
    
        def info(self, msg, extra: dict = None) -> None:
            try:
                self.log.info(msg, extra=extra)
            except UnicodeEncodeError:
                self.log.info(msg.encode("cp932", "ignore"), extra=extra)
    
        def warning(self, msg, extra=None) -> None:
            try:
                self.log.warning(msg, extra=extra)
            except UnicodeEncodeError:
                self.log.warning(msg.encode("cp932", "ignore"), extra=extra)
    
        def error(self, msg, extra: dict = None) -> None:
            try:
                self.log.error(msg, extra=extra)
            except UnicodeEncodeError:
                self.log.error(msg.encode("cp932", "ignore"), extra=extra)
    
        def exception(self, extra=None) -> None:
            """
            log.exceptionでエラーメッセージを出力する
            """
            self.log.exception("\n---------------------------------------")
    

    それではこのコードをご紹介します。

    使い方

    上のクラスをimportし、log = Log(名前) というふうにインスタンスを作成します。
    ログ出力するときにはlog.info(msg)log.error(msg) というふうに出力すれば良いだけです。

    Exceptionエラーをログ出力する際には、log.exception() と宣言することでTracebackを取得して出力してくれます。

    下のコード例のように使用する想定で作成しています。

    # 使用例
    import Log
    
    log = Log("LogName")
    log.debug("debugメッセージ")
    log.info("infoメッセージ")
    log.warning("warningメッセージ")
    log.error("errorメッセージ")
    
    try:
        x = 1 / 0
    except Exception:
        log.exception() # エラーをキャッチしてログ出力する
    
    ================== 実行結果 ==================
    
    2022-12-03 22:43:15,233 - DEBUG - LogName: debugメッセージ
    2022-12-03 22:43:15,234 - INFO - LogName: infoメッセージ
    2022-12-03 22:43:15,234 - WARNING - LogName: warningメッセージ
    2022-12-03 22:43:15,234 - ERROR - LogName: errorメッセージ
    2022-12-03 22:43:15,234 - ERROR - LogName: 
    ---------------------------------------
    Traceback (most recent call last):
      File "/Users/teruteru/src/practice.py", line 10, in <module>
        x = 1 / 0
    ZeroDivisionError: division by zero

    ログ出力すると、自動的にログファイルを格納するフォルダが1階層上に作成されるようになっています。log / 年 / 月 / 日時.log という構造でファイルを作成するので管理しやすい仕様です。

    .
    ├── log
    │   └── 2022年
    │       └── 12月
    │           └── 2022-12-03.log
    └── src
        ├── log_class.py
        └── practice.py


    次にコードの説明を行なっていきます。

    コード説明

    コンストラクター

    コンストラクターでは、ログを判別するための名前出力フォーマット出力レベルを設定できます。

    内部では、指定された名前で logging.getLogger() からインスタンスを作成したあと、重複が起こらないように loggers というset型 に格納しています。

    もし既に同じ名前のインスタンスが作成されていたときは、loggersにあるインスタンスを使用するようにし、出力先を整えるために handlers を一度クリアしています。

    ...中略
        loggers = {}
    
        def __init__(
            self,
            name: str,
            format: str = "%(asctime)s - %(levelname)s - %(name)s: %(message)s",
            level=DEBUG,
        ):
            """
            初期化 フォーマットやlogファイルの設定、
    
            Args:
                name (str): logにつける固有な名前
                format (str, optional): 出力するフォーマット  Defaults to '%(asctime)s - %(levelname)s - %(name)s: %(message)s '.
                level : 表示するレベルを設定 [DEBUG, INFO, WARNING, ERROR, CRITICAL]  Defaults to DEBUG.
            """
            self.name = name
            self.format = format
            self.level = level
    
            # 重複ログ防止のため、nameでのlogが作成済みならば再利用
            if Log.loggers.get(name):
                self.log = Log.loggers.get(name)
                # ハンドラーをいったん削除して再設定する
                self.log.handlers.clear()
    
            else:
                # loggersに格納して、同じlogを生成しないように管理
                self.log = logging.getLogger(self.name)
                self.log.setLevel(self.level)
                Log.loggers[name] = self.log
    
            # logの複数発行を防ぐ
            self.log.propagate = False

    さらに、念のために propagate プロパティをFalseにすることで親ルートと子ルートのログ出力を防止しているため、複数のログが出力されないようになっています。

    参考1:logging — Python 用ロギング機能 – propagate
    参考2:Python loggingで2回同じログが表示される場合

    ファイル作成

    ログ出力先フォルダとファイルの名前を決める処理です。

    特に大したことはしていないですが、本日の日付を取得して、フォルダ名とファイル名を決めてフォルダを作成しています。

    ...中略
        # logファイルで使用する日付 ./log/2020年/11月/2020-11-04.logというふうにする
        self.today = datetime.datetime.now()
        self.text_today = datetime.datetime.strftime(self.today, "%Y-%m-%d")
    
        # このクラスの1階層上に logフォルダを作成
        self.log_folder_name = os.path.join(
            os.path.dirname(__file__),
            "../log/" + str(self.today.year) + "年/" + str(self.today.month) + "月",
        )
        self.log_file_name = self.text_today + ".log"
        os.makedirs(self.log_folder_name, exist_ok=True)

    ハンドラー

    ハンドラー部分では、StreamHandlerFileHandler を指定しています。
    それぞれ名前の通り、StreamHandler はコンソールに対してログ出力できるようにし、FileHandlerはファイルに対してログ出力できるようにしています。

    ログの出力先ファイルは self.log_file_name で決めた名前のファイル名にしています。

    出力フォーマットを決めたあと、それぞれのハンドラーにフォーマットを当ててインスタンスに追加します。

    ...中略
        # StreamHandlerの設定
        self.sh = logging.StreamHandler()
        self.sh.setLevel(self.level)
    
        # FileHandlerの設定 logファイル場所を指定
        self.fh = logging.FileHandler(
            self.log_folder_name + "/" + self.log_file_name, encoding="utf-8"
        )
        self.fh.setLevel(self.level)
    
        # フォーマットをセットし、logにハンドラーを追加する
        self.formatter = logging.Formatter(self.format)
        self.sh.setFormatter(self.formatter)
        self.fh.setFormatter(self.formatter)
        self.log.addHandler(self.sh)
        self.log.addHandler(self.fh)

    参考:logging.handlers — ロギングハンドラ

    ログメソッド

    このクラスで使用できるメソッドはこんな感じです。最低限のメソッドだけ用意しています。

    # メソッド一覧
    log.get_log() # getter
    
    # メッセージ出力
    log.debug(msg)
    log.info(msg)
    log.warning(msg)
    log.error(msg)
    
    # エラーメッセージ出力
    log.exception()

    ゲッターを実装しているので、PySimpleGUI などでログ出力したい時にはハンドラークラスを作成してログを渡すことができます。


    直したかったところ、できなかったところ

    引数に複数のオブジェクトが与えられるように実装できなかったのがとても悔しいですね。自分の実力だと実装できませんでした…

    Pythonの print 文のように複数のオブジェクトでも出力できるようにしたかったですね。ソースコードを見ましたがC言語で書かれており参考にできませんでした。

    良い実装方法があれば教えていただけると嬉しいです。

    まとめ

    当時は Logging の使い方が全くわからなかったので、簡単に設定できたらいいなの気持ちでこのクラスを作成しました。

    Logクラスを作成したことで使い回しができるようになり、機械学習やGUI作成の際にもログ出力が簡単に行えるようになったので今でもめちゃくちゃ重宝しています。

    ぜひこちらを参考にしてカスタマイズしていただいて、最強のLogクラスを作ってみてください!

    この記事が誰かの役に立てますように。

    副業で稼ぎたい方におすすめの無料サイト!
    フリーランスとして活躍したい、プログラミングで副業してみたいという方へ。週1日〜から始められる フリーランス & 副業 無料求人サイト

    第1位フリーランスを始めるならITプロパートナーズ
    位:キャリアサポートサービス「クラウドテック」
    位:ライティングからサイト制作まで【Bizseek】


    Pythonカテゴリの最新記事