Published on

PDM 的内部实现(2)

Lock 策略

Reading Time

10 min read

pdm-lock

这篇文章将会介绍 PDM 的 lock 策略,基于当前最新版本 2.13。英文版由 LLM 辅助翻译。

PDM 是如何解析依赖的?

PDM 底层是用的一个纯 Python 实现的 PubGrub 解析算法Resolvelib。若用通俗的语言解释,它的解析过程大致如下:

  1. 选择一个未解析的依赖,获取它的所有版本的列表
  2. 从最新版本开始尝试,获取这个版本的依赖
  3. 检查这个版本的依赖与已解析的依赖是否有冲突
  4. 若有冲突,尝试下一个版本
  5. 若无冲突,将这个依赖的版本加入结果中
  6. 若尝试过所有版本都无法解析,回溯到上个确定结果的依赖,尝试下一个版本
  7. 最终所有依赖都解析完成,得到一个满足所有依赖的版本列表

解析完成以后,PDM 就会将结果写到 pdm.lock 文件中,这个文件除了包含所有依赖的版本信息,还包含了一些其他元数据。

条件依赖

有时我们需要根据不同的条件安装不同的包版本,这会利用 Marker,例如:

pytest >= 7.0; python_version >= "3.6"
pytest < 7.0; python_version < "3.6"

但是 PDM 现在暂时不支持解析这种条件依赖,原因是 PDM 的依赖解析器实现会把包名作为解集中的 key,换句话说,每个包在解集中有且只有一个确定的版本。不得不承认,这确实是 PDM 的一大缺陷,欢迎大家贡献代码来解决这个问题。

Lock 文件的元数据

pdm.lock 文件是一个 TOML 格式的文件,在它的 [metadata] 表中包含了一些元数据,包括:

[metadata]
groups = ["default", "all", "doc", "pytest", "test", "tox", "workflow"]
strategy = ["cross_platform", "inherit_metadata"]
lock_version = "4.4.1"
content_hash = "sha256:13270582f610302a77a5e1fef2192e1a65f5b6202cf15aedf12bf799de8de45c"

lock_version

这是一个三位的版本号,标明这个 lock 文件的兼容情况。第一位表示向后不兼容的改动,第二位表示向后兼容但向前不兼容的改动,第三位表示向前向后都兼容的改动。

比如说,假如当前 lock 文件的版本是 4.4.2,那么:

  • 支持读取的版本有:4.3.0, 4.4.0, 4.4.3
  • 不能读取的版本有:5.1.0, 3.0.0

每当更新 lock 文件时,PDM 会把当前的 lock 版本写入文件中。通过这个版本号,PDM 就可以决定是否应该尝试读取这个 lock 文件,或是提示用户重新生成 lock 文件。

groups

记录了这个 lock 文件是从哪些依赖分组生成的,列表中的每个值都对应了 pyproject.tomloptional-dependenciesdev-dependencies 的一个分组。 当依赖解析完成时,这些分组就会被记录在 lock 文件中,安装时,PDM 会检查你要求安装的分组是否包含其中。

content_hash

因为 lock 文件对应了一组初始输入,即从哪些依赖解析生成。在 PDM 中,这个输入就是 pyproject.toml 中写的依赖信息,content_hash 就是从这些内容计算出来的一个 sha256 值,当你的 pyproject.toml 发生变化,PDM 就会重新计算这个值,如果发现与 lock 文件中的值不一致,就会为你更新 lock 文件,并把新的 content_hash 写入其中。

Lock 策略

[metadata] 表中的 strategy 字段就记录了当前 lock 文件的策略,用来控制依赖解析的过程。

cross_platform

默认启用,PDM 会将所有平台的文件都写入 Lock 文件中,详见上篇文章。但有时我们不得不使用当前环境的 Lock,一大原因是某些包在不同平台发布的包有着完成不同的依赖列表,这样会使得跨平台锁产生错误的结果。在 PDM 中,如果要禁用一个 Lock 策略,只需要:

pdm lock --strategy=no_cross_platform

这条命令会取消 cross_platform 策略,已使用的其他策略不会被影响。

static_urls

默认情况下,[[package]]files 字段中只会记录包文件的文件名,而不包含 URL。这样做的好处是用户可以自由切换到其他 PyPI 的镜像源,PDM 安装时只会检查下载的文件名包含在 Lock 文件中。而如果启用了 static_urls 策略,PDM 会记录包文件的 URL,安装时就会直接通过这些 URL 下载安装包。这样也可以方便一些安全审计工具检查包的来源。

inherit_metadata

默认启用,PDM 会尝试为每个包计算它的最终 Marker(详见 上篇文章)。这样的好处是,安装时 PDM 只需要 pdm.lock 这一个数据来源,并且遍历 lock 文件和求值 Markers 这个过程使用 Python 标准库1就可以完成,不需要依赖 PDM 的其他组件。如果禁用这个策略,Marker 将不会记录在包中,这样 lock 文件中记录的信息就不足以让安装器决定是否安装这个包,安装过程会有些许变化。PDM 会通过要求安装和依赖列表,和 Lock 文件中的依赖信息,运行一次依赖解析过程,来取得最终需要安装的包版本的列表。

direct_minimal_versions

默认情况下,解析依赖时会从最新版本开始尝试,这样得到的 lock 结果通常包含了尽可能新的包版本。但有时为了测试库的兼容性,我们会希望看到它是否能在指定的最小依赖版本下工作。启用这个策略后,PDM 会尝试从最小版本开始解析依赖,得到的 lock 文件中的版本号就是最小版本的依赖。

--exclude-newer DATE

除了上述策略之外,PDM lock 还支持一个一次性选项 --exclude-newer。这个选项的作用有点类似于时光机,当指定了一个时间或日期之后,PDM 解析依赖时会跳过那些晚于这个时间点上传的包版本。使用这个选项可以让 lock 文件是可复现的。需要注意的是,包的上传时间需要 PyPI 源的支持,它必须实现了 PEP 700,否则,这个包会被认为不满足条件并会被忽略

更新策略

在你尝试更新 lock 文件中的包版本时,PDM 也提供了不同的更新策略,这些策略可以通过 --update-* 选项来指定,pdm addpdm lockpdm update 均支持这组选项。

  • --update-all:更新所有包(直接依赖+间接依赖)到最新版本,也就是完全忽略 lock 文件中的版本信息
  • --update-reuse:只更新直接依赖的版本,复用 lock 文件中的间接依赖版本
  • --update-eager:更新指定依赖及其间接依赖到最新版本,复用 lock 文件中的其他依赖版本
  • --update-reuse-installed: 尽可能复用当前已安装的版本

更新依赖版本时,仍然会尊重 pyproject.toml 中指定的版本范围,这个行为可以通过 --unconstrained 选项关闭,即取消版本范围的限制。

到此为止,我们介绍了围绕 PDM 的 lock 文件的一系列功能和背后的逻辑,希望这些信息能帮助你更好地理解 PDM 的工作原理。

Footnotes

  1. 包括 packaging 库,因为它是诸多 Python 打包的标准实现,已经几乎是标准库。

Share: