【Python】汎用コンテナとclassを相互に変換する

## ざっくり要約
かなり間が空きましたが、以前書いた記事の続編的位置づけです。
今回作ったものは[github](https://github.com/akio-1978/object_converter)に上げました。pipでインストールできる形にしてあるのは自分用です。

#### やりたかったこと
1. jsonで受け取ったデータ構造をちょこちょこ処理して、別のデータ構造でDynamoDBにputしようとした。
2. dictやlistを直接操作するコードが増えてうんざり。**意味のあるデータ自身に振舞いを持たせたい**というオブジェクト指向気取りな気分になる。
3. ならばclassに変換してしまおう。処理結果のクラスをdictに変換してしまおう。

#### 実際にやったこと
1. 「dictやlistなどの汎用コンテナで作られた構造」を「任意のclass構造」に変換する。それと、その逆。[^generic-conteiners]
2. 変換ルールを定義するためのマッピングをdictで作って、それに従って変換するクラスを作りました。
3. 使用例をユニットテストで紹介します。

実際のコードを[github](https://github.com/akio-1978/object_converter)に置いておきます。

## さっそく作る
[以前似たような物を作っています](https://qiita.com/Akio-1978/items/38cab9872601b5c0b549)が、コードも変わったし、時間も経ったので端折らずに1から書きます。

### イメージ
例えば、変換元のソースとしてこんなdictがあるとします。dictの中にdictを含む構造です。
#### 変換元のソース

```python
src_dict = {
    'value' : 'AAA',
    'nested' : {
        'nested_value' : 'BBB'
    }
}
```
これを、こんなclassに変換したいとします。TastClassの`self.nested`にNestedClassのインスタンスを持たせたいと考えています。

#### 変換先のクラス
```python
# クラスその1
class TestClass():
    def test_method(self):
        return 'assigned value: ' + self.value
# クラスその2 - その1にぶら下がるイメージ
class NestedTestClass:
    def test_method(self):
        return 'nested assigned value: ' + self.nested_value
```

#### 二つのデータを結びつける情報
上の変換を行うには、コンテナのどの要素をどのclassに変換するか、というマッピングが必要だと思うので、こんな形のdictを考えてみた。

```python
mapping = {
    '<MAPPING_ROOT>' : TestClass, # 最上位は名前がないので'<MAPPING_ROOT>'とする
    'nested' : NestedClass
}
```
`[class/dictのキー] : [関数]`のマッピングをdictで表現します。`src_dict`が指すdictそのものには名前がないので、代わりに`<MAPPING_ROOT>`をキーとします。関数としてコンストラクタを指定しています。
`mapping`に含まれない項目、この例だと`src_dict['value']`などは、そのまま変換先に設定します。

#### 使い方
こんな風に使いたいと思っています。

```python:usage
# コンストラクタでマッピングを取る
converter = ObjectConverter(mapping=mapping)
# 変換メソッドに変換元データを渡す
converted_class = converter.convert(src_dict)
# 変換されたクラスのメソッドを呼ぶ
converted_class.test_method()
```

## 実装
こんな風に作ってみました。
<details><summary>converter.pyとして作りましたが、長い。畳みます。</summary><div>

```python:converter.py

class ObjectConverter:
    # 生成時にマッピング定義を受け取る
    def __init__(self, *, mapping):
        self.mapping = mapping

    # 変換の呼出しメソッド
    def convert(self, src):
        # 最上位の要素はマッピング'<root>'と必ずマッチする前提
        return self._convert_value('<MAPPING_ROOT>', self.mapping['<MAPPING_ROOT>'], src)

    # 値に従って処理方法を決める
    def _convert_value(self, key, func, value):
        # リストの場合、要素全てをfuncで変換していく
        if isinstance(value, (list, tuple)):
            return self._convert_sequence(key, func, value)

        # dictの場合、そのままキーと値を取り出す
        if isinstance(value, dict):
            return self._convert_dict(key, func, value)

        # classの場合__dict__を取り出してdictとして扱う
        if isinstance(value, object) and hasattr(value, '__dict__'):
            return self._convert_dict(key, func, value.__dict__)

        # どれにも該当しないものはそのまま返す
        return value

    # dictの中身を変換していく
    def _convert_dict(self, key, func, src):
        # _call_functionで生成したオブジェクトに中身を詰める
        return self._assign_dict(self._call_function(key, func, src), key, src)

    # mappingで指定されたオブジェクトの生成
    def _call_function(self, key, func, src):
        return func()

    # dictの中身を取り出して当てはめて行く
    def _assign_dict(self, dest, key, src):

        for srcKey, value in src.items():

            # keyがマッピングに定義されている
            if srcKey in self.mapping:
                func = self.mapping[srcKey]
                # マッピングされた関数を実行して結果をセットする
                self._set_value(dest, srcKey, self._convert_value(srcKey, func, value))

            # keyがマッピング定義にない物は、そのままセットする
            else:
                # ここで渡されたvalueの中にマッピング定義された値があっても無視する
                self._set_value(dest, srcKey, value)

        # createdにsrcの中身が反映された状態
        return dest

    # リストの処理
    def _convert_sequence(self, key, func, sequence):
        current = []
        for value in sequence:
            current.append(self._convert_value(key, func, value))
        return current

    # dictとclass両方に対応する値setter
    def _set_value(self, dest, key, value):
        if isinstance(dest, dict):
            dest[key] = value
        else:
            setattr(dest, key, value)

    # dict変換用インスタンスを得るユーティリティメソッド
    #
    @classmethod
    def dict_converter(cls, mapping, *, dict_object=dict):
        reverse_mapping = {}

        # マッピング先を全てdictにしてしまう
        for key in mapping.keys():
            reverse_mapping[key] = dict_object

        # dictに変換するためのインスタンス
        return ObjectConverter(mapping=reverse_mapping)

```
</div></details>

### 大体の解説
**完全な仕様はソースを見てください。**とはいえ、なんのことはない、mappingに含まれるキーと合致した値をただ走査するだけです。ざっくり言えばこんなことをしています。

* 最上位要素は`<mapping_root>`のキーと合致したとみなす。
* dictの中にdictがあったら再帰的に走査する。
* classの場合、__dict__を取り出してdictと同じように扱う。
* list/tupleを見つけたら、全ての要素がmappingのキーと合致したものとして走査する。
* 合致したキーに応じて、キーに対応する関数を呼び出して変換先を生成する。
* mappingに含まない値は変換先にそのまま設定する。変換先に応じて、`setattr`を使うか、dictの値として代入するか使い分ける。

#### クラスの中身に触れる
classを見つけた場合に`__dict__`を取り出してしまうことで、結局は全てdictを走査するだけの処理になります。`__dict__`に触れるのはできれば避けたいですが、今回は他の方法を使う労力を避けることにします。特殊なクラスを扱わない限り問題はないはずです。[^dont-touch-this]

#### キーに反応しないケース
mappingに含まれない値はそのまま変換先のオブジェクトに設定されますが、このときの値がdictであったとして、その中にmappingに含まれる名前を持った値があっても、mappingの処理はされません。これは「mappingに含まれたdictの処理 or mappingに含まれue
dictの処理 」という場合分けが嫌なのと、そのような変換が必要になるデータ構造はあまり美しくないと思ったからそうしています。

#### class to dict変換
dict to class変換の後にclass to dict変換して元の形に戻したいケースのために、クラスメソッド`dict_converter`を用意して逆方向のコンバータが簡単に得られるようにしています。mapping上の変換先を全部dictにするだけなので簡単です。[^not-dict-something]

## 使い方の説明も兼ねたユニットテスト
<details><summary>長いので折りたたみます。</summary><div>

```python:test_objectconverter.py
import unittest
import json
from objectonverter import ObjectConverter

# テスト用クラスその1
class TestClass():
    def test_method(self):
        return 'TestObject.test_method'

# テスト用クラスその2
class NestedTestClass:
    def test_method(self):
        return 'NestedObject.test_method'


class ClassConverterTest(unittest.TestCase):

    # ルートになるクラスのプロパティを設定するだけ
    def test_object_convert(self):
        dict_data = {
            'value1' : 'string value 1'
        }

        converter = ObjectConverter(mapping={'<MAPPING_ROOT>' : TestClass})
        result = converter.convert(dict_data)
        self.assertEqual(result.value1, 'string value 1')

        # 生成したクラスのメソッドを呼んでみる
        self.assertEqual(result.test_method(), 'TestObject.test_method')

    # ネストしたクラスを生成する
    def test_nested_object(self):
        # jsonのキーとクラスをマッピングするdict
        object_mapping = {
            '<MAPPING_ROOT>' : TestClass,
            'nested' : NestedTestClass
        }
        # 生成元のソース
        dict_data = {
            'value1' : 'string value 1',
            'nested' : {
                'value' : 'nested value 1'
            }
       }

        converter = ObjectConverter(mapping=object_mapping)
        result = converter.convert(dict_data)
        self.assertEqual(result.value1, 'string value 1')

        self.assertIsInstance(result.nested, NestedTestClass)
        self.assertEqual(result.nested.value, 'nested value 1')

    # マッピングを指定しない場合はただのdict
    def test_nested_dict(self):
        object_mapping = {
            '<MAPPING_ROOT>' : TestClass
        }

        # 生成元のソース
        dict_data = {
            'value1' : 'string value 1',
            'nested' : {
                'value' : 'nested value 1'
            }
        }

        converter = ObjectConverter(mapping = object_mapping)
        result = converter.convert(dict_data)
        self.assertEqual(result.value1, 'string value 1')
        self.assertIsInstance(result.nested, dict)
        self.assertEqual(result.nested['value'], 'nested value 1')

    # リストの処理
    def test_sequence(self):
        mapping = {
            '<MAPPING_ROOT>' : TestClass,
            'nestedObjects' : NestedTestClass,
        }
        source_dict = {
            "value1" : "string value 1",
            "nestedObjects" : [
                {'value' : '0'},
                {'value' : '1'},
                {'value' : '2'},
            ]
        }

        converter = ObjectConverter(mapping=mapping)
        result = converter.convert(source_dict)
        self.assertEqual(result.value1, 'string value 1')
        self.assertEqual(len(result.nestedObjects), 3)

        for i in range(3):
            self.assertIsInstance(result.nestedObjects[i], NestedTestClass)
            self.assertEqual(result.nestedObjects[i].value, str(i))

    # ルート要素自体がリストの場合
    def test_root_sequence(self):
        object_mapping = {
            '<MAPPING_ROOT>' : TestClass,
        }

        source_list = [
            {'value' : '0'},
            {'value' : '1'},
            {'value' : '2'},
        ]

        converter = ObjectConverter(mapping=object_mapping)
        result = converter.convert(source_list)

        self.assertIsInstance(result, list)
        self.assertEqual(len(result), 3)

        for i in range(3):
            self.assertIsInstance(result[i], TestClass)
            self.assertEqual(result[i].value, str(i))

    # json -> class -> json
    def test_json_to_class_to_json(self):
        # クラスからjsonの相互変換に使う関数
        def default_method(item):
            if isinstance(item, object) and hasattr(item, '__dict__'):
                return item.__dict__
            else:
                raise TypeError

        # jsonのキーとクラスをマッピングするdict
        object_mapping = {
            '<MAPPING_ROOT>' : TestClass,
            'nested' : NestedTestClass
        }
        # 生成元のソース - 比較の都合のため一行で
        string_data = '{"value1": "string value 1", "nested": {"value": "nested value 1"}}'
        dict_data = json.loads(string_data)

        converter = ObjectConverter(mapping=object_mapping)
        result = converter.convert(dict_data)
        dump_string = json.dumps(result, default=default_method)
        self.assertEqual(dump_string, string_data)

        # 再変換しても結果が同じこと
        result = converter.convert(json.loads(dump_string))
        self.assertEqual(result.value1, 'string value 1')
        self.assertIsInstance(result.nested, NestedTestClass)
        self.assertEqual(result.nested.value, 'nested value 1')

    # 変換 -> 逆変換
    def test_reverse_convert(self):
        dict_data = {
            'value1' : 'string value 1'
        }
        mapping = {'<MAPPING_ROOT>' : TestClass}

        converter = ObjectConverter(mapping=mapping)
        result = converter.convert(dict_data)
        self.assertEqual(result.value1, 'string value 1')
        
        # 逆変換コンバータを生成
        reverse_converter = ObjectConverter.dict_converter(mapping=mapping)
        reversed_result = reverse_converter.convert(result)
        self.assertEqual(result.value1, reversed_result['value1'])



if __name__ == '__main__':
    unittest.main()
```
</div></details>

#### 解説
基本はまあ、見てもらえばわかるかなと
`test_json_to_class_to_json`という長い名前のテストケースがありますが、このクラスが元々jsonとの変換を強く意識していたゆえにこうなっています。

#### 蛇足な言い訳
前回記事から半年以上過ぎていますが、実はやっと就職してですね…時間が取れなかったので遅くなりました。

[^generic-conteiners]: 以下何度も「dict/list」と書くのは長いので、汎用コンテナと書きます。ついでに「クラス」というのも正確には「クラスのインスタンス」ですが、長いので「クラス」です。
[^json-to-class]: 最初に考えていたのがjsonの扱いだったので、jsonに囚われてしまった。物事を追いかけていると、実は非常にシンプルな結果を求めていたことに気が付く。「あるある」な流れですが、もっと注意深く考えないといけませんね。
[^dont-touch-this]: 楽だからついやってしまうんですが、`__dict__`は裏口みたいなもので、`__dict__`から値を取り出すと`__getattribute__`が呼び出されなかったりするので、クラスの本来の意図とは異なる動作をするかも知れません。ここでは、そこまでは考えないでいきます。
[^not-dict-something]: いちおうdict以外の型にも対応するように書きましたが、使い道は思いつかない。