Skip to content

Commit 539fe7c

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 <[email protected]>
1 parent 3ac42dc commit 539fe7c

File tree

4 files changed

+465
-11
lines changed

4 files changed

+465
-11
lines changed

src/fromager/bootstrapper.py

Lines changed: 199 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,
@@ -217,15 +268,31 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
217268
)
218269

219270
# 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-
)
271+
try:
272+
build_result = self._build_from_source(
273+
req=req,
274+
resolved_version=resolved_version,
275+
source_url=source_url,
276+
build_sdist_only=build_sdist_only,
277+
cached_wheel_filename=cached_wheel_filename,
278+
unpacked_cached_wheel=unpacked_cached_wheel,
279+
)
280+
281+
except Exception as build_error:
282+
if not self.test_mode:
283+
raise
284+
285+
fallback_result = self._handle_test_mode_failure(
286+
req=req,
287+
resolved_version=resolved_version,
288+
req_type=req_type,
289+
build_error=build_error,
290+
)
291+
if fallback_result is None:
292+
self.why.pop()
293+
return resolved_version
228294

295+
build_result, resolved_version = fallback_result
229296
hooks.run_post_bootstrap_hooks(
230297
ctx=self.ctx,
231298
req=req,
@@ -605,6 +672,90 @@ def _build_from_source(
605672
source_type=source_type,
606673
)
607674

675+
def _handle_test_mode_failure(
676+
self,
677+
req: Requirement,
678+
resolved_version: Version,
679+
req_type: RequirementType,
680+
build_error: Exception,
681+
) -> tuple[SourceBuildResult, Version] | None:
682+
"""Handle build failure in test mode.
683+
684+
Attempts pre-built wheel fallback. Returns SourceBuildResult and
685+
fallback version if successful, None if both build and fallback failed.
686+
687+
Args:
688+
req: Package requirement
689+
resolved_version: Version that failed to build
690+
req_type: Type of requirement
691+
build_error: The original build exception
692+
693+
Returns:
694+
Tuple of (SourceBuildResult, fallback_version) if fallback succeeded,
695+
None otherwise.
696+
"""
697+
logger.warning(
698+
"test mode: build failed for %s==%s, attempting pre-built fallback",
699+
req.name,
700+
resolved_version,
701+
exc_info=True,
702+
)
703+
704+
try:
705+
wheel_url, fallback_version = self._resolve_prebuilt_with_history(
706+
req=req,
707+
req_type=req_type,
708+
)
709+
710+
if fallback_version != resolved_version:
711+
logger.warning(
712+
"test mode: version mismatch for %s - requested %s, fallback %s",
713+
req.name,
714+
resolved_version,
715+
fallback_version,
716+
)
717+
718+
wheel_filename, unpack_dir = self._download_prebuilt(
719+
req=req,
720+
req_type=req_type,
721+
resolved_version=fallback_version,
722+
wheel_url=wheel_url,
723+
)
724+
725+
logger.info(
726+
"test mode: successfully used pre-built wheel for %s==%s",
727+
req.name,
728+
fallback_version,
729+
)
730+
731+
build_result = SourceBuildResult(
732+
wheel_filename=wheel_filename,
733+
sdist_filename=None,
734+
unpack_dir=unpack_dir,
735+
sdist_root_dir=None,
736+
build_env=None,
737+
source_type=SourceType.PREBUILT,
738+
)
739+
return (build_result, fallback_version)
740+
741+
except Exception as fallback_error:
742+
logger.error(
743+
"test mode: pre-built fallback also failed for %s: %s",
744+
req.name,
745+
fallback_error,
746+
exc_info=True,
747+
)
748+
source_url_type = str(sources.get_source_type(self.ctx, req))
749+
self.failed_builds.append(
750+
BuildFailure.from_exception(
751+
req=req,
752+
resolved_version=resolved_version,
753+
source_url_type=source_url_type,
754+
exception=build_error,
755+
)
756+
)
757+
return None
758+
608759
def _look_for_existing_wheel(
609760
self,
610761
req: Requirement,
@@ -1127,3 +1278,43 @@ def _add_to_build_order(
11271278
# Requirement and Version instances that can't be
11281279
# converted to JSON without help.
11291280
json.dump(self._build_stack, f, indent=2, default=str)
1281+
1282+
def write_test_mode_report(self, work_dir: pathlib.Path) -> None:
1283+
"""Write test mode failure report to JSON files.
1284+
1285+
Generates two JSON files:
1286+
- test-mode-failures.json: Detailed list of all failures
1287+
- test-mode-summary.json: Summary statistics
1288+
"""
1289+
if not self.test_mode:
1290+
return
1291+
1292+
failures_file = work_dir / "test-mode-failures.json"
1293+
summary_file = work_dir / "test-mode-summary.json"
1294+
1295+
# Generate failures report
1296+
failures_data = {
1297+
"failures": [build_result.to_dict() for build_result in self.failed_builds]
1298+
}
1299+
1300+
with open(failures_file, "w") as f:
1301+
json.dump(failures_data, f, indent=2)
1302+
logger.info("test mode: wrote failure details to %s", failures_file)
1303+
1304+
# Generate summary report
1305+
exception_counts: dict[str, int] = {}
1306+
for build_result in self.failed_builds:
1307+
exception_type = build_result.exception_type or "Unknown"
1308+
exception_counts[exception_type] = (
1309+
exception_counts.get(exception_type, 0) + 1
1310+
)
1311+
1312+
summary_data = {
1313+
"total_packages": len(self._build_stack),
1314+
"total_failures": len(self.failed_builds),
1315+
"failure_breakdown": exception_counts,
1316+
}
1317+
1318+
with open(summary_file, "w") as f:
1319+
json.dump(summary_data, f, indent=2)
1320+
logger.info("test mode: wrote summary to %s", summary_file)

src/fromager/commands/bootstrap.py

Lines changed: 60 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,49 @@ 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+
if test_mode:
204+
logger.info("test mode: successfully processed %s", req)
205+
except Exception as err:
206+
if test_mode:
207+
logger.error(
208+
"test mode: failed to process %s: %s",
209+
req,
210+
err,
211+
exc_info=True,
212+
)
213+
bt.failed_builds.append(
214+
bootstrapper.BuildFailure.from_exception(
215+
req=req,
216+
resolved_version=None,
217+
source_url_type="unknown",
218+
exception=err,
219+
)
220+
)
221+
progressbar.update()
222+
else:
223+
raise
224+
finally:
225+
requirement_ctxvar.reset(token)
226+
227+
if test_mode:
228+
bt.write_test_mode_report(wkctx.work_dir)
229+
if bt.failed_builds:
230+
logger.error(
231+
"test mode: %d package(s) failed to build",
232+
len(bt.failed_builds),
233+
)
234+
for build_result in bt.failed_builds:
235+
logger.error(
236+
" - %s: %s",
237+
build_result.req,
238+
build_result.exception_type,
239+
)
240+
raise SystemExit(1)
241+
else:
242+
logger.info("test mode: all packages processed successfully")
189243

190244
constraints_filename = wkctx.work_dir / "constraints.txt"
191245
if skip_constraints:
@@ -480,6 +534,9 @@ def bootstrap_parallel(
480534
remaining wheels in parallel. The bootstrap step downloads sdists
481535
and builds build-time dependency in serial. The build-parallel step
482536
builds the remaining wheels in parallel.
537+
538+
Note: --test-mode is not supported in parallel builds. Use the serial
539+
bootstrap command for test mode.
483540
"""
484541
# Do not remove build environments in bootstrap phase to speed up the
485542
# parallel build phase.

0 commit comments

Comments
 (0)