OmegaConf は、Python の Configuration フレームワークのひとつ。 Hydra が低レイヤー API に利用している、という点が有名だと思う。 というより、Hydra を使おうとすると OmegaConf の API が部分的にそのまま露出していることに気づく。 なので、OmegaConf を理解しておかないと、Hydra はちゃんと扱えないくらいまであると思う。 今回は、そんな OmegaConf の使い方を一通り見ていく。
OmegaConf には、ざっくりと次のような特徴がある。
良い (と私が感じた) ところ
発展途上 (と私が略) なところ
- 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=8080
と server.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.host
が 127.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.port
と server.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
でも構わない
- 言いかえると Value が
#!/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 は自分で書くこともできて、使いこなすとだいぶ便利そうな感じがする。
正直、ここは文字で説明するのが難しいので以下のサンプルコードを見てもらいたい。
たとえば ClientStructure
の url
というアトリビュートは、別の場所で定義した ServerStructure
の内容を参照して組み立てている。
また、UserStructure
の name
は、プレフィックスに env
をつけることで環境変数 USER
から読み込むようになっている。
そして、SystemStructure
の date
と some_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.url
は conf.server.host
と conf.server.port
のデフォルト値で組み立てられていることが確認できる。
次に参照している conf.user.name
は、実行したシェルの環境変数を読み込んでいる。
その次に参照している conf.system.date
は today_resolver()
によって、今日の日付が設定されている。
そして、最後に参照している conf.system.some_value
は、元の値である 100
を double_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) を使った実装が検討されているようだ。
後の方については、まあ Custom Resolver を駆使して何とかする方法もあるかな?
そんな感じで。
- 作者:もみじあめ
- 発売日: 2020/02/29
- メディア: Kindle版