スクエニ ITエンジニア ブログ

pyreverse での相談

はじめに

みなさんこんにちは。情報システム部のネバー・フレンズ・Tです。最近はブロックチェーンもはじめました。

ここでは自分が担当した技術面で面白かった件を、問題の無い形に変えて(伏せたり、一部フィクションにしたりしてw 1 )、カジュアルにお送りします。

スクエニ情シスの実際の業務の出来事をベースにしてますので、その雰囲気を十分に感じていただければと思います!

ではゆるゆると、どぞー。

はじまりはいつも…

「とあるpython3のコマンドが、エラーを起こして困ってる」という相談がslackで来ました。みんな大好きpython3。こちらについての相談です。

スクエニの情シスは、社内・お客様サービス用のLinuxサーバも、あらゆるサービスのゲームのサーバも担当します。そのためたくさんの構築・運用・トラブルシュート・技術相談対応・技術評価実施と日夜向き合っています。インストールしたソフトウェアでなにか不具合があると、サービス側の開発スタッフさんらと一緒に解決策を練るのも良く行われます。

ただ、不具合があっても、そのままシュバババっと解決して&俺強えぇぇーっ!というラノベばりの展開だと最高ですが、そんなわけないです。slack/zoomその他今時のリモートワークで人気のITツールを駆使して、落とし所を開発側スタッフさんと泥臭く探るということも多いです。とにかく問題を解決することが最優先なので、「高いハードルは、くぐってしまえ!」という解決策も大歓迎です!!

今回は…

pylint付属のpyreverseコマンドです。このツール、ご存じない方にお伝えするとpythonのプログラムのクラス相関図を自動で解析して図示するというツールです。人の書いたpythonを保守担当したりするとクラス相関の掌握がしやすくなるので、大変便利なツールです。

$ sudo apt install pylint graphviz lsb-release xdot
$ lsb_release -a
No LSB modules are available.
Distributor ID:	Debian
Description:	Debian GNU/Linux 11 (bullseye)
Release:	11
Codename:	bullseye
$ pylint --version
pylint 2.7.2
astroid 2.5.1
Python 3.9.2 (default, Feb 28 2021, 17:03:44) 
[GCC 10.2.1 20210110]
$ pyreverse -AS -m y logging
$ ls 
classes.dot packages.dot
$ xdot classes.dot;xdot packages.dot

生成された図

しかし!今回は違いました。保守対象のプログラムの解析にてpyreverseがエラーを出力して落ちるのです。同じ問題を持つパッケージにdbusがありますので、ここではdbusを例に説明します。

$ pyreverse -AS -m y dbus
parsing /usr/lib/python3/dist-packages/dbus/__init__.py...
parsing /usr/lib/python3/dist-packages/dbus/proxies.py...
...中略...
parsing /usr/lib/python3/dist-packages/dbus/mainloop/__init__.py...
Traceback (most recent call last):
  File "/usr/bin/pyreverse", line 33, in <module>
    sys.exit(load_entry_point('pylint==2.7.2', 'console_scripts', 'pyreverse')())
  File "/usr/lib/python3/dist-packages/pylint/__init__.py", line 37, in run_pyreverse
    PyreverseRun(sys.argv[1:])
  File "/usr/lib/python3/dist-packages/pylint/pyreverse/main.py", line 188, in __init__
    sys.exit(self.run(args))
  File "/usr/lib/python3/dist-packages/pylint/pyreverse/main.py", line 213, in run
    writer.DotWriter(self.config).write(diadefs)
  File "/usr/lib/python3/dist-packages/pylint/pyreverse/writer.py", line 37, in write
    self.write_classes(diagram)
  File "/usr/lib/python3/dist-packages/pylint/pyreverse/writer.py", line 58, in write_classes
    self.printer.emit_node(i, **self.get_values(obj))
  File "/usr/lib/python3/dist-packages/pylint/pyreverse/writer.py", line 131, in get_values
    if func.args.args:
AttributeError: 'list' object has no attribute 'args'

見事にpyreverseがクラッシュしましたw

OSSなら直せ!直せ!

趣味のパソコンいじりで本問題に遭遇すれば、pyreverseバグってます!作者直すまで俺待ちます!キリッで終わりなのですが、サーバのよろず対応を自負する、我らが情シスはそうもいきません。駄目なら駄目でどういう理由で駄目なのか?も相談者(プログラマ)が納得できるまで調査して説明できるのが理想です。今回保守したいプログラムが解析できないわけですから誰かがなんとかする必要があります。相談受けたばかりで時間はまだあります。

当時のpylintはupstream 最新版でも同じ問題が発生してしまいました 2 。せっかくupstream最新版にして問題から逃げちゃおうと思ったのに…。

$ sudo apt purge pylint
$ pip3 install --user pip-autoremove
$ git clone https://github.com/PyCQA/pylint.git
$ cd pylint && pip3 install --user .
$ PATH=~/.local/bin:$PATH
$ hash -r
$ pyreverse -AS -m y dbus
...クラッシュ...

しかもdebianもupstreamも、bugreportもissueはまだ無い。

まずは解析からです。進捗どうですかwを言われるまでに目処たてなきゃいけないので、スピード・ランの心境です!

pylint付属のプログラムなので、メインディッシュのpylintではなく、テストプログラム並の単純な構造である可能性に賭けてみます。

$ python3 -mpdb $(command -v pyreverse) dbus
(Pdb) c
...中略...
AttributeError: 'list' object has no attribute 'args'
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
> /usr/lib/python3/dist-packages/pylint/pyreverse/writer.py(131)get_values()
-> if func.args.args:
(Pdb) l 120
115  
116         def get_title(self, obj):
117             """get project title"""
118             return obj.title
119  
120         def get_values(self, obj):
121             """get label and shape for classes.
122  
123             The label contains all attributes and methods
124             """
125             label = obj.title
(Pdb) l
126             if obj.shape == "interface":
127                 label = "«interface»\\n%s" % label
128             if not self.config.only_classnames:
129                 label = r"{}|{}\l|".format(label, r"\l".join(obj.attrs))
130                 for func in obj.methods:
131  ->                 if func.args.args:
132                         args = [arg.name for arg in func.args.args if arg.name != "self"]
133                     else:
134                         args = []
135                     label = r"{}{}({})\l".format(label, func.name, ", ".join(args))
136                 label = "{%s}" % label
(Pdb) p func
<Property.<lambda> l.517 at 0x7fcbe8181f10>
(Pdb) p obj.methods
[<Property.<lambda> l.517 at 0x7fcbe8181f10>, <FunctionDef.connect_to_signal l.532 at 0x7fcbea9afdf0>, <FunctionDef.get_
dbus_method l.554 at 0x7fcbea9af3d0>]
(Pdb) p func.args
[]

どうもfor func in obj.methodsからobj.methodsの最初の要素のデータがおかしいのが原因のようです。objはどこから?ということでstackを上方向に移動して出所を追いかけてみます。

(Pdb) up
> /usr/lib/python3/dist-packages/pylint/pyreverse/writer.py(58)write_classes()
-> self.printer.emit_node(i, **self.get_values(obj))
(Pdb) l 50
 45             for i, obj in enumerate(sorted(diagram.modules(), key=lambda x: x.title)):
 46                 self.printer.emit_node(i, label=self.get_title(obj), shape="box")
 47                 obj.fig_id = i
 48             # package dependencies
 49             for rel in diagram.get_relationships("depends"):
 50                 self.printer.emit_edge(
 51                     rel.from_object.fig_id, rel.to_object.fig_id, **self.pkg_edges
 52                 )
 53  
 54         def write_classes(self, diagram):
 55             """write a class diagram"""
(Pdb) l
 56             # sorted to get predictable (hence testable) results
 57             for i, obj in enumerate(sorted(diagram.objects, key=lambda x: x.title)):
 58  ->             self.printer.emit_node(i, **self.get_values(obj))
 59                 obj.fig_id = i
 60             # inheritance links
 61             for rel in diagram.get_relationships("specialization"):
 62                 self.printer.emit_edge(
 63                     rel.from_object.fig_id, rel.to_object.fig_id, **self.inh_edges
 64                 )
 65             # implementation links
 66             for rel in diagram.get_relationships("implements"):

どうもfor i, obj in … のコードからdiagram.objectsがobjsの実体のようです。さらにstackを駆け上がってdiagramの出所を確認します。

(Pdb) up
> /usr/lib/python3/dist-packages/pylint/pyreverse/writer.py(37)write()
-> self.write_classes(diagram)
(Pdb) l 30
 25         def __init__(self, config, styles):
 26             self.config = config
 27             self.pkg_edges, self.inh_edges, self.imp_edges, self.association_edges = styles
 28             self.printer = None  # defined in set_printer
 29  
 30         def write(self, diadefs):
 31             """write files for <project> according to <diadefs>"""
 32             for diagram in diadefs:
 33                 basename = diagram.title.strip().replace(" ", "_")
 34                 file_name = f"{basename}.{self.config.output_format}"
 35                 self.set_printer(file_name, basename)
(Pdb) l
 36                 if diagram.TYPE == "class":
 37  ->                 self.write_classes(diagram)
 38                 else:
 39                     self.write_packages(diagram)
 40                 self.close_graph()
 41  
 42         def write_packages(self, diagram):
 43             """write a package diagram"""
 44             # sorted to get predictable (hence testable) results
 45             for i, obj in enumerate(sorted(diagram.modules(), key=lambda x: x.title)):
 46                 self.printer.emit_node(i, label=self.get_title(obj), shape="box")

for diagram in diadefsというコードからdiadefsがdaiagramの出どころになるようです。さらにstackを駆け上がります。

(Pdb) up
> /usr/lib/python3/dist-packages/pylint/pyreverse/main.py(213)run()
-> writer.DotWriter(self.config).write(diadefs)
(Pdb) l 200
195             # insert current working directory to the python path to recognize
196             # dependencies to local modules even if cwd is not in the PYTHONPATH
197             sys.path.insert(0, os.getcwd())
198             try:
199                 project = project_from_files(
200                     args,
201                     project_name=self.config.project,
202                     black_list=self.config.black_list,
203                 )
204                 linker = Linker(project, tag=True)
205                 handler = DiadefsHandler(self.config)
(Pdb) l
206                 diadefs = handler.get_diadefs(project, linker)
207             finally:
208                 sys.path.pop(0)
209  
210             if self.config.output_format == "vcg":
211                 writer.VCGWriter(self.config).write(diadefs)
212             else:
213  ->             writer.DotWriter(self.config).write(diadefs)
214             return 0
215  
216  

データの流れを見ながら、stackを駆け上がることにより、一連の処理が俯瞰できるところに到達しました。当初の推測どおり、pyreverseの基本構造は単純で、図1に示した通りです。

図1

figure-3

どうもpylint.pyreverse.diadefslib.DiadefsHandler.get_diadefs()あたりで生成されたdiadefsに格納されたオブジェクトに問題があるようです。

このままdbusのような巨大なpythonモジュールで解析をすすめると大変すぎるので、簡単な例で問題が再現できないか?を検討します。ここでエラーを起こしたfunc変数には何がはいっているかを調べます。

(Pdb) p func
<Property.<lambda> l.517 at 0x7fcbe8181f10>

と、Propertyという言葉と、行番号らしきl.517というのがあります。これを含むファイルがdbusパッケージにあるか? を調べました。

$ find /usr/lib/python3/dist-packages/dbus -type f -print -exec sed -ne '517p' {} \;
/usr/lib/python3/dist-packages/dbus/connection.p/usr/lib/python3/dist-packages/dbus/server.py
/usr/lib/python3/dist-packages/dbus/proxies.py
    object_path = property (lambda self: self._obj.object_path, None, None,
/usr/lib/python3/dist-packages/dbus/_expat_introspect_parser.py

ありました!propertyにlambdaというまさに問題のfunc変数が指示してそうな内容です。さらに短いテストプログラムを作って同じエラーが発生するか?を調べます。

$ tee -a test.py <<HERE
class PyreverseTest:
     prop1 = property(lambda self: self._prop1*2, None, None, "property usage 1")

HERE
$ pyreverse -AS -m y ./test.py
parsing test.py...
Traceback (most recent call last):
  File "/usr/bin/pyreverse", line 33, in <module>
    sys.exit(load_entry_point('pylint==2.7.2', 'console_scripts', 'pyreverse')())
  File "/usr/lib/python3/dist-packages/pylint/__init__.py", line 37, in run_pyreverse
    PyreverseRun(sys.argv[1:])
  File "/usr/lib/python3/dist-packages/pylint/pyreverse/main.py", line 188, in __init__
    sys.exit(self.run(args))
  File "/usr/lib/python3/dist-packages/pylint/pyreverse/main.py", line 213, in run
    writer.DotWriter(self.config).write(diadefs)
  File "/usr/lib/python3/dist-packages/pylint/pyreverse/writer.py", line 37, in write
    self.write_classes(diagram)
  File "/usr/lib/python3/dist-packages/pylint/pyreverse/writer.py", line 58, in write_classes
    self.printer.emit_node(i, **self.get_values(obj))
  File "/usr/lib/python3/dist-packages/pylint/pyreverse/writer.py", line 131, in get_values
    if func.args.args:
AttributeError: 'list' object has no attribute 'args'

先生!こいつです!というわけで、再現に成功しました。

運よく簡単に再現もでき、pyreverseの構造も単純だったので、デバッガ回すのも容易です。早速pylint.pyreverse.diadefslib.DiadefsHandler.get_diadefs()から見ていきます。こちらの関数も構造は単純で、diagrams配列に生成したものを格納して返却するだけです。今回test.pyがきわめて単純なプログラムなので、生成されるものが単純ということで、diagrams[0].objects[0].mothodsが作成あれるのはいつか?を元にステップ実行をします。するとdiagram.extract_relationships()であることがわかります。

pylint.pyreverse.diagrams.ClassDiagram.extract_relationships()のソースを確認すると、いかにも怪しい

obj.methods = self.get_methods(node)

というのが見つかりますので、ここをステップ実行します。すると、

100         def get_methods(self, node):
101             """return visible methods"""
102  ->         methods = [
103                 m
104                 for m in node.values()
105                 if isinstance(m, astroid.FunctionDef)
106                 and not decorated_with_property(m)
107                 and self.show_attr(m.name)
(Pdb) l
108             ]
109             return sorted(methods, key=lambda n: n.name)

mothods変数に今回問題の、

[<Property.<lambda> l.2 at 0x7fcb5cc3fbb0>]

が紛れ込んでしまいます。for文の中の条件文に not decorated_with_property(m)となってますので、propertyデコレータは除外のポリシーのようです。であれば、

     prop1 = property(lambda self: self._prop1*2, None, None, "property usage 1")

の形式も除外されるべきなので、この条件を追加すれば治るとなります。オブジェクトのタイプを調べてみます。

(Pdb) type(methods[0])
<class 'astroid.objects.Property'>

というわけで、astroid.objects.Propertyだったら除外なので以下の通り条件を追加します。

 # pylint/pyreverse/diagrams.pyの ClassDiagram中

    def get_methods(self, node):
        """return visible methods"""
        methods = [
            m
            for m in node.values()
            if isinstance(m, astroid.FunctionDef)
            and not isinstance(m, astroid.objects.Property) #<-追加
            and not decorated_with_property(m)
            and self.show_attr(m.name)
        ]
        return sorted(methods, key=lambda n: n.name)

この改造を行ってpyreverse dbusしたところ、無事エラー無しでclass図が出来上がりました!

後日談

相談者に、pyreverseにはこういう問題が埋まってたよ、幸いパッチで治るよと連絡して一件落着しました。

今回は、解析したら治せたというきわめてまれな例を紹介しました。基本的にこんなに恵まれた相談事はなかなか無くて、普通は、「原因はわかったけど困難過ぎて治せない/時間がない」とか「もっと他のやり方があるので、このソフトは使わない」とかの選択もよく行われます。

ついでに、今回の件はupstreamにはPR済、debian側にはupstream側治したよ!と連絡済で、両者無事修正となってます。ヨカッタヨカッタ!


  1. やっぱり大人の事情っていろいろありますもんね! ↩︎

  2. 後で書きますが、upstreamを直してます。debianだと開発版(bookworm/sid)か、次期バージョンですでに治してます。はい。 ↩︎

この記事を書いた人

記事一覧
SQUARE ENIXでは一緒に働く仲間を募集しています!
興味をお持ちいただけたら、ぜひ採用情報ページもご覧下さい!