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
128 changes: 83 additions & 45 deletions src/fromager/bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
wheels,
)
from .dependency_graph import DependencyGraph
from .log import req_ctxvar_context
from .log import req_ctxvar_context, requirement_ctxvar
from .requirements_file import RequirementType, SourceType

if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -199,12 +199,14 @@ def __init__(
# Track failed versions in multiple_versions mode
self._failed_versions: dict[tuple[str, str], Exception] = {}

def resolve_and_add_top_level(
def _resolve_and_add_top_level(
self,
req: Requirement,
) -> tuple[str, Version] | None:
"""Resolve a top-level requirement and add it to the dependency graph.
Private method called only by ``bootstrap()``.
This is the pre-resolution phase before recursive bootstrapping begins.
In test mode, catches resolution errors and records them as failures.
Expand Down Expand Up @@ -324,6 +326,76 @@ def resolve_versions(
return_all_versions=return_all_versions,
)

def bootstrap(self, requirements: list[Requirement]) -> None:
"""Bootstrap all top-level requirements and their transitive dependencies.
.. versionadded:: 0.89
Replaces the former ``bootstrap(req, req_type)`` signature.
Resolves each requirement, adds it to the dependency graph, and processes
the full dependency tree using an iterative DFS loop. Handles
``requirement_ctxvar`` context internally; callers do not need to manage it.
In test mode, records failures and continues instead of raising. In
``multiple_versions`` mode, processes all matching versions per requirement.
Args:
requirements: Top-level requirements to resolve and bootstrap.
"""
# Resolve all top-level reqs and build initial stack.
# Use the token pattern (no try/finally) so that if resolution raises
# in normal mode, the context var stays set for the top-level error
# handler in __main__.py to include the package name in its log message.
stack: list[WorkItem] = []
for req in requirements:
token = requirement_ctxvar.set(req)
result = self._resolve_and_add_top_level(req)
requirement_ctxvar.reset(token)
if result is not None:
stack.append(
WorkItem(
req=req,
req_type=RequirementType.TOP_LEVEL,
phase=BootstrapPhase.RESOLVE,
why_snapshot=[],
parent=None,
)
)

self._run_bootstrap_loop(stack)

def _run_bootstrap_loop(self, stack: list[WorkItem]) -> None:
"""Run the iterative DFS bootstrap loop over a pre-built work stack.
Pops items one at a time, dispatches each phase, and pushes any
follow-on items (continuations and new dependencies) back onto the
stack. Updates the progress bar as items complete.
Args:
stack: Initial list of ``WorkItem`` objects to process. Modified
in-place; empty on return.
"""
while stack:
self._record_stack_state(stack)
item = stack.pop()
self.why = list(item.why_snapshot)

with req_ctxvar_context(item.req), self._track_why(item):
try:
new_items = self._dispatch_phase(item)
except Exception as err:
new_items = self._handle_phase_error(item, err)

new_dep_count = sum(
1 for it in new_items if it.phase == BootstrapPhase.RESOLVE
)
if new_dep_count > 0:
self.progressbar.update_total(new_dep_count)
if not new_items:
self.progressbar.update()

stack.extend(new_items)

def _processing_build_requirement(self, current_req_type: RequirementType) -> bool:
"""Are we currently processing a build requirement?
Expand All @@ -350,8 +422,12 @@ def _processing_build_requirement(self, current_req_type: RequirementType) -> bo
logger.debug("is not a build requirement")
return False

def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
"""Bootstrap a package and its dependencies using an iterative loop.
def _bootstrap_one(self, req: Requirement, req_type: RequirementType) -> None:
"""Bootstrap a single requirement using an iterative DFS loop.
Internal method used only by the git URL resolution path
(``_handle_build_requirements``). All other callers should use
``bootstrap(requirements)`` instead.
Uses an explicit LIFO stack instead of recursion to handle arbitrarily
deep dependency graphs without hitting Python's recursion limit.
Expand Down Expand Up @@ -386,49 +462,11 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
)
]

# Main iterative DFS loop
while stack:
self._record_stack_state(stack)
item = stack.pop()
self.why = list(item.why_snapshot)

with req_ctxvar_context(item.req), self._track_why(item):
try:
new_items = self._dispatch_phase(item)
except Exception as err:
new_items = self._handle_phase_error(item, err)

# Progress bar: count new RESOLVE-phase items as new dependencies
new_dep_count = sum(
1 for it in new_items if it.phase == BootstrapPhase.RESOLVE
)
if new_dep_count > 0:
self.progressbar.update_total(new_dep_count)
if not new_items:
self.progressbar.update()

# Phase handlers return [continuation, *new_deps] so extend()
# naturally puts new deps on top of the stack (processed first).
stack.extend(new_items)
self._run_bootstrap_loop(stack)

# Restore why stack for the caller
self.why = saved_why

# In multiple versions mode, report any failures for this requirement
if self.multiple_versions and self._failed_versions:
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.items():
logger.warning(f" - {name}=={ver}: {type(exc).__name__}: {exc}")

@contextlib.contextmanager
def _track_why(
self,
Expand Down Expand Up @@ -626,7 +664,7 @@ def _handle_build_requirements(
# Save/restore self.why because the iterative bootstrap()
# modifies it internally for each work item.
saved_why = list(self.why)
self.bootstrap(req=dep, req_type=build_type)
self._bootstrap_one(req=dep, req_type=build_type)
self.why = saved_why
self.progressbar.update()

Expand Down Expand Up @@ -1437,7 +1475,7 @@ def _phase_start(self, item: WorkItem) -> list[WorkItem]:
assert item.resolved_version is not None
assert item.source_url is not None

# Add to graph (skip TOP_LEVEL, already added in resolve_and_add_top_level)
# Add to graph (skip TOP_LEVEL, already added in _resolve_and_add_top_level)
if item.req_type != RequirementType.TOP_LEVEL:
self._add_to_graph(
item.req,
Expand Down
28 changes: 5 additions & 23 deletions src/fromager/commands/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
resolver,
server,
)
from ..log import requirement_ctxvar
from .build import build_parallel
from .graph import find_why, show_explain_duplicates

Expand Down Expand Up @@ -200,28 +199,11 @@ def bootstrap(
multiple_versions=multiple_versions,
)

# Pre-resolution phase: Resolve all top-level dependencies before recursive
# bootstrapping begins. Test-mode error handling is in Bootstrapper.
# Note: We don't use try/finally here because:
# - In test-mode: exceptions are caught inside resolve_and_add_top_level()
# - In normal mode: exceptions should propagate with context preserved for logging
logger.info("resolving top-level dependencies before building")
resolved_reqs: list[Requirement] = []
for req in to_build:
token = requirement_ctxvar.set(req)
result = bt.resolve_and_add_top_level(req)
if result is not None:
resolved_reqs.append(req)
# If result is None, test_mode or multiple_versions recorded the failure
requirement_ctxvar.reset(token)

# Bootstrap only packages that were successfully resolved
# Note: Same pattern - no try/finally to preserve context for error logging
for req in resolved_reqs:
token = requirement_ctxvar.set(req)
bt.bootstrap(req, requirements_file.RequirementType.TOP_LEVEL)
progressbar.update()
requirement_ctxvar.reset(token)
# Resolve and bootstrap all top-level dependencies and their transitive
# dependencies. Context management and error handling are handled internally
# by Bootstrapper.bootstrap().
logger.info("resolving and bootstrapping top-level dependencies")
bt.bootstrap(list(to_build))

# Finalize test mode and check for failures
exit_code = bt.finalize()
Expand Down
4 changes: 0 additions & 4 deletions tests/test_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,6 @@ def test_multiple_versions_auto_disables_constraints(
mock_progress.return_value.__enter__.return_value = Mock()
mock_progress.return_value.__exit__.return_value = None
mock_bt_instance = Mock()
mock_bt_instance.resolve_and_add_top_level.return_value = ("url", Version("1.0"))
mock_bt_instance.finalize.return_value = 0
mock_bootstrapper.return_value = mock_bt_instance

Expand Down Expand Up @@ -608,7 +607,6 @@ def test_multiple_versions_with_skip_constraints_no_duplicate_log(
mock_progress.return_value.__enter__.return_value = Mock()
mock_progress.return_value.__exit__.return_value = None
mock_bt_instance = Mock()
mock_bt_instance.resolve_and_add_top_level.return_value = ("url", Version("1.0"))
mock_bt_instance.finalize.return_value = 0
mock_bootstrapper.return_value = mock_bt_instance

Expand Down Expand Up @@ -657,7 +655,6 @@ def test_without_multiple_versions_constraints_not_disabled(
mock_progress.return_value.__enter__.return_value = Mock()
mock_progress.return_value.__exit__.return_value = None
mock_bt_instance = Mock()
mock_bt_instance.resolve_and_add_top_level.return_value = ("url", Version("1.0"))
mock_bt_instance.finalize.return_value = 0
mock_bootstrapper.return_value = mock_bt_instance
mock_write_constraints.return_value = True
Expand Down Expand Up @@ -720,7 +717,6 @@ def test_max_release_age_sets_context(
mock_progress.return_value.__enter__.return_value = Mock()
mock_progress.return_value.__exit__.return_value = None
mock_bt_instance = Mock()
mock_bt_instance.resolve_and_add_top_level.return_value = ("url", Version("1.0"))
mock_bt_instance.finalize.return_value = 0
mock_bootstrapper.return_value = mock_bt_instance

Expand Down
5 changes: 2 additions & 3 deletions tests/test_bootstrap_test_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from packaging.requirements import Requirement

from fromager import bootstrapper, context
from fromager.requirements_file import RequirementType


class TestBootstrapperInitialization:
Expand Down Expand Up @@ -267,7 +266,7 @@ def test_resolution_failure_recorded_in_test_mode(
side_effect=RuntimeError("Version resolution failed"),
):
# Should not raise in test mode
bt.bootstrap(req=req, req_type=RequirementType.TOP_LEVEL)
bt.bootstrap([req])

# Verify failure was recorded
assert len(bt.failed_packages) == 1
Expand All @@ -293,4 +292,4 @@ def test_resolution_failure_raises_in_normal_mode(
side_effect=RuntimeError("Version resolution failed"),
):
with pytest.raises(RuntimeError, match="Version resolution failed"):
bt.bootstrap(req=req, req_type=RequirementType.TOP_LEVEL)
bt.bootstrap([req])
Loading
Loading