Python Monthly Topics

Python 3.11から新たに仲間に加わったTOMLパーサー

門脇@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 = "[email protected]" }]
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)

TOMLの書き込みについて

先述のとおり、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 Monthly Topics 2022年9月の記事Python最新バージョン対応!より良い型ヒントの書き方 」でも紹介したTypedDictや、サードパーティライブラリであるpydanticを利用して、読み込まれたTOMLにバリデーションを行うことができます。

本記事では上記の紹介のみとなりますが、興味のある方はTypedDictやpydanticを試してみてください。

まとめ

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

設定ファイルをどのフォーマットにするか悩まされてきたみなさんも、今後はTOMLフォーマットを中心に考えると良さそうです。Python 3.11に移行する際には是非利用してみてください!

おすすめ記事

記事・ニュース一覧