Skip to content

Commit d5950de

Browse files
committed
refactor(resolver): add *_all() functions for multiple version resolution
This commit introduces new *_all() functions that return all matching versions while keeping existing functions unchanged for backward compatibility. New functions return list[tuple[str, Version]] sorted by version (highest first): - resolver.resolve_all() - sources.resolve_source_all() and default_resolve_source_all() - wheels.resolve_prebuilt_wheel_all() - RequirementResolver.resolve_all() Existing functions now call the new *_all() functions and return the highest version (first element), maintaining identical behavior: - resolver.resolve() -> tuple[str, Version] - sources.resolve_source() -> tuple[str, Version] - wheels.resolve_prebuilt_wheel() -> tuple[str, Version] - RequirementResolver.resolve() -> tuple[str, Version] Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Rohan Devasthale <rdevasth@redhat.com>
1 parent 29350ce commit d5950de

7 files changed

Lines changed: 309 additions & 139 deletions

File tree

src/fromager/bootstrapper.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def resolve_version(
176176
) -> tuple[str, Version]:
177177
"""Resolve the version of a requirement.
178178
179-
Returns the source URL and the version of the requirement.
179+
Returns the source URL and the version of the requirement (highest matching version).
180180
181181
Git URL resolution stays in Bootstrapper because it requires
182182
build orchestration (BuildEnvironment, build dependencies).
@@ -193,19 +193,22 @@ def resolve_version(
193193
cached_result = self._resolver.get_cached_resolution(req, pre_built=False)
194194
if cached_result is not None:
195195
logger.debug(f"resolved {req} from cache")
196-
return cached_result
196+
# Pick highest version from cached list
197+
return cached_result[0]
197198

198199
logger.info("resolving source via URL, ignoring any plugins")
199200
source_url, resolved_version = self._resolve_version_from_git_url(req=req)
200201
# Cache the git URL resolution (always source, not prebuilt)
202+
# Store as list for consistency with cache structure
201203
self._resolver.cache_resolution(
202-
req, pre_built=False, result=(source_url, resolved_version)
204+
req, pre_built=False, result=[(source_url, resolved_version)]
203205
)
204206
return source_url, resolved_version
205207

206208
# Delegate to RequirementResolver
207209
parent_req = self.why[-1][1] if self.why else None
208210

211+
# Returns the highest matching version
209212
return self._resolver.resolve(
210213
req=req,
211214
req_type=req_type,

src/fromager/requirement_resolver.py

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,19 @@ def __init__(
5151
self.prev_graph = prev_graph
5252
# Session-level resolution cache to avoid re-resolving same requirements
5353
# Key: (requirement_string, pre_built) to distinguish source vs prebuilt
54-
self._resolved_requirements: dict[tuple[str, bool], tuple[str, Version]] = {}
54+
# Value: list of (url, version) tuples sorted by version (highest first)
55+
self._resolved_requirements: dict[
56+
tuple[str, bool], list[tuple[str, Version]]
57+
] = {}
5558

56-
def resolve(
59+
def resolve_all(
5760
self,
5861
req: Requirement,
5962
req_type: RequirementType,
6063
parent_req: Requirement | None = None,
6164
pre_built: bool | None = None,
62-
) -> tuple[str, Version]:
63-
"""Resolve package requirement.
65+
) -> list[tuple[str, Version]]:
66+
"""Resolve package requirement to all matching versions.
6467
6568
Tries resolution strategies in order:
6669
1. Session cache (if previously resolved)
@@ -75,7 +78,7 @@ def resolve(
7578
If None (default), uses package build info to determine.
7679
7780
Returns:
78-
Tuple of (url, resolved_version)
81+
List of (url, version) tuples sorted by version (highest first)
7982
8083
Raises:
8184
ValueError: If req contains a git URL and pre_built is False
@@ -101,20 +104,50 @@ def resolve(
101104
return cached_result
102105

103106
# Resolve using strategies
104-
url, resolved_version = self._resolve(req, req_type, parent_req, pre_built)
107+
results = self._resolve(req, req_type, parent_req, pre_built)
105108

106109
# Cache the result
107-
result = (url, resolved_version)
108-
self.cache_resolution(req, pre_built, result)
109-
return url, resolved_version
110+
self.cache_resolution(req, pre_built, results)
111+
return results
112+
113+
def resolve(
114+
self,
115+
req: Requirement,
116+
req_type: RequirementType,
117+
parent_req: Requirement | None = None,
118+
pre_built: bool | None = None,
119+
) -> tuple[str, Version]:
120+
"""Resolve package requirement to the best matching version.
121+
122+
Tries resolution strategies in order:
123+
1. Session cache (if previously resolved)
124+
2. Previous dependency graph
125+
3. PyPI resolution (source or prebuilt based on package build info)
126+
127+
Args:
128+
req: Package requirement
129+
req_type: Type of requirement
130+
parent_req: Parent requirement from dependency chain
131+
pre_built: Optional override to force prebuilt (True) or source (False).
132+
If None (default), uses package build info to determine.
133+
134+
Returns:
135+
(url, version) tuple for the highest matching version
136+
137+
Raises:
138+
ValueError: If req contains a git URL and pre_built is False
139+
(git URL source resolution must be handled by Bootstrapper)
140+
"""
141+
results = self.resolve_all(req, req_type, parent_req, pre_built)
142+
return results[0]
110143

111144
def _resolve(
112145
self,
113146
req: Requirement,
114147
req_type: RequirementType,
115148
parent_req: Requirement | None,
116149
pre_built: bool,
117-
) -> tuple[str, Version]:
150+
) -> list[tuple[str, Version]]:
118151
"""Internal resolution logic without caching.
119152
120153
Tries resolution strategies in order:
@@ -128,7 +161,7 @@ def _resolve(
128161
pre_built: Whether to resolve prebuilt (True) or source (False)
129162
130163
Returns:
131-
Tuple of (url, resolved_version)
164+
List of (url, version) tuples sorted by version (highest first)
132165
"""
133166
# Try graph
134167
cached_resolution = self._resolve_from_graph(
@@ -139,43 +172,44 @@ def _resolve(
139172
)
140173

141174
if cached_resolution and not req.url:
142-
url, resolved_version = cached_resolution
143-
logger.debug(f"resolved from previous bootstrap to {resolved_version}")
144-
return url, resolved_version
175+
logger.debug(
176+
f"resolved from previous bootstrap: {len(cached_resolution)} version(s)"
177+
)
178+
return cached_resolution
145179

146180
# Fallback to PyPI
181+
result: list[tuple[str, Version]]
147182
if pre_built:
148183
# Resolve prebuilt wheel
149184
servers = wheels.get_wheel_server_urls(
150185
self.ctx, req, cache_wheel_server_url=resolver.PYPI_SERVER_URL
151186
)
152-
url, resolved_version = wheels.resolve_prebuilt_wheel(
187+
result = wheels.resolve_prebuilt_wheel_all(
153188
ctx=self.ctx, req=req, wheel_server_urls=servers, req_type=req_type
154189
)
155190
else:
156191
# Resolve source (sdist)
157-
url, resolved_version = sources.resolve_source(
192+
result = sources.resolve_source_all(
158193
ctx=self.ctx,
159194
req=req,
160195
sdist_server_url=resolver.PYPI_SERVER_URL,
161196
req_type=req_type,
162197
)
163-
164-
return url, resolved_version
198+
return result
165199

166200
def get_cached_resolution(
167201
self,
168202
req: Requirement,
169203
pre_built: bool,
170-
) -> tuple[str, Version] | None:
204+
) -> list[tuple[str, Version]] | None:
171205
"""Get a cached resolution result if it exists.
172206
173207
Args:
174208
req: Package requirement to look up in cache
175209
pre_built: Whether looking for prebuilt or source resolution
176210
177211
Returns:
178-
Tuple of (source_url, resolved_version) if cached, None otherwise
212+
List of (url, version) tuples if cached, None otherwise
179213
"""
180214
cache_key = (str(req), pre_built)
181215
return self._resolved_requirements.get(cache_key)
@@ -184,7 +218,7 @@ def cache_resolution(
184218
self,
185219
req: Requirement,
186220
pre_built: bool,
187-
result: tuple[str, Version],
221+
result: list[tuple[str, Version]],
188222
) -> None:
189223
"""Cache a resolution result.
190224
@@ -194,7 +228,7 @@ def cache_resolution(
194228
Args:
195229
req: Package requirement to cache
196230
pre_built: Whether this is a prebuilt or source resolution
197-
result: Tuple of (source_url, resolved_version)
231+
result: List of (url, version) tuples
198232
"""
199233
cache_key = (str(req), pre_built)
200234
self._resolved_requirements[cache_key] = result
@@ -205,7 +239,7 @@ def _resolve_from_graph(
205239
req_type: RequirementType,
206240
pre_built: bool,
207241
parent_req: Requirement | None,
208-
) -> tuple[str, Version] | None:
242+
) -> list[tuple[str, Version]] | None:
209243
"""Resolve from previous dependency graph.
210244
211245
Extracted from Bootstrapper._resolve_from_graph().
@@ -217,7 +251,7 @@ def _resolve_from_graph(
217251
parent_req: Parent requirement for graph traversal
218252
219253
Returns:
220-
Tuple of (url, version) if found in graph, None otherwise
254+
List of (url, version) tuples if found in graph, None otherwise
221255
"""
222256
if not self.prev_graph:
223257
return None
@@ -307,8 +341,8 @@ def _resolve_from_version_source(
307341
self,
308342
version_source: list[tuple[str, Version]],
309343
req: Requirement,
310-
) -> tuple[str, Version] | None:
311-
"""Select best version from candidates.
344+
) -> list[tuple[str, Version]] | None:
345+
"""Filter and return all matching versions from candidates.
312346
313347
Extracted from Bootstrapper._resolve_from_version_source().
314348
@@ -317,7 +351,7 @@ def _resolve_from_version_source(
317351
req: Package requirement with version specifier
318352
319353
Returns:
320-
Tuple of (url, version) for best match, None if no match
354+
List of (url, version) tuples for all matches, None if no matches
321355
"""
322356
if not version_source:
323357
return None
@@ -329,6 +363,7 @@ def _resolve_from_version_source(
329363
constraints=self.ctx.constraints,
330364
use_resolver_cache=False,
331365
)
366+
# resolve_from_provider now returns all matching candidates
332367
return resolver.resolve_from_provider(provider, req)
333368
except Exception as err:
334369
logger.debug(f"could not resolve {req} from {version_source}: {err}")

src/fromager/resolver.py

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def match_py_req(py_req: str, *, python_version: Version = PYTHON_VERSION) -> bo
7575
return python_version in SpecifierSet(py_req)
7676

7777

78-
def resolve(
78+
def resolve_all(
7979
*,
8080
ctx: context.WorkContext,
8181
req: Requirement,
@@ -84,7 +84,11 @@ def resolve(
8484
include_wheels: bool = True,
8585
req_type: RequirementType | None = None,
8686
ignore_platform: bool = False,
87-
) -> tuple[str, Version]:
87+
) -> list[tuple[str, Version]]:
88+
"""Resolve requirement and return all matching versions.
89+
90+
Returns list of (url, version) tuples sorted by version (highest first).
91+
"""
8892
# Create the (reusable) resolver.
8993
provider = overrides.find_and_invoke(
9094
req.name,
@@ -101,6 +105,32 @@ def resolve(
101105
return resolve_from_provider(provider, req)
102106

103107

108+
def resolve(
109+
*,
110+
ctx: context.WorkContext,
111+
req: Requirement,
112+
sdist_server_url: str,
113+
include_sdists: bool = True,
114+
include_wheels: bool = True,
115+
req_type: RequirementType | None = None,
116+
ignore_platform: bool = False,
117+
) -> tuple[str, Version]:
118+
"""Resolve requirement and return the best matching version.
119+
120+
Returns (url, version) tuple for the highest matching version.
121+
"""
122+
results = resolve_all(
123+
ctx=ctx,
124+
req=req,
125+
sdist_server_url=sdist_server_url,
126+
include_sdists=include_sdists,
127+
include_wheels=include_wheels,
128+
req_type=req_type,
129+
ignore_platform=ignore_platform,
130+
)
131+
return results[0]
132+
133+
104134
def default_resolver_provider(
105135
ctx: context.WorkContext,
106136
req: Requirement,
@@ -158,26 +188,31 @@ def ending(self, state: typing.Any) -> None:
158188

159189
def resolve_from_provider(
160190
provider: BaseProvider, req: Requirement
161-
) -> tuple[str, Version]:
162-
reporter = LogReporter(req)
163-
rslvr: resolvelib.Resolver = resolvelib.Resolver(provider, reporter)
191+
) -> list[tuple[str, Version]]:
192+
"""Resolve requirement and return all matching candidates.
193+
194+
Returns list of (url, version) tuples sorted by version (highest first).
195+
"""
196+
# Get all matching candidates directly from provider
197+
# instead of using resolvelib's resolver which picks just one
198+
identifier = provider.identify(req)
164199
try:
165-
result = rslvr.resolve([req])
200+
candidates = provider.find_matches(
201+
identifier=identifier,
202+
requirements={identifier: [req]},
203+
incompatibilities={},
204+
)
166205
except resolvelib.resolvers.ResolverException as err:
167206
constraint = provider.constraints.get_constraint(req.name)
168207
provider_desc = provider.get_provider_description()
169-
# Include the original error message to preserve detailed information
170-
# (e.g., file types, pre-release info from PyPIProvider)
171208
original_msg = str(err)
172209
raise resolvelib.resolvers.ResolverException(
173210
f"Unable to resolve requirement specifier {req} with constraint {constraint} using {provider_desc}: {original_msg}"
174211
) from err
175-
# resolvelib actually just returns one candidate per requirement.
176-
# result.mapping is map from an identifier to its resolved candidate
177-
candidate: Candidate
178-
for candidate in result.mapping.values():
179-
return candidate.url, candidate.version
180-
raise ValueError(f"Unable to resolve {req}")
212+
213+
# Convert candidates to list of (url, version) tuples
214+
# Candidates are already sorted by version (highest first)
215+
return [(candidate.url, candidate.version) for candidate in candidates]
181216

182217

183218
def get_project_from_pypi(
@@ -454,8 +489,8 @@ def validate_candidate(
454489
incompatibilities: CandidatesMap,
455490
candidate: Candidate,
456491
) -> bool:
457-
identifier_reqs = list(requirements[identifier])
458-
bad_versions = {c.version for c in incompatibilities[identifier]}
492+
identifier_reqs = list(requirements.get(identifier, []))
493+
bad_versions = {c.version for c in incompatibilities.get(identifier, [])}
459494
# Skip versions that are known bad
460495
if candidate.version in bad_versions:
461496
if DEBUG_RESOLVER:
@@ -559,8 +594,11 @@ def _get_no_match_error_message(
559594
560595
Subclasses should override this to provide provider-specific error details.
561596
"""
562-
r = next(iter(requirements[identifier]))
563-
return f"found no match for {r} using {self.get_provider_description()}"
597+
reqs = requirements.get(identifier, [])
598+
if reqs:
599+
r = next(iter(reqs))
600+
return f"found no match for {r} using {self.get_provider_description()}"
601+
return f"found no match for identifier {identifier} using {self.get_provider_description()}"
564602

565603
def find_matches(
566604
self,

0 commit comments

Comments
 (0)