Skip to content
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

Fix nondeterminism in fixture collection order #2617

Merged
merged 4 commits into from
Jul 30, 2017
Merged

Fix nondeterminism in fixture collection order #2617

merged 4 commits into from
Jul 30, 2017

Conversation

wence-
Copy link
Contributor

@wence- wence- commented Jul 25, 2017

fixtures.reorder_items is non-deterministic because it reorders based
on iteration over an (unordered) set. Change the code to use an
OrderedDict instead, so that we get deterministic behaviour.

The xdist issue in #920 is a red-herring. Fixture collection is non-deterministic across pytest invocations full stop if hash randomisation is on.

For example, consider the following test file:

import pytest

@pytest.fixture(scope="module",
                params=["A",
                        "B",
                        "C"])
def A(request):
    return request.param


@pytest.fixture(scope="module",
                params=["DDDDDDDDD", "EEEEEEEEEEEE", "FFFFFFFFFFF", "GGGGGGGGGGGG"])
def B(request, A):
    return request.param


def test_foo(B):
    assert True
$pytest test_foo.py --collect-only
=============================================================================================== test session starts ================================================================================================
platform linux -- Python 3.5.2, pytest-3.1.3, py-1.4.34, pluggy-0.4.0
rootdir: /data/lmitche1/src/doodles, inifile:
plugins: xdist-1.18.1
collected 12 items 
<Module 'test_foo.py'>
  <Function 'test_foo[DDDDDDDDD-A]'>
  <Function 'test_foo[DDDDDDDDD-B]'>
  <Function 'test_foo[EEEEEEEEEEEE-B]'>
  <Function 'test_foo[EEEEEEEEEEEE-A]'>
  <Function 'test_foo[EEEEEEEEEEEE-C]'>
  <Function 'test_foo[DDDDDDDDD-C]'>
  <Function 'test_foo[FFFFFFFFFFF-C]'>
  <Function 'test_foo[FFFFFFFFFFF-B]'>
  <Function 'test_foo[FFFFFFFFFFF-A]'>
  <Function 'test_foo[GGGGGGGGGGGG-C]'>
  <Function 'test_foo[GGGGGGGGGGGG-B]'>
  <Function 'test_foo[GGGGGGGGGGGG-A]'>

=========================================================================================== no tests ran in 0.01 seconds ===========================================================================================
$ pytest test_foo.py --collect-only
=============================================================================================== test session starts ================================================================================================
platform linux -- Python 3.5.2, pytest-3.1.3, py-1.4.34, pluggy-0.4.0
rootdir: /data/lmitche1/src/doodles, inifile:
plugins: xdist-1.18.1
collected 12 items 
<Module 'test_foo.py'>
  <Function 'test_foo[DDDDDDDDD-A]'>
  <Function 'test_foo[EEEEEEEEEEEE-A]'>
  <Function 'test_foo[EEEEEEEEEEEE-B]'>
  <Function 'test_foo[DDDDDDDDD-B]'>
  <Function 'test_foo[FFFFFFFFFFF-B]'>
  <Function 'test_foo[FFFFFFFFFFF-A]'>
  <Function 'test_foo[FFFFFFFFFFF-C]'>
  <Function 'test_foo[EEEEEEEEEEEE-C]'>
  <Function 'test_foo[DDDDDDDDD-C]'>
  <Function 'test_foo[GGGGGGGGGGGG-C]'>
  <Function 'test_foo[GGGGGGGGGGGG-B]'>
  <Function 'test_foo[GGGGGGGGGGGG-A]'>

=========================================================================================== no tests ran in 0.01 seconds ===========================================================================================

Note how successive incantations of pytest arrive at different orders of the collected tests.

The problem lies in fixtures.reorder_items which reorders (in part) based on iteration over an unordered set. Fix this by switching to using an OrderedDict.

It's not obvious to me how to write a good test for this.

Thanks for submitting a PR, your contribution is really appreciated!

Here's a quick checklist that should be present in PRs:

  • Add a new news fragment into the changelog folder
    • name it $issue_id.$type for example (588.bug)
    • if you don't have an issue_id change it to the pr id after creating the pr
    • ensure type is one of removal, feature, bugfix, vendor, doc or trivial
    • Make sure to use full sentences with correct case and punctuation, for example: "Fix issue with non-ascii contents in doctest text files."
  • Target: for bugfix, vendor, doc or trivial fixes, target master; for removals or features target features;
  • Make sure to include reasonable tests for your change if necessary

Unless your change is a trivial or a documentation fix (e.g., a typo or reword of a small section) please:

  • Add yourself to AUTHORS;

@The-Compiler
Copy link
Member

This will probably break on Python 2.6, as collections.OrderedDict was added in 2.7.

@coveralls
Copy link

Coverage Status

Coverage increased (+0.0009%) to 92.057% when pulling 8ff10ee on wence-:fix/nondeterministic-fixtures into 79097e8 on pytest-dev:master.

@nicoddemus
Copy link
Member

Thanks for the PR!

I'm not sure how to easily fix the OrderedDict issue, suggestions are welcome.

About the test, I suggest to use the testdir fixture and inline_genitems() to get the list of collected items and ensure this is called with hash randomization on.

@RonnyPfannschmidt
Copy link
Member

@nicoddemus this test will always have to use subprocesses as hash randomizarion is on a per process basis

@wence-
Copy link
Contributor Author

wence- commented Jul 26, 2017

I am about to push updates with test.

@wence-
Copy link
Contributor Author

wence- commented Jul 26, 2017

Added a test, and required ordereddict from pypi for py2.6.

Although the test I posted in the original comment reliably fails with different values of PYTHONHASHSEED, it does not reliably fail when run as a subprocess from the full pytest test suite (when providing the same PYTHONHASHSEED variation to the subprocesses). I cannot understand how this might happen.

@The-Compiler
Copy link
Member

I'm okay with adding the dependency, but that should definitely be noted in the changelog, and the PR should go to the features branch.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.02%) to 92.038% when pulling 2d2e97b on wence-:fix/nondeterministic-fixtures into 79097e8 on pytest-dev:master.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.02%) to 92.038% when pulling 2d2e97b on wence-:fix/nondeterministic-fixtures into 79097e8 on pytest-dev:master.

@wence- wence- changed the base branch from master to features July 26, 2017 12:56
@wence-
Copy link
Contributor Author

wence- commented Jul 26, 2017

Added new dependency for py26 in both changelog and docs/getting-started.rst. Retargeted to features rather than master.

Is there a policy on python2.6 support? I note that the last ever bug-fix release was October 2013.

@nicoddemus
Copy link
Member

I'm fine with depending on ordereddict for py26, we already do the same with argparse.

Is there a policy on python2.6 support? I note that the last ever bug-fix release was October 2013.

We plan to drop it as soon as pip does in pip 10.0, see #1273.

Copy link
Member

@nicoddemus nicoddemus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot!

I commented on some small nitpicks below.

@@ -1,6 +1,12 @@
from __future__ import absolute_import, division, print_function
import sys

try:
from collections import OrderedDict
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check the version explicitly instead of relying on ImportError:

if sys.version_info[:2] == (2, 6):
    from ordereddict import OrderedDict
else:
    from collections import OrderedDict

Using ImportError for that may lead to nasty surprises (see pytest-dev/pytest-mock#68 for a detailed example), plus this will make it easier for us later to find it and remove it when we drop 2.6 support.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I guess it could go into _pytest/compat.py instead of here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, I guess this import could go into _pytest/compat.py rather than here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, this could be:

from _pytest.compat import OrderedDict

👍

@@ -2547,6 +2548,44 @@ def test_foo(fix):
'*test_foo*alpha*',
'*test_foo*beta*'])

@pytest.mark.issue920
def test_deterministic_fixture_collection(self, testdir):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the monkeypatch fixture instead of importing and creating your own MonkeyPatch instance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

monkeypatch = MonkeyPatch()
monkeypatch.setenv("PYTHONHASHSEED", "1")
out1 = testdir.runpytest_subprocess("-v")
monkeypatch.undo()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to undo here

monkeypatch.undo()
monkeypatch.setenv("PYTHONHASHSEED", "2")
out2 = testdir.runpytest_subprocess("-v")
monkeypatch.undo()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nor here, the monkeypatch fixture ensures to undo itself when the test ends.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, done.

@@ -2547,6 +2548,44 @@ def test_foo(fix):
'*test_foo*alpha*',
'*test_foo*beta*'])

@pytest.mark.issue920
def test_deterministic_fixture_collection(self, testdir):
a = testdir.mkdir("a")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure you need the sub directory, plus you can ask testdir to create the file for you:

testdir.makepyfile(''''
    import pytest

    @pytest.fixture(scope="module",
    ...

This will create a test file named test_deterministic_fixture_collection.py (same as the test name) in the test directory.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see, I was cargo-culting from the wrong place.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.02%) to 91.899% when pulling b6ead46 on wence-:fix/nondeterministic-fixtures into 309152d on pytest-dev:features.

fixtures.reorder_items is non-deterministic because it reorders based
on iteration over an (unordered) set.  Change the code to use an
OrderedDict instead so that we get deterministic behaviour, fixes #920.
Copy link
Member

@nicoddemus nicoddemus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome thanks @wence- !

That last bit about _pytest.compat is up to you and minor, it can be merged as soon as CI passes IMO.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.008%) to 91.909% when pulling f047e07 on wence-:fix/nondeterministic-fixtures into 309152d on pytest-dev:features.

@wence-
Copy link
Contributor Author

wence- commented Jul 26, 2017

That last bit about _pytest.compat is up to you and minor,

I'm happy to leave as is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants