Skip to content

Commit 0c2fe3f

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 0c2fe3f

File tree

3 files changed

+248
-30
lines changed

3 files changed

+248
-30
lines changed

src/fromager/bootstrapper.py

Lines changed: 75 additions & 23 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
@@ -57,6 +58,20 @@ class SourceBuildResult:
5758
source_type: SourceType
5859

5960

61+
# Valid failure types for test mode error recording
62+
FailureType = typing.Literal["resolution", "bootstrap", "hook", "dependency_extraction"]
63+
64+
65+
class FailureRecord(typing.TypedDict):
66+
"""Record of a package that failed during bootstrap in test mode."""
67+
68+
package: str
69+
version: str | None
70+
exception_type: str
71+
exception_message: str
72+
failure_type: FailureType
73+
74+
6075
class Bootstrapper:
6176
def __init__(
6277
self,
@@ -97,8 +112,8 @@ def __init__(
97112

98113
self._build_order_filename = self.ctx.work_dir / "build-order.json"
99114

100-
# Track failed packages in test mode (simple list of package names)
101-
self.failed_packages: list[str] = []
115+
# Track failed packages in test mode (list of dicts for JSON export)
116+
self.failed_packages: list[FailureRecord] = []
102117

103118
def resolve_and_add_top_level(
104119
self,
@@ -140,10 +155,7 @@ def resolve_and_add_top_level(
140155
except Exception as err:
141156
if not self.test_mode:
142157
raise
143-
logger.error(
144-
"test mode: failed to resolve %s: %s", req.name, err, exc_info=True
145-
)
146-
self.failed_packages.append(str(req.name))
158+
self._record_test_mode_failure(req, None, err, "resolution")
147159
return None
148160

149161
def resolve_version(
@@ -212,10 +224,14 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
212224
except Exception as err:
213225
if not self.test_mode:
214226
raise
215-
logger.error(
216-
"test mode: failed to bootstrap %s: %s", req.name, err, exc_info=True
217-
)
218-
self.failed_packages.append(str(req.name))
227+
# Get version from cache if available
228+
cached = self._resolved_requirements.get(str(req))
229+
if cached:
230+
_source_url, resolved_version = cached
231+
version = str(resolved_version)
232+
else:
233+
version = None
234+
self._record_test_mode_failure(req, version, err, "bootstrap")
219235

220236
def _bootstrap_impl(self, req: Requirement, req_type: RequirementType) -> None:
221237
"""Internal implementation of bootstrap logic.
@@ -319,13 +335,9 @@ def _bootstrap_impl(self, req: Requirement, req_type: RequirementType) -> None:
319335
except Exception as hook_error:
320336
if not self.test_mode:
321337
raise
322-
logger.warning(
323-
"test mode: post-bootstrap hook failed for %s==%s: %s (continuing)",
324-
req.name,
325-
resolved_version,
326-
hook_error,
338+
self._record_test_mode_failure(
339+
req, str(resolved_version), hook_error, "hook", "warning"
327340
)
328-
# Continue - hooks are not critical for dependency discovery
329341

330342
# Extract install dependencies (handles test-mode internally)
331343
install_dependencies = self._get_install_dependencies(
@@ -383,6 +395,40 @@ def _track_why(
383395
finally:
384396
self.why.pop()
385397

398+
def _record_test_mode_failure(
399+
self,
400+
req: Requirement,
401+
version: str | None,
402+
err: Exception,
403+
failure_type: FailureType,
404+
log_level: typing.Literal["error", "warning"] = "error",
405+
) -> None:
406+
"""Record a failure in test mode. Call this after checking test_mode.
407+
408+
Args:
409+
req: The requirement that failed.
410+
version: The version that was being processed (None if not yet resolved).
411+
err: The exception that was raised.
412+
failure_type: Category of failure.
413+
log_level: Log at error (fatal to package) or warning (non-fatal, continuing).
414+
"""
415+
version_str = f"=={version}" if version else ""
416+
msg = f"test mode: {failure_type} failed for {req.name}{version_str}"
417+
if log_level == "warning":
418+
logger.warning("%s: %s (continuing)", msg, err)
419+
else:
420+
logger.error("%s: %s", msg, err, exc_info=True)
421+
422+
self.failed_packages.append(
423+
{
424+
"package": str(req.name),
425+
"version": version,
426+
"exception_type": err.__class__.__name__,
427+
"exception_message": str(err),
428+
"failure_type": failure_type,
429+
}
430+
)
431+
386432
@property
387433
def _explain(self) -> str:
388434
"""Return message formatting current version of why stack."""
@@ -623,11 +669,8 @@ def _get_install_dependencies(
623669
except Exception as err:
624670
if not self.test_mode:
625671
raise
626-
logger.warning(
627-
"test mode: failed to extract dependencies for %s==%s: %s (continuing)",
628-
req.name,
629-
resolved_version,
630-
err,
672+
self._record_test_mode_failure(
673+
req, str(resolved_version), err, "dependency_extraction", "warning"
631674
)
632675
return []
633676

@@ -1395,7 +1438,7 @@ def _add_to_build_order(
13951438
def finalize(self) -> int:
13961439
"""Finalize bootstrap and return exit code.
13971440
1398-
In test mode, logs summary and returns non-zero if there were failures.
1441+
In test mode, writes failure report and returns non-zero if there were failures.
13991442
14001443
Returns:
14011444
0 if all packages built successfully (or not in test mode)
@@ -1408,9 +1451,18 @@ def finalize(self) -> int:
14081451
logger.info("test mode: all packages processed successfully")
14091452
return 0
14101453

1454+
# Write JSON failure report with timestamp for uniqueness
1455+
timestamp = datetime.datetime.now(tz=datetime.UTC).strftime("%Y%m%d-%H%M%S-%f")
1456+
failures_file = self.ctx.work_dir / f"test-mode-failures-{timestamp}.json"
1457+
with open(failures_file, "w") as f:
1458+
json.dump({"failures": self.failed_packages}, f, indent=2)
1459+
logger.info("test mode: wrote failure report to %s", failures_file)
1460+
1461+
# Log summary
1462+
failed_names = [f["package"] for f in self.failed_packages]
14111463
logger.error(
14121464
"test mode: %d package(s) failed: %s",
14131465
len(self.failed_packages),
1414-
", ".join(self.failed_packages),
1466+
", ".join(failed_names),
14151467
)
14161468
return 1

src/fromager/commands/bootstrap.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def _get_requirements_from_args(
101101
"test_mode",
102102
is_flag=True,
103103
default=False,
104-
help="Test mode: mark failed packages as pre-built and continue, report failures at end",
104+
help="Test mode: continue processing after failures, report failures at end",
105105
)
106106
@click.argument("toplevel", nargs=-1)
107107
@click.pass_obj
@@ -144,7 +144,7 @@ def bootstrap(
144144

145145
if test_mode:
146146
logger.info(
147-
"test mode enabled: will mark failed packages as pre-built and continue"
147+
"test mode enabled: will continue processing after failures and report at end"
148148
)
149149

150150
pre_built = wkctx.settings.list_pre_built()

tests/test_bootstrap_test_mode.py

Lines changed: 171 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
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
8+
- failure_type field for categorizing failures
79
"""
810

11+
import json
912
import pathlib
1013
import tempfile
1114
import typing
@@ -85,7 +88,15 @@ def test_finalize_with_failures_returns_one(
8588
) -> None:
8689
"""Test finalize returns 1 when there are failures in test mode."""
8790
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
88-
bt.failed_packages.append("failing-pkg")
91+
bt.failed_packages.append(
92+
{
93+
"package": "failing-pkg",
94+
"version": "1.0.0",
95+
"exception_type": "RuntimeError",
96+
"exception_message": "Build failed",
97+
"failure_type": "bootstrap",
98+
}
99+
)
89100
assert bt.finalize() == 1
90101

91102
def test_finalize_not_in_test_mode_returns_zero(
@@ -94,15 +105,47 @@ def test_finalize_not_in_test_mode_returns_zero(
94105
"""Test finalize returns 0 when not in test mode (regardless of failures)."""
95106
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=False)
96107
# Even if we manually add failures (shouldn't happen), it returns 0
97-
bt.failed_packages.append("some-pkg")
108+
bt.failed_packages.append(
109+
{
110+
"package": "some-pkg",
111+
"version": "1.0.0",
112+
"exception_type": "RuntimeError",
113+
"exception_message": "Error",
114+
"failure_type": "bootstrap",
115+
}
116+
)
98117
assert bt.finalize() == 0
99118

100119
def test_finalize_logs_failed_packages(
101120
self, mock_context: context.WorkContext, caplog: pytest.LogCaptureFixture
102121
) -> None:
103122
"""Test finalize logs the list of failed packages."""
104123
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
105-
bt.failed_packages.extend(["pkg-a", "pkg-b", "pkg-c"])
124+
bt.failed_packages.extend(
125+
[
126+
{
127+
"package": "pkg-a",
128+
"version": "1.0",
129+
"exception_type": "E",
130+
"exception_message": "m",
131+
"failure_type": "bootstrap",
132+
},
133+
{
134+
"package": "pkg-b",
135+
"version": "2.0",
136+
"exception_type": "E",
137+
"exception_message": "m",
138+
"failure_type": "hook",
139+
},
140+
{
141+
"package": "pkg-c",
142+
"version": "3.0",
143+
"exception_type": "E",
144+
"exception_message": "m",
145+
"failure_type": "dependency_extraction",
146+
},
147+
]
148+
)
106149

107150
exit_code = bt.finalize()
108151

@@ -111,3 +154,126 @@ def test_finalize_logs_failed_packages(
111154
assert "pkg-a" in caplog.text
112155
assert "pkg-b" in caplog.text
113156
assert "pkg-c" in caplog.text
157+
158+
159+
def _find_failure_report(work_dir: pathlib.Path) -> pathlib.Path | None:
160+
"""Find the test-mode-failures-*.json file in work_dir."""
161+
reports = list(work_dir.glob("test-mode-failures-*.json"))
162+
return reports[0] if reports else None
163+
164+
165+
class TestJsonFailureReport:
166+
"""Test JSON failure report generation."""
167+
168+
def test_finalize_writes_json_report(
169+
self, mock_context: context.WorkContext
170+
) -> None:
171+
"""Test finalize writes test-mode-failures-<timestamp>.json with failure details."""
172+
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
173+
bt.failed_packages.append(
174+
{
175+
"package": "failing-pkg",
176+
"version": "1.0.0",
177+
"exception_type": "CalledProcessError",
178+
"exception_message": "Compilation failed",
179+
"failure_type": "bootstrap",
180+
}
181+
)
182+
183+
bt.finalize()
184+
185+
report_path = _find_failure_report(mock_context.work_dir)
186+
assert report_path is not None
187+
assert report_path.name.startswith("test-mode-failures-")
188+
assert report_path.name.endswith(".json")
189+
190+
with open(report_path) as f:
191+
report = json.load(f)
192+
193+
assert "failures" in report
194+
assert len(report["failures"]) == 1
195+
assert report["failures"][0]["package"] == "failing-pkg"
196+
assert report["failures"][0]["version"] == "1.0.0"
197+
assert report["failures"][0]["exception_type"] == "CalledProcessError"
198+
assert report["failures"][0]["exception_message"] == "Compilation failed"
199+
assert report["failures"][0]["failure_type"] == "bootstrap"
200+
201+
def test_finalize_no_report_when_no_failures(
202+
self, mock_context: context.WorkContext
203+
) -> None:
204+
"""Test finalize does not write report when there are no failures."""
205+
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
206+
207+
bt.finalize()
208+
209+
report_path = _find_failure_report(mock_context.work_dir)
210+
assert report_path is None
211+
212+
def test_finalize_report_with_null_version(
213+
self, mock_context: context.WorkContext
214+
) -> None:
215+
"""Test finalize handles failures where version is None (resolution failure)."""
216+
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
217+
bt.failed_packages.append(
218+
{
219+
"package": "failed-to-resolve",
220+
"version": None,
221+
"exception_type": "ResolutionError",
222+
"exception_message": "Could not resolve version",
223+
"failure_type": "resolution",
224+
}
225+
)
226+
227+
bt.finalize()
228+
229+
report_path = _find_failure_report(mock_context.work_dir)
230+
assert report_path is not None
231+
with open(report_path) as f:
232+
report = json.load(f)
233+
234+
assert report["failures"][0]["version"] is None
235+
assert report["failures"][0]["failure_type"] == "resolution"
236+
237+
def test_finalize_report_multiple_failure_types(
238+
self, mock_context: context.WorkContext
239+
) -> None:
240+
"""Test finalize correctly reports multiple failures with different types."""
241+
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
242+
bt.failed_packages.extend(
243+
[
244+
{
245+
"package": "pkg-a",
246+
"version": "1.0.0",
247+
"exception_type": "BuildError",
248+
"exception_message": "Failed to compile",
249+
"failure_type": "bootstrap",
250+
},
251+
{
252+
"package": "pkg-b",
253+
"version": "2.0.0",
254+
"exception_type": "HookError",
255+
"exception_message": "Validation failed",
256+
"failure_type": "hook",
257+
},
258+
{
259+
"package": "pkg-c",
260+
"version": "3.0.0",
261+
"exception_type": "MetadataError",
262+
"exception_message": "Could not read metadata",
263+
"failure_type": "dependency_extraction",
264+
},
265+
]
266+
)
267+
268+
bt.finalize()
269+
270+
report_path = _find_failure_report(mock_context.work_dir)
271+
assert report_path is not None
272+
with open(report_path) as f:
273+
report = json.load(f)
274+
275+
assert len(report["failures"]) == 3
276+
failure_types = [f["failure_type"] for f in report["failures"]]
277+
assert "bootstrap" in failure_types
278+
assert "hook" in failure_types
279+
assert "dependency_extraction" in failure_types

0 commit comments

Comments
 (0)