Skip to content

Commit e050933

Browse files
authored
Merge branch 'master' into pytest-ini
2 parents 7ab61ef + 23fc351 commit e050933

File tree

9 files changed

+278
-14
lines changed

9 files changed

+278
-14
lines changed

docs/source/history.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ Release history
55

66
.. towncrier release notes start
77
8+
pytest-trio 0.5.2 (2019-02-13)
9+
------------------------------
10+
11+
Features
12+
~~~~~~~~
13+
14+
- pytest-trio now makes the Trio scheduler deterministic while running
15+
inside a Hypothesis test. Hopefully you won't see any change, but if
16+
you had scheduler-dependent bugs Hypothesis will be more effective now. (`#73 <https://github.com/python-trio/pytest-trio/issues/73>`__)
17+
18+
- Updated for compatibility with trio v0.11.0.
19+
820
pytest-trio 0.5.1 (2018-09-28)
921
------------------------------
1022

docs/source/reference.rst

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,114 @@ but Trio fixtures **must be test scoped**. Class, module, and session
106106
scope are not supported.
107107

108108

109+
.. _cancel-yield:
110+
111+
An important note about ``yield`` fixtures
112+
------------------------------------------
113+
114+
Like any pytest fixture, Trio fixtures can contain both setup and
115+
teardown code separated by a ``yield``::
116+
117+
@pytest.fixture
118+
async def my_fixture():
119+
... setup code ...
120+
yield
121+
... teardown code ...
122+
123+
When pytest-trio executes this fixture, it creates a new task, and
124+
runs the setup code until it reaches the ``yield``. Then the fixture's
125+
task goes to sleep. Once the test has finished, the fixture task wakes
126+
up again and resumes at the ``yield``, so it can execute the teardown
127+
code.
128+
129+
So the ``yield`` in a fixture is sort of like calling ``await
130+
wait_for_test_to_finish()``. And in Trio, any ``await``\-able
131+
operation can be cancelled. For example, we could put a timeout on the
132+
``yield``::
133+
134+
@pytest.fixture
135+
async def my_fixture():
136+
... setup code ...
137+
with trio.move_on_after(5):
138+
yield # this yield gets cancelled after 5 seconds
139+
... teardown code ...
140+
141+
Now if the test takes more than 5 seconds to execute, this fixture
142+
will cancel the ``yield``.
143+
144+
That's kind of a strange thing to do, but there's another version of
145+
this that's extremely common. Suppose your fixture spawns a background
146+
task, and then the background task raises an exception. Whenever a
147+
background task raises an exception, it automatically cancels
148+
everything inside the nursery's scope – which includes our ``yield``::
149+
150+
@pytest.fixture
151+
async def my_fixture(nursery):
152+
nursery.start_soon(function_that_raises_exception)
153+
yield # this yield gets cancelled after the background task crashes
154+
... teardown code ...
155+
156+
If you use fixtures with background tasks, you'll probably end up
157+
cancelling one of these ``yield``\s sooner or later. So what happens
158+
if the ``yield`` gets cancelled?
159+
160+
First, pytest-trio assumes that something has gone wrong and there's
161+
no point in continuing the test. If the top-level test function is
162+
running, then it cancels it.
163+
164+
Then, pytest-trio waits for the test function to finish, and
165+
then begins tearing down fixtures as normal.
166+
167+
During this teardown process, it will eventually reach the fixture
168+
that cancelled its ``yield``. This fixture gets resumed to execute its
169+
teardown logic, but with a special twist: since the ``yield`` was
170+
cancelled, the ``yield`` raises :exc:`trio.Cancelled`.
171+
172+
Now, here's the punchline: this means that in our examples above, the
173+
teardown code might not be executed at all! **This is different from
174+
how pytest fixtures normally work.** Normally, the ``yield`` in a
175+
pytest fixture never raises an exception, so you can be certain that
176+
any code you put after it will execute as normal. But if you have a
177+
fixture with background tasks, and they crash, then your ``yield``
178+
might raise an exception, and Python will skip executing the code
179+
after the ``yield``.
180+
181+
In our experience, most fixtures are fine with this, and it prevents
182+
some `weird problems
183+
<https://github.com/python-trio/pytest-trio/issues/75>`__ that can
184+
happen otherwise. But it's something to be aware of.
185+
186+
If you have a fixture where the ``yield`` might be cancelled but you
187+
still need to run teardown code, then you can use a ``finally``
188+
block::
189+
190+
@pytest.fixture
191+
async def my_fixture(nursery):
192+
nursery.start_soon(function_that_crashes)
193+
try:
194+
# This yield could be cancelled...
195+
yield
196+
finally:
197+
# But this code will run anyway
198+
... teardown code ...
199+
200+
(But, watch out: the teardown code is still running in a cancelled
201+
context, so if it has any ``await``\s it could raise
202+
:exc:`trio.Cancelled` again.)
203+
204+
Or if you use ``with`` to handle teardown, then you don't have to
205+
worry about this because ``with`` blocks always perform cleanup even
206+
if there's an exception::
207+
208+
@pytest.fixture
209+
async def my_fixture(nursery):
210+
with get_obj_that_must_be_torn_down() as obj:
211+
nursery.start_soon(function_that_crashes, obj)
212+
# This could raise trio.Cancelled...
213+
# ...but that's OK, the 'with' block will still tear down 'obj'
214+
yield obj
215+
216+
109217
Concurrent setup/teardown
110218
-------------------------
111219

newsfragments/75.feature.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Incompatible change: if you use ``yield`` inside a Trio fixture, and
2+
the ``yield`` gets cancelled (for example, due to a background task
3+
crashing), then the ``yield`` will now raise :exc:`trio.Cancelled`.
4+
See :ref:`cancel-yield` for details. Also, in this same case,
5+
pytest-trio will now reliably mark the test as failed, even if the
6+
fixture doesn't go on to raise an exception.

pytest_trio/_tests/test_fixture_mistakes.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,32 @@ def test_whatever(async_fixture):
117117
result.stdout.fnmatch_lines(
118118
["*: Trio fixtures can only be used by Trio tests*"]
119119
)
120+
121+
122+
@enable_trio_mode
123+
def test_fixture_cancels_test_but_doesnt_raise(testdir, enable_trio_mode):
124+
enable_trio_mode(testdir)
125+
126+
testdir.makepyfile(
127+
"""
128+
import pytest
129+
import trio
130+
from async_generator import async_generator, yield_
131+
132+
@pytest.fixture
133+
@async_generator
134+
async def async_fixture():
135+
with trio.CancelScope() as cscope:
136+
cscope.cancel()
137+
await yield_()
138+
139+
140+
async def test_whatever(async_fixture):
141+
pass
142+
"""
143+
)
144+
145+
result = testdir.runpytest()
146+
147+
result.assert_outcomes(failed=1)
148+
result.stdout.fnmatch_lines(["*async_fixture*cancelled the test*"])

pytest_trio/_tests/test_fixture_ordering.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ async def crash_late_agen():
171171
raise RuntimeError("crash_late_agen".upper())
172172
173173
async def crash(when, token):
174-
with trio.open_cancel_scope(shield=True):
174+
with trio.CancelScope(shield=True):
175175
await trio.sleep(when)
176176
raise RuntimeError(token.upper())
177177
@@ -213,7 +213,8 @@ def test_background_crash_cancellation_propagation(bgmode, testdir):
213213
@trio_fixture
214214
def crashyfix(nursery):
215215
nursery.start_soon(crashy)
216-
yield
216+
with pytest.raises(trio.Cancelled):
217+
yield
217218
# We should be cancelled here
218219
teardown_deadlines["crashyfix"] = trio.current_effective_deadline()
219220
"""
@@ -224,7 +225,8 @@ def crashyfix(nursery):
224225
async def crashyfix():
225226
async with trio.open_nursery() as nursery:
226227
nursery.start_soon(crashy)
227-
await yield_()
228+
with pytest.raises(trio.Cancelled):
229+
await yield_()
228230
# We should be cancelled here
229231
teardown_deadlines["crashyfix"] = trio.current_effective_deadline()
230232
"""
@@ -284,3 +286,47 @@ def test_post():
284286

285287
result = testdir.runpytest()
286288
result.assert_outcomes(passed=1, failed=1)
289+
290+
291+
# See the thread starting at
292+
# https://github.com/python-trio/pytest-trio/pull/77#issuecomment-499979536
293+
# for details on the real case that this was minimized from
294+
def test_complex_cancel_interaction_regression(testdir):
295+
testdir.makepyfile(
296+
"""
297+
import pytest
298+
import trio
299+
from async_generator import asynccontextmanager, async_generator, yield_
300+
301+
async def die_soon():
302+
raise RuntimeError('oops'.upper())
303+
304+
@asynccontextmanager
305+
@async_generator
306+
async def async_finalizer():
307+
try:
308+
await yield_()
309+
finally:
310+
await trio.sleep(0)
311+
312+
@pytest.fixture
313+
@async_generator
314+
async def fixture(nursery):
315+
async with trio.open_nursery() as nursery1:
316+
async with async_finalizer():
317+
async with trio.open_nursery() as nursery2:
318+
nursery2.start_soon(die_soon)
319+
await yield_()
320+
nursery1.cancel_scope.cancel()
321+
322+
@pytest.mark.trio
323+
async def test_try(fixture):
324+
await trio.sleep_forever()
325+
"""
326+
)
327+
328+
result = testdir.runpytest()
329+
result.assert_outcomes(passed=0, failed=1)
330+
result.stdout.fnmatch_lines_random([
331+
"*OOPS*",
332+
])

pytest_trio/_tests/test_hypothesis_interaction.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import pytest
2+
import trio
3+
from trio.tests.test_scheduler_determinism import (
4+
scheduler_trace, test_the_trio_scheduler_is_not_deterministic,
5+
test_the_trio_scheduler_is_deterministic_if_seeded
6+
)
27
from hypothesis import given, settings, strategies as st
38

9+
from pytest_trio.plugin import _trio_test_runner_factory
10+
411
# deadline=None avoids unpredictable warnings/errors when CI happens to be
512
# slow (example: https://travis-ci.org/python-trio/pytest-trio/jobs/406738296)
613
# max_examples=5 speeds things up a bit
@@ -28,3 +35,23 @@ async def test_mark_outer(n):
2835
async def test_mark_and_parametrize(x, y):
2936
assert x is None
3037
assert y in (1, 2)
38+
39+
40+
def test_the_trio_scheduler_is_deterministic_under_hypothesis():
41+
traces = []
42+
43+
@our_settings
44+
@given(st.integers())
45+
@pytest.mark.trio
46+
async def inner(_):
47+
traces.append(await scheduler_trace())
48+
49+
# The pytest.mark.trio doesn't do it's magic thing to
50+
# inner functions, so we invoke it explicitly here.
51+
inner.hypothesis.inner_test = _trio_test_runner_factory(
52+
None, inner.hypothesis.inner_test
53+
)
54+
inner() # Tada, now it's a sync function!
55+
56+
assert len(traces) >= 5
57+
assert len(set(traces)) == 1

pytest_trio/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# This file is imported from __init__.py and exec'd from setup.py
22

3-
__version__ = "0.5.1"
3+
__version__ = "0.5.2+dev"

0 commit comments

Comments
 (0)