python-venv如何实现切换虚拟环境

之前写过如何管理python环境的文章,比较浅显,这次打算稍微深入一点,探究下我们最常用的python虚拟环境是如何实现的,也就是我们熟悉的那句指令

1
python3 -m venv venv

执行后会在当前目录创建venv目录,执行这个目录下的activate脚本就会将当前终端切换到虚拟python环境中,那这是如何实现的呢?

基础使用

为什么我们需要python虚拟环境呢,直接使用本地环境不可以吗?多数情况下没问题,但时间长了总会遇到特殊情况,这里列举5.5.1 什么是 venv | Python 简单入门指北文章中的案例:

假设我们开发程序A是用到了pip install module1==1.0,也就是安装了module1这个第三方库的1.0版本,同时开发程序B用到了这个第三方库的2.0版本,但是在/usr/local/lib/python3.5/site-packages这个目录下只能留一份,那么 A 和 B 就无法分别使用两个版本的第三方库了。

除此之外,多数开发者希望保持本地环境的简洁有序,而使用python的过程中难免下载各种各样版本不一致的第三方库,这些库之间可能会产生一些奇妙的冲突,比如jwt和pyjwt。当然更多的还是本地的不同项目要求使用同一个库的不同版本。

1
sudo apt install python3.12-venv

常用操作很简单,就三板斧,记住就行

1
2
3
4
5
6
7
# 创建虚拟环境
python3 -m venv venv
# 启动虚拟环境
source ./venv/bin/activate
./venv/Scripts/Activate.ps1
# 退出虚拟环境
deactivate

会用这三句指令就掌握了python虚拟环境日常操作的大半了。但我们还是要有点小追求,看看venv到底是在做什么,怎么就创建了一个虚拟环境,具体的实现原理是什么。

深入实现原理

常规的venv虚拟环境目录如下,在开始说明原理之前,我们先来盘点下每个目录/文件的作用

以Unix环境中创建的虚拟环境为例

1
2
# ls ./venv
bin include lib lib64 pyvenv.cfg

pyenv.cfg是虚拟环境的元数据,当你激活虚拟环境时,激活脚本(如activate)会读取pyenv.cfg文件中的信息,以确保使用正确的python解释器和配置。例如,激活脚本会设置环境变量,使得命令行会话使用虚拟环境中的python和pip。

1
2
3
4
5
home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/ubuntu/Temporary/use-venv/venv

这个配置文件中各个配置项的功能如下

  • home: 指向创建虚拟环境时使用的python解释器的路径。
  • include-system-site-packages: 指示是否包含系统范围的包(false表示不包含)。
  • version: 指示python版本。
  • executable: 指向虚拟环境中python解释器的路径。
  • command: 记录创建虚拟环境时使用的命令。

现在可以说说激活脚本了。这东西全在bin目录下,

1
2
# ls venv/bin
activate activate.csh activate.fish Activate.ps1 pip pip3 pip3.12 python python3 python3.12

都是可执行文件(这种说法不严谨,一会儿订正),其中activate activate.csh activate.fish Activate.ps1是适配各种流行shell的激活文件,后三者分别对应c-shell、fish-shell和powershell。

fish 也是支棱起来了,确实好用还方便

其余可执行文件就是常规的pip和python,注意到各自有三个不同的版本。以python为例,python是默认版本,一般指向的就是我们创建虚拟环境时使用的python;python3是考虑到
本地环境中可能同时有python2和python3,遂指定python3;python3.12是考虑本地可能有多个版本的python3,所以就再指定一次python3.12。pip的版本号也是同理。

  • python:这是指向虚拟环境中python解释器的可执行文件。
  • python3:这是指向python 3解释器的可执行文件。
  • python3.12:这是特定于python 3.12版本的可执行文件。

如果你使用cat指令,会发现python和pip还不太一样:

image.png

python在此处其实是一个符号链接,指向的是本地python的解释器;而pip则是正儿八经的可执行文件。我们可以看看pip3的具体内容

1
2
3
4
5
6
7
8
#!/home/ubuntu/Temporary/use-venv/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

这个可执行文件做的其实就是调用虚拟环境中的python解释器(也就是调用本地的python解释器)将第三方库下载到虚拟环境的lib目录中

1
2
# <venv>/lib/python<version>/site-packages/
/home/ubuntu/Temporary/use-venv/venv/lib/python3.12/site-packages/

lib目录包含了虚拟环境中安装的python库,那lib64又是怎么回事儿?在某些系统上(如某些Linux发行版),lib64目录用于存放64位库。它的结构与lib目录相似,通常也包含python包。像我这里使用的是64位ubuntuserver@24.04,lib64@就直接指向了lib目录。

还有个include目录,里面是python@3.12的头文件,允许你在虚拟环境中编译依赖于python 的c扩展。有时开发者需要使用c语言编写扩展模块,以提高性能或访问某些底层系统功能。这个不在此处赘述。


ok,现在我们对整个虚拟环境(venv目录)的结构有了大致的了解。现在我们可以来看看激活脚本了,venv究竟是怎么支持我们使用虚拟环境的呢?以下是一个去除注释的activate,你也可以自己cat出来源码看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
deactivate () {
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
hash -r 2> /dev/null
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
unset -f deactivate
fi
}

deactivate nondestructive

if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
export VIRTUAL_ENV=$(cygpath /home/ubuntu/Temporary/use-venv/venv)
else
export VIRTUAL_ENV=/home/ubuntu/Temporary/use-venv/venv
fi

_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/bin:$PATH"
export PATH

if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi

if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1='(venv) '"${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT='(venv) '
export VIRTUAL_ENV_PROMPT
fi

hash -r 2> /dev/null

我们可以把activate脚本做的事情整理为如下的流程

  1. 环境变量的设置:脚本通过设置VIRTUAL_ENV环境变量来指向虚拟环境的路径,PATH环境变量被修改,以便在虚拟环境的bin目录中查找可执行文件。
  2. 保存旧的环境变量:脚本在激活虚拟环境之前保存了旧的PATH和PYTHONHOME环境变量,以便在退出虚拟环境时可以恢复这些变量。
  3. 提示符的修改:脚本修改了命令行提示符(PS1),在提示符前添加了(venv),以便用户可以清楚地知道他们当前处于虚拟环境中。
  4. 去除不必要的变量:脚本在激活时会清除一些不必要的变量,以避免潜在的冲突。
  5. 去除命令哈希:使用hash -r命令来清除命令哈希,以确保新的PATH设置能够立即生效。
  6. 非破坏性退出:deactivate函数允许用户退出虚拟环境,并在退出时恢复之前的环境变量。

看完这个描述相信你已经清晰明了,为什么我们激活虚拟环境后会多出一个(venv)的标志,为什么我们可以使用deactivate指令直接退出虚拟环境,为什么我们连续开启多个虚拟环境时是在不同的虚拟环境中嵌套而不是水平切换……这些问题都可以得到解答了。

后话

emmm,其实venv的原理并不复杂,说白了就是保存旧环境变量切换到新环境变量,然后把三方库下载到venv目录下,不需要了就切换回旧的环境变量,不要虚拟环境了就直接把目录一删了事儿。可以不叨叨那么多直接展示activate文件的源码,有基础的基本上就可以理解是怎么回事了。