-
-
Notifications
You must be signed in to change notification settings - Fork 25
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
Design discussion: support higher-scoped (class/module/session level) fixtures? #89
Comments
Using the same trio loop with different tests is a very slippy slope (typically with the current Adding greenlet to the mix is a whole new layer of complexity, pytest-trio is already far from trivial so this seems like another big red flag. I think the best way to implement higher level fixture is to use them to start a separate trio loop within a thread. |
I was investigating using pytest-trio to write some integration tests, where I have some expensive services to setup, so want to have session scoped fixtures for the services. I also want to collect logs from the services as they run, and sometimes restart those services from the tests. It is definitely less appealing if I need to manage my own loop for anything session scoped, or that interacts with code from the session scoped things. In terms of implementation, it looks like how trio is structured to support guest mode could be used to support suspending and resuming the loop. With the current public API, you could have one long-running trio loop, which is what is exposed to users; then, when running a async test or fixture, iterate the host-loop calls (perhaps using a separate trio loop). This would require at least a little glue to capture the guest calls to It is definitely true that you can't use the current nursery fixture from non-function scoped fixtures; the same is true of pytest's |
I'm unclear on what the danger is in using the same trio loop for both session and module fixtures in addition to just function fixtures. I don't understand why we couldn't have a session-scope nursery, a module-scope nursery under that, then the function-level nursery under that, then automatically cancel the nurseries when the module is complete and at the end of the session, in the same way the function level nursery is already cancelled. Is it somehow possible for an async method to outlive the nursery it was spawned in? |
I wanted to chime in here to emphasize that the lack of support for session/module/class-scoped async fixtures is a serious limitation for us. We're writing unit tests to exercise code accessing a PostgreSQL database using the I know that I might be willing to donate some effort to make session/module/class-scoped async fixtures in |
@jmehnle The basic difficulty is that all of pytest's internal test runner logic is synchronous code, so we can't easily open an event loop "around" the test runner and run tests inside of it, because synchronous functions can't do a blocking call into an enclosing async context. However, we now have two technologies that we didn't have when this issue was first opened, either of which might be able to bridge that gap:
Of these I think guest mode is probably a better fit for pytest, and it also avoids the greenlet dependency. Higher-scoped fixtures require a single Trio run that wraps all tests in their scope. This is incompatible with current popular use of fixtures like To solve your immediate problem, it is probably going to be much much easier to use a synchronous session-level fixture that starts a thread and runs the container from the thread. |
Thanks for the great overview. I will ponder this in the coming weeks. FWIW, and for anyone having a similar problem, I have worked around the limitation by using a synchronous session-scoped fixture that spins up the database container and talks to the database using a synchronous database driver to set up the DB, in deviation from the asynchronous driver we use in application code. This is pretty ugly, and I'd still very much love to get rid of this pile of dirt. |
Echoing @jmehnle's comment, we use That being said, I had an idea for an alternative architecture of Instead of having a per-test call to This architecture seems to greatly simplify the plugin logic. So far, I can't think of any major downsides. I've provided a complete, working from inspect import iscoroutinefunction, isasyncgenfunction
from math import inf
from queue import Queue
from threading import Thread
import pytest
from pytest import Session, StashKey, Item, FixtureDef, FixtureRequest
import trio
from trio import CancelScope
from trio.lowlevel import TrioToken
trio_token_key = StashKey[TrioToken]()
trio_thread_key = StashKey[Thread]()
trio_cancel_key = StashKey[CancelScope]()
type TrioQueue = Queue[tuple[TrioToken, CancelScope]]
async def trio_main(queue: TrioQueue) -> None:
trio_token = trio.lowlevel.current_trio_token()
with CancelScope() as cancel_scope:
queue.put((trio_token, cancel_scope))
await trio.sleep(inf)
@pytest.hookimpl(wrapper=True)
def pytest_sessionstart(session: Session):
try:
# If pytest.main was called with trio.lowlevel.to_thread.run_sync,
# then a TrioToken should be available.
trio_token = trio.lowlevel.current_trio_token()
except RuntimeError:
# Otherwise, start a dedicated trio thread
queue: TrioQueue = Queue()
trio_thread = Thread(target=trio.run, args=(trio_main, queue))
trio_thread.start()
trio_token, cancel_scope = queue.get()
session.stash[trio_thread_key] = trio_thread
session.stash[trio_cancel_key] = cancel_scope
session.stash[trio_token_key] = trio_token
yield
@pytest.hookimpl(wrapper=True)
def pytest_fixture_setup(fixturedef: FixtureDef, request: FixtureRequest):
fn = fixturedef.func
trio_token = request.session.stash[trio_token_key]
if isasyncgenfunction(fn):
def gen_adapter(*args, **kwargs):
gen = fn(*args, **kwargs)
yield trio.from_thread.run(anext, gen, trio_token=trio_token)
try:
trio.from_thread.run(anext, gen, trio_token=trio_token)
except StopAsyncIteration:
pass
fixturedef.func = gen_adapter
elif iscoroutinefunction(fn):
def coro_adapter(*args, **kwargs):
coro = fn(*args, **kwargs)
async def inner():
return await coro
return trio.from_thread.run(inner, trio_token=trio_token)
fixturedef.func = coro_adapter
yield
@pytest.hookimpl(wrapper=True)
def pytest_runtest_call(item: Item):
fn = item.obj
if iscoroutinefunction(fn):
trio_token = item.session.stash[trio_token_key]
def adapter(*args, **kwargs):
coro = fn(*args, **kwargs)
async def inner():
return await coro
trio.from_thread.run(inner, trio_token=trio_token)
item.obj = adapter
yield
@pytest.hookimpl(wrapper=True)
def pytest_sessionfinish(session: Session, exitstatus):
cancel_scope = session.stash.get(trio_cancel_key, None)
if cancel_scope is not None:
trio_token = session.stash[trio_token_key]
trio_thread = session.stash[trio_thread_key]
trio.from_thread.run_sync(cancel_scope.cancel, trio_token=trio_token)
trio_thread.join()
yield |
Hi @bradleyharden ;-) It's been a while since I've touched this pytest-trio so I won't be able to provide you with much information... My guess regarding why pytest-trio never used a threading strategy is we never needed it: the first version of the plugin I wrote was simple enough, then we iterated (at lot) from there. As you say, your approach seems appealing regarding its simplicity, however if I've learned something working with asynchronous codebase, it's things are always more complicated than first expected (and that's usually @njsmith's legendary wisdom that brings us the "more complicated part" 😄 ) btw have you had a look at #147 ? (given the issue is also about refactoring this plugin) |
@touilleMan, yes, I fully anticipated that it might be more complicated than I thought, hence my question of whether the architecture was considered and rejected for some reason. In any event, I did see the other thread, but I didn't look into it closely. I think it's a better fit for this discussion. |
Note that it's currently possible to get higher-scoped fixtures if you use the anyio pytest plugin, which you can configure to only run in trio mode. It doesn't have 100% feature parity with pytest-trio, but it's fairly close atm. |
I'm pretty sure it's possible if we're willing to use greenlet.
Here's a sketch that's missing important details like exception propagation.
Hook pytest_runtest_loop to keep a Trio event loop running in another greenlet for the whole pytest invocation:
Hook fixture setup to run async fixtures in the Trio greenlet:
Hook running-the-test to run async tests in the Trio greenlet (not shown).
The details are decidedly nontrivial, but I don't see any showstoppers... thoughts? Maybe this could totally replace pytest-trio's current strategy, but it's not a win across the board, so I'm imagining it more as an alternative mode.
Benefits:
Drawbacks:
The text was updated successfully, but these errors were encountered: