Skip to content

Commit f2e6259

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 c2604a0 commit f2e6259

4 files changed

Lines changed: 496 additions & 18 deletions

File tree

src/fromager/bootstrapper.py

Lines changed: 251 additions & 15 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,
@@ -145,6 +222,47 @@ def _processing_build_requirement(self, current_req_type: RequirementType) -> bo
145222
return False
146223

147224
def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
225+
"""Bootstrap a package and its dependencies.
226+
227+
In test mode, catches build exceptions, records failures, and continues.
228+
In normal mode, raises exceptions immediately (fail-fast).
229+
230+
Returns:
231+
The resolved version (even on build failure in test mode).
232+
233+
Raises:
234+
Exception: Always in normal mode; in test mode only if version
235+
resolution failed (no version to return).
236+
"""
237+
try:
238+
return self._bootstrap_impl(req, req_type)
239+
except Exception as err:
240+
if not self.test_mode:
241+
raise
242+
243+
cached = self._resolved_requirements.get(str(req))
244+
if not cached:
245+
logger.error(
246+
"test mode: failed to resolve version for %s, cannot continue: %s",
247+
req,
248+
err,
249+
exc_info=True,
250+
)
251+
raise
252+
253+
resolved_version = cached[1]
254+
logger.error(
255+
"test mode: failed to bootstrap %s==%s: %s",
256+
req.name,
257+
resolved_version,
258+
err,
259+
exc_info=True,
260+
)
261+
self._record_failure(req, resolved_version, err)
262+
return resolved_version
263+
264+
def _bootstrap_impl(self, req: Requirement, req_type: RequirementType) -> Version:
265+
"""Internal implementation of bootstrap logic."""
148266
logger.info(f"bootstrapping {req} as {req_type} dependency of {self.why[-1:]}")
149267
constraint = self.ctx.constraints.get_constraint(req.name)
150268
if constraint:
@@ -185,9 +303,7 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
185303

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

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.
306+
# Track dependency chain for error messages
191307
self.why.append((req_type, req, resolved_version))
192308

193309
cached_wheel_filename: pathlib.Path | None = None
@@ -200,7 +316,6 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
200316
resolved_version=resolved_version,
201317
wheel_url=source_url,
202318
)
203-
# Remember that this is a prebuilt wheel, and where we got it.
204319
build_result = SourceBuildResult(
205320
wheel_filename=wheel_filename,
206321
sdist_filename=None,
@@ -210,21 +325,36 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
210325
source_type=SourceType.PREBUILT,
211326
)
212327
else:
213-
# Look for an existing wheel in caches (3 levels: build, downloads,
214-
# cache server) before building from source.
328+
# Look for an existing wheel in caches before building
215329
cached_wheel_filename, unpacked_cached_wheel = self._find_cached_wheel(
216330
req, resolved_version
217331
)
218332

219333
# 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-
)
334+
try:
335+
build_result = self._build_from_source(
336+
req=req,
337+
resolved_version=resolved_version,
338+
source_url=source_url,
339+
build_sdist_only=build_sdist_only,
340+
cached_wheel_filename=cached_wheel_filename,
341+
unpacked_cached_wheel=unpacked_cached_wheel,
342+
)
343+
except Exception as build_error:
344+
if not self.test_mode:
345+
raise
346+
347+
fallback_result = self._handle_test_mode_failure(
348+
req=req,
349+
resolved_version=resolved_version,
350+
req_type=req_type,
351+
build_error=build_error,
352+
)
353+
if fallback_result is None:
354+
self.why.pop()
355+
return resolved_version
356+
357+
build_result = fallback_result
228358

229359
hooks.run_post_bootstrap_hooks(
230360
ctx=self.ctx,
@@ -268,7 +398,7 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
268398
raise ValueError(f"could not handle {self._explain}") from err
269399
self.progressbar.update()
270400

271-
# we are done processing this req, so lets remove it from the why chain
401+
# Done processing this req, remove from why chain and clean up
272402
self.why.pop()
273403
self.ctx.clean_build_dirs(build_result.sdist_root_dir, build_result.build_env)
274404
return resolved_version
@@ -605,6 +735,72 @@ def _build_from_source(
605735
source_type=source_type,
606736
)
607737

738+
def _handle_test_mode_failure(
739+
self,
740+
req: Requirement,
741+
resolved_version: Version,
742+
req_type: RequirementType,
743+
build_error: Exception,
744+
) -> SourceBuildResult | None:
745+
"""Handle build failure in test mode by attempting pre-built fallback.
746+
747+
Returns:
748+
SourceBuildResult if fallback succeeded, None if fallback also failed.
749+
Records failure via _record_failure() before returning None.
750+
"""
751+
logger.warning(
752+
"test mode: build failed for %s==%s, attempting pre-built fallback",
753+
req.name,
754+
resolved_version,
755+
exc_info=True,
756+
)
757+
758+
try:
759+
wheel_url, fallback_version = self._resolve_prebuilt_with_history(
760+
req=req,
761+
req_type=req_type,
762+
)
763+
764+
if fallback_version != resolved_version:
765+
logger.warning(
766+
"test mode: version mismatch for %s - requested %s, fallback %s",
767+
req.name,
768+
resolved_version,
769+
fallback_version,
770+
)
771+
772+
wheel_filename, unpack_dir = self._download_prebuilt(
773+
req=req,
774+
req_type=req_type,
775+
resolved_version=fallback_version,
776+
wheel_url=wheel_url,
777+
)
778+
779+
logger.info(
780+
"test mode: successfully used pre-built wheel for %s==%s",
781+
req.name,
782+
fallback_version,
783+
)
784+
785+
return SourceBuildResult(
786+
wheel_filename=wheel_filename,
787+
sdist_filename=None,
788+
unpack_dir=unpack_dir,
789+
sdist_root_dir=None,
790+
build_env=None,
791+
source_type=SourceType.PREBUILT,
792+
)
793+
794+
except Exception as fallback_error:
795+
logger.error(
796+
"test mode: pre-built fallback also failed for %s: %s",
797+
req.name,
798+
fallback_error,
799+
exc_info=True,
800+
)
801+
self._record_failure(req, resolved_version, build_error)
802+
return None
803+
608804
def _look_for_existing_wheel(
609805
self,
610806
req: Requirement,
@@ -1127,3 +1323,43 @@ def _add_to_build_order(
11271323
# Requirement and Version instances that can't be
11281324
# converted to JSON without help.
11291325
json.dump(self._build_stack, f, indent=2, default=str)
1326+
1327+
def write_test_mode_report(self, work_dir: pathlib.Path) -> None:
1328+
"""Write test mode failure report to JSON files.
1329+
1330+
Generates two JSON files:
1331+
- test-mode-failures.json: Detailed list of all failures
1332+
- test-mode-summary.json: Summary statistics
1333+
"""
1334+
if not self.test_mode:
1335+
return
1336+
1337+
failures_file = work_dir / "test-mode-failures.json"
1338+
summary_file = work_dir / "test-mode-summary.json"
1339+
1340+
# Generate failures report
1341+
failures_data = {
1342+
"failures": [build_result.to_dict() for build_result in self.failed_builds]
1343+
}
1344+
1345+
with open(failures_file, "w") as f:
1346+
json.dump(failures_data, f, indent=2)
1347+
logger.info("test mode: wrote failure details to %s", failures_file)
1348+
1349+
# Generate summary report
1350+
exception_counts: dict[str, int] = {}
1351+
for build_result in self.failed_builds:
1352+
exception_type = build_result.exception_type or "Unknown"
1353+
exception_counts[exception_type] = (
1354+
exception_counts.get(exception_type, 0) + 1
1355+
)
1356+
1357+
summary_data = {
1358+
"total_packages": len(self._build_stack),
1359+
"total_failures": len(self.failed_builds),
1360+
"failure_breakdown": exception_counts,
1361+
}
1362+
1363+
with open(summary_file, "w") as f:
1364+
json.dump(summary_data, f, indent=2)
1365+
logger.info("test mode: wrote summary to %s", summary_file)

0 commit comments

Comments
 (0)