きっかけ
Python には標準ライブラリに configparser がある。普通に使える。でもいくつか不満がある。キー名をデフォルトで小文字にする。重複セクションを黙ってマージする。コメントを保持しない。行番号を教えてくれない。そして出力は ConfigParser オブジェクトであり、jq にパイプできるデータではない。
INI ファイルを「設定オブジェクト」ではなく「構造化データ」として扱いたかった。パースした結果を JSON で出力し、セクション・キー・値・コメント・行番号をすべてメタデータとして保持する。重複セクションや重複キーはマージせずにエラーにする。型推論で port = 8080 は文字列ではなく整数として返す。
そういうパーサーを Python でゼロから書いた。依存ゼロ、テスト49件。
🔗 GitHub: https://github.com/sen-ltd/ini-parser
INI フォーマットの曖昧さ
INI には正式な仕様がない。JSON(RFC 8259)や TOML にはスペックがあるが、INI は「慣習」と「使っているパーサーの挙動」で定義されている。Windows の GetPrivateProfileString、Python の configparser、PHP の parse_ini_file、Git の config フォーマット、全部細部が違う。
共通部分はある。[section] でセクション、= でキーと値の分離、; か # でコメント、空行は無視。しかしそれ以降は分岐する。セクション外のキーは許すか?キーは大文字小文字を区別するか?複数行の値は?インラインコメントは?重複キーは?
ini-parser では各問題に対して明示的に方針を決め、曖昧なものはエラーにした。
アーキテクチャ
パーサーは3つのモジュールに分かれている。
-
types.py— 型推論(文字列 → int / float / bool / string) -
parser.py— 1行ずつパースしてデータ構造を構築 -
serializer.py— データ構造を INI テキストに戻す
型推論
INI ファイルはすべてを文字列として保存するが、port = 8080 をパースしたら整数 8080 が欲しい。型推論モジュールがこれを担当する。
_BOOL_TRUE = frozenset({"true", "yes", "on", "1"})
_BOOL_FALSE = frozenset({"false", "no", "off", "0"})
_INT_RE = re.compile(r"^[+-]?\d+$")
_FLOAT_RE = re.compile(r"^[+-]?(\d+\.\d*|\.\d+)([eE][+-]?\d+)?$")
def infer_type(value: str) -> dict[str, Any]:
stripped = value.strip()
if stripped.lower() in _BOOL_TRUE:
return {"raw": value, "value": True, "type": "bool"}
if stripped.lower() in _BOOL_FALSE:
return {"raw": value, "value": False, "type": "bool"}
if _INT_RE.match(stripped):
return {"raw": value, "value": int(stripped), "type": "int"}
if _FLOAT_RE.match(stripped):
return {"raw": value, "value": float(stripped), "type": "float"}
return {"raw": value, "value": stripped, "type": "string"}
チェック順序が重要で、"1" や "0" は bool と int の両方に該当するが、INI ファイルでは enabled = 1 は「true」の意味がほとんどなので、bool を先に判定する。すべての結果に元の文字列 (raw) を含めるので、情報を失わない。
1行ずつのパーサー
パーサーは入力を1行ずつ読み、状態を管理する。現在のセクション、直前のキー(複数行の継続用)、既出のセクションとキー(重複検出用)。
各行は固定の優先順で1つのパターンにマッチする。空行 → コメント → セクションヘッダ → 継続行 → キーバリュー → エラー。どれにもマッチしない行は INIParseError を行番号付きで出す。バックトラッキングなし、推測なし。
複数行の値
先頭が空白の行は、直前のキーの継続行として扱う。
[server]
description = This is a long
description that spans
multiple lines
空行を挟むと継続がリセットされる。これがないと、ファイル内のどこかにあるインデントされた行がすべて直前のキーに結合されてしまう。
重複検出
重複セクションと重複キーの両方をパースエラーとして扱う。configparser は重複セクションを黙ってマージするが、それだとセクション名のタイプミスで意図しないセクションにキーが混入しても気づけない。
CLI インターフェース
4つのモードに対応している。
# デフォルト: JSON に変換
python main.py config.ini
# バリデーション(exit 0 = OK, exit 2 = NG)
python main.py --validate config.ini
# 特定の値を取得
python main.py --get server.port config.ini
# 値を変更(ファイルを直接書き換え)
python main.py --set server.port=9090 config.ini
--set は特筆に値する。パースして構造を変更してシリアライズし直すのではなく、元のテキストの行配列を直接操作する。これにより、フォーマット・空行・コメントの位置がすべて保持される。変わるのは対象キーの行だけ。
JSON 出力の構造
出力はパーサーが知っているすべてを保持する。
{
"sections": {
"server": {
"keys": {
"port": {
"raw": "8080",
"value": 8080,
"type": "int",
"inline_comment": "default HTTP port",
"line": 6
}
},
"line": 4
}
},
"comments": [
{ "text": "Application configuration", "line": 1 }
],
"globals": { "keys": {}, "line": 0 }
}
各キーは raw(元の文字列)と value(型推論後の値)の両方を持つ。行番号はセクションヘッダとキーの両方に付く。この構造があれば、lint ツールや diff ツールを上に構築するのは簡単。
テスト
テストは49件。基本的なパース、コメント、型推論、複数行、重複検出、エッジケース(BOM 処理、Unicode キー、値の中の =)、シリアライザ、CLI の全モードをカバーしている。
def test_bom_handling(self):
ini = "\ufeff[s]\nk = v"
data = parse(ini)
assert "s" in data["sections"]
def test_unicode_keys(self):
ini = "[s]\n名前 = テスト"
data = parse(ini)
assert data["sections"]["s"]["keys"]["名前"]["value"] == "テスト"
def test_equals_in_value(self):
data = parse("[s]\nformula = a=b+c")
assert data["sections"]["s"]["keys"]["formula"]["value"] == "a=b+c"
BOM は Windows のエディタが UTF-8 ファイルの先頭に付けることがある。これを処理しないと最初のセクション名がおかしくなる。
おわりに
パーサーをゼロから書くと、ユーザーとしては気にしないエッジケースを全部考えることになる。BOM、重複キー、継続行の終端条件。それぞれが設計判断であり、フォーマットの理解を深めてくれる。
JSON 出力にしたことで jq や grep と組み合わせる Unix ツールになった。--validate フラグと exit code 2 で CI にも組み込める。
ソースは GitHub にある。MIT ライセンス、依存ゼロ、Python 3.10+。
