54
54
_ExcInfoType = Union [Tuple [Type [BaseException ], BaseException , types .TracebackType ], Tuple [None , None , None ]]
55
55
56
56
57
+ _SDIST_NAME_REGEX = re .compile (r'(?P<distribution>.+)-(?P<version>.+)\.tar.gz' )
58
+
59
+
57
60
_WHEEL_NAME_REGEX = re .compile (
58
61
r'(?P<distribution>.+)-(?P<version>.+)'
59
62
r'(-(?P<build_tag>.+))?-(?P<python_tag>.+)'
@@ -104,6 +107,30 @@ def __str__(self) -> str:
104
107
return f'Failed to validate `build-system` in pyproject.toml: { self .args [0 ]} '
105
108
106
109
110
+ class CircularBuildSystemDependencyError (BuildException ):
111
+ """
112
+ Exception raised when a ``[build-system]`` requirement in pyproject.toml is circular.
113
+ """
114
+
115
+ def __init__ (
116
+ self , project_name : str , ancestral_req_strings : Tuple [str , ...], req_string : str , backend : Optional [str ]
117
+ ) -> None :
118
+ super ().__init__ ()
119
+ self .project_name : str = project_name
120
+ self .ancestral_req_strings : Tuple [str , ...] = ancestral_req_strings
121
+ self .req_string : str = req_string
122
+ self .backend : Optional [str ] = backend
123
+
124
+ def __str__ (self ) -> str :
125
+ cycle_err_str = f'`{ self .project_name } `'
126
+ if self .backend :
127
+ cycle_err_str += f' -> `{ self .backend } `'
128
+ for dep in self .ancestral_req_strings :
129
+ cycle_err_str += f' -> `{ dep } `'
130
+ cycle_err_str += f' -> `{ self .req_string } `'
131
+ return f'Failed to validate `build-system` in pyproject.toml, dependency cycle detected: { cycle_err_str } '
132
+
133
+
107
134
class TypoWarning (Warning ):
108
135
"""
109
136
Warning raised when a potential typo is found
@@ -131,8 +158,17 @@ def _validate_source_directory(srcdir: PathType) -> None:
131
158
raise BuildException (f'Source { srcdir } does not appear to be a Python project: no pyproject.toml or setup.py' )
132
159
133
160
161
+ # https://www.python.org/dev/peps/pep-0503/#normalized-names
162
+ def _normalize (name : str ) -> str :
163
+ return re .sub (r'[-_.]+' , '-' , name ).lower ()
164
+
165
+
134
166
def check_dependency (
135
- req_string : str , ancestral_req_strings : Tuple [str , ...] = (), parent_extras : AbstractSet [str ] = frozenset ()
167
+ req_string : str ,
168
+ ancestral_req_strings : Tuple [str , ...] = (),
169
+ parent_extras : AbstractSet [str ] = frozenset (),
170
+ project_name : Optional [str ] = None ,
171
+ backend : Optional [str ] = None ,
136
172
) -> Iterator [Tuple [str , ...]]:
137
173
"""
138
174
Verify that a dependency and all of its dependencies are met.
@@ -159,6 +195,12 @@ def check_dependency(
159
195
# dependency is satisfied.
160
196
return
161
197
198
+ # Front ends SHOULD check explicitly for requirement cycles, and
199
+ # terminate the build with an informative message if one is found.
200
+ # https://www.python.org/dev/peps/pep-0517/#build-requirements
201
+ if project_name is not None and _normalize (req .name ) == _normalize (project_name ):
202
+ raise CircularBuildSystemDependencyError (project_name , ancestral_req_strings , req_string , backend )
203
+
162
204
try :
163
205
dist = importlib_metadata .distribution (req .name ) # type: ignore[no-untyped-call]
164
206
except importlib_metadata .PackageNotFoundError :
@@ -171,7 +213,7 @@ def check_dependency(
171
213
elif dist .requires :
172
214
for other_req_string in dist .requires :
173
215
# yields transitive dependencies that are not satisfied.
174
- yield from check_dependency (other_req_string , ancestral_req_strings + (req_string ,), req .extras )
216
+ yield from check_dependency (other_req_string , ancestral_req_strings + (req_string ,), req .extras , project_name )
175
217
176
218
177
219
def _find_typo (dictionary : Mapping [str , str ], expected : str ) -> None :
@@ -222,6 +264,23 @@ def _parse_build_system_table(pyproject_toml: Mapping[str, Any]) -> Dict[str, An
222
264
return build_system_table
223
265
224
266
267
+ def _parse_project_name (pyproject_toml : Mapping [str , Any ]) -> Optional [str ]:
268
+ if 'project' not in pyproject_toml :
269
+ return None
270
+
271
+ project_table = dict (pyproject_toml ['project' ])
272
+
273
+ # If [project] is present, it must have a ``name`` field (per PEP 621)
274
+ if 'name' not in project_table :
275
+ return None
276
+
277
+ project_name = project_table ['name' ]
278
+ if not isinstance (project_name , str ):
279
+ return None
280
+
281
+ return project_name
282
+
283
+
225
284
class ProjectBuilder :
226
285
"""
227
286
The PEP 517 consumer API.
@@ -267,8 +326,10 @@ def __init__(
267
326
except TOMLDecodeError as e :
268
327
raise BuildException (f'Failed to parse { spec_file } : { e } ' )
269
328
329
+ self .project_name : Optional [str ] = _parse_project_name (spec )
270
330
self ._build_system = _parse_build_system_table (spec )
271
331
self ._backend = self ._build_system ['build-backend' ]
332
+ self ._requires_for_build_cache : dict [str , Optional [Set [str ]]] = {'wheel' : None , 'sdist' : None }
272
333
self ._scripts_dir = scripts_dir
273
334
self ._hook_runner = runner
274
335
self ._hook = pep517 .wrappers .Pep517HookCaller (
@@ -341,6 +402,15 @@ def get_requires_for_build(self, distribution: str, config_settings: Optional[Co
341
402
with self ._handle_backend (hook_name ):
342
403
return set (get_requires (config_settings ))
343
404
405
+ def check_build_dependencies (self ) -> Set [Tuple [str , ...]]:
406
+ """
407
+ Return the dependencies which are not satisfied from
408
+ :attr:`build_system_requires`
409
+
410
+ :returns: Set of variable-length unmet dependency tuples
411
+ """
412
+ return {u for d in self .build_system_requires for u in check_dependency (d , project_name = self .project_name )}
413
+
344
414
def check_dependencies (
345
415
self , distribution : str , config_settings : Optional [ConfigSettingsType ] = None
346
416
) -> Set [Tuple [str , ...]]:
@@ -353,8 +423,20 @@ def check_dependencies(
353
423
:param config_settings: Config settings for the build backend
354
424
:returns: Set of variable-length unmet dependency tuples
355
425
"""
356
- dependencies = self .get_requires_for_build (distribution , config_settings ).union (self .build_system_requires )
357
- return {u for d in dependencies for u in check_dependency (d )}
426
+ build_system_dependencies = self .check_build_dependencies ()
427
+ requires_for_build : Set [str ]
428
+ requires_for_build_cache : Optional [Set [str ]] = self ._requires_for_build_cache [distribution ]
429
+ if requires_for_build_cache is not None :
430
+ requires_for_build = requires_for_build_cache
431
+ else :
432
+ requires_for_build = self .get_requires_for_build (distribution , config_settings )
433
+ dependencies = {
434
+ u for d in requires_for_build for u in check_dependency (d , project_name = self .project_name , backend = self ._backend )
435
+ }
436
+ unmet_dependencies = dependencies .union (build_system_dependencies )
437
+ if len (unmet_dependencies ) == 0 :
438
+ self ._requires_for_build_cache [distribution ] = requires_for_build
439
+ return unmet_dependencies
358
440
359
441
def prepare (
360
442
self , distribution : str , output_directory : PathType , config_settings : Optional [ConfigSettingsType ] = None
@@ -399,7 +481,15 @@ def build(
399
481
"""
400
482
self .log (f'Building { distribution } ...' )
401
483
kwargs = {} if metadata_directory is None else {'metadata_directory' : metadata_directory }
402
- return self ._call_backend (f'build_{ distribution } ' , output_directory , config_settings , ** kwargs )
484
+ basename = self ._call_backend (f'build_{ distribution } ' , output_directory , config_settings , ** kwargs )
485
+ match = None
486
+ if distribution == 'wheel' :
487
+ match = _WHEEL_NAME_REGEX .match (os .path .basename (basename ))
488
+ elif distribution == 'sdist' :
489
+ match = _SDIST_NAME_REGEX .match (os .path .basename (basename ))
490
+ if match :
491
+ self .project_name = match ['distribution' ]
492
+ return basename
403
493
404
494
def metadata_path (self , output_directory : PathType ) -> str :
405
495
"""
@@ -413,13 +503,17 @@ def metadata_path(self, output_directory: PathType) -> str:
413
503
# prepare_metadata hook
414
504
metadata = self .prepare ('wheel' , output_directory )
415
505
if metadata is not None :
506
+ match = _WHEEL_NAME_REGEX .match (os .path .basename (metadata ))
507
+ if match :
508
+ self .project_name = match ['distribution' ]
416
509
return metadata
417
510
418
511
# fallback to build_wheel hook
419
512
wheel = self .build ('wheel' , output_directory )
420
513
match = _WHEEL_NAME_REGEX .match (os .path .basename (wheel ))
421
514
if not match :
422
515
raise ValueError ('Invalid wheel' )
516
+ self .project_name = match ['distribution' ]
423
517
distinfo = f"{ match ['distribution' ]} -{ match ['version' ]} .dist-info"
424
518
member_prefix = f'{ distinfo } /'
425
519
with zipfile .ZipFile (wheel ) as w :
0 commit comments