leafee98-blog/content/posts/how-python-module-search-work-with-pyenv-and-pyenv-virtualenv.md
leafee98 5dc60fc9a1
All checks were successful
ci/woodpecker/push/deploy Pipeline was successful
add tag and category info to post how-python...
2023-04-12 22:03:46 +08:00

13 KiB
Raw Blame History

title date tags categories weight show_comments draft
Python 搜索模块的逻辑和 Pyenv 的工作逻辑 2023-04-12T21:58:50+08:00
linux
python
pyenv
tech
50 true false

pyenv 是一款 Python 版本管理器,但是它不管理虚拟环境,所以不能像 conda 一样开箱即用地管理环境,但是 pyenv 搭配 pyenv-virtualenv 插件以后就具备了管理虚拟环境的能力。本篇文章对 pyenv-virtualenv 下 Python 的虚拟环境的工作方式进行简单介绍。

在这里,我已经通过 pyenv 安装了 3.10.9 版本的 Python并借助 pyenv-virtualenv 插件创建了一个名为 play 的虚拟环境。关于如何安装 pyenv 和 pyenv-virtualenv 以及创建环境请参考 pyenv 的主页pyenv-virtualenv 的主页

Python 搜索模块的逻辑

本节的内容可以见 Python 文档

首先在使用 importPython 会从 sys.path 的列表所记录的路径中搜索模块,这和 GNU/Linux 环境下环境变量 PATH 的工作逻辑很像。所以虚拟环境的目的就是使运行中的 Python 的 sys.path 的内容与系统全局的 Python 环境进行区分,从而实现在不同的位置搜索模块和安装模块。

那么问题就变成了 sys.path 是怎样确定的,答案就在 文档 里面,简单汇总为以下:

sys.path 的确定

如果有实际运行的 Python 脚本文件的话,比如 main.py那么该脚本所在的目录就是 sys.path 的第一个条目,否则(比如使用 python -c 的方式运行命令)当前目录会是 sys.path 的第一个条目。

然后环境变量 PYTHONPATH 的值会被追加到 sys.path 之中,如果 PYTHONPATH 存在的话。

然后是添加依赖包含标准 Python 模块的目录和拓展模块extension modules的目录这里与 prefixexec_prefix 有关。

最后是添加 site-packages,它的路径是由 site 模块所生成的。

prefix 和 exec_prefix 的确定

本节表述中 X 表示 Python 的主版本号,Y 表示 Python 的次版本号,例如对于 Python 3.11 来说,X 为 3Y 为 11。

在 Linux 下 ${prefix}/lib/pythonX.Y 下包含不依赖平台的标准 Python 模块,${exec_prefix}/lib/pythonX.Y/lib-dynload 下包含依赖于平台拓展模块extension modules所以这两个路径会添加到 sys.path 中去,因此需要先确定 prefixexec_prefix 的值。

拓展模块extension modules是由 C 或 C++ 编写的模块,与用户代码通过 C API 交互,如 Windows 平台的 .pyd 文件和其他平台的 .so 文件

如果环境变量 PYTHONHOME 存在,那么 prefixexec_prefix 会直接从变量中获取。

如果环境变量 PYTHONHOME 不存在,prefixexec_prefix 会从 home 开始,逐级向上寻找“地标”文件和目录来确定。home 的默认值是包含 Python 二进制可执行文件的目录的真实路径(符号链接会被解引用,所以只有真实位置会作为起点)。

如果环境变量 PYTHONHOME 不存在,并且在 Python 二进制可执行文件相同目录或其上一级目录下存在 pyvenv.cfg 文件,那么 home 会遵守 pyvenv.cfg 中的配置,这和 Python 虚拟环境有关。

home 开始,prefix 会通过寻找 pythonXY.zip 来确定,在 Windows 上会在 home 直接查找,但是在 Unix 上会在 lib/ 下查找。注意即便这个压缩文件完全找不到,也会最终添加到 sys.path 中,这个行为的解释在 这里 有提到。如果没有找到这个 .zip 文件Windows 上会继续寻找 Lib\os.py 来确定 prefix,对应在 Unix 上会寻找 lib/pythonX.Y/os.py

在 Windows 上 prefixexec_prefix 是相同的,但是其他平台上会继续搜索 lib/pythonX.Y/lib-dynload 来确定 exec_prefix

home 搜索的过程类似如下,一般在第二次检查会成功,如果一直找不到“地标”文件,则会抛出错误:

Python executable path is /usr/bin/python3
check /usr/bin/lib/pythonX.Y/os.py  -> if exists, prefix=/usr/bin
check /usr/lib/pythonX.Y/os.py      -> if exists, prefix=/usr
check /lib/pythonX.Y/os.py          -> if exists, prefix=/

site-packages 的确定

preifxexec_prefix 已经确定的情况下,使用 site 模块来生成 site-packages 的路径。

它的工作逻辑时,使用 prefixexec_prefix 作为路径的前半部分,在 Windows 上使用 lib/site-packages、Linux 上使用 lib/pythonX.Y/site-packages 作为后半部分,分别组合得到两个路径,并忽略掉不存在的路径,存在的路径会添加到 sys.path 中。

开始我们的验证

验证时使用一个简单的程序打印信息,在我的环境中,它保存在 ~/Desktop/tmp/main.py

#!/usr/bin/env python3

import sys

def main():
    print("base_prefix:", sys.base_prefix)
    print("base_exec_prefix:", sys.base_exec_prefix)
    print("prefix:", sys.prefix)
    print("exec_prefix:", sys.exec_prefix)
    for i, j in enumerate(sys.path):
        print("sys.path", i, ":", j)

if __name__ == "__main__":
    main()

在系统全局下,程序输出如下:

$ python ~/Desktop/tmp/main.py
base_prefix: /usr
base_exec_prefix: /usr
prefix: /usr
exec_prefix: /usr
sys.path 0 : /home/leafee98/Desktop/tmp
sys.path 1 : /usr/lib/python310.zip
sys.path 2 : /usr/lib/python3.10
sys.path 3 : /usr/lib/python3.10/lib-dynload
sys.path 4 : /usr/lib/python3.10/site-packages

在我的一个虚拟环境下,程序输出如下:

$ python ~/Desktop/tmp/main.py
base_prefix: /home/leafee98/.pyenv/versions/3.10.9
base_exec_prefix: /home/leafee98/.pyenv/versions/3.10.9
prefix: /home/leafee98/.pyenv/versions/diffusion-webui
exec_prefix: /home/leafee98/.pyenv/versions/diffusion-webui
sys.path 0 : /home/leafee98/Desktop/tmp
sys.path 1 : /home/leafee98/.pyenv/versions/3.10.9/lib/python310.zip
sys.path 2 : /home/leafee98/.pyenv/versions/3.10.9/lib/python3.10
sys.path 3 : /home/leafee98/.pyenv/versions/3.10.9/lib/python3.10/lib-dynload
sys.path 4 : /home/leafee98/.pyenv/versions/diffusion-webui/lib/python3.10/site-packages

在系统全局环境下,通过设置环境变量 PYTHONHOME 来控制 prefixexec_prefix,输出如下

$ PYTHONHOME=/home/leafee98/.pyenv/versions/3.10.9:/usr python ~/Desktop/tmp/main.py
base_prefix: /home/leafee98/.pyenv/versions/3.10.9
base_exec_prefix: /usr
prefix: /home/leafee98/.pyenv/versions/3.10.9
exec_prefix: /usr
sys.path 0 : /home/leafee98/Desktop/tmp
sys.path 1 : /home/leafee98/.pyenv/versions/3.10.9/lib/python310.zip
sys.path 2 : /home/leafee98/.pyenv/versions/3.10.9/lib/python3.10
sys.path 3 : /usr/lib/python3.10/lib-dynload
sys.path 4 : /home/leafee98/.pyenv/versions/3.10.9/lib/python3.10/site-packages
sys.path 5 : /usr/lib/python3.10/site-packages

从上面的例子,可以看到 sys.path 的第一个元素就是脚本所在的位置。从第三个例子中看到标准 Python 模块的路径是借助 prefix 设置的,而拓展模块是借助 exec_prefix 设置的,最后将 prefixexec_prefix 分别和 lib/pythonX.Y/site-packages 结合,得到两个路径并且这两个路径全部存在,所以全部添加到了 sys.path 中。

pyenv 下的工作流程

$ python ~/Desktop/tmp/main.py
base_prefix: /home/leafee98/.pyenv/versions/3.10.9
base_exec_prefix: /home/leafee98/.pyenv/versions/3.10.9
prefix: /home/leafee98/.pyenv/versions/diffusion-webui
exec_prefix: /home/leafee98/.pyenv/versions/diffusion-webui
sys.path 0 : /home/leafee98/Desktop/tmp
sys.path 1 : /home/leafee98/.pyenv/versions/3.10.9/lib/python310.zip
sys.path 2 : /home/leafee98/.pyenv/versions/3.10.9/lib/python3.10
sys.path 3 : /home/leafee98/.pyenv/versions/3.10.9/lib/python3.10/lib-dynload
sys.path 4 : /home/leafee98/.pyenv/versions/diffusion-webui/lib/python3.10/site-packages

在上面的输出中,虚拟环境所使用的工具就是 pyenv所以本节将介绍这个输出中 sys.path 中每一个值的来源。

sys.path 中的 0 就是脚本所在的位置。

在虚拟环境下, pyenv 将 /home/leafee98/.pyenv/plugins/pyenv-virtualenv/shims/home/leafee98/.pyenv/shims 添加到 PATH 中,于是当敲出 python 时,实际运行的是下面这样的一个脚本

$ cat /home/leafee98/.pyenv/shims/python
#!/usr/bin/env bash
set -e
[ -n "$PYENV_DEBUG" ] && set -x

program="${0##*/}"

export PYENV_ROOT="/home/leafee98/.pyenv"
exec "/usr/share/pyenv/libexec/pyenv" exec "$program" "$@"

不想分析脚本运行逻辑了,使用 PYENV_DEBUG=1 python3 可以看到 pyenv 所输出的调试信息(部分输出如下),其中可以得知最后运行的二进制 Python 的路径是 /home/leafee98/.pyenv/versions/diffusion-webui/bin/python3,于是就回到了上面初始化 Python sys.path 的流程。

$ PYENV_DEBUG=1 python ~/Desktop/tmp/main.py
+ program=python
+ export PYENV_ROOT=/home/leafee98/.pyenv
+ PYENV_ROOT=/home/leafee98/.pyenv
+ exec /usr/share/pyenv/libexec/pyenv exec python /home/leafee98/Desktop/tmp/main.py
+(/usr/share/pyenv/libexec/pyenv:23): enable -f /usr/share/pyenv/libexec/../libexec/pyenv-realpath.dylib
......
+(/usr/share/pyenv/libexec/pyenv-exec:46): PATH=/home/leafee98/.pyenv/versions/diffusion-webui/bin:/usr/share/pyenv/libexec:/home/leafee98/.pyenv/plugins/pyenv-virtualenv/bin:/usr/share/pyenv/plugins/python-build/bin:/home/leafee98/.pyenv/plugins/pyenv-virtualenv/shims:/home/leafee98/.pyenv/shims:/usr/local/sbin:/usr/local/bin:/usr/bin:/opt/cuda/bin:/opt/cuda/nsight_compute:/opt/cuda/nsight_systems/bin:/usr/lib/jvm/default/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl:/home/leafee98/.local/bin:/home/leafee98/.yarn/bin:/home/leafee98/go/bin
+(/usr/share/pyenv/libexec/pyenv-exec:48): exec /home/leafee98/.pyenv/versions/diffusion-webui/bin/python /home/leafee98/Desktop/tmp/main.py
......

首先 /home/leafee98/.pyenv/versions/diffusion-webui/bin/python3 的上一级存在 pyvenv.cfg,它的内容如下

$ cat /home/leafee98/.pyenv/versions/diffusion-webui/bin/../pyvenv.cfg
home = /home/leafee98/.pyenv/versions/3.10.9/bin
include-system-site-packages = false
version = 3.10.9

所以遵循该虚拟环境的配置,/home/leafee98/.pyenv/versions/3.10.9/bin 作为 home 开始寻找地标文件 lib/python310.ziplib/python3.10/os.py,以此来确定 prefix

$ echo_if_exist() { [[ -e "$1" ]] && echo "$1 exist" ; }
$ home=/home/leafee98/.pyenv/versions/3.10.9/bin
$ echo_if_exist ${home}/lib/python3.10.zip
$ echo_if_exist ${home}/lib/python3.10/os.py
$ home=$(dirname $home)
$ echo_if_exist ${home}/lib/python3.10.zip
$ echo_if_exist ${home}/lib/python3.10/os.py
/home/leafee98/.pyenv/versions/3.10.9/lib/python3.10/os.py exist

/home/leafee98/.pyenv/versions/3.10.9/lib/python3.10/os.py 存在,所以 prefix 确定为 home/leafee98/.pyenv/versions/3.10.9

重新从 home 开始寻找地标文件 lib/python3.10/lib-dynload 来确定 exec_prefix

$ home=/home/leafee98/.pyenv/versions/3.10.9/bin
$ echo_if_exist ${home}/lib/python3.10/lib-dynload
$ home=$(dirname $home)
$ echo_if_exist ${home}/lib/python3.10/lib-dynload
/home/leafee98/.pyenv/versions/3.10.9/lib/python3.10/lib-dynload exist

/home/leafee98/.pyenv/versions/3.10.9/lib/python3.10/lib-dynload 存在,所以 exec_prefix 确定为 /home/leafee98/.pyenv/versions/3.10.9

于是根据 prefixexec_prefix 添加标准模块和拓展模块的路径到 sys.path,即虚拟环境的输出中 sys.path 中的 1、2、3。

然后是 site 模块来添加 site-packagessys.path,但是在虚拟环境中,prefixexec_prefix 会指向虚拟环境的路径,而不是通过地标文件所找到的路径:

When a Python interpreter is running from a virtual environment, sys.prefix and sys.exec_prefix point to the directories of the virtual environment, whereas sys.base_prefix and sys.base_exec_prefix point to those of the base Python used to create the environment. It is sufficient to check sys.prefix == sys.base_prefix to determine if the current interpreter is running from a virtual environment.

Source

在这里即指向 /home/leafee98/.pyenv/versions/diffusion-webui,于是分别拼接 lib/python3.10/site-packages 并去除不存在的路径和重复的路径,得到 /home/leafee98/.pyenv/versions/diffusion-webui/lib/python3.10/site-packages,即虚拟环境的输出中 sys.path 中的 5。

参考:

  1. https://github.com/pyenv/pyenv
  2. https://github.com/pyenv/pyenv-virtualenv
  3. https://docs.python.org/3/library/sys_path_init.html#sys-path-init
  4. https://stackoverflow.com/questions/34822593/why-does-sys-path-have-c-windows-system-python34-zip/49293544#49293544
  5. https://docs.python.org/3/library/site.html#module-site
  6. https://docs.python.org/3/library/venv.html#how-venvs-work