Repository: tox-dev/tox

Issue:
Depency group implementation does not correctly normalize group names
## Issue

Consider this `pyproject.toml`

```
[project]
name = "foo"
version = "0.0.1"

[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"

[dependency-groups]
test = [{include-group = "_pytest"}]
_pytest = ["pytest"]

[tool.tox]
requires = ["tox>=4.26"]
env_list = ["3.13"]

[tool.tox.env.test]
dependency_groups = ["test"]
commands = [
    ["unit", "python -m pytest -V"]
]
```

When running `pip install --group test`, pytest is successfully installed.

However, when trying to run `tox -f test`, tox errors out saying
> test: failed with dependency group '-pytest' not found
>  test: FAIL code 1 (0.04 seconds)
>  evaluation failed :( (0.11 seconds)

This leads me to believe that group names are not always normalized in tox. However, looking at PEP-735 which states "These keys must be [valid non-normalized names](https://packaging.python.org/en/latest/specifications/name-normalization/#valid-non-normalized-names), and must be [normalized](https://packaging.python.org/en/latest/specifications/name-normalization/#normalization) before comparisons." and also looking at the [reference implementation](https://peps.python.org/pep-0735/#reference-implementation), it seems they really should be.

## Environment

Provide at least:

- OS: Linux Mint 21

<details open>
<summary>Output of <code>pip list</code> of the host Python, where <code>tox</code> is installed</summary>

```console
Package       Version
------------- -------
cachetools    6.0.0
chardet       5.2.0
colorama      0.4.6
distlib       0.3.9
filelock      3.18.0
packaging     25.0
pip           24.3.1
platformdirs  4.3.8
pluggy        1.6.0
pyproject-api 1.9.1
tox           4.26.0
virtualenv    20.31.2

```

</details>

## Output of running tox

<details open>
<summary>Output of <code>tox -rvv</code></summary>

```console
test: 229 W remove tox env folder /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test [tox/tox_env/api.py:333]
.pkg: 251 W remove tox env folder /home/xxxxxx/tmp/tox_dep_group_repro/.tox/.pkg [tox/tox_env/api.py:333]
test: 257 I find interpreter for spec PythonSpec(path=/home/xxxxxx/tmp/tox_dep_group_repro/.venv/bin/python) [virtualenv/discovery/builtin.py:76]
test: 258 D filesystem is case-sensitive [virtualenv/info.py:27]
test: 259 D got python info of /home/xxxxxx/.pyenv/versions/3.13.1/bin/python3.13 from /home/xxxxxx/.local/share/virtualenv/py_info/2/3d3183e8132c1820530140783e1de1b790bfeadf628f4e809c913da0ec231843.json [virtualenv/app_data/via_disk_folder.py:132]
test: 259 I proposed PythonInfo(spec=CPython3.13.1.final.0-64, system=/home/xxxxxx/.pyenv/versions/3.13.1/bin/python3.13, exe=/home/xxxxxx/tmp/tox_dep_group_repro/.venv/bin/python, platform=linux, version='3.13.1 (main, Jan 28 2025, 15:47:29) [GCC 11.4.0]', encoding_fs_io=utf-8-utf-8) [virtualenv/discovery/builtin.py:83]
test: 259 D accepted PythonInfo(spec=CPython3.13.1.final.0-64, system=/home/xxxxxx/.pyenv/versions/3.13.1/bin/python3.13, exe=/home/xxxxxx/tmp/tox_dep_group_repro/.venv/bin/python, platform=linux, version='3.13.1 (main, Jan 28 2025, 15:47:29) [GCC 11.4.0]', encoding_fs_io=utf-8-utf-8) [virtualenv/discovery/builtin.py:85]
test: 287 I create virtual environment via CPython3Posix(dest=/home/xxxxxx/tmp/tox_dep_group_repro/.tox/test, clear=False, no_vcs_ignore=False, global=False) [virtualenv/run/session.py:52]
test: 287 D create folder /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/bin [virtualenv/util/path/_sync.py:14]
test: 287 D create folder /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/lib/python3.13/site-packages [virtualenv/util/path/_sync.py:14]
test: 287 D write /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/pyvenv.cfg [virtualenv/create/pyenv_cfg.py:35]
test: 288 D 	home = /home/xxxxxx/.pyenv/versions/3.13.1/bin [virtualenv/create/pyenv_cfg.py:40]
test: 288 D 	implementation = CPython [virtualenv/create/pyenv_cfg.py:40]
test: 288 D 	version_info = 3.13.1.final.0 [virtualenv/create/pyenv_cfg.py:40]
test: 288 D 	virtualenv = 20.31.2 [virtualenv/create/pyenv_cfg.py:40]
test: 288 D 	include-system-site-packages = false [virtualenv/create/pyenv_cfg.py:40]
test: 288 D 	base-prefix = /home/xxxxxx/.pyenv/versions/3.13.1 [virtualenv/create/pyenv_cfg.py:40]
test: 288 D 	base-exec-prefix = /home/xxxxxx/.pyenv/versions/3.13.1 [virtualenv/create/pyenv_cfg.py:40]
test: 288 D 	base-executable = /home/xxxxxx/.pyenv/versions/3.13.1/bin/python3.13 [virtualenv/create/pyenv_cfg.py:40]
test: 288 D symlink /home/xxxxxx/.pyenv/versions/3.13.1/bin/python3.13 to /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/bin/python [virtualenv/util/path/_sync.py:34]
test: 289 D create virtualenv import hook file /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/lib/python3.13/site-packages/_virtualenv.pth [virtualenv/create/via_global_ref/api.py:93]
test: 289 D create /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/lib/python3.13/site-packages/_virtualenv.py [virtualenv/create/via_global_ref/api.py:96]
test: 290 D ============================== target debug ============================== [virtualenv/run/session.py:54]
test: 290 D debug via /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/bin/python /home/xxxxxx/tmp/tox_dep_group_repro/.venv/lib/python3.13/site-packages/virtualenv/create/debug.py [virtualenv/create/creator.py:215]
test: 290 D {
  "sys": {
    "executable": "/home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/bin/python",
    "_base_executable": "/home/xxxxxx/.pyenv/versions/3.13.1/bin/python3.13",
    "prefix": "/home/xxxxxx/tmp/tox_dep_group_repro/.tox/test",
    "base_prefix": "/home/xxxxxx/.pyenv/versions/3.13.1",
    "real_prefix": null,
    "exec_prefix": "/home/xxxxxx/tmp/tox_dep_group_repro/.tox/test",
    "base_exec_prefix": "/home/xxxxxx/.pyenv/versions/3.13.1",
    "path": [
      "/home/xxxxxx/.pyenv/versions/3.13.1/lib/python313.zip",
      "/home/xxxxxx/.pyenv/versions/3.13.1/lib/python3.13",
      "/home/xxxxxx/.pyenv/versions/3.13.1/lib/python3.13/lib-dynload",
      "/home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/lib/python3.13/site-packages"
    ],
    "meta_path": [
      "<class '_virtualenv._Finder'>",
      "<class '_frozen_importlib.BuiltinImporter'>",
      "<class '_frozen_importlib.FrozenImporter'>",
      "<class '_frozen_importlib_external.PathFinder'>"
    ],
    "fs_encoding": "utf-8",
    "io_encoding": "utf-8"
  },
  "version": "3.13.1 (main, Jan 28 2025, 15:47:29) [GCC 11.4.0]",
  "makefile_filename": "/home/xxxxxx/.pyenv/versions/3.13.1/lib/python3.13/config-3.13-x86_64-linux-gnu/Makefile",
  "os": "<module 'os' (frozen)>",
  "site": "<module 'site' (frozen)>",
  "datetime": "<module 'datetime' from '/home/xxxxxx/.pyenv/versions/3.13.1/lib/python3.13/datetime.py'>",
  "math": "<module 'math' from '/home/xxxxxx/.pyenv/versions/3.13.1/lib/python3.13/lib-dynload/math.cpython-313-x86_64-linux-gnu.so'>",
  "json": "<module 'json' from '/home/xxxxxx/.pyenv/versions/3.13.1/lib/python3.13/json/__init__.py'>"
} [virtualenv/run/session.py:55]
test: 324 I add seed packages via FromAppData(download=False, pip=bundle, via=copy, app_data_dir=/home/xxxxxx/.local/share/virtualenv) [virtualenv/run/session.py:59]
test: 325 D got embed update of distribution pip from /home/xxxxxx/.local/share/virtualenv/wheel/3.13/embed/3/pip.json [virtualenv/app_data/via_disk_folder.py:132]
test: 328 D install pip from wheel /home/xxxxxx/tmp/tox_dep_group_repro/.venv/lib/python3.13/site-packages/virtualenv/seed/wheels/embed/pip-25.1.1-py3-none-any.whl via CopyPipInstall [virtualenv/seed/embed/via_app_data/via_app_data.py:51]
test: 329 D copy directory /home/xxxxxx/.local/share/virtualenv/wheel/3.13/image/1/CopyPipInstall/pip-25.1.1-py3-none-any/pip to /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/lib/python3.13/site-packages/pip [virtualenv/util/path/_sync.py:42]
test: 419 D copy /home/xxxxxx/.local/share/virtualenv/wheel/3.13/image/1/CopyPipInstall/pip-25.1.1-py3-none-any/pip-25.1.1.virtualenv to /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/lib/python3.13/site-packages/pip-25.1.1.virtualenv [virtualenv/util/path/_sync.py:42]
test: 419 D copy directory /home/xxxxxx/.local/share/virtualenv/wheel/3.13/image/1/CopyPipInstall/pip-25.1.1-py3-none-any/pip-25.1.1.dist-info to /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/lib/python3.13/site-packages/pip-25.1.1.dist-info [virtualenv/util/path/_sync.py:42]
test: 423 D generated console scripts pip-3.13 pip3.13 pip3 pip [virtualenv/seed/embed/via_app_data/pip_install/base.py:45]
test: 423 I add activators for Bash, CShell, Fish, Nushell, PowerShell, Python [virtualenv/run/session.py:65]
test: 427 D write /home/xxxxxx/tmp/tox_dep_group_repro/.tox/test/pyvenv.cfg [virtualenv/create/pyenv_cfg.py:35]
test: 427 D 	home = /home/xxxxxx/.pyenv/versions/3.13.1/bin [virtualenv/create/pyenv_cfg.py:40]
test: 427 D 	implementation = CPython [virtualenv/create/pyenv_cfg.py:40]
test: 427 D 	version_info = 3.13.1.final.0 [virtualenv/create/pyenv_cfg.py:40]
test: 427 D 	virtualenv = 20.31.2 [virtualenv/create/pyenv_cfg.py:40]
test: 427 D 	include-system-site-packages = false [virtualenv/create/pyenv_cfg.py:40]
test: 427 D 	base-prefix = /home/xxxxxx/.pyenv/versions/3.13.1 [virtualenv/create/pyenv_cfg.py:40]
test: 427 D 	base-exec-prefix = /home/xxxxxx/.pyenv/versions/3.13.1 [virtualenv/create/pyenv_cfg.py:40]
test: 427 D 	base-executable = /home/xxxxxx/.pyenv/versions/3.13.1/bin/python3.13 [virtualenv/create/pyenv_cfg.py:40]
test: 428 E failed with dependency group '-pytest' not found [tox/session/cmd/run/single.py:57]
  test: FAIL code 1 (0.20 seconds)
  evaluation failed :( (0.28 seconds)
```

</details>



Discussion:
I just realized, that the group `_pytest` is not actually a valid name

https://packaging.python.org/en/latest/specifications/name-normalization/#name-format states
> A valid name consists only of ASCII letters and numbers, period, underscore and hyphen. It must start and end with a letter or number. [...]  with `re.IGNORECASE`
> `^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$`

But that reproducer also fails with valid names such as `dep_pytest`.



Patch:
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,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)


Test patch:
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