Skip to content

Commit 1a128e2

Browse files
committed
asyncio: #8 consider contextvars
Make it so everything gets executed in the same asyncio context
1 parent 60dc586 commit 1a128e2

File tree

11 files changed

+266
-13
lines changed

11 files changed

+266
-13
lines changed

Diff for: README.rst

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ generator fixtures.
2121
Changelog
2222
---------
2323

24+
0.8.0 - TBD
25+
* Provide simple support for tests being aware of asyncio.Context
26+
2427
0.7.2 - 1 October 2023
2528
* Timeouts don't take affect if the debugger is active
2629

Diff for: alt_pytest_asyncio/async_converters.py

+73-11
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,78 @@
1414
from functools import wraps
1515

1616

17-
def convert_fixtures(fixturedef, request, node):
17+
def convert_fixtures(ctx, fixturedef, request, node):
1818
"""Used to replace async fixtures"""
1919
if not hasattr(fixturedef, "func"):
2020
return
2121

22+
if hasattr(fixturedef.func, "__alt_asyncio_pytest_converted__"):
23+
return
24+
2225
if inspect.iscoroutinefunction(fixturedef.func):
23-
convert_async_coroutine_fixture(fixturedef, request, node)
26+
convert_async_coroutine_fixture(ctx, fixturedef, request, node)
27+
fixturedef.func.__alt_asyncio_pytest_converted__ = True
2428

2529
elif inspect.isasyncgenfunction(fixturedef.func):
26-
convert_async_gen_fixture(fixturedef, request, node)
30+
convert_async_gen_fixture(ctx, fixturedef, request, node)
31+
fixturedef.func.__alt_asyncio_pytest_converted__ = True
32+
33+
elif inspect.isgeneratorfunction(fixturedef.func):
34+
convert_sync_gen_fixture(ctx, fixturedef)
35+
fixturedef.func.__alt_asyncio_pytest_converted__ = True
36+
37+
else:
38+
convert_sync_fixture(ctx, fixturedef)
39+
fixturedef.func.__alt_asyncio_pytest_converted__ = True
40+
41+
42+
def convert_sync_fixture(ctx, fixturedef):
43+
"""
44+
Used to make sure a non-async fixture is run in our
45+
asyncio contextvars
46+
"""
47+
original = fixturedef.func
48+
49+
@wraps(original)
50+
def run_fixture(*args, **kwargs):
51+
try:
52+
ctx.run(lambda: None)
53+
run = lambda func, *a, **kw: ctx.run(func, *a, **kw)
54+
except RuntimeError:
55+
run = lambda func, *a, **kw: func(*a, **kw)
2756

57+
return run(original, *args, **kwargs)
2858

29-
def converted_async_test(test_tasks, func, timeout, *args, **kwargs):
59+
fixturedef.func = run_fixture
60+
61+
62+
def convert_sync_gen_fixture(ctx, fixturedef):
63+
"""
64+
Used to make sure a non-async generator fixture is run in our
65+
asyncio contextvars
66+
"""
67+
original = fixturedef.func
68+
69+
@wraps(original)
70+
def run_fixture(*args, **kwargs):
71+
try:
72+
ctx.run(lambda: None)
73+
run = lambda func, *a, **kw: ctx.run(func, *a, **kw)
74+
except RuntimeError:
75+
run = lambda func, *a, **kw: func(*a, **kw)
76+
77+
cm = original(*args, **kwargs)
78+
value = run(cm.__next__)
79+
try:
80+
yield value
81+
run(cm.__next__)
82+
except StopIteration:
83+
pass
84+
85+
fixturedef.func = run_fixture
86+
87+
88+
def converted_async_test(ctx, test_tasks, func, timeout, *args, **kwargs):
3089
"""Used to replace async tests"""
3190
__tracebackhide__ = True
3291

@@ -39,7 +98,7 @@ def look_at_task(t):
3998
info["func"] = func
4099

41100
return _run_and_raise(
42-
loop, info, func, async_runner(func, timeout, info, args, kwargs), look_at_task
101+
ctx, loop, info, func, async_runner(func, timeout, info, args, kwargs), look_at_task
43102
)
44103

45104

@@ -83,15 +142,15 @@ def raise_error():
83142
raise_error()
84143

85144

86-
def _run_and_raise(loop, info, func, coro, look_at_task=None):
145+
def _run_and_raise(ctx, loop, info, func, coro, look_at_task=None):
87146
__tracebackhide__ = True
88147

89148
def silent_done_task(res):
90149
if res.cancelled():
91150
pass
92151
res.exception()
93152

94-
task = loop.create_task(coro)
153+
task = loop.create_task(coro, context=ctx)
95154
task.add_done_callback(silent_done_task)
96155

97156
if look_at_task:
@@ -148,7 +207,7 @@ def wrapper(*args, **kwargs):
148207
return wrapper
149208

150209

151-
def convert_async_coroutine_fixture(fixturedef, request, node):
210+
def convert_async_coroutine_fixture(ctx, fixturedef, request, node):
152211
"""
153212
Run our async fixture in our event loop and capture the error from
154213
inside the loop.
@@ -161,12 +220,14 @@ def override(extra, *args, **kwargs):
161220

162221
info = {"func": func}
163222
loop = asyncio.get_event_loop_policy().get_event_loop()
164-
return _run_and_raise(loop, info, func, async_runner(func, timeout, info, args, kwargs))
223+
return _run_and_raise(
224+
ctx, loop, info, func, async_runner(func, timeout, info, args, kwargs)
225+
)
165226

166227
fixturedef.func = _wrap(fixturedef, [], override, func)
167228

168229

169-
def convert_async_gen_fixture(fixturedef, request, node):
230+
def convert_async_gen_fixture(ctx, fixturedef, request, node):
170231
"""
171232
Return the yield'd value from the generator and ensure the generator is
172233
finished.
@@ -204,11 +265,12 @@ async def async_finalizer():
204265
else:
205266
info["e"] = ValueError("Async generator fixture should only yield once")
206267

207-
_run_and_raise(loop, info, generator, async_finalizer())
268+
_run_and_raise(ctx, loop, info, generator, async_finalizer())
208269

209270
request.addfinalizer(finalizer)
210271

211272
return _run_and_raise(
273+
ctx,
212274
loop,
213275
info,
214276
generator,

Diff for: alt_pytest_asyncio/plugin.py

+23-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import contextvars
23
import inspect
34
import sys
45
from collections import defaultdict
@@ -19,7 +20,9 @@ def __init__(self, loop=None):
1920
self.own_loop = True
2021
loop = asyncio.new_event_loop()
2122
asyncio.set_event_loop(loop)
23+
2224
self.loop = loop
25+
self.ctx = contextvars.copy_context()
2326

2427
def pytest_configure(self, config):
2528
"""Register our timeout marker which is used to signify async timeouts"""
@@ -57,7 +60,7 @@ def pytest_sessionfinish(self, session, exitstatus):
5760
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
5861
def pytest_fixture_setup(self, fixturedef, request):
5962
"""Convert async fixtures to sync fixtures"""
60-
convert_fixtures(fixturedef, request, request.node)
63+
convert_fixtures(self.ctx, fixturedef, request, request.node)
6164
yield
6265

6366
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
@@ -75,7 +78,25 @@ def pytest_pyfunc_call(self, pyfuncitem):
7578
)
7679

7780
o = pyfuncitem.obj
78-
pyfuncitem.obj = wraps(o)(partial(converted_async_test, self.test_tasks, o, timeout))
81+
pyfuncitem.obj = wraps(o)(
82+
partial(converted_async_test, self.ctx, self.test_tasks, o, timeout)
83+
)
84+
else:
85+
86+
original = pyfuncitem.obj
87+
88+
@wraps(original)
89+
def run_obj(*args, **kwargs):
90+
try:
91+
self.ctx.run(lambda: None)
92+
run = lambda func, *a, **kw: self.ctx.run(func, *a, **kw)
93+
except RuntimeError:
94+
run = lambda func, *a, **kw: func(*a, **kw)
95+
96+
run(original, *args, **kwargs)
97+
98+
pyfuncitem.obj = run_obj
99+
79100
yield
80101

81102

Diff for: pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ tests = [
2525
"nest-asyncio==1.0.0",
2626
"noseOfYeti[black]==2.4.1",
2727
"pytest==7.3.0",
28+
"pytest-order==1.2.1"
2829
]
2930

3031
[project.entry-points.pytest11]

Diff for: tests/code_contextvars/__init__.py

Whitespace-only changes.

Diff for: tests/code_contextvars/contextvars_for_test.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import string
2+
from contextvars import ContextVar
3+
4+
allvars = {}
5+
6+
7+
class Empty:
8+
pass
9+
10+
11+
def assertVarsEmpty(excluding=None):
12+
for letter, var in allvars.items():
13+
if not excluding or letter not in excluding:
14+
assert var.get(Empty) is Empty
15+
16+
17+
for letter in string.ascii_letters:
18+
var = ContextVar(letter)
19+
locals()[letter] = var
20+
allvars[letter] = var
21+
22+
__all__ = ["allvars", "Empty", "assertVarsEmpty"] + sorted(allvars)

Diff for: tests/conftest.py

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import sys
2+
from pathlib import Path
3+
14
import pytest
25

6+
sys.path.append(str(Path(__file__).parent / "code_contextvars"))
7+
38
pytest_plugins = ["pytester"]
49

510

Diff for: tests/test_contextvars/__init__.py

Whitespace-only changes.

Diff for: tests/test_contextvars/conftest.py

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import contextvars_for_test as ctxvars
2+
import pytest
3+
4+
5+
@pytest.fixture(scope="session", autouse=True)
6+
async def a_set_conftest_fixture_session_autouse():
7+
ctxvars.a.set("a_set_conftest_fixture_session_autouse")
8+
9+
10+
@pytest.fixture(scope="session", autouse=True)
11+
async def b_set_conftest_cm_session_autouse():
12+
ctxvars.b.set("b_set_conftest_cm_session_autouse")
13+
yield
14+
15+
16+
@pytest.fixture()
17+
async def c_set_conftest_fixture_test():
18+
ctxvars.c.set("c_set_conftest_fixture_test")
19+
20+
21+
@pytest.fixture()
22+
async def c_set_conftest_cm_test():
23+
token = ctxvars.c.set("c_set_conftest_cm_test")
24+
try:
25+
yield
26+
finally:
27+
ctxvars.c.reset(token)

Diff for: tests/test_contextvars/test_contextvars.py

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# coding: spec
2+
3+
import contextvars_for_test as ctxvars
4+
import pytest
5+
6+
7+
@pytest.fixture(scope="module", autouse=True)
8+
def d_set_conftest_cm_module_autouse():
9+
token = ctxvars.d.set("d_set_conftest_fixture_test")
10+
try:
11+
yield
12+
finally:
13+
ctxvars.d.reset(token)
14+
15+
16+
@pytest.fixture(scope="module", autouse=True)
17+
async def e_set_conftest_cm_test():
18+
assert ctxvars.f.get(ctxvars.Empty) is ctxvars.Empty
19+
ctxvars.e.set("e_set_conftest_cm_module")
20+
yield
21+
assert ctxvars.f.get() == "f_set_conftest_cm_module"
22+
23+
24+
@pytest.fixture(scope="module", autouse=True)
25+
async def f_set_conftest_cm_module(e_set_conftest_cm_test):
26+
assert ctxvars.e.get() == "e_set_conftest_cm_module"
27+
ctxvars.f.set("f_set_conftest_cm_module")
28+
yield
29+
30+
31+
@pytest.mark.order(1)
32+
async it "gets session modified vars":
33+
assert ctxvars.a.get() == "a_set_conftest_fixture_session_autouse"
34+
assert ctxvars.b.get() == "b_set_conftest_cm_session_autouse"
35+
assert ctxvars.d.get() == "d_set_conftest_fixture_test"
36+
assert ctxvars.e.get() == "e_set_conftest_cm_module"
37+
assert ctxvars.f.get() == "f_set_conftest_cm_module"
38+
ctxvars.assertVarsEmpty(excluding=("a", "b", "d", "e", "f"))
39+
40+
41+
@pytest.mark.order(2)
42+
async it "can use a fixture to change the var", c_set_conftest_fixture_test:
43+
assert ctxvars.a.get() == "a_set_conftest_fixture_session_autouse"
44+
assert ctxvars.b.get() == "b_set_conftest_cm_session_autouse"
45+
assert ctxvars.d.get() == "d_set_conftest_fixture_test"
46+
assert ctxvars.e.get() == "e_set_conftest_cm_module"
47+
assert ctxvars.f.get() == "f_set_conftest_cm_module"
48+
49+
assert ctxvars.c.get() == "c_set_conftest_fixture_test"
50+
ctxvars.assertVarsEmpty(excluding=("a", "b", "c", "d", "e", "f"))
51+
52+
53+
@pytest.mark.order(3)
54+
async it "does not reset contextvars for you":
55+
"""
56+
It's too hard to know when a contextvar should be reset. It should
57+
be up to whatever sets the contextvar to know when it should be unset
58+
"""
59+
assert ctxvars.a.get() == "a_set_conftest_fixture_session_autouse"
60+
assert ctxvars.b.get() == "b_set_conftest_cm_session_autouse"
61+
assert ctxvars.d.get() == "d_set_conftest_fixture_test"
62+
assert ctxvars.e.get() == "e_set_conftest_cm_module"
63+
assert ctxvars.f.get() == "f_set_conftest_cm_module"
64+
65+
assert ctxvars.c.get() == "c_set_conftest_fixture_test"
66+
ctxvars.assertVarsEmpty(excluding=("a", "b", "c", "d", "e", "f"))
67+
68+
69+
@pytest.mark.order(4)
70+
async it "works in context manager fixtures", c_set_conftest_cm_test:
71+
"""
72+
It's too hard to know when a contextvar should be reset. It should
73+
be up to whatever sets the contextvar to know when it should be unset
74+
"""
75+
assert ctxvars.a.get() == "a_set_conftest_fixture_session_autouse"
76+
assert ctxvars.b.get() == "b_set_conftest_cm_session_autouse"
77+
assert ctxvars.d.get() == "d_set_conftest_fixture_test"
78+
assert ctxvars.e.get() == "e_set_conftest_cm_module"
79+
assert ctxvars.f.get() == "f_set_conftest_cm_module"
80+
81+
assert ctxvars.c.get() == "c_set_conftest_cm_test"
82+
ctxvars.assertVarsEmpty(excluding=("a", "b", "c", "d", "e", "f"))
83+
84+
85+
@pytest.mark.order(5)
86+
it "resets the contextvar successfully when cm attempts that":
87+
"""
88+
It's too hard to know when a contextvar should be reset. It should
89+
be up to whatever sets the contextvar to know when it should be unset
90+
"""
91+
assert ctxvars.a.get() == "a_set_conftest_fixture_session_autouse"
92+
assert ctxvars.b.get() == "b_set_conftest_cm_session_autouse"
93+
assert ctxvars.d.get() == "d_set_conftest_fixture_test"
94+
assert ctxvars.e.get() == "e_set_conftest_cm_module"
95+
assert ctxvars.f.get() == "f_set_conftest_cm_module"
96+
97+
assert ctxvars.c.get() == "c_set_conftest_fixture_test"
98+
ctxvars.assertVarsEmpty(excluding=("a", "b", "c", "d", "e", "f"))

Diff for: tests/test_contextvars/test_contextvars2.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# coding: spec
2+
3+
import contextvars_for_test as ctxvars
4+
import pytest
5+
6+
7+
@pytest.mark.order(100)
8+
it "only keeps values from other module fixtures if they haven't been cleaned up":
9+
assert ctxvars.a.get() == "a_set_conftest_fixture_session_autouse"
10+
assert ctxvars.b.get() == "b_set_conftest_cm_session_autouse"
11+
assert ctxvars.c.get() == "c_set_conftest_fixture_test"
12+
assert ctxvars.e.get() == "e_set_conftest_cm_module"
13+
assert ctxvars.f.get() == "f_set_conftest_cm_module"
14+
ctxvars.assertVarsEmpty(excluding=("a", "b", "c", "e", "f"))

0 commit comments

Comments
 (0)