@@ -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 ,
@@ -145,6 +196,56 @@ def _processing_build_requirement(self, current_req_type: RequirementType) -> bo
145196 return False
146197
147198 def bootstrap (self , req : Requirement , req_type : RequirementType ) -> Version :
199+ """Bootstrap a package and its dependencies.
200+
201+ In test mode, catches all exceptions, records failures, and continues.
202+ In normal mode, raises exceptions immediately (fail-fast).
203+ """
204+ try :
205+ return self ._bootstrap_impl (req , req_type )
206+ except Exception as err :
207+ if not self .test_mode :
208+ raise
209+ return self ._handle_bootstrap_failure (req , err )
210+
211+ def _handle_bootstrap_failure (self , req : Requirement , err : Exception ) -> Version :
212+ """Handle complete bootstrap failure in test mode.
213+
214+ Called when an exception escapes from _bootstrap_impl(). Records the
215+ failure and returns a version to allow processing to continue.
216+ """
217+ logger .error (
218+ "test mode: failed to bootstrap %s: %s" ,
219+ req ,
220+ err ,
221+ exc_info = True ,
222+ )
223+
224+ # Try to get resolved version from cache if available
225+ cached = self ._resolved_requirements .get (str (req ))
226+ resolved_version = cached [1 ] if cached else None
227+
228+ # Get source type if we have a resolved version
229+ if resolved_version :
230+ source_url_type = str (sources .get_source_type (self .ctx , req ))
231+ else :
232+ source_url_type = "unknown"
233+
234+ self .failed_builds .append (
235+ BuildFailure .from_exception (
236+ req = req ,
237+ resolved_version = resolved_version ,
238+ source_url_type = source_url_type ,
239+ exception = err ,
240+ )
241+ )
242+
243+ # Return a version to allow processing to continue
244+ # Use cached version if available, otherwise return a null version
245+ return resolved_version or Version ("0.0.0" )
246+
247+ def _bootstrap_impl (self , req : Requirement , req_type : RequirementType ) -> Version :
248+ """Internal implementation of bootstrap logic."""
148249 logger .info (f"bootstrapping { req } as { req_type } dependency of { self .why [- 1 :]} " )
149250 constraint = self .ctx .constraints .get_constraint (req .name )
150251 if constraint :
@@ -217,15 +318,31 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> Version:
217318 )
218319
219320 # 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- )
321+ try :
322+ build_result = self ._build_from_source (
323+ req = req ,
324+ resolved_version = resolved_version ,
325+ source_url = source_url ,
326+ build_sdist_only = build_sdist_only ,
327+ cached_wheel_filename = cached_wheel_filename ,
328+ unpacked_cached_wheel = unpacked_cached_wheel ,
329+ )
330+
331+ except Exception as build_error :
332+ if not self .test_mode :
333+ raise
334+
335+ fallback_result = self ._handle_test_mode_failure (
336+ req = req ,
337+ resolved_version = resolved_version ,
338+ req_type = req_type ,
339+ build_error = build_error ,
340+ )
341+ if fallback_result is None :
342+ self .why .pop ()
343+ return resolved_version
228344
345+ build_result , resolved_version = fallback_result
229346 hooks .run_post_bootstrap_hooks (
230347 ctx = self .ctx ,
231348 req = req ,
@@ -605,6 +722,90 @@ def _build_from_source(
605722 source_type = source_type ,
606723 )
607724
725+ def _handle_test_mode_failure (
726+ self ,
727+ req : Requirement ,
728+ resolved_version : Version ,
729+ req_type : RequirementType ,
730+ build_error : Exception ,
731+ ) -> tuple [SourceBuildResult , Version ] | None :
732+ """Handle build failure in test mode.
733+
734+ Attempts pre-built wheel fallback. Returns SourceBuildResult and
735+ fallback version if successful, None if both build and fallback failed.
736+
737+ Args:
738+ req: Package requirement
739+ resolved_version: Version that failed to build
740+ req_type: Type of requirement
741+ build_error: The original build exception
742+
743+ Returns:
744+ Tuple of (SourceBuildResult, fallback_version) if fallback succeeded,
745+ None otherwise.
746+ """
747+ logger .warning (
748+ "test mode: build failed for %s==%s, attempting pre-built fallback" ,
749+ req .name ,
750+ resolved_version ,
751+ exc_info = True ,
752+ )
753+
754+ try :
755+ wheel_url , fallback_version = self ._resolve_prebuilt_with_history (
756+ req = req ,
757+ req_type = req_type ,
758+ )
759+
760+ if fallback_version != resolved_version :
761+ logger .warning (
762+ "test mode: version mismatch for %s - requested %s, fallback %s" ,
763+ req .name ,
764+ resolved_version ,
765+ fallback_version ,
766+ )
767+
768+ wheel_filename , unpack_dir = self ._download_prebuilt (
769+ req = req ,
770+ req_type = req_type ,
771+ resolved_version = fallback_version ,
772+ wheel_url = wheel_url ,
773+ )
774+
775+ logger .info (
776+ "test mode: successfully used pre-built wheel for %s==%s" ,
777+ req .name ,
778+ fallback_version ,
779+ )
780+
781+ build_result = SourceBuildResult (
782+ wheel_filename = wheel_filename ,
783+ sdist_filename = None ,
784+ unpack_dir = unpack_dir ,
785+ sdist_root_dir = None ,
786+ build_env = None ,
787+ source_type = SourceType .PREBUILT ,
788+ )
789+ return (build_result , fallback_version )
790+
791+ except Exception as fallback_error :
792+ logger .error (
793+ "test mode: pre-built fallback also failed for %s: %s" ,
794+ req .name ,
795+ fallback_error ,
796+ exc_info = True ,
797+ )
798+ source_url_type = str (sources .get_source_type (self .ctx , req ))
799+ self .failed_builds .append (
800+ BuildFailure .from_exception (
801+ req = req ,
802+ resolved_version = resolved_version ,
803+ source_url_type = source_url_type ,
804+ exception = build_error ,
805+ )
806+ )
807+ return None
808+
608809 def _look_for_existing_wheel (
609810 self ,
610811 req : Requirement ,
@@ -1127,3 +1328,43 @@ def _add_to_build_order(
11271328 # Requirement and Version instances that can't be
11281329 # converted to JSON without help.
11291330 json .dump (self ._build_stack , f , indent = 2 , default = str )
1331+
1332+ def write_test_mode_report (self , work_dir : pathlib .Path ) -> None :
1333+ """Write test mode failure report to JSON files.
1334+
1335+ Generates two JSON files:
1336+ - test-mode-failures.json: Detailed list of all failures
1337+ - test-mode-summary.json: Summary statistics
1338+ """
1339+ if not self .test_mode :
1340+ return
1341+
1342+ failures_file = work_dir / "test-mode-failures.json"
1343+ summary_file = work_dir / "test-mode-summary.json"
1344+
1345+ # Generate failures report
1346+ failures_data = {
1347+ "failures" : [build_result .to_dict () for build_result in self .failed_builds ]
1348+ }
1349+
1350+ with open (failures_file , "w" ) as f :
1351+ json .dump (failures_data , f , indent = 2 )
1352+ logger .info ("test mode: wrote failure details to %s" , failures_file )
1353+
1354+ # Generate summary report
1355+ exception_counts : dict [str , int ] = {}
1356+ for build_result in self .failed_builds :
1357+ exception_type = build_result .exception_type or "Unknown"
1358+ exception_counts [exception_type ] = (
1359+ exception_counts .get (exception_type , 0 ) + 1
1360+ )
1361+
1362+ summary_data = {
1363+ "total_packages" : len (self ._build_stack ),
1364+ "total_failures" : len (self .failed_builds ),
1365+ "failure_breakdown" : exception_counts ,
1366+ }
1367+
1368+ with open (summary_file , "w" ) as f :
1369+ json .dump (summary_data , f , indent = 2 )
1370+ logger .info ("test mode: wrote summary to %s" , summary_file )
0 commit comments