venvを使った仮想環境の仕組みを除いてみる

## 対象読者

* venvを普段何気なく使っているが、実際に中で何が起こっているのか知らない方。
* Pythonで仮想環境に興味がある、という方も前半は助けになるかも。

## そもそも venv って何？

Pythonで、ライブラリを環境（プロジェクトや用途）ごとにインストールしたい場合に使うツール。
以下のように使う（WSLでの例）

インストール

```sh
$ sudo apt update
$ sudo apt install python3.10-venv
```

現在のディレクトリで仮想環境を作成する

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

仮想環境の有効化（環境に入る）

```sh
$ . .env/bin/activate
```
※各環境用に activate ファイルは用意されているので違う環境の方は `.env/bin/` を除いてみると吉。

以降、プロンプトの先頭に仮想環境名（この例だと .env）が表示される。

何か試しにインストールして使ってみる。

```sh
(.env) $ pip install pyjokes
(.env) $ python3 -c "import pyjokes; print(pyjokes.get_joke())"
I love pressing the F5 key. It's refreshing.
```

仮想環境の無効化（環境から抜ける）

```sh
deactivate
```

## なんでこれで仮想環境の有効化ができるの？

「仮想環境の作成」では、必要なファイルやディレクトリを作成しているだけなので、有効化から見てみる。

### 最初のドット

これは、今いる実行環境に影響する形でその後に続くスクリプトを実行するコマンド `source` の省略形。

例えば、以下のファイルを考える。

```sh
# test.sh
cd ..
pwd
```

これをドットなしで実行しても、親ディレクトリのパスは表示されるがディレクトリの移動は実行環境では起こらない。
一方、ドットありで実行すると実行環境の親ディレクトリに移動させられる。

つまり、有効化のスクリプトは `.env/bin/activate` を実行環境に影響する形で実行している。

### activate の中身

というわけで、どのようなスクリプトが現在の実行環境に影響する形で実行されているのかを見てみる。

ざっくり .env/bin/activate ファイルを見てみると以下の構成になっている。

* `deactivate` 関数を定義
* `deactivate` 関数を、引数に `nondestructive` を指定して呼び出し（初期化処理）
* `PATH` の先頭に `.env/bin` を追加して `export`
* `PYTHONHOME` が設定されていたらバックアップして `unset`
* `VIRTUAL_ENV_DISABLE_PROMPT` が設定されていなければ

  * `PS1` をバックアップしてから`(.env) `を前に追加して `export`
  * `VIRTUAL_ENV_PROMPT` を `(.env)` として `export`
* bash や zsh が存在していたらハッシュテーブル（キャッシュされたコマンドのパス情報）をクリア

つまり、かみ砕くとざっくり以下をやっている。

* 無効化用の関数定義
* `.env/bin` をコマンド実行時に最初に参照される場所（PATH の左端）に設定。

  * ついでにBashやZshがキャッシュしているコマンドのパスをクリアして PATH が使われるように強制する。
* プロンプトで表示される名前の先頭に `(.env) ` を追加。

で、この `.env/bin` の中に `pip` コマンドも作成されているので、これ以降 `pip install xxx` とやると `.env/bin/pip install xxx` が実行されるという仕組みになっている。

## 仮想環境を有効化した後の動きは？

`.env/bin/pip` を見てみる。中身はPythonスクリプトで、主な処理は以下の二行。

```py
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())
```

最初の行は `-script.pyw` か `.exe` で終わるならそれを削除して第一引数に入れて`main()`を実行。

最後に実行される `main()` はこの処理の上で書かれている下記（pipのエントリーポイント）に対応する。

```py
from pip._internal.cli.main import main
```

というわけで、 `pip._internal.cli.main()` が調整された引数と共に実行される。

#### .env/bin/pip から .env/lib/ 内の pip.\_internal.cli.main を参照している裏側

大分細かいが、これは `.env/bin/pip` をPythonが実行する際に親ディレクトリにある `pyvenv.cfg` （今回は `.env/pyvenv.cfg`）ファイルを参照することで仮想環境であることを認識し、それゆえに `sys.path` に `.env/lib/python3.10/site-packages` ディレクトリへのパスを追加することで実現されている。
この `../pyvenv.cfg` の参照および `sys.path` へのパス追加動作はPython自体（具体的には `cpython/Lib/site.py`）に組み込まれていて、`pyvenv.cfg` がPython実行ファイル（またはシンボリックリンク）の親ディレクトリに存在していればvenvに関係なくパスの追加まで同様に実行される。

### .env内のpipを実行して、.env内にパッケージが保存される仕組み

これは、そもそも pip がパッケージの保存場所をどうやって決めているかを理解する必要がある。

結論から言うと、pipを実行しているPythonで定義されている`sys.path`の値のうち、ディレクトリ名が `site-packages` となっている場所に保存する。

つまり、さっきの `pyvenv.cfg` によって追加された `sys.path` の値（`.env/lib/python3.10/site-packages`）にパッケージは追加されることになるし、Pythonを実行したときに同ディレクトリからパッケージが参照されることになる。

## 無効化は何をしている？

残るは無効化の処理のみだが、これは単純で設定した`PATH`や`PS1`（プロンプトの表示名）をもともとの値に戻していると予想できる。

実際に仮想環境有効化時に定義された `deactivate()` 関数を見てみると、以下の処理が書かれている。

* activate 時にバックアップしておいた元々の `PATH` 、 `PYTHONHOME` および `PS1` を `export` し、バックアップを `unset`
* bash や zsh が存在していたらハッシュテーブル（キャッシュされたコマンドのパス情報）をクリア
* 引数に `nondestructive` が設定されていなければ `deactivate()` 自体を `unset`

というわけで、予想通り設定した環境変数を activate 前に戻している、という動作をしていることが分かる。

## まとめ

普段何気なく使っている venv だが、こうやって調べてみると実際は仮想環境に入っているという感じではなく、環境を一時的に上書きしているという状態なのが分かる。

すでにvenvの中身について解説している記事はあるが、今回自分で色々と調べながら、疑問点を順番に解決していく過程を記事にしてみた。

知らなくても困らない内容ではあるが、この記事が誰かの知的好奇心の解消の一助になれば幸いである。