Frost's Blog
3151 字
16 分钟
PDM 的内部实现(1)
2024-03-11

为了解答一些高频出现的问题和方便未来的贡献者,我计划从这篇文章开始,写一系列关于 PDM 内部实现的文章。 这篇文章将会介绍 PDM 的 lockfile,基于当前最新版本 2.12。英文版由 LLM 辅助翻译.

Lockfile 是什么?#

Lockfile1 是一个用来记录项目依赖的文件,它记录了项目的依赖和依赖的版本号。这个文件在包管理器中比较常见,比如 yarn.lock, Cargo.lock, go.sum 等。 在 Python 的生态中,Pipenv 和 Poetry 也有自己的 Lockfile。PDM 同样是一个有 Lockfile 的包管理器,这区分于其他不带 Lockfile 的包管理器,比如 pip。有了 Lockfile 会有一些行为上的改变。 很多人都会在 PDM 的 Issue 里问为什么 pip 能安装 PDM 不能,希望这篇文章能解答这个问题。

虽然如此,我们似乎需要先界定一下 Lockfile 的作用。这似乎不是一件很容易的事,至少在 PDM 中,Lockfile 是用来限定所有安装过程中可能会安装的包的版本,以及它的来源、checksum 等,目的是提供可复现的 Python 环境。 你可以通过运行 pdm lock 来产生一个 Lockfile,PDM也会在你运行 pdm install 时确保 Lockfile 存在与有效,并在必要时候生成它。 最近新的一轮 Lockfile 提案讨论正在进行中,讨论比较长,有余力可以看看大家对 Lockfile 有什么不同的理解和期待。

Lockfile 是如何生成的?#

跨版本 lock 与当前环境 lock#

最初 Python 的包管理器都是不带 Lockfile 的,但依赖解析仍然是一个必要的过程。那么当 pip 安装一个包 foo 时会发生什么呢?

  1. 访问 https://pypi.org/simple/foo/ 获取 foo 的所有版本
  2. 从满足条件的最新版本开始,逐个检查每个包文件是否满足当前环境和 Python 版本。如果满足,就选择这个版本进行下一步。
  3. 从这个文件取得它的依赖列表,依赖也可以有环境要求,所以也要逐个检查每个依赖是否满足当前环境和 Python 版本,如果满足,就记录这个依赖。
  4. 对记录好的且没有找到对应包文件的依赖,重复步骤 1。
  5. 如果没有找到一个符合的版本,就退回步骤 2,选择下一个符合要求的文件。

可以发现我加粗了当前环境和 Python 版本,是的,解析器在检查是否满足条件时都是考虑当前环境和 Python 版本。这就是一种只针对当前环境的 lock。在写作这篇文章时,除了 Poetry 和 PDM 的 Python 包管理器,都是这种依赖解析方式。

这对于那些不生成的 Lockfile 的包管理器来说,每次安装依赖都是现场解析,只需要考虑当前环境,完全没必要考虑其他。但如果包管理器生成了 Lockfile,既然它的目的就是复现环境,那么就有可能会在不同的 Python 版本或操作系统上执行安装。 那么就需要一种跨版本的 Lockfile,你既可以为每个目标环境都生成一份,但 PDM 选择的是把所有包版本,以及它的环境信息都记录在一个 Lockfile 里。

requires-python#

requires-pythonPEP 621 中定义的一个元数据字段,写在 [project] 表中,但其实相似的概念在更早的时候就引入了,setuptools.setup() 就有 python_requires 这个参数,作用是一样的,都是限制这个包能在哪些 Python 环境上安装。在 Python 3 的破坏性更新之后,理论上所有的包或者 Python 项目都应该有这个字段,表明它支持的 Python 版本范围。

这个字段在 PDM 的依赖解析中起到了非常关键的作用,为了说明它的机制,我们来看一个例子:

[project]
name = "foo"
requires-python = ">=3.8"

现在运行 pdm add numpy

输出
Adding packages to default dependencies: numpy
${SITE_PACKAGES}/pdm/resolver/providers.py:196: PackageWarning: Skipping [email protected] because it requires Python>=3.9 but the
project claims to work with Python>=3.8. Instead, another version of numpy that supports Python>=3.8 will be used.
If you want to install [email protected], narrow down the `requires-python` range to include this version. For example, ">=3.9" should work.
  return self.repository.find_candidates(
${SITE_PACKAGES}/pdm/resolver/providers.py:196: PackageWarning: Skipping [email protected] because it requires Python>=3.9 but the
project claims to work with Python>=3.8. Instead, another version of numpy that supports Python>=3.8 will be used.
If you want to install [email protected], narrow down the `requires-python` range to include this version. For example, ">=3.9" should work.
  return self.repository.find_candidates(
${SITE_PACKAGES}/pdm/resolver/providers.py:196: PackageWarning: Skipping [email protected] because it requires Python>=3.9 but the
project claims to work with Python>=3.8. Instead, another version of numpy that supports Python>=3.8 will be used.
If you want to install [email protected], narrow down the `requires-python` range to include this version. For example, ">=3.9" should work.
  return self.repository.find_candidates(
${SITE_PACKAGES}/pdm/resolver/providers.py:196: PackageWarning: Skipping [email protected] because it requires Python<3.13,>=3.9
but the project claims to work with Python>=3.8. Instead, another version of numpy that supports Python>=3.8 will be used.
If you want to install [email protected], narrow down the `requires-python` range to include this version. For example, "<3.13,>=3.9" should work.
  return self.repository.find_candidates(
${SITE_PACKAGES}/pdm/resolver/providers.py:196: PackageWarning: Skipping [email protected] because it requires Python<3.13,>=3.9
but the project claims to work with Python>=3.8. Instead, another version of numpy that supports Python>=3.8 will be used.
If you want to install [email protected], narrow down the `requires-python` range to include this version. For example, "<3.13,>=3.9" should work.
  return self.repository.find_candidates(
${SITE_PACKAGES}/pdm/resolver/providers.py:196: PackageWarning: Skipping [email protected] because it requires Python>=3.9 but the
project claims to work with Python>=3.8. Instead, another version of numpy that supports Python>=3.8 will be used.
If you want to install [email protected], narrow down the `requires-python` range to include this version. For example, ">=3.9" should work.
  return self.repository.find_candidates(
${SITE_PACKAGES}/pdm/resolver/providers.py:196: PackageWarning: Skipping [email protected] because it requires Python>=3.9 but the
project claims to work with Python>=3.8. Instead, another version of numpy that supports Python>=3.8 will be used.
If you want to install [email protected], narrow down the `requires-python` range to include this version. For example, ">=3.9" should work.
  return self.repository.find_candidates(
${SITE_PACKAGES}/pdm/resolver/providers.py:196: PackageWarning: Skipping [email protected] because it requires Python>=3.9 but the
project claims to work with Python>=3.8. Instead, another version of numpy that supports Python>=3.8 will be used.
If you want to install [email protected], narrow down the `requires-python` range to include this version. For example, ">=3.9" should work.
  return self.repository.find_candidates(
INFO: Use `-q/--quiet` to suppress these warnings, or ignore them per-package with `ignore_package_warnings` config in [tool.pdm] table.
🔒 Lock successful
Changes are written to pyproject.toml.
Synchronizing working set with resolved packages: 1 to add, 0 to update, 0 to remove

  ✔️ Install numpy 1.24.4 success

可以看到它拒绝了一堆 numpy 的新版本2,只是因为它们支持的 Python 的版本最低只到 3.9,而你的项目指定的最低 Python 版本是 3.8。所以如果你想安装某个依赖了 numpy>=1.26 的包,PDM 会解析失败并报错。 有很多用户都问过这个问题,明明我现在使用的 Python 版本是 3.10,为何 PDM 依然拒绝这些新版本的包?

答案是, PDM 总是尝试让这个 Lockfile 能在所有你指定的 Python 版本上工作,它不会考虑你当前使用的是哪个 Python 版本

这样做是因为,如果你的项目中指定了 requires-python = ">=3.8" 并分享了出去,那么就表示你完全允许一个用户使用 Python 3.8 来安装这个包。这时所有的依赖都必须支持 Python 3.8,[email protected] 明显是不满足的。 所以在实际使用中,你项目中 requires-python 所指定的范围,必须是所有依赖的包的 requires-python 范围的子集。PDM 会为你计算出这个合适的值,显示在警告信息中,就像上面的例子一样。

Markers#

另一个与环境限制相关的概念是 Markers3,它来自于 PEP 508规范。它是一种条件表达式,用来限制包的安装条件。比如 foo>=1.0; sys_platform == "win32" 表示只有在 Windows 平台上才会安装 foo。 在当前环境 lock 中,如果解析器碰到这样的表达式,并且检测发现当前环境不满足这个条件,那么这个包就会被忽略,否则会将 foo>=1.0 记录下来。 而在跨版本 lock 中,表达式并不能被求值,所以这个表达式会被整体记录下来 foo>=1.0; sys_platform == "win32",并继续解析 foo 的依赖。等到安装包时,才会求值这个表达式,选择是否安装这个包。

pdm-lock-graph

但需注意到,条件依赖的依赖,也应该应用同样的条件,即若 foo 依赖了 bar,那 bar 也应该只在 Windows 上安装。这叫做条件的传递。甚至如果 bar 本身有一个另外的安装条件,那么这两个条件应该用与逻辑合并。另一方面,同个依赖可能上游的来源不同,于是「继承」得到的条件也不同,这些条件又应该用或逻辑合并。

marker-propagation

在 PDM 的 Lockfile 中,这些 markers 的运算结果,会被记录在每个包的 markers 字段中。这样在安装时,只需要拿到这个条件表达式,就能判断是否需要安装此包,而不用管他上游祖先具有什么条件限制。

比如这是 `rich` 的解析结果
# This file is @generated by PDM.
# It is not intended for manual editing.

[metadata]
groups = [ "default" ]
strategy = [
  "cross_platform",
  "inherit_metadata"
]
lock_version = "4.4.1"
content_hash = "sha256:37d2aae470ae5f416baf9366fdba3a83f22de379a8ab288ec6077f4ce3b0ec59"

[[package]]
name = "markdown-it-py"
version = "3.0.0"
requires_python = ">=3.8"
summary = "Python port of markdown-it. Markdown parsing, done right!"
groups = [ "default" ]
dependencies = [ "mdurl~=0.1", ]
files = [
  { file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" },
  { file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1" },
]

[[package]]
name = "mdurl"
version = "0.1.2"
requires_python = ">=3.7"
summary = "Markdown URL utilities"
groups = [ "default" ]
files = [
  { file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" },
  { file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" },
]

[[package]]
name = "pygments"
version = "2.17.2"
requires_python = ">=3.7"
summary = "Pygments is a syntax highlighting package written in Python."
groups = [ "default" ]
files = [
  { file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c" },
  { file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" },
]

[[package]]
name = "rich"
version = "13.7.1"
requires_python = ">=3.7.0"
summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
groups = [ "default" ]
dependencies = [
  "markdown-it-py>=2.2.0",
  "pygments<3.0.0,>=2.13.0",
  "typing-extensions<5.0,>=4.0.0; python_version < \"3.9\"",
]
files = [
  { file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222" },
  { file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432" },
]

[[package]]
name = "typing-extensions"
version = "4.10.0"
requires_python = ">=3.8"
summary = "Backported and Experimental Type Hints for Python 3.8+"
groups = [ "default" ]
marker = "python_version < \"3.9\""
files = [
  { file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475" },
  { file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb" },
]

rich 依赖的 typing-extensions 上附带的条件传递到了 typing-extensions 包上面。PDM 的实现是利用了我写的另一个库 dep-logic,它提供了对 markers 的逻辑运算能力。

元数据#

一个包文件的依赖列表,支持 Python 版本(requires-python),都属于这个包的元数据(metadata)。PDM 获取包的元数据有两种方式,一种是把文件下载下来,然后读取它的 METADATA/PKG_INFO 文件内容,另一钟是利用 PEP 658 中规范的 metadata 链接,单独请求元数据的内容。 但由于 PDM 的 Lockfile 是跨版本的,所以需要解析、记录的包文件一瞬间就多了很多,比如 charset-normalizer 一个版本 就包含了 90 个文件!而且不是所有包仓库(Package Index)都支持了 PEP 658,遍历这么多文件是不现实的。所以 PDM 作了一些妥协,引入了一个假设:

同一个版本的不同文件的元数据是一样的。

这不是很正确,没有任何一个规范规定如此。实际上你可以给一个包的不同文件指定完全不同的依赖,对于 sdist,元数据需要运行构建才能得到,其结果是完全不可预料的,甚至有可能这一秒得到的元数据和下一秒的不一样。但这个假设在大多数情况下是成立的,所以 PDM 选择了这个假设,以换取性能上的提升。其实并不是只有 PDM,Poetry 和 uv 也是这么做的。所以在 PDM 的 Lockfile 中,元数据是以包的版本为单位记录的,而且目前 PDM 对于每个包,只能锁定一个版本。换句话说,PDM 锁定的是版本,而不是某一个特定的文件,安装时再通过这个指定的版本,选择正确的文件下载并安装。这又需要引入另一条不那么正确的假设:

每个版本都是完整的,包含当前版本支持的所有平台对应的包文件。

这是一种折中,有时不得不为了性能牺牲一些正确性,对于那么没有覆盖到的 Corner case,PDM 大概率会解析失败。

PDM 的 Lockfile 还有一个重要的特性是支持多种不同的 Lock 策略,留待下一篇文章再介绍了,感谢阅读。

Footnotes#

  1. 中文应该翻译成「(依赖)锁文件」,但这个名字用起来觉得很别扭,所以后文还是用 Lockfile。

  2. 你可能会认为这么多警告太嘈杂了,但我担忧不加这些信息会让用户更加困惑,不知道结果为何如此。这些警告可以通过添加 --quiet 屏蔽。

  3. 全称 Environment Markers,似乎应译作「环境标记」,但同上述原因,中文名称极少使用,所以这里还是用英文。

PDM 的内部实现(1)
https://frostming.com/2024/pdm-lockfile/
作者
Frost Ming
发布于
2024-03-11
许可协议
CC BY-NC-SA 4.0