From 27f442d3bb3d68069b724a1d71609054dbb9bde3 Mon Sep 17 00:00:00 2001 From: niroshaimos Date: Sat, 14 Sep 2024 21:54:20 +0300 Subject: [PATCH 1/3] no cache fixtures POC --- src/_pytest/fixtures.py | 33 ++++++++++-- testing/test_no_cache.py | 108 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 testing/test_no_cache.py diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 6b882fa351..70577376ba 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -534,6 +534,12 @@ 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.use_cache is False): + self._fixture_defs.pop(argname) + return fixturedef.cached_result[0] def _iter_chain(self) -> Iterator[SubRequest]: @@ -614,9 +620,15 @@ 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.use_cache is not True: + self._fixture_defs.pop(arg_name) - self._fixture_defs[argname] = fixturedef return fixturedef def _check_fixturedef_without_param(self, fixturedef: FixtureDef[object]) -> None: @@ -957,6 +969,7 @@ def __init__( scope: Scope | _ScopeName | Callable[[str, Config], _ScopeName] | None, params: Sequence[object] | None, ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, + use_cache: bool = True, *, _ispytest: bool = False, ) -> None: @@ -1004,6 +1017,7 @@ 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]]] = [] + self.use_cache = use_cache @property def scope(self) -> _ScopeName: @@ -1054,7 +1068,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.use_cache: request_cache_key = self.cache_key(request) cache_key = self.cached_result[1] try: @@ -1183,6 +1197,7 @@ class FixtureFunctionMarker: autouse: bool = False ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None name: str | None = None + cache_result: bool = True _ispytest: dataclasses.InitVar[bool] = False @@ -1225,6 +1240,7 @@ def fixture( autouse: bool = ..., ids: Sequence[object | None] | Callable[[Any], object | None] | None = ..., name: str | None = ..., + cache_result: bool = True, ) -> FixtureFunction: ... @@ -1237,6 +1253,7 @@ def fixture( autouse: bool = ..., ids: Sequence[object | None] | Callable[[Any], object | None] | None = ..., name: str | None = None, + cache_result: bool = True, ) -> FixtureFunctionMarker: ... @@ -1248,6 +1265,7 @@ def fixture( autouse: bool = False, ids: Sequence[object | None] | Callable[[Any], object | None] | None = None, name: str | None = None, + cache_result: bool = True, ) -> FixtureFunctionMarker | FixtureFunction: """Decorator to mark a fixture factory function. @@ -1298,6 +1316,11 @@ def fixture( function arg that requests the fixture; one way to resolve this is to name the decorated function ``fixture_`` and then use ``@pytest.fixture(name='')``. + + :param cache_result: + If True (the default), the fixture result is cached and the fixture + only runs once per scope. + If False, the fixture will run each time it is requested """ fixture_marker = FixtureFunctionMarker( scope=scope, @@ -1306,6 +1329,7 @@ def fixture( ids=None if ids is None else ids if callable(ids) else tuple(ids), name=name, _ispytest=True, + cache_result=cache_result ) # Direct decoration. @@ -1636,6 +1660,7 @@ def _register_fixture( params: Sequence[object] | None = None, ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, autouse: bool = False, + cache_result: bool = True, ) -> None: """Register a fixture @@ -1666,6 +1691,7 @@ def _register_fixture( params=params, ids=ids, _ispytest=True, + use_cache=cache_result, ) faclist = self._arg2fixturedefs.setdefault(name, []) @@ -1762,6 +1788,7 @@ def parsefactories( params=marker.params, ids=marker.ids, autouse=marker.autouse, + cache_result=marker.cache_result ) def getfixturedefs( diff --git a/testing/test_no_cache.py b/testing/test_no_cache.py new file mode 100644 index 0000000000..1f27e5acc3 --- /dev/null +++ b/testing/test_no_cache.py @@ -0,0 +1,108 @@ +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(cache_result=False) + 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(cache_result=False) + 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(cache_result=False) + 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(cache_result=False) + 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) From f014d55cbfeedd48258d7e71f00ac74fb67341ee Mon Sep 17 00:00:00 2001 From: niroshaimos Date: Sat, 5 Oct 2024 12:28:57 +0300 Subject: [PATCH 2/3] Added invocation scope --- src/_pytest/fixtures.py | 27 ++++++--------------------- src/_pytest/scope.py | 5 +++-- testing/test_no_cache.py | 8 ++++---- testing/test_scope.py | 5 +++-- 4 files changed, 16 insertions(+), 29 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 70577376ba..9b713f091f 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] @@ -537,7 +537,7 @@ def getfixturevalue(self, argname: str) -> Any: if (isinstance(fixturedef, FixtureDef) and fixturedef is not None - and fixturedef.use_cache is False): + and fixturedef.scope == Scope.Invocation.value): self._fixture_defs.pop(argname) return fixturedef.cached_result[0] @@ -626,7 +626,7 @@ def _get_active_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.use_cache is not True: + if arg_fixture is not None and arg_fixture.scope == Scope.Invocation.value: self._fixture_defs.pop(arg_name) return fixturedef @@ -769,7 +769,7 @@ 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. @@ -969,7 +969,6 @@ def __init__( scope: Scope | _ScopeName | Callable[[str, Config], _ScopeName] | None, params: Sequence[object] | None, ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, - use_cache: bool = True, *, _ispytest: bool = False, ) -> None: @@ -1017,7 +1016,6 @@ 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]]] = [] - self.use_cache = use_cache @property def scope(self) -> _ScopeName: @@ -1068,7 +1066,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 and self.use_cache: + 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: @@ -1197,7 +1195,6 @@ class FixtureFunctionMarker: autouse: bool = False ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None name: str | None = None - cache_result: bool = True _ispytest: dataclasses.InitVar[bool] = False @@ -1240,7 +1237,6 @@ def fixture( autouse: bool = ..., ids: Sequence[object | None] | Callable[[Any], object | None] | None = ..., name: str | None = ..., - cache_result: bool = True, ) -> FixtureFunction: ... @@ -1253,7 +1249,6 @@ def fixture( autouse: bool = ..., ids: Sequence[object | None] | Callable[[Any], object | None] | None = ..., name: str | None = None, - cache_result: bool = True, ) -> FixtureFunctionMarker: ... @@ -1265,7 +1260,6 @@ def fixture( autouse: bool = False, ids: Sequence[object | None] | Callable[[Any], object | None] | None = None, name: str | None = None, - cache_result: bool = True, ) -> FixtureFunctionMarker | FixtureFunction: """Decorator to mark a fixture factory function. @@ -1316,11 +1310,6 @@ def fixture( function arg that requests the fixture; one way to resolve this is to name the decorated function ``fixture_`` and then use ``@pytest.fixture(name='')``. - - :param cache_result: - If True (the default), the fixture result is cached and the fixture - only runs once per scope. - If False, the fixture will run each time it is requested """ fixture_marker = FixtureFunctionMarker( scope=scope, @@ -1329,7 +1318,6 @@ def fixture( ids=None if ids is None else ids if callable(ids) else tuple(ids), name=name, _ispytest=True, - cache_result=cache_result ) # Direct decoration. @@ -1660,7 +1648,6 @@ def _register_fixture( params: Sequence[object] | None = None, ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None, autouse: bool = False, - cache_result: bool = True, ) -> None: """Register a fixture @@ -1691,7 +1678,6 @@ def _register_fixture( params=params, ids=ids, _ispytest=True, - use_cache=cache_result, ) faclist = self._arg2fixturedefs.setdefault(name, []) @@ -1788,7 +1774,6 @@ def parsefactories( params=marker.params, ids=marker.ids, autouse=marker.autouse, - cache_result=marker.cache_result ) def getfixturedefs( diff --git a/src/_pytest/scope.py b/src/_pytest/scope.py index 976a3ba242..5330d3da6b 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,4 @@ 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 index 1f27e5acc3..d62b5c5ad7 100644 --- a/testing/test_no_cache.py +++ b/testing/test_no_cache.py @@ -7,7 +7,7 @@ def test_setup_teardown_executed_for_every_fixture_usage_without_caching(pyteste import pytest import logging - @pytest.fixture(cache_result=False) + @pytest.fixture(scope="invocation") def fixt(): logging.info("&&Setting up fixt&&") yield @@ -42,7 +42,7 @@ def test_setup_teardown_executed_for_every_getfixturevalue_usage_without_caching import pytest import logging - @pytest.fixture(cache_result=False) + @pytest.fixture(scope="invocation") def fixt(): logging.info("&&Setting up fixt&&") yield @@ -67,7 +67,7 @@ def test_non_cached_fixture_generates_unique_values_per_usage(pytester: Pytester """ import pytest - @pytest.fixture(cache_result=False) + @pytest.fixture(scope="invocation") def random_num(): import random return random.randint(-100_000_000_000, 100_000_000_000) @@ -94,7 +94,7 @@ def test_non_cached_fixture_generates_unique_values_per_getfixturevalue_usage(py """ import pytest - @pytest.fixture(cache_result=False) + @pytest.fixture(scope="invocation") def random_num(): import random yield random.randint(-100_000_000_000, 100_000_000_000) 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: From bd6126b35c2ab7e351e995cfae6ee0554dd64e17 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 5 Oct 2024 14:07:35 +0000 Subject: [PATCH 3/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/fixtures.py | 18 ++++++++---- src/_pytest/scope.py | 4 ++- testing/test_no_cache.py | 60 +++++++++++++++++++++++++--------------- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 9b713f091f..a77e5c446b 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -535,9 +535,11 @@ def getfixturevalue(self, argname: str) -> Any: "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): + 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] @@ -626,7 +628,10 @@ def _get_active_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: + if ( + arg_fixture is not None + and arg_fixture.scope == Scope.Invocation.value + ): self._fixture_defs.pop(arg_name) return fixturedef @@ -769,7 +774,10 @@ def _check_scope( requested_fixturedef: FixtureDef[object] | PseudoFixtureDef[object], requested_scope: Scope, ) -> None: - if isinstance(requested_fixturedef, PseudoFixtureDef) or requested_scope == Scope.Invocation: + if ( + isinstance(requested_fixturedef, PseudoFixtureDef) + or requested_scope == Scope.Invocation + ): return if self._scope > requested_scope: # Try to report something helpful. diff --git a/src/_pytest/scope.py b/src/_pytest/scope.py index 5330d3da6b..d274e1eca1 100644 --- a/src/_pytest/scope.py +++ b/src/_pytest/scope.py @@ -89,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 and x is not Scope.Invocation] +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 index d62b5c5ad7..76169d5afd 100644 --- a/testing/test_no_cache.py +++ b/testing/test_no_cache.py @@ -1,7 +1,11 @@ +from __future__ import annotations + from _pytest.pytester import Pytester -def test_setup_teardown_executed_for_every_fixture_usage_without_caching(pytester: Pytester) -> None: +def test_setup_teardown_executed_for_every_fixture_usage_without_caching( + pytester: Pytester, +) -> None: pytester.makepyfile( """ import pytest @@ -26,17 +30,22 @@ 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, - ]) + 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: +def test_setup_teardown_executed_for_every_getfixturevalue_usage_without_caching( + pytester: Pytester, +) -> None: pytester.makepyfile( """ import pytest @@ -56,13 +65,17 @@ def test(request): ) result = pytester.runpytest("--log-level=INFO") assert result.ret == 1 - result.stdout.fnmatch_lines([ - *["*&&Setting up fixt&&*"] * 3, - *["*&&Tearing down fixt&&*"] * 3, - ]) + 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: +def test_non_cached_fixture_generates_unique_values_per_usage( + pytester: Pytester, +) -> None: pytester.makepyfile( """ import pytest @@ -71,25 +84,28 @@ def test_non_cached_fixture_generates_unique_values_per_usage(pytester: Pytester 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: +def test_non_cached_fixture_generates_unique_values_per_getfixturevalue_usage( + pytester: Pytester, +) -> None: pytester.makepyfile( """ import pytest @@ -98,8 +114,8 @@ def test_non_cached_fixture_generates_unique_values_per_getfixturevalue_usage(py 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]