jupyterを支える技術：traitlets（の解読を試みようとした話）

Python Advent Calendar 2019 23日目の記事です。

## はじめに

皆さんは、traitletsというライブラリをご存知でしょうか？
私も少し前にjupyter notebookの実装を眺めていて、存在を知りました。元々IPythonの開発から生まれて切り離されたライブラリのようです。なので、IPythonやjupyter notebookを使用されている方はお世話になっていますし、なんなら知らない内に使っていたりします。

というのも、jupyter notebookやIPythonの設定ファイルは、traitletsを使って読み込まれているからです例えば`jupyter_notebook_config.py`[^1]や`ipython_config.py`[^2]を編集して

```python
# 基本コメントアウトされています
c.Application.log_datefmt = '%Y-%m-%d %H:%M:%S'
```
みたいな記述を見たor編集した経験のある方もおられるかもしれません。


[^1]: 生成済みの方は`jupyter --path`で打ってでてくる、`config:`の指すディレクトリ(基本はホーム直下の`.jupyter`)にあります。未設定の方は、`jupyter notebook --generate-config`で生成できます。

[^2]: `ipython locate`で出てくるディレクトリ以下にプロファイルディレクトリ(profileで始まる)がある方はそこにあります。未作成の方は、`ipython profile create`(名前を指定しない場合)で生成できます。

実はここで出てくる、謎の`c`は、traitletsのConfigクラスのインスタンスとなっています。そして、`c.Application.log_datefmt = ...`と書いた時、これはこの記述が書かれた設定ファイルを読み込むConfigurableクラス（実際は、それらを束ねるApplicationクラス？）が管理するApplicationクラスのlog_datefmtというメンバ変数に`...`の値が割り当てられます。


実際、jupyter notebookのコアクラスとも言えるNotebookAppクラス([定義](https://github.com/jupyter/notebook/blob/76a323e677b7080a1e9a88437d6b5cea6cc0403b/notebook/notebookapp.py#L567))は、jupyter_coreモジュールのJupyterAppクラス([定義](https://github.com/jupyter/jupyter_core/blob/b129001bb88abf558df5dfab791a5aeeba79e25c/jupyter_core/application.py#L61))を継承していますが、このJupyterAppクラスは、traitletsのApplicationクラスを継承([ここ](https://github.com/jupyter/jupyter_core/blob/b129001bb88abf558df5dfab791a5aeeba79e25c/jupyter_core/application.py#L30))しています。


本記事は、このtraitletsがどういうライブラリか、ちょっと調べてみたけど、ドキュメントが雑で結局ちゃんと理解できなかったから、とりあえず存在だけ広めておく、という目的で書きました。

そして以下は、[使い方](https://traitlets.readthedocs.io/en/stable/using_traitlets.html)の翻訳＋内容希釈した文書となります。


## Traitletsの使い方

pythonは、いわゆる"型"が動的に決まるため、明示的に書かない限り、クラスの属性（メンバ変数）に、好き勝手な値を割り当てる事が可能です。このクラスの属性の型をきちんと定めて、さらに細かいチェック機能を簡単に呼び出せるようにしておこう、というのがtraitletsの提供する役割の１つだと理解しています。実際はjupyterやipython実装でも用いられている設定ファイルの読み込みのほうが、主役な機能である気もしますが...

### 型チェック機能

HasTraitsのサブクラスFooを次のように定義します：

```python
from traitlets import HasTraits, Int

class Foo(HasTraits):
    bar = Int()
```
これは、通常のクラスと同様に、Fooというクラスにbarという属性(attribute)を持たせています。ただし通常のクラス変数と違い、これは**trait**と呼ばれる特殊な属性になっています。
特に、このbarはintと呼ばれるタイプのtraitになっており、名前から分かるように整数値を格納するtraitです。

実際にインスタンスを生成してみましょう：

```python
> foo = Foo(bar=3)
> print(foo.bar)
3
> foo.bar = 6
> print(foo.bar)
6
```
でfooは整数値"型"のbarという属性を持っており、値を変更することも可能です。

一方で、文字列を与えてみるとどうでしょうか？

```python
> foo = Foo(bar="3")
TraitError: The 'bar' trait of a Foo instance must be an int, but a value of '3' <class 'str'> was specified.
```
このような型の割り当てが間違っている旨のエラーメッセージが出るはずです。これにより、`__setattr__`などを用いて、型チェックを自分で実装する必要がなくなります。

ここでInt型しか紹介していませんが、Listなどのコンテナ型含め、幾らか用意されていますし、自分で定義する事も可能です。詳しくはドキュメントを参照してください。

### デフォルト値設定

traitletでは、デフォルト値を、インスタンス生成時に動的に指定できます。ちなみに先の`Int`というtraitタイプでは、何も指定しないとデフォルト値として0がセットされます：

```python
> foo = Foo()
> print(foo.bar)
0
```

次の例では、todayというtraitに、今日の日付を格納しています。

```python
from traitlets import Tuple

class Foo(HasTraits):
    today = Tuple(Int(), Int(), Int())

    @default("today")
    def default_today(self):
        import datetime
        today_ = datetime.datetime.today()
        return (today_.year, today_.month, today_.day)

> foo = Foo()
> foo.today
(2019, 12, 22)
```

ちなみにコードを見れば一目瞭然ですが、todayのtraitタイプは、整数値3つから成るtupleです。
`Tuple`のデフォルト値は、`()`なので、デフォルト値を明示しなかったり、インスタンス生成時に値を指定したりしないと、型が違うので、割り当てエラーが発生する事に注意してください。

なお、これは次のように書くのとおそらく等価だと思いますが、ロジックの切り分け、という観点からは前者の方が明らかに読みやすいですね：

```python
class Foo(HasTraits):
    today = Tuple(Int(), Int(), Int())

    def __init__(self):
        import datetime
        today_ = datetime.datetime.today()
        self.today = (today_.year, today_.month, today_.day)
```

### 値の検証

次に紹介するのは、値割り当ての検証機能です。型チェックができるようになっても、その値が適切かどうかは分かりません。例えば、あるtrait（何かの個数を表すとしましょう）が非負整数である事が要求される場合に、`Int`だけでは不十分です。

もっと言えば、この制限が、別のtraitに依存している可能性もあります。例えば、月を格納したmonthと、日を格納したdayがある場合に、monthの値によって許されるdayの範囲が変わります。こういったチェックを行うのが、`validate`です。

ここでは、11月と12月だけ実装してみます。

```python
from traitlets import validate

class Foo(HasTraits):
    today = Tuple(Int(), Int(), Int())

    @validate('today')
    def _valid_month_day(self, proposal):
        year, month, day = proposal['value']
        if month not in [11,12]:
            raise TraitError('invalid month')
        if month == 11 and day not in range(1,31):
            raise TraitError('invalid day')
        elif month == 12 and day not in range(1,32):
            raise TraitError('invalid day')
        return proposal['value']

> foo = Foo(today=(2000,12,1))
> foo.today
(2000, 12, 1)
> foo.today = (2000,13,1)
TraitError: invalid month
> foo.today = (2000,12,31)
> foo.today = (2000,12,32)
TraitError: invalid day
```

なお、複数のtrait変数が相互参照している場合、１つ値を変更していくと、途中で検証エラーに引っかかる可能性があります。このような場合、全てのtraitが変更されまで検証をスキップする必要があります。これは`hold_trait_notifications`スコープ内で実現できます。以下の例を見てましょう：

```python
class Foo(HasTraits):
    a, b = Int(), Int()
    @validate('a')
    def _valid_a(self, proposal):
        if proposal['value'] * self.b <= 0:
            raise TraitError("invalid a")
        return proposal['value']
    @validate('b')
    def _valid_b(self, proposal):
        if proposal['value'] * self.a <= 0:
            raise TraitError("invalid b")
        return proposal['value']

> foo = Foo(a=1,b=1)
> foo.a = -1
> foo.b = -1
TraitError: invalid a
> with foo.hold_trait_notifications():
>     foo.a = -1
>     foo.b = -1
> print(foo.a, foo.b)
-1 -1
```

この例では、a,bという2つのtraitが定義されていますが、それらの積は非負である事が要求されるとします。すると、両方の値を負にしても、この検証は通りますが、片方だけ変更すると、検証エラーが発生してしまいます。一方、`hold_trait_notifications`内でa, b両traitの値を変更すると、このスコープ終了時に遅延検証されるので、そのような心配がなくなります。

### 変更通知


最後に紹介するのは、traitにobserverパターンを実装する機能です。これは、指定したtraitの値が書き換わった時に（イベント発生）、何か処理を行うことができます。

```python
class Foo(HasTraits):
    bar = Int()

    @observe('bar')
    def _observe_bar(self, change):
        ...
```
は、もはや完全なコードではないですが、barというtraitの値が変更された際に、_observe_barという関数が実行されます。

---

以上、めちゃくちゃ内容が薄いですが、初めてのプログラミング言語系の投稿、という事でこれでお許しを。また、traitlets詳しい方いたら、寂しすぎるドキュメントや[examples](https://github.com/ipython/traitlets/tree/master/examples)を充実させてください...