目前正在努力为 Python 建立一个新的通用锁文件标准,其中大部分是在 Python 讨论论坛上进行的。这一举措凸显了创建一个令所有人满意的标准的难度。很明显,不同的 Python 打包工具对于锁文件的外观甚至用途有稍微不同的想法。
然而,在这些讨论中,另一个小方面也重新出现:Python 存在元数据问题。 Python 的元数据系统过于复杂,并且存在我所说的“缺乏约束”的问题。
JavaScript:有用的约束示例
JavaScript 提供了一个很好的例子来说明约束如何简化和改进系统。在 JavaScript 中,元数据很简单。无论您是在本地针对包进行开发还是使用 npm 中的包,元数据都以相同的方式表示自身。有一个package.json文件,其中包含包最重要的元数据,例如名称、版本或依赖项。这种简单性带来了重要但有益的限制:
- npm 包与其元数据之间存在 1:1 的关系。每个 npm 包都有一个package.json文件,它是元数据的真实来源。元数据可以通过require('packageName/package.json')轻松访问,甚至可以通过编程方式访问。
- 依赖关系(以及所有其他元数据)在平台和架构之间是一致的。特定于平台的二进制文件通过与可选依赖项配对的过滤器机制(操作系统和CPU )进行处理。 [1]
- 所有元数据都是静态的,更新需要在分发或安装之前对package.json进行显式更改。提供了操作元数据的工具,例如npm 版本补丁,它将就地编辑文件。
这些限制有几个好处:
- 无论依赖项是在本地安装还是从远程源安装,行为都是一致的。来自 git 或 npm 的文件系统布局甚至没有差异。这使得诸如用本地开发副本替换已安装的依赖项之类的事情成为可能,而无需更改功能。
- 所有元数据都有一个单一的事实来源。您可以编辑package.json ,该元数据的任何使用者都可以监视该文件的更改。无需查阅复杂的外部信息。
- 解析器可以依靠单个 API 调用来获取版本的依赖元数据,从而提高效率。实际上,这也意味着解析器只需要访问单个 URL 即可检索依赖项的所有可能的依赖项。 [2]
- 它使审计变得更加容易,因为移动部件更少,并且只有一个规范的元数据位置。
Python:约束太少的代价
相比之下,Python 历来对元数据施加的限制非常少。例如,基于旧的setup.py的构建系统本质上允许在构建过程中执行任意代码。在某一时刻,至少有人强烈建议该构建步骤生成的版本更好地匹配上传到 PyPI 的版本。然而,在实践中,如果你对版本撒谎也没关系。您可以将源代码分发上传到 PyPI,声称它是2.0 ,但实际上会在此处安装2.0+或完全不同的版本。
发生的情况是,在将包发布到 PyPI 之前以及下载后在本地安装包时,元数据都是从头生成的。这不仅意味着元数据不必匹配,还意味着元数据可以完全不同。一个包声称它依赖于你的机器上的cool依赖,但依赖于我的机器上的uncool依赖,这是绝对可以的。或者根据月相的一天中的时间来依赖不同的套餐。
可编辑的安装和缓存尤其成问题,因为元数据在写入后几乎立即变得无效。 [3]
其中一些已得到一定程度的改进,因为新的pyproject.toml标准鼓励静态元数据。然而,构建系统完全可以通过回退到所谓的“动态元数据”来覆盖它,这是常见的做法。
实际上,这个系统给每个人带来了巨大的税收,而这一点很容易被忽视。
-
元数据访问脱节且复杂: PyPI 包名称和已安装的 Python 模块没有明确的关系。如果您知道 PyPI 包名称是什么,则可以通过importlib.metadata访问元数据。元数据不是从pyproject.toml读取的,即使它是静态的,而是获取包名称并从安装到site-packages中的.dist-info文件夹(最具体的是其中的METADATA文件)访问元数据。
-
强制元数据重新生成:因此,如果您编辑pyproject.toml来编辑一段元数据,则需要重新安装包才能在.dist-info中更新该元数据。人们通常会忘记这样做,因此元数据不同步很常见。即使对于今天的静态元数据也是如此!
-
不明确的缓存失效:由于元数据可以是动态的,因此不清楚何时应该自动重新安装软件包。使用动态元数据时,仅跟踪pyproject.toml 的更改是不够的。例如, uv有一个非常复杂、明确的缓存管理系统,因此可以帮助 uv 检测过时的元数据。这显然是非标准化的,需要 uv 理解版本控制系统,并且也不与其他工具共享。例如,如果您知道版本信息包含 git 哈希,您可以告诉 uv 注意 git 提交。
-
元数据存储碎片:即使存储生成的元数据也很复杂。不同的系统存储元数据的行为略有不同。
- 在本地工作时(例如:可编辑安装),发生的情况取决于构建系统:
- 如果使用setuptools ,元数据会写入两个位置。旧版<PACKAGE_NAME>.egg-info/PKG-INFO文件。此外,它还放置在<PACKAGE_NAME>.dist-info/METADATA文件中站点包内元数据的新位置。
- 如果使用hatch和大多数其他现代构建系统,则元数据仅写入site-packages中。 (进入<PACKAGE_NAME>.dist-info/METADATA )
- 如果没有配置构建系统,它会有点依赖于安装程序。 pip 甚至可以为可编辑的安装使用setuptools构建一个轮子,而uv只会构建一个轮子并在运行uv build时使元数据可用。否则,元数据不可用(理论上,只要它不是动态的,就可以在pyproject.toml中找到它)。
- 对于源发行版 ( sdist ),首先构建步骤如前一节所述。然后,元数据被放入PKG-INFO文件中。它当前放置在sdist中的两个位置:根目录中的PKG-INFO和<PACKAGE_NAME>.egg-info/PKG-INFO 。然而,我相信该元数据仅用于 PyPI,在本地安装sdist时,元数据是从pyproject.toml重新生成的(或者如果使用 setuptools setup.py )。这也是元数据可以从 sdist 中的内容更改为安装后的内容的原因。
- 对于车轮,元数据专门放置在<PACKAGE_NAME>.dist-info/METADATA中。轮子具有静态元数据,因此不会发生任何构建步骤。轮子里的东西总是被使用的。
- 在本地工作时(例如:可编辑安装),发生的情况取决于构建系统:
-
动态元数据使解析器变慢:动态元数据使解析器和安装器的工作变得非常困难并减慢它们的速度。例如,如今像诗歌或 uv 这样的高级解析器有时无法安装正确的包,因为它们假设依赖元数据在 sdists 和wheels 之间是一致的。然而,PyPI 上有很多可用的 sdist,它们发布不完整的依赖元数据(无论在开发人员计算机上创建的 sdist 的构建步骤是什么,都会在 PyPI 上缓存)。
如果不正确地做到这一点,可能会导致使用所有元数据访问一个静态 URL、下载 zip 文件、创建 virtualenv、安装构建依赖项、生成整个 sdist,然后读取最终生成的元数据。执行所需的时间存在许多数量级的差异。
这也延伸到缓存。如果元数据可以不断变化,那么解析器将如何缓存它?作为解析的一部分,是否需要构建所有可能的源发行版来确定元数据?
-
认知复杂性:系统引入了巨大的认知开销,这使得用户很难理解,特别是当出现问题时。用户几乎不可能调试错误缓存的元数据,因为他们不明白发生了什么。他们的pyproject.toml显示了正确的信息,但由于某种原因它的行为不正确。大多数人不知道“egg info”或“dist info”是什么。或者为什么 sdist 的元数据位于与轮子或本地结帐不同的位置。
支持动态元数据还意味着开发人员需要继续维护复杂且令人困惑的系统。例如,有一个 hatch 插件可以动态创建自述文件,甚至需要运行任意 Python 代码才能显示文档。有一些插件可以自动更改版本以合并 git 版本哈希。因此,要弄清楚您实际安装的版本,仅仅查看单个文件是不够的,您可能必须依赖一个工具来告诉您发生了什么。
移动奶酪
Python 中的动态元数据面临的挑战是巨大的,但除非您正在编写解析器或打包工具,否则您不会经历那么多的痛苦。事实上,您可能非常喜欢动态元数据的强大功能。毫不奇怪,提出删除它的想法很不受欢迎。有很多工作流程似乎依赖于它。
目前解决这个问题可能真的很困难,因为它是一个社会问题,而不是一个技术问题。如果一开始就将约束放在那里,这些奇怪的用例就永远不会出现。但由于不存在限制,人们可以自由进城,利用它带来的所有后果。
我认为在这一点上值得移动奶酪,但尚不清楚这是否可以通过标准来完成。也许解决方案是使用像uv或诗歌这样的工具来警告是否使用动态元数据并强烈阻止它。然后,随着时间的推移,使用动态元数据的包的用户将开始敦促包作者停止使用它。
动态元数据的成本是真实存在的,但始终只能在很小的方面感受到。当你的解析器比它必须的慢时你会注意到它,如果你的打包工具安装了错误的依赖项你会注意到它,如果你需要第一次阅读手册当你需要重新配置你的缓存键时你会注意到它或者强制不断重新安装软件包,如果您需要一遍又一遍地重新安装本地依赖项以免损坏它们,您会注意到这一点。您可以通过多种方式注意到它。你不会注意到它是一个障碍,只是一个很小很小的税收。除此之外,这是我们所有人都要缴纳的税,它使用户体验比实际情况要差得多。
这里更深刻的教训是,如果你给开发人员太多的灵活性,他们将不可避免地突破界限,并且正如我们所看到的,这可能会产生重大的缺点。由于Python的打包生态系统从一开始就缺乏约束,现在施加这些约束已成为一项艰巨的挑战。与此同时,其他生态系统,比如 JavaScript,很早就采取了更加结构化的方法,完全避免了许多这些陷阱。
[1] | 例如,您可以看到它在sentry-cli中是如何工作的。 @sentry/cli包将其所有特定于平台的依赖项声明为可选依赖项(相关的package.json )。每个平台构建的package.json中都有一个针对os和cpu 的过滤器。例如,arm64 linux 二进制依赖项如下所示: package.json 。 npm 将尝试安装所有可选依赖项,但会跳过与当前平台不兼容的依赖项。 |
[2] | 例如,对于版本 2.39.0 的@sentry/cli,这意味着这个单一 URL 将返回解析器所需的所有信息: registry.npmjs.org/@sentry/cli/2.39.0 |
[3] | 过去的一个常见错误是尝试在本地开发中运行脚本时收到pkg_resources.DistributionNotFound异常 |
原文: http://lucumr.pocoo.org/2024/11/26/python-packaging-metadata