Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/fromager/bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def resolve_version(
) -> tuple[str, Version]:
"""Resolve the version of a requirement.

Returns the source URL and the version of the requirement.
Returns the source URL and the version of the requirement (highest matching version).

Git URL resolution stays in Bootstrapper because it requires
build orchestration (BuildEnvironment, build dependencies).
Expand All @@ -193,19 +193,22 @@ def resolve_version(
cached_result = self._resolver.get_cached_resolution(req, pre_built=False)
if cached_result is not None:
logger.debug(f"resolved {req} from cache")
return cached_result
# Pick highest version from cached list
return cached_result[0]

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

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

# Returns the highest matching version
return self._resolver.resolve(
req=req,
req_type=req_type,
Expand Down
89 changes: 62 additions & 27 deletions src/fromager/requirement_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,19 @@ def __init__(
self.prev_graph = prev_graph
# Session-level resolution cache to avoid re-resolving same requirements
# Key: (requirement_string, pre_built) to distinguish source vs prebuilt
self._resolved_requirements: dict[tuple[str, bool], tuple[str, Version]] = {}
# Value: list of (url, version) tuples sorted by version (highest first)
self._resolved_requirements: dict[
tuple[str, bool], list[tuple[str, Version]]
] = {}

def resolve(
def resolve_all(
self,
req: Requirement,
req_type: RequirementType,
parent_req: Requirement | None = None,
pre_built: bool | None = None,
) -> tuple[str, Version]:
"""Resolve package requirement.
) -> list[tuple[str, Version]]:
"""Resolve package requirement to all matching versions.

Tries resolution strategies in order:
1. Session cache (if previously resolved)
Expand All @@ -75,7 +78,7 @@ def resolve(
If None (default), uses package build info to determine.

Returns:
Tuple of (url, resolved_version)
List of (url, version) tuples sorted by version (highest first)

Raises:
ValueError: If req contains a git URL and pre_built is False
Expand All @@ -101,20 +104,50 @@ def resolve(
return cached_result

# Resolve using strategies
url, resolved_version = self._resolve(req, req_type, parent_req, pre_built)
results = self._resolve(req, req_type, parent_req, pre_built)

# Cache the result
result = (url, resolved_version)
self.cache_resolution(req, pre_built, result)
return url, resolved_version
self.cache_resolution(req, pre_built, results)
return results

def resolve(
self,
req: Requirement,
req_type: RequirementType,
parent_req: Requirement | None = None,
pre_built: bool | None = None,
) -> tuple[str, Version]:
"""Resolve package requirement to the best matching version.

Tries resolution strategies in order:
1. Session cache (if previously resolved)
2. Previous dependency graph
3. PyPI resolution (source or prebuilt based on package build info)

Args:
req: Package requirement
req_type: Type of requirement
parent_req: Parent requirement from dependency chain
pre_built: Optional override to force prebuilt (True) or source (False).
If None (default), uses package build info to determine.

Returns:
(url, version) tuple for the highest matching version

Raises:
ValueError: If req contains a git URL and pre_built is False
(git URL source resolution must be handled by Bootstrapper)
"""
results = self.resolve_all(req, req_type, parent_req, pre_built)
return results[0]

def _resolve(
self,
req: Requirement,
req_type: RequirementType,
parent_req: Requirement | None,
pre_built: bool,
) -> tuple[str, Version]:
) -> list[tuple[str, Version]]:
"""Internal resolution logic without caching.

Tries resolution strategies in order:
Expand All @@ -128,7 +161,7 @@ def _resolve(
pre_built: Whether to resolve prebuilt (True) or source (False)

Returns:
Tuple of (url, resolved_version)
List of (url, version) tuples sorted by version (highest first)
"""
# Try graph
cached_resolution = self._resolve_from_graph(
Expand All @@ -139,43 +172,44 @@ def _resolve(
)

if cached_resolution and not req.url:
url, resolved_version = cached_resolution
logger.debug(f"resolved from previous bootstrap to {resolved_version}")
return url, resolved_version
logger.debug(
f"resolved from previous bootstrap: {len(cached_resolution)} version(s)"
)
return cached_resolution

# Fallback to PyPI
result: list[tuple[str, Version]]
if pre_built:
# Resolve prebuilt wheel
servers = wheels.get_wheel_server_urls(
self.ctx, req, cache_wheel_server_url=resolver.PYPI_SERVER_URL
)
url, resolved_version = wheels.resolve_prebuilt_wheel(
result = wheels.resolve_prebuilt_wheel_all(
ctx=self.ctx, req=req, wheel_server_urls=servers, req_type=req_type
)
else:
# Resolve source (sdist)
url, resolved_version = sources.resolve_source(
result = sources.resolve_source_all(
ctx=self.ctx,
req=req,
sdist_server_url=resolver.PYPI_SERVER_URL,
req_type=req_type,
)

return url, resolved_version
return result

def get_cached_resolution(
self,
req: Requirement,
pre_built: bool,
) -> tuple[str, Version] | None:
) -> list[tuple[str, Version]] | None:
"""Get a cached resolution result if it exists.

Args:
req: Package requirement to look up in cache
pre_built: Whether looking for prebuilt or source resolution

Returns:
Tuple of (source_url, resolved_version) if cached, None otherwise
List of (url, version) tuples if cached, None otherwise
"""
cache_key = (str(req), pre_built)
return self._resolved_requirements.get(cache_key)
Expand All @@ -184,7 +218,7 @@ def cache_resolution(
self,
req: Requirement,
pre_built: bool,
result: tuple[str, Version],
result: list[tuple[str, Version]],
) -> None:
"""Cache a resolution result.

Expand All @@ -194,7 +228,7 @@ def cache_resolution(
Args:
req: Package requirement to cache
pre_built: Whether this is a prebuilt or source resolution
result: Tuple of (source_url, resolved_version)
result: List of (url, version) tuples
"""
cache_key = (str(req), pre_built)
self._resolved_requirements[cache_key] = result
Expand All @@ -205,7 +239,7 @@ def _resolve_from_graph(
req_type: RequirementType,
pre_built: bool,
parent_req: Requirement | None,
) -> tuple[str, Version] | None:
) -> list[tuple[str, Version]] | None:
"""Resolve from previous dependency graph.

Extracted from Bootstrapper._resolve_from_graph().
Expand All @@ -217,7 +251,7 @@ def _resolve_from_graph(
parent_req: Parent requirement for graph traversal

Returns:
Tuple of (url, version) if found in graph, None otherwise
List of (url, version) tuples if found in graph, None otherwise
"""
if not self.prev_graph:
return None
Expand Down Expand Up @@ -307,8 +341,8 @@ def _resolve_from_version_source(
self,
version_source: list[tuple[str, Version]],
req: Requirement,
) -> tuple[str, Version] | None:
"""Select best version from candidates.
) -> list[tuple[str, Version]] | None:
"""Filter and return all matching versions from candidates.

Extracted from Bootstrapper._resolve_from_version_source().

Expand All @@ -317,7 +351,7 @@ def _resolve_from_version_source(
req: Package requirement with version specifier

Returns:
Tuple of (url, version) for best match, None if no match
List of (url, version) tuples for all matches, None if no matches
"""
if not version_source:
return None
Expand All @@ -329,6 +363,7 @@ def _resolve_from_version_source(
constraints=self.ctx.constraints,
use_resolver_cache=False,
)
# resolve_from_provider now returns all matching candidates
return resolver.resolve_from_provider(provider, req)
except Exception as err:
logger.debug(f"could not resolve {req} from {version_source}: {err}")
Expand Down
74 changes: 56 additions & 18 deletions src/fromager/resolver.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like there are now 2 "top" layers to the resolver, this module and the new one. Does one call the other, or are they being called from different places?

Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def match_py_req(py_req: str, *, python_version: Version = PYTHON_VERSION) -> bo
return python_version in SpecifierSet(py_req)


def resolve(
def resolve_all(
*,
ctx: context.WorkContext,
req: Requirement,
Expand All @@ -84,7 +84,11 @@ def resolve(
include_wheels: bool = True,
req_type: RequirementType | None = None,
ignore_platform: bool = False,
) -> tuple[str, Version]:
) -> list[tuple[str, Version]]:
"""Resolve requirement and return all matching versions.

Returns list of (url, version) tuples sorted by version (highest first).
"""
# Create the (reusable) resolver.
provider = overrides.find_and_invoke(
req.name,
Expand All @@ -101,6 +105,32 @@ def resolve(
return resolve_from_provider(provider, req)


def resolve(
*,
ctx: context.WorkContext,
req: Requirement,
sdist_server_url: str,
include_sdists: bool = True,
include_wheels: bool = True,
req_type: RequirementType | None = None,
ignore_platform: bool = False,
) -> tuple[str, Version]:
"""Resolve requirement and return the best matching version.

Returns (url, version) tuple for the highest matching version.
"""
results = resolve_all(
ctx=ctx,
req=req,
sdist_server_url=sdist_server_url,
include_sdists=include_sdists,
include_wheels=include_wheels,
req_type=req_type,
ignore_platform=ignore_platform,
)
return results[0]


def default_resolver_provider(
ctx: context.WorkContext,
req: Requirement,
Expand Down Expand Up @@ -158,26 +188,31 @@ def ending(self, state: typing.Any) -> None:

def resolve_from_provider(
provider: BaseProvider, req: Requirement
) -> tuple[str, Version]:
reporter = LogReporter(req)
rslvr: resolvelib.Resolver = resolvelib.Resolver(provider, reporter)
) -> list[tuple[str, Version]]:
"""Resolve requirement and return all matching candidates.

Returns list of (url, version) tuples sorted by version (highest first).
"""
# Get all matching candidates directly from provider
# instead of using resolvelib's resolver which picks just one
identifier = provider.identify(req)
try:
result = rslvr.resolve([req])
candidates = provider.find_matches(
identifier=identifier,
requirements={identifier: [req]},
incompatibilities={},
)
except resolvelib.resolvers.ResolverException as err:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not calling resolvelib here any more, so we're not going to get this exception type, I think? Will we get an exception at all, or will the candidate list just be empty?

Since we're not calling resolvelib any more, do we still need that library?

constraint = provider.constraints.get_constraint(req.name)
provider_desc = provider.get_provider_description()
# Include the original error message to preserve detailed information
# (e.g., file types, pre-release info from PyPIProvider)
original_msg = str(err)
raise resolvelib.resolvers.ResolverException(
f"Unable to resolve requirement specifier {req} with constraint {constraint} using {provider_desc}: {original_msg}"
) from err
# resolvelib actually just returns one candidate per requirement.
# result.mapping is map from an identifier to its resolved candidate
candidate: Candidate
for candidate in result.mapping.values():
return candidate.url, candidate.version
raise ValueError(f"Unable to resolve {req}")

# Convert candidates to list of (url, version) tuples
# Candidates are already sorted by version (highest first)
return [(candidate.url, candidate.version) for candidate in candidates]


def get_project_from_pypi(
Expand Down Expand Up @@ -454,8 +489,8 @@ def validate_candidate(
incompatibilities: CandidatesMap,
candidate: Candidate,
) -> bool:
identifier_reqs = list(requirements[identifier])
bad_versions = {c.version for c in incompatibilities[identifier]}
identifier_reqs = list(requirements.get(identifier, []))
bad_versions = {c.version for c in incompatibilities.get(identifier, [])}
# Skip versions that are known bad
if candidate.version in bad_versions:
if DEBUG_RESOLVER:
Expand Down Expand Up @@ -559,8 +594,11 @@ def _get_no_match_error_message(

Subclasses should override this to provide provider-specific error details.
"""
r = next(iter(requirements[identifier]))
return f"found no match for {r} using {self.get_provider_description()}"
reqs = requirements.get(identifier, [])
if reqs:
r = next(iter(reqs))
return f"found no match for {r} using {self.get_provider_description()}"
return f"found no match for identifier {identifier} using {self.get_provider_description()}"

def find_matches(
self,
Expand Down
Loading
Loading