Port fixes for unchaining exceptions in contextmanager from cpython#228
Conversation
njsmith
left a comment
There was a problem hiding this comment.
Hey, thanks for putting this together! Two small comments:
trio/tests/test_util.py
Outdated
| except Exception as ex: | ||
| assert type(ex) is RuntimeError | ||
| assert ex.args[0] == 'issue29692:Chained' | ||
| assert isinstance(ex.__cause__, ZeroDivisionError) |
There was a problem hiding this comment.
The problem with writing tests this way is that if no exception is raised, then everything looks good... so it's generally better practice to do:
with pytest.raises(RuntimeError) as excinfo:
...
assert excinfo.value.args[0] == "..."
...which will actually fail if it doesn't see a RuntimeError. OTOH if this is copied directly from the upstream fix and you'd rather keep it close to upstream there's a reasonable argument for that.
There was a problem hiding this comment.
Went ahead and fixed it up as you suggested. I was just copy-pasting from upstream, but I think it's nicer this way.
trio/tests/test_util.py
Outdated
| @acontextmanager | ||
| async def test_issue29692(): | ||
| try: | ||
| yield |
There was a problem hiding this comment.
Unfortunately we still support py35, so we can't use native generators like this in the test suite (this is why appveyor and travis are failing) – it needs to be like
@async_generator
async def test_issue29692():
try:
await yield_()
except ...There was a problem hiding this comment.
I was just digging into this. Unfortunately, it seems that when I try it this way (actually using both async_generator and acontextmanager decorators), the actual fix doesn't work anymore.
There was a problem hiding this comment.
Uh oh. Should I look more closely? async_generator is a pretty solid emulation of the built-in async generators, but it's had subtle bugs before....
One thing that just popped out at me looking again though: we surely want to replace all the StopIterations in this patch with StopAsyncIterations
There was a problem hiding this comment.
That was it, thanks! Weird that it worked with the wrong exception using native generators.
There was a problem hiding this comment.
.....okay, I just re-read PEP 479 and this is more complicated than I realized. I'm actually not sure that what I said above about replacing StopIteration with StopAsyncIteration is correct, and actually it's quite likely that async_generator doesn't handle this correctly. Need to do some experiments to see exactly what the python 3.6 native async generator behavior actually is...
There was a problem hiding this comment.
OK, current version has passing tests for what it's worth, but I'll sign off for now
4c2d587 to
94136de
Compare
Codecov Report
@@ Coverage Diff @@
## master #228 +/- ##
==========================================
+ Coverage 99.06% 99.09% +0.02%
==========================================
Files 63 63
Lines 8420 8466 +46
Branches 609 610 +1
==========================================
+ Hits 8341 8389 +48
+ Misses 62 61 -1
+ Partials 17 16 -1
Continue to review full report at Codecov.
|
94136de to
e1f9a17
Compare
|
Here's my current understanding of this. The problem being fixed is: suppose we have some code like: try:
with my_context_manager():
raise StopIteration
except StopIteration:
print("caught it!")this is totally fine and normal, and should print So the solution is that the Phew, ok, now we understand what's going on upstream with regular synchronous I don't think this is documented anywhere, but based on some experimentation, it looks like the way PEP 479 was applied to the native async generators in 3.6 is: if they raise either So this suggests that Then there's an additional wrinkle: it looks like currently, So: We should make the unwrapping logic check for both We should probably test throwing both From comments above, it sounds like making that last test use And currently that last test isn't actually testing the unwrapping logic, because it's using Arguably this is not a big deal though because |
|
Also, sorry about this -- I really underestimated how complicated this would be when I put the good-for-new-contributors tag on #203! |
Discovered as part of: python-trio/trio#228
I just sorted this out and released async_generator 1.8, which now properly implements PEP 479. Going to try hitting the rebuild buttons for this PR now and see if it changes anything... |
njsmith
left a comment
There was a problem hiding this comment.
Well, Travis is doing something weird, hopefully it will wake up and run the tests eventually, but in the mean time this worked on appveyor, so it's probably OK :-). Hopefully that means that we just need a few more small changes:
njsmith
left a comment
There was a problem hiding this comment.
Well, Travis is doing something weird, hopefully it will wake up and run the tests eventually, but in the mean time this worked on appveyor, so it's probably OK :-). Hopefully that means that we just need a few more small changes:
trio/_util.py
Outdated
| # was passed to throw() and later wrapped into a RuntimeError | ||
| # (see PEP 479). | ||
| if exc.__cause__ is value: | ||
| if type is StopAsyncIteration and exc.__cause__ is value: |
There was a problem hiding this comment.
Should check for StopIteration as well. I guess if issubclass(type, (StopIteration, StopAsyncIteration)) and exc.__cause__ is value: might be the most idiomatic way to write it
There was a problem hiding this comment.
I copied the code from the 3.7 version you linked and used "isinstance(value, (StopIteration, StopAsyncIteration)" which appeared to be equivalent to your suggestion.
trio/_util.py
Outdated
| if sys.exc_info()[1] is value: | ||
| return False | ||
| raise | ||
| raise RuntimeError("generator didn't stop after throw()") |
There was a problem hiding this comment.
This last line (raise RuntimeError(...)) is redundant -- there's already another check for the same thing up above. Should be deleted.
| async with test_issue29692(): | ||
| raise StopAsyncIteration('issue29692:Unchained') | ||
| assert excinfo.value.args[0] == 'issue29692:Unchained' | ||
| assert excinfo.value.__cause__ is None |
There was a problem hiding this comment.
...And we need to a copy of this test, except with raise StopIteration(...) replacing raise StopAsyncIteration
There was a problem hiding this comment.
Thanks for the intense commentary, very interesting. I think I follow. ;)
I've updated everything with your comments and grabbed the newest async_generator, and most things look better, but still running into one issue.
I added some tests gated on version that use native async generators and they all pass, but using async_generator passes for StopAsyncIteration and fails for StopIteration.
I've dug in a bit, but hope you see something obvious that I'm missing.
Pushed my updates for now.
There was a problem hiding this comment.
Thanks for the intense commentary, very interesting. I think I follow. ;)
Feel free to ask for clarifications if I get too telegraphic! Often when I write a big chunk of text like that I'm partly writing it as a way to think things through for myself, so I sometimes do a bad job of making it interpretable to other people – but part of the goal is to explain to others and to leave a record for when we come back to an issue later. And both of these work much better if someone pokes me when I'm being unclear :-)
e1f9a17 to
42a1462
Compare
trio/tests/test_util.py
Outdated
| try: | ||
| yield | ||
| except Exception as exc: | ||
| raise RuntimeError('issue29692:Chained') from exc |
There was a problem hiding this comment.
Unfortunately 3.5 blows up if you even have syntax like this in your file, it doesn't matter whether you try executing it or not. So the only way I know to write a test like this is to use exec:
code = textwrap.dedent("""
@acontextmanager
async def test_issue29692_2():
...
""")
ns = {"acontextmanager": acontextmanager}
exec(code, ns)
test_issue29692_2 = ns["test_issue29692_2"](and then you still need a version check to make sure you only do this on 3.6+, or alternatively try it always and then catch the SyntaxError if it doesn't work)
It might make sense to consolidate with the previous test... like make a version dependent list of test context managers, and then do a for loop over it? Or not, sometimes it's simpler just to have a bit of copy-paste in test code :-)
There was a problem hiding this comment.
Yeah, was just discovering that myself. I hadn't actually installed Python3.5 until about 15 minutes ago and didn't realize what I was doing didn't work until Travis let me know.
There was a problem hiding this comment.
I hadn't actually installed Python3.5 until about 15 minutes ago
That's very sensible of you really... I'm surprised at how quickly the py35 support is becoming an annoying limitation :-(. I'm really reluctant to drop pypy support though, and they don't even have an ETA for starting to do 3.6 yet :-(.
There was a problem hiding this comment.
Got the test stuff fixed up on my computer for both versions of python.
If I'm understanding you correctly, we're ok with some differences in async_generator for StopIteration, so I just fleshed that out enough to know what the result is at the moment.
Probably not a huge deal until Python3.5 support isn't needed anymore, hopefully.
There was a problem hiding this comment.
If I'm understanding you correctly, we're ok with some differences in async_generator for StopIteration
Yep!
trio/tests/test_util.py
Outdated
|
|
||
| # Native async generators are only available from Python 3.6 and onwards | ||
| nativeasyncgenerators = sys.version_info >= (3,6) | ||
| pytestmark = pytest.mark.skipif(not nativeasyncgenerators, reason="Python with native async generators") |
There was a problem hiding this comment.
I think this marks the whole file as skipped on 3.5? That doesn't seem like what we want. IIRC the syntax is
# skip a single test
@pytest.mark.skipif(check, reason="whatever")
def test_something():
...
# skip multiple tests with the same reason
need_native_async_generators = pytest.mark.skipif(...)
@need_native_async_generators
def test_something():
...
@need_native_async_generators
def test_something_else():
...| try: | ||
| await yield_() | ||
| except Exception as exc: | ||
| raise RuntimeError('issue29692:Chained') from exc |
There was a problem hiding this comment.
OKAY, I figured out the problem with StopIteration and async_generator!
Here it is: when a StopIteration gets thrown into an async_generator, it actually ends up inside the magic await yield_() function. But Python doesn't know that it's supposed to be a magic function that acts like yield; it just thinks its some random async function. And then it tries to raise StopIteration, and Python's like uh, no, haven't you heard of PEP 479? Async functions can't raise StopIteration. So it immediately wraps it into a RuntimeError. Which ... is a little weird and annoying, but there's nothing we can do about it, and it doesn't stop us from unwrapped it later. Except... the way this particular test is written, where it wraps everything in a RuntimeError, means that we end up wrapping our StopIteration in a RuntimeError twice. We get RuntimeError → RuntimeError → StopIteration.
So there is something of a bug here, but it's intrinsic to how async_generator works, and we can't fix that part.
But! We can still test the main thing we care about, which is that a StopIteration is able to pass through a context manager like
@acontextmanager
@async_generator
async def simpler_context_manager():
await yield_()And this should work, though slightly differently than if it were a native generator. With a native generator, the yield would raise StopIteration, which would then get wrapped in a RuntimeError when it tried to leave the function. With async_generator, the await yield_() directly wraps the StopIteration into a RuntimeError, and then that propagates through the function and out. But either way we can catch it and unwrap it in @acontextmanager.
There was a problem hiding this comment.
See #229 for an issue to record the weird edge case that this creates in trio proper...
Cherry-pick 647438fa5a7b9a5fb3fd1af57541b52646c7a013, bpo-29692: contextlib.contextmanager may incorrectly unchain RuntimeError
42a1462 to
a002811
Compare
|
Canceled and restarted the job since Travis was having some maintenance hopefully it gets scheduled now? |
|
Seems they have some problems with non container builds actually: https://www.traviscistatus.com Backlogs keep building without being processed. Build slaves fell over maybe. |
|
Thanks! It looks like I missed doing this on your last PR – our policy is that anyone who submits at least 1 merged PR automatically gets an invitation to join the github organization if they want it. So, check your inbox! No pressure – it's totally up to you whether you want to accept, and if you do then you can participate as much or as little as you like. Basically this is our way of saying hey, welcome, we'd love it if you want to stick around :-). |
Two small followups to gh-228
Cherry-pick 647438fa5a7b9a5fb3fd1af57541b52646c7a013,
bpo-29692: contextlib.contextmanager may incorrectly unchain RuntimeError
Fixes #203