LoginSignup
3
4

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

Last updated at Posted at 2023-06-10

本稿の趣旨

 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_venvsub1 ディレクトリには仮想環境がありません。venv1, venv2, venv3 ディレクトリには仮想環境があります。また、venv2 には alicebob のふたつの仮想環境があります。このように、仮想環境がひとつだけの場合には .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 に渡され選択画面が表示されます。

peco.png

ここでは 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 コマンドを置き換えます。ここでは以下のことをします。

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

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4