Merge pull request #8219 from bluetech/setupstate-refactor

runner: refactor SetupState
This commit is contained in:
Ran Benita 2021-01-24 14:51:58 +02:00 committed by GitHub
commit d5df8f99ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 152 additions and 92 deletions

View File

@ -372,6 +372,7 @@ def _fill_fixtures_impl(function: "Function") -> None:
fi = fm.getfixtureinfo(function.parent, function.obj, None)
function._fixtureinfo = fi
request = function._request = FixtureRequest(function, _ispytest=True)
fm.session._setupstate.prepare(function)
request._fillfixtures()
# Prune out funcargs for jstests.
newfuncargs = {}
@ -543,8 +544,8 @@ class FixtureRequest:
self._addfinalizer(finalizer, scope=self.scope)
def _addfinalizer(self, finalizer: Callable[[], object], scope) -> None:
item = self._getscopeitem(scope)
item.addfinalizer(finalizer)
node = self._getscopeitem(scope)
node.addfinalizer(finalizer)
def applymarker(self, marker: Union[str, MarkDecorator]) -> None:
"""Apply a marker to a single test function invocation.

View File

@ -33,8 +33,10 @@ from _pytest.nodes import Collector
from _pytest.nodes import Item
from _pytest.nodes import Node
from _pytest.outcomes import Exit
from _pytest.outcomes import OutcomeException
from _pytest.outcomes import Skipped
from _pytest.outcomes import TEST_OUTCOME
from _pytest.store import StoreKey
if TYPE_CHECKING:
from typing_extensions import Literal
@ -103,7 +105,7 @@ def pytest_sessionstart(session: "Session") -> None:
def pytest_sessionfinish(session: "Session") -> None:
session._setupstate.teardown_all()
session._setupstate.teardown_exact(None)
def pytest_runtest_protocol(item: Item, nextitem: Optional[Item]) -> bool:
@ -175,7 +177,7 @@ def pytest_runtest_call(item: Item) -> None:
def pytest_runtest_teardown(item: Item, nextitem: Optional[Item]) -> None:
_update_current_test_var(item, "teardown")
item.session._setupstate.teardown_exact(item, nextitem)
item.session._setupstate.teardown_exact(nextitem)
_update_current_test_var(item, None)
@ -401,26 +403,118 @@ def pytest_make_collect_report(collector: Collector) -> CollectReport:
class SetupState:
"""Shared state for setting up/tearing down test items or collectors."""
"""Shared state for setting up/tearing down test items or collectors
in a session.
def __init__(self):
self.stack: List[Node] = []
self._finalizers: Dict[Node, List[Callable[[], object]]] = {}
Suppose we have a collection tree as follows:
def addfinalizer(self, finalizer: Callable[[], object], colitem) -> None:
"""Attach a finalizer to the given colitem."""
assert colitem and not isinstance(colitem, tuple)
<Session session>
<Module mod1>
<Function item1>
<Module mod2>
<Function item2>
The SetupState maintains a stack. The stack starts out empty:
[]
During the setup phase of item1, prepare(item1) is called. What it does
is:
push session to stack, run session.setup()
push mod1 to stack, run mod1.setup()
push item1 to stack, run item1.setup()
The stack is:
[session, mod1, item1]
While the stack is in this shape, it is allowed to add finalizers to
each of session, mod1, item1 using addfinalizer().
During the teardown phase of item1, teardown_exact(item2) is called,
where item2 is the next item to item1. What it does is:
pop item1 from stack, run its teardowns
pop mod1 from stack, run its teardowns
mod1 was popped because it ended its purpose with item1. The stack is:
[session]
During the setup phase of item2, prepare(item2) is called. What it does
is:
push mod2 to stack, run mod2.setup()
push item2 to stack, run item2.setup()
Stack:
[session, mod2, item2]
During the teardown phase of item2, teardown_exact(None) is called,
because item2 is the last item. What it does is:
pop item2 from stack, run its teardowns
pop mod2 from stack, run its teardowns
pop session from stack, run its teardowns
Stack:
[]
The end!
"""
def __init__(self) -> None:
# Maps node -> the node's finalizers.
# The stack is in the dict insertion order.
self.stack: Dict[Node, List[Callable[[], object]]] = {}
_prepare_exc_key = StoreKey[Union[OutcomeException, Exception]]()
def prepare(self, item: Item) -> None:
"""Setup objects along the collector chain to the item."""
# If a collector fails its setup, fail its entire subtree of items.
# The setup is not retried for each item - the same exception is used.
for col in self.stack:
prepare_exc = col._store.get(self._prepare_exc_key, None)
if prepare_exc:
raise prepare_exc
needed_collectors = item.listchain()
for col in needed_collectors[len(self.stack) :]:
assert col not in self.stack
self.stack[col] = [col.teardown]
try:
col.setup()
except TEST_OUTCOME as e:
col._store[self._prepare_exc_key] = e
raise e
def addfinalizer(self, finalizer: Callable[[], object], node: Node) -> None:
"""Attach a finalizer to the given node.
The node must be currently active in the stack.
"""
assert node and not isinstance(node, tuple)
assert callable(finalizer)
# assert colitem in self.stack # some unit tests don't setup stack :/
self._finalizers.setdefault(colitem, []).append(finalizer)
assert node in self.stack, (node, self.stack)
self.stack[node].append(finalizer)
def _pop_and_teardown(self):
colitem = self.stack.pop()
self._teardown_with_finalization(colitem)
def teardown_exact(self, nextitem: Optional[Item]) -> None:
"""Teardown the current stack up until reaching nodes that nextitem
also descends from.
def _callfinalizers(self, colitem) -> None:
finalizers = self._finalizers.pop(colitem, None)
When nextitem is None (meaning we're at the last item), the entire
stack is torn down.
"""
needed_collectors = nextitem and nextitem.listchain() or []
exc = None
while self.stack:
if list(self.stack.keys()) == needed_collectors[: len(self.stack)]:
break
node, finalizers = self.stack.popitem()
while finalizers:
fin = finalizers.pop()
try:
@ -432,56 +526,8 @@ class SetupState:
exc = e
if exc:
raise exc
def _teardown_with_finalization(self, colitem) -> None:
self._callfinalizers(colitem)
colitem.teardown()
for colitem in self._finalizers:
assert colitem in self.stack
def teardown_all(self) -> None:
while self.stack:
self._pop_and_teardown()
for key in list(self._finalizers):
self._teardown_with_finalization(key)
assert not self._finalizers
def teardown_exact(self, item, nextitem) -> None:
needed_collectors = nextitem and nextitem.listchain() or []
self._teardown_towards(needed_collectors)
def _teardown_towards(self, needed_collectors) -> None:
exc = None
while self.stack:
if self.stack == needed_collectors[: len(self.stack)]:
break
try:
self._pop_and_teardown()
except TEST_OUTCOME as e:
# XXX Only first exception will be seen by user,
# ideally all should be reported.
if exc is None:
exc = e
if exc:
raise exc
def prepare(self, colitem) -> None:
"""Setup objects along the collector chain to the test-method."""
# Check if the last collection node has raised an error.
for col in self.stack:
if hasattr(col, "_prepare_exc"):
exc = col._prepare_exc # type: ignore[attr-defined]
raise exc
needed_collectors = colitem.listchain()
for col in needed_collectors[len(self.stack) :]:
self.stack.append(col)
try:
col.setup()
except TEST_OUTCOME as e:
col._prepare_exc = e # type: ignore[attr-defined]
raise e
if nextitem is None:
assert not self.stack
def collect_one_node(collector: Collector) -> CollectReport:

View File

@ -130,7 +130,8 @@ class TestFillFixtures:
pytester.copy_example()
item = pytester.getitem(Path("test_funcarg_basic.py"))
assert isinstance(item, Function)
item._request._fillfixtures()
# Execute's item's setup, which fills fixtures.
item.session._setupstate.prepare(item)
del item.funcargs["request"]
assert len(get_public_names(item.funcargs)) == 2
assert item.funcargs["some"] == "test_func"
@ -809,18 +810,25 @@ class TestRequestBasic:
item = pytester.getitem(
"""
import pytest
values = [2]
@pytest.fixture
def something(request): return 1
def something(request):
return 1
values = [2]
@pytest.fixture
def other(request):
return values.pop()
def test_func(something): pass
"""
)
assert isinstance(item, Function)
req = item._request
# Execute item's setup.
item.session._setupstate.prepare(item)
with pytest.raises(pytest.FixtureLookupError):
req.getfixturevalue("notexists")
val = req.getfixturevalue("something")
@ -831,7 +839,6 @@ class TestRequestBasic:
assert val2 == 2
val2 = req.getfixturevalue("other") # see about caching
assert val2 == 2
item._request._fillfixtures()
assert item.funcargs["something"] == 1
assert len(get_public_names(item.funcargs)) == 2
assert "request" in item.funcargs
@ -856,7 +863,7 @@ class TestRequestBasic:
teardownlist = parent.obj.teardownlist
ss = item.session._setupstate
assert not teardownlist
ss.teardown_exact(item, None)
ss.teardown_exact(None)
print(ss.stack)
assert teardownlist == [1]

View File

@ -22,21 +22,22 @@ from _pytest.pytester import Pytester
class TestSetupState:
def test_setup(self, pytester: Pytester) -> None:
ss = runner.SetupState()
item = pytester.getitem("def test_func(): pass")
ss = item.session._setupstate
values = [1]
ss.prepare(item)
ss.addfinalizer(values.pop, colitem=item)
ss.addfinalizer(values.pop, item)
assert values
ss._pop_and_teardown()
ss.teardown_exact(None)
assert not values
def test_teardown_exact_stack_empty(self, pytester: Pytester) -> None:
item = pytester.getitem("def test_func(): pass")
ss = runner.SetupState()
ss.teardown_exact(item, None)
ss.teardown_exact(item, None)
ss.teardown_exact(item, None)
ss = item.session._setupstate
ss.prepare(item)
ss.teardown_exact(None)
ss.teardown_exact(None)
ss.teardown_exact(None)
def test_setup_fails_and_failure_is_cached(self, pytester: Pytester) -> None:
item = pytester.getitem(
@ -46,9 +47,11 @@ class TestSetupState:
def test_func(): pass
"""
)
ss = runner.SetupState()
pytest.raises(ValueError, lambda: ss.prepare(item))
pytest.raises(ValueError, lambda: ss.prepare(item))
ss = item.session._setupstate
with pytest.raises(ValueError):
ss.prepare(item)
with pytest.raises(ValueError):
ss.prepare(item)
def test_teardown_multiple_one_fails(self, pytester: Pytester) -> None:
r = []
@ -63,12 +66,13 @@ class TestSetupState:
r.append("fin3")
item = pytester.getitem("def test_func(): pass")
ss = runner.SetupState()
ss = item.session._setupstate
ss.prepare(item)
ss.addfinalizer(fin1, item)
ss.addfinalizer(fin2, item)
ss.addfinalizer(fin3, item)
with pytest.raises(Exception) as err:
ss._callfinalizers(item)
ss.teardown_exact(None)
assert err.value.args == ("oops",)
assert r == ["fin3", "fin1"]
@ -82,11 +86,12 @@ class TestSetupState:
raise Exception("oops2")
item = pytester.getitem("def test_func(): pass")
ss = runner.SetupState()
ss = item.session._setupstate
ss.prepare(item)
ss.addfinalizer(fin1, item)
ss.addfinalizer(fin2, item)
with pytest.raises(Exception) as err:
ss._callfinalizers(item)
ss.teardown_exact(None)
assert err.value.args == ("oops2",)
def test_teardown_multiple_scopes_one_fails(self, pytester: Pytester) -> None:
@ -99,13 +104,14 @@ class TestSetupState:
module_teardown.append("fin_module")
item = pytester.getitem("def test_func(): pass")
ss = runner.SetupState()
ss.addfinalizer(fin_module, item.listchain()[-2])
ss.addfinalizer(fin_func, item)
mod = item.listchain()[-2]
ss = item.session._setupstate
ss.prepare(item)
ss.addfinalizer(fin_module, mod)
ss.addfinalizer(fin_func, item)
with pytest.raises(Exception, match="oops1"):
ss.teardown_exact(item, None)
assert module_teardown
ss.teardown_exact(None)
assert module_teardown == ["fin_module"]
class BaseFunctionalTests: