Skip to content

Commit d5df8f9

Browse files
authored
Merge pull request #8219 from bluetech/setupstate-refactor
runner: refactor SetupState
2 parents d4f8e4b + c30feee commit d5df8f9

File tree

4 files changed

+152
-92
lines changed

4 files changed

+152
-92
lines changed

src/_pytest/fixtures.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ def _fill_fixtures_impl(function: "Function") -> None:
372372
fi = fm.getfixtureinfo(function.parent, function.obj, None)
373373
function._fixtureinfo = fi
374374
request = function._request = FixtureRequest(function, _ispytest=True)
375+
fm.session._setupstate.prepare(function)
375376
request._fillfixtures()
376377
# Prune out funcargs for jstests.
377378
newfuncargs = {}
@@ -543,8 +544,8 @@ def addfinalizer(self, finalizer: Callable[[], object]) -> None:
543544
self._addfinalizer(finalizer, scope=self.scope)
544545

545546
def _addfinalizer(self, finalizer: Callable[[], object], scope) -> None:
546-
item = self._getscopeitem(scope)
547-
item.addfinalizer(finalizer)
547+
node = self._getscopeitem(scope)
548+
node.addfinalizer(finalizer)
548549

549550
def applymarker(self, marker: Union[str, MarkDecorator]) -> None:
550551
"""Apply a marker to a single test function invocation.

src/_pytest/runner.py

Lines changed: 112 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@
3333
from _pytest.nodes import Item
3434
from _pytest.nodes import Node
3535
from _pytest.outcomes import Exit
36+
from _pytest.outcomes import OutcomeException
3637
from _pytest.outcomes import Skipped
3738
from _pytest.outcomes import TEST_OUTCOME
39+
from _pytest.store import StoreKey
3840

3941
if TYPE_CHECKING:
4042
from typing_extensions import Literal
@@ -103,7 +105,7 @@ def pytest_sessionstart(session: "Session") -> None:
103105

104106

105107
def pytest_sessionfinish(session: "Session") -> None:
106-
session._setupstate.teardown_all()
108+
session._setupstate.teardown_exact(None)
107109

108110

109111
def pytest_runtest_protocol(item: Item, nextitem: Optional[Item]) -> bool:
@@ -175,7 +177,7 @@ def pytest_runtest_call(item: Item) -> None:
175177

176178
def pytest_runtest_teardown(item: Item, nextitem: Optional[Item]) -> None:
177179
_update_current_test_var(item, "teardown")
178-
item.session._setupstate.teardown_exact(item, nextitem)
180+
item.session._setupstate.teardown_exact(nextitem)
179181
_update_current_test_var(item, None)
180182

181183

@@ -401,88 +403,132 @@ def pytest_make_collect_report(collector: Collector) -> CollectReport:
401403

402404

403405
class SetupState:
404-
"""Shared state for setting up/tearing down test items or collectors."""
406+
"""Shared state for setting up/tearing down test items or collectors
407+
in a session.
405408
406-
def __init__(self):
407-
self.stack: List[Node] = []
408-
self._finalizers: Dict[Node, List[Callable[[], object]]] = {}
409+
Suppose we have a collection tree as follows:
409410
410-
def addfinalizer(self, finalizer: Callable[[], object], colitem) -> None:
411-
"""Attach a finalizer to the given colitem."""
412-
assert colitem and not isinstance(colitem, tuple)
413-
assert callable(finalizer)
414-
# assert colitem in self.stack # some unit tests don't setup stack :/
415-
self._finalizers.setdefault(colitem, []).append(finalizer)
411+
<Session session>
412+
<Module mod1>
413+
<Function item1>
414+
<Module mod2>
415+
<Function item2>
416416
417-
def _pop_and_teardown(self):
418-
colitem = self.stack.pop()
419-
self._teardown_with_finalization(colitem)
417+
The SetupState maintains a stack. The stack starts out empty:
420418
421-
def _callfinalizers(self, colitem) -> None:
422-
finalizers = self._finalizers.pop(colitem, None)
423-
exc = None
424-
while finalizers:
425-
fin = finalizers.pop()
426-
try:
427-
fin()
428-
except TEST_OUTCOME as e:
429-
# XXX Only first exception will be seen by user,
430-
# ideally all should be reported.
431-
if exc is None:
432-
exc = e
433-
if exc:
434-
raise exc
419+
[]
435420
436-
def _teardown_with_finalization(self, colitem) -> None:
437-
self._callfinalizers(colitem)
438-
colitem.teardown()
439-
for colitem in self._finalizers:
440-
assert colitem in self.stack
421+
During the setup phase of item1, prepare(item1) is called. What it does
422+
is:
441423
442-
def teardown_all(self) -> None:
443-
while self.stack:
444-
self._pop_and_teardown()
445-
for key in list(self._finalizers):
446-
self._teardown_with_finalization(key)
447-
assert not self._finalizers
424+
push session to stack, run session.setup()
425+
push mod1 to stack, run mod1.setup()
426+
push item1 to stack, run item1.setup()
448427
449-
def teardown_exact(self, item, nextitem) -> None:
450-
needed_collectors = nextitem and nextitem.listchain() or []
451-
self._teardown_towards(needed_collectors)
428+
The stack is:
452429
453-
def _teardown_towards(self, needed_collectors) -> None:
454-
exc = None
455-
while self.stack:
456-
if self.stack == needed_collectors[: len(self.stack)]:
457-
break
458-
try:
459-
self._pop_and_teardown()
460-
except TEST_OUTCOME as e:
461-
# XXX Only first exception will be seen by user,
462-
# ideally all should be reported.
463-
if exc is None:
464-
exc = e
465-
if exc:
466-
raise exc
430+
[session, mod1, item1]
431+
432+
While the stack is in this shape, it is allowed to add finalizers to
433+
each of session, mod1, item1 using addfinalizer().
434+
435+
During the teardown phase of item1, teardown_exact(item2) is called,
436+
where item2 is the next item to item1. What it does is:
437+
438+
pop item1 from stack, run its teardowns
439+
pop mod1 from stack, run its teardowns
440+
441+
mod1 was popped because it ended its purpose with item1. The stack is:
442+
443+
[session]
444+
445+
During the setup phase of item2, prepare(item2) is called. What it does
446+
is:
447+
448+
push mod2 to stack, run mod2.setup()
449+
push item2 to stack, run item2.setup()
467450
468-
def prepare(self, colitem) -> None:
469-
"""Setup objects along the collector chain to the test-method."""
451+
Stack:
470452
471-
# Check if the last collection node has raised an error.
453+
[session, mod2, item2]
454+
455+
During the teardown phase of item2, teardown_exact(None) is called,
456+
because item2 is the last item. What it does is:
457+
458+
pop item2 from stack, run its teardowns
459+
pop mod2 from stack, run its teardowns
460+
pop session from stack, run its teardowns
461+
462+
Stack:
463+
464+
[]
465+
466+
The end!
467+
"""
468+
469+
def __init__(self) -> None:
470+
# Maps node -> the node's finalizers.
471+
# The stack is in the dict insertion order.
472+
self.stack: Dict[Node, List[Callable[[], object]]] = {}
473+
474+
_prepare_exc_key = StoreKey[Union[OutcomeException, Exception]]()
475+
476+
def prepare(self, item: Item) -> None:
477+
"""Setup objects along the collector chain to the item."""
478+
# If a collector fails its setup, fail its entire subtree of items.
479+
# The setup is not retried for each item - the same exception is used.
472480
for col in self.stack:
473-
if hasattr(col, "_prepare_exc"):
474-
exc = col._prepare_exc # type: ignore[attr-defined]
475-
raise exc
481+
prepare_exc = col._store.get(self._prepare_exc_key, None)
482+
if prepare_exc:
483+
raise prepare_exc
476484

477-
needed_collectors = colitem.listchain()
485+
needed_collectors = item.listchain()
478486
for col in needed_collectors[len(self.stack) :]:
479-
self.stack.append(col)
487+
assert col not in self.stack
488+
self.stack[col] = [col.teardown]
480489
try:
481490
col.setup()
482491
except TEST_OUTCOME as e:
483-
col._prepare_exc = e # type: ignore[attr-defined]
492+
col._store[self._prepare_exc_key] = e
484493
raise e
485494

495+
def addfinalizer(self, finalizer: Callable[[], object], node: Node) -> None:
496+
"""Attach a finalizer to the given node.
497+
498+
The node must be currently active in the stack.
499+
"""
500+
assert node and not isinstance(node, tuple)
501+
assert callable(finalizer)
502+
assert node in self.stack, (node, self.stack)
503+
self.stack[node].append(finalizer)
504+
505+
def teardown_exact(self, nextitem: Optional[Item]) -> None:
506+
"""Teardown the current stack up until reaching nodes that nextitem
507+
also descends from.
508+
509+
When nextitem is None (meaning we're at the last item), the entire
510+
stack is torn down.
511+
"""
512+
needed_collectors = nextitem and nextitem.listchain() or []
513+
exc = None
514+
while self.stack:
515+
if list(self.stack.keys()) == needed_collectors[: len(self.stack)]:
516+
break
517+
node, finalizers = self.stack.popitem()
518+
while finalizers:
519+
fin = finalizers.pop()
520+
try:
521+
fin()
522+
except TEST_OUTCOME as e:
523+
# XXX Only first exception will be seen by user,
524+
# ideally all should be reported.
525+
if exc is None:
526+
exc = e
527+
if exc:
528+
raise exc
529+
if nextitem is None:
530+
assert not self.stack
531+
486532

487533
def collect_one_node(collector: Collector) -> CollectReport:
488534
ihook = collector.ihook

testing/python/fixtures.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,8 @@ def test_funcarg_basic(self, pytester: Pytester) -> None:
130130
pytester.copy_example()
131131
item = pytester.getitem(Path("test_funcarg_basic.py"))
132132
assert isinstance(item, Function)
133-
item._request._fillfixtures()
133+
# Execute's item's setup, which fills fixtures.
134+
item.session._setupstate.prepare(item)
134135
del item.funcargs["request"]
135136
assert len(get_public_names(item.funcargs)) == 2
136137
assert item.funcargs["some"] == "test_func"
@@ -809,18 +810,25 @@ def test_getfixturevalue(self, pytester: Pytester) -> None:
809810
item = pytester.getitem(
810811
"""
811812
import pytest
812-
values = [2]
813+
813814
@pytest.fixture
814-
def something(request): return 1
815+
def something(request):
816+
return 1
817+
818+
values = [2]
815819
@pytest.fixture
816820
def other(request):
817821
return values.pop()
822+
818823
def test_func(something): pass
819824
"""
820825
)
821826
assert isinstance(item, Function)
822827
req = item._request
823828

829+
# Execute item's setup.
830+
item.session._setupstate.prepare(item)
831+
824832
with pytest.raises(pytest.FixtureLookupError):
825833
req.getfixturevalue("notexists")
826834
val = req.getfixturevalue("something")
@@ -831,7 +839,6 @@ def test_func(something): pass
831839
assert val2 == 2
832840
val2 = req.getfixturevalue("other") # see about caching
833841
assert val2 == 2
834-
item._request._fillfixtures()
835842
assert item.funcargs["something"] == 1
836843
assert len(get_public_names(item.funcargs)) == 2
837844
assert "request" in item.funcargs
@@ -856,7 +863,7 @@ def test_func(something): pass
856863
teardownlist = parent.obj.teardownlist
857864
ss = item.session._setupstate
858865
assert not teardownlist
859-
ss.teardown_exact(item, None)
866+
ss.teardown_exact(None)
860867
print(ss.stack)
861868
assert teardownlist == [1]
862869

testing/test_runner.py

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,22 @@
2222

2323
class TestSetupState:
2424
def test_setup(self, pytester: Pytester) -> None:
25-
ss = runner.SetupState()
2625
item = pytester.getitem("def test_func(): pass")
26+
ss = item.session._setupstate
2727
values = [1]
2828
ss.prepare(item)
29-
ss.addfinalizer(values.pop, colitem=item)
29+
ss.addfinalizer(values.pop, item)
3030
assert values
31-
ss._pop_and_teardown()
31+
ss.teardown_exact(None)
3232
assert not values
3333

3434
def test_teardown_exact_stack_empty(self, pytester: Pytester) -> None:
3535
item = pytester.getitem("def test_func(): pass")
36-
ss = runner.SetupState()
37-
ss.teardown_exact(item, None)
38-
ss.teardown_exact(item, None)
39-
ss.teardown_exact(item, None)
36+
ss = item.session._setupstate
37+
ss.prepare(item)
38+
ss.teardown_exact(None)
39+
ss.teardown_exact(None)
40+
ss.teardown_exact(None)
4041

4142
def test_setup_fails_and_failure_is_cached(self, pytester: Pytester) -> None:
4243
item = pytester.getitem(
@@ -46,9 +47,11 @@ def setup_module(mod):
4647
def test_func(): pass
4748
"""
4849
)
49-
ss = runner.SetupState()
50-
pytest.raises(ValueError, lambda: ss.prepare(item))
51-
pytest.raises(ValueError, lambda: ss.prepare(item))
50+
ss = item.session._setupstate
51+
with pytest.raises(ValueError):
52+
ss.prepare(item)
53+
with pytest.raises(ValueError):
54+
ss.prepare(item)
5255

5356
def test_teardown_multiple_one_fails(self, pytester: Pytester) -> None:
5457
r = []
@@ -63,12 +66,13 @@ def fin3():
6366
r.append("fin3")
6467

6568
item = pytester.getitem("def test_func(): pass")
66-
ss = runner.SetupState()
69+
ss = item.session._setupstate
70+
ss.prepare(item)
6771
ss.addfinalizer(fin1, item)
6872
ss.addfinalizer(fin2, item)
6973
ss.addfinalizer(fin3, item)
7074
with pytest.raises(Exception) as err:
71-
ss._callfinalizers(item)
75+
ss.teardown_exact(None)
7276
assert err.value.args == ("oops",)
7377
assert r == ["fin3", "fin1"]
7478

@@ -82,11 +86,12 @@ def fin2():
8286
raise Exception("oops2")
8387

8488
item = pytester.getitem("def test_func(): pass")
85-
ss = runner.SetupState()
89+
ss = item.session._setupstate
90+
ss.prepare(item)
8691
ss.addfinalizer(fin1, item)
8792
ss.addfinalizer(fin2, item)
8893
with pytest.raises(Exception) as err:
89-
ss._callfinalizers(item)
94+
ss.teardown_exact(None)
9095
assert err.value.args == ("oops2",)
9196

9297
def test_teardown_multiple_scopes_one_fails(self, pytester: Pytester) -> None:
@@ -99,13 +104,14 @@ def fin_module():
99104
module_teardown.append("fin_module")
100105

101106
item = pytester.getitem("def test_func(): pass")
102-
ss = runner.SetupState()
103-
ss.addfinalizer(fin_module, item.listchain()[-2])
104-
ss.addfinalizer(fin_func, item)
107+
mod = item.listchain()[-2]
108+
ss = item.session._setupstate
105109
ss.prepare(item)
110+
ss.addfinalizer(fin_module, mod)
111+
ss.addfinalizer(fin_func, item)
106112
with pytest.raises(Exception, match="oops1"):
107-
ss.teardown_exact(item, None)
108-
assert module_teardown
113+
ss.teardown_exact(None)
114+
assert module_teardown == ["fin_module"]
109115

110116

111117
class BaseFunctionalTests:

0 commit comments

Comments
 (0)