Skip to content

Include dependency chain in resolution error messages#1241

Open
raravind007 wants to merge 4 commits into
python-wheel-build:mainfrom
raravind007:fix-1214-resolver-error-dependency-chain
Open

Include dependency chain in resolution error messages#1241
raravind007 wants to merge 4 commits into
python-wheel-build:mainfrom
raravind007:fix-1214-resolver-error-dependency-chain

Conversation

@raravind007

Copy link
Copy Markdown

Summary

Fixes #1214.

Resolution failures are logged with a package-name prefix derived from
requirement_ctxvar, which reflects whatever package happens to be active
in the logging context when the exception is finally reported — not
necessarily the package whose requirement actually conflicted. When a
transitive dependency several levels deep fails to resolve (e.g.
its-hub -> reward-hub -> vllm -> flashinfer-python), the error message
correctly names the failing package (flashinfer-python) but the log
prefix can show an unrelated top-level package (its-hub), making it look
like its-hub is the culprit when the real conflict originates with
vllm's pin on flashinfer-python.

Bootstrapper already tracks the full dependency chain for each
WorkItem via why_snapshot. This adds _dependency_chain_suffix(),
which turns that chain into a
(dependency chain: its-hub==1.0 -> reward-hub==2.0 -> vllm==3.0 -> flashinfer-python==0.6.8.post1) suffix, appended to any
ResolverException raised during dependency resolution in
_phase_resolve() — regardless of what the log prefix happens to show.

  • Only wraps the exception when there's an actual chain to add; top-level
    resolution failures (no chain) propagate unchanged (verified by
    exception identity in tests).
  • The wrapped exception suppresses its cause (from None) since the
    chain suffix already embeds the original message — chaining it would
    make __main__._format_exception() print the resolver message twice
    via "... because ...".

Test plan

  • pytest tests/test_bootstrapper_iterative.py — added three
    regression tests: chain-suffix message content, no-duplicate-cause
    behavior (verified through the real __main__._format_exception),
    and identity-preservation for the no-chain path.
  • pytest tests/test_resolver.py
  • ruff check / ruff format --check
  • mypy -p fromager — no new errors introduced

@raravind007 raravind007 requested a review from a team as a code owner July 8, 2026 15:00
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@raravind007, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 56487a0a-f0df-4b1b-8839-05e63f09d743

📥 Commits

Reviewing files that changed from the base of the PR and between 5af6e96 and 39cee94.

📒 Files selected for processing (2)
  • src/fromager/__main__.py
  • tests/test_external_commands.py
📝 Walkthrough

Walkthrough

Bootstrapper now appends dependency-chain context to ResolverException failures during resolve when why_snapshot is present, and leaves top-level resolver failures unchanged. _format_exception() now avoids repeating cause text when the outer exception message already includes it. Tests cover chained dependency messages, preserved exception cause behavior, unchanged propagation, and the updated formatting rules.

Estimated code review effort: 2 (Simple) | ~12 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: dependency chains added to resolution error messages.
Description check ✅ Passed The description matches the code changes and explains the resolution-message fix and tests.
Linked Issues check ✅ Passed The changes address #1214 by appending the full dependency chain and preserving the real resolver cause.
Out of Scope Changes check ✅ Passed The main formatting tweak and added tests are directly needed for the dependency-chain error-message fix.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mergify mergify Bot added the ci label Jul 8, 2026
…uild#1214)

Resolution failures were logged using whatever top-level package
happened to be active in the requirement_ctxvar-based logging context,
which can make an unrelated top-level package look like the culprit
when the actual conflict is several levels down the dependency tree.

_phase_resolve now appends the requirement chain (from WorkItem's
existing why_snapshot) to ResolverException messages, e.g.:

  found no match for flashinfer-python==0.6.8.post1 ... (dependency
  chain: its-hub==1.0 -> reward-hub==2.0 -> vllm==3.0 ->
  flashinfer-python==0.6.8.post1)

The wrapped exception suppresses its cause (`from None`) since the
chain suffix already embeds the original message, and top-level
failures with no chain propagate unchanged.

Signed-off-by: Reshmi Aravind <raravind@redhat.com>
Self-review follow-up: _phase_resolve was suppressing the cause
(from None) to avoid __main__._format_exception() printing the
resolver message twice via "... because ...". That violated the
project's documented rule to chain exceptions, and silently discarded
the original traceback that debug/test-mode logging relies on.

Restore `from err` and instead teach _format_exception() to skip the
"because" clause when the cause's message is already a substring of
the outer message - the actual duplication this PR needs to avoid.

Also drop the redundant empty-string-as-boolean round trip between
_dependency_chain_suffix() and its caller (check item.why_snapshot
directly instead).

Signed-off-by: Reshmi Aravind <raravind@redhat.com>
@raravind007 raravind007 force-pushed the fix-1214-resolver-error-dependency-chain branch from 6311c78 to b8c5955 Compare July 8, 2026 15:40
@raravind007

Copy link
Copy Markdown
Author

Ran an AI-assisted code review (self-review) against this diff before/after opening the PR, which caught a couple of real issues, now fixed in the second commit:

  • The original version suppressed the exception cause (raise ... from None) to avoid __main__._format_exception() printing the resolver message twice via "... because ...". That broke this project's own documented convention (AGENTS.md: "Chain exceptions: raise ValueError(...) from err") and silently discarded the original traceback that debug/test-mode logging relies on.
  • Fixed by keeping from err (preserving the real traceback/cause chain) and instead teaching _format_exception() to skip the "because" clause when the cause's message is already embedded in the outer message — the actual duplication this needed to avoid, fixed at the right layer instead of a local workaround.
  • Also dropped a small redundant empty-string-as-boolean round trip between _dependency_chain_suffix() and its caller.
  • Rebased to add the DCO Signed-off-by trailer to both commits (the first was missing it) — hence the force-push; branch still has the same two logical commits.

Happy to share the fuller review notes if useful.

@rd4398 rd4398 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this fully solves #1214

There are two separate issues that need to be addressed:

  1. The error message body doesn't show the dependency path (PR fixes this)
    The PR appends (dependency chain: its-hub==1.0 -> reward-hub==2.0 -> vllm==3.0 -> flashinfer-python==0.6.8.post1) to the ResolverException message. That helps a user trace the real path.
  2. The its-hub: log prefix is still wrong (PR does NOT fix this)
    The misleading prefix comes from requirement_ctxvar.

I have also added a suggestion for current PR. Can you take a look at this and wither send a second PR or change the original issue description and create a follow up card? cc @LalatenduMohanty for awareness

Comment thread src/fromager/__main__.py Outdated
# embedded in the outer exception's own message (e.g. an exception
# re-raised with extra context appended to the original text) —
# otherwise the cause would be printed a second time verbatim.
if cause and cause in exc_str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks very fragile. A safer check would be to verify the outer message starts with the cause text, since the pattern is always f"{err}{suffix}":

  if cause and exc_str.startswith(cause):
      return exc_str

Or even more precise: we could check that the outer message equals cause + some suffix. The startswith variant is still general enough for any append context to original message pattern while avoiding false positives from coincidental substring matches

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the startswith suggestion, Rohan! I applied that, but while adding a regression test using the actual nested resolver-style exception shape, I found one more issue. :)

The check should compare against the immediate cause's raw message, str(exc.__cause__), rather than the recursively formatted cause. When the cause itself has a cause, _format_exception(exc.__cause__) can already include its own " because ..." suffix, which no longer matches the prefix of the outer f"{err}{suffix}" message.

I updated the formatter to use str(exc.__cause__) for the dedupe check, while still using the recursively formatted cause when the "because" clause is actually emitted. I also added a regression test for the real resolver.py → _phase_resolve() wrapping shape. Pushed this as a separate commit so the correction is clear in the PR history.

Per review feedback from Rohan (PR python-wheel-build#1241): the dedup pattern is
always f"{err}{suffix}", so `cause in exc_str` is unnecessarily loose
and could false-positive if the cause's text coincidentally appears
elsewhere in an unrelated outer message. `exc_str.startswith(cause)`
matches the actual invariant instead.

Adds a regression test proving the old substring check would have
incorrectly suppressed the "because" clause for a coincidental match.

Signed-off-by: Reshmi Aravind <raravind@redhat.com>
The previous `startswith` fix compared the outer exception message
against the recursively formatted cause string. That breaks when the
immediate cause has a cause of its own, because the recursively
formatted cause may already contain a `" because ..."` suffix.

That matters for the real resolver failure shape: `resolver.py` can
raise a `ResolverException` from an inner error while embedding the
inner message in its own text, and `_phase_resolve()` can then wrap
that `ResolverException` again with dependency-chain context. In that
nested case, comparing against `_format_exception(exc.__cause__)`
fails to recognize the append-context pattern and can still produce a
duplicated, hard-to-read message.

Compare against `str(exc.__cause__)` instead: the immediate cause's
raw message. That matches the actual `f"{err}{suffix}"` pattern while
still using the recursively formatted cause when the `"because"`
clause really needs to be printed.

Add a regression test using the real nested resolver-style exception
shape rather than a flat mock.

Signed-off-by: Reshmi Aravind <raravind@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: fromager might "lie" about the reason of dependency resolution issue

2 participants