cd時にPython venv を自動有効化・無効化する

# 本稿の趣旨

　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` へ移動する

```sh
. $ cd no_venv
no_venv $
```

`no_venv` へ移動しました。仮想環境はありません。

(3) `venv1` へ移動する

```sh
no_venv $ cd ../venv1
(venv1) venv1 $
```

`venv1` へ移動しました。仮想環境がひとつだけあります。この仮想環境を有効化します。

(4) `venv1/sub1` へ移動する

```sh
(venv1) venv1 $ cd sub1
(venv1) venv1/sub1 $
```

`sub1` ディレクトリには仮想環境がありません。親ディレクトリを探索すると、`venv1` に見つかりました。しかしこの仮想環境は、現在有効な仮想環境と同じです。そこで仮想環境の切り替えは行いません。

(5) `no_venv` へ移動する

```sh
(venv1) venv1/sub1 $ cd ../../no_venv
no_venv $
```

`no_venv` には仮想環境がありません。現在の仮想環境を無効化します。

(6) `venv2` へ移動する

```sh
no_venv $ cd venv2
```

`venv2` には仮想環境ディレクトリ `.venv` があり、その配下に複数の仮想環境が存在します。このような場合にはパスが peco に渡され選択画面が表示されます。


![peco.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/400ffd05-e0da-50fb-e8f9-f5489c9f5296.png)


ここでは `alice` を選んだものとします。

```sh
(alice) venv2 $
```

(7) `venv1/sub1` へ移動する

```sh
(alice) venv2 $ cd ../venv1/sub1
(venv1) venv1 $
```

`sub1` ディレクトリには仮想環境がありません。親ディレクトリを探索すると、`venv1` に見つかりました。しかしこの仮想環境は、現在有効な仮想環境 `alice` とは異なります。まず現在の仮想環境を無効化し、続いて新しい仮想環境 `venv1` を有効化します。

# 仮想環境の配置

　仮想環境は `.venv` ディレクトリの中に作ります。仮想環境かどうかは `pyvenv.cfg` ファイルがあるかどうかで判定しています。

## 仮想環境がひとつの場合

```sh
$ python -m venv .venv
```

## 仮想環境を複数切り替えて使う場合

```sh
$ mkdir .venv
$ python -m venv .venv/alice
$ python -m venv .venv/bob
```

# コード

　フルコードは [Gist: cd_with_venv_selection.zsh](https://gist.github.com/rinodrops/d1ca35e2074a6e8de5e8dc94047e60b1) に置いてあるほか、記事の完全性のため本稿末尾にも掲載しました。

## autoload

　このような関数は `.zsh_functions` 等のディレクトリに入れておき、`autoload` で読み込めると便利です。Zsh の `autoload` は、まずプレースホルダを設定します。

```sh
$ where cd
cd () {
	# undefined
	builtin autoload -XUz
}
cd: shell built-in command
/usr/bin/cd
```

　初回の `cd` により、実際に関数を読み、このプレースホルダを置き換えます。このため、初回の `cd` は動作せず、ディレクトリ変更が無視されてしまいます。これを避けるため、以下のように読み込みます。

```sh
autoload -Uz "cd" && cd
```

オプションの `-U` は alias での上書きを禁止、`-z` は Zsh スクリプトであることを指定します。`autoload` 直後に `cd` コマンド（ホームディレクトリへの移動）を実行することで、実際に関数を設定させます。

## cd コマンドの置き換え

　`function cd() { }` により、`cd` コマンドを置き換えます。ここでは以下のことをします。

1. ビルトイン cd コマンドを実行する。失敗したら処理を終える。
2. 現在、仮想環境が有効化されているが、その仮想環境配下ではないディレクトリへの移動の場合は、現在環境を無効化する。
3. 仮想環境が見つかるまで親ディレクトリを探索する関数を呼ぶ。

```sh
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` である。                | 

　まず、ローカル変数をいくつか定義します。

```sh
function find_venv_in_parents() {
    local dir=$1
    local venv_path="$dir/.venv"
    local pyvenv_cfg="$venv_path/pyvenv.cfg"
```

　`pyvenv.cfg` ファイルが存在するか、`.venv` ディレクトリを探索します。もし存在すれば仮想環境であり、activate します。

```sh
    # 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` に代入します。

```sh
    elif [[ -d $venv_path ]]; then
        # Enumerate directories, including dotfiles
        setopt LOCAL_OPTIONS NULL_GLOB
        local dirs=($(ls -d "$venv_path/"*))
        local num_dirs=${#dirs[@]}
```

　もしディレクトリが見つからなければ、現在ディレクトリには仮想環境がないものとし、親ディレクトリを探索するため自分自身を再帰的に呼び出します。

```sh
       # 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
```

　もしディレクトリがひとつだけ存在すれば、その仮想環境を有効化します。

```sh
        # If there's only one directory, activate it
        elif (( num_dirs == 1 )); then
            source "${dirs[1]}/bin/activate"
```

　もし複数のディレクトリが存在すれば、`peco` を用いてユーザーにそのうちのひとつを選択させます。選択された仮想環境を有効化します。

```sh
        # 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
```

　仮想環境が見つからなければ、親ディレクトリを探索するため自分自身を再帰的に呼び出します。

```sh
    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 で選択できるようにしました。

# フルコード

```sh
#!/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
}
#-------------------------------------------------------------------------------
```

[^1]: Microsoft: "Using Python environments in VS Code", https://code.visualstudio.com/docs/python/environments
[^2]: JetBrains: "Configure a virtual environment", https://www.jetbrains.com/help/pycharm/creating-virtual-environment.html
[^3]: TimeCpsule: "(Python3)venvのactivateとdeactivateを自動化する", https://n2kia4.hatenablog.com/entry/20170217/1487333599
[^4]: @koshigoe: "Python の venv を自動で有効(無効)化させたい", https://qiita.com/koshigoe/items/15351b1b4d137d47ca00
[^5]: @zmtkr (Takuro ZAMA): "自動でvenvを(de)activateする", https://qiita.com/zmtkr/items/365655fe6e2a4072e962