Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft. Support for resetting function-scoped fixtures #12597

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions doc/en/example/fixtures/test_fixtures_order_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ def func(order):
order.append("function")


@pytest.fixture(scope="item")
def item(order):
order.append("item")


@pytest.fixture(scope="class")
def cls(order):
order.append("class")
Expand All @@ -34,5 +39,5 @@ def sess(order):


class TestClass:
def test_order(self, func, cls, mod, pack, sess, order):
assert order == ["session", "package", "module", "class", "function"]
def test_order(self, func, item, cls, mod, pack, sess, order):
assert order == ["session", "package", "module", "class", "item", "function"]
12 changes: 7 additions & 5 deletions doc/en/example/fixtures/test_fixtures_order_scope.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions doc/en/reference/fixtures.rst
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,12 @@ The order breaks down to this:
.. image:: /example/fixtures/test_fixtures_order_scope.*
:align: center

.. note:

The ``item`` and ``function`` scopes are equivalent unless using an
executor that runs the test function multiple times internally, such
as ``@hypothesis.given(...)``. If unsure, use ``function``.

Fixtures of the same order execute based on dependencies
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
122 changes: 95 additions & 27 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,29 @@
# Cache key.
object,
None,
# Sequence counter
int,
],
Tuple[
None,
# Cache key.
object,
# The exception and the original traceback.
Tuple[BaseException, Optional[types.TracebackType]],
# Sequence counter
int,
],
]

# Global fixture sequence counter
_fixture_seq_counter: int = 0


def _fixture_seq():
global _fixture_seq_counter
_fixture_seq_counter += 1
return _fixture_seq_counter - 1


@dataclasses.dataclass(frozen=True)
class PseudoFixtureDef(Generic[FixtureValue]):
Expand All @@ -136,7 +149,7 @@
def get_scope_node(node: nodes.Node, scope: Scope) -> nodes.Node | None:
import _pytest.python

if scope is Scope.Function:
if scope <= Scope.Item:
# Type ignored because this is actually safe, see:
# https://github.com/python/mypy/issues/4717
return node.getparent(nodes.Item) # type: ignore[type-abstract]
Expand Down Expand Up @@ -184,7 +197,7 @@
) -> Iterator[FixtureArgKey]:
"""Return list of keys for all parametrized arguments which match
the specified scope."""
assert scope is not Scope.Function
assert scope > Scope.Item

try:
callspec: CallSpec2 = item.callspec # type: ignore[attr-defined]
Expand Down Expand Up @@ -243,7 +256,7 @@
],
scope: Scope,
) -> OrderedSet[nodes.Item]:
if scope is Scope.Function or len(items) < 3:
if scope <= Scope.Item or len(items) < 3:
return items

scoped_items_by_argkey = items_by_argkey[scope]
Expand Down Expand Up @@ -400,7 +413,7 @@

@property
def scope(self) -> _ScopeName:
"""Scope string, one of "function", "class", "module", "package", "session"."""
"""Scope string, one of "function", "item", "class", "module", "package", "session"."""
return self._scope.value

@abc.abstractmethod
Expand Down Expand Up @@ -545,12 +558,35 @@
yield current
current = current._parent_request

def _create_subrequest(self, fixturedef) -> SubRequest:
"""Create a SubRequest suitable for calling the given fixture"""
argname = fixturedef.argname
try:
callspec = self._pyfuncitem.callspec
except AttributeError:
callspec = None
if callspec is not None and argname in callspec.params:
param = callspec.params[argname]
param_index = callspec.indices[argname]
# The parametrize invocation scope overrides the fixture's scope.
scope = callspec._arg2scope[argname]
else:
param = NOTSET
param_index = 0
scope = fixturedef._scope
self._check_fixturedef_without_param(fixturedef)
self._check_scope(fixturedef, scope)
subrequest = SubRequest(
self, scope, param, param_index, fixturedef, _ispytest=True
)
return subrequest

def _get_active_fixturedef(
self, argname: str
) -> FixtureDef[object] | PseudoFixtureDef[object]:
if argname == "request":
cached_result = (self, [0], None)
return PseudoFixtureDef(cached_result, Scope.Function)
return PseudoFixtureDef(cached_result, Scope.Item)

# If we already finished computing a fixture by this name in this item,
# return it.
Expand Down Expand Up @@ -593,24 +629,7 @@
fixturedef = fixturedefs[index]

# Prepare a SubRequest object for calling the fixture.
try:
callspec = self._pyfuncitem.callspec
except AttributeError:
callspec = None
if callspec is not None and argname in callspec.params:
param = callspec.params[argname]
param_index = callspec.indices[argname]
# The parametrize invocation scope overrides the fixture's scope.
scope = callspec._arg2scope[argname]
else:
param = NOTSET
param_index = 0
scope = fixturedef._scope
self._check_fixturedef_without_param(fixturedef)
self._check_scope(fixturedef, scope)
subrequest = SubRequest(
self, scope, param, param_index, fixturedef, _ispytest=True
)
subrequest = self._create_subrequest(fixturedef)

# Make sure the fixture value is cached, running it if it isn't
fixturedef.execute(request=subrequest)
Expand All @@ -632,7 +651,7 @@
)
fail(msg, pytrace=False)
if has_params:
frame = inspect.stack()[3]
frame = inspect.stack()[4]
frameinfo = inspect.getframeinfo(frame[0])
source_path = absolutepath(frameinfo.filename)
source_lineno = frameinfo.lineno
Expand All @@ -656,6 +675,49 @@
values.reverse()
return values

def _reset_function_scoped_fixtures(self):
"""Can be called by an external subtest runner to reset function scoped
fixtures in-between function calls within a single test item."""
info = self._fixturemanager.getfixtureinfo(

Check warning on line 681 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L681

Added line #L681 was not covered by tests
node=self._pyfuncitem, func=self._pyfuncitem.function, cls=None
)

# Build a safe traversal order where dependencies are always processed
# before any dependents, by virtue of ordering them exactly as in the
# initial fixture setup. After reset, their relative ordering remains
# the same.
fixture_defs = []

Check warning on line 689 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L689

Added line #L689 was not covered by tests
for v in info.name2fixturedefs.values():
fixture_defs.extend(v)

Check warning on line 691 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L691

Added line #L691 was not covered by tests
fixture_defs.sort(key=lambda fixturedef: fixturedef._exec_seq)

current_closure = {}
updated_names = set()

Check warning on line 695 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L694-L695

Added lines #L694 - L695 were not covered by tests

for fixturedef in fixture_defs:
fixture_name = fixturedef.argname

Check warning on line 698 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L698

Added line #L698 was not covered by tests

subrequest = self._create_subrequest(fixturedef)

Check warning on line 700 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L700

Added line #L700 was not covered by tests
if subrequest._scope is Scope.Function:
subrequest._fixture_defs = current_closure

Check warning on line 702 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L702

Added line #L702 was not covered by tests

# Teardown and execute the fixture again! Note that finish(...) will
# invalidate dependent fixtures, so many of the later calls are no-ops.
fixturedef.finish(subrequest)
fixturedef.execute(subrequest)
updated_names.add(fixture_name)

Check warning on line 708 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L706-L708

Added lines #L706 - L708 were not covered by tests

# This ensures all fixtures in current_closure are in the correct state
# for the next subrequest (as a consequence of the safe traversal order)
current_closure[fixture_name] = fixturedef

Check warning on line 712 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L712

Added line #L712 was not covered by tests

kwargs = {}

Check warning on line 714 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L714

Added line #L714 was not covered by tests
for fixture_name in updated_names:
fixture_val = self.getfixturevalue(fixture_name)
kwargs[fixture_name] = self._pyfuncitem.funcargs[fixture_name] = fixture_val

Check warning on line 717 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L716-L717

Added lines #L716 - L717 were not covered by tests

return kwargs

Check warning on line 719 in src/_pytest/fixtures.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/fixtures.py#L719

Added line #L719 was not covered by tests


@final
class TopRequest(FixtureRequest):
Expand Down Expand Up @@ -738,8 +800,8 @@
@property
def node(self):
scope = self._scope
if scope is Scope.Function:
# This might also be a non-function Item despite its attribute name.
if scope <= Scope.Item:
# This might also be a non-function Item
node: nodes.Node | None = self._pyfuncitem
elif scope is Scope.Package:
node = get_scope_package(self._pyfuncitem, self._fixturedef)
Expand Down Expand Up @@ -1003,10 +1065,13 @@
# Can change if the fixture is executed with different parameters.
self.cached_result: _FixtureCachedResult[FixtureValue] | None = None
self._finalizers: Final[list[Callable[[], object]]] = []
# The sequence number of the last execution. Used to reconstruct
# initialization order.
self._exec_seq = None

@property
def scope(self) -> _ScopeName:
"""Scope string, one of "function", "class", "module", "package", "session"."""
"""Scope string, one of "function", "item", "class", "module", "package", "session"."""
return self._scope.value

def addfinalizer(self, finalizer: Callable[[], object]) -> None:
Expand Down Expand Up @@ -1070,6 +1135,9 @@
self.finish(request)
assert self.cached_result is None

# We have decided to execute the fixture, so update the sequence counter.
self._exec_seq = _fixture_seq()

# Add finalizer to requested fixtures we saved previously.
# We make sure to do this after checking for cached value to avoid
# adding our finalizer multiple times. (#12135)
Expand Down
2 changes: 1 addition & 1 deletion src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -1249,7 +1249,7 @@ def parametrize(
# to make sure we only ever create an according fixturedef on
# a per-scope basis. We thus store and cache the fixturedef on the
# node related to the scope.
if scope_ is not Scope.Function:
if scope_ > Scope.Item:
collector = self.definition.parent
assert collector is not None
node = get_scope_node(collector, scope_)
Expand Down
9 changes: 5 additions & 4 deletions src/_pytest/scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from typing import Literal


_ScopeName = Literal["session", "package", "module", "class", "function"]
_ScopeName = Literal["session", "package", "module", "class", "item", "function"]


@total_ordering
Expand All @@ -27,13 +27,14 @@ class Scope(Enum):

->>> higher ->>>

Function < Class < Module < Package < Session
Function < Item < Class < Module < Package < Session

<<<- lower <<<-
"""

# Scopes need to be listed from lower to higher.
Function: _ScopeName = "function"
Item: _ScopeName = "item"
Class: _ScopeName = "class"
Module: _ScopeName = "module"
Package: _ScopeName = "package"
Expand Down Expand Up @@ -87,5 +88,5 @@ def from_user(
_SCOPE_INDICES = {scope: index for index, scope in enumerate(_ALL_SCOPES)}


# Ordered list of scopes which can contain many tests (in practice all except Function).
HIGH_SCOPES = [x for x in Scope if x is not Scope.Function]
# Ordered list of scopes which can contain many tests (in practice all except Item/Function).
HIGH_SCOPES = [x for x in Scope if x > Scope.Item]
5 changes: 5 additions & 0 deletions src/_pytest/stash.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ def get(self, key: StashKey[T], default: D) -> T | D:
except KeyError:
return default

def pop(self, key: StashKey[T], default: D) -> T | D:
"""Get and remove the value for key, or return default if the key wasn't set
before."""
return self._storage.pop(key, default)

def setdefault(self, key: StashKey[T], default: T) -> T:
"""Return the value of key if already set, otherwise set the value
of key to default and return default."""
Expand Down
8 changes: 5 additions & 3 deletions src/_pytest/tmpdir.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,15 +276,17 @@ def tmp_path(
# Remove the tmpdir if the policy is "failed" and the test passed.
tmp_path_factory: TempPathFactory = request.session.config._tmp_path_factory # type: ignore
policy = tmp_path_factory._retention_policy
result_dict = request.node.stash[tmppath_result_key]
# The result dict is set inside pytest_runtest_makereport, but for multi-execution
# the report (and indeed the test status) isn't available until all subtest
# executions are finished. We assume that earlier executions of this item can
# be treated as "not failed".
result_dict = request.node.stash.pop(tmppath_result_key, {})

if policy == "failed" and result_dict.get("call", True):
# We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
# permissions, etc, in which case we ignore it.
rmtree(path, ignore_errors=True)

del request.node.stash[tmppath_result_key]


def pytest_sessionfinish(session, exitstatus: int | ExitCode):
"""After each session, remove base directory if all the tests passed,
Expand Down
40 changes: 40 additions & 0 deletions testing/fixtures/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

import pytest


num_test = 0


@pytest.fixture(scope="function")
def fixture_test():
"""To be extended by same-name fixture in module"""
global num_test
num_test += 1
print("->test [conftest]")
return num_test

Check warning on line 15 in testing/fixtures/conftest.py

View check run for this annotation

Codecov / codecov/patch

testing/fixtures/conftest.py#L13-L15

Added lines #L13 - L15 were not covered by tests


@pytest.fixture(scope="function")
def fixture_test_2(fixture_test):
"""Should pick up extended fixture_test, even if it's not defined yet"""
print("->test_2 [conftest]")
return fixture_test

Check warning on line 22 in testing/fixtures/conftest.py

View check run for this annotation

Codecov / codecov/patch

testing/fixtures/conftest.py#L21-L22

Added lines #L21 - L22 were not covered by tests


@pytest.fixture(scope="function")
def fixt_1():
"""Part of complex dependency chain"""
return "f1_c"

Check warning on line 28 in testing/fixtures/conftest.py

View check run for this annotation

Codecov / codecov/patch

testing/fixtures/conftest.py#L28

Added line #L28 was not covered by tests


@pytest.fixture(scope="function")
def fixt_2(fixt_1):
"""Part of complex dependency chain"""
return f"f2_c({fixt_1})"

Check warning on line 34 in testing/fixtures/conftest.py

View check run for this annotation

Codecov / codecov/patch

testing/fixtures/conftest.py#L34

Added line #L34 was not covered by tests


@pytest.fixture(scope="function")
def fixt_3(fixt_1):
"""Part of complex dependency chain"""
return f"f3_c({fixt_1})"

Check warning on line 40 in testing/fixtures/conftest.py

View check run for this annotation

Codecov / codecov/patch

testing/fixtures/conftest.py#L40

Added line #L40 was not covered by tests
Loading
Loading