pythonでlogging.Loggerとlogging.LoggerAdapterを許す型を定義する方法のメモ

pythonlogging.Loggerlogging.LoggerAdapterを許す型を定義する方法のメモ。

別の言い方をするとunion typeとprotocolを使ったstructured subtypingの違いのメモ。

(:warning: おそらくまだProtocolをimportするにはtyping_extensionsが必要です)

例えば以下のような関数があるとする。これを利用したい。duck typing的にはl.info()の形で使えれば十分。

def use(l: logging.Logger) -> None:
    l.info("hello")

ここでちょっとしたラッピングをしたオブジェクトでも使いたくなる。

こういうときにはlogging.LoggerAdapterを使ってラップすることが多い(逆に言うとLoggerAdapterを使わなかった場合にはログ出力中の行番号や呼び出された関数名などの位置に関する情報が壊れる)。

class WLogger(logging.LoggerAdapter):
    def process(
        self, msg: str, kwargs: t.MutableMapping[str, t.Any]
    ) -> t.Tuple[str, t.MutableMapping[str, t.Any]]:
        return f"Wrap [{msg}]", kwargs

利用してみる。大丈夫そう。2つ目は Wrap [%s] でラップされた出力になっている。

$ python 00first.py
INFO:__main__:hello (use)
INFO:__main__:Wrap [hello] (use)

コードの全体は以下の様な以下の様なコード。

import typing as t
import logging


logger = logging.getLogger(__name__)


class WLogger(logging.LoggerAdapter):
    def process(
        self, msg: str, kwargs: t.MutableMapping[str, t.Any]
    ) -> t.Tuple[str, t.MutableMapping[str, t.Any]]:
        return f"Wrap [{msg}]", kwargs


def use(l: logging.Logger) -> None:
    l.info("hello")


def main() -> None:
    logging.basicConfig(
        level=logging.INFO, format=logging.BASIC_FORMAT + " (%(funcName)s)"
    )
    use(logger)
    use(WLogger(logger, {}))


if __name__ == "__main__":
    main()

mypyでチェック

さて先程のコードをmypyでチェックしようとエラーが起きる。当たり前といえば当たり前だけれど。

$ mypy --strict 00first.py
00first.py:24: error: Argument 1 to "use" has incompatible type "WLogger"; expected "Logger"

logging.AdapterはLoggerではないので当然。コレへの対応方法をどうするかという話。

対応方法

対応方法は2つある。

  • Unionを使う方法
  • Protocolを使う方法

Unionを使う方法

1つの対応方法はUnionを使う方法。以下のような形に変更する。

LoggerType = t.Union[logging.Logger, logging.LoggerAdapter]

そしてuse()の型を変える。

-def use(l: logging.Logger) -> None:
+def use(l: LoggerType) -> None:
     l.info("hello")

今度は大丈夫。

$ mypy --strict 01union.py

Protocolを使う方法

もう1つの方法はProtocolを使う方法。Protocolというのはまぁ雑に言えばTypeScriptのinterfaceのようなものとおもってくれれば良い。導入の経緯などはPEP544などを参考に。

signatureなどの確認にはtypeshedを覗くのが手軽。こんな感じの値を定義してあげる。

from typing_extensions import Protocol


_SysExcInfoType = t.Union[
    t.Tuple[type, BaseException, t.Optional[types.TracebackType]],
    t.Tuple[None, None, None],
]
_ExcInfoType = t.Union[None, bool, _SysExcInfoType, BaseException]


class LoggerProtocol(Protocol):
    def info(
        self,
        msg: t.Any,
        *args: t.Any,
        exc_info: _ExcInfoType = ...,
        stack_info: bool = ...,
        extra: t.Optional[t.Dict[str, t.Any]] = ...,
        **kwargs: t.Any,
    ) -> None:
        ...

そしてuse()の型もこちらを使ったものに変更。こうしてあげると、logging.Loggerと同様のinfo()を持ったオブジェクトということになる(ちなみに可変長引数部分などが組み合わさったときの型の等値性が意外と厄介だったりする。まぁ今回の話とは別の話)。

-def use(l: logging.Logger) -> None:
+def use(l: LoggerProtocol) -> None:
     l.info("hello")

こちらも大丈夫。

$ mypy --strict 02protocol.py

Unionを利用したものとProtocolを利用したものとの違いは?

次の疑問はUnionを利用したものとProtocolを利用したものとの違いは?ということになるかもしれない。これは以下の様な形で分かるかもしれない。

Unionを利用するとは?

例えばUnionはまだ分岐が終わっていない状態で型情報を保持し続けるので、特定の型が選択された状態の関数を利用するにはガード(isinstanceによる分岐)が必要。

def use(l: LoggerType) -> None:
    l.info("hello")
    use2(l)


def use2(l: logging.LoggerAdapter) -> None:
    l.info("hai")

use2()の部分でダメ。

$ mypy --strict 03union.py
03union.py:19: error: Argument 1 to "use2" has incompatible type "Union[Logger, LoggerAdapter]"; expected "LoggerAdapter"

例えば以下の様に変える必要がある。

def use(l: LoggerType) -> None:
    l.info("hello")
    if isinstance(l, logging.LoggerAdapter):
        use2(l)

Protocolを利用するとは?

Protocolは静的なducktypingと見做せる。同様に具体的な型に直接変換されたりなどもしない。

$ mypy --strict 04protocol.py
04protocol.py:37: error: Argument 1 to "use2" has incompatible type "LoggerProtocol"; expected "LoggerAdapter"

そしてisinstanceによる分岐は動的に行われるので以下の分岐はOK。

def use(l: LoggerProtocol) -> None:
    l.info("hello")
    if isinstance(l, logging.LoggerAdapter):
        use2(l)


def use2(l: logging.LoggerAdapter) -> None:
    l.info("hai")

一度制限されたProtocolになったものをより豊かなProtocolとして扱うことはできない。

$ mypy --strict 05protocol.py
05protocol.py:61: error: Argument 1 to "use2" has incompatible type "LoggerProtocol"; expected "LoggerProtocol2"
05protocol.py:61: note: 'LoggerProtocol' is missing following 'LoggerProtocol2' protocol member:
05protocol.py:61: note:     debug

このとき

def use(l: LoggerProtocol) -> None:
    l.info("hello")
    use2(l)


def use2(l: LoggerProtocol2) -> None:
    l.info("hello")


class LoggerProtocol2(Protocol):
    def debug(
        self,
        msg: t.Any,
        *args: t.Any,
        exc_info: _ExcInfoType = ...,
        stack_info: bool = ...,
        extra: t.Optional[t.Dict[str, t.Any]] = ...,
        **kwargs: t.Any,
    ) -> None:
        ...

    def info(
        self,
        msg: t.Any,
        *args: t.Any,
        exc_info: _ExcInfoType = ...,
        stack_info: bool = ...,
        extra: t.Optional[t.Dict[str, t.Any]] = ...,
        **kwargs: t.Any,
    ) -> None:
        ...

逆はOK。LoggerProtocol2はLoggerProtocolを含んでいるので。

$ mypy --strict 06protocol.py

このとき

def use(l: LoggerProtocol2) -> None:
    l.info("hello")
    use2(l)


def use2(l: LoggerProtocol) -> None:
    l.info("hello")

両方利用した場合

ちなみにlogging.LoggerAdapterもlogging.Loggerも両者を含んでいるので以下もOK。つまりUnion[logger,LoggerAdapter] も LoggerProtocolを満たす。

$ mypy --strict 07both.py

このとき

def use(l: LoggerType) -> None:
    l.info("hello")
    use2(l)
    use3(l)


def use2(l: LoggerProtocol) -> None:
    l.info("hello")


def use3(l: LoggerProtocol2) -> None:
    l.info("hello")

gist

まじめにgoでDIを考える前のメモ

そろそろDIについてもまじめに考える必要が出てきたので考えることにする(この記事では終わらない)。たまには答えになっていないようなメモでも。

何となく最近思うのは、goゆえの制約はあっても、goだからで省略できる特別なことは特に無いなということ(省略できるかどうかは書こうとしてるものの領域に依存してる)。

あと、アプリケーションのコードが書ける人と、ライブラリのコードが書ける人と、設計をやる人はいても、ライブラリを上手くアプリケーション側に統合できる人は少ないのかもしれないというようなこと。

いつDIを気にしたくなるか

いつDIを気にしたくなるかあるいはどういう状態なら必ずどうしてもDIのことを考える必要が出てくるかについてもメモしておきたい。DIを本気で考える必要があるというタイミングは個人的には以下かなと想った。

依存の依存が共有されたとき

前提として設定による分岐と文脈による分岐が分離できている必要がある。そして設定の分岐による生成が面倒というところで片足DIに踏み込む。特にconfigとコンポーネント(ないしはその依存ライブラリ)との結びつきがめんどうという話と複数のバイナリを相手にしたくなったとき。

ここまではまだどうにか無くても頑張っていけるが依存の依存が共有されたときが無理。

依存の依存が共有されたとき

図示すると以下の様な形。AはXからもYからも依存されている。

A -> X
A -> Y

ここで、例えば以下の様なXやYのファクトリー関数をいちいち変更するのが大変という状況で

x/New :: A -> something* -> x/X
y/New :: A -> something* -> y/Y

設定からX,Yを作る関数を定義するだけでは避けられないとき

XFromConfig :: conf -> x/X
YFromConfig :: conf -> y/Y
// 内部的にAを共有したい

(その他登録するコンポーネントはファクトリーであるべきだとか細々とした実装依存はある)

ところでなぜ設定からX,Yを作る関数を定義したくなるかというとXやYの依存が時折変わるから。その際に数十のmain.goを書き換えて回るのは不毛。

どう対応するつもりか

wireとかを調べたのだけれど、なるべく段階的に移行したかったり、なるべく特殊なコマンドの実行が必須になることは後々に遅延したい。

対応予定の方針

個人的には以下のような方針で対応することを考えている。

  • sharedなパッケージを作りここでコンポーネントを作成
  • registry的なオブジェクトに登録する
  • ただし、そのままだと全ての依存が全てのバイナリの依存になるので直接は使わないようにbuild

ここで特別なコマンドが必須になりそうでそれなら既存の何かを上手に使った方が良いのではという気持ちになっている。

制約

ただし以下の様なことも気にしたいと思っている(制約)。

  • main.goひとつだけを指定してgo runで動かせることは死守したい
  • main.go内のコードが太るのは悪
  • ビルドタグで分岐という形で持っていくと楽だけれどビルドシステム必須ということはなるべく避けたい

間違いなさそうだなと思っているのは、個々のバイナリでの不要な依存を断ち切るにはコード生成(コード出力)が必須だということ。一方で依存最小を考えすぎるのはもはやロートル的な思考という感じもしなくもない(機械学習系の何かとかはそのまま入れるとひどいことになるし(goとは無関係))。

misc的なこと

misc的なメモを。まずDIコンテナとかは実装の詳細の話な気がするので依存管理の本質ではないような感覚がある。とはいえ手軽ななにかで済ませられるなら済ませたい。

あとこの辺のパッケージは調べてみたりした。

例えばwireなどをまじめに使っている人などの話を聞いてみたりしたい。(一応触ったことはあるしこれらについて意見は持っているものの)。