@dataclass のような、 () を付けても付けなくてもいいデコレータはどう作る?

@dataclass のような、 () を付けても付けなくてもいいデコレータはどう作る?

Event

Python駿河 勉強会 #28

Presented

2021/08/28 nikkie

11月 #pycon_shizu 開催🎉 楽しみですね

お前、誰よ

  • Python大好き にっきー @ftnext / @ftnext

  • Python歴3年半。株式会社ユーザベースのデータサイエンティスト(NLPer)

  • アニメも大好き(🌟💫🐙💫🌟 🌲🌳🐲 2️⃣0️⃣0️⃣6️⃣♻️)

お前、誰よ:PyCon JP 2021 座長🇨🇭

PyCon JP 2021、チケット発売開始です!🎫🙏

https://pyconjp.connpass.com/event/221241/

LT本題: @dataclass のような、 () を付けても付けなくてもいいデコレータを作りたい

ドキュメント より、以下の3つは同等


@dataclass  # ()を付けない
class C:
    ...

@dataclass()  # ()を付ける
class C:
    ...

# デフォルトの値で各引数を指定
@dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)
class C:
    ...

@dataclass のようなデコレータの要件

  1. @decorator@decorator()同じ

  2. その上で、 引数も渡せる

そもそもデコレータとは

別の関数を返す関数で、通常、 @wrapper 構文で関数変換として適用されます( 用語集 より)

デコレータで関数呼び出しの前後に 追加コード を実行できる

🚨 デコレータは関数とクラスに付けられる

  • 今回 () を付けても付けなくてもいいデコレータを作りたかった

  • そのデコレータは 関数 をデコレートするのに使う

  • 実装する上で、知っている範囲から @dataclass を参考にした

デコレータの書き換え(イメージ)

@dataclass の3つの例について、 だいたい等価 な書き方( 参考


@dataclass  # C = dataclass(C)
class C:
    ...

@dataclass()  # C = dataclass()(C)
class C:
    ...

# C = dataclass(init=True, ..., frozen=False)(C)
@dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)
class C:
    ...

@dataclass はどう実現している?🔍


def dataclass(cls=None, /, *, init=True, repr=True, eq=True, order=False,
              unsafe_hash=False, frozen=False):

    def wrap(cls):
        return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)

    if cls is None:
        return wrap

    return wrap(cls)

Lib/dataclasses.py@dataclass の実装 1/4


# 引数に **デフォルト値** が設定されている
def dataclass(cls=None, /, *, init=True, repr=True, eq=True, order=False,
              unsafe_hash=False, frozen=False):

    def wrap(cls):
        return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)

    if cls is None:
        return wrap

    return wrap(cls)

Lib/dataclasses.py@dataclass の実装 2/4


def dataclass(cls=None, /, *, init=True, repr=True, eq=True, order=False,
              unsafe_hash=False, frozen=False):

    def wrap(cls):
        # クロージャ:外側のスコープ(dataclass)の変数initなどを参照。
        # 外側のスコープ(dataclass)の変数はデコレータの呼び出しで設定される
        return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)

    if cls is None:
        return wrap

    return wrap(cls)

Lib/dataclasses.py@dataclass の実装 3/4


def dataclass(cls=None, /, *, init=True, repr=True, eq=True, order=False,
              unsafe_hash=False, frozen=False):

    def wrap(cls):
        return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)

    # @dataclass() や @dataclass(frozen=True) では cls is None
    if cls is None:
        # C = dataclass()(C) 👉 C = wrap(C)
        return wrap

    return wrap(cls)

Lib/dataclasses.py@dataclass の実装 4/4


def dataclass(cls=None, /, *, init=True, repr=True, eq=True, order=False,
              unsafe_hash=False, frozen=False):

    def wrap(cls):
        return _process_class(cls, init, repr, eq, order, unsafe_hash, frozen)

    if cls is None:
        return wrap

    # @dataclass では cls は、デコレータに続いて定義されるクラスを指す
    # C = dataclass(C) 👉 C = wrap(C) となっている
    return wrap(cls)

@dataclass を参考に実装したデコレータ @using_firefox

copy_existing_event.py


@using_firefox
@logged_in
def show_copy_popup(url):
    copy_existing_event(url, human_confirms=True)

Helium

  • ブラウザ操作 ライブラリ(2600 star)

  • Seleniumのラッパーで、 非常に簡単 に書ける!💫

  • 詳しくは 9/11(土) #pycharity のLTで共有します

@using_firefox

  • ブラウザ(Firefox)を起動する処理 helium.start_firefox は必ず呼ぶ

  • デコレータで実装することで、 関数呼び出しの前に必ず追加 できる!🤩

  • さらに @using_chrome とデコレータを変えたら起動するブラウザも変わる実装🆒

@using_firefox(options=...)

  • helium.start_firefoxselenium.webdriver.FirefoxOptions を渡したい

    • 例:ダウンロードのポップアップを出さないようにFirefoxを設定する

  • 1つのデコレータ @using_firefox で実現したく今回取り組んだ

@using_firefox(options=...) の例

download_participants_csv.py


@using_firefox(options=options)
@logged_in
def download(url):
    download_latest_participants_csv(url)

@dataclass にならって実装


def using_firefox(func=None, /, *, options=None):
    def middle(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            start_firefox(options=options)  # 関数呼び出し前に追加したかった!
            func(*args, **kwargs)

        return wrapper

    if func is None:
        return middle

    return middle(func)

@using_firefox() でデコレートしたとき( () を付けて)


def using_firefox(func=None, /, *, options=None):
    # middle の定義は省略

    if func is None:
        # f = using_firefox()(f) 👉 f = middle(f)
        return middle

    return middle(func)

@using_firefox でデコレートしたとき( () を付けないで)


def using_firefox(func=None, /, *, options=None):
    # middle の定義は省略

    if func is None:
        return middle

    # f = using_firefox(f) 👉 f = middle(f)
    return middle(func)

まとめ🌯: @dataclass のような、 () を付けても付けなくてもいいデコレータはどう作る?

PyCon JP 2021 チケットお願いします🐦🍕🙏

https://pyconjp.connpass.com/event/221241/

() を付けても付けなくてもいいデコレータの作り方

  • 第1引数 func / clsデフォルト値None にする

  • func / cls の値で 分岐

() を付けても付けなくてもいいデコレータの作り方(承前)

  • func / cls の値で分岐

    • None なら、クラス/関数を引数に受け取る 関数 を返す( () 付きに対応)

    • None でないなら、クラス/関数を引数に受け取る関数に func / cls を渡した 返り値 を返す( () なしに対応)

クラス/関数を引数に受け取る関数

  • 実装には クロージャ を利用

  • この関数の中で、 デコレータの引数を参照 して実装する

  • クロージャ: functions that refer to variables from the scope in which they were defined (『Effective Python second edition』Item 21 p.84)

ご清聴ありがとうございました

Enjoy development with decorators!

References、 Appendix が続きます(よろしければどうぞ!)

References:直近のデコレータ関連アウトプット⚡️

Appendix

  • 位置専用・キーワード専用引数

  • @dataclass のようなデコレータの別の例

  • print を仕込んで decorator()(f) 呼び出し順を確認

位置専用・キーワード専用引数

  • def using_firefox(func=None, /, *, options=None):

  • func位置専用 引数(🙅‍♂️ using_firefox(func=f)

  • optionsキーワード専用options=... と指定する必要がある(位置引数として指定できない 🙅‍♂️ using_firefox(func, options)

位置専用引数 in Pythonチュートリアル

位置専用 の場合、引数の順序が重要であり、キーワードで引数を渡せません。 位置専用引数は / (スラッシュ)の前に配置されます。

https://docs.python.org/ja/3/tutorial/controlflow.html#positional-only-parameters

キーワード専用引数 in Pythonチュートリアル

引数をキーワード引数で渡す必要があることを示す キーワード専用 として引数をマークするには、引数リストの最初の キーワード専用 引数の直前に * を配置します。

https://docs.python.org/ja/3/tutorial/controlflow.html#keyword-only-arguments

@dataclass のようなデコレータの別の例


def once_per_minutes(func=None, /, *, n=1):
    def middle(func):
        last_ran_at = 0

        @wraps(func)
        def wrapper(*args, **kwargs):
            nonlocal last_ran_at
            current_time = time.time()
            if current_time - last_ran_at < n * 60:
                raise RunTooOftenError(
                    f"Wait longer before running {func.__name__}"
                )
            last_ran_at = current_time
            value = func(*args, **kwargs)
            return value

        return wrapper

    if func is None:
        return middle

    return middle(func)

別の例のコンテキスト

  • 引数を取るデコレータの例:呼び出しはN分に1回(詳しくはReferencesのスライドをどうぞ)

  • 今回の内容をもとに、 引数を取らない使い方もできる ように拡張した

別の例を実行

実行環境 Python 3.9.4


>>> calculate_bmi1(1.58, 46)
18.426534209261334
>>> # calculate_bmi1(1.58, 46)  # Raise RunTooOftenError
>>> calculate_bmi2(1.58, 46)
18.426534209261334
>>> # calculate_bmi2(1.58, 46)  # Raise RunTooOftenError
>>> calculate_bmi3(1.58, 46)
18.426534209261334
>>> calculate_bmi3(1.58, 46)
18.426534209261334

print を仕込んで decorator()(f) 呼び出し順を確認


def decorator(func=None, /, *, n=1):
    print(f"decorator start: {func=} {n=}")

    def middle(func):
        print("middle start")

        def wrapper(*args, **kwargs):
            print(f"wrapper start: {args=}, {kwargs=}")
            func(*args, **kwargs)
            print("wrapper end")

        print("middle end")
        return wrapper

    print("decorator end")
    if func is None:
        return middle

    return middle(func)

(1) () なしでデコレート


>>> @decorator  
... def f1(): ...
...
decorator start: func=<function f1 at 0x1092a9a60> n=1  # funcにf1が渡っている
decorator end
middle start  # middle(func) を返したことによる実行
middle end

(2) () を付け、デフォルト値でデコレート


>>> @decorator()  
... def f2(): ...
...
decorator start: func=None n=1
decorator end
middle start  # f2 = middle(f2) 部分
middle end

寄り道: middle が返っている 1/2


>>> decorator()  
decorator start: func=None n=1
decorator end
<function decorator.<locals>.middle at 0x1092ba280>  # decorator() の返り値

寄り道: middle が返っている 2/2


>>> def f(): ...
...
>>> decorator()(f)  
decorator start: func=None n=1
decorator end
middle start
middle end
<function decorator.<locals>.middle.<locals>.wrapper at 0x1092ba430>
>>> f2  # decorator()(f) と同様に wrapper  
<function decorator.<locals>.middle.<locals>.wrapper at 0x1092ba550>

(3) () を付け、値も指定してデコレート


>>> @decorator(n=3)  
... def f3(): ...
...
decorator start: func=None n=3  # nは渡した値
decorator end
middle start
middle end

print で確認した呼び出し順

  • (1)~(3)の3例とも 出力されるメッセージは同様

  • つまり () の有無によらず、関数がデコレートされている

  • 分岐の実装により () の有無によらず、関数がデコレートされる

EOF