TL;DR
mycmd project_name
というコマンドを実行したときにproject_name
として登録したディレクトリに移動できたら便利。助かる。嬉しい。
案外一筋縄ではいかず泥臭くなっており、「cd したい」と題打ってはいますが、Pythonで自作コマンドを作るときに使えそうなテクニックをいくつか組み合わせた感じになっています。
読むのが面倒臭い人は
alias project_name="cd ~/develop/project1"
で代替するのが正攻法だと思います。結城浩先生の yukitask はこっちの正攻法で実現しています。Simple is the best。
動機
CLIをいっぱい叩く人は自作コマンドを作りたくなるのは道理です。中でも地味に面倒臭いのがプロジェクト間のディレクトリの移動です。作業開始時、切り替え時などに頻繁に行う割に
- なんて名前だったっけ?
- 頭文字をタイプ
-
ls
とcd
とTabを連打
というストレスフルな動作が入ります。pushd
,popd
を使うと多少の効率化はできるものの、それでもやはり面倒臭いです。
そこで、プロジェクトのディレクトリで
$ proj -i devel
を入力することでこのディレクトリをdevel
という名前で登録し、
$ proj devel
と入力することでいつでもdevel
のディレクトリに移動できるようにしたいです。できたら嬉しいですね。
何が問題か?
自作コマンドでシェルのカレントディレクトリを変更することは基本的にはできません。数時間くらい調べたのですが見つからなかったので「それOOでできるよ」とご存知でしたら教えていただけると助かります。
ちなみにコマンドの alias が増えることを恐れなければ
alias devel="pushd ~/develop/project1"
という記述を.bashrc
なり.zshrc
なりに追加すれば
$ devel
と入力するだけで~/develop/project1
へpushd
により移動することが可能です。結城浩先生の yukitask はこの方法を取っています。
私は alias が他のコマンドと被るかどうか気遣いながら名前をつけるのが面倒だったことと、「あくまでもproj
というコマンドにより操作している」ということを明確にしたかったので、最初にproj
とコマンド名を宣言する方法にこだわることにしました。
実装
環境は
- OS: macOS Catalina
- terminal: iTerm2
- shell: zsh
- コマンド実装: Python
で行いますが、これから説明する方法は汎用性を考慮しており、シェルスクリプトの互換性さえあれば大抵どんな環境でも動くと思います。互換性がなかった場合も大筋は変わらないはずなのでご自身で多少改変してください。
キーになるのは
- Pythonで実装されたコマンド本体としての
proj
- 本物の
proj
コマンドをオーバーラップするシェルスクリプトのproj
という2段階の構造で、シェルスクリプトのほうはcd
コマンドを実行するためだけに存在し、他の高度な操作はPythonで組まれたコマンドが担当します。
ややこしいので、説明する間はPythonで実装したコマンドをproj
、シェルスクリプトで実装したコマンドをproj.sh
としておきます。実際に実装するときは拡張子は消しても大丈夫です。
大まかな流れとしては
-
proj devel
という入力をproj.sh
が受け取る。 -
proj.sh
がproj
を呼び出し移動先の情報を取得。 -
proj.sh
がcd
コマンドを実行してシェルのカレントディレクトリを変更する。
ということになります。実際にやろうとするといくつか注意点があるので、順に見ていきます。
1. シェルスクリプトでシェルのカレントディレクトリを変更する
proj.sh
に以下の内容を記述します。
# !/bin/sh
cd test
これはシェルのカレントディレクトリをtest
に変更することを意図したスクリプトです。これを実行するには
$ ./proj.sh
$ source proj.sh
の2種類がありますが、source
のほうを使ってください。source
は現在のシェルでスクリプトを実行しますが、.
は新しく生成したプロセスでシェルを起動してスクリプトを実行します。
つまり./proj.sh
を実行するとcd
は新しいシェルで実行され、スクリプトの実行が終わるとそのシェルは破棄されるので、cd
の結果は現在のシェルに反映されません。source
を使うとこの問題を回避できます。
2. proj という名前で Python のコマンドを登録する
自作コマンドの開発ディレクトリは以下のような構造にします。
proj
├── main.py
└── setup.py
そしてsetup.py
に以下のコードを記述してください。
from setuptools import setup
setup(
install_requires=[],
entry_points={
"console_scripts": [
"proj = main:main"
]
}
)
これでproj
というコマンドを実行するとmain.py
のmain()
関数が呼び出されるようになります。開発中に手元の環境にこのコマンドをインストールするにはproj
のディレクトリで
$ pip install -e .
を実行します。
main.py
は最初は適当なプログラムを書いておけばいいです。
def main():
print('test')
if __name__ == '__main__':
main()
3. シェルスクリプトと設定ファイルの置き場所
proj
のコマンドは設定ファイルを必要とするので、シェルスクリプトproj.sh
も設定ファイルと同じ場所に置いておけばよいでしょう(そのほうがユーザがカスタマイズできて親切です)。
コマンドの設定ファイルは~/.config/proj
という隠しディレクトリに保存します。~/.config
はコマンドの設定を置いておくために一般的に用いられるディレクトリで、ここに置くのがお行儀がよいかと思われます。構成例は以下のような感じです。
~/.config/proj
├── projrc
├── proj.sh
└── projects.json
projrc
はシェルでproj
の設定を有効化するための.zshrc
相当の設定ファイルです。.zshrc
に以下のコマンドを追記して zsh 起動時にprojrc
が読み込まれるようにしておいてください。
source ~/.config/proj/projrc
proj.sh
は『1. シェルスクリプトでシェルのカレントディレクトリを変更する』で作ったシェルスクリプトです。projects.json
にはdevel
がどのディレクトリを指しているのかといった情報を記録しておきます。
4. proj.sh から proj コマンドを呼び出す(一時的に alias を剥がす)
proj devel
と入力したときにproj.sh
が呼び出されるためには、当然
alias proj='source ~/.config/proj/proj.sh'
のような alias を作成することになります。しかしsource
コマンドで実行しているシェルスクリプトは現在のシェルで実行されるので、alias も反映されますから、シェルスクリプト内でproj
を実行すると再びproj.sh
が実行されてしまい、Pythonで実装したコマンドを実行することはできません。
bash および zsh では、バックスラッシュ\
をコマンドの先頭につけることで alias を剥がすことができます。つまり上のalias
を設定したあとでも
$ \proj
を実行すればPythonで実装したほうのコマンドが叩かれます。シェルスクリプト内でもこのルールは変わりません。したがって以下の手順でproj.sh
でproj
コマンドをオーバーラップすることが可能です。
まずprojrc
で alias を貼ります。
alias proj='source ~/.config/proj/proj.sh'
次にproj.sh
で以下のようにproj
コマンドを呼び出します。
# !/bin/sh
result=$(\proj) # $() は()内のコマンドを実行して標準出力を結果として返す
cd $result
これで大まかな準備は完了です。シェルを再起動して
$ proj
を実行するとcd test
を実行するのと同じ結果が得られるはずです。
5. シェルスクリプトとコマンドを改良してコマンドライン引数を捌く
現状ではコマンドライン引数を受け取ることはできません。任意のディレクトリを登録して移動できるようにするためにはもうひと工夫必要です。
いまの想定は
$ proj devel
というコマンドのときだけcd
が発動し、それ以外の引数、たとえば
$ proj -i devel
$ proj --show-todo
といった他のコマンドではコマンドライン引数を素通りさせてproj
にそのまま渡したいわけです。これを実現するためには、シェルスクリプトとコマンドの両方でコマンドライン引数を解析せねばなりません。
簡単のため、ディレクトリ移動を伴う場合はコマンドライン引数をひとつしか取らず、またその引数はプロジェクト名を指しているものと決めてしまいましょう。そうしておけばとりあえず、引数の個数で条件分岐ができます。
# !/bin/sh
if test $# -eq 1 ; then
result=$(\proj --echo $1)
cd $result
else
\proj "$@"
fi
順に解説します。$#
はコマンドライン引数の個数が格納された変数であり、$1
は1個目のコマンドライン引数が格納された変数で、proj devel
を実行するとdevel
という文字列が格納されています。proj
コマンドはproj --echo [project_name]
でそのプロジェクトのディレクトリを標準出力に出力するように設計されていれば、結果はresult
に格納されてcd $result
で所望のディレクトリに移動できます。
また"$@"
はすべてのコマンドライン引数を展開するので、proj "$@"
はコマンドライン引数をすべてproj
コマンドに引き渡すことになります。
しかしまだ以下のような問題があります。
-
proj --echo [project_name]
で指定したproject_name
が登録されていないときどうするか。 -
proj --echo [project_name]
が予期せぬエラーで終了したらどうするか。
まずは 1 の『登録されていないプロジェクトが指定されたらどうするか』という問題に対処するためにPythonのコマンドのコードも変更していきましょう。
import argparse
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--echo', # オプション名
nargs='?', # このオプションの引数は0個または1個
default=None, # オプションが指定されなかったとき None
const='' # オプションの引数が0個のとき ''
)
args = parser.parse_args()
# プロジェクトはとりあえず仮置き
projects = {
'devel': '~/develop',
'test1': '~/develop/test1',
'test2': '~/develop/test2',
}
if args.echo is not None:
if args.echo in projects:
print(projects[args.echo])
sys.exit(0)
else:
print(f'Error: project \'{args.echo}\' is not registered.')
sys.exit(1)
print('other options')
sys.exit(0)
exit code に 1 を指定するのはよくない、という考えもあるようですが、指定されたプロジェクト名が存在しないことは以降の処理を続行不能な一般的なエラーなので 1 でもいいのではと思います。気に入らなければご自身で変更してください。
ちなみに現時点でコマンドを再インストールして
$ proj -s
など存在しないオプションを指定すると、シェルスクリプトから\proj --echo -s
が実行されてargparse
のライブラリがエラーを吐きます。これが 2 の『予期せぬエラーで終了したらどうするか』の問題にあたります。プログラムがエラーで終了すると exit code は 0 以外の値を取ります。実際、argparse
はオプションのパースエラーに際して exit code を 2 に設定してプログラムを終了します。
つまり exit code が 0 なら成功で 0 以外ならエラーです。exit code はシェルスクリプトでも取得できるので、これを見てやればcd
を実行するタイミングが掴めます。
# !/bin/sh
if test $# -eq 1 ; then
result=$(\proj --echo $1)
exit_code=$? # $? は最後に実行したコマンドの exit code を格納した変数
if test $exit_code -eq 0 ; then
cd $result
elif test $exit_code -eq 1 ; then
echo $result
fi
else
\proj "$@"
fi
上記のスクリプトでほぼ十分なのですが、いろいろ試行錯誤した結果、argparse
のエラーを綺麗に表示するにはproj -s
など単一オプション指定は素通りさせてelse
のブロックで処理するほうがよさそうでした。つまり最初のif文でオプションを弾く正規表現を追加するとよいです。
# !/bin/sh
if test $# -eq 1 && [[ $1 =~ ^[^\-] ]] ; then
result=$(\proj --echo $1)
exit_code=$? # $? は最後に実行したコマンドの exit code を格納した変数
if test $exit_code -eq 0 ; then
cd $result
elif test $exit_code -eq 1 ; then
echo $result
fi
else
\proj "$@"
fi
自作コマンドでcd
を可能にする部分はこれで完成です。お疲れ様でした。プロジェクトの登録とかは~/.config/proj/projects.json
に読み書きするだけなので、好きにやってください。
6. pyenv による仮想化を回避する
コマンドを作るためにPythonを使っている場合に限った話ではあるのですが、pyenvによる仮想化を行っているとproj
コマンドはインストール時にpyenvにより仮想化された環境に紐づけられます。つまり
$ pyenv local 3.6.1
$ pip install -e .
でproj
をインストールした後で仮装環境を切り替えた場合、
$ pyenv local 3.8.1
$ proj devel
pyenv: proj: command not found
The `proj' command exists in these Python versions:
3.6.1
と command not found になってしまいます。これを回避するには、pyenv global
で指定している環境にproj
コマンドをインストールし、proj
コマンドを叩くときはどんなディレクトリからでもグローバル環境のproj
コマンドを直接叩きに行くようにシェルスクリプトを変更します。
# !/bin/sh
gpython=${PYENV_ROOT}/versions/$(pyenv global)/bin/python
gproj=${PYENV_ROOT}/versions/$(pyenv global)/bin/proj
if test $# -eq 1 && [[ $1 =~ ^[^\-] ]] ; then
result=$(exec $gpython $gproj --echo $1)
exit_code=$?
if test $exit_code -eq 0 ; then
cd $result ; pwd
elif test $exit_code -eq 1 ; then
echo $result
fi
else
(exec $gpython $gproj "$@")
fi