Account for changes in fixture dependencies properly#14104
Account for changes in fixture dependencies properly#14104Anton3 wants to merge 28 commits intopytest-dev:mainfrom
Conversation
|
@RonnyPfannschmidt please review 😃 |
RonnyPfannschmidt
left a comment
There was a problem hiding this comment.
I'm currently not able to do a deep review
This has a few details that give me headaches
- it sidesteps setup/teardown minimizing and im not sure if its feasible to integrate in a comprehensible manner
- its unclear to me what happens if the involved fixtures are indirect to a test
- Its unclear to me what happens if someone creates a cycle via the new dependency tracking
Are you wondering if fixing #14095 is an overall positive change? If so, I can remove that part from the PR for now, and it can be investigated later separately. There is also a minor issue with my implementation. When I do request._execute_fixturedef(fixturedef, only_maintain_cache=True)it may I can change it so that the other fixtures are not
What do you mean? Parametrized / non-parametrized case? In the parametrized case, we cannot get the parametrized fixture using
The initial cache handling step uses There is |
|
trigger a rebuild is more correct than not with indirect i mean what happens if a replaced fixture is not a dependency of the directly used fixtures, but rather down in the dependency tree with loops i mean what actually happens when one fixture goads another ones closure into calling getfixturevalue for itself |
|
I've found another issue in my implementation. Suppose we depend on some fixture dynamically. After a recomputation we stop depending on it. Everything is fine, but at some point that fixture gets finalized, and because we did One solution is to add "evaluation epoch" to the finalizers to avoid finalizing a more recent evaluation of the fixture than expected. |
|
teardown should idieally only happen in teardown phases - thats why the teardown_towards heleprs got added we should fail rather than teardown in setup/call phases |
|
I've moved parametrized fixture finalization to teardown phase. In We need to be cautious with what to teardown when. Consider these tests: @pytest.fixture(scope="session")
def foo(request):
return request.param
@pytest.mark.parametrize("foo", [1], indirect=True)
def test_a(foo):
pass
@pytest.mark.parametrize("foo", [1], indirect=True)
def test_b(foo):
pass
def test_c():
pass
@pytest.mark.parametrize("foo", [2], indirect=True)
def test_d(foo):
passFinalizing For each parametrized fixture in the current item we should find the next item that uses the fixture with that parameter and if parameters differ then finalize the fixture in the current item's teardown. This requires some code restructuring, because we now require arbitrary lookahead instead of just Overall, setup-plan did get prettier. A hefty chunk of I need to write more tests, for issues you pointed out and some more:
|
25fab5f to
643afdf
Compare
|
I've reworked More importantly, this disallows
The implementations of Alternatively, we could avoid all of those troubles by dropping support for carrying fixtures along tests that do not depend on them: @pytest.fixture(scope="session")
def foo(request):
return request.param
@pytest.parametrize("foo", [1], indirect=True)
def test_a(foo):
pass
def test_b():
assert 2 + 2 == 4
@pytest.parametrize("foo", [1], indirect=True)
def test_c(foo):
passIn this case, pytest auto-reorders fixtures based on param values (if reordering is not turned off by the user), but in a more complex scenario additional teardown-setup work will surface: @pytest.fixture(scope="session")
def foo():
pass
@pytest.fixture(scope="session")
def bar():
pass
@pytest.fixture(scope="session")
def baz():
pass
@pytest.parametrize(("foo", "bar"), [1, 1], indirect=True)
def test_a(foo, bar):
pass
@pytest.parametrize(("bar, "baz"), [1, 1], indirect=True)
def test_b(bar, baz):
pass
@pytest.parametrize(("baz, "foo"), [1, 1], indirect=True)
def test_c(bar, baz):
passCurrently all those fixtures are carried over. They will not be carried over. And so I come back to the "disgusting" idea of tearing down parametrized fixtures in the test before the parameter changes. So in this test setup, |
|
Done! I should add the following tests (besides the ones mentioned earlier in one, two):
|
|
By the way, this PR also relates to:
|
|
@RonnyPfannschmidt what do you think is the lesser evil in this case? import pytest
@pytest.fixture(scope="session")
def foo(request):
return getattr(request, "param", None)
@pytest.mark.parametrize("foo", [1], indirect=True)
def test_a(foo):
assert foo == 1
def test_b(request):
hmmm(request)
@pytest.mark.parametrize("foo", [1], indirect=True)
def test_c(foo):
assert foo == 1
* Assume that parametrized tests autoreordering is turned off, or that the user forced a custom order, or that there are multiple parameters, and autoreordering had to pick an argname to not be consecutive, and our fixture was picked. UPD: I will go with option 1 for now, because in most cases extra setup-teardown won't happen due to autoreordering, and worst-case scenario is tests slowdown, while option 2 risks breaking some tests in-the-wild (I'm almost certain that I'll find out at my worksite that the new version of pytest broke some of the millions of pytest tests.) |
|
teardown when the next item doesnt use is worse than failure at first glance the failure is a acceptable first step as it turns a silent error into a visible one, but the real issue is that teardown_towards should tear it down in the teardown of test_b as test_c has indeed different parameters |
There is no issue there:
The issue is only in |
|
But I got the idea, you think failing explicitly is better than the current state of teardown in setup phase. Will go on with this approach. |
|
the goal should be to eventually e able to do it in teardown_towards but bascially if a fixture has to be removed in the setup phase something went wrong i do fear that i am missing some edge cases here where the teardown is correct but my current understanding is that the teardown ought to happen in the teardown phase of the prior test item |
|
@RonnyPfannschmidt ready for review |
|
@RonnyPfannschmidt I've moved fixture finalizers management to |
|
Thanks for moving it into setupstate That removes my biggest concern I did another skim and now it looks solid at first glance to me As this necessary is something big ill have to allocate some time for a deeper read Thank you |
RonnyPfannschmidt
left a comment
There was a problem hiding this comment.
this looks correct to me now
I’m tempted to suggest figuring how to make the finalizer handles a bit less opaque - to ease debugging the data (should be a followup)
i'd like a second pair of eyes on it and i think i have to run a local test
based on the current code and integration in setupstate i'm confident this is good to go
my due diligence needs some more work
thanks for getting this started and powering through the follow-ups
this was hard and important work and I’m grateful someone actually went down that rabbit hole -
|
@RonnyPfannschmidt I've reworked the |
|
Please someone else review this PR! |
|
@nicoddemus @bluetech ping on this one - i'll also try to take another looks tis week but im stretched very thin amt |
|
I've skimmed through fixture-related issues updated in the last 2 years:
|
|
@nicoddemus @bluetech Could you please take a look at the PR? |
RonnyPfannschmidt
left a comment
There was a problem hiding this comment.
i did another round of walk-trough and approve this - i'd still like another pair of critical eyes as this one is a important enhancement that introduces a new mechanism
|
@The-Compiler Could you perhaps take a look at the PR? |
|
@nicoddemus @bluetech gentle ping? |
|
Sorry I won't be able to review this properly. @RonnyPfannschmidt if you have looked at it thoroughly and are confident with the result, feel free to go ahead and merge it. |
|
I will try to review this. If you can split the changes to smaller atomic commits (if possible) that would help a lot. Also, if you can update the pull request summary to explain what is the problem being fixed here that would also help. Currently it lists the code changes, which don't explain the rationale, and links to issues, which I'm sure explain the problems but are a bit hard to sift through. So a clear description would help a lot. |
|
@bluetech I'm afraid the main change in I've written a detailed description. Walking through the tests and linked issues again was actually useful, I've found that #2043 is actually irrelevant to this PR. For now, please try reading through the PR as-is and let me know if anything is unclear. One more thing I could try is adding comments throughout the PR, describing what problems are being solved. This may or may not prove to be necessary. |
|
@Anton3 I understand if it can't be broken down. Thanks for adding the description. I will try to take a look this weekend. |
|
@bluetech friendly reminder 👀 |
|
@bluetech weekly ping 🤔 |
bluetech
left a comment
There was a problem hiding this comment.
Hey @Anton3,
Thanks for the impressive work!
I am still going through this PR.
First, I split the pytest_fixture_post_finalizer changes to #14275, please have a look at my comments there. If the changes there are merged, I will ask you to rebase here.
In the meantime please see the small comments below (still not a review of the main changes).
src/_pytest/runner.py
Outdated
| # Push onto the stack. | ||
| self.stack[col] = ([col.teardown], None) | ||
| finalizers = _FinalizerStorage() | ||
| _append_finalizer(finalizers, col.teardown) |
There was a problem hiding this comment.
Here we throw away the FinalizerHandle, so I figure it's better not to allocate it. Maybe inline _append_finalizer?
| if nextitem is None: | ||
| assert not self.stack | ||
|
|
||
| def fixture_setup(self, fixturedef: FixtureDef[object]) -> None: |
There was a problem hiding this comment.
Please add a small docstring to the non-private methods of SetupState. SetupState is not a public API, but I mean as documentation for the internal users of it.
It would also be good to discuss the fixture stuff in SetupState's docstring, it currently only discusses the setup/teardown for nodes.
src/_pytest/runner.py
Outdated
| except TEST_OUTCOME as e: | ||
| exceptions.append(e) | ||
|
|
||
| self._fixture_finalizers.pop(fixturedef) |
There was a problem hiding this comment.
| self._fixture_finalizers.pop(fixturedef) | |
| del self._fixture_finalizers[fixturedef] |
src/_pytest/runner.py
Outdated
| msg = f'errors while tearing down fixture "{fixturedef.argname}" of {node}' | ||
| raise BaseExceptionGroup(msg, exceptions[::-1]) | ||
|
|
||
| def _finish_stale_fixtures(self, nextitem: Item) -> None: |
There was a problem hiding this comment.
It would be good to explain here what is a "stale fixture".
|
|
||
| def _finish_stale_fixtures(self, nextitem: Item) -> None: | ||
| exceptions: list[BaseException] = [] | ||
| for fixturedef in reversed(list(self._fixture_finalizers.keys())): |
There was a problem hiding this comment.
I assume the list() here is necessary because the dict can change? Can the situation where it changes cause problems?
There was a problem hiding this comment.
It actually can, thanks for discovering! Fixed and added test_getfixturevalue_parametrized_dependency_order
changelog/9287.bugfix.rst
Outdated
|
|
||
| Previously teardown would happen in the setup stage of the test where the parameter changes. | ||
|
|
||
| If a test forces teardown of a parametrized fixture, e.g. using ``request.getfixturevalue()``, it instead fails. An example of such test: |
There was a problem hiding this comment.
| If a test forces teardown of a parametrized fixture, e.g. using ``request.getfixturevalue()``, it instead fails. An example of such test: | |
| If a test forces teardown of a parametrized fixture, e.g. using :func:`request.getfixturevalue() <pytest.FixtureRequest.getfixturevalue>`, it instead fails. An example of such test: |
|
|
||
| This produces the following error: | ||
|
|
||
| .. code-block:: console |
There was a problem hiding this comment.
Maybe also show the explanation part?
This could happen because the current test requested the previously parametrized fixture dynamically via 'getfixturevalue' and did not provide a parameter for the fixture.
Either provide a parameter for the fixture, or make fixture 'foo' statically reachable from the current test, e.g. by adding it as an argument to the test function.
| fixturedef = fixturedefs[index] | ||
|
|
||
| # Prepare a SubRequest object for calling the fixture. | ||
| try: |
There was a problem hiding this comment.
Is this change necessary? If not, I'd leave it as-is, it works better with "find usages" in an IDE, and to reduce the diff.
|
|
||
| @property | ||
| def node(self): | ||
| def node(self) -> nodes.Node: |
There was a problem hiding this comment.
Please remove this change, the missing type annotation here is intentional.
There was a problem hiding this comment.
Then mypy complains in TopRequest.addfinalizer:
src/_pytest/fixtures.py:711: error: Returning Any from function declared to return "FinalizerHandle" [no-any-return]
Note that I'm only adding this type annotation to TopRequest, not to FixtureRequest.
| setupstate = self._cached_request.session._setupstate | ||
| return setupstate.fixture_addfinalizer(finalizer, self) | ||
|
|
||
| def finish(self, request: SubRequest) -> None: |
There was a problem hiding this comment.
Here we now have request == self._cached_request (at least running pytets's tests with this assert passes). So we can remove the request parameter, but I need to think a bit more if that's the way.
Move fixture teardown to
SetupStateA fixture's teardown should first perform teardown of the dependent fixtures - this is the only known way that maintains the correct teardown order in all cases. Before this PR, this code resided in
FixtureDef. The disadvantage is that the responsibilities and implementation ofSetupState(including exception handling) was spread across the codebase. This PR moves the execution of fixture teardown intoSetupState, which facilitates code reuse and enforces Single-Responsibility Principle.A more important issue was that fixture teardown for parametrized fixtures was performed during the setup of the next test. This lead to incorrect attribution of fixture teardown errors, time spent and logs to the next test. This PR makes sure to precompute
paramchanges for parametrized fixtures in advance usingnextitemand always runs fixture teardown at test teardown stage.Relevant tests:
test_teardown_stale_fixtures_single_exception- shows correct attribution of teardown errorstest_teardown_stale_fixtures_multiple_exceptionstest_parametrized_fixture_carries_over_unaware_itemtest_fixture_rebuilt_when_param_appearstest_fixture_not_rebuilt_when_not_requestedtest_cache_mismatch_error_on_sudden_getfixturevalue- as was insisted in the review comments, if we discover at setup or call stage that a fixture needs to be recomputed, we prefer to error out insteadtest_cache_key_mismatch_error_on_unexpected_param_changetest_fixture_teardown_when_not_requested_but_param_changestest_fixture_teardown_with_reordered_teststest_parametrized_fixtures_teardown_in_reverse_setup_ordertest_parametrized_fixture_teardown_order_with_mixed_scopestest_override_fixture_with_new_parametrized_fixture- this is a peculiar test that works by sheer luck; we should track fixture overrides more precisely in the futureCloses: #9287
Add
FinalizerHandleto avoid accumulating stale finalizersTo reiterate, adding teardown of the dependent fixture as a "finalizer" to the dependency-fixture is the only known way of achieving the fully correct teardown order. But this meant that stale finalizers from
function-scoped fixtures accumulated at higher-scoped fixtures andNodes. This manifested as a "memory leak" in very large test suites.Besides performance concerns, with
getfixturevaluedependency tracking added, a stale finalizer could result in tearing down a fixture randomly.To solve those issues, this PR introduces
FinalizerHandlethat allows to remove stale finalizers, and plucks all remaining self-finalizers at fixture's teardown.Relevant tests:
test_stale_finalizer_not_invokedCloses: #4871
Execute
pytest_fixture_post_finalizeronce per fixture teardownThis is implemented by turning
pytest_fixture_post_finalizercall into one of fixture finalizers inSetupState.Also, exception handling is improved: if
pytest_fixture_post_finalizerraises an exception, the fixture is still reset correctly.Relevant tests:
test_fixture_post_finalizer_called_oncetest_fixture_post_finalizer_hook_exceptionCloses: #5848
Closes: #14114
Account for changes in
getfixturevaluedependenciesPreviously, for a given fixture, only dependencies from
argnameswere checked to see if they need to be recomputed, recomputing the current fixture. Meanwhile, if a fixture depends on a parametrized fixture viagetfixturevalue, then the current fixture's value could erroneously be carried over, even though the dependency was recomputed due to its parameter change.The fix involves tracking fixture dependencies (
_own_fixture_defs) inFixtureRequest, then adding finalizers to all those fixtures inFixtureDef.execute.This precise dependency information was also used in
setuponly.py.Relevant tests:
test_getfixturevalue_parametrized_dependency_trackedtest_getfixturevalue_parametrized_dependency_ordertest_request_stealing_then_getfixturevalue_on_parametrizedCloses: #14103