2022年11月: 新たに仲間に加わったTOMLパーサー(kadowaki)

門脇です(@satoru_kadowaki)。 11月の「Python Monthly Topics」は、Python 3.11で新しく標準ライブラリに追加された「tomllib」モジュールについて解説します。

本題の前に、ご存知の方も多いと思いますが、10月24日、ついに Python 3.11がリリース されました! 先月のPython Monthly Topicsでも紹介した非同期I/Oタスクグループや新しい例外処理といった新機能の他にも、高速化や例外を正確に示すTracebackなど、今回のリリースでも注目の改善点が多く、それらをいち早く把握しておきたいところです。 今回ご紹介するtomllibモジュールも新機能の1つですが、すでに馴染みがある人も、今まであまり使ったことがない人も、この機会に目をとおしてみてください。

TOMLとは

TOML(Tom's Obvious Minimal Language) は、設定ファイルフォーマットの1つで、可読性が高く「ミニマル」であることを目指して作られました。 TOMLの主な仕様は以下の通りです。

  • 構成要素は、キー(Key)値(Value) の組からなり、キーと値は 等号(=) で区切られる

  • 値は、文字列、整数、浮動少数点数、ブーリアン、日時、配列もしくはインライン・テーブルなどのデータ型を扱うことができる

    • null (または nil)タイプは存在しない

  • 大文字と小文字を区別(ケース・センシティブ)するテキストファイルである

  • TOMLファイルはUTF-8でエンコードされている必要がある

  • ハッシュ記号 # から行末までをコメントとして扱う

主な型を使用したTOMLのサンプルを以下に示します。

# ハッシュ記号以降はコメントです

key = "value"  # 左辺にキー、イコールで右辺に値を指定します

# 文字列以外の主なデータ型
month = 11 # 整数(Integer)
pi = 3.14  # 浮動小数点数(Float)
is_example = true  # ブーリアン(Boolean): true/false
created_at = 2022-11-01T22:38:34+09:00  # オフセット付き日時(Offset Date-Time)
values = [1, 2, 3]  # 配列(Array)

その他の細かい仕様については 詳細仕様 ページに記載がありますので、興味のある方は読んでみてください。 同様のファイルフォーマットとして YAMLJSON もありますが、よりシンプルなTOMLは多くのプログラミング言語でパーサーが実装されています。

TOMLがYAMLやJSONとどのように違うのか、それぞれの構造の違いを簡単にみてみましょう。 それぞれのフォーマットにおける大きな違いは以下のようなものがあります。

特徴

YAML

JSON

TOML

キーと値の区切り文字

: (コロン)

: (コロン)

= (イコール)

コメントが書ける

書ける

書けない

書ける

日付型の有無

有り

無し

有り

null値の有無

有り

有り

無し

構造化データの表現

インデントを使用

データの最初と最後を波括弧で括る

角括弧やドットを使用

いずれの形式でも簡単なデータを表す場合はそれほど違いがありません。 同一のデータがそれぞれのフォーマットでどのように表現されるか、簡単な例を使用して見比べてみます。

TOMLでは以下のように簡潔に表現できます。

[app]
app_name = "example"
environment.NAME = "sandbox"
environment.VERSION = "0.0.1"
volumes = ["vol1", "vol2"]

YAMLでは以下のようになります。 このデータにおいてはシンプルで可読性も悪くありません。 しかし、YAMLはインデントを使用してデータ構造を表現することから、ネストが深くなってしまうことがあります。 ネストが深くなると可読性が悪くなり、インデントがずれてパースエラーや意図しない階層構造で読み込まれてしまうことがあるので注意が必要です。

app:
  app_name: exsample
  environment:
    NAME: sandbox
    VERSION: 0.0.1
  volumes:  # volumes: [vol1, vol2]  のような表現も可能
    - vol1
    - vol2

JSONも以下のようにこのデータについてはシンプルです。 JSONは波括弧でデータの括りを表現するため、複雑な構造になるほど波括弧の位置に注意して書く必要があります。 APIなどプログラム同士の情報のやりとりによく使用されるJSONですが、大きなデータを手入力で書くのは大変な作業です。

{
  "app": {
    "app_name": "example",
    "environment": {
      "NAME": "sandbox",
      "VERSION": "0.0.1"
    },
    "volumes": [
      "vol1",
      "vol2"
    ]
  }
}

PythonとTOML

Python 3.11では、TOMLフォーマットをパースする tomllib が標準ライブラリとして提供されることになりました。 これは、PEP 680 - tomllib: Support for Parsing TOML in the Standard Library で提案され、実装されました。 PEP 680の主な内容を簡単にまとめると、以下のような記載があります。

  • TOMLはPythonのパッケージングに選ばれているファイルフォーマットであり、 下記のPEPで提案され実装されている

    • PEP 517 : パッケージングのビルドシステムにおけるインターフェース

    • PEP 518 : パッケージングにおける依存関係を指定する設定ファイル(pyproject.toml)フォーマット

    • PEP 621 : プロジェクトのメタデータの記述方法に関する規定

  • TOMLがPythonで標準サポートされることにより、ベンダー依存のあるTOMLパーサーの問題を解決できる

  • TOMLはPythonのエコシステムにおいても既に特別な位置にあり、標準サポートになることは理になかっている

  • パーサーはオープンソースとして提供されている tomli を実装の基本として使用している

  • 現在のところ、読み込みだけがサポートされている

読み込みのみのサポートについて補足すると、PEP 680 においてはTOMLの書き込みに関する具体的なサードパーティパッケージの記述はありません。 しかし、tomllibモジュールのドキュメント には以下のパッケージについて引用されています。TOMLの書き込みが必要な場合に導入を検討しましょう。

Pythonのライブラリパッケージングにおいては、インストールに必要な依存関係を pyproject.toml に定義することになっており、このような背景もありTOMLパーサーの標準ライブラリ化が進んだと思われます。 pyproject.toml については先述の PEP 517PEP 518PEP 621 の他にも以下のサイトに記載がありますので参考にしてみてください。

以下は pyproject.toml をビルド設定ファイルとして記述した例です。 build-system テーブルにビルドに関する情報を定義し、 project テーブルにメタデータが定義されていることがわかります。

[build-system]
requires      = ["setuptools>=40.8.0", "wheel"]  # 使用ツール
build-backend = "setuptools.build_meta"

[project]
name = "hello-monthly-topics"  # パッケージ名
version = "1.0.0"
description = "This is an example app"
readme = "README.md"
authors = [{ name = "Satoru Kadowaki", email = "mail@example.com" }]
dependencies = [  # 依存パッケージ
    "requests >= 2.25.1",
    'tomli; python_version < "3.11"',
]
requires-python = ">=3.9"

tomllibの基本的な使い方

それでは、ファイルや文字列に記述された簡単なTOMLフォーマットをパースしてみます。 tomllib モジュールはとてもシンプルで、以下の2つの関数が利用できるだけです。

  • loads() 関数: 文字列で記載されたTOMLをパースして辞書(dict)型を返す

  • load() 関数: TOMLファイルをパースして辞書(dict)型を返す

サンプルコードを実行する際に使用したTOMLは以下のとおりです。

writer = "kadowaki"
month = 11
pi = 3.14
is_example = true
created_at = 2022-11-01T22:38:34+09:00
due_date = 2022-10-30
values = [1, 2, 3]

[table]
name.first = "Tom"  # ドット付きキーで同一属性をまとめる(インラインテーブル)
name.last = "Preston-Werner"
birthday = { year = 1994, month = 1 }  # 波括弧でまとめたインラインテーブル

[[editors]]  # テーブルの配列
name = "kadowaki"
month = 11
short_title = "toml"

[[editors]]
name = "Fukuda"
month = 10
short_title = "async"

example.tomlというファイルに保存された上記のTOMLをパースする場合は以下のように行います。

import tomllib
from pprint import pprint

with open("example.toml", mode="rb") as f:
    pprint(tomllib.load(f))

サンプルコードを実行すると、下記の結果が得られます。 指定したキーと値が辞書型で取得され、以下のように自動的にPythonのオブジェクトに変換されていることがわかります。 また、インラインテーブル( table )はネスト構造化された辞書型に、テーブルの配列( editors )は辞書型のリストに変換されています。

{'created_at': datetime.datetime(2022, 11, 1, 22, 38, 34, tzinfo=datetime.timezone(datetime.timedelta(seconds=32400))),
 'due_date': datetime.date(2022, 10, 30),
 'editors': [{'month': 11, 'name': 'kadowaki', 'short_title': 'toml'},
             {'month': 10, 'name': 'Fukuda', 'short_title': 'async'}],
 'is_example': True,
 'month': 11,
 'pi': 3.14,
 'table': {'birthday': {'month': 1, 'year': 1994},
           'name': {'first': 'Tom', 'last': 'Preston-Werner'}},
 'values': [1, 2, 3],
 'writer': 'kadowaki'}

同様に文字列として読み込む場合は以下のように行います。

import tomllib
from pprint import pprint

toml_doc = """ここにexample.tomlの内容を記述します"""

pprint(tomllib.loads(toml_doc))

先述のとおり、loadまたはloads関数ではTOMLフォーマットを読み込んだ結果を辞書(dict)型として返します。 それぞれの値はPythonの基本ブジェクト(str, int, boolなど)に自動的に変換されますが、その変換テーブルは以下のようになっています。

TOML

Python

table

dict

string

str

integer

int

float

float

boolean

bool

オフセットdate-time

datetime.datetime(Awareオブジェクト) [1]

ローカルdate-time

datetime.datetime(Naiveオブジェクト) [1]

local date

datetime.date

local time

datetime.time

array

list

例外処理

TOMLフォーマットをloadまたはloads関数でパースできない場合に TOMLDecodeError が返されます。 下記のサンプルコードでは、 同一のキー name が2回定義されています。 TOMLではキーの重複を許容していないためエラーになります。

import tomllib
from pprint import pprint

toml_doc = """
name = "Tom"
name = "Preston-Werner"
"""

pprint(tomllib.loads(toml_doc))

このコードを実行すると、以下のような結果になります。 エラーメッセージとして tomllib.TOMLDecodeError: Cannot overwrite a value (at line 3, column 24) と出力されている他にも、Python 3.11で改善されたTracebackがより細かいエラー箇所の位置を示しています。

$ python example_error.py
Traceback (most recent call last):
  File "/202211-code/example_error.py", line 9, in <module>
    pprint(tomllib.loads(toml_doc))
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/tomllib/_parser.py", line 102, in loads
    pos = key_value_rule(src, pos, out, header, parse_float)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/tomllib/_parser.py", line 349, in key_value_rule
    raise suffixed_err(src, pos, "Cannot overwrite a value")
tomllib.TOMLDecodeError: Cannot overwrite a value (at line 3, column 24)

書き込みについて

先述のとおり、tomllibモジュールでは読み込みのみがサポートされています。 TOMLの書き込みがサポートされない大きな理由として、TOML自体のスタイル仕様でインデントや引用符については揺らぎを許容していることがあげられます。 書き込みをサポートする場合にはインデントをどうするかなどを確定する必要があったことから、書き込みについてはサードパーティに委ねることになりました。

TOMLの書き込みについては先述のとおり tomli-wtomlkit がありますが、ここでは tomli-w の使用方法について簡単に紹介します。

インストールは pipコマンドで行います。

$ pip install tomli-w

書き込みは dumps() 関数または dump 関数を使用します。

  • dumps() 関数: 辞書オブジェクトをTOMLフォーマットに変換して文字列(str)型を返す

  • dump() 関数: 辞書オブジェクトをTOMLフォーマットに変換してファイルオブジェクトに書き込む

以下のサンプルではparamsで定義した辞書型オブジェクトをTOMLフォーマットに変換して、標準出力とファイルに出力しています。 paramsの中身は load() 関数で出力された結果の一部を抜粋してPythonオブジェクトにしています。

import datetime
import tomli_w

params = {
    "created_at": datetime.datetime(
        2022, 11, 1, 22, 38, 34, tzinfo=datetime.timezone(datetime.timedelta(seconds=32400))
    ),
    "editors": [
        {"month": 11, "name": "kadowaki", "short_title": "toml"},
    ],
    "table": {
        "birthday": {"month": 1, "year": 1994},
        "dob": datetime.datetime(1979, 5, 27, 7, 32),
        "name": {"first": "Tom", "last": "Preston-Werner"},
    },
}


print(tomli_w.dumps(params))  # 文字列として出力

with open("./example2.toml", "wb") as f:  # ファイルに出力
    tomli_w.dump(params, f)

出力結果は以下のようになります。 [table] の出力結果が元データとは少し異なっていることからも、書き込みモジュールによって揺らぎが起こることは容易に想像できそうです。

created_at = 2022-11-01 22:38:34+09:00
editors = [
    { month = 11, name = "kadowaki", short_title = "toml" },
]

[table]
dob = 1979-05-27 07:32:00

[table.birthday]
month = 1
year = 1994

[table.name]
first = "Tom"
last = "Preston-Werner"

TOMLフォーマットのバリデーション

現時点ではTOMLフォーマットに関する明確な型チェックはサポートされていません。 しかしながら、以前の記事 Python最新バージョン対応!より良い型ヒントの書き方 でも紹介したTypedDictや、サードパーティライブラリである pydantic を利用して読み込まれたTOMLにバリデーションを行うことができます。 本記事では上記の紹介のみとなりますが、興味のある方はTypedDictやPydanticを試してみてください。

まとめ

Pythonスクリプトを実行するために必要な設定ファイルのフォーマットにJSONやYAMLがよく使用されますが、TOMLはこれらと比較しても簡潔に柔軟なデータ構造を表現できるデータフォーマットです。 複雑なデータ構造の読み込みを追加モジュールなしに利用でき、Pythonオブジェクトに自動変換されることは開発者にとってとてもありがたいことです。 設定ファイルをどのフォーマットにするか悩まされてきたみなさんも、今後はTOMLフォーマットを中心に考えると良さそうです。 Python 3.11に移行する際には是非利用してみてください!