本稿の趣旨
Zsh で cd をした際に Python の標準仮想環境 venv が存在するか探索し、自動で有効化・無効化を行う関数を紹介し、説明します。
Python インタープリタのバージョンや必要なモジュール一式は、プロジェクトごとに異なります。これらを管理するツールが「仮想環境」です。よく知られた仮想環境管理ツールとしては、venv
, virtualenv
, pyvenv
, pipenv
などがあります。本稿では、Python のビルトイン・モジュールである venv
を用います。
こうした仮想環境を利用することはあまりに一般的であることから、Visual Studio Code 1 や PyCharm 2 等の統合開発環境で簡易に利用できる方法が準備されていたり、ターミナルの cd をフックして自動選択する試みがなされてきました(3, 4, 5)。しかし、仮想環境のディレクトリ配下のサブディレクトリへいきなり cd した場合や、複数の仮想環境がある場合の取り扱いなどは不十分であり、さらなる改良の余地がある状況です。
本稿では、既存の方法ではサポートされていなかったいくつかの点を改良した Python 仮想環境自動選択方法について説明します。任意のディレクトリに移動したとき、仮想環境があるかどうか root ディレクトリまで遡って探索し、最初に見つかった仮想環境へ切り替えます。また、複数の仮想環境があった場合は peco ツールで選択できるようにします。仮想環境が有効な状態で、仮想環境がないまたは異なる仮想環境のディレクトリへ移動するときには無効化します。
開発環境
- Python 3.11.3: Python 3.3 以降であれば対応しているものと思われる
- macOS Sonoma 14.0: 手がすべった
- zsh 5.9 (x86_64-apple-darwin23.0): macOS 標準のもの
- peco version v0.5.11 (built with go1.20.2): Homebrew で入れた
(参考)他の動作環境
- Python 3.10.6, Ubuntu 22.04.2 LTS, zsh 5.8.1 (x86_64-ubuntu-linux-gnu), peco version v0.5.10 (built with go1.17) でも動作しています。
Python 3, Zsh, peco の適当なバージョンが入っていれば動くはずです。
どんな動きをするのか
次のようなディレクトリ構造の場合を考えます。.venv
は仮想環境を作成するディレクトリです。no_venv
と sub1
ディレクトリには仮想環境がありません。venv1
, venv2
, venv3
ディレクトリには仮想環境があります。また、venv2
には alice
と bob
のふたつの仮想環境があります。このように、仮想環境がひとつだけの場合には .venv
ディレクトリ自体に作り、ひとつ以上作る場合(将来複数作ることが想定される場合、または現に複数切り替えて使う場合)には .venv
ディレクトリ内に作ります。
.
├── no_venv
├── venv1
│ ├── .venv
│ └── sub1
├── venv2
│ └── .venv
│ ├── alice
│ └── bob
└── venv3
└── .venv
(1) .
ディレクトリにいます。仮想環境はありません。
(2) no_venv
へ移動する
. $ cd no_venv
no_venv $
no_venv
へ移動しました。仮想環境はありません。
(3) venv1
へ移動する
no_venv $ cd ../venv1
(venv1) venv1 $
venv1
へ移動しました。仮想環境がひとつだけあります。この仮想環境を有効化します。
(4) venv1/sub1
へ移動する
(venv1) venv1 $ cd sub1
(venv1) venv1/sub1 $
sub1
ディレクトリには仮想環境がありません。親ディレクトリを探索すると、venv1
に見つかりました。しかしこの仮想環境は、現在有効な仮想環境と同じです。そこで仮想環境の切り替えは行いません。
(5) no_venv
へ移動する
(venv1) venv1/sub1 $ cd ../../no_venv
no_venv $
no_venv
には仮想環境がありません。現在の仮想環境を無効化します。
(6) venv2
へ移動する
no_venv $ cd venv2
venv2
には仮想環境ディレクトリ .venv
があり、その配下に複数の仮想環境が存在します。このような場合にはパスが peco に渡され選択画面が表示されます。
ここでは alice
を選んだものとします。
(alice) venv2 $
(7) venv1/sub1
へ移動する
(alice) venv2 $ cd ../venv1/sub1
(venv1) venv1 $
sub1
ディレクトリには仮想環境がありません。親ディレクトリを探索すると、venv1
に見つかりました。しかしこの仮想環境は、現在有効な仮想環境 alice
とは異なります。まず現在の仮想環境を無効化し、続いて新しい仮想環境 venv1
を有効化します。
仮想環境の配置
仮想環境は .venv
ディレクトリの中に作ります。仮想環境かどうかは pyvenv.cfg
ファイルがあるかどうかで判定しています。
仮想環境がひとつの場合
$ python -m venv .venv
仮想環境を複数切り替えて使う場合
$ mkdir .venv
$ python -m venv .venv/alice
$ python -m venv .venv/bob
コード
フルコードは Gist: cd_with_venv_selection.zsh に置いてあるほか、記事の完全性のため本稿末尾にも掲載しました。
autoload
このような関数は .zsh_functions
等のディレクトリに入れておき、autoload
で読み込めると便利です。Zsh の autoload
は、まずプレースホルダを設定します。
$ where cd
cd () {
# undefined
builtin autoload -XUz
}
cd: shell built-in command
/usr/bin/cd
初回の cd
により、実際に関数を読み、このプレースホルダを置き換えます。このため、初回の cd
は動作せず、ディレクトリ変更が無視されてしまいます。これを避けるため、以下のように読み込みます。
autoload -Uz "cd" && cd
オプションの -U
は alias での上書きを禁止、-z
は Zsh スクリプトであることを指定します。autoload
直後に cd
コマンド(ホームディレクトリへの移動)を実行することで、実際に関数を設定させます。
cd コマンドの置き換え
function cd() { }
により、cd
コマンドを置き換えます。ここでは以下のことをします。
- ビルトイン cd コマンドを実行する。失敗したら処理を終える。
- 現在、仮想環境が有効化されているが、その仮想環境配下ではないディレクトリへの移動の場合は、現在環境を無効化する。
- 仮想環境が見つかるまで親ディレクトリを探索する関数を呼ぶ。
function cd() {
# Call the built-in cd command
builtin cd "$@" || return
# Deactivate virtual environment
if [[ -n "$VIRTUAL_ENV" && $PWD != ${VIRTUAL_ENV%/*} && $PWD != ${VIRTUAL_ENV%/*}/* ]]
then
deactivate
fi
# Recursively find virtual env in parent directories
find_venv_in_parents $PWD
}
cd
コマンドで実際にディレクトリを変更させたいため、まずビルトイン関数を実行します。これで $PWD
は新しいディレクトリを指すようになります。
$VIRTUAL_ENV
は仮想環境が有効化されているときには、その仮想環境のパス(例:/Users/rino/venv_test/venv1/.venv
)が設定されます。仮想環境が無効な場合は何も設定されません。
仮想環境が有効な状態で、さらなる仮想環境を有効化することは避けたいですから、無効化を行います。ただし、cd .
(ディレクトリ変更なし)だったり cd subdir
だったりする場合には現在環境を維持します。そこで if
を用い、以下の条件すべてを満たす場合にのみ無効化します。:
-
$VIRTUAL_ENV
が設定されている(仮想環境が有効である) -
$PWD
が$VIRTUAL_ENV
直下である(現在ディレクトリに現在と同一の仮想環境がある) -
$PWD
が$VIRTUAL_ENV
配下のどこかである(親ディレクトリを辿ったら、現在と同一の仮想環境がある)
これでクリーンな状況になりました。仮想環境を探索し、存在すれば、有効化を試みます。
仮想環境の探索
探索は仮想環境が見つかるまで root へ向かって再帰的に行います。以下の変数を用います。
変数名 | 用途 |
---|---|
dir |
いま対象としているディレクトリ。まずカレントディレクトリが設定され、再帰的に呼び出されるたびに親ディレクトリが設定される。/ に到達したら、仮想環境が見つからなかったものとする。 |
venv_path |
探索する仮想環境ディレクトリ名。$dir に /.venv をつけたもの。 |
pyvenv_cfg |
pyvenv.cfg ファイルのパス。venv_path に /pyvenv.cfg をつけたもの。 |
dirs |
venv_path 内のすべてのディレクトリを表す配列。 |
num_dirs |
dirs の要素数。.venv 内のディレクトリ数。 |
selected_dir |
num_dirs が 2 以上の場合は、複数の仮想環境が存在する可能性がある。peco にディレクトリ名を渡し、選択させる。その選択されたディレクトリが selected_dir である。 |
まず、ローカル変数をいくつか定義します。
function find_venv_in_parents() {
local dir=$1
local venv_path="$dir/.venv"
local pyvenv_cfg="$venv_path/pyvenv.cfg"
pyvenv.cfg
ファイルが存在するか、.venv
ディレクトリを探索します。もし存在すれば仮想環境であり、activate します。
# Check for pyvenv.cfg in the .venv directory
if [[ -f $pyvenv_cfg ]]; then
source "$venv_path/bin/activate"
pyvenv.cfg
は存在しないが、.venv
ディレクトリ自体はある場合、.venv
ディレクトリ内に何個のディレクトリがあるか確認します。見つかったディレクトリは配列 dirs
に格納されます。配列の要素数、すなわち見つかったディレクトリ数を num_dirs
に代入します。
elif [[ -d $venv_path ]]; then
# Enumerate directories, including dotfiles
setopt LOCAL_OPTIONS NULL_GLOB
local dirs=($(ls -d "$venv_path/"*))
local num_dirs=${#dirs[@]}
もしディレクトリが見つからなければ、現在ディレクトリには仮想環境がないものとし、親ディレクトリを探索するため自分自身を再帰的に呼び出します。
# If there's no directory, ignore it and recursively check the parent
if (( num_dirs == 0 )); then
if [[ $dir != '/' ]]; then
find_venv_in_parents "$(dirname "$dir")"
fi
もしディレクトリがひとつだけ存在すれば、その仮想環境を有効化します。
# If there's only one directory, activate it
elif (( num_dirs == 1 )); then
source "${dirs[1]}/bin/activate"
もし複数のディレクトリが存在すれば、peco
を用いてユーザーにそのうちのひとつを選択させます。選択された仮想環境を有効化します。
# If there are multiple directories, use peco to select one and activate it
elif (( num_dirs > 1 )); then
local selected_dir=$(printf '%s\n' "${dirs[@]}" | peco)
if [[ $selected_dir == $venv_path/* ]]; then
source "$selected_dir/bin/activate"
fi
fi
仮想環境が見つからなければ、親ディレクトリを探索するため自分自身を再帰的に呼び出します。
else
# If we are not in the root directory, recursively check the parent
if [[ $dir != '/' ]]; then
find_venv_in_parents "$(dirname "$dir")"
fi
fi
}
まとめ
Python venv を切り替えながら使いたいものの、毎回 deactivate/activate するのは手間だし間違えそうです。自動で切り替えられれば利便性が大きく向上します。本稿では、cd 時に仮想環境を自動で切り替える方法について、探索範囲を root ディレクトリまで広げ、複数あった場合は peco で選択できるようにしました。
フルコード
#!/bin/zsh
#-------------------------------------------------------------------------------
# Automatic activate/deactivate python virtual environment
#-------------------------------------------------------------------------------
function cd() {
# Call the built-in cd command
builtin cd "$@" || return
# Deactivate virtual environment
if [[ -n "$VIRTUAL_ENV" && $PWD != ${VIRTUAL_ENV%/*} && $PWD != ${VIRTUAL_ENV%/*}/* ]]
then
deactivate
fi
# Recursively find virtual env in parent directories
find_venv_in_parents $PWD
}
#-------------------------------------------------------------------------------
function find_venv_in_parents() {
local dir=$1
local venv_path="$dir/.venv"
local pyvenv_cfg="$venv_path/pyvenv.cfg"
# Check for pyvenv.cfg in the .venv directory
if [[ -f $pyvenv_cfg ]]; then
source "$venv_path/bin/activate"
elif [[ -d $venv_path ]]; then
# Enumerate directories, including dotfiles
setopt LOCAL_OPTIONS NULL_GLOB
local dirs=($(ls -d "$venv_path/"*))
local num_dirs=${#dirs[@]}
# If there's no directory, ignore it and recursively check the parent
if (( num_dirs == 0 )); then
if [[ $dir != '/' ]]; then
find_venv_in_parents "$(dirname "$dir")"
fi
# If there's only one directory, activate it
elif (( num_dirs == 1 )); then
source "${dirs[1]}/bin/activate"
# If there are multiple directories, use peco to select one and activate it
elif (( num_dirs > 1 )); then
local selected_dir=$(printf '%s\n' "${dirs[@]}" | peco)
if [[ $selected_dir == $venv_path/* ]]; then
source "$selected_dir/bin/activate"
fi
fi
else
# If we are not in the root directory, recursively check the parent
if [[ $dir != '/' ]]; then
find_venv_in_parents "$(dirname "$dir")"
fi
fi
}
#-------------------------------------------------------------------------------
-
Microsoft: "Using Python environments in VS Code", https://code.visualstudio.com/docs/python/environments ↩
-
JetBrains: "Configure a virtual environment", https://www.jetbrains.com/help/pycharm/creating-virtual-environment.html ↩
-
TimeCpsule: "(Python3)venvのactivateとdeactivateを自動化する", https://n2kia4.hatenablog.com/entry/20170217/1487333599 ↩
-
@koshigoe: "Python の venv を自動で有効(無効)化させたい", https://qiita.com/koshigoe/items/15351b1b4d137d47ca00 ↩
-
@zmtkr (Takuro ZAMA): "自動でvenvを(de)activateする", https://qiita.com/zmtkr/items/365655fe6e2a4072e962 ↩