LoginSignup
6

More than 3 years have passed since last update.

jupyterを支える技術:traitlets(の解読を試みようとした話)

Posted at

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

はじめに

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

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

# 基本コメントアウトされています
c.Application.log_datefmt = '%Y-%m-%d %H:%M:%S'

みたいな記述を見たor編集した経験のある方もおられるかもしれません。

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

実際、jupyter notebookのコアクラスとも言えるNotebookAppクラス(定義)は、jupyter_coreモジュールのJupyterAppクラス(定義)を継承していますが、このJupyterAppクラスは、traitletsのApplicationクラスを継承(ここ)しています。

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

そして以下は、使い方の翻訳+内容希釈した文書となります。

Traitletsの使い方

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

型チェック機能

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

from traitlets import HasTraits, Int

class Foo(HasTraits):
    bar = Int()

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

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

> foo = Foo(bar=3)
> print(foo.bar)
3
> foo.bar = 6
> print(foo.bar)
6

でfooは整数値"型"のbarという属性を持っており、値を変更することも可能です。

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

> 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がセットされます:

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

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

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のデフォルト値は、()なので、デフォルト値を明示しなかったり、インスタンス生成時に値を指定したりしないと、型が違うので、割り当てエラーが発生する事に注意してください。

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

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月だけ実装してみます。

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

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の値が書き換わった時に(イベント発生)、何か処理を行うことができます。

class Foo(HasTraits):
    bar = Int()

    @observe('bar')
    def _observe_bar(self, change):
        ...

は、もはや完全なコードではないですが、barというtraitの値が変更された際に、_observe_barという関数が実行されます。


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


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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6