3
3
4
4
from __future__ import absolute_import
5
5
6
+ import os .path
6
7
from argparse import Namespace , _ActionsContainer
7
8
8
- from pex import requirements
9
+ from pex import requirements , toml
9
10
from pex .build_system import pep_517
10
11
from pex .common import pluralize
12
+ from pex .compatibility import string
11
13
from pex .dependency_configuration import DependencyConfiguration
12
- from pex .dist_metadata import DistMetadata , Requirement
14
+ from pex .dist_metadata import DistMetadata , Requirement , RequirementParseError
13
15
from pex .fingerprinted_distribution import FingerprintedDistribution
14
16
from pex .interpreter import PythonInterpreter
15
17
from pex .jobs import Raise , SpawnedJob , execute_parallel
18
+ from pex .orderedset import OrderedSet
16
19
from pex .pep_427 import InstallableType
20
+ from pex .pep_503 import ProjectName
17
21
from pex .pip .version import PipVersionValue
18
22
from pex .requirements import LocalProjectRequirement , ParseError
19
23
from pex .resolve .configured_resolve import resolve
25
29
from pex .typing import TYPE_CHECKING
26
30
27
31
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
29
33
30
34
import attr # vendor:skip
31
35
else :
@@ -148,9 +152,147 @@ def __len__(self):
148
152
return len (self .projects )
149
153
150
154
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
+
151
293
def register_options (
152
294
parser , # type: _ActionsContainer
153
- help , # type: str
295
+ project_help , # type: str
154
296
):
155
297
# type: (...) -> None
156
298
@@ -161,7 +303,27 @@ def register_options(
161
303
default = [],
162
304
type = str ,
163
305
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
+ ),
165
327
)
166
328
167
329
@@ -207,3 +369,13 @@ def get_projects(options):
207
369
)
208
370
209
371
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