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

246 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: "Python 搜索模块的逻辑和 Pyenv 的工作逻辑"
date: 2023-04-12T21:58:50+08:00
tags: [ linux, python, pyenv ]
categories: [ tech ]
weight: 50
show_comments: true
draft: false
---
pyenv 是一款 Python 版本管理器,但是它不管理虚拟环境,所以不能像 conda 一样开箱即用地管理环境,但是 pyenv 搭配 pyenv-virtualenv 插件以后就具备了管理虚拟环境的能力。本篇文章对 pyenv-virtualenv 下 Python 的虚拟环境的工作方式进行简单介绍。
<!--more-->
在这里,我已经通过 pyenv 安装了 3.10.9 版本的 Python并借助 pyenv-virtualenv 插件创建了一个名为 play 的虚拟环境。关于如何安装 pyenv 和 pyenv-virtualenv 以及创建环境请参考 [pyenv 的主页][1] 和 [pyenv-virtualenv 的主页][2]。
## Python 搜索模块的逻辑
> 本节的内容可以见 [Python 文档][3]。
首先在使用 `import`Python 会从 `sys.path` 的列表所记录的路径中搜索模块,这和 GNU/Linux 环境下环境变量 `PATH` 的工作逻辑很像。所以虚拟环境的目的就是使运行中的 Python 的 `sys.path` 的内容与系统全局的 Python 环境进行区分,从而实现在不同的位置搜索模块和安装模块。
那么问题就变成了 `sys.path` 是怎样确定的,答案就在 [文档][3] 里面,简单汇总为以下:
### sys.path 的确定
如果有实际运行的 Python 脚本文件的话,比如 main.py那么该脚本所在的目录就是 `sys.path` 的第一个条目,否则(比如使用 `python -c` 的方式运行命令)当前目录会是 `sys.path` 的第一个条目。
然后环境变量 `PYTHONPATH` 的值会被追加到 `sys.path` 之中,如果 `PYTHONPATH` 存在的话。
然后是添加依赖包含标准 Python 模块的目录和拓展模块extension modules的目录这里与 `prefix``exec_prefix` 有关。
最后是添加 `site-packages`,它的路径是由 site 模块所生成的。
### prefix 和 exec_prefix 的确定
> 本节表述中 `X` 表示 Python 的主版本号,`Y` 表示 Python 的次版本号,例如对于 Python 3.11 来说,`X` 为 3`Y` 为 11。
在 Linux 下 `${prefix}/lib/pythonX.Y` 下包含不依赖平台的标准 Python 模块,`${exec_prefix}/lib/pythonX.Y/lib-dynload` 下包含依赖于平台拓展模块extension modules所以这两个路径会添加到 `sys.path` 中去,因此需要先确定 `prefix``exec_prefix` 的值。
> 拓展模块extension modules是由 C 或 C++ 编写的模块,与用户代码通过 C API 交互,如 Windows 平台的 `.pyd` 文件和其他平台的 `.so` 文件
如果环境变量 `PYTHONHOME` 存在,那么 `prefix``exec_prefix` 会直接从变量中获取。
如果环境变量 `PYTHONHOME` 不存在,`prefix` 和 `exec_prefix` 会从 `home` 开始,逐级向上寻找“地标”文件和目录来确定。`home` 的默认值是包含 Python 二进制可执行文件的目录的真实路径(符号链接会被解引用,所以只有真实位置会作为起点)。
如果环境变量 `PYTHONHOME` 不存在,并且在 Python 二进制可执行文件相同目录或其上一级目录下存在 `pyvenv.cfg` 文件,那么 `home` 会遵守 `pyvenv.cfg` 中的配置,这和 Python 虚拟环境有关。
`home` 开始,`prefix` 会通过寻找 `pythonXY.zip` 来确定,在 Windows 上会在 `home` 直接查找,但是在 Unix 上会在 `lib/` 下查找。注意即便这个压缩文件完全找不到,也会最终添加到 `sys.path` 中,这个行为的解释在 [这里][4] 有提到。如果没有找到这个 .zip 文件Windows 上会继续寻找 `Lib\os.py` 来确定 `prefix`,对应在 Unix 上会寻找 `lib/pythonX.Y/os.py`
在 Windows 上 `prefix``exec_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 的确定
`preifx``exec_prefix` 已经确定的情况下,使用 [site][5] 模块来生成 `site-packages` 的路径。
它的工作逻辑时,使用 `prefix``exec_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` 来控制 `prefix``exec_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` 设置的,最后将 `prefix``exec_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.zip``lib/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`
于是根据 `prefix``exec_prefix` 添加标准模块和拓展模块的路径到 `sys.path`,即虚拟环境的输出中 `sys.path` 中的 1、2、3。
然后是 site 模块来添加 `site-packages``sys.path`,但是在虚拟环境中,`prefix` 和 `exec_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][6]
在这里即指向 `/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
[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