[python] attrs ライブラリに自動バリデーション機能をデコレータで追加してみた

この記事は 富士通クラウドテクノロジーズ Advent Calendar 2017 の9日目です。
昨日は @ntoofu さんの IaaS基盤をテストする環境を作る話 でした。
当たり前ですが、 IaaS が大きくなるほど運用は大変になるのでぜひ理想的なテスト環境の構築を実現していただきたいですね!

概要

python の attrs というライブラリをご存知でしょうか。
これは python のクラス定義を簡潔に書くことが出来るようになるライブラリで、オプションからインスタンス変数のバリデーションなどを定義することができます。
今回はこの attrs ライブラリの挙動を モンキーパッチ 感覚でデコレータを利用して変更してみました。
今回使用した実行環境は python 3.6.3 です。

attrs とは

概要でも簡単に説明しましたが、 attrs とは python のクラス定義の記述方法を変更し、コード量と可読性を減らすことを目的としたライブラリです。
attrs を利用することでコードがどのように変更されるのかを具体例で見てみましょう。

  • 普通の python のクラスの書き方
class Menu:

    def __init__(self, name, price=500):
        self.name = name
        self.price = price
  • attrs を利用したクラスの書き方
import attr


@attr.s
class Menu:
    name = attr.ib()
    price = attr.ib(default=500)

うーん...。少しだけ簡単になったような気がします。
では次にこれにバリデーションを追加してみます。

  • 通常の場合
class Menu:

    def __init__(self, name, price=500):
        self.name = name
        self.price = price

    @property
    def name(self):
        return self._name

    @a.setter
    def name(self, val):
        if not isinstance(val, str):
            raise TypeError('name must be <class str>.')
        self._name = val

    @property
    def price(self):
        return self._price

    @a.setter
    def price(self, val):
        if not isinstance(val, int):
            raise TypeError('price must be <class int>.')
        self._price = price
  • attrs の場合
import attr


@attr.s
class Menu:
    name = attr.ib(validator=attr.validators.instance_of(str))
    price = attr.ib(validator=attr.validators.instance_of(int), default=500)

どうでしょうか。かなり簡潔に書けるようになったかなと思います。
インスタンス変数にバリデーションをかけたい場合などには便利そうなライブラリに見えますね!

この他にもすべての変数を変更不可にするなどのオプションがありますので、気になった方は 公式サンプル を御覧ください。

attrs の問題点

前節では attrs のバリデーション機能が便利そうというお話をしましたが、実はこのバリデーション機能には落とし穴があります。
それはインスタンス化時にはバリデーションがかかるが、後からインスタンス変数に代入したとき (setter を呼んだとき) にはバリデーションが実行されず、別途 attr.validate() という関数を呼ぶ必要があるという点です。

  • クラス定義
In [1]: import attr

In [2]: @attr.s
   ...: class Menu:
   ...:     name = attr.ib(validator=attr.validators.instance_of(str))
   ...:     price = attr.ib(validator=attr.validators.instance_of(int), default=500)
   ...:

  • インスタンス化時にはバリデーションが正常に動作する
In [3]: menu = Menu(1, 200)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-3-301c1ef48d44> in <module>()
----> 1 menu = Menu(1, 200)

<attrs generated init 91f6999608033e1467ab8d8567315a259cc5d3c9> in __init__(self, name, price)
      3     self.price = price
      4     if _config._run_validators is True:
----> 5         __attr_validator_name(self, __attr_name, self.name)
      6         __attr_validator_price(self, __attr_price, self.price)

/Users/sato-mh/.pyenv/versions/3.6.3/envs/global/lib/python3.6/site-packages/attr/validators.py in __call__(self, inst, attr, value)
     31                 .format(name=attr.name, type=self.type,
     32                         actual=value.__class__, value=value),
---> 33                 attr, self.type, value,
     34             )
     35

TypeError: ("'name' must be <class 'str'> (got 1 that is a <class 'int'>).", Attribute(name='name', default=NOTHING, validator=<instance_of validator for type <class 'str'>>, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None), <class 'str'>, 1)

  • インスタンス変数への代入時に時にはバリデーションが動作しない
In [4]: menu = Menu('ハンバーグ', 800)

In [5]: menu.name = 1

  • attr.validate() を呼んだ際にバリデーションが動作する
In [6]: attr.validate(menu)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-6-3ac5fb0e9042> in <module>()
----> 1 attr.validate(menu)

/Users/sato-mh/.pyenv/versions/3.6.3/envs/global/lib/python3.6/site-packages/attr/_make.py in validate(inst)
    929         v = a.validator
    930         if v is not None:
--> 931             v(inst, a, getattr(inst, a.name))
    932
    933

/Users/sato-mh/.pyenv/versions/3.6.3/envs/global/lib/python3.6/site-packages/attr/validators.py in __call__(self, inst, attr, value)
     31                 .format(name=attr.name, type=self.type,
     32                         actual=value.__class__, value=value),
---> 33                 attr, self.type, value,
     34             )
     35

TypeError: ("'name' must be <class 'str'> (got 1 that is a <class 'int'>).", Attribute(name='name', default=NOTHING, validator=<instance_of validator for type <class 'str'>>, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None), <class 'str'>, 1)

バリデーションの定義が簡潔に書けるのはメリットですが、変数に別の値を代入するたびに attr.validate() を呼ぶのは非常に面倒です。
個人開発の場合はまだしも、チーム開発の場合はその運用をメンバー全員に強要しなければならずバグの原因になるかもしれません。

自動バリデーション機能の実装

この setter 時にバリデーションが実行されない問題を解決するため、今回は python のデコレータを活用して自動バリデーション機能を実装しました。
デコレータについては下記の記事が分かりやすかったのでご参照ください。

デコレータの実装

今回作成したデコレータはこちらです。

def auto_validate(cls):
    super_setattr = cls.__setattr__

    def __setattr__(self, name, value):
        super_setattr(self, name, value)
        attrs = attr.fields(cls)
        target = [a for a in attrs if a.name == name]
        if len(target) < 1:
            return None
        target = target[0]
        if target.validator is not None:
            target.validator(self, target, value)

    cls.__setattr__ = __setattr__
    return cls

コードについて少し解説します。
まず、引数の cls はデコレートされるクラスを表します。
また、 __setattr__特殊メソッド と呼ばれるものの一つで、クラスの setter 時に呼ばれる関数です。
今回実装したい機能は setter 時のバリデーション機能なので、基本的な方針としては新しい __setattr__ を定義し、それをデコレートされるクラスの __setattr__ に上書きします。

ここで注意したいのは、単純にバリデーション機能だけを記述した __setattr__ を定義してしまうと、 menu.name = 'カレーライス' とした際にバリデーションのみが実行されて、インスタンス変数に新しい値が代入されなくなってしまうという点です。
そこで、予めデコレートされるクラスのもともとの __setattr__ を別の変数に退避させておき、新しく定義する __setattr__ の中で呼ぶという処理をしています。
それがコード中の super_setattr になります。

ここまで理解できれば、あとは条件を満たした際にバリデーションを実行するという処理を追加することで、今回実装したかった機能を作成することができます。

利用方法

このデコレータを利用することで、 attrs で定義したバリデーションが setter 時にも適用されるようになります。
使い方は下記の通りです。

In [8]: @auto_validate
   ...: @attr.s
   ...: class Menu:
   ...:     name = attr.ib(validator=attr.validators.instance_of(str))
   ...:     price = attr.ib(validator=attr.validators.instance_of(int), default=500)
   ...:

In [9]: menu = Menu('ハンバーグ', 800)

In [10]: menu.name = 1
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-10-9fc3b59e311b> in <module>()
----> 1 menu.name = 1

<ipython-input-8-3fb3af2c8ebd> in __setattr__(self, name, value)
     10         target = target[0]
     11         if target.validator is not None:
---> 12             target.validator(self, target, value)
     13
     14     cls.__setattr__ = __setattr__

/Users/sato-mh/.pyenv/versions/3.6.3/envs/global/lib/python3.6/site-packages/attr/validators.py in __call__(self, inst, attr, value)
     31                 .format(name=attr.name, type=self.type,
     32                         actual=value.__class__, value=value),
---> 33                 attr, self.type, value,
     34             )
     35

TypeError: ("'name' must be <class 'str'> (got 1 that is a <class 'int'>).", Attribute(name='name', default=NOTHING, validator=<instance_of validator for type <class 'str'>>, repr=True, cmp=True, hash=None, init=True, convert=None, metadata=mappingproxy({}), type=None), <class 'str'>, 1)

まとめ

今回は attrs というライブラリのバリデーションの挙動をデコレータを用いて変更してみました。
クラス定義時にデコレータを追加するだけなので、毎回 attr.validate() を呼ぶ運用よりはずいぶん楽になったかなと思います。
しかし、このようなライブラリの挙動変更はコードの可読性の低下やバグを生む原因になることもありますので、用法用量を守って利用することをおすすめします。
(やってるうちに楽しくなっていろいろと機能追加して遊びたくなってしまいますので...w)

実は今回取り上げた setter の呼び出し時にバリデーションが動作するようにしてほしいという要望は issue にもなっています。
2017/12/09 の段階ではまだ利用できませんが、今後デフォルトの機能として setter 時のバリデーションが使えるようになるとうれしいですね。

明日は @alice02 さんが「サーバ脆弱性スキャンの定期実行・結果通知を自動化してみた」というお話をしてくれるようです。
脆弱性スキャンはサービス運用をする上で重要な要素だと思いますのでとても楽しみですね!

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.