22
33import contextlib
44import dataclasses
5+ import datetime
56import json
67import logging
78import operator
@@ -97,8 +98,8 @@ def __init__(
9798
9899 self ._build_order_filename = self .ctx .work_dir / "build-order.json"
99100
100- # Track failed packages in test mode (simple list of package names )
101- self .failed_packages : list [str ] = []
101+ # Track failed packages in test mode (list of dicts for JSON export )
102+ self .failed_packages : list [dict [ str , typing . Any ] ] = []
102103
103104 def resolve_and_add_top_level (
104105 self ,
@@ -143,7 +144,14 @@ def resolve_and_add_top_level(
143144 logger .error (
144145 "test mode: failed to resolve %s: %s" , req .name , err , exc_info = True
145146 )
146- self .failed_packages .append (str (req .name ))
147+ self .failed_packages .append (
148+ {
149+ "package" : str (req .name ),
150+ "version" : None ,
151+ "exception_type" : err .__class__ .__name__ ,
152+ "exception_message" : str (err ),
153+ }
154+ )
147155 return None
148156
149157 def resolve_version (
@@ -212,22 +220,30 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
212220 except Exception as err :
213221 if not self .test_mode :
214222 raise
223+ # Get version from cache if available
224+ cached = self ._resolved_requirements .get (str (req ))
225+ if cached :
226+ _source_url , resolved_version = cached
227+ version = str (resolved_version )
228+ else :
229+ version = None
215230 logger .error (
216231 "test mode: failed to bootstrap %s: %s" , req .name , err , exc_info = True
217232 )
218- self .failed_packages .append (str (req .name ))
233+ self .failed_packages .append (
234+ {
235+ "package" : str (req .name ),
236+ "version" : version ,
237+ "exception_type" : err .__class__ .__name__ ,
238+ "exception_message" : str (err ),
239+ }
240+ )
219241
220242 def _bootstrap_impl (self , req : Requirement , req_type : RequirementType ) -> None :
221243 """Internal implementation of bootstrap logic.
222244
223- Error Handling:
224- Fatal errors (version resolution, source build, prebuilt download)
225- raise exceptions for bootstrap() to catch and record.
226-
227- Non-fatal errors (post-hook, dependency extraction) are recorded
228- locally and processing continues. These are recorded here rather
229- than in bootstrap() because the package build succeeded - only
230- optional processing failed.
245+ Errors raise exceptions for bootstrap() to catch and record in test mode.
246+ In normal mode, exceptions propagate immediately (fail-fast).
231247 """
232248 logger .info (f"bootstrapping { req } as { req_type } dependency of { self .why [- 1 :]} " )
233249 constraint = self .ctx .constraints .get_constraint (req .name )
@@ -306,28 +322,17 @@ def _bootstrap_impl(self, req: Requirement, req_type: RequirementType) -> None:
306322 unpacked_cached_wheel = unpacked_cached_wheel ,
307323 )
308324
309- # Run post-bootstrap hooks - in test mode, log and continue on failure
310- try :
311- hooks .run_post_bootstrap_hooks (
312- ctx = self .ctx ,
313- req = req ,
314- dist_name = canonicalize_name (req .name ),
315- dist_version = str (resolved_version ),
316- sdist_filename = build_result .sdist_filename ,
317- wheel_filename = build_result .wheel_filename ,
318- )
319- except Exception as hook_error :
320- if not self .test_mode :
321- raise
322- logger .warning (
323- "test mode: post-bootstrap hook failed for %s==%s: %s (continuing)" ,
324- req .name ,
325- resolved_version ,
326- hook_error ,
327- )
328- # Continue - hooks are not critical for dependency discovery
325+ # Run post-bootstrap hooks
326+ hooks .run_post_bootstrap_hooks (
327+ ctx = self .ctx ,
328+ req = req ,
329+ dist_name = canonicalize_name (req .name ),
330+ dist_version = str (resolved_version ),
331+ sdist_filename = build_result .sdist_filename ,
332+ wheel_filename = build_result .wheel_filename ,
333+ )
329334
330- # Extract install dependencies (handles test-mode internally)
335+ # Extract install dependencies
331336 install_dependencies = self ._get_install_dependencies (
332337 req = req ,
333338 resolved_version = resolved_version ,
@@ -578,58 +583,43 @@ def _get_install_dependencies(
578583 ) -> list [Requirement ]:
579584 """Extract install dependencies from wheel or sdist.
580585
581- In test mode, returns empty list on failure instead of raising.
582-
583586 Returns:
584- List of install requirements (empty list on failure in test mode) .
587+ List of install requirements.
585588
586589 Raises:
587590 RuntimeError: If both wheel_filename and sdist_filename are None.
588- Exception: In normal mode, re-raises any extraction error.
589591 """
590- try :
591- if wheel_filename is not None :
592- assert unpack_dir is not None
593- logger .debug (
594- "get install dependencies of wheel %s" ,
595- wheel_filename .name ,
596- )
597- return list (
598- dependencies .get_install_dependencies_of_wheel (
599- req = req ,
600- wheel_filename = wheel_filename ,
601- requirements_file_dir = unpack_dir ,
602- )
603- )
604- elif sdist_filename is not None :
605- assert sdist_root_dir is not None
606- assert build_env is not None
607- logger .debug (
608- "get install dependencies of sdist from directory %s" ,
609- sdist_root_dir ,
592+ if wheel_filename is not None :
593+ assert unpack_dir is not None
594+ logger .debug (
595+ "get install dependencies of wheel %s" ,
596+ wheel_filename .name ,
597+ )
598+ return list (
599+ dependencies .get_install_dependencies_of_wheel (
600+ req = req ,
601+ wheel_filename = wheel_filename ,
602+ requirements_file_dir = unpack_dir ,
610603 )
611- return list (
612- dependencies .get_install_dependencies_of_sdist (
613- ctx = self .ctx ,
614- req = req ,
615- version = resolved_version ,
616- sdist_root_dir = sdist_root_dir ,
617- build_env = build_env ,
618- )
604+ )
605+ elif sdist_filename is not None :
606+ assert sdist_root_dir is not None
607+ assert build_env is not None
608+ logger .debug (
609+ "get install dependencies of sdist from directory %s" ,
610+ sdist_root_dir ,
611+ )
612+ return list (
613+ dependencies .get_install_dependencies_of_sdist (
614+ ctx = self .ctx ,
615+ req = req ,
616+ version = resolved_version ,
617+ sdist_root_dir = sdist_root_dir ,
618+ build_env = build_env ,
619619 )
620- else :
621- raise RuntimeError ("wheel_filename and sdist_filename are None" )
622-
623- except Exception as err :
624- if not self .test_mode :
625- raise
626- logger .warning (
627- "test mode: failed to extract dependencies for %s==%s: %s (continuing)" ,
628- req .name ,
629- resolved_version ,
630- err ,
631620 )
632- return []
621+ else :
622+ raise RuntimeError ("wheel_filename and sdist_filename are None" )
633623
634624 def _download_source (
635625 self ,
@@ -1395,7 +1385,7 @@ def _add_to_build_order(
13951385 def finalize (self ) -> int :
13961386 """Finalize bootstrap and return exit code.
13971387
1398- In test mode, logs summary and returns non-zero if there were failures.
1388+ In test mode, writes failure report and returns non-zero if there were failures.
13991389
14001390 Returns:
14011391 0 if all packages built successfully (or not in test mode)
@@ -1408,9 +1398,18 @@ def finalize(self) -> int:
14081398 logger .info ("test mode: all packages processed successfully" )
14091399 return 0
14101400
1401+ # Write JSON failure report with timestamp for uniqueness
1402+ timestamp = datetime .datetime .now (tz = datetime .UTC ).strftime ("%Y%m%d-%H%M%S-%f" )
1403+ failures_file = self .ctx .work_dir / f"test-mode-failures-{ timestamp } .json"
1404+ with open (failures_file , "w" ) as f :
1405+ json .dump ({"failures" : self .failed_packages }, f , indent = 2 )
1406+ logger .info ("test mode: wrote failure report to %s" , failures_file )
1407+
1408+ # Log summary
1409+ failed_names = [f ["package" ] for f in self .failed_packages ]
14111410 logger .error (
14121411 "test mode: %d package(s) failed: %s" ,
14131412 len (self .failed_packages ),
1414- ", " .join (self . failed_packages ),
1413+ ", " .join (failed_names ),
14151414 )
14161415 return 1
0 commit comments