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
どうも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側治したよ!と連絡済で、両者無事修正となってます。ヨカッタヨカッタ!