Pythonのオブジェクトからplantumlのオブジェクト図を生成出力する

## オブジェクト図

Pythonでは、値、変数、関数、クラス、メソッド、モジュールなど、すべてがオブジェクトで、メモリ上に存在するものです。
メモリ上にどんなオブジェクトが存在しているのか把握することで、Pythonの動作を理解したり、実行状態を把握することができます。
実際、他の方のPython記事に対して、変数辞書からのオブジェクト図を示してコメントすることがあります。

そこで、指定したオブジェクトからplantumlのオブジェクト図（テキスト形式）を生成出力するプログラムを作ってみました。
なお、plantumlから画像に変換するには別の作業が必要です。Qiitaであれば、出力されたマークダウンテキストを投稿欄やコメント欄にコピー＆ペーストすれば図に変換して表示してくれます。

- コメントの例: https://qiita.com/risuoku/items/c71e4fdb7ea9d9800e20#comment-7b342ec242f0debc8e41
- 自分で書いたオブジェクト図の例
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29070/40de5c2e-8a57-53af-862c-6369a2c7e405.png)
- 指定したオブジェクトからplantumlを生成出力して作成した図の例（上の図を90度回転したような配置）:
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29070/c9dc7d21-4df3-506a-9d2d-e66d47a82fdc.png)

  色の説明：

  - ピンク： 出力指定したオブジェクト
  - 紫： 関数
  - 緑： クラス
  - 青： インスタンス
  - オレンジ: 変数辞書

## プログラム

### plantumlオブジェクト図生成出力スクリプト

```py:objectuml.py
import sys
import argparse
import importlib
from abc import ABCMeta

_making = []
_exists = {}


def exists(obj):
    if id(obj) in _exists:
        return True
    _exists[id(obj)] = obj  # Keep obj to prevent reassignment of the same id
    return False


def empty_function():
    pass


class EmptyClass:
    pass


FUNCTION = type(empty_function)
LINK_TYPES = {
    'int', 'float', 'complex', 'function',
    'type', 'list', 'tuple', 'dict', 'set', 'mappingproxy', 'ABCMeta',
}
NOLINK_TYPES = {
    'object', 'NoneType', 'bool', 'module', 'property', 'abstractproperty',
    'builtin_function_or_method', 'method', 'staticmethod', 'classmethod',
    'abstractmethod', 'abstractstaticmethod', 'abstractclassmethod',
    'member_descriptor', 'method_descriptor', 'getset_descriptor',
    'wrapper_descriptor', 'weakref', 'WeakSet',
}
MINIMIZE_TYPES = {
    'int', 'float', 'complex', 'str', 'bytes', 'function', 'module'
}
BUILTIN_TYPES = {
    *LINK_TYPES,
    *NOLINK_TYPES,
    *{name for name, obj in vars(__builtins__).items() if type(obj) == type}
}
BUILTIN_OBJECTS = (empty_function, EmptyClass, ABCMeta('', (), {}))
BUILTIN_MEMBERS = {name
                   for names in map(dir, BUILTIN_OBJECTS)
                   for name in names
                   if name not in ('__new__', '__init__', '__del__')}
BUILTIN_MEMBERS.update(globals())

COLOR_START = '#ffc6ff'  # pink
COLOR_VARS = '#ffe2c6'  # orange
COLOR_FUNCTION = '#c6c6ff'  # purple
COLOR_CLASS = '#c6ffc6'  # green
COLOR_OBJECT = '#c6ffff'  # blue


def link(base, from_, arrow, to_):
    if to_ is None or to_ == {}:
        return f'{from_} => {repr(to_)}'
    if options.minimize and type(to_).__name__ in MINIMIZE_TYPES:
        return f'{from_} => <{type(to_).__name__}> {repr(to_)}'
    if type(to_).__name__ in NOLINK_TYPES:
        return f'{from_} => {repr(to_)}'
    try:
        if to_.__name__ in NOLINK_TYPES:
            return f'{from_} => {to_.__name__}'
    except:
        pass
    dump(object_diagram(to_, color=COLOR_FUNCTION if isinstance(to_, FUNCTION) else COLOR_OBJECT))
    if to_ in _making:
        return f'{from_} => cycle link to {id(to_)}'
    # return f"{arrow} {id(to_)}  /' {type(to_)}:{repr(to_)} '/"  # for DEBUG
    return f'{from_} {arrow} {id(to_)}'


def number_vars(obj):
    yield f'real => {obj.real}'
    if isinstance(obj, complex):
        yield f'imag => {obj.imag}'


def repr_vars(obj):
    yield f'~__repr__() => {repr(obj)}'


def dict_vars(obj):
    for name, member in obj.items():
        if name in BUILTIN_MEMBERS:
            continue
        yield link(id(obj), f'[{repr(name)}]', '*-->', member)


def set_vars(obj):
    for i, item in enumerate(obj, 1):
        yield link(id(obj), f'~#{i}', '*-->', item)


def enumerate_vars(obj):
    for i, item in enumerate(obj):
        yield link(id(obj), f'[{i}]', '*-->', item)


def name_vars(obj):
    yield f'~__name__ => {repr(obj.__name__)}'


def object_vars(obj):
    try:
        variables = vars(obj)
    except:
        return
    if variables:
        dump(object_diagram(variables, color=COLOR_VARS))
        yield f'~__dict__ *--> {id(variables)}'
    else:
        yield '~__dict__ => {}'


OBJECT_VARS = {
    'int': number_vars,
    'float': number_vars,
    'complex': number_vars,
    'str': repr_vars,
    'bytes': repr_vars,
    'list': enumerate_vars,
    'tuple': enumerate_vars,
    'dict': dict_vars,
    'mappingproxy': dict_vars,
    'set': set_vars,
    'function': name_vars,
    'module': name_vars,
}


def class_diagram(cls, color=COLOR_CLASS):
    if exists(cls):
        return
    name = f'{id(cls)}'
    base = cls.__base__
    _making.append(cls)
    yield ''
    yield f'map {name} {color} {{'
    yield f'~__class__ => {type(cls).__name__}'
    yield f'~__name__ => {repr(cls.__name__)}'
    if base.__name__ in BUILTIN_TYPES:
        yield f'~__base__ => {base.__name__}'
    else:
        dump(class_diagram(base))
        yield link(id(cls), f'~__base__', '*->',  base)
    yield from object_vars(cls)
    yield f'}}'
    _making.remove(cls)


def object_diagram(obj, color=COLOR_OBJECT):
    if isinstance(obj, type):
        yield from class_diagram(obj)
        return
    if exists(obj):
        return
    name = f'{id(obj)}'
    cls = type(obj)
    yield ''
    _making.append(obj)
    yield f'map {name} {color} {{'
    if cls.__name__ in BUILTIN_TYPES:
        yield f'~__class__ => {type(obj).__name__}'
    else:
        yield f'~__class__ *-> {id(type(obj))}'
    if hasattr(obj, '__len__'):
        yield f'~__len__() => {len(obj)}'
    if cls.__name__ in OBJECT_VARS:
        yield from OBJECT_VARS[cls.__name__](obj)
    if cls.__name__ not in BUILTIN_TYPES:
        yield from object_vars(obj)
    yield f'}}'
    if cls.__name__ not in BUILTIN_TYPES:
        dump(class_diagram(cls))
    _making.remove(obj)


def dump(lines):
    print(*lines, sep='\n')


def plantuml(obj):
    print('```plantuml')
    print('@startuml')
    dump(object_diagram(obj, color=COLOR_START))
    print()
    print('@enduml')
    print('```')
    _making.clear()
    _exists.clear()


def parse_args():
    parser = argparse.ArgumentParser(description='create object diagrams in plantuml')
    parser.add_argument('-m', '--minimize', action='store_true',
                        help='minimize links')
    parser.add_argument('module_name', metavar='MODULE_NAME', type=str,
                        help='a module name')
    return parser.parse_args()


if __name__ == '__main__':
    options = parse_args()
    sys.path.append('.')
    plantuml(vars(importlib.import_module(options.module_name)))
```

### 工夫点

- ジェネレータ関数でplantuml要素を出力
- オブジェクトリンクがあれば、リンク先をジェネレートして先にprint出力

### 制限事項

~~オブジェクトの相互参照（循環参照）があるとplantumlから図を生成できません。~~
オブジェクトが相互参照（循環参照）しているところは、「cycle link to ～」という表示にして、リンク線を描かないようにしてあります。

## 使用方法

### コマンド実行

最初に示したオブジェクト図のソースコード `sample.py` を以下に示します。
`sample.py` のモジュール名は `.py` を取り除いた `sample` ですので、次ようにコマンド実行することでplantumlが生成出力されます。

```command:plantuml生成出力コマンド実行
$ python3 objectuml sample
```

#### サンプルコード

```py:sample.py
class A:
    prop1 = 123
    prop2 = prop1
    def hoge(self):
        return 'superhoge'
    fuga = hoge

class SubA(A):
    prop1 = 777
    def hoge(self):
        return 'hoge'

a = SubA()
```

##### 生成出力されたplantuml

~~~
```plantuml
@startuml

map 15703550080 #c6ffff {
~__class__ => int
real => 123
}


map 123145298897216 #c6c6ff {
~__class__ => function
~__name__ => 'hoge'
}


map 123145300468016 #ffe2c6 {
~__class__ => mappingproxy
~__len__() => 8
['prop1'] *--> 15703550080
['prop2'] *--> 15703550080
['hoge'] *--> 123145298897216
['fuga'] *--> 123145298897216
}

map 34361615648 #c6ffc6 {
~__class__ => 'type'
~__name__ => 'A'
~__base__ => object
~__dict__ *--> 123145300468016
}



map 123145298640944 #c6ffff {
~__class__ => int
real => 777
}

map 123145298897072 #c6c6ff {
~__class__ => function
~__name__ => 'hoge'
}

map 123145300508832 #ffe2c6 {
~__class__ => mappingproxy
~__len__() => 4
['prop1'] *--> 123145298640944
['hoge'] *--> 123145298897072
}

map 34361616592 #c6ffc6 {
~__class__ => 'type'
~__name__ => 'SubA'
~__base__ *-> 34361615648
~__dict__ *--> 123145300508832
}


map 123145298931824 #c6ffff {
~__class__ *-> 34361616592
~__dict__ => {}
}

map 123145298889664 #ffc6ff {
~__class__ => dict
~__len__() => 11
['A'] *--> 34361615648
['SubA'] *--> 34361616592
['a'] *--> 123145298931824
}

@enduml
`` `
~~~

#### 標準モジュールのオブジェクト図

標準ライブラリのオブジェクト図を出力することも可能です。
~~構造が複雑なライブラリはリンクが正しく出力されないので、BUILTIN_TYPES定義の追加が必要そうですが。~~ 出力されるようになったと思います。

```py
$ python3 objectuml math
```

##### mathモジュールのオブジェクト図

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29070/ad382da4-856c-f0dc-e4b1-d86aa12b6300.png)

### ユーザプログラムから呼び出す

利用者のプログラムの中から本ライブラリを呼び出してplantumlを出力することもできます。

```py
import objectuml

objectuml.plantuml(オブジェクト図出力対象変数名)
```

#### オセロゲームのオブジェクト図

以下のソースコードのmain関数に本ライブラリを呼び出す処理を追加して実行してみた例を示します。

- ソースコード: https://gist.github.com/shiracamus/492201c280cf937beb114224c871a5b5
- 生成出力されたplantumlから変換した画像：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29070/6dbdaf00-9736-332a-7079-5df85576b1ac.png)


## 参考資料

- https://plantuml.com/ja/
- https://plantuml.com/ja/object-diagram  
  このサイトの各図にある `Edit online` ボタンを押して、上記の plantuml コードをコピー＆ペーストして `Submit` ボタンを押すと図が生成されます。