-
Notifications
You must be signed in to change notification settings - Fork 132
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
Switched to a trampoline-style test runner in the pytest plugin #497
Conversation
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.
Sweet! I have some questions and minor comments. It looks like there's pretty good prior test coverage for different sorts of fixtures / cancellation / etc.
This change also makes task groups created in fixtures available in tests, right?
Are there any expected negative side-effects to running everything in a single task?
@@ -1674,6 +1676,8 @@ def _create_task_info(task: asyncio.Task) -> TaskInfo: | |||
|
|||
|
|||
class TestRunner(abc.TestRunner): | |||
_send_stream: MemoryObjectSendStream[tuple[Awaitable[Any], asyncio.Future[Any]]] |
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.
Any particular reason this is defined at the class level instead of in __init__
alongside _runner_task
?
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 see you've defined it in __init__
for the trio backend.
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.
In the trio implementation, I do a null check against it so I needed to set a value. It felt natural to do that in __init__()
since I don't have any other class annotations there. Just a matter of taste I suppose.
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 have a preference for keeping instance variables in __init__
and declaring class variables at the top level, but I think whatever your preference is here is still plenty clear.
|
||
coro = func(*args, **kwargs) | ||
future: asyncio.Future[T_Retval] = self._loop.create_future() | ||
self._send_stream.send_nowait((coro, future)) |
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.
Here you are relying on the null check for self._runner_task
to ensure that self._send_stream
exists. I'm surprised mypy doesn't complain about this, but I'm guessing it's because it's not declared as an optional 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.
I don't know why mypy would complain about it. I never set _send_stream
to None
, and as long as _runner_task
is around, _send_stream
is as well.
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.
Makes sense. If you set it to None
at close (#497 (comment)) then the type will change and then mypy may have qualms.
future.set_result(retval) | ||
|
||
async def _call_in_runner_task( | ||
self, func: Callable[..., Awaitable[T_Retval]], *args: object, **kwargs: object |
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 curious about the use of object
as the annotation here instead of Any
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.
It's to ensure that we're not accidentally trying to use them for anything.
src/anyio/_backends/_asyncio.py
Outdated
self._runner_task = None | ||
|
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.
Should self._send_stream
be released here too?
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.
Hm, I suppose that would be good form, even if not strictly necessary.
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.
Agreed, seems nice for bookkeeping but I doubt it's critical for it to be garbage collected.
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've added code that cleanly closes, and then deletes, the send stream.
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.
Nice! Seems great to include the aclose
call there.
except StopAsyncIteration: | ||
self._raise_async_exceptions() |
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.
Why is this reraised now? It wasn't previously and is not in the trio implementation.
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.
Trio does not have callbacks like asyncio does. Here we raise any errors from those callbacks that occurred during the run. I'm unsure if we should do this in the setup phase too.
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.
Now that I look at the previous code, we did that in the setup phase but not teardown. I think it might make sense to do it in both.
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 I see it's raising exceptions that occurred in teardown callbacks not reraising the captured iteration error. Might be nice to include a brief comment noting that.
Raising exceptions during setup and teardown makes sense right? Is there a reason you'd ever want them to pass silently? I wish it was easier to see the expected behavior in these tests https://github.com/agronholm/anyio/blob/trampoline-test/tests/test_pytest_plugin.py#L155-L159
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 think a clarification is necessary. What's being raised here are errors caught by the asyncio exception handler, for callbacks scheduled via loop.call_soon()
, loop.call_later()
and loop.call_at()
, not pytest's setup/teardown callbacks.
And as for reraising these in setup/teardown, I forgot that I'm already raising them in both the setup/teardown phases, as we should be doing.
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.
Great thanks for taking the time to explain! I should have just read the implementation of _raise_async_exceptions
in the first place but this was helpful.
They were available to tests before this.
The reason I marked it as backwards incompatible is because users might have depended on contextvars set in fixtures to be visible only within those fixtures, and this approach might cause fixtures and tests to interfere with each other if they assume the previous behavior. In particular, context variables set in tests will carry on to subsequent tests if the same runner is used (which it would be if the next test shares a higher scoped async fixture with the current one). |
For the record, setting a context variable and not resetting it at the end of the fixture/test is just plain irresponsible, and the regular pytest machinery doesn't protect against that either. |
Ah in @pytest.fixture
async def task_group():
async with create_task_group() as tg:
yield tg
async def test_foo(task_group):
pass
# You can also use the task group to run something but it's not needed for the teardown error Separately from this pull request, it may be worth adding test coverage for using a task group yielded in a fixture from a test — I don't see one like that right now.
This may be a cause of confusion and worth highlighting in documentation.
Agreed! |
That was already made possible in v3.6.0 by running the setup and teardown phases of each asyncgen fixture in the same task (this was mentioned in the version history). |
What's this then? https://github.com/agronholm/anyio/blob/master/tests/test_pytest_plugin.py#L242-L245 |
That test uses a stream fixture that uses the task group fixture but the test itself does not use the task group's methods. I'm guessing it covers the relevant code paths, but it's not clear to an outsider that a task group yielded in a fixture could be consumed directly in a test. I'm not actually sure why you'd want to do that though — it'd leave a task running outside the test's context which seems like pretty bad practice. I think the existing test is great :) |
What I intend to use this for is higher scoped fixtures that start some heavy duty server like a complex web app. Such fixtures tend to be costly to set up or tear down. |
So, what's left? Documenting that context variables now work the same way in async tests/fixtures as with synchronous tests/fixtures? |
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.
Documenting that context variables now work the same way in async tests/fixtures as with synchronous tests/fixtures?
Yeah I'd point out the potential pitfall there otherwise this looks great to me :)
I did some testing and turns out it's slightly more complex than I originally thought. I'll write down the semantics in the testing docs. |
@madkinsz Ok, I've documented the logic and added a small section for comparison to |
@agronholm that reads clearly to me! |
Thanks for the review! |
This allows async fixtures to provide context variables for tests.