Skip to content
Merged
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
21 changes: 11 additions & 10 deletions docs/concepts/resolver-architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ priority order and accumulates results in a thread-safe session cache:
│ ▼ │
│ 3. Network (PyPI / GitHub / GitLab) │
│ (per-package configurable) │
│ │ age filter empties result │
│ ▼ │
│ 4. Cache server fallback │
│ (multiple_versions mode only) │
└───────────────────────────────────────────────────────┘

Git URL requirements (top-level only) bypass this
Expand All @@ -44,6 +40,10 @@ Versions discovered from any source are merged into the session cache.
Subsequent requests for the same package skip the network entirely if
the cache already contains a matching version.

The configured wheel cache server is not a version-resolution fallback.
After a version has been selected, the bootstrapper checks the cache
server separately for a previously built wheel.

Provider Hierarchy
------------------

Expand Down Expand Up @@ -73,8 +73,8 @@ CLI commands interact with providers through a common
Filters by platform tags, Python version, and yanked status.
* - ``PyPICacheProvider``
- Subclass of ``PyPIProvider`` pointing at fromager's own wheel
server. No cooldown applied. Used as a fallback when age
filtering eliminates all candidates.
server. No cooldown applied. Used by the bootstrapper's
previously-built-wheel lookup, not by source version resolution.
* - ``GenericProvider``
- Callback-based provider that pairs a version source function
with a configurable match function (plain parse or regex).
Expand Down Expand Up @@ -117,10 +117,11 @@ Two age-based filters can narrow the set of acceptable versions:
``multiple_versions`` mode to limit the range of versions built.

When both are active, only versions published within the window are
considered. If all candidates are filtered out, the behavior depends
on the mode: in single-version mode a warning is logged and all
candidates are kept; in ``multiple_versions`` mode the cache server
fallback is tried instead.
considered. If all source candidates are filtered out, the behavior
depends on the mode: in single-version mode a warning is logged and all
candidates are kept; in ``multiple_versions`` mode only the newest
candidate is retained. A source provider with no matching candidates
raises a resolution error; it is not replaced by a cache-server lookup.

Flat Resolution by Design
-------------------------
Expand Down
64 changes: 7 additions & 57 deletions src/fromager/bootstrap_requirement_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from packaging.utils import NormalizedName, canonicalize_name
from packaging.version import Version

from . import finders, resolver, sources, wheels
from . import resolver, sources, wheels
from .dependency_graph import DependencyGraph
from .requirements_file import RequirementType

Expand Down Expand Up @@ -43,24 +43,21 @@ def __init__(
ctx: context.WorkContext,
prev_graph: DependencyGraph | None = None,
multiple_versions: bool = False,
cache_wheel_server_url: str = "",
) -> None:
"""Initialize requirement resolver.

Args:
ctx: Work context with constraints and settings
prev_graph: Optional previous dependency graph for caching
multiple_versions: If ``True`` and no results are found through
any other approach, takes the latest candidate from the
cache server, ignoring the age filters. In all other
cases, returns an empty list when no candidates are found.
cache_wheel_server_url: URL of the remote wheel cache server.
Used as a fallback when age filtering produces no candidates.
multiple_versions: If ``True``, keep the newest matching source
version when the age filter removes all candidates. If
``False``, keep all matching versions in that case. If no
source version matches the requirement, resolution raises an
error.
"""
self.ctx = ctx
self.prev_graph = prev_graph
self.multiple_versions = multiple_versions
self.cache_wheel_server_url = cache_wheel_server_url
# All known versions for a package, accumulated across resolution
# contexts. Versions discovered via different specifiers or req_types
# are merged so that later lookups see the widest set.
Expand Down Expand Up @@ -211,19 +208,9 @@ def _resolve_and_extend(
age_fallback=age_fallback,
)

if not results and self.multiple_versions and self.cache_wheel_server_url:
logger.info(
"no results found with normal resolution, "
"falling back to the cache server %s",
self.cache_wheel_server_url,
)
results = self._resolve_from_cache_server(req)

if not results:
logger.warning(
"resolver returned no results "
"(wheel server URL %s, %s version mode)",
self.cache_wheel_server_url or "(none)",
"resolver returned no results (%s version mode)",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"multiple" if self.multiple_versions else "single",
)

Expand All @@ -235,43 +222,6 @@ def _resolve_and_extend(
versions[version] = url
self._resolved_rules.add((str(req), pre_built))

def _resolve_from_cache_server(self, req: Requirement) -> list[tuple[str, Version]]:
"""Fall back to the remote wheel cache server for a cached version.

When age filtering removes all candidates in multi-version mode,
queries the remote cache server for the newest available wheel.
Returns at most one version so that transitive dependencies are
re-processed without rebuilding every old version.
"""
logger.info(
"checking cache server %s for existing build",
self.cache_wheel_server_url,
)
best: tuple[str, Version] | None = None
for include_sdists, include_wheels in [(False, True), (True, False)]:
try:
provider = finders.PyPICacheProvider(
cache_server_url=self.cache_wheel_server_url,
constraints=self.ctx.constraints,
include_sdists=include_sdists,
include_wheels=include_wheels,
)
results = resolver.find_all_matching_from_provider(provider, req)
if results:
url, version = results[0]
if best is None or version > best[1]:
best = (url, version)
except Exception as err:
logger.warning(
"error checking cache server %s: %s",
self.cache_wheel_server_url,
err,
)
if best is not None:
logger.info("found version %s on cache server", best[1])
return [best]
return []

def get_matching_versions(
self,
req: Requirement,
Expand Down
1 change: 0 additions & 1 deletion src/fromager/bootstrapper/_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ def __init__(
ctx=ctx,
prev_graph=prev_graph,
multiple_versions=multiple_versions,
cache_wheel_server_url=self.cache_wheel_server_url,
)
# Push items onto the stack as we start to resolve their
# dependencies so at the end we have a list of items that need to
Expand Down
170 changes: 2 additions & 168 deletions tests/test_bootstrap_requirement_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,173 +658,8 @@ def test_resolve_prebuilt_after_source_uses_separate_cache(
assert version2 == Version("1.5")


class TestResolveFromCacheServer:
"""Tests for the cache server fallback in _resolve_from_cache_server."""

def test_returns_newest_version_from_cache(self, tmp_context: WorkContext) -> None:
"""Falls back to cache server and returns only the newest version."""
brr = BootstrapRequirementResolver(
tmp_context,
multiple_versions=True,
cache_wheel_server_url="http://cache.test/simple",
)
req = Requirement("testpkg")

with patch(
"fromager.bootstrap_requirement_resolver.resolver"
".find_all_matching_from_provider",
return_value=[
("http://cache.test/testpkg-3.0.whl", Version("3.0")),
("http://cache.test/testpkg-2.0.whl", Version("2.0")),
],
):
result = brr._resolve_from_cache_server(req)

assert len(result) == 1
assert result[0][1] == Version("3.0")

def test_returns_empty_when_cache_has_no_match(
self, tmp_context: WorkContext
) -> None:
"""Returns empty list when cache server has nothing."""
brr = BootstrapRequirementResolver(
tmp_context,
multiple_versions=True,
cache_wheel_server_url="http://cache.test/simple",
)
req = Requirement("testpkg")

with patch(
"fromager.bootstrap_requirement_resolver.resolver"
".find_all_matching_from_provider",
return_value=[],
):
result = brr._resolve_from_cache_server(req)

assert result == []

def test_returns_empty_on_exception(self, tmp_context: WorkContext) -> None:
"""Returns empty list when cache server query fails."""
brr = BootstrapRequirementResolver(
tmp_context,
multiple_versions=True,
cache_wheel_server_url="http://cache.test/simple",
)
req = Requirement("testpkg")

with patch(
"fromager.bootstrap_requirement_resolver.resolver"
".find_all_matching_from_provider",
side_effect=RuntimeError("connection refused"),
):
result = brr._resolve_from_cache_server(req)

assert result == []

def test_resolve_uses_cache_fallback_when_age_filter_empties(
self, tmp_context: WorkContext
) -> None:
"""resolve() falls back to cache server when age filter produces empty result."""
brr = BootstrapRequirementResolver(
tmp_context,
multiple_versions=True,
cache_wheel_server_url="http://cache.test/simple",
)
req = Requirement("testpkg")

with (
patch.object(brr, "_resolve_from_graph", return_value=None),
patch(
"fromager.bootstrap_requirement_resolver.sources.get_source_provider",
),
patch(
"fromager.bootstrap_requirement_resolver.resolver"
".find_all_matching_from_provider",
return_value=[],
) as mock_pypi,
patch.object(
brr,
"_resolve_from_cache_server",
return_value=[("http://cache.test/testpkg-1.0.whl", Version("1.0"))],
) as mock_cache,
):
result = brr.resolve(
req,
RequirementType.INSTALL,
parent_req=None,
pre_built=False,
return_all_versions=True,
)

mock_pypi.assert_called_once()
mock_cache.assert_called_once_with(req)
assert len(result) == 1
assert result[0][1] == Version("1.0")

def test_resolve_skips_cache_fallback_in_single_version_mode(
self, tmp_context: WorkContext
) -> None:
"""resolve() does not fall back to cache server in single-version mode."""
brr = BootstrapRequirementResolver(
tmp_context,
multiple_versions=False,
cache_wheel_server_url="http://cache.test/simple",
)
req = Requirement("testpkg")

with (
patch.object(brr, "_resolve_from_graph", return_value=None),
patch(
"fromager.bootstrap_requirement_resolver.sources.get_source_provider",
),
patch(
"fromager.bootstrap_requirement_resolver.resolver"
".find_all_matching_from_provider",
return_value=[("url", Version("1.0"))],
),
patch.object(brr, "_resolve_from_cache_server") as mock_cache,
):
brr.resolve(
req,
RequirementType.INSTALL,
parent_req=None,
pre_built=False,
)

mock_cache.assert_not_called()

def test_resolve_skips_cache_fallback_when_no_server_url(
self, tmp_context: WorkContext
) -> None:
"""resolve() does not fall back when no cache_wheel_server_url is set."""
brr = BootstrapRequirementResolver(
tmp_context,
multiple_versions=True,
cache_wheel_server_url="",
)
req = Requirement("testpkg")

with (
patch.object(brr, "_resolve_from_graph", return_value=None),
patch(
"fromager.bootstrap_requirement_resolver.sources.get_source_provider",
),
patch(
"fromager.bootstrap_requirement_resolver.resolver"
".find_all_matching_from_provider",
return_value=[],
),
patch.object(brr, "_resolve_from_cache_server") as mock_cache,
):
brr.resolve(
req,
RequirementType.INSTALL,
parent_req=None,
pre_built=False,
return_all_versions=True,
)

mock_cache.assert_not_called()
class TestAgeFallback:
"""Tests for age filtering fallback behavior."""

def test_multi_version_passes_newest_age_fallback(
self, tmp_context: WorkContext
Expand All @@ -833,7 +668,6 @@ def test_multi_version_passes_newest_age_fallback(
brr = BootstrapRequirementResolver(
tmp_context,
multiple_versions=True,
cache_wheel_server_url="http://cache.test/simple",
)
req = Requirement("testpkg")

Expand Down
Loading