[小ネタ] PyYAMLとゼロで始まる数字の話

PyYAMLで数字だけの文字列を扱うとき、自分の直感とは異なる動作があったので、その備忘録です。
2019.10.09

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

PyYAMLで数字だけの文字列を扱うとき、自分の直感とは異なる動作があったので、その備忘録です。

実行環境は以下のとおりです。

  • Python: 3.7.3
  • PyYAML: 5.1.2

tl;dr

  • PyYAMLはゼロで始まる数字を以下のように扱います
    • 8進数として解釈できるなら8進数として扱う
    • 8進数として解釈できないなら文字列として扱う
  • PyYAMLがYAMLを出力する時、ゼロで始まる数字だけの文字列は以下のように出力されます
    • 8進数として解釈できる文字列ならクォートして出力
    • 8進数として解釈できない文字列ならクォートせずに出力

発端

AWSのアカウントIDは12桁の数字で構成されており、当然"012345678901"のようにゼロで始まる場合もあります。 これをプログラムで扱う場合、整数として扱ってしまうと頭のゼロが消えてしまったりして面倒なことになりそうなので、全て文字列として扱うことが多いと思います。

先日、複数のAWSアカウントに対してとある処理を行って、結果をそれぞれ所定のS3バケットに出力する、という感じのプログラムを書く機会がありました。 そこで、処理対象のアカウントリストをYAMLで作りたいと思い、以下のようなことをやると

data = [
    {'account_id': '012345678901', 'bucket': 'bucket012345678901'},
    {'account_id': '012345678902', 'bucket': 'bucket012345678902'},
]
print(yaml.dump(data))

結果は以下のように出力されました。文字列になって欲しいのでaccount_idの値がクォートされていて欲しいと思ったのですがされていません。

- account_id: 012345678901
  bucket: bucket012345678901
- account_id: 012345678902
  bucket: bucket012345678902

これでもちゃんと文字列として扱われるのだろうか?というのが分かっていなかったので調べた、というお話です。

YAML 1.1の仕様とPyYAMLの動作を理解する

この現象を理解するには、まずはYAML 1.1の仕様を知る必要があります。PyYAMLはドキュメントに以下のようにある通りYAML 1.1を完全にサポートしています。

a complete YAML 1.1 parser. In particular, PyYAML can parse all examples from the specification. The parsing algorithm is simple enough to be a reference for YAML parser implementors.

YAML 1.1では以下のような整数の表記が利用でき、ゼロで始まる数字は8進数として扱うことになっています。つまり010は10進数の8になります。 *1

  • 123: 普通のinteger (10進数)
  • 0123: 8進数
  • 0x123: 16進数

これをPyYAMLで確認してみます。例えば以下のようなプログラムを書いてみると

a = """
- 010
- 011
"""
print(yaml.safe_load(a))

出力は以下のようになります。8進数として解釈されていますね。

[8, 9]

ここで、8進数として扱われるのは、数字が全て0〜7の時のみであることに注意が必要です。 つまり、8か9が入っていればそれは8進数ではないので文字列として扱われ、以下のようになります。

a = """
- 010
- 011
- 018
- 11
"""
print(yaml.safe_load(a))
[8, 9, '018', 11]

これを踏まえて、逆にPyYAMLでYAMLを出力する場合を見てみます。 以下のように、数字だけの文字列をYAMLとして出力してみます。

d = ['011', '012', '018', '11']
print(yaml.dump(d))

結果は以下のようになります。

- '011'
- '012'
- 018
- '11'

8進数でないので文字列として扱われる'018'はクォートされず、その他はクォートされています。つまり、以下のような動作になっているわけです。

  • dumpしたものをもう一度loadした時、ちゃんと元の型が維持されるようになっている
  • その上で、クォートしなくて良いものはクォートしない

同じ数字だけの文字列でもクォートされたりされなかったりするのがちょっと気持ち悪い感じもしますが、 仕様を正確に適用して無駄なクォートを省くとこうなるということなのだと思います。

まとめ

というわけで、最初に書いた以下のYAMLですが

- account_id: 012345678901
  bucket: bucket012345678901
- account_id: 012345678902
  bucket: bucket012345678902

これをPyYAMLで読み込むと、ちゃんとaccount_idは文字列として認識されます。

a = """
- account_id: 012345678901
  bucket: bucket012345678901
- account_id: 012345678902
  bucket: bucket01234567
"""
print(yaml.safe_load(a))
[{'account_id': '012345678901', 'bucket': 'bucket012345678901'}, {'account_id': '012345678902', 'bucket': 'bucket012345678902'}]

つまり、dumpもloadもPyYAMLで行っている限りは、ちゃんと文字列は文字列として扱われます。

参考

脚注

  1. なお、YAML 1.2では8進数は "0o10" のように書きます(参照