CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: OmegaConf を使ってみる

OmegaConf は、Python の Configuration フレームワークのひとつ。 Hydra が低レイヤー API に利用している、という点が有名だと思う。 というより、Hydra を使おうとすると OmegaConf の API が部分的にそのまま露出していることに気づく。 なので、OmegaConf を理解しておかないと、Hydra はちゃんと扱えないくらいまであると思う。 今回は、そんな OmegaConf の使い方を一通り見ていく。

OmegaConf には、ざっくりと次のような特徴がある。

  • 良い (と私が感じた) ところ

    • YAML 形式で設定ファイルを管理できる
    • 設定の Key-Value ペアを階層的に管理できる
    • Data Classes (PEP 557) を使って設定の構造を定義できる
    • Syntax for Variable Annotations (PEP 526) を使ってバリデーションできる
    • ある場所で定義されている Value を、別の Value から利用できる (Interpolation)
    • Interpolation の解決をカスタマイズする仕組みがある (Custom Resolver)
  • 発展途上 (と私が略) なところ

    • Value のバリデーションをカスタマイズする仕組みがない
    • プリミティブ型以外を Value として扱うための仕組みが限られている

検証に使った環境は次のとおり。

$ sw_vers                
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2
$ python -V            
Python 3.8.6
$ pip list | grep -i omegaconf
omegaconf                 2.0.4

下準備

あらかじめ OmegaConf をインストールしておく。

$ pip install omegaconf

設定を表すオブジェクトを作る

OmegaConf では omegaconf.OmegaConf というクラスのオブジェクトが設定を司っている。 まずは、あまり実用性はないサンプルコードを以下に示す。 このサンプルコードでは、設定の元ネタになる辞書から OmegaConf のインスタンスを生成している。 そして、それを YAML 形式で標準出力に書き出している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from omegaconf import OmegaConf


def main():
    # 辞書からコンフィグを読み込む
    dict_conf = {
        'name': 'Alice',
        # リストを含んだり
        'friends': ['Bob', 'Carol'],
        # ネストさせても良い
        'location': {
            'country': 'Japan',
            'prefecture': 'Tokyo',
        }
    }
    conf = OmegaConf.create(dict_conf)
    # 読み込んだ内容を出力する
    print(OmegaConf.to_yaml(conf))


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、辞書から読み込んだ設定が YAML 形式で表示される。

$ python example.py            
name: Alice
friends:
- Bob
- Carol
location:
  country: Japan
  prefecture: Tokyo

読み込んだ設定にアクセスする

先ほどのサンプルコードでは、辞書から読み込んだ内容を、ただ YAML 形式にしているだけだった。 続いては、読み込んだ内容にプログラムからアクセスする方法について示す。 以下のサンプルコードでは、OmegaConf に内包されている設定に、「object style」と「dictionary style」という 2 つの方法でアクセスしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from omegaconf import OmegaConf


def main():
    dict_conf = {
        'name': 'Alice',
        'friends': ['Bob', 'Carol'],
        'location': {
            'country': 'Japan',
            'prefecture': None,
        }
    }
    conf = OmegaConf.create(dict_conf)

    # 読み込んだコンフィグの使い方
    print(conf.location.country)  # object style
    print(conf['location']['country'])  # dictionary style

    # 取得するタイミングでデフォルト値を設定する場合
    print(conf.location.get('prefecture', 'Tokyo'))


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、辞書に入っていた内容に OmegaConf のインスタンス経由でアクセスできることがわかる。

$ python example.py            
Japan
Japan
Tokyo

ただ、このサンプルも実用上はあまり意味がない。 なぜなら、上記であれば OmegaConf を使うまでもなく辞書をそのまま使っていれば良い。

YAML 形式の設定ファイルから読み出す

続いては、もう少し実用的なサンプルになる。 以下のサンプルコードでは YAML 形式で書かれた設定ファイルから OmegaConf のインスタンスを生成している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import tempfile
import os

from omegaconf import OmegaConf


def main():
    yaml_conf = """
    name: Alice
    friends:
    - Bob
    - Carol
    location:
      country: Japan
      prefecture: Tokyo
    """
    with tempfile.NamedTemporaryFile(mode='w', delete=False) as fp:
        # 一時ファイルに YAML を書き込む
        fp.write(yaml_conf)
    # YAML ファイルから設定を読み込む
    conf_filepath = fp.name
    conf = OmegaConf.load(conf_filepath)
    """
    # あるいは File-like オブジェクトからも読み込める
    with open(conf_filepath, mode='r') as fp:
        conf = OmegaConf.load(fp)
    """
    # 読み込んだ内容を出力する
    print(OmegaConf.to_yaml(conf))
    # 後片付け
    os.remove(conf_filepath)


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、YAML 形式の設定ファイルから読み込まれた内容が、また YAML 形式で出力される。

$ python example.py            
name: Alice
friends:
- Bob
- Carol
location:
  country: Japan
  prefecture: Tokyo

とはいえ、このサンプルも別に OmegaConf を使う必要はない。 なぜなら、YAML 形式を Python の辞書と相互に変換するパッケージさえあれば実現できるため。

コマンドラインの引数から設定を読み込む

ここらへんから、じわりじわりと便利なポイントが見えてくる。 次のサンプルコードでは、スクリプトを実行する際に渡されたコマンドラインの引数から設定を読み込んでいる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from omegaconf import OmegaConf


def main():
    conf = OmegaConf.from_cli()
    print(OmegaConf.to_yaml(conf))


if __name__ == '__main__':
    main()

上記を実行してみよう。 以下では、コマンドラインの引数として server.port=8080server.host=127.0.0.1 を渡した。 すると、コマンドライン経由で仮想構造になった設定がちゃんと読み込まれていることがわかる。

$ python example.py server.port=8080 server.host=127.0.0.1
server:
  port: 8080
  host: 127.0.0.1

コマンドラインで指定するのがめんどくさいときは、次のようにしても良い。 以下では sys.argv の内容をスクリプトの中で上書きしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys

from omegaconf import OmegaConf


def main():
    sys.argv = [__file__,
                'server.port=8080',
                'server.host=127.0.0.1']
    conf = OmegaConf.from_cli()
    print(OmegaConf.to_yaml(conf))


if __name__ == '__main__':
    main()

複数の経路で読み込んだ設定をマージする

また、設定ファイルや辞書から作った OmegaConf をひとつにマージすることもできる。 次のサンプルコードでは、辞書の設定をベースとして、コマンドラインの設定で上書きしている。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys

from omegaconf import OmegaConf


def main():
    dict_conf = {
        'server': {
            'port': 80,
            'host': '127.0.0.1',
        }
    }
    base_conf = OmegaConf.create(dict_conf)

    sys.argv = [__file__,
                'server.host=0.0.0.0']
    cli_conf = OmegaConf.from_cli()

    # 複数のコンフィグをマージする
    merged_conf = OmegaConf.merge(base_conf, cli_conf)
    print(OmegaConf.to_yaml(merged_conf))


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、ベースとなった辞書の設定では server.host127.0.0.1 だったのに、その内容がコマンドライン経由で渡された 0.0.0.0 に上書きされている。

$ python example.py                                       
server:
  port: 80
  host: 0.0.0.0

Data Classes (PEP 557) で設定の構造を定義する

OmegaConf は、Python 3.7 から導入された Data Classes を使って設定の構造を定義できる。 以下のサンプルコードでは、先ほどの server.portserver.host という要素が含まれる設定を定義している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from dataclasses import dataclass

from omegaconf import OmegaConf


@dataclass
class ServerStructure:
    port: int = 80
    host: str = '127.0.0.1'


@dataclass
class RootStructure:
    server: ServerStructure = ServerStructure()


def main():
    # Data Classes を使って設定を読み込む
    conf = OmegaConf.structured(RootStructure)
    print(OmegaConf.to_yaml(conf))


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、Data Classes を使って設定の構造を定義できていることがわかる。 また、デフォルト値も読み込むことができている。

$ python example.py                                       
server:
  port: 80
  host: 127.0.0.1

Data Classes の有無 (structured / non-structured) の違いについて

続いては、Data Classes を使う場合と、使わない場合で OmegaConf のインスタンスにどのような違いがあるか見ておく。 以下のサンプルコードでは辞書から生成した OmegaConf と Data Classes で生成した OmegaConf を比較している。 コメントで補足してあるけど、Data Classes から生成したものは struct モードになっている。 このモードでは、元々の定義には存在しない Key-Value ペアを設定に追加できない。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging

from dataclasses import dataclass

from omegaconf import OmegaConf
from omegaconf.errors import ConfigAttributeError


LOGGER = logging.getLogger(__name__)


@dataclass
class ConfigStructure:
    message: str = 'Hello, World!'


def main():
    # OmegaConf.create() で作ったコンフィグ
    dict_conf = OmegaConf.create({
        'message': 'Hello, World!'
    })
    # OmegaConf.structured() で作ったコンフィグ
    struct_conf = OmegaConf.structured(ConfigStructure)

    # 後者は struct フラグが有効になっている
    OmegaConf.is_struct(dict_conf)
    OmegaConf.is_struct(struct_conf)

    # struct でない場合は後から存在しない要素を追加できる
    dict_conf.hoge = 'fuga'
    # 一方で struct になっていると存在しない要素を追加できない
    try:
        struct_conf.hoge = 'fuga'
    except ConfigAttributeError as e:
        LOGGER.error(e)

    # この振る舞いは後から変更することもできる
    # たとえば、次のようにすると non struct にできる
    OmegaConf.set_struct(struct_conf, False)
    struct_conf.hoge = 'fuga'


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、struct モードな OmegaConf に存在しない Key-Value を設定しようとすると例外になることが確認できる。

$ python example.py                                       
Key 'hoge' not in 'ConfigStructure'
    full_key: hoge
    reference_type=Optional[ConfigStructure]
    object_type=ConfigStructure

Syntax for Variable Annotations (PEP 526) を使ったバリデーション

先ほどの例にもあったけど Data Classes のアトリビュートには型ヒントがつけられる。 以下のサンプルコードでは、型ヒントと合わない Value をアトリビュートに代入しようとしたときの振る舞いを確認している。 また、設定を後から上書きされたくないときに用いる OmegaConf のインスタンスを読み取り専用にする方法も試している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging

from dataclasses import dataclass

from omegaconf import OmegaConf
from omegaconf.errors import ValidationError
from omegaconf.errors import ReadonlyConfigError


LOGGER = logging.getLogger(__name__)


@dataclass
class ServerStructure:
    port: int = 80
    host: str = '127.0.0.1'


@dataclass
class RootStructure:
    server: ServerStructure = ServerStructure()


def main():
    # DataClass から読み込む
    conf = OmegaConf.structured(RootStructure)

    # 型ヒントが int になっている要素に対して...
    # OK: 当然 int の値は入る
    conf.server.port = 8080
    # OK: int として解釈できるときは自動的に変換される
    conf.server.port = '8080'
    # NG: int として解釈できないときは例外になる
    try:
        conf.server.port = 'Oops!'
    except ValidationError as e:
        LOGGER.error(e)

    # 値を上書きされたくないときは read only にする
    OmegaConf.set_readonly(conf, True)

    try:
        # NG: read only にしたコンフィグには代入できない
        conf.server.port = 8888
    except ReadonlyConfigError as e:
        LOGGER.error(e)


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、型の合わない代入をしたり、読み取り専用のオブジェクトに代入をしたときに例外になることがわかる。

$ python example.py                                       
Value 'Oops!' could not be converted to Integer
    full_key: server.port
    reference_type=ServerStructure
    object_type=ServerStructure
Cannot change read-only config container
    full_key: server.port
    reference_type=ServerStructure
    object_type=ServerStructure

ちなみに Data Classes で読み込んだ OmegaConf のインスタンスを読み取り専用にする方法はもうひとつある。 それは Data Classes を定義するときにデコレータに frozen=True を設定すること。 こうすると、後から変更するまでもなく最初から OmegaConf のインスタンスが読み取り専用になる。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging
from typing import List
from dataclasses import dataclass
from dataclasses import field

from omegaconf import OmegaConf
from omegaconf import ReadonlyConfigError


LOGGER = logging.getLogger(__name__)


# デフォルトで上書きさせたくないときは dataclass を frozen にする
@dataclass(frozen=True)
class RootStructure:
    name: str = 'Alice'
    friends: List[str] = field(default_factory=lambda: ['Bob', 'Carol'])


def main():
    conf = OmegaConf.structured(RootStructure)

    # デフォルトで上書きができなくなっている
    try:
        conf.friends.append('Daniel')
    except ReadonlyConfigError as e:
        LOGGER.error(e)


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、読み取り専用なのにリストに要素を追加したことで例外となることがわかる。

$ python example.py                                       
ListConfig is read-only
    full_key: friends[2]
    reference_type=List[str]
    object_type=list

項目ごとに必須かオプションかを指定する

また、項目が必須かオプションかは、次のように指定できる。

  • アトリビュートのデフォルト値を omegaconf.MISSING にすることで必須のパラメータにできる
  • アトリビュートの型ヒントを typing.Optional にすることでオプションのパラメータにできる
    • 言いかえると Value が None でも構わない
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging

from dataclasses import dataclass
from typing import Optional

from omegaconf import OmegaConf
from omegaconf import MISSING
from omegaconf import MissingMandatoryValue
from omegaconf import ValidationError


LOGGER = logging.getLogger(__name__)


@dataclass
class ServerStructure:
    # デフォルト値に MISSING を設定しておくと指定が必須のパラメータになる
    port: int = MISSING
    # 型ヒントが Optional になっていると None を入れても良い
    host: Optional[str] = '127.0.0.1'


@dataclass
class RootStructure:
    server: ServerStructure = ServerStructure()


def main():
    conf = OmegaConf.structured(RootStructure)

    try:
        # 中身が空のままで読み取ろうとすると例外になる
        conf.server.port
    except MissingMandatoryValue as e:
        LOGGER.error(e)

    # ちゃんと中身を入れれば例外は出なくなる
    conf.server.port = 8080
    print(conf.server.port)

    # また、デフォルトでは None を入れようとしても例外になる
    try:
        conf.server.port = None
    except ValidationError as e:
        LOGGER.error(e)

    # 一方、型ヒントが Optional になっていれば None を入れても例外にならない
    conf.server.host = None


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、項目ごとに必須かオプションかが制御できていることがわかる。

$ python example.py                                       
Missing mandatory value: server.port
    full_key: server.port
    reference_type=ServerStructure
    object_type=ServerStructure
8080
child 'server.port' is not Optional
    full_key: server.port
    reference_type=ServerStructure
    object_type=ServerStructure

Interpolation と Custom Resolver について

OmegaConf には Interpolation という機能がある。 この機能を使うと、典型的にはある Value を別の Value を使って定義できる。 文字だと説明しにくいけど Literal String Interpolation (PEP 498 / いわゆる f-string) とかテンプレートの考え方に近い。 また、Interpolation の指定に : (コロン) でプレフィックスをつけると、それを解釈する Resolver が指定できる。 Resolver は自分で書くこともできて、使いこなすとだいぶ便利そうな感じがする。

正直、ここは文字で説明するのが難しいので以下のサンプルコードを見てもらいたい。 たとえば ClientStructureurl というアトリビュートは、別の場所で定義した ServerStructure の内容を参照して組み立てている。 また、UserStructurename は、プレフィックスに env をつけることで環境変数 USER から読み込むようになっている。 そして、SystemStructuredatesome_value には、自分で Resolver を書いて OmegaConf に登録している。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import logging
from datetime import datetime
from dataclasses import dataclass

from omegaconf import OmegaConf


LOGGER = logging.getLogger(__name__)


@dataclass
class ServerStructure:
    port: int = 80
    host: str = '127.0.0.1'


@dataclass
class ClientStructure:
    # Interpolation を使うと別の場所にある値を埋め込むことができる
    url: str = 'http://${server.host}:${server.port}'


@dataclass
class UserStructure:
    # 環境変数を読み込むこともできる
    name: str = '${env:USER}'


# Interpolation の解決をカスタマイズする (引数なし)
def today_resolver() -> str:
    return datetime.now().strftime('%Y-%m-%d')


# Interpolation の解決をカスタマイズする (引数あり)
def double_resolver(n: str) -> int:
    # 解決する時点では文字列で渡される点に注意する
    return int(n) * 2


@dataclass
class SystemStructure:
    date: str = '${today:}'
    some_value: int = '${double:100}'


@dataclass
class RootStructure:
    server: ServerStructure = ServerStructure()
    client: ClientStructure = ClientStructure()
    user: UserStructure = UserStructure()
    system: SystemStructure = SystemStructure()


def main():
    conf = OmegaConf.structured(RootStructure)

    # カスタムリゾルバを登録する
    OmegaConf.register_resolver('today', today_resolver)
    OmegaConf.register_resolver('double', double_resolver)

    print(conf.client.url)
    print(conf.user.name)
    print(conf.system.date)
    print(conf.system.some_value)


if __name__ == '__main__':
    main()

上記を実行してみよう。 すると、最初に参照している conf.client.urlconf.server.hostconf.server.port のデフォルト値で組み立てられていることが確認できる。 次に参照している conf.user.name は、実行したシェルの環境変数を読み込んでいる。 その次に参照している conf.system.datetoday_resolver() によって、今日の日付が設定されている。 そして、最後に参照している conf.system.some_value は、元の値である 100double_resolver() が 2 倍にしている。

$ python example.py                                       
http://127.0.0.1:80
amedama
2020-11-05
200
$ echo $USER                                
amedama
$ date +%Y-%m-%d                   
2020-11-05

補足

最初に、発展途上なところとして以下の 2 点を挙げた。

  • Value のバリデーションをカスタマイズする仕組みがない
  • プリミティブ型以外を Value として扱うための仕組みが限られている

このうち、最初の方については Flexible function and variable annotations (PEP 593) を使った実装が検討されているようだ。

github.com

後の方については、まあ Custom Resolver を駆使して何とかする方法もあるかな?

そんな感じで。