diff --git a/docs/changelog/3539.bugfix.rst b/docs/changelog/3539.bugfix.rst new file mode 100644 index 000000000..a3e092ce5 --- /dev/null +++ b/docs/changelog/3539.bugfix.rst @@ -0,0 +1 @@ +Fix dependency-group name normalization. diff --git a/src/tox/tox_env/python/dependency_groups.py b/src/tox/tox_env/python/dependency_groups.py index e56a04389..7307eef5f 100644 --- a/src/tox/tox_env/python/dependency_groups.py +++ b/src/tox/tox_env/python/dependency_groups.py @@ -1,6 +1,7 @@ from __future__ import annotations import sys +from collections import defaultdict from typing import TYPE_CHECKING, TypedDict from packaging.requirements import InvalidRequirement, Requirement @@ -26,28 +27,64 @@ def resolve(root: Path, groups: set[str]) -> set[Requirement]: return set() with pyproject_file.open("rb") as file_handler: pyproject = tomllib.load(file_handler) - dependency_groups = pyproject["dependency-groups"] - if not isinstance(dependency_groups, dict): - msg = f"dependency-groups is {type(dependency_groups).__name__} instead of table" + dependency_groups_raw = pyproject["dependency-groups"] + if not isinstance(dependency_groups_raw, dict): + msg = f"dependency-groups is {type(dependency_groups_raw).__name__} instead of table" raise Fail(msg) + original_names_lookup, dependency_groups = _normalize_group_names(dependency_groups_raw) result: set[Requirement] = set() for group in groups: - result = result.union(_resolve_dependency_group(dependency_groups, group)) + result = result.union(_resolve_dependency_group(dependency_groups, group, original_names_lookup)) return result +def _normalize_group_names( + dependency_groups: dict[str, list[str] | _IncludeGroup], +) -> tuple[dict[str, str], dict[str, list[str] | _IncludeGroup]]: + original_names = defaultdict(list) + normalized_groups = {} + + for group_name, value in dependency_groups.items(): + normed_group_name: str = canonicalize_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: + msg = f"Duplicate dependency group names: {', '.join(errors)}" + raise ValueError(msg) + + original_names_lookup = { + normed_name: original_names[0] + for normed_name, original_names in original_names.items() + if len(original_names) == 1 + } + + return original_names_lookup, normalized_groups + + def _resolve_dependency_group( - dependency_groups: dict[str, list[str] | _IncludeGroup], group: str, past_groups: tuple[str, ...] = () + dependency_groups: dict[str, list[str] | _IncludeGroup], + group: str, + original_names_lookup: dict[str, str], + past_groups: tuple[str, ...] = (), ) -> set[Requirement]: if group in past_groups: - msg = f"Cyclic dependency group include: {group!r} -> {past_groups!r}" + original_group = original_names_lookup.get(group, group) + original_past_groups = tuple(original_names_lookup.get(g, g) for g in past_groups) + msg = f"Cyclic dependency group include: {original_group!r} -> {original_past_groups!r}" raise Fail(msg) if group not in dependency_groups: - msg = f"dependency group {group!r} not found" + original_group = original_names_lookup.get(group, group) + msg = f"dependency group {original_group!r} not found" raise Fail(msg) raw_group = dependency_groups[group] if not isinstance(raw_group, list): - msg = f"dependency group {group!r} is not a list" + original_group = original_names_lookup.get(group, group) + msg = f"dependency group {original_group!r} is not a list" raise Fail(msg) result = set() @@ -63,7 +100,11 @@ def _resolve_dependency_group( raise Fail(msg) from exc elif isinstance(item, dict) and tuple(item.keys()) == ("include-group",): include_group = canonicalize_name(next(iter(item.values()))) - result = result.union(_resolve_dependency_group(dependency_groups, include_group, (*past_groups, group))) + result = result.union( + _resolve_dependency_group( + dependency_groups, include_group, original_names_lookup, (*past_groups, group) + ) + ) else: msg = f"invalid dependency group item: {item!r}" raise Fail(msg) diff --git a/tests/tox_env/python/test_python_runner.py b/tests/tox_env/python/test_python_runner.py index 2e5eea3ae..902328e98 100644 --- a/tests/tox_env/python/test_python_runner.py +++ b/tests/tox_env/python/test_python_runner.py @@ -261,8 +261,12 @@ def test_dependency_groups_include(tox_project: ToxProjectCreator) -> None: "furo>=2024.8.6", "sphinx>=8.0.2", ] + "friendly.Bard" = [ + "bard-song", + ] type = [ {include-group = "test"}, + {include-group = "FrIeNdLy-._.-bArD"}, "mypy>=1", ] """, @@ -278,7 +282,7 @@ def test_dependency_groups_include(tox_project: ToxProjectCreator) -> None: ( "py", "install_dependency-groups", - ["python", "-I", "-m", "pip", "install", "furo>=2024.8.6", "mypy>=1", "sphinx>=8.0.2"], + ["python", "-I", "-m", "pip", "install", "bard-song", "furo>=2024.8.6", "mypy>=1", "sphinx>=8.0.2"], ) ] @@ -330,18 +334,18 @@ def test_dependency_groups_not_list(tox_project: ToxProjectCreator) -> None: "tox.toml": """ [env_run_base] skip_install = true - dependency_groups = ["test"] + dependency_groups = ["tEst"] """, "pyproject.toml": """ [dependency-groups] - test = 1 + teSt = 1 """, }, ) result = project.run("r", "-e", "py") result.assert_failed() - assert "py: failed with dependency group 'test' is not a list\n" in result.out + assert "py: failed with dependency group 'teSt' is not a list\n" in result.out def test_dependency_groups_bad_requirement(tox_project: ToxProjectCreator) -> None: @@ -398,12 +402,12 @@ def test_dependency_groups_cyclic(tox_project: ToxProjectCreator) -> None: """, "pyproject.toml": """ [dependency-groups] - test = [ { include-group = "type" } ] - type = [ { include-group = "test" } ] + teSt = [ { include-group = "type" } ] + tyPe = [ { include-group = "test" } ] """, }, ) result = project.run("r", "-e", "py") result.assert_failed() - assert "py: failed with Cyclic dependency group include: 'test' -> ('test', 'type')\n" in result.out + assert "py: failed with Cyclic dependency group include: 'teSt' -> ('teSt', 'tyPe')\n" in result.out