-
-
Notifications
You must be signed in to change notification settings - Fork 348
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
Add reason for failed match to RaisesGroup #3145
base: main
Are you sure you want to change the base?
Conversation
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## main #3145 +/- ##
================================================
Coverage 100.00000% 100.00000%
================================================
Files 124 124
Lines 18460 18723 +263
Branches 1216 1265 +49
================================================
+ Hits 18460 18723 +263
|
….. though somewhat skeptical of their complexity cost vs benefit
There was a problem hiding this 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 😅
… on check fail. Fix output issues when multiple copies of the same object were expected/raised
…. Also fix succesful->successful typo.
…raised-exception by adding another example
…and suggest using Matcher if match would match against a lone expected & raised exception
The only remaining TODO now is trio/src/trio/_tests/test_testing_raisesgroup.py Lines 103 to 110 in 9612d1e
which I'm procrastinating on because |
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.
Code is perhaps a bit messy, and the tests definitely are (I think I'll restructure them when moving to pytest), but otherwise I'm happy with current functionality. So please review away! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest we check the two tweaks to messages below, and then come back for the hypothesis-plugin-monkeypatch in a followup PR. Otherwise, looks great and I can't wait to use it!
# Ideally the "did you mean to re.escape" should be indented twice | ||
"Matcher(match='h(ell)o'): Regex pattern 'h(ell)o' did not match 'h(ell)o'\n" | ||
"Did you mean to `re.escape()` the regex?", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we just put " "
at the start of the message to get this effect?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
..yes! And can actually resolve the flatten_subgroups
problem as well by only doing so when the old fail_reason
isn't multi-line.
match=( | ||
r"^Raised exception group did not match: \n" | ||
r"The following expected exceptions did not find a match:\n" | ||
r" Matcher\(check=<function test_assert_message_nested.<locals>.<lambda> at .*>\)\n" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah... if we do the monkeypatch from Trio's hypothesis plugin as I suggest above, we'll then also want to use the maybe-monkeypatched _check_repr
function to construct the expected string; and maybe also assert that that string is one of two known possibilities. Which is kinda ugly, but imo showing the lambda source here is nice enough to be worth it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh.. I just realized if hypothesis
start always monkeypatching it as soon as it's installed then the test suite would fail on install as well. Or possible even in some scenarios of hypothesis-pytest-plugin thingie thingies.
I'll split out a test case showing how ugly the lambda repr is and make it skip if hypothesis is in sys.modules
, and convert the rest to use _check_repr
, to avoid future headaches.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't looked that hard at the logic but this looks good! (note to self: restart review at _check_exceptions
)
This is mostly wording/nitpicks/minor changes. A bunch of them, but it's a few concerns duplicated:
- I don't like
_depth
and proposed some changes to get rid of it __str__
defaults to__repr__
- honestly I can't remember, probably just suggestions about errors/docs
src/trio/_tests/test_exports.py
Outdated
# Ignore classes that don't use attrs, they only define their members once | ||
# __init__ is called (and reason they don't use attrs is because they're going | ||
# to be reimplemented in pytest). | ||
# I think that's why these are failing at least? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could try tossing attrs on them and seeing! But I think so too.
Maybe even just try putting __slots__
on them and using that to double check, then removing this (checked) comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ugh, they're passing locally (but a bunch of other classes aren't 🙃)
and this is running through a minimal tox with -r test-requirements.txt
as the only dep so I have no clue what's going on. That's for another day
Co-authored-by: A5rocks <[email protected]>
…n check suggestion
Okay I like the new structure with an ABC, just a couple last things. @A5rocks I still can't get rid of the |
I ... moved the |
There was a problem hiding this 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.
src/trio/testing/_raises_group.py
Outdated
if isinstance(exception, BaseExceptionGroup): | ||
return f"Unexpected nested {actual_type_str}, expected bare {expected_type_str}" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah it's potentially error worthy given this is a test construct. But even if it is, this error message won't make sense.
src/trio/testing/_raises_group.py
Outdated
# "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] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 🙃
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- 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 beBaseExceptionGroup[BaseException]
- getting rid of the
type: ignore
is likely not worth the effort, if it's even theoretically possible.
if self.check(exception): | ||
return True | ||
|
||
check_repr = "" if self._nested else " " + repr_callable(self.check) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This self._nested
is nicer than threading a parameter before, but I think a slight change to the output format would simplify this. From the tests above, I assume this is the effect when nested:
r" Matcher\(check=<function test_assert_message_nested.<locals>.<lambda> at .*>\): check did not return True\n"
I assume it would be simpler to support this:
r" Matcher\(check=...\): check <function test_assert_message_nested.<locals>.<lambda> at .*> did not return True\n"
In my mind at least, Matcher.__repr__
calls Matcher._repr(show_check=True)
and then when printing this nicer reason, we call Matcher._repr(show_check=False)
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One problem with that one is you would need logic to suppress the printing of check
in Matcher.__repr__
(and RaisesGroup.__repr__
) only when failing due to the check failing (otherwise you might get issues telling Matcher
/RaisesGroup
s apart if other parameters are the same); and then you'd end up with inconsistent __repr__
outputs depending on the fail reason.
Not saying I love the current logic though. Another option would be to never print the check_repr
in _check_check
and always add repr(self)
to the fail_reason
a la
trio/src/trio/_tests/test_testing_raisesgroup.py
Lines 632 to 634 in 561660b
# TODO: this line is not great, should maybe follow the same format as the other and say | |
# 'ValueError': Unexpected nested 'ExceptionGroup' (?) | |
" Unexpected nested 'ExceptionGroup', expected bare 'ValueError'\n" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking that instead of doing {whatever!r}
you could do {whatever._repr(show_check=False)}
* 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
repr_callable in __repr__s.
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 usingRaisesGroup
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
, likepytest.raises
, would silently pass through the exception if it didn't match - but now it will instead fail with anAssertionError
. This also has precedent upstream though,pytest.raises
will fail with anAssertionError
iff you've specifiedmatch
.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:
Another improvement would be making.matches()
directly raise anAssertionError
, instead of quietly setting.fail_reason
. This would break any test currently doingif not RaisesGroup(...).matches(...):
, or even more plausiblyassert not RaisesGroup(...).matches(...)
, soI think I should add a new method
.assert_matches()
to bothRaisesGroup
andMatcher
, which either calls.matches()
and asserts the return value withfail_reason
as the message - or do it the other way around and have.matches()
call.assert_matches()
in atry:
.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.