使用 --prefix 和 -e pip 安装本地包时的奇怪之处。 importlib.metadata 未正确遵循 .egg-link 到 .egg-info 的潜在错误

oddities when pip installing a local package with --prefix and -e. Potential bug with importlib.metadata not following .egg-link to .egg-info properly

Update 从各方面看来 python 3.8-3.10 没有遵循 .egg-link 或 easy-install.pth 存根正确获取 .egg-info 元数据。不知道为什么。尝试使用 brew 安装 python 3.10.1 并且 importlib.metadata 在 .egg-link 或 easy-install.pth 文件之后正确查找 .egg-info 元数据也有问题,尽管 .egg-link 和 easy-install.pth 在 $PYTHONPATH

背景: 我们工作的 CentOS 8 服务器安装了 python 3.6.8(使用 pip 9.0.3) .在工作时 一个项目,我们使用模块实用程序加载特定版本的程序,包括 python 3.8.3 (使用 pip 20.2.2)。在项目目录下是它自己的 bin/lib/ 等。这允许我们将项目特定的 python 包安装到这些项目目录。其中有一个内部开发的包,我们使用该包在 console_scripts 入口点的帮助下管理我们的项目。这个内部开发的包在 git 的 VCS 下,可以在项目的生命周期内进行编辑。因此,在这个项目的上下文中工作时,我们希望能够编辑这个 python 包的源代码,同时将它安装在本地,以便可以使用它的控制台脚本.这只是 pip install --prefix project_dir -e pkg_src_dir

的用例

问题是,这适用于 python 3.6.8,但不适用于 python 3.8.3,这是我们实际用于项目的版本。而且我不确定它是否是 importlib.metadata 特定版本的错误,包括 python 3.8.3.

我创建了一个虚拟的 Hello World 包来尝试调试它。 mypkg.py 定义了一个打印 Hello World 的函数。 main.py 的 main() 函数调用了 mypkg 的 Hello World 打印函数。简单且此结构遵循python.org自己的打包教程。

mypkg/
├── setup.py
└── src/
   └── mypkg/
      ├── __init__.py
      ├── mypkg.py
      └── __main__.py

使用 python 3.6.8 及其 pip 9.0.3,pip install --prefix project_dir -e mypkg 的工作方式与您预期的一样。 project_dir/lib/python-3.6.8/site-packages 包含指向 mypkg/src 目录的 mypkg.egg-link 文件。在 project_dir/bin 中是 mypkg 控制台脚本。

#!/usr/bin/python3.6
# EASY-INSTALL-ENTRY-SCRIPT: 'mypkg','console_scripts','mypkg'
__requires__ = 'mypkg'
import re
import sys
from pkg_resources import load_entry_point

if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
    sys.exit(
        load_entry_point('mypkg', 'console_scripts', 'mypkg')()
    )

通过将 project_dir/lib/python-3.6/site-packages 目录添加到 $PYTHONPATH 之前,我能够 运行 这个控制台脚本 mypkg 没有问题并让它打印 Hello World。我什至可以 运行 这个带有 python 3.8.3 的控制台脚本 运行 直接用 python 的那个版本 python, python-3.8.3 ./mypkg。这是因为,正如我后来发现的那样,因为它使用的是 pkg_resources 中较旧的 load_entry_point 函数,而不是 importlib.metadata.

中的较新版本

但是,如果我尝试以完全相同的方式使用 python 3.8.3 安装相同的软件包,控制台脚本将无法 运行。这是在将 $PYTHONPATH 更新为 project_dir/lib/python-3.8/site-packages 之后。

Traceback (most recent call last):
  File "./mypkg", line 33, in <module>
    sys.exit(load_entry_point('mypkg', 'console_scripts', 'mypkg')())
  File "./mypkg", line 22, in importlib_load_entry_point
    for entry_point in distribution(dist_name).entry_points
  File "/tools/conda/anaconda3/2020.07/lib/python3.8/importlib/metadata.py", line 504, in distribution
    return Distribution.from_name(distribution_name)
  File "/tools/conda/anaconda3/2020.07/lib/python3.8/importlib/metadata.py", line 177, in from_name
    raise PackageNotFoundError(name)
importlib.metadata.PackageNotFoundError: mypkg

控制台脚本存根有很大不同,这些更改与使用 importlib.metadata 提供 load_entry_point 功能有关。

#!/tools/conda/anaconda3/2020.07/bin/python
# EASY-INSTALL-ENTRY-SCRIPT: 'mypkg','console_scripts','mypkg'
import re
import sys

# for compatibility with easy_install; see #2198
__requires__ = 'mypkg'

try:
    from importlib.metadata import distribution
except ImportError:
    try:
        from importlib_metadata import distribution
    except ImportError:
        from pkg_resources import load_entry_point


def importlib_load_entry_point(spec, group, name):
    dist_name, _, _ = spec.partition('==')
    matches = (
        entry_point
        for entry_point in distribution(dist_name).entry_points
        if entry_point.group == group and entry_point.name == name
    )
    return next(matches).load()


globals().setdefault('load_entry_point', importlib_load_entry_point)


if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
    sys.exit(load_entry_point('mypkg', 'console_scripts', 'mypkg')())

有趣的是,当 运行 直接使用 python 3.6.8 二进制文件时,此控制台脚本可以正常工作。 编辑:这是有道理的,因为它回退到加载旧的 pkg_resources 版本的 load_entry_point,因为围绕导入的所有尝试 尽管有两个安装在 sys.path 搜索中共享相同的本地路径(即 project_dir/lib/python-3.8/site-packages)。只有他们的 system/installation 特定路径不同,其中本地 mypkg 应该找不到。

我还发现,如果我将 python 3.6.8 控制台脚本中的 from pkg_resources import load_entry_point 行添加到 python 3.8.3 控制台脚本,当 运行 使用 python 3.8.3 时,我不再收到错误。 编辑:这又是完全有道理的,因为问题的根源与 importlib.metadata

有关

这是我的 setup.py 的完整披露。我不确定是否可以添加一些东西来解决这个问题,以便 python 3.8.3 可以 运行 --prefix --editable pip 安装包。

import setuptools

setuptools.setup(
    name="mypkg",
    version="0.1.0",
    entry_points = {
        'console_scripts': ['mypkg=mypkg.__main__:main']
    },
    package_dir={"": "src"},
    packages=setuptools.find_packages(where="src"),
    python_requires=">=3.6",
)

UPDATE 因此,在进一步研究之后,很明显是带有 python 3.8.3 的 importlib.metadata 模块导致了此问题问题。它适用于较旧的 from pkg_resources import load_entry_point,但不适用于 from importlib.metadata import distribution我发现如果我将源 mypkg 包路径添加到我的 $PYTHONPATH,我可以让它工作。大概这是因为它在那里找到了 mypkg.egg-info 目录。但是,如何在可编辑模式下 importlib.metadata 找到 mypkg 元数据而不需要添加原始源目录?

有关此问题的更多详细信息和可行的解决方案,请查看

https://github.com/python/importlib_metadata/issues/364

基本上您需要在您的 --prefix site-packages 目录中创建一个 sitecustomize.py 文件(您也已将其添加到您的 $PYTHONPATH 中)。 sitecustomize.py 自动加载,可用于将路径附加到 sys.path

import os
import io
import sys

try:
    names = os.listdir('.')
except OSError:
    pass

names = [name for name in names if name.endswith(".pth")]
for name in sorted(names):
    try:
        f = io.TextIOWrapper(io.open_code(name), encoding="utf-8")
    except OSError:
        pass
    with f:
        for n, line in enumerate(f):
            if line.startswith("#"):
                continue
            if line.strip() == "":
                continue
            try:
                if line.startswith(("import ", "import\t")):
                    exec(line)
                    continue
                line = line.rstrip()
                if os.path.exists(line):
                    sys.path.append(line)
            except Exception:
                print("Error processing line {:d} of {}:\n".format(n+1, name),
                      file=sys.stderr)
                import traceback
                for record in traceback.format_exception(*sys.exc_info()):
                    for line in record.splitlines():
                        print('  '+line, file=sys.stderr)
                print("\nRemainder of file ignored", file=sys.stderr)
                break