Skip to content

Commit c81de55

Browse files
authored
Add support for PEP-735 dependency groups. (#2584)
You can now specify one or more `--group <name>@<project_dir>` as sources of requirements when building a PEX or creating a lock. See: https://peps.python.org/pep-0735
1 parent 0e665f1 commit c81de55

38 files changed

+2081
-46
lines changed

CHANGES.md

+12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Release Notes
22

3+
## 2.23.0
4+
5+
This release adds support for drawing requirements from
6+
[PEP-735][PEP-735] dependency groups when creating PEXes or lock files.
7+
Groups are requested via `--group <name>@<project dir>` or just
8+
`--group <name>` if the project directory is the current working
9+
directory.
10+
11+
* Add support for PEP-735 dependency groups. (#2584)
12+
13+
[PEP-735]: https://peps.python.org/pep-0735/
14+
315
## 2.22.0
416

517
This release adds support for `--pip-version 24.3.1`.

pex/bin/pex.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,7 @@ def configure_clp_sources(parser):
746746

747747
project.register_options(
748748
parser,
749-
help=(
749+
project_help=(
750750
"Add the local project at the specified path to the generated .pex file along with "
751751
"its transitive dependencies."
752752
),
@@ -1016,6 +1016,14 @@ def build_pex(
10161016
else resolver_configuration.pip_configuration
10171017
)
10181018

1019+
group_requirements = project.get_group_requirements(options)
1020+
if group_requirements:
1021+
requirements = OrderedSet(requirement_configuration.requirements)
1022+
requirements.update(str(req) for req in group_requirements)
1023+
requirement_configuration = attr.evolve(
1024+
requirement_configuration, requirements=requirements
1025+
)
1026+
10191027
project_dependencies = OrderedSet() # type: OrderedSet[Requirement]
10201028
with TRACER.timed(
10211029
"Adding distributions built from local projects and collecting their requirements: "

pex/build_system/pep_518.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os.path
77
import subprocess
88

9+
from pex import toml
910
from pex.build_system import DEFAULT_BUILD_BACKEND
1011
from pex.common import REPRODUCIBLE_BUILDS_ENV, CopyMode
1112
from pex.dist_metadata import Distribution
@@ -26,9 +27,8 @@
2627
from typing import Iterable, Mapping, Optional, Tuple, Union
2728

2829
import attr # vendor:skip
29-
import toml # vendor:skip
3030
else:
31-
from pex.third_party import attr, toml
31+
from pex.third_party import attr
3232

3333

3434
@attr.s(frozen=True)
@@ -43,8 +43,7 @@ def _read_build_system_table(
4343
):
4444
# type: (...) -> Union[Optional[BuildSystemTable], Error]
4545
try:
46-
with open(pyproject_toml) as fp:
47-
data = toml.load(fp)
46+
data = toml.load(pyproject_toml)
4847
except toml.TomlDecodeError as e:
4948
return Error(
5049
"Problem parsing toml in {pyproject_toml}: {err}".format(

pex/cli/commands/lock.py

+18-15
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ def _add_resolve_options(cls, parser):
416416
requirement_options.register(options_group)
417417
project.register_options(
418418
options_group,
419-
help=(
419+
project_help=(
420420
"Add the transitive dependencies of the local project at the specified path to "
421421
"the lock but do not lock project itself."
422422
),
@@ -846,25 +846,28 @@ def _gather_requirements(
846846
):
847847
# type: (...) -> RequirementConfiguration
848848
requirement_configuration = requirement_options.configure(self.options)
849+
group_requirements = project.get_group_requirements(self.options)
849850
projects = project.get_projects(self.options)
850-
if not projects:
851+
if not projects and not group_requirements:
851852
return requirement_configuration
852853

853854
requirements = OrderedSet(requirement_configuration.requirements)
854-
with TRACER.timed(
855-
"Collecting requirements from {count} local {projects}".format(
856-
count=len(projects), projects=pluralize(projects, "project")
857-
)
858-
):
859-
requirements.update(
860-
str(req)
861-
for req in projects.collect_requirements(
862-
resolver=ConfiguredResolver(pip_configuration),
863-
interpreter=targets.interpreter,
864-
pip_version=pip_configuration.version,
865-
max_jobs=pip_configuration.max_jobs,
855+
requirements.update(str(req) for req in group_requirements)
856+
if projects:
857+
with TRACER.timed(
858+
"Collecting requirements from {count} local {projects}".format(
859+
count=len(projects), projects=pluralize(projects, "project")
860+
)
861+
):
862+
requirements.update(
863+
str(req)
864+
for req in projects.collect_requirements(
865+
resolver=ConfiguredResolver(pip_configuration),
866+
interpreter=targets.interpreter,
867+
pip_version=pip_configuration.version,
868+
max_jobs=pip_configuration.max_jobs,
869+
)
866870
)
867-
)
868871
return attr.evolve(requirement_configuration, requirements=requirements)
869872

870873
def _create(self):

pex/pep_723.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import re
88
from collections import OrderedDict
99

10+
from pex import toml
1011
from pex.common import pluralize
1112
from pex.compatibility import string
1213
from pex.dist_metadata import Requirement, RequirementParseError
@@ -17,9 +18,8 @@
1718
from typing import Any, List, Mapping, Tuple
1819

1920
import attr # vendor:skip
20-
import toml # vendor:skip
2121
else:
22-
from pex.third_party import attr, toml
22+
from pex.third_party import attr
2323

2424

2525
_UNSPECIFIED_SOURCE = "<unspecified source>"

pex/resolve/project.py

+177-5
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@
33

44
from __future__ import absolute_import
55

6+
import os.path
67
from argparse import Namespace, _ActionsContainer
78

8-
from pex import requirements
9+
from pex import requirements, toml
910
from pex.build_system import pep_517
1011
from pex.common import pluralize
12+
from pex.compatibility import string
1113
from pex.dependency_configuration import DependencyConfiguration
12-
from pex.dist_metadata import DistMetadata, Requirement
14+
from pex.dist_metadata import DistMetadata, Requirement, RequirementParseError
1315
from pex.fingerprinted_distribution import FingerprintedDistribution
1416
from pex.interpreter import PythonInterpreter
1517
from pex.jobs import Raise, SpawnedJob, execute_parallel
18+
from pex.orderedset import OrderedSet
1619
from pex.pep_427 import InstallableType
20+
from pex.pep_503 import ProjectName
1721
from pex.pip.version import PipVersionValue
1822
from pex.requirements import LocalProjectRequirement, ParseError
1923
from pex.resolve.configured_resolve import resolve
@@ -25,7 +29,7 @@
2529
from pex.typing import TYPE_CHECKING
2630

2731
if TYPE_CHECKING:
28-
from typing import Iterable, Iterator, List, Optional, Set, Tuple
32+
from typing import Any, Iterable, Iterator, List, Mapping, Optional, Set, Tuple, Union
2933

3034
import attr # vendor:skip
3135
else:
@@ -148,9 +152,147 @@ def __len__(self):
148152
return len(self.projects)
149153

150154

155+
@attr.s(frozen=True)
156+
class GroupName(ProjectName):
157+
# N.B.: A dependency group name follows the same rules, including canonicalization, as project
158+
# names.
159+
pass
160+
161+
162+
@attr.s(frozen=True)
163+
class DependencyGroup(object):
164+
@classmethod
165+
def parse(cls, spec):
166+
# type: (str) -> DependencyGroup
167+
168+
group, sep, project_dir = spec.partition("@")
169+
abs_project_dir = os.path.realpath(project_dir)
170+
if not os.path.isdir(abs_project_dir):
171+
raise ValueError(
172+
"The project directory specified by '{spec}' is not a directory".format(spec=spec)
173+
)
174+
175+
pyproject_toml = os.path.join(abs_project_dir, "pyproject.toml")
176+
if not os.path.isfile(pyproject_toml):
177+
raise ValueError(
178+
"The project directory specified by '{spec}' does not contain a pyproject.toml "
179+
"file".format(spec=spec)
180+
)
181+
182+
group_name = GroupName(group)
183+
try:
184+
dependency_groups = {
185+
GroupName(name): group
186+
for name, group in toml.load(pyproject_toml)["dependency-groups"].items()
187+
} # type: Mapping[GroupName, Any]
188+
except (IOError, OSError, KeyError, ValueError, AttributeError) as e:
189+
raise ValueError(
190+
"Failed to read `[dependency-groups]` metadata from {pyproject_toml} when parsing "
191+
"dependency group spec '{spec}': {err}".format(
192+
pyproject_toml=pyproject_toml, spec=spec, err=e
193+
)
194+
)
195+
if group_name not in dependency_groups:
196+
raise KeyError(
197+
"The dependency group '{group}' specified by '{spec}' does not exist in "
198+
"{pyproject_toml}".format(group=group, spec=spec, pyproject_toml=pyproject_toml)
199+
)
200+
201+
return cls(project_dir=abs_project_dir, name=group_name, groups=dependency_groups)
202+
203+
project_dir = attr.ib() # type: str
204+
name = attr.ib() # type: GroupName
205+
_groups = attr.ib() # type: Mapping[GroupName, Any]
206+
207+
def _parse_group_items(
208+
self,
209+
group, # type: GroupName
210+
required_by=None, # type: Optional[GroupName]
211+
):
212+
# type: (...) -> Iterator[Union[GroupName, Requirement]]
213+
214+
members = self._groups.get(group)
215+
if not members:
216+
if not required_by:
217+
raise KeyError(
218+
"The dependency group '{group}' does not exist in the project at "
219+
"{project_dir}.".format(group=group, project_dir=self.project_dir)
220+
)
221+
else:
222+
raise KeyError(
223+
"The dependency group '{group}' required by dependency group '{required_by}' "
224+
"does not exist in the project at {project_dir}.".format(
225+
group=group, required_by=required_by, project_dir=self.project_dir
226+
)
227+
)
228+
229+
if not isinstance(members, list):
230+
raise ValueError(
231+
"Invalid dependency group '{group}' in the project at {project_dir}.\n"
232+
"The value must be a list containing dependency specifiers or dependency group "
233+
"includes.\n"
234+
"See https://peps.python.org/pep-0735/#specification for the specification "
235+
"of [dependency-groups] syntax."
236+
)
237+
238+
for index, item in enumerate(members, start=1):
239+
if isinstance(item, string):
240+
try:
241+
yield Requirement.parse(item)
242+
except RequirementParseError as e:
243+
raise ValueError(
244+
"Invalid [dependency-group] entry '{name}'.\n"
245+
"Item {index}: '{req}', is an invalid dependency specifier: {err}".format(
246+
name=group.raw, index=index, req=item, err=e
247+
)
248+
)
249+
elif isinstance(item, dict):
250+
try:
251+
yield GroupName(item["include-group"])
252+
except KeyError:
253+
raise ValueError(
254+
"Invalid [dependency-group] entry '{name}'.\n"
255+
"Item {index} is a non 'include-group' table and only dependency "
256+
"specifiers and single entry 'include-group' tables are allowed in group "
257+
"dependency lists.\n"
258+
"See https://peps.python.org/pep-0735/#specification for the specification "
259+
"of [dependency-groups] syntax.\n"
260+
"Given: {item}".format(name=group.raw, index=index, item=item)
261+
)
262+
else:
263+
raise ValueError(
264+
"Invalid [dependency-group] entry '{name}'.\n"
265+
"Item {index} is not a dependency specifier or a dependency group include.\n"
266+
"See https://peps.python.org/pep-0735/#specification for the specification "
267+
"of [dependency-groups] syntax.\n"
268+
"Given: {item}".format(name=group.raw, index=index, item=item)
269+
)
270+
271+
def iter_requirements(self):
272+
# type: () -> Iterator[Requirement]
273+
274+
visited_groups = set() # type: Set[GroupName]
275+
276+
def iter_group(
277+
group, # type: GroupName
278+
required_by=None, # type: Optional[GroupName]
279+
):
280+
# type: (...) -> Iterator[Requirement]
281+
if group not in visited_groups:
282+
visited_groups.add(group)
283+
for item in self._parse_group_items(group, required_by=required_by):
284+
if isinstance(item, Requirement):
285+
yield item
286+
else:
287+
for req in iter_group(item, required_by=group):
288+
yield req
289+
290+
return iter_group(self.name)
291+
292+
151293
def register_options(
152294
parser, # type: _ActionsContainer
153-
help, # type: str
295+
project_help, # type: str
154296
):
155297
# type: (...) -> None
156298

@@ -161,7 +303,27 @@ def register_options(
161303
default=[],
162304
type=str,
163305
action="append",
164-
help=help,
306+
help=project_help,
307+
)
308+
309+
parser.add_argument(
310+
"--group",
311+
"--dependency-group",
312+
dest="dependency_groups",
313+
metavar="GROUP[@DIR]",
314+
default=[],
315+
type=DependencyGroup.parse,
316+
action="append",
317+
help=(
318+
"Pull requirements from the specified PEP-735 dependency group. Dependency groups are "
319+
"specified by referencing the group name in a given project's pyproject.toml in the "
320+
"form `<group name>@<project directory>`; e.g.: `test@local/project/directory`. If "
321+
"either the `@<project directory>` suffix is not present or the suffix is just `@`, "
322+
"the current working directory is assumed to be the project directory to read the "
323+
"dependency group information from. Multiple dependency groups across any number of "
324+
"projects can be specified. Read more about dependency groups at "
325+
"https://peps.python.org/pep-0735/."
326+
),
165327
)
166328

167329

@@ -207,3 +369,13 @@ def get_projects(options):
207369
)
208370

209371
return Projects(projects=tuple(projects))
372+
373+
374+
def get_group_requirements(options):
375+
# type: (Namespace) -> Iterable[Requirement]
376+
377+
group_requirements = OrderedSet() # type: OrderedSet[Requirement]
378+
for dependency_group in options.dependency_groups:
379+
for requirement in dependency_group.iter_requirements():
380+
group_requirements.add(requirement)
381+
return group_requirements

0 commit comments

Comments
 (0)