对我来说,这是很常见的一种情况:
- 你的一个库,要运行一些用户的 python 的代码文件。
对我个人来说,我在 3 种情况中遇到了这个问题:
最简单的解决办法就是读进来exec
对吧,当然你有点经验的话就知道这样对 traceback 不友好。比较正确的办法是用runpy.run_path
但是还会遇到一个问题:
- 用户如果这个文件用到一些包,而这些包不在你安装你这个库的环境中,导致在你的库的 python 进程中会 import 失败
最好的办法当然是在用户侧解决,比如在用户的项目中安装你这个库,但是
- 可能这是一个开发工具,用户不太愿意往项目中引入开发依赖
- 终归还是得让用户多做一步
IPython 的解决方案
如果你用过 ipython,你会发现如果你在一个没有安装 ipython 的虚拟环境中运行 ipython(你在外部安装了 ipython),它照样能 import 你虚拟环境中的包,但是会有一行 warning:
UserWarning: Attempting to work in a virtualenv. If you encounter problems, please install IPython inside the virtualenv.
去翻源码,能看到是在 这里 警告的。会出现什么 problems 呢,如果你 import 一些非纯 python 库,确实有可能出问题。复现的方式,就是你安装 ipython 的 python minor version 和虚拟环境的 python 不一样。
源码 也给出了答案,让你能 import 虚拟环境的库是靠把虚拟环境的 site-packages 也添加到当前 python 进程的 sys.path 中解决的
我也在我的m
中 实现了这个逻辑,来支持运行用户在项目中定义的 m 插件,相关代码如下:
if os.getenv("VIRTUAL_ENV") and (venv_python := shutil.which("python")) and not Path(venv_python).samefile(sys.executable):
from site import addsitepackages
from subprocess import run
site_packages: list[str] = eval(run([venv_python, "-c", "import site; print(repr(site.getsitepackages()))"], capture_output=True, text=True, check=True).stdout)
path.extend(site_packages)
addsitepackages(set(site_packages))
和 IPython 有一点区别,它是把 site-packages 与 VIRTUAL_ENV 的相对路径写死了,考虑了 Windows 和 unix 的可能性。但我不喜欢这样,我是通过在 subprocess 中执行site.getsitepackages()
来获得的。这样理应更鲁棒,而且我也曾经在 pdm 的源码中看到过大量类似的使用。
更好的解决方法
我不满足于实现类似 IPython 的这种 workaround。我之前在参与 embed-ai/embed 的时候就在想,其实可以在对应的虚拟环境中启动 python,然后将自己这个库加到它的虚拟环境中再启动。这样的效果应该和“在那个虚拟环境中安装这个库”是一样的(如果我们这个库和它的依赖都是纯 Python 的)。
于是我就修改了下我的代码,在这段subprocess.run
中也返回一下sys.version_info
,代表 python 的版本,检查如果和当前进程不符的话就用虚拟环境的 python executable 来重新执行自己。
但是这样其实启动了两次 subprocess,并不符合我追求极致性能的惯例。转念一想,其实既然都要用 subprocess 了,不如第一次也不用了,只要用户在虚拟环境中,就一定用虚拟环境的 Python
if os.getenv("VIRTUAL_ENV") and (venv_python := shutil.which("python")) and str(Path(venv_python)) != sys.executable:
from site import getsitepackages
from subprocess import run
s = set(getsitepackages())
exit(
run(
[
venv_python,
"-c",
f"import site,sys; sys.argv={['m', *sys.argv[1:]]}; sys.path.extend({[*s, str(Path(__file__, '../' * 3).resolve())]}); site.addsitepackages({s}); from m.cli.main import app; app()",
]
).returncode
)
源文件在这里:utils/inject.py · CNSeniorious000/m
隐患
这样的问题在于,执行你的库的环境可能不是安装你的库的环境。这意味着如果你这个库有非纯 Python 的依赖,就很可能会报错(除非恰好用户的虚拟环境中也装了你的这个依赖)。