diff --git a/e2e/ci_bootstrap_suite.sh b/e2e/ci_bootstrap_suite.sh index bc7527a5..dc3707c2 100755 --- a/e2e/ci_bootstrap_suite.sh +++ b/e2e/ci_bootstrap_suite.sh @@ -15,6 +15,7 @@ test_section "basic bootstrap tests" run_test "bootstrap" run_test "bootstrap_extras" run_test "bootstrap_build_tags" +run_test "bootstrap_iterative" test_section "bootstrap constraint tests" run_test "bootstrap_constraints" diff --git a/e2e/test_bootstrap_iterative.sh b/e2e/test_bootstrap_iterative.sh new file mode 100755 index 00000000..fa757b5a --- /dev/null +++ b/e2e/test_bootstrap_iterative.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# -*- indent-tabs-mode: nil; tab-width: 2; sh-indentation: 2; -*- + +# Tests that the iterative bootstrap produces correct results for a +# package with transitive dependencies. Verifies that the LIFO-based +# iterative loop builds dependencies in the correct order (deps before +# their dependents) and that the build-order.json and graph.json are +# consistent. + +SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +source "$SCRIPTDIR/common.sh" + +fromager \ + --log-file="$OUTDIR/bootstrap.log" \ + --error-log-file="$OUTDIR/fromager-errors.log" \ + --sdists-repo="$OUTDIR/sdists-repo" \ + --wheels-repo="$OUTDIR/wheels-repo" \ + --work-dir="$OUTDIR/work-dir" \ + bootstrap 'stevedore==5.2.0' + +# Verify expected output files exist +EXPECTED_FILES=" +$OUTDIR/wheels-repo/downloads/setuptools-*.whl +$OUTDIR/wheels-repo/downloads/pbr-*.whl +$OUTDIR/wheels-repo/downloads/stevedore-*.whl +$OUTDIR/work-dir/build-order.json +$OUTDIR/work-dir/graph.json +" + +pass=true +for pattern in $EXPECTED_FILES; do + if [ ! -f "${pattern}" ]; then + echo "Did not find $pattern" 1>&2 + pass=false + fi +done + +# Verify build order: dependencies must appear before dependents +# pbr and setuptools must come before stevedore in build-order.json +BUILD_ORDER="$OUTDIR/work-dir/build-order.json" +pbr_idx=$(python3 -c " +import json, sys +data = json.load(open('$BUILD_ORDER')) +dists = [e['dist'] for e in data] +print(dists.index('pbr') if 'pbr' in dists else -1) +") +stevedore_idx=$(python3 -c " +import json, sys +data = json.load(open('$BUILD_ORDER')) +dists = [e['dist'] for e in data] +print(dists.index('stevedore') if 'stevedore' in dists else -1) +") + +if [ "$pbr_idx" -ge "$stevedore_idx" ] || [ "$pbr_idx" -eq "-1" ]; then + echo "ERROR: pbr (idx=$pbr_idx) must appear before stevedore (idx=$stevedore_idx) in build order" 1>&2 + pass=false +fi + +# Verify graph.json has the expected dependency edges +python3 -c " +import json, sys +graph = json.load(open('$OUTDIR/work-dir/graph.json')) +# stevedore should exist as a node +stevedore_nodes = [k for k in graph if k.startswith('stevedore==')] +if not stevedore_nodes: + print('ERROR: stevedore not found in graph', file=sys.stderr) + sys.exit(1) +# pbr should exist as a node +pbr_nodes = [k for k in graph if k.startswith('pbr==')] +if not pbr_nodes: + print('ERROR: pbr not found in graph', file=sys.stderr) + sys.exit(1) +print('Graph structure verified') +" || pass=false + +$pass diff --git a/src/fromager/bootstrapper.py b/src/fromager/bootstrapper.py index a2eb9a3e..9574c4ed 100644 --- a/src/fromager/bootstrapper.py +++ b/src/fromager/bootstrapper.py @@ -12,6 +12,7 @@ import tempfile import typing import zipfile +from enum import StrEnum from urllib.parse import urlparse import requests.exceptions @@ -47,9 +48,10 @@ @dataclasses.dataclass class SourceBuildResult: - """Result of building a package from source. + """Result of building or downloading a package. - Used to return multiple values from _build_from_source(). + Captures the output artifacts from either a source build or + prebuilt wheel download, used across bootstrap phases. """ wheel_filename: pathlib.Path | None @@ -82,6 +84,66 @@ class FailureRecord(typing.TypedDict): failure_type: FailureType +class BootstrapPhase(StrEnum): + """Processing phases for iterative bootstrap. + + All packages: RESOLVE -> START -> ... + Source packages: ... -> PREPARE_SOURCE -> PREPARE_BUILD -> BUILD + -> PROCESS_INSTALL_DEPS -> COMPLETE. + Prebuilt packages: ... -> PREPARE_SOURCE -> PROCESS_INSTALL_DEPS -> COMPLETE. + """ + + RESOLVE = "resolve" + START = "start" + PREPARE_SOURCE = "prepare-source" + PREPARE_BUILD = "prepare-build" + BUILD = "build" + PROCESS_INSTALL_DEPS = "process-install-deps" + COMPLETE = "complete" + + @property + def tracks_why(self) -> bool: + """Whether this phase pushes onto the dependency-chain (why) stack.""" + return self not in (BootstrapPhase.RESOLVE, BootstrapPhase.START) + + +@dataclasses.dataclass +class WorkItem: + """A unit of work in the iterative bootstrap loop. + + Carries identity fields set at creation time and accumulated state + populated across phases as processing advances. + + Items enter at the RESOLVE phase with only req and req_type set. + The RESOLVE phase populates source_url and resolved_version, then + creates new items at the START phase for each resolved version. + """ + + # Identity (set at creation) + req: Requirement + req_type: RequirementType + phase: BootstrapPhase + why_snapshot: list[tuple[RequirementType, Requirement, Version]] + parent: tuple[Requirement, Version] | None = None + + # Populated by RESOLVE phase (None until then) + source_url: str | None = None + resolved_version: Version | None = None + + build_sdist_only: bool = False + + # Accumulated state (populated during phases) + build_env: build_environment.BuildEnvironment | None = None + sdist_root_dir: pathlib.Path | None = None + unpack_dir: pathlib.Path | None = None + cached_wheel_filename: pathlib.Path | None = None + build_result: SourceBuildResult | None = None + pbi_pre_built: bool = False + build_system_deps: set[Requirement] = dataclasses.field(default_factory=set) + build_backend_deps: set[Requirement] = dataclasses.field(default_factory=set) + build_sdist_deps: set[Requirement] = dataclasses.field(default_factory=set) + + class Bootstrapper: def __init__( self, @@ -272,10 +334,10 @@ def _processing_build_requirement(self, current_req_type: RequirementType) -> bo return False def bootstrap(self, req: Requirement, req_type: RequirementType) -> None: - """Bootstrap a package and its dependencies. + """Bootstrap a package and its dependencies using an iterative loop. - Handles setup, validation, and error handling. Delegates actual build - work to _bootstrap_impl(). + Uses an explicit LIFO stack instead of recursion to handle arbitrarily + deep dependency graphs without hitting Python's recursion limit. In test mode, catches build exceptions, records package name, and continues. In normal mode, raises exceptions immediately (fail-fast). @@ -285,29 +347,54 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None: """ logger.info(f"bootstrapping {req} as {req_type} dependency of {self.why[-1:]}") - # Resolve versions - get all if multiple_versions mode is enabled, else get highest - # In test mode, record resolution failures and continue. - try: - resolved_versions = self.resolve_versions( + # Capture parent from current why stack before creating work items + parent: tuple[Requirement, Version] | None = None + if self.why: + _, parent_req, parent_version = self.why[-1] + parent = (parent_req, parent_version) + + # Save the why stack so we can restore it after the iterative loop + # (the loop modifies self.why for each work item) + saved_why = list(self.why) + + # Single RESOLVE item — resolution, version expansion, and error + # handling all happen inside the loop via _phase_resolve. + stack: list[WorkItem] = [ + WorkItem( req=req, req_type=req_type, - return_all_versions=self.multiple_versions, + phase=BootstrapPhase.RESOLVE, + why_snapshot=list(self.why), + parent=parent, ) - if self.multiple_versions: - logger.info(f"resolved {len(resolved_versions)} version(s) for {req}") - except Exception as err: - if not self.test_mode: - raise - self._record_test_mode_failure(req, None, err, "resolution") - return + ] - # Check if resolution returned no versions - if not resolved_versions: - raise RuntimeError(f"Could not resolve any versions for {req}") + # Main iterative DFS loop + while 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) - # Bootstrap each resolved version - for source_url, resolved_version in resolved_versions: - self._bootstrap_single_version(req, req_type, source_url, resolved_version) + # 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) + + # 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: @@ -323,224 +410,22 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None: for name, ver, exc in failed_for_req: logger.warning(f" - {name}=={ver}: {type(exc).__name__}: {exc}") - def _bootstrap_single_version( - self, - req: Requirement, - req_type: RequirementType, - source_url: str, - resolved_version: Version, - ) -> None: - """Bootstrap a single version of a package. - - Extracted from bootstrap() to handle both single and multiple version modes. - """ - # Capture parent before _track_why pushes current package onto the stack - parent: tuple[Requirement, Version] | None = None - if self.why: - _, parent_req, parent_version = self.why[-1] - parent = (parent_req, parent_version) - - # Update dependency graph unconditionally (before seen check to capture all edges) - # Skip for TOP_LEVEL as they were already added in resolve_and_add_top_level() - if req_type != RequirementType.TOP_LEVEL: - self._add_to_graph(req, req_type, resolved_version, source_url, parent) - - # Build sdist-only (no wheel) if flag is set, unless this is a build - # requirement which always needs a full wheel. - build_sdist_only = self.sdist_only and not self._processing_build_requirement( - req_type - ) - - # Avoid cyclic dependencies and redundant processing. - if self._has_been_seen(req, resolved_version, build_sdist_only): - logger.debug( - f"redundant {req_type} dependency {req} " - f"({resolved_version}, sdist_only={build_sdist_only}) for {self._explain}" - ) - return - self._mark_as_seen(req, resolved_version, build_sdist_only) - - logger.info(f"new {req_type} dependency {req} resolves to {resolved_version}") - - # Track dependency chain - context manager ensures cleanup even on exception - with self._track_why(req_type, req, resolved_version): - try: - self._bootstrap_impl( - req, req_type, source_url, resolved_version, build_sdist_only - ) - except Exception as err: - # In test_mode, record failure and continue - if self.test_mode: - self._record_test_mode_failure( - req, str(resolved_version), err, "bootstrap" - ) - return - - # In multiple_versions mode, record failure and continue to next version - if self.multiple_versions: - pkg_name = canonicalize_name(req.name) - self._failed_versions.append((pkg_name, str(resolved_version), err)) - logger.warning( - f"{req.name}=={resolved_version}: failed to bootstrap: {type(err).__name__}: {err}" - ) - # Remove failed node from graph since bootstrap didn't complete - self.ctx.dependency_graph.remove_dependency( - pkg_name, resolved_version - ) - self.ctx.write_to_graph_to_file() - return - - # Otherwise, raise the exception (fail-fast) - raise - - def _bootstrap_impl( - self, - req: Requirement, - req_type: RequirementType, - source_url: str, - resolved_version: Version, - build_sdist_only: bool, - ) -> None: - """Internal implementation - performs the actual bootstrap work. - - Called by bootstrap() after setup, validation, and seen-checking. - - Args: - req: The requirement to bootstrap. - req_type: The type of requirement. - source_url: The resolved source URL. - resolved_version: The resolved version. - build_sdist_only: Whether to build only sdist (no wheel). - - Error Handling: - Fatal errors (source build, prebuilt download) raise exceptions - for bootstrap() to catch and record. - - Non-fatal errors (post-hook, dependency extraction) are recorded - locally and processing continues. These are recorded here because - the package build succeeded - only optional post-processing failed. - """ - constraint = self.ctx.constraints.get_constraint(req.name) - if constraint: - logger.info( - f"incoming requirement {req} matches constraint {constraint}. Will apply both." - ) - - pbi = self.ctx.package_build_info(req) - - cached_wheel_filename: pathlib.Path | None = None - unpacked_cached_wheel: pathlib.Path | None = None - - if pbi.pre_built: - wheel_filename, unpack_dir = self._download_prebuilt( - req=req, - req_type=req_type, - resolved_version=resolved_version, - wheel_url=source_url, - ) - build_result = SourceBuildResult( - wheel_filename=wheel_filename, - sdist_filename=None, - unpack_dir=unpack_dir, - sdist_root_dir=None, - build_env=None, - source_type=SourceType.PREBUILT, - ) - else: - # Look for an existing wheel in caches before building - cached_wheel_filename, unpacked_cached_wheel = self._find_cached_wheel( - req, resolved_version - ) - - # Build from source (handles test-mode fallback internally) - build_result = self._build_from_source( - req=req, - resolved_version=resolved_version, - source_url=source_url, - req_type=req_type, - build_sdist_only=build_sdist_only, - cached_wheel_filename=cached_wheel_filename, - unpacked_cached_wheel=unpacked_cached_wheel, - ) - - # Run post-bootstrap hooks (non-fatal in test mode) - try: - hooks.run_post_bootstrap_hooks( - ctx=self.ctx, - req=req, - dist_name=canonicalize_name(req.name), - dist_version=str(resolved_version), - sdist_filename=build_result.sdist_filename, - wheel_filename=build_result.wheel_filename, - ) - except Exception as hook_error: - if not self.test_mode: - raise - self._record_test_mode_failure( - req, str(resolved_version), hook_error, "hook", "warning" - ) - - # Extract install dependencies (non-fatal in test mode) - try: - install_dependencies = self._get_install_dependencies( - req=req, - resolved_version=resolved_version, - wheel_filename=build_result.wheel_filename, - sdist_filename=build_result.sdist_filename, - sdist_root_dir=build_result.sdist_root_dir, - build_env=build_result.build_env, - unpack_dir=build_result.unpack_dir, - ) - except Exception as dep_error: - if not self.test_mode: - raise - self._record_test_mode_failure( - req, - str(resolved_version), - dep_error, - "dependency_extraction", - "warning", - ) - install_dependencies = [] - - logger.debug( - "install dependencies: %s", - ", ".join(sorted(str(r) for r in install_dependencies)), - ) - - self._add_to_build_order( - req=req, - version=resolved_version, - source_url=source_url, - source_type=build_result.source_type, - prebuilt=pbi.pre_built, - constraint=constraint, - ) - - self.progressbar.update_total(len(install_dependencies)) - for dep in self._sort_requirements(install_dependencies): - with req_ctxvar_context(dep): - # In test mode, bootstrap() catches and records failures internally. - # In normal mode, it raises immediately which we propagate. - self.bootstrap(req=dep, req_type=RequirementType.INSTALL) - self.progressbar.update() - - # Clean up build directories - self.ctx.clean_build_dirs(build_result.sdist_root_dir, build_result.build_env) - @contextlib.contextmanager def _track_why( self, - req_type: RequirementType, - req: Requirement, - resolved_version: Version, + item: WorkItem, ) -> typing.Generator[None, None, None]: """Context manager to track dependency chain in self.why stack. - Ensures the entry is always popped from the stack, even if an - exception occurs during processing. This prevents stack corruption. + No-op for phases where tracks_why is False (RESOLVE and START). + For all other phases, pushes the item onto the why stack and + ensures it is popped even if an exception occurs. """ - self.why.append((req_type, req, resolved_version)) + if not item.phase.tracks_why: + yield + return + assert item.resolved_version is not None + self.why.append((item.req_type, item.req, item.resolved_version)) try: yield finally: @@ -644,6 +529,12 @@ def _prepare_build_dependencies( sdist_root_dir: pathlib.Path, build_env: build_environment.BuildEnvironment, ) -> set[Requirement]: + """Prepare build dependencies for a package. + + Only used by the git URL resolution path + (_resolve_version_from_git_url -> _get_version_from_package_metadata). + The main iterative bootstrap loop handles build deps via phase handlers. + """ # build system build_system_dependencies = dependencies.get_build_system_dependencies( ctx=self.ctx, @@ -703,13 +594,21 @@ def _handle_build_requirements( build_type: RequirementType, build_dependencies: set[Requirement], ) -> None: + """Bootstrap build dependencies. + + Only used by the git URL resolution path + (_resolve_version_from_git_url -> _get_version_from_package_metadata). + The main iterative bootstrap loop handles build deps via phase handlers. + """ self.progressbar.update_total(len(build_dependencies)) for dep in self._sort_requirements(build_dependencies): with req_ctxvar_context(dep): - # In test mode, bootstrap() catches and records failures internally. - # In normal mode, it raises immediately which we propagate. + # 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.why = saved_why self.progressbar.update() def _download_prebuilt( @@ -887,105 +786,6 @@ def _do_build( ) return self._build_wheel(req, resolved_version, sdist_root_dir, build_env) - def _build_from_source( - self, - req: Requirement, - resolved_version: Version, - source_url: str, - req_type: RequirementType, - build_sdist_only: bool, - cached_wheel_filename: pathlib.Path | None, - unpacked_cached_wheel: pathlib.Path | None, - ) -> SourceBuildResult: - """Build package from source. - - Orchestrates download, preparation, build environment setup, and build. - In test mode, attempts pre-built fallback on failure. - - Raises: - Exception: In normal mode, if build fails. - In test mode, only if build fails AND fallback also fails. - """ - try: - # Download and prepare source (if no cached wheel) - if not unpacked_cached_wheel: - logger.debug("no cached wheel, downloading sources") - source_filename = self._download_source( - req=req, - resolved_version=resolved_version, - source_url=source_url, - ) - sdist_root_dir = self._prepare_source( - req=req, - resolved_version=resolved_version, - source_filename=source_filename, - ) - else: - logger.debug(f"have cached wheel in {unpacked_cached_wheel}") - sdist_root_dir = unpacked_cached_wheel / unpacked_cached_wheel.stem - - assert sdist_root_dir is not None - - if sdist_root_dir.parent.parent != self.ctx.work_dir: - raise ValueError( - f"'{sdist_root_dir}/../..' should be {self.ctx.work_dir}" - ) - unpack_dir = sdist_root_dir.parent - - build_env = self._create_build_env( - req=req, - resolved_version=resolved_version, - parent_dir=sdist_root_dir.parent, - ) - - # Prepare build dependencies (always needed) - # Note: This may recursively call bootstrap() for build deps, - # which has its own error handling. - self._prepare_build_dependencies( - req=req, - resolved_version=resolved_version, - sdist_root_dir=sdist_root_dir, - build_env=build_env, - ) - - # Build wheel or sdist - wheel_filename, sdist_filename = self._do_build( - req=req, - resolved_version=resolved_version, - sdist_root_dir=sdist_root_dir, - build_env=build_env, - build_sdist_only=build_sdist_only, - cached_wheel_filename=cached_wheel_filename, - ) - - source_type = sources.get_source_type(self.ctx, req) - - return SourceBuildResult( - wheel_filename=wheel_filename, - sdist_filename=sdist_filename, - unpack_dir=unpack_dir, - sdist_root_dir=sdist_root_dir, - build_env=build_env, - source_type=source_type, - ) - - except Exception as build_error: - if not self.test_mode: - raise - - # Test mode: attempt pre-built fallback - fallback_result = self._handle_test_mode_failure( - req=req, - resolved_version=resolved_version, - req_type=req_type, - build_error=build_error, - ) - if fallback_result is None: - # Fallback failed, re-raise for bootstrap() to catch - raise - - return fallback_result - def _handle_test_mode_failure( self, req: Requirement, @@ -1473,6 +1273,470 @@ def _add_to_build_order( # converted to JSON without help. json.dump(self._build_stack, f, indent=2, default=str) + # ---- Iterative bootstrap: phase handlers and helpers ---- + + def _create_unresolved_work_items( + self, + deps: typing.Iterable[Requirement], + dep_req_type: RequirementType, + parent_req: Requirement, + parent_version: Version, + ) -> list[WorkItem]: + """Create RESOLVE-phase work items for dependencies. + + Called inside a parent's _track_why context so that why_snapshot + captures the parent's dependency chain. Resolution and error + handling happen later when each item's RESOLVE phase runs. + """ + return [ + WorkItem( + req=dep, + req_type=dep_req_type, + phase=BootstrapPhase.RESOLVE, + why_snapshot=list(self.why), + parent=(parent_req, parent_version), + ) + for dep in self._sort_requirements(deps) + ] + + 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. Future filtering (e.g. versions already on disk) and + parallelization can be added here in one place. + + Returns: + One START-phase item per resolved version. + """ + resolved_versions = self.resolve_versions( + item.req, + item.req_type, + return_all_versions=self.multiple_versions, + ) + if not resolved_versions: + raise RuntimeError(f"Could not resolve any versions for {item.req}") + + if self.multiple_versions: + logger.info(f"resolved {len(resolved_versions)} version(s) for {item.req}") + + # Build list so highest version ends up on top of the stack + # (last element after extend) and is processed first. + items: list[WorkItem] = [] + for source_url, version in reversed(resolved_versions): + items.append( + WorkItem( + req=item.req, + req_type=item.req_type, + phase=BootstrapPhase.START, + why_snapshot=list(item.why_snapshot), + parent=item.parent, + source_url=source_url, + resolved_version=version, + ) + ) + return items + + def _phase_start(self, item: WorkItem) -> list[WorkItem]: + """START phase: add to graph, check if already seen. + + _track_why is a no-op for this phase (tracks_why is False), + matching the original behavior where graph addition and + seen-check happen before pushing onto the why stack. + + Returns: + Empty list if already seen (nothing to do). + [item] advanced to PREPARE_SOURCE if this is new work. + """ + 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) + if item.req_type != RequirementType.TOP_LEVEL: + self._add_to_graph( + item.req, + item.req_type, + item.resolved_version, + item.source_url, + item.parent, + ) + + item.build_sdist_only = ( + self.sdist_only and not self._processing_build_requirement(item.req_type) + ) + + if self._has_been_seen(item.req, item.resolved_version, item.build_sdist_only): + logger.debug( + f"redundant {item.req_type} dependency {item.req} " + f"({item.resolved_version}, sdist_only={item.build_sdist_only}) " + f"for {self._explain}" + ) + return [] + self._mark_as_seen(item.req, item.resolved_version, item.build_sdist_only) + + logger.info( + f"new {item.req_type} dependency {item.req} " + f"resolves to {item.resolved_version}" + ) + + item.pbi_pre_built = self.ctx.package_build_info(item.req).pre_built + item.phase = BootstrapPhase.PREPARE_SOURCE + return [item] + + def _phase_prepare_source(self, item: WorkItem) -> list[WorkItem]: + """PREPARE_SOURCE phase: download source or prebuilt, get build system deps. + + Returns: + Prebuilt: [item] advanced to PROCESS_INSTALL_DEPS (skip build phases). + Source: [item advanced to PREPARE_BUILD, *build_system_dep_items]. + """ + assert item.resolved_version is not None + assert item.source_url is not None + + constraint = self.ctx.constraints.get_constraint(item.req.name) + if constraint: + logger.info( + f"incoming requirement {item.req} matches constraint " + f"{constraint}. Will apply both." + ) + + if item.pbi_pre_built: + wheel_filename, unpack_dir = self._download_prebuilt( + req=item.req, + req_type=item.req_type, + resolved_version=item.resolved_version, + wheel_url=item.source_url, + ) + item.build_result = SourceBuildResult( + wheel_filename=wheel_filename, + sdist_filename=None, + unpack_dir=unpack_dir, + sdist_root_dir=None, + build_env=None, + source_type=SourceType.PREBUILT, + ) + item.phase = BootstrapPhase.PROCESS_INSTALL_DEPS + return [item] + + # Source build path + cached_wheel, unpacked = self._find_cached_wheel( + item.req, item.resolved_version + ) + item.cached_wheel_filename = cached_wheel + + if not unpacked: + logger.debug("no cached wheel, downloading sources") + source_filename = self._download_source( + req=item.req, + resolved_version=item.resolved_version, + source_url=item.source_url, + ) + sdist_root_dir = self._prepare_source( + req=item.req, + resolved_version=item.resolved_version, + source_filename=source_filename, + ) + else: + logger.debug(f"have cached wheel in {unpacked}") + sdist_root_dir = unpacked / unpacked.stem + + assert sdist_root_dir is not None + + if sdist_root_dir.parent.parent != self.ctx.work_dir: + raise ValueError(f"'{sdist_root_dir}/../..' should be {self.ctx.work_dir}") + item.sdist_root_dir = sdist_root_dir + item.unpack_dir = sdist_root_dir.parent + + item.build_env = self._create_build_env( + req=item.req, + resolved_version=item.resolved_version, + parent_dir=sdist_root_dir.parent, + ) + + # Get build system dependencies + item.build_system_deps = dependencies.get_build_system_dependencies( + ctx=self.ctx, + req=item.req, + version=item.resolved_version, + sdist_root_dir=sdist_root_dir, + ) + + dep_items = self._create_unresolved_work_items( + item.build_system_deps, + RequirementType.BUILD_SYSTEM, + item.req, + item.resolved_version, + ) + + item.phase = BootstrapPhase.PREPARE_BUILD + return [item] + dep_items + + def _phase_prepare_build(self, item: WorkItem) -> list[WorkItem]: + """PREPARE_BUILD phase: install system deps, get backend/sdist deps. + + Returns: + [item advanced to BUILD, *backend_dep_items, *sdist_dep_items]. + """ + assert item.resolved_version is not None + assert item.build_env is not None + assert item.sdist_root_dir is not None + + # Install build system deps (their wheels exist from DFS processing) + item.build_env.install(item.build_system_deps) + + # Get build backend dependencies + item.build_backend_deps = dependencies.get_build_backend_dependencies( + ctx=self.ctx, + req=item.req, + version=item.resolved_version, + sdist_root_dir=item.sdist_root_dir, + build_env=item.build_env, + ) + + # Get build sdist dependencies + item.build_sdist_deps = dependencies.get_build_sdist_dependencies( + ctx=self.ctx, + req=item.req, + version=item.resolved_version, + sdist_root_dir=item.sdist_root_dir, + build_env=item.build_env, + ) + + backend_items = self._create_unresolved_work_items( + item.build_backend_deps, + RequirementType.BUILD_BACKEND, + item.req, + item.resolved_version, + ) + sdist_items = self._create_unresolved_work_items( + item.build_sdist_deps, + RequirementType.BUILD_SDIST, + item.req, + item.resolved_version, + ) + dep_items = backend_items + sdist_items + + item.phase = BootstrapPhase.BUILD + return [item] + dep_items + + def _phase_build(self, item: WorkItem) -> list[WorkItem]: + """BUILD phase: install remaining deps, build wheel/sdist. + + Returns: + [item] advanced to PROCESS_INSTALL_DEPS. + """ + assert item.resolved_version is not None + assert item.build_env is not None + assert item.sdist_root_dir is not None + + # Install backend+sdist deps if disjoint from system deps + remaining_deps = item.build_backend_deps | item.build_sdist_deps + if remaining_deps.isdisjoint(item.build_system_deps): + item.build_env.install(remaining_deps) + + wheel_filename, sdist_filename = self._do_build( + req=item.req, + resolved_version=item.resolved_version, + sdist_root_dir=item.sdist_root_dir, + build_env=item.build_env, + build_sdist_only=item.build_sdist_only, + cached_wheel_filename=item.cached_wheel_filename, + ) + + source_type = sources.get_source_type(self.ctx, item.req) + + item.build_result = SourceBuildResult( + wheel_filename=wheel_filename, + sdist_filename=sdist_filename, + unpack_dir=item.sdist_root_dir.parent, + sdist_root_dir=item.sdist_root_dir, + build_env=item.build_env, + source_type=source_type, + ) + + item.phase = BootstrapPhase.PROCESS_INSTALL_DEPS + return [item] + + def _phase_process_install_deps(self, item: WorkItem) -> list[WorkItem]: + """PROCESS_INSTALL_DEPS phase: hooks, extract deps, build order. + + Returns: + [item advanced to COMPLETE, *install_dep_items]. + """ + assert item.resolved_version is not None + assert item.source_url is not None + assert item.build_result is not None + + # Run post-bootstrap hooks (non-fatal in test mode) + try: + hooks.run_post_bootstrap_hooks( + ctx=self.ctx, + req=item.req, + dist_name=canonicalize_name(item.req.name), + dist_version=str(item.resolved_version), + sdist_filename=item.build_result.sdist_filename, + wheel_filename=item.build_result.wheel_filename, + ) + except Exception as hook_error: + if not self.test_mode: + raise + self._record_test_mode_failure( + item.req, + str(item.resolved_version), + hook_error, + "hook", + "warning", + ) + + # Extract install dependencies (non-fatal in test mode) + try: + install_dependencies = self._get_install_dependencies( + req=item.req, + resolved_version=item.resolved_version, + wheel_filename=item.build_result.wheel_filename, + sdist_filename=item.build_result.sdist_filename, + sdist_root_dir=item.build_result.sdist_root_dir, + build_env=item.build_result.build_env, + unpack_dir=item.build_result.unpack_dir, + ) + except Exception as dep_error: + if not self.test_mode: + raise + self._record_test_mode_failure( + item.req, + str(item.resolved_version), + dep_error, + "dependency_extraction", + "warning", + ) + install_dependencies = [] + + logger.debug( + "install dependencies: %s", + ", ".join(sorted(str(r) for r in install_dependencies)), + ) + + pbi = self.ctx.package_build_info(item.req) + constraint = self.ctx.constraints.get_constraint(item.req.name) + self._add_to_build_order( + req=item.req, + version=item.resolved_version, + source_url=item.source_url, + source_type=item.build_result.source_type, + prebuilt=pbi.pre_built, + constraint=constraint, + ) + + dep_items = self._create_unresolved_work_items( + install_dependencies, + RequirementType.INSTALL, + item.req, + item.resolved_version, + ) + + item.phase = BootstrapPhase.COMPLETE + return [item] + dep_items + + def _phase_complete(self, item: WorkItem) -> list[WorkItem]: + """COMPLETE phase: clean up build directories. + + Returns: + Empty list (processing finished for this item). + """ + if item.build_result is not None: + self.ctx.clean_build_dirs( + item.build_result.sdist_root_dir, + item.build_result.build_env, + ) + return [] + + def _dispatch_phase(self, item: WorkItem) -> list[WorkItem]: + """Route a work item to the appropriate phase handler.""" + match item.phase: + case BootstrapPhase.RESOLVE: + return self._phase_resolve(item) + case BootstrapPhase.START: + return self._phase_start(item) + case BootstrapPhase.PREPARE_SOURCE: + return self._phase_prepare_source(item) + case BootstrapPhase.PREPARE_BUILD: + return self._phase_prepare_build(item) + case BootstrapPhase.BUILD: + return self._phase_build(item) + case BootstrapPhase.PROCESS_INSTALL_DEPS: + return self._phase_process_install_deps(item) + case BootstrapPhase.COMPLETE: + return self._phase_complete(item) + case _: + raise ValueError(f"unexpected phase: {item.phase}") + + def _handle_phase_error( + self, + item: WorkItem, + err: Exception, + ) -> list[WorkItem]: + """Handle errors from phase processing. + + Returns work items to continue processing (e.g. prebuilt fallback), + or empty list to skip this item. Raises in normal mode (fail-fast). + """ + # Resolution failures: only recoverable in test mode + if item.phase == BootstrapPhase.RESOLVE: + if self.test_mode: + self._record_test_mode_failure(item.req, None, err, "resolution") + return [] + raise + + # Test mode: try prebuilt fallback for build-related phases + if self.test_mode: + if ( + item.phase + in ( + BootstrapPhase.PREPARE_SOURCE, + BootstrapPhase.PREPARE_BUILD, + BootstrapPhase.BUILD, + ) + and not item.pbi_pre_built + ): + assert item.resolved_version is not None + fallback = self._handle_test_mode_failure( + req=item.req, + resolved_version=item.resolved_version, + req_type=item.req_type, + build_error=err, + ) + if fallback is not None: + item.build_result = fallback + item.phase = BootstrapPhase.PROCESS_INSTALL_DEPS + return [item] + self._record_test_mode_failure( + item.req, str(item.resolved_version), err, "bootstrap" + ) + return [] + + # Multiple versions mode: record failure, remove from graph, continue + if self.multiple_versions: + assert item.resolved_version is not None + pkg_name = canonicalize_name(item.req.name) + self._failed_versions.append((pkg_name, str(item.resolved_version), err)) + logger.warning( + f"{item.req.name}=={item.resolved_version}: " + f"failed to bootstrap during {item.phase} phase: " + f"{type(err).__name__}: {err}" + ) + self.ctx.dependency_graph.remove_dependency(pkg_name, item.resolved_version) + self._seen_requirements.discard( + self._resolved_key(item.req, item.resolved_version, "sdist") + ) + self._seen_requirements.discard( + self._resolved_key(item.req, item.resolved_version, "wheel") + ) + self.ctx.write_to_graph_to_file() + return [] + + # Normal mode: fail-fast + raise + def finalize(self) -> int: """Finalize bootstrap and return exit code. diff --git a/tests/test_bootstrapper.py b/tests/test_bootstrapper.py index 4144d418..67007b10 100644 --- a/tests/test_bootstrapper.py +++ b/tests/test_bootstrapper.py @@ -9,7 +9,7 @@ from packaging.version import Version from resolvelib.resolvers import ResolverException -from fromager import bootstrapper, requirements_file +from fromager import bootstrapper from fromager.context import WorkContext from fromager.requirements_file import RequirementType, SourceType @@ -260,48 +260,53 @@ def test_get_install_dependencies_returns_list( mock_get_deps.assert_called_once() -def test_build_from_source_returns_dataclass(tmp_context: WorkContext) -> None: - """Verify _build_from_source returns SourceBuildResult with correct values.""" +def test_phase_build_produces_source_build_result(tmp_context: WorkContext) -> None: + """Verify _phase_build produces a SourceBuildResult with correct values.""" bt = bootstrapper.Bootstrapper(tmp_context) mock_sdist_root = tmp_context.work_dir / "package-1.0.0" / "package-1.0.0" mock_sdist_root.parent.mkdir(parents=True, exist_ok=True) - mock_source_file = tmp_context.work_dir / "package-1.0.0.tar.gz" mock_wheel = tmp_context.work_dir / "package-1.0.0-py3-none-any.whl" - expected_unpack_dir = mock_sdist_root.parent + + item = bootstrapper.WorkItem( + req=Requirement("test-package"), + req_type=RequirementType.TOP_LEVEL, + source_url="https://pypi.org/simple/test-package", + resolved_version=Version("1.0.0"), + phase=bootstrapper.BootstrapPhase.BUILD, + why_snapshot=[], + sdist_root_dir=mock_sdist_root, + unpack_dir=mock_sdist_root.parent, + build_env=Mock(), + build_system_deps=set(), + build_backend_deps=set(), + build_sdist_deps=set(), + ) + + # Set up why stack so _track_why works + bt.why = [] with ( - patch("fromager.sources.download_source", return_value=mock_source_file), - patch("fromager.sources.prepare_source", return_value=mock_sdist_root), patch("fromager.sources.get_source_type", return_value=SourceType.SDIST), - patch.object(bt, "_prepare_build_dependencies"), patch.object(bt, "_build_wheel", return_value=(mock_wheel, None)), ): - result = bt._build_from_source( - req=Requirement("test-package"), - resolved_version=Version("1.0.0"), - source_url="https://pypi.org/simple/test-package", - req_type=requirements_file.RequirementType.TOP_LEVEL, - build_sdist_only=False, - cached_wheel_filename=None, - unpacked_cached_wheel=None, - ) + with bt._track_why(item): + result_items = bt._phase_build(item) - # Verify return type is SourceBuildResult - assert isinstance(result, bootstrapper.SourceBuildResult) + assert len(result_items) == 1 + assert result_items[0].phase == bootstrapper.BootstrapPhase.PROCESS_INSTALL_DEPS - # Verify all expected fields have correct values - assert result.wheel_filename == mock_wheel - assert result.sdist_filename is None - assert result.unpack_dir == expected_unpack_dir - assert result.sdist_root_dir == mock_sdist_root - assert result.build_env is not None - assert result.source_type == SourceType.SDIST + result = result_items[0].build_result + assert isinstance(result, bootstrapper.SourceBuildResult) + assert result.wheel_filename == mock_wheel + assert result.sdist_filename is None + assert result.unpack_dir == mock_sdist_root.parent + assert result.sdist_root_dir == mock_sdist_root + assert result.source_type == SourceType.SDIST def test_multiple_versions_continues_on_error(tmp_context: WorkContext) -> None: """Test that multiple versions mode continues when one version fails.""" - # Enable multiple versions mode bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True) # Mock the resolver to return 3 versions @@ -314,38 +319,34 @@ def test_multiple_versions_continues_on_error(tmp_context: WorkContext) -> None: ("https://pypi.org/testpkg-1.0.tar.gz", Version("1.0")), ], ): - # Mock _bootstrap_impl to fail for version 1.5 only - call_count = {"count": 0} - - def mock_bootstrap_impl( - req: Requirement, - req_type: RequirementType, - source_url: str, - resolved_version: Version, - build_sdist_only: bool, - ) -> None: - call_count["count"] += 1 - if str(resolved_version) == "1.5": + # Mock _dispatch_phase to let RESOLVE and START run normally + # but fail for version 1.5 in build phases. + original_dispatch = bt._dispatch_phase + build_phase_count = {"count": 0} + + def mock_dispatch(item: bootstrapper.WorkItem) -> list[bootstrapper.WorkItem]: + if item.phase in ( + bootstrapper.BootstrapPhase.RESOLVE, + bootstrapper.BootstrapPhase.START, + ): + return original_dispatch(item) + build_phase_count["count"] += 1 + if str(item.resolved_version) == "1.5": raise ValueError("Simulated failure for version 1.5") - # For other versions, just mark as seen to avoid actual build - bt._mark_as_seen(req, resolved_version, build_sdist_only) + return [] - with patch.object(bt, "_bootstrap_impl", side_effect=mock_bootstrap_impl): - # Mock _has_been_seen to return False so we attempt bootstrap + with patch.object(bt, "_dispatch_phase", side_effect=mock_dispatch): with patch.object(bt, "_has_been_seen", return_value=False): - # Capture log output with patch("fromager.bootstrapper.logger") as mock_logger: req = Requirement("testpkg>=1.0") - # Call bootstrap with INSTALL type (not TOP_LEVEL, since TOP_LEVEL - # nodes are added in resolve_and_add_top_level()) bt.bootstrap( req=req, req_type=RequirementType.INSTALL, ) - # Verify _bootstrap_impl was called 3 times (all versions attempted) - assert call_count["count"] == 3 + # All 3 versions should reach build phases + assert build_phase_count["count"] == 3 # Verify that version 1.5 is in failed_versions assert len(bt._failed_versions) == 1 @@ -364,7 +365,6 @@ def mock_bootstrap_impl( assert len(warning_calls) >= 1 # Verify that failed version 1.5 is NOT in the dependency graph - # (should have been removed) failed_key = f"{canonicalize_name('testpkg')}==1.5" assert failed_key not in tmp_context.dependency_graph.nodes diff --git a/tests/test_bootstrapper_iterative.py b/tests/test_bootstrapper_iterative.py new file mode 100644 index 00000000..e5441a01 --- /dev/null +++ b/tests/test_bootstrapper_iterative.py @@ -0,0 +1,791 @@ +"""Tests for the iterative bootstrap implementation. + +Tests cover: +- BootstrapPhase enum and tracks_why property +- WorkItem dataclass defaults and state accumulation +- _track_why context manager behavior +- _create_unresolved_work_items helper +- _phase_resolve version expansion +- _phase_start graph addition and seen-check +- _phase_complete cleanup +- _dispatch_phase routing +- _handle_phase_error for all three error modes +- End-to-end iterative loop with LIFO ordering +""" + +from unittest.mock import Mock, patch + +import pytest +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import Version + +from fromager import bootstrapper +from fromager.bootstrapper import BootstrapPhase, SourceBuildResult, WorkItem +from fromager.context import WorkContext +from fromager.requirements_file import RequirementType, SourceType + + +def _make_resolve_item( + req: str = "testpkg", + req_type: RequirementType = RequirementType.INSTALL, + why_snapshot: list | None = None, + parent: tuple | None = None, +) -> WorkItem: + return WorkItem( + req=Requirement(req), + req_type=req_type, + phase=BootstrapPhase.RESOLVE, + why_snapshot=why_snapshot or [], + parent=parent, + ) + + +def _make_start_item( + req: str = "testpkg", + req_type: RequirementType = RequirementType.INSTALL, + source_url: str = "https://pypi.org/testpkg-1.0.tar.gz", + version: str = "1.0", + why_snapshot: list | None = None, + parent: tuple | None = None, +) -> WorkItem: + return WorkItem( + req=Requirement(req), + req_type=req_type, + phase=BootstrapPhase.START, + why_snapshot=why_snapshot or [], + parent=parent, + source_url=source_url, + resolved_version=Version(version), + ) + + +def _make_build_item( + req: str = "testpkg", + version: str = "1.0", + phase: BootstrapPhase = BootstrapPhase.PREPARE_SOURCE, +) -> WorkItem: + return WorkItem( + req=Requirement(req), + req_type=RequirementType.INSTALL, + phase=phase, + why_snapshot=[], + source_url="https://pypi.org/testpkg-1.0.tar.gz", + resolved_version=Version(version), + ) + + +class TestBootstrapPhase: + def test_tracks_why_false_for_resolve(self) -> None: + assert BootstrapPhase.RESOLVE.tracks_why is False + + def test_tracks_why_false_for_start(self) -> None: + assert BootstrapPhase.START.tracks_why is False + + def test_tracks_why_true_for_build_phases(self) -> None: + for phase in ( + BootstrapPhase.PREPARE_SOURCE, + BootstrapPhase.PREPARE_BUILD, + BootstrapPhase.BUILD, + BootstrapPhase.PROCESS_INSTALL_DEPS, + BootstrapPhase.COMPLETE, + ): + assert phase.tracks_why is True, f"{phase} should track why" + + +class TestWorkItem: + def test_defaults_for_resolve_item(self) -> None: + item = _make_resolve_item() + assert item.source_url is None + assert item.resolved_version is None + assert item.build_sdist_only is False + assert item.build_env is None + assert item.build_result is None + assert item.pbi_pre_built is False + assert item.build_system_deps == set() + assert item.build_backend_deps == set() + assert item.build_sdist_deps == set() + + def test_state_accumulation(self) -> None: + item = _make_start_item() + item.pbi_pre_built = True + item.build_sdist_only = True + mock_env = Mock() + item.build_env = mock_env + assert item.pbi_pre_built is True + assert item.build_sdist_only is True + assert item.build_env is mock_env + + +class TestTrackWhy: + def test_noop_for_resolve_phase(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + bt.why = [(RequirementType.TOP_LEVEL, Requirement("parent"), Version("1.0"))] + item = _make_resolve_item() + + with bt._track_why(item): + assert len(bt.why) == 1 + + assert len(bt.why) == 1 + + def test_noop_for_start_phase(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + bt.why = [] + item = _make_start_item() + + with bt._track_why(item): + assert len(bt.why) == 0 + + assert len(bt.why) == 0 + + def test_pushes_and_pops_for_build_phase(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + bt.why = [] + item = _make_build_item(phase=BootstrapPhase.PREPARE_SOURCE) + + with bt._track_why(item): + assert len(bt.why) == 1 + assert bt.why[0][1] == item.req + assert bt.why[0][2] == item.resolved_version + + assert len(bt.why) == 0 + + def test_pops_on_exception(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + bt.why = [] + item = _make_build_item(phase=BootstrapPhase.BUILD) + + with pytest.raises(ValueError, match="boom"): + with bt._track_why(item): + assert len(bt.why) == 1 + raise ValueError("boom") + + assert len(bt.why) == 0 + + +class TestCreateUnresolvedWorkItems: + def test_creates_resolve_phase_items(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + deps = [Requirement("dep-a"), Requirement("dep-b")] + + items = bt._create_unresolved_work_items( + deps, RequirementType.BUILD_SYSTEM, Requirement("parent"), Version("1.0") + ) + + assert len(items) == 2 + for item in items: + assert item.phase == BootstrapPhase.RESOLVE + assert item.req_type == RequirementType.BUILD_SYSTEM + assert item.parent == (Requirement("parent"), Version("1.0")) + assert item.source_url is None + assert item.resolved_version is None + + def test_captures_why_snapshot(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + bt.why = [(RequirementType.TOP_LEVEL, Requirement("root"), Version("2.0"))] + + items = bt._create_unresolved_work_items( + [Requirement("dep")], + RequirementType.INSTALL, + Requirement("parent"), + Version("1.0"), + ) + + assert len(items) == 1 + assert items[0].why_snapshot == bt.why + # Verify it's a copy, not a reference + bt.why.append((RequirementType.INSTALL, Requirement("other"), Version("3.0"))) + assert len(items[0].why_snapshot) == 1 + + def test_sorts_by_name(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + deps = [Requirement("zebra"), Requirement("alpha"), Requirement("middle")] + + items = bt._create_unresolved_work_items( + deps, RequirementType.INSTALL, Requirement("p"), Version("1.0") + ) + + names = [str(item.req.name) for item in items] + assert names == ["alpha", "middle", "zebra"] + + def test_empty_deps(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + items = bt._create_unresolved_work_items( + [], RequirementType.INSTALL, Requirement("p"), Version("1.0") + ) + assert items == [] + + +class TestPhaseResolve: + def test_single_version(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + item = _make_resolve_item() + parent = (Requirement("parent"), Version("2.0")) + item.parent = parent + + with patch.object( + bt, + "resolve_versions", + return_value=[("https://pypi.org/testpkg-1.0.tar.gz", Version("1.0"))], + ): + result = bt._phase_resolve(item) + + assert len(result) == 1 + assert result[0].phase == BootstrapPhase.START + assert result[0].source_url == "https://pypi.org/testpkg-1.0.tar.gz" + assert result[0].resolved_version == Version("1.0") + assert result[0].parent == parent + + def test_multiple_versions(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True) + item = _make_resolve_item() + + with patch.object( + bt, + "resolve_versions", + return_value=[ + ("https://pypi.org/testpkg-2.0.tar.gz", Version("2.0")), + ("https://pypi.org/testpkg-1.0.tar.gz", Version("1.0")), + ], + ): + result = bt._phase_resolve(item) + + assert len(result) == 2 + # Reversed so highest version ends up on top of stack (last element) + assert result[0].resolved_version == Version("1.0") + assert result[1].resolved_version == Version("2.0") + + def test_empty_resolution_raises(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + item = _make_resolve_item() + + with patch.object(bt, "resolve_versions", return_value=[]): + with pytest.raises(RuntimeError, match="Could not resolve"): + bt._phase_resolve(item) + + def test_preserves_why_snapshot(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + snapshot = [(RequirementType.TOP_LEVEL, Requirement("root"), Version("1.0"))] + item = _make_resolve_item(why_snapshot=list(snapshot)) + + with patch.object( + bt, + "resolve_versions", + return_value=[("url", Version("1.0"))], + ): + result = bt._phase_resolve(item) + + assert result[0].why_snapshot == snapshot + + +class TestPhaseStart: + def test_new_item_advances_to_prepare_source( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + bt.why = [] + item = _make_start_item() + + result = bt._phase_start(item) + + assert len(result) == 1 + assert result[0].phase == BootstrapPhase.PREPARE_SOURCE + assert result[0] is item + + def test_already_seen_returns_empty(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + bt.why = [] + item = _make_start_item() + + # Mark as seen first + assert item.resolved_version is not None + bt._mark_as_seen(item.req, item.resolved_version) + + result = bt._phase_start(item) + + assert result == [] + + def test_adds_to_graph_for_non_toplevel(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + bt.why = [] + item = _make_start_item(req_type=RequirementType.INSTALL) + + bt._phase_start(item) + + key = f"{canonicalize_name('testpkg')}==1.0" + assert key in tmp_context.dependency_graph.nodes + + def test_skips_graph_for_toplevel(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + bt.why = [] + item = _make_start_item(req_type=RequirementType.TOP_LEVEL) + + bt._phase_start(item) + + key = f"{canonicalize_name('testpkg')}==1.0" + assert key not in tmp_context.dependency_graph.nodes + + def test_sdist_only_set_for_non_build_requirement( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, sdist_only=True) + bt.why = [] + item = _make_start_item(req_type=RequirementType.INSTALL) + + bt._phase_start(item) + + assert item.build_sdist_only is True + + def test_sdist_only_not_set_for_build_requirement( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, sdist_only=True) + bt.why = [] + item = _make_start_item(req_type=RequirementType.BUILD_SYSTEM) + + bt._phase_start(item) + + assert item.build_sdist_only is False + + def test_marks_as_seen(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + bt.why = [] + item = _make_start_item() + assert item.resolved_version is not None + + assert not bt._has_been_seen(item.req, item.resolved_version) + bt._phase_start(item) + assert bt._has_been_seen(item.req, item.resolved_version) + + +class TestPhaseComplete: + def test_calls_clean_build_dirs(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + mock_sdist_root = tmp_context.work_dir / "pkg-1.0" / "pkg-1.0" + mock_env = Mock() + build_result = SourceBuildResult( + wheel_filename=None, + sdist_filename=None, + unpack_dir=tmp_context.work_dir, + sdist_root_dir=mock_sdist_root, + build_env=mock_env, + source_type=SourceType.SDIST, + ) + item = _make_build_item(phase=BootstrapPhase.COMPLETE) + item.build_result = build_result + + with patch.object(tmp_context, "clean_build_dirs") as mock_clean: + result = bt._phase_complete(item) + + assert result == [] + mock_clean.assert_called_once_with(mock_sdist_root, mock_env) + + def test_no_build_result_skips_cleanup(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + item = _make_build_item(phase=BootstrapPhase.COMPLETE) + item.build_result = None + + with patch.object(tmp_context, "clean_build_dirs") as mock_clean: + result = bt._phase_complete(item) + + assert result == [] + mock_clean.assert_not_called() + + +class TestDispatchPhase: + @pytest.mark.parametrize( + "phase,method_name", + [ + (BootstrapPhase.RESOLVE, "_phase_resolve"), + (BootstrapPhase.START, "_phase_start"), + (BootstrapPhase.PREPARE_SOURCE, "_phase_prepare_source"), + (BootstrapPhase.PREPARE_BUILD, "_phase_prepare_build"), + (BootstrapPhase.BUILD, "_phase_build"), + (BootstrapPhase.PROCESS_INSTALL_DEPS, "_phase_process_install_deps"), + (BootstrapPhase.COMPLETE, "_phase_complete"), + ], + ) + def test_routes_to_correct_handler( + self, tmp_context: WorkContext, phase: BootstrapPhase, method_name: str + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + item = _make_build_item(phase=phase) + expected = [item] + + with patch.object(bt, method_name, return_value=expected) as mock_method: + result = bt._dispatch_phase(item) + + assert result is expected + mock_method.assert_called_once_with(item) + + +class TestHandlePhaseError: + # -- RESOLVE phase errors -- + + def test_resolve_error_in_test_mode_records_failure( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, test_mode=True) + item = _make_resolve_item() + err = RuntimeError("resolution failed") + + result = bt._handle_phase_error(item, err) + + assert result == [] + assert len(bt.failed_packages) == 1 + assert bt.failed_packages[0]["failure_type"] == "resolution" + assert bt.failed_packages[0]["version"] is None + + def test_resolve_error_in_normal_mode_raises( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + item = _make_resolve_item() + err = RuntimeError("resolution failed") + + with pytest.raises(RuntimeError, match="resolution failed"): + try: + raise err + except RuntimeError: + bt._handle_phase_error(item, err) + + def test_resolve_error_in_multiple_versions_mode_raises( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True) + item = _make_resolve_item() + err = RuntimeError("resolution failed") + + with pytest.raises(RuntimeError, match="resolution failed"): + try: + raise err + except RuntimeError: + bt._handle_phase_error(item, err) + + # -- Build phase errors in test mode -- + + def test_build_phase_test_mode_fallback_success( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, test_mode=True) + item = _make_build_item(phase=BootstrapPhase.PREPARE_SOURCE) + item.pbi_pre_built = False + err = RuntimeError("build failed") + + mock_fallback = Mock(spec=SourceBuildResult) + with patch.object(bt, "_handle_test_mode_failure", return_value=mock_fallback): + result = bt._handle_phase_error(item, err) + + assert len(result) == 1 + assert result[0] is item + assert item.build_result is mock_fallback + assert item.phase == BootstrapPhase.PROCESS_INSTALL_DEPS + assert len(bt.failed_packages) == 0 + + def test_build_phase_test_mode_fallback_failure( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, test_mode=True) + item = _make_build_item(phase=BootstrapPhase.BUILD) + item.pbi_pre_built = False + err = RuntimeError("build failed") + + with patch.object(bt, "_handle_test_mode_failure", return_value=None): + result = bt._handle_phase_error(item, err) + + assert result == [] + assert len(bt.failed_packages) == 1 + assert bt.failed_packages[0]["failure_type"] == "bootstrap" + + def test_build_phase_test_mode_prebuilt_skips_fallback( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, test_mode=True) + item = _make_build_item(phase=BootstrapPhase.PREPARE_SOURCE) + item.pbi_pre_built = True + err = RuntimeError("download failed") + + result = bt._handle_phase_error(item, err) + + assert result == [] + assert len(bt.failed_packages) == 1 + assert bt.failed_packages[0]["failure_type"] == "bootstrap" + + def test_non_build_phase_test_mode_records_failure( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, test_mode=True) + item = _make_build_item(phase=BootstrapPhase.PROCESS_INSTALL_DEPS) + err = RuntimeError("hook failed") + + result = bt._handle_phase_error(item, err) + + assert result == [] + assert len(bt.failed_packages) == 1 + assert bt.failed_packages[0]["failure_type"] == "bootstrap" + + # -- Multiple versions mode errors -- + + def test_multiple_versions_records_and_removes_from_graph( + self, tmp_context: WorkContext + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True) + item = _make_build_item(phase=BootstrapPhase.BUILD) + assert item.resolved_version is not None + assert item.source_url is not None + err = ValueError("build failed") + + # Add to graph first so remove_dependency has something to remove + tmp_context.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=item.req, + req_version=item.resolved_version, + download_url=item.source_url, + pre_built=False, + ) + # Mark as seen + bt._mark_as_seen(item.req, item.resolved_version) + + result = bt._handle_phase_error(item, err) + + 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" + # Removed from graph + key = f"{canonicalize_name('testpkg')}==1.0" + assert key not in tmp_context.dependency_graph.nodes + # Seen markers cleared + assert not bt._has_been_seen(item.req, item.resolved_version) + assert not bt._has_been_seen(item.req, item.resolved_version, sdist_only=True) + + def test_multiple_versions_logs_phase( + self, tmp_context: WorkContext, caplog: pytest.LogCaptureFixture + ) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True) + item = _make_build_item(phase=BootstrapPhase.PREPARE_BUILD) + err = ValueError("compile error") + + bt._handle_phase_error(item, err) + + assert "prepare-build phase" in caplog.text + assert "compile error" in caplog.text + + # -- Normal mode errors -- + + def test_normal_mode_raises(self, tmp_context: WorkContext) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + item = _make_build_item(phase=BootstrapPhase.BUILD) + err = RuntimeError("build failed") + + with pytest.raises(RuntimeError, match="build failed"): + try: + raise err + except RuntimeError: + bt._handle_phase_error(item, err) + + +class TestIterativeBootstrapLoop: + def test_full_lifecycle_source_package(self, tmp_context: WorkContext) -> None: + """Drive a package through RESOLVE -> START -> ... -> COMPLETE.""" + bt = bootstrapper.Bootstrapper(tmp_context) + + # Track which phases are visited + phases_visited: list[BootstrapPhase] = [] + original_dispatch = bt._dispatch_phase + + def tracking_dispatch(item: WorkItem) -> list[WorkItem]: + phases_visited.append(item.phase) + if item.phase == BootstrapPhase.RESOLVE: + return original_dispatch(item) + if item.phase == BootstrapPhase.START: + return original_dispatch(item) + if item.phase == BootstrapPhase.PREPARE_SOURCE: + # Skip actual source download, simulate source build path + item.phase = BootstrapPhase.PREPARE_BUILD + item.build_env = Mock() + item.sdist_root_dir = tmp_context.work_dir / "pkg-1.0" / "pkg-1.0" + return [item] + if item.phase == BootstrapPhase.PREPARE_BUILD: + item.phase = BootstrapPhase.BUILD + return [item] + if item.phase == BootstrapPhase.BUILD: + item.build_result = SourceBuildResult( + wheel_filename=None, + sdist_filename=None, + unpack_dir=tmp_context.work_dir, + sdist_root_dir=None, + build_env=None, + source_type=SourceType.SDIST, + ) + item.phase = BootstrapPhase.PROCESS_INSTALL_DEPS + return [item] + if item.phase == BootstrapPhase.PROCESS_INSTALL_DEPS: + item.phase = BootstrapPhase.COMPLETE + return [item] + if item.phase == BootstrapPhase.COMPLETE: + return [] + return [] + + with ( + patch.object(bt, "_dispatch_phase", side_effect=tracking_dispatch), + patch.object( + bt, + "resolve_versions", + return_value=[("https://pypi.org/pkg-1.0.tar.gz", Version("1.0"))], + ), + ): + bt.bootstrap(Requirement("pkg"), RequirementType.TOP_LEVEL) + + assert phases_visited == [ + BootstrapPhase.RESOLVE, + BootstrapPhase.START, + BootstrapPhase.PREPARE_SOURCE, + BootstrapPhase.PREPARE_BUILD, + BootstrapPhase.BUILD, + BootstrapPhase.PROCESS_INSTALL_DEPS, + BootstrapPhase.COMPLETE, + ] + + def test_lifo_ordering_deps_before_continuation( + self, tmp_context: WorkContext + ) -> None: + """Verify dependencies are processed before the parent continues.""" + bt = bootstrapper.Bootstrapper(tmp_context) + + processing_order: list[tuple[str, str]] = [] + original_dispatch = bt._dispatch_phase + + def tracking_dispatch(item: WorkItem) -> list[WorkItem]: + phase_name = str(item.phase) + req_name = str(item.req.name) + processing_order.append((req_name, phase_name)) + + if item.phase == BootstrapPhase.RESOLVE: + return original_dispatch(item) + if item.phase == BootstrapPhase.START: + return original_dispatch(item) + + # Parent discovers a dep at PREPARE_SOURCE + if req_name == "parent" and item.phase == BootstrapPhase.PREPARE_SOURCE: + assert item.resolved_version is not None + dep_item = WorkItem( + req=Requirement("child"), + req_type=RequirementType.BUILD_SYSTEM, + phase=BootstrapPhase.RESOLVE, + why_snapshot=[], + parent=(item.req, item.resolved_version), + ) + item.phase = BootstrapPhase.COMPLETE + return [item, dep_item] + + return [] + + # Pre-add parent to graph so child can reference it as parent + tmp_context.dependency_graph.add_dependency( + parent_name=None, + parent_version=None, + req_type=RequirementType.TOP_LEVEL, + req=Requirement("parent"), + req_version=Version("1.0"), + download_url="https://pypi.org/pkg-1.0.tar.gz", + pre_built=False, + ) + + with ( + patch.object(bt, "_dispatch_phase", side_effect=tracking_dispatch), + patch.object( + bt, + "resolve_versions", + return_value=[("https://pypi.org/pkg-1.0.tar.gz", Version("1.0"))], + ), + ): + bt.bootstrap(Requirement("parent"), RequirementType.TOP_LEVEL) + + # child's RESOLVE and START must appear before parent's COMPLETE + req_phase_pairs = [ + (name, phase) + for name, phase in processing_order + if name in ("parent", "child") + ] + + parent_complete_idx = next( + i + for i, (n, p) in enumerate(req_phase_pairs) + if n == "parent" and p == "complete" + ) + child_indices = [i for i, (n, _) in enumerate(req_phase_pairs) if n == "child"] + + assert all(idx < parent_complete_idx for idx in child_indices), ( + f"child must be processed before parent completes: {req_phase_pairs}" + ) + + def test_multiple_versions_error_isolation(self, tmp_context: WorkContext) -> None: + """Each version fails independently without crashing the loop.""" + bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True) + + original_dispatch = bt._dispatch_phase + + def mock_dispatch(item: WorkItem) -> list[WorkItem]: + if item.phase in (BootstrapPhase.RESOLVE, BootstrapPhase.START): + return original_dispatch(item) + # Fail version 1.5, succeed for others + if str(item.resolved_version) == "1.5": + raise ValueError("1.5 broken") + return [] + + with ( + patch.object(bt, "_dispatch_phase", side_effect=mock_dispatch), + patch.object( + bt._resolver, + "resolve", + return_value=[ + ("url-2.0", Version("2.0")), + ("url-1.5", Version("1.5")), + ("url-1.0", Version("1.0")), + ], + ), + patch.object(bt, "_has_been_seen", return_value=False), + ): + bt.bootstrap(Requirement("pkg"), RequirementType.INSTALL) + + assert len(bt._failed_versions) == 1 + assert bt._failed_versions[0][1] == "1.5" + # 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 + + def test_test_mode_continues_after_failure(self, tmp_context: WorkContext) -> None: + """In test mode, failed items are recorded and processing continues.""" + bt = bootstrapper.Bootstrapper(tmp_context, test_mode=True) + + original_dispatch = bt._dispatch_phase + items_completed: list[str] = [] + + def mock_dispatch(item: WorkItem) -> list[WorkItem]: + if item.phase in (BootstrapPhase.RESOLVE, BootstrapPhase.START): + return original_dispatch(item) + if str(item.req.name) == "fail-pkg": + raise RuntimeError("build error") + items_completed.append(str(item.req.name)) + return [] + + with ( + patch.object(bt, "_dispatch_phase", side_effect=mock_dispatch), + patch.object( + bt, + "resolve_versions", + return_value=[("url", Version("1.0"))], + ), + ): + # Bootstrap a package that will fail + bt.bootstrap(Requirement("fail-pkg"), RequirementType.TOP_LEVEL) + # Bootstrap another that will succeed + bt.bootstrap(Requirement("ok-pkg"), RequirementType.TOP_LEVEL) + + assert len(bt.failed_packages) == 1 + assert bt.failed_packages[0]["package"] == "fail-pkg" + assert "ok-pkg" in items_completed