YAML(PyYAML)で余計なことをしないカスタムローダーを作る

## 概要

YAML仕様は良かれと思っているのかいろんな機能が盛り込まれているのですが、それゆえにノルウェー問題などの弊害ばかり目立つ結果になってしまっています。Excelの二の舞いですね。

とはいえYAMLは設定ファイル記述フォーマットとしてそれなりに有名にはなってしまっているし、代替の設定記述言語はいろいろ出てはいるのですがどれも現状はイマイチ広まってない状態(個人の感想です)なので、つなぎとして基本的に余計なことは何もしないようなYAMLローダーを作ってみようと思いました。


## 目標

* 問答無用でキーと値全てを文字列みなすローダーと、値に関してはtrue,false,null,数値(整数・浮動小数点)くらいは型変換するローダーを作る
* タグ・エイリアス・アンカー・型指定のような特殊機能は全て無効、それらの機能に相当する特殊な文字列もそのまま単純な文字列として解釈させる


## 全部文字列として解釈するローダー

実は基本的にキーや値を全部文字列と解釈してほしいだけなら`BaseLoader`をローダーに適用するだけで実現できます。

```py
import yaml

yaml_str = """適当なYAML文字列"""

data = yaml.load(yaml_str, Loader=yaml.BaseLoader)
```

ただし、これだけでは特殊機能が有効なままになっており、エイリアスやアンカーもそのまま使えてしまいます。
一応型指定も有効です(指定しても特に意味はないようですが)

これらを無効にしたい場合は、自作ローダーを作るしかないようです。

```py
class SpecialCharactorDisabler:
    # 型設定文字列(!!)を無効化
    DEFAULT_TAGS = {
        '!': '!',
        '!!': '!!'
    }

    def fetch_alias(self):
        # alias(&)の無効化
        return self.fetch_plain()

    def fetch_anchor(self):
        # anchor(*)の無効化
        return self.fetch_plain()

    def check_plain(self):
        # 文字列として@と%を許可
        if self.peek() == '@':
            return True
        elif self.peek() == '%':
            return True
        else:
            return super().check_plain()

    def disable_tag(self):
        # !文字の無効化(タグの無効化)
        self.__class__.add_multi_constructor('!', lambda loader, suffix, node: f"!{suffix} {node.value}")


class StrictStringLoader(SpecialCharactorDisabler, yaml.BaseLoader):
    def __init__(self, stream):
        super().__init__(stream)
        self.disable_tag()

```

参考

https://github.com/guyskk/simple-yaml/blob/master/simple_yaml.py?utm_source=pocket_saves

PyYAMLの`Scanner`クラスのメソッドの一部をオーバーライドして本来特殊文字として扱う文字を普通の文字であるとみなすように書き換えるのがキモになります。`!`に関しては`add_multi_constructer`で文字列をそのまま返すラムダ関数を設定しています。

この`StrictStringLoader`を`yaml.load`の`Loader`引数に指定すると内容に関係なくタグや特殊文字列も含めて普通の文字列として処理してくれます。

```py
yaml_str = """
# 型指定文字列
key1: !!int 100
# 数値
key2: 100
# アンカー
&anchor1 key3: aliased_value
# エイリアス
key4: *anchor1
# 真偽値
key5: true
# タグ
key6: !test tag
"""

# YAMLをStrictStringLoaderでパース
data = yaml.load(yaml_str, Loader=StrictStringLoader)

print('Parsing StrictStringLoader')
print(data)

# 同じYAMLをBaseLoaderでパース
data2 = yaml.load(yaml_str, Loader=yaml.BaseLoader)

print('Parsing BaseLoader')
print(data2)

"""
実行結果：

Parsing StrictStringLoader
{'key1': '!!int 100', 'key2': '100', '&anchor1 key3': 'aliased_value', 'key4': '*anchor1', 'key5': 'true', 'key6': '!test tag'}
Parsing BaseLoader
{'key1': '100', 'key2': '100', 'key3': 'aliased_value', 'key4': 'key3', 'key5': 'true', 'key6': 'tag'}
"""
```

アンカー・エイリアス・タグ・型指定の特殊な文字列もそのままキーや値の文字列として適用してくれるようになりました。

`BaseLoader`ではエラーになる`@`や`%`が先頭についている文字列も`StrictStringLoader`ではそのまま文字列として解釈してくれます。

```py
yaml_str2 = """
# @
key7: @abc
# %
key8: %def123
"""

# YAMLをパース(BaseLoaderではエラーになるのでStrictStringLoaderのみ)
data3 = yaml.load(yaml_str2, Loader=StrictStringLoader)

print(data3)

"""
実行結果

{'key7': '@abc', 'key8': '%def123'}
"""
```

## 数値と一部文字列を最低限型変換するローダー

さすがにJSONでも解釈できる`true`,`false`,`null`と数値(整数と浮動小数点)くらいは型変換してほしい場面もあるので、それくらいは型変換するカスタムローダーも作ってみます。

```py
class StrictLoader(SpecialCharactorDisabler, yaml.SafeLoader):
    def __init__(self, stream):
        super().__init__(stream)
        self.yaml_implicit_resolvers = self.set_resolver()
        self.disable_tag()
        StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, StrictLoader.construct_mapping)

    def set_resolver(self):
        # revolverの変更
        new_implicit_resolvers = {}
        for first_char, type_list in yaml.resolver.Resolver.yaml_implicit_resolvers.items():
            new_type_list = []
            for i in range(len(type_list)):
                if type_list[i][0] == 'tag:yaml.org,2002:bool':
                    # bool型の正規表現を変更(true,falseのすべて小文字のみ認める。ノルウェー問題対応)
                    new_type_list.append(('tag:yaml.org,2002:bool', re.compile(r'^(?:true|false)$', re.VERBOSE)))
                elif type_list[i][0] == 'tag:yaml.org,2002:int':
                    # int型の正規表現を変更(+記号を認めない、_による桁区切りも認めない、8,16,60進数表現を削除)
                    new_type_list.append(('tag:yaml.org,2002:int', re.compile(r'^(?:[-]?(?:0|[1-9][0-9]*))$', re.VERBOSE)))
                elif type_list[i][0] == 'tag:yaml.org,2002:float':
                    # float型の正規表現を変更(指数表現以外の+記号を認めない、_による桁区切りも認めない、60進数表現を削除)
                    new_type_list.append(('tag:yaml.org,2002:float', re.compile(r'^(?:[-]?(?:[0-9][0-9]*)\.[0-9]*(?:[eE][-+][0-9]+)?|\.[0-9][0-9]*(?:[eE][-+][0-9]+)?)$', re.VERBOSE)))
                elif type_list[i][0] == 'tag:yaml.org,2002:null':
                    # null型の正規表現を変更(チルダを認めず、nullのすべて小文字のみ認める)
                    new_type_list.append(('tag:yaml.org,2002:null', re.compile(r'^(?:null)$', re.VERBOSE)))
                elif type_list[i][0] == 'tag:yaml.org,2002:timestamp':
                    # timestamp型の正規表現を削除
                    pass
                else:
                    # その他の型はそのまま追加
                    new_type_list.append(copy.deepcopy(type_list[i]))
            new_implicit_resolvers[first_char] = new_type_list
        return new_implicit_resolvers

    def construct_mapping(self, node, deep=False):
        # 辞書内のキーを文字列として処理
        mapping = super().construct_mapping(node, deep=deep)
        return {str(key): value for key, value in mapping.items()}
```

PyYAMLには`resolver`というものがあり、これがクオートのない文字列のうち特定の正規表現にマッチするものを設定した型とみなす動作をしているようで、この正規表現を書き換えることで型変換の動作を変えようというのがこのローダーのキモになっています。

`resolver`で使う正規表現は`yaml_implicit_resolvers`という`BaseResolver`のクラス変数に入っているのですが、これは先頭の文字をキーとした辞書になっており、値は型名とその型であるとみなす条件になる正規表現のコードのタプルがキーの文字が先頭になっている場合に変換できる可能性のある型の分だけ入ったリストになっているので、全てのキーとその中のリストを列挙して正規表現を全て置き換える必要があります。

`yaml_implicit_resolvers`の内容を直接変更すると後で他のローダーを使ったときに挙動が変わってしまうようです。そのため新しい辞書を作り列挙しながら変更のある部分は変更したものを追加、変更のない部分は元の情報のコピーを追加していくことで他のローダーには影響しないようにしました。

```py
yaml_str = """
# 数値
key1: 100
key2: 3.14
# 真偽値
key3: true
key4: false
# null
key5: null
# 日時表現は無効(文字列扱い)
key6: 2021-07-27
# 8, 16, 60進数表現は無効(文字列扱い)
key7: 0123
key8: 0x123
key9: 12:34:56
# _による桁区切りは無効(文字列扱い)
key10: 1_000_000
# 真偽値・nullは小文字のみ有効
key11: True
key12: False
key13: Null
key14: TRUE
key15: FALSE
key16: NULL
# ノルウェー問題対応
key17: yes
key18: no
key19: on
key20: off
key21: YES
key22: NO
key23: ON
key24: OFF
key25: Yes
key26: No
key27: On
key28: Off
"""

# YAMLをStrictLoaderでパース
data = yaml.load(yaml_str, Loader=StrictLoader)

print('Parsing StrictLoader')
print(data)

# YAMLをSafeLoaderでパース
data2 = yaml.load(yaml_str, Loader=yaml.SafeLoader)

print('Parsing SafeLoader')
print(data2)

"""
実行結果

Parsing StrictLoader
{'key1': 100, 'key2': 3.14, 'key3': True, 'key4': False, 'key5': None, 'key6': '2021-07-27', 'key7': '0123', 'key8': '0x123', 'key9': '12:34:56', 'key10': '1_000_000', 'key11': 'True', 'key12': 'False', 'key13': 'Null', 'key14': 'TRUE', 'key15': 'FALSE', 'key16': 'NULL', 'key17': 'yes', 'key18': 'no', 'key19': 'on', 'key20': 'off', 'key21': 'YES', 'key22': 'NO', 'key23': 'ON', 'key24': 'OFF', 'key25': 'Yes', 'key26': 'No', 'key27': 'On', 'key28': 'Off'}
Parsing SafeLoader
{'key1': 100, 'key2': 3.14, 'key3': True, 'key4': False, 'key5': None, 'key6': datetime.date(2021, 7, 27), 'key7': 83, 'key8': 291, 'key9': 45296, 'key10': 1000000, 'key11': True, 'key12': False, 'key13': None, 'key14': True, 'key15': False, 'key16': None, 'key17': True, 'key18': False, 'key19': True, 'key20': False, 'key21': True, 'key22': False, 'key23': True, 'key24': False, 'key25': True, 'key26': False, 'key27': True, 'key28': False}
"""
```

ノルウェー問題への対応はもちろん、その次に問題としてあげられる時刻表現のような60進数表記(`12:34:56`)もこれで無効になりました。


## ちょっと緩いローダー

個人的には`true`,`false`,`null`の大文字小文字違いとか、数値の`_`による桁区切りくらいは認めてもいいんじゃないかと感じているので、それらを認めたローダーとして`SimpleLoader`も作ってみました

```py
class SimpleLoader(StrictLoader):
    def set_resolver(self):
        # revolverの変更
        new_implicit_resolvers = {}
        for first_char, type_list in yaml.resolver.Resolver.yaml_implicit_resolvers.items():
            new_type_list = []
            for i in range(len(type_list)):
                if type_list[i][0] == 'tag:yaml.org,2002:bool':
                    # bool型の正規表現を変更(true,falseのすべて小文字、先頭大文字、すべて大文字のみ認める。ノルウェー問題対応)
                    new_type_list.append(('tag:yaml.org,2002:bool', re.compile(r'^(?:true|True|TRUE|false|False|FALSE)$', re.VERBOSE)))
                elif type_list[i][0] == 'tag:yaml.org,2002:int':
                    # int型の正規表現を変更(+記号を認めない、_による桁区切りは認める、8,16,60進数表現を削除)
                    new_type_list.append(('tag:yaml.org,2002:int', re.compile(r'^(?:[-]?(?:0|[1-9][0-9_]*))$', re.VERBOSE)))
                elif type_list[i][0] == 'tag:yaml.org,2002:float':
                    # float型の正規表現を変更(指数表現以外の+記号を認めない、_による桁区切りは認める、60進数表現を削除)
                    new_type_list.append(('tag:yaml.org,2002:float', re.compile(r'^(?:[-]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)?|\.[0-9][0-9_]*(?:[eE][-+][0-9]+)?)$', re.VERBOSE)))
                elif type_list[i][0] == 'tag:yaml.org,2002:null':
                    # null型の正規表現を変更(チルダを認めず、nullのすべて小文字、先頭大文字、全て大文字のみ認める)
                    new_type_list.append(('tag:yaml.org,2002:null', re.compile(r'^(?:null|Null|NULL)$', re.VERBOSE)))
                elif type_list[i][0] == 'tag:yaml.org,2002:timestamp':
                    # timestamp型の正規表現を削除
                    pass
                else:
                    # その他の型はそのまま追加
                    new_type_list.append(copy.deepcopy(type_list[i]))
            new_implicit_resolvers[first_char] = new_type_list
        return new_implicit_resolvers


data3 = yaml.load(yaml_str, Loader=SimpleLoader)

print('Parsing SimpleLoader')
print(data3)

"""
実行結果

Parsing SimpleLoader
{'key1': 100, 'key2': 3.14, 'key3': True, 'key4': False, 'key5': None, 'key6': '2021-07-27', 'key7': '0123', 'key8': '0x123', 'key9': '12:34:56', 'key10': 1000000, 'key11': True, 'key12': False, 'key13': None, 'key14': True, 'key15': False, 'key16': None, 'key17': 'yes', 'key18': 'no', 'key19': 'on', 'key20': 'off', 'key21': 'YES', 'key22': 'NO', 'key23': 'ON', 'key24': 'OFF', 'key25': 'Yes', 'key26': 'No', 'key27': 'On', 'key28': 'Off'}
"""
```