依存関係グループ

この仕様では、ビルド時にプロジェクトメタデータに含まれなかったパッケージ要求事項を pyproject.toml 内に保存するための機構であるところの、依存関係グループ <dependency groups> を定義しています。

依存関係グループは、関連するスクリプトの集合のような配布物作成のためにビルドされたのではないプロジェクト向けと同様に、静的解析ツール (lint) や試験用ツールのような内部開発でのユースケース向けに適切です。

基本的に、依存関係グループは、(pip に特有の) requirements.txt ファイルできることを標準化したサブセットであるものと考えるべきです。

仕様

これは、 docs グループと test グループを示す単純なテーブルです:

[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] キーは、ある時には "group names" とも呼ばれますが、 正当な非標準化名称 でなければなりません。依存関係グループ <Dependency Groups> を取り扱うツール類は、比較の前にこのような名称を 標準化 しなければなりません。

ツール類は、オリジナルの非標準化名称をユーザに表示することを選好するべきで、標準化後に名称の重複が検出された場合にはツール類はエラーを発するべきです。

要求事項リスト、つまり [dependency-groups] 内の値は、文字列やテーブル (Python の dict) やその混合物を含んでいても構いません。文字列は正当な 依存関係指定子 でなければならず、テーブルは正当な Dependency Group Includes でなければなりません。

Dependency Group Include

Dependency Group Include は、現在のグループに他の依存関係グループをインクルードするものです。

include は正確に一つだけのキーを持つテーブルで "include-group" といい、その値は他の依存関係グループの名称文字列です。

Include は指定された依存関係グループの内容と厳密に同等になるように定義されていて、それが現在のグループの include の位置に挿入されます。例えば、 foo = ["a", "b"] がひとつのグループで bar = ["c", {include-group = "foo"}, "d"] がもうひとつのグループであるなら、依存関係グループが転換された時には、 bar["c", "a", "b", "d"] と評価されるべきです。

依存関係グループ引用では、同じパッケージを複数回にわたって指定しても構いません。ツールは重複排除を行うべきではなく、そうでなければ引用 <include> によって生成されたリストの内容を改変してしまうことになります。例えば、次のテーブルがあるとして:

[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"},
]

all を解決して得られた値は、 ["foo", "foo", "foo>1.0", "foo<1.0"] であるはずです。ツールは、このようなリストを、同じ要求事項に異なるバージョン制約が課されたものを、複数回にわたって処理するように依頼されているような他のどんなケースでも正確に同じように取り扱うべきです。

依存関係グループ引用 <Dependency Group Include> は、依存関係グループ引用を含むグループを引用 <include> しても構いませんし、そのような引用されグループも同様に展開されるべきです。依存関係グループ引用はループを引用してはならず、ツールはループを検出した時にはエラーを報告するべきです。

パッケージビルディング

ビルドバックエンドは、ビルドした配布物の中の依存関係グループのデータを、パッケージのメタデータとして含めてはなりません。これが意味するところは、 sdist の PKG-INFO や wheel の METADATA ファイルが依存関係グループを含む参照可能なフィールドを含んでいてはならないということです。

しかしながら、動的なメタデータの評価で依存関係グループを使うことは正当なことであり、 sdist に含まれる pyproject.toml ファイルは依然として [dynamic-groups] を含んでいることでしょう。しかしながら、このテーブルの内容は、ビルドされたパッケージのインタフェースの一部分ではありません。

依存関係グループ <Dependency Groups> と追加物 <Extras> をインストールする

依存関係グループをインストールしたり参照したりするインタフェースに関する文法も定義済みの仕様も存在しません。ツール類には、この目的のために専用のインタフェースを提供することが期待されています。

ツール類は、依存関係グループとの相互作用を行うためのインタフェースとして、追加物 <extras> を管理する時と同一またはよく似たものを提供することを選択しても構いません。ツール類の作者には、依存関係グループと名前が一致する追加物を持つことを仕様は禁じていない、と助言しておきます。これとは別に、ユーザには、追加物の名前と一致する依存関係グループを作成しないように、そのような一致があるとツール類がエラーとして取り扱うかもしれませんよ、と助言しておきます。

正当性検証と互換性

依存関係グループをサポートするツール類は、データを使う前に正当性を検証したいと望むかもしれません。そのような正当性検証を実装する時には、作者は、不要なエラーや警告を発することのないように、この仕様に対する将来の拡張の可能性を意識しておくべきです。

ツール類は、依存関係グループ内で認識できないデータを評価または処理しようとした時にはエラーを発するべきです。

ツール類は、そうする必要がない限り、 すべての 依存関係グループの内容の正当性評価を好んで行うべきではありません。

これが意味するところは、以下のデータがあるとすると、ほとんどのツールは foo グループが使われることを許すべきで、 bar グループが使われた時にのみエラーを発生させるべきです:

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

注釈

ツールがより厳密に取り扱う良い理由のあるケースがいくつか知られています。静的解析ツール <Linters> や正当性確認ツール <validaters> はひとつの例で、その目的がすべての依存関係グループの内容の正当性を検証することだからです。

参照実装

次に示す参照実装は依存関係グループの内容を改行で区切って標準出力に書き出します。その出力は、従って、正当な requiurements.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 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 を通じてこの仕様が承認されました。