Skip to content

Commit 0824779

Browse files
feat(test-mode): add JSON failure report generation
Store failure details (package, version, exception info) and write test-mode-failures.json to work directory for post-analysis. Co-Authored-By: Claude <claude@anthropic.com> Signed-off-by: Lalatendu Mohanty <lmohanty@redhat.com>
1 parent 3598819 commit 0824779

3 files changed

Lines changed: 231 additions & 86 deletions

File tree

src/fromager/bootstrapper.py

Lines changed: 78 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import contextlib
44
import dataclasses
5+
import datetime
56
import json
67
import logging
78
import 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

src/fromager/commands/bootstrap.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def _get_requirements_from_args(
101101
"test_mode",
102102
is_flag=True,
103103
default=False,
104-
help="Test mode: mark failed packages as pre-built and continue, report failures at end",
104+
help="Test mode: continue processing after failures, report failures at end",
105105
)
106106
@click.argument("toplevel", nargs=-1)
107107
@click.pass_obj
@@ -144,7 +144,7 @@ def bootstrap(
144144

145145
if test_mode:
146146
logger.info(
147-
"test mode enabled: will mark failed packages as pre-built and continue"
147+
"test mode enabled: will continue processing after failures and report at end"
148148
)
149149

150150
pre_built = wkctx.settings.list_pre_built()

0 commit comments

Comments
 (0)