# Python venv injection (April 3, 2025)

对我来说，这是很常见的一种情况：

+ **你的一个库**，要运行一些用户的 python 的代码文件。

> 对我个人来说，我在 3 种情况中遇到了这个问题：
>
> + [m](./kya1tmyz86lsyaae) 这个库，支持注册 plugin，plugin 就是在当前目录或任何`sys.path`中的目录创建 m/commands 文件夹，在里面的文件夹暴露一个 app。这里面的代码会被暴露为一个 entry point
> + [hmr](./yfw3tf2h1mzghaz7) 这个库，会运行用户的文件，其衍生品`uvicorn-hmr`会执行用户的某个 entry point
> + [embed](https://github.com/embed-ai/embed) 这个库，会执行用户 pull 下来的 app，这个 app 可能有单独的虚拟环境
>

最简单的解决办法就是读进来`exec`对吧，当然你有点经验的话就知道这样对 traceback 不友好。比较正确的办法是用`runpy.run_path`

但是还会遇到一个问题：

+ 用户如果这个文件用到一些包，而这些包不在你**安装你这个库**的环境中，导致在**你的库**的 python 进程中会 import 失败

最好的办法当然是在用户侧解决，比如在用户的项目中安装**你这个库**，但是

1. 可能这是一个开发工具，用户不太愿意往项目中引入开发依赖
2. 终归还是得让用户多做一步

---

## IPython 的解决方案
如果你用过 ipython，你会发现如果你在一个没有安装 ipython 的虚拟环境中运行 ipython（你在外部安装了 ipython），它照样能 import 你虚拟环境中的包，但是会有一行 warning：

```plain
UserWarning: Attempting to work in a virtualenv. If you encounter problems, please install IPython inside the virtualenv.
```

去翻源码，能看到是在 [这里](https://github.com/ipython/ipython/blob/9.0.2/IPython/core/interactiveshell.py#L954-L958) 警告的。会出现什么 problems 呢，如果你 import 一些非纯 python 库，确实有可能出问题。复现的方式，就是你安装 ipython 的 python minor version 和虚拟环境的 python 不一样。

[源码](https://github.com/ipython/ipython/blob/9.0.2/IPython/core/interactiveshell.py#L959-L961) 也给出了答案，让你能 import 虚拟环境的库是靠把虚拟环境的 site-packages 也添加到当前 python 进程的 sys.path 中解决的

我也在我的`m`中 [实现了这个逻辑](https://github.com/CNSeniorious000/m/blob/647d8a46d012ef2b7eecc38a23f19054f7c39486/packages/core/m/utils/register.py#L10-L18)，来支持运行用户在项目中定义的 m 插件，相关代码如下：

```python
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](https://github.com/pdm-project/pdm) 的源码中看到过大量类似的使用。

---

## 更好的解决方法
我不满足于实现类似 IPython 的这种 workaround。我之前在参与 [embed-ai/embed](https://github.com/embed-ai/) 的时候就在想，其实可以在对应的虚拟环境中启动 python，然后将自己这个库加到它的虚拟环境中再启动。这样的效果应该和“在那个虚拟环境中安装这个库”是一样的（如果我们这个库和它的依赖都是纯 Python 的）。

于是我就修改了下我的代码，在这段`subprocess.run`中也返回一下`sys.version_info`，代表 python 的版本，检查如果和当前进程不符的话就用虚拟环境的 python executable 来重新执行自己。

但是这样其实启动了两次 subprocess，并不符合我追求极致性能的惯例。转念一想，其实既然都要用 subprocess 了，不如第一次也不用了，只要用户在虚拟环境中，就一定用虚拟环境的 Python

```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](https://github.com/CNSeniorious000/m/blob/f0fcc64e6c665b8de8004bb681f5513c4d1a2028/packages/core/m/utils/inject.py)

---

## 隐患
这样的问题在于，执行你的库的环境可能不是安装你的库的环境。这意味着如果你这个库有非纯 Python 的依赖，就很可能会报错（除非恰好用户的虚拟环境中也装了你的这个依赖）。