特定のコードの特定の箇所を引用して言及したいという話

前回の記事の続き

前回の記事で、以下のようなことを言っていた。

そしてわかりやすさを考えると、「どのクラスのどのメソッドの中のどの箇所か?」という形で表現したい(今回の場合は「WebSocketEndpointのdecode()メソッドのjson.loads()部分」という形)。

どうしても誰かに伝えたい場合や説明したい場合、丁寧に言及したい場合には、丁寧に整形するのだけれど、元々が備忘録という関係上なるべくコストを抑えたい。有り体に言えば整形のコストを払いたくない。

これをどうにかしたいねという話。

具体的には、こういうリンクをこういう感じで出力したい。

class WebSocketEndpoint:

# ...

    async def decode(self, websocket: WebSocket, message: Message) -> typing.Any:

# ...
            try:
                return json.loads(text)
            except json.decoder.JSONDecodeError:
                await websocket.close(code=status.WS_1003_UNSUPPORTED_DATA)
                raise RuntimeError("Malformed JSON data received.")

これは手動で整形したものだけれど。これを良い感じにそれこそgithubでpemalinkを生成するのと同じ程度のコストでできたりしないものかという試行錯誤。

どうやったら良い感じに出力できそうか考えてみる

雰囲気でほしい情報を列挙

とりあえずコードを書く前にどういう表現で出力したかったのかを雑に考えてみる。手動での整形が面倒ということなので自動で整形された形で出力されて欲しいということだった。たぶん頭の中で思い描いていたのは以下のような事柄。

  • あるライブラリのあるモジュールからある特定の箇所の実装を抜き出す
  • 特定の箇所を表示する前段階でその親であるような階層の情報を表示したい
  • ただし全てを出力してしまっては見た目がうるさすぎるので数行程度だけ表示したい
  • 行が飛んだ場合にはそれとなく分かるような表示になって欲しい (e.g. ... のような)

大雑把な方針を考える

次にどうやって実装するかを雑に考えてみる。ある特定のライブラリのある特定の箇所と言っているけれど、引数として渡すのは何かということを考えてみると、これは昨日の記事でも言及していたような気がする。

エディタから利用する場合には物理的な行番号を指定して利用することができたら便利なんじゃないかとふわっと思っていたりしている(エディタは物理行の指定、CLIでは何らかのパスのような形で指定がやりやすそう)

まぁそうかな。少なくとも引用対象のコードが手元にあること前提になってはしまうけれど。エディタから指定できるというのが嬉しい形かもしれない。あとあるクラスのあるメソッドのまではけっこうパスとして表現(例えば starllette.endpoints:WebSocketEndpoint)できるけれどその特定の箇所をとなるとむずかしい。物理的行番号で指定するのが無難。

なので以下の様な感じになりそう。

  • ファイル名と物理的な行番号で言及したい箇所を指定
  • その箇所を構成するAST(Abstract Syntax Tree)を取り出す
  • AST上の指定した行に対応するnodeを見つける
  • 対応するnodeの親要素になりそうなnodeを集める

そして取り出したASTの情報を元にテキトウにコードを出力すれば良さそう。

実際に色々やってみる

実際に色々やってみる。

とりあえずlib2to3で

ASTを取り出してあれこれする方法は色々ある。標準ライブラリのastを使う方法(これは本当にASTなので情報に欠損が出がち)。あるいはpycqaが作っているbaron familyを使う方法(redbaronbaron。FST(Full Syntax Tree)なのでコードの情報は全部取れる)。あるいはコードの補完などでお世話になるjediで使われているパーサーを使ってみてもよいかもしれない(parsoというパーサーを自分で書いて使っている)。

今回は標準ライブラリの範囲でやりたいという気持ちがあるのでlib2to3を使ってみようと思う。これは元々はpython2.xからpython3.xへの変換を担うツールを提供するためのライブラリなのだけれど、それなりにpythonのコードをtreeに変換してのあれこれを行える。

その辺りについては昔記事を書いたりした。

行番号に対応するnodeを取り出す

とりあえず行番号に対応したnodeを取り出そうとしてみる。今回はまだ試行錯誤の段階なので横着をして過去に自分が作ったpycommentの持つモジュール(pycomment.parse)を拝借して使うことにする。これはlib2to3の薄いwrapper。

そして以下の様なテキトーなコード片を用意しておく。このコード片をしばらくは実行結果の確認に使うことにする。

target00.py

class A:
    def f(self, x):
        if x is None:
            return "[]"  # <- この辺りを指定してみる
        else:
            return f"[{x}]"

visitorをてきとうに実装して呼んであげる。

00parse.py

def run(filename: str, *, lineno: int) -> None:
    visitor = Visitor(lineno)

    t = parse_file(filename)
    visitor.visit(t)

    node = next(iter(visitor.r))
    print(f"lineno:{node.get_lineno()}    code:{str(node).rstrip()}   node:{node!r}")

filename = "target00.py"
run(filename, lineno=4)

まぁ何か取れていそう(実装はこちら)。

lineno:4 code:return "[]"    node:Node(simple_stmt, [Node(return_stmt, [Leaf(1, 'return'), Leaf(3, '"[]"')]), Leaf(4, '\n')])

性能を考えるなら行に対応するnodeの選択にひと手間加えると良さそうだけれど。とりあえずはget_lineno()で指定した行番号と一致したnodeが見つかれば良しと言うことにする。

取得したnodeの親的なnodeたちを集める

次は取得したnodeの親的なnodeを集めたい。親的なnodeってなんだろう?と考えて見るとまぁ以下があれば良さそうなきがする。

  • 関数定義のdef部分の行
  • クラス定義のclass部分の行

それらを集めたい。

テキトウに調べたりするとclassdeffuncdefが定義に対応するnodeということが分かる。たとえばこれはこういう感じのコードを書いてあげると調べられる。あるいはふつうにドキュメント(10. Full Grammar specification)からあたりをつけても良い。

まぁ何はともあれ、親階層も含めたnodeを集めてこれる。

対応する行のコードを取ってくるのにはlinecacheモジュールを使うと便利。

01find_parents.py

def run(filename: str, *, lineno: int) -> None:
    t = parse_file(filename)

    node = select_node(t, lineno=lineno)
    parents = find_parents(node)

    for node in parents:
        print(
            f"lineno:{node.get_lineno()} node:{node_name(node)}  value:{getattr(node.children[1], 'value', '')}"
        )


filename = "target00.py"
run(filename, lineno=4)

実装はこちら

lineno:1 node:classdef   value:A
lineno:2    node:funcdef    value:f
lineno:4    node:simple_stmt    value:

----------------------------------------
01:class A:
02:    def f(self, x):
04:            return "[]"

テキトウに良い感じに整形して出力する

あとはテキトウに良い感じに整形して出力すると良い。この整形の部分はもうちょっと調整が必要そう。実装はここ。ただし後で変わりそう。

対象のコードが短すぎるので全部表示されてしまった。。

python 02*.py target00.py --lineno=4
class A:
    def f(self, x):
        if x is None:
            return "[]"

行数を一緒に表示させてあげるとそれっぽいかもしれない。

$ python 02*.py target00.py --lineno=4 --show-lineno
001: class A:
002:     def f(self, x):
003:         if x is None:
004:             return "[]"

少し対象となるコードを増やしてみる。

target01.py

from something import do_something


class A:
    def f(self, x):
        if x is None:
            return "[]"
        else:
            return f"[{x}]"

    def g(self, x):
        if x is None:
            do_something()
            do_something()
            do_something()

            def closure0(y):
                return f"[{y}]"  # <- ここを指定(18行目)

            return closure0
        else:
            do_something()
            do_something()
            do_something()

            def closure1(y):
                return f"[{x}, {y}]"

            return closure1

    def h(self, x):
        if x is None:
            return "[]"
        else:
            return f"[{x}]"

実行してみた結果。

$ python 02*.py target01.py --lineno=18 --show-lineno
004: class A:
005:     def f(self, x):
006:         if x is None:
...
011:     def g(self, x):
012:         if x is None:
013:             do_something()
...
017:             def closure0(y):
018:                 return f"[{y}]"  # <- ここを指定(18行目)
019:

それっぽい雰囲気のものは出力された。まだまだ調整したい部分があるかも。例えば以下のようなもの。

  • 親の階層の表示は周辺行ではなく、対応する行の下側だけを表示したい
  • 親に関しても引数が長くなければ、表示する行のlimitを越えて全部表示したい(現在はlimitは2)
  • そうは言っても長過ぎる引数が現れたら...で省略したい。
  • 親の階層の周辺の表示で関係ないメソッドのトップレベルメソッドの定義は省きたい(↑の例ではf()の定義)
  • (本当は該当行をhighlightしてその後下側数行程度は表示したい)

ただ曖昧な位置関係を表示したいこともあって、その場合は上のようなすごくコンテキストに寄り添った表示ではなく、機械的に該当行の上N行下N行を全部表示みたいなこともしたい。

ちなみに、標準ライブラリのargparseの2000行目などは以下の様な感じになる(docstringが切れるというのもひどい。。考えてみるとlinecacheを経由しない方が良いのかもしれない)。

class ArgumentParser(_AttributeHolder, _ActionsContainer):
    """Object for parsing command line strings into Python objects.

# ...
    def _parse_known_args(self, arg_strings, namespace):
        # replace arg strings that are file references
        if self.fromfile_prefix_chars is not None:
# ...
        for action in self._actions:
            if action not in seen_actions:
                if action.required:

そして冒頭のstarletteのWebSocketEndpointのjson.loads()部分は以下の様な感じになる。

class WebSocketEndpoint:

    encoding = None  # May be "text", "bytes", or "json".
# ...
    async def decode(self, websocket: WebSocket, message: Message) -> typing.Any:

        if self.encoding == "text":
# ...

            try:
                return json.loads(text)

近づいてはきた。もうちょっと調整が必要かも。。

該当箇所の末端部分はやっぱり下のほうも数行欲しいかも。

class WebSocketEndpoint:

    encoding = None  # May be "text", "bytes", or "json".
# ...
    async def decode(self, websocket: WebSocket, message: Message) -> typing.Any:

        if self.encoding == "text":
# ...

            try:
                return json.loads(text)
            except json.decoder.JSONDecodeError:
                await websocket.close(code=status.WS_1003_UNSUPPORTED_DATA)

今回はこのへんでおしまい。ぜんぶをまとめたgist