├── .drone.yml ├── .gitignore ├── README.rst ├── examples ├── conftest.py ├── functions │ ├── __init__.py │ ├── test_concurrent.py │ └── test_regular.py └── methods │ ├── __init__.py │ ├── test_concurrent.py │ └── test_regular.py ├── pytest_yield ├── __init__.py ├── fixtures.py ├── mark.py ├── newhooks.py ├── plugin.py └── runner.py ├── setup.cfg └── setup.py /.drone.yml: -------------------------------------------------------------------------------- 1 | workspace: 2 | base: /app 3 | path: . 4 | 5 | pipeline: 6 | build-2.7.14: 7 | image: python:2.7.14 8 | commands: 9 | - pip install flake8 10 | - pip install -e . 11 | - flake8 12 | - pytest examples/ 13 | build-3.7.1: 14 | image: python:3.7.1 15 | commands: 16 | - pip install flake8 17 | - pip install -e . 18 | - flake8 19 | - pytest examples/ 20 | pypi-publish: 21 | image: python:2.7.14 22 | secrets: [username, password] 23 | commands: 24 | - git fetch --tags 25 | - pip install twine 26 | - python setup.py sdist 27 | - twine upload dist/pytest-yield-${DRONE_TAG}.zip -u $username -p $password 28 | when: 29 | event: tag -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .idea/ 3 | .eggs/ 4 | __pycache__/ 5 | .pytest_cache/ 6 | pytest_yield.egg-info/ 7 | venv/ 8 | *.pyc -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | 5 | .. start-badges 6 | 7 | .. list-table:: 8 | :stub-columns: 1 9 | 10 | * - tests 11 | - | |drone| 12 | * - package 13 | - | |version| |supported-versions| |supported-implementations| 14 | 15 | .. |drone| image:: https://cloud.drone.io/api/badges/devova/pytest-yield/status.svg 16 | :alt: Drone-CI Build Status 17 | :target: https://cloud.drone.io/devova/pytest-yield 18 | 19 | 20 | .. |version| image:: https://img.shields.io/pypi/v/pytest-yield.svg 21 | :alt: PyPI Package latest release 22 | :target: https://pypi.python.org/pypi/pytest-yield 23 | 24 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/pytest-yield.svg 25 | :alt: Supported versions 26 | :target: https://pypi.python.org/pypi/pytest-yield 27 | 28 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/pytest-yield.svg 29 | :alt: Supported implementations 30 | :target: https://pypi.python.org/pypi/pytest-cov 31 | 32 | .. end-badges 33 | 34 | What? 35 | ~~~~~ 36 | 37 | **pytest\_yield** is a plugin that allows run tests as coroutines. This 38 | means that a few tests can being executing at same time. 39 | 40 | Why? 41 | ~~~~ 42 | 43 | This is first question that engineers asking. General theory said us 44 | that each test have to be run separetelly and independently, meaning 45 | without any influence on other tests. This plugin breaks this rules at 46 | all. 47 | 48 | So why do we need it? 49 | 50 | Imagine we have integration tests where each test execution takes very 51 | long time. For examle test should wait for some reactions depend on 52 | initial actions. This waiting could take up to e.g. 1 hour. And even 53 | after it we need perform next action from scenario and wait more. 54 | Syncronous execution of all tests, one by one, will take huge amout of 55 | time. But what if all test cases are independent, so actions of *test1* 56 | does not influence results of *test2*. Than make sense some how skip 57 | waiting prosess of *test1* and switch execution context to *test2*. This 58 | actually what **pytest\_yield** doing. 59 | 60 | How? 61 | ~~~~ 62 | 63 | Each concurrent test is suppose to be a generator. Switching of 64 | execution context is performed after each ``yield``. Test add itself to 65 | the end of a deueue if generator is not exausted yet. After new one is 66 | pulled from left side of dequeue. Assume test have ``N`` yields, tahn it 67 | will be ``N`` times rescheduled. 68 | 69 | |image2| 70 | 71 | Do not use with 72 | ~~~~~~~~~~~~~~~ 73 | 74 | Tests that are cross dependent. Most 75 | particular example is unittests with mocks, if *test1* mock some method, 76 | this will be implicitly mocked in *test2* also. 77 | 78 | .. |image2| image:: https://raw.githubusercontent.com/devova/pytest-yield/b0c7aa058df5f50cb9a05272fce01fc62a78bbee/how-it-works-pytest-yield.svg?sanitize=true 79 | -------------------------------------------------------------------------------- /examples/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import time 3 | 4 | from collections import defaultdict 5 | 6 | pytest_plugins = ['pytest_yield'] 7 | 8 | 9 | class CallCounter(object): 10 | def __init__(self): 11 | self.count = defaultdict(lambda: 0) 12 | 13 | def incr(self, lvl): 14 | self.count[lvl] += 1 15 | 16 | def decr(self, lvl): 17 | self.count[lvl] -= 1 18 | 19 | 20 | @pytest.fixture(autouse=True) 21 | def one(): 22 | return 1 23 | 24 | 25 | @pytest.fixture(autouse=True) 26 | def two(): 27 | return 2 28 | 29 | 30 | @pytest.fixture(scope='session') 31 | def call_counter(request): 32 | counter = CallCounter() 33 | counter.incr('session') 34 | yield counter 35 | counter.decr('session') 36 | assert counter.count['function'] == 0 37 | assert counter.count['class'] == 0 38 | assert counter.count['module'] == 0 39 | 40 | 41 | @pytest.fixture(scope='module') 42 | def check_teardown_module(call_counter): 43 | call_counter.incr('module') 44 | yield 45 | call_counter.decr('module') 46 | 47 | 48 | @pytest.fixture(scope='class') 49 | def check_teardown_class(call_counter): 50 | call_counter.incr('class') 51 | yield 52 | call_counter.decr('class') 53 | 54 | 55 | @pytest.fixture(autouse=True) 56 | def check_teardown_function(call_counter): 57 | call_counter.incr('function') 58 | yield 59 | call_counter.decr('function') 60 | 61 | 62 | def pytest_round_finished(): 63 | time.sleep(1) 64 | -------------------------------------------------------------------------------- /examples/functions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devova/pytest-yield/a89200a9efa57b503a1c3c5ea58088376d5cac5c/examples/functions/__init__.py -------------------------------------------------------------------------------- /examples/functions/test_concurrent.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest_yield.mark import Report 3 | 4 | pytestmark = pytest.mark.usefixtures('check_teardown_module') 5 | 6 | 7 | def sub_generator(num): 8 | for x in range(num): 9 | yield x + 1 10 | 11 | 12 | @pytest.mark.skip(reason="Skip this test") 13 | @pytest.mark.concurrent 14 | def test_skip_concurrent(): 15 | yield 16 | assert 1 == 2 17 | 18 | 19 | @pytest.mark.concurrent 20 | def test_concurrent(one): 21 | yield 22 | assert one == 1 23 | 24 | 25 | @pytest.mark.parametrize('v', [1, 2]) 26 | @pytest.mark.concurrent 27 | def test_concurrent_with_param(one, v): 28 | assert one + v == 1 + v 29 | yield 30 | 31 | 32 | @pytest.mark.concurrent 33 | def test_concurrent_with_report(one, two): 34 | yield Report("Hello World") 35 | assert one + two == 3 36 | yield "Hello Worl2" 37 | 38 | 39 | @pytest.mark.concurrent 40 | def test_concurrent_with_sub_generator(one): 41 | number = yield sub_generator(2) 42 | assert one == 1 43 | assert number == 2 44 | 45 | 46 | @pytest.mark.xfail 47 | @pytest.mark.concurrent 48 | def test_concurrent_xfail(two): 49 | yield 50 | assert two == 1 51 | -------------------------------------------------------------------------------- /examples/functions/test_regular.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytestmark = pytest.mark.usefixtures('check_teardown_module') 4 | 5 | 6 | @pytest.mark.skip(reason="Skip this test") 7 | def test_skip_regular(): 8 | assert 1 == 2 9 | 10 | 11 | def test_regular(one): 12 | assert one == 1 13 | 14 | 15 | @pytest.mark.parametrize('v', [1, 2]) 16 | def test_regular_with_param(one, v): 17 | assert one + v == 1 + v 18 | 19 | 20 | @pytest.mark.xfail 21 | def test_regular_xfail(two): 22 | assert two == 1 23 | -------------------------------------------------------------------------------- /examples/methods/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devova/pytest-yield/a89200a9efa57b503a1c3c5ea58088376d5cac5c/examples/methods/__init__.py -------------------------------------------------------------------------------- /examples/methods/test_concurrent.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pytest_yield.mark import Report 4 | 5 | pytestmark = pytest.mark.usefixtures('check_teardown_module') 6 | 7 | 8 | def sub_generator(num): 9 | for x in range(num): 10 | yield x + 1 11 | 12 | 13 | @pytest.mark.usefixtures('check_teardown_class') 14 | class TestFirstRegular(object): 15 | 16 | @pytest.mark.skip(reason="Skip this test") 17 | @pytest.mark.concurrent 18 | def test_skip_concurrent(self): 19 | yield 20 | assert 1 == 2 21 | 22 | @pytest.mark.concurrent 23 | def test_concurrent(self, one): 24 | yield 25 | assert one == 1 26 | 27 | @pytest.mark.parametrize('v', [1, 2]) 28 | @pytest.mark.concurrent 29 | def test_concurrent_with_param(self, one, v): 30 | assert one + v == 1 + v 31 | yield 32 | 33 | @pytest.mark.concurrent 34 | def test_concurrent_with_report(self, one, two): 35 | yield Report("Hello World") 36 | assert one + two == 3 37 | yield "Hello Worl2" 38 | 39 | @pytest.mark.concurrent 40 | def test_concurrent_with_sub_generator(self, one): 41 | number = yield sub_generator(2) 42 | assert one == 1 43 | assert number == 2 44 | 45 | @pytest.mark.xfail 46 | @pytest.mark.concurrent 47 | def test_concurrent_xfail(self, two): 48 | yield 49 | assert two == 1 50 | 51 | 52 | @pytest.mark.usefixtures('check_teardown_class') 53 | class TestSecondRegular(TestFirstRegular): 54 | pass 55 | -------------------------------------------------------------------------------- /examples/methods/test_regular.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytestmark = pytest.mark.usefixtures('check_teardown_module') 4 | 5 | 6 | @pytest.mark.usefixtures('check_teardown_class') 7 | class TestFirstRegular(object): 8 | 9 | @pytest.mark.skip(reason="Skip this test") 10 | def test_skip(self): 11 | assert 1 == 2 12 | 13 | def test_regular(self, one): 14 | assert one == 1 15 | 16 | @pytest.mark.parametrize('v', [1, 2]) 17 | def test_regular_with_param(self, one, v): 18 | assert one + v == 1 + v 19 | 20 | @pytest.mark.xfail 21 | def test_regular_xfail(two): 22 | assert two == 1 23 | 24 | 25 | @pytest.mark.usefixtures('check_teardown_class') 26 | class TestSecondRegular(TestFirstRegular): 27 | pass 28 | -------------------------------------------------------------------------------- /pytest_yield/__init__.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import get_distribution, DistributionNotFound 2 | try: 3 | __version__ = get_distribution(__name__).version 4 | except DistributionNotFound: 5 | # package is not installed 6 | pass 7 | -------------------------------------------------------------------------------- /pytest_yield/fixtures.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import functools 3 | import py 4 | import sys 5 | 6 | from _pytest.compat import NOTSET, getlocation, exc_clear 7 | from _pytest.fixtures import FixtureDef, FixtureRequest, scopes, SubRequest 8 | from pytest import fail 9 | 10 | 11 | class YieldFixtureDef(FixtureDef): 12 | 13 | @staticmethod 14 | def finish(self, request): 15 | exceptions = [] 16 | try: 17 | _finalizers = getattr(self, '_finalizers_per_item', {}).get( 18 | request.node, self._finalizers) 19 | while _finalizers: 20 | try: 21 | func = _finalizers.pop() 22 | func() 23 | except: # noqa 24 | exceptions.append(sys.exc_info()) 25 | if exceptions: 26 | e = exceptions[0] 27 | del exceptions # ensure we don't keep all frames alive because of the traceback 28 | py.builtin._reraise(*e) 29 | 30 | finally: 31 | hook = self._fixturemanager.session.gethookproxy(request.node.fspath) 32 | hook.pytest_fixture_post_finalizer(fixturedef=self, request=request) 33 | # even if finalization fails, we invalidate 34 | # the cached fixture value and remove 35 | # all finalizers because they may be bound methods which will 36 | # keep instances alive 37 | if hasattr(self, "cached_result"): 38 | del self.cached_result 39 | del _finalizers[:] 40 | 41 | @staticmethod 42 | def addfinalizer(self, finalizer, colitem=None): 43 | if colitem: 44 | if not hasattr(self, '_finalizers_per_item'): 45 | self._finalizers_per_item = {} 46 | self._finalizers_per_item.setdefault(colitem, []).append(finalizer) 47 | else: 48 | self._finalizers.append(finalizer) 49 | 50 | @staticmethod 51 | def execute(self, request): 52 | # get required arguments and register our own finish() 53 | # with their finalization 54 | for argname in self.argnames: 55 | fixturedef = request._get_active_fixturedef(argname) 56 | if argname != "request": 57 | fixturedef.addfinalizer( 58 | functools.partial(self.finish, request=request), colitem=request.node) 59 | 60 | my_cache_key = request.param_index 61 | cached_result = getattr(self, "cached_result", None) 62 | if cached_result is not None: 63 | result, cache_key, err = cached_result 64 | if my_cache_key == cache_key: 65 | if err is not None: 66 | py.builtin._reraise(*err) 67 | else: 68 | return result 69 | # we have a previous but differently parametrized fixture instance 70 | # so we need to tear it down before creating a new one 71 | self.finish(request) 72 | assert not hasattr(self, "cached_result") 73 | 74 | hook = self._fixturemanager.session.gethookproxy(request.node.fspath) 75 | return hook.pytest_fixture_setup(fixturedef=self, request=request) 76 | 77 | 78 | class CachedResultStore(object): 79 | def cached_store_for_function(self): 80 | return self 81 | 82 | def cached_store_for_class(self): 83 | return self.node.cls 84 | 85 | def cached_store_for_module(self): 86 | return self.node.module 87 | 88 | def cached_store_for_session(self): 89 | return self.node.session 90 | 91 | def _compute_fixture_value(self, fixturedef): 92 | """ 93 | Creates a SubRequest based on "self" and calls the execute method of the given 94 | fixturedef object. This will force the FixtureDef object to throw away any previous results 95 | and compute a new fixture value, which will be stored into the FixtureDef object itself. 96 | 97 | :param FixtureDef fixturedef: 98 | """ 99 | # prepare a subrequest object before calling fixture function 100 | # (latter managed by fixturedef) 101 | argname = fixturedef.argname 102 | funcitem = self._pyfuncitem 103 | scope = fixturedef.scope 104 | try: 105 | param = funcitem.callspec.getparam(argname) 106 | except (AttributeError, ValueError): 107 | param = NOTSET 108 | param_index = 0 109 | if fixturedef.params is not None: 110 | frame = inspect.stack()[3] 111 | frameinfo = inspect.getframeinfo(frame[0]) 112 | source_path = frameinfo.filename 113 | source_lineno = frameinfo.lineno 114 | source_path = py.path.local(source_path) 115 | if source_path.relto(funcitem.config.rootdir): 116 | source_path = source_path.relto(funcitem.config.rootdir) 117 | msg = ( 118 | "The requested fixture has no parameter defined for the " 119 | "current test.\n\nRequested fixture '{0}' defined in:\n{1}" 120 | "\n\nRequested here:\n{2}:{3}".format( 121 | fixturedef.argname, 122 | getlocation(fixturedef.func, funcitem.config.rootdir), 123 | source_path, 124 | source_lineno, 125 | ) 126 | ) 127 | fail(msg) 128 | else: 129 | # indices might not be set if old-style metafunc.addcall() was used 130 | param_index = funcitem.callspec.indices.get(argname, 0) 131 | # if a parametrize invocation set a scope it will override 132 | # the static scope defined with the fixture function 133 | paramscopenum = funcitem.callspec._arg2scopenum.get(argname) 134 | if paramscopenum is not None: 135 | scope = scopes[paramscopenum] 136 | 137 | subrequest = YieldSubRequest(self, scope, param, param_index, fixturedef) 138 | 139 | # check if a higher-level scoped fixture accesses a lower level one 140 | subrequest._check_scope(argname, self.scope, scope) 141 | 142 | # clear sys.exc_info before invoking the fixture (python bug?) 143 | # if its not explicitly cleared it will leak into the call 144 | exc_clear() 145 | 146 | try: 147 | # call the fixture function 148 | cache_store = getattr( 149 | self, 'cached_store_for_%s' % scope, lambda: None)() 150 | if cache_store and not hasattr(cache_store, '_fixturedef_cached_results'): 151 | cache_store._fixturedef_cached_results = dict() 152 | if hasattr(fixturedef, 'cached_result'): 153 | fixturedef_cached_result = cache_store._fixturedef_cached_results.get(argname) 154 | if fixturedef_cached_result: 155 | fixturedef.cached_result = fixturedef_cached_result 156 | else: 157 | del fixturedef.cached_result 158 | fixturedef.execute(request=subrequest) 159 | finally: 160 | # if fixture function failed it might have registered finalizers 161 | self.session._setupstate.addfinalizer( 162 | functools.partial( 163 | fixturedef.finish, request=subrequest), 164 | subrequest.node) 165 | cached_result = getattr(fixturedef, 'cached_result', None) 166 | if cache_store and cached_result: 167 | cache_store._fixturedef_cached_results[argname] = cached_result 168 | 169 | 170 | class YieldSubRequest(CachedResultStore, SubRequest): 171 | 172 | def __init__(self, *args, **kwargs): 173 | super(YieldSubRequest, self).__init__(*args, **kwargs) 174 | self._fixturedef_finalizers = [] 175 | 176 | def addfinalizer(self, finalizer): 177 | self._fixturedef_finalizers.append(finalizer) 178 | 179 | 180 | class YieldFixtureRequest(CachedResultStore, FixtureRequest): 181 | pass 182 | -------------------------------------------------------------------------------- /pytest_yield/mark.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | concurrent = pytest.mark.concurrent 5 | 6 | 7 | class Report(str): 8 | pass 9 | -------------------------------------------------------------------------------- /pytest_yield/newhooks.py: -------------------------------------------------------------------------------- 1 | def pytest_round_finished(): 2 | pass 3 | -------------------------------------------------------------------------------- /pytest_yield/plugin.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import six 3 | import py 4 | import pytest 5 | from _pytest.compat import get_real_func 6 | 7 | from _pytest.runner import ( 8 | call_and_report, call_runtest_hook, 9 | check_interactive_exception, show_test_item, 10 | TestReport) 11 | from _pytest.python import Generator 12 | 13 | from collections import deque 14 | from .mark import Report 15 | from pytest_yield.fixtures import YieldFixtureRequest, YieldFixtureDef 16 | from pytest_yield.runner import YieldSetupState 17 | 18 | 19 | if pytest.__version__ > '3.4': 20 | @pytest.mark.trylast 21 | def pytest_configure(config): 22 | from . import newhooks 23 | config.pluginmanager.add_hookspecs(newhooks) 24 | fixture_def = config.pluginmanager.get_plugin('fixtures').FixtureDef 25 | fixture_def.finish = YieldFixtureDef.finish 26 | fixture_def.addfinalizer = YieldFixtureDef.addfinalizer 27 | fixture_def.execute = YieldFixtureDef.execute 28 | else: 29 | @pytest.mark.trylast 30 | def pytest_addhooks(pluginmanager): 31 | from . import newhooks 32 | pluginmanager.add_hookspecs(newhooks) 33 | pluginmanager.get_plugin('fixtures').FixtureDef.finish = YieldFixtureDef.finish 34 | pluginmanager.get_plugin('fixtures').FixtureDef.addfinalizer = YieldFixtureDef.addfinalizer 35 | pluginmanager.get_plugin('fixtures').FixtureDef.execute = YieldFixtureDef.execute 36 | 37 | 38 | @pytest.mark.trylast 39 | def pytest_sessionstart(session): 40 | session._setupstate = YieldSetupState() 41 | 42 | 43 | @pytest.hookimpl(hookwrapper=True) 44 | def pytest_pycollect_makeitem(collector, name, obj): 45 | outcome = yield 46 | item = outcome.get_result() 47 | 48 | concurrent_mark = getattr(obj, 'concurrent', None) 49 | if not concurrent_mark: 50 | concurrent_mark = getattr(obj, 'async', None) 51 | 52 | if concurrent_mark: 53 | if isinstance(item, Generator): 54 | obj = get_real_func(obj) 55 | items = list(collector._genfunctions(name, obj)) 56 | outcome.force_result(items) 57 | else: 58 | raise Exception( 59 | 'Attempt to set `concurrent` mark for non generator %s' % name) 60 | 61 | 62 | @pytest.mark.trylast 63 | def pytest_collection_modifyitems(items): 64 | items_dict = {item.name: item for item in items} 65 | for item in items: 66 | item._request = YieldFixtureRequest(item) 67 | item_chain = item.listchain() 68 | session = item_chain[0] 69 | session._setupstate.collection_stack.add_nested(item_chain) 70 | 71 | if pytest.__version__ >= '3.6': 72 | concurrent_mark = item.get_closest_marker('concurrent') 73 | else: 74 | concurrent_mark = getattr(item.obj, 'concurrent', None) 75 | if not concurrent_mark: 76 | if pytest.__version__ >= '3.6': 77 | concurrent_mark = item.get_closest_marker('async') 78 | else: 79 | concurrent_mark = getattr(item.obj, 'async', None) 80 | 81 | item.is_concurrent = concurrent_mark or False 82 | item.was_already_run = False 83 | item.was_finished = False 84 | if item.is_concurrent and 'upstream' in concurrent_mark.kwargs: 85 | upstream_name = concurrent_mark.kwargs['upstream'] 86 | if upstream_name in items_dict: 87 | item.upstream = items_dict[upstream_name] 88 | else: 89 | # someone did a mistake in name 90 | # lets figure out is there any parametrized tests 91 | msg = '\nCould not find upstream test with name `%s`.\n' % \ 92 | upstream_name 93 | for potential_upstream_name in items_dict.keys(): 94 | if potential_upstream_name.startswith(upstream_name): 95 | msg += 'Maybe you want to specify `%s`?\n' % \ 96 | potential_upstream_name 97 | break 98 | hook = item.ihook 99 | report = TestReport( 100 | item.nodeid, item.location, 101 | {}, 'failed', msg, 'configure', 102 | [], 0) 103 | hook.pytest_runtest_logreport(report=report) 104 | item.session.shouldstop = True 105 | 106 | if item.is_concurrent and 'downstream' in concurrent_mark.kwargs: 107 | downstream_name = concurrent_mark.kwargs['downstream'] 108 | if downstream_name in items_dict: 109 | items_dict[downstream_name].upstream = item 110 | else: 111 | # someone did a mistake in name 112 | # lets figure out is there any parametrized tests 113 | msg = '\nCould not find downstream test with name `%s`.\n' % \ 114 | downstream_name 115 | for potential_downstream_name in items_dict.keys(): 116 | if potential_downstream_name.startswith(downstream_name): 117 | msg += 'Maybe you want to specify `%s`?\n' % \ 118 | potential_downstream_name 119 | break 120 | hook = item.ihook 121 | report = TestReport( 122 | item.nodeid, item.location, 123 | {}, 'failed', msg, 'configure', 124 | [], 0) 125 | hook.pytest_runtest_logreport(report=report) 126 | item.session.shouldstop = True 127 | 128 | 129 | def pytest_runtestloop(session): 130 | if (session.testsfailed and 131 | not session.config.option.continue_on_collection_errors): 132 | raise session.Interrupted( 133 | "%d errors during collection" % session.testsfailed) 134 | 135 | if session.config.option.collectonly: 136 | return True 137 | has_items = len(session.items) > 0 138 | items = deque(session.items) 139 | if has_items: 140 | items[-1].last_in_round = True 141 | while has_items: 142 | try: 143 | item = items.popleft() 144 | upstream_item = getattr(item, 'upstream', None) 145 | if upstream_item and not upstream_item.was_finished: 146 | items.append(item) 147 | maybe_last_in_round(item, items) 148 | continue 149 | nextitem = items[0] if len(items) > 0 else None 150 | item.config.hook.pytest_runtest_protocol(item=item, nextitem=nextitem) 151 | if session.shouldstop: 152 | raise session.Interrupted(session.shouldstop) 153 | if item.is_concurrent and not item.was_finished: 154 | items.append(item) 155 | maybe_last_in_round(item, items) 156 | except IndexError: 157 | has_items = False 158 | return True 159 | 160 | 161 | def maybe_last_in_round(item, items): 162 | if getattr(item, 'last_in_round', False): 163 | item.config.hook.pytest_round_finished() 164 | delattr(item, 'last_in_round') 165 | if len(items) > 0: 166 | items[-1].last_in_round = True 167 | 168 | 169 | def pytest_runtest_protocol(item, nextitem): 170 | if item.is_concurrent: 171 | if not item.was_already_run: 172 | item.ihook.pytest_runtest_logstart( 173 | nodeid=item.nodeid, location=item.location, 174 | ) 175 | result = yieldtestprotocol(item, nextitem=nextitem) 176 | try: 177 | if item.was_finished: 178 | item.ihook.pytest_runtest_logfinish( 179 | nodeid=item.nodeid, location=item.location, 180 | ) 181 | except AttributeError: # compatibilyty with pytest==3.0 182 | pass 183 | return result 184 | 185 | 186 | def yieldtestprotocol(item, log=True, nextitem=None): 187 | hasrequest = hasattr(item, "_request") 188 | result = True 189 | if hasrequest and not item._request: 190 | item._initrequest() 191 | if not item.was_already_run: 192 | rep = call_and_report(item, "setup", log) 193 | if not rep.passed: 194 | item.was_finished = True 195 | if rep.passed and item.config.option.setupshow: 196 | show_test_item(item) 197 | if item.was_already_run or rep.passed: 198 | if not item.config.option.setuponly: 199 | result = yield_and_report(item, "call", log) 200 | if item.was_finished: 201 | call_and_report(item, "teardown", log, nextitem=nextitem) 202 | if hasrequest: 203 | item._request = False 204 | item.funcargs = None 205 | return result 206 | 207 | 208 | def yield_and_report(item, when, log=True, **kwds): 209 | call = call_runtest_hook(item, when, **kwds) 210 | call.when = 'yield' 211 | hook = item.ihook 212 | report = hook.pytest_runtest_makereport(item=item, call=call) 213 | report.call_result = getattr(item, 'call_result', None) 214 | if not item.was_finished and report.passed and not isinstance(report.call_result, Report): 215 | log = False 216 | if log: 217 | hook.pytest_runtest_logreport(report=report) 218 | if check_interactive_exception(call, report): 219 | hook.pytest_exception_interact(node=item, call=call, report=report) 220 | return report 221 | 222 | 223 | def pytest_report_teststatus(report): 224 | if report.when == "yield" and report.passed and \ 225 | isinstance(report.call_result, Report): 226 | letter = 'y' 227 | word = report.call_result 228 | return report.outcome, letter, word 229 | 230 | 231 | @pytest.hookimpl(hookwrapper=True) 232 | def pytest_runtest_call(item): 233 | yield 234 | if not item.is_concurrent: 235 | item.was_finished = True 236 | 237 | 238 | def init_generator(pyfuncitem): 239 | testfunction = pyfuncitem.obj 240 | if pyfuncitem._isyieldedfunction(): 241 | res = testfunction(*pyfuncitem._args) 242 | else: 243 | funcargs = pyfuncitem.funcargs 244 | testargs = {} 245 | for arg in pyfuncitem._fixtureinfo.argnames: 246 | testargs[arg] = funcargs[arg] 247 | res = testfunction(**testargs) 248 | return res 249 | 250 | 251 | def pytest_pyfunc_call(pyfuncitem): 252 | if pyfuncitem.is_concurrent: 253 | if not pyfuncitem.was_already_run: 254 | pyfuncitem._concurrent_stack = [init_generator(pyfuncitem)] 255 | pyfuncitem.was_already_run = True 256 | try: 257 | pyfuncitem._concurrent_res = six.next(pyfuncitem._concurrent_stack[-1]) 258 | except Exception: 259 | pyfuncitem.was_finished = True 260 | if hasattr(pyfuncitem, '_concurrent_res'): 261 | del pyfuncitem._concurrent_res 262 | raise 263 | return 264 | try: 265 | if hasattr(pyfuncitem._concurrent_res, 'next') or hasattr( 266 | pyfuncitem._concurrent_res, '__next__'): 267 | pyfuncitem._concurrent_stack.append(pyfuncitem._concurrent_res) 268 | pyfuncitem._concurrent_res = six.next(pyfuncitem._concurrent_stack[-1]) 269 | else: 270 | pyfuncitem._concurrent_res = pyfuncitem._concurrent_stack[-1].send( 271 | pyfuncitem._concurrent_res) 272 | except StopIteration: 273 | pyfuncitem._concurrent_stack.pop() 274 | if len(pyfuncitem._concurrent_stack) == 0: 275 | pyfuncitem.was_finished = True 276 | del pyfuncitem._concurrent_res 277 | except Exception: 278 | pyfuncitem.was_finished = True 279 | del pyfuncitem._concurrent_res 280 | raise 281 | pyfuncitem.call_result = getattr(pyfuncitem, '_concurrent_res', None) 282 | return pyfuncitem.call_result 283 | 284 | 285 | def pytest_fixture_post_finalizer(fixturedef, request): 286 | exceptions = [] 287 | try: 288 | while getattr(request, '_fixturedef_finalizers', None): 289 | try: 290 | func = request._fixturedef_finalizers.pop() 291 | func() 292 | except: # noqa 293 | exceptions.append(sys.exc_info()) 294 | if exceptions: 295 | e = exceptions[0] 296 | del exceptions # ensure we don't keep all frames alive because of the traceback 297 | py.builtin._reraise(*e) 298 | 299 | finally: 300 | pass 301 | 302 | 303 | def pytest_round_finished(): 304 | pass 305 | -------------------------------------------------------------------------------- /pytest_yield/runner.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import py 3 | 4 | from _pytest.outcomes import TEST_OUTCOME 5 | from _pytest.runner import SetupState 6 | 7 | 8 | class TreeStack(dict): 9 | 10 | def __missing__(self, key): 11 | value = self[key] = type(self)() 12 | return value 13 | 14 | def __contains__(self, item): 15 | contains = dict.__contains__(self, item) 16 | if not contains: 17 | for val in self.values(): 18 | contains = item in val 19 | if contains: 20 | break 21 | return contains 22 | 23 | def add_nested(self, keys): 24 | d = self 25 | added = [] 26 | for key in keys: 27 | if not dict.__contains__(d, key): 28 | added.append(key) 29 | d = d[key] 30 | return added 31 | 32 | def flat(self): 33 | res = list(self.keys()) 34 | for val in self.values(): 35 | res += val.flat() 36 | return list(res) 37 | 38 | def pop(self, key): 39 | contains = dict.__contains__(self, key) 40 | if contains: 41 | return dict.pop(self, key) 42 | else: 43 | for val in self.values(): 44 | res = val.pop(key) 45 | if res is not None: 46 | return res 47 | 48 | def get(self, key): 49 | contains = dict.__contains__(self, key) 50 | if contains: 51 | return dict.get(self, key) 52 | else: 53 | for val in self.values(): 54 | res = val.get(key) 55 | if res is not None: 56 | return res 57 | 58 | def popitem(self): 59 | for val in self.values(): 60 | if val: 61 | return val.popitem() 62 | return dict.popitem(self) 63 | 64 | 65 | class YieldSetupState(SetupState): 66 | 67 | def __init__(self): 68 | super(YieldSetupState, self).__init__() 69 | self.stack = TreeStack() 70 | self.collection_stack = TreeStack() 71 | 72 | def _teardown_towards(self, needed_collectors): 73 | return 74 | 75 | def _pop_and_teardown(self): 76 | colitem, _ = self.stack.popitem() 77 | self._teardown_with_finalization(colitem) 78 | 79 | def teardown_exact(self, item, nextitem): 80 | self._teardown_with_finalization(item) 81 | self.stack.pop(item) 82 | self.collection_stack.pop(item) 83 | items_on_same_lvl = self.collection_stack.get(item.parent) 84 | if items_on_same_lvl is not None and len(items_on_same_lvl) == 0: 85 | self.teardown_exact(item.parent, None) 86 | 87 | def prepare(self, colitem): 88 | """ setup objects along the collector chain to the test-method 89 | and teardown previously setup objects.""" 90 | needed_collectors = colitem.listchain() 91 | 92 | # check if the last collection node has raised an error 93 | for col in self.stack.flat(): 94 | if hasattr(col, '_prepare_exc'): 95 | py.builtin._reraise(*col._prepare_exc) 96 | 97 | added_to_stack = self.stack.add_nested(needed_collectors) 98 | for col in added_to_stack: 99 | try: 100 | col.setup() 101 | except TEST_OUTCOME: 102 | col._prepare_exc = sys.exc_info() 103 | raise 104 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [sdist] 2 | formats=zip 3 | [metadata] 4 | description-file = README.md 5 | [flake8] 6 | ignore = E124,E201,E202,E225,E128,E226,W504,W601,E265,E731,E127 7 | max-line-length=100 8 | exclude = build,venv,venv3,.eggs 9 | jobs=auto -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup( 6 | name='pytest-yield', 7 | packages=['pytest_yield'], 8 | description='PyTest plugin to run tests concurrently, each `yield` switch context to other one', 9 | long_description=open("README.rst").read(), 10 | author='Volodymyr Trotsyshyn', 11 | author_email='devova@gmail.com', 12 | use_2to3=True, 13 | url='https://github.com/devova/pytest-yield', 14 | py_modules=['pytest_yield'], 15 | install_requires=["pytest>=3.0,<4.1"], 16 | keywords=['testing', 'pytest'], 17 | classifiers=[ 18 | 'Programming Language :: Python :: 2.7', 19 | 'Programming Language :: Python :: 3.7', 20 | ], 21 | setup_requires=[ 22 | 'setuptools_scm', 23 | ], 24 | use_scm_version={'root': '.', 'relative_to': __file__}, 25 | entry_points={ 26 | 'pytest11': [ 27 | 'yield = pytest_yield.plugin', 28 | ] 29 | } 30 | ) 31 | --------------------------------------------------------------------------------