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,88 +403,132 @@ 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)
assert callable(finalizer)
# assert colitem in self.stack # some unit tests don't setup stack :/
self._finalizers.setdefault(colitem, []).append(finalizer)
<Session session>
<Module mod1>
<Function item1>
<Module mod2>
<Function item2>
def _pop_and_teardown(self):
colitem = self.stack.pop()
self._teardown_with_finalization(colitem)
The SetupState maintains a stack. The stack starts out empty:
def _callfinalizers(self, colitem) -> None:
finalizers = self._finalizers.pop(colitem, None)
exc = None
while finalizers:
fin = finalizers.pop()
try:
fin()
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 _teardown_with_finalization(self, colitem) -> None:
self._callfinalizers(colitem)
colitem.teardown()
for colitem in self._finalizers:
assert colitem in self.stack
During the setup phase of item1, prepare(item1) is called. What it does
is:
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
push session to stack, run session.setup()
push mod1 to stack, run mod1.setup()
push item1 to stack, run item1.setup()
def teardown_exact(self, item, nextitem) -> None:
needed_collectors = nextitem and nextitem.listchain() or []
self._teardown_towards(needed_collectors)
The stack is:
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
[session, mod1, item1]
def prepare(self, colitem) -> None:
"""Setup objects along the collector chain to the test-method."""
While the stack is in this shape, it is allowed to add finalizers to
each of session, mod1, item1 using addfinalizer().
# Check if the last collection node has raised an error.
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:
if hasattr(col, "_prepare_exc"):
exc = col._prepare_exc # type: ignore[attr-defined]
raise exc
prepare_exc = col._store.get(self._prepare_exc_key, None)
if prepare_exc:
raise prepare_exc
needed_collectors = colitem.listchain()
needed_collectors = item.listchain()
for col in needed_collectors[len(self.stack) :]:
self.stack.append(col)
assert col not in self.stack
self.stack[col] = [col.teardown]
try:
col.setup()
except TEST_OUTCOME as e:
col._prepare_exc = e # type: ignore[attr-defined]
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 node in self.stack, (node, self.stack)
self.stack[node].append(finalizer)
def teardown_exact(self, nextitem: Optional[Item]) -> None:
"""Teardown the current stack up until reaching nodes that nextitem
also descends from.
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:
fin()
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
if nextitem is None:
assert not self.stack
def collect_one_node(collector: Collector) -> CollectReport:
ihook = collector.ihook

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: