👻

[python] import 宣言を複数行またぐケース含めて grep 風出力で検索できるツールを作ってみた

2022/11/05に公開

おことわり

「はじめに」で書く用事を満たしたいなら find - xargs grep で十分です。ここで紹介するツールを使う必要は一切ないです。

find . -name "*.py" | xargs grep -n 'import'

ast のお勉強がてら作ってみました。

はじめに

引き継いで以降ずっと塩漬けしてた古いプロジェクトの依存関係を更新しよう、という仕事が発生したのですが、依存関係の定義である requirements.txt が pip freeze の出力そのままリダイレクトしたような内容になっていて少々困りました。

# 依存関係は明示的に書いてほしい...
pip freeze > requirements.txt

基本的には、requirements.txt には自分が "直接" 依存しているパッケージに関するものだけを ~= 演算子でバージョン指定したりするのが多くの人にとって一般的であろうと考えています(たぶん)。

freeze の出力そのままだと自分の直接の関心外にあたる依存が含まれてしまうのと、全部のバージョン指定が == で書かれているので場合によっては pip install 自体が再現不可能(失敗)になってしまうこともあります。それらを自分で一つ一つ見て解消していくのは大変です。

こうした事情から、 requirements.txt を「自分が直接必要なものだけ」書かれた状態にダイエットしたい欲が生じました。

ソースのディレクトリ上で find - xargs grep を使えば検索自体はできるんですが、import/from import が複数行に渡って記述されていた場合に grep では検出漏れが生じるのでは?というのが少々気になります。
「なら、ast で構文パースした情報を検索するツールを作れば grep では扱いづらい行またぎの import 文でも検索できるんじゃね?」と考えて ast の練習がてらコードを書いてみました(※)。

※ ... 直近の PyCon JP 2022 で興味深い ast ネタの発表がいくつかあって、それで ast に興味を持ってたんですよね。今回のがお誂え向きっぽく見えたので飛びつきました

出来上がったものはこちらになります。

https://github.com/hassaku63/find-py-imports

自分の想定ユースケースにとってさほど実用性がなさそうだと、あらかた書いてから気づきました。
なのでテストコード書かずに仕上げてしまいましたし、PyPI にも登録する気もいまのところありません。使いみちあるなら公開しようかとも考えてますが自分のニーズにはマッチしませんでしたね... find/xargs/grep が有能すぎました。

使い方

usage: find_py_imports [-h] [-n] [--hide-syntax-error] [--print-file-not-found-error] [file ...]

positional arguments:
  file                  filename(s)

options:
  -h, --help            show this help message and exit
  -n, --line-number     show line number
  --hide-syntax-error   do not print syntax error print to stderr
  --print-file-not-found-error
                        print not founded files to stderr

grep っぽい見た目になるように作ってみました。

といっても、ファイル名や行番号のプレフィックスをつけるかどうか、とか xargs を使った利用方法が同じように使えるとか、似せた部分はその2点くらいです。

  • 複数個のフィアル名を引数として受け付けられる
  • 複数個のファイルを受け取った場合は検索結果のプレフィックスにファイル名が入るようにする
  • -n で出力のプレフィックスに行番号が追加される

なお importlib を利用した動的 import の検出は今回の想定外としています。こっちに関しては(少なくとも作ってる最中は)grep で検索すれば十分じゃんと思っていたし、ちゃんとやればやろうとするほど考慮すべきことが増えすぎるなと考えて断念しました(その動的 import が実際に何を import しうるのか?まで考えだすと途方もないですね)

実装の話

ast の NodeVisitor があるので、それを使ってほぼ一瞬でした。GoF の Visitor パターンの実例を見たのはこれが初めてかもしれません。
(実際 GoF 本の紹介でも Visitor の解説は構文木を扱うものでしたね)

NodeVisitor クラスを継承して、興味のある対象ノード名を探して、 visit_<NodeName>() メソッドを継承するだけです。

ソースコード上だとこちら↓になります。今回は ast.Importast.FromImport の2つに関心があるので、それらに対応した visit メソッドを継承しています。

https://github.com/hassaku63/find-py-imports/blob/main/find_py_imports/visitor.py#L22-L43

使う側としては、コールバック関数とクロージャの組み合わせで検索結果を収集するようにしています。

https://github.com/hassaku63/find-py-imports/blob/main/find_py_imports/cli.py#L52-L71

「結果」を受け取るクロージャが検索対象とコード情報を受け取れる必要があったので、Visitor の中にファイル名を持たせてコールバックに一緒に渡す実装を採用しています。

ぶっちゃけ Visitor からすればファイル名なんて責務の外ですし、実装としてはイマイチの感がありますが、、、まあ、作るモノ自体が小さく単純ですし、今がっつり設計の最適化を考える必要もなかろうと思って安直なアプローチを採りました。

もうちょっとユースケースが広がったりするようなら、たとえば unittest の TestSuite のようなな設計を考えると思います。Suite は内部的に Visitor を持っていて仕事を委譲する、クライアント向けのインタフェースとしては Suite で隠蔽して Visitor 部分を意識させないみたいなノリですね。まあでも、過剰だと思います(そもそも広がる予定がないわけで...)。

おわりに

作成したコマンドは実用的ではありませんでしたが、ast のお試しは一応達成したし、久々に setup.py を書けたので素振りとしてはまあ満足です。

一応複数の処理系バージョンで動作する想定が必要な類のパッケージなので、あとは練習がてら GitHub Actions で対応する処理系全部網羅したテストとかが書ければいいなって思ってます。

strategy.matrix.python-version を使って割と簡単に書けそうですね。

https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

Discussion