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

#3034 Added new option "--new-first" #3218

Merged
merged 3 commits into from
Mar 2, 2018
Merged

#3034 Added new option "--new-first" #3218

merged 3 commits into from
Mar 2, 2018

Conversation

feuillemorte
Copy link
Contributor

Fixes #3034
Added new option --new-first

How it works:
Create different test files:
tests
|- test_one.py
- test_two.py

Every file contains two tests:

def test_1():
    assert 1

def test_2():
    assert 1

run command

pytest tests -v

result:

tests/test_one.py::test_1 PASSED
tests/test_one.py::test_2 PASSED
tests/test_two.py::test_1 PASSED
tests/test_two.py::test_2 PASSED

run command

pytest tests -v --nf

result:

tests/test_two.py::test_1 PASSED
tests/test_two.py::test_2 PASSED
tests/test_one.py::test_1 PASSED
tests/test_one.py::test_2 PASSED

Order changed to modifyed files first

Add new test to test_two.py file and then modify test_one.py file

run command

pytest tests -v --nf

result:

tests/test_two.py::test_3 PASSED
tests/test_one.py::test_1 PASSED
tests/test_one.py::test_2 PASSED
tests/test_two.py::test_1 PASSED
tests/test_two.py::test_2 PASSED

Order changed: first new test, then modified files

@coveralls
Copy link

coveralls commented Feb 13, 2018

Coverage Status

Coverage increased (+0.07%) to 92.593% when pulling c032d4c on feuillemorte:3034-new-tests-first into 0a3c80e on pytest-dev:features.

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 work @feuillemorte, this is a great first pass. Please take a look at my comments and see what you think. 👍

def __init__(self, config):
self.config = config
self.active = config.option.newfirst
self.all_items = config.cache.get("cache/allitems", {})
Copy link
Member

Choose a reason for hiding this comment

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

I believe the default value here should be [], for consistency more than anything else.

other_items = []
for item in items:
mod_timestamp = os.path.getmtime(str(item.fspath))
if self.all_items and item.nodeid not in self.all_items:
Copy link
Member

Choose a reason for hiding this comment

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

Unfortunately item.nodeid not in self.all_items will be a linear search, which can be expensive if we have a large collection.

I think you should change new_items and other_items to a dict(nodeid -> timestamp) instead, given that you will be sorting them later anyway.

Also you can check just with if item.nodeid not in self.all_items: because and empty list or dict will return False for the containment check.

Copy link
Member

Choose a reason for hiding this comment

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

the code is incorrect about the typo of all_items, it changes multiple times

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@RonnyPfannschmidt You mean I need to rename it to smth like "cached_items"?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@nicoddemus

I think you should change new_items and other_items to a dict(nodeid -> timestamp) instead, given that you will be sorting them later anyway.

In this case I will not know about new tests, isn't it? Only about modified files

Copy link
Member

Choose a reason for hiding this comment

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

In this case I will not know about new tests, isn't it? Only about modified files

I did not explain correctly sorry, I meant this:

            new_items = {}
            other_items = {}
            for item in items:
                if item.nodeid not in self.all_items:
                    new_items[item.nodeid] = item
                else:
                    other_items[item.nodeid] = item

And you can get the timestamp during your sorting function:

            items[:] = self._get_increasing_order(six.itervalues(new_items)) + \
                self._get_increasing_order(six.itervalues(other_items.values))
            ...

    def _get_increasing_order(self, items):
        return sorted(items, key=lambda item: item.fspath.mtime(), reverse=True)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@nicoddemus this is not working in py27. Every time different order:

test_2/test_2.py::test_1[1] PASSED
test_2/test_2.py::test_1[2] PASSED
test_1/test_1.py::test_1[2] PASSED
test_1/test_1.py::test_1[1] PASSED

I think it's better to save my code based on lists. What do you think?

Copy link
Member

Choose a reason for hiding this comment

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

Try changing new_items and other_items to a OrderedDict instead, that should be equivalent to the previous code.

if config.getoption("cacheshow") or hasattr(config, "slaveinput"):
return
config.cache.set("cache/allitems",
[item.nodeid for item in self.all_items if isinstance(item, Function)])
Copy link
Member

Choose a reason for hiding this comment

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

Why are you testing isinstance 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.

Please, check testing/acceptance_test.py:584

monkeypatch.setenv('PYTHONPATH', join_pythonpath(testdir))
result = testdir.runpytest("--pyargs", "tpkg.test_missing", syspathinsert=True)
assert result.ret != 0
result.stderr.fnmatch_lines([
"*not*found*test_missing*",
])

It failes because sometimes item is a string, not object:

AttributeError: 'unicode' object has no attribute 'nodeid'

Copy link
Member

Choose a reason for hiding this comment

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

I see thanks. Sometimes pytest_collect_modifyitems can received strings, weird. Didn't take the time to investigate this further, but regardless I think it is safer to check for pytest.Item instead while still in pytest_collect_modifyitems:

self.cached_nodeids = [x.nodeid for x in items if isinstance(x, pytest.Item)]

(Also already suggesting renaming all_items to cached_nodeids). During pytest_sessionfinish you can dump it directly:

config.cache.set("cache/allitems", self.cached_nodeids )

@@ -179,6 +216,10 @@ def pytest_addoption(parser):
help="run all tests but run the last failures first. "
"This may re-order tests and thus lead to "
"repeated fixture setup/teardown")
group.addoption(
'--nf', '--new-first', action='store_true', dest="newfirst",
help="run all tests but run new tests first, then tests from "
Copy link
Member

Choose a reason for hiding this comment

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

I think this reads better: run tests from new files first, then the rest of the tests sorted by file mtime

doc/en/cache.rst Outdated
New tests first
-----------------

The plugin provides command line options to run tests in another order:
Copy link
Member

Choose a reason for hiding this comment

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

I think this should be moved closer to --lf, right before the The new config.cache object section.

Copy link
Member

Choose a reason for hiding this comment

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

I also think we should use the same blurb from the changelog. 👍

@@ -0,0 +1 @@
Added new option `--nf`, `--new-first`. This option enables run tests in next order: first new tests, then last modified files with tests in descending order (default order inside file).
Copy link
Member

Choose a reason for hiding this comment

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

Suggestion:

New ``--nf``, ``--new-first`` options: run new tests first followed by the rest of the tests, in both cases tests are also sorted by the file modified time, with more recent files coming first.


class TestNewFirst(object):
def test_newfirst_usecase(self, testdir):
t1 = testdir.mkdir("test_1")
Copy link
Member

Choose a reason for hiding this comment

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

You can write those files more easily with makepyfile:

testdir.makepyfile(**{
    'test_1/test_1.py': '''
        def test_1(): assert 1
        def test_2(): assert 1
        def test_3(): assert 1
    ''',
    'test_1/test_2.py': '''
        def test_1(): assert 1
        def test_2(): assert 1
        def test_3(): assert 1
    ''' 
})

)

path_to_test_1 = str('{}/test_1/test_1.py'.format(testdir.tmpdir))
os.utime(path_to_test_1, (1, 1))
Copy link
Member

Choose a reason for hiding this comment

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

You can use setmtime here for the same effect.

testdir.tmpdir.join('test_1/test_1.py').setmtime(1)

])

def test_newfirst_parametrize(self, testdir):
t1 = testdir.mkdir("test_1")
Copy link
Member

Choose a reason for hiding this comment

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

Same suggestions here about using testdir. 👍

new_items = []
other_items = []
for item in items:
mod_timestamp = os.path.getmtime(str(item.fspath))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@nicoddemus
Will it work on windows? Could you test if you work on windows, please?

new_items = []
other_items = []
for item in items:
mod_timestamp = os.path.getmtime(str(item.fspath))
Copy link
Member

Choose a reason for hiding this comment

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

use item.fspath.mtime() here - it works on windows btw, the tests passedthere at least

other_items = []
for item in items:
mod_timestamp = os.path.getmtime(str(item.fspath))
if self.all_items and item.nodeid not in self.all_items:
Copy link
Member

Choose a reason for hiding this comment

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

the code is incorrect about the typo of all_items, it changes multiple times

other_items = []
for item in items:
mod_timestamp = os.path.getmtime(str(item.fspath))
if self.all_items and item.nodeid not in self.all_items:
Copy link
Member

Choose a reason for hiding this comment

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

In this case I will not know about new tests, isn't it? Only about modified files

I did not explain correctly sorry, I meant this:

            new_items = {}
            other_items = {}
            for item in items:
                if item.nodeid not in self.all_items:
                    new_items[item.nodeid] = item
                else:
                    other_items[item.nodeid] = item

And you can get the timestamp during your sorting function:

            items[:] = self._get_increasing_order(six.itervalues(new_items)) + \
                self._get_increasing_order(six.itervalues(other_items.values))
            ...

    def _get_increasing_order(self, items):
        return sorted(items, key=lambda item: item.fspath.mtime(), reverse=True)

if config.getoption("cacheshow") or hasattr(config, "slaveinput"):
return
config.cache.set("cache/allitems",
[item.nodeid for item in self.all_items if isinstance(item, Function)])
Copy link
Member

Choose a reason for hiding this comment

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

I see thanks. Sometimes pytest_collect_modifyitems can received strings, weird. Didn't take the time to investigate this further, but regardless I think it is safer to check for pytest.Item instead while still in pytest_collect_modifyitems:

self.cached_nodeids = [x.nodeid for x in items if isinstance(x, pytest.Item)]

(Also already suggesting renaming all_items to cached_nodeids). During pytest_sessionfinish you can dump it directly:

config.cache.set("cache/allitems", self.cached_nodeids )

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 @feuillemorte!

@feuillemorte
Copy link
Contributor Author

@RonnyPfannschmidt please, review :)

@nicoddemus nicoddemus merged commit 0883139 into pytest-dev:features Mar 2, 2018
@@ -56,7 +56,7 @@ def test_error():
assert result.ret == 1
result.stdout.fnmatch_lines([
"*could not create cache path*",
"*1 warnings*",
"*2 warnings*",
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the new warning here? Shouldn't it get matched also?

Copy link
Contributor Author

@feuillemorte feuillemorte Apr 8, 2018

Choose a reason for hiding this comment

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

1st warning is

could not create cache path .pytest_cache/v/cache/lastfailed

and second is for new-first. don't need to create another test for it

@feuillemorte feuillemorte deleted the 3034-new-tests-first branch April 8, 2018 21:23
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