Skip to content

Commit 9726e2a

Browse files
feat(test-mode): add JSON failure report generation
Store failure details (package, version, exception info) and write test-mode-failures.json to work directory for post-analysis. Co-Authored-By: Claude <[email protected]> Signed-off-by: Lalatendu Mohanty <[email protected]>
1 parent 3598819 commit 9726e2a

File tree

3 files changed

+193
-17
lines changed

3 files changed

+193
-17
lines changed

src/fromager/bootstrapper.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import contextlib
44
import dataclasses
5+
import datetime
56
import json
67
import logging
78
import operator
@@ -97,8 +98,8 @@ def __init__(
9798

9899
self._build_order_filename = self.ctx.work_dir / "build-order.json"
99100

100-
# Track failed packages in test mode (simple list of package names)
101-
self.failed_packages: list[str] = []
101+
# Track failed packages in test mode (list of dicts for JSON export)
102+
self.failed_packages: list[dict[str, typing.Any]] = []
102103

103104
def resolve_and_add_top_level(
104105
self,
@@ -143,7 +144,14 @@ def resolve_and_add_top_level(
143144
logger.error(
144145
"test mode: failed to resolve %s: %s", req.name, err, exc_info=True
145146
)
146-
self.failed_packages.append(str(req.name))
147+
self.failed_packages.append(
148+
{
149+
"package": str(req.name),
150+
"version": None,
151+
"exception_type": err.__class__.__name__,
152+
"exception_message": str(err),
153+
}
154+
)
147155
return None
148156

149157
def resolve_version(
@@ -215,7 +223,17 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
215223
logger.error(
216224
"test mode: failed to bootstrap %s: %s", req.name, err, exc_info=True
217225
)
218-
self.failed_packages.append(str(req.name))
226+
# Get version from cache if available
227+
cached = self._resolved_requirements.get(str(req))
228+
resolved_version = str(cached[1]) if cached else None
229+
self.failed_packages.append(
230+
{
231+
"package": str(req.name),
232+
"version": resolved_version,
233+
"exception_type": err.__class__.__name__,
234+
"exception_message": str(err),
235+
}
236+
)
219237

220238
def _bootstrap_impl(self, req: Requirement, req_type: RequirementType) -> None:
221239
"""Internal implementation of bootstrap logic.
@@ -1392,10 +1410,13 @@ def _add_to_build_order(
13921410
# converted to JSON without help.
13931411
json.dump(self._build_stack, f, indent=2, default=str)
13941412

1395-
def finalize(self) -> int:
1413+
def finalize(self, work_dir: pathlib.Path) -> int:
13961414
"""Finalize bootstrap and return exit code.
13971415
1398-
In test mode, logs summary and returns non-zero if there were failures.
1416+
In test mode, writes failure report and returns non-zero if there were failures.
1417+
1418+
Args:
1419+
work_dir: Directory to write reports to.
13991420
14001421
Returns:
14011422
0 if all packages built successfully (or not in test mode)
@@ -1408,9 +1429,18 @@ def finalize(self) -> int:
14081429
logger.info("test mode: all packages processed successfully")
14091430
return 0
14101431

1432+
# Write JSON failure report with timestamp for uniqueness
1433+
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
1434+
failures_file = work_dir / f"test-mode-failures-{timestamp}.json"
1435+
with open(failures_file, "w") as f:
1436+
json.dump({"failures": self.failed_packages}, f, indent=2)
1437+
logger.info("test mode: wrote failure report to %s", failures_file)
1438+
1439+
# Log summary
1440+
failed_names = [f["package"] for f in self.failed_packages]
14111441
logger.error(
14121442
"test mode: %d package(s) failed: %s",
14131443
len(self.failed_packages),
1414-
", ".join(self.failed_packages),
1444+
", ".join(failed_names),
14151445
)
14161446
return 1

src/fromager/commands/bootstrap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def bootstrap(
187187
requirement_ctxvar.reset(token)
188188

189189
# Finalize test mode and check for failures
190-
exit_code = bt.finalize()
190+
exit_code = bt.finalize(wkctx.work_dir)
191191
if exit_code != 0:
192192
raise SystemExit(exit_code)
193193

tests/test_bootstrap_test_mode.py

Lines changed: 155 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
"""Tests for --test-mode feature (Phase 1: Basic functionality).
1+
"""Tests for --test-mode feature (Phase 2: JSON Failure Reports).
22
3-
Tests the essential test mode functionality:
3+
Tests the test mode functionality:
44
- Bootstrapper initialization with test_mode flag
55
- Exception handling: catch errors, log, continue
66
- Bootstrapper.finalize() exit codes
7+
- JSON failure report generation
78
"""
89

10+
import json
911
import pathlib
1012
import tempfile
1113
import typing
@@ -78,36 +80,180 @@ def test_finalize_no_failures_returns_zero(
7880
) -> None:
7981
"""Test finalize returns 0 when no failures in test mode."""
8082
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
81-
assert bt.finalize() == 0
83+
assert bt.finalize(mock_context.work_dir) == 0
8284

8385
def test_finalize_with_failures_returns_one(
8486
self, mock_context: context.WorkContext
8587
) -> None:
8688
"""Test finalize returns 1 when there are failures in test mode."""
8789
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
88-
bt.failed_packages.append("failing-pkg")
89-
assert bt.finalize() == 1
90+
bt.failed_packages.append(
91+
{
92+
"package": "failing-pkg",
93+
"version": "1.0.0",
94+
"exception_type": "RuntimeError",
95+
"exception_message": "Build failed",
96+
}
97+
)
98+
assert bt.finalize(mock_context.work_dir) == 1
9099

91100
def test_finalize_not_in_test_mode_returns_zero(
92101
self, mock_context: context.WorkContext
93102
) -> None:
94103
"""Test finalize returns 0 when not in test mode (regardless of failures)."""
95104
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=False)
96105
# Even if we manually add failures (shouldn't happen), it returns 0
97-
bt.failed_packages.append("some-pkg")
98-
assert bt.finalize() == 0
106+
bt.failed_packages.append(
107+
{
108+
"package": "some-pkg",
109+
"version": "1.0.0",
110+
"exception_type": "RuntimeError",
111+
"exception_message": "Error",
112+
}
113+
)
114+
assert bt.finalize(mock_context.work_dir) == 0
99115

100116
def test_finalize_logs_failed_packages(
101117
self, mock_context: context.WorkContext, caplog: pytest.LogCaptureFixture
102118
) -> None:
103119
"""Test finalize logs the list of failed packages."""
104120
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
105-
bt.failed_packages.extend(["pkg-a", "pkg-b", "pkg-c"])
121+
bt.failed_packages.extend(
122+
[
123+
{
124+
"package": "pkg-a",
125+
"version": "1.0",
126+
"exception_type": "E",
127+
"exception_message": "m",
128+
},
129+
{
130+
"package": "pkg-b",
131+
"version": "2.0",
132+
"exception_type": "E",
133+
"exception_message": "m",
134+
},
135+
{
136+
"package": "pkg-c",
137+
"version": "3.0",
138+
"exception_type": "E",
139+
"exception_message": "m",
140+
},
141+
]
142+
)
106143

107-
exit_code = bt.finalize()
144+
exit_code = bt.finalize(mock_context.work_dir)
108145

109146
assert exit_code == 1
110147
assert "3 package(s) failed" in caplog.text
111148
assert "pkg-a" in caplog.text
112149
assert "pkg-b" in caplog.text
113150
assert "pkg-c" in caplog.text
151+
152+
153+
def _find_failure_report(work_dir: pathlib.Path) -> pathlib.Path | None:
154+
"""Find the test-mode-failures-*.json file in work_dir."""
155+
reports = list(work_dir.glob("test-mode-failures-*.json"))
156+
return reports[0] if reports else None
157+
158+
159+
class TestJsonFailureReport:
160+
"""Test JSON failure report generation."""
161+
162+
def test_finalize_writes_json_report(
163+
self, mock_context: context.WorkContext
164+
) -> None:
165+
"""Test finalize writes test-mode-failures-<timestamp>.json with failure details."""
166+
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
167+
bt.failed_packages.append(
168+
{
169+
"package": "failing-pkg",
170+
"version": "1.0.0",
171+
"exception_type": "CalledProcessError",
172+
"exception_message": "Compilation failed",
173+
}
174+
)
175+
176+
bt.finalize(mock_context.work_dir)
177+
178+
report_path = _find_failure_report(mock_context.work_dir)
179+
assert report_path is not None
180+
assert report_path.name.startswith("test-mode-failures-")
181+
assert report_path.name.endswith(".json")
182+
183+
with open(report_path) as f:
184+
report = json.load(f)
185+
186+
assert "failures" in report
187+
assert len(report["failures"]) == 1
188+
assert report["failures"][0]["package"] == "failing-pkg"
189+
assert report["failures"][0]["version"] == "1.0.0"
190+
assert report["failures"][0]["exception_type"] == "CalledProcessError"
191+
assert report["failures"][0]["exception_message"] == "Compilation failed"
192+
193+
def test_finalize_no_report_when_no_failures(
194+
self, mock_context: context.WorkContext
195+
) -> None:
196+
"""Test finalize does not write report when there are no failures."""
197+
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
198+
199+
bt.finalize(mock_context.work_dir)
200+
201+
report_path = _find_failure_report(mock_context.work_dir)
202+
assert report_path is None
203+
204+
def test_finalize_report_with_null_version(
205+
self, mock_context: context.WorkContext
206+
) -> None:
207+
"""Test finalize handles failures where version is None."""
208+
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
209+
bt.failed_packages.append(
210+
{
211+
"package": "failed-to-resolve",
212+
"version": None,
213+
"exception_type": "ResolutionError",
214+
"exception_message": "Could not resolve version",
215+
}
216+
)
217+
218+
bt.finalize(mock_context.work_dir)
219+
220+
report_path = _find_failure_report(mock_context.work_dir)
221+
assert report_path is not None
222+
with open(report_path) as f:
223+
report = json.load(f)
224+
225+
assert report["failures"][0]["version"] is None
226+
227+
def test_finalize_report_multiple_failures(
228+
self, mock_context: context.WorkContext
229+
) -> None:
230+
"""Test finalize correctly reports multiple failures."""
231+
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
232+
bt.failed_packages.extend(
233+
[
234+
{
235+
"package": "pkg-a",
236+
"version": "1.0.0",
237+
"exception_type": "BuildError",
238+
"exception_message": "Failed to compile",
239+
},
240+
{
241+
"package": "pkg-b",
242+
"version": "2.0.0",
243+
"exception_type": "DownloadError",
244+
"exception_message": "Network timeout",
245+
},
246+
]
247+
)
248+
249+
bt.finalize(mock_context.work_dir)
250+
251+
report_path = _find_failure_report(mock_context.work_dir)
252+
assert report_path is not None
253+
with open(report_path) as f:
254+
report = json.load(f)
255+
256+
assert len(report["failures"]) == 2
257+
packages = [f["package"] for f in report["failures"]]
258+
assert "pkg-a" in packages
259+
assert "pkg-b" in packages

0 commit comments

Comments
 (0)