@@ -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+
59100class 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 )
0 commit comments