diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 2018b84..351e8c1 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -381,3 +381,42 @@ write `stateful tests `__ for Trio-based libraries, then check out `hypothesis-trio `__. + + +.. _trio-run-config: + +Using alternative Trio runners +------------------------------ + +If you are working with a library that provides integration with Trio, +such as via :ref:`guest mode `, it can be used with +pytest-trio as well. Setting ``trio_run`` in the pytest configuration +makes your choice the global default for both tests explicitly marked +with ``@pytest.mark.trio`` and those automatically marked by Trio mode. +``trio_run`` presently supports ``trio`` and ``qtrio``. + +.. code-block:: ini + + # pytest.ini + [pytest] + trio_mode = true + trio_run = qtrio + +.. code-block:: python + + import pytest + + @pytest.mark.trio + async def test(): + assert True + +If you want more granular control or need to use a specific function, +it can be passed directly to the marker. + +.. code-block:: python + + import pytest + + @pytest.mark.trio(run=qtrio.run) + async def test(): + assert True diff --git a/pytest_trio/_tests/helpers.py b/pytest_trio/_tests/helpers.py index 5ae5f9e..3715fa5 100644 --- a/pytest_trio/_tests/helpers.py +++ b/pytest_trio/_tests/helpers.py @@ -5,11 +5,20 @@ def enable_trio_mode_via_pytest_ini(testdir): testdir.makefile(".ini", pytest="[pytest]\ntrio_mode = true\n") +def enable_trio_mode_trio_run_via_pytest_ini(testdir): + testdir.makefile( + ".ini", pytest="[pytest]\ntrio_mode = true\ntrio_run = trio\n" + ) + + def enable_trio_mode_via_conftest_py(testdir): testdir.makeconftest("from pytest_trio.enable_trio_mode import *") enable_trio_mode = pytest.mark.parametrize( - "enable_trio_mode", - [enable_trio_mode_via_pytest_ini, enable_trio_mode_via_conftest_py] + "enable_trio_mode", [ + enable_trio_mode_via_pytest_ini, + enable_trio_mode_trio_run_via_pytest_ini, + enable_trio_mode_via_conftest_py, + ] ) diff --git a/pytest_trio/_tests/test_fixture_mistakes.py b/pytest_trio/_tests/test_fixture_mistakes.py index 5521a80..077861d 100644 --- a/pytest_trio/_tests/test_fixture_mistakes.py +++ b/pytest_trio/_tests/test_fixture_mistakes.py @@ -146,3 +146,28 @@ async def test_whatever(async_fixture): result.assert_outcomes(failed=1) result.stdout.fnmatch_lines(["*async_fixture*cancelled the test*"]) + + +@enable_trio_mode +def test_too_many_clocks(testdir, enable_trio_mode): + enable_trio_mode(testdir) + + testdir.makepyfile( + """ + import pytest + + @pytest.fixture + def extra_clock(mock_clock): + return mock_clock + + async def test_whatever(mock_clock, extra_clock): + pass + """ + ) + + result = testdir.runpytest() + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + ["*ValueError: too many clocks spoil the broth!*"] + ) diff --git a/pytest_trio/_tests/test_trio_mode.py b/pytest_trio/_tests/test_trio_mode.py index efc66a8..f7bf61f 100644 --- a/pytest_trio/_tests/test_trio_mode.py +++ b/pytest_trio/_tests/test_trio_mode.py @@ -36,3 +36,148 @@ def test_trio_mode(testdir, enable_trio_mode): result = testdir.runpytest() result.assert_outcomes(passed=2, failed=2) + + +# This is faking qtrio due to real qtrio's dependence on either +# PyQt5 or PySide2. They are both large and require special +# handling in CI. The testing here is able to focus on the +# pytest-trio features with just this minimal substitute. +qtrio_text = """ +import trio + +fake_used = False + +def run(*args, **kwargs): + global fake_used + fake_used = True + + return trio.run(*args, **kwargs) +""" + + +def test_trio_mode_and_qtrio_run_configuration(testdir): + testdir.makefile( + ".ini", pytest="[pytest]\ntrio_mode = true\ntrio_run = qtrio\n" + ) + + testdir.makepyfile(qtrio=qtrio_text) + + test_text = """ + import qtrio + import trio + + async def test_fake_qtrio_used(): + await trio.sleep(0) + assert qtrio.fake_used + """ + testdir.makepyfile(test_text) + + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_trio_mode_and_qtrio_marker(testdir): + testdir.makefile(".ini", pytest="[pytest]\ntrio_mode = true\n") + + testdir.makepyfile(qtrio=qtrio_text) + + test_text = """ + import pytest + import qtrio + import trio + + @pytest.mark.trio(run=qtrio.run) + async def test_fake_qtrio_used(): + await trio.sleep(0) + assert qtrio.fake_used + """ + testdir.makepyfile(test_text) + + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_qtrio_just_run_configuration(testdir): + testdir.makefile(".ini", pytest="[pytest]\ntrio_run = qtrio\n") + + testdir.makepyfile(qtrio=qtrio_text) + + test_text = """ + import pytest + import qtrio + import trio + + @pytest.mark.trio + async def test_fake_qtrio_used(): + await trio.sleep(0) + assert qtrio.fake_used + """ + testdir.makepyfile(test_text) + + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_invalid_trio_run_fails(testdir): + run_name = "invalid_trio_run" + + testdir.makefile( + ".ini", pytest=f"[pytest]\ntrio_mode = true\ntrio_run = {run_name}\n" + ) + + test_text = """ + async def test(): + pass + """ + testdir.makepyfile(test_text) + + result = testdir.runpytest() + result.assert_outcomes() + result.stdout.fnmatch_lines( + [ + f"*ValueError: {run_name!r} not valid for 'trio_run' config. Must be one of: *" + ] + ) + + +def test_closest_explicit_run_wins(testdir): + testdir.makefile( + ".ini", pytest=f"[pytest]\ntrio_mode = true\ntrio_run = trio\n" + ) + testdir.makepyfile(qtrio=qtrio_text) + + test_text = """ + import pytest + import pytest_trio + import qtrio + + @pytest.mark.trio(run='should be ignored') + @pytest.mark.trio(run=qtrio.run) + async def test(): + assert qtrio.fake_used + """ + testdir.makepyfile(test_text) + + result = testdir.runpytest() + result.assert_outcomes(passed=1) + + +def test_ini_run_wins_with_blank_marker(testdir): + testdir.makefile( + ".ini", pytest=f"[pytest]\ntrio_mode = true\ntrio_run = qtrio\n" + ) + testdir.makepyfile(qtrio=qtrio_text) + + test_text = """ + import pytest + import pytest_trio + import qtrio + + @pytest.mark.trio + async def test(): + assert qtrio.fake_used + """ + testdir.makepyfile(test_text) + + result = testdir.runpytest() + result.assert_outcomes(passed=1) diff --git a/pytest_trio/plugin.py b/pytest_trio/plugin.py index 12e774b..61c4181 100644 --- a/pytest_trio/plugin.py +++ b/pytest_trio/plugin.py @@ -1,4 +1,5 @@ """pytest-trio implementation.""" +from functools import wraps, partial import sys from traceback import format_exception from collections.abc import Coroutine, Generator @@ -7,7 +8,8 @@ import outcome import pytest import trio -from trio.testing import MockClock, trio_test +from trio.abc import Clock, Instrument +from trio.testing import MockClock from async_generator import ( async_generator, yield_, asynccontextmanager, isasyncgen, isasyncgenfunction @@ -39,6 +41,11 @@ def pytest_addoption(parser): type="bool", default=False, ) + parser.addini( + "trio_run", + "what runner should pytest-trio use? [trio, qtrio]", + default="trio", + ) def pytest_configure(config): @@ -307,8 +314,53 @@ async def run(self, test_ctx, contextvars_ctx): raise RuntimeError("too many yields in fixture") +def _trio_test(run): + """Use: + @trio_test + async def test_whatever(): + await ... + + Also: if a pytest fixture is passed in that subclasses the ``Clock`` abc, then + that clock is passed to ``trio.run()``. + """ + + def decorator(fn): + @wraps(fn) + def wrapper(**kwargs): + __tracebackhide__ = True + clocks = [c for c in kwargs.values() if isinstance(c, Clock)] + if not clocks: + clock = None + elif len(clocks) == 1: + clock = clocks[0] + else: + raise ValueError("too many clocks spoil the broth!") + instruments = [ + i for i in kwargs.values() if isinstance(i, Instrument) + ] + return run( + partial(fn, **kwargs), clock=clock, instruments=instruments + ) + + return wrapper + + return decorator + + def _trio_test_runner_factory(item, testfunc=None): - testfunc = testfunc or item.obj + if testfunc: + run = trio.run + else: + testfunc = item.obj + + for marker in item.iter_markers("trio"): + maybe_run = marker.kwargs.get('run') + if maybe_run is not None: + run = maybe_run + break + else: + # no marker found that explicitly specifiers the runner so use config + run = choose_run(config=item.config) if getattr(testfunc, '_trio_test_runner_wrapped', False): # We have already wrapped this, perhaps because we combined Hypothesis @@ -320,7 +372,7 @@ def _trio_test_runner_factory(item, testfunc=None): 'test function `%r` is marked trio but is not async' % item ) - @trio_test + @_trio_test(run=run) async def _bootstrap_fixtures_and_run_test(**kwargs): __tracebackhide__ = True @@ -438,19 +490,36 @@ def pytest_fixture_setup(fixturedef, request): ################################################################ -def automark(items): +def automark(items, run=trio.run): for item in items: if hasattr(item.obj, "hypothesis"): test_func = item.obj.hypothesis.inner_test else: test_func = item.obj if iscoroutinefunction(test_func): - item.add_marker(pytest.mark.trio) + item.add_marker(pytest.mark.trio(run=run)) + + +def choose_run(config): + run_string = config.getini("trio_run") + + if run_string == "trio": + run = trio.run + elif run_string == "qtrio": + import qtrio + run = qtrio.run + else: + raise ValueError( + f"{run_string!r} not valid for 'trio_run' config." + + " Must be one of: trio, qtrio" + ) + + return run def pytest_collection_modifyitems(config, items): if config.getini("trio_mode"): - automark(items) + automark(items, run=choose_run(config=config)) ################################################################