Skip to content
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

Merged
merged 5 commits into from
Nov 23, 2022

Conversation

agronholm
Copy link
Owner

This allows async fixtures to provide context variables for tests.

Copy link
Contributor

@zanieb zanieb left a 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]]]
Copy link
Contributor

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?

Copy link
Contributor

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.

Copy link
Owner Author

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.

Copy link
Contributor

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))
Copy link
Contributor

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.

Copy link
Owner Author

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.

Copy link
Contributor

@zanieb zanieb Nov 11, 2022

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
Copy link
Contributor

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

Copy link
Owner Author

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.

Comment on lines 1770 to 1771
self._runner_task = None

Copy link
Contributor

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?

Copy link
Owner Author

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.

Copy link
Contributor

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.

Copy link
Owner Author

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.

Copy link
Contributor

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.

Comment on lines +1789 to +1790
except StopAsyncIteration:
self._raise_async_exceptions()
Copy link
Contributor

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.

Copy link
Owner Author

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.

Copy link
Owner Author

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.

Copy link
Contributor

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

Copy link
Owner Author

@agronholm agronholm Nov 12, 2022

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.

Copy link
Contributor

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.

@agronholm
Copy link
Owner Author

This change also makes task groups created in fixtures available in tests, right?

They were available to tests before this.

Are there any expected negative side-effects to running everything in a single task?

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).

@agronholm
Copy link
Owner Author

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.

@zanieb
Copy link
Contributor

zanieb commented Nov 11, 2022

This change also makes task groups created in fixtures available in tests, right?

They were available to tests before this.

Ah in pytest-asyncio I get RuntimeError: Attempted to exit cancel scope in a different task than it was entered in so I presumed the separate tasks caused problems:

@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.

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).

This may be a cause of confusion and worth highlighting in documentation.

For the record, setting a context variable and not resetting it at the end of the fixture/test is just plain irresponsible

Agreed!

@agronholm
Copy link
Owner Author

agronholm commented Nov 11, 2022

Ah in pytest-asyncio I get RuntimeError: Attempted to exit cancel scope in a different task than it was entered in so I presumed the separate tasks caused problems:

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).

@agronholm
Copy link
Owner Author

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.

What's this then? https://github.com/agronholm/anyio/blob/master/tests/test_pytest_plugin.py#L242-L245

@zanieb
Copy link
Contributor

zanieb commented Nov 13, 2022

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 :)

@agronholm
Copy link
Owner Author

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.

@agronholm
Copy link
Owner Author

So, what's left? Documenting that context variables now work the same way in async tests/fixtures as with synchronous tests/fixtures?

Copy link
Contributor

@zanieb zanieb left a 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 :)

@agronholm
Copy link
Owner Author

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.

@agronholm agronholm marked this pull request as draft November 14, 2022 06:59
@agronholm agronholm marked this pull request as ready for review November 14, 2022 06:59
@agronholm
Copy link
Owner Author

@madkinsz Ok, I've documented the logic and added a small section for comparison to pytest-asyncio and pytest-trio. Are we good now? 🙂

@zanieb
Copy link
Contributor

zanieb commented Nov 23, 2022

@agronholm that reads clearly to me!

@agronholm agronholm merged commit 1b57189 into master Nov 23, 2022
@agronholm agronholm deleted the trampoline-test branch November 23, 2022 22:07
@agronholm
Copy link
Owner Author

Thanks for the review!

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.

None yet

2 participants