8000 Add reason for failed match to RaisesGroup by jakkdl · Pull Request #3145 · python-trio/trio · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Add reason for failed match to RaisesGroup #3145

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 41 commits into from
Jan 29, 2025

Conversation

jakkdl
Copy link
Member
@jakkdl jakkdl commented Nov 29, 2024

As another preparatory step for adding RaisesGroup into pytest, I thought I'd finally get around to making it.. not a nightmare to debug why it fails to match. When I've been using RaisesGroup with a complicated structure I've sometimes found it very tricky to figure out why it's not matching - and I imagine that's 10x worse for somebody not intimately familiar with its innards.

This does introduce a major change in behavior, previously RaisesGroup, like pytest.raises, would silently pass through the exception if it didn't match - but now it will instead fail with an AssertionError. This also has precedent upstream though, pytest.raises will fail with an AssertionError iff you've specified match.

You still see the original exception in the traceback, and in many ways I think always failing with an AssertionError is more legible.
I don't think this will impact end user's test suites in a majority of cases, unless they're either testing RaisesGroup behavior itself, or doing some very weird nesting. But even if so, they can rewrite their code as:

with pytest.raises(AssertionError) as exc_info:
    with RaisesGroup(...):
        ...
assert isinstance(exc_info.type, ValueError)

Another improvement would be making .matches() directly raise an AssertionError, instead of quietly setting .fail_reason. This would break any test currently doing if not RaisesGroup(...).matches(...):, or even more plausibly assert not RaisesGroup(...).matches(...), so
I think I should add a new method .assert_matches() to both RaisesGroup and Matcher, which either calls .matches() and asserts the return value with fail_reason as the message - or do it the other way around and have .matches() call .assert_matches() in a try:.

There's lots of bikeshedding possible with the phrasing of each error message, and am not completely happy with nested cases, so would very much appreciate any suggestions.

@jakkdl jakkdl requested a review from Zac-HD November 29, 2024 12:16
Copy link
codecov bot commented Nov 29, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00000%. Comparing base (ed2a7d3) to head (8cb294f).
Report is 238 commits behind head on main.

Additional details and impacted files
@@               Coverage Diff                @@
##                 main        #3145    +/-   ##
================================================
  Coverage   100.00000%   100.00000%            
================================================
  Files             124          124            
  Lines           18460        18733   +273     
  Branches         1216         1265    +49     
================================================
+ Hits            18460        18733   +273     
Files with missing lines Coverage Δ
src/trio/_core/_run.py 100.00000% <ø> (ø)
src/trio/_tests/test_exports.py 100.00000% <100.00000%> (ø)
src/trio/_tests/test_testing_raisesgroup.py 100.00000% <100.00000%> (ø)
src/trio/testing/_raises_group.py 100.00000% <100.00000%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Member
@Zac-HD Zac-HD left a comment

Choose a reason for hiding this comment

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

I'm excited to see continued progress here! Really good support for ExceptionGroup in testing is one of those things that doesn't seem like a huge problem until you're living with it 😅

@jakkdl jakkdl requested a review from Zac-HD December 9, 2024 16:11
@jakkdl
Copy link
Member Author
jakkdl commented Jan 8, 2025

The only remaining TODO now is

# TODO: this one should maybe say that it matched an already matched exception
with (
fails_raises_group(
"1 matched exception. Too few exceptions raised, found no match for: [<class 'ValueError'>]"
),
RaisesGroup(ValueError, ValueError),
):
raise ExceptionGroup("", (ValueError(),))

which I'm procrastinating on because _check_exceptions is becoming an unwieldy beast.
I think I'm sticking to my guns on the matching being greedy, but _check_exceptions is so close to being fully exhaustive on fail already that we can probably have a message for "there is a pairing that would've worked, you should specify your requirements more stringently to allow the greedy matching to work"

@CoolCat467
Copy link
Member

Run failure for pypi looks familiar, I think the issue last time was that package cache pypi runner was using had cached an outdated version of what uv versions were available, leading it to believe that constraint-specified version of uv does not exist when it actually does in reality, just using an old cache.

…h no matches. Print if there's a possible covering that failed due to too-general expected.
@jakkdl
Copy link
Member Author
jakkdl commented Jan 13, 2025

Okay I like the new structure with an ABC, just a couple last things.
A couple methods should probably be renamed to disambiguate _check_xxx methods that set fail_reason and those that don't.

@A5rocks I still can't get rid of the type: ign A3DB ore, if you want to take a look feel free. But even so I'm not sure it's worth having a bunch of @overloads (if they're necessary) and sending a dummy variable to _check_exceptions just to get rid of a type: ignore/cast.

@jakkdl
Copy link
Member Author
jakkdl commented Jan 13, 2025

Okay I like the new structure with an ABC, just a couple last things. A couple methods should probably be renamed to disambiguate _check_xxx methods that set fail_reason and those that don't.

@A5rocks I still can't get rid of the type: ignore, if you want to take a look feel free. But even so I'm not sure it's worth having a bunch of @overloads (if they're necessary) and sending a dummy variable to _check_exceptions just to get rid of a type: ignore/cast.

I ... moved the type: ignore to RaisesGroup.__init__ 🙃

@jakkdl jakkdl requested a review from A5rocks January 13, 2025 16:04
Copy link
< F438 /span>
Contributor
@A5rocks A5rocks left a comment

Choose a reason for hiding this comment

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

Looks fine.

I feel like there should be a (polynomial-time) algorithm that tells us whether there's a solution that works for non-greedy matching, but I couldn't think of anything after maybe 30 minutes of staring at examples/counterexamples... Oh well.

(that is, it feels weird that there's no way to go from {a: [1, 2, 3], b: [1], c: [1]} to "there is no way of assigning those" in polynomial time in general)


I thought even more about it and actually it makes sense that it's not possible.

# "Optional[Callable[[BaseExceptionGroup[Union[ExcT_1, BaseExcT_1, BaseExceptionGroup[BaseExcT_2]]]], bool]]"
# which is kinda obviously off, but idk how to resolve that while also not
# getting any errors about overloads
super().__init__(match, check) # type: ignore[arg-type]
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe this could be fixed by updating the abstract class's __init__. (I'm less and less sure about whether it's a good idea as I come across downsides like this, but oh well. It isn't that bad.)

Namely I think the issue is that AbstractMatcher expects a callable that can take BaseExceptionGroup[BaseExceptionGroup[BaseExcT_2]]...? That's weird.

Copy link
Member Author
@jakkdl jakkdl Jan 20, 2025

Choose a reason for hiding this comment

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

Nah that's not weird, BaseExceptionGroup[BaseExceptionGroup[BaseExcT_2]] comes from the nested case of self: RaisesGroup[..., BaseExceptionGroup[BaseExcT_2]], exception: ... | RaisesGroup[BaseExcT_2]
i.e.

RaisesGroup(
    RaisesGroup(ValueError),
    check=<function that should take BaseExceptionGroup[BaseExceptionGroup[ValueError]]>)

and staring at that I found an issue in the current typing that might be related:

    def handle_value_or_kbi(e: BaseExceptionGroup[ValueError | KeyboardInterrupt]) -> bool:
        return True
    RaisesGroup(ValueError, KeyboardInterrupt, check=handle_value_or_kbi)
error: Value of type variable "ExcT_1" of "RaisesGroup" cannot be "BaseException"
error: Argument "check" to "RaisesGroup" has incompatible type "Callable[[BaseExceptionGroup[Union[ValueError, KeyboardInterrupt]]], bool]"; expected "Optional[Callable[[ExceptionGroup[BaseException]], bool]]"

expecting ExceptionGroup[BaseException] is obviously bonkers, but I think that's downstream of the first error.

Copy link
Member Author
@jakkdl jakkdl Jan 20, 2025

Choose a reason for hiding this comment

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

...except that's only in mypy, not pyright, so might just be an unrelated mypy bug 🙃

Copy link
Member Author

Choose a reason for hiding this comment

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

The error itself is fairly straightforward: (the non-overloaded) RaisesGroup.__init__ defines self as RaisesGroup[ExcT_1 | BaseExcT_1 | BaseExceptionGroup[BaseExcT_2]], and inherits from AbstractMatcher[BaseExceptionGroup[BaseExcT_co]], so AbstractMatcher.__init__(..., check: Callable[[BaseExcT_co], bool] means it expects check to be

Callable[[BaseExceptionGroup[ExcT_1 | BaseExcT_1 | BaseExceptionGroup[BaseExcT_2]], bool]

but then that doesn't match up with the typing of check in RaisesGroup.__init__

        check: (
            Callable[[BaseExceptionGroup[BaseExcT_1]], bool]
            | Callable[[ExceptionGroup[ExcT_1]], bool]
            | None
        ) = None,

(tbh I'm kinda surprised that the check type hint works, given that there's no case for BaseExceptionGroup[BaseExceptionGroup[BaseExcT_2]] - which is why I found the mypy error)

but I suspect the problem ultimately stems from me setting the inheritance as class RaisesGroup[BaseExceptionGroup[BaseExcT_co]] which locks in the BaseExceptionGroup.

Feel free to message me on Gitter if you wanna delve into this further

Copy link
Member Author

Choose a reason for hiding this comment

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

  1. The mypy error appears to stem from union-vs-join, so not something we can fix. (The end user can work around it by e.g. changing the type hint of handle_value_or_kbi to be BaseExceptionGroup[BaseException]
  2. getting rid of the type: ignore is likely not worth the effort, if it's even theoretically possible.

Copy link
Contributor

Choose a reason for hiding this comment

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

OK I tried several different things but I don't know how to best solve this. I totally agree that it's ultimately an issue with this specific type signature -- I suspect any specific overload signature would be fine w/r/t check's type, but unfortunately we need to satisfy all of them? I'm not totally sure though... (I remember making this __init__ signature through trial and error so it's not like it makes sense to begin with)

* Indent flatten_subgroups and re.escape suggestions.
* Don't call expected `Exceptiongroup` "unexpected nested".
* Use repr_callable so tests dont fail if hypothesis imported.
* small polish to typing tests
@jakkdl jakkdl requested a review from A5rocks January 22, 2025 16:39
Copy link
Contributor
@A5rocks A5rocks left a comment

Choose a reason for hiding this comment

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

The typing issues are a bit too much for me! To follow up on my last comment, I did actually end up thinking more about this and I think there's an algorithm we could follow that could have better properties (detailed in a drop down because a) I'm not sure it actually works, b) it's mostly just a "hey maybe consider this as a new PR"):

extra rambling that might not even work

I'll focus purely on mechanics cause like, I don't even know if it works let alone has better properties.

Firstly, sort the actual exceptions based on subtyping relationship w/ a topological sort: for instance if BaseException, Exception, and ValueError get raised, then [BaseException, Exception, ValueError].

Next, apply matchers in left to right order, on the actual exceptions.

Topological sort the expected types too. Then just do what we're already doing with using the first match.

Then you should have no actual and no expected.

Cases this fails on:

  • matchers that are overly broad
  • exceptions/types that are circular in terms of issubclass/isinstance.

On the other hand I think it should always handle a case without any matchers and ordering does not matter -- matcher quality is all people need to care about. Error notes just becomes "here's what the matchers matched".

# "Optional[Callable[[BaseExceptionGroup[Union[ExcT_1, BaseExcT_1, BaseExceptionGroup[BaseExcT_2]]]], bool]]"
# which is kinda obviously off, but idk how to resolve that while also not
# getting any errors about overloads
super().__init__(match, check) # type: ignore[arg-type]
Copy link
Contributor

Choose a reason for hiding this comment

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

OK I tried several different things but I don't know how to best solve this. I totally agree that it's ultimately an issue with this specific type signature -- I suspect any specific overload signature would be fine w/r/t check's type, but unfortunately we need to satisfy all of them? I'm not totally sure though... (I remember making this __init__ signature through trial and error so it's not like it makes sense to begin with)

@jakkdl
Copy link
Member Author
jakkdl commented Jan 28, 2025

To follow up on my last comment, I did actually end up thinking more about this and I think there's an algorithm we could follow that could have better properties (detailed in a drop down because a) I'm not sure it actually works, b) it's mostly just a "hey maybe consider this as a new PR"):
extra rambling that might not even work

I don't think improving the "see-if-there's-a-full-match" algorithm is worth the effort/complexity. It currently only triggers on failing test cases, and unless people start expecting groups with like ... hundreds? thousands? of exceptions, I'm pretty sure the runtime cost of executing all the other logic will dwarf the O(n²) complexity of the "see-if-there's-a-full-match" check.

It might be worth it if we switched from greedy matching to best-effort in the standard case, but:

  1. If a user has a RaisesGroup that always requires best-effort they're paying a runtime cost each time it's checked, primarily from it needing to do more expected-raised checks.
  2. It should be fairly rare for it to really be needed. To require best-effort you need a scenario where the order of exception raises differ, and the exceptions are overlapping/hard-to-distinguish/idek. The former is fairly common, but the latter is kinda weird to me; and I think doing the equivalent of RaisesGroup(Exception, ValueError) is often going to be bad practice. And even in the latter case I'd be shocked if it can't be resolved with proper Matcher usage.
    1. and even if you can't solve it with Matcher because you're doing super complicated shit.. well then the user should probably do with RaisesGroup(Exception, Exception) as exc: ... and do their own logic later.
  3. There's some precedent in raises-too-broad of encouraging users to be very explicit about the exception they expect.

@jakkdl jakkdl added this pull request to the merge queue Jan 29, 2025
Merged via the queue into python-trio:main with commit ab6c9bd Jan 29, 2025
39 checks passed
@jakkdl jakkdl deleted the raisesgroup_fail_reason branch January 29, 2025 11:10
@jakkdl
Copy link
Member Author
jakkdl commented Jan 29, 2025

Working on getting this into pytest, and they run with warn_unreachable which we don't (don't think there's any reason we don't, it's just nobody's ever suggested it), and it turns out mypy is not picking up on self._fail_reason getting mutated in AbstractMatcher._check_match and RaisesGroup._check_exceptions; so we're back to needing some cast/type: ignore 🙃

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants
0