diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 6b882fa351..a77e5c446b 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -137,7 +137,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 is Scope.Function or scope is Scope.Invocation: # Type ignored because this is actually safe, see: # https://github.com/python/mypy/issues/4717 return node.getparent(nodes.Item) # type: ignore[type-abstract] @@ -185,7 +185,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 in HIGH_SCOPES try: callspec: CallSpec2 = item.callspec # type: ignore[attr-defined] @@ -534,6 +534,14 @@ def getfixturevalue(self, argname: str) -> Any: f'The fixture value for "{argname}" is not available. ' "This can happen when the fixture has already been torn down." ) + + if ( + isinstance(fixturedef, FixtureDef) + and fixturedef is not None + and fixturedef.scope == Scope.Invocation.value + ): + self._fixture_defs.pop(argname) + return fixturedef.cached_result[0] def _iter_chain(self) -> Iterator[SubRequest]: @@ -614,9 +622,18 @@ def _get_active_fixturedef( ) # Make sure the fixture value is cached, running it if it isn't - fixturedef.execute(request=subrequest) + try: + fixturedef.execute(request=subrequest) + self._fixture_defs[argname] = fixturedef + finally: + for arg_name in fixturedef.argnames: + arg_fixture = self._fixture_defs.get(arg_name) + if ( + arg_fixture is not None + and arg_fixture.scope == Scope.Invocation.value + ): + self._fixture_defs.pop(arg_name) - self._fixture_defs[argname] = fixturedef return fixturedef def _check_fixturedef_without_param(self, fixturedef: FixtureDef[object]) -> None: @@ -757,7 +774,10 @@ def _check_scope( requested_fixturedef: FixtureDef[object] | PseudoFixtureDef[object], requested_scope: Scope, ) -> None: - if isinstance(requested_fixturedef, PseudoFixtureDef): + if ( + isinstance(requested_fixturedef, PseudoFixtureDef) + or requested_scope == Scope.Invocation + ): return if self._scope > requested_scope: # Try to report something helpful. @@ -1054,7 +1074,7 @@ def execute(self, request: SubRequest) -> FixtureValue: requested_fixtures_that_should_finalize_us.append(fixturedef) # Check for (and return) cached value/exception. - if self.cached_result is not None: + if self.cached_result is not None and self.scope != Scope.Invocation.value: request_cache_key = self.cache_key(request) cache_key = self.cached_result[1] try: diff --git a/src/_pytest/scope.py b/src/_pytest/scope.py index 976a3ba242..d274e1eca1 100644 --- a/src/_pytest/scope.py +++ b/src/_pytest/scope.py @@ -15,7 +15,7 @@ from typing import Literal -_ScopeName = Literal["session", "package", "module", "class", "function"] +_ScopeName = Literal["session", "package", "module", "class", "function", "invocation"] @total_ordering @@ -33,6 +33,7 @@ class Scope(Enum): """ # Scopes need to be listed from lower to higher. + Invocation: _ScopeName = "invocation" Function: _ScopeName = "function" Class: _ScopeName = "class" Module: _ScopeName = "module" @@ -88,4 +89,6 @@ def from_user( # 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] +HIGH_SCOPES = [ + x for x in Scope if x is not Scope.Function and x is not Scope.Invocation +] diff --git a/testing/test_no_cache.py b/testing/test_no_cache.py new file mode 100644 index 0000000000..76169d5afd --- /dev/null +++ b/testing/test_no_cache.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from _pytest.pytester import Pytester + + +def test_setup_teardown_executed_for_every_fixture_usage_without_caching( + pytester: Pytester, +) -> None: + pytester.makepyfile( + """ + import pytest + import logging + + @pytest.fixture(scope="invocation") + def fixt(): + logging.info("&&Setting up fixt&&") + yield + logging.info("&&Tearing down fixt&&") + + + @pytest.fixture() + def a(fixt): + ... + + + @pytest.fixture() + def b(fixt): + ... + + + def test(a, b, fixt): + assert False + """ + ) + + result = pytester.runpytest("--log-level=INFO") + assert result.ret == 1 + result.stdout.fnmatch_lines( + [ + *["*&&Setting up fixt&&*"] * 3, + *["*&&Tearing down fixt&&*"] * 3, + ] + ) + + +def test_setup_teardown_executed_for_every_getfixturevalue_usage_without_caching( + pytester: Pytester, +) -> None: + pytester.makepyfile( + """ + import pytest + import logging + + @pytest.fixture(scope="invocation") + def fixt(): + logging.info("&&Setting up fixt&&") + yield + logging.info("&&Tearing down fixt&&") + + + def test(request): + random_nums = [request.getfixturevalue('fixt') for _ in range(3)] + assert False + """ + ) + result = pytester.runpytest("--log-level=INFO") + assert result.ret == 1 + result.stdout.fnmatch_lines( + [ + *["*&&Setting up fixt&&*"] * 3, + *["*&&Tearing down fixt&&*"] * 3, + ] + ) + + +def test_non_cached_fixture_generates_unique_values_per_usage( + pytester: Pytester, +) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope="invocation") + def random_num(): + import random + return random.randint(-100_000_000_000, 100_000_000_000) + + + @pytest.fixture() + def a(random_num): + return random_num + + + @pytest.fixture() + def b(random_num): + return random_num + + + def test(a, b, random_num): + assert a != b != random_num + """ + ) + pytester.runpytest().assert_outcomes(passed=1) + + +def test_non_cached_fixture_generates_unique_values_per_getfixturevalue_usage( + pytester: Pytester, +) -> None: + pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope="invocation") + def random_num(): + import random + yield random.randint(-100_000_000_000, 100_000_000_000) + + + def test(request): + random_nums = [request.getfixturevalue('random_num') for _ in range(3)] + assert random_nums[0] != random_nums[1] != random_nums[2] + """ + ) + pytester.runpytest().assert_outcomes(passed=1) diff --git a/testing/test_scope.py b/testing/test_scope.py index 3cb811469a..0bf3e01a04 100644 --- a/testing/test_scope.py +++ b/testing/test_scope.py @@ -18,9 +18,10 @@ def test_next_lower() -> None: assert Scope.Package.next_lower() is Scope.Module assert Scope.Module.next_lower() is Scope.Class assert Scope.Class.next_lower() is Scope.Function + assert Scope.Function.next_lower() is Scope.Invocation - with pytest.raises(ValueError, match="Function is the lower-most scope"): - Scope.Function.next_lower() + with pytest.raises(ValueError, match="Invocation is the lower-most scope"): + Scope.Invocation.next_lower() def test_next_higher() -> None: