PythonでYAMLをJSON変換して見て分かったこと

## 概要
AWS CloudFormation の template.yaml を JSON に変換できないかな…と思って、変換方法を色々試した。
結果、次のことが分かった。

* PyYAML は `YYYY-MM-DD` を見つけると datetime.date として読み込むので何もせずにJSON出力すると失敗する  
  → これは何とかなるので別にいい
* AWS の YAML と JSON でキー名の慣例が違う (YAML: PascalCase, JSON: CamelCase)し、そもそも統一されていない  
  → 自動変換するツールを作るのは無理。というのは AWS側の仕様で決めたキー名と利用者が決めた名前のキーでルールがまちまちだから。
* **そもそも YAML は表現を保存しようとは思っていない**

最後の点を取り上げようと思う。

## YAML は表現方法を保存しようとは思っていない
YAMLの仕様で書式は定められているが、それに従うとその書式から解析した値を取り込む。

### 時刻のようなものの扱い
YAMLの仕様で `:` 区切りの時刻のようなものは 60進数として扱うので、PyYAML もそれに従って数値として取り込む。そのため、時刻書式という表現は保存されない。Python の datetime.time は 0-23時の範囲で入力制限があるが YAML のこの書式は 23より大きな値も入る。

```python
>>> import yaml
>>> yaml.safe_load('Time: 1:01')
{'Time': 61}
>>> yaml.safe_load('Time: 1:00:00')
{'Time': 3600}
```

### 数値の扱い

16進数や8進数も数値として取り込む。もともとが 8進数や16進数であったことは忘れて整数になる。

```python
>>> yaml.safe_load('hex: 0xC')
{'hex': 12}
>>> yaml.safe_load('hex: 077')
{'hex': 63}
>>> 7*8+7
63
>>>
```

### 日付時刻の扱い
ISO8601の標準的な形式以外に日付時刻の間に空白をいれた形式も理解する。ポイントは datetime オブジェクトで解釈することで、JSON変換するような場合に `T` で区切ってあったのか ` ` で区切ってあったのかを忘れるということ。

```python
>>> yaml.safe_load('2021-07-27T10:10:10')
datetime.datetime(2021, 7, 27, 10, 10, 10)
>>> yaml.safe_load('2021-07-27 10:10:10')
datetime.datetime(2021, 7, 27, 10, 10, 10)
>>>
```

## 変換ツール
* Python 3.8.x
* Ubuntu 18.04 LTS

### 素朴な JSON変換
もっと分かりにくく短く書くことはできるのですが、まぁ、これで。

```python
"""Convert yaml to json."""
import json
import datetime
import sys
import typing

import yaml


def json_dumper(obj: typing.Any) -> typing.Any:
    """Dump json."""
    if isinstance(obj, datetime.date):
        # date and datetime
        return obj.isoformat()
    else:
        return obj


def yaml_to_json(yamlFileName: str) -> str:
    """Convert yaml to json"""
    with open(yamlFileName, 'rb') as f:
        d = yaml.safe_load(f)
        s = json.dumps(
            d,
            default=json_dumper,
            indent=4
        )
        return s


if __name__ == '__main__':
    print(yaml_to_json(sys.argv[1]))

# EOF
```

### 日付の変換をやめさせる

日付程度なら実は上のコードで十分なのだが、Loaderを使った例があまりないので掲載しておく。
日付書式の文字列をそのまま JSON 側に適用してくれる。

なお、 `yaml.load(xxx)` のように Loader を省略すると deprecated の警告が出る。 `yaml.safe_load` はローダーがハードコーディングされた形式となっている。

```python
"""Convert yaml to json."""
import json
import sys
import typing

import yaml


class NoDatesSafeLoader(yaml.SafeLoader):
    """Disable SafeLoader.
    https://stackoverflow.com/questions/34667108/ignore-dates-and-times-while-parsing-yaml
    """
    @classmethod
    def remove_implicit_resolver(cls, tag_to_remove: str) -> typing.NoReturn:
        """
        Remove implicit resolvers for a particular tag

        Takes care not to modify resolvers in super classes.

        We want to load datetimes as strings, not dates, because we
        go on to serialize as json which doesn't have the advanced types
        of yaml, and leads to incompatibilities down the track.
        """
        if 'yaml_implicit_resolvers' not in cls.__dict__:
            cls.yaml_implicit_resolvers = cls.yaml_implicit_resolvers.copy()

        for first_letter, mappings in cls.yaml_implicit_resolvers.items():
            cls.yaml_implicit_resolvers[first_letter] = [
                (tag, regexp) for tag, regexp in mappings if tag != tag_to_remove
            ]


def yaml_to_json(yamlFileName: str) -> str:
    """Convert yaml to json"""
    NoDatesSafeLoader.remove_implicit_resolver('tag:yaml.org,2002:timestamp')

    with open(yamlFileName, 'rb') as f:
        d = yaml.load(f, Loader=NoDatesSafeLoader)
        return json.dumps(d, indent=4)


if __name__ == '__main__':
    print(yaml_to_json(sys.argv[1]))

# EOF
```

## テストデータ

```yaml
# Date and Time
DateTime:
  Time:
    time: 20:03:47
  Timestamps:
    canonical: 2001-12-15T02:59:43.999999Z
    Date: 2012-10-17
    dt spaced 1: 2012-10-17 00:00:00
    dt spaced 2: 2012-10-17 23:59:00
    dt spaced 3: 2012-10-17 23:59:00 -1
    dt iso8601 1: 2012-10-17t23:59:00+09:00
    dt iso8601 2: 2012-10-17T23:59:00-01:00
Variables:
  ArrayStr:
  - Foo
  - Bar
  - Hello
    World
  - |
    Hello
    World
  Integers:
    canonical: 12345
    decimal: +12345
    octal: 02472256     # 685230
    hexadecimal: 0xC    # 12
    base 60: 190:20:30  # 685230
  Floats:
    pi: 3.14159265358979323846264338327950
    fixed: 685_230.15
    canonical: 6.8523015e+5
    exponential: 685.230_15e+03
    Not a number: .NaN
    Inf: .inf
    NegativeInf: -.inf
  Booleans:
  - true
  - false
  Null: null
  Sequence of Sequences:
  - [Apple, 200]
  - [Banana, 100]
Resources:
  SNSTopic:
    Type: AWS::SNS::Topic
    Condition: CreateResources
    Properties:
      TopicName: foo-topic
```

## 参考
* [Language-Independent Types for YAML™ Version 1.1](https://yaml.org/type/)