Skip to content

Commit 568b90f

Browse files
authored
Add dependency-groups support (PEP-735) (#3409)
1 parent f919d0d commit 568b90f

File tree

10 files changed

+453
-78
lines changed

10 files changed

+453
-78
lines changed

.readthedocs.yaml

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
version: 2
2+
build:
3+
os: ubuntu-lts-latest
4+
tools:
5+
python: "3"
6+
commands:
7+
- pip install uv
8+
- uv venv
9+
- uv pip install tox-uv tox@.
10+
- .venv/bin/tox run -e docs --

.readthedocs.yml

-15
This file was deleted.

docs/changelog/3408.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Implement dependency group support as defined in :pep:`735` - see :ref:`dependency_groups` - by :user:`gaborbernat`.

docs/config.rst

+42
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,45 @@ Python options
820820

821821
Python run
822822
~~~~~~~~~~
823+
.. conf::
824+
:keys: dependency_groups
825+
:default: <empty list>
826+
:version_added: 4.22
827+
828+
A list of names of dependency groups (as defined by :pep:`735`) to install into this Python environment. The
829+
installation will happen before installing the package or any of its dependencies.
830+
831+
For example:
832+
833+
.. tab:: TOML
834+
835+
.. code-block:: toml
836+
837+
[dependency_groups]
838+
test = [
839+
"pytest>=8",
840+
]
841+
842+
[tool.pyproject.env_run_base]
843+
dependency_groups = [
844+
"test",
845+
]
846+
847+
.. tab:: INI
848+
849+
.. code-block:: ini
850+
851+
[testenv]
852+
dependency_groups =
853+
test
854+
855+
.. code-block:: toml
856+
857+
[dependency_groups]
858+
test = [
859+
"pytest>=8",
860+
]
861+
823862
.. conf::
824863
:keys: deps
825864
:default: <empty list>
@@ -834,6 +873,9 @@ Python run
834873
- a `constraint file <https://pip.pypa.io/en/stable/user_guide/#constraints-files>`_ when the value starts with
835874
``-c`` (followed by a file path).
836875

876+
If you are only defining :pep:`508` requirements (aka no pip requirement files), you should use
877+
:ref:`dependency_groups` instead.
878+
837879
For example:
838880
.. tab:: TOML
839881

pyproject.toml

+56-35
Original file line numberDiff line numberDiff line change
@@ -62,41 +62,6 @@ dependencies = [
6262
"typing-extensions>=4.12.2; python_version<'3.11'",
6363
"virtualenv>=20.26.6",
6464
]
65-
optional-dependencies.docs = [
66-
"furo>=2024.8.6",
67-
"sphinx>=8.0.2",
68-
"sphinx-argparse-cli>=1.18.2",
69-
"sphinx-autodoc-typehints>=2.4.4",
70-
"sphinx-copybutton>=0.5.2",
71-
"sphinx-inline-tabs>=2023.4.21",
72-
"sphinxcontrib-towncrier>=0.2.1a0",
73-
"towncrier>=24.8",
74-
]
75-
optional-dependencies.testing = [
76-
"build[virtualenv]>=1.2.2",
77-
"covdefaults>=2.3",
78-
"detect-test-pollution>=1.2",
79-
"devpi-process>=1.0.2",
80-
"diff-cover>=9.2",
81-
"distlib>=0.3.8",
82-
"flaky>=3.8.1",
83-
"hatch-vcs>=0.4",
84-
"hatchling>=1.25",
85-
"psutil>=6",
86-
"pytest>=8.3.3",
87-
"pytest-cov>=5",
88-
"pytest-mock>=3.14",
89-
"pytest-xdist>=3.6.1",
90-
"re-assert>=1.1",
91-
"setuptools>=75.1",
92-
"time-machine>=2.15; implementation_name!='pypy'",
93-
"wheel>=0.44",
94-
]
95-
optional-dependencies.type = [
96-
"mypy==1.11.2",
97-
"types-cachetools>=5.5.0.20240820",
98-
"types-chardet>=5.0.4.6",
99-
]
10065
urls.Documentation = "https://tox.wiki"
10166
urls.Homepage = "http://tox.readthedocs.org"
10267
urls."Release Notes" = "https://tox.wiki/en/latest/changelog.html"
@@ -227,3 +192,59 @@ overrides = [
227192
"virtualenv.*",
228193
], ignore_missing_imports = true },
229194
]
195+
196+
[dependency-groups]
197+
dev = [
198+
{ include-group = "docs" },
199+
{ include-group = "test" },
200+
{ include-group = "type" },
201+
]
202+
docs = [
203+
"furo>=2024.8.6",
204+
"sphinx>=8.0.2",
205+
"sphinx-argparse-cli>=1.18.2",
206+
"sphinx-autodoc-typehints>=2.4.4",
207+
"sphinx-copybutton>=0.5.2",
208+
"sphinx-inline-tabs>=2023.4.21",
209+
"sphinxcontrib-towncrier>=0.2.1a0",
210+
"towncrier>=24.8",
211+
]
212+
fix = [
213+
"pre-commit-uv>=4.1.3",
214+
]
215+
pkg-meta = [
216+
"check-wheel-contents>=0.6",
217+
"twine>=5.1.1",
218+
"uv>=0.4.17",
219+
]
220+
release = [
221+
"gitpython>=3.1.43",
222+
"packaging>=24.1",
223+
"towncrier>=24.8",
224+
]
225+
test = [
226+
"build[virtualenv]>=1.2.2",
227+
"covdefaults>=2.3",
228+
"detect-test-pollution>=1.2",
229+
"devpi-process>=1.0.2",
230+
"diff-cover>=9.2",
231+
"distlib>=0.3.8",
232+
"flaky>=3.8.1",
233+
"hatch-vcs>=0.4",
234+
"hatchling>=1.25",
235+
"psutil>=6",
236+
"pytest>=8.3.3",
237+
"pytest-cov>=5",
238+
"pytest-mock>=3.14",
239+
"pytest-xdist>=3.6.1",
240+
"re-assert>=1.1",
241+
"setuptools>=75.1",
242+
"time-machine>=2.15; implementation_name!='pypy'",
243+
"wheel>=0.44",
244+
]
245+
type = [
246+
"mypy==1.11.2",
247+
"types-cachetools>=5.5.0.20240820",
248+
"types-chardet>=5.0.4.6",
249+
{ include-group = "test" },
250+
]

src/tox/pytest.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def _setup_files(dest: Path, base: Path | None, content: dict[str, Any]) -> None
160160
msg = f"could not handle {at_path / key} with content {value!r}" # pragma: no cover
161161
raise TypeError(msg) # pragma: no cover
162162

163-
def patch_execute(self, handle: Callable[[ExecuteRequest], int | None]) -> MagicMock: # noqa: C901
163+
def patch_execute(self, handle: Callable[[ExecuteRequest], int | None] | None = None) -> MagicMock: # noqa: C901
164164
class MockExecute(Execute):
165165
def __init__(self, colored: bool, exit_code: int) -> None: # noqa: FBT001
166166
self.exit_code = exit_code
@@ -228,7 +228,7 @@ def _execute_call(
228228
request: ExecuteRequest,
229229
show: bool, # noqa: FBT001
230230
) -> Iterator[ExecuteStatus]:
231-
exit_code = handle(request)
231+
exit_code = 0 if handle is None else handle(request)
232232
if exit_code is not None:
233233
executor = MockExecute(colored=executor._colored, exit_code=exit_code) # noqa: SLF001
234234
with original_execute_call(self, executor, out_err, request, show) as status:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from typing import TYPE_CHECKING, TypedDict
5+
6+
from packaging.requirements import InvalidRequirement, Requirement
7+
from packaging.utils import canonicalize_name
8+
9+
from tox.tox_env.errors import Fail
10+
11+
if TYPE_CHECKING:
12+
from pathlib import Path
13+
14+
15+
if sys.version_info >= (3, 11): # pragma: no cover (py311+)
16+
import tomllib
17+
else: # pragma: no cover (py311+)
18+
import tomli as tomllib
19+
20+
_IncludeGroup = TypedDict("_IncludeGroup", {"include-group": str})
21+
22+
23+
def resolve(root: Path, groups: set[str]) -> set[Requirement]:
24+
pyproject_file = root / "pyproject.toml"
25+
if not pyproject_file.exists(): # check if it's static PEP-621 metadata
26+
return set()
27+
with pyproject_file.open("rb") as file_handler:
28+
pyproject = tomllib.load(file_handler)
29+
dependency_groups = pyproject["dependency-groups"]
30+
if not isinstance(dependency_groups, dict):
31+
msg = f"dependency-groups is {type(dependency_groups).__name__} instead of table"
32+
raise Fail(msg)
33+
result: set[Requirement] = set()
34+
for group in groups:
35+
result = result.union(_resolve_dependency_group(dependency_groups, group))
36+
return result
37+
38+
39+
def _resolve_dependency_group(
40+
dependency_groups: dict[str, list[str] | _IncludeGroup], group: str, past_groups: tuple[str, ...] = ()
41+
) -> set[Requirement]:
42+
if group in past_groups:
43+
msg = f"Cyclic dependency group include: {group!r} -> {past_groups!r}"
44+
raise Fail(msg)
45+
if group not in dependency_groups:
46+
msg = f"dependency group {group!r} not found"
47+
raise Fail(msg)
48+
raw_group = dependency_groups[group]
49+
if not isinstance(raw_group, list):
50+
msg = f"dependency group {group!r} is not a list"
51+
raise Fail(msg)
52+
53+
result = set()
54+
for item in raw_group:
55+
if isinstance(item, str):
56+
# packaging.requirements.Requirement parsing ensures that this is a valid
57+
# PEP 508 Dependency Specifier
58+
# raises InvalidRequirement on failure
59+
try:
60+
result.add(Requirement(item))
61+
except InvalidRequirement as exc:
62+
msg = f"{item!r} is not valid requirement due to {exc}"
63+
raise Fail(msg) from exc
64+
elif isinstance(item, dict) and tuple(item.keys()) == ("include-group",):
65+
include_group = canonicalize_name(next(iter(item.values())))
66+
result = result.union(_resolve_dependency_group(dependency_groups, include_group, (*past_groups, group)))
67+
else:
68+
msg = f"invalid dependency group item: {item!r}"
69+
raise Fail(msg)
70+
return result
71+
72+
73+
__all__ = [
74+
"resolve",
75+
]

src/tox/tox_env/python/runner.py

+28-5
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@
1515
from tox.tox_env.runner import RunToxEnv
1616

1717
from .api import Python
18+
from .dependency_groups import resolve
1819

1920
if TYPE_CHECKING:
21+
from pathlib import Path
22+
2023
from tox.config.cli.parser import Parsed
2124
from tox.config.sets import CoreConfigSet, EnvConfigSet
2225
from tox.tox_env.api import ToxEnvCreateArgs
@@ -37,6 +40,13 @@ def register_config(self) -> None:
3740
default=PythonDeps("", root),
3841
desc="Name of the python dependencies as specified by PEP-440",
3942
)
43+
self.conf.add_config(
44+
keys=["dependency_groups"],
45+
of_type=Set[str],
46+
default=set(),
47+
desc="dependency groups to install of the target package",
48+
post_process=_normalize_extras,
49+
)
4050
add_skip_missing_interpreters_to_core(self.core, self.options)
4151

4252
@property
@@ -87,11 +97,23 @@ def pkg_type(self) -> str:
8797
def _setup_env(self) -> None:
8898
super()._setup_env()
8999
self._install_deps()
100+
self._install_dependency_groups()
90101

91102
def _install_deps(self) -> None:
92103
requirements_file: PythonDeps = self.conf["deps"]
93104
self._install(requirements_file, PythonRun.__name__, "deps")
94105

106+
def _install_dependency_groups(self) -> None:
107+
groups: set[str] = self.conf["dependency_groups"]
108+
if not groups:
109+
return
110+
try:
111+
root: Path = self.core["package_root"]
112+
except KeyError:
113+
root = self.core["tox_root"]
114+
requirements = resolve(root, groups)
115+
self._install(list(requirements), PythonRun.__name__, "dependency-groups")
116+
95117
def _build_packages(self) -> list[Package]:
96118
package_env = self.package_env
97119
assert package_env is not None # noqa: S101
@@ -120,11 +142,6 @@ def skip_missing_interpreters_post_process(value: bool) -> bool: # noqa: FBT001
120142

121143

122144
def add_extras_to_env(conf: EnvConfigSet) -> None:
123-
def _normalize_extras(values: set[str]) -> set[str]:
124-
# although _ and . is allowed this will be normalized during packaging to -
125-
# https://packaging.python.org/en/latest/specifications/dependency-specifiers/#grammar
126-
return {canonicalize_name(v) for v in values}
127-
128145
conf.add_config(
129146
keys=["extras"],
130147
of_type=Set[str],
@@ -134,6 +151,12 @@ def _normalize_extras(values: set[str]) -> set[str]:
134151
)
135152

136153

154+
def _normalize_extras(values: set[str]) -> set[str]:
155+
# although _ and . is allowed this will be normalized during packaging to -
156+
# https://packaging.python.org/en/latest/specifications/dependency-specifiers/#grammar
157+
return {canonicalize_name(v) for v in values}
158+
159+
137160
__all__ = [
138161
"PythonRun",
139162
"add_extras_to_env",

0 commit comments

Comments
 (0)