Account for changes in fixture dependencies properly#14104
Account for changes in fixture dependencies properly#14104Anton3 wants to merge 5 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 |
|
Could anyone review this please? |
RonnyPfannschmidt
left a comment
There was a problem hiding this comment.
i just did a preliminary skim of the change
the finalizer handles are a interesting way to sort the dependencies out, unfortunately it creates a very global way to sort it
i haven’t done my own die diligence yet, i hope that there’s potentially a sensible way to integrate the detail handling of this with setupstate,
after my first iteration im getting the impression it should be integrated into finalizers in a more direct manner
this is quite the tricky problem, in particular wit the current architecture
i am of the impression that a missing underlying refactoring/feature addition is making the task you set out to solve very hard
we have to be very careful about the effect of the tech debt incurred by this
|
@RonnyPfannschmidt fixed + replied Why removal handles are needed:
In #4871 they also suggest that ideally we should avoid fixture finalizers and instead plan fixture setup-teardown at collection stage. This is impossible, because, as explained here, custom implementations of An alternative implementation without finalizers would be the following:
Still, I can't see a non-ad-hoc way of avoiding the pile-up of finalizers of parametrized fixtures in nodes. Removal handles as currently in the PR are the most straightforward solution that I see. A solution for nodes without handles would be to remember the node for which we have a finalizer currently registered and not readd it on fixture recomputation. The drawback would be incorrect finalizer ordering: @pytest.fixture(scope="module")
def foo():
...
@pytest.fixture(scope="module")
def bar():
...
@pytest.mark.parametrize("foo", [1], indirect=True)
def test1(foo, bar):
...
@pytest.mark.parametrize("foo", [2], indirect=True)
def test2(bar, foo):
...The order of fixture teardown on But the instance of AFAICS the only way the correct order can be achieved is by removing the finalizers of |
|
ok, i need to reiterate the handles idea a bit more fir my own understaniding i think the fact that its currently a global variable is part of my confusion ideally the complete details on caching/recreation and current fixture closure turn into the responsibility of the setup state but that is sooo out of scope for this change i do believe having the handles as one variable may create possibly neglectable issues wit in process pytest runs for testing pytest/pytest plugins - off hand it looks to me as if we could leave handles in place by accident if the pytest under test gets really broken - i suspect that this could be resolved by having a weakref.WeakKeyDictionary which would automatically clean up |
|
Alternatively, there is the following compromise:
What do you think? Edit: After thinking for a while, I still believe handles are a cleaner solution, because this approach
|
|
I don't think weakref can be used with handles. If we drop a handle, that does not mean that we no longer want the finalizer to run, unless we want to break the current finalizers API for users. What dropping a handle currently means in the PR is that we don't want the ability to remove the finalizer. Edit: actually, a handle could keep a weakref to If handles are kept past their intended scope by mistake, then finalizers are eventually invoked and cleared, and handles are left owning a bunch of |
|
@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 |
|
@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
| 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.
89342ab to
f4f968a
Compare
|
@bluetech done |
| * Teardowns of multiple fixtures with the same containing node run in the reverse | ||
| order of fixture setup. |
There was a problem hiding this comment.
This rule is actually broken for parametrized fixtures. The actual order (as implemented in the PR) is:
function-scoped fixturesclass,module,package,session-scoped parametrized fixtures, as well as their dependencies - first in the reverse order of parametrized fixture setup, then (for dependencies of a given parametrized fixture) in the reverse order of fixture setupclass-scoped fixturesmodule-scoped fixturespackage-scoped fixturessession-scoped fixtures
That's still better than the status quo (where parametrized fixture teardown runs at the test setup stage), but needs further refinement (perhaps in follow-up PRs).
There was a problem hiding this comment.
Thinking about it, we can eventually come up with the perfect algorithm for SetupState, if it knows about all fixture dependencies (not wrapped in opaque finalizers).
Instead of FinalizerHandle, I'll add deeper fixture dependency info to SetupState.
There was a problem hiding this comment.
This sacrifices the current ordering of user finalizers w.r.t. teardown of fixtures that depend on current fixture / node.
This PR adds `FixtureRequest._own_fixture_defs` and uses it for a more accurate `--setuponly` and `--setupshow` output. This might be a bit overkill for this specific purpose, but `_own_fixture_defs` can also be used to track fixture dependencies for proper teardown in case of parametrized fixtures, see PR pytest-dev#14104. Fixes: pytest-dev#14287
bluetech
left a comment
There was a problem hiding this comment.
I'm still reviewing this, but just a couple of comments.
bluetech
left a comment
There was a problem hiding this comment.
I made some small comments inline, here are some high level comments.
First, just stating the problem in my own words a bit.
The basic problem we need to handle is the fact that a high-scope fixture can arbitrarily change its param between items (because pytest allows plugins to choose any arbitrary order of items), which necessities tearing down that fixture and its dependents "early".
By "early" I mean the following. Suppose that this param change scenario wasn't a thing, then it would have been sufficient(?) to rely solely on the node stack for fixture teardown, i.e. attach each fixture to a node in the stack as a dependent, and have the node tear its dependents down when it's normally popped from the stack.
Alas, param changes are a thing so we need to handle this "early" or "sudden" changes which break the stack ordering.
The current code tries to handle this by having fixtures register finalizers on other fixtures and nodes, which is somewhat inefficient, too dynamic and buggy in some edge cases.
In #4871 (comment) I discussed a couple of other solutions, one not really viable and the other probably not either.
The solution proposed here is to stop relying on finalizers and instead "take the hit" and build a full bidirectional dependency graph of fixtures and nodes in SetupState (bidirectional = both dependencies and dependents).
About _own_fixture_defs, I want to see if we can avoid adding it. With it we now have:
arg2fixturedefs-- static argname ->FixtureDefs for the item (after #14290)_fixture_defs-- active argname ->FixtureDeffor the item (both static and dynamic)_active_fixtures-- active FixtureDef -> _FixtureInfo for the item (both static and dynamic)_own_fixture_defs-- active argname ->FixtureDeffor the FixtureRequest (both static and dynamic)- and we also have
SubRequest._parent_request.
I feel like some of this must be redundant and can be consolidated.
Regarding the full bidrectionality of the graph, are we sure we need that?
Ignoring the param-value-change problem for a moment, let's suppose _active_fixtures as a "fixture stack", i.e. setup -> push, teardown -> pop. Before teardown, the stack is effectively the topological ordering in which we chose to execute the fixture DAG. In this case, we could avoid keeping dependent_fixtures as follows: when a Node is torn down, pop and teardown from the _active_fixtures stack as long as the top-of-stack fixture scope is the node being torn down.
But in sudden param-value-change teardown situation, we only want to teardown that fixture and its dependents, not the entire fixture stack up to it (this is the design decision by pytest, which is maybe debatable...). OK, so let's say that for this case we need dependent_fixtures on fixtures. But maybe not for nodes?
The code also keeps dependencies for fixtures. This is needed to unregister from dependent_fixtures of dependency fixtures on teardown, to avoid double teardowns and garbage accumulation on high-scope fixtures. Hmm, it would be nice to avoid dependencies but (without spending too much thought on it) I don't see how.
Another thing this PR fixes is to tear down "stale" fixtures during item teardown rather than nextitem setup. This is achieved in _finish_stale_fixtures by going over all _active_fixtures and seeing if nextitem is going to change their param and if so do an early teardown.
The "go over all active fixtures" part I am a bit worried about, seems to me like it could be quite slow in some scenarios. What can we do to avoid this "full graph scan"?
| self._check_cache_hit(request, self.cached_result[1]) | ||
| return _get_cached_value(self.cached_result) |
There was a problem hiding this comment.
- Let's move the check logic (i.e. the actual
if) back here. Can leave the error raise out of line as it would be too distracting here. - Let's inline
_get_cached_valueback.
The reason is that I feel it's easier to grok the code logic when it's just reading top to bottom, without jumping to other functions.
| @@ -0,0 +1,3 @@ | |||
| Garbage finalizers for fixture teardown are no longer accumulated in nodes and fixtures. | |||
|
|
|||
| :func:`Node.addfinalizer <_pytest.nodes.Node.addfinalizer>` and :func:`request.addfinalizer() <pytest.FixtureRequest.addfinalizer>` now return a handle that allows to remove the finalizer. | |||
There was a problem hiding this comment.
Should remove this paragraph now.
| @@ -0,0 +1 @@ | |||
| Fixtures are now rebuilt when param changes for a fixture they depend on, if the dependency is via :func:`request.getfixturevalue() <pytest.FixtureRequest.getfixturevalue>`. | |||
There was a problem hiding this comment.
| Fixtures are now rebuilt when param changes for a fixture they depend on, if the dependency is via :func:`request.getfixturevalue() <pytest.FixtureRequest.getfixturevalue>`. | |
| Fixtures are now rebuilt when param changes for a fixture they depend on, even if the dependency is via :func:`request.getfixturevalue() <pytest.FixtureRequest.getfixturevalue>`. |
|
|
||
| def test_b(request, foo): | ||
| # This is now a little unnecessary as written, but imagine that this dynamic | ||
| # fixture loading happens inside another fixture or inside an utility function. |
There was a problem hiding this comment.
| # fixture loading happens inside another fixture or inside an utility function. | |
| # fixture loading happens inside another fixture or inside a utility function. |
| assert not self.stack | ||
|
|
||
| def fixture_setup(self, fixturedef: FixtureDef[object], node: Node) -> None: | ||
| """Register the fixture as active. Should be called before fixture setup.""" |
There was a problem hiding this comment.
| """Register the fixture as active. Should be called before fixture setup.""" | |
| """Register the fixture as active. Should be called before fixture setup. | |
| node: | |
| The node in the stack matching the fixture's scope (e.g. for a | |
| module-scoped fixture, the Module node). | |
| """ |
| ) | ||
|
|
||
|
|
||
| def test_fixture_rebuilt_when_param_appears(pytester: Pytester) -> None: |
There was a problem hiding this comment.
That's one wicked test 😀 I kinda wish we didn't allow a fixture to be both parametrized and not parametrized, though maybe there's a realistic use case for it.
|
|
||
| callspec: CallSpec2 | None = getattr(nextitem, "callspec", None) | ||
| if callspec is not None: | ||
| new_cache_key = callspec.params.get(self.argname, None) |
There was a problem hiding this comment.
I think this might need to involve callspec.indices somehow, I don't remember so marking a note to check.
| tuple[OutcomeException | Exception, types.TracebackType | None] | None, | ||
| ], | ||
| ] = {} | ||
| self._active_fixtures: dict[FixtureDef[object], _FixtureInfo] = {} |
There was a problem hiding this comment.
Can we explain that the ordering of _active_fixtures is meaningful and some details about it?
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
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