名古屋出身ソフトウェアエンジニアのブログ

オプション引数をとるデコレータを楽に定義できるデコレータ

公開:
更新:

Python で定義されるデコレータは、以下のようにキーワードオプションによって挙動が parameterized されているものが多いです。また、オプションをデフォルトから変更しないときは、オプションを渡すための関数適用を省略できるように巧妙に定義されているのが慣例です。そしてこれは実際便利です。

@functools.lru_cache()
def f(): ...

@functools.lru_cache(maxsize=758)
def g(): ...

@functools.lru_cache
def h(): ...

デコレータ関数定義時に、この分岐を毎回作成しているとかなり面倒であることが分かったので、このイディオマティックなインターフェースを素早く作成できる万能クラスを作りました。wrapt パッケージでも似たようなことはできると思いますが、おそらく細かい仕様が異なっています。

import functools
import inspect
from collections.abc import Callable


class kwops_decorator:
  """
  修飾対象を第一引数に取り、以降にキーワード引数を取れる「オプション付きデコレータ」を簡単に定義できるようにするデコレータ

  適用対象:
  - deco(f, /, **kwargs): 通常の関数によるデコレータ
  - deco(self, f, /, **kwargs): インスタンスメソッド由来のデコレータ
  - deco(cls, f, /, **kwargs): クラスメソッド由来のデコレータ
  - deco(klass, /, **kwargs): クラス向けデコレータ
  - deco(self, klass, /, **kwargs): クラス向けデコレータ(インスタンスメソッド由来)
  - deco(cls, klass, /, **kwargs): クラス向けデコレータ(クラスメソッド由来)
  """

  def __init__(self, deco: Callable, /):
    self._deco = deco
    functools.update_wrapper(self, deco)

    params = tuple(inspect.signature(deco).parameters.values())
    # デコレーション対象(作りだすデコレータ)では、*args は使用禁止
    if tuple(p for p in params if p.kind == inspect.Parameter.VAR_POSITIONAL):
      raise TypeError()
    # デコレーション対象(作りだすデコレータ)は、デフォルト値なしの位置引数を 1 個受け入れるよう定義する必要あり
    # ただしメソッドの場合は、2 個受け入れるよう定義する
    req_pos = [p for p in params if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) and p.default is inspect.Parameter.empty]
    match len(req_pos):
      case 1:
        self._expects_owner_and_target = False
      case 2:
        # owner + target を要求する場合(例: def deco(self/cls, f, /, ...))
        self._expects_owner_and_target = True
      case _:
        raise TypeError()

  # @decorator(...)
  # @classmethod / @staticmethod の順番で積めるようにするための細工が必要

  def _apply(self, owner, target, *args, **kwargs):
    kind, f = self._unwrap_descriptor(target)
    if owner is None:
      res = self._deco(f, *args, **kwargs)
    else:
      res = self._deco(owner, f, *args, **kwargs)
    return self._rewrap_descriptor(kind, res)

  @staticmethod
  def _unwrap_descriptor(obj):
    if isinstance(obj, classmethod):
      return classmethod, obj.__func__
    if isinstance(obj, staticmethod):
      return staticmethod, obj.__func__
    return None, obj

  @staticmethod
  def _rewrap_descriptor(kind, func):
    if kind is classmethod:
      return classmethod(func)
    if kind is staticmethod:
      return staticmethod(func)
    return func

  @staticmethod
  def _is_decoratee_like(obj):
    return callable(obj) or inspect.isclass(obj) or isinstance(obj, (classmethod, staticmethod))

  # インスタンスメソッド以外から作成されたデコレータのエントリー
  def __call__(self, *args, **kwargs):
    # classmethod 由来のデコレータ(cls が余計に注入される)
    if self._expects_owner_and_target and args and inspect.isclass(args[0]):
      cls, *rest = args
      if len(rest) >= 2:
        raise TypeError()

      # @decorator
      if rest and not kwargs and self._is_decoratee_like(rest[0]):
        return self._apply(cls, rest[0])

      # @decorator(...)
      def real_decorator(f):
        return self._apply(cls, f, *rest, **kwargs)

      return real_decorator

    if len(args) >= 2:
      raise TypeError()

    # classmethod 以外のとき
    # @decorator
    if len(args) == 1 and not kwargs and self._is_decoratee_like(args[0]):
      return self._apply(None, args[0])

    # @decorator(...)
    def real_decorator(f):
      return self._apply(None, f, *args, **kwargs)

    return real_decorator

  # インスタンスメソッドからデコレータを作成した場合ディスクリプタを経由する
  def __get__(self, instance, owner):
    if instance is None:
      return self

    @functools.wraps(self._deco)
    def bound(*args, **kwargs):
      if len(args) >= 2:
        raise TypeError()

      # @decorator
      if len(args) == 1 and not kwargs and self._is_decoratee_like(args[0]):
        return self._apply(instance, args[0])

      # @decorator(...)
      def real_decorator(f):
        return self._apply(instance, f, *args, **kwargs)

      return real_decorator

    return bound

次回記事で使い方の例を示すので、この記事での詳細な説明は割愛します。

Python におけるリトライ抽象を再考する
前回記事では、オプション引数をとるデコレータを楽に定義できるデコレータである kwops_decorator を作成しました。 今回は実演がてら、Python のリトライ処理記述ライブラリ Tenacity を題材に、よく使うリトライ表現を抽象化してみます。 Tenacity は基本的に、実行対象の関数にデコレータを付けることで、その関数はリトライされるべきであることを表現するのですが、関数内でリトライ回数をカウントするためには外のスコープに変数や、クラスにインスタンス変数を置いて自分で数える必要があり、カウントのリセットなども考慮すると微妙に面倒です。そこの改善を主目的に、kwops_decorator を活用し Tenacity のデコレータをラップします。