Skip to content

Commit 5a7269a

Browse files
committed
Update --group option to take [path:]name
In discussions about the correct interface for `pip` to use [dependency-groups], no strong consensus arose. However, the option with the most support appears to be to make it possible to pass a file path plus a group name. This change converts the `--group` option to take colon-separated path:groupname pairs, with the path part optional. The CLI parsing code is responsible for handling the syntax and for filling in a default path of `"pyproject.toml"`. If a path is provided, it must have a basename of `pyproject.toml`. Failing to meet this constraint is an error at arg parsing time. The `dependency_groups` usage is updated to create a DependencyGroupResolver per `pyproject.toml` file provided. This ensures that we only parse each file once, and we keep the results of previous resolutions when resolving multiple dependency groups from the same file. (Technically, the implementation is a resolver per path, which is subtly different from per-file, in that it doesn't account for symlinks, hardlinks, etc.)
1 parent 34c85e0 commit 5a7269a

File tree

5 files changed

+147
-45
lines changed

5 files changed

+147
-45
lines changed

src/pip/_internal/cli/cmdoptions.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import importlib.util
1414
import logging
1515
import os
16+
import pathlib
1617
import textwrap
1718
from functools import partial
1819
from optparse import SUPPRESS_HELP, Option, OptionGroup, OptionParser, Values
@@ -733,15 +734,44 @@ def _handle_no_cache_dir(
733734
help="Don't install package dependencies.",
734735
)
735736

737+
738+
def _handle_dependency_group(
739+
option: Option, opt: str, value: str, parser: OptionParser
740+
) -> None:
741+
"""
742+
Process a value provided for the --group option.
743+
744+
Splits on the rightmost ":", and validates that the path (if present) ends
745+
in `pyproject.toml`. Defaults the path to `pyproject.toml` when one is not given.
746+
747+
`:` cannot appear in dependency group names, so this is a safe and simple parse.
748+
749+
This is an optparse.Option callback for the dependency_groups option.
750+
"""
751+
path, sep, groupname = value.rpartition(":")
752+
if not sep:
753+
path = "pyproject.toml"
754+
else:
755+
# check for 'pyproject.toml' filenames using pathlib
756+
if pathlib.PurePath(path).name != "pyproject.toml":
757+
msg = "group paths use 'pyproject.toml' filenames"
758+
raise_option_error(parser, option=option, msg=msg)
759+
760+
parser.values.dependency_groups.append((path, groupname))
761+
762+
736763
dependency_groups: Callable[..., Option] = partial(
737764
Option,
738765
"--group",
739766
dest="dependency_groups",
740767
default=[],
741-
action="append",
742-
metavar="group",
743-
help="Install a named dependency-group from `pyproject.toml` "
744-
"in the current directory.",
768+
type=str,
769+
action="callback",
770+
callback=_handle_dependency_group,
771+
metavar="[path:]group",
772+
help='Install a named dependency-group from a "pyproject.toml" file. '
773+
'If a path is given, it must end in "pyproject.toml:". '
774+
'Defaults to using "pyproject.toml" in the current directory.',
745775
)
746776

747777
ignore_requires_python: Callable[..., Option] = partial(
Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,74 @@
1-
from typing import Any, Dict, List
1+
from typing import Any, Dict, Iterable, Iterator, List, Tuple
22

33
from pip._vendor import tomli
4-
from pip._vendor.dependency_groups import resolve as resolve_dependency_group
4+
from pip._vendor.dependency_groups import DependencyGroupResolver
55

66
from pip._internal.exceptions import InstallationError
77

88

9-
def parse_dependency_groups(groups: List[str]) -> List[str]:
9+
def parse_dependency_groups(groups: List[Tuple[str, str]]) -> List[str]:
1010
"""
11-
Parse dependency groups data in a way which is sensitive to the `pip` context and
12-
raises InstallationErrors if anything goes wrong.
11+
Parse dependency groups data as provided via the CLI, in a `[path:]group` syntax.
12+
13+
Raises InstallationErrors if anything goes wrong.
1314
"""
14-
pyproject = _load_pyproject()
15-
16-
if "dependency-groups" not in pyproject:
17-
raise InstallationError(
18-
"[dependency-groups] table was missing. Cannot resolve '--group' options."
19-
)
20-
raw_dependency_groups = pyproject["dependency-groups"]
21-
if not isinstance(raw_dependency_groups, dict):
22-
raise InstallationError(
23-
"[dependency-groups] table was malformed. Cannot resolve '--group' options."
24-
)
15+
resolvers = _build_resolvers(path for (path, _) in groups)
16+
return list(_resolve_all_groups(resolvers, groups))
2517

26-
try:
27-
return list(resolve_dependency_group(raw_dependency_groups, *groups))
28-
except (ValueError, TypeError, LookupError) as e:
29-
raise InstallationError(f"[dependency-groups] resolution failed: {e}") from e
18+
19+
def _resolve_all_groups(
20+
resolvers: Dict[str, DependencyGroupResolver], groups: List[Tuple[str, str]]
21+
) -> Iterator[str]:
22+
"""
23+
Run all resolution, converting any error from `DependencyGroupResolver` into
24+
an InstallationError.
25+
"""
26+
for path, groupname in groups:
27+
resolver = resolvers[path]
28+
try:
29+
yield from (str(req) for req in resolver.resolve(groupname))
30+
except (ValueError, TypeError, LookupError) as e:
31+
raise InstallationError(
32+
f"[dependency-groups] resolution failed for '{groupname}' "
33+
f"from '{path}': {e}"
34+
) from e
35+
36+
37+
def _build_resolvers(paths: Iterable[str]) -> Dict[str, Any]:
38+
resolvers = {}
39+
for path in paths:
40+
if path in resolvers:
41+
continue
42+
43+
pyproject = _load_pyproject(path)
44+
if "dependency-groups" not in pyproject:
45+
raise InstallationError(
46+
f"[dependency-groups] table was missing from '{path}'. "
47+
"Cannot resolve '--group' option."
48+
)
49+
raw_dependency_groups = pyproject["dependency-groups"]
50+
if not isinstance(raw_dependency_groups, dict):
51+
raise InstallationError(
52+
f"[dependency-groups] table was malformed in {path}. "
53+
"Cannot resolve '--group' option."
54+
)
55+
56+
resolvers[path] = DependencyGroupResolver(raw_dependency_groups)
57+
return resolvers
3058

3159

32-
def _load_pyproject() -> Dict[str, Any]:
60+
def _load_pyproject(path: str) -> Dict[str, Any]:
3361
"""
34-
This helper loads pyproject.toml from the current working directory.
62+
This helper loads a pyproject.toml as TOML.
3563
36-
It does not allow specification of the path to be used and raises an
37-
InstallationError if the operation fails.
64+
It raises an InstallationError if the operation fails.
3865
"""
3966
try:
40-
with open("pyproject.toml", "rb") as fp:
67+
with open(path, "rb") as fp:
4168
return tomli.load(fp)
4269
except FileNotFoundError:
43-
raise InstallationError(
44-
"pyproject.toml not found. Cannot resolve '--group' options."
45-
)
70+
raise InstallationError(f"{path} not found. Cannot resolve '--group' option.")
4671
except tomli.TOMLDecodeError as e:
47-
raise InstallationError(f"Error parsing pyproject.toml: {e}") from e
72+
raise InstallationError(f"Error parsing {path}: {e}") from e
4873
except OSError as e:
49-
raise InstallationError(f"Error reading pyproject.toml: {e}") from e
74+
raise InstallationError(f"Error reading {path}: {e}") from e

tests/functional/test_install.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,30 @@ def test_install_exit_status_code_when_empty_dependency_group(
333333
script.pip("install", "--group", "empty")
334334

335335

336+
@pytest.mark.parametrize("file_exists", [True, False])
337+
def test_install_dependency_group_bad_filename_error(
338+
script: PipTestEnvironment, file_exists: bool
339+
) -> None:
340+
"""
341+
Test install exit status code is 2 (usage error) when a dependency group path is
342+
specified which isn't a `pyproject.toml`
343+
"""
344+
if file_exists:
345+
script.scratch_path.joinpath("not-pyproject.toml").write_text(
346+
textwrap.dedent(
347+
"""
348+
[dependency-groups]
349+
publish = ["twine"]
350+
"""
351+
)
352+
)
353+
result = script.pip(
354+
"install", "--group", "not-pyproject.toml:publish", expect_error=True
355+
)
356+
assert "group paths use 'pyproject.toml' filenames" in result.stderr
357+
assert result.returncode == 2
358+
359+
336360
@pytest.mark.network
337361
def test_basic_install_from_pypi(script: PipTestEnvironment) -> None:
338362
"""

tests/functional/test_install_reqs.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,22 @@ def test_requirements_file(script: PipTestEnvironment) -> None:
9494

9595

9696
@pytest.mark.network
97-
def test_dependency_group(script: PipTestEnvironment) -> None:
97+
@pytest.mark.parametrize(
98+
"path, groupname",
99+
[
100+
(None, "initools"),
101+
("pyproject.toml", "initools"),
102+
("./pyproject.toml", "initools"),
103+
(lambda path: path.absolute(), "initools"),
104+
],
105+
)
106+
def test_dependency_group(
107+
script: PipTestEnvironment,
108+
path: Any,
109+
groupname: str,
110+
) -> None:
98111
"""
99112
Test installing from a dependency group.
100-
101113
"""
102114
pyproject = script.scratch_path / "pyproject.toml"
103115
pyproject.write_text(
@@ -111,7 +123,13 @@ def test_dependency_group(script: PipTestEnvironment) -> None:
111123
"""
112124
)
113125
)
114-
result = script.pip("install", "--group", "initools")
126+
if path is None:
127+
arg = groupname
128+
else:
129+
if callable(path):
130+
path = path(pyproject)
131+
arg = f"{path}:{groupname}"
132+
result = script.pip("install", "--group", arg)
115133
result.did_create(script.site_packages / "INITools-0.2.dist-info")
116134
result.did_create(script.site_packages / "initools")
117135
assert result.files_created[script.site_packages / "peppercorn"].dir

tests/unit/test_req_dependency_group.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def test_parse_simple_dependency_groups(
2323
)
2424
monkeypatch.chdir(tmp_path)
2525

26-
result = list(parse_dependency_groups(["foo"]))
26+
result = list(parse_dependency_groups([("pyproject.toml", "foo")]))
2727

2828
assert len(result) == 1, result
2929
assert result[0] == "bar"
@@ -45,9 +45,13 @@ def test_parse_cyclic_dependency_groups(
4545
monkeypatch.chdir(tmp_path)
4646

4747
with pytest.raises(
48-
InstallationError, match=r"\[dependency-groups\] resolution failed:"
48+
InstallationError,
49+
match=(
50+
r"\[dependency-groups\] resolution failed for "
51+
r"'foo' from 'pyproject\.toml':"
52+
),
4953
) as excinfo:
50-
parse_dependency_groups(["foo"])
54+
parse_dependency_groups([("pyproject.toml", "foo")])
5155

5256
exception = excinfo.value
5357
assert (
@@ -63,9 +67,10 @@ def test_parse_with_no_dependency_groups_defined(
6367
monkeypatch.chdir(tmp_path)
6468

6569
with pytest.raises(
66-
InstallationError, match=r"\[dependency-groups\] table was missing\."
70+
InstallationError,
71+
match=(r"\[dependency-groups\] table was missing from 'pyproject\.toml'\."),
6772
):
68-
parse_dependency_groups(["foo"])
73+
parse_dependency_groups([("pyproject.toml", "foo")])
6974

7075

7176
def test_parse_with_no_pyproject_file(
@@ -74,7 +79,7 @@ def test_parse_with_no_pyproject_file(
7479
monkeypatch.chdir(tmp_path)
7580

7681
with pytest.raises(InstallationError, match=r"pyproject\.toml not found\."):
77-
parse_dependency_groups(["foo"])
82+
parse_dependency_groups([("pyproject.toml", "foo")])
7883

7984

8085
def test_parse_with_malformed_pyproject_file(
@@ -92,7 +97,7 @@ def test_parse_with_malformed_pyproject_file(
9297
monkeypatch.chdir(tmp_path)
9398

9499
with pytest.raises(InstallationError, match=r"Error parsing pyproject\.toml"):
95-
parse_dependency_groups(["foo"])
100+
parse_dependency_groups([("pyproject.toml", "foo")])
96101

97102

98103
def test_parse_gets_unexpected_oserror(
@@ -119,4 +124,4 @@ def epipe_toml_load(*args: Any, **kwargs: Any) -> None:
119124
)
120125

121126
with pytest.raises(InstallationError, match=r"Error reading pyproject\.toml"):
122-
parse_dependency_groups(["foo"])
127+
parse_dependency_groups([("pyproject.toml", "foo")])

0 commit comments

Comments
 (0)