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

Rework fixtures #50

Merged
merged 16 commits into from
Aug 24, 2018
Merged

Rework fixtures #50

merged 16 commits into from
Aug 24, 2018

Conversation

njsmith
Copy link
Member

@njsmith njsmith commented Jul 24, 2018

  • Add @pytest_trio.trio_fixture for explicitly marking a fixture as
    being a trio fixture
  • Make the nursery fixture a @trio_fixture
  • Refactor Trio fixture classes into one class
  • Check for trio marker instead of trio keyword (fixes Problem running trio's tests when pytest-trio is installed #43)
    • This also raises the minimum pytest version to 3.6
  • Raise an error if a Trio fixture is used with a non-function
    scope (fixes Verify scope for async fixtures #18)
  • Raise an error if a Trio fixture is used with a non-Trio test

I think this also closes gh-10's discussion, though we still need to
convince pytest-asyncio to fix their side of things.

@njsmith njsmith changed the title IRework fixtures Rework fixtures Jul 24, 2018
@codecov
Copy link

codecov bot commented Jul 24, 2018

Codecov Report

Merging #50 into master will increase coverage by 2.59%.
The diff coverage is 99.62%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master      #50      +/-   ##
==========================================
+ Coverage   96.75%   99.34%   +2.59%     
==========================================
  Files          13       19       +6     
  Lines         308      457     +149     
  Branches       30       44      +14     
==========================================
+ Hits          298      454     +156     
+ Misses          6        2       -4     
+ Partials        4        1       -3
Impacted Files Coverage Δ
pytest_trio/_tests/test_async_yield_fixture.py 100% <ø> (ø) ⬆️
pytest_trio/_tests/test_sync_fixture.py 100% <ø> (ø) ⬆️
pytest_trio/_tests/test_fixture_nursery.py 100% <ø> (ø)
pytest_trio/_tests/test_fixture_ordering.py 100% <100%> (ø)
pytest_trio/enable_trio_mode.py 100% <100%> (ø) ⬆️
pytest_trio/_tests/test_trio_asyncio.py 100% <100%> (ø)
pytest_trio/_tests/test_contextvars.py 100% <100%> (ø)
pytest_trio/_tests/test_fixture_mistakes.py 100% <100%> (ø)
pytest_trio/__init__.py 100% <100%> (ø) ⬆️
pytest_trio/_tests/test_basic.py 100% <100%> (ø) ⬆️
... and 11 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update c905f15...98ba48b. Read the comment docs.

@njsmith
Copy link
Member Author

njsmith commented Jul 24, 2018

Also renamed the nursery fixture → test_nursery in this branch.

@njsmith njsmith mentioned this pull request Jul 24, 2018
@njsmith njsmith force-pushed the fixture-rework branch 3 times, most recently from 9b5c3e5 to 243fdf3 Compare July 25, 2018 03:30
- Add @pytest_trio.trio_fixture for explicitly marking a fixture as
  being a trio fixture
- Make the nursery fixture a @trio_fixture
- Refactor Trio fixture classes into one class
- Check for trio marker instead of trio keyword (fixes python-triogh-43)
  - This also raises the minimum pytest version to 3.6
- Raise an error if a Trio fixture is used with a non-function
  scope (fixes python-triogh-18)
- Raise an error if a Trio fixture is used with a non-Trio test

I think this also closes python-triogh-10's discussion, though we still need to
convince pytest-asyncio to fix their side of things.
Every nursery is called 'nursery'. This is less generic, and
emphasizes that this nursery is special, because its lifetime is
linked to the test.

Kept 'nursery' around for now as a deprecated alias.
The main benefit of this is that it lets us catch more cases where
these fixtures are accidentally misused.

Closes python-triogh-18
Closes python-triogh-51
@njsmith
Copy link
Member Author

njsmith commented Jul 25, 2018

Just pushed another commit that refines the fixture handling logic a bit more: now async fixtures are treated as trio fixtures if (they're used by a trio test OR trio mode is enabled). I think this is the best of both worlds (see also #47 (comment)).

See python-trio#55 for discussion.

Main points:

* Fixture setup/teardown is now performed concurrently whenever
  possible.

* Each fixture is now run in an isolated task, and it's now safe to
  use nurseries inside fixtures (fixes python-triogh-55).

* New magical fixture 'fixture_nursery' which gives a nursery that's
  cancelled immediately after *the requesting fixture finishes
  teardown*. If requested directly by the test itself, gives a nursery
  which is cancelled after the test finishes, before other fixtures
  are torn down.
@njsmith
Copy link
Member Author

njsmith commented Aug 15, 2018

I just rewrote the fixture/test code yet again, based on the discussion in #55. I'm really happy with how it came out. Main points:

  • Fixture setup/teardown is now performed concurrently whenever
    possible.

  • Each fixture is now run in an isolated task, and it's now safe to
    use nurseries inside fixtures (fixes It's very annoying that fixtures can't yield inside a nursery/cancel scope #55).

  • New magical fixture 'fixture_nursery' which gives a nursery that's
    cancelled immediately after the requesting fixture finishes
    teardown
    . If requested directly by the test itself, gives a nursery
    which is cancelled after the test finishes, before other fixtures
    are torn down.

@njsmith
Copy link
Member Author

njsmith commented Aug 15, 2018

Well that's bizarre. All of the test_async_yield_fixture.py tests are failing, in their async_generator version but not the native generator version...... if you have pytest 3.7.1 installed. With pytest 3.6.3, the tests all pass.

Edit: 3.6.4 works. With 3.7.0... everything fails, I guess it's just a bad release. Some something that changed in 3.7.0 or 3.7.1.

@njsmith
Copy link
Member Author

njsmith commented Aug 15, 2018

Ah-hah, it's this bug, which is already fixed in pytest master but hasn't been released yet: pytest-dev/pytest#3774

(Specifically the issue is that pytest is now putting a wrapper on fixture functions and then trying to unwrap it again... but it gets too eager with the "unwrapping", so if you combine @pytest.fixture and @async_generator, like in these tests, then the @async_generator decorator gets stripped off again before the fixture is run!)

This doesn't really have anything to do with this PR but I'll pin the pytest version here anyway so it doesn't block things.

At this point this:

@pytest.fixture
async fix(nursery):
    ...

is just a shorthand for this (they should be exactly equivalent):

@pytest.fixture
async fix():
    async with trio.open_nursery() as nursery:
        try:
            ...
        finally:
            nursery.cancel_scope.cancel()

And the latter is the obvious thing you would write by hand, there's
really no other way to do it. And since it no longer has special
subtle semantics, I'm no longer bothered by using the short name
'nursery'. Plus the longer I looked at 'fixture_nursery' the more
annoying the name seemed. Plus I made that last minute change so you
can use the magic nursery inside tests too, not just fixtures, so the
name didn't even make much sense.
@njsmith
Copy link
Member Author

njsmith commented Aug 15, 2018

I want to add some more tests, but otherwise this is ready to review.

Not sure the best way to approach this, since it's grown to become a major rewrite of pytest-trio's core... @touilleMan, do you think you'll be up to looking soon? Anyone else? I'd like to merge and release this soon, because it'll make a huge difference in usability, and also let us merge #47 to get proper docs.

(It would even be helpful if anyone who's using pytest-trio could try out this branch and make sure that their test suite still works.)

@njsmith
Copy link
Member Author

njsmith commented Aug 15, 2018

One small question I've been pondering: when we get a background crash, maybe it would make more sense to cancel all users, instead of just the test itself?

@touilleMan
Copy link
Member

@njsmith This seems like an incredible work, thanks a lot for this PR !
I've had a first quick look at it, but want to go deeper on it this weekend (especially porting my application's tests on this codebase and see how crashes from more or less random places in the application/fixtures/test are handled)

@njsmith
Copy link
Member Author

njsmith commented Aug 17, 2018

Added more tests. I'm sure we could add even more, but the tests do push at some of the nastier edge cases now (including background tasks crashing at different moments), and the coverage report is solid.

Also, I added some code to use friendly names for all the fixture tasks, which could hopefully help with this issue (#47 (comment)):

The great thing I find about this is you don't end up with a root nursery containing all the coroutines which make make it much harder to tell which one is responsible when your test hangs with a deadlock.

In the course of this (and also while thinking about python-trio/trio#607), I have started to think that maybe the details of our new cancellation rules aren't quite right: I'm leaning towards saying that when a fixture crashes, we should cancel all "downstream" fixtures, but not do anything at all to independent fixtures. Right now, we only cancel the test, not other downstream fixtures... except that we also cancel all fixtures that haven't started yet, including independent ones. This means that if you have two independent fixtures that both crash during setup, you'll actually get different exceptions on different runs, randomly, depending on which one happens to run first.

But these are some pretty obscure details that aren't going to really matter to 99.9% of users, and this PR is already out of control, so I'd rather merge this as is and then open an issue to keep track of that as a possible followup change.

njsmith added a commit to njsmith/pytest-trio that referenced this pull request Aug 17, 2018
Also wrote some draft release notes
njsmith added a commit to njsmith/pytest-trio that referenced this pull request Aug 17, 2018
Also wrote some draft release notes
@njsmith
Copy link
Member Author

njsmith commented Aug 21, 2018

@touilleMan Did you get a chance to take a look this weekend?

@touilleMan
Copy link
Member

@njsmith Sorry for the delay, I've spent my weekend repairing my old car so not much time for computing :'-(

Anyway, I've played with this PR and it seems pretty cool (work as a drop in replacement for my test suite).

However I've found the fixtures doesn't play nice with trio_asyncio:

import pytest
import trio_asyncio
import asyncio

@trio_asyncio.trio2aio
async def work_in_asyncio():
    await asyncio.sleep(0)

@pytest.fixture()
async def asyncio_loop():
    async with trio_asyncio.open_loop() as loop:
        yield loop

@pytest.fixture()
async def asyncio_fiture_with_fixtured_loop(asyncio_loop):
    await work_in_asyncio()
    yield 42

@pytest.fixture()
async def asyncio_fixture_own_loop():
    async with trio_asyncio.open_loop():
        await work_in_asyncio()
        yield 42

@pytest.mark.trio
async def test_no_fixture():
    async with trio_asyncio.open_loop():
        await work_in_asyncio()

@pytest.mark.trio
async def test_half_fixtured_asyncpg_conn(
    asyncio_fixture_own_loop
):
    pass

@pytest.mark.trio
async def test_fixtured_asyncpg_conn(
    asyncio_fiture_with_fixtured_loop
):
    pass

This snippet works fine with current master of pytest-trio

$ pip freeze | grep -iE 'trio|asyncio'
-e git+git@github.com:python-trio/pytest-trio.git@70894fdc13745dd65aa8596a2836562f5eb6c63f#egg=pytest_trio
sphinxcontrib-trio==1.0.1
trio==0.6.0
-e git+git@github.com:python-trio/trio-asyncio.git@c6d583c3788018baa9467a2f84faaf29e1a5e934#egg=trio_asyncio
-e git+git@github.com:python-trio/triopg.git@b57b5d3bc17e4ce97b7e639860f8c3640d2021cb#egg=triopg
$ py.test triopg/_tests/test_async_loop_in_fixture.py -vvv
============================================================================== test session starts ==============================================================================
platform linux -- Python 3.6.1, pytest-3.6.4, py-1.5.4, pluggy-0.6.0 -- /home/emmanuel/projects/triopg/venv/bin/python3.6
cachedir: .pytest_cache
rootdir: /home/emmanuel/projects/triopg, inifile:
plugins: cov-2.5.1, trio-0.4.2
collected 3 items                                                                                                                                                               

triopg/_tests/test_async_loop_in_fixture.py::test_no_fixture PASSED                                                                                                       [ 33%]
triopg/_tests/test_async_loop_in_fixture.py::test_half_fixtured_asyncpg_conn PASSED                                                                                       [ 66%]
triopg/_tests/test_async_loop_in_fixture.py::test_fixtured_asyncpg_conn PASSED                                                                                            [100%]

=========================================================================== 3 passed in 0.02 seconds ============================================================================

And with this PR:

$ pip freeze | grep -iE 'trio|asyncio'
-e git+git@github.com:python-trio/pytest-trio.git@4d2ec80c6de50b3626027ad4d0432fb9be2f084e#egg=pytest_trio
sphinxcontrib-trio==1.0.1
trio==0.6.0
-e git+git@github.com:python-trio/trio-asyncio.git@c6d583c3788018baa9467a2f84faaf29e1a5e934#egg=trio_asyncio
-e git+git@github.com:python-trio/triopg.git@b57b5d3bc17e4ce97b7e639860f8c3640d2021cb#egg=triopg
$ py.test triopg/_tests/test_async_loop_in_fixture.py -vvv
============================================================================== test session starts ==============================================================================
platform linux -- Python 3.6.1, pytest-3.6.4, py-1.5.4, pluggy-0.6.0 -- /home/emmanuel/projects/triopg/venv/bin/python3.6
cachedir: .pytest_cache
rootdir: /home/emmanuel/projects/triopg, inifile:
plugins: cov-2.5.1, trio-0.4.2
collected 3 items                                                                                                                                                               

triopg/_tests/test_async_loop_in_fixture.py::test_no_fixture PASSED                                                                                                       [ 33%]
triopg/_tests/test_async_loop_in_fixture.py::test_half_fixtured_asyncpg_conn PASSED                                                                                       [ 66%]
triopg/_tests/test_async_loop_in_fixture.py::test_fixtured_asyncpg_conn FAILED                                                                                            [100%]

=================================================================================== FAILURES ====================================================================================
__________________________________________________________________________ test_fixtured_asyncpg_conn ___________________________________________________________________________

value = <trio._core._run.Nursery object at 0x7f72fe563fd0>

    async def yield_(value=None):
>       return await _yield_(value)

venv/lib/python3.6/site-packages/async_generator/_impl.py:106: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
venv/lib/python3.6/site-packages/async_generator/_impl.py:99: in _yield_
    return (yield _wrap(value))
triopg/_tests/test_async_loop_in_fixture.py:19: in asyncio_fiture_with_fixtured_loop
    await work_in_asyncio()
../trio-asyncio/trio_asyncio/adapter.py:32: in call
    return await trio_asyncio.run_asyncio(proc_, *args)
../trio-asyncio/trio_asyncio/loop.py:302: in run_asyncio
    loop = asyncio.get_event_loop()
../trio-asyncio/trio_asyncio/loop.py:195: in _new_loop_get
    loop = _new_run_get()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

    def _new_run_get():
        try:
            task = trio.hazmat.current_task()
        except RuntimeError:
            loop = _orig_run_get()
        else:
            loop = task.context.get(current_loop, None)
            if loop is None:
>               raise RuntimeError("No trio_asyncio loop is active.")
E               RuntimeError: No trio_asyncio loop is active.

../trio-asyncio/trio_asyncio/loop.py:182: RuntimeError
====================================================================== 1 failed, 2 passed in 0.08 seconds =======================================================================

I even have the feeling this snippet could be added in pytest-trio testsuite given how subtle trio-asyncio crashes can be, what do you think ?

@njsmith
Copy link
Member Author

njsmith commented Aug 21, 2018

Ughhhh of course it doesn't. Ughhhh.

So... our two options are to either find some way to move back towards normality, so trio-asyncio automatically works because we're actually doing what it expects, or to double down on our elaborate house of mirrors. In general my instinct is to do the former, but in this case that would require going back to nesting fixtures inside each other, and I just can't see how to make that work. So... plan B.

Here's my evil idea: what if we make it so that all the tasks we're creating for fixtures, actually share a contextvar context, so changes one fixture makes are visible to the others. Then when trio-asyncio registers stashes the loop in a contextvar, the subsequent fixtures and test will be able to find it.

The main thing that gives me pause here is that if we literally use the same Context object for all the fixtures, then there's a theoretical possibility of interactions and race conditions between otherwise-independent fixtures, if they run in parallel and are messing with the same contextvar. Of course, if you have to fixtures running in parallel and messing with the same contextvar then I can't see how you could ever expect that to work anyway... But there is another option I guess, which is to use separate contexts, but when we start up a new fixture the first thing it does is copy over all the contextvar settings from all the fixtures it depends on. But this requires doing something clever, like a 3-way merge, to detect which contextvars are most up-to-date.

I guess we could literally do a three way merge. Or really go wild and do mark-merge.

This is hilarious overkill and ridiculous but now that I've said it I'm kind of tempted. I guess it's not even the fully general case, since we would error out on conflicts (trying to depend on two fixtures that set the same contextvar to different values), and this means there would never be resolved conflicts in the history.

@njsmith
Copy link
Member Author

njsmith commented Aug 21, 2018

We could also stop running fixtures in parallel. If we run them sequentially then there's no problem. I'm kind of dubious about whether running them in parallel really helps anything, actually – I mostly did it because once we were isolating fixtures into independent tasks, it seemed easier than doing a toposort to get a sequential order. Though now that I think about it, there's a great trick for picking the sequential order: just record what order pytest tries to set up the fixtures, and duplicate it. And this would reduce the chance of race conditions in fixtures, which sound like a real nightmare to deal with.

This is sort of a gross hack, but it's simple and works. If it causes
problems, then this comment discusses some other options:
  python-trio#50 (comment)

Also added tests for using trio-asyncio in fixtures (which was the
motivating use case for sharing contextvars).
@njsmith
Copy link
Member Author

njsmith commented Aug 22, 2018

Okay, I went with the simplest-possible-thing and enabled sharing of contextvars context across all the fixtures in a test. We may want to clean this up later, but it solves the immediate problem.

I also added your trio-asyncio test to the test suite. It passes now. (At least on my laptop, we'll see what CI says.)

@touilleMan
Copy link
Member

Sorry for the python3.6 only snippet that broke the CI ^^

@njsmith
Copy link
Member Author

njsmith commented Aug 22, 2018

Heh, no worries :-)

When importing trio-asyncio v0.8.0 on py35, we get a crash at import
time:

  .../python3.5/site-packages/trio_asyncio/loop.py:171: in <module>
      _orig_run_get = _aio_event._get_running_loop
  E   AttributeError: module 'asyncio.events' has no attribute '_get_running_loop'

See: https://travis-ci.org/python-trio/pytest-trio/jobs/419092404

So let's skip testing this for now...
@njsmith
Copy link
Member Author

njsmith commented Aug 22, 2018

Filed python-trio/trio-asyncio#33 for the trio-asyncio errors on py35.

So assuming CI passes now, I think this is OK to merge, again :-). With follow-up issues to file:

  • Consider reworking cancellation details: Rework fixtures #50 (comment)
  • Consider whether we actually want to share Contexts. In particular, if a "downstream" fixture mutates the context, should that be visible to "upstream" teardown code? Currently it is.

@touilleMan
Copy link
Member

I've retried your last version, it works fine and solve as expected issues I had with test never stopping when an exception occurs in the asyncio world (bad SQL syntax given to asyncpg in my case).

@touilleMan
Copy link
Member

If we run them sequentially then there's no problem. I'm kind of dubious about whether running them in parallel really helps anything,

Same thing here ! I would say sequential order is good enough and avoid painful concurrency debugging when multiple fixtures are stepping on each other toes ;-)

But anyway this can be fixed in another PR as you said

@belm0
Copy link
Member

belm0 commented Aug 23, 2018

my project is using this PR so I'm in favor of a merge 👍

  • exercising trio_mode and async fixture with yield
  • correctly received error for trio fixture with module scope

@njsmith njsmith merged commit dd27d70 into python-trio:master Aug 24, 2018
@njsmith
Copy link
Member Author

njsmith commented Aug 24, 2018

Okay, then let's do this :-)

@touilleMan I believe #47 is up to date and ready to merge as soon as this goes in, do you (or anyone else) want to take a look?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants