diff --git a/AUTHORS b/AUTHORS index c63c0a00591..dabeb1c061c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -59,6 +59,7 @@ Danielle Jenkins Dave Hunt David Díaz-Barquero David Mohr +David Szotten David Vierra Daw-Ran Liou Denis Kirisov @@ -161,6 +162,7 @@ Miro Hrončok Nathaniel Waisbrot Ned Batchelder Neven Mundar +Niclas Olofsson Nicolas Delaby Oleg Pidsadnyi Oleg Sushchenko @@ -202,6 +204,7 @@ Stefan Zimmermann Stefano Taschini Steffen Allner Stephan Obermann +Sven-Hendrik Haase Tadek Teleżyński Tarcisio Fischer Tareq Alayan diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6b06cbfb526..eef3b42e99e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,72 @@ with advance notice in the **Deprecations** section of releases. .. towncrier release notes start +pytest 3.10.0 (2018-11-03) +========================== + +Features +-------- + +- `#2619 `_: Resume capturing output after ``continue`` with ``__import__("pdb").set_trace()``. + + This also adds a new ``pytest_leave_pdb`` hook, and passes in ``pdb`` to the + existing ``pytest_enter_pdb`` hook. + + +- `#4147 `_: Add ``-sw``, ``--stepwise`` as an alternative to ``--lf -x`` for stopping at the first failure, but starting the next test invocation from that test. See `the documentation `__ for more info. + + +- `#4188 `_: Make ``--color`` emit colorful dots when not running in verbose mode. Earlier, it would only colorize the test-by-test output if ``--verbose`` was also passed. + + +- `#4225 `_: Improve performance with collection reporting in non-quiet mode with terminals. + + The "collecting …" message is only printed/updated every 0.5s. + + + +Bug Fixes +--------- + +- `#2701 `_: Fix false ``RemovedInPytest4Warning: usage of Session... is deprecated, please use pytest`` warnings. + + +- `#4046 `_: Fix problems with running tests in package ``__init__.py`` files. + + +- `#4260 `_: Swallow warnings during anonymous compilation of source. + + +- `#4262 `_: Fix access denied error when deleting stale directories created by ``tmpdir`` / ``tmp_path``. + + +- `#611 `_: Naming a fixture ``request`` will now raise a warning: the ``request`` fixture is internal and + should not be overwritten as it will lead to internal errors. + + + +Improved Documentation +---------------------- + +- `#4255 `_: Added missing documentation about the fact that module names passed to filter warnings are not regex-escaped. + + + +Trivial/Internal Changes +------------------------ + +- `#4272 `_: Display cachedir also in non-verbose mode if non-default. + + +- `#4277 `_: pdb: improve message about output capturing with ``set_trace``. + + Do not display "IO-capturing turned off/on" when ``-s`` is used to avoid + confusion. + + +- `#4279 `_: Improve message and stack level of warnings issued by ``monkeypatch.setenv`` when the value of the environment variable is not a ``str``. + + pytest 3.9.3 (2018-10-27) ========================= @@ -366,7 +432,7 @@ Features the standard warnings filters to manage those warnings. This introduces ``PytestWarning``, ``PytestDeprecationWarning`` and ``RemovedInPytest4Warning`` warning types as part of the public API. - Consult `the documentation `_ for more info. + Consult `the documentation `__ for more info. - `#2908 `_: ``DeprecationWarning`` and ``PendingDeprecationWarning`` are now shown by default if no other warning filter is diff --git a/changelog/2701.bugfix.rst b/changelog/2701.bugfix.rst deleted file mode 100644 index a942234fdb3..00000000000 --- a/changelog/2701.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix false ``RemovedInPytest4Warning: usage of Session... is deprecated, please use pytest`` warnings. diff --git a/changelog/4046.bugfix.rst b/changelog/4046.bugfix.rst deleted file mode 100644 index 2b0da70cd0e..00000000000 --- a/changelog/4046.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix problems with running tests in package ``__init__.py`` files. diff --git a/changelog/4255.doc.rst b/changelog/4255.doc.rst deleted file mode 100644 index 673027cf53f..00000000000 --- a/changelog/4255.doc.rst +++ /dev/null @@ -1 +0,0 @@ -Added missing documentation about the fact that module names passed to filter warnings are not regex-escaped. diff --git a/changelog/4260.bugfix.rst b/changelog/4260.bugfix.rst deleted file mode 100644 index e1e1a009f2d..00000000000 --- a/changelog/4260.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Swallow warnings during anonymous compilation of source. diff --git a/changelog/4262.bugfix.rst b/changelog/4262.bugfix.rst deleted file mode 100644 index 1487138b75e..00000000000 --- a/changelog/4262.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Fix access denied error when deleting stale directories created by ``tmpdir`` / ``tmp_path``. diff --git a/changelog/4279.trivial.rst b/changelog/4279.trivial.rst deleted file mode 100644 index 9f4c4c4735b..00000000000 --- a/changelog/4279.trivial.rst +++ /dev/null @@ -1 +0,0 @@ -Improve message and stack level of warnings issued by ``monkeypatch.setenv`` when the value of the environment variable is not a ``str``. diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index 3d019ad80a8..8f583c5f527 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,7 @@ Release announcements :maxdepth: 2 + release-3.10.0 release-3.9.3 release-3.9.2 release-3.9.1 diff --git a/doc/en/announce/release-3.10.0.rst b/doc/en/announce/release-3.10.0.rst new file mode 100644 index 00000000000..b53df270219 --- /dev/null +++ b/doc/en/announce/release-3.10.0.rst @@ -0,0 +1,43 @@ +pytest-3.10.0 +======================================= + +The pytest team is proud to announce the 3.10.0 release! + +pytest is a mature Python testing tool with more than a 2000 tests +against itself, passing on many different interpreters and platforms. + +This release contains a number of bugs fixes and improvements, so users are encouraged +to take a look at the CHANGELOG: + + https://docs.pytest.org/en/latest/changelog.html + +For complete documentation, please visit: + + https://docs.pytest.org/en/latest/ + +As usual, you can upgrade from pypi via: + + pip install -U pytest + +Thanks to all who contributed to this release, among them: + +* Anders Hovmöller +* Andreu Vallbona Plazas +* Ankit Goel +* Anthony Sottile +* Bernardo Gomes +* Brianna Laugher +* Bruno Oliveira +* Daniel Hahler +* David Szotten +* Mick Koch +* Niclas Olofsson +* Palash Chatterjee +* Ronny Pfannschmidt +* Sven-Hendrik Haase +* Ville Skyttä +* William Jamir Silva + + +Happy testing, +The Pytest Development Team diff --git a/doc/en/cache.rst b/doc/en/cache.rst index 08f20465573..4a917d45a18 100644 --- a/doc/en/cache.rst +++ b/doc/en/cache.rst @@ -244,6 +244,8 @@ You can always peek at the content of the cache using the {'test_caching.py::test_function': True} cache/nodeids contains: ['test_caching.py::test_function'] + cache/stepwise contains: + [] example/value contains: 42 @@ -260,3 +262,9 @@ by adding the ``--cache-clear`` option like this:: This is recommended for invocations from Continuous Integration servers where isolation and correctness is more important than speed. + + +Stepwise +-------- + +As an alternative to ``--lf -x``, especially for cases where you expect a large part of the test suite will fail, ``--sw``, ``--stepwise`` allows you to fix them one at a time. The test suite will run until the first failure and then stop. At the next invocation, tests will continue from the last failing test and then run until the next failing test. You may use the ``--stepwise-skip`` option to ignore one failing test and stop the test execution on the second failing test instead. This is useful if you get stuck on a failing test and just want to ignore it until later. diff --git a/doc/en/example/multipython.py b/doc/en/example/multipython.py index a5360ed5a6a..7846ddb9894 100644 --- a/doc/en/example/multipython.py +++ b/doc/en/example/multipython.py @@ -33,7 +33,7 @@ def dumps(self, obj): dumpfile = self.picklefile.dirpath("dump.py") dumpfile.write( textwrap.dedent( - r"""\ + r""" import pickle f = open({!r}, 'wb') s = pickle.dump({!r}, f, protocol=2) @@ -49,7 +49,7 @@ def load_and_is_true(self, expression): loadfile = self.picklefile.dirpath("load.py") loadfile.write( textwrap.dedent( - r"""\ + r""" import pickle f = open({!r}, 'rb') obj = pickle.load(f) diff --git a/doc/en/example/nonpython.rst b/doc/en/example/nonpython.rst index bda15065ae7..8bcb75b4385 100644 --- a/doc/en/example/nonpython.rst +++ b/doc/en/example/nonpython.rst @@ -85,8 +85,9 @@ interesting to just look at the collection tree:: rootdir: $REGENDOC_TMPDIR/nonpython, inifile: collected 2 items - - - + + + + ======================= no tests ran in 0.12 seconds ======================= diff --git a/doc/en/writing_plugins.rst b/doc/en/writing_plugins.rst index 464f8eb0017..527a7263ab5 100644 --- a/doc/en/writing_plugins.rst +++ b/doc/en/writing_plugins.rst @@ -421,21 +421,9 @@ additionally it is possible to copy examples for an example folder before runnin test_example.py::test_plugin $REGENDOC_TMPDIR/test_example.py:4: PytestExperimentalApiWarning: testdir.copy_example is an experimental api that may change over time testdir.copy_example("test_example.py") - $PYTHON_PREFIX/lib/python3.6/site-packages/_pytest/compat.py:332: RemovedInPytest4Warning: usage of Session.Class is deprecated, please use pytest.Class instead - return getattr(object, name, default) - $PYTHON_PREFIX/lib/python3.6/site-packages/_pytest/compat.py:332: RemovedInPytest4Warning: usage of Session.File is deprecated, please use pytest.File instead - return getattr(object, name, default) - $PYTHON_PREFIX/lib/python3.6/site-packages/_pytest/compat.py:332: RemovedInPytest4Warning: usage of Session.Function is deprecated, please use pytest.Function instead - return getattr(object, name, default) - $PYTHON_PREFIX/lib/python3.6/site-packages/_pytest/compat.py:332: RemovedInPytest4Warning: usage of Session.Instance is deprecated, please use pytest.Instance instead - return getattr(object, name, default) - $PYTHON_PREFIX/lib/python3.6/site-packages/_pytest/compat.py:332: RemovedInPytest4Warning: usage of Session.Item is deprecated, please use pytest.Item instead - return getattr(object, name, default) - $PYTHON_PREFIX/lib/python3.6/site-packages/_pytest/compat.py:332: RemovedInPytest4Warning: usage of Session.Module is deprecated, please use pytest.Module instead - return getattr(object, name, default) -- Docs: https://docs.pytest.org/en/latest/warnings.html - =================== 2 passed, 7 warnings in 0.12 seconds =================== + =================== 2 passed, 1 warnings in 0.12 seconds =================== For more information about the result object that ``runpytest()`` returns, and the methods that it provides please check out the :py:class:`RunResult diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 8e7f7a80454..a78d857f603 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -319,7 +319,8 @@ def cache(request): def pytest_report_header(config): - if config.option.verbose: + """Display cachedir with --cache-show and if non-default.""" + if config.option.verbose or config.getini("cache_dir") != ".pytest_cache": cachedir = config.cache._cachedir # TODO: evaluate generating upward relative paths # starting with .., ../.. if sensible diff --git a/src/_pytest/capture.py b/src/_pytest/capture.py index 38f4292f919..99e95a442ff 100644 --- a/src/_pytest/capture.py +++ b/src/_pytest/capture.py @@ -102,6 +102,9 @@ def _getcapture(self, method): # Global capturing control + def is_globally_capturing(self): + return self._method != "no" + def start_global_capturing(self): assert self._global_capturing is None self._global_capturing = self._getcapture(self._method) diff --git a/src/_pytest/compat.py b/src/_pytest/compat.py index 2b2cf659f47..ead9ffd8d80 100644 --- a/src/_pytest/compat.py +++ b/src/_pytest/compat.py @@ -428,3 +428,16 @@ class FuncargnamesCompatAttr(object): def funcargnames(self): """ alias attribute for ``fixturenames`` for pre-2.3 compatibility""" return self.fixturenames + + +if six.PY2: + + def lru_cache(*_, **__): + def dec(fn): + return fn + + return dec + + +else: + from functools import lru_cache # noqa: F401 diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index 8460562ab81..6fbf8144a8f 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -27,6 +27,7 @@ from .findpaths import exists from _pytest._code import ExceptionInfo from _pytest._code import filter_traceback +from _pytest.compat import lru_cache from _pytest.compat import safe_str from _pytest.outcomes import Skipped @@ -133,6 +134,7 @@ def directory_arg(path, optname): "freeze_support", "setuponly", "setupplan", + "stepwise", "warnings", "logging", ) @@ -212,7 +214,7 @@ def __init__(self): self._conftest_plugins = set() # state related to local conftest plugins - self._path2confmods = {} + self._dirpath2confmods = {} self._conftestpath2mod = {} self._confcutdir = None self._noconftest = False @@ -383,31 +385,35 @@ def _try_load_conftest(self, anchor): if x.check(dir=1): self._getconftestmodules(x) + @lru_cache(maxsize=128) def _getconftestmodules(self, path): if self._noconftest: return [] - try: - return self._path2confmods[path] - except KeyError: - if path.isfile(): - directory = path.dirpath() - else: - directory = path - # XXX these days we may rather want to use config.rootdir - # and allow users to opt into looking into the rootdir parent - # directories instead of requiring to specify confcutdir - clist = [] - for parent in directory.realpath().parts(): - if self._confcutdir and self._confcutdir.relto(parent): - continue - conftestpath = parent.join("conftest.py") - if conftestpath.isfile(): - mod = self._importconftest(conftestpath) - clist.append(mod) - - self._path2confmods[path] = clist - return clist + if path.isfile(): + directory = path.dirpath() + else: + directory = path + + if six.PY2: # py2 is not using lru_cache. + try: + return self._dirpath2confmods[directory] + except KeyError: + pass + + # XXX these days we may rather want to use config.rootdir + # and allow users to opt into looking into the rootdir parent + # directories instead of requiring to specify confcutdir + clist = [] + for parent in directory.realpath().parts(): + if self._confcutdir and self._confcutdir.relto(parent): + continue + conftestpath = parent.join("conftest.py") + if conftestpath.isfile(): + mod = self._importconftest(conftestpath) + clist.append(mod) + self._dirpath2confmods[directory] = clist + return clist def _rget_with_confmod(self, name, path): modules = self._getconftestmodules(path) @@ -448,8 +454,8 @@ def _importconftest(self, conftestpath): self._conftest_plugins.add(mod) self._conftestpath2mod[conftestpath] = mod dirpath = conftestpath.dirpath() - if dirpath in self._path2confmods: - for path, mods in self._path2confmods.items(): + if dirpath in self._dirpath2confmods: + for path, mods in self._dirpath2confmods.items(): if path and path.relto(dirpath) or path == dirpath: assert mod not in mods mods.append(mod) diff --git a/src/_pytest/debugging.py b/src/_pytest/debugging.py index 5a9729d5baa..94866de5633 100644 --- a/src/_pytest/debugging.py +++ b/src/_pytest/debugging.py @@ -80,10 +80,54 @@ def set_trace(cls, set_break=True): capman.suspend_global_capture(in_=True) tw = _pytest.config.create_terminal_writer(cls._config) tw.line() - tw.sep(">", "PDB set_trace (IO-capturing turned off)") - cls._pluginmanager.hook.pytest_enter_pdb(config=cls._config) + if capman and capman.is_globally_capturing(): + tw.sep(">", "PDB set_trace (IO-capturing turned off)") + else: + tw.sep(">", "PDB set_trace") + + class _PdbWrapper(cls._pdb_cls, object): + _pytest_capman = capman + _continued = False + + def do_continue(self, arg): + ret = super(_PdbWrapper, self).do_continue(arg) + if self._pytest_capman: + tw = _pytest.config.create_terminal_writer(cls._config) + tw.line() + if self._pytest_capman.is_globally_capturing(): + tw.sep(">", "PDB continue (IO-capturing resumed)") + else: + tw.sep(">", "PDB continue") + self._pytest_capman.resume_global_capture() + cls._pluginmanager.hook.pytest_leave_pdb( + config=cls._config, pdb=self + ) + self._continued = True + return ret + + do_c = do_cont = do_continue + + def setup(self, f, tb): + """Suspend on setup(). + + Needed after do_continue resumed, and entering another + breakpoint again. + """ + ret = super(_PdbWrapper, self).setup(f, tb) + if not ret and self._continued: + # pdb.setup() returns True if the command wants to exit + # from the interaction: do not suspend capturing then. + if self._pytest_capman: + self._pytest_capman.suspend_global_capture(in_=True) + return ret + + _pdb = _PdbWrapper() + cls._pluginmanager.hook.pytest_enter_pdb(config=cls._config, pdb=_pdb) + else: + _pdb = cls._pdb_cls() + if set_break: - cls._pdb_cls().set_trace(frame) + _pdb.set_trace(frame) class PdbInvoke(object): diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index bc1b2a6ec71..8d7a17bcade 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -12,6 +12,7 @@ from __future__ import division from __future__ import print_function +from _pytest.warning_types import PytestDeprecationWarning from _pytest.warning_types import RemovedInPytest4Warning from _pytest.warning_types import UnformattedWarning @@ -57,6 +58,10 @@ "See https://docs.pytest.org/en/latest/fixture.html for more information.", ) +FIXTURE_NAMED_REQUEST = PytestDeprecationWarning( + "'request' is a reserved name for fixtures and will raise an error in future versions" +) + CFG_PYTEST_SECTION = UnformattedWarning( RemovedInPytest4Warning, "[pytest] section in {filename} files is deprecated, use [tool:pytest] instead.", diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 9020562dba3..73aed837151 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -34,6 +34,7 @@ from _pytest.compat import NOTSET from _pytest.compat import safe_getattr from _pytest.deprecated import FIXTURE_FUNCTION_CALL +from _pytest.deprecated import FIXTURE_NAMED_REQUEST from _pytest.outcomes import fail from _pytest.outcomes import TEST_OUTCOME @@ -1036,6 +1037,9 @@ def __call__(self, function): function = wrap_function_to_warning_if_called_directly(function, self) + name = self.name or function.__name__ + if name == "request": + warnings.warn(FIXTURE_NAMED_REQUEST) function._pytestfixturefunction = self return function diff --git a/src/_pytest/hookspec.py b/src/_pytest/hookspec.py index 18f23a50a38..625f59e5a0a 100644 --- a/src/_pytest/hookspec.py +++ b/src/_pytest/hookspec.py @@ -603,9 +603,21 @@ def pytest_exception_interact(node, call, report): """ -def pytest_enter_pdb(config): +def pytest_enter_pdb(config, pdb): """ called upon pdb.set_trace(), can be used by plugins to take special action just before the python debugger enters in interactive mode. :param _pytest.config.Config config: pytest config object + :param pdb.Pdb pdb: Pdb instance + """ + + +def pytest_leave_pdb(config, pdb): + """ called when leaving pdb (e.g. with continue after pdb.set_trace()). + + Can be used by plugins to take special action just after the python + debugger leaves interactive mode. + + :param _pytest.config.Config config: pytest config object + :param pdb.Pdb pdb: Pdb instance """ diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 2bb40508120..3c908ec4c30 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -18,6 +18,7 @@ from _pytest.config import hookimpl from _pytest.config import UsageError from _pytest.outcomes import exit +from _pytest.pathlib import parts from _pytest.runner import collect_one_node @@ -469,8 +470,8 @@ def _perform_collect(self, args, genitems): return items def collect(self): - for parts in self._initialparts: - arg = "::".join(map(str, parts)) + for initialpart in self._initialparts: + arg = "::".join(map(str, initialpart)) self.trace("processing argument", arg) self.trace.root.indent += 1 try: @@ -488,7 +489,7 @@ def _collect(self, arg): names = self._parsearg(arg) argpath = names.pop(0).realpath() - paths = [] + paths = set() root = self # Start with a Session root, and delve to argpath item (dir or file) @@ -517,21 +518,37 @@ def _collect(self, arg): # Let the Package collector deal with subnodes, don't collect here. if argpath.check(dir=1): assert not names, "invalid arg %r" % (arg,) + + if six.PY2: + + def filter_(f): + return f.check(file=1) and not f.strpath.endswith("*.pyc") + + else: + + def filter_(f): + return f.check(file=1) + + seen_dirs = set() for path in argpath.visit( - fil=lambda x: x.check(file=1), rec=self._recurse, bf=True, sort=True + fil=filter_, rec=self._recurse, bf=True, sort=True ): - pkginit = path.dirpath().join("__init__.py") - if pkginit.exists() and not any(x in pkginit.parts() for x in paths): - for x in root._collectfile(pkginit): - yield x - paths.append(x.fspath.dirpath()) + dirpath = path.dirpath() + if dirpath not in seen_dirs: + seen_dirs.add(dirpath) + pkginit = dirpath.join("__init__.py") + if pkginit.exists() and parts(pkginit.strpath).isdisjoint(paths): + for x in root._collectfile(pkginit): + yield x + paths.add(x.fspath.dirpath()) - if not any(x in path.parts() for x in paths): + if parts(path.strpath).isdisjoint(paths): for x in root._collectfile(path): - if (type(x), x.fspath) in self._node_cache: - yield self._node_cache[(type(x), x.fspath)] + key = (type(x), x.fspath) + if key in self._node_cache: + yield self._node_cache[key] else: - self._node_cache[(type(x), x.fspath)] = x + self._node_cache[key] = x yield x else: assert argpath.check(file=1) @@ -570,15 +587,17 @@ def _collectfile(self, path): return ihook.pytest_collect_file(path=path, parent=self) - def _recurse(self, path): - ihook = self.gethookproxy(path.dirpath()) - if ihook.pytest_ignore_collect(path=path, config=self.config): - return + def _recurse(self, dirpath): + if dirpath.basename == "__pycache__": + return False + ihook = self.gethookproxy(dirpath.dirpath()) + if ihook.pytest_ignore_collect(path=dirpath, config=self.config): + return False for pat in self._norecursepatterns: - if path.check(fnmatch=pat): + if dirpath.check(fnmatch=pat): return False - ihook = self.gethookproxy(path) - ihook.pytest_collect_directory(path=path, parent=self) + ihook = self.gethookproxy(dirpath) + ihook.pytest_collect_directory(path=dirpath, parent=self) return True def _tryconvertpyarg(self, x): diff --git a/src/_pytest/pathlib.py b/src/_pytest/pathlib.py index d55bebcbb6e..5ecdd6026bc 100644 --- a/src/_pytest/pathlib.py +++ b/src/_pytest/pathlib.py @@ -5,7 +5,6 @@ import operator import os import shutil -import stat import sys import uuid from functools import reduce @@ -43,17 +42,10 @@ def ensure_reset_dir(path): path.mkdir() -def _shutil_rmtree_remove_writable(func, fspath, _): - "Clear the readonly bit and reattempt the removal" - os.chmod(fspath, stat.S_IWRITE) - func(fspath) - - def rmtree(path, force=False): if force: - # ignore_errors leaves dead folders around - # python needs a rm -rf as a followup - # the trick with _shutil_rmtree_remove_writable is unreliable + # NOTE: ignore_errors might leave dead folders around. + # Python needs a rm -rf as a followup. shutil.rmtree(str(path), ignore_errors=True) else: shutil.rmtree(str(path)) @@ -321,3 +313,8 @@ def fnmatch_ex(pattern, path): else: name = six.text_type(path) return fnmatch.fnmatch(name, pattern) + + +def parts(s): + parts = s.split(sep) + return {sep.join(parts[: i + 1]) or sep for i in range(len(parts))} diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 955f9d45151..03a9fe03105 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -42,6 +42,7 @@ from _pytest.mark.structures import normalize_mark_list from _pytest.mark.structures import transfer_markers from _pytest.outcomes import fail +from _pytest.pathlib import parts from _pytest.warning_types import PytestWarning from _pytest.warning_types import RemovedInPytest4Warning @@ -517,15 +518,17 @@ def __init__(self, fspath, parent=None, config=None, session=None, nodeid=None): self._norecursepatterns = session._norecursepatterns self.fspath = fspath - def _recurse(self, path): - ihook = self.gethookproxy(path.dirpath()) - if ihook.pytest_ignore_collect(path=path, config=self.config): + def _recurse(self, dirpath): + if dirpath.basename == "__pycache__": return False + ihook = self.gethookproxy(dirpath.dirpath()) + if ihook.pytest_ignore_collect(path=dirpath, config=self.config): + return for pat in self._norecursepatterns: - if path.check(fnmatch=pat): + if dirpath.check(fnmatch=pat): return False - ihook = self.gethookproxy(path) - ihook.pytest_collect_directory(path=path, parent=self) + ihook = self.gethookproxy(dirpath) + ihook.pytest_collect_directory(path=dirpath, parent=self) return True def gethookproxy(self, fspath): @@ -561,19 +564,16 @@ def collect(self): yield Module(init_module, self) pkg_prefixes = set() for path in this_path.visit(rec=self._recurse, bf=True, sort=True): - # we will visit our own __init__.py file, in which case we skip it - skip = False - if path.basename == "__init__.py" and path.dirpath() == this_path: - continue - - for pkg_prefix in pkg_prefixes: - if ( - pkg_prefix in path.parts() - and pkg_prefix.join("__init__.py") != path - ): - skip = True + # We will visit our own __init__.py file, in which case we skip it. + if path.isfile(): + if path.basename == "__init__.py" and path.dirpath() == this_path: + continue - if skip: + parts_ = parts(path.strpath) + if any( + pkg_prefix in parts_ and pkg_prefix.join("__init__.py") != path + for pkg_prefix in pkg_prefixes + ): continue if path.isdir() and path.join("__init__.py").check(file=1): diff --git a/src/_pytest/stepwise.py b/src/_pytest/stepwise.py new file mode 100644 index 00000000000..1efa2e7ca74 --- /dev/null +++ b/src/_pytest/stepwise.py @@ -0,0 +1,102 @@ +import pytest + + +def pytest_addoption(parser): + group = parser.getgroup("general") + group.addoption( + "--sw", + "--stepwise", + action="store_true", + dest="stepwise", + help="exit on test fail and continue from last failing test next time", + ) + group.addoption( + "--stepwise-skip", + action="store_true", + dest="stepwise_skip", + help="ignore the first failing test but stop on the next failing test", + ) + + +@pytest.hookimpl +def pytest_configure(config): + config.pluginmanager.register(StepwisePlugin(config), "stepwiseplugin") + + +class StepwisePlugin: + def __init__(self, config): + self.config = config + self.active = config.getvalue("stepwise") + self.session = None + + if self.active: + self.lastfailed = config.cache.get("cache/stepwise", None) + self.skip = config.getvalue("stepwise_skip") + + def pytest_sessionstart(self, session): + self.session = session + + def pytest_collection_modifyitems(self, session, config, items): + if not self.active or not self.lastfailed: + return + + already_passed = [] + found = False + + # Make a list of all tests that have been run before the last failing one. + for item in items: + if item.nodeid == self.lastfailed: + found = True + break + else: + already_passed.append(item) + + # If the previously failed test was not found among the test items, + # do not skip any tests. + if not found: + already_passed = [] + + for item in already_passed: + items.remove(item) + + config.hook.pytest_deselected(items=already_passed) + + def pytest_collectreport(self, report): + if self.active and report.failed: + self.session.shouldstop = ( + "Error when collecting test, stopping test execution." + ) + + def pytest_runtest_logreport(self, report): + # Skip this hook if plugin is not active or the test is xfailed. + if not self.active or "xfail" in report.keywords: + return + + if report.failed: + if self.skip: + # Remove test from the failed ones (if it exists) and unset the skip option + # to make sure the following tests will not be skipped. + if report.nodeid == self.lastfailed: + self.lastfailed = None + + self.skip = False + else: + # Mark test as the last failing and interrupt the test session. + self.lastfailed = report.nodeid + self.session.shouldstop = ( + "Test failed, continuing from this test next run." + ) + + else: + # If the test was actually run and did pass. + if report.when == "call": + # Remove test from the failed ones, if exists. + if report.nodeid == self.lastfailed: + self.lastfailed = None + + def pytest_sessionfinish(self, session): + if self.active: + self.config.cache.set("cache/stepwise", self.lastfailed) + else: + # Clear the list of failing tests if the plugin is not active. + self.config.cache.set("cache/stepwise", []) diff --git a/src/_pytest/terminal.py b/src/_pytest/terminal.py index 0d5a08185ab..a00dc08427e 100644 --- a/src/_pytest/terminal.py +++ b/src/_pytest/terminal.py @@ -246,6 +246,7 @@ def __init__(self, config, file=None): self.isatty = file.isatty() self._progress_nodeids_reported = set() self._show_progress_info = self._determine_show_progress_info() + self._collect_report_last_write = None def _determine_show_progress_info(self): """Return True if we should display progress information based on the current config""" @@ -261,7 +262,7 @@ def hasopt(self, char): char = {"xfailed": "x", "skipped": "s"}.get(char, char) return char in self.reportchars - def write_fspath_result(self, nodeid, res): + def write_fspath_result(self, nodeid, res, **markup): fspath = self.config.rootdir.join(nodeid.split("::")[0]) if fspath != self.currentfspath: if self.currentfspath is not None and self._show_progress_info: @@ -270,7 +271,7 @@ def write_fspath_result(self, nodeid, res): fspath = self.startdir.bestrelpath(fspath) self._tw.line() self._tw.write(fspath + " ") - self._tw.write(res) + self._tw.write(res, **markup) def write_ensure_prefix(self, prefix, extra="", **kwargs): if self.currentfspath != prefix: @@ -384,22 +385,22 @@ def pytest_runtest_logreport(self, report): # probably passed setup/teardown return running_xdist = hasattr(rep, "node") + if markup is None: + if rep.passed: + markup = {"green": True} + elif rep.failed: + markup = {"red": True} + elif rep.skipped: + markup = {"yellow": True} + else: + markup = {} if self.verbosity <= 0: if not running_xdist and self.showfspath: - self.write_fspath_result(rep.nodeid, letter) + self.write_fspath_result(rep.nodeid, letter, **markup) else: - self._tw.write(letter) + self._tw.write(letter, **markup) else: self._progress_nodeids_reported.add(rep.nodeid) - if markup is None: - if rep.passed: - markup = {"green": True} - elif rep.failed: - markup = {"red": True} - elif rep.skipped: - markup = {"yellow": True} - else: - markup = {} line = self._locationline(rep.nodeid, *rep.location) if not running_xdist: self.write_ensure_prefix(line, word, **markup) @@ -472,7 +473,11 @@ def _width_of_current_line(self): return self._tw.chars_on_current_line def pytest_collection(self): - if not self.isatty and self.config.option.verbose >= 1: + if self.isatty: + if self.config.option.verbose >= 0: + self.write("collecting ... ", bold=True) + self._collect_report_last_write = time.time() + elif self.config.option.verbose >= 1: self.write("collecting ... ", bold=True) def pytest_collectreport(self, report): @@ -483,13 +488,19 @@ def pytest_collectreport(self, report): items = [x for x in report.result if isinstance(x, pytest.Item)] self._numcollected += len(items) if self.isatty: - # self.write_fspath_result(report.nodeid, 'E') self.report_collect() def report_collect(self, final=False): if self.config.option.verbose < 0: return + if not final: + # Only write "collecting" report every 0.5s. + t = time.time() + if self._collect_report_last_write > t - 0.5: + return + self._collect_report_last_write = t + errors = len(self.stats.get("error", [])) skipped = len(self.stats.get("skipped", [])) deselected = len(self.stats.get("deselected", [])) diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index e4e2860d109..37ade6d6a60 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -6,6 +6,8 @@ import pytest +pytestmark = pytest.mark.pytester_example_path("deprecated") + @pytest.mark.filterwarnings("default") def test_yield_tests_deprecation(testdir): @@ -394,3 +396,13 @@ def _makeitem(self, *k): with pytest.warns(RemovedInPytest4Warning): collector.makeitem("foo", "bar") assert collector.called + + +def test_fixture_named_request(testdir): + testdir.copy_example() + result = testdir.runpytest() + result.stdout.fnmatch_lines( + [ + "*'request' is a reserved name for fixtures and will raise an error in future versions" + ] + ) diff --git a/testing/example_scripts/deprecated/test_fixture_named_request.py b/testing/example_scripts/deprecated/test_fixture_named_request.py new file mode 100644 index 00000000000..75514bf8b8c --- /dev/null +++ b/testing/example_scripts/deprecated/test_fixture_named_request.py @@ -0,0 +1,10 @@ +import pytest + + +@pytest.fixture +def request(): + pass + + +def test(): + pass diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index 43b8392e5d5..cd888dce135 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -66,7 +66,8 @@ def test_error(): ) result = testdir.runpytest("-rw") assert result.ret == 1 - result.stdout.fnmatch_lines(["*could not create cache path*", "*2 warnings*"]) + # warnings from nodeids, lastfailed, and stepwise + result.stdout.fnmatch_lines(["*could not create cache path*", "*3 warnings*"]) def test_config_cache(self, testdir): testdir.makeconftest( diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 7d14d790d3f..2b66d8fa713 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -49,14 +49,14 @@ def test_basic_init(self, basedir): def test_immediate_initialiation_and_incremental_are_the_same(self, basedir): conftest = PytestPluginManager() - len(conftest._path2confmods) + len(conftest._dirpath2confmods) conftest._getconftestmodules(basedir) - snap1 = len(conftest._path2confmods) - # assert len(conftest._path2confmods) == snap1 + 1 + snap1 = len(conftest._dirpath2confmods) + # assert len(conftest._dirpath2confmods) == snap1 + 1 conftest._getconftestmodules(basedir.join("adir")) - assert len(conftest._path2confmods) == snap1 + 1 + assert len(conftest._dirpath2confmods) == snap1 + 1 conftest._getconftestmodules(basedir.join("b")) - assert len(conftest._path2confmods) == snap1 + 2 + assert len(conftest._dirpath2confmods) == snap1 + 2 def test_value_access_not_existing(self, basedir): conftest = ConftestWithSetinitial(basedir) diff --git a/testing/test_pdb.py b/testing/test_pdb.py index 56fe5fc7a72..4c236c55d52 100644 --- a/testing/test_pdb.py +++ b/testing/test_pdb.py @@ -167,6 +167,7 @@ def test_not_called_due_to_quit(): assert "= 1 failed in" in rest assert "def test_1" not in rest assert "Exit: Quitting debugger" in rest + assert "PDB continue (IO-capturing resumed)" not in rest self.flush(child) @staticmethod @@ -498,18 +499,39 @@ def test_1(): """ ) child = testdir.spawn_pytest(str(p1)) + child.expect(r"PDB set_trace \(IO-capturing turned off\)") child.expect("test_1") child.expect("x = 3") child.expect("Pdb") child.sendline("c") + child.expect(r"PDB continue \(IO-capturing resumed\)") + child.expect(r"PDB set_trace \(IO-capturing turned off\)") child.expect("x = 4") child.expect("Pdb") child.sendeof() + child.expect("_ test_1 _") + child.expect("def test_1") + child.expect("Captured stdout call") rest = child.read().decode("utf8") - assert "1 failed" in rest - assert "def test_1" in rest assert "hello17" in rest # out is captured assert "hello18" in rest # out is captured + assert "1 failed" in rest + self.flush(child) + + def test_pdb_without_capture(self, testdir): + p1 = testdir.makepyfile( + """ + import pytest + def test_1(): + pytest.set_trace() + """ + ) + child = testdir.spawn_pytest("-s %s" % p1) + child.expect(r">>> PDB set_trace >>>") + child.expect("Pdb") + child.sendline("c") + child.expect(r">>> PDB continue >>>") + child.expect("1 passed") self.flush(child) def test_pdb_used_outside_test(self, testdir): @@ -550,15 +572,29 @@ def test_pdb_collection_failure_is_shown(self, testdir): ["E NameError: *xxx*", "*! *Exit: Quitting debugger !*"] # due to EOF ) - def test_enter_pdb_hook_is_called(self, testdir): + def test_enter_leave_pdb_hooks_are_called(self, testdir): testdir.makeconftest( """ - def pytest_enter_pdb(config): - assert config.testing_verification == 'configured' - print('enter_pdb_hook') + mypdb = None def pytest_configure(config): config.testing_verification = 'configured' + + def pytest_enter_pdb(config, pdb): + assert config.testing_verification == 'configured' + print('enter_pdb_hook') + + global mypdb + mypdb = pdb + mypdb.set_attribute = "bar" + + def pytest_leave_pdb(config, pdb): + assert config.testing_verification == 'configured' + print('leave_pdb_hook') + + global mypdb + assert mypdb is pdb + assert mypdb.set_attribute == "bar" """ ) p1 = testdir.makepyfile( @@ -567,11 +603,17 @@ def pytest_configure(config): def test_foo(): pytest.set_trace() + assert 0 """ ) child = testdir.spawn_pytest(str(p1)) child.expect("enter_pdb_hook") - child.send("c\n") + child.sendline("c") + child.expect(r"PDB continue \(IO-capturing resumed\)") + child.expect("Captured stdout call") + rest = child.read().decode("utf8") + assert "leave_pdb_hook" in rest + assert "1 failed" in rest child.sendeof() self.flush(child) diff --git a/testing/test_stepwise.py b/testing/test_stepwise.py new file mode 100644 index 00000000000..ad9b77296bc --- /dev/null +++ b/testing/test_stepwise.py @@ -0,0 +1,148 @@ +import pytest + + +@pytest.fixture +def stepwise_testdir(testdir): + # Rather than having to modify our testfile between tests, we introduce + # a flag for wether or not the second test should fail. + testdir.makeconftest( + """ +def pytest_addoption(parser): + group = parser.getgroup('general') + group.addoption('--fail', action='store_true', dest='fail') + group.addoption('--fail-last', action='store_true', dest='fail_last') +""" + ) + + # Create a simple test suite. + testdir.makepyfile( + test_a=""" +def test_success_before_fail(): + assert 1 + +def test_fail_on_flag(request): + assert not request.config.getvalue('fail') + +def test_success_after_fail(): + assert 1 + +def test_fail_last_on_flag(request): + assert not request.config.getvalue('fail_last') + +def test_success_after_last_fail(): + assert 1 +""" + ) + + testdir.makepyfile( + test_b=""" +def test_success(): + assert 1 +""" + ) + + return testdir + + +@pytest.fixture +def error_testdir(testdir): + testdir.makepyfile( + test_a=""" +def test_error(nonexisting_fixture): + assert 1 + +def test_success_after_fail(): + assert 1 +""" + ) + + return testdir + + +@pytest.fixture +def broken_testdir(testdir): + testdir.makepyfile( + working_testfile="def test_proper(): assert 1", broken_testfile="foobar" + ) + return testdir + + +def test_run_without_stepwise(stepwise_testdir): + result = stepwise_testdir.runpytest("-v", "--strict", "--fail") + + result.stdout.fnmatch_lines(["*test_success_before_fail PASSED*"]) + result.stdout.fnmatch_lines(["*test_fail_on_flag FAILED*"]) + result.stdout.fnmatch_lines(["*test_success_after_fail PASSED*"]) + + +def test_fail_and_continue_with_stepwise(stepwise_testdir): + # Run the tests with a failing second test. + result = stepwise_testdir.runpytest("-v", "--strict", "--stepwise", "--fail") + assert not result.stderr.str() + + stdout = result.stdout.str() + # Make sure we stop after first failing test. + assert "test_success_before_fail PASSED" in stdout + assert "test_fail_on_flag FAILED" in stdout + assert "test_success_after_fail" not in stdout + + # "Fix" the test that failed in the last run and run it again. + result = stepwise_testdir.runpytest("-v", "--strict", "--stepwise") + assert not result.stderr.str() + + stdout = result.stdout.str() + # Make sure the latest failing test runs and then continues. + assert "test_success_before_fail" not in stdout + assert "test_fail_on_flag PASSED" in stdout + assert "test_success_after_fail PASSED" in stdout + + +def test_run_with_skip_option(stepwise_testdir): + result = stepwise_testdir.runpytest( + "-v", "--strict", "--stepwise", "--stepwise-skip", "--fail", "--fail-last" + ) + assert not result.stderr.str() + + stdout = result.stdout.str() + # Make sure first fail is ignore and second fail stops the test run. + assert "test_fail_on_flag FAILED" in stdout + assert "test_success_after_fail PASSED" in stdout + assert "test_fail_last_on_flag FAILED" in stdout + assert "test_success_after_last_fail" not in stdout + + +def test_fail_on_errors(error_testdir): + result = error_testdir.runpytest("-v", "--strict", "--stepwise") + + assert not result.stderr.str() + stdout = result.stdout.str() + + assert "test_error ERROR" in stdout + assert "test_success_after_fail" not in stdout + + +def test_change_testfile(stepwise_testdir): + result = stepwise_testdir.runpytest( + "-v", "--strict", "--stepwise", "--fail", "test_a.py" + ) + assert not result.stderr.str() + + stdout = result.stdout.str() + assert "test_fail_on_flag FAILED" in stdout + + # Make sure the second test run starts from the beginning, since the + # test to continue from does not exist in testfile_b. + result = stepwise_testdir.runpytest("-v", "--strict", "--stepwise", "test_b.py") + assert not result.stderr.str() + + stdout = result.stdout.str() + assert "test_success PASSED" in stdout + + +def test_stop_on_collection_errors(broken_testdir): + result = broken_testdir.runpytest( + "-v", "--strict", "--stepwise", "working_testfile.py", "broken_testfile.py" + ) + + stdout = result.stdout.str() + assert "errors during collection" in stdout