[博客翻译]多版本Python思考


原文地址:https://lucumr.pocoo.org/2024/9/9/multiversion-python/


现在 uv 正在迅速发展,我开始重新研究如何让多版本导入在 Python 中工作。这里的目标是使 uv 中的解析器能够支持多个版本,以便可以同时安装和使用两个不兼容的库版本。

简化地说,应该能够让一个库同时依赖于 pydantic 的 1.x 和 2.x 版本。

虽然我还没有做到这一点,但我认为已经找到了所有阻碍的因素。本文主要目的在于分享如何通过尽可能少的修改 Python 来实现这一点。

基本操作

Python 的导入系统会在模块缓存中放置模块。此缓存可以通过 sys.modules 访问。每个导入的模块都在初始化之前放入该容器中。键是模块的导入路径。这在某种程度上带来了第一个问题。

假设你有两个 Python 发行版,它们提供了相同的顶级包。在这种情况下,它们会在 sys.modules 中冲突。由于实际上分发名称与 sys.modules 中的条目并没有直接关系,这是一个不仅仅是多版本导入中存在的问题,但发生的并不频繁。

因此我们有两个发行版:foo@1.0.0foo@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.0foo@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 的历史情况:

  1. 导入钩子不知道(总是)是什么模块触发了导入。
  2. 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_GetItemString (_dict, "THECOLORS");
         /* TODO */
     }
     else
     {
         MODINIT_ERROR;
     }

     /* snip */
 }

这说得通。一个足够大的 Python 包将在 C 编写的部分和 Python 代码之间存在相互依赖。这也使得 C 模块初始化的过程变得复杂,因为它不具有自然的模块作用域。C 扩展初始化该模块的方式是使用 PyModule_Create API:

static struct PyModuleDef module_def = {
    PyModuleDef_HEAD_INIT,
    "foo", /* name of module */
    NULL,
    -1,
    SpamMethods
};

PyMODINIT_FUNC
PyInit_foo(void)
{
    return PyModule_Create(&module_def);
}

因此,创建的模块名称以及被导入的名称都是硬编码的。C 扩展并不“知道”预期的名称是什么,必须知道这一点。

在某种程度上,这是 Python 世界和 C 世界的分歧。Python 有相对导入(如 from .foo import bar)。这是通过检查全局变量来实现的。然而,在 C 层面没有 API 实现这些相对导入。

目前我知道的唯一解决方法是进行堆栈跟踪。这样一来,就可以尝试隔离触发导入的共享库以确定其来源哪个模块。另一种可能是,将当前活动的 C 扩展模块携带在解释器状态上,但这可能会相当昂贵。

目的是找出触发导入的 .so/.dylib 文件。堆栈跟踪是一个相当耗时的操作,并且非常脆弱,但可能别无他法。理想的解决办法是,Python 在任何时刻都能知道当前活跃的 C 扩展模块。

从模块到发行版

假设我们已经理清了调用的 Python 模块:我们现在需要弄清楚相关的 PyPI 发行名字。不幸的是,这样的映射根本不存在。理想的情况是,在创建一个 sys.modules 条目时,我们记录一个特殊的属性(例如 __distribution__),其中包含 PyPI 发行版的名称,以便我们可以调用 importlib.metadata.distribution(__distribution__).requires 获取要求,或者我们有一些其他 API 来映射它。

在没有这种情况的情况下,我们该如何获得它呢?有一种耗费较高的反向映射办法(importlib.metadata.packages_distributions),但不幸的是它有些局限性:

  1. 它非常慢。
  2. 有时候无法揭示包的发行版。
  3. 对于某些包它可以揭示多个发行版本。

特别是因为命名空间包的存在,它可能会返回提供 foo 包的多个发行版(例如 foo-bar 提供 foo.bar 而 foo-baz 提供 foo.baz。在这种情况下,它只会返回 foo-bar 和 foo-baz 来代表 foo)。

这里的解决方法可能是像 uv 这样的安装程序开始以某种方式在模块上实现发行版名称。

整合一切

The end to end solution might be this:

  1. 在 site-packages 之外安装多版本包
  2. 在模块上实现一个 distribution 字段 或 提供一个将导入名称映射到其 PyPI 分发名称的 API,以便可以发现元数据(依赖项)
  3. 修改 import 以根据谁导入它来解析包到其完全限定的多版本名称
    • 通过 globals() 对于 Python 代码
    • 通过堆栈跟踪对于 C 扩展(除非找到更好的选项)
  4. 在 sys.modules 中注册具有动态 getattr 的代理条目,如果需要,该属性会重定向到完全限定的名称。这将允许某人访问 sys.modules['foo'] 并自动将其代理到 foo@1.0.0 或 foo@2.0.0。

不幸的是,这种方法有很多不足之处。部分原因是人们在 sys.modules 中进行修补。有趣的是,sys.modules 可以被操纵,但不能被替换。这可能使在未来版本的 Python 中用一些更具魔力的字典替换该字典成为可能。