-
-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Move fixture cache from FixtureDef to SetupState #14770
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
base: main
Are you sure you want to change the base?
Changes from all commits
87453b1
9204a14
21565b5
8306a1f
e09fda8
ee27c17
7ebde2f
18813a7
5497a67
c29c6e9
c5b7e16
c5d2887
2a28f9e
f04e086
c9555d4
0269198
18f5852
61286bd
dd68168
b49c266
8a17fc1
052ad16
8d136d4
0e24562
9720a75
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| Moved `FixtureDef.cached_result` to `SetupState`. | ||
|
|
||
| This is part of the effort to add thread safety in pytest. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ | |
| from typing import final | ||
| from typing import Generic | ||
| from typing import Literal | ||
| from typing import NamedTuple | ||
| from typing import NoReturn | ||
| from typing import overload | ||
| from typing import TYPE_CHECKING | ||
|
|
@@ -96,23 +97,32 @@ | |
| FixtureFunction = Callable[..., object] | ||
| # The type of a fixture function (type alias generic in fixture value). | ||
| _FixtureFunc = Callable[..., FixtureValue] | Callable[..., Generator[FixtureValue]] | ||
| # The type of FixtureDef.cached_result (type alias generic in fixture value). | ||
| _FixtureCachedResult = ( | ||
| tuple[ | ||
| # The result. | ||
| FixtureValue, | ||
| # Cache key. | ||
| object, | ||
| None, | ||
| ] | ||
| | tuple[ | ||
| None, | ||
| # Cache key. | ||
| object, | ||
| # The exception and the original traceback. | ||
| tuple[BaseException, types.TracebackType | None], | ||
| ] | ||
| ) | ||
| # Sentinel value for unset fixture params | ||
| _NO_PARAM = object() | ||
|
|
||
|
|
||
| # NamedTuples cannot take generic arguments before Python 3.11 | ||
| if TYPE_CHECKING and sys.version_info >= (3, 11): | ||
|
|
||
| class _FixtureResult(NamedTuple, Generic[FixtureValue]): | ||
| value: FixtureValue | ||
| param: object | ||
| exception_and_traceback: None | ||
| else: | ||
|
|
||
| class _FixtureResult(NamedTuple): | ||
| value: Any | ||
| param: object | ||
| exception_and_traceback: None | ||
|
|
||
|
|
||
| class _FixtureException(NamedTuple): | ||
| value: None | ||
| param: object | ||
| exception_and_traceback: tuple[BaseException, types.TracebackType | None] | ||
|
|
||
|
|
||
| _FixtureCachedResult = _FixtureResult[FixtureValue] | _FixtureException # type: ignore[type-arg] | ||
|
|
||
|
|
||
| def pytest_sessionstart(session: Session) -> None: | ||
|
|
@@ -629,11 +639,44 @@ def getfixturevalue(self, argname: str) -> Any: | |
| # (using function parameters, autouse, etc). | ||
|
|
||
| fixturedef = self._get_active_fixturedef(argname) | ||
| assert fixturedef.cached_result is not None, ( | ||
| fixture_result = self._get_cached_result(fixturedef) | ||
| assert fixture_result is not None, ( | ||
| f'The fixture value for "{argname}" is not available. ' | ||
| "This can happen when the fixture has already been torn down." | ||
| ) | ||
| return fixturedef.cached_result[0] | ||
| return fixture_result.value | ||
|
|
||
| def _get_cached_result( | ||
| self, fixturedef: FixtureDef[FixtureValue] | ||
| ) -> _FixtureCachedResult[FixtureValue] | None: | ||
| return self.session._setupstate.fixture_cache.get(fixturedef) | ||
|
|
||
| def _cache_value( | ||
| self, | ||
| fixturedef: FixtureDef[FixtureValue], | ||
| value: FixtureValue, # type: ignore[misc] | ||
| ) -> None: | ||
| """Write a value into the cache for a fixture definition.""" | ||
| self.session._setupstate.fixture_cache[fixturedef] = _FixtureResult( | ||
| value, self._active_param, None | ||
| ) | ||
|
|
||
| def _cache_exception( | ||
| self, | ||
| fixturedef: FixtureDef[FixtureValue], | ||
| exception: BaseException, | ||
| ) -> None: | ||
| """Write an exception result into the cache for a fixture definition.""" | ||
| self.session._setupstate.fixture_cache[fixturedef] = _FixtureException( | ||
| None, self._active_param, (exception, exception.__traceback__) | ||
| ) | ||
|
|
||
| @property | ||
| def _active_param(self) -> object: | ||
| return getattr(self, "param", _NO_PARAM) | ||
|
|
||
| def _invalidate_fixture_cache(self, fixturedef: FixtureDef[FixtureValue]) -> None: | ||
| del self.session._setupstate.fixture_cache[fixturedef] | ||
|
|
||
| def _iter_chain(self) -> Iterator[SubRequest]: | ||
| """Yield all SubRequests in the chain, from self up. | ||
|
|
@@ -1123,9 +1166,6 @@ def __init__( | |
| self.ids: Final = ids | ||
| # The names requested by the fixtures. | ||
| self.argnames: Final = getfuncargnames(func, name=argname) | ||
| # If the fixture was executed, the current value of the fixture. | ||
| # Can change if the fixture is executed with different parameters. | ||
| self.cached_result: _FixtureCachedResult[FixtureValue] | None = None | ||
| self._finalizers: Final[list[Callable[[], object]]] = [] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unrelated to the PR, more of a question (@RonnyPfannschmidt): what about the finalizers? We likely would also want finalizers to be hosted per-thread? |
||
|
|
||
| # only used to emit a deprecationwarning, can be removed in pytest9 | ||
|
|
@@ -1145,7 +1185,7 @@ def addfinalizer(self, finalizer: Callable[[], object]) -> None: | |
| self._finalizers.append(finalizer) | ||
|
|
||
| def finish(self, request: SubRequest) -> None: | ||
| if self.cached_result is None: | ||
| if request._get_cached_result(self) is None: | ||
| # Already finished. It is assumed that finalizers cannot be added in | ||
| # this state. | ||
| return | ||
|
|
@@ -1161,7 +1201,7 @@ def finish(self, request: SubRequest) -> None: | |
| # Even if finalization fails, we invalidate the cached fixture | ||
| # value and remove all finalizers because they may be bound methods | ||
| # which will keep instances alive. | ||
| self.cached_result = None | ||
| request._invalidate_fixture_cache(self) | ||
| self._finalizers.clear() | ||
| if len(exceptions) == 1: | ||
| raise exceptions[0] | ||
|
|
@@ -1175,7 +1215,8 @@ def execute(self, request: SubRequest) -> FixtureValue: | |
| # This needs to be done before checking if we have a cached value, since | ||
| # if a dependent fixture has their cache invalidated, e.g. due to | ||
| # parametrization, they finalize themselves and fixtures depending on it | ||
| # (which will likely include this fixture) setting `self.cached_result = None`. | ||
| # (which will likely include this fixture), invalidating the respective | ||
| # cached values. | ||
| # See #4871 | ||
| requested_fixtures_that_should_finalize_us = [] | ||
| for argname in self.argnames: | ||
|
|
@@ -1188,27 +1229,27 @@ 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: | ||
| request_cache_key = self.cache_key(request) | ||
| cache_key = self.cached_result[1] | ||
| if (cached_result := request._get_cached_result(self)) is not None: | ||
| active_param = request._active_param | ||
| cached_param = cached_result.param | ||
| try: | ||
| # Attempt to make a normal == check: this might fail for objects | ||
| # which do not implement the standard comparison (like numpy arrays -- #6497). | ||
| cache_hit = bool(request_cache_key == cache_key) | ||
| cache_hit = bool(active_param == cached_param) | ||
| except (ValueError, RuntimeError): | ||
| # If the comparison raises, use 'is' as fallback. | ||
| cache_hit = request_cache_key is cache_key | ||
| cache_hit = active_param is cached_param | ||
|
|
||
| if cache_hit: | ||
| if self.cached_result[2] is not None: | ||
| exc, exc_tb = self.cached_result[2] | ||
| if cached_result.exception_and_traceback is not None: | ||
| exc, exc_tb = cached_result.exception_and_traceback | ||
| raise exc.with_traceback(exc_tb) | ||
| else: | ||
| return self.cached_result[0] | ||
| return cached_result.value # type: ignore[no-any-return] | ||
| # We have a previous but differently parametrized fixture instance | ||
| # so we need to tear it down before creating a new one. | ||
| self.finish(request) | ||
| assert self.cached_result is None | ||
| assert request._get_cached_result(self) is None | ||
|
|
||
| # Add finalizer to requested fixtures we saved previously. | ||
| # We make sure to do this after checking for cached value to avoid | ||
|
|
@@ -1229,7 +1270,6 @@ def execute(self, request: SubRequest) -> FixtureValue: | |
| ihook = request.node.ihook | ||
| try: | ||
| # Setup the fixture, run the code in it, and cache the value | ||
| # in self.cached_result. | ||
| result: FixtureValue = ihook.pytest_fixture_setup( | ||
| fixturedef=self, request=request | ||
| ) | ||
|
|
@@ -1239,9 +1279,6 @@ def execute(self, request: SubRequest) -> FixtureValue: | |
|
|
||
| return result | ||
|
|
||
| def cache_key(self, request: SubRequest) -> object: | ||
| return getattr(request, "param", None) | ||
|
|
||
| def __repr__(self) -> str: | ||
| return f"<FixtureDef argname={self.argname!r} scope={self.scope!r} baseid={self.baseid!r}>" | ||
|
|
||
|
|
@@ -1263,7 +1300,7 @@ def __init__(self, request: FixtureRequest) -> None: | |
| node=request.node, | ||
| _ispytest=True, | ||
| ) | ||
| self.cached_result = (request, [0], None) | ||
| request._cache_value(self, request) | ||
|
|
||
| def addfinalizer(self, finalizer: Callable[[], object]) -> None: | ||
| pass | ||
|
|
@@ -1321,7 +1358,6 @@ def pytest_fixture_setup( | |
| kwargs[argname] = request.getfixturevalue(argname) | ||
|
|
||
| fixturefunc = resolve_fixture_function(fixturedef, request) | ||
| my_cache_key = fixturedef.cache_key(request) | ||
|
|
||
| if inspect.isasyncgenfunction(fixturefunc) or inspect.iscoroutinefunction( | ||
| fixturefunc | ||
|
|
@@ -1342,9 +1378,12 @@ def pytest_fixture_setup( | |
| # Don't show the fixture as the skip location, as then the user | ||
| # wouldn't know which test skipped. | ||
| e._use_item_location = True | ||
| fixturedef.cached_result = (None, my_cache_key, (e, e.__traceback__)) | ||
| request._cache_exception( | ||
| fixturedef, | ||
| e, | ||
| ) | ||
| raise | ||
| fixturedef.cached_result = (result, my_cache_key, None) | ||
| request._cache_value(fixturedef, result) | ||
| return result | ||
|
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -511,6 +511,9 @@ def __init__(self) -> None: | |
| tuple[OutcomeException | Exception, types.TracebackType | None] | None, | ||
| ], | ||
| ] = {} | ||
| # Importing the appropriate types from the fixtures module lead to circular | ||
| # imports, so we leave the cache untyped for now | ||
| self.fixture_cache = {} # type: ignore[var-annotated] | ||
|
Comment on lines
+514
to
+516
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think having the type checking here is important, let's move the type definitions we need to a new |
||
|
|
||
| def is_node_active(self, node: Node) -> bool: | ||
| """Check if a node is currently active in the stack -- set up and not | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -259,6 +259,25 @@ def test_capturing(two): | |
| ) | ||
|
|
||
|
|
||
| def test_suppress_capturing(pytester: Pytester) -> None: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this test defined here?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It was added to make the CI pass. The codecov job seems to fail, if the coverage of the PR is less than 100%. Apparently this also applies to files that are touched by the PR, but didn't have full coverage. This test specifically covers the negative branch of this if statement. |
||
| p = pytester.makepyfile( | ||
| """ | ||
| import pytest, sys | ||
| @pytest.fixture() | ||
| def one(): | ||
| sys.stdout.write('this should not be captured') | ||
| @pytest.fixture() | ||
| def two(one): | ||
| assert 0 | ||
| def test_capturing(two): | ||
| pass | ||
| """ | ||
| ) | ||
|
|
||
| result = pytester.runpytest("--setup-only", "-s", p) | ||
| result.stdout.no_fnmatch_line("this should not be captured") | ||
|
|
||
|
|
||
| def test_show_fixtures_and_execute_test(pytester: Pytester) -> None: | ||
| """Verify that setups are shown and tests are executed.""" | ||
| p = pytester.makepyfile( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code is marked as uncovered or partially covered, even though it's only used for type checking.
Do you have any suggestions how I can deal with that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lets add a
# pragma: no coveron theifline.Or better yet, update the exceptions in
pytest/pyproject.toml
Lines 476 to 487 in ef052d0
if TYPE_CHECKING\s.*