Skip to content

Commit 779c8ee

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 aec9c9c commit 779c8ee

File tree

7 files changed

+309
-139
lines changed

7 files changed

+309
-139
lines changed

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
@@ -76,7 +76,7 @@ def match_py_req(py_req: str, *, python_version: Version = PYTHON_VERSION) -> bo
7676
return python_version in SpecifierSet(py_req)
7777

7878

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

104108

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

166196
def resolve_from_provider(
167197
provider: BaseProvider, req: Requirement
168-
) -> tuple[str, Version]:
169-
reporter = LogReporter(req)
170-
rslvr: resolvelib.Resolver = resolvelib.Resolver(provider, reporter)
198+
) -> list[tuple[str, Version]]:
199+
"""Resolve requirement and return all matching candidates.
200+
201+
Returns list of (url, version) tuples sorted by version (highest first).
202+
"""
203+
# Get all matching candidates directly from provider
204+
# instead of using resolvelib's resolver which picks just one
205+
identifier = provider.identify(req)
171206
try:
172-
result = rslvr.resolve([req])
207+
candidates = provider.find_matches(
208+
identifier=identifier,
209+
requirements={identifier: [req]},
210+
incompatibilities={},
211+
)
173212
except resolvelib.resolvers.ResolverException as err:
174213
constraint = provider.constraints.get_constraint(req.name)
175214
provider_desc = provider.get_provider_description()
176-
# Include the original error message to preserve detailed information
177-
# (e.g., file types, pre-release info from PyPIProvider)
178215
original_msg = str(err)
179216
raise resolvelib.resolvers.ResolverException(
180217
f"Unable to resolve requirement specifier {req} with constraint {constraint} using {provider_desc}: {original_msg}"
181218
) from err
182-
# resolvelib actually just returns one candidate per requirement.
183-
# result.mapping is map from an identifier to its resolved candidate
184-
candidate: Candidate
185-
for candidate in result.mapping.values():
186-
return candidate.url, candidate.version
187-
raise ValueError(f"Unable to resolve {req}")
219+
220+
# Convert candidates to list of (url, version) tuples
221+
# Candidates are already sorted by version (highest first)
222+
return [(candidate.url, candidate.version) for candidate in candidates]
188223

189224

190225
def get_project_from_pypi(
@@ -468,8 +503,8 @@ def validate_candidate(
468503
incompatibilities: CandidatesMap,
469504
candidate: Candidate,
470505
) -> bool:
471-
identifier_reqs = list(requirements[identifier])
472-
bad_versions = {c.version for c in incompatibilities[identifier]}
506+
identifier_reqs = list(requirements.get(identifier, []))
507+
bad_versions = {c.version for c in incompatibilities.get(identifier, [])}
473508
# Skip versions that are known bad
474509
if candidate.version in bad_versions:
475510
if DEBUG_RESOLVER:
@@ -573,8 +608,11 @@ def _get_no_match_error_message(
573608
574609
Subclasses should override this to provide provider-specific error details.
575610
"""
576-
r = next(iter(requirements[identifier]))
577-
return f"found no match for {r} using {self.get_provider_description()}"
611+
reqs = requirements.get(identifier, [])
612+
if reqs:
613+
r = next(iter(reqs))
614+
return f"found no match for {r} using {self.get_provider_description()}"
615+
return f"found no match for identifier {identifier} using {self.get_provider_description()}"
578616

579617
def find_matches(
580618
self,

0 commit comments

Comments
 (0)