@@ -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 ,
@@ -145,6 +222,47 @@ def _processing_build_requirement(self, current_req_type: RequirementType) -> bo
145222 return False
146223
147224 def bootstrap (self , req : Requirement , req_type : RequirementType ) -> Version :
225+ """Bootstrap a package and its dependencies.
226+
227+ In test mode, catches build exceptions, records failures, and continues.
228+ In normal mode, raises exceptions immediately (fail-fast).
229+
230+ Returns:
231+ The resolved version (even on build failure in test mode).
232+
233+ Raises:
234+ Exception: Always in normal mode; in test mode only if version
235+ resolution failed (no version to return).
236+ """
237+ try :
238+ return self ._bootstrap_impl (req , req_type )
239+ except Exception as err :
240+ if not self .test_mode :
241+ raise
242+
243+ cached = self ._resolved_requirements .get (str (req ))
244+ if not cached :
245+ logger .error (
246+ "test mode: failed to resolve version for %s, cannot continue: %s" ,
247+ req ,
248+ err ,
249+ exc_info = True ,
250+ )
251+ raise
252+
253+ resolved_version = cached [1 ]
254+ logger .error (
255+ "test mode: failed to bootstrap %s==%s: %s" ,
256+ req .name ,
257+ resolved_version ,
258+ err ,
259+ exc_info = True ,
260+ )
261+ self ._record_failure (req , resolved_version , err )
262+ return resolved_version
263+
264+ def _bootstrap_impl (self , req : Requirement , req_type : RequirementType ) -> Version :
265+ """Internal implementation of bootstrap logic."""
148266 logger .info (f"bootstrapping { req } as { req_type } dependency of { self .why [- 1 :]} " )
149267 constraint = self .ctx .constraints .get_constraint (req .name )
150268 if constraint :
@@ -185,9 +303,7 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
185303
186304 logger .info (f"new { req_type } dependency { req } resolves to { resolved_version } " )
187305
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.
306+ # Track dependency chain for error messages
191307 self .why .append ((req_type , req , resolved_version ))
192308
193309 cached_wheel_filename : pathlib .Path | None = None
@@ -200,7 +316,6 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
200316 resolved_version = resolved_version ,
201317 wheel_url = source_url ,
202318 )
203- # Remember that this is a prebuilt wheel, and where we got it.
204319 build_result = SourceBuildResult (
205320 wheel_filename = wheel_filename ,
206321 sdist_filename = None ,
@@ -210,21 +325,36 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
210325 source_type = SourceType .PREBUILT ,
211326 )
212327 else :
213- # Look for an existing wheel in caches (3 levels: build, downloads,
214- # cache server) before building from source.
328+ # Look for an existing wheel in caches before building
215329 cached_wheel_filename , unpacked_cached_wheel = self ._find_cached_wheel (
216330 req , resolved_version
217331 )
218332
219333 # 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- )
334+ try :
335+ build_result = self ._build_from_source (
336+ req = req ,
337+ resolved_version = resolved_version ,
338+ source_url = source_url ,
339+ build_sdist_only = build_sdist_only ,
340+ cached_wheel_filename = cached_wheel_filename ,
341+ unpacked_cached_wheel = unpacked_cached_wheel ,
342+ )
343+ except Exception as build_error :
344+ if not self .test_mode :
345+ raise
346+
347+ fallback_result = self ._handle_test_mode_failure (
348+ req = req ,
349+ resolved_version = resolved_version ,
350+ req_type = req_type ,
351+ build_error = build_error ,
352+ )
353+ if fallback_result is None :
354+ self .why .pop ()
355+ return resolved_version
356+
357+ build_result = fallback_result
228358
229359 hooks .run_post_bootstrap_hooks (
230360 ctx = self .ctx ,
@@ -268,7 +398,7 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
268398 raise ValueError (f"could not handle { self ._explain } " ) from err
269399 self .progressbar .update ()
270400
271- # we are done processing this req, so lets remove it from the why chain
401+ # Done processing this req, remove from why chain and clean up
272402 self .why .pop ()
273403 self .ctx .clean_build_dirs (build_result .sdist_root_dir , build_result .build_env )
274404 return resolved_version
@@ -605,6 +735,72 @@ def _build_from_source(
605735 source_type = source_type ,
606736 )
607737
738+ def _handle_test_mode_failure (
739+ self ,
740+ req : Requirement ,
741+ resolved_version : Version ,
742+ req_type : RequirementType ,
743+ build_error : Exception ,
744+ ) -> SourceBuildResult | None :
745+ """Handle build failure in test mode by attempting pre-built fallback.
746+
747+ Returns:
748+ SourceBuildResult if fallback succeeded, None if fallback also failed.
749+ Records failure via _record_failure() before returning None.
750+ """
751+ logger .warning (
752+ "test mode: build failed for %s==%s, attempting pre-built fallback" ,
753+ req .name ,
754+ resolved_version ,
755+ exc_info = True ,
756+ )
757+
758+ try :
759+ wheel_url , fallback_version = self ._resolve_prebuilt_with_history (
760+ req = req ,
761+ req_type = req_type ,
762+ )
763+
764+ if fallback_version != resolved_version :
765+ logger .warning (
766+ "test mode: version mismatch for %s - requested %s, fallback %s" ,
767+ req .name ,
768+ resolved_version ,
769+ fallback_version ,
770+ )
771+
772+ wheel_filename , unpack_dir = self ._download_prebuilt (
773+ req = req ,
774+ req_type = req_type ,
775+ resolved_version = fallback_version ,
776+ wheel_url = wheel_url ,
777+ )
778+
779+ logger .info (
780+ "test mode: successfully used pre-built wheel for %s==%s" ,
781+ req .name ,
782+ fallback_version ,
783+ )
784+
785+ return SourceBuildResult (
786+ wheel_filename = wheel_filename ,
787+ sdist_filename = None ,
788+ unpack_dir = unpack_dir ,
789+ sdist_root_dir = None ,
790+ build_env = None ,
791+ source_type = SourceType .PREBUILT ,
792+ )
793+
794+ except Exception as fallback_error :
795+ logger .error (
796+ "test mode: pre-built fallback also failed for %s: %s" ,
797+ req .name ,
798+ fallback_error ,
799+ exc_info = True ,
800+ )
801+ self ._record_failure (req , resolved_version , build_error )
802+ return None
803+
608804 def _look_for_existing_wheel (
609805 self ,
610806 req : Requirement ,
@@ -1127,3 +1323,43 @@ def _add_to_build_order(
11271323 # Requirement and Version instances that can't be
11281324 # converted to JSON without help.
11291325 json .dump (self ._build_stack , f , indent = 2 , default = str )
1326+
1327+ def write_test_mode_report (self , work_dir : pathlib .Path ) -> None :
1328+ """Write test mode failure report to JSON files.
1329+
1330+ Generates two JSON files:
1331+ - test-mode-failures.json: Detailed list of all failures
1332+ - test-mode-summary.json: Summary statistics
1333+ """
1334+ if not self .test_mode :
1335+ return
1336+
1337+ failures_file = work_dir / "test-mode-failures.json"
1338+ summary_file = work_dir / "test-mode-summary.json"
1339+
1340+ # Generate failures report
1341+ failures_data = {
1342+ "failures" : [build_result .to_dict () for build_result in self .failed_builds ]
1343+ }
1344+
1345+ with open (failures_file , "w" ) as f :
1346+ json .dump (failures_data , f , indent = 2 )
1347+ logger .info ("test mode: wrote failure details to %s" , failures_file )
1348+
1349+ # Generate summary report
1350+ exception_counts : dict [str , int ] = {}
1351+ for build_result in self .failed_builds :
1352+ exception_type = build_result .exception_type or "Unknown"
1353+ exception_counts [exception_type ] = (
1354+ exception_counts .get (exception_type , 0 ) + 1
1355+ )
1356+
1357+ summary_data = {
1358+ "total_packages" : len (self ._build_stack ),
1359+ "total_failures" : len (self .failed_builds ),
1360+ "failure_breakdown" : exception_counts ,
1361+ }
1362+
1363+ with open (summary_file , "w" ) as f :
1364+ json .dump (summary_data , f , indent = 2 )
1365+ logger .info ("test mode: wrote summary to %s" , summary_file )
0 commit comments