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) fi = fm.getfixtureinfo(function.parent, function.obj, None)
function._fixtureinfo = fi function._fixtureinfo = fi
request = function._request = FixtureRequest(function, _ispytest=True) request = function._request = FixtureRequest(function, _ispytest=True)
fm.session._setupstate.prepare(function)
request._fillfixtures() request._fillfixtures()
# Prune out funcargs for jstests. # Prune out funcargs for jstests.
newfuncargs = {} newfuncargs = {}
@ -543,8 +544,8 @@ class FixtureRequest:
self._addfinalizer(finalizer, scope=self.scope) self._addfinalizer(finalizer, scope=self.scope)
def _addfinalizer(self, finalizer: Callable[[], object], scope) -> None: def _addfinalizer(self, finalizer: Callable[[], object], scope) -> None:
item = self._getscopeitem(scope) node = self._getscopeitem(scope)
item.addfinalizer(finalizer) node.addfinalizer(finalizer)
def applymarker(self, marker: Union[str, MarkDecorator]) -> None: def applymarker(self, marker: Union[str, MarkDecorator]) -> None:
"""Apply a marker to a single test function invocation. """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 Item
from _pytest.nodes import Node from _pytest.nodes import Node
from _pytest.outcomes import Exit from _pytest.outcomes import Exit
from _pytest.outcomes import OutcomeException
from _pytest.outcomes import Skipped from _pytest.outcomes import Skipped
from _pytest.outcomes import TEST_OUTCOME from _pytest.outcomes import TEST_OUTCOME
from _pytest.store import StoreKey
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Literal from typing_extensions import Literal
@ -103,7 +105,7 @@ def pytest_sessionstart(session: "Session") -> None:
def pytest_sessionfinish(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: 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: def pytest_runtest_teardown(item: Item, nextitem: Optional[Item]) -> None:
_update_current_test_var(item, "teardown") _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) _update_current_test_var(item, None)
@ -401,88 +403,132 @@ def pytest_make_collect_report(collector: Collector) -> CollectReport:
class SetupState: 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): Suppose we have a collection tree as follows:
self.stack: List[Node] = []
self._finalizers: Dict[Node, List[Callable[[], object]]] = {}
def addfinalizer(self, finalizer: Callable[[], object], colitem) -> None: <Session session>
"""Attach a finalizer to the given colitem.""" <Module mod1>
assert colitem and not isinstance(colitem, tuple) <Function item1>
assert callable(finalizer) <Module mod2>
# assert colitem in self.stack # some unit tests don't setup stack :/ <Function item2>
self._finalizers.setdefault(colitem, []).append(finalizer)
def _pop_and_teardown(self): The SetupState maintains a stack. The stack starts out empty:
colitem = self.stack.pop()
self._teardown_with_finalization(colitem)
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: During the setup phase of item1, prepare(item1) is called. What it does
self._callfinalizers(colitem) is:
colitem.teardown()
for colitem in self._finalizers:
assert colitem in self.stack
def teardown_all(self) -> None: push session to stack, run session.setup()
while self.stack: push mod1 to stack, run mod1.setup()
self._pop_and_teardown() push item1 to stack, run item1.setup()
for key in list(self._finalizers):
self._teardown_with_finalization(key)
assert not self._finalizers
def teardown_exact(self, item, nextitem) -> None: The stack is:
needed_collectors = nextitem and nextitem.listchain() or []
self._teardown_towards(needed_collectors)
def _teardown_towards(self, needed_collectors) -> None: [session, mod1, item1]
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: While the stack is in this shape, it is allowed to add finalizers to
"""Setup objects along the collector chain to the test-method.""" 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: for col in self.stack:
if hasattr(col, "_prepare_exc"): prepare_exc = col._store.get(self._prepare_exc_key, None)
exc = col._prepare_exc # type: ignore[attr-defined] if prepare_exc:
raise exc raise prepare_exc
needed_collectors = colitem.listchain() needed_collectors = item.listchain()
for col in needed_collectors[len(self.stack) :]: for col in needed_collectors[len(self.stack) :]:
self.stack.append(col) assert col not in self.stack
self.stack[col] = [col.teardown]
try: try:
col.setup() col.setup()
except TEST_OUTCOME as e: except TEST_OUTCOME as e:
col._prepare_exc = e # type: ignore[attr-defined] col._store[self._prepare_exc_key] = e
raise 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: def collect_one_node(collector: Collector) -> CollectReport:
ihook = collector.ihook ihook = collector.ihook

View File

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

View File

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