Skip to content

Commit 6b2e255

Browse files
Adding bootstrap --test-mode
--test-mode enables resilient bootstrap processing that continues building packages even when individual builds fail, instead of stopping at the first error. When a package fails to build from source, it attempts to download a pre-built wheel as a fallback, and if both fail, records the failure and continues processing remaining packages. At the end, it generates JSON reports (test-mode-failures.json and test-mode-summary.json) containing all failure details for automation and CI/CD integration. Closes #713 Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent 3ac42dc commit 6b2e255

File tree

4 files changed

+483
-19
lines changed

4 files changed

+483
-19
lines changed

src/fromager/bootstrapper.py

Lines changed: 238 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,47 @@ class SourceBuildResult:
5656
source_type: SourceType
5757

5858

59+
@dataclasses.dataclass
60+
class BuildFailure:
61+
"""Tracks a failed build in test mode for reporting.
62+
63+
Contains only fields needed for failure tracking and JSON serialization.
64+
"""
65+
66+
req: Requirement
67+
resolved_version: Version | None = None
68+
source_url_type: str = "unknown"
69+
exception_type: str | None = None
70+
exception_message: str | None = None
71+
72+
@classmethod
73+
def from_exception(
74+
cls,
75+
req: Requirement,
76+
resolved_version: Version | None,
77+
source_url_type: str,
78+
exception: Exception,
79+
) -> BuildFailure:
80+
"""Create a BuildFailure from an exception."""
81+
return cls(
82+
req=req,
83+
resolved_version=resolved_version,
84+
source_url_type=source_url_type,
85+
exception_type=exception.__class__.__name__,
86+
exception_message=str(exception),
87+
)
88+
89+
def to_dict(self) -> dict[str, typing.Any]:
90+
"""Convert to JSON-serializable dict."""
91+
return {
92+
"package": str(self.req),
93+
"version": str(self.resolved_version) if self.resolved_version else None,
94+
"source_url_type": self.source_url_type,
95+
"exception_type": self.exception_type,
96+
"exception_message": self.exception_message,
97+
}
98+
99+
59100
class Bootstrapper:
60101
def __init__(
61102
self,
@@ -64,12 +105,19 @@ def __init__(
64105
prev_graph: DependencyGraph | None = None,
65106
cache_wheel_server_url: str | None = None,
66107
sdist_only: bool = False,
108+
test_mode: bool = False,
67109
) -> None:
110+
if test_mode and sdist_only:
111+
raise ValueError(
112+
"--test-mode requires full wheel builds; incompatible with --sdist-only"
113+
)
114+
68115
self.ctx = ctx
69116
self.progressbar = progressbar or progress.Progressbar(None)
70117
self.prev_graph = prev_graph
71118
self.cache_wheel_server_url = cache_wheel_server_url or ctx.wheel_server_url
72119
self.sdist_only = sdist_only
120+
self.test_mode = test_mode
73121
self.why: list[tuple[RequirementType, Requirement, Version]] = []
74122
# Push items onto the stack as we start to resolve their
75123
# dependencies so at the end we have a list of items that need to
@@ -89,6 +137,35 @@ def __init__(
89137

90138
self._build_order_filename = self.ctx.work_dir / "build-order.json"
91139

140+
# Track failed builds in test mode
141+
self.failed_builds: list[BuildFailure] = []
142+
143+
def _record_failure(
144+
self,
145+
req: Requirement,
146+
resolved_version: Version | None,
147+
exception: Exception,
148+
) -> None:
149+
"""Record a build failure for test mode reporting.
150+
151+
Single point for failure recording, used by all failure paths.
152+
"""
153+
source_url_type = "unknown"
154+
if resolved_version:
155+
try:
156+
source_url_type = str(sources.get_source_type(self.ctx, req))
157+
except Exception:
158+
pass
159+
160+
self.failed_builds.append(
161+
BuildFailure.from_exception(
162+
req=req,
163+
resolved_version=resolved_version,
164+
source_url_type=source_url_type,
165+
exception=exception,
166+
)
167+
)
168+
92169
def resolve_version(
93170
self,
94171
req: Requirement,
@@ -144,7 +221,34 @@ def _processing_build_requirement(self, current_req_type: RequirementType) -> bo
144221
logger.debug("is not a build requirement")
145222
return False
146223

147-
def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
224+
def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version | None:
225+
"""Bootstrap a package and its dependencies.
226+
227+
In test mode, catches all exceptions, records failures, and continues.
228+
In normal mode, raises exceptions immediately (fail-fast).
229+
230+
Returns:
231+
The resolved version on success, None on failure in test mode.
232+
"""
233+
try:
234+
return self._bootstrap_impl(req, req_type)
235+
except Exception as err:
236+
if not self.test_mode:
237+
raise
238+
logger.error(
239+
"test mode: failed to bootstrap %s: %s",
240+
req,
241+
err,
242+
exc_info=True,
243+
)
244+
# Get cached version if available for better failure reporting
245+
cached = self._resolved_requirements.get(str(req))
246+
resolved_version = cached[1] if cached else None
247+
self._record_failure(req, resolved_version, err)
248+
return None
249+
250+
def _bootstrap_impl(self, req: Requirement, req_type: RequirementType) -> Version:
251+
"""Internal implementation of bootstrap logic."""
148252
logger.info(f"bootstrapping {req} as {req_type} dependency of {self.why[-1:]}")
149253
constraint = self.ctx.constraints.get_constraint(req.name)
150254
if constraint:
@@ -185,9 +289,7 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
185289

186290
logger.info(f"new {req_type} dependency {req} resolves to {resolved_version}")
187291

188-
# Build the dependency chain up to the point of this new
189-
# requirement using a new list so we can avoid modifying the list
190-
# we're given.
292+
# Track dependency chain for error messages
191293
self.why.append((req_type, req, resolved_version))
192294

193295
cached_wheel_filename: pathlib.Path | None = None
@@ -200,7 +302,6 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
200302
resolved_version=resolved_version,
201303
wheel_url=source_url,
202304
)
203-
# Remember that this is a prebuilt wheel, and where we got it.
204305
build_result = SourceBuildResult(
205306
wheel_filename=wheel_filename,
206307
sdist_filename=None,
@@ -210,21 +311,36 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
210311
source_type=SourceType.PREBUILT,
211312
)
212313
else:
213-
# Look for an existing wheel in caches (3 levels: build, downloads,
214-
# cache server) before building from source.
314+
# Look for an existing wheel in caches before building
215315
cached_wheel_filename, unpacked_cached_wheel = self._find_cached_wheel(
216316
req, resolved_version
217317
)
218318

219319
# Build from source (download, prepare, build wheel/sdist)
220-
build_result = self._build_from_source(
221-
req=req,
222-
resolved_version=resolved_version,
223-
source_url=source_url,
224-
build_sdist_only=build_sdist_only,
225-
cached_wheel_filename=cached_wheel_filename,
226-
unpacked_cached_wheel=unpacked_cached_wheel,
227-
)
320+
try:
321+
build_result = self._build_from_source(
322+
req=req,
323+
resolved_version=resolved_version,
324+
source_url=source_url,
325+
build_sdist_only=build_sdist_only,
326+
cached_wheel_filename=cached_wheel_filename,
327+
unpacked_cached_wheel=unpacked_cached_wheel,
328+
)
329+
except Exception as build_error:
330+
if not self.test_mode:
331+
raise
332+
333+
fallback_result = self._handle_test_mode_failure(
334+
req=req,
335+
resolved_version=resolved_version,
336+
req_type=req_type,
337+
build_error=build_error,
338+
)
339+
if fallback_result is None:
340+
self.why.pop()
341+
return resolved_version
342+
343+
build_result = fallback_result
228344

229345
hooks.run_post_bootstrap_hooks(
230346
ctx=self.ctx,
@@ -268,7 +384,7 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
268384
raise ValueError(f"could not handle {self._explain}") from err
269385
self.progressbar.update()
270386

271-
# we are done processing this req, so lets remove it from the why chain
387+
# Done processing this req, remove from why chain and clean up
272388
self.why.pop()
273389
self.ctx.clean_build_dirs(build_result.sdist_root_dir, build_result.build_env)
274390
return resolved_version
@@ -605,6 +721,72 @@ def _build_from_source(
605721
source_type=source_type,
606722
)
607723

724+
def _handle_test_mode_failure(
725+
self,
726+
req: Requirement,
727+
resolved_version: Version,
728+
req_type: RequirementType,
729+
build_error: Exception,
730+
) -> SourceBuildResult | None:
731+
"""Handle build failure in test mode by attempting pre-built fallback.
732+
733+
Returns:
734+
SourceBuildResult if fallback succeeded, None if fallback also failed.
735+
Records failure via _record_failure() before returning None.
736+
"""
737+
logger.warning(
738+
"test mode: build failed for %s==%s, attempting pre-built fallback",
739+
req.name,
740+
resolved_version,
741+
exc_info=True,
742+
)
743+
744+
try:
745+
wheel_url, fallback_version = self._resolve_prebuilt_with_history(
746+
req=req,
747+
req_type=req_type,
748+
)
749+
750+
if fallback_version != resolved_version:
751+
logger.warning(
752+
"test mode: version mismatch for %s - requested %s, fallback %s",
753+
req.name,
754+
resolved_version,
755+
fallback_version,
756+
)
757+
758+
wheel_filename, unpack_dir = self._download_prebuilt(
759+
req=req,
760+
req_type=req_type,
761+
resolved_version=fallback_version,
762+
wheel_url=wheel_url,
763+
)
764+
765+
logger.info(
766+
"test mode: successfully used pre-built wheel for %s==%s",
767+
req.name,
768+
fallback_version,
769+
)
770+
771+
return SourceBuildResult(
772+
wheel_filename=wheel_filename,
773+
sdist_filename=None,
774+
unpack_dir=unpack_dir,
775+
sdist_root_dir=None,
776+
build_env=None,
777+
source_type=SourceType.PREBUILT,
778+
)
779+
780+
except Exception as fallback_error:
781+
logger.error(
782+
"test mode: pre-built fallback also failed for %s: %s",
783+
req.name,
784+
fallback_error,
785+
exc_info=True,
786+
)
787+
self._record_failure(req, resolved_version, build_error)
788+
return None
789+
608790
def _look_for_existing_wheel(
609791
self,
610792
req: Requirement,
@@ -1127,3 +1309,43 @@ def _add_to_build_order(
11271309
# Requirement and Version instances that can't be
11281310
# converted to JSON without help.
11291311
json.dump(self._build_stack, f, indent=2, default=str)
1312+
1313+
def write_test_mode_report(self, work_dir: pathlib.Path) -> None:
1314+
"""Write test mode failure report to JSON files.
1315+
1316+
Generates two JSON files:
1317+
- test-mode-failures.json: Detailed list of all failures
1318+
- test-mode-summary.json: Summary statistics
1319+
"""
1320+
if not self.test_mode:
1321+
return
1322+
1323+
failures_file = work_dir / "test-mode-failures.json"
1324+
summary_file = work_dir / "test-mode-summary.json"
1325+
1326+
# Generate failures report
1327+
failures_data = {
1328+
"failures": [build_result.to_dict() for build_result in self.failed_builds]
1329+
}
1330+
1331+
with open(failures_file, "w") as f:
1332+
json.dump(failures_data, f, indent=2)
1333+
logger.info("test mode: wrote failure details to %s", failures_file)
1334+
1335+
# Generate summary report
1336+
exception_counts: dict[str, int] = {}
1337+
for build_result in self.failed_builds:
1338+
exception_type = build_result.exception_type or "Unknown"
1339+
exception_counts[exception_type] = (
1340+
exception_counts.get(exception_type, 0) + 1
1341+
)
1342+
1343+
summary_data = {
1344+
"total_packages": len(self._build_stack),
1345+
"total_failures": len(self.failed_builds),
1346+
"failure_breakdown": exception_counts,
1347+
}
1348+
1349+
with open(summary_file, "w") as f:
1350+
json.dump(summary_data, f, indent=2)
1351+
logger.info("test mode: wrote summary to %s", summary_file)

0 commit comments

Comments
 (0)