プログラムでおかえしできるかな

定年を過ぎて何かの役に立てないかなと始めた元SEのブログです

TOMLで設定ファイルを扱うユーティリティ【Python】

このエントリーをはてなブックマークに追加

今まで、Python でアプリを作成してきて設定ファイルは settings.py ファイルを使用してきました。
これだと利用する方にソースファイルを編集していただくことになります。

設定ファイルの読み書きをアプリの中で行い、設定画面を提供し、利用する方に分かりやすくしたいと考えました。

そのために設定ファイルとして TOML ファイルを使うことにしました。
TOML ファイルがあれば設定画面も作成するようなユーティリティを作成したので紹介します。

目次

◆なぜTOMLファイル

まずはウィキペディアの解説から

TOMLは、設定ファイルのフォーマットの1種である。「ミニマル」であることを目指した明確な構文を採用することで、読みやすいフォーマットとなるように作られた。TOMLの設定項目は、ディクショナリ構造に明確にマッピングされるように設計されている。 TOML - Wikipedia

また、設定ファイルとして候補に挙がる JSON, YAML, INI, TOML の比較記事 1 2 を参考に自分なりの判断は、

  • INI 風で書きやすく見やすい
  • コメントが書ける ※今回、コメントは使いませんでした
  • 型がある
  • Python ライブラリがある

ということで、可能性を感じて設定ファイルに TOML を使ってみることにしました。

解説にある通り、ディクショナリ構造(辞書型)にマッピングして使えます。
Python の辞書と相性が良いです。

Python用TOMLライブラリ

今回、使用したのは次の2つのパッケージです。

  • tomli :読み込み用
  • tomli-w :書き込み用

※これらを pip すると toml も入ります

また、tomlkit を使うとスタイルとコメントを保持することができます。

◇書き方(手書き)

TOML の設定ファイルの書き方を簡単にまとめます。
この記事では TOML ファイルを手書きで書くのが主目的ではないので簡単な説明にします。
詳しい解説はこちらの外部サイト公式サイト を参照してください。

  • コメント:#以降
  • キーと値キー=値
  • キーA-Z, a-z ,0-9 ,_ ,-
       ベア・キー、クォート付きキー、ドット付きキーがあります
    • データ型:文字列("")、整数、小数、論理値(true,false)、
      日付(yyyy-mm-ddThh:mm:ssなど)、
      配列(Pythonのリストと同じ記述)
    • テーブル:キーと値の集合。INIファイルのグループ相当
      例 [table]
        key=value
        [table2]
        key=value2
    • インラインテーブル:1行で記述するテーブル。
      name = { first = "Python", last = "太郎" }

▽キーの設定サンプル

# この行は全てコメントです。
key = "value" # 行末までコメントです。
bare_key = "value"
bare-key = "value"
"character encoding" = "value" # クォート付キー
physical.color = "orange"        # ドット付キー
physical.shape = "round"
1234 = "value"
int1 = +99
int2 = 42
int3 = 0
int4 = -17
bool1 = true
bool2 = false
odt1 = 1979-05-27T07:32:00Z
odt2 = 1979-05-27T00:32:00-07:00

# 配列
integers = [ 1, 2, 3 ]
colors = [ "red", "yellow", "green" ]

# テーブル
[table-1]
key1 = "some string"
key2 = 123

[table-2]
key1 = "another string"
key2 = 456
# インラインテーブル
name = { first = "Tom", last = "Preston-Werner" }
point = { x = 1, y = 2 }

◆TOMLでやりたいこと

今回、TOML で実装したいのは次の2点です。

  • Python アプリの設定ファイルとして TOML ファイルを読み書きする

  • TOMLファイルの中に設定値の定義を入れて設定画面を簡単に作る

TOML は、設定ファイル用の仕様で Python のパッケージも提供されているので、設定ファイルとしての読み書きは難しくありません。
アプリを作成した場合、その設定値を編集する UI をアプリごとに作るのは面倒だなと思います。
そこで、設定ファイルに設定値の定義を持たせてフレキシブルな画面作成ができるように考えてみました。

これらを目的にしたので、目で見て分かりやすい書式の TOML ファイルを手書きで作成するところは、解説していません。

TOML ファイルは、Python のデータとしては辞書型のデータなので、Python のプログラムで辞書を定義して、それをファイルに出力して TOML ファイルを作成しています。

プログラムで出力した TOML ファイルなので読み込みも問題なくできます。
手書きで TOML ファイルを作成した場合、プログラムで読んだらうまく読めないということが起きるかもしれません。プログラムで作成すれば、その問題は起きずに済みます。

◆TOMLファイルの読み書き

tomlitomli-w パッケージを使えば TOML ファイルの読み込み書き込みは簡単です。

  • 読み込み: load() メソッド
  • 書き込み: dump() メソッド

どちらも通常のファイル操作と併せて使用します。

【コード】

▽読み込み

import tomli, tomli_w

with open(path, "rb") as f:
    toml_dict = tomli.load(f)

▽書き込み

import tomli, tomli_w

with open(path, 'wb') as configfile:
    tomli_w.dump(toml_dict, configfile)

◆TOMLファイルから設定画面を作成

TOML ファイルは設定ファイルとして可読性に優れているとされています。
しかし、プログラミングとかに縁がない方には設定画面が必要です。
アプリごとに設定画面を用意するのが UI としてはユーザーフレンドリーなのでしょうが、そこは少し手抜きさせていただいて、複数のアプリで使える設定画面を検討しました。

具体的には、実際に使う設定値の他に画面作成用の設定値を用意し、それを素に画面を作成します。

そのための TOML ファイルの使い方の仕様を考えました。

◇要件

  • 定義用のテーブルを持つ
  • デフォルトロード用のテーブルを持つ
  • ユーザー用のテーブルを持つ
  • ユーザー用は複数のテーブルも可
  • 各テーブルにはINIファイルのセクションにあたるテーブル(以下セクション)を持つ
    • セクションは定義用など各テーブルで同じ構成とする
  • セクション内のキーはテーブル内でユニークとする
  • 定義用テーブル内のキーの値は配列とし、キーの説明、型とする

◇テーブル構成

  • 定義用テーブル
    • セクション1テーブル
      • キー1=[キーの説明、型]
      • キー2=[キーの説明、型]
    • セクション2テーブル
      • キー3=[キーの説明、型]
      • キー4=[キーの説明、型]
  • デフォルト用テーブル
    • セクション1テーブル
      • キー1=値1
      • キー2=値2
    • セクション2テーブル
      • キー3=値3
      • キー4=値4
  • ユーザー用テーブル
    • セクション1テーブル
      • キー1=値1
      • キー2=値2
    • セクション2テーブル
      • キー3=値3
      • キー4=値4

◇要件にあったTOMLファイル

TOML ファイルを読んでその TOML ファイルの書式が正しいかどうか悩むのが嫌だったので、TOML ファイルはプログラムで出力することにしました。
Python の場合、辞書型のデータを作成して dump() メソッドで出力すれば、TOML ファイルを作成できます。

▽サンプル TOML ファイル(Python のプログラムで出力したもの)

▽定義部分を見やすくしたもの

[DEFINITION."セクション1"]
key1 = ["key1の説明","bool",]
key2 = ["key2の説明","str",]
key3 = ["key3の説明","int",]

[DEFINITION."セクション2"]
key4 = ["key4の説明","bool",]
key5 = ["key5の説明","str",]
key6 = ["key6の説明","str",]

[DEFINITION."セクション3"]
key7 = ["key7の説明","bool",]
key8 = ["key8の説明","str",]

[DEFAULT."セクション1"]
key1 = true
key2 = "文字列2"
key3 = 800

[DEFAULT."セクション2"]
key4 = false
key5 = "文字列5"
key6 = "文字列6"

[DEFAULT."セクション3"]
key7 = true
key8 = "文字列8"

[USER."セクション1"]
key1 = true
key2 = "文字列2"
key3 = 800

[USER."セクション2"]
key4 = false
key5 = "文字列5"
key6 = "文字列6"

[USER."セクション3"]
key7 = true
key8 = "文字列8"

▽プログラムで出力したそのもの

[DEFINITION."セクション1"]
key1 = [
    "key1の説明",
    "bool",
]
key2 = [
    "key2の説明",
    "str",
]
key3 = [
    "key3の説明",
    "int",
]

[DEFINITION."セクション2"]
key4 = [
    "key4の説明",
    "bool",
]
key5 = [
    "key5の説明",
    "str",
]
key6 = [
    "key6の説明",
    "str",
]

[DEFINITION."セクション3"]
key7 = [
    "key7の説明",
    "bool",
]
key8 = [
    "key8の説明",
    "str",
]

[DEFAULT."セクション1"]
key1 = true
key2 = "文字列2"
key3 = 800

[DEFAULT."セクション2"]
key4 = false
key5 = "文字列5"
key6 = "文字列6"

[DEFAULT."セクション3"]
key7 = true
key8 = "文字列8"

[USER."セクション1"]
key1 = true
key2 = "文字列2"
key3 = 800

[USER."セクション2"]
key4 = false
key5 = "文字列5"
key6 = "文字列6"

[USER."セクション3"]
key7 = true
key8 = "文字列8"

◇TOMLファイルの作成

要件にあった TOML ファイルを Python アプリで出力します。
Python で TOML ファイルを作成するのは、単に辞書を定義して出力するだけです。
辞書は、テーブルごとにネストして作成します。
ネストとは、辞書の値に辞書を設定することです。

◎TOMLファイル作成コード

    def create_toml_sample(self, path:str):
        """
        tomlファイルを作成する
        出力内容を辞書でロジックに定義
        tomlファイルを手作業で作ってうまく読めないと悩むより辞書から作った方が間違いないから
        tomlをjsonにすれば転用できるかも
        """
        toml_data = {}
        toml_data['DEFINITION'] = {
            "セクション1":{    "key1":["key1の説明", "bool"]
                            , "key2":["key2の説明", "str"]
                            , "key3":["key3の説明", "int"]
                    }, 
            "セクション2":{    "key4":["key4の説明", "bool"]
                            , "key5":["key5の説明", "str"]  
                            , "key6":["key6の説明", "str"]
                        }, 
            "セクション3":{    "key7":["key7の説明", "bool"]
                            , "key8":["key8の説明", "str"]
                            }
            }
        toml_data['DEFAULT'] = {
            "セクション1":{    "key1":True
                            , "key2":"文字列2"
                            , "key3":800
                    }, 
            "セクション2":{    "key4":False
                            , "key5":"文字列5" 
                            , "key6":"文字列6"
                        }, 
            "セクション3":{    "key7":True
                            , "key8":"文字列8"
                            }
            }
        toml_data['USER'] = {
            "セクション1":{    "key1":True
                            , "key2":"文字列2"
                            , "key3":800
                    }, 
            "セクション2":{    "key4":False
                            , "key5":"文字列5" 
                            , "key6":"文字列6" 
                        }, 
            "セクション3":{    "key7":True
                            , "key8":"文字列8"
                            }
            }

        self.tomlu.dump_toml(toml_data, path)

    def dump_toml(self, toml_dict:dict, path:str):
        """
        tomlで読み込んだ辞書をtomlファイルに出力する
      Args:
            dict:   tomlで読み込んだ辞書
            str:    保存先ファイル名
        """
        try:
            with open(path, 'wb') as configfile:
                tomli_w.dump(toml_dict, configfile)
        except exception as e:
            print(e)

◆設定画面の作成

設定画面作成の考え方はシンプルです。

前述の キー = [ キーの説明、型 ] に合わせてウィジェットを積み重ねていきます。
設定画面に作成したウィジェットの値を利用するにはウィジェット変数を用意する必要があります。
ウィジェット変数は型によって異なるクラスを使用するので、型ごとに処理を分けます。
もちろん、型ごとに作成するウィジェットの種類も変更します。

今回対応した型とその処理は次の通りです。

ウィジェット ウィジェット変数
str Label+Entry StringVar
int Label+Entry IntVar
bool CheckButton BooleanVar

ウィジェットの配置

ウィジェットの配置方法を2つ用意しました。

  • pack() による配置
    • 単純に上から列挙します
    • 説明文は左寄せします
    • Entry は設定項目によって幅を変えていないので中央に配置します
    • Checkbutton はすべての Checkbutton に表示する文字の最大幅を幅として設定して左寄せします
  • grid() による配置
    • Checkbutton と説明文はカラム0に Entry はカラム1に配置します
    • 説明文は Entry に寄せるため右寄せします
    • Checkbutton はすべての Checkbutton に表示する文字の最大幅を幅として設定して左寄せします
    • Entry の位置を合わせるため、カラム 0 の余白を伸ばすのにカラム 0 の weight を 1 にします

▽ pack による配置と grid による配置の例

◇処理

  1. TOML ファイルを読み込んでできた辞書から DEFINITION キーの値を取得
  2. 説明文の最大文字数を求める
  3. DEFINITION キーの値はセクション名をキーにした辞書なので キーごとにLabelFrame ウィジェットを作成する
  4. セクション名をキーにした値は、設定項目のキーと説明文と型なので設定項目ごとに以下の処理を行う
    1. 型が str なら StringVar オブジェクトを作成する
      LabelEntry ウィジェットを作成し、配置する
    2. 型が int なら IntVar オブジェクトを作成する
      LabelEntry ウィジェットを作成し、配置する Entry ウィジェットは入力検証 3 を付加する
    3. 型が bool なら BooleanVar オブジェクトを作成する
      Checkbutton ウィジェットを作成し、配置する
  5. grid の場合、カラム0の weight を 1 にする
  6. 必要なら「設定保存」ボタンを作成する
  7. 必要なら「デフォルトに戻す」ボタンを作成する

◎設定画面作成コード

    def create_frame_from_toml_dict(self, parent:Any
        , has_save_button:bool=False, has_default_button:bool=False, is_grid:bool=False) -> dict:
        """
        tomlデータからDEFINITIONセクション(キー:[説明, 型]の辞書)を抜き出し、フレームを作成
        tomlデータは事前に読んでおく
        保存ボタンを表示する場合、ボタンに割り付けるメソッドを後から指定する
            例 obj.btn_save.config(command=lambda path=my_path: toml.save_toml(path, "USER"))
      Args:
            Any:    主にFrame 親コンテナ
            bool:   保存用ボタンの有無
            bool:   labelとentryを横並びにする
      Returns:
          dict:   ウィジェット変数の辞書
        """
        toml_dict = self.toml_doc["DEFINITION"]
        self._var_dict = {}
        # 説明の最大文字数を求める
        max_length = max([len(v2[0]) for v1 in toml_dict.values() for v2 in v1.values()])
        for group_key, group_item in toml_dict.items():
            label_frame = tk.LabelFrame(parent, text=group_key, padx=5, pady=5)
            label_frame.pack(fill="x", padx=5,pady=5)
            for i, (k, item) in enumerate(group_item.items()):
                if item[1] == "str":
                    self._var_dict[k] = tk.StringVar()
                    entry_ = tk.Entry(label_frame, textvariable=self._var_dict[k])
                if item[1] == "int":
                    self._var_dict[k] = tk.IntVar()
                    entry_ = tk.Entry(label_frame, textvariable=self._var_dict[k]
                            , validate="key", vcmd=(parent.register(self.entry_validate), "%d", "%S"))
                if item[1] == "bool":
                    self._var_dict[k] = tk.BooleanVar()
                    checkbutton_ = tk.Checkbutton(label_frame, variable=self._var_dict[k]
                        , text=item[0], anchor="w", width=max_length*2)
                    if is_grid:
                        checkbutton_.grid(row=i, column=0, columnspan=2 , sticky="w")
                    else:
                        checkbutton_.pack()
                    continue
                label_ = tk.Label(label_frame, text=item[0])
                # pack,grid
                if is_grid:
                    label_.grid(row=i, column=0, sticky="e")
                    entry_.grid(row=i, column=1)
                else:
                    label_.pack(anchor="w")
                    entry_.pack()
            if is_grid:
                label_frame.columnconfigure(0, weight=1)    # grid column 0 の余白を引き延ばす
        if has_save_button:
            self.btn_save = tk.Button(parent, text="設定保存")
            self.btn_save.pack(side="bottom", fill="x")

        if has_default_button:
            self.btn_default = tk.Button(parent, text="デフォルトに戻す"
                , command=self.load_default)
            self.btn_default.pack(side="bottom", fill="x")

        return self._var_dict

◆設定画面作成メソッド(TomlFileUtilクラス)

前節で説明したものを TomlFileUtil クラスの create_frame_from_toml_dict() メソッドとして作成しました。
使い方を説明します。

◇設定画面作成メソッドの使い方

create_frame_from_toml_dict() メソッド

インポート

  • from toml_file_util import TomlFileUtil
    ※もちろんインポートでなく、コピーしても使えます

メソッド

  • 【構文】 create_frame_from_toml_dict( self, parent:Any, has_save_button:bool=False, has_default_button:bool=False, is_grid:bool=False)

  • 引数

    • parent:親ウィジェット(通常 Frame を想定)
    • has_save_button:保存用ボタンの有無
    • has_default_button:labelとentryを横並びにするかどうか
  • 戻り値

  • 使い方

    1. TomlFileUtil クラスのインスタンスを作成
      toml = TomlFileUtil()
    2. TOML ファイルを読み込みます
      result = toml.read_toml(ファイルパス)
    3. メソッドの呼び出し
      create_frame_from_toml_dict( 親フレーム )
    4. 保存ボタンを表示する場合、ボタンに割り付けるメソッドを後から指定します
      obj.btn_save.config( command = lambda path = my_path: toml.save_toml( path, "USER"))

◇TomlFileUtilクラス

TomlFileUtil クラスについて補足します。

属性

  • path:TOML ファイルのパス
  • toml_doc:TOML ファイルを読んで作成した TOML 辞書
  • _var_dictTkinter variable変数(ウィジェット変数)を値にした辞書
    create_frame_from_toml_dict() メソッドで返す

メソッド

  • dump_toml(self, toml_dict:dict, path:str):TOML 辞書を TOMLファイルに出力する
  • save_toml(self, save_path:str="", section:str="", event=None)ウィジェット変数の値を section で指定したセクションにコピーして save_path で指定したファイルに保存
  • load_default(self, event=None)ウィジェット変数の辞書に TOML 辞書の DEFAULT セクションの値を設定
  • read_toml(self, path:str):TOML ファイルを読み込み TOML 辞書を返す
  • create_frame_from_toml_dictこちらを参照
  • set_toml2var_dict(self, section:str):TOML 辞書から section で指定したセクションをウィジェット変数の辞書に設定する
  • set_var_dict2toml_dict(self, section:str)ウィジェット変数の辞書の設定値を TOML 辞書の section で指定したセクションに設定する

◆settings.pyを使ったアプリでの互換性

以前に作成したアプリでは、設定ファイルとして settings.py を使いました。
同じアプリをライブラリとして使い、TOML ファイルから設定値を辞書として作成して動かすようにしました。
そのため元々のアプリは、settings.py に定義した変数と値を取得して辞書として作れれば同じように動きます。

その方法を捜し、次のような方法を見つけられたので紹介します。

◇設定ファイルを辞書に

globals() 関数を用いて実装します。

▽例

def get_book_variable_module_name(module_name):
    module = globals().get(module_name, None)
    book = {}
    if module:
        book = {key: value for key, value in module.__dict__.items() if not (key.startswith('__') or key.startswith('_'))}
    return book

※globals()はグローバル名前空間にあるシンボル一覧を返します。
 シンボル一覧には __dict__ など Python が自動的に割り当てる変数も含まれるため if 節で除きます。

■必要なパッケージ

□必要な Python パッケージ

  • tomli
    【インストール】pip install tomli
    【インポート】 import tomli
  • tomli-w
    【インストール】pip install tomli-w
    【インポート】 import tomli-w

◆ソースの取得

全体のソースはこちらから取得できます。

◆免責事項

ご利用に際しては、『免責事項』をご確認ください。

お気づきの点がございましたら『お問い合わせ』からお問い合わせください。
ただし、回答をお約束するものではありません。

◆さいごに

アプリの設定ファイルに何を使うのか試行錯誤しています。
はじめは手軽さから settings.py を使いました。
しかし、ユーザーフレンドリーではないので、やはり画面の必要性を感じました。
画面には凝りたい気持ちと手間をかけたくない気持ちが交錯します。
間を取って作ってみたのですが、中途半端なものになっていないか心配です。
ご意見があればぜひお聞かせください。

今回、ユーティリティを作ってみて、Python の場合、内部的に辞書でデータを扱うことが多いように思います。
今回作成したユーティリティも TOML ファイルの読み込みのところを JSON の読み込みに変えただけで JSON でも使えるように思います。

◆参考

投稿: