Skip to content

Commit 1d40126

Browse files
feat(test-mode): add failure type categorization (Phase 3)
Added type-safe failure categorization for --test-mode. Hook and dependency extraction failures are now logged as warnings and allow processing to continue, while resolution and bootstrap failures remain fatal to the package. Co-Authored-By: Claude <[email protected]> Signed-off-by: Lalatendu Mohanty <[email protected]>
1 parent 0824779 commit 1d40126

File tree

2 files changed

+134
-54
lines changed

2 files changed

+134
-54
lines changed

src/fromager/bootstrapper.py

Lines changed: 105 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@ class SourceBuildResult:
5858
source_type: SourceType
5959

6060

61+
# Valid failure types for test mode error recording
62+
FailureType = typing.Literal["resolution", "bootstrap", "hook", "dependency_extraction"]
63+
64+
65+
class FailureRecord(typing.TypedDict):
66+
"""Record of a package that failed during bootstrap in test mode.
67+
68+
Attributes:
69+
package: The package name that failed.
70+
version: The resolved version (None if resolution failed).
71+
exception_type: The exception class name.
72+
exception_message: The exception message string.
73+
failure_type: Category of failure for analysis.
74+
"""
75+
76+
package: str
77+
version: str | None
78+
exception_type: str
79+
exception_message: str
80+
failure_type: FailureType
81+
82+
6183
class Bootstrapper:
6284
def __init__(
6385
self,
@@ -98,8 +120,8 @@ def __init__(
98120

99121
self._build_order_filename = self.ctx.work_dir / "build-order.json"
100122

101-
# Track failed packages in test mode (list of dicts for JSON export)
102-
self.failed_packages: list[dict[str, typing.Any]] = []
123+
# Track failed packages in test mode (list of typed dicts for JSON export)
124+
self.failed_packages: list[FailureRecord] = []
103125

104126
def resolve_and_add_top_level(
105127
self,
@@ -141,17 +163,7 @@ def resolve_and_add_top_level(
141163
except Exception as err:
142164
if not self.test_mode:
143165
raise
144-
logger.error(
145-
"test mode: failed to resolve %s: %s", req.name, err, exc_info=True
146-
)
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-
)
166+
self._record_test_mode_failure(req, None, err, "resolution")
155167
return None
156168

157169
def resolve_version(
@@ -227,23 +239,18 @@ def bootstrap(self, req: Requirement, req_type: RequirementType) -> None:
227239
version = str(resolved_version)
228240
else:
229241
version = None
230-
logger.error(
231-
"test mode: failed to bootstrap %s: %s", req.name, err, exc_info=True
232-
)
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-
)
242+
self._record_test_mode_failure(req, version, err, "bootstrap")
241243

242244
def _bootstrap_impl(self, req: Requirement, req_type: RequirementType) -> None:
243245
"""Internal implementation of bootstrap logic.
244246
245-
Errors raise exceptions for bootstrap() to catch and record in test mode.
246-
In normal mode, exceptions propagate immediately (fail-fast).
247+
Error Handling:
248+
Fatal errors (version resolution, source build, prebuilt download)
249+
raise exceptions for bootstrap() to catch and record.
250+
251+
Non-fatal errors (post-hook, dependency extraction) are recorded
252+
locally and processing continues. These are recorded here because
253+
the package build succeeded - only optional post-processing failed.
247254
"""
248255
logger.info(f"bootstrapping {req} as {req_type} dependency of {self.why[-1:]}")
249256
constraint = self.ctx.constraints.get_constraint(req.name)
@@ -322,26 +329,45 @@ def _bootstrap_impl(self, req: Requirement, req_type: RequirementType) -> None:
322329
unpacked_cached_wheel=unpacked_cached_wheel,
323330
)
324331

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-
)
332+
# Run post-bootstrap hooks (non-fatal in test mode)
333+
try:
334+
hooks.run_post_bootstrap_hooks(
335+
ctx=self.ctx,
336+
req=req,
337+
dist_name=canonicalize_name(req.name),
338+
dist_version=str(resolved_version),
339+
sdist_filename=build_result.sdist_filename,
340+
wheel_filename=build_result.wheel_filename,
341+
)
342+
except Exception as hook_error:
343+
if not self.test_mode:
344+
raise
345+
self._record_test_mode_failure(
346+
req, str(resolved_version), hook_error, "hook", "warning"
347+
)
334348

335-
# Extract install dependencies
336-
install_dependencies = self._get_install_dependencies(
337-
req=req,
338-
resolved_version=resolved_version,
339-
wheel_filename=build_result.wheel_filename,
340-
sdist_filename=build_result.sdist_filename,
341-
sdist_root_dir=build_result.sdist_root_dir,
342-
build_env=build_result.build_env,
343-
unpack_dir=build_result.unpack_dir,
344-
)
349+
# Extract install dependencies (non-fatal in test mode)
350+
try:
351+
install_dependencies = self._get_install_dependencies(
352+
req=req,
353+
resolved_version=resolved_version,
354+
wheel_filename=build_result.wheel_filename,
355+
sdist_filename=build_result.sdist_filename,
356+
sdist_root_dir=build_result.sdist_root_dir,
357+
build_env=build_result.build_env,
358+
unpack_dir=build_result.unpack_dir,
359+
)
360+
except Exception as dep_error:
361+
if not self.test_mode:
362+
raise
363+
self._record_test_mode_failure(
364+
req,
365+
str(resolved_version),
366+
dep_error,
367+
"dependency_extraction",
368+
"warning",
369+
)
370+
install_dependencies = []
345371

346372
logger.debug(
347373
"install dependencies: %s",
@@ -388,6 +414,40 @@ def _track_why(
388414
finally:
389415
self.why.pop()
390416

417+
def _record_test_mode_failure(
418+
self,
419+
req: Requirement,
420+
version: str | None,
421+
err: Exception,
422+
failure_type: FailureType,
423+
log_level: typing.Literal["error", "warning"] = "error",
424+
) -> None:
425+
"""Record a failure in test mode. Call this after checking test_mode.
426+
427+
Args:
428+
req: The requirement that failed.
429+
version: The version being processed (None if not yet resolved).
430+
err: The exception that was raised.
431+
failure_type: Category of failure for analysis.
432+
log_level: Log at error (fatal) or warning (non-fatal, continuing).
433+
"""
434+
version_str = f"=={version}" if version else ""
435+
msg = f"test mode: {failure_type} failed for {req.name}{version_str}"
436+
if log_level == "warning":
437+
logger.warning("%s: %s (continuing)", msg, err)
438+
else:
439+
logger.error("%s: %s", msg, err, exc_info=True)
440+
441+
self.failed_packages.append(
442+
{
443+
"package": str(req.name),
444+
"version": version,
445+
"exception_type": err.__class__.__name__,
446+
"exception_message": str(err),
447+
"failure_type": failure_type,
448+
}
449+
)
450+
391451
@property
392452
def _explain(self) -> str:
393453
"""Return message formatting current version of why stack."""

tests/test_bootstrap_test_mode.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
"""Tests for --test-mode feature (Phase 2: JSON Failure Reports).
1+
"""Tests for --test-mode feature (Phase 3: Failure Type Categorization).
22
33
Tests the test mode functionality:
44
- Bootstrapper initialization with test_mode flag
55
- Exception handling: catch errors, log, continue
66
- Bootstrapper.finalize() exit codes
77
- JSON failure report generation
8+
- failure_type field for categorizing failures
89
"""
910

1011
import json
@@ -93,6 +94,7 @@ def test_finalize_with_failures_returns_one(
9394
"version": "1.0.0",
9495
"exception_type": "RuntimeError",
9596
"exception_message": "Build failed",
97+
"failure_type": "bootstrap",
9698
}
9799
)
98100
assert bt.finalize() == 1
@@ -109,6 +111,7 @@ def test_finalize_not_in_test_mode_returns_zero(
109111
"version": "1.0.0",
110112
"exception_type": "RuntimeError",
111113
"exception_message": "Error",
114+
"failure_type": "bootstrap",
112115
}
113116
)
114117
assert bt.finalize() == 0
@@ -125,18 +128,21 @@ def test_finalize_logs_failed_packages(
125128
"version": "1.0",
126129
"exception_type": "E",
127130
"exception_message": "m",
131+
"failure_type": "bootstrap",
128132
},
129133
{
130134
"package": "pkg-b",
131135
"version": "2.0",
132136
"exception_type": "E",
133137
"exception_message": "m",
138+
"failure_type": "hook",
134139
},
135140
{
136141
"package": "pkg-c",
137142
"version": "3.0",
138143
"exception_type": "E",
139144
"exception_message": "m",
145+
"failure_type": "dependency_extraction",
140146
},
141147
]
142148
)
@@ -170,6 +176,7 @@ def test_finalize_writes_json_report(
170176
"version": "1.0.0",
171177
"exception_type": "CalledProcessError",
172178
"exception_message": "Compilation failed",
179+
"failure_type": "bootstrap",
173180
}
174181
)
175182

@@ -189,6 +196,7 @@ def test_finalize_writes_json_report(
189196
assert report["failures"][0]["version"] == "1.0.0"
190197
assert report["failures"][0]["exception_type"] == "CalledProcessError"
191198
assert report["failures"][0]["exception_message"] == "Compilation failed"
199+
assert report["failures"][0]["failure_type"] == "bootstrap"
192200

193201
def test_finalize_no_report_when_no_failures(
194202
self, mock_context: context.WorkContext
@@ -212,6 +220,7 @@ def test_finalize_report_with_null_version(
212220
"version": None,
213221
"exception_type": "ResolutionError",
214222
"exception_message": "Could not resolve version",
223+
"failure_type": "resolution",
215224
}
216225
)
217226

@@ -223,11 +232,12 @@ def test_finalize_report_with_null_version(
223232
report = json.load(f)
224233

225234
assert report["failures"][0]["version"] is None
235+
assert report["failures"][0]["failure_type"] == "resolution"
226236

227-
def test_finalize_report_multiple_failures(
237+
def test_finalize_report_multiple_failure_types(
228238
self, mock_context: context.WorkContext
229239
) -> None:
230-
"""Test finalize correctly reports multiple failures."""
240+
"""Test finalize correctly reports multiple failures with different types."""
231241
bt = bootstrapper.Bootstrapper(ctx=mock_context, test_mode=True)
232242
bt.failed_packages.extend(
233243
[
@@ -236,12 +246,21 @@ def test_finalize_report_multiple_failures(
236246
"version": "1.0.0",
237247
"exception_type": "BuildError",
238248
"exception_message": "Failed to compile",
249+
"failure_type": "bootstrap",
239250
},
240251
{
241252
"package": "pkg-b",
242253
"version": "2.0.0",
243-
"exception_type": "ConnectionError",
244-
"exception_message": "Download failed",
254+
"exception_type": "HookError",
255+
"exception_message": "Validation failed",
256+
"failure_type": "hook",
257+
},
258+
{
259+
"package": "pkg-c",
260+
"version": "3.0.0",
261+
"exception_type": "MetadataError",
262+
"exception_message": "Could not read metadata",
263+
"failure_type": "dependency_extraction",
245264
},
246265
]
247266
)
@@ -253,7 +272,8 @@ def test_finalize_report_multiple_failures(
253272
with open(report_path) as f:
254273
report = json.load(f)
255274

256-
assert len(report["failures"]) == 2
257-
packages = [f["package"] for f in report["failures"]]
258-
assert "pkg-a" in packages
259-
assert "pkg-b" in packages
275+
assert len(report["failures"]) == 3
276+
failure_types = [f["failure_type"] for f in report["failures"]]
277+
assert "bootstrap" in failure_types
278+
assert "hook" in failure_types
279+
assert "dependency_extraction" in failure_types

0 commit comments

Comments
 (0)