Skip to content
Closed
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_package(
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 @@ def get_parametrized_fixture_argkeys(
) -> 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 @@ def reorder_items_atscope(
],
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 @@ def _scope(self) -> Scope:

@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 @@ def _iter_chain(self) -> Iterator[SubRequest]:
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 @@ def _get_active_fixturedef(
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 @@ def _check_fixturedef_without_param(self, fixturedef: FixtureDef[object]) -> Non
)
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 @@ def _get_fixturestack(self) -> list[FixtureDef[Any]]:
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(
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 = []
for v in info.name2fixturedefs.values():
fixture_defs.extend(v)
fixture_defs.sort(key=lambda fixturedef: fixturedef._exec_seq)

current_closure = {}
updated_names = set()

for fixturedef in fixture_defs:
fixture_name = fixturedef.argname

subrequest = self._create_subrequest(fixturedef)
if subrequest._scope is Scope.Function:
subrequest._fixture_defs = current_closure

# 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)

# 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

kwargs = {}
for fixture_name in updated_names:
fixture_val = self.getfixturevalue(fixture_name)
kwargs[fixture_name] = self._pyfuncitem.funcargs[fixture_name] = fixture_val

return kwargs


@final
class TopRequest(FixtureRequest):
Expand Down Expand Up @@ -738,8 +800,8 @@ def _scope(self) -> Scope:
@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 @@ def __init__(
# 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 @@ def execute(self, request: SubRequest) -> FixtureValue:
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


@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


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


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


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