现在 uv 正在迅速发展,我开始重新研究如何让多版本导入在 Python 中工作。这里的目标是使 uv 中的解析器能够支持多个版本,以便可以同时安装和使用两个不兼容的库版本。
简化地说,应该能够让一个库同时依赖于 pydantic 的 1.x 和 2.x 版本。
虽然我还没有做到这一点,但我认为已经找到了所有阻碍的因素。本文主要目的在于分享如何通过尽可能少的修改 Python 来实现这一点。
基本操作
Python 的导入系统会在模块缓存中放置模块。此缓存可以通过 sys.modules
访问。每个导入的模块都在初始化之前放入该容器中。键是模块的导入路径。这在某种程度上带来了第一个问题。
假设你有两个 Python 发行版,它们提供了相同的顶级包。在这种情况下,它们会在 sys.modules
中冲突。由于实际上分发名称与 sys.modules
中的条目并没有直接关系,这是一个不仅仅是多版本导入中存在的问题,但发生的并不频繁。
因此我们有两个发行版:foo@1.0.0
和 foo@2.0.0
。两者都暴露了一个名为 foo
的顶级模块,这是一个带有单个 __init__.py
文件的真实 Python 包。安装程序会因为一个完全覆盖另一个而无法放置这些文件。
所以第一步是在不同位置放置这些模块。通常情况下它们位于 site-packages
目录下,在这种情况下我们可能不希望将这些包放在那里。这解决了文件系统的冲突。
所以我们可能会将它们放在一个额外的缓存中,看起来像这样:
.venv/
multi-version-packages/
foo@1.0.0/
foo/
__init__.py
foo@2.0.0/
foo/
__init__.py
现在这个包完全不可导入了,因为没有任何组件去查看 multi-version-packages
。我们需要一个自定义的导入钩子来使它们被导入。这个导入钩子还需要更改 sys.modules
中存储的内容的名字。
因此,而不是将 foo
注册为 sys.modules['foo']
,我们可能需要尝试将其注册为 sys.modules['foo@1.0.0']
和 sys.modules['foo@2.0.0']
。但这有一个问题,即这个非常常见的模式:
import sys
def import_module(name):
__import__(name)
return sys.modules[name]
这会带来一些问题,因为很可能会有人这样调用:import_module('foo')
,那么我们现在无法在 sys.modules
中找到该条目。
这意味着除了新的条目之外,我们还需要注册一些代理,以“重定向”到实际的名称。然而,这些代理需要知道它们指向的是 1.0.0
还是 2.0.0
。
元数据
让我们首先解决这个问题。我们如何知道需要 1.0.0
还是 2.0.0
?答案可能是包的依赖项。与其让一个包同时依赖同一个依赖的不同版本,我们可以从一个更简单的问题开始,即每个包只能依赖一个版本。这意味着如果我有一个 myapp
包,它必须在 foo@1.0.0
或 foo@2.0.0
之间选择。但是如果它依赖另一个包(例如 slow-package
),那么后者可以依赖一个与 myapp
不同的 foo
版本:
myapp v0.1.0
├── foo v2.0.0
└── slow-package v0.1.0
└── foo v1.0.0
在这种情况下,当有人试图导入 foo
时,我们将通过调用者包的元数据来确定哪个版本被尝试导入。
目前存在两个挑战,这来自于 Python 的历史情况:
- 导入钩子不知道(总是)是什么模块触发了导入。
- Python 模块不了解自己的发行包。
让我们详细看看这些问题。
导入上下文
目标是当 slow_package/__init__.py
导入 foo
时,我们得到的是 foo@1.0.0
版本;当 myapp/__init__.py
导入 foo
时,我们得到 foo@2.0.0
版本。要使这工作,导入系统不仅仅需要理解导入什么,还要理解谁在导入。在某种意义上,Python 是有这个功能的。这是因为在 __import__
(它是导入机制的入口点)获取到模块的全局变量。这里的导入语句大致映射为:
from foo import bar
# 内部实现
_rv = __import__('foo', globals(), locals(), ['bar'])
bar = _rv.bar
导入该包的名称可以通过检查全局变量来获得。理论上,导入系统可以利用这些信息。globals()['__name__']
将告诉我们是 slow_package
还是 myapp
。但是有一个问题是,导入名称不是分发名称。PyPI 包可以称为 mycompany-myapp
, 而它导出的 Python 包仅称为 myapp
。这种情况非常常见。例如,在 PyPI 上安装 Scikit-learn
,但实际上安装的 Python 包是 sklearn
。
还有一个问题是解释器内部组件和 C/Rust 扩展。我们已经确定 Python 包导入时会传递全局变量和局部变量。但是 C 扩展做了什么呢?最常见的内部导入 API 称为 PyImport_ImportModule
并且只接收模块名称。是否存在问题?C 扩展是否甚至导入内容呢?答案是肯定的。以下是 pygame
的一个例子:
MODINIT_DEFINE (color)
{
PyObject *colordict;
colordict = PyImport_ImportModule ("pygame.colordict");
if (colordict)
{
PyObject *_dict = PyModule_GetDict (colordict);
PyObject *colors = PyDict_GetItemStri