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
39 changes: 26 additions & 13 deletions src/fromager/bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,7 @@ def __init__(
self.failed_packages: list[FailureRecord] = []

# Track failed versions in multiple_versions mode
# Maps (package_name, version) -> exception info
self._failed_versions: list[tuple[str, str, Exception]] = []
self._failed_versions: dict[tuple[str, str], Exception] = {}

def resolve_and_add_top_level(
self,
Expand Down Expand Up @@ -417,16 +416,17 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:

# In multiple versions mode, report any failures for this requirement
if self.multiple_versions and self._failed_versions:
failed_for_req = [
(name, ver, exc)
for name, ver, exc in self._failed_versions
if name == canonicalize_name(req.name)
]
req_name = canonicalize_name(req.name)
failed_for_req = {
(name, ver): exc
for (name, ver), exc in self._failed_versions.items()
if name == req_name
}
if failed_for_req:
logger.warning(
f"{req.name}: {len(failed_for_req)} version(s) failed to bootstrap"
)
for name, ver, exc in failed_for_req:
for (name, ver), exc in failed_for_req.items():
logger.warning(f" - {name}=={ver}: {type(exc).__name__}: {exc}")

@contextlib.contextmanager
Expand Down Expand Up @@ -1353,9 +1353,10 @@ def _phase_resolve(self, item: WorkItem) -> list[WorkItem]:
"""RESOLVE phase: resolve versions and expand into START-phase items.

Centralizes version resolution so all dependencies are expanded
uniformly. In multiple_versions mode, filters out versions whose
wheels are already cached to avoid redundant builds and
transitive dependency processing.
uniformly. In multiple_versions mode, filters out versions that
already failed in this run and versions whose wheels are already
cached to avoid redundant builds and transitive dependency
processing.

Returns:
One START-phase item per resolved version that needs building.
Expand All @@ -1370,6 +1371,18 @@ def _phase_resolve(self, item: WorkItem) -> list[WorkItem]:
raise RuntimeError(f"Could not resolve any versions for {item.req}")

if self.multiple_versions:
pkg_name = canonicalize_name(item.req.name)
resolved_versions = [
(url, ver)
for url, ver in resolved_versions
if (pkg_name, str(ver)) not in self._failed_versions
]
if not resolved_versions:
raise RuntimeError(
f"Could not resolve any versions for {item.req}"
f" (all candidates failed previously)"
)

logger.info(f"resolved {len(resolved_versions)} version(s) for {item.req}")
filtered: list[tuple[str, Version]] = []
for source_url, version in resolved_versions:
Expand Down Expand Up @@ -1831,7 +1844,7 @@ def _record_failed_version(
) -> None:
"""Record a version failure in multiple versions mode."""
pkg_name = canonicalize_name(req.name)
self._failed_versions.append((pkg_name, version, err))
self._failed_versions[(pkg_name, version)] = err
logger.warning(
"%s==%s: %s: %s: %s",
req.name,
Expand All @@ -1844,7 +1857,7 @@ def _record_failed_version(
def _log_failed_versions_table(self) -> None:
"""Log a summary table of all failed versions."""
logger.warning("%d version(s) failed to bootstrap:", len(self._failed_versions))
for name, ver, exc in self._failed_versions:
for (name, ver), exc in self._failed_versions.items():
logger.warning(" %s==%s: %s: %s", name, ver, type(exc).__name__, exc)

def finalize(self) -> int:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,9 @@ def mock_dispatch(item: bootstrapper.WorkItem) -> list[bootstrapper.WorkItem]:

# Verify that version 1.5 is in failed_versions
assert len(bt._failed_versions) == 1
pkg_name, version_str, exc = bt._failed_versions[0]
assert pkg_name == canonicalize_name("testpkg")
assert version_str == "1.5"
key = (canonicalize_name("testpkg"), "1.5")
assert key in bt._failed_versions
exc = bt._failed_versions[key]
assert isinstance(exc, ValueError)
assert str(exc) == "Simulated failure for version 1.5"

Expand Down
83 changes: 76 additions & 7 deletions tests/test_bootstrapper_iterative.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,76 @@ def test_empty_resolution_raises_runtime_error(
):
bt._phase_resolve(item)

def test_filters_failed_versions_in_multiple_versions_mode(
self, tmp_context: WorkContext
) -> None:
"""Previously failed versions are excluded before creating START items."""
bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True)
item = _make_resolve_item()

bt._failed_versions[(canonicalize_name("testpkg"), "2.0")] = RuntimeError(
"boom"
)

with (
patch.object(
bt,
"resolve_versions",
return_value=[
("url-3.0", Version("3.0")),
("url-2.0", Version("2.0")),
("url-1.0", Version("1.0")),
],
),
patch.object(bt, "_find_cached_wheel", return_value=(None, None)),
):
result = bt._phase_resolve(item)

versions = {str(it.resolved_version) for it in result}
assert versions == {"1.0", "3.0"}

def test_failed_version_filter_does_not_apply_in_single_version_mode(
self, tmp_context: WorkContext
) -> None:
"""Failed-version filtering only applies in multiple_versions mode."""
bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=False)
item = _make_resolve_item()

bt._failed_versions[(canonicalize_name("testpkg"), "1.0")] = RuntimeError(
"boom"
)

with patch.object(
bt,
"resolve_versions",
return_value=[("url-1.0", Version("1.0"))],
):
result = bt._phase_resolve(item)

assert len(result) == 1
assert result[0].resolved_version == Version("1.0")

def test_all_versions_failed_raises_runtime_error(
self, tmp_context: WorkContext
) -> None:
"""Raises RuntimeError when all resolved versions already failed."""
bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True)
item = _make_resolve_item()

bt._failed_versions[(canonicalize_name("testpkg"), "1.0")] = RuntimeError(
"boom"
)

with (
patch.object(
bt,
"resolve_versions",
return_value=[("url-1.0", Version("1.0"))],
),
pytest.raises(RuntimeError, match="failed previously"),
):
bt._phase_resolve(item)


class TestPhaseStart:
def test_new_item_advances_to_prepare_source(
Expand Down Expand Up @@ -584,9 +654,9 @@ def test_resolve_error_in_multiple_versions_mode_continues(

assert result == []
assert len(bt._failed_versions) == 1
assert bt._failed_versions[0][0] == canonicalize_name("testpkg")
assert bt._failed_versions[0][1] == "unresolved"
assert bt._failed_versions[0][2] is err
key = (canonicalize_name("testpkg"), "unresolved")
assert key in bt._failed_versions
assert bt._failed_versions[key] is err

# -- Build phase errors in test mode --

Expand Down Expand Up @@ -679,8 +749,7 @@ def test_multiple_versions_records_and_removes_from_graph(
assert result == []
# Failure recorded
assert len(bt._failed_versions) == 1
assert bt._failed_versions[0][0] == canonicalize_name("testpkg")
assert bt._failed_versions[0][1] == "1.0"
assert (canonicalize_name("testpkg"), "1.0") in bt._failed_versions
# Removed from graph
key = f"{canonicalize_name('testpkg')}==1.0"
assert key not in tmp_context.dependency_graph.nodes
Expand Down Expand Up @@ -879,7 +948,7 @@ def mock_dispatch(item: WorkItem) -> list[WorkItem]:
bt.bootstrap(Requirement("pkg"), RequirementType.INSTALL)

assert len(bt._failed_versions) == 1
assert bt._failed_versions[0][1] == "1.5"
assert (canonicalize_name("pkg"), "1.5") in bt._failed_versions
# Other versions processed successfully (in graph)
assert f"{canonicalize_name('pkg')}==2.0" in tmp_context.dependency_graph.nodes
assert f"{canonicalize_name('pkg')}==1.0" in tmp_context.dependency_graph.nodes
Expand Down Expand Up @@ -918,7 +987,7 @@ def mock_dispatch(item: WorkItem) -> list[WorkItem]:

assert "good-pkg" in completed
assert "another-good" in completed
failed_names = [name for name, _, _ in bt._failed_versions]
failed_names = [name for name, _ in bt._failed_versions]
assert canonicalize_name("bad-dep") in failed_names

def test_test_mode_continues_after_failure(self, tmp_context: WorkContext) -> None:
Expand Down
Loading