Skip to content

Commit 4b69da3

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 4b69da3

File tree

4 files changed

+494
-11
lines changed

4 files changed

+494
-11
lines changed

src/fromager/bootstrapper.py

Lines changed: 249 additions & 8 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,9 @@ 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+
92143
def resolve_version(
93144
self,
94145
req: Requirement,
@@ -145,6 +196,56 @@ def _processing_build_requirement(self, current_req_type: RequirementType) -> bo
145196
return False
146197

147198
def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
199+
"""Bootstrap a package and its dependencies.
200+
201+
In test mode, catches all exceptions, records failures, and continues.
202+
In normal mode, raises exceptions immediately (fail-fast).
203+
"""
204+
try:
205+
return self._bootstrap_impl(req, req_type)
206+
except Exception as err:
207+
if not self.test_mode:
208+
raise
209+
return self._handle_bootstrap_failure(req, err)
210+
211+
def _handle_bootstrap_failure(self, req: Requirement, err: Exception) -> Version:
212+
"""Handle complete bootstrap failure in test mode.
213+
214+
Called when an exception escapes from _bootstrap_impl(). Records the
215+
failure and returns a version to allow processing to continue.
216+
"""
217+
logger.error(
218+
"test mode: failed to bootstrap %s: %s",
219+
req,
220+
err,
221+
exc_info=True,
222+
)
223+
224+
# Try to get resolved version from cache if available
225+
cached = self._resolved_requirements.get(str(req))
226+
resolved_version = cached[1] if cached else None
227+
228+
# Get source type if we have a resolved version
229+
if resolved_version:
230+
source_url_type = str(sources.get_source_type(self.ctx, req))
231+
else:
232+
source_url_type = "unknown"
233+
234+
self.failed_builds.append(
235+
BuildFailure.from_exception(
236+
req=req,
237+
resolved_version=resolved_version,
238+
source_url_type=source_url_type,
239+
exception=err,
240+
)
241+
)
242+
243+
# Return a version to allow processing to continue
244+
# Use cached version if available, otherwise return a null version
245+
return resolved_version or Version("0.0.0")
246+
247+
def _bootstrap_impl(self, req: Requirement, req_type: RequirementType) -> Version:
248+
"""Internal implementation of bootstrap logic."""
148249
logger.info(f"bootstrapping {req} as {req_type} dependency of {self.why[-1:]}")
149250
constraint = self.ctx.constraints.get_constraint(req.name)
150251
if constraint:
@@ -217,15 +318,31 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
217318
)
218319

219320
# 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-
)
321+
try:
322+
build_result = self._build_from_source(
323+
req=req,
324+
resolved_version=resolved_version,
325+
source_url=source_url,
326+
build_sdist_only=build_sdist_only,
327+
cached_wheel_filename=cached_wheel_filename,
328+
unpacked_cached_wheel=unpacked_cached_wheel,
329+
)
330+
331+
except Exception as build_error:
332+
if not self.test_mode:
333+
raise
334+
335+
fallback_result = self._handle_test_mode_failure(
336+
req=req,
337+
resolved_version=resolved_version,
338+
req_type=req_type,
339+
build_error=build_error,
340+
)
341+
if fallback_result is None:
342+
self.why.pop()
343+
return resolved_version
228344

345+
build_result, resolved_version = fallback_result
229346
hooks.run_post_bootstrap_hooks(
230347
ctx=self.ctx,
231348
req=req,
@@ -605,6 +722,90 @@ def _build_from_source(
605722
source_type=source_type,
606723
)
607724

725+
def _handle_test_mode_failure(
726+
self,
727+
req: Requirement,
728+
resolved_version: Version,
729+
req_type: RequirementType,
730+
build_error: Exception,
731+
) -> tuple[SourceBuildResult, Version] | None:
732+
"""Handle build failure in test mode.
733+
734+
Attempts pre-built wheel fallback. Returns SourceBuildResult and
735+
fallback version if successful, None if both build and fallback failed.
736+
737+
Args:
738+
req: Package requirement
739+
resolved_version: Version that failed to build
740+
req_type: Type of requirement
741+
build_error: The original build exception
742+
743+
Returns:
744+
Tuple of (SourceBuildResult, fallback_version) if fallback succeeded,
745+
None otherwise.
746+
"""
747+
logger.warning(
748+
"test mode: build failed for %s==%s, attempting pre-built fallback",
749+
req.name,
750+
resolved_version,
751+
exc_info=True,
752+
)
753+
754+
try:
755+
wheel_url, fallback_version = self._resolve_prebuilt_with_history(
756+
req=req,
757+
req_type=req_type,
758+
)
759+
760+
if fallback_version != resolved_version:
761+
logger.warning(
762+
"test mode: version mismatch for %s - requested %s, fallback %s",
763+
req.name,
764+
resolved_version,
765+
fallback_version,
766+
)
767+
768+
wheel_filename, unpack_dir = self._download_prebuilt(
769+
req=req,
770+
req_type=req_type,
771+
resolved_version=fallback_version,
772+
wheel_url=wheel_url,
773+
)
774+
775+
logger.info(
776+
"test mode: successfully used pre-built wheel for %s==%s",
777+
req.name,
778+
fallback_version,
779+
)
780+
781+
build_result = SourceBuildResult(
782+
wheel_filename=wheel_filename,
783+
sdist_filename=None,
784+
unpack_dir=unpack_dir,
785+
sdist_root_dir=None,
786+
build_env=None,
787+
source_type=SourceType.PREBUILT,
788+
)
789+
return (build_result, fallback_version)
790+
791+
except Exception as fallback_error:
792+
logger.error(
793+
"test mode: pre-built fallback also failed for %s: %s",
794+
req.name,
795+
fallback_error,
796+
exc_info=True,
797+
)
798+
source_url_type = str(sources.get_source_type(self.ctx, req))
799+
self.failed_builds.append(
800+
BuildFailure.from_exception(
801+
req=req,
802+
resolved_version=resolved_version,
803+
source_url_type=source_url_type,
804+
exception=build_error,
805+
)
806+
)
807+
return None
808+
608809
def _look_for_existing_wheel(
609810
self,
610811
req: Requirement,
@@ -1127,3 +1328,43 @@ def _add_to_build_order(
11271328
# Requirement and Version instances that can't be
11281329
# converted to JSON without help.
11291330
json.dump(self._build_stack, f, indent=2, default=str)
1331+
1332+
def write_test_mode_report(self, work_dir: pathlib.Path) -> None:
1333+
"""Write test mode failure report to JSON files.
1334+
1335+
Generates two JSON files:
1336+
- test-mode-failures.json: Detailed list of all failures
1337+
- test-mode-summary.json: Summary statistics
1338+
"""
1339+
if not self.test_mode:
1340+
return
1341+
1342+
failures_file = work_dir / "test-mode-failures.json"
1343+
summary_file = work_dir / "test-mode-summary.json"
1344+
1345+
# Generate failures report
1346+
failures_data = {
1347+
"failures": [build_result.to_dict() for build_result in self.failed_builds]
1348+
}
1349+
1350+
with open(failures_file, "w") as f:
1351+
json.dump(failures_data, f, indent=2)
1352+
logger.info("test mode: wrote failure details to %s", failures_file)
1353+
1354+
# Generate summary report
1355+
exception_counts: dict[str, int] = {}
1356+
for build_result in self.failed_builds:
1357+
exception_type = build_result.exception_type or "Unknown"
1358+
exception_counts[exception_type] = (
1359+
exception_counts.get(exception_type, 0) + 1
1360+
)
1361+
1362+
summary_data = {
1363+
"total_packages": len(self._build_stack),
1364+
"total_failures": len(self.failed_builds),
1365+
"failure_breakdown": exception_counts,
1366+
}
1367+
1368+
with open(summary_file, "w") as f:
1369+
json.dump(summary_data, f, indent=2)
1370+
logger.info("test mode: wrote summary to %s", summary_file)

src/fromager/commands/bootstrap.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ def _get_requirements_from_args(
9797
default=False,
9898
help="Skip generating constraints.txt file to allow building collections with conflicting versions",
9999
)
100+
@click.option(
101+
"--test-mode",
102+
"test_mode",
103+
is_flag=True,
104+
default=False,
105+
help="Test mode: mark failed packages as pre-built and continue, report failures at end",
106+
)
100107
@click.argument("toplevel", nargs=-1)
101108
@click.pass_obj
102109
def bootstrap(
@@ -106,6 +113,7 @@ def bootstrap(
106113
cache_wheel_server_url: str | None,
107114
sdist_only: bool,
108115
skip_constraints: bool,
116+
test_mode: bool,
109117
toplevel: list[str],
110118
) -> None:
111119
"""Compute and build the dependencies of a set of requirements recursively
@@ -135,6 +143,11 @@ def bootstrap(
135143
else:
136144
logger.info("build all missing wheels")
137145

146+
if test_mode:
147+
logger.info(
148+
"test mode enabled: will mark failed packages as pre-built and continue"
149+
)
150+
138151
pre_built = wkctx.settings.list_pre_built()
139152
if pre_built:
140153
logger.info("treating %s as pre-built wheels", sorted(pre_built))
@@ -148,6 +161,7 @@ def bootstrap(
148161
prev_graph,
149162
cache_wheel_server_url,
150163
sdist_only=sdist_only,
164+
test_mode=test_mode,
151165
)
152166

153167
# we need to resolve all the top level dependencies before we start bootstrapping.
@@ -183,9 +197,28 @@ def bootstrap(
183197

184198
for req in to_build:
185199
token = requirement_ctxvar.set(req)
186-
bt.bootstrap(req, requirements_file.RequirementType.TOP_LEVEL)
187-
progressbar.update()
188-
requirement_ctxvar.reset(token)
200+
try:
201+
bt.bootstrap(req, requirements_file.RequirementType.TOP_LEVEL)
202+
progressbar.update()
203+
finally:
204+
requirement_ctxvar.reset(token)
205+
206+
if test_mode:
207+
bt.write_test_mode_report(wkctx.work_dir)
208+
if bt.failed_builds:
209+
logger.error(
210+
"test mode: %d package(s) failed to build",
211+
len(bt.failed_builds),
212+
)
213+
for build_result in bt.failed_builds:
214+
logger.error(
215+
" - %s: %s",
216+
build_result.req,
217+
build_result.exception_type,
218+
)
219+
raise SystemExit(1)
220+
else:
221+
logger.info("test mode: all packages processed successfully")
189222

190223
constraints_filename = wkctx.work_dir / "constraints.txt"
191224
if skip_constraints:
@@ -480,6 +513,9 @@ def bootstrap_parallel(
480513
remaining wheels in parallel. The bootstrap step downloads sdists
481514
and builds build-time dependency in serial. The build-parallel step
482515
builds the remaining wheels in parallel.
516+
517+
Note: --test-mode is not supported in parallel builds. Use the serial
518+
bootstrap command for test mode.
483519
"""
484520
# Do not remove build environments in bootstrap phase to speed up the
485521
# parallel build phase.

0 commit comments

Comments
 (0)