@@ -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,35 @@ 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+
143+ def _record_failure (
144+ self ,
145+ req : Requirement ,
146+ resolved_version : Version | None ,
147+ exception : Exception ,
148+ ) -> None :
149+ """Record a build failure for test mode reporting.
150+
151+ Single point for failure recording, used by all failure paths.
152+ """
153+ source_url_type = "unknown"
154+ if resolved_version :
155+ try :
156+ source_url_type = str (sources .get_source_type (self .ctx , req ))
157+ except Exception :
158+ pass
159+
160+ self .failed_builds .append (
161+ BuildFailure .from_exception (
162+ req = req ,
163+ resolved_version = resolved_version ,
164+ source_url_type = source_url_type ,
165+ exception = exception ,
166+ )
167+ )
168+
92169 def resolve_version (
93170 self ,
94171 req : Requirement ,
@@ -144,7 +221,34 @@ def _processing_build_requirement(self, current_req_type: RequirementType) -> bo
144221 logger .debug ("is not a build requirement" )
145222 return False
146223
147- def bootstrap (self , req : Requirement , req_type : RequirementType ) -> Version :
224+ def bootstrap (self , req : Requirement , req_type : RequirementType ) -> Version | None :
225+ """Bootstrap a package and its dependencies.
226+
227+ In test mode, catches all exceptions, records failures, and continues.
228+ In normal mode, raises exceptions immediately (fail-fast).
229+
230+ Returns:
231+ The resolved version on success, None on failure in test mode.
232+ """
233+ try :
234+ return self ._bootstrap_impl (req , req_type )
235+ except Exception as err :
236+ if not self .test_mode :
237+ raise
238+ logger .error (
239+ "test mode: failed to bootstrap %s: %s" ,
240+ req ,
241+ err ,
242+ exc_info = True ,
243+ )
244+ # Get cached version if available for better failure reporting
245+ cached = self ._resolved_requirements .get (str (req ))
246+ resolved_version = cached [1 ] if cached else None
247+ self ._record_failure (req , resolved_version , err )
248+ return None
249+
250+ def _bootstrap_impl (self , req : Requirement , req_type : RequirementType ) -> Version :
251+ """Internal implementation of bootstrap logic."""
148252 logger .info (f"bootstrapping { req } as { req_type } dependency of { self .why [- 1 :]} " )
149253 constraint = self .ctx .constraints .get_constraint (req .name )
150254 if constraint :
@@ -185,9 +289,7 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
185289
186290 logger .info (f"new { req_type } dependency { req } resolves to { resolved_version } " )
187291
188- # Build the dependency chain up to the point of this new
189- # requirement using a new list so we can avoid modifying the list
190- # we're given.
292+ # Track dependency chain for error messages
191293 self .why .append ((req_type , req , resolved_version ))
192294
193295 cached_wheel_filename : pathlib .Path | None = None
@@ -200,7 +302,6 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
200302 resolved_version = resolved_version ,
201303 wheel_url = source_url ,
202304 )
203- # Remember that this is a prebuilt wheel, and where we got it.
204305 build_result = SourceBuildResult (
205306 wheel_filename = wheel_filename ,
206307 sdist_filename = None ,
@@ -210,21 +311,36 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
210311 source_type = SourceType .PREBUILT ,
211312 )
212313 else :
213- # Look for an existing wheel in caches (3 levels: build, downloads,
214- # cache server) before building from source.
314+ # Look for an existing wheel in caches before building
215315 cached_wheel_filename , unpacked_cached_wheel = self ._find_cached_wheel (
216316 req , resolved_version
217317 )
218318
219319 # 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- )
320+ try :
321+ build_result = self ._build_from_source (
322+ req = req ,
323+ resolved_version = resolved_version ,
324+ source_url = source_url ,
325+ build_sdist_only = build_sdist_only ,
326+ cached_wheel_filename = cached_wheel_filename ,
327+ unpacked_cached_wheel = unpacked_cached_wheel ,
328+ )
329+ except Exception as build_error :
330+ if not self .test_mode :
331+ raise
332+
333+ fallback_result = self ._handle_test_mode_failure (
334+ req = req ,
335+ resolved_version = resolved_version ,
336+ req_type = req_type ,
337+ build_error = build_error ,
338+ )
339+ if fallback_result is None :
340+ self .why .pop ()
341+ return resolved_version
342+
343+ build_result = fallback_result
228344
229345 hooks .run_post_bootstrap_hooks (
230346 ctx = self .ctx ,
@@ -268,7 +384,7 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
268384 raise ValueError (f"could not handle { self ._explain } " ) from err
269385 self .progressbar .update ()
270386
271- # we are done processing this req, so lets remove it from the why chain
387+ # Done processing this req, remove from why chain and clean up
272388 self .why .pop ()
273389 self .ctx .clean_build_dirs (build_result .sdist_root_dir , build_result .build_env )
274390 return resolved_version
@@ -605,6 +721,72 @@ def _build_from_source(
605721 source_type = source_type ,
606722 )
607723
724+ def _handle_test_mode_failure (
725+ self ,
726+ req : Requirement ,
727+ resolved_version : Version ,
728+ req_type : RequirementType ,
729+ build_error : Exception ,
730+ ) -> SourceBuildResult | None :
731+ """Handle build failure in test mode by attempting pre-built fallback.
732+
733+ Returns:
734+ SourceBuildResult if fallback succeeded, None if fallback also failed.
735+ Records failure via _record_failure() before returning None.
736+ """
737+ logger .warning (
738+ "test mode: build failed for %s==%s, attempting pre-built fallback" ,
739+ req .name ,
740+ resolved_version ,
741+ exc_info = True ,
742+ )
743+
744+ try :
745+ wheel_url , fallback_version = self ._resolve_prebuilt_with_history (
746+ req = req ,
747+ req_type = req_type ,
748+ )
749+
750+ if fallback_version != resolved_version :
751+ logger .warning (
752+ "test mode: version mismatch for %s - requested %s, fallback %s" ,
753+ req .name ,
754+ resolved_version ,
755+ fallback_version ,
756+ )
757+
758+ wheel_filename , unpack_dir = self ._download_prebuilt (
759+ req = req ,
760+ req_type = req_type ,
761+ resolved_version = fallback_version ,
762+ wheel_url = wheel_url ,
763+ )
764+
765+ logger .info (
766+ "test mode: successfully used pre-built wheel for %s==%s" ,
767+ req .name ,
768+ fallback_version ,
769+ )
770+
771+ return SourceBuildResult (
772+ wheel_filename = wheel_filename ,
773+ sdist_filename = None ,
774+ unpack_dir = unpack_dir ,
775+ sdist_root_dir = None ,
776+ build_env = None ,
777+ source_type = SourceType .PREBUILT ,
778+ )
779+
780+ except Exception as fallback_error :
781+ logger .error (
782+ "test mode: pre-built fallback also failed for %s: %s" ,
783+ req .name ,
784+ fallback_error ,
785+ exc_info = True ,
786+ )
787+ self ._record_failure (req , resolved_version , build_error )
788+ return None
789+
608790 def _look_for_existing_wheel (
609791 self ,
610792 req : Requirement ,
@@ -1127,3 +1309,43 @@ def _add_to_build_order(
11271309 # Requirement and Version instances that can't be
11281310 # converted to JSON without help.
11291311 json .dump (self ._build_stack , f , indent = 2 , default = str )
1312+
1313+ def write_test_mode_report (self , work_dir : pathlib .Path ) -> None :
1314+ """Write test mode failure report to JSON files.
1315+
1316+ Generates two JSON files:
1317+ - test-mode-failures.json: Detailed list of all failures
1318+ - test-mode-summary.json: Summary statistics
1319+ """
1320+ if not self .test_mode :
1321+ return
1322+
1323+ failures_file = work_dir / "test-mode-failures.json"
1324+ summary_file = work_dir / "test-mode-summary.json"
1325+
1326+ # Generate failures report
1327+ failures_data = {
1328+ "failures" : [build_result .to_dict () for build_result in self .failed_builds ]
1329+ }
1330+
1331+ with open (failures_file , "w" ) as f :
1332+ json .dump (failures_data , f , indent = 2 )
1333+ logger .info ("test mode: wrote failure details to %s" , failures_file )
1334+
1335+ # Generate summary report
1336+ exception_counts : dict [str , int ] = {}
1337+ for build_result in self .failed_builds :
1338+ exception_type = build_result .exception_type or "Unknown"
1339+ exception_counts [exception_type ] = (
1340+ exception_counts .get (exception_type , 0 ) + 1
1341+ )
1342+
1343+ summary_data = {
1344+ "total_packages" : len (self ._build_stack ),
1345+ "total_failures" : len (self .failed_builds ),
1346+ "failure_breakdown" : exception_counts ,
1347+ }
1348+
1349+ with open (summary_file , "w" ) as f :
1350+ json .dump (summary_data , f , indent = 2 )
1351+ logger .info ("test mode: wrote summary to %s" , summary_file )
0 commit comments