依赖组

本规范定义了依赖组,这是一种在 pyproject.toml 文件中存储包需求,但构建时不会将其包含在项目元数据中的机制。

依赖组适用于内部开发用例,如 linting 和测试,也适用于不用于分发的项目,如相关脚本的集合。

从根本上说,依赖组应被视为 requirements.txt 文件(pip 专用)功能的标准化子集。

规范

示例

这是一个简单的表格,显示了 docstest

[dependency-groups]
docs = ["sphinx"]
test = ["pytest>7", "coverage"]

以及一个类似的表格,定义了 docstestcoverage

[dependency-groups]
docs = ["sphinx"]
coverage = ["coverage[toml]"]
test = ["pytest>7", {include-group = "coverage"}]

[dependency-groups]

依赖组在 pyproject.toml 中定义为一个名为 dependency-groups 的表。 dependency-groups 表包含任意数量的用户定义键,每个键的值都是一个需求列表。

[dependency-groups] 键,有时也称为“组名”,必须是有效的非规范化名称。处理依赖组的工具必须在比较之前规范化这些名称。

工具应优先向用户呈现原始的、非规范化名称,如果规范化后检测到重复名称,工具应发出错误。

需求列表,即 [dependency-groups] 中的值,可能包含字符串、表(Python 中的 dict)或字符串和表的混合。字符串必须是有效的依赖指定符,表必须是有效的依赖组包含。

依赖组包含

依赖组包含将另一个依赖组包含在当前组中。

包含是一个只有一个键 "include-group" 的表,其值是一个字符串,即另一个依赖组的名称。

包含定义为与命名依赖组的内容完全等价,并插入到当前组中包含的位置。例如,如果 foo = ["a", "b"] 是一个组,bar = ["c", {include-group = "foo"}, "d"] 是另一个组,那么当依赖组包含被展开时,bar 应评估为 ["c", "a", "b", "d"]

依赖组包含可以多次指定同一个包。工具不应去重或以其他方式更改包含生成的列表内容。例如,给定下表

[dependency-groups]
group-a = ["foo"]
group-b = ["foo>1.0"]
group-c = ["foo<1.0"]
all = [
    "foo",
    {include-group = "group-a"},
    {include-group = "group-b"},
    {include-group = "group-c"},
]

的解析值应为 ["foo", "foo", "foo>1.0", "foo<1.0"]。工具应像处理要求多次处理具有不同版本约束的相同需求的其他情况一样处理此类列表。

依赖组包含可以包含包含依赖组包含的组,在这种情况下,这些包含也应展开。依赖组包含不得包含循环,如果工具检测到循环,则应报告错误。

包构建

构建后端不得在构建分发中将依赖组数据包含为包元数据。这意味着 sdist PKG-INFO 和 wheel METADATA 文件不应包含包含依赖组的可引用字段。

然而,在动态元数据的评估中使用依赖组是有效的,并且 sdist 中包含的 pyproject.toml 文件仍将包含 [dependency-groups]。但是,该表的内容不属于构建包的接口。

安装依赖组和 extras

没有用于安装或引用依赖组的语法或规范定义接口。工具应为此目的提供专用接口。

工具可以选择提供与管理 extras 相同或相似的接口来与依赖组交互。建议工具作者,规范不禁止存在名称与依赖组匹配的 extra。此外,建议用户避免创建名称与 extras 匹配的依赖组,工具可以将此类匹配视为错误。

验证和兼容性

支持依赖组的工具可能希望在使用数据之前对其进行验证。在实现此类验证时,作者应注意未来规范扩展的可能性,以免不必要地发出错误或警告。

工具在评估或处理依赖组中无法识别的数据时应报错。

工具不应急于验证*所有*依赖组的内容,除非有必要这样做。

这意味着在以下数据存在的情况下,大多数工具应允许使用 foo 组,并且仅在使用 bar 组时报错

[dependency-groups]
foo = ["pyparsing"]
bar = [{set-phasers-to = "stun"}]

注意

已知有几种工具在严格性方面有充分理由。Linters 和验证器就是一个例子,因为它们的目的是验证所有依赖组的内容。

参考实现

以下参考实现将依赖组的内容打印到标准输出,以换行符分隔。因此,输出是有效的 requirements.txt 数据。

import re
import sys
import tomllib
from collections import defaultdict

from packaging.requirements import Requirement


def _normalize_name(name: str) -> str:
    return re.sub(r"[-_.]+", "-", name).lower()


def _normalize_group_names(dependency_groups: dict) -> dict:
    original_names = defaultdict(list)
    normalized_groups = {}

    for group_name, value in dependency_groups.items():
        normed_group_name = _normalize_name(group_name)
        original_names[normed_group_name].append(group_name)
        normalized_groups[normed_group_name] = value

    errors = []
    for normed_name, names in original_names.items():
        if len(names) > 1:
            errors.append(f"{normed_name} ({', '.join(names)})")
    if errors:
        raise ValueError(f"Duplicate dependency group names: {', '.join(errors)}")

    return normalized_groups


def _resolve_dependency_group(
    dependency_groups: dict, group: str, past_groups: tuple[str, ...] = ()
) -> list[str]:
    if group in past_groups:
        raise ValueError(f"Cyclic dependency group include: {group} -> {past_groups}")

    if group not in dependency_groups:
        raise LookupError(f"Dependency group '{group}' not found")

    raw_group = dependency_groups[group]
    if not isinstance(raw_group, list):
        raise ValueError(f"Dependency group '{group}' is not a list")

    realized_group = []
    for item in raw_group:
        if isinstance(item, str):
            # packaging.requirements.Requirement parsing ensures that this is a valid
            # PEP 508 Dependency Specifier
            # raises InvalidRequirement on failure
            Requirement(item)
            realized_group.append(item)
        elif isinstance(item, dict):
            if tuple(item.keys()) != ("include-group",):
                raise ValueError(f"Invalid dependency group item: {item}")

            include_group = _normalize_name(next(iter(item.values())))
            realized_group.extend(
                _resolve_dependency_group(
                    dependency_groups, include_group, past_groups + (group,)
                )
            )
        else:
            raise ValueError(f"Invalid dependency group item: {item}")

    return realized_group


def resolve(dependency_groups: dict, group: str) -> list[str]:
    if not isinstance(dependency_groups, dict):
        raise TypeError("Dependency Groups table is not a dict")
    if not isinstance(group, str):
        raise TypeError("Dependency group name is not a str")
    return _resolve_dependency_group(dependency_groups, group)


if __name__ == "__main__":
    with open("pyproject.toml", "rb") as fp:
        pyproject = tomllib.load(fp)

    dependency_groups_raw = pyproject["dependency-groups"]
    dependency_groups = _normalize_group_names(dependency_groups_raw)
    print("\n".join(resolve(pyproject["dependency-groups"], sys.argv[1])))

历史

  • 2024 年 10 月:本规范已通过 PEP 735 批准。