Skip to content

Commit 09630a6

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 09630a6

File tree

1 file changed

+235
-0
lines changed

1 file changed

+235
-0
lines changed

src/fromager/commands/bootstrap.py

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

0 commit comments

Comments
 (0)