Ryota Kondo

Ryota Kondo

2023/09/09

Python|JSON、TOML形式のコンフィグファイルをコーディングしやすく読み込む方法

Python標準ライブラリを使ってJSONやTOML(Python3.11~)形式のファイルを読み込んだ場合は、dict型で読み込まれます。読み込んだ内容の値を取得するときは、下の様にコーディングします。

value1 = config["key1"]
# もしくは
value1 = config.get("key1")

今回は下の様にコンフィグ変数.キー名で設定値を取得する方法について説明します。

value1 = config.key1

"key1"のような文字列キーの指定が不要になりますので、スペルミスを防いだり、カッコや囲み文字分コード量が減ります。

コンフィグの値を取得する箇所は多くなりがちですので、その分、効果が期待できます。

2つの実装方法(2024/02/12追記)

もともとこの記事では「拡張したdictにコンフィグファイルを読み込むパターン」のみを紹介していましたが、「dataclassにコンフィグファイルを読み込むパターン」を追記しました。2つのメリット・デメリットは下の通りです。

拡張したdictにコンフィグファイルを読み込むパターン

【メリット】

  • コンフィグの構成を変更する場合は、コンフィグファイルの変更のみで完了する。

【デメリット】

  • 使用箇所はVSCodeなどの参照先検索でヒットしないため、使用箇所の調査がしづらい。

dataclassにコンフィグファイルを読み込むパターン

【メリット】

  • 変数を代入不可(イミュータブル)にできる。
  • VSCodeなどの参照先検索で使用箇所がヒットする。
  • VSCodeなどで変数のコード補完機能が利用できる。

【デメリット】

  • コンフィグの構成を変更する場合は、コンフィグファイルの変更とdataclassの2か所の修正が必要。

基本的には、コンフィグの構成を変更したときの手間が少し増えますが、「dataclassにコンフィグファイルを読み込むパターン」がいいと思います。

後々、コンフィグキーの名前を変更したり、削除したりするときに、VSCodeなどの参照先検索ができるのはとても優位になります。コードが大規模になるほど、この優位性は大きくなります。

拡張したdictにコンフィグファイルを読み込むパターン

コンフィグを読み込むデータ型をdictから、dictを継承したオリジナルクラスのデータ型に変更します。オリジナルクラスのコードはとても簡単で下の通りです。

class ConfigDict(dict):

    __getattr__ = dict.get

何をしているのか説明します。__getattr__は存在しない属性を呼び出した場合に実行されるObjectの特殊メソッドで、上の様にすると値を取得するdict.getの処理を実行するようになります。つまり属性でdictのキーを指定し、値が取得できるようになります。

例えばconfig.get("key1") config.key1 で同じ値が取得可能です。
補足として、存在しないコンフィグキーにアクセスした場合は例外が発生せず、Noneが返却されます。また、getcopyなどdictに存在する属性をコンフィグのキーにすることはできません。

JSONの場合のコード

JSON形式のコンフィグファイルを読み込むコードは下の通りです。json.load関数のobject_hookオプションにConfigDictを指定することで、dict型の代わりにConfigDict型でファイルを読み込むようにしています。

config.py
import json

class ConfigDict(dict):
    """
    __getattr__ は存在しない属性を呼び出した場合に実行されるObjectの
    特殊メソッドで、下の様にするとdict.getの処理を実行する。
    これにより属性でdictのキーを指定でき、値が取得できるようになる。
    例 config.get("key1") と config.key1 で同じ値が取得可能。
    """

    __getattr__ = dict.get

def get_json_config(config_path):
    """JSON形式のコンフィグファイルを読込み、ConfigDict型で返す"""
    with open(config_path) as f:
        return json.load(f, object_hook=ConfigDict)

TOMLの場合のコード

TOML形式のコンフィグファイルを読み込むコードは下の通りです。JSONのload関数と異なりdict型の代わりを設定するオプションはないため、一度dict型で読み込みConfigDict型に変換するようにしています。変換する際は多層のコンフィグ設定でも対応できるよう、再帰的に変換処理を行うようにしています。

config.py
import tomllib

class ConfigDict(dict):
    """
    __getattr__ は存在しない属性を呼び出した場合に実行されるObjectの
    特殊メソッドで、下の様にするとdict.getの処理を実行する。
    これにより属性でdictのキーを指定でき、値が取得できるようになる。
    例 config.get("key1") と config.key1 で同じ値が取得可能。
    """

    __getattr__ = dict.get


def _dict_to_config_dict(target):
    """targetを再帰的に探索し、dict型であればConfigDict型に変換する"""

    if isinstance(target, dict):
        for key, value in target.items():
            target[key] = _dict_to_config_dict(value)

    if isinstance(target, list):
        for index, item in enumerate(target):
            target[index] = _dict_to_config_dict(item)

    if isinstance(target, dict):
        target = ConfigDict(target)

    return target

def get_toml_config(config_path):
    """TOML形式のコンフィグファイルを読込み、ConfigDict型で返す"""
    with open(config_path, mode="rb") as f:
        return _dict_to_config_dict(tomllib.load(f))

dataclassにコンフィグファイルを読み込むパターン

例として、下の階層構造を持つJSON形式のコンフィグファイルを読み込む場合で説明します。

config.json
{
    "key01": "AAA",
    "key02": "BBB",
    "key03": "CCC",
    "child_object": {
        "child01": 1,
        "child02": 2,
        "child03": 3,
        "object_list": [
            {
                "list_key01": "aaa",
                "list_key02": "bbb",
                "list_key03": "ccc"
            },
            {
                "list_key01": "ddd",
                "list_key02": "eee",
                "list_key03": "fff"
            }
        ]
    }
}

コードはこちらです。@dataclass(frozen=True)をクラスにつけることでイミュータブルなdataclassにしています。子要素をそのまま読み込むとdict型になりますので、__post_init__関数で子要素のdataclassに変換しています。Config(**json_dict)でdictをアンパックしてConfigクラスに渡しますので、コンフィグファイルのキーとdataclassの変数名を一致させる必要があります。

import json
import tomllib
from dataclasses import dataclass

@dataclass(frozen=True)
class Config:
    @dataclass(frozen=True)
    class ChildObject:
        @dataclass(frozen=True)
        class ObjectList:
            list_key01: str
            list_key02: str
            list_key03: str

        child01: int
        child02: int
        child03: int
        object_list: list[ObjectList]

        def __post_init__(self):
            """
            子の要素の配列はdictのlistで読み込まれるため、
            ループしてdataclassに変換する
            """
            object.__setattr__(
                self,
                "object_list",
                [self.ObjectList(**sub_value) for sub_value in self.object_list],
            )

    key01: str
    key02: str
    key03: str
    child_object: ChildObject

    def __post_init__(self):
        """
        子の要素はdict型で読み込まれるため、
        dataclassに変換する
        """
        object.__setattr__(
            self, 
            "child_object", 
            self.ChildObject(**self.child_object)
        )


def read_config_json():
    """ JSON形式のコンフィグファイルを読み込む """
    with open("config.json", "rb") as f:
        json_dict = json.load(f)

    return Config(**json_dict)


def read_config_toml():
    """ TOML形式のコンフィグファイルを読み込む """
    with open("config.toml", "rb") as f:
        toml_dict = tomllib.load(f)

    return Config(**toml_dict)

使用した場合はこのようになります。

config = read_config_json()
print(config.key01)
# AAA

print(config.child_object.child01)
# 1

print(config.child_object.object_list[0].list_key01)
# aaa

# JSON形式のコンフィグファイルと同じ構造をもつ、TOMLファイルを読み込んだ場合
config = read_config_toml()
print(config.key01)
# AAA

print(config.child_object.child01)
# 1

print(config.child_object.object_list[0].list_key01)
# aaa

参考

この記事は以下の情報を参考にしました。

更新履歴

  • 2023/09/09 初版
  • 2024/02/12 「dataclassにコンフィグファイルを読み込むパターン」を追記

関連タグの記事

Ryota Kondo
Ryota Kondo

システムエンジニア・プログラマー|このブログサイトの運営もしており、思いついたことをまとめて記事を書いています💡