Skip to content

Commit 919ec13

Browse files
feat: Add --test-mode for resilient bootstrap with failure handling
Adds --test-mode flag that marks failed packages as pre-built and continues bootstrap until all packages are processed. Uses optimal n+1 retry logic and reports comprehensive failure summary. Enables discovery of all build failures rather than stopping on first failure, supporting mixed source/binary dependency workflows. Fixes #713 Co-developed-with: Cursor IDE with Claude 4.0 Sonnet Signed-off-by: Lalatendu Mohanty <[email protected]>
1 parent 2a03733 commit 919ec13

File tree

1 file changed

+244
-0
lines changed

1 file changed

+244
-0
lines changed

src/fromager/commands/bootstrap.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ def _get_requirements_from_args(
9696
default=False,
9797
help="Skip generating constraints.txt file to allow building collections with conflicting versions",
9898
)
99+
@click.option(
100+
"--test-mode",
101+
"test_mode",
102+
is_flag=True,
103+
default=False,
104+
help="Test mode: mark failed packages as pre-built and continue, report failures at end",
105+
)
99106
@click.argument("toplevel", nargs=-1)
100107
@click.pass_obj
101108
def bootstrap(
@@ -105,6 +112,7 @@ def bootstrap(
105112
cache_wheel_server_url: str | None,
106113
sdist_only: bool,
107114
skip_constraints: bool,
115+
test_mode: bool,
108116
toplevel: list[str],
109117
) -> None:
110118
"""Compute and build the dependencies of a set of requirements recursively
@@ -115,6 +123,20 @@ def bootstrap(
115123
"""
116124
logger.info(f"cache wheel server url: {cache_wheel_server_url}")
117125

126+
if test_mode:
127+
logger.info(
128+
"test mode enabled: will mark failed packages as pre-built and continue"
129+
)
130+
return _bootstrap_test_mode(
131+
wkctx=wkctx,
132+
requirements_files=requirements_files,
133+
previous_bootstrap_file=previous_bootstrap_file,
134+
cache_wheel_server_url=cache_wheel_server_url,
135+
sdist_only=sdist_only,
136+
skip_constraints=skip_constraints,
137+
toplevel=toplevel,
138+
)
139+
118140
to_build = _get_requirements_from_args(toplevel, requirements_files)
119141
if not to_build:
120142
raise RuntimeError(
@@ -450,6 +472,13 @@ def write_constraints_file(
450472
default=None,
451473
help="maximum number of parallel workers to run (default: unlimited)",
452474
)
475+
@click.option(
476+
"--test-mode",
477+
"test_mode",
478+
is_flag=True,
479+
default=False,
480+
help="Test mode: mark failed packages as pre-built and continue, report failures at end",
481+
)
453482
@click.argument("toplevel", nargs=-1)
454483
@click.pass_obj
455484
@click.pass_context
@@ -463,6 +492,7 @@ def bootstrap_parallel(
463492
skip_constraints: bool,
464493
force: bool,
465494
max_workers: int | None,
495+
test_mode: bool,
466496
toplevel: list[str],
467497
) -> None:
468498
"""Bootstrap and build-parallel
@@ -486,6 +516,7 @@ def bootstrap_parallel(
486516
cache_wheel_server_url=cache_wheel_server_url,
487517
sdist_only=True,
488518
skip_constraints=skip_constraints,
519+
test_mode=test_mode,
489520
toplevel=toplevel,
490521
)
491522

@@ -520,3 +551,216 @@ def bootstrap_parallel(
520551
timedelta(seconds=round(time.perf_counter() - start_build, 0)),
521552
timedelta(seconds=round(time.perf_counter() - start, 0)),
522553
)
554+
555+
556+
def _bootstrap_test_mode(
557+
wkctx: context.WorkContext,
558+
requirements_files: list[str],
559+
previous_bootstrap_file: str | None,
560+
cache_wheel_server_url: str | None,
561+
sdist_only: bool,
562+
skip_constraints: bool,
563+
toplevel: list[str],
564+
) -> None:
565+
"""Bootstrap in test mode: mark failed packages as pre-built and continue."""
566+
import sys
567+
568+
from packaging.utils import canonicalize_name
569+
570+
to_build = _get_requirements_from_args(toplevel, requirements_files)
571+
if not to_build:
572+
raise RuntimeError(
573+
"Pass a requirement specificiation or use -r to pass a requirements file"
574+
)
575+
576+
logger.info("bootstrapping %r variant of %s in test mode", wkctx.variant, to_build)
577+
578+
failed_packages: list[str] = []
579+
attempt_count = 0
580+
max_attempts = len(to_build) + 1 # n failures + 1 success attempt
581+
582+
while attempt_count < max_attempts:
583+
attempt_count += 1
584+
logger.info(f"test mode: bootstrap attempt {attempt_count}")
585+
586+
try:
587+
wkctx.dependency_graph.clear()
588+
if previous_bootstrap_file:
589+
logger.info(
590+
"reading previous bootstrap data from %s", previous_bootstrap_file
591+
)
592+
prev_graph = dependency_graph.DependencyGraph.from_file(
593+
previous_bootstrap_file
594+
)
595+
else:
596+
logger.info("no previous bootstrap data")
597+
prev_graph = None
598+
599+
if sdist_only:
600+
logger.info("sdist-only (fast mode), getting metadata from sdists")
601+
else:
602+
logger.info("build all missing wheels")
603+
604+
pre_built = wkctx.settings.list_pre_built()
605+
if pre_built:
606+
logger.info("treating %s as pre-built wheels", sorted(pre_built))
607+
608+
server.start_wheel_server(wkctx)
609+
610+
with progress.progress_context(total=len(to_build * 2)) as progressbar:
611+
bt = bootstrapper.Bootstrapper(
612+
wkctx,
613+
progressbar,
614+
prev_graph,
615+
cache_wheel_server_url,
616+
sdist_only=sdist_only,
617+
)
618+
619+
logger.info("resolving top-level dependencies before building")
620+
for req in to_build:
621+
token = requirement_ctxvar.set(req)
622+
pbi = wkctx.package_build_info(req)
623+
source_url, version = bt.resolve_version(
624+
req=req,
625+
req_type=RequirementType.TOP_LEVEL,
626+
)
627+
logger.info("%s resolves to %s", req, version)
628+
wkctx.dependency_graph.add_dependency(
629+
parent_name=None,
630+
parent_version=None,
631+
req_type=requirements_file.RequirementType.TOP_LEVEL,
632+
req=req,
633+
req_version=version,
634+
download_url=source_url,
635+
pre_built=pbi.pre_built,
636+
)
637+
requirement_ctxvar.reset(token)
638+
639+
for req in to_build:
640+
token = requirement_ctxvar.set(req)
641+
bt.bootstrap(req, requirements_file.RequirementType.TOP_LEVEL)
642+
progressbar.update()
643+
requirement_ctxvar.reset(token)
644+
645+
logger.info("test mode: bootstrap completed successfully")
646+
break
647+
648+
except Exception as err:
649+
logger.warning(
650+
f"test mode: bootstrap failed on attempt {attempt_count}: {err}"
651+
)
652+
653+
failed_package = _extract_failed_package_from_error(err, wkctx)
654+
655+
if failed_package:
656+
canonical_name = str(canonicalize_name(failed_package))
657+
if canonical_name not in failed_packages:
658+
failed_packages.append(canonical_name)
659+
logger.info(
660+
f"test mode: marking {canonical_name} as pre-built due to failure"
661+
)
662+
663+
_mark_package_as_pre_built(wkctx, canonical_name)
664+
continue
665+
else:
666+
logger.warning(
667+
f"test mode: {canonical_name} already marked as pre-built, still failing"
668+
)
669+
logger.error(
670+
"test mode: pre-built package still failing, stopping attempts"
671+
)
672+
break
673+
else:
674+
logger.error(
675+
f"test mode: unable to identify failed package from error: {err}"
676+
)
677+
break
678+
if attempt_count >= max_attempts:
679+
logger.warning(
680+
f"test mode: reached maximum attempt limit ({max_attempts}) - "
681+
f"stopping to prevent infinite loops"
682+
)
683+
684+
constraints_filename = wkctx.work_dir / "constraints.txt"
685+
if skip_constraints:
686+
logger.info("skipping constraints.txt generation as requested")
687+
else:
688+
logger.info(f"writing installation dependencies to {constraints_filename}")
689+
try:
690+
with open(constraints_filename, "w") as f:
691+
if not write_constraints_file(graph=wkctx.dependency_graph, output=f):
692+
logger.warning(
693+
f"Could not produce a pip compatible constraints file. Please review {constraints_filename} for more details"
694+
)
695+
except Exception as err:
696+
logger.warning(f"Failed to write constraints file: {err}")
697+
698+
metrics.summarize(wkctx, "Test Mode Bootstrapping")
699+
700+
if failed_packages:
701+
logger.error("test mode: the following packages failed to build:")
702+
for package in sorted(failed_packages):
703+
logger.error(f" - {package}")
704+
logger.error(f"test mode: {len(failed_packages)} package(s) failed to build")
705+
sys.exit(1)
706+
else:
707+
logger.info("test mode: all packages built successfully")
708+
709+
710+
def _extract_failed_package_from_error(
711+
error: Exception, wkctx: context.WorkContext
712+
) -> str | None:
713+
"""Extract the package name that caused the build failure from the error."""
714+
current_req = requirement_ctxvar.get(None)
715+
if current_req:
716+
return current_req.name
717+
718+
error_str = str(error)
719+
import re
720+
721+
# Pattern: "package_name-version" in error messages
722+
version_pattern = r"([a-zA-Z0-9_-]+)-\d+(?:\.\d+)*(?:[a-zA-Z0-9._-]*)"
723+
match = re.search(version_pattern, error_str)
724+
if match:
725+
return match.group(1)
726+
727+
# Pattern: package names in quotes
728+
quoted_pattern = r"'([a-zA-Z0-9_-]+)'"
729+
match = re.search(quoted_pattern, error_str)
730+
if match:
731+
return match.group(1)
732+
733+
return None
734+
735+
736+
def _mark_package_as_pre_built(wkctx: context.WorkContext, package_name: str) -> None:
737+
"""Mark a package as pre-built in the settings."""
738+
from packaging.utils import canonicalize_name
739+
740+
from fromager.packagesettings import Package, PackageSettings, Variant, VariantInfo
741+
742+
canonical_name = Package(canonicalize_name(package_name, validate=True))
743+
package_settings = wkctx.settings.package_setting(canonical_name)
744+
variant_info = VariantInfo(pre_built=True)
745+
746+
new_variants = dict(package_settings.variants)
747+
new_variants[Variant(wkctx.variant)] = variant_info
748+
749+
# Pydantic models are immutable, so create new instance
750+
new_package_settings = PackageSettings(
751+
name=package_settings.name,
752+
has_config=package_settings.has_config,
753+
build_dir=package_settings.build_dir,
754+
changelog=package_settings.changelog,
755+
config_settings=package_settings.config_settings,
756+
env=package_settings.env,
757+
download_source=package_settings.download_source,
758+
resolver_dist=package_settings.resolver_dist,
759+
build_options=package_settings.build_options,
760+
git_options=package_settings.git_options,
761+
project_override=package_settings.project_override,
762+
variants=new_variants,
763+
)
764+
765+
wkctx.settings._package_settings[canonical_name] = new_package_settings
766+
wkctx.settings._pbi_cache.clear()

0 commit comments

Comments
 (0)