Skip to content

Commit 27e56a7

Browse files
dhellmannclaude
andcommitted
feat(bootstrapper): record bootstrap stack state on each loop iteration
Write `bootstrap-stack.json` to the work directory before each iteration of the DFS loop, capturing the full stack as a JSON array (index 0 = next item to pop). Enables debugging, progress inspection, and post-mortem analysis of interrupted runs. Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Doug Hellmann <dhellmann@redhat.com>
1 parent bd86c56 commit 27e56a7

2 files changed

Lines changed: 205 additions & 0 deletions

File tree

src/fromager/bootstrapper.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ def __init__(
188188
self._seen_requirements: set[SeenKey] = set()
189189

190190
self._build_order_filename = self.ctx.work_dir / "build-order.json"
191+
self._stack_filename = self.ctx.work_dir / "bootstrap-stack.json"
191192

192193
# Track failed packages in test mode (list of typed dicts for JSON export)
193194
self.failed_packages: list[FailureRecord] = []
@@ -371,6 +372,7 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
371372

372373
# Main iterative DFS loop
373374
while stack:
375+
self._record_stack_state(stack)
374376
item = stack.pop()
375377
self.why = list(item.why_snapshot)
376378

@@ -1269,6 +1271,41 @@ def _add_to_build_order(
12691271
# converted to JSON without help.
12701272
json.dump(self._build_stack, f, indent=2, default=str)
12711273

1274+
def _record_stack_state(self, stack: list[WorkItem]) -> None:
1275+
"""Write the current bootstrap stack to `self._stack_filename`.
1276+
1277+
Index 0 in the output corresponds to `stack[-1]`, the next item to be
1278+
processed. Overwrites the file on each call.
1279+
"""
1280+
1281+
def serialize(item: WorkItem) -> dict[str, typing.Any]:
1282+
return {
1283+
"req": str(item.req),
1284+
"req_type": str(item.req_type),
1285+
"phase": str(item.phase),
1286+
"resolved_version": str(item.resolved_version)
1287+
if item.resolved_version is not None
1288+
else None,
1289+
"source_url": item.source_url,
1290+
"build_sdist_only": item.build_sdist_only,
1291+
"why": [
1292+
{"req_type": str(rt), "req": str(r), "version": str(v)}
1293+
for rt, r, v in item.why_snapshot
1294+
],
1295+
"parent": (
1296+
{"req": str(item.parent[0]), "version": str(item.parent[1])}
1297+
if item.parent
1298+
else None
1299+
),
1300+
"build_system_deps": sorted(str(r) for r in item.build_system_deps),
1301+
"build_backend_deps": sorted(str(r) for r in item.build_backend_deps),
1302+
"build_sdist_deps": sorted(str(r) for r in item.build_sdist_deps),
1303+
}
1304+
1305+
records = [serialize(item) for item in reversed(stack)]
1306+
with open(self._stack_filename, "w") as f:
1307+
json.dump(records, f, indent=2, default=str)
1308+
12721309
# ---- Iterative bootstrap: phase handlers and helpers ----
12731310

12741311
def _create_unresolved_work_items(

tests/test_bootstrapper.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import pathlib
3+
import typing
34
from unittest.mock import Mock, patch
45

56
import pytest
@@ -549,3 +550,170 @@ def test_cache_lookup_no_cache_url_returns_none(tmp_context: WorkContext) -> Non
549550
)
550551

551552
assert result == (None, None)
553+
554+
555+
def _make_resolve_item(
556+
req: str = "testpkg",
557+
req_type: RequirementType = RequirementType.TOP_LEVEL,
558+
why_snapshot: list[tuple[RequirementType, Requirement, Version]] | None = None,
559+
parent: tuple[Requirement, Version] | None = None,
560+
) -> bootstrapper.WorkItem:
561+
return bootstrapper.WorkItem(
562+
req=Requirement(req),
563+
req_type=req_type,
564+
phase=bootstrapper.BootstrapPhase.RESOLVE,
565+
why_snapshot=why_snapshot or [],
566+
parent=parent,
567+
)
568+
569+
570+
def _record_and_load(
571+
bt: bootstrapper.Bootstrapper, stack: list[bootstrapper.WorkItem]
572+
) -> list[typing.Any]:
573+
bt._record_stack_state(stack)
574+
return typing.cast(list[typing.Any], json.loads(bt._stack_filename.read_text()))
575+
576+
577+
def test_record_stack_state_minimal_item(tmp_context: WorkContext) -> None:
578+
"""Minimal RESOLVE-phase item serializes with all optional fields None/empty."""
579+
bt = bootstrapper.Bootstrapper(tmp_context)
580+
contents = _record_and_load(bt, [_make_resolve_item()])
581+
582+
result = contents[0]
583+
assert result["req"] == "testpkg"
584+
assert result["req_type"] == str(RequirementType.TOP_LEVEL)
585+
assert result["phase"] == str(bootstrapper.BootstrapPhase.RESOLVE)
586+
assert result["resolved_version"] is None
587+
assert result["source_url"] is None
588+
assert result["build_sdist_only"] is False
589+
assert result["why"] == []
590+
assert result["parent"] is None
591+
assert result["build_system_deps"] == []
592+
assert result["build_backend_deps"] == []
593+
assert result["build_sdist_deps"] == []
594+
595+
596+
def test_record_stack_state_full_item(tmp_context: WorkContext) -> None:
597+
"""Fully-populated item serializes resolved_version, parent, why, and dep sets."""
598+
bt = bootstrapper.Bootstrapper(tmp_context)
599+
parent_req = Requirement("parent-pkg")
600+
parent_version = Version("2.0")
601+
why_snapshot = [(RequirementType.INSTALL, parent_req, parent_version)]
602+
603+
item = bootstrapper.WorkItem(
604+
req=Requirement("child-pkg>=1.0"),
605+
req_type=RequirementType.INSTALL,
606+
phase=bootstrapper.BootstrapPhase.BUILD,
607+
why_snapshot=why_snapshot,
608+
parent=(parent_req, parent_version),
609+
resolved_version=Version("1.5"),
610+
source_url="https://pypi.test/child-pkg-1.5.tar.gz",
611+
build_sdist_only=True,
612+
build_system_deps={Requirement("setuptools")},
613+
build_backend_deps={Requirement("wheel")},
614+
build_sdist_deps={Requirement("flit-core")},
615+
)
616+
617+
contents = _record_and_load(bt, [item])
618+
result = contents[0]
619+
620+
assert result["resolved_version"] == "1.5"
621+
assert result["source_url"] == "https://pypi.test/child-pkg-1.5.tar.gz"
622+
assert result["build_sdist_only"] is True
623+
assert result["why"] == [
624+
{
625+
"req_type": str(RequirementType.INSTALL),
626+
"req": "parent-pkg",
627+
"version": "2.0",
628+
}
629+
]
630+
assert result["parent"] == {"req": "parent-pkg", "version": "2.0"}
631+
assert result["build_system_deps"] == ["setuptools"]
632+
assert result["build_backend_deps"] == ["wheel"]
633+
assert result["build_sdist_deps"] == ["flit-core"]
634+
635+
636+
def test_record_stack_state_dep_sets_are_sorted(tmp_context: WorkContext) -> None:
637+
"""Mixed-order dep sets come out alphabetically sorted."""
638+
bt = bootstrapper.Bootstrapper(tmp_context)
639+
item = bootstrapper.WorkItem(
640+
req=Requirement("mypkg"),
641+
req_type=RequirementType.TOP_LEVEL,
642+
phase=bootstrapper.BootstrapPhase.BUILD,
643+
why_snapshot=[],
644+
build_system_deps={Requirement("zzz"), Requirement("aaa"), Requirement("mmm")},
645+
)
646+
647+
contents = _record_and_load(bt, [item])
648+
assert contents[0]["build_system_deps"] == ["aaa", "mmm", "zzz"]
649+
650+
651+
def test_record_stack_state_writes_file(tmp_context: WorkContext) -> None:
652+
"""File is created; list length matches stack size."""
653+
bt = bootstrapper.Bootstrapper(tmp_context)
654+
stack = [_make_resolve_item("pkga"), _make_resolve_item("pkgb")]
655+
656+
bt._record_stack_state(stack)
657+
658+
assert bt._stack_filename.exists()
659+
contents = json.loads(bt._stack_filename.read_text())
660+
assert isinstance(contents, list)
661+
assert len(contents) == 2
662+
663+
664+
def test_record_stack_state_ordering(tmp_context: WorkContext) -> None:
665+
"""Index 0 = stack[-1] (next to pop); last index = stack[0]."""
666+
bt = bootstrapper.Bootstrapper(tmp_context)
667+
stack = [
668+
_make_resolve_item("pkga"),
669+
_make_resolve_item("pkgb"),
670+
_make_resolve_item("pkgc"),
671+
]
672+
673+
contents = _record_and_load(bt, stack)
674+
675+
assert contents[0]["req"] == "pkgc"
676+
assert contents[-1]["req"] == "pkga"
677+
678+
679+
def test_record_stack_state_overwrites_each_call(tmp_context: WorkContext) -> None:
680+
"""Second call replaces first call's content."""
681+
bt = bootstrapper.Bootstrapper(tmp_context)
682+
683+
bt._record_stack_state([_make_resolve_item("pkga"), _make_resolve_item("pkgb")])
684+
first_content = bt._stack_filename.read_text()
685+
686+
bt._record_stack_state([_make_resolve_item("pkgc")])
687+
second_content = bt._stack_filename.read_text()
688+
689+
assert first_content != second_content
690+
contents = json.loads(second_content)
691+
assert len(contents) == 1
692+
assert contents[0]["req"] == "pkgc"
693+
694+
695+
def test_bootstrap_calls_record_stack_state(tmp_context: WorkContext) -> None:
696+
"""`_record_stack_state` is called at least once during `bootstrap()`."""
697+
bt = bootstrapper.Bootstrapper(tmp_context)
698+
call_count = {"n": 0}
699+
700+
original = bt._record_stack_state
701+
702+
def counting_record(stack: list[bootstrapper.WorkItem]) -> None:
703+
call_count["n"] += 1
704+
original(stack)
705+
706+
req = Requirement("testpkg")
707+
708+
with (
709+
patch.object(bt, "_record_stack_state", side_effect=counting_record),
710+
patch.object(
711+
bt._resolver,
712+
"resolve",
713+
return_value=[("https://pypi.test/testpkg-1.0.tar.gz", Version("1.0"))],
714+
),
715+
patch.object(bt, "_phase_start", return_value=[]),
716+
):
717+
bt.bootstrap(req=req, req_type=RequirementType.TOP_LEVEL)
718+
719+
assert call_count["n"] >= 1

0 commit comments

Comments
 (0)