@@ -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
101108def 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