diff --git a/src/fromager/bootstrapper/_bootstrapper.py b/src/fromager/bootstrapper/_bootstrapper.py index 7ab6fb17..af5df542 100644 --- a/src/fromager/bootstrapper/_bootstrapper.py +++ b/src/fromager/bootstrapper/_bootstrapper.py @@ -266,6 +266,25 @@ def bootstrap(self, requirements: list[Requirement]) -> None: Args: requirements: Top-level requirements to resolve and bootstrap. """ + # Warm the resolver cache with every top-level rule before adding any + # of them to the graph: a later rule for the same package (e.g. an + # exact pin that bypasses the release-age cooldown) changes what an + # earlier rule resolves to. Errors are reported by + # _resolve_and_add_top_level below. + for req in requirements: + with req_ctxvar_context(req): + try: + self.resolve_versions( + req=req, + req_type=RequirementType.TOP_LEVEL, + parent_req=None, + return_all_versions=self.multiple_versions, + ) + except Exception as err: + logger.debug( + "deferring resolution error to top-level handling: %s", err + ) + # 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 diff --git a/tests/test_bootstrapper.py b/tests/test_bootstrapper.py index 1f3b2516..0a924eec 100644 --- a/tests/test_bootstrapper.py +++ b/tests/test_bootstrapper.py @@ -35,6 +35,7 @@ ) from fromager.bootstrapper._work_item import WorkItem from fromager.context import WorkContext +from fromager.dependency_graph import ROOT from fromager.requirements_file import RequirementType, SourceType @@ -955,6 +956,85 @@ def capture_run(self: Resolve, bt_arg: bootstrapper.Bootstrapper) -> list[Phase] assert sorted(dispatch_calls) == ["pkg1", "pkg2"] +def _bootstrap_and_record_started( + bt: bootstrapper.Bootstrapper, + requirements: list[Requirement], + versions_by_rule: dict[str, list[tuple[str, Version]]], +) -> set[str]: + """Run bootstrap() against a fake index and return the node keys Start saw. + + versions_by_rule maps each top-level rule string to what the index + returns for it, which is how the release-age cooldown looks to the + bootstrapper: an unpinned rule does not see a version inside the + cooldown window, an exact pin does. + """ + started: set[str] = set() + + def fake_lookup( + provider: typing.Any, req: Requirement, **kwargs: typing.Any + ) -> list[tuple[str, Version]]: + return list(versions_by_rule[str(req)]) + + def fake_start_run(self: Start, bt_arg: bootstrapper.Bootstrapper) -> list[Phase]: + wi = self.work_item + started.add(f"{canonicalize_name(wi.req.name)}=={wi.resolved_version}") + return [] + + with ( + patch.object(bt._resolver, "_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", + side_effect=fake_lookup, + ), + patch.object(Start, "run", fake_start_run), + patch.object(bt, "_record_stack_state"), + ): + bt.bootstrap(requirements) + + return started + + +_OLD = ("https://files.test/pkg-0.39.0.tar.gz", Version("0.39.0")) +_NEW = ("https://files.test/pkg-0.39.1.tar.gz", Version("0.39.1")) + + +def test_bootstrap_toplevel_unpinned_then_pin_leaves_no_stale_node( + tmp_context: WorkContext, +) -> None: + bt = bootstrapper.Bootstrapper(tmp_context) + requirements = [Requirement("pkg"), Requirement("pkg==0.39.1")] + + started = _bootstrap_and_record_started( + bt, requirements, {"pkg": [_OLD], "pkg==0.39.1": [_NEW]} + ) + + graph_nodes = set(tmp_context.dependency_graph.nodes) - {ROOT} + assert started == {"pkg==0.39.1"} + assert graph_nodes == started + root_edges = tmp_context.dependency_graph.nodes[ROOT].children + assert sorted(str(edge.req) for edge in root_edges) == ["pkg", "pkg==0.39.1"] + assert {edge.destination_node.key for edge in root_edges} == {"pkg==0.39.1"} + + +def test_bootstrap_toplevel_multiple_versions_graph_matches_started( + tmp_context: WorkContext, +) -> None: + bt = bootstrapper.Bootstrapper(tmp_context, multiple_versions=True) + requirements = [Requirement("pkg"), Requirement("pkg==0.39.1")] + + with patch("fromager.bootstrapper._resolve._cache.find_cached_wheel") as cached: + cached.return_value = (None, None) + started = _bootstrap_and_record_started( + bt, requirements, {"pkg": [_OLD], "pkg==0.39.1": [_NEW]} + ) + + graph_nodes = set(tmp_context.dependency_graph.nodes) - {ROOT} + assert started == {"pkg==0.39.0", "pkg==0.39.1"} + assert graph_nodes == started + + def test_bg_prepare_source_log_prefix_includes_version( tmp_context: WorkContext, caplog: pytest.LogCaptureFixture,