Skip to content

Commit 3295838

Browse files
authored
Support recursive extras defined in pyproject.toml (#2905)
* test_package_pyproject: recursive extras Add regression test for issue #2904 * test_package_pyproject: when project deps has a self-referential extra the project depends on an extra defined within itself * Support recursive extras defined in pyproject.toml Expand extras that reference an extra of the same package name to respect local changes to package metadata. Fix #2904
1 parent acadf36 commit 3295838

File tree

4 files changed

+74
-6
lines changed

4 files changed

+74
-6
lines changed

docs/changelog/2904.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Tox will now expand self-referential extras discovered in package deps to respect local modifications to package
2+
metadata. This allows a package extra to explicitly depend on another package extra, which previously only worked with
3+
non-static metadata - by :user:`masenf`.

src/tox/tox_env/python/virtual_env/package/pyproject.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from tox.util.file_view import create_session_view
3434

3535
from ..api import VirtualEnv
36-
from .util import dependencies_with_extras
36+
from .util import dependencies_with_extras, dependencies_with_extras_from_markers
3737

3838
if sys.version_info >= (3, 8): # pragma: no cover (py38+)
3939
from importlib.metadata import Distribution, PathDistribution
@@ -253,11 +253,17 @@ def _load_deps_from_static(self, for_env: EnvConfigSet) -> list[Requirement] | N
253253
if dynamic == "dependencies" or (extras and dynamic == "optional-dependencies"):
254254
return None # if any dependencies are dynamic we can just calculate all dynamically
255255

256-
deps: list[Requirement] = [Requirement(i) for i in project.get("dependencies", [])]
256+
deps_with_markers: list[tuple[Requirement, set[str | None]]] = [
257+
(Requirement(i), {None}) for i in project.get("dependencies", [])
258+
]
257259
optional_deps = project.get("optional-dependencies", {})
258-
for extra in extras:
259-
deps.extend(Requirement(i) for i in optional_deps.get(extra, []))
260-
return deps
260+
for extra, reqs in optional_deps.items():
261+
deps_with_markers.extend((Requirement(req), {extra}) for req in (reqs or []))
262+
return dependencies_with_extras_from_markers(
263+
deps_with_markers=deps_with_markers,
264+
extras=extras,
265+
package_name=project.get("name", "."),
266+
)
261267

262268
def _load_deps_from_built_metadata(self, for_env: EnvConfigSet) -> list[Requirement]:
263269
# dependencies might depend on the python environment we're running in => if we build a wheel use that env

src/tox/tox_env/python/virtual_env/package/util.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88

99

1010
def dependencies_with_extras(deps: list[Requirement], extras: set[str], package_name: str) -> list[Requirement]:
11-
deps_with_markers = extract_extra_markers(deps)
11+
return dependencies_with_extras_from_markers(extract_extra_markers(deps), extras, package_name)
12+
13+
14+
def dependencies_with_extras_from_markers(
15+
deps_with_markers: list[tuple[Requirement, set[str | None]]],
16+
extras: set[str],
17+
package_name: str,
18+
) -> list[Requirement]:
1219
result: list[Requirement] = []
1320
found: set[str] = set()
1421
todo: set[str | None] = extras | {None}

tests/tox_env/python/virtual_env/package/test_package_pyproject.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from pathlib import Path
4+
from textwrap import dedent
45

56
import pytest
67

@@ -92,6 +93,57 @@ def test_package_root_via_testenv(tox_project: ToxProjectCreator, demo_pkg_inlin
9293
["A"],
9394
id="deps_with_dynamic_optional_no_extra",
9495
),
96+
pytest.param(
97+
dedent(
98+
"""
99+
[project]
100+
name='foo'
101+
dependencies=['foo[alpha]']
102+
optional-dependencies.alpha=['A']""",
103+
),
104+
"",
105+
["A"],
106+
id="deps_reference_extra",
107+
),
108+
pytest.param(
109+
dedent(
110+
"""
111+
[project]
112+
name='foo'
113+
dependencies=['A']
114+
optional-dependencies.alpha=['B']
115+
optional-dependencies.beta=['foo[alpha]']""",
116+
),
117+
"beta",
118+
["A", "B"],
119+
id="deps_with_recursive_extra",
120+
),
121+
pytest.param(
122+
dedent(
123+
"""
124+
[project]
125+
name='foo'
126+
dependencies=['A']
127+
optional-dependencies.alpha=['B']
128+
optional-dependencies.beta=['foo[alpha]']
129+
optional-dependencies.delta=['foo[beta]', 'D']""",
130+
),
131+
"delta",
132+
["A", "B", "D"],
133+
id="deps_with_two_recursive_extra",
134+
),
135+
pytest.param(
136+
dedent(
137+
"""
138+
[project]
139+
name='foo'
140+
optional-dependencies.alpha=['foo[beta]', 'A']
141+
optional-dependencies.beta=['foo[alpha]', 'B']""",
142+
),
143+
"alpha",
144+
["A", "B"],
145+
id="deps_with_circular_recursive_extra",
146+
),
95147
],
96148
)
97149
def test_pyproject_deps_from_static(

0 commit comments

Comments
 (0)