8000 Add RaisesGroup, a helper for catching ExceptionGroups in tests by jakkdl · Pull Request #2898 · python-trio/trio · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Add RaisesGroup, a helper for catching ExceptionGroups in tests #2898

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 32 commits into from
Jan 7, 2024

Conversation

jakkdl
Copy link
Member
@jakkdl jakkdl commented Dec 6, 2023

pytest does not seem overly excited to add my proposed extensions to pytest.raises in a timely manner, although I did get good feedback on the design - see pytest-dev/pytest#11538

So I think we might want to settle with having it in trio (or possible in a separate sub-project? catching exceptiongroups is not exclusive to trio users) for some time, so I thought I'd make a proper implementation+tests and rewrite any current tests that can make use of this. This PR is largely to prep for #2886, together with #2891, and splits out some code from it (except this uses my RaisesGroup implementation from pytest-dev/pytest#11671 instead of ExpectedExceptionGroup from pytest-dev/pytest#11656).

TODO:

  • Documentation
  • Changelog
  • explicit tests for _ExceptionInfo
  • add docstrings (ETA 2023-12-30)
    • also why doesn't pyright complain about them missing

Copy link
codecov bot commented Dec 6, 2023

Codecov Report

All modified and coverable lines are covered by tests ✅

Comparison is base (e317d9c) 99.67% compared to head (80b7031) 99.68%.

Additional details and impacted files
@@           Coverage Diff            @@
##           master    #2898    +/-   ##
========================================
  Coverage   99.67%   99.68%            
========================================
  Files         115      117     +2     
  Lines       17308    17546   +238     
  Branches     3109     3152    +43     
========================================
+ Hits        17252    17490   +238     
  Misses         38       38            
  Partials       18       18            
Files Coverage Δ
src/trio/_core/_tests/test_run.py 100.00% <100.00%> (ø)
src/trio/_tests/test_exports.py 99.60% <100.00%> (+<0.01%) ⬆️
src/trio/_tests/test_highlevel_open_tcp_stream.py 100.00% <100.00%> (ø)
src/trio/_tests/test_testing_raisesgroup.py 100.00% <100.00%> (ø)
src/trio/testing/__init__.py 100.00% <100.00%> (ø)
src/trio/testing/_raises_group.py 100.00% <100.00%> (ø)

@jakkdl
Copy link
Member Author
jakkdl commented Dec 6, 2023

Failing tests are due to RaisesGroup inheriting from BaseExceptionGroup when type-checking, but not on runtime, which confuses the export tests. Might move away from that messiness and add a couple properties to RaisesGroup so it can be treated as an ExceptionGroup by end users using exc_info, or add exceptions to the export tests.

@jakkdl
Copy link
Member Author
jakkdl commented Dec 9, 2023
   FAILED ../../../../hostedtoolcache/Python/3.8.18/x64/lib/python3.8/site-packages/trio/_tests/test_exports.py::test_static_tool_sees_class_members[mypy-trio.testing] - AssertionError: assert not {'trio.testing.Matcher': {'extra': {'check', 'exception_type', 'match'}, 'missing': set()}}

wait, I think this is the same thing as a lot of the other weird exceptions in the test - inspect.getmembers and dir() only gives us static class members - not runtime properties/members/attributes/etc, but the way we look it up with mypy gives us the runtime members. I feel a bit silly for not realizing this earlier.

Copy link
Contributor
@TeamSpen210 TeamSpen210 left a comment

Choose a reason for hiding this comment

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

Looks good and useful. In regard to the typing, it does seem better to avoid inheriting from BaseExceptionGroup, if it doesn't actually use that behaviour.

@jakkdl
Copy link
Member Author
jakkdl commented Dec 10, 2023

@TeamSpen210
the reason for the inheritance from BaseExceptionGroup and lies in general is that excinfo.value is an ExceptionGroup, not a RaisesGroup.
We're essentially saying we expect a structure with RaisesGroups, but in fact we're not - we are expecting a structure of ExceptionGroups, and excinfo.value will be a structure of ExceptionGroups.

with RaisesGroup(RaisesGroup(ValueError)) as excinfo:
   ...
print(type(excinfo.value))  # prints ExceptionGroup

so what I kind of want is to be able to search&replace instances of RaisesGroup with ExceptionGroup in the TypeVar that RaisesGroup accepts - in order to set the type on the ExceptionInfo correctly, but I have no clue how to do that.

@TeamSpen210
Copy link
Contributor

Ahh, that is tricky, and probably not possible without a plugin or something.

* Add longer explanation of what the problem with the types are, and
  expand type tests
* Add __new__ to satisfy pyright
* replace RaisesGroup.__repr__ with RaisesGroup.expected_type to not lie
to repr(), and add support for printing *Base*ExceptionGroup when
correct.
* add tests for the above
@jakkdl jakkdl requested a review from TeamSpen210 December 10, 2023 14:24
@jakkdl
Copy link
Member Author
jakkdl commented Dec 10, 2023

huh, why is pyright --verifytypes not complaining about missing docstrings. Neither Matcher nor RaisesGroup has ~any

@jakkdl
Copy link
Member Author
jakkdl commented Dec 16, 2023

huh, why is pyright --verifytypes not complaining about missing docstrings. Neither Matcher nor RaisesGroup has ~any

opened an issue in pyright after debugging this. I'm pretty sure we'll get warnings for lots of random symbols if that gets fixed 😅
microsoft/pyright#6758

@jakkdl
Copy link
Member Author
jakkdl commented Dec 19, 2023

Issue in pyright confirmed and will be fixed in the next release, so expect failing tests next time pyright is bumped @A5rocks
microsoft/pyright#6758

@jakkdl
Copy link
Member Author
jakkdl commented Dec 23, 2023

The error was in the exceptiongroup backport, not pyright. So wrote a PR to fix it: agronholm/exceptiongroup#101

That's the only remaining failure in the checks, so unless we wanna wait for the docstrings I forgot at home this is ready to merge as soon as exceptiongroup does a new release with my PR.

@jakkdl
Copy link
Member Author
jakkdl commented Dec 27, 2023

@Zac-HD would you mind reviewing this? It's the blocker for continuing work on #2886

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.

Looks great - I've left a few nitpick comments, merge when you feel they've been addressed.

(I'm about to leave on a ten-day camping trip, and will probably be offline even if I have internet access, so don't wait for me!)

Comment on lines +227 to +229
# TODO: print/raise why a match fails, in a way that works properly in nested cases
# maybe have a list of strings logging failed matches, that __exit__ can
# recursively step through and print on a failing match.
Copy link
Member

Choose a reason for hiding this comment

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

Do we need to do this before merging?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'd say it depends on expected usage - if it's a helper that lots of people start using, and/or if it takes a long time until it's supplied by pytest, then it's probably worth chugging through now.

But I don't know how much of it would be applicable to any eventual solution in pytest, or if anybody cares to use this, so I'm hesitant to go all-in on polishing this.

Comment on lines +224 to +234
ExceptionGroup helpers
----------------------

.. autoclass:: RaisesGroup
:members:

.. autoclass:: Matcher
:members:

.. autoclass:: trio.testing._raises_group._ExceptionInfo
:members:
Copy link
Member

Choose a reason for hiding this comment

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

These classes should have docstrings aimed at explaining their use to end-users, if we're exposing them publicly.

I'd also prefer to mark this as a provisional interface, and note that we hope for it to be obsoleted by upstream work in pytest itself someday soon. (note: carefully noncommital about what happens or when)

Copy link
Member Author

Choose a reason for hiding this comment

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

I wrote extensive docstrings... on a computer I'm currently 500km away from, and forgot to push. So I'll add them once back the 30th of december.
But good call to explicitly mark them as provisional

Copy link
Contributor

Choose a reason for hiding this comment

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

Just a bump about this!

Copy link
Member Author

Choose a reason for hiding this comment

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

Added them, and a note about them being provisional in the newsfragment.

Copy link
Contributor
@TeamSpen210 TeamSpen210 left a comment

Choose a reason for hiding this comment

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

Looks all good, though noticed a few things in Matcher.

# this may bite users with type checkers not using pytest, but that should F438
# be rare and give quite obvious errors in tests trying to do so.
# Ignoring "incorrect import of pytest", it makes sense in this situation.
if TYPE_CHECKING:
Copy link
Contributor

Choose a reason for hiding this comment

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

If the class above is sufficient, why use the pytest version at all?

Copy link
Member Author

Choose a reason for hiding this comment

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

pytest.ExceptionInfo has a couple helper methods I didn't bother reproducing - match, getrepr, exconly. And somebody writing tests might very well want to make use of those after catching an ExceptionGroup

Copy link
Member Author
@jakkdl jakkdl Jan 5, 2024

Choose a reason for hiding this comment

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

realized that anybody using this without pytest would have zero types on exceptioninfo, so reversed it to always pretend we return _ExceptionInfo and added stubs for the helper methods. But maybe better off to simply always use _ExceptionInfo and disregard the intersection of people that wants to use RaisesGroup + the weird helper methods until it's merged upstream.

Comment on lines +224 to +234
ExceptionGroup helpers
----------------------

.. autoclass:: RaisesGroup
:members:

.. autoclass:: Matcher
:members:

.. autoclass:: trio.testing._raises_group._ExceptionInfo
:members:
Copy link
Contributor

Choose a reason for hiding this comment

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

Just a bump about this!

if TYPE_CHECKING:
SuperClass = BaseExceptionGroup
else:
SuperClass = Generic
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not just always inherit from BaseExceptionGroup?

And I feel like inheriting from that's super fishy given we don't actually have the information BaseExceptionGroup would have. But TBH I don't really see another solution. The typing on this is really icky.

Copy link
Contributor

Choose a reason for hiding this comment

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

I gave typing a go and ran across:

import contextlib
from typing import ContextManager, Generic, TypeVar, overload

E = TypeVar("E", bound=BaseException)

class FakeExcInfo(Generic[E]):
    value: E

class RaisesGroup(ContextManager[FakeExcInfo[BaseExceptionGroup[E]]], Generic[E]):
    @overload
    def __init__(self, a: type[E], *b: type[E]):
        ...
    
    @overload
    def __init__(self: RaisesGroup[BaseExceptionGroup[E]], a: RaisesGroup[E],
CEB7
 *b: RaisesGroup[E]):
        ...
    
    def __init__(self: object, *args: object):
        ...
    
    def __exit__(self, *args: object) -> bool:
        ...


with RaisesGroup(RaisesGroup(ValueError)) as excinfo:
    pass

reveal_type(excinfo.value)

it has a couple issues (not accepting both RaisesGroup and normal errors, plus whatever else kind of exceptions we allow) and also mypy doesn't like the overloads idk why but like. maybe that's cleaner?

Copy link
Contributor

Choose a reason for hiding this comment

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

For what it's worth, pyright seems to dislike that (probably a bug) but there's an easy workaround for it:

from typing import ContextManager, Generic, TypeVar, overload

E = TypeVar("E", bound=BaseException)
E2 = TypeVar("E2", bound=BaseException)

class FakeExcInfo(Generic[E]):
    value: E

class RaisesGroup(ContextManager[FakeExcInfo[BaseExceptionGroup[E]]], Generic[E]):
    @overload
    def __init__(self, a: type[E], *b: type[E]):
        ...
    
    @overload
    def __init__(self: "RaisesGroup[BaseExceptionGroup[E2]]", a: "RaisesGroup[E2]", *b: "RaisesGroup[E2]"):
        ...
    
    def __init__(self: object, *args: object):
        ...
    
    def __exit__(self, *args: object) -> bool:
        ...


with RaisesGroup(RaisesGroup(ValueError)) as excinfo:
    pass

reveal_type(excinfo.value)

Copy link
Member Author

Choose a reason for hiding this comment

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

Why not just always inherit from BaseExceptionGroup?

This gives errors due to the funky __new__ stuff that BaseExceptionGroup does.

>       with RaisesGroup(ValueError, KeyError):
E       TypeError: BaseExceptionGroup.__new__() argument 1 must be str, not type

implementing a __new__ that works like object.__new__:

>       obj = object.__new__(cls)
E       TypeError: object.__new__(RaisesGroup) is not safe, use BaseExceptionGroup.__new__()

and since we don't actually care for any of the functionality in BaseExceptionGroup, not inheriting at runtime seems clearly correct.

Copy link
Member Author
@jakkdl jakkdl Jan 5, 2024

Choose a reason for hiding this comment

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

And I feel like inheriting from that's super fishy given we don't actually have the information BaseExceptionGroup would have. But TBH I don't really see another solution. The typing on this is really icky.

Yeah this is clearly doing stuff that typing wasn't meant to handle.
The underlying problem is getting this to pass typing:

with RaisesGroup(RaisesGroup(ValueError)) as excinfo:
  ...
myexc: BaseExceptionGroup = excinfo.value.exceptions[0]

and the only way I see to manage this is to lie to the type checker that it can treate RaisesGroup like a BaseExceptionGroup.

I'll update comments with reasons for some of the shady stuff. EDIT: this was mostly said in the comment above the lines you marked. Clarified it a bit but not sure I can make it much clearer.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ugh I poked a bit more and the E2 thing above that pyright accepts, mypy doesn't. I'm fine with this hacky inheritance thing I guess, hopefully inheritance doesn't fall under semver.

Copy link
Member

Choose a reason for hiding this comment

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

I wouldn't think so for this particular case, because there would be no need to preform isinstance checks because you would not expect RaisesGroup to be in a list mixed with other objects.

Copy link
Member Author

Choose a reason for hiding this comment

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

What did you think of my proposed workaround using overloads btw?

Rest makes sense though.

Oh I didn't trying to run it and failed to grok at a glance how it resolved the issue, sorry! I'll try and understand it and see if I can get it to work, otherwise I'll stick with the ugly inheritance thing.

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 E2 version + pyright doesn't work for me with a third level of nesting

with RaisesGroup(RaisesGroup(RaisesGroup(ValueError))) as excinfo3:
    pass

reveal_type(excinfo3.value)

gives

information: Type of "excinfo3.value" is "BaseExceptionGroup[BaseExceptionGroup[Unknown]]"

Copy link
Member Author

Choose a reason for hiding this comment

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

but yeah let's stick with the hacky inheritance for now, and we can revisit this if a version of this is accepted for upstream pyright.

@jakkdl jakkdl requested review from CoolCat467 and A5rocks January 5, 2024 13:04
Copy link
Member
@CoolCat467 CoolCat467 left a comment

Choose a reason for hiding this comment

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

As far as I can tell reading through this, everything makes sense.

@jakkdl
Copy link
Member Author
jakkdl commented Jan 7, 2024

Thanks everybody for the help, reviews, and feedback! 🚀 🚀 🚀

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.

5 participants
0