structlog.get_logger() と configure()

structlogシリーズ、今回(たぶん最終回)は get_logger() について。ソースコードを解説していく前に、まず重要な挙動から。

>>> import structlog
>>> from structlog import PrintLogger
>>> def logger_factory():
...     print("creating logger")
...     return PrintLogger()
...

>>> structlog.configure(logger_factory=logger_factory)
>>> logger = structlog.get_logger()
>>> logger
<BoundLoggerLazyProxy(logger=None, wrapper_class=None, processors=None, context_class=None, initial_values={}, logger_factory_args=())>
>>> logger.info("hello")
creating logger
2024-06-24 15:31:41 [info     ] hello
>>> logger.info("hello")
creating logger
2024-06-24 15:31:43 [info     ] hello
  • logger_factory は Bound Logger に渡す wrapped logger を生成する関数
  • get_logger() は Bound Logger ではなく、BoundLoggerLazyProxy というクラスのインスタンスを返す
  • BoundLoggerLazyProxyのメソッドを呼び出すたびに Bound Logger が作られ、その度に logger_factory も実行される

structlog は単なるアプリのロギングだけでなく標準ライブラリの logging の代わりに使われることも考えて作られているので、構成設定前に get_logger が呼ばれることも考えてデフォルトでは毎回設定からBoundLoggerを生成するようになっています。

しかし流石にこれは効率や性能面で良くないので、 cache_logger_on_first_use というオプションを使うことをお勧めします。このオプションを有効にすると logger (BoundLoggerLazyProxy) は一度作ったBoundLoggerを再利用するようになります。

>>> structlog.configure(cache_logger_on_first_use=True)
>>> logger.info("hello")
creating logger
2024-06-24 15:56:03 [info     ] hello
>>> logger.info("hello")
2024-06-24 15:56:06 [info     ] hello
  • BoundLoggerLazyProxy はデフォルトでグローバルの cache_logger_on_first_use 設定を参照する
    • get_logger() で logger が作られた後に cache_logger_on_first_use=True を設定しても良いようになっている
  • ただし、異なる BoundLoggerLazyProxy 間ではこのキャッシュは共有されていない
    • 同一の logger インスタンスを複数の logger から使いたい場合は、 logger factory が何度呼ばれても同一の logger インスタンスを返すようにする

ソースコード解説

get_logger() も BoundLoggerLazyProxy も、 structlog._config というモジュールで実装されています。

https://github.com/hynek/structlog/blob/527f492fcdef65619ae1de9d4f9ec692ca64a81e/src/structlog/_config.py

def get_logger(*args: Any, **initial_values: Any) -> Any:
    return wrap_logger(None, logger_factory_args=args, **initial_values)

def wrap_logger(
    logger: WrappedLogger | None,
    processors: Iterable[Processor] | None = None,
    wrapper_class: type[BindableLogger] | None = None,
    context_class: type[Context] | None = None,
    cache_logger_on_first_use: bool | None = None,
    logger_factory_args: Iterable[Any] | None = None,
    **initial_values: Any,
) -> Any:

    return BoundLoggerLazyProxy(
        logger,
        wrapper_class=wrapper_class,
        processors=processors,
        context_class=context_class,
        cache_logger_on_first_use=cache_logger_on_first_use,
        initial_values=initial_values,
        logger_factory_args=logger_factory_args,
    )
  • get_logger() は logger_factory_args という引数(後述)を渡しやすくなっている以外は、 wrap_logger() をデフォルト引数のまま呼んでいるだけ
  • wrap_logger() は BoundLoggerLazyProxy() を生成する。グローバルの設定を引数で上書きできるようになっている。
class BoundLoggerLazyProxy:

    def __getattr__(self, name: str) -> Any:
        bl = self.bind()
        return getattr(bl, name)

    def bind(self, **new_values: Any) -> BindableLogger:
        ...
        cls = self._wrapper_class or _CONFIG.default_wrapper_class
        logger = cls(_logger, processors=procs, context=ctx)

        def finalized_bind(**new_values: Any) -> BindableLogger:
            """
            Use cached assembled logger to bind potentially new values.
            """
            if new_values:
                return logger.bind(**new_values)
            return logger

        if self._cache_logger_on_first_use is True or (
            self._cache_logger_on_first_use is None
            and _CONFIG.cache_logger_on_first_use is True
        ):
            self.bind = finalized_bind  # type: ignore[method-assign]

        return finalized_bind(**new_values)
  • BoundLoggerLazyProxy はログメソッドを呼ばれるたびに bind() を実行している
  • 先に明示的に log = logger.bind() しておけば、 BoundLoggerLazyProxy を回避して直接 Bound Logger のメソッドを呼び出せる

structlog.stdlibについて

structlogのデフォルトの設定はPrintLoggerを使うようになっているのですが、 structlog.stdlib モジュールの recreate_defaults() を使うとほぼ同じ出力を、 logging 経由で出力できるようになっています。

https://github.com/hynek/structlog/blob/527f492fcdef65619ae1de9d4f9ec692ca64a81e/src/structlog/stdlib.py#L57

def recreate_defaults(*, log_level: int | None = logging.NOTSET) -> None:

    if log_level is not None:
        kw = {"force": True}

        logging.basicConfig(
            format="%(message)s",
            stream=sys.stdout,
            level=log_level,
            **kw,  # type: ignore[arg-type]
        )

    _config.reset_defaults()
    _config.configure(
        processors=[
           ...  # デフォルトと似たテキストにフォーマットする設定
        ],
        wrapper_class=BoundLogger,
        logger_factory=LoggerFactory(),
    )

def get_logger(*args: Any, **initial_values: Any) -> BoundLogger:
    return _config.get_logger(*args, **initial_values)

class LoggerFactory:

    def __call__(self, *args: Any) -> logging.Logger:

        if args:
            return logging.getLogger(args[0])

        _, name = _find_first_app_frame_and_name(self._ignore)
        return logging.getLogger(name)
  • stdlib.recreates_defaults() を使うと、 stdlib.BoundLogger と stdlib.LoggerFactory を使うようになる
  • BoundLoggerは以前に説明した通り、 logging.Logger とシグネチャを似せて static type hint をつけた上で、ログメッセージの位置引数に対応したもの
  • ただしprocessorsには PositionalArgumentsFormatter が入っていないので、位置引数はフォーマットされず、 event_dict["positional_args"] に入ったままフォーマットされる
  • log_level を渡すと logging.basicConfig() もしてくれる。ログフォーマットは "%(message)s" で、すでに structlog がフォーマットしたログをそのまま表示するだけ(タイムスタンプ等はstructlogがつける)
  • stdlib.get_logger() は戻り値の型ヒントが stdlib.BoundLogger になるだけで、あとは structlog.get_logger() のまま。
    • recreates_defaults() を使ってなくても型ヒントのためだけに使うことはできるが、その場合は型ヒントでは位置引数を取ることになるので注意が必要。
  • stdlib.LoggerFactorylogging.getLogger() を呼ぶ。
    • structlog.get_logger() の引数が LoggerFactory に渡される仕組みがここで生きてくる。
    • structlog.get_logger("foo.bar")logging.getLogger("foo.bar") を呼ぶ。
    • 引数を渡さない場合は呼び出し元のモジュール名を利用する。

アプリケーションでstructlogを使う場合

stdlib.LoggerFactoryやstdlib.BoundLoggerを使うかどうかはどちらでも良い。loggingとstructlogを組み合わせる別の方法は過去に書いた記事を参考に。

structlogとloggingの併用方法について - methaneのブログ

アプリケーション内でログを書く場合、ライブラリが書く場合と違ってアプリケーションの起動直後にまずロギングの設定を始められる。そうすると、 structlog.get_logger() を使う理由はあまりなくなる。 myapp.log モジュールで logger = structlog.BoundLogger(...) のように直接 BoundLogger インスタンスを作って、それ以外のモジュールは from myapp.log import logger すれば良い。

loggingを使っていた時に慣習として logging.getLogger(__name__) を使っていた場合は、structlogに移行する時にその慣習を残すかどうか考えなすと良いだろう。

  • stdlib.LoggerFactory とモジュールごとの logger = structlog.get_logger(__name__) を使う
  • logger = myapp.log.logger.bind(module=__name__) のようにして、構造化ログの中身に module を入れる
  • 完全にこの慣習を止め、代わりに呼び出し元の場所を CallsiteParameterAdder で行数まで残す。

また、複数種類のログを残したい場合も、 structlog.get_logger() を使う代わりに直接 BoundLogger インスタンスを作ると良いだろう。そうすればログの種類ごとに出力先やフォーマットなどの設定を変えられる。例えばデバッグ用のテキストログを標準出力に出して、別にリクエストごとに構造化イベントをJSONフォーマットでfluentbit経由でBigQueryに送るなどの組み合わせが可能だ。

structlogのwrapped loggerとrendererについて

前回までの記事でstructlogの中心となるBoundLoggerの役割とコードについて解説しました。

ざっくりとシーケンス図にまとめると次のようになっています。

sequenceDiagram
  participant U as App
  box Bound Logger
  participant B as BoundLogger
  participant P as _processors
  participant W as wrapped logger
  end
  U->>B: logger.debug()
  loop
    B->>+P: event_dict
    P-->>-B: event_dict
  end
  B->>+P: event_dict
  P-->>-B: (args, kw)
  B->>W: _logger.debug(*args, **kw)

processorの出力が次のprocessorの入力になり、最後のprocessorの出力がwrapped loggerの入力になっているのが重要なポイントです。この最後のprocessorのことをrendererとも呼びます。

今回はwrapped loggerとrendererの関係を見ていきます。

WriteLoggerとLogFmtRenderer

wrapped logger とそのための最終processorのシンプルな組み合わせとして WriteLogger と LogFmtRenderer を見ていきます。まずはWriteLoggerから。

(WriteLogger)

class WriteLogger:

    def msg(self, message: str) -> None:
        with self._lock:
            self._write(message + "\n")
            self._flush()

    log = debug = info = warn = warning = msg
    fatal = failure = err = error = critical = exception = msg

.debug() 等のログメソッドを用意していますが、それらは全部単一の実装を共有しています。その単一の実装は、受け取ったメッセージに改行をつけてファイルに書き出すだけのシンプルなものです。

メッセージ本体の中にログレベルを入れる等の処理はprocessorsの中で全て終わっているので、WriteLoggerの実装は単に書くだけになっています。そのため、rendererの役割は event_dict を1つのテキストメッセージに変換することです。次にLogFmtRendererを見ていきましょう。

(LogFmtRenderer)

    def __call__(
        self, _: WrappedLogger, __: str, event_dict: EventDict
    ) -> str:
        elements: list[str] = []
        for key, value in self._ordered_items(event_dict):
            # ...
            # bool を true/false にしたり必要ならクォート&エスケープしたりの処理
            # ...
            elements.append(f"{key}={value}")

        return " ".join(elements)

event_dict から key=value をスペース区切りで繋げたテキストを作って返していますね。このprocessorはevent_dictの文字列化だけに特化していて、ログレベルやタイムスタンプをevent_dictに入れるのはTimeStamperやadd_log_levelといった他のprocessorに任せることで、再利用性を高めています。

logging.Logger と stdlib.render_to_log_kwargs

BoundLoggerからの出力先になるwrapped loggerとして、標準ライブラリの logging.Logger を使う事ができます。 logging.Logger の debug() メソッドは次のような実装になっています。

logging.Logger.debug

    def debug(self, msg, *args, **kwargs):
        """
        Log 'msg % args' with severity 'DEBUG'.
        """
        if self.isEnabledFor(DEBUG):
            self._log(DEBUG, msg, args, **kwargs)

このメソッドの引数を作るための renderer は structlog.stdlib.render_to_log_kwargs になります。

structlog.stdlib.render_to_log_kwargs

def render_to_log_kwargs(
    _: logging.Logger, __: str, event_dict: EventDict
) -> EventDict:

    return {
        "msg": event_dict.pop("event"),
        "extra": event_dict,
        **{
            kw: event_dict.pop(kw)
            for kw in ("exc_info", "stack_info", "stacklevel")
            if kw in event_dict
        },
    }

(args, kw) のペアではなくてキーワード引数のための辞書だけを返していますね。この場合はBoundLoggerは (), **kw を引数として wrapped logger を呼び出しますが、 "msg" というキーワード引数が Logger.debug() の第一引数にマッピングされるのでちゃんとログメッセージが伝わっています。しかし、標準ライブラリの logging.Logger では logger.debug("hello, %s", "world") みたいに追加の位置引数でログ文字列を作れていたのに、この呼び出し方では第二引数以降の位置引数がありませんね?

PositionalArgumentsFormatter

既存の logging を使ったコードを structlog に置き換えたい時には、 structlog.stdlib.BoundLogger を使います。このBoundLoggerの実装は以前の記事で紹介しましたが、改めて書いておきます。

    def debug(self, event: str | None = None, *args: Any, **kw: Any) -> Any:
        """
        Process event and call `logging.Logger.debug` with the result.
        """
        return self._proxy_to_logger("debug", event, *args, **kw)

    def _proxy_to_logger(
        self,
        method_name: str,
        event: str | None = None,
        *event_args: str,
        **event_kw: Any,
    ) -> Any:
        """
        Propagate a method call to the wrapped logger.

        This is the same as the superclass implementation, except that
        it also preserves positional arguments in the ``event_dict`` so
        that the stdlib's support for format strings can be used.
        """
        if event_args:
            event_kw["positional_args"] = event_args

        return super()._proxy_to_logger(method_name, event=event, **event_kw)

stdlib.BoundLogger は logging.Logger との互換性のために第二引数以降の位置引数 *event_args を受け取ります。そしてそれを event_kw["positional_args"] に格納しています。 event_kw["positional_args"] を処理するためのProcessorとして、 PositionalArgumentsFormatter があります。

    def __call__(
        self, _: WrappedLogger, __: str, event_dict: EventDict
    ) -> EventDict:
        args = event_dict.get("positional_args")  # !!!

        # Mimic the formatting behaviour of the stdlib's logging module, which
        # accepts both positional arguments and a single dict argument. The
        # "single dict" check is the same one as the stdlib's logging module
        # performs in LogRecord.__init__().
        if args:
            if len(args) == 1 and isinstance(args[0], dict) and args[0]:
                args = args[0]

            event_dict["event"] %= args  # !!!

        if self.remove_positional_args and args is not None:
            del event_dict["positional_args"]

        return event_dict

重要な部分に !!! というコメントをつけておきました。 event_dict から positional_args を取り出して、 event_dict["event"] %= args しています。

PositionalArgumentsFormatterを使えば、既存のlogging.Loggerを使った logger.debug("hello, %s", "world") のようなログを含むコードのloggerをstructlog.stdlib.BoundLoggerに置き換えたとき、出力先(wrapped logger)が logging.Logger であれ structlog の WriterLogger であれ位置引数を使った文字列フォーマットに対応できます。

ProcessorFormatter

wrapped logger に logging.Logger を使う場合、 PositionalArgumentsFormatter を使う代わりに、 logging.Logger.StreamHandler の Formatterとして structlog.stdlib.ProcessorFormatter を使う方法もあります。

このクラスを使うと logging.Logger と structlog をより高度に連携させられるのですが、構成が複雑になるので今回は(多分今後も)詳しくは説明しません。 structlog のドキュメントにある次の章を頑張って読み解いてみてください。

www.structlog.org

また、過去に書いたこの記事では、 "Rendering Using structlog-based Formatters Within logging" が ProcessorFormatter を利用する方法を指していました。

methane.hatenablog.jp

structlogのBoundLoggerについて (2/2)

前回はBoundLoggerのコンテキスト管理とログメソッドについて解説しました。

methane.hatenablog.jp

今回はログメソッドが呼ばれた後の処理についてみていきます。

_proxy_to_logger()

まずはログメソッドがどうなっていたかのおさらいです。

# FilteringBoundLogger の方 (_native.py)

        def meth(self: Any, event: str, *args: Any, **kw: Any) -> Any:
            if not args:
                return self._proxy_to_logger(name, event, **kw)

            return self._proxy_to_logger(name, event % args, **kw)

# stdlib.BoundLogger の方 (stdlib.py)

    def debug(self, event: str | None = None, *args: Any, **kw: Any) -> Any:
        return self._proxy_to_logger("debug", event, *args, **kw)

    def _proxy_to_logger(
        self,
        method_name: str,
        event: str | None = None,
        *event_args: str,
        **event_kw: Any,
    ) -> Any:
        if event_args:
            event_kw["positional_args"] = event_args

        return super()._proxy_to_logger(method_name, event=event, **event_kw)

BoundLoggerBase._proxy_to_logger() の第一引数にメソッド名 ("debug"), 第二引数は event という名前でログ文字列、そしてキーワード引数を渡しています。

stdlib側はloggingのLoggerと互換性を持たせるために _proxy_to_logger() をオーバーライドして、位置引数のタプルをキーワード引数 positional_args に格納しています。これはその後logging互換用のprocessorが処理に利用します。

BoundLoggerBase._proxy_to_logger() の実装は次のようになっています。

structlog/src/structlog/_base.py at 527f492fcdef65619ae1de9d4f9ec692ca64a81e · hynek/structlog · GitHub

    def _proxy_to_logger(
        self, method_name: str, event: str | None = None, **event_kw: Any
    ) -> Any:

        try:
            args, kw = self._process_event(method_name, event, event_kw)
            return getattr(self._logger, method_name)(*args, **kw)
        except DropEvent:
            return None

self._process_event() が processors を呼び出すなどの処理を行い、 args, kw を返します。 getattr(self._logger, method_name) の部分は、 self._logger が wrapped logger、 method_name がログメソッド名なので、例えば BoundLogger.debug() が呼ばれたのだとしたら self._logger.debug(*args, **kw) を呼び出すことになります。

最後の except DropEvent: ですが、 processors で動的なログのフィルタリングを行うことができて、そのログをスキップしたい場合に DropEvent を発生させることになっています。この except 句はそのスキップを行う部分になります。

_process_event()

次に self._process_event(method_name, event, event_kw) の中を見ていきましょう。

この関数の役割は method_name ("debug"など), event (ログ文字列), event_kw (キーワード引数) から、 wrapped logger に渡すための args, kw を作ることでした。 少し行数があるので、コード内にコメントで解説を書いていきます。

structlog/src/structlog/_base.py at 527f492fcdef65619ae1de9d4f9ec692ca64a81e · hynek/structlog · GitHub

    def _process_event(
        self, method_name: str, event: str | None, event_kw: dict[str, Any]
    ) -> tuple[Sequence[Any], Mapping[str, Any]]:

        # context.copy()はdictを返すが、後でprocessorがdict以外を返す可能性があるので typing としては Any を指定する。
        event_dict: Any = self._context.copy()

        # contextから作った辞書とキーワード引数辞書をマージする
        event_dict.update(**event_kw)

        # イベント(ログ文字列)を "event" という名前で event_dict に格納する。
        # processorはイベント文字列を独立した引数としてではなく、 event_dict に入った状態で受け取ることになる。
        if event is not None:
            event_dict["event"] = event

        # processors をループで適用していく。
        for proc in self._processors:
            event_dict = proc(self._logger, method_name, event_dict)

        # 最後の processors の出力が文字列かバイト列なら、それを単独の引数として wrapped logger に渡す
        if isinstance(event_dict, (str, bytes, bytearray)):
            return (event_dict,), {}

        # タプルであれば、それは (*args, **kw) の2要素タプルとして wrapped logger に渡す
        if isinstance(event_dict, tuple):
            return event_dict

        # dictであれば、それをキーワード引数とし、位置引数はなしとして wrapped logger に渡す
        if isinstance(event_dict, dict):
            return (), event_dict

        # それ以外の形であればエラーにする
        msg = (
            "Last processor didn't return an appropriate value.  "
            "Valid return values are a dict, a tuple of (args, kwargs), bytes, or a str."
        )
        raise ValueError(msg)

processor の書き方

Processorを正しく設定したり独自のProcessorを作る場合のためにドキュメントがあります。 _proxy_to_logger()_process_event() の実装を見てきたので、このドキュメントを完全に理解できるはずです。

www.structlog.org

  • processorは wrapped_logger, method_name, event_dict を受け取り、 event_dict を返す
  • 最初の processor に渡される event_dict は dict 型になっている
    • ログメソッドに渡されたログメッセージ文字列は、(あれば) event_dict["event"] に入っている
  • 途中の processor に渡される event_dict は、その前に処理された processor の戻り値である
    • 汎用のprocessorであれば dict にするべき
    • 何かの処理を前段と後段の2つのprocessorの組み合わせとして実装する時は、前段の戻り値=後段のevent_dict引数にdict以外を渡せる
  • 最後の processor は wrapped logger の引数を組み立てる役割を持つ
  • ログのフィルタリングをするなら、 DropEvent を raise する
    • processororsの中のなるべく早い場所でフィルタリングすれば、後続のprocessorsの処理をまるごとスキップできる

structlogのBoundLoggerについて (1/n)

structlogを採用した場合、アプリ側がログを書くために使うインスタンスはBoundLoggerかそれをラップしたクラスのインスタンスになります。このクラスは重要なのでstructlogのドキュメントにもこのクラスのためのページがあります。

www.structlog.org

structlogを本格的にカスタマイズして使っていく場合は、まずこのクラスを理解する必要があります。このクラスの動作と実装について解説していきます。

BoundLogger の役割

https://www.structlog.org/en/stable/_images/BoundLogger.svg

BoundLoggerは、コンテキスト変数を管理し、 .debug() などのログメソッドが呼ばれたらそれを処理して "wrapped logger" と呼ばれるクラスのメソッドを呼び出すのが役割です。まずはその役割を示す例を見てみましょう。

>>> import structlog
>>> logger = structlog.get_logger()  # (1)
>>> log = logger.bind(a=1)   # (2)
>>> structlog.get_context(logger)
{}
>>> structlog.get_context(log)
{'a': 1}
>>> log.debug("hello", target="world")  # (3)
2024-06-13 18:42:23 [debug    ] hello                          a=1 target=world

(1) で structlog のデフォルト構成で BoundLogger (実際にはそれをラップしているクラス) のインスタンスを作っています。

(2) で a=1 というコンテキストをbindして、新たなBoundLoggerを作っています。これで logger と log は、同一のプロセッサーや wrapped logger を共有していますが、異なるコンテキストを持つBoundLoggerインスタンスになります。 structlog.get_context() にBoundLoggerを渡せばロガーが持っているコンテキストが異なることを確認できます。

(3) で .debug() というログメソッドを呼び出しました。このメソッドはキーワード引数とコンテキスト変数、そしてログメッセージからイベントを作り、それを processor で処理してから wrapped logger に渡します。デフォルトの wrapped logger は単に print() を実行するだけの PrintLogger です。デフォルトのprocessorが、ターミナル出力向けに色をつけたりカラムに揃えたりするようになっているので、このように綺麗な出力が得られます。

出力例

コンテキスト管理

ここからstructlogのソースコードを読んでいきましょう。BoundLoggerの主な実装はBoundLoggerBaseにあります。

structlog/src/structlog/_base.py at 527f492fcdef65619ae1de9d4f9ec692ca64a81e · hynek/structlog · GitHub

WrappedLogger = Any  # typing.py で定義されている

class BoundLoggerBase:

    _logger: WrappedLogger

    def __init__(
        self,
        logger: WrappedLogger,
        processors: Iterable[Processor],
        context: Context,
    ):
        self._logger = logger
        self._processors = processors
        self._context = context

ここでは引数で与えられた logger (wrapped logger) と processors, context を単純に持つだけのクラスを作っています。

    def bind(self, **new_values: Any) -> BoundLoggerBase:
        """
        Return a new logger with *new_values* added to the existing ones.
        """
        return self.__class__(
            self._logger,
            self._processors,
            self._context.__class__(self._context, **new_values),
        )

コンテキスト変数を追加した新たな BoundLogger を作って返します。 logger や processor は同一のものが利用されるので、BoundLoggerのインスタンスが分かれていてもログのフォーマットや出力は同一のものになります。

ログメソッドの提供

.debug().info() のようなログメソッドはBoundLoggerBaseではなく、それを継承した具象クラスで実装されます。

具象クラスには大きく二つあります。1つ目は標準ライブラリの logging.Logger と互換性の高い structlog.stdlib.BoundLogger です。次のように普通にログメソッドを定義し、 self._proxy_to_logger() に転送しています。

structlog/src/structlog/stdlib.py at 527f492fcdef65619ae1de9d4f9ec692ca64a81e · hynek/structlog · GitHub

class BoundLogger(BoundLoggerBase):
...
    def debug(self, event: str | None = None, *args: Any, **kw: Any) -> Any:
        return self._proxy_to_logger("debug", event, *args, **kw)

    def info(self, event: str | None = None, *args: Any, **kw: Any) -> Any:
        return self._proxy_to_logger("info", event, *args, **kw)
...

もう一つの具象クラスは FilteringBoundLogger です。このクラスは実際には具象クラスではなく structlog.typing で定義されているだけの静的型で、実際にはフィルタリングするログレベルごとにクラスが生成されています。

structlog/src/structlog/_native.py at 527f492fcdef65619ae1de9d4f9ec692ca64a81e · hynek/structlog · GitHub

def _make_filtering_bound_logger(min_level: int) -> type[FilteringBoundLogger]:

    def make_method(
        level: int,
    ) -> tuple[Callable[..., Any], Callable[..., Any]]:
        if level < min_level:
            return _nop, _anop   # !!! min_level 未満のログレベルに対応するログメソッドは return None するだけのメソッドを使う
        ...
        meth.__name__ = name
        ameth.__name__ = f"a{name}"
        return meth, ameth

    meths: dict[str, Callable[..., Any]] = {"log": log, "alog": alog}
    for lvl, name in LEVEL_TO_NAME.items():
        meths[name], meths[f"a{name}"] = make_method(lvl)
    ...
    return type(
        f"BoundLoggerFilteringAt{LEVEL_TO_NAME.get(min_level, 'Notset').capitalize()}",
        (BoundLoggerBase,),
        meths,
    )


# Pre-create all possible filters to make them pickleable.
BoundLoggerFilteringAtNotset = _make_filtering_bound_logger(NOTSET)
BoundLoggerFilteringAtDebug = _make_filtering_bound_logger(DEBUG)
BoundLoggerFilteringAtInfo = _make_filtering_bound_logger(INFO)
BoundLoggerFilteringAtWarning = _make_filtering_bound_logger(WARNING)
BoundLoggerFilteringAtError = _make_filtering_bound_logger(ERROR)
BoundLoggerFilteringAtCritical = _make_filtering_bound_logger(CRITICAL)

ちょっとトリッキーですが、現在のログレベルをチェックする if 文などがなく、指定したログレベル未満のログメソッドの実装が単に return None するだけの実装になっているので高速です。デフォルトで structlog.get_logger() した時に得られるBoundLoggerは最低ログレベルを指定しないですべてのログを出力する BoundLoggerFilteringAtNotset になります。

動的なログレベルによるフィルタリングの実装がありませんが、 processorsでフィルタリングするか、あるいは wrapped logger 自体が logging.Logger のようなレベル付きloggerになっている場合はそちらにフィルタリングを任せることになります。

pyenvを初心者に薦めるのはもうやめよう

Pythonのパッケージ・プロジェクト管理ツールはまだ乱立状態にあって、どれを使えばいいのかわからないから慣れたpyenv+pipを使おうという判断をする人がいるかもしれない。その判断自体は別に否定しないけれども、初心者に教える時にpyenvを教えるのはもうそろそろやめてほしい。

  • Pythonをソースからビルドするので、コンパイラや依存ライブラリを事前に揃えないといけない。依存ライブラリが足りないと中途半端なPython環境もできうる。
  • デフォルトで最適化オプション(PGO+LTO)が付いてないので、最適化ビルドしたPythonより~5%程度遅い
  • Windowsで使えない

Rye, pdm, Hatch などは python-build-standalone と呼ばれるビルド済みPythonをインストールする機能があるので、これらの欠点が存在しない。 Pythonをインストールするところまで、あるいはそこからvenvを作るところまでをこれらのツールに任せさえすれば、そのあとは慣れた pip を使ってもいい。 特にRyeとHatchは、その本体をインストールするのに事前にPythonを用意する必要がないので、初期状態でPythonが入っていないWindowsmacOSで使う時にも導入手順がシンプルだ。

どうしてもプロジェクト管理ツールをインストールしたくない、言語ランタイムだけを管理したいという場合は、最近は mise というものがあるらしい。 pyenv のような言語ごとのランタイム管理ツールを置き換える anyenv や asdf というツールがあるが、 mise はそれを似たツールだけれどもデフォルトでPythonのインストールが python-build-standalone になっている。pyenv/anyenvの使い勝手のままの移行先としては最適だろう。

note.com

ちなみにRyeとmiseはRustで書かれているし、 Hatch は Python で書かれているものの PyApp というRust製のツールでパッケージ化されていてPythonランタイムや依存ライブラリを自動で用意してくれる。Rustのおかげで最近のPythonのエコシステムは大きく改善されている。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。