@@ -89,6 +89,7 @@ def __init__(
8989 cache_wheel_server_url : str | None = None ,
9090 sdist_only : bool = False ,
9191 test_mode : bool = False ,
92+ multiple_versions : bool = False ,
9293 ) -> None :
9394 if test_mode and sdist_only :
9495 raise ValueError (
@@ -101,6 +102,7 @@ def __init__(
101102 self .cache_wheel_server_url = cache_wheel_server_url or ctx .wheel_server_url
102103 self .sdist_only = sdist_only
103104 self .test_mode = test_mode
105+ self .multiple_versions = multiple_versions
104106 self .why : list [tuple [RequirementType , Requirement , Version ]] = []
105107
106108 # Delegate resolution to BootstrapRequirementResolver
@@ -126,6 +128,10 @@ def __init__(
126128 # Track failed packages in test mode (list of typed dicts for JSON export)
127129 self .failed_packages : list [FailureRecord ] = []
128130
131+ # Track failed versions in multiple_versions mode
132+ # Maps (package_name, version) -> exception info
133+ self ._failed_versions : list [tuple [str , str , Exception ]] = []
134+
129135 def resolve_and_add_top_level (
130136 self ,
131137 req : Requirement ,
@@ -135,6 +141,10 @@ def resolve_and_add_top_level(
135141 This is the pre-resolution phase before recursive bootstrapping begins.
136142 In test mode, catches resolution errors and records them as failures.
137143
144+ When multiple_versions is enabled, resolves and adds all matching versions
145+ to the graph, but still returns only the first (highest) version for
146+ backward compatibility.
147+
138148 Args:
139149 req: The top-level requirement to resolve.
140150
@@ -147,40 +157,59 @@ def resolve_and_add_top_level(
147157 """
148158 try :
149159 pbi = self .ctx .package_build_info (req )
150- source_url , version = self .resolve_version (
160+ results = self .resolve_versions (
151161 req = req ,
152162 req_type = RequirementType .TOP_LEVEL ,
163+ return_all_versions = self .multiple_versions ,
153164 )
154- logger .info ("%s resolves to %s" , req , version )
155- self .ctx .dependency_graph .add_dependency (
156- parent_name = None ,
157- parent_version = None ,
158- req_type = RequirementType .TOP_LEVEL ,
159- req = req ,
160- req_version = version ,
161- download_url = source_url ,
162- pre_built = pbi .pre_built ,
163- constraint = self .ctx .constraints .get_constraint (req .name ),
164- )
165- return (source_url , version )
165+ if self .multiple_versions :
166+ logger .info (f"resolved { len (results )} version(s) for { req } " )
167+
168+ # Add all resolved versions to the graph
169+ for source_url , version in results :
170+ logger .info ("%s resolves to %s" , req , version )
171+ self .ctx .dependency_graph .add_dependency (
172+ parent_name = None ,
173+ parent_version = None ,
174+ req_type = RequirementType .TOP_LEVEL ,
175+ req = req ,
176+ req_version = version ,
177+ download_url = source_url ,
178+ pre_built = pbi .pre_built ,
179+ constraint = self .ctx .constraints .get_constraint (req .name ),
180+ )
181+
182+ # Return first result for backward compatibility
183+ return results [0 ]
166184 except Exception as err :
167185 if not self .test_mode :
168186 raise
169187 self ._record_test_mode_failure (req , None , err , "resolution" )
170188 return None
171189
172- def resolve_version (
190+ def resolve_versions (
173191 self ,
174192 req : Requirement ,
175193 req_type : RequirementType ,
176- ) -> tuple [str , Version ]:
177- """Resolve the version of a requirement.
194+ return_all_versions : bool = False ,
195+ ) -> list [tuple [str , Version ]]:
196+ """Resolve version(s) of a requirement.
178197
179- Returns the source URL and the version of the requirement (highest matching version ).
198+ Returns list of ( source URL, version) tuples, sorted by version (highest first ).
180199
181200 Git URL resolution stays in Bootstrapper because it requires
182201 build orchestration (BuildEnvironment, build dependencies).
183202 Delegates PyPI/graph resolution to BootstrapRequirementResolver.
203+
204+ Args:
205+ req: Package requirement to resolve
206+ req_type: Type of requirement
207+ return_all_versions: If True, return all matching versions.
208+ If False, return only highest version.
209+
210+ Returns:
211+ List of (url, version) tuples. Contains one item when
212+ return_all_versions=False, or all matching versions when True.
184213 """
185214 if req .url :
186215 if req_type != RequirementType .TOP_LEVEL :
@@ -193,26 +222,23 @@ def resolve_version(
193222 cached_result = self ._resolver .get_cached_resolution (req , pre_built = False )
194223 if cached_result is not None :
195224 logger .debug (f"resolved { req } from cache" )
196- # Pick highest version from cached list
197- return cached_result [0 ]
225+ return cached_result if return_all_versions else [cached_result [0 ]]
198226
199227 logger .info ("resolving source via URL, ignoring any plugins" )
200228 source_url , resolved_version = self ._resolve_version_from_git_url (req = req )
201229 # Cache the git URL resolution (always source, not prebuilt)
202230 # Store as list for consistency with cache structure
203- self ._resolver .cache_resolution (
204- req , pre_built = False , result = [(source_url , resolved_version )]
205- )
206- return source_url , resolved_version
231+ result = [(source_url , resolved_version )]
232+ self ._resolver .cache_resolution (req , pre_built = False , result = result )
233+ return result # Git URLs always return single version
207234
208235 # Delegate to RequirementResolver
209236 parent_req = self .why [- 1 ][1 ] if self .why else None
210-
211- # Returns the highest matching version
212237 return self ._resolver .resolve (
213238 req = req ,
214239 req_type = req_type ,
215240 parent_req = parent_req ,
241+ return_all_versions = return_all_versions ,
216242 )
217243
218244 def _processing_build_requirement (self , current_req_type : RequirementType ) -> bool :
@@ -249,30 +275,71 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
249275
250276 In test mode, catches build exceptions, records package name, and continues.
251277 In normal mode, raises exceptions immediately (fail-fast).
278+
279+ When multiple_versions is enabled, bootstraps all matching versions instead
280+ of just the highest version.
252281 """
253282 logger .info (f"bootstrapping { req } as { req_type } dependency of { self .why [- 1 :]} " )
254283
255- # Resolve version first so we have it for error reporting.
284+ # Resolve versions - get all if multiple_versions mode is enabled, else get highest
256285 # In test mode, record resolution failures and continue.
257286 try :
258- source_url , resolved_version = self .resolve_version (
287+ resolved_versions = self .resolve_versions (
259288 req = req ,
260289 req_type = req_type ,
290+ return_all_versions = self .multiple_versions ,
261291 )
292+ if self .multiple_versions :
293+ logger .info (f"resolved { len (resolved_versions )} version(s) for { req } " )
262294 except Exception as err :
263295 if not self .test_mode :
264296 raise
265297 self ._record_test_mode_failure (req , None , err , "resolution" )
266298 return
267299
300+ # Check if resolution returned no versions
301+ if not resolved_versions :
302+ raise RuntimeError (f"Could not resolve any versions for { req } " )
303+
304+ # Bootstrap each resolved version
305+ for source_url , resolved_version in resolved_versions :
306+ self ._bootstrap_single_version (req , req_type , source_url , resolved_version )
307+
308+ # In multiple versions mode, report any failures for this requirement
309+ if self .multiple_versions and self ._failed_versions :
310+ failed_for_req = [
311+ (name , ver , exc )
312+ for name , ver , exc in self ._failed_versions
313+ if name == canonicalize_name (req .name )
314+ ]
315+ if failed_for_req :
316+ logger .warning (
317+ f"{ req .name } : { len (failed_for_req )} version(s) failed to bootstrap"
318+ )
319+ for name , ver , exc in failed_for_req :
320+ logger .warning (f" - { name } =={ ver } : { type (exc ).__name__ } : { exc } " )
321+
322+ def _bootstrap_single_version (
323+ self ,
324+ req : Requirement ,
325+ req_type : RequirementType ,
326+ source_url : str ,
327+ resolved_version : Version ,
328+ ) -> None :
329+ """Bootstrap a single version of a package.
330+
331+ Extracted from bootstrap() to handle both single and multiple version modes.
332+ """
268333 # Capture parent before _track_why pushes current package onto the stack
269334 parent : tuple [Requirement , Version ] | None = None
270335 if self .why :
271336 _ , parent_req , parent_version = self .why [- 1 ]
272337 parent = (parent_req , parent_version )
273338
274339 # Update dependency graph unconditionally (before seen check to capture all edges)
275- self ._add_to_graph (req , req_type , resolved_version , source_url , parent )
340+ # Skip for TOP_LEVEL as they were already added in resolve_and_add_top_level()
341+ if req_type != RequirementType .TOP_LEVEL :
342+ self ._add_to_graph (req , req_type , resolved_version , source_url , parent )
276343
277344 # Build sdist-only (no wheel) if flag is set, unless this is a build
278345 # requirement which always needs a full wheel.
@@ -298,11 +365,29 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
298365 req , req_type , source_url , resolved_version , build_sdist_only
299366 )
300367 except Exception as err :
301- if not self .test_mode :
302- raise
303- self ._record_test_mode_failure (
304- req , str (resolved_version ), err , "bootstrap"
305- )
368+ # In test_mode, record failure and continue
369+ if self .test_mode :
370+ self ._record_test_mode_failure (
371+ req , str (resolved_version ), err , "bootstrap"
372+ )
373+ return
374+
375+ # In multiple_versions mode, record failure and continue to next version
376+ if self .multiple_versions :
377+ pkg_name = canonicalize_name (req .name )
378+ self ._failed_versions .append ((pkg_name , str (resolved_version ), err ))
379+ logger .warning (
380+ f"{ req .name } =={ resolved_version } : failed to bootstrap: { type (err ).__name__ } : { err } "
381+ )
382+ # Remove failed node from graph since bootstrap didn't complete
383+ self .ctx .dependency_graph .remove_dependency (
384+ pkg_name , resolved_version
385+ )
386+ self .ctx .write_to_graph_to_file ()
387+ return
388+
389+ # Otherwise, raise the exception (fail-fast)
390+ raise
306391
307392 def _bootstrap_impl (
308393 self ,
@@ -924,12 +1009,13 @@ def _handle_test_mode_failure(
9241009
9251010 try :
9261011 parent_req = self .why [- 1 ][1 ] if self .why else None
927- wheel_url , fallback_version = self ._resolver .resolve (
1012+ results = self ._resolver .resolve (
9281013 req = req ,
9291014 req_type = req_type ,
9301015 parent_req = parent_req ,
9311016 pre_built = True , # Force prebuilt for test mode fallback
9321017 )
1018+ wheel_url , fallback_version = results [0 ]
9331019
9341020 if fallback_version != resolved_version :
9351021 logger .warning (
@@ -1261,9 +1347,6 @@ def _add_to_graph(
12611347 download_url : str ,
12621348 parent : tuple [Requirement , Version ] | None ,
12631349 ) -> None :
1264- if req_type == RequirementType .TOP_LEVEL :
1265- return
1266-
12671350 parent_req , parent_version = parent if parent else (None , None )
12681351 pbi = self .ctx .package_build_info (req )
12691352 # Update the dependency graph after we determine that this requirement is
0 commit comments