From d784155fd2d306157bca115cb87c8eb3c488b746 Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Wed, 17 Jan 2018 23:02:31 +0300 Subject: [PATCH 001/107] #1642 Add rootdir option --- AUTHORS | 1 + _pytest/main.py | 19 +++++++++++++++++++ testing/test_session.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/AUTHORS b/AUTHORS index 862378be9f5..d4447462c6d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -138,6 +138,7 @@ Ned Batchelder Neven Mundar Nicolas Delaby Oleg Pidsadnyi +Oleg Sushchenko Oliver Bestwalter Omar Kohl Omer Hadari diff --git a/_pytest/main.py b/_pytest/main.py index fce4f35f383..7fa86db17e2 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -33,6 +33,9 @@ def pytest_addoption(parser): parser.addini("testpaths", "directories to search for tests when no files or directories are given in the " "command line.", type="args", default=[]) + parser.addini("rootdir", "define root directory for tests. If this parameter defined command argument " + "'--rootdir' will not work", + type="args", default=[]) # parser.addini("dirpatterns", # "patterns specifying possible locations of test files", # type="linelist", default=["**/test_*.txt", @@ -53,6 +56,11 @@ def pytest_addoption(parser): group._addoption("--continue-on-collection-errors", action="store_true", default=False, dest="continue_on_collection_errors", help="Force test execution even if collection errors occur.") + group._addoption("--rootdir", action="store", + dest="rootdir", + help="Define root directory for tests. Can be relative path: 'root_dir', './root_dir', " + "'root_dir/another_dir/'; absolute path: '/home/user/root_dir'; path with variables: " + "'$HOME/root_dir'. If parameter 'rootdir' defined in *.ini file this argument will not work") group = parser.getgroup("collect", "collection") group.addoption('--collectonly', '--collect-only', action="store_true", @@ -283,6 +291,17 @@ def __init__(self, config): self.trace = config.trace.root.get("collection") self._norecursepatterns = config.getini("norecursedirs") self.startdir = py.path.local() + + rootdir_ini = config.getini('rootdir') + self.rootdir = rootdir_ini[0] if rootdir_ini else config.option.rootdir + if self.rootdir: + rootdir_abs_path = py.path.local(self.rootdir) + if not os.path.isdir(str(rootdir_abs_path)): + raise UsageError("Directory '{}' not found. Check your '--rootdir' option.".format(rootdir_abs_path)) + config.invocation_dir = rootdir_abs_path + config.rootdir = rootdir_abs_path + sys.path.append(str(rootdir_abs_path)) + self.config.pluginmanager.register(self, name="session") def _makeid(self): diff --git a/testing/test_session.py b/testing/test_session.py index 9ec13f523e6..a8c5a408df8 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -253,3 +253,35 @@ def pytest_sessionfinish(): """) res = testdir.runpytest("--collect-only") assert res.ret == EXIT_NOTESTSCOLLECTED + + +def test_rootdir_option_arg(testdir): + rootdir = testdir.mkdir("root") + rootdir.join("spoon.py").write("spoon_number = 1") + testsdir = rootdir.mkdir("tests") + testsdir.join("test_one.py").write("from spoon import spoon_number\ndef test_one():\n assert spoon_number") + + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*No module named*spoon*"]) + + result = testdir.runpytest("--rootdir=root") + result.stdout.fnmatch_lines(["*1 passed*"]) + + +def test_rootdir_option_ini_file(testdir): + rootdir = testdir.mkdir("root") + rootdir.join("spoon.py").write("spoon_number = 1") + testsdir = rootdir.mkdir("tests") + testsdir.join("test_one.py").write("from spoon import spoon_number\ndef test_one():\n assert spoon_number") + + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*No module named*spoon*"]) + testdir.makeini(""" + [pytest] + rootdir=root + """) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*1 passed*"]) + result = testdir.runpytest("--rootdir=ignored_argument") + print(result.stdout.str()) + result.stdout.fnmatch_lines(["*1 passed*"]) From 86f01967e160550468cfb4a5efce8a2d744ccabc Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Wed, 17 Jan 2018 23:05:22 +0300 Subject: [PATCH 002/107] #1642 Added changelog entry --- changelog/1642.trivial | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/1642.trivial diff --git a/changelog/1642.trivial b/changelog/1642.trivial new file mode 100644 index 00000000000..a8f1f3fe46f --- /dev/null +++ b/changelog/1642.trivial @@ -0,0 +1 @@ +Added option `--rootdir` for command line and `rootdir` for *.ini file. Define root directory for tests. Can be relative path: 'root_dir', './root_dir', 'root_dir/another_dir/'; absolute path: '/home/user/root_dir'; path with variables: '$HOME/root_dir'. If parameter 'rootdir' defined in *.ini file this argument will not work. \ No newline at end of file From 4a18d7616078fa4a0c99b67501ac5e041faf5f21 Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Wed, 17 Jan 2018 23:14:40 +0300 Subject: [PATCH 003/107] #1642 remove print --- testing/test_session.py | 1 - 1 file changed, 1 deletion(-) diff --git a/testing/test_session.py b/testing/test_session.py index a8c5a408df8..732a3af06df 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -283,5 +283,4 @@ def test_rootdir_option_ini_file(testdir): result = testdir.runpytest() result.stdout.fnmatch_lines(["*1 passed*"]) result = testdir.runpytest("--rootdir=ignored_argument") - print(result.stdout.str()) result.stdout.fnmatch_lines(["*1 passed*"]) From a7c39c894b5b51f3efa9eb48de421b34bd069c26 Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Wed, 17 Jan 2018 23:48:04 +0300 Subject: [PATCH 004/107] #1642 fix flake8 --- changelog/1642.trivial | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog/1642.trivial b/changelog/1642.trivial index a8f1f3fe46f..87ed2913fce 100644 --- a/changelog/1642.trivial +++ b/changelog/1642.trivial @@ -1 +1,3 @@ -Added option `--rootdir` for command line and `rootdir` for *.ini file. Define root directory for tests. Can be relative path: 'root_dir', './root_dir', 'root_dir/another_dir/'; absolute path: '/home/user/root_dir'; path with variables: '$HOME/root_dir'. If parameter 'rootdir' defined in *.ini file this argument will not work. \ No newline at end of file +Added option "--rootdir" for command line and "rootdir" for .ini file. Define root directory for tests. +Can be relative path: "root_dir", "./root_dir", "root_dir/another_dir/"; absolute path: "/home/user/root_dir"; +path with variables: "$HOME/root_dir". If parameter "rootdir" defined in .ini file this argument will not work. \ No newline at end of file From 83034bbd489aa3b5bfcf3efadc63c883b046ba1a Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Sat, 20 Jan 2018 22:30:01 +0300 Subject: [PATCH 005/107] #1642 Fix rootdir option --- _pytest/config.py | 13 +++++++++++-- _pytest/main.py | 15 +-------------- testing/test_session.py | 18 ------------------ 3 files changed, 12 insertions(+), 34 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index ce7468f7204..14ed4d09b48 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -990,11 +990,15 @@ def pytest_load_initial_conftests(self, early_config): def _initini(self, args): ns, unknown_args = self._parser.parse_known_and_unknown_args(args, namespace=self.option.copy()) - r = determine_setup(ns.inifilename, ns.file_or_dir + unknown_args, warnfunc=self.warn) + rootdir = ns.rootdir if ns.rootdir else None + r = determine_setup(ns.inifilename, ns.file_or_dir + unknown_args, warnfunc=self.warn, rootdir_cmd_arg=rootdir) self.rootdir, self.inifile, self.inicfg = r self._parser.extra_info['rootdir'] = self.rootdir self._parser.extra_info['inifile'] = self.inifile self.invocation_dir = py.path.local() + if ns.rootdir: + self.invocation_dir = self.rootdir + sys.path.append(str(self.rootdir)) self._parser.addini('addopts', 'extra command line options', 'args') self._parser.addini('minversion', 'minimally required pytest version') self._override_ini = ns.override_ini or () @@ -1323,7 +1327,7 @@ def get_dir_from_path(path): ] -def determine_setup(inifile, args, warnfunc=None): +def determine_setup(inifile, args, warnfunc=None, rootdir_cmd_arg=None): dirs = get_dirs_from_args(args) if inifile: iniconfig = py.iniconfig.IniConfig(inifile) @@ -1346,6 +1350,11 @@ def determine_setup(inifile, args, warnfunc=None): is_fs_root = os.path.splitdrive(str(rootdir))[1] == '/' if is_fs_root: rootdir = ancestor + if rootdir_cmd_arg: + rootdir_abs_path = py.path.local(rootdir_cmd_arg) + if not os.path.isdir(str(rootdir_abs_path)): + raise UsageError("Directory '{}' not found. Check your '--rootdir' option.".format(rootdir_abs_path)) + rootdir = rootdir_abs_path return rootdir, inifile, inicfg or {} diff --git a/_pytest/main.py b/_pytest/main.py index 7fa86db17e2..ed70bfb3123 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -33,9 +33,6 @@ def pytest_addoption(parser): parser.addini("testpaths", "directories to search for tests when no files or directories are given in the " "command line.", type="args", default=[]) - parser.addini("rootdir", "define root directory for tests. If this parameter defined command argument " - "'--rootdir' will not work", - type="args", default=[]) # parser.addini("dirpatterns", # "patterns specifying possible locations of test files", # type="linelist", default=["**/test_*.txt", @@ -60,7 +57,7 @@ def pytest_addoption(parser): dest="rootdir", help="Define root directory for tests. Can be relative path: 'root_dir', './root_dir', " "'root_dir/another_dir/'; absolute path: '/home/user/root_dir'; path with variables: " - "'$HOME/root_dir'. If parameter 'rootdir' defined in *.ini file this argument will not work") + "'$HOME/root_dir'.") group = parser.getgroup("collect", "collection") group.addoption('--collectonly', '--collect-only', action="store_true", @@ -292,16 +289,6 @@ def __init__(self, config): self._norecursepatterns = config.getini("norecursedirs") self.startdir = py.path.local() - rootdir_ini = config.getini('rootdir') - self.rootdir = rootdir_ini[0] if rootdir_ini else config.option.rootdir - if self.rootdir: - rootdir_abs_path = py.path.local(self.rootdir) - if not os.path.isdir(str(rootdir_abs_path)): - raise UsageError("Directory '{}' not found. Check your '--rootdir' option.".format(rootdir_abs_path)) - config.invocation_dir = rootdir_abs_path - config.rootdir = rootdir_abs_path - sys.path.append(str(rootdir_abs_path)) - self.config.pluginmanager.register(self, name="session") def _makeid(self): diff --git a/testing/test_session.py b/testing/test_session.py index 732a3af06df..5c1b9918f72 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -266,21 +266,3 @@ def test_rootdir_option_arg(testdir): result = testdir.runpytest("--rootdir=root") result.stdout.fnmatch_lines(["*1 passed*"]) - - -def test_rootdir_option_ini_file(testdir): - rootdir = testdir.mkdir("root") - rootdir.join("spoon.py").write("spoon_number = 1") - testsdir = rootdir.mkdir("tests") - testsdir.join("test_one.py").write("from spoon import spoon_number\ndef test_one():\n assert spoon_number") - - result = testdir.runpytest() - result.stdout.fnmatch_lines(["*No module named*spoon*"]) - testdir.makeini(""" - [pytest] - rootdir=root - """) - result = testdir.runpytest() - result.stdout.fnmatch_lines(["*1 passed*"]) - result = testdir.runpytest("--rootdir=ignored_argument") - result.stdout.fnmatch_lines(["*1 passed*"]) From a7066ba8373f9fcfa424d698a304294f978a7c14 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 23 Jan 2018 17:31:07 -0200 Subject: [PATCH 006/107] Update formatting in the CHANGELOG --- changelog/1642.trivial | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/changelog/1642.trivial b/changelog/1642.trivial index 87ed2913fce..a6acc5565fa 100644 --- a/changelog/1642.trivial +++ b/changelog/1642.trivial @@ -1,3 +1 @@ -Added option "--rootdir" for command line and "rootdir" for .ini file. Define root directory for tests. -Can be relative path: "root_dir", "./root_dir", "root_dir/another_dir/"; absolute path: "/home/user/root_dir"; -path with variables: "$HOME/root_dir". If parameter "rootdir" defined in .ini file this argument will not work. \ No newline at end of file +Add ``--rootdir`` command-line option to override the rules for discovering the root directory. See `customize `_ in the documentation for details. From 0cfa975930f450537543e8d8334f4518f63fba56 Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Thu, 25 Jan 2018 15:57:04 +0300 Subject: [PATCH 007/107] #1642 Fix tests --- testing/test_session.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/testing/test_session.py b/testing/test_session.py index 5c1b9918f72..798e1d9cbb0 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -257,12 +257,24 @@ def pytest_sessionfinish(): def test_rootdir_option_arg(testdir): rootdir = testdir.mkdir("root") - rootdir.join("spoon.py").write("spoon_number = 1") - testsdir = rootdir.mkdir("tests") - testsdir.join("test_one.py").write("from spoon import spoon_number\ndef test_one():\n assert spoon_number") + rootdir.mkdir("tests") + testdir.makepyfile(""" + import os + def test_one(): + assert os.path.isdir('.cache') + """) result = testdir.runpytest() - result.stdout.fnmatch_lines(["*No module named*spoon*"]) + result.stdout.fnmatch_lines(["*AssertionError*"]) result = testdir.runpytest("--rootdir=root") result.stdout.fnmatch_lines(["*1 passed*"]) + + +def test_rootdir_wrong_option_arg(testdir): + rootdir = testdir.mkdir("root") + testsdir = rootdir.mkdir("tests") + testsdir.join("test_one.py").write("def test_one():\n assert 1") + + result = testdir.runpytest("--rootdir=wrong_dir") + result.stderr.fnmatch_lines(["*Directory *wrong_dir* not found. Check your '--rootdir' option.*"]) From 503e00f7ffca1b2805abe97b4a9f63870a98eaaa Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Thu, 25 Jan 2018 15:57:29 +0300 Subject: [PATCH 008/107] #1642 Remove adding rootdir to sys path --- _pytest/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/_pytest/config.py b/_pytest/config.py index 14ed4d09b48..dfdbeab3fc0 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -998,7 +998,6 @@ def _initini(self, args): self.invocation_dir = py.path.local() if ns.rootdir: self.invocation_dir = self.rootdir - sys.path.append(str(self.rootdir)) self._parser.addini('addopts', 'extra command line options', 'args') self._parser.addini('minversion', 'minimally required pytest version') self._override_ini = ns.override_ini or () From 3a004a4507e57aa199884183ab735e501121130d Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Thu, 25 Jan 2018 19:46:22 +0300 Subject: [PATCH 009/107] added rootdir description to customize.rst --- doc/en/customize.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/en/customize.rst b/doc/en/customize.rst index 8133704a52c..4101ca28329 100644 --- a/doc/en/customize.rst +++ b/doc/en/customize.rst @@ -331,3 +331,9 @@ Builtin configuration file options # content of pytest.ini [pytest] console_output_style = classic + +.. confval:: rootdir + + Sets a :ref:`rootdir ` directory. Directory may be relative or absolute path. + Additionally path may contain environment variables, that will be expanded. + For more information about rootdir please refer to :ref:`rootdir `. From 949a620d3a182d469f8c6bb14498e5ec6802c6e6 Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Wed, 31 Jan 2018 22:36:28 +0300 Subject: [PATCH 010/107] #1478 Added --no-stdout option --- _pytest/terminal.py | 5 +++++ changelog/1478.trivial | 1 + testing/test_terminal.py | 13 +++++++++++++ 3 files changed, 19 insertions(+) create mode 100644 changelog/1478.trivial diff --git a/_pytest/terminal.py b/_pytest/terminal.py index f0a2fa6187e..7f42f12840b 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -42,6 +42,9 @@ def pytest_addoption(parser): action="store", dest="tbstyle", default='auto', choices=['auto', 'long', 'short', 'no', 'line', 'native'], help="traceback print mode (auto/long/short/line/native/no).") + group._addoption('--no-stdout', + action="store_false", dest="nostdout", + help="Do not print stdout") group._addoption('--fulltrace', '--full-trace', action="store_true", default=False, help="don't cut any tracebacks (default is to cut).") @@ -623,6 +626,8 @@ def summary_errors(self): def _outrep_summary(self, rep): rep.toterminal(self._tw) for secname, content in rep.sections: + if not self.config.option.nostdout and 'stdout' in secname: + continue self._tw.sep("-", secname) if content[-1:] == "\n": content = content[:-1] diff --git a/changelog/1478.trivial b/changelog/1478.trivial new file mode 100644 index 00000000000..aa88151e7c4 --- /dev/null +++ b/changelog/1478.trivial @@ -0,0 +1 @@ +Added `--no-stdout` feature. Stdout will not shown in terminal if you use this option. Only Stderr will shown. \ No newline at end of file diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 7dfa4b01efd..bedd6a9a549 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -823,6 +823,19 @@ def pytest_report_header(config, startdir): str(testdir.tmpdir), ]) + def test_no_stdout(self, testdir): + testdir.makepyfile(""" + def test_one(): + print('!This is stdout!') + assert False, 'Something failed' + """) + + result = testdir.runpytest("--tb=short") + result.stdout.fnmatch_lines(["!This is stdout!"]) + + result = testdir.runpytest("--no-stdout", "--tb=short") + assert "!This is stdout!" not in result.stdout.str() + @pytest.mark.xfail("not hasattr(os, 'dup')") def test_fdopen_kept_alive_issue124(testdir): From 741b571f3b6cd1bcfec6db7ba90f112cd5bbc8d1 Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Thu, 1 Feb 2018 00:03:24 +0300 Subject: [PATCH 011/107] #1642 fix tests and config.py --- _pytest/config.py | 2 -- testing/test_session.py | 21 ++++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index dfdbeab3fc0..59858f6e842 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -996,8 +996,6 @@ def _initini(self, args): self._parser.extra_info['rootdir'] = self.rootdir self._parser.extra_info['inifile'] = self.inifile self.invocation_dir = py.path.local() - if ns.rootdir: - self.invocation_dir = self.rootdir self._parser.addini('addopts', 'extra command line options', 'args') self._parser.addini('minversion', 'minimally required pytest version') self._override_ini = ns.override_ini or () diff --git a/testing/test_session.py b/testing/test_session.py index 798e1d9cbb0..5f85c6309de 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -1,4 +1,7 @@ from __future__ import absolute_import, division, print_function + +import os + import pytest from _pytest.main import EXIT_NOTESTSCOLLECTED @@ -255,20 +258,24 @@ def pytest_sessionfinish(): assert res.ret == EXIT_NOTESTSCOLLECTED -def test_rootdir_option_arg(testdir): +@pytest.mark.parametrize("path", ["root", "{relative}/root", "{environment}/root"]) +def test_rootdir_option_arg(testdir, path): + if 'relative' in path: + path = path.format(relative=os.getcwd()) + if 'environment' in path: + os.environ['PY_ROOTDIR_PATH'] = os.getcwd() + path = path.format(environment='$PY_ROOTDIR_PATH') + rootdir = testdir.mkdir("root") rootdir.mkdir("tests") testdir.makepyfile(""" import os def test_one(): - assert os.path.isdir('.cache') + assert 1 """) - result = testdir.runpytest() - result.stdout.fnmatch_lines(["*AssertionError*"]) - - result = testdir.runpytest("--rootdir=root") - result.stdout.fnmatch_lines(["*1 passed*"]) + result = testdir.runpytest("--rootdir={}".format(os.path.expandvars(path))) + result.stdout.fnmatch_lines(['*rootdir: {}/root, inifile:*'.format(os.getcwd()), "*1 passed*"]) def test_rootdir_wrong_option_arg(testdir): From 936651702bc14ed785dc40e364feed7f48892f1a Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Thu, 1 Feb 2018 00:27:48 +0300 Subject: [PATCH 012/107] #1642 Fix tests --- testing/test_session.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/testing/test_session.py b/testing/test_session.py index 5f85c6309de..ddebe2f6745 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -279,9 +279,11 @@ def test_one(): def test_rootdir_wrong_option_arg(testdir): - rootdir = testdir.mkdir("root") - testsdir = rootdir.mkdir("tests") - testsdir.join("test_one.py").write("def test_one():\n assert 1") + testdir.makepyfile(""" + import os + def test_one(): + assert 1 + """) result = testdir.runpytest("--rootdir=wrong_dir") result.stderr.fnmatch_lines(["*Directory *wrong_dir* not found. Check your '--rootdir' option.*"]) From 3eb6cad222b3c5eb5d060f19faf94d046b95dfde Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Thu, 1 Feb 2018 11:20:37 +0300 Subject: [PATCH 013/107] #1642 Fix comments --- _pytest/config.py | 6 +++--- doc/en/customize.rst | 9 +++------ testing/test_session.py | 16 ++++++---------- 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index 986f46ff0a9..a9b071b6f3a 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -991,8 +991,8 @@ def pytest_load_initial_conftests(self, early_config): def _initini(self, args): ns, unknown_args = self._parser.parse_known_and_unknown_args(args, namespace=self.option.copy()) - rootdir = ns.rootdir if ns.rootdir else None - r = determine_setup(ns.inifilename, ns.file_or_dir + unknown_args, warnfunc=self.warn, rootdir_cmd_arg=rootdir) + r = determine_setup(ns.inifilename, ns.file_or_dir + unknown_args, warnfunc=self.warn, + rootdir_cmd_arg=ns.rootdir or None) self.rootdir, self.inifile, self.inicfg = r self._parser.extra_info['rootdir'] = self.rootdir self._parser.extra_info['inifile'] = self.inifile @@ -1348,7 +1348,7 @@ def determine_setup(inifile, args, warnfunc=None, rootdir_cmd_arg=None): if is_fs_root: rootdir = ancestor if rootdir_cmd_arg: - rootdir_abs_path = py.path.local(rootdir_cmd_arg) + rootdir_abs_path = py.path.local(os.path.expandvars(rootdir_cmd_arg)) if not os.path.isdir(str(rootdir_abs_path)): raise UsageError("Directory '{}' not found. Check your '--rootdir' option.".format(rootdir_abs_path)) rootdir = rootdir_abs_path diff --git a/doc/en/customize.rst b/doc/en/customize.rst index 1f0d8bb487f..7aa1641fa73 100644 --- a/doc/en/customize.rst +++ b/doc/en/customize.rst @@ -38,6 +38,9 @@ Here's a summary what ``pytest`` uses ``rootdir`` for: Important to emphasize that ``rootdir`` is **NOT** used to modify ``sys.path``/``PYTHONPATH`` or influence how modules are imported. See :ref:`pythonpath` for more details. +``--rootdir=path`` command line option sets a ``rootdir`` directory. Directory may be relative or absolute path. +Additionally path may contain environment variables, that will be expanded. + Finding the ``rootdir`` ~~~~~~~~~~~~~~~~~~~~~~~ @@ -371,9 +374,3 @@ passed multiple times. The expected format is ``name=value``. For example:: .. _`#3155`: https://github.com/pytest-dev/pytest/issues/3155 - -.. confval:: rootdir - - Sets a :ref:`rootdir ` directory. Directory may be relative or absolute path. - Additionally path may contain environment variables, that will be expanded. - For more information about rootdir please refer to :ref:`rootdir `. diff --git a/testing/test_session.py b/testing/test_session.py index ddebe2f6745..68534b102d9 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -1,7 +1,5 @@ from __future__ import absolute_import, division, print_function -import os - import pytest from _pytest.main import EXIT_NOTESTSCOLLECTED @@ -259,12 +257,10 @@ def pytest_sessionfinish(): @pytest.mark.parametrize("path", ["root", "{relative}/root", "{environment}/root"]) -def test_rootdir_option_arg(testdir, path): - if 'relative' in path: - path = path.format(relative=os.getcwd()) - if 'environment' in path: - os.environ['PY_ROOTDIR_PATH'] = os.getcwd() - path = path.format(environment='$PY_ROOTDIR_PATH') +def test_rootdir_option_arg(testdir, monkeypatch, path): + monkeypatch.setenv('PY_ROOTDIR_PATH', str(testdir.tmpdir)) + path = path.format(relative=str(testdir.tmpdir), + environment='$PY_ROOTDIR_PATH') rootdir = testdir.mkdir("root") rootdir.mkdir("tests") @@ -274,8 +270,8 @@ def test_one(): assert 1 """) - result = testdir.runpytest("--rootdir={}".format(os.path.expandvars(path))) - result.stdout.fnmatch_lines(['*rootdir: {}/root, inifile:*'.format(os.getcwd()), "*1 passed*"]) + result = testdir.runpytest("--rootdir={}".format(path)) + result.stdout.fnmatch_lines(['*rootdir: {}/root, inifile:*'.format(testdir.tmpdir), "*1 passed*"]) def test_rootdir_wrong_option_arg(testdir): From 37d836d754eb8e20540ff1a16da76edee9a7adda Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 1 Feb 2018 19:34:15 -0200 Subject: [PATCH 014/107] Reword docs slightly --- doc/en/customize.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/en/customize.rst b/doc/en/customize.rst index 7aa1641fa73..f819c5974a0 100644 --- a/doc/en/customize.rst +++ b/doc/en/customize.rst @@ -38,8 +38,9 @@ Here's a summary what ``pytest`` uses ``rootdir`` for: Important to emphasize that ``rootdir`` is **NOT** used to modify ``sys.path``/``PYTHONPATH`` or influence how modules are imported. See :ref:`pythonpath` for more details. -``--rootdir=path`` command line option sets a ``rootdir`` directory. Directory may be relative or absolute path. -Additionally path may contain environment variables, that will be expanded. +``--rootdir=path`` command-line option can be used to force a specific directory. +The directory passed may contain environment variables when it is used in conjunction +with ``addopts`` in a ``pytest.ini`` file. Finding the ``rootdir`` ~~~~~~~~~~~~~~~~~~~~~~~ From c0ef4a4d3544b103921946f66ef8c26fe6d192b5 Mon Sep 17 00:00:00 2001 From: Thomas Hisch Date: Fri, 26 Jan 2018 09:28:23 +0100 Subject: [PATCH 015/107] Add captured log msgs to junit xml file For each test this adds the captured log msgs to a system-* tag in the junit xml output file. The destination of the system-* tag is specified by junit_logging ini option. --- _pytest/junitxml.py | 53 ++++++++++++++++++++++++++++++++++++---- _pytest/runner.py | 8 ++++++ changelog/3156.feature | 1 + testing/test_junitxml.py | 23 ++++++++++++++--- 4 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 changelog/3156.feature diff --git a/_pytest/junitxml.py b/_pytest/junitxml.py index e929eeba8e4..a8cea6fc1c5 100644 --- a/_pytest/junitxml.py +++ b/_pytest/junitxml.py @@ -130,10 +130,47 @@ def _add_simple(self, kind, message, data=None): self.append(node) def write_captured_output(self, report): - for capname in ('out', 'err'): - content = getattr(report, 'capstd' + capname) + content_out = report.capstdout + content_log = report.caplog + content_err = report.capstderr + + if content_log or content_out: + if content_log and self.xml.logging == 'system-out': + if content_out: + # syncing stdout and the log-output is not done yet. It's + # probably not worth the effort. Therefore, first the captured + # stdout is shown and then the captured logs. + content = '\n'.join([ + ' Captured Stdout '.center(80, '-'), + content_out, + '', + ' Captured Log '.center(80, '-'), + content_log]) + else: + content = content_log + else: + content = content_out + + if content: + tag = getattr(Junit, 'system-out') + self.append(tag(bin_xml_escape(content))) + + if content_log or content_err: + if content_log and self.xml.logging == 'system-err': + if content_err: + content = '\n'.join([ + ' Captured Stderr '.center(80, '-'), + content_err, + '', + ' Captured Log '.center(80, '-'), + content_log]) + else: + content = content_log + else: + content = content_err + if content: - tag = getattr(Junit, 'system-' + capname) + tag = getattr(Junit, 'system-err') self.append(tag(bin_xml_escape(content))) def append_pass(self, report): @@ -254,13 +291,18 @@ def pytest_addoption(parser): default=None, help="prepend prefix to classnames in junit-xml output") parser.addini("junit_suite_name", "Test suite name for JUnit report", default="pytest") + parser.addini("junit_logging", "Write captured log messages to JUnit report: " + "one of no|system-out|system-err", + default="no") # choices=['no', 'stdout', 'stderr']) def pytest_configure(config): xmlpath = config.option.xmlpath # prevent opening xmllog on slave nodes (xdist) if xmlpath and not hasattr(config, 'slaveinput'): - config._xml = LogXML(xmlpath, config.option.junitprefix, config.getini("junit_suite_name")) + config._xml = LogXML(xmlpath, config.option.junitprefix, + config.getini("junit_suite_name"), + config.getini("junit_logging")) config.pluginmanager.register(config._xml) @@ -287,11 +329,12 @@ def mangle_test_address(address): class LogXML(object): - def __init__(self, logfile, prefix, suite_name="pytest"): + def __init__(self, logfile, prefix, suite_name="pytest", logging="no"): logfile = os.path.expanduser(os.path.expandvars(logfile)) self.logfile = os.path.normpath(os.path.abspath(logfile)) self.prefix = prefix self.suite_name = suite_name + self.logging = logging self.stats = dict.fromkeys([ 'error', 'passed', diff --git a/_pytest/runner.py b/_pytest/runner.py index d82865b7684..fda8c785a81 100644 --- a/_pytest/runner.py +++ b/_pytest/runner.py @@ -256,6 +256,14 @@ def longreprtext(self): exc = tw.stringio.getvalue() return exc.strip() + @property + def caplog(self): + """Return captured log lines, if log capturing is enabled + + .. versionadded:: 3.4 + """ + return '\n'.join(content for (prefix, content) in self.get_sections('Captured log')) + @property def capstdout(self): """Return captured text from stdout, if capturing is enabled diff --git a/changelog/3156.feature b/changelog/3156.feature new file mode 100644 index 00000000000..125605b38bf --- /dev/null +++ b/changelog/3156.feature @@ -0,0 +1 @@ +Captured log messages are added to the ```` tag in the generated junit xml file if the ``junit_logging`` ini option is set to ``system-out``. If the value of this ini option is ``system-err`, the logs are written to ````. The default value for ``junit_logging`` is ``no``, meaning captured logs are not written to the output file. diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 49318ef762d..031caeb206a 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -328,23 +328,28 @@ def test_internal_error(self, testdir): fnode.assert_attr(message="internal error") assert "Division" in fnode.toxml() - def test_failure_function(self, testdir): + @pytest.mark.parametrize('junit_logging', ['no', 'system-out', 'system-err']) + def test_failure_function(self, testdir, junit_logging): testdir.makepyfile(""" + import logging import sys + def test_fail(): print ("hello-stdout") sys.stderr.write("hello-stderr\\n") + logging.info('info msg') + logging.warning('warning msg') raise ValueError(42) """) - result, dom = runandparse(testdir) + result, dom = runandparse(testdir, '-o', 'junit_logging=%s' % junit_logging) assert result.ret node = dom.find_first_by_tag("testsuite") node.assert_attr(failures=1, tests=1) tnode = node.find_first_by_tag("testcase") tnode.assert_attr( file="test_failure_function.py", - line="1", + line="3", classname="test_failure_function", name="test_fail") fnode = tnode.find_first_by_tag("failure") @@ -353,9 +358,21 @@ def test_fail(): systemout = fnode.next_siebling assert systemout.tag == "system-out" assert "hello-stdout" in systemout.toxml() + assert "info msg" not in systemout.toxml() systemerr = systemout.next_siebling assert systemerr.tag == "system-err" assert "hello-stderr" in systemerr.toxml() + assert "info msg" not in systemerr.toxml() + + if junit_logging == 'system-out': + assert "warning msg" in systemout.toxml() + assert "warning msg" not in systemerr.toxml() + elif junit_logging == 'system-err': + assert "warning msg" not in systemout.toxml() + assert "warning msg" in systemerr.toxml() + elif junit_logging == 'no': + assert "warning msg" not in systemout.toxml() + assert "warning msg" not in systemerr.toxml() def test_failure_verbose_message(self, testdir): testdir.makepyfile(""" From 2d0c1e941ef096f4c6a82ee84c659112bf2f9da0 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 5 Feb 2018 20:22:21 -0200 Subject: [PATCH 016/107] Fix versionadded tag in caplog function --- _pytest/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pytest/runner.py b/_pytest/runner.py index fda8c785a81..b41a3d350f0 100644 --- a/_pytest/runner.py +++ b/_pytest/runner.py @@ -260,7 +260,7 @@ def longreprtext(self): def caplog(self): """Return captured log lines, if log capturing is enabled - .. versionadded:: 3.4 + .. versionadded:: 3.5 """ return '\n'.join(content for (prefix, content) in self.get_sections('Captured log')) From 67558e0e22042e8f46fb3e31248e547225782487 Mon Sep 17 00:00:00 2001 From: Pedro Algarvio Date: Wed, 31 Jan 2018 13:48:55 +0000 Subject: [PATCH 017/107] Additionally handle logstart and logfinish hooks --- _pytest/logging.py | 15 ++++++++++++++- changelog/3189.feature | 1 + testing/logging/test_reporting.py | 23 ++++++++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 changelog/3189.feature diff --git a/_pytest/logging.py b/_pytest/logging.py index 095115cd979..2de7786eae4 100644 --- a/_pytest/logging.py +++ b/_pytest/logging.py @@ -371,6 +371,11 @@ def _runtest_for(self, item, when): formatter=self.formatter, level=self.log_level) as log_handler: if self.log_cli_handler: self.log_cli_handler.set_when(when) + + if item is None: + yield # run the test + return + if not hasattr(item, 'catch_log_handlers'): item.catch_log_handlers = {} item.catch_log_handlers[when] = log_handler @@ -402,9 +407,17 @@ def pytest_runtest_teardown(self, item): with self._runtest_for(item, 'teardown'): yield + @pytest.hookimpl(hookwrapper=True) def pytest_runtest_logstart(self): if self.log_cli_handler: self.log_cli_handler.reset() + with self._runtest_for(None, 'start'): + yield + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_logfinish(self): + with self._runtest_for(None, 'finish'): + yield @pytest.hookimpl(hookwrapper=True) def pytest_runtestloop(self, session): @@ -474,7 +487,7 @@ def emit(self, record): if self.capture_manager is not None: self.capture_manager.suspend_global_capture() try: - if not self._first_record_emitted or self._when == 'teardown': + if not self._first_record_emitted or self._when in ('teardown', 'finish'): self.stream.write('\n') self._first_record_emitted = True if not self._section_name_shown: diff --git a/changelog/3189.feature b/changelog/3189.feature new file mode 100644 index 00000000000..5fd81f99fc5 --- /dev/null +++ b/changelog/3189.feature @@ -0,0 +1 @@ +Allow the logging plugin, when live logs are enabled, to handle the hooks `pytest_runtest_logstart` and `pytest_runtest_logfinish`. diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index f5272aa0983..40e2cdbc530 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -161,6 +161,7 @@ def test_log_cli(): if enabled: result.stdout.fnmatch_lines([ 'test_log_cli_enabled_disabled.py::test_log_cli ', + '*-- live log call --*', 'test_log_cli_enabled_disabled.py* CRITICAL critical message logged by test', 'PASSED*', ]) @@ -226,8 +227,20 @@ def test_log_2(): def test_log_cli_default_level_sections(testdir, request): - """Check that with live logging enable we are printing the correct headers during setup/call/teardown.""" + """Check that with live logging enable we are printing the correct headers during + start/setup/call/teardown/finish.""" filename = request.node.name + '.py' + testdir.makeconftest(''' + import pytest + import logging + + def pytest_runtest_logstart(): + logging.warning('>>>>> START >>>>>') + + def pytest_runtest_logfinish(): + logging.warning('<<<<< END <<<<<<<') + ''') + testdir.makepyfile(''' import pytest import logging @@ -252,6 +265,8 @@ def test_log_2(fix): result = testdir.runpytest() result.stdout.fnmatch_lines([ '{}::test_log_1 '.format(filename), + '*-- live log start --*', + '*WARNING* >>>>> START >>>>>*', '*-- live log setup --*', '*WARNING*log message from setup of test_log_1*', '*-- live log call --*', @@ -259,8 +274,12 @@ def test_log_2(fix): 'PASSED *50%*', '*-- live log teardown --*', '*WARNING*log message from teardown of test_log_1*', + '*-- live log finish --*', + '*WARNING* <<<<< END <<<<<<<*', '{}::test_log_2 '.format(filename), + '*-- live log start --*', + '*WARNING* >>>>> START >>>>>*', '*-- live log setup --*', '*WARNING*log message from setup of test_log_2*', '*-- live log call --*', @@ -268,6 +287,8 @@ def test_log_2(fix): 'PASSED *100%*', '*-- live log teardown --*', '*WARNING*log message from teardown of test_log_2*', + '*-- live log finish --*', + '*WARNING* <<<<< END <<<<<<<*', '=* 2 passed in *=', ]) From 00d8787bb85996a185370ea424d3b216d5265bc9 Mon Sep 17 00:00:00 2001 From: Pedro Algarvio Date: Mon, 5 Feb 2018 17:46:49 +0000 Subject: [PATCH 018/107] Add name to AUTHORS --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 583fa6f9e18..d3244dfe378 100644 --- a/AUTHORS +++ b/AUTHORS @@ -148,6 +148,7 @@ Omar Kohl Omer Hadari Patrick Hayes Paweł Adamczak +Pedro Algarvio Pieter Mulder Piotr Banaszkiewicz Punyashloka Biswal From ea06c1345fb800396fc0ba7c83dc4a75c4a1fb73 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 6 Feb 2018 08:35:31 -0200 Subject: [PATCH 019/107] Update changelog wording slightly --- changelog/3189.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/3189.feature b/changelog/3189.feature index 5fd81f99fc5..d35789b1f8f 100644 --- a/changelog/3189.feature +++ b/changelog/3189.feature @@ -1 +1 @@ -Allow the logging plugin, when live logs are enabled, to handle the hooks `pytest_runtest_logstart` and `pytest_runtest_logfinish`. +Allow the logging plugin to handle ``pytest_runtest_logstart`` and ``pytest_runtest_logfinish`` hooks when live logs are enabled. From 1a650a9eb9b301fd2f47f627868fbabcfb65f37e Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Tue, 6 Feb 2018 23:38:51 +0300 Subject: [PATCH 020/107] #1478 Added --show-capture option --- _pytest/terminal.py | 11 +++++++---- changelog/1478.feature | 4 ++++ changelog/1478.trivial | 1 - testing/test_terminal.py | 14 +++++++++++--- 4 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 changelog/1478.feature delete mode 100644 changelog/1478.trivial diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 7f42f12840b..23fc10794a0 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -42,9 +42,10 @@ def pytest_addoption(parser): action="store", dest="tbstyle", default='auto', choices=['auto', 'long', 'short', 'no', 'line', 'native'], help="traceback print mode (auto/long/short/line/native/no).") - group._addoption('--no-stdout', - action="store_false", dest="nostdout", - help="Do not print stdout") + group._addoption('--show-capture', + action="store", dest="showcapture", + choices=['no', 'stdout', 'stderr'], + help="Print only stdout/stderr on not print both.") group._addoption('--fulltrace', '--full-trace', action="store_true", default=False, help="don't cut any tracebacks (default is to cut).") @@ -625,8 +626,10 @@ def summary_errors(self): def _outrep_summary(self, rep): rep.toterminal(self._tw) + if self.config.option.showcapture == 'no': + return for secname, content in rep.sections: - if not self.config.option.nostdout and 'stdout' in secname: + if self.config.option.showcapture and not (self.config.option.showcapture in secname): continue self._tw.sep("-", secname) if content[-1:] == "\n": diff --git a/changelog/1478.feature b/changelog/1478.feature new file mode 100644 index 00000000000..316943e782a --- /dev/null +++ b/changelog/1478.feature @@ -0,0 +1,4 @@ +Added `--show-capture` feature. You can choose 'no', 'stdout' or 'stderr' option. +'no': stdout and stderr will now shown +'stdout': stdout will shown in terminal if you use this option but stderr will not shown. +'stderr': stderr will shown in terminal if you use this option but stdout will not shown. \ No newline at end of file diff --git a/changelog/1478.trivial b/changelog/1478.trivial deleted file mode 100644 index aa88151e7c4..00000000000 --- a/changelog/1478.trivial +++ /dev/null @@ -1 +0,0 @@ -Added `--no-stdout` feature. Stdout will not shown in terminal if you use this option. Only Stderr will shown. \ No newline at end of file diff --git a/testing/test_terminal.py b/testing/test_terminal.py index bedd6a9a549..b2dcf84a6d0 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -823,18 +823,26 @@ def pytest_report_header(config, startdir): str(testdir.tmpdir), ]) - def test_no_stdout(self, testdir): + def test_show_capture(self, testdir): testdir.makepyfile(""" + import sys def test_one(): - print('!This is stdout!') + sys.stdout.write('!This is stdout!') + sys.stderr.write('!This is stderr!') assert False, 'Something failed' """) result = testdir.runpytest("--tb=short") result.stdout.fnmatch_lines(["!This is stdout!"]) + result.stdout.fnmatch_lines(["!This is stderr!"]) - result = testdir.runpytest("--no-stdout", "--tb=short") + result = testdir.runpytest("--show-capture=stdout", "--tb=short") + assert "!This is stderr!" not in result.stdout.str() + assert "!This is stdout!" in result.stdout.str() + + result = testdir.runpytest("--show-capture=stderr", "--tb=short") assert "!This is stdout!" not in result.stdout.str() + assert "!This is stderr!" in result.stdout.str() @pytest.mark.xfail("not hasattr(os, 'dup')") From 0b71255ddaea1b31c52c52454e0adc81a395577c Mon Sep 17 00:00:00 2001 From: Pedro Algarvio Date: Mon, 5 Feb 2018 18:07:40 +0000 Subject: [PATCH 021/107] Expose `log_cli` as a CLI parser option. --- _pytest/logging.py | 11 ++++++-- changelog/3190.feature | 1 + testing/logging/test_reporting.py | 42 +++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 changelog/3190.feature diff --git a/_pytest/logging.py b/_pytest/logging.py index 2de7786eae4..685c2831e96 100644 --- a/_pytest/logging.py +++ b/_pytest/logging.py @@ -336,9 +336,10 @@ def __init__(self, config): create a single one for the entire test session here. """ self._config = config + self._stream_logs = None # enable verbose output automatically if live logging is enabled - if self._config.getini('log_cli') and not config.getoption('verbose'): + if self._stream_logs_enabled() and not config.getoption('verbose'): # sanity check: terminal reporter should not have been loaded at this point assert self._config.pluginmanager.get_plugin('terminalreporter') is None config.option.verbose = 1 @@ -364,6 +365,12 @@ def __init__(self, config): # initialized during pytest_runtestloop self.log_cli_handler = None + def _stream_logs_enabled(self): + if self._stream_logs is None: + self._stream_logs = self._config.getoption('--log-cli-level') is not None or \ + self._config.getini('log_cli') + return self._stream_logs + @contextmanager def _runtest_for(self, item, when): """Implements the internals of pytest_runtest_xxx() hook.""" @@ -438,7 +445,7 @@ def _setup_cli_logging(self): This must be done right before starting the loop so we can access the terminal reporter plugin. """ terminal_reporter = self._config.pluginmanager.get_plugin('terminalreporter') - if self._config.getini('log_cli') and terminal_reporter is not None: + if self._stream_logs_enabled() and terminal_reporter is not None: capture_manager = self._config.pluginmanager.get_plugin('capturemanager') log_cli_handler = _LiveLoggingStreamHandler(terminal_reporter, capture_manager) log_cli_format = get_option_ini(self._config, 'log_cli_format', 'log_format') diff --git a/changelog/3190.feature b/changelog/3190.feature new file mode 100644 index 00000000000..95bb5e39be4 --- /dev/null +++ b/changelog/3190.feature @@ -0,0 +1 @@ +Passing `--log-cli-level` in the command-line now automatically activates live logging. diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 40e2cdbc530..d7ba63a4ea4 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -366,6 +366,48 @@ def test_log_cli(request): assert result.ret == 0 +@pytest.mark.parametrize('cli_args', ['', + '--log-level=WARNING', + '--log-file-level=WARNING', + '--log-cli-level=WARNING']) +def test_log_cli_auto_enable(testdir, request, cli_args): + """Check that live logs are enabled if --log-level or --log-cli-level is passed on the CLI. + It should not be auto enabled if the same configs are set on the INI file. + """ + testdir.makepyfile(''' + import pytest + import logging + + def test_log_1(): + logging.info("log message from test_log_1 not to be shown") + logging.warning("log message from test_log_1") + + ''') + testdir.makeini(''' + [pytest] + log_level=INFO + log_cli_level=INFO + ''') + + result = testdir.runpytest(cli_args) + if cli_args == '--log-cli-level=WARNING': + result.stdout.fnmatch_lines([ + '*::test_log_1 ', + '*-- live log call --*', + '*WARNING*log message from test_log_1*', + 'PASSED *100%*', + '=* 1 passed in *=', + ]) + assert 'INFO' not in result.stdout.str() + else: + result.stdout.fnmatch_lines([ + '*test_log_cli_auto_enable*100%*', + '=* 1 passed in *=', + ]) + assert 'INFO' not in result.stdout.str() + assert 'WARNING' not in result.stdout.str() + + def test_log_file_cli(testdir): # Default log file level testdir.makepyfile(''' From ad7d63df97f60e23a26424ac04f515957e998531 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 8 Feb 2018 09:48:51 -0200 Subject: [PATCH 022/107] Rename _stream_logs_enabled to _log_cli_enabled and remove _stream_logs --- _pytest/logging.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/_pytest/logging.py b/_pytest/logging.py index 685c2831e96..fd90e9c949f 100644 --- a/_pytest/logging.py +++ b/_pytest/logging.py @@ -336,10 +336,9 @@ def __init__(self, config): create a single one for the entire test session here. """ self._config = config - self._stream_logs = None # enable verbose output automatically if live logging is enabled - if self._stream_logs_enabled() and not config.getoption('verbose'): + if self._log_cli_enabled() and not config.getoption('verbose'): # sanity check: terminal reporter should not have been loaded at this point assert self._config.pluginmanager.get_plugin('terminalreporter') is None config.option.verbose = 1 @@ -365,11 +364,12 @@ def __init__(self, config): # initialized during pytest_runtestloop self.log_cli_handler = None - def _stream_logs_enabled(self): - if self._stream_logs is None: - self._stream_logs = self._config.getoption('--log-cli-level') is not None or \ - self._config.getini('log_cli') - return self._stream_logs + def _log_cli_enabled(self): + """Return True if log_cli should be considered enabled, either explicitly + or because --log-cli-level was given in the command-line. + """ + return self._config.getoption('--log-cli-level') is not None or \ + self._config.getini('log_cli') @contextmanager def _runtest_for(self, item, when): @@ -445,7 +445,7 @@ def _setup_cli_logging(self): This must be done right before starting the loop so we can access the terminal reporter plugin. """ terminal_reporter = self._config.pluginmanager.get_plugin('terminalreporter') - if self._stream_logs_enabled() and terminal_reporter is not None: + if self._log_cli_enabled() and terminal_reporter is not None: capture_manager = self._config.pluginmanager.get_plugin('capturemanager') log_cli_handler = _LiveLoggingStreamHandler(terminal_reporter, capture_manager) log_cli_format = get_option_ini(self._config, 'log_cli_format', 'log_format') From 71367881ed14eb1cbdfee47a0cf8e0aa431f161c Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Thu, 8 Feb 2018 16:21:22 +0300 Subject: [PATCH 023/107] #1478 Added --show-capture=both option (fix comments) --- _pytest/terminal.py | 11 +++++++---- changelog/1478.feature | 5 +---- testing/test_terminal.py | 8 ++++++++ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 23fc10794a0..a75fce4c743 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -44,8 +44,9 @@ def pytest_addoption(parser): help="traceback print mode (auto/long/short/line/native/no).") group._addoption('--show-capture', action="store", dest="showcapture", - choices=['no', 'stdout', 'stderr'], - help="Print only stdout/stderr on not print both.") + choices=['no', 'stdout', 'stderr', 'both'], default='both', + help="Controls how captured stdout/stderr is shown on failed tests. " + "Default is 'both'.") group._addoption('--fulltrace', '--full-trace', action="store_true", default=False, help="don't cut any tracebacks (default is to cut).") @@ -629,8 +630,10 @@ def _outrep_summary(self, rep): if self.config.option.showcapture == 'no': return for secname, content in rep.sections: - if self.config.option.showcapture and not (self.config.option.showcapture in secname): - continue + print(self.config.option.showcapture) + if self.config.option.showcapture != 'both': + if not (self.config.option.showcapture in secname): + continue self._tw.sep("-", secname) if content[-1:] == "\n": content = content[:-1] diff --git a/changelog/1478.feature b/changelog/1478.feature index 316943e782a..de6bd311890 100644 --- a/changelog/1478.feature +++ b/changelog/1478.feature @@ -1,4 +1 @@ -Added `--show-capture` feature. You can choose 'no', 'stdout' or 'stderr' option. -'no': stdout and stderr will now shown -'stdout': stdout will shown in terminal if you use this option but stderr will not shown. -'stderr': stderr will shown in terminal if you use this option but stdout will not shown. \ No newline at end of file +New ``--show-capture`` command-line option that allows to specify how to display captured output when tests fail: ``no``, ``stdout``, ``stderr`` or ``both`` (the default). \ No newline at end of file diff --git a/testing/test_terminal.py b/testing/test_terminal.py index b2dcf84a6d0..26739165d3f 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -836,6 +836,10 @@ def test_one(): result.stdout.fnmatch_lines(["!This is stdout!"]) result.stdout.fnmatch_lines(["!This is stderr!"]) + result = testdir.runpytest("--show-capture=both", "--tb=short") + result.stdout.fnmatch_lines(["!This is stdout!"]) + result.stdout.fnmatch_lines(["!This is stderr!"]) + result = testdir.runpytest("--show-capture=stdout", "--tb=short") assert "!This is stderr!" not in result.stdout.str() assert "!This is stdout!" in result.stdout.str() @@ -844,6 +848,10 @@ def test_one(): assert "!This is stdout!" not in result.stdout.str() assert "!This is stderr!" in result.stdout.str() + result = testdir.runpytest("--show-capture=no", "--tb=short") + assert "!This is stdout!" not in result.stdout.str() + assert "!This is stderr!" not in result.stdout.str() + @pytest.mark.xfail("not hasattr(os, 'dup')") def test_fdopen_kept_alive_issue124(testdir): From a4cbd035359f1ac01924b0b05cf93bb1c877cdc6 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 8 Feb 2018 13:22:32 -0200 Subject: [PATCH 024/107] Fix linting --- _pytest/logging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pytest/logging.py b/_pytest/logging.py index fd90e9c949f..ecb992cead6 100644 --- a/_pytest/logging.py +++ b/_pytest/logging.py @@ -369,7 +369,7 @@ def _log_cli_enabled(self): or because --log-cli-level was given in the command-line. """ return self._config.getoption('--log-cli-level') is not None or \ - self._config.getini('log_cli') + self._config.getini('log_cli') @contextmanager def _runtest_for(self, item, when): From d776e5610e163665dddc1322c49f16c70cb7a3f5 Mon Sep 17 00:00:00 2001 From: Pedro Algarvio Date: Thu, 8 Feb 2018 23:23:12 +0000 Subject: [PATCH 025/107] Fix issue where a new line was always written for the live log finish section --- _pytest/logging.py | 9 ++++- testing/logging/test_reporting.py | 59 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/_pytest/logging.py b/_pytest/logging.py index ecb992cead6..b5034751871 100644 --- a/_pytest/logging.py +++ b/_pytest/logging.py @@ -480,6 +480,7 @@ def __init__(self, terminal_reporter, capture_manager): self.capture_manager = capture_manager self.reset() self.set_when(None) + self._test_outcome_written = False def reset(self): """Reset the handler; should be called before the start of each test""" @@ -489,14 +490,20 @@ def set_when(self, when): """Prepares for the given test phase (setup/call/teardown)""" self._when = when self._section_name_shown = False + if when == 'start': + self._test_outcome_written = False def emit(self, record): if self.capture_manager is not None: self.capture_manager.suspend_global_capture() try: - if not self._first_record_emitted or self._when in ('teardown', 'finish'): + if not self._first_record_emitted: self.stream.write('\n') self._first_record_emitted = True + elif self._when in ('teardown', 'finish'): + if not self._test_outcome_written: + self._test_outcome_written = True + self.stream.write('\n') if not self._section_name_shown: self.stream.section('live log ' + self._when, sep='-', bold=True) self._section_name_shown = True diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index d7ba63a4ea4..97b31521fea 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import re import os import six @@ -293,6 +294,64 @@ def test_log_2(fix): ]) +def test_sections_single_new_line_after_test_outcome(testdir, request): + """Check that only a single new line is written between log messages during + teardown/finish.""" + filename = request.node.name + '.py' + testdir.makeconftest(''' + import pytest + import logging + + def pytest_runtest_logstart(): + logging.warning('>>>>> START >>>>>') + + def pytest_runtest_logfinish(): + logging.warning('<<<<< END <<<<<<<') + logging.warning('<<<<< END <<<<<<<') + ''') + + testdir.makepyfile(''' + import pytest + import logging + + @pytest.fixture + def fix(request): + logging.warning("log message from setup of {}".format(request.node.name)) + yield + logging.warning("log message from teardown of {}".format(request.node.name)) + logging.warning("log message from teardown of {}".format(request.node.name)) + + def test_log_1(fix): + logging.warning("log message from test_log_1") + ''') + testdir.makeini(''' + [pytest] + log_cli=true + ''') + + result = testdir.runpytest() + result.stdout.fnmatch_lines([ + '{}::test_log_1 '.format(filename), + '*-- live log start --*', + '*WARNING* >>>>> START >>>>>*', + '*-- live log setup --*', + '*WARNING*log message from setup of test_log_1*', + '*-- live log call --*', + '*WARNING*log message from test_log_1*', + 'PASSED *100%*', + '*-- live log teardown --*', + '*WARNING*log message from teardown of test_log_1*', + '*-- live log finish --*', + '*WARNING* <<<<< END <<<<<<<*', + '*WARNING* <<<<< END <<<<<<<*', + '=* 1 passed in *=', + ]) + assert re.search(r'(.+)live log teardown(.+)\n(.+)WARNING(.+)\n(.+)WARNING(.+)', + result.stdout.str(), re.MULTILINE) is not None + assert re.search(r'(.+)live log finish(.+)\n(.+)WARNING(.+)\n(.+)WARNING(.+)', + result.stdout.str(), re.MULTILINE) is not None + + def test_log_cli_level(testdir): # Default log file level testdir.makepyfile(''' From da5882c2d5607174128f4618020a1ff26adc8316 Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Fri, 9 Feb 2018 21:36:48 +0300 Subject: [PATCH 026/107] #1478 Add doc and remove print --- _pytest/terminal.py | 1 - doc/en/capture.rst | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/_pytest/terminal.py b/_pytest/terminal.py index a75fce4c743..257fa87f165 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -630,7 +630,6 @@ def _outrep_summary(self, rep): if self.config.option.showcapture == 'no': return for secname, content in rep.sections: - print(self.config.option.showcapture) if self.config.option.showcapture != 'both': if not (self.config.option.showcapture in secname): continue diff --git a/doc/en/capture.rst b/doc/en/capture.rst index 3315065c529..901def6021c 100644 --- a/doc/en/capture.rst +++ b/doc/en/capture.rst @@ -9,7 +9,8 @@ Default stdout/stderr/stdin capturing behaviour During test execution any output sent to ``stdout`` and ``stderr`` is captured. If a test or a setup method fails its according captured -output will usually be shown along with the failure traceback. +output will usually be shown along with the failure traceback. (this +behavior can be configured by the ``--show-capture`` command-line option). In addition, ``stdin`` is set to a "null" object which will fail on attempts to read from it because it is rarely desired From dff0500114971b30a7bb9043acb0d0fb6a9e01c4 Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Tue, 13 Feb 2018 22:49:28 +0300 Subject: [PATCH 027/107] #3034 Added new option "--new-first" --- _pytest/cacheprovider.py | 42 ++++++++++++ changelog/3034.feature | 1 + doc/en/cache.rst | 10 +++ testing/test_cacheprovider.py | 121 ++++++++++++++++++++++++++++++++-- 4 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 changelog/3034.feature diff --git a/_pytest/cacheprovider.py b/_pytest/cacheprovider.py index 27dadb32803..04dacf83780 100755 --- a/_pytest/cacheprovider.py +++ b/_pytest/cacheprovider.py @@ -6,6 +6,8 @@ """ from __future__ import absolute_import, division, print_function import py +from _pytest.python import Function + import pytest import json import os @@ -168,6 +170,41 @@ def pytest_sessionfinish(self, session): config.cache.set("cache/lastfailed", self.lastfailed) +class NFPlugin(object): + """ Plugin which implements the --nf (run new-first) option """ + + def __init__(self, config): + self.config = config + self.active = config.option.newfirst + self.all_items = config.cache.get("cache/allitems", {}) + + def pytest_collection_modifyitems(self, session, config, items): + if self.active: + new_items = [] + 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: + new_items.append((item, mod_timestamp)) + else: + other_items.append((item, mod_timestamp)) + + items[:] = self._get_increasing_order(new_items) + \ + self._get_increasing_order(other_items) + self.all_items = items + + def _get_increasing_order(self, test_list): + test_list = sorted(test_list, key=lambda x: x[1], reverse=True) + return [test[0] for test in test_list] + + def pytest_sessionfinish(self, session): + config = self.config + 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)]) + + def pytest_addoption(parser): group = parser.getgroup("general") group.addoption( @@ -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 " + "last modified files, then other tests") group.addoption( '--cache-show', action='store_true', dest="cacheshow", help="show cache contents, don't perform collection or tests") @@ -200,6 +241,7 @@ def pytest_cmdline_main(config): def pytest_configure(config): config.cache = Cache(config) config.pluginmanager.register(LFPlugin(config), "lfplugin") + config.pluginmanager.register(NFPlugin(config), "nfplugin") @pytest.fixture diff --git a/changelog/3034.feature b/changelog/3034.feature new file mode 100644 index 00000000000..62c7ba78d35 --- /dev/null +++ b/changelog/3034.feature @@ -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). diff --git a/doc/en/cache.rst b/doc/en/cache.rst index e3423e95b0a..138ff6dfb2c 100644 --- a/doc/en/cache.rst +++ b/doc/en/cache.rst @@ -266,3 +266,13 @@ dumps/loads API of the json stdlib module .. automethod:: Cache.get .. automethod:: Cache.set .. automethod:: Cache.makedir + + +New tests first +----------------- + +The plugin provides command line options to run tests in another order: + +* ``--nf``, ``--new-first`` - to run tests in next order: first new tests, then + last modified files with tests in descending order (default order inside file). + diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index 038fd229eea..b03b02d3429 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -56,7 +56,7 @@ def test_error(): assert result.ret == 1 result.stdout.fnmatch_lines([ "*could not create cache path*", - "*1 warnings*", + "*2 warnings*", ]) def test_config_cache(self, testdir): @@ -495,15 +495,15 @@ def test_lastfailed_creates_cache_when_needed(self, testdir): # Issue #1342 testdir.makepyfile(test_empty='') testdir.runpytest('-q', '--lf') - assert not os.path.exists('.pytest_cache') + assert not os.path.exists('.pytest_cache/v/cache/lastfailed') testdir.makepyfile(test_successful='def test_success():\n assert True') testdir.runpytest('-q', '--lf') - assert not os.path.exists('.pytest_cache') + assert not os.path.exists('.pytest_cache/v/cache/lastfailed') testdir.makepyfile(test_errored='def test_error():\n assert False') testdir.runpytest('-q', '--lf') - assert os.path.exists('.pytest_cache') + assert os.path.exists('.pytest_cache/v/cache/lastfailed') def test_xfail_not_considered_failure(self, testdir): testdir.makepyfile(''' @@ -603,3 +603,116 @@ def test_foo_4(): result = testdir.runpytest('--last-failed') result.stdout.fnmatch_lines('*4 passed*') assert self.get_cached_last_failed(testdir) == [] + + +class TestNewFirst(object): + def test_newfirst_usecase(self, testdir): + t1 = testdir.mkdir("test_1") + t2 = testdir.mkdir("test_2") + + t1.join("test_1.py").write( + "def test_1(): assert 1\n" + "def test_2(): assert 1\n" + "def test_3(): assert 1\n" + ) + t2.join("test_2.py").write( + "def test_1(): assert 1\n" + "def test_2(): assert 1\n" + "def test_3(): assert 1\n" + ) + + path_to_test_1 = str('{}/test_1/test_1.py'.format(testdir.tmpdir)) + os.utime(path_to_test_1, (1, 1)) + + result = testdir.runpytest("-v") + result.stdout.fnmatch_lines([ + "*test_1/test_1.py::test_1 PASSED*", + "*test_1/test_1.py::test_2 PASSED*", + "*test_1/test_1.py::test_3 PASSED*", + "*test_2/test_2.py::test_1 PASSED*", + "*test_2/test_2.py::test_2 PASSED*", + "*test_2/test_2.py::test_3 PASSED*", + ]) + + result = testdir.runpytest("-v", "--nf") + + result.stdout.fnmatch_lines([ + "*test_2/test_2.py::test_1 PASSED*", + "*test_2/test_2.py::test_2 PASSED*", + "*test_2/test_2.py::test_3 PASSED*", + "*test_1/test_1.py::test_1 PASSED*", + "*test_1/test_1.py::test_2 PASSED*", + "*test_1/test_1.py::test_3 PASSED*", + ]) + + t1.join("test_1.py").write( + "def test_1(): assert 1\n" + "def test_2(): assert 1\n" + "def test_3(): assert 1\n" + "def test_4(): assert 1\n" + ) + os.utime(path_to_test_1, (1, 1)) + + result = testdir.runpytest("-v", "--nf") + + result.stdout.fnmatch_lines([ + "*test_1/test_1.py::test_4 PASSED*", + "*test_2/test_2.py::test_1 PASSED*", + "*test_2/test_2.py::test_2 PASSED*", + "*test_2/test_2.py::test_3 PASSED*", + "*test_1/test_1.py::test_1 PASSED*", + "*test_1/test_1.py::test_2 PASSED*", + "*test_1/test_1.py::test_3 PASSED*", + ]) + + def test_newfirst_parametrize(self, testdir): + t1 = testdir.mkdir("test_1") + t2 = testdir.mkdir("test_2") + + t1.join("test_1.py").write( + "import pytest\n" + "@pytest.mark.parametrize('num', [1, 2])\n" + "def test_1(num): assert num\n" + ) + t2.join("test_2.py").write( + "import pytest\n" + "@pytest.mark.parametrize('num', [1, 2])\n" + "def test_1(num): assert num\n" + ) + + path_to_test_1 = str('{}/test_1/test_1.py'.format(testdir.tmpdir)) + os.utime(path_to_test_1, (1, 1)) + + result = testdir.runpytest("-v") + result.stdout.fnmatch_lines([ + "*test_1/test_1.py::test_1[1*", + "*test_1/test_1.py::test_1[2*", + "*test_2/test_2.py::test_1[1*", + "*test_2/test_2.py::test_1[2*" + ]) + + result = testdir.runpytest("-v", "--nf") + + result.stdout.fnmatch_lines([ + "*test_2/test_2.py::test_1[1*", + "*test_2/test_2.py::test_1[2*", + "*test_1/test_1.py::test_1[1*", + "*test_1/test_1.py::test_1[2*", + ]) + + t1.join("test_1.py").write( + "import pytest\n" + "@pytest.mark.parametrize('num', [1, 2, 3])\n" + "def test_1(num): assert num\n" + ) + os.utime(path_to_test_1, (1, 1)) + + result = testdir.runpytest("-v", "--nf") + + result.stdout.fnmatch_lines([ + "*test_1/test_1.py::test_1[3*", + "*test_2/test_2.py::test_1[1*", + "*test_2/test_2.py::test_1[2*", + "*test_1/test_1.py::test_1[1*", + "*test_1/test_1.py::test_1[2*", + ]) From 6496131b799afc711b96fc0464ec0f8c0c9ba785 Mon Sep 17 00:00:00 2001 From: Thomas Hisch Date: Tue, 13 Feb 2018 14:46:11 +0100 Subject: [PATCH 028/107] Show deselection count before tests are exectued Fixes #1527 --- _pytest/terminal.py | 10 ++++------ changelog/3213.feature | 1 + testing/test_cacheprovider.py | 2 +- testing/test_terminal.py | 27 ++++++++++++++++++++++++++- 4 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 changelog/3213.feature diff --git a/_pytest/terminal.py b/_pytest/terminal.py index d37dd2c433a..69d4ab8add1 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -361,6 +361,7 @@ def report_collect(self, final=False): errors = len(self.stats.get('error', [])) skipped = len(self.stats.get('skipped', [])) + deselected = len(self.stats.get('deselected', [])) if final: line = "collected " else: @@ -368,6 +369,8 @@ def report_collect(self, final=False): line += str(self._numcollected) + " item" + ('' if self._numcollected == 1 else 's') if errors: line += " / %d errors" % errors + if deselected: + line += " / %d deselected" % deselected if skipped: line += " / %d skipped" % skipped if self.isatty: @@ -377,6 +380,7 @@ def report_collect(self, final=False): else: self.write_line(line) + @pytest.hookimpl(trylast=True) def pytest_collection_modifyitems(self): self.report_collect(True) @@ -484,7 +488,6 @@ def pytest_sessionfinish(self, exitstatus): if exitstatus == EXIT_INTERRUPTED: self._report_keyboardinterrupt() del self._keyboardinterrupt_memo - self.summary_deselected() self.summary_stats() def pytest_keyboard_interrupt(self, excinfo): @@ -649,11 +652,6 @@ def summary_stats(self): if self.verbosity == -1: self.write_line(msg, **markup) - def summary_deselected(self): - if 'deselected' in self.stats: - self.write_sep("=", "%d tests deselected" % ( - len(self.stats['deselected'])), bold=True) - def repr_pythonversion(v=None): if v is None: diff --git a/changelog/3213.feature b/changelog/3213.feature new file mode 100644 index 00000000000..755942f1b68 --- /dev/null +++ b/changelog/3213.feature @@ -0,0 +1 @@ +Output item deselection count before tests are run. \ No newline at end of file diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index 038fd229eea..d337199205c 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -361,7 +361,7 @@ def test_b2(): result = testdir.runpytest('--lf') result.stdout.fnmatch_lines([ - 'collected 4 items', + 'collected 4 items / 2 deselected', 'run-last-failure: rerun previous 2 failures', '*2 failed, 2 deselected in*', ]) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index e95a3ed2b6a..f23dffe2524 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -431,11 +431,36 @@ def test_three(): ) result = testdir.runpytest("-k", "test_two:", testpath) result.stdout.fnmatch_lines([ + "collected 3 items / 1 deselected", "*test_deselected.py ..*", - "=* 1 test*deselected *=", ]) assert result.ret == 0 + def test_show_deselected_items_using_markexpr_before_test_execution( + self, testdir): + testdir.makepyfile(""" + import pytest + + @pytest.mark.foo + def test_foobar(): + pass + + @pytest.mark.bar + def test_bar(): + pass + + def test_pass(): + pass + """) + result = testdir.runpytest('-m', 'not foo') + result.stdout.fnmatch_lines([ + "collected 3 items / 1 deselected", + "*test_show_des*.py ..*", + "*= 2 passed, 1 deselected in * =*", + ]) + assert "= 1 deselected =" not in result.stdout.str() + assert result.ret == 0 + def test_no_skip_summary_if_failure(self, testdir): testdir.makepyfile(""" import pytest From 82cdc487cefbbbd1848705427fed6276461835e6 Mon Sep 17 00:00:00 2001 From: Thomas Hisch Date: Thu, 15 Feb 2018 21:09:44 +0100 Subject: [PATCH 029/107] Fix raised warning when attrs 17.4.0 is used Related: #3223 --- _pytest/fixtures.py | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index 2bc6f108b66..27ecf37dab4 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -831,9 +831,9 @@ def _ensure_immutable_ids(ids): @attr.s(frozen=True) class FixtureFunctionMarker(object): scope = attr.ib() - params = attr.ib(convert=attr.converters.optional(tuple)) + params = attr.ib(converter=attr.converters.optional(tuple)) autouse = attr.ib(default=False) - ids = attr.ib(default=None, convert=_ensure_immutable_ids) + ids = attr.ib(default=None, converter=_ensure_immutable_ids) name = attr.ib(default=None) def __call__(self, function): diff --git a/setup.py b/setup.py index e08be845ebf..dcfac37c5e4 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,7 @@ def main(): 'py>=1.5.0', 'six>=1.10.0', 'setuptools', - 'attrs>=17.2.0', + 'attrs>=17.4.0', ] # if _PYTEST_SETUP_SKIP_PLUGGY_DEP is set, skip installing pluggy; # used by tox.ini to test with pluggy master From df2f019997d77004a29d6f784f1fa76ef02265cf Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 15 Feb 2018 19:45:05 -0200 Subject: [PATCH 030/107] Slight rewording in the CHANGELOG --- changelog/3213.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/3213.feature b/changelog/3213.feature index 755942f1b68..1b25793a783 100644 --- a/changelog/3213.feature +++ b/changelog/3213.feature @@ -1 +1 @@ -Output item deselection count before tests are run. \ No newline at end of file +Deselected item count is now shown before tests are run, e.g. ``collected X items / Y deselected``. From 774c539f1a03b75b7b6c9b5188c5beeae9638640 Mon Sep 17 00:00:00 2001 From: Jordan Speicher Date: Fri, 9 Feb 2018 17:44:03 -0600 Subject: [PATCH 031/107] Add --deselect command line option Fixes #3198 --- AUTHORS | 1 + _pytest/main.py | 20 ++++++++++++++++++++ changelog/3198.feature.rst | 1 + doc/en/example/pythoncollection.rst | 8 ++++++++ testing/test_session.py | 14 ++++++++++++++ 5 files changed, 44 insertions(+) create mode 100644 changelog/3198.feature.rst diff --git a/AUTHORS b/AUTHORS index 3f43c7479d7..a4ce2e8e6d3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -97,6 +97,7 @@ Jon Sonesen Jonas Obrist Jordan Guymon Jordan Moldow +Jordan Speicher Joshua Bronson Jurko Gospodnetić Justyna Janczyszyn diff --git a/_pytest/main.py b/_pytest/main.py index f3dbd43448e..97c6e276ca6 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -66,6 +66,8 @@ def pytest_addoption(parser): help="try to interpret all arguments as python packages.") group.addoption("--ignore", action="append", metavar="path", help="ignore path during collection (multi-allowed).") + group.addoption("--deselect", action="append", metavar="nodeid_prefix", + help="deselect item during collection (multi-allowed).") # when changing this to --conf-cut-dir, config.py Conftest.setinitial # needs upgrading as well group.addoption('--confcutdir', dest="confcutdir", default=None, @@ -208,6 +210,24 @@ def pytest_ignore_collect(path, config): return False +def pytest_collection_modifyitems(items, config): + deselect_prefixes = tuple(config.getoption("deselect") or []) + if not deselect_prefixes: + return + + remaining = [] + deselected = [] + for colitem in items: + if colitem.nodeid.startswith(deselect_prefixes): + deselected.append(colitem) + else: + remaining.append(colitem) + + if deselected: + config.hook.pytest_deselected(items=deselected) + items[:] = remaining + + @contextlib.contextmanager def _patched_find_module(): """Patch bug in pkgutil.ImpImporter.find_module diff --git a/changelog/3198.feature.rst b/changelog/3198.feature.rst new file mode 100644 index 00000000000..3c7838302c6 --- /dev/null +++ b/changelog/3198.feature.rst @@ -0,0 +1 @@ +Add command line option ``--deselect`` to allow deselection of individual tests at collection time. diff --git a/doc/en/example/pythoncollection.rst b/doc/en/example/pythoncollection.rst index c9d31d7c420..fc8dbf1b515 100644 --- a/doc/en/example/pythoncollection.rst +++ b/doc/en/example/pythoncollection.rst @@ -39,6 +39,14 @@ you will see that ``pytest`` only collects test-modules, which do not match the ======= 5 passed in 0.02 seconds ======= +Deselect tests during test collection +------------------------------------- + +Tests can individually be deselected during collection by passing the ``--deselect=item`` option. +For example, say ``tests/foobar/test_foobar_01.py`` contains ``test_a`` and ``test_b``. +You can run all of the tests within ``tests/`` *except* for ``tests/foobar/test_foobar_01.py::test_a`` +by invoking ``pytest`` with ``--deselect tests/foobar/test_foobar_01.py::test_a``. +``pytest`` allows multiple ``--deselect`` options. Keeping duplicate paths specified from command line ---------------------------------------------------- diff --git a/testing/test_session.py b/testing/test_session.py index 68534b102d9..32d8ce689b9 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -240,6 +240,20 @@ def test_exclude(testdir): result.stdout.fnmatch_lines(["*1 passed*"]) +def test_deselect(testdir): + testdir.makepyfile(test_a=""" + import pytest + def test_a1(): pass + @pytest.mark.parametrize('b', range(3)) + def test_a2(b): pass + """) + result = testdir.runpytest("-v", "--deselect=test_a.py::test_a2[1]", "--deselect=test_a.py::test_a2[2]") + assert result.ret == 0 + result.stdout.fnmatch_lines(["*2 passed, 2 deselected*"]) + for line in result.stdout.lines: + assert not line.startswith(('test_a.py::test_a2[1]', 'test_a.py::test_a2[2]')) + + def test_sessionfinish_with_start(testdir): testdir.makeconftest(""" import os From dfbaa20240bd1178496db117450aaaba7c0a913a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 17 Feb 2018 09:24:13 -0200 Subject: [PATCH 032/107] Bring test_live_logs_unknown_sections directly due to merge conflicts --- testing/logging/test_reporting.py | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 97b31521fea..f84f7e459d8 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -294,6 +294,60 @@ def test_log_2(fix): ]) +def test_live_logs_unknown_sections(testdir, request): + """Check that with live logging enable we are printing the correct headers during + start/setup/call/teardown/finish.""" + filename = request.node.name + '.py' + testdir.makeconftest(''' + import pytest + import logging + + def pytest_runtest_protocol(item, nextitem): + logging.warning('Unknown Section!') + + def pytest_runtest_logstart(): + logging.warning('>>>>> START >>>>>') + + def pytest_runtest_logfinish(): + logging.warning('<<<<< END <<<<<<<') + ''') + + testdir.makepyfile(''' + import pytest + import logging + + @pytest.fixture + def fix(request): + logging.warning("log message from setup of {}".format(request.node.name)) + yield + logging.warning("log message from teardown of {}".format(request.node.name)) + + def test_log_1(fix): + logging.warning("log message from test_log_1") + + ''') + testdir.makeini(''' + [pytest] + log_cli=true + ''') + + result = testdir.runpytest() + result.stdout.fnmatch_lines([ + '*WARNING*Unknown Section*', + '{}::test_log_1 '.format(filename), + '*WARNING* >>>>> START >>>>>*', + '*-- live log setup --*', + '*WARNING*log message from setup of test_log_1*', + '*-- live log call --*', + '*WARNING*log message from test_log_1*', + 'PASSED *100%*', + '*-- live log teardown --*', + '*WARNING*log message from teardown of test_log_1*', + '*WARNING* <<<<< END <<<<<<<*', + '=* 1 passed in *=', + ]) + + def test_sections_single_new_line_after_test_outcome(testdir, request): """Check that only a single new line is written between log messages during teardown/finish.""" From 069f32a8c452e7dbd4d10d4da2c142dd24bb5953 Mon Sep 17 00:00:00 2001 From: Brian Maissy Date: Mon, 12 Feb 2018 22:05:46 +0200 Subject: [PATCH 033/107] print captured logs before entering pdb --- _pytest/debugging.py | 5 +++++ changelog/3204.feature | 1 + testing/test_pdb.py | 16 ++++++++++++++++ 3 files changed, 22 insertions(+) create mode 100644 changelog/3204.feature diff --git a/_pytest/debugging.py b/_pytest/debugging.py index 43472f23bb3..d74cbe186ae 100644 --- a/_pytest/debugging.py +++ b/_pytest/debugging.py @@ -97,6 +97,11 @@ def _enter_pdb(node, excinfo, rep): tw.sep(">", "captured stderr") tw.line(captured_stderr) + captured_logs = rep.caplog + if len(captured_logs) > 0: + tw.sep(">", "captured logs") + tw.line(captured_logs) + tw.sep(">", "traceback") rep.toterminal(tw) tw.sep(">", "entering PDB") diff --git a/changelog/3204.feature b/changelog/3204.feature new file mode 100644 index 00000000000..8ab129a12c9 --- /dev/null +++ b/changelog/3204.feature @@ -0,0 +1 @@ +Captured logs are printed before entering pdb. diff --git a/testing/test_pdb.py b/testing/test_pdb.py index d882c2cf6dc..f6d03d6bb7f 100644 --- a/testing/test_pdb.py +++ b/testing/test_pdb.py @@ -187,6 +187,22 @@ def test_1(): assert "captured stderr" not in output self.flush(child) + def test_pdb_print_captured_logs(self, testdir): + p1 = testdir.makepyfile(""" + def test_1(): + import logging + logging.warn("get rekt") + assert False + """) + child = testdir.spawn_pytest("--pdb %s" % p1) + child.expect("captured logs") + child.expect("get rekt") + child.expect("(Pdb)") + child.sendeof() + rest = child.read().decode("utf8") + assert "1 failed" in rest + self.flush(child) + def test_pdb_interaction_exception(self, testdir): p1 = testdir.makepyfile(""" import pytest From 81fa547fa8efddbf3665c7c5a4a6a6146239e3ab Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 17 Feb 2018 20:18:32 -0200 Subject: [PATCH 034/107] Add CHANGELOG entry about changed attrs req --- changelog/3228.trivial.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3228.trivial.rst diff --git a/changelog/3228.trivial.rst b/changelog/3228.trivial.rst new file mode 100644 index 00000000000..8b69e25b434 --- /dev/null +++ b/changelog/3228.trivial.rst @@ -0,0 +1 @@ +Change minimum requirement of ``attrs`` to ``17.4.0``. From 51ece00923885fa41107e7469d510cb2233406af Mon Sep 17 00:00:00 2001 From: Thomas Hisch Date: Sun, 18 Feb 2018 12:42:25 +0100 Subject: [PATCH 035/107] Add captured-log support to --show-capture Fixes: #3233 --- _pytest/debugging.py | 24 ++++++++----------- _pytest/terminal.py | 14 +++++------ changelog/1478.feature | 2 +- testing/test_pdb.py | 12 ++++++---- testing/test_terminal.py | 52 ++++++++++++++++++++++++++-------------- 5 files changed, 59 insertions(+), 45 deletions(-) diff --git a/_pytest/debugging.py b/_pytest/debugging.py index d74cbe186ae..fada117e5dc 100644 --- a/_pytest/debugging.py +++ b/_pytest/debugging.py @@ -87,20 +87,16 @@ def _enter_pdb(node, excinfo, rep): tw = node.config.pluginmanager.getplugin("terminalreporter")._tw tw.line() - captured_stdout = rep.capstdout - if len(captured_stdout) > 0: - tw.sep(">", "captured stdout") - tw.line(captured_stdout) - - captured_stderr = rep.capstderr - if len(captured_stderr) > 0: - tw.sep(">", "captured stderr") - tw.line(captured_stderr) - - captured_logs = rep.caplog - if len(captured_logs) > 0: - tw.sep(">", "captured logs") - tw.line(captured_logs) + showcapture = node.config.option.showcapture + + for sectionname, content in (('stdout', rep.capstdout), + ('stderr', rep.capstderr), + ('log', rep.caplog)): + if showcapture in (sectionname, 'all') and content: + tw.sep(">", "captured " + sectionname) + if content[-1:] == "\n": + content = content[:-1] + tw.line(content) tw.sep(">", "traceback") rep.toterminal(tw) diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 69d4ab8add1..55a632b22f7 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -44,9 +44,9 @@ def pytest_addoption(parser): help="traceback print mode (auto/long/short/line/native/no).") group._addoption('--show-capture', action="store", dest="showcapture", - choices=['no', 'stdout', 'stderr', 'both'], default='both', - help="Controls how captured stdout/stderr is shown on failed tests. " - "Default is 'both'.") + choices=['no', 'stdout', 'stderr', 'log', 'all'], default='all', + help="Controls how captured stdout/stderr/log is shown on failed tests. " + "Default is 'all'.") group._addoption('--fulltrace', '--full-trace', action="store_true", default=False, help="don't cut any tracebacks (default is to cut).") @@ -630,12 +630,12 @@ def summary_errors(self): def _outrep_summary(self, rep): rep.toterminal(self._tw) - if self.config.option.showcapture == 'no': + showcapture = self.config.option.showcapture + if showcapture == 'no': return for secname, content in rep.sections: - if self.config.option.showcapture != 'both': - if not (self.config.option.showcapture in secname): - continue + if showcapture != 'all' and showcapture not in secname: + continue self._tw.sep("-", secname) if content[-1:] == "\n": content = content[:-1] diff --git a/changelog/1478.feature b/changelog/1478.feature index de6bd311890..defc79b9b65 100644 --- a/changelog/1478.feature +++ b/changelog/1478.feature @@ -1 +1 @@ -New ``--show-capture`` command-line option that allows to specify how to display captured output when tests fail: ``no``, ``stdout``, ``stderr`` or ``both`` (the default). \ No newline at end of file +New ``--show-capture`` command-line option that allows to specify how to display captured output when tests fail: ``no``, ``stdout``, ``stderr``, ``log`` or ``all`` (the default). diff --git a/testing/test_pdb.py b/testing/test_pdb.py index f6d03d6bb7f..fa3d86d314c 100644 --- a/testing/test_pdb.py +++ b/testing/test_pdb.py @@ -187,16 +187,18 @@ def test_1(): assert "captured stderr" not in output self.flush(child) - def test_pdb_print_captured_logs(self, testdir): + @pytest.mark.parametrize('showcapture', ['all', 'no', 'log']) + def test_pdb_print_captured_logs(self, testdir, showcapture): p1 = testdir.makepyfile(""" def test_1(): import logging - logging.warn("get rekt") + logging.warn("get " + "rekt") assert False """) - child = testdir.spawn_pytest("--pdb %s" % p1) - child.expect("captured logs") - child.expect("get rekt") + child = testdir.spawn_pytest("--show-capture=%s --pdb %s" % (showcapture, p1)) + if showcapture in ('all', 'log'): + child.expect("captured log") + child.expect("get rekt") child.expect("(Pdb)") child.sendeof() rest = child.read().decode("utf8") diff --git a/testing/test_terminal.py b/testing/test_terminal.py index f23dffe2524..b3ea0170969 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -851,31 +851,47 @@ def pytest_report_header(config, startdir): def test_show_capture(self, testdir): testdir.makepyfile(""" import sys + import logging def test_one(): sys.stdout.write('!This is stdout!') sys.stderr.write('!This is stderr!') + logging.warning('!This is a warning log msg!') assert False, 'Something failed' """) result = testdir.runpytest("--tb=short") - result.stdout.fnmatch_lines(["!This is stdout!"]) - result.stdout.fnmatch_lines(["!This is stderr!"]) - - result = testdir.runpytest("--show-capture=both", "--tb=short") - result.stdout.fnmatch_lines(["!This is stdout!"]) - result.stdout.fnmatch_lines(["!This is stderr!"]) - - result = testdir.runpytest("--show-capture=stdout", "--tb=short") - assert "!This is stderr!" not in result.stdout.str() - assert "!This is stdout!" in result.stdout.str() - - result = testdir.runpytest("--show-capture=stderr", "--tb=short") - assert "!This is stdout!" not in result.stdout.str() - assert "!This is stderr!" in result.stdout.str() - - result = testdir.runpytest("--show-capture=no", "--tb=short") - assert "!This is stdout!" not in result.stdout.str() - assert "!This is stderr!" not in result.stdout.str() + result.stdout.fnmatch_lines(["!This is stdout!", + "!This is stderr!", + "*WARNING*!This is a warning log msg!"]) + + result = testdir.runpytest("--show-capture=all", "--tb=short") + result.stdout.fnmatch_lines(["!This is stdout!", + "!This is stderr!", + "*WARNING*!This is a warning log msg!"]) + + stdout = testdir.runpytest( + "--show-capture=stdout", "--tb=short").stdout.str() + assert "!This is stderr!" not in stdout + assert "!This is stdout!" in stdout + assert "!This is a warning log msg!" not in stdout + + stdout = testdir.runpytest( + "--show-capture=stderr", "--tb=short").stdout.str() + assert "!This is stdout!" not in stdout + assert "!This is stderr!" in stdout + assert "!This is a warning log msg!" not in stdout + + stdout = testdir.runpytest( + "--show-capture=log", "--tb=short").stdout.str() + assert "!This is stdout!" not in stdout + assert "!This is stderr!" not in stdout + assert "!This is a warning log msg!" in stdout + + stdout = testdir.runpytest( + "--show-capture=no", "--tb=short").stdout.str() + assert "!This is stdout!" not in stdout + assert "!This is stderr!" not in stdout + assert "!This is a warning log msg!" not in stdout @pytest.mark.xfail("not hasattr(os, 'dup')") From ac7eb63a6b9899250ebd58f61f348ef69bf55875 Mon Sep 17 00:00:00 2001 From: Thomas Hisch Date: Sun, 18 Feb 2018 20:48:07 +0100 Subject: [PATCH 036/107] Remove --no-print-logs option This option is superseded by the --show-capture option. With --no-print-logs it was possible to only disable the reporting of captured logs, which is no longer possible with --show-capture. If --show-capture=no is used, no captured content (stdout, stderr and logs) is reported for failed tests. --- _pytest/logging.py | 13 ++------ doc/en/logging.rst | 22 ++----------- testing/logging/test_reporting.py | 54 ------------------------------- 3 files changed, 6 insertions(+), 83 deletions(-) diff --git a/_pytest/logging.py b/_pytest/logging.py index d3ac81e6528..b719f3a79df 100644 --- a/_pytest/logging.py +++ b/_pytest/logging.py @@ -84,11 +84,6 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs): help='default value for ' + option) group.addoption(option, dest=dest, **kwargs) - add_option_ini( - '--no-print-logs', - dest='log_print', action='store_const', const=False, default=True, - type='bool', - help='disable printing caught logs on failed tests.') add_option_ini( '--log-level', dest='log_level', default=None, @@ -343,7 +338,6 @@ def __init__(self, config): assert self._config.pluginmanager.get_plugin('terminalreporter') is None config.option.verbose = 1 - self.print_logs = get_option_ini(config, 'log_print') self.formatter = logging.Formatter(get_option_ini(config, 'log_format'), get_option_ini(config, 'log_date_format')) self.log_level = get_actual_log_level(config, 'log_level') @@ -394,10 +388,9 @@ def _runtest_for(self, item, when): if when == 'teardown': del item.catch_log_handlers - if self.print_logs: - # Add a captured log section to the report. - log = log_handler.stream.getvalue().strip() - item.add_report_section(when, 'log', log) + # Add a captured log section to the report. + log = log_handler.stream.getvalue().strip() + item.add_report_section(when, 'log', log) @pytest.hookimpl(hookwrapper=True) def pytest_runtest_setup(self, item): diff --git a/doc/en/logging.rst b/doc/en/logging.rst index 82119043b01..ad59be83f20 100644 --- a/doc/en/logging.rst +++ b/doc/en/logging.rst @@ -50,26 +50,10 @@ These options can also be customized through ``pytest.ini`` file: log_format = %(asctime)s %(levelname)s %(message)s log_date_format = %Y-%m-%d %H:%M:%S -Further it is possible to disable reporting logs on failed tests completely -with:: +Further it is possible to disable reporting of captured content (stdout, +stderr and logs) on failed tests completely with:: - pytest --no-print-logs - -Or in the ``pytest.ini`` file: - -.. code-block:: ini - - [pytest] - log_print = False - - -Shows failed tests in the normal manner as no logs were captured:: - - ----------------------- Captured stdout call ---------------------- - text going to stdout - ----------------------- Captured stderr call ---------------------- - text going to stderr - ==================== 2 failed in 0.02 seconds ===================== + pytest --show-capture=no caplog fixture diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index f84f7e459d8..7f4c3f17d84 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -91,60 +91,6 @@ def teardown_function(function): '*text going to logger from teardown*']) -def test_disable_log_capturing(testdir): - testdir.makepyfile(''' - import sys - import logging - - logger = logging.getLogger(__name__) - - def test_foo(): - sys.stdout.write('text going to stdout') - logger.warning('catch me if you can!') - sys.stderr.write('text going to stderr') - assert False - ''') - result = testdir.runpytest('--no-print-logs') - print(result.stdout) - assert result.ret == 1 - result.stdout.fnmatch_lines(['*- Captured stdout call -*', - 'text going to stdout']) - result.stdout.fnmatch_lines(['*- Captured stderr call -*', - 'text going to stderr']) - with pytest.raises(pytest.fail.Exception): - result.stdout.fnmatch_lines(['*- Captured *log call -*']) - - -def test_disable_log_capturing_ini(testdir): - testdir.makeini( - ''' - [pytest] - log_print=False - ''' - ) - testdir.makepyfile(''' - import sys - import logging - - logger = logging.getLogger(__name__) - - def test_foo(): - sys.stdout.write('text going to stdout') - logger.warning('catch me if you can!') - sys.stderr.write('text going to stderr') - assert False - ''') - result = testdir.runpytest() - print(result.stdout) - assert result.ret == 1 - result.stdout.fnmatch_lines(['*- Captured stdout call -*', - 'text going to stdout']) - result.stdout.fnmatch_lines(['*- Captured stderr call -*', - 'text going to stderr']) - with pytest.raises(pytest.fail.Exception): - result.stdout.fnmatch_lines(['*- Captured *log call -*']) - - @pytest.mark.parametrize('enabled', [True, False]) def test_log_cli_enabled_disabled(testdir, enabled): msg = 'critical message logged by test' From acda6c46fb188514738bbb90f17279c11daa0510 Mon Sep 17 00:00:00 2001 From: Thomas Hisch Date: Mon, 19 Feb 2018 20:34:11 +0100 Subject: [PATCH 037/107] Partially revert "Remove --no-print-logs option" We'll deprecate --no-print-logs beginning with pytest-4.0. This reverts commit ac7eb63a6b9899250ebd58f61f348ef69bf55875. --- _pytest/logging.py | 13 ++++++-- testing/logging/test_reporting.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/_pytest/logging.py b/_pytest/logging.py index b719f3a79df..d3ac81e6528 100644 --- a/_pytest/logging.py +++ b/_pytest/logging.py @@ -84,6 +84,11 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs): help='default value for ' + option) group.addoption(option, dest=dest, **kwargs) + add_option_ini( + '--no-print-logs', + dest='log_print', action='store_const', const=False, default=True, + type='bool', + help='disable printing caught logs on failed tests.') add_option_ini( '--log-level', dest='log_level', default=None, @@ -338,6 +343,7 @@ def __init__(self, config): assert self._config.pluginmanager.get_plugin('terminalreporter') is None config.option.verbose = 1 + self.print_logs = get_option_ini(config, 'log_print') self.formatter = logging.Formatter(get_option_ini(config, 'log_format'), get_option_ini(config, 'log_date_format')) self.log_level = get_actual_log_level(config, 'log_level') @@ -388,9 +394,10 @@ def _runtest_for(self, item, when): if when == 'teardown': del item.catch_log_handlers - # Add a captured log section to the report. - log = log_handler.stream.getvalue().strip() - item.add_report_section(when, 'log', log) + if self.print_logs: + # Add a captured log section to the report. + log = log_handler.stream.getvalue().strip() + item.add_report_section(when, 'log', log) @pytest.hookimpl(hookwrapper=True) def pytest_runtest_setup(self, item): diff --git a/testing/logging/test_reporting.py b/testing/logging/test_reporting.py index 7f4c3f17d84..f84f7e459d8 100644 --- a/testing/logging/test_reporting.py +++ b/testing/logging/test_reporting.py @@ -91,6 +91,60 @@ def teardown_function(function): '*text going to logger from teardown*']) +def test_disable_log_capturing(testdir): + testdir.makepyfile(''' + import sys + import logging + + logger = logging.getLogger(__name__) + + def test_foo(): + sys.stdout.write('text going to stdout') + logger.warning('catch me if you can!') + sys.stderr.write('text going to stderr') + assert False + ''') + result = testdir.runpytest('--no-print-logs') + print(result.stdout) + assert result.ret == 1 + result.stdout.fnmatch_lines(['*- Captured stdout call -*', + 'text going to stdout']) + result.stdout.fnmatch_lines(['*- Captured stderr call -*', + 'text going to stderr']) + with pytest.raises(pytest.fail.Exception): + result.stdout.fnmatch_lines(['*- Captured *log call -*']) + + +def test_disable_log_capturing_ini(testdir): + testdir.makeini( + ''' + [pytest] + log_print=False + ''' + ) + testdir.makepyfile(''' + import sys + import logging + + logger = logging.getLogger(__name__) + + def test_foo(): + sys.stdout.write('text going to stdout') + logger.warning('catch me if you can!') + sys.stderr.write('text going to stderr') + assert False + ''') + result = testdir.runpytest() + print(result.stdout) + assert result.ret == 1 + result.stdout.fnmatch_lines(['*- Captured stdout call -*', + 'text going to stdout']) + result.stdout.fnmatch_lines(['*- Captured stderr call -*', + 'text going to stderr']) + with pytest.raises(pytest.fail.Exception): + result.stdout.fnmatch_lines(['*- Captured *log call -*']) + + @pytest.mark.parametrize('enabled', [True, False]) def test_log_cli_enabled_disabled(testdir, enabled): msg = 'critical message logged by test' From 8b49ddfa585e8dee77ba3aeef654598e0052237e Mon Sep 17 00:00:00 2001 From: Carlos Jenkins Date: Wed, 16 Aug 2017 05:23:28 -0600 Subject: [PATCH 038/107] Renamed the fixture record_xml_property to record_property and adapted logic so that the properties are passed to the TestReport object and thus allow compatibility with pytest-xdist. --- AUTHORS | 1 + _pytest/deprecated.py | 6 +++++ _pytest/junitxml.py | 44 ++++++++++++++++++++----------- _pytest/nodes.py | 4 +++ _pytest/runner.py | 8 +++++- changelog/2770.feature | 2 ++ doc/en/builtin.rst | 9 ++++--- doc/en/example/simple.rst | 2 +- doc/en/usage.rst | 54 +++++++++++++++++++++++++++++++-------- testing/test_junitxml.py | 16 ++++++------ 10 files changed, 107 insertions(+), 39 deletions(-) create mode 100644 changelog/2770.feature diff --git a/AUTHORS b/AUTHORS index cda6511a097..a008ba98110 100644 --- a/AUTHORS +++ b/AUTHORS @@ -35,6 +35,7 @@ Brianna Laugher Bruno Oliveira Cal Leeming Carl Friedrich Bolz +Carlos Jenkins Ceridwen Charles Cloud Charnjit SiNGH (CCSJ) diff --git a/_pytest/deprecated.py b/_pytest/deprecated.py index 9c0fbeca7bc..aa1235013ba 100644 --- a/_pytest/deprecated.py +++ b/_pytest/deprecated.py @@ -41,6 +41,12 @@ class RemovedInPytest4Warning(DeprecationWarning): "For more details, see: https://docs.pytest.org/en/latest/parametrize.html" ) +RECORD_XML_PROPERTY = ( + 'Fixture renamed from "record_xml_property" to "record_property" as user ' + 'properties are now available to all reporters.\n' + '"record_xml_property" is now deprecated.' +) + COLLECTOR_MAKEITEM = RemovedInPytest4Warning( "pycollector makeitem was removed " "as it is an accidentially leaked internal api" diff --git a/_pytest/junitxml.py b/_pytest/junitxml.py index a8cea6fc1c5..22686313a3d 100644 --- a/_pytest/junitxml.py +++ b/_pytest/junitxml.py @@ -233,31 +233,41 @@ def finalize(self): @pytest.fixture -def record_xml_property(request): - """Add extra xml properties to the tag for the calling test. - The fixture is callable with ``(name, value)``, with value being automatically - xml-encoded. +def record_property(request): + """Add an extra properties the calling test. + User properties become part of the test report and are available to the + configured reporters, like JUnit XML. + The fixture is callable with ``(name, value)``. """ request.node.warn( code='C3', - message='record_xml_property is an experimental feature', + message='record_property is an experimental feature', ) - xml = getattr(request.config, "_xml", None) - if xml is not None: - node_reporter = xml.node_reporter(request.node.nodeid) - return node_reporter.add_property - else: - def add_property_noop(name, value): - pass - return add_property_noop + def append_property(name, value): + request.node.user_properties.append((name, value)) + return append_property + + +@pytest.fixture +def record_xml_property(request): + """(Deprecated) use record_property.""" + import warnings + from _pytest import deprecated + warnings.warn( + deprecated.RECORD_XML_PROPERTY, + DeprecationWarning, + stacklevel=2 + ) + + return record_property(request) @pytest.fixture def record_xml_attribute(request): """Add extra xml attributes to the tag for the calling test. - The fixture is callable with ``(name, value)``, with value being automatically - xml-encoded + The fixture is callable with ``(name, value)``, with value being + automatically xml-encoded """ request.node.warn( code='C3', @@ -442,6 +452,10 @@ def pytest_runtest_logreport(self, report): if report.when == "teardown": reporter = self._opentestcase(report) reporter.write_captured_output(report) + + for propname, propvalue in report.user_properties: + reporter.add_property(propname, propvalue) + self.finalize(report) report_wid = getattr(report, "worker_id", None) report_ii = getattr(report, "item_index", None) diff --git a/_pytest/nodes.py b/_pytest/nodes.py index e836cd4d6a6..7d802004fce 100644 --- a/_pytest/nodes.py +++ b/_pytest/nodes.py @@ -360,6 +360,10 @@ def __init__(self, name, parent=None, config=None, session=None): super(Item, self).__init__(name, parent, config, session) self._report_sections = [] + #: user properties is a list of tuples (name, value) that holds user + #: defined properties for this test. + self.user_properties = [] + def add_report_section(self, when, key, content): """ Adds a new report section, similar to what's done internally to add stdout and diff --git a/_pytest/runner.py b/_pytest/runner.py index b41a3d350f0..e8aac76fcfe 100644 --- a/_pytest/runner.py +++ b/_pytest/runner.py @@ -317,6 +317,7 @@ def pytest_runtest_makereport(item, call): sections.append(("Captured %s %s" % (key, rwhen), content)) return TestReport(item.nodeid, item.location, keywords, outcome, longrepr, when, + item.user_properties, sections, duration) @@ -326,7 +327,8 @@ class TestReport(BaseReport): """ def __init__(self, nodeid, location, keywords, outcome, - longrepr, when, sections=(), duration=0, **extra): + longrepr, when, user_properties, + sections=(), duration=0, **extra): #: normalized collection node id self.nodeid = nodeid @@ -348,6 +350,10 @@ def __init__(self, nodeid, location, keywords, outcome, #: one of 'setup', 'call', 'teardown' to indicate runtest phase. self.when = when + #: user properties is a list of tuples (name, value) that holds user + #: defined properties of the test + self.user_properties = user_properties + #: list of pairs ``(str, str)`` of extra information which needs to #: marshallable. Used by pytest to add captured text #: from ``stdout`` and ``stderr``, but may be used by other plugins diff --git a/changelog/2770.feature b/changelog/2770.feature new file mode 100644 index 00000000000..248f2893d94 --- /dev/null +++ b/changelog/2770.feature @@ -0,0 +1,2 @@ +``record_xml_property`` renamed to ``record_property`` and is now compatible with xdist, markers and any reporter. +``record_xml_property`` name is now deprecated. diff --git a/doc/en/builtin.rst b/doc/en/builtin.rst index a380b9abd18..ba033849c3e 100644 --- a/doc/en/builtin.rst +++ b/doc/en/builtin.rst @@ -112,10 +112,11 @@ You can ask for available builtin or project-custom Inject names into the doctest namespace. pytestconfig the pytest config object with access to command line opts. - record_xml_property - Add extra xml properties to the tag for the calling test. - The fixture is callable with ``(name, value)``, with value being automatically - xml-encoded. + record_property + Add an extra properties the calling test. + User properties become part of the test report and are available to the + configured reporters, like JUnit XML. + The fixture is callable with ``(name, value)``. record_xml_attribute Add extra xml attributes to the tag for the calling test. The fixture is callable with ``(name, value)``, with value being automatically diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index ffc68b29629..d509d56f126 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -537,7 +537,7 @@ We can run this:: file $REGENDOC_TMPDIR/b/test_error.py, line 1 def test_root(db): # no db here, will error out E fixture 'db' not found - > available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, monkeypatch, pytestconfig, record_xml_attribute, record_xml_property, recwarn, tmpdir, tmpdir_factory + > available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, monkeypatch, pytestconfig, record_xml_attribute, record_property, recwarn, tmpdir, tmpdir_factory > use 'pytest --fixtures [testpath]' for help on them. $REGENDOC_TMPDIR/b/test_error.py:1 diff --git a/doc/en/usage.rst b/doc/en/usage.rst index abd8bac2bd8..2b86420bb24 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -220,19 +220,24 @@ To set the name of the root test suite xml item, you can configure the ``junit_s [pytest] junit_suite_name = my_suite -record_xml_property +record_property ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. versionadded:: 2.8 +.. versionchanged:: 3.5 + + Fixture renamed from ``record_xml_property`` to ``record_property`` as user + properties are now available to all reporters. + ``record_xml_property`` is now deprecated. If you want to log additional information for a test, you can use the -``record_xml_property`` fixture: +``record_property`` fixture: .. code-block:: python - def test_function(record_xml_property): - record_xml_property("example_key", 1) - assert 0 + def test_function(record_property): + record_property("example_key", 1) + assert True This will add an extra property ``example_key="1"`` to the generated ``testcase`` tag: @@ -245,13 +250,42 @@ This will add an extra property ``example_key="1"`` to the generated -.. warning:: +Alternatively, you can integrate this functionality with custom markers: - ``record_xml_property`` is an experimental feature, and its interface might be replaced - by something more powerful and general in future versions. The - functionality per-se will be kept, however. +.. code-block:: python + + # content of conftest.py + + def pytest_collection_modifyitems(session, config, items): + for item in items: + marker = item.get_marker('test_id') + if marker is not None: + test_id = marker.args[0] + item.user_properties.append(('test_id', test_id)) + +And in your tests: + +.. code-block:: python + + # content of test_function.py + + @pytest.mark.test_id(1501) + def test_function(): + assert True + +Will result in: + +.. code-block:: xml + + + + + + + +.. warning:: - Currently it does not work when used with the ``pytest-xdist`` plugin. + ``record_property`` is an experimental feature and may change in the future. Also please note that using this feature will break any schema verification. This might be a problem when used with some CI servers. diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 031caeb206a..b8bbd888faa 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -863,10 +863,10 @@ def test_record_property(testdir): import pytest @pytest.fixture - def other(record_xml_property): - record_xml_property("bar", 1) - def test_record(record_xml_property, other): - record_xml_property("foo", "<1"); + def other(record_property): + record_property("bar", 1) + def test_record(record_property, other): + record_property("foo", "<1"); """) result, dom = runandparse(testdir, '-rw') node = dom.find_first_by_tag("testsuite") @@ -877,15 +877,15 @@ def test_record(record_xml_property, other): pnodes[1].assert_attr(name="foo", value="<1") result.stdout.fnmatch_lines([ 'test_record_property.py::test_record', - '*record_xml_property*experimental*', + '*record_property*experimental*', ]) def test_record_property_same_name(testdir): testdir.makepyfile(""" - def test_record_with_same_name(record_xml_property): - record_xml_property("foo", "bar") - record_xml_property("foo", "baz") + def test_record_with_same_name(record_property): + record_property("foo", "bar") + record_property("foo", "baz") """) result, dom = runandparse(testdir, '-rw') node = dom.find_first_by_tag("testsuite") From 3d4d0a261468e9f354b86f5149fe4dc2e8a85c7e Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 21 Feb 2018 18:54:39 +0100 Subject: [PATCH 039/107] remove addcall in the terminal tests --- changelog/3246.trival.rst | 1 + testing/test_terminal.py | 29 +++++++++++++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) create mode 100644 changelog/3246.trival.rst diff --git a/changelog/3246.trival.rst b/changelog/3246.trival.rst new file mode 100644 index 00000000000..6210289664b --- /dev/null +++ b/changelog/3246.trival.rst @@ -0,0 +1 @@ +remove usage of the deprecated addcall in our own tests \ No newline at end of file diff --git a/testing/test_terminal.py b/testing/test_terminal.py index b3ea0170969..c9e0eb8b3d6 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -32,16 +32,19 @@ def args(self): return values -def pytest_generate_tests(metafunc): - if "option" in metafunc.fixturenames: - metafunc.addcall(id="default", - funcargs={'option': Option(verbose=False)}) - metafunc.addcall(id="verbose", - funcargs={'option': Option(verbose=True)}) - metafunc.addcall(id="quiet", - funcargs={'option': Option(verbose=-1)}) - metafunc.addcall(id="fulltrace", - funcargs={'option': Option(fulltrace=True)}) +@pytest.fixture(params=[ + Option(verbose=False), + Option(verbose=True), + Option(verbose=-1), + Option(fulltrace=True), +], ids=[ + "default", + "verbose", + "quiet", + "fulltrace", +]) +def option(request): + return request.param @pytest.mark.parametrize('input,expected', [ @@ -682,10 +685,12 @@ def test_this(i): def test_getreportopt(): - class config(object): - class option(object): + class Config(object): + class Option(object): reportchars = "" disable_warnings = True + option = Option() + config = Config() config.option.reportchars = "sf" assert getreportopt(config) == "sf" From d844ad18c2c5cc7241278e7ecb4f764c81baca53 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Feb 2018 15:40:25 -0300 Subject: [PATCH 040/107] Fix formatting of CHANGELOG entry --- changelog/3246.trival.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/3246.trival.rst b/changelog/3246.trival.rst index 6210289664b..58e13a1ddd9 100644 --- a/changelog/3246.trival.rst +++ b/changelog/3246.trival.rst @@ -1 +1 @@ -remove usage of the deprecated addcall in our own tests \ No newline at end of file +Remove usage of deprecated ``metafunc.addcall`` in our own tests. From d838193d2db7f9644c7f7959c3f9cf66033b5dfa Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Feb 2018 17:45:52 -0300 Subject: [PATCH 041/107] Add note about deprecating record_xml_property Also make record_xml_property return record_property directly --- _pytest/junitxml.py | 4 ++-- changelog/2770.removal.rst | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 changelog/2770.removal.rst diff --git a/_pytest/junitxml.py b/_pytest/junitxml.py index 22686313a3d..98b2d13cf1a 100644 --- a/_pytest/junitxml.py +++ b/_pytest/junitxml.py @@ -250,7 +250,7 @@ def append_property(name, value): @pytest.fixture -def record_xml_property(request): +def record_xml_property(record_property): """(Deprecated) use record_property.""" import warnings from _pytest import deprecated @@ -260,7 +260,7 @@ def record_xml_property(request): stacklevel=2 ) - return record_property(request) + return record_property @pytest.fixture diff --git a/changelog/2770.removal.rst b/changelog/2770.removal.rst new file mode 100644 index 00000000000..0e38009ab99 --- /dev/null +++ b/changelog/2770.removal.rst @@ -0,0 +1 @@ +``record_xml_property`` fixture is now deprecated in favor of the more generic ``record_property``. \ No newline at end of file From 567b1ea7a137757d32d5b187abe744bd4ee27b85 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Feb 2018 17:56:49 -0300 Subject: [PATCH 042/107] Move user_properties to the end of parameter list for backward compatibility --- _pytest/runner.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/_pytest/runner.py b/_pytest/runner.py index e8aac76fcfe..6792387db82 100644 --- a/_pytest/runner.py +++ b/_pytest/runner.py @@ -317,8 +317,7 @@ def pytest_runtest_makereport(item, call): sections.append(("Captured %s %s" % (key, rwhen), content)) return TestReport(item.nodeid, item.location, keywords, outcome, longrepr, when, - item.user_properties, - sections, duration) + sections, duration, user_properties=item.user_properties) class TestReport(BaseReport): @@ -327,8 +326,7 @@ class TestReport(BaseReport): """ def __init__(self, nodeid, location, keywords, outcome, - longrepr, when, user_properties, - sections=(), duration=0, **extra): + longrepr, when, sections=(), duration=0, user_properties=(), **extra): #: normalized collection node id self.nodeid = nodeid From c31e1a379700873267dfb520f74f0cb62713e736 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 22 Feb 2018 13:36:33 +0100 Subject: [PATCH 043/107] turn mark into a package --- _pytest/{mark.py => mark/__init__.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename _pytest/{mark.py => mark/__init__.py} (99%) diff --git a/_pytest/mark.py b/_pytest/mark/__init__.py similarity index 99% rename from _pytest/mark.py rename to _pytest/mark/__init__.py index 3cac9dc91b3..d2d601e9e20 100644 --- a/_pytest/mark.py +++ b/_pytest/mark/__init__.py @@ -10,8 +10,8 @@ from six.moves import map from _pytest.config import UsageError -from .deprecated import MARK_PARAMETERSET_UNPACKING -from .compat import NOTSET, getfslineno +from ..deprecated import MARK_PARAMETERSET_UNPACKING +from ..compat import NOTSET, getfslineno EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark" From cf40c0743c565ed25bc14753e2350e010b39025a Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 22 Feb 2018 14:05:10 +0100 Subject: [PATCH 044/107] move mark evaluator into mark package --- _pytest/mark/evaluate.py | 126 +++++++++++++++++++++++++++++++++++++++ _pytest/skipping.py | 125 +------------------------------------- 2 files changed, 128 insertions(+), 123 deletions(-) create mode 100644 _pytest/mark/evaluate.py diff --git a/_pytest/mark/evaluate.py b/_pytest/mark/evaluate.py new file mode 100644 index 00000000000..27eaa78b319 --- /dev/null +++ b/_pytest/mark/evaluate.py @@ -0,0 +1,126 @@ +import os +import six +import sys +import traceback + +from . import MarkDecorator, MarkInfo +from ..outcomes import fail, TEST_OUTCOME + + +def cached_eval(config, expr, d): + if not hasattr(config, '_evalcache'): + config._evalcache = {} + try: + return config._evalcache[expr] + except KeyError: + import _pytest._code + exprcode = _pytest._code.compile(expr, mode="eval") + config._evalcache[expr] = x = eval(exprcode, d) + return x + + +class MarkEvaluator(object): + def __init__(self, item, name): + self.item = item + self._marks = None + self._mark = None + self._mark_name = name + + def __bool__(self): + self._marks = self._get_marks() + return bool(self._marks) + __nonzero__ = __bool__ + + def wasvalid(self): + return not hasattr(self, 'exc') + + def _get_marks(self): + + keyword = self.item.keywords.get(self._mark_name) + if isinstance(keyword, MarkDecorator): + return [keyword.mark] + elif isinstance(keyword, MarkInfo): + return [x.combined for x in keyword] + else: + return [] + + def invalidraise(self, exc): + raises = self.get('raises') + if not raises: + return + return not isinstance(exc, raises) + + def istrue(self): + try: + return self._istrue() + except TEST_OUTCOME: + self.exc = sys.exc_info() + if isinstance(self.exc[1], SyntaxError): + msg = [" " * (self.exc[1].offset + 4) + "^", ] + msg.append("SyntaxError: invalid syntax") + else: + msg = traceback.format_exception_only(*self.exc[:2]) + fail("Error evaluating %r expression\n" + " %s\n" + "%s" + % (self._mark_name, self.expr, "\n".join(msg)), + pytrace=False) + + def _getglobals(self): + d = {'os': os, 'sys': sys, 'config': self.item.config} + if hasattr(self.item, 'obj'): + d.update(self.item.obj.__globals__) + return d + + def _istrue(self): + if hasattr(self, 'result'): + return self.result + self._marks = self._get_marks() + + if self._marks: + self.result = False + for mark in self._marks: + self._mark = mark + if 'condition' in mark.kwargs: + args = (mark.kwargs['condition'],) + else: + args = mark.args + + for expr in args: + self.expr = expr + if isinstance(expr, six.string_types): + d = self._getglobals() + result = cached_eval(self.item.config, expr, d) + else: + if "reason" not in mark.kwargs: + # XXX better be checked at collection time + msg = "you need to specify reason=STRING " \ + "when using booleans as conditions." + fail(msg) + result = bool(expr) + if result: + self.result = True + self.reason = mark.kwargs.get('reason', None) + self.expr = expr + return self.result + + if not args: + self.result = True + self.reason = mark.kwargs.get('reason', None) + return self.result + return False + + def get(self, attr, default=None): + if self._mark is None: + return default + return self._mark.kwargs.get(attr, default) + + def getexplanation(self): + expl = getattr(self, 'reason', None) or self.get('reason', None) + if not expl: + if not hasattr(self, 'expr'): + return "" + else: + return "condition: " + str(self.expr) + return expl + diff --git a/_pytest/skipping.py b/_pytest/skipping.py index 98fc51c7ff8..588b2a4a0a9 100644 --- a/_pytest/skipping.py +++ b/_pytest/skipping.py @@ -1,14 +1,11 @@ """ support for skip/xfail functions and markers. """ from __future__ import absolute_import, division, print_function -import os -import six -import sys -import traceback from _pytest.config import hookimpl from _pytest.mark import MarkInfo, MarkDecorator -from _pytest.outcomes import fail, skip, xfail, TEST_OUTCOME +from _pytest.mark.evaluate import MarkEvaluator +from _pytest.outcomes import fail, skip, xfail def pytest_addoption(parser): @@ -60,112 +57,6 @@ def nop(*args, **kwargs): ) -class MarkEvaluator(object): - def __init__(self, item, name): - self.item = item - self._marks = None - self._mark = None - self._mark_name = name - - def __bool__(self): - self._marks = self._get_marks() - return bool(self._marks) - __nonzero__ = __bool__ - - def wasvalid(self): - return not hasattr(self, 'exc') - - def _get_marks(self): - - keyword = self.item.keywords.get(self._mark_name) - if isinstance(keyword, MarkDecorator): - return [keyword.mark] - elif isinstance(keyword, MarkInfo): - return [x.combined for x in keyword] - else: - return [] - - def invalidraise(self, exc): - raises = self.get('raises') - if not raises: - return - return not isinstance(exc, raises) - - def istrue(self): - try: - return self._istrue() - except TEST_OUTCOME: - self.exc = sys.exc_info() - if isinstance(self.exc[1], SyntaxError): - msg = [" " * (self.exc[1].offset + 4) + "^", ] - msg.append("SyntaxError: invalid syntax") - else: - msg = traceback.format_exception_only(*self.exc[:2]) - fail("Error evaluating %r expression\n" - " %s\n" - "%s" - % (self._mark_name, self.expr, "\n".join(msg)), - pytrace=False) - - def _getglobals(self): - d = {'os': os, 'sys': sys, 'config': self.item.config} - if hasattr(self.item, 'obj'): - d.update(self.item.obj.__globals__) - return d - - def _istrue(self): - if hasattr(self, 'result'): - return self.result - self._marks = self._get_marks() - - if self._marks: - self.result = False - for mark in self._marks: - self._mark = mark - if 'condition' in mark.kwargs: - args = (mark.kwargs['condition'],) - else: - args = mark.args - - for expr in args: - self.expr = expr - if isinstance(expr, six.string_types): - d = self._getglobals() - result = cached_eval(self.item.config, expr, d) - else: - if "reason" not in mark.kwargs: - # XXX better be checked at collection time - msg = "you need to specify reason=STRING " \ - "when using booleans as conditions." - fail(msg) - result = bool(expr) - if result: - self.result = True - self.reason = mark.kwargs.get('reason', None) - self.expr = expr - return self.result - - if not args: - self.result = True - self.reason = mark.kwargs.get('reason', None) - return self.result - return False - - def get(self, attr, default=None): - if self._mark is None: - return default - return self._mark.kwargs.get(attr, default) - - def getexplanation(self): - expl = getattr(self, 'reason', None) or self.get('reason', None) - if not expl: - if not hasattr(self, 'expr'): - return "" - else: - return "condition: " + str(self.expr) - return expl - - @hookimpl(tryfirst=True) def pytest_runtest_setup(item): # Check if skip or skipif are specified as pytest marks @@ -341,18 +232,6 @@ def show_xpassed(terminalreporter, lines): lines.append("XPASS %s %s" % (pos, reason)) -def cached_eval(config, expr, d): - if not hasattr(config, '_evalcache'): - config._evalcache = {} - try: - return config._evalcache[expr] - except KeyError: - import _pytest._code - exprcode = _pytest._code.compile(expr, mode="eval") - config._evalcache[expr] = x = eval(exprcode, d) - return x - - def folded_skips(skipped): d = {} for event in skipped: From 25a3e9296adb3b29483895ec94f2a88616819207 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 22 Feb 2018 14:18:49 +0100 Subject: [PATCH 045/107] reduce the complexity of skipping terminal summary --- _pytest/skipping.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/_pytest/skipping.py b/_pytest/skipping.py index 588b2a4a0a9..1a4187c1b72 100644 --- a/_pytest/skipping.py +++ b/_pytest/skipping.py @@ -185,18 +185,8 @@ def pytest_terminal_summary(terminalreporter): lines = [] for char in tr.reportchars: - if char == "x": - show_xfailed(terminalreporter, lines) - elif char == "X": - show_xpassed(terminalreporter, lines) - elif char in "fF": - show_simple(terminalreporter, lines, 'failed', "FAIL %s") - elif char in "sS": - show_skipped(terminalreporter, lines) - elif char == "E": - show_simple(terminalreporter, lines, 'error', "ERROR %s") - elif char == 'p': - show_simple(terminalreporter, lines, 'passed', "PASSED %s") + action = REPORTCHAR_ACTIONS.get(char, lambda tr, lines: None) + action(terminalreporter, lines) if lines: tr._tw.sep("=", "short test summary info") @@ -274,3 +264,22 @@ def show_skipped(terminalreporter, lines): lines.append( "SKIP [%d] %s: %s" % (num, fspath, reason)) + + +def shower(stat, format): + def show_(terminalreporter, lines): + return show_simple(terminalreporter, lines, stat, format) + return show_ + + +REPORTCHAR_ACTIONS = { + 'x': show_xfailed, + 'X': show_xpassed, + 'f': shower('failed', "FAIL %s"), + 'F': shower('failed', "FAIL %s"), + 's': show_skipped, + 'S': show_skipped, + 'p': shower('passed', "PASSED %s"), + 'E': shower('error', "ERROR %s") + +} From de2de00de90271049424f819092e052ca88a10ac Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 22 Feb 2018 14:19:31 +0100 Subject: [PATCH 046/107] update setup.py for the mark package --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e2013f3fce6..78b3ebc5e37 100644 --- a/setup.py +++ b/setup.py @@ -101,7 +101,7 @@ def main(): python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', install_requires=install_requires, extras_require=extras_require, - packages=['_pytest', '_pytest.assertion', '_pytest._code'], + packages=['_pytest', '_pytest.assertion', '_pytest._code', '_pytest.mark'], py_modules=['pytest'], zip_safe=False, ) From cef0423b2704e8d03eeef918751b79387dee69d8 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 22 Feb 2018 14:26:22 +0100 Subject: [PATCH 047/107] move the keyword/mark matching to the "legacy" module --- _pytest/mark/__init__.py | 85 +----------------------------------- _pytest/mark/legacy.py | 93 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 84 deletions(-) create mode 100644 _pytest/mark/legacy.py diff --git a/_pytest/mark/__init__.py b/_pytest/mark/__init__.py index d2d601e9e20..54d0e6447a4 100644 --- a/_pytest/mark/__init__.py +++ b/_pytest/mark/__init__.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, division, print_function import inspect -import keyword import warnings import attr from collections import namedtuple @@ -174,6 +173,7 @@ def pytest_cmdline_main(config): def pytest_collection_modifyitems(items, config): + from .legacy import matchkeyword, matchmark keywordexpr = config.option.keyword.lstrip() matchexpr = config.option.markexpr if not keywordexpr and not matchexpr: @@ -207,89 +207,6 @@ def pytest_collection_modifyitems(items, config): items[:] = remaining -@attr.s -class MarkMapping(object): - """Provides a local mapping for markers where item access - resolves to True if the marker is present. """ - - own_mark_names = attr.ib() - - @classmethod - def from_keywords(cls, keywords): - mark_names = set() - for key, value in keywords.items(): - if isinstance(value, MarkInfo) or isinstance(value, MarkDecorator): - mark_names.add(key) - return cls(mark_names) - - def __getitem__(self, name): - return name in self.own_mark_names - - -class KeywordMapping(object): - """Provides a local mapping for keywords. - Given a list of names, map any substring of one of these names to True. - """ - - def __init__(self, names): - self._names = names - - def __getitem__(self, subname): - for name in self._names: - if subname in name: - return True - return False - - -python_keywords_allowed_list = ["or", "and", "not"] - - -def matchmark(colitem, markexpr): - """Tries to match on any marker names, attached to the given colitem.""" - return eval(markexpr, {}, MarkMapping.from_keywords(colitem.keywords)) - - -def matchkeyword(colitem, keywordexpr): - """Tries to match given keyword expression to given collector item. - - Will match on the name of colitem, including the names of its parents. - Only matches names of items which are either a :class:`Class` or a - :class:`Function`. - Additionally, matches on names in the 'extra_keyword_matches' set of - any item, as well as names directly assigned to test functions. - """ - mapped_names = set() - - # Add the names of the current item and any parent items - import pytest - for item in colitem.listchain(): - if not isinstance(item, pytest.Instance): - mapped_names.add(item.name) - - # Add the names added as extra keywords to current or parent items - for name in colitem.listextrakeywords(): - mapped_names.add(name) - - # Add the names attached to the current function through direct assignment - if hasattr(colitem, 'function'): - for name in colitem.function.__dict__: - mapped_names.add(name) - - mapping = KeywordMapping(mapped_names) - if " " not in keywordexpr: - # special case to allow for simple "-k pass" and "-k 1.3" - return mapping[keywordexpr] - elif keywordexpr.startswith("not ") and " " not in keywordexpr[4:]: - return not mapping[keywordexpr[4:]] - for kwd in keywordexpr.split(): - if keyword.iskeyword(kwd) and kwd not in python_keywords_allowed_list: - raise UsageError("Python keyword '{}' not accepted in expressions passed to '-k'".format(kwd)) - try: - return eval(keywordexpr, {}, mapping) - except SyntaxError: - raise UsageError("Wrong expression passed to '-k': {}".format(keywordexpr)) - - def pytest_configure(config): config._old_mark_config = MARK_GEN._config if config.option.strict: diff --git a/_pytest/mark/legacy.py b/_pytest/mark/legacy.py new file mode 100644 index 00000000000..8c9e86d102e --- /dev/null +++ b/_pytest/mark/legacy.py @@ -0,0 +1,93 @@ +""" +this is a place where we put datastructures used by legacy apis +we hope ot remove +""" +import attr +import keyword + +from . import MarkInfo, MarkDecorator + +from _pytest.config import UsageError + + +@attr.s +class MarkMapping(object): + """Provides a local mapping for markers where item access + resolves to True if the marker is present. """ + + own_mark_names = attr.ib() + + @classmethod + def from_keywords(cls, keywords): + mark_names = set() + for key, value in keywords.items(): + if isinstance(value, MarkInfo) or isinstance(value, MarkDecorator): + mark_names.add(key) + return cls(mark_names) + + def __getitem__(self, name): + return name in self.own_mark_names + + +class KeywordMapping(object): + """Provides a local mapping for keywords. + Given a list of names, map any substring of one of these names to True. + """ + + def __init__(self, names): + self._names = names + + def __getitem__(self, subname): + for name in self._names: + if subname in name: + return True + return False + + +python_keywords_allowed_list = ["or", "and", "not"] + + +def matchmark(colitem, markexpr): + """Tries to match on any marker names, attached to the given colitem.""" + return eval(markexpr, {}, MarkMapping.from_keywords(colitem.keywords)) + + +def matchkeyword(colitem, keywordexpr): + """Tries to match given keyword expression to given collector item. + + Will match on the name of colitem, including the names of its parents. + Only matches names of items which are either a :class:`Class` or a + :class:`Function`. + Additionally, matches on names in the 'extra_keyword_matches' set of + any item, as well as names directly assigned to test functions. + """ + mapped_names = set() + + # Add the names of the current item and any parent items + import pytest + for item in colitem.listchain(): + if not isinstance(item, pytest.Instance): + mapped_names.add(item.name) + + # Add the names added as extra keywords to current or parent items + for name in colitem.listextrakeywords(): + mapped_names.add(name) + + # Add the names attached to the current function through direct assignment + if hasattr(colitem, 'function'): + for name in colitem.function.__dict__: + mapped_names.add(name) + + mapping = KeywordMapping(mapped_names) + if " " not in keywordexpr: + # special case to allow for simple "-k pass" and "-k 1.3" + return mapping[keywordexpr] + elif keywordexpr.startswith("not ") and " " not in keywordexpr[4:]: + return not mapping[keywordexpr[4:]] + for kwd in keywordexpr.split(): + if keyword.iskeyword(kwd) and kwd not in python_keywords_allowed_list: + raise UsageError("Python keyword '{}' not accepted in expressions passed to '-k'".format(kwd)) + try: + return eval(keywordexpr, {}, mapping) + except SyntaxError: + raise UsageError("Wrong expression passed to '-k': {}".format(keywordexpr)) From be2e3a973e34373533ffbea6d7d1f6554cdb6f5f Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 22 Feb 2018 14:30:32 +0100 Subject: [PATCH 048/107] remove complexity from match_keywords --- _pytest/mark/legacy.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/_pytest/mark/legacy.py b/_pytest/mark/legacy.py index 8c9e86d102e..ec45f12afdb 100644 --- a/_pytest/mark/legacy.py +++ b/_pytest/mark/legacy.py @@ -37,6 +37,27 @@ class KeywordMapping(object): def __init__(self, names): self._names = names + @classmethod + def from_item(cls, item): + mapped_names = set() + + # Add the names of the current item and any parent items + import pytest + for item in item.listchain(): + if not isinstance(item, pytest.Instance): + mapped_names.add(item.name) + + # Add the names added as extra keywords to current or parent items + for name in item.listextrakeywords(): + mapped_names.add(name) + + # Add the names attached to the current function through direct assignment + if hasattr(item, 'function'): + for name in item.function.__dict__: + mapped_names.add(name) + + return cls(mapped_names) + def __getitem__(self, subname): for name in self._names: if subname in name: @@ -61,24 +82,7 @@ def matchkeyword(colitem, keywordexpr): Additionally, matches on names in the 'extra_keyword_matches' set of any item, as well as names directly assigned to test functions. """ - mapped_names = set() - - # Add the names of the current item and any parent items - import pytest - for item in colitem.listchain(): - if not isinstance(item, pytest.Instance): - mapped_names.add(item.name) - - # Add the names added as extra keywords to current or parent items - for name in colitem.listextrakeywords(): - mapped_names.add(name) - - # Add the names attached to the current function through direct assignment - if hasattr(colitem, 'function'): - for name in colitem.function.__dict__: - mapped_names.add(name) - - mapping = KeywordMapping(mapped_names) + mapping = KeywordMapping.from_item(colitem) if " " not in keywordexpr: # special case to allow for simple "-k pass" and "-k 1.3" return mapping[keywordexpr] From c8d24739ed3ddfa491ca40f9966440f4391f2ad0 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 22 Feb 2018 14:43:01 +0100 Subject: [PATCH 049/107] move current mark datastructures to own module --- _pytest/mark/__init__.py | 335 +------------------------------------ _pytest/mark/structures.py | 330 ++++++++++++++++++++++++++++++++++++ _pytest/python.py | 2 +- 3 files changed, 336 insertions(+), 331 deletions(-) create mode 100644 _pytest/mark/structures.py diff --git a/_pytest/mark/__init__.py b/_pytest/mark/__init__.py index 54d0e6447a4..cf26c2683c1 100644 --- a/_pytest/mark/__init__.py +++ b/_pytest/mark/__init__.py @@ -1,112 +1,11 @@ """ generic mechanism for marking and selecting python functions. """ from __future__ import absolute_import, division, print_function - -import inspect -import warnings -import attr -from collections import namedtuple -from operator import attrgetter -from six.moves import map - from _pytest.config import UsageError -from ..deprecated import MARK_PARAMETERSET_UNPACKING -from ..compat import NOTSET, getfslineno - -EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark" - - -def alias(name, warning=None): - getter = attrgetter(name) - - def warned(self): - warnings.warn(warning, stacklevel=2) - return getter(self) - - return property(getter if warning is None else warned, doc='alias for ' + name) - - -class ParameterSet(namedtuple('ParameterSet', 'values, marks, id')): - @classmethod - def param(cls, *values, **kw): - marks = kw.pop('marks', ()) - if isinstance(marks, MarkDecorator): - marks = marks, - else: - assert isinstance(marks, (tuple, list, set)) - - def param_extract_id(id=None): - return id - - id = param_extract_id(**kw) - return cls(values, marks, id) - - @classmethod - def extract_from(cls, parameterset, legacy_force_tuple=False): - """ - :param parameterset: - a legacy style parameterset that may or may not be a tuple, - and may or may not be wrapped into a mess of mark objects - - :param legacy_force_tuple: - enforce tuple wrapping so single argument tuple values - don't get decomposed and break tests - - """ - - if isinstance(parameterset, cls): - return parameterset - if not isinstance(parameterset, MarkDecorator) and legacy_force_tuple: - return cls.param(parameterset) - - newmarks = [] - argval = parameterset - while isinstance(argval, MarkDecorator): - newmarks.append(MarkDecorator(Mark( - argval.markname, argval.args[:-1], argval.kwargs))) - argval = argval.args[-1] - assert not isinstance(argval, ParameterSet) - if legacy_force_tuple: - argval = argval, - - if newmarks: - warnings.warn(MARK_PARAMETERSET_UNPACKING) - - return cls(argval, marks=newmarks, id=None) - - @classmethod - def _for_parametrize(cls, argnames, argvalues, function, config): - if not isinstance(argnames, (tuple, list)): - argnames = [x.strip() for x in argnames.split(",") if x.strip()] - force_tuple = len(argnames) == 1 - else: - force_tuple = False - parameters = [ - ParameterSet.extract_from(x, legacy_force_tuple=force_tuple) - for x in argvalues] - del argvalues - - if not parameters: - mark = get_empty_parameterset_mark(config, argnames, function) - parameters.append(ParameterSet( - values=(NOTSET,) * len(argnames), - marks=[mark], - id=None, - )) - return argnames, parameters - - -def get_empty_parameterset_mark(config, argnames, function): - requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION) - if requested_mark in ('', None, 'skip'): - mark = MARK_GEN.skip - elif requested_mark == 'xfail': - mark = MARK_GEN.xfail(run=False) - else: - raise LookupError(requested_mark) - fs, lineno = getfslineno(function) - reason = "got empty parameter set %r, function %s at %s:%d" % ( - argnames, function.__name__, fs, lineno) - return mark(reason=reason) +from .structures import ( + ParameterSet, EMPTY_PARAMETERSET_OPTION, MARK_GEN, + Mark, MarkInfo, MarkDecorator, +) +__all__ = ['Mark', 'MarkInfo', 'MarkDecorator'] class MarkerError(Exception): @@ -222,227 +121,3 @@ def pytest_configure(config): def pytest_unconfigure(config): MARK_GEN._config = getattr(config, '_old_mark_config', None) - - -class MarkGenerator(object): - """ Factory for :class:`MarkDecorator` objects - exposed as - a ``pytest.mark`` singleton instance. Example:: - - import pytest - @pytest.mark.slowtest - def test_function(): - pass - - will set a 'slowtest' :class:`MarkInfo` object - on the ``test_function`` object. """ - _config = None - - def __getattr__(self, name): - if name[0] == "_": - raise AttributeError("Marker name must NOT start with underscore") - if self._config is not None: - self._check(name) - return MarkDecorator(Mark(name, (), {})) - - def _check(self, name): - try: - if name in self._markers: - return - except AttributeError: - pass - self._markers = values = set() - for line in self._config.getini("markers"): - marker = line.split(":", 1)[0] - marker = marker.rstrip() - x = marker.split("(", 1)[0] - values.add(x) - if name not in self._markers: - raise AttributeError("%r not a registered marker" % (name,)) - - -def istestfunc(func): - return hasattr(func, "__call__") and \ - getattr(func, "__name__", "") != "" - - -@attr.s(frozen=True) -class Mark(object): - name = attr.ib() - args = attr.ib() - kwargs = attr.ib() - - def combined_with(self, other): - assert self.name == other.name - return Mark( - self.name, self.args + other.args, - dict(self.kwargs, **other.kwargs)) - - -@attr.s -class MarkDecorator(object): - """ A decorator for test functions and test classes. When applied - it will create :class:`MarkInfo` objects which may be - :ref:`retrieved by hooks as item keywords `. - MarkDecorator instances are often created like this:: - - mark1 = pytest.mark.NAME # simple MarkDecorator - mark2 = pytest.mark.NAME(name1=value) # parametrized MarkDecorator - - and can then be applied as decorators to test functions:: - - @mark2 - def test_function(): - pass - - When a MarkDecorator instance is called it does the following: - 1. If called with a single class as its only positional argument and no - additional keyword arguments, it attaches itself to the class so it - gets applied automatically to all test cases found in that class. - 2. If called with a single function as its only positional argument and - no additional keyword arguments, it attaches a MarkInfo object to the - function, containing all the arguments already stored internally in - the MarkDecorator. - 3. When called in any other case, it performs a 'fake construction' call, - i.e. it returns a new MarkDecorator instance with the original - MarkDecorator's content updated with the arguments passed to this - call. - - Note: The rules above prevent MarkDecorator objects from storing only a - single function or class reference as their positional argument with no - additional keyword or positional arguments. - - """ - - mark = attr.ib(validator=attr.validators.instance_of(Mark)) - - name = alias('mark.name') - args = alias('mark.args') - kwargs = alias('mark.kwargs') - - @property - def markname(self): - return self.name # for backward-compat (2.4.1 had this attr) - - def __eq__(self, other): - return self.mark == other.mark if isinstance(other, MarkDecorator) else False - - def __repr__(self): - return "" % (self.mark,) - - def with_args(self, *args, **kwargs): - """ return a MarkDecorator with extra arguments added - - unlike call this can be used even if the sole argument is a callable/class - - :return: MarkDecorator - """ - - mark = Mark(self.name, args, kwargs) - return self.__class__(self.mark.combined_with(mark)) - - def __call__(self, *args, **kwargs): - """ if passed a single callable argument: decorate it with mark info. - otherwise add *args/**kwargs in-place to mark information. """ - if args and not kwargs: - func = args[0] - is_class = inspect.isclass(func) - if len(args) == 1 and (istestfunc(func) or is_class): - if is_class: - store_mark(func, self.mark) - else: - store_legacy_markinfo(func, self.mark) - store_mark(func, self.mark) - return func - return self.with_args(*args, **kwargs) - - -def get_unpacked_marks(obj): - """ - obtain the unpacked marks that are stored on a object - """ - mark_list = getattr(obj, 'pytestmark', []) - - if not isinstance(mark_list, list): - mark_list = [mark_list] - return [ - getattr(mark, 'mark', mark) # unpack MarkDecorator - for mark in mark_list - ] - - -def store_mark(obj, mark): - """store a Mark on a object - this is used to implement the Mark declarations/decorators correctly - """ - assert isinstance(mark, Mark), mark - # always reassign name to avoid updating pytestmark - # in a reference that was only borrowed - obj.pytestmark = get_unpacked_marks(obj) + [mark] - - -def store_legacy_markinfo(func, mark): - """create the legacy MarkInfo objects and put them onto the function - """ - if not isinstance(mark, Mark): - raise TypeError("got {mark!r} instead of a Mark".format(mark=mark)) - holder = getattr(func, mark.name, None) - if holder is None: - holder = MarkInfo(mark) - setattr(func, mark.name, holder) - else: - holder.add_mark(mark) - - -class MarkInfo(object): - """ Marking object created by :class:`MarkDecorator` instances. """ - - def __init__(self, mark): - assert isinstance(mark, Mark), repr(mark) - self.combined = mark - self._marks = [mark] - - name = alias('combined.name') - args = alias('combined.args') - kwargs = alias('combined.kwargs') - - def __repr__(self): - return "".format(self.combined) - - def add_mark(self, mark): - """ add a MarkInfo with the given args and kwargs. """ - self._marks.append(mark) - self.combined = self.combined.combined_with(mark) - - def __iter__(self): - """ yield MarkInfo objects each relating to a marking-call. """ - return map(MarkInfo, self._marks) - - -MARK_GEN = MarkGenerator() - - -def _marked(func, mark): - """ Returns True if :func: is already marked with :mark:, False otherwise. - This can happen if marker is applied to class and the test file is - invoked more than once. - """ - try: - func_mark = getattr(func, mark.name) - except AttributeError: - return False - return mark.args == func_mark.args and mark.kwargs == func_mark.kwargs - - -def transfer_markers(funcobj, cls, mod): - """ - this function transfers class level markers and module level markers - into function level markinfo objects - - this is the main reason why marks are so broken - the resolution will involve phasing out function level MarkInfo objects - - """ - for obj in (cls, mod): - for mark in get_unpacked_marks(obj): - if not _marked(funcobj, mark): - store_legacy_markinfo(funcobj, mark) diff --git a/_pytest/mark/structures.py b/_pytest/mark/structures.py new file mode 100644 index 00000000000..5550dc5462d --- /dev/null +++ b/_pytest/mark/structures.py @@ -0,0 +1,330 @@ +from collections import namedtuple +import warnings +from operator import attrgetter +import inspect + +import attr +from ..deprecated import MARK_PARAMETERSET_UNPACKING +from ..compat import NOTSET, getfslineno +from six.moves import map + + +EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark" + + +def alias(name, warning=None): + getter = attrgetter(name) + + def warned(self): + warnings.warn(warning, stacklevel=2) + return getter(self) + + return property(getter if warning is None else warned, doc='alias for ' + name) + + +def istestfunc(func): + return hasattr(func, "__call__") and \ + getattr(func, "__name__", "") != "" + + +def get_empty_parameterset_mark(config, argnames, function): + requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION) + if requested_mark in ('', None, 'skip'): + mark = MARK_GEN.skip + elif requested_mark == 'xfail': + mark = MARK_GEN.xfail(run=False) + else: + raise LookupError(requested_mark) + fs, lineno = getfslineno(function) + reason = "got empty parameter set %r, function %s at %s:%d" % ( + argnames, function.__name__, fs, lineno) + return mark(reason=reason) + + +class ParameterSet(namedtuple('ParameterSet', 'values, marks, id')): + @classmethod + def param(cls, *values, **kw): + marks = kw.pop('marks', ()) + if isinstance(marks, MarkDecorator): + marks = marks, + else: + assert isinstance(marks, (tuple, list, set)) + + def param_extract_id(id=None): + return id + + id = param_extract_id(**kw) + return cls(values, marks, id) + + @classmethod + def extract_from(cls, parameterset, legacy_force_tuple=False): + """ + :param parameterset: + a legacy style parameterset that may or may not be a tuple, + and may or may not be wrapped into a mess of mark objects + + :param legacy_force_tuple: + enforce tuple wrapping so single argument tuple values + don't get decomposed and break tests + + """ + + if isinstance(parameterset, cls): + return parameterset + if not isinstance(parameterset, MarkDecorator) and legacy_force_tuple: + return cls.param(parameterset) + + newmarks = [] + argval = parameterset + while isinstance(argval, MarkDecorator): + newmarks.append(MarkDecorator(Mark( + argval.markname, argval.args[:-1], argval.kwargs))) + argval = argval.args[-1] + assert not isinstance(argval, ParameterSet) + if legacy_force_tuple: + argval = argval, + + if newmarks: + warnings.warn(MARK_PARAMETERSET_UNPACKING) + + return cls(argval, marks=newmarks, id=None) + + @classmethod + def _for_parametrize(cls, argnames, argvalues, function, config): + if not isinstance(argnames, (tuple, list)): + argnames = [x.strip() for x in argnames.split(",") if x.strip()] + force_tuple = len(argnames) == 1 + else: + force_tuple = False + parameters = [ + ParameterSet.extract_from(x, legacy_force_tuple=force_tuple) + for x in argvalues] + del argvalues + + if not parameters: + mark = get_empty_parameterset_mark(config, argnames, function) + parameters.append(ParameterSet( + values=(NOTSET,) * len(argnames), + marks=[mark], + id=None, + )) + return argnames, parameters + + +@attr.s(frozen=True) +class Mark(object): + name = attr.ib() + args = attr.ib() + kwargs = attr.ib() + + def combined_with(self, other): + assert self.name == other.name + return Mark( + self.name, self.args + other.args, + dict(self.kwargs, **other.kwargs)) + + +@attr.s +class MarkDecorator(object): + """ A decorator for test functions and test classes. When applied + it will create :class:`MarkInfo` objects which may be + :ref:`retrieved by hooks as item keywords `. + MarkDecorator instances are often created like this:: + + mark1 = pytest.mark.NAME # simple MarkDecorator + mark2 = pytest.mark.NAME(name1=value) # parametrized MarkDecorator + + and can then be applied as decorators to test functions:: + + @mark2 + def test_function(): + pass + + When a MarkDecorator instance is called it does the following: + 1. If called with a single class as its only positional argument and no + additional keyword arguments, it attaches itself to the class so it + gets applied automatically to all test cases found in that class. + 2. If called with a single function as its only positional argument and + no additional keyword arguments, it attaches a MarkInfo object to the + function, containing all the arguments already stored internally in + the MarkDecorator. + 3. When called in any other case, it performs a 'fake construction' call, + i.e. it returns a new MarkDecorator instance with the original + MarkDecorator's content updated with the arguments passed to this + call. + + Note: The rules above prevent MarkDecorator objects from storing only a + single function or class reference as their positional argument with no + additional keyword or positional arguments. + + """ + + mark = attr.ib(validator=attr.validators.instance_of(Mark)) + + name = alias('mark.name') + args = alias('mark.args') + kwargs = alias('mark.kwargs') + + @property + def markname(self): + return self.name # for backward-compat (2.4.1 had this attr) + + def __eq__(self, other): + return self.mark == other.mark if isinstance(other, MarkDecorator) else False + + def __repr__(self): + return "" % (self.mark,) + + def with_args(self, *args, **kwargs): + """ return a MarkDecorator with extra arguments added + + unlike call this can be used even if the sole argument is a callable/class + + :return: MarkDecorator + """ + + mark = Mark(self.name, args, kwargs) + return self.__class__(self.mark.combined_with(mark)) + + def __call__(self, *args, **kwargs): + """ if passed a single callable argument: decorate it with mark info. + otherwise add *args/**kwargs in-place to mark information. """ + if args and not kwargs: + func = args[0] + is_class = inspect.isclass(func) + if len(args) == 1 and (istestfunc(func) or is_class): + if is_class: + store_mark(func, self.mark) + else: + store_legacy_markinfo(func, self.mark) + store_mark(func, self.mark) + return func + return self.with_args(*args, **kwargs) + + +def get_unpacked_marks(obj): + """ + obtain the unpacked marks that are stored on a object + """ + mark_list = getattr(obj, 'pytestmark', []) + + if not isinstance(mark_list, list): + mark_list = [mark_list] + return [ + getattr(mark, 'mark', mark) # unpack MarkDecorator + for mark in mark_list + ] + + +def store_mark(obj, mark): + """store a Mark on a object + this is used to implement the Mark declarations/decorators correctly + """ + assert isinstance(mark, Mark), mark + # always reassign name to avoid updating pytestmark + # in a reference that was only borrowed + obj.pytestmark = get_unpacked_marks(obj) + [mark] + + +def store_legacy_markinfo(func, mark): + """create the legacy MarkInfo objects and put them onto the function + """ + if not isinstance(mark, Mark): + raise TypeError("got {mark!r} instead of a Mark".format(mark=mark)) + holder = getattr(func, mark.name, None) + if holder is None: + holder = MarkInfo(mark) + setattr(func, mark.name, holder) + else: + holder.add_mark(mark) + + +def transfer_markers(funcobj, cls, mod): + """ + this function transfers class level markers and module level markers + into function level markinfo objects + + this is the main reason why marks are so broken + the resolution will involve phasing out function level MarkInfo objects + + """ + for obj in (cls, mod): + for mark in get_unpacked_marks(obj): + if not _marked(funcobj, mark): + store_legacy_markinfo(funcobj, mark) + + +def _marked(func, mark): + """ Returns True if :func: is already marked with :mark:, False otherwise. + This can happen if marker is applied to class and the test file is + invoked more than once. + """ + try: + func_mark = getattr(func, mark.name) + except AttributeError: + return False + return mark.args == func_mark.args and mark.kwargs == func_mark.kwargs + + +class MarkInfo(object): + """ Marking object created by :class:`MarkDecorator` instances. """ + + def __init__(self, mark): + assert isinstance(mark, Mark), repr(mark) + self.combined = mark + self._marks = [mark] + + name = alias('combined.name') + args = alias('combined.args') + kwargs = alias('combined.kwargs') + + def __repr__(self): + return "".format(self.combined) + + def add_mark(self, mark): + """ add a MarkInfo with the given args and kwargs. """ + self._marks.append(mark) + self.combined = self.combined.combined_with(mark) + + def __iter__(self): + """ yield MarkInfo objects each relating to a marking-call. """ + return map(MarkInfo, self._marks) + + +class MarkGenerator(object): + """ Factory for :class:`MarkDecorator` objects - exposed as + a ``pytest.mark`` singleton instance. Example:: + + import pytest + @pytest.mark.slowtest + def test_function(): + pass + + will set a 'slowtest' :class:`MarkInfo` object + on the ``test_function`` object. """ + _config = None + + def __getattr__(self, name): + if name[0] == "_": + raise AttributeError("Marker name must NOT start with underscore") + if self._config is not None: + self._check(name) + return MarkDecorator(Mark(name, (), {})) + + def _check(self, name): + try: + if name in self._markers: + return + except AttributeError: + pass + self._markers = values = set() + for line in self._config.getini("markers"): + marker = line.split(":", 1)[0] + marker = marker.rstrip() + x = marker.split("(", 1)[0] + values.add(x) + if name not in self._markers: + raise AttributeError("%r not a registered marker" % (name,)) + + +MARK_GEN = MarkGenerator() diff --git a/_pytest/python.py b/_pytest/python.py index fb7bac8b86c..9f633812f22 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -28,7 +28,7 @@ safe_str, getlocation, enum, ) from _pytest.outcomes import fail -from _pytest.mark import transfer_markers +from _pytest.mark.legacy import transfer_markers # relative paths that we use to filter traceback entries from appearing to the user; From 935dd3aaa5368f3a61f2e4996a09deed6c524406 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 22 Feb 2018 15:11:55 +0100 Subject: [PATCH 050/107] simplify complexyity in mark plugin modifyitems --- _pytest/mark/__init__.py | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/_pytest/mark/__init__.py b/_pytest/mark/__init__.py index cf26c2683c1..0856cf926a2 100644 --- a/_pytest/mark/__init__.py +++ b/_pytest/mark/__init__.py @@ -5,6 +5,8 @@ ParameterSet, EMPTY_PARAMETERSET_OPTION, MARK_GEN, Mark, MarkInfo, MarkDecorator, ) +from .legacy import matchkeyword, matchmark + __all__ = ['Mark', 'MarkInfo', 'MarkDecorator'] @@ -71,15 +73,8 @@ def pytest_cmdline_main(config): pytest_cmdline_main.tryfirst = True -def pytest_collection_modifyitems(items, config): - from .legacy import matchkeyword, matchmark +def deselect_by_keyword(items, config): keywordexpr = config.option.keyword.lstrip() - matchexpr = config.option.markexpr - if not keywordexpr and not matchexpr: - return - # pytest used to allow "-" for negating - # but today we just allow "-" at the beginning, use "not" instead - # we probably remove "-" altogether soon if keywordexpr.startswith("-"): keywordexpr = "not " + keywordexpr[1:] selectuntil = False @@ -95,10 +90,6 @@ def pytest_collection_modifyitems(items, config): else: if selectuntil: keywordexpr = None - if matchexpr: - if not matchmark(colitem, matchexpr): - deselected.append(colitem) - continue remaining.append(colitem) if deselected: @@ -106,6 +97,29 @@ def pytest_collection_modifyitems(items, config): items[:] = remaining +def deselect_by_mark(items, config): + matchexpr = config.option.markexpr + if not matchexpr: + return + + remaining = [] + deselected = [] + for item in items: + if matchmark(item, matchexpr): + remaining.append(item) + else: + deselected.append(item) + + if deselected: + config.hook.pytest_deselected(items=deselected) + items[:] = remaining + + +def pytest_collection_modifyitems(items, config): + deselect_by_keyword(items, config) + deselect_by_mark(items, config) + + def pytest_configure(config): config._old_mark_config = MARK_GEN._config if config.option.strict: From 2cd69cf632801ac975b55881d7cb3d3c17a09b0a Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Thu, 22 Feb 2018 15:13:01 +0100 Subject: [PATCH 051/107] sort out import misstake --- _pytest/mark/__init__.py | 8 ++++++-- _pytest/python.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/_pytest/mark/__init__.py b/_pytest/mark/__init__.py index 0856cf926a2..f22db582061 100644 --- a/_pytest/mark/__init__.py +++ b/_pytest/mark/__init__.py @@ -3,11 +3,15 @@ from _pytest.config import UsageError from .structures import ( ParameterSet, EMPTY_PARAMETERSET_OPTION, MARK_GEN, - Mark, MarkInfo, MarkDecorator, + Mark, MarkInfo, MarkDecorator, MarkGenerator, + transfer_markers, get_empty_parameterset_mark ) from .legacy import matchkeyword, matchmark -__all__ = ['Mark', 'MarkInfo', 'MarkDecorator'] +__all__ = [ + 'Mark', 'MarkInfo', 'MarkDecorator', 'MarkGenerator', + 'transfer_markers', 'get_empty_parameterset_mark' +] class MarkerError(Exception): diff --git a/_pytest/python.py b/_pytest/python.py index 9f633812f22..cdcfed49b4e 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -28,7 +28,7 @@ safe_str, getlocation, enum, ) from _pytest.outcomes import fail -from _pytest.mark.legacy import transfer_markers +from _pytest.mark.structures import transfer_markers # relative paths that we use to filter traceback entries from appearing to the user; From 0f58fc881b795478d3f93f8f2bbd6b73a11b4c57 Mon Sep 17 00:00:00 2001 From: Thomas Hisch Date: Thu, 22 Feb 2018 19:26:46 +0100 Subject: [PATCH 052/107] Add pdb test with disabled logging plugin Implement the test from #3210, which was not merged yet, because the PR was abandoned in favor or #3234. --- testing/test_pdb.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/testing/test_pdb.py b/testing/test_pdb.py index a36ada05b23..445cafcc5f9 100644 --- a/testing/test_pdb.py +++ b/testing/test_pdb.py @@ -205,6 +205,24 @@ def test_1(): assert "1 failed" in rest self.flush(child) + def test_pdb_print_captured_logs_nologging(self, testdir): + p1 = testdir.makepyfile(""" + def test_1(): + import logging + logging.warn("get " + "rekt") + assert False + """) + child = testdir.spawn_pytest("--show-capture=all --pdb " + "-p no:logging %s" % p1) + child.expect("get rekt") + output = child.before.decode("utf8") + assert "captured log" not in output + child.expect("(Pdb)") + child.sendeof() + rest = child.read().decode("utf8") + assert "1 failed" in rest + self.flush(child) + def test_pdb_interaction_exception(self, testdir): p1 = testdir.makepyfile(""" import pytest From 60358b6db8ef4ff082090103d095d9886e199c9f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 22 Feb 2018 18:49:20 -0300 Subject: [PATCH 053/107] Fix linting --- _pytest/mark/evaluate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/_pytest/mark/evaluate.py b/_pytest/mark/evaluate.py index 27eaa78b319..f84d7455d6d 100644 --- a/_pytest/mark/evaluate.py +++ b/_pytest/mark/evaluate.py @@ -123,4 +123,3 @@ def getexplanation(self): else: return "condition: " + str(self.expr) return expl - From 9959164c9ac919d3be56776509b569accc50289a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 22 Feb 2018 18:55:25 -0300 Subject: [PATCH 054/107] Add CHANGELOG entry for #3250 --- changelog/3250.trivial.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3250.trivial.rst diff --git a/changelog/3250.trivial.rst b/changelog/3250.trivial.rst new file mode 100644 index 00000000000..a80bac5131e --- /dev/null +++ b/changelog/3250.trivial.rst @@ -0,0 +1 @@ +Internal ``mark.py`` module has been turned into a package. From fbc45be83f279f936121355649728f7aeec6e6a6 Mon Sep 17 00:00:00 2001 From: William Lee Date: Thu, 22 Feb 2018 21:00:54 -0600 Subject: [PATCH 055/107] Fixed #3149 where doctest does not continue to run when there is a failure --- AUTHORS | 1 + _pytest/doctest.py | 186 ++++++++++++++++++++++++++++++---------- testing/test_doctest.py | 19 ++++ 3 files changed, 161 insertions(+), 45 deletions(-) diff --git a/AUTHORS b/AUTHORS index cda6511a097..c3a9cfd2a25 100644 --- a/AUTHORS +++ b/AUTHORS @@ -195,6 +195,7 @@ Victor Uriarte Vidar T. Fauske Vitaly Lashmanov Vlad Dragos +William Lee Wouter van Ackooy Xuan Luong Xuecong Liao diff --git a/_pytest/doctest.py b/_pytest/doctest.py index f54f833ece9..1345d586871 100644 --- a/_pytest/doctest.py +++ b/_pytest/doctest.py @@ -24,6 +24,9 @@ DOCTEST_REPORT_CHOICE_ONLY_FIRST_FAILURE, ) +# Lazy definiton of runner class +RUNNER_CLASS = None + def pytest_addoption(parser): parser.addini('doctest_optionflags', 'option flags for doctests', @@ -77,14 +80,91 @@ def _is_doctest(config, path, parent): class ReprFailDoctest(TerminalRepr): - def __init__(self, reprlocation, lines): - self.reprlocation = reprlocation - self.lines = lines + def __init__(self, reprlocation_lines): + # List of (reprlocation, lines) tuples + self.reprlocation_lines = reprlocation_lines def toterminal(self, tw): - for line in self.lines: - tw.line(line) - self.reprlocation.toterminal(tw) + for reprlocation, lines in self.reprlocation_lines: + for line in lines: + tw.line(line) + reprlocation.toterminal(tw) + + +# class DoctestFailureContainer(object): +# +# NAME = 'DocTestFailure' +# +# def __init__(self, test, example, got): +# self.test = test +# self.example = example +# self.got = got +# +# +# class DoctestUnexpectedExceptionContainer(object): +# +# NAME = 'DoctestUnexpectedException' +# +# def __init__(self, test, example, exc_info): +# self.test = test +# self.example = example +# self.exc_info = exc_info + + +class MultipleDoctestFailures(Exception): + def __init__(self, failures): + super(MultipleDoctestFailures, self).__init__() + self.failures = failures + + +def _init_runner_class(): + import doctest + + class PytestDoctestRunner(doctest.DocTestRunner): + """ + Runner to collect failures. Note that the out variable in this case is + a list instead of a stdout-like object + """ + def __init__(self, checker=None, verbose=None, optionflags=0, + continue_on_failure=True): + doctest.DocTestRunner.__init__( + self, checker=checker, verbose=verbose, optionflags=optionflags) + self.continue_on_failure = continue_on_failure + + def report_start(self, out, test, example): + pass + + def report_success(self, out, test, example, got): + pass + + def report_failure(self, out, test, example, got): + # failure = DoctestFailureContainer(test, example, got) + failure = doctest.DocTestFailure(test, example, got) + if self.continue_on_failure: + out.append(failure) + else: + raise failure + + def report_unexpected_exception(self, out, test, example, exc_info): + # failure = DoctestUnexpectedExceptionContainer(test, example, exc_info) + failure = doctest.UnexpectedException(test, example, exc_info) + if self.continue_on_failure: + out.append(failure) + else: + raise failure + + return PytestDoctestRunner + + +def _get_runner(checker=None, verbose=None, optionflags=0, + continue_on_failure=True): + # We need this in order to do a lazy import on doctest + global RUNNER_CLASS + if RUNNER_CLASS is None: + RUNNER_CLASS = _init_runner_class() + return RUNNER_CLASS( + checker=checker, verbose=verbose, optionflags=optionflags, + continue_on_failure=continue_on_failure) class DoctestItem(pytest.Item): @@ -106,7 +186,10 @@ def setup(self): def runtest(self): _check_all_skipped(self.dtest) self._disable_output_capturing_for_darwin() - self.runner.run(self.dtest) + failures = [] + self.runner.run(self.dtest, out=failures) + if failures: + raise MultipleDoctestFailures(failures) def _disable_output_capturing_for_darwin(self): """ @@ -122,42 +205,51 @@ def _disable_output_capturing_for_darwin(self): def repr_failure(self, excinfo): import doctest + failures = None if excinfo.errisinstance((doctest.DocTestFailure, doctest.UnexpectedException)): - doctestfailure = excinfo.value - example = doctestfailure.example - test = doctestfailure.test - filename = test.filename - if test.lineno is None: - lineno = None - else: - lineno = test.lineno + example.lineno + 1 - message = excinfo.type.__name__ - reprlocation = ReprFileLocation(filename, lineno, message) - checker = _get_checker() - report_choice = _get_report_choice(self.config.getoption("doctestreport")) - if lineno is not None: - lines = doctestfailure.test.docstring.splitlines(False) - # add line numbers to the left of the error message - lines = ["%03d %s" % (i + test.lineno + 1, x) - for (i, x) in enumerate(lines)] - # trim docstring error lines to 10 - lines = lines[max(example.lineno - 9, 0):example.lineno + 1] - else: - lines = ['EXAMPLE LOCATION UNKNOWN, not showing all tests of that example'] - indent = '>>>' - for line in example.source.splitlines(): - lines.append('??? %s %s' % (indent, line)) - indent = '...' - if excinfo.errisinstance(doctest.DocTestFailure): - lines += checker.output_difference(example, - doctestfailure.got, report_choice).split("\n") - else: - inner_excinfo = ExceptionInfo(excinfo.value.exc_info) - lines += ["UNEXPECTED EXCEPTION: %s" % - repr(inner_excinfo.value)] - lines += traceback.format_exception(*excinfo.value.exc_info) - return ReprFailDoctest(reprlocation, lines) + failures = [excinfo.value] + elif excinfo.errisinstance(MultipleDoctestFailures): + failures = excinfo.value.failures + + if failures is not None: + reprlocation_lines = [] + for failure in failures: + example = failure.example + test = failure.test + filename = test.filename + if test.lineno is None: + lineno = None + else: + lineno = test.lineno + example.lineno + 1 + message = type(failure).__name__ + reprlocation = ReprFileLocation(filename, lineno, message) + checker = _get_checker() + report_choice = _get_report_choice(self.config.getoption("doctestreport")) + if lineno is not None: + lines = failure.test.docstring.splitlines(False) + # add line numbers to the left of the error message + lines = ["%03d %s" % (i + test.lineno + 1, x) + for (i, x) in enumerate(lines)] + # trim docstring error lines to 10 + lines = lines[max(example.lineno - 9, 0):example.lineno + 1] + else: + lines = ['EXAMPLE LOCATION UNKNOWN, not showing all tests of that example'] + indent = '>>>' + for line in example.source.splitlines(): + lines.append('??? %s %s' % (indent, line)) + indent = '...' + if isinstance(failure, doctest.DocTestFailure): + lines += checker.output_difference(example, + failure.got, + report_choice).split("\n") + else: + inner_excinfo = ExceptionInfo(failure.exc_info) + lines += ["UNEXPECTED EXCEPTION: %s" % + repr(inner_excinfo.value)] + lines += traceback.format_exception(*failure.exc_info) + reprlocation_lines.append((reprlocation, lines)) + return ReprFailDoctest(reprlocation_lines) else: return super(DoctestItem, self).repr_failure(excinfo) @@ -202,8 +294,10 @@ def collect(self): globs = {'__name__': '__main__'} optionflags = get_optionflags(self) - runner = doctest.DebugRunner(verbose=0, optionflags=optionflags, - checker=_get_checker()) + continue_on_failure = not self.config.getvalue("usepdb") + runner = _get_runner(verbose=0, optionflags=optionflags, + checker=_get_checker(), + continue_on_failure=continue_on_failure) _fix_spoof_python2(runner, encoding) parser = doctest.DocTestParser() @@ -238,8 +332,10 @@ def collect(self): # uses internal doctest module parsing mechanism finder = doctest.DocTestFinder() optionflags = get_optionflags(self) - runner = doctest.DebugRunner(verbose=0, optionflags=optionflags, - checker=_get_checker()) + continue_on_failure = not self.config.getvalue("usepdb") + runner = _get_runner(verbose=0, optionflags=optionflags, + checker=_get_checker(), + continue_on_failure=continue_on_failure) for test in finder.find(module, module.__name__): if test.examples: # skip empty doctests diff --git a/testing/test_doctest.py b/testing/test_doctest.py index b15067f15e9..8c8f452246b 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -756,6 +756,25 @@ def test_vacuous_all_skipped(self, testdir, makedoctest): reprec = testdir.inline_run("--doctest-modules") reprec.assertoutcome(passed=0, skipped=0) + def test_continue_on_failure(self, testdir): + testdir.maketxtfile(test_something=""" + >>> i = 5 + >>> def foo(): + ... raise ValueError('error1') + >>> foo() + >>> i + >>> i + 2 + 7 + >>> i + 1 + """) + result = testdir.runpytest("--doctest-modules") + result.assert_outcomes(passed=0, failed=1) + # We need to make sure we have two failure lines (4, 5, and 8) instead of + # one. + result.stdout.fnmatch_lines("*test_something.txt:4: DoctestUnexpectedException*") + result.stdout.fnmatch_lines("*test_something.txt:5: DocTestFailure*") + result.stdout.fnmatch_lines("*test_something.txt:8: DocTestFailure*") + class TestDoctestAutoUseFixtures(object): From 14cd1e9d9438e86b0c0af9a9437abe578072aedc Mon Sep 17 00:00:00 2001 From: William Lee Date: Thu, 22 Feb 2018 21:01:19 -0600 Subject: [PATCH 056/107] Added the feature in change log for #3149 --- changelog/3149.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3149.feature diff --git a/changelog/3149.feature b/changelog/3149.feature new file mode 100644 index 00000000000..ed71b5a1909 --- /dev/null +++ b/changelog/3149.feature @@ -0,0 +1 @@ +Doctest runs now show multiple failures for each doctest snippet, instead of stopping at the first failure. From e865f2a2358a8cbbf33018167a33c44598b9da55 Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Fri, 23 Feb 2018 22:49:17 +0300 Subject: [PATCH 057/107] #3034 Fix comments --- _pytest/cacheprovider.py | 37 ++++++++++---------- changelog/3034.feature | 2 +- doc/en/cache.rst | 14 +++----- testing/test_cacheprovider.py | 64 ++++++++++++++++------------------- 4 files changed, 54 insertions(+), 63 deletions(-) diff --git a/_pytest/cacheprovider.py b/_pytest/cacheprovider.py index 04dacf83780..0ac1b81023c 100755 --- a/_pytest/cacheprovider.py +++ b/_pytest/cacheprovider.py @@ -5,8 +5,11 @@ ignores the external pytest-cache """ from __future__ import absolute_import, division, print_function + +from collections import OrderedDict + import py -from _pytest.python import Function +import six import pytest import json @@ -176,33 +179,31 @@ class NFPlugin(object): def __init__(self, config): self.config = config self.active = config.option.newfirst - self.all_items = config.cache.get("cache/allitems", {}) + self.cached_nodeids = config.cache.get("cache/nodeids", []) def pytest_collection_modifyitems(self, session, config, items): if self.active: - new_items = [] - other_items = [] + new_items = OrderedDict() + other_items = OrderedDict() for item in items: - mod_timestamp = os.path.getmtime(str(item.fspath)) - if self.all_items and item.nodeid not in self.all_items: - new_items.append((item, mod_timestamp)) + if item.nodeid not in self.cached_nodeids: + new_items[item.nodeid] = item else: - other_items.append((item, mod_timestamp)) + other_items[item.nodeid] = item - items[:] = self._get_increasing_order(new_items) + \ - self._get_increasing_order(other_items) - self.all_items = items + items[:] = self._get_increasing_order(six.itervalues(new_items)) + \ + self._get_increasing_order(six.itervalues(other_items)) + self.cached_nodeids = [x.nodeid for x in items if isinstance(x, pytest.Item)] - def _get_increasing_order(self, test_list): - test_list = sorted(test_list, key=lambda x: x[1], reverse=True) - return [test[0] for test in test_list] + def _get_increasing_order(self, items): + return sorted(items, key=lambda item: item.fspath.mtime(), reverse=True) def pytest_sessionfinish(self, session): config = self.config 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)]) + + config.cache.set("cache/nodeids", self.cached_nodeids) def pytest_addoption(parser): @@ -218,8 +219,8 @@ def pytest_addoption(parser): "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 " - "last modified files, then other tests") + help="run tests from new files first, then the rest of the tests " + "sorted by file mtime") group.addoption( '--cache-show', action='store_true', dest="cacheshow", help="show cache contents, don't perform collection or tests") diff --git a/changelog/3034.feature b/changelog/3034.feature index 62c7ba78d35..12330cdd6a5 100644 --- a/changelog/3034.feature +++ b/changelog/3034.feature @@ -1 +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). +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. diff --git a/doc/en/cache.rst b/doc/en/cache.rst index 138ff6dfb2c..db72249f9c0 100644 --- a/doc/en/cache.rst +++ b/doc/en/cache.rst @@ -152,6 +152,10 @@ of ``FF`` and dots):: .. _`config.cache`: +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. + The new config.cache object -------------------------------- @@ -266,13 +270,3 @@ dumps/loads API of the json stdlib module .. automethod:: Cache.get .. automethod:: Cache.set .. automethod:: Cache.makedir - - -New tests first ------------------ - -The plugin provides command line options to run tests in another order: - -* ``--nf``, ``--new-first`` - to run tests in next order: first new tests, then - last modified files with tests in descending order (default order inside file). - diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index b03b02d3429..24dffa1dadd 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -607,22 +607,20 @@ def test_foo_4(): class TestNewFirst(object): def test_newfirst_usecase(self, testdir): - t1 = testdir.mkdir("test_1") - t2 = testdir.mkdir("test_2") - - t1.join("test_1.py").write( - "def test_1(): assert 1\n" - "def test_2(): assert 1\n" - "def test_3(): assert 1\n" - ) - t2.join("test_2.py").write( - "def test_1(): assert 1\n" - "def test_2(): assert 1\n" - "def test_3(): assert 1\n" - ) + testdir.makepyfile(**{ + 'test_1/test_1.py': ''' + def test_1(): assert 1 + def test_2(): assert 1 + def test_3(): assert 1 + ''', + 'test_2/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)) + testdir.tmpdir.join('test_1/test_1.py').setmtime(1) result = testdir.runpytest("-v") result.stdout.fnmatch_lines([ @@ -645,13 +643,13 @@ def test_newfirst_usecase(self, testdir): "*test_1/test_1.py::test_3 PASSED*", ]) - t1.join("test_1.py").write( + testdir.tmpdir.join("test_1/test_1.py").write( "def test_1(): assert 1\n" "def test_2(): assert 1\n" "def test_3(): assert 1\n" "def test_4(): assert 1\n" ) - os.utime(path_to_test_1, (1, 1)) + testdir.tmpdir.join('test_1/test_1.py').setmtime(1) result = testdir.runpytest("-v", "--nf") @@ -666,22 +664,20 @@ def test_newfirst_usecase(self, testdir): ]) def test_newfirst_parametrize(self, testdir): - t1 = testdir.mkdir("test_1") - t2 = testdir.mkdir("test_2") - - t1.join("test_1.py").write( - "import pytest\n" - "@pytest.mark.parametrize('num', [1, 2])\n" - "def test_1(num): assert num\n" - ) - t2.join("test_2.py").write( - "import pytest\n" - "@pytest.mark.parametrize('num', [1, 2])\n" - "def test_1(num): assert num\n" - ) + testdir.makepyfile(**{ + 'test_1/test_1.py': ''' + import pytest + @pytest.mark.parametrize('num', [1, 2]) + def test_1(num): assert num + ''', + 'test_2/test_2.py': ''' + import pytest + @pytest.mark.parametrize('num', [1, 2]) + def test_1(num): assert num + ''' + }) - path_to_test_1 = str('{}/test_1/test_1.py'.format(testdir.tmpdir)) - os.utime(path_to_test_1, (1, 1)) + testdir.tmpdir.join('test_1/test_1.py').setmtime(1) result = testdir.runpytest("-v") result.stdout.fnmatch_lines([ @@ -700,12 +696,12 @@ def test_newfirst_parametrize(self, testdir): "*test_1/test_1.py::test_1[2*", ]) - t1.join("test_1.py").write( + testdir.tmpdir.join("test_1/test_1.py").write( "import pytest\n" "@pytest.mark.parametrize('num', [1, 2, 3])\n" "def test_1(num): assert num\n" ) - os.utime(path_to_test_1, (1, 1)) + testdir.tmpdir.join('test_1/test_1.py').setmtime(1) result = testdir.runpytest("-v", "--nf") From 7f2dd74ae9ba23cc8c8a33e933b87df9bf708be0 Mon Sep 17 00:00:00 2001 From: William Lee Date: Fri, 23 Feb 2018 21:20:14 -0600 Subject: [PATCH 058/107] Fixed test for the continue run --- _pytest/doctest.py | 22 ---------------------- testing/test_doctest.py | 12 +++++++----- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/_pytest/doctest.py b/_pytest/doctest.py index 1345d586871..1c16f4c84e7 100644 --- a/_pytest/doctest.py +++ b/_pytest/doctest.py @@ -91,26 +91,6 @@ def toterminal(self, tw): reprlocation.toterminal(tw) -# class DoctestFailureContainer(object): -# -# NAME = 'DocTestFailure' -# -# def __init__(self, test, example, got): -# self.test = test -# self.example = example -# self.got = got -# -# -# class DoctestUnexpectedExceptionContainer(object): -# -# NAME = 'DoctestUnexpectedException' -# -# def __init__(self, test, example, exc_info): -# self.test = test -# self.example = example -# self.exc_info = exc_info - - class MultipleDoctestFailures(Exception): def __init__(self, failures): super(MultipleDoctestFailures, self).__init__() @@ -138,7 +118,6 @@ def report_success(self, out, test, example, got): pass def report_failure(self, out, test, example, got): - # failure = DoctestFailureContainer(test, example, got) failure = doctest.DocTestFailure(test, example, got) if self.continue_on_failure: out.append(failure) @@ -146,7 +125,6 @@ def report_failure(self, out, test, example, got): raise failure def report_unexpected_exception(self, out, test, example, exc_info): - # failure = DoctestUnexpectedExceptionContainer(test, example, exc_info) failure = doctest.UnexpectedException(test, example, exc_info) if self.continue_on_failure: out.append(failure) diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 8c8f452246b..93127982007 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -769,11 +769,13 @@ def test_continue_on_failure(self, testdir): """) result = testdir.runpytest("--doctest-modules") result.assert_outcomes(passed=0, failed=1) - # We need to make sure we have two failure lines (4, 5, and 8) instead of - # one. - result.stdout.fnmatch_lines("*test_something.txt:4: DoctestUnexpectedException*") - result.stdout.fnmatch_lines("*test_something.txt:5: DocTestFailure*") - result.stdout.fnmatch_lines("*test_something.txt:8: DocTestFailure*") + # The lines that contains the failure are 4, 5, and 8. The first one + # is a stack trace and the other two are mismatches. + result.stdout.fnmatch_lines([ + "*4: UnexpectedException*", + "*5: DocTestFailure*", + "*8: DocTestFailure*", + ]) class TestDoctestAutoUseFixtures(object): From f4cc45bb41b29f1aeab61dc3c3219d1e1baadc66 Mon Sep 17 00:00:00 2001 From: William Lee Date: Fri, 23 Feb 2018 22:31:11 -0600 Subject: [PATCH 059/107] Turn on the continue on failure only when the flag is given --- _pytest/doctest.py | 41 +++++++++++++++++++++++++---------------- testing/test_doctest.py | 2 +- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/_pytest/doctest.py b/_pytest/doctest.py index 1c16f4c84e7..03775a09a1c 100644 --- a/_pytest/doctest.py +++ b/_pytest/doctest.py @@ -50,6 +50,10 @@ def pytest_addoption(parser): action="store_true", default=False, help="ignore doctest ImportErrors", dest="doctest_ignore_import_errors") + group.addoption("--doctest-continue-on-failure", + action="store_true", default=False, + help="for a given doctest, continue to run after the first failure", + dest="doctest_continue_on_failure") def pytest_collect_file(path, parent): @@ -100,23 +104,17 @@ def __init__(self, failures): def _init_runner_class(): import doctest - class PytestDoctestRunner(doctest.DocTestRunner): + class PytestDoctestRunner(doctest.DebugRunner): """ Runner to collect failures. Note that the out variable in this case is a list instead of a stdout-like object """ def __init__(self, checker=None, verbose=None, optionflags=0, continue_on_failure=True): - doctest.DocTestRunner.__init__( + doctest.DebugRunner.__init__( self, checker=checker, verbose=verbose, optionflags=optionflags) self.continue_on_failure = continue_on_failure - def report_start(self, out, test, example): - pass - - def report_success(self, out, test, example, got): - pass - def report_failure(self, out, test, example, got): failure = doctest.DocTestFailure(test, example, got) if self.continue_on_failure: @@ -257,6 +255,16 @@ def get_optionflags(parent): return flag_acc +def _get_continue_on_failure(config): + continue_on_failure = config.getvalue('doctest_continue_on_failure') + if continue_on_failure: + # We need to turn off this if we use pdb since we should stop at + # the first failure + if config.getvalue("usepdb"): + continue_on_failure = False + return continue_on_failure + + class DoctestTextfile(pytest.Module): obj = None @@ -272,10 +280,11 @@ def collect(self): globs = {'__name__': '__main__'} optionflags = get_optionflags(self) - continue_on_failure = not self.config.getvalue("usepdb") - runner = _get_runner(verbose=0, optionflags=optionflags, - checker=_get_checker(), - continue_on_failure=continue_on_failure) + + runner = _get_runner( + verbose=0, optionflags=optionflags, + checker=_get_checker(), + continue_on_failure=_get_continue_on_failure(self.config)) _fix_spoof_python2(runner, encoding) parser = doctest.DocTestParser() @@ -310,10 +319,10 @@ def collect(self): # uses internal doctest module parsing mechanism finder = doctest.DocTestFinder() optionflags = get_optionflags(self) - continue_on_failure = not self.config.getvalue("usepdb") - runner = _get_runner(verbose=0, optionflags=optionflags, - checker=_get_checker(), - continue_on_failure=continue_on_failure) + runner = _get_runner( + verbose=0, optionflags=optionflags, + checker=_get_checker(), + continue_on_failure=_get_continue_on_failure(self.config)) for test in finder.find(module, module.__name__): if test.examples: # skip empty doctests diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 93127982007..31439839554 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -767,7 +767,7 @@ def test_continue_on_failure(self, testdir): 7 >>> i + 1 """) - result = testdir.runpytest("--doctest-modules") + result = testdir.runpytest("--doctest-modules", "--doctest-continue-on-failure") result.assert_outcomes(passed=0, failed=1) # The lines that contains the failure are 4, 5, and 8. The first one # is a stack trace and the other two are mismatches. From c21eb7292451aa53b5fd24bc7e8f2ea9fb7ab912 Mon Sep 17 00:00:00 2001 From: William Lee Date: Fri, 23 Feb 2018 22:42:22 -0600 Subject: [PATCH 060/107] Added documentation for the continue-on-failure flag --- doc/en/doctest.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/en/doctest.rst b/doc/en/doctest.rst index 61fbe04d4da..cdbc34682ad 100644 --- a/doc/en/doctest.rst +++ b/doc/en/doctest.rst @@ -115,6 +115,11 @@ itself:: >>> get_unicode_greeting() # doctest: +ALLOW_UNICODE 'Hello' +By default, pytest would report only the first failure for a given doctest. If +you want to continue the test even when you have failures, do:: + + pytest --doctest-modules --doctest-continue-on-failure + The 'doctest_namespace' fixture ------------------------------- From 307cd6630f6a6b39228dd21bd15ca478fd612a0d Mon Sep 17 00:00:00 2001 From: Jeffrey Rackauckas Date: Sun, 25 Feb 2018 22:38:25 -0800 Subject: [PATCH 061/107] Add the ability to use platform in pytest.mark.skipif --- _pytest/mark/evaluate.py | 3 ++- changelog/3236.trivial.rst | 1 + testing/test_skipping.py | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 changelog/3236.trivial.rst diff --git a/_pytest/mark/evaluate.py b/_pytest/mark/evaluate.py index f84d7455d6d..295373e17c6 100644 --- a/_pytest/mark/evaluate.py +++ b/_pytest/mark/evaluate.py @@ -1,6 +1,7 @@ import os import six import sys +import platform import traceback from . import MarkDecorator, MarkInfo @@ -67,7 +68,7 @@ def istrue(self): pytrace=False) def _getglobals(self): - d = {'os': os, 'sys': sys, 'config': self.item.config} + d = {'os': os, 'sys': sys, 'platform': platform, 'config': self.item.config} if hasattr(self.item, 'obj'): d.update(self.item.obj.__globals__) return d diff --git a/changelog/3236.trivial.rst b/changelog/3236.trivial.rst new file mode 100644 index 00000000000..75830d453a0 --- /dev/null +++ b/changelog/3236.trivial.rst @@ -0,0 +1 @@ +Add the usage of ``platform`` in ``pytest.mark.skipif`` \ No newline at end of file diff --git a/testing/test_skipping.py b/testing/test_skipping.py index db4e6d3f7c9..08581e9055f 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -612,6 +612,16 @@ def test_that(): ]) assert result.ret == 0 + def test_skipif_using_platform(self, testdir): + item = testdir.getitem(""" + import pytest + @pytest.mark.skipif("platform.platform() == platform.platform()") + def test_func(): + pass + """) + pytest.raises(pytest.skip.Exception, lambda: + pytest_runtest_setup(item)) + @pytest.mark.parametrize('marker, msg1, msg2', [ ('skipif', 'SKIP', 'skipped'), ('xfail', 'XPASS', 'xpassed'), From e8f9a910563f72b599ad6b97903553373cbed433 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 26 Feb 2018 17:10:59 -0300 Subject: [PATCH 062/107] Small adjustment to CHANGELOG entry --- changelog/3149.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/3149.feature b/changelog/3149.feature index ed71b5a1909..0431f76ceee 100644 --- a/changelog/3149.feature +++ b/changelog/3149.feature @@ -1 +1 @@ -Doctest runs now show multiple failures for each doctest snippet, instead of stopping at the first failure. +New ``--doctest-continue-on-failure`` command-line option to enable doctests to show multiple failures for each snippet, instead of stopping at the first failure. From d196ab45d32cfbf40f678b25191504c7a469b1c2 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 26 Feb 2018 19:56:53 -0300 Subject: [PATCH 063/107] Change 1642 entry from "trivial" to "feature" --- changelog/{1642.trivial => 1642.feature.rst} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog/{1642.trivial => 1642.feature.rst} (67%) diff --git a/changelog/1642.trivial b/changelog/1642.feature.rst similarity index 67% rename from changelog/1642.trivial rename to changelog/1642.feature.rst index a6acc5565fa..cb40bee75af 100644 --- a/changelog/1642.trivial +++ b/changelog/1642.feature.rst @@ -1 +1 @@ -Add ``--rootdir`` command-line option to override the rules for discovering the root directory. See `customize `_ in the documentation for details. +New ``--rootdir`` command-line option to override the rules for discovering the root directory. See `customize `_ in the documentation for details. From 4e405dd9f96e69cdd53901db930a2d79bc4f7e58 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 22 Feb 2018 19:49:30 -0300 Subject: [PATCH 064/107] Show "short test summary info" after tracebacks and warnings --- _pytest/hookspec.py | 12 ++++++++++-- _pytest/skipping.py | 18 ++++++++++-------- _pytest/terminal.py | 13 +++++++++---- changelog/3255.feature.rst | 1 + testing/test_skipping.py | 15 +++++++++++++++ 5 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 changelog/3255.feature.rst diff --git a/_pytest/hookspec.py b/_pytest/hookspec.py index f1b1fe5a28b..cffc4f8b0af 100644 --- a/_pytest/hookspec.py +++ b/_pytest/hookspec.py @@ -489,8 +489,16 @@ def pytest_report_teststatus(report): Stops at first non-None result, see :ref:`firstresult` """ -def pytest_terminal_summary(terminalreporter, exitstatus): - """ add additional section in terminal summary reporting. """ +def pytest_terminal_summary(config, terminalreporter, exitstatus): + """Add a section to terminal summary reporting. + + :param _pytest.config.Config config: pytest config object + :param _pytest.terminal.TerminalReporter terminalreporter: the internal terminal reporter object + :param int exitstatus: the exit status that will be reported back to the OS + + .. versionadded:: 3.5 + The ``config`` parameter. + """ @hookspec(historic=True) diff --git a/_pytest/skipping.py b/_pytest/skipping.py index 1a4187c1b72..48b837def0a 100644 --- a/_pytest/skipping.py +++ b/_pytest/skipping.py @@ -1,7 +1,6 @@ """ support for skip/xfail functions and markers. """ from __future__ import absolute_import, division, print_function - from _pytest.config import hookimpl from _pytest.mark import MarkInfo, MarkDecorator from _pytest.mark.evaluate import MarkEvaluator @@ -14,11 +13,11 @@ def pytest_addoption(parser): action="store_true", dest="runxfail", default=False, help="run tests even if they are marked xfail") - parser.addini("xfail_strict", "default for the strict parameter of xfail " - "markers when not given explicitly (default: " - "False)", - default=False, - type="bool") + parser.addini("xfail_strict", + "default for the strict parameter of xfail " + "markers when not given explicitly (default: False)", + default=False, + type="bool") def pytest_configure(config): @@ -130,7 +129,7 @@ def pytest_runtest_makereport(item, call): rep.outcome = "passed" rep.wasxfail = rep.longrepr elif item.config.option.runxfail: - pass # don't interefere + pass # don't interefere elif call.excinfo and call.excinfo.errisinstance(xfail.Exception): rep.wasxfail = "reason: " + call.excinfo.value.msg rep.outcome = "skipped" @@ -160,6 +159,7 @@ def pytest_runtest_makereport(item, call): filename, line = item.location[:2] rep.longrepr = filename, line, reason + # called by terminalreporter progress reporting @@ -170,6 +170,7 @@ def pytest_report_teststatus(report): elif report.passed: return "xpassed", "X", ("XPASS", {'yellow': True}) + # called by the terminalreporter instance/plugin @@ -233,7 +234,7 @@ def folded_skips(skipped): # TODO: revisit after marks scope would be fixed when = getattr(event, 'when', None) if when == 'setup' and 'skip' in keywords and 'pytestmark' not in keywords: - key = (key[0], None, key[2], ) + key = (key[0], None, key[2]) d.setdefault(key, []).append(event) values = [] for key, events in d.items(): @@ -269,6 +270,7 @@ def show_skipped(terminalreporter, lines): def shower(stat, format): def show_(terminalreporter, lines): return show_simple(terminalreporter, lines, stat, format) + return show_ diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 55a632b22f7..90c5d87d992 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -480,16 +480,21 @@ def pytest_sessionfinish(self, exitstatus): EXIT_NOTESTSCOLLECTED) if exitstatus in summary_exit_codes: self.config.hook.pytest_terminal_summary(terminalreporter=self, + config=self.config, exitstatus=exitstatus) - self.summary_errors() - self.summary_failures() - self.summary_warnings() - self.summary_passes() if exitstatus == EXIT_INTERRUPTED: self._report_keyboardinterrupt() del self._keyboardinterrupt_memo self.summary_stats() + @pytest.hookimpl(hookwrapper=True) + def pytest_terminal_summary(self): + self.summary_errors() + self.summary_failures() + yield + self.summary_warnings() + self.summary_passes() + def pytest_keyboard_interrupt(self, excinfo): self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True) diff --git a/changelog/3255.feature.rst b/changelog/3255.feature.rst new file mode 100644 index 00000000000..d4994740d92 --- /dev/null +++ b/changelog/3255.feature.rst @@ -0,0 +1 @@ +The *short test summary info* section now is displayed after tracebacks and warnings in the terminal. diff --git a/testing/test_skipping.py b/testing/test_skipping.py index db4e6d3f7c9..161a6e69e57 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -1065,3 +1065,18 @@ def pytest_collect_file(path, parent): assert not failed xfailed = [r for r in skipped if hasattr(r, 'wasxfail')] assert xfailed + + +def test_summary_list_after_errors(testdir): + """Ensure the list of errors/fails/xfails/skips appear after tracebacks in terminal reporting.""" + testdir.makepyfile(""" + import pytest + def test_fail(): + assert 0 + """) + result = testdir.runpytest('-ra') + result.stdout.fnmatch_lines([ + '=* FAILURES *=', + '*= short test summary info =*', + 'FAIL test_summary_list_after_errors.py::test_fail', + ]) From a6762f7328903af8157c4c120dda4f17f3a0f99f Mon Sep 17 00:00:00 2001 From: Jeffrey Rackauckas Date: Mon, 26 Feb 2018 19:11:13 -0800 Subject: [PATCH 065/107] Update test_skipping to test that platform can be used in xfail --- testing/test_skipping.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/testing/test_skipping.py b/testing/test_skipping.py index 08581e9055f..1abf4fcf6f9 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -156,6 +156,21 @@ def test_func(): assert callreport.passed assert callreport.wasxfail == "this is an xfail" + def test_xfail_use_platform(self, testdir): + """ + Verify that platform can be used with xfail statements. + """ + item = testdir.getitem(""" + import pytest + @pytest.mark.xfail("platform.platform() == platform.platform()") + def test_func(): + assert 0 + """) + reports = runtestprotocol(item, log=False) + assert len(reports) == 3 + callreport = reports[1] + assert callreport.wasxfail + def test_xfail_xpassed_strict(self, testdir): item = testdir.getitem(""" import pytest From f6ad25928e8c388b07cc45e9a594ba2c5c1b28d5 Mon Sep 17 00:00:00 2001 From: Jeffrey Rackauckas Date: Mon, 26 Feb 2018 19:15:10 -0800 Subject: [PATCH 066/107] Fixing grammar. --- changelog/3236.trivial.rst | 2 +- testing/test_skipping.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/3236.trivial.rst b/changelog/3236.trivial.rst index 75830d453a0..841ba750091 100644 --- a/changelog/3236.trivial.rst +++ b/changelog/3236.trivial.rst @@ -1 +1 @@ -Add the usage of ``platform`` in ``pytest.mark.skipif`` \ No newline at end of file +Add the usage of ``platform`` in ``pytest.mark`` \ No newline at end of file diff --git a/testing/test_skipping.py b/testing/test_skipping.py index 1abf4fcf6f9..fc9230eda74 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -156,7 +156,7 @@ def test_func(): assert callreport.passed assert callreport.wasxfail == "this is an xfail" - def test_xfail_use_platform(self, testdir): + def test_xfail_using_platform(self, testdir): """ Verify that platform can be used with xfail statements. """ From 9479bda39215505562b5fd65512d8833ccffc273 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 27 Feb 2018 07:03:20 -0300 Subject: [PATCH 067/107] Adjust the CHANGELOG --- changelog/3236.trivial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/3236.trivial.rst b/changelog/3236.trivial.rst index 841ba750091..0fd9c9b5819 100644 --- a/changelog/3236.trivial.rst +++ b/changelog/3236.trivial.rst @@ -1 +1 @@ -Add the usage of ``platform`` in ``pytest.mark`` \ No newline at end of file +The builtin module ``platform`` is now available for use in expressions in ``pytest.mark``. From 94050a8aaf63f7890e3599bf10f43e1e2a31e8b7 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 27 Feb 2018 07:26:25 -0300 Subject: [PATCH 068/107] Remove config paramter from pytest_terminal_summary as discussed during review --- _pytest/hookspec.py | 3 +-- _pytest/terminal.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/_pytest/hookspec.py b/_pytest/hookspec.py index cffc4f8b0af..70349416e20 100644 --- a/_pytest/hookspec.py +++ b/_pytest/hookspec.py @@ -489,10 +489,9 @@ def pytest_report_teststatus(report): Stops at first non-None result, see :ref:`firstresult` """ -def pytest_terminal_summary(config, terminalreporter, exitstatus): +def pytest_terminal_summary(terminalreporter, exitstatus): """Add a section to terminal summary reporting. - :param _pytest.config.Config config: pytest config object :param _pytest.terminal.TerminalReporter terminalreporter: the internal terminal reporter object :param int exitstatus: the exit status that will be reported back to the OS diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 90c5d87d992..18200945bf3 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -480,7 +480,6 @@ def pytest_sessionfinish(self, exitstatus): EXIT_NOTESTSCOLLECTED) if exitstatus in summary_exit_codes: self.config.hook.pytest_terminal_summary(terminalreporter=self, - config=self.config, exitstatus=exitstatus) if exitstatus == EXIT_INTERRUPTED: self._report_keyboardinterrupt() From 8239103aa95f18b10ea204aa22ca7967522d63fd Mon Sep 17 00:00:00 2001 From: Daniel Hahler Date: Tue, 27 Feb 2018 21:07:00 +0100 Subject: [PATCH 069/107] Fix typo with test_summary_list_after_errors --- testing/test_skipping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_skipping.py b/testing/test_skipping.py index f67819a1c98..90562c93999 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -1093,7 +1093,7 @@ def pytest_collect_file(path, parent): def test_summary_list_after_errors(testdir): - """Ensure the list of errors/fails/xfails/skips appear after tracebacks in terminal reporting.""" + """Ensure the list of errors/fails/xfails/skips appears after tracebacks in terminal reporting.""" testdir.makepyfile(""" import pytest def test_fail(): From ea854086e172b4738680894817453a386aa1081c Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 27 Feb 2018 22:46:12 -0300 Subject: [PATCH 070/107] Rename 3236.trivial.rst to 3236.feature.rst --- changelog/{3236.trivial.rst => 3236.feature.rst} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/{3236.trivial.rst => 3236.feature.rst} (100%) diff --git a/changelog/3236.trivial.rst b/changelog/3236.feature.rst similarity index 100% rename from changelog/3236.trivial.rst rename to changelog/3236.feature.rst From 99aab2c3f532fa128257f79852078292001b5247 Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Fri, 2 Mar 2018 10:52:38 +0300 Subject: [PATCH 071/107] #3268 Added deprecation to custom configs --- _pytest/config.py | 4 ++++ testing/deprecated_test.py | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/_pytest/config.py b/_pytest/config.py index 06214e6cae3..54efdde1f74 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -1329,10 +1329,14 @@ def determine_setup(inifile, args, warnfunc=None, rootdir_cmd_arg=None): if inifile: iniconfig = py.iniconfig.IniConfig(inifile) is_cfg_file = str(inifile).endswith('.cfg') + # TODO: [pytest] section in *.cfg files is depricated. Need refactoring. sections = ['tool:pytest', 'pytest'] if is_cfg_file else ['pytest'] for section in sections: try: inicfg = iniconfig[section] + if is_cfg_file and section == 'pytest' and warnfunc: + from _pytest.deprecated import SETUP_CFG_PYTEST + warnfunc('C1', SETUP_CFG_PYTEST.replace('setup.cfg', str(inifile))) break except KeyError: inicfg = None diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 92ec029d488..77e0e389351 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -48,6 +48,15 @@ def test_pytest_setup_cfg_deprecated(testdir): result.stdout.fnmatch_lines(['*pytest*section in setup.cfg files is deprecated*use*tool:pytest*instead*']) +def test_pytest_custom_cfg_deprecated(testdir): + testdir.makefile('.cfg', custom=''' + [pytest] + addopts = --verbose + ''') + result = testdir.runpytest("-c", "custom.cfg") + result.stdout.fnmatch_lines(['*pytest*section in custom.cfg files is deprecated*use*tool:pytest*instead*']) + + def test_str_args_deprecated(tmpdir, testdir): """Deprecate passing strings to pytest.main(). Scheduled for removal in pytest-4.0.""" from _pytest.main import EXIT_NOTESTSCOLLECTED From a506052d12e28562f92bf6c07fac58c6c593e9aa Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Fri, 2 Mar 2018 10:54:31 +0300 Subject: [PATCH 072/107] #3268 Added changelog file --- changelog/3268.trivial | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3268.trivial diff --git a/changelog/3268.trivial b/changelog/3268.trivial new file mode 100644 index 00000000000..1cfb3ff10b0 --- /dev/null +++ b/changelog/3268.trivial @@ -0,0 +1 @@ +Added warning when ``[pytest]`` section is used in a ``.cfg`` file passed with ``-c`` From f501d0021c3a86a46da928cf0fc3d806fd5ac03d Mon Sep 17 00:00:00 2001 From: feuillemorte Date: Fri, 2 Mar 2018 11:28:30 +0300 Subject: [PATCH 073/107] #3268 Fix warning variable --- _pytest/config.py | 8 ++++---- _pytest/deprecated.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index 54efdde1f74..908c5bf5a5f 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -1251,7 +1251,7 @@ def getcfg(args, warnfunc=None): This parameter should be removed when pytest adopts standard deprecation warnings (#1804). """ - from _pytest.deprecated import SETUP_CFG_PYTEST + from _pytest.deprecated import CFG_PYTEST_SECTION inibasenames = ["pytest.ini", "tox.ini", "setup.cfg"] args = [x for x in args if not str(x).startswith("-")] if not args: @@ -1265,7 +1265,7 @@ def getcfg(args, warnfunc=None): iniconfig = py.iniconfig.IniConfig(p) if 'pytest' in iniconfig.sections: if inibasename == 'setup.cfg' and warnfunc: - warnfunc('C1', SETUP_CFG_PYTEST) + warnfunc('C1', CFG_PYTEST_SECTION.format(filename=inibasename)) return base, p, iniconfig['pytest'] if inibasename == 'setup.cfg' and 'tool:pytest' in iniconfig.sections: return base, p, iniconfig['tool:pytest'] @@ -1335,8 +1335,8 @@ def determine_setup(inifile, args, warnfunc=None, rootdir_cmd_arg=None): try: inicfg = iniconfig[section] if is_cfg_file and section == 'pytest' and warnfunc: - from _pytest.deprecated import SETUP_CFG_PYTEST - warnfunc('C1', SETUP_CFG_PYTEST.replace('setup.cfg', str(inifile))) + from _pytest.deprecated import CFG_PYTEST_SECTION + warnfunc('C1', CFG_PYTEST_SECTION.format(filename=str(inifile))) break except KeyError: inicfg = None diff --git a/_pytest/deprecated.py b/_pytest/deprecated.py index aa1235013ba..1eae354b391 100644 --- a/_pytest/deprecated.py +++ b/_pytest/deprecated.py @@ -22,7 +22,7 @@ class RemovedInPytest4Warning(DeprecationWarning): 'and scheduled to be removed in pytest 4.0. ' 'Please remove the prefix and use the @pytest.fixture decorator instead.') -SETUP_CFG_PYTEST = '[pytest] section in setup.cfg files is deprecated, use [tool:pytest] instead.' +CFG_PYTEST_SECTION = '[pytest] section in {filename} files is deprecated, use [tool:pytest] instead.' GETFUNCARGVALUE = "use of getfuncargvalue is deprecated, use getfixturevalue" From 25399da9047d044174f090f1315f9a4244b91135 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sun, 4 Mar 2018 10:59:46 -0300 Subject: [PATCH 074/107] Reintroduce more_itertools dependency Reintroduce more_itertools that was added in #3265, now in the features branch --- _pytest/python_api.py | 15 ++++++--------- changelog/3265.trivial.rst | 1 + setup.py | 1 + 3 files changed, 8 insertions(+), 9 deletions(-) create mode 100644 changelog/3265.trivial.rst diff --git a/_pytest/python_api.py b/_pytest/python_api.py index 5413714497f..3dce7f6b40c 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -2,7 +2,8 @@ import sys import py -from six.moves import zip +from six.moves import zip, filterfalse +from more_itertools.more import always_iterable from _pytest.compat import isclass from _pytest.outcomes import fail @@ -566,14 +567,10 @@ def raises(expected_exception, *args, **kwargs): """ __tracebackhide__ = True - msg = ("exceptions must be old-style classes or" - " derived from BaseException, not %s") - if isinstance(expected_exception, tuple): - for exc in expected_exception: - if not isclass(exc): - raise TypeError(msg % type(exc)) - elif not isclass(expected_exception): - raise TypeError(msg % type(expected_exception)) + for exc in filterfalse(isclass, always_iterable(expected_exception)): + msg = ("exceptions must be old-style classes or" + " derived from BaseException, not %s") + raise TypeError(msg % type(exc)) message = "DID NOT RAISE {0}".format(expected_exception) match_expr = None diff --git a/changelog/3265.trivial.rst b/changelog/3265.trivial.rst new file mode 100644 index 00000000000..b4ad22ecfe6 --- /dev/null +++ b/changelog/3265.trivial.rst @@ -0,0 +1 @@ +``pytest`` now depends on the `more_itertools `_ package. diff --git a/setup.py b/setup.py index 78b3ebc5e37..1cbabd72e41 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ def main(): 'six>=1.10.0', 'setuptools', 'attrs>=17.4.0', + 'more_itertools>=4.0.0', ] # if _PYTEST_SETUP_SKIP_PLUGGY_DEP is set, skip installing pluggy; # used by tox.ini to test with pluggy master From fbf01bd31a49e41b0fb2a1e3f874c5c1bfa433c9 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 6 Mar 2018 10:29:26 +0100 Subject: [PATCH 075/107] enhance skip except clause by directly using the Skipped exception --- _pytest/config.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index 908c5bf5a5f..fe6e324a5ce 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -11,6 +11,8 @@ # DON't import pytest here because it causes import cycle troubles import sys import os +from _pytest.outcomes import Skipped + import _pytest._code import _pytest.hookspec # the extension point definitions import _pytest.assertion @@ -435,10 +437,7 @@ def import_plugin(self, modname): six.reraise(new_exc_type, new_exc, sys.exc_info()[2]) - except Exception as e: - import pytest - if not hasattr(pytest, 'skip') or not isinstance(e, pytest.skip.Exception): - raise + except Skipped as e: self._warn("skipped plugin %r: %s" % ((modname, e.msg))) else: mod = sys.modules[importspec] From c4430e435476f19b6c46557253aa1bdcf9c2cef5 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 6 Mar 2018 10:32:41 +0100 Subject: [PATCH 076/107] extract _warn_about_missing_assertion into freestanding function --- _pytest/config.py | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/_pytest/config.py b/_pytest/config.py index fe6e324a5ce..cdd996896fc 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -54,7 +54,7 @@ def main(args=None, plugins=None): tw = py.io.TerminalWriter(sys.stderr) for line in traceback.format_exception(*e.excinfo): tw.line(line.rstrip(), red=True) - tw.line("ERROR: could not load %s\n" % (e.path), red=True) + tw.line("ERROR: could not load %s\n" % (e.path,), red=True) return 4 else: try: @@ -1016,7 +1016,7 @@ def _consider_importhook(self, args): mode = 'plain' else: self._mark_plugins_for_rewrite(hook) - self._warn_about_missing_assertion(mode) + _warn_about_missing_assertion(mode) def _mark_plugins_for_rewrite(self, hook): """ @@ -1043,23 +1043,6 @@ def _mark_plugins_for_rewrite(self, hook): for name in _iter_rewritable_modules(package_files): hook.mark_rewrite(name) - def _warn_about_missing_assertion(self, mode): - try: - assert False - except AssertionError: - pass - else: - if mode == 'plain': - sys.stderr.write("WARNING: ASSERTIONS ARE NOT EXECUTED" - " and FAILING TESTS WILL PASS. Are you" - " using python -O?") - else: - sys.stderr.write("WARNING: assertions not in test modules or" - " plugins will be ignored" - " because assert statements are not executed " - "by the underlying Python interpreter " - "(are you using python -O?)\n") - def _preparse(self, args, addopts=True): if addopts: args[:] = shlex.split(os.environ.get('PYTEST_ADDOPTS', '')) + args @@ -1233,6 +1216,29 @@ def getvalueorskip(self, name, path=None): return self.getoption(name, skip=True) +def _assertion_supported(): + try: + assert False + except AssertionError: + return True + else: + return False + + +def _warn_about_missing_assertion(mode): + if not _assertion_supported(): + if mode == 'plain': + sys.stderr.write("WARNING: ASSERTIONS ARE NOT EXECUTED" + " and FAILING TESTS WILL PASS. Are you" + " using python -O?") + else: + sys.stderr.write("WARNING: assertions not in test modules or" + " plugins will be ignored" + " because assert statements are not executed " + "by the underlying Python interpreter " + "(are you using python -O?)\n") + + def exists(path, ignore=EnvironmentError): try: return path.check() From 09ce84e64e153a5a23c59fd89dbe2302b73fc422 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 10:21:42 +0100 Subject: [PATCH 077/107] remove unneeded commas --- _pytest/terminal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/_pytest/terminal.py b/_pytest/terminal.py index b84b2414c7b..15492ad4bca 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -22,9 +22,9 @@ def pytest_addoption(parser): group = parser.getgroup("terminal reporting", "reporting", after="general") group._addoption('-v', '--verbose', action="count", - dest="verbose", default=0, help="increase verbosity."), + dest="verbose", default=0, help="increase verbosity.") group._addoption('-q', '--quiet', action="count", - dest="quiet", default=0, help="decrease verbosity."), + dest="quiet", default=0, help="decrease verbosity.") group._addoption('-r', action="store", dest="reportchars", default='', metavar="chars", help="show extra test summary info as specified by chars (f)ailed, " From c9b9d796e6d5e400035e255f342ebcb05b830ec1 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 10:22:43 +0100 Subject: [PATCH 078/107] remove dead code --- _pytest/nodes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/_pytest/nodes.py b/_pytest/nodes.py index 7d802004fce..e076d5747c5 100644 --- a/_pytest/nodes.py +++ b/_pytest/nodes.py @@ -227,7 +227,6 @@ def get_marker(self, name): def listextrakeywords(self): """ Return a set of all extra keywords in self and any parents.""" extra_keywords = set() - item = self for item in self.listchain(): extra_keywords.update(item.extra_keyword_matches) return extra_keywords From 45b6b7df921a6ea7c3b0aad9a9c192ade6b48538 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 10:23:18 +0100 Subject: [PATCH 079/107] move nodekeywords to the mark structures --- _pytest/mark/structures.py | 38 ++++++++++++++++++++++++++++++++++++- _pytest/nodes.py | 39 +------------------------------------- 2 files changed, 38 insertions(+), 39 deletions(-) diff --git a/_pytest/mark/structures.py b/_pytest/mark/structures.py index 5550dc5462d..bfe9efc7829 100644 --- a/_pytest/mark/structures.py +++ b/_pytest/mark/structures.py @@ -1,4 +1,4 @@ -from collections import namedtuple +from collections import namedtuple, MutableMapping as MappingMixin import warnings from operator import attrgetter import inspect @@ -328,3 +328,39 @@ def _check(self, name): MARK_GEN = MarkGenerator() + + +class NodeKeywords(MappingMixin): + def __init__(self, node): + self.node = node + self.parent = node.parent + self._markers = {node.name: True} + + def __getitem__(self, key): + try: + return self._markers[key] + except KeyError: + if self.parent is None: + raise + return self.parent.keywords[key] + + def __setitem__(self, key, value): + self._markers[key] = value + + def __delitem__(self, key): + raise ValueError("cannot delete key in keywords dict") + + def __iter__(self): + seen = set(self._markers) + if self.parent is not None: + seen.update(self.parent.keywords) + return iter(seen) + + def __len__(self): + return len(self.__iter__()) + + def keys(self): + return list(self) + + def __repr__(self): + return "" % (self.node, ) \ No newline at end of file diff --git a/_pytest/nodes.py b/_pytest/nodes.py index e076d5747c5..9f51fc884ad 100644 --- a/_pytest/nodes.py +++ b/_pytest/nodes.py @@ -1,5 +1,4 @@ from __future__ import absolute_import, division, print_function -from collections import MutableMapping as MappingMixin import os import six @@ -7,7 +6,7 @@ import attr import _pytest - +from _pytest.mark.structures import NodeKeywords SEP = "/" @@ -66,42 +65,6 @@ def __get__(self, obj, owner): return getattr(__import__('pytest'), self.name) -class NodeKeywords(MappingMixin): - def __init__(self, node): - self.node = node - self.parent = node.parent - self._markers = {node.name: True} - - def __getitem__(self, key): - try: - return self._markers[key] - except KeyError: - if self.parent is None: - raise - return self.parent.keywords[key] - - def __setitem__(self, key, value): - self._markers[key] = value - - def __delitem__(self, key): - raise ValueError("cannot delete key in keywords dict") - - def __iter__(self): - seen = set(self._markers) - if self.parent is not None: - seen.update(self.parent.keywords) - return iter(seen) - - def __len__(self): - return len(self.__iter__()) - - def keys(self): - return list(self) - - def __repr__(self): - return "" % (self.node, ) - - class Node(object): """ base class for Collector and Item the test collection tree. Collector subclasses have children, Items are terminal nodes.""" From 5e5935759e8926f79279e4a3a92546a4cfc1bf10 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 10:52:59 +0100 Subject: [PATCH 080/107] make nodeids precalculated, there is no sane reason to commpute lazyly --- _pytest/main.py | 5 +--- _pytest/nodes.py | 54 +++++++++++++++++++++------------------ testing/test_resultlog.py | 2 +- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/_pytest/main.py b/_pytest/main.py index 35994517549..9b59e03a246 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -300,7 +300,7 @@ class Session(nodes.FSCollector): def __init__(self, config): nodes.FSCollector.__init__( self, config.rootdir, parent=None, - config=config, session=self) + config=config, session=self, nodeid="") self.testsfailed = 0 self.testscollected = 0 self.shouldstop = False @@ -311,9 +311,6 @@ def __init__(self, config): self.config.pluginmanager.register(self, name="session") - def _makeid(self): - return "" - @hookimpl(tryfirst=True) def pytest_collectstart(self): if self.shouldfail: diff --git a/_pytest/nodes.py b/_pytest/nodes.py index 9f51fc884ad..97f4da6028b 100644 --- a/_pytest/nodes.py +++ b/_pytest/nodes.py @@ -6,6 +6,8 @@ import attr import _pytest +import _pytest._code + from _pytest.mark.structures import NodeKeywords SEP = "/" @@ -69,7 +71,7 @@ class Node(object): """ base class for Collector and Item the test collection tree. Collector subclasses have children, Items are terminal nodes.""" - def __init__(self, name, parent=None, config=None, session=None): + def __init__(self, name, parent=None, config=None, session=None, fspath=None, nodeid=None): #: a unique name within the scope of the parent node self.name = name @@ -83,7 +85,7 @@ def __init__(self, name, parent=None, config=None, session=None): self.session = session or parent.session #: filesystem path where this node was collected from (can be None) - self.fspath = getattr(parent, 'fspath', None) + self.fspath = fspath or getattr(parent, 'fspath', None) #: keywords/markers collected from all scopes self.keywords = NodeKeywords(self) @@ -94,6 +96,12 @@ def __init__(self, name, parent=None, config=None, session=None): # used for storing artificial fixturedefs for direct parametrization self._name2pseudofixturedef = {} + if nodeid is not None: + self._nodeid = nodeid + else: + assert parent is not None + self._nodeid = self.parent.nodeid + "::" + self.name + @property def ihook(self): """ fspath sensitive hook proxy used to call pytest hooks""" @@ -137,14 +145,7 @@ def warn(self, code, message): @property def nodeid(self): """ a ::-separated string denoting its collection tree address. """ - try: - return self._nodeid - except AttributeError: - self._nodeid = x = self._makeid() - return x - - def _makeid(self): - return self.parent.nodeid + "::" + self.name + return self._nodeid def __hash__(self): return hash(self.nodeid) @@ -281,8 +282,14 @@ def _prunetraceback(self, excinfo): excinfo.traceback = ntraceback.filter() +def _check_initialpaths_for_relpath(session, fspath): + for initial_path in session._initialpaths: + if fspath.common(initial_path) == initial_path: + return fspath.relto(initial_path.dirname) + + class FSCollector(Collector): - def __init__(self, fspath, parent=None, config=None, session=None): + def __init__(self, fspath, parent=None, config=None, session=None, nodeid=None): fspath = py.path.local(fspath) # xxx only for test_resultlog.py? name = fspath.basename if parent is not None: @@ -290,22 +297,19 @@ def __init__(self, fspath, parent=None, config=None, session=None): if rel: name = rel name = name.replace(os.sep, SEP) - super(FSCollector, self).__init__(name, parent, config, session) self.fspath = fspath - def _check_initialpaths_for_relpath(self): - for initialpath in self.session._initialpaths: - if self.fspath.common(initialpath) == initialpath: - return self.fspath.relto(initialpath.dirname) + session = session or parent.session + + if nodeid is None: + nodeid = self.fspath.relto(session.config.rootdir) - def _makeid(self): - relpath = self.fspath.relto(self.config.rootdir) + if not nodeid: + nodeid = _check_initialpaths_for_relpath(session, fspath) + if os.sep != SEP: + nodeid = nodeid.replace(os.sep, SEP) - if not relpath: - relpath = self._check_initialpaths_for_relpath() - if os.sep != SEP: - relpath = relpath.replace(os.sep, SEP) - return relpath + super(FSCollector, self).__init__(name, parent, config, session, nodeid=nodeid, fspath=fspath) class File(FSCollector): @@ -318,8 +322,8 @@ class Item(Node): """ nextitem = None - def __init__(self, name, parent=None, config=None, session=None): - super(Item, self).__init__(name, parent, config, session) + def __init__(self, name, parent=None, config=None, session=None, nodeid=None): + super(Item, self).__init__(name, parent, config, session, nodeid=nodeid) self._report_sections = [] #: user properties is a list of tuples (name, value) that holds user diff --git a/testing/test_resultlog.py b/testing/test_resultlog.py index 45fed707892..b1760721c2f 100644 --- a/testing/test_resultlog.py +++ b/testing/test_resultlog.py @@ -13,7 +13,7 @@ def test_generic_path(testdir): from _pytest.main import Session config = testdir.parseconfig() session = Session(config) - p1 = Node('a', config=config, session=session) + p1 = Node('a', config=config, session=session, nodeid='a') # assert p1.fspath is None p2 = Node('B', parent=p1) p3 = Node('()', parent=p2) From 50e682d2db48a321a7860bf8068ce330491c1220 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 11:06:26 +0100 Subject: [PATCH 081/107] add changelog --- changelog/3291.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3291.feature diff --git a/changelog/3291.feature b/changelog/3291.feature new file mode 100644 index 00000000000..b011193f2e2 --- /dev/null +++ b/changelog/3291.feature @@ -0,0 +1 @@ +move code around and ensure nodeids are computed eagerly From c67f45b7166dd74c3cc8e52bf5fb86b2098456c7 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 12:06:50 +0100 Subject: [PATCH 082/107] simplify a few imports in _pytest._code.source --- _pytest/_code/source.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/_pytest/_code/source.py b/_pytest/_code/source.py index 409961d9aeb..d7b86edbf0b 100644 --- a/_pytest/_code/source.py +++ b/_pytest/_code/source.py @@ -131,13 +131,7 @@ def isparseable(self, deindent=True): """ return True if source is parseable, heuristically deindenting it by default. """ - try: - import parser - except ImportError: - def syntax_checker(x): - return compile(x, 'asd', 'exec') - else: - syntax_checker = parser.suite + from parser import suite as syntax_checker if deindent: source = str(self.deindent()) @@ -219,9 +213,9 @@ def getfslineno(obj): """ Return source location (path, lineno) for the given object. If the source cannot be determined return ("", -1) """ - import _pytest._code + from .code import Code try: - code = _pytest._code.Code(obj) + code = Code(obj) except TypeError: try: fn = inspect.getsourcefile(obj) or inspect.getfile(obj) @@ -259,8 +253,8 @@ def findsource(obj): def getsource(obj, **kwargs): - import _pytest._code - obj = _pytest._code.getrawcode(obj) + from .code import getrawcode + obj = getrawcode(obj) try: strsrc = inspect.getsource(obj) except IndentationError: From 74884b190133c2fbf374ef206233ad045a622a89 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 14:21:56 +0100 Subject: [PATCH 083/107] turn FormattedExcinfo into a attrs class --- _pytest/_code/code.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/_pytest/_code/code.py b/_pytest/_code/code.py index 84627a435f4..76e1437741a 100644 --- a/_pytest/_code/code.py +++ b/_pytest/_code/code.py @@ -3,6 +3,8 @@ import sys import traceback from inspect import CO_VARARGS, CO_VARKEYWORDS + +import attr import re from weakref import ref from _pytest.compat import _PY2, _PY3, PY35, safe_str @@ -458,19 +460,19 @@ def match(self, regexp): return True +@attr.s class FormattedExcinfo(object): """ presenting information about failing Functions and Generators. """ # for traceback entries flow_marker = ">" fail_marker = "E" - def __init__(self, showlocals=False, style="long", abspath=True, tbfilter=True, funcargs=False): - self.showlocals = showlocals - self.style = style - self.tbfilter = tbfilter - self.funcargs = funcargs - self.abspath = abspath - self.astcache = {} + showlocals = attr.ib(default=False) + style = attr.ib(default="long") + abspath = attr.ib(default=True) + tbfilter = attr.ib(default=True) + funcargs = attr.ib(default=False) + astcache = attr.ib(default=attr.Factory(dict), init=False, repr=False) def _getindent(self, source): # figure out indent for given source From 2e5337f5e3bef0b197947c19d602f5e7b8ad9802 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 14:39:54 +0100 Subject: [PATCH 084/107] makr strutures: lint fixings, removal of shadowing --- _pytest/mark/structures.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/_pytest/mark/structures.py b/_pytest/mark/structures.py index bfe9efc7829..c5697298066 100644 --- a/_pytest/mark/structures.py +++ b/_pytest/mark/structures.py @@ -27,7 +27,7 @@ def istestfunc(func): getattr(func, "__name__", "") != "" -def get_empty_parameterset_mark(config, argnames, function): +def get_empty_parameterset_mark(config, argnames, func): requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION) if requested_mark in ('', None, 'skip'): mark = MARK_GEN.skip @@ -35,9 +35,9 @@ def get_empty_parameterset_mark(config, argnames, function): mark = MARK_GEN.xfail(run=False) else: raise LookupError(requested_mark) - fs, lineno = getfslineno(function) + fs, lineno = getfslineno(func) reason = "got empty parameter set %r, function %s at %s:%d" % ( - argnames, function.__name__, fs, lineno) + argnames, func.__name__, fs, lineno) return mark(reason=reason) @@ -53,8 +53,8 @@ def param(cls, *values, **kw): def param_extract_id(id=None): return id - id = param_extract_id(**kw) - return cls(values, marks, id) + id_ = param_extract_id(**kw) + return cls(values, marks, id_) @classmethod def extract_from(cls, parameterset, legacy_force_tuple=False): @@ -90,7 +90,7 @@ def extract_from(cls, parameterset, legacy_force_tuple=False): return cls(argval, marks=newmarks, id=None) @classmethod - def _for_parametrize(cls, argnames, argvalues, function, config): + def _for_parametrize(cls, argnames, argvalues, func, config): if not isinstance(argnames, (tuple, list)): argnames = [x.strip() for x in argnames.split(",") if x.strip()] force_tuple = len(argnames) == 1 @@ -102,7 +102,7 @@ def _for_parametrize(cls, argnames, argvalues, function, config): del argvalues if not parameters: - mark = get_empty_parameterset_mark(config, argnames, function) + mark = get_empty_parameterset_mark(config, argnames, func) parameters.append(ParameterSet( values=(NOTSET,) * len(argnames), marks=[mark], @@ -351,16 +351,17 @@ def __delitem__(self, key): raise ValueError("cannot delete key in keywords dict") def __iter__(self): + seen = self._seen() + return iter(seen) + + def _seen(self): seen = set(self._markers) if self.parent is not None: seen.update(self.parent.keywords) - return iter(seen) + return seen def __len__(self): - return len(self.__iter__()) - - def keys(self): - return list(self) + return len(self._seen()) def __repr__(self): - return "" % (self.node, ) \ No newline at end of file + return "" % (self.node, ) From a406ca14d6aff396eaa545ca1e44e0ad5801a403 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 14:48:28 +0100 Subject: [PATCH 085/107] remove getstatementrange_old - its documented for python <= 2.4 --- _pytest/_code/source.py | 42 ++--------------------------------------- 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/_pytest/_code/source.py b/_pytest/_code/source.py index d7b86edbf0b..7972b2908dd 100644 --- a/_pytest/_code/source.py +++ b/_pytest/_code/source.py @@ -332,11 +332,8 @@ def get_statement_startend2(lineno, node): def getstatementrange_ast(lineno, source, assertion=False, astnode=None): if astnode is None: content = str(source) - try: - astnode = compile(content, "source", "exec", 1024) # 1024 for AST - except ValueError: - start, end = getstatementrange_old(lineno, source, assertion) - return None, start, end + astnode = compile(content, "source", "exec", 1024) # 1024 for AST + start, end = get_statement_startend2(lineno, astnode) # we need to correct the end: # - ast-parsing strips comments @@ -368,38 +365,3 @@ def getstatementrange_ast(lineno, source, assertion=False, astnode=None): else: break return astnode, start, end - - -def getstatementrange_old(lineno, source, assertion=False): - """ return (start, end) tuple which spans the minimal - statement region which containing the given lineno. - raise an IndexError if no such statementrange can be found. - """ - # XXX this logic is only used on python2.4 and below - # 1. find the start of the statement - from codeop import compile_command - for start in range(lineno, -1, -1): - if assertion: - line = source.lines[start] - # the following lines are not fully tested, change with care - if 'super' in line and 'self' in line and '__init__' in line: - raise IndexError("likely a subclass") - if "assert" not in line and "raise" not in line: - continue - trylines = source.lines[start:lineno + 1] - # quick hack to prepare parsing an indented line with - # compile_command() (which errors on "return" outside defs) - trylines.insert(0, 'def xxx():') - trysource = '\n '.join(trylines) - # ^ space here - try: - compile_command(trysource) - except (SyntaxError, OverflowError, ValueError): - continue - - # 2. find the end of the statement - for end in range(lineno + 1, len(source) + 1): - trysource = source[start:end] - if trysource.isparseable(): - return start, end - raise SyntaxError("no valid source range around line %d " % (lineno,)) From 3284d575e8f5fe0e7e3f1bf95516ca975fa83336 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 14:58:11 +0100 Subject: [PATCH 086/107] readline generator no longer needs to yield empty strings --- _pytest/_code/source.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/_pytest/_code/source.py b/_pytest/_code/source.py index 7972b2908dd..ddc41dc89c9 100644 --- a/_pytest/_code/source.py +++ b/_pytest/_code/source.py @@ -280,8 +280,6 @@ def deindent(lines, offset=None): def readline_generator(lines): for line in lines: yield line + '\n' - while True: - yield '' it = readline_generator(lines) From 2fe56b97c9ab5c547d4a8498f93526854151ed5c Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 15:03:57 +0100 Subject: [PATCH 087/107] remove unused assertion parameter in source and minor cleanups --- _pytest/_code/source.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/_pytest/_code/source.py b/_pytest/_code/source.py index ddc41dc89c9..304a7528990 100644 --- a/_pytest/_code/source.py +++ b/_pytest/_code/source.py @@ -98,14 +98,14 @@ def indent(self, indent=' ' * 4): newsource.lines = [(indent + line) for line in self.lines] return newsource - def getstatement(self, lineno, assertion=False): + def getstatement(self, lineno): """ return Source statement which contains the given linenumber (counted from 0). """ - start, end = self.getstatementrange(lineno, assertion) + start, end = self.getstatementrange(lineno) return self[start:end] - def getstatementrange(self, lineno, assertion=False): + def getstatementrange(self, lineno): """ return (start, end) tuple which spans the minimal statement region which containing the given lineno. """ @@ -310,9 +310,9 @@ def get_statement_startend2(lineno, node): # AST's line numbers start indexing at 1 values = [] for x in ast.walk(node): - if isinstance(x, ast.stmt) or isinstance(x, ast.ExceptHandler): + if isinstance(x, (ast.stmt, ast.ExceptHandler)): values.append(x.lineno - 1) - for name in "finalbody", "orelse": + for name in ("finalbody", "orelse"): val = getattr(x, name, None) if val: # treat the finally/orelse part as its own statement From 543bac925ad749680f44a0fe5b29517c5751a7ff Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Fri, 9 Mar 2018 16:50:46 +0100 Subject: [PATCH 088/107] fix if-chain in _code.source --- _pytest/_code/source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/_pytest/_code/source.py b/_pytest/_code/source.py index 304a7528990..ca2a11fb4d5 100644 --- a/_pytest/_code/source.py +++ b/_pytest/_code/source.py @@ -26,7 +26,7 @@ def __init__(self, *parts, **kwargs): for part in parts: if not part: partlines = [] - if isinstance(part, Source): + elif isinstance(part, Source): partlines = part.lines elif isinstance(part, (tuple, list)): partlines = [x.rstrip("\n") for x in part] @@ -239,7 +239,6 @@ def getfslineno(obj): # helper functions # - def findsource(obj): try: sourcelines, lineno = inspect.findsource(obj) From 5f9bc557ea7be9e38badf098890efbdc571ff944 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 9 Mar 2018 17:44:39 -0300 Subject: [PATCH 089/107] Fix linting --- _pytest/_code/source.py | 1 + 1 file changed, 1 insertion(+) diff --git a/_pytest/_code/source.py b/_pytest/_code/source.py index ca2a11fb4d5..cb5e13f05a3 100644 --- a/_pytest/_code/source.py +++ b/_pytest/_code/source.py @@ -239,6 +239,7 @@ def getfslineno(obj): # helper functions # + def findsource(obj): try: sourcelines, lineno = inspect.findsource(obj) From d2dbbd4caabe21b2ef088cae4e06222d590ec81e Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Fri, 9 Mar 2018 17:46:44 -0300 Subject: [PATCH 090/107] Add CHANGELOG entry --- changelog/3292.trivial.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3292.trivial.rst diff --git a/changelog/3292.trivial.rst b/changelog/3292.trivial.rst new file mode 100644 index 00000000000..0e60e343193 --- /dev/null +++ b/changelog/3292.trivial.rst @@ -0,0 +1 @@ +Internal refactoring of ``FormattedExcinfo`` to use ``attrs`` facilities and remove old support code for legacy Python versions. From 54b15f5826438a311c22c693bdb248ed5f379e97 Mon Sep 17 00:00:00 2001 From: Brian Maissy Date: Wed, 21 Feb 2018 01:27:24 +0200 Subject: [PATCH 091/107] deprecated pytest_plugins in non-top-level conftest --- _pytest/config.py | 6 ++++ _pytest/deprecated.py | 6 ++++ changelog/3084.removal | 1 + doc/en/plugins.rst | 6 ++++ doc/en/writing_plugins.rst | 12 +++++++ testing/deprecated_test.py | 67 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+) create mode 100644 changelog/3084.removal diff --git a/_pytest/config.py b/_pytest/config.py index cdd996896fc..b99b1bbcbb5 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -201,6 +201,8 @@ def __init__(self): # Config._consider_importhook will set a real object if required. self.rewrite_hook = _pytest.assertion.DummyRewriteHook() + # Used to know when we are importing conftests after the pytest_configure stage + self._configured = False def addhooks(self, module_or_class): """ @@ -276,6 +278,7 @@ def pytest_configure(self, config): config.addinivalue_line("markers", "trylast: mark a hook implementation function such that the " "plugin machinery will try to call it last/as late as possible.") + self._configured = True def _warn(self, message): kwargs = message if isinstance(message, dict) else { @@ -366,6 +369,9 @@ def _importconftest(self, conftestpath): _ensure_removed_sysmodule(conftestpath.purebasename) try: mod = conftestpath.pyimport() + if hasattr(mod, 'pytest_plugins') and self._configured: + from _pytest.deprecated import PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST + warnings.warn(PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST) except Exception: raise ConftestImportFailure(conftestpath, sys.exc_info()) diff --git a/_pytest/deprecated.py b/_pytest/deprecated.py index 1eae354b391..a0eec0e7df6 100644 --- a/_pytest/deprecated.py +++ b/_pytest/deprecated.py @@ -56,3 +56,9 @@ class RemovedInPytest4Warning(DeprecationWarning): "Metafunc.addcall is deprecated and scheduled to be removed in pytest 4.0.\n" "Please use Metafunc.parametrize instead." ) + +PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST = RemovedInPytest4Warning( + "Defining pytest_plugins in a non-top-level conftest is deprecated, " + "because it affects the entire directory tree in a non-explicit way.\n" + "Please move it to the top level conftest file instead." +) diff --git a/changelog/3084.removal b/changelog/3084.removal new file mode 100644 index 00000000000..52bf7ed91df --- /dev/null +++ b/changelog/3084.removal @@ -0,0 +1 @@ +Defining ``pytest_plugins`` is now deprecated in non-top-level conftest.py files, because they "leak" to the entire directory tree. diff --git a/doc/en/plugins.rst b/doc/en/plugins.rst index 2a9fff81bab..a918f634d74 100644 --- a/doc/en/plugins.rst +++ b/doc/en/plugins.rst @@ -79,6 +79,12 @@ will be loaded as well. which will import the specified module as a ``pytest`` plugin. +.. note:: + Requiring plugins using a ``pytest_plugins`` variable in non-root + ``conftest.py`` files is deprecated. See + :ref:`full explanation ` + in the Writing plugins section. + .. _`findpluginname`: Finding out which plugins are active diff --git a/doc/en/writing_plugins.rst b/doc/en/writing_plugins.rst index d88bb8f15af..c4bfa092b21 100644 --- a/doc/en/writing_plugins.rst +++ b/doc/en/writing_plugins.rst @@ -254,6 +254,18 @@ application modules: if ``myapp.testsupport.myplugin`` also declares ``pytest_plugins``, the contents of the variable will also be loaded as plugins, and so on. +.. _`requiring plugins in non-noot conftests`: + +.. note:: + Requiring plugins using a ``pytest_plugins`` variable in non-root + ``conftest.py`` files is deprecated. + + This is important because ``conftest.py`` files implement per-directory + hook implementations, but once a plugin is imported, it will affect the + entire directory tree. In order to avoid confusion, defining + ``pytest_plugins`` in any ``conftest.py`` file which is not located in the + tests root directory is deprecated, and will raise a warning. + This mechanism makes it easy to share fixtures within applications or even external applications without the need to create external plugins using the ``setuptools``'s entry point technique. diff --git a/testing/deprecated_test.py b/testing/deprecated_test.py index 77e0e389351..cb66472c9d8 100644 --- a/testing/deprecated_test.py +++ b/testing/deprecated_test.py @@ -134,3 +134,70 @@ def test_func(pytestconfig): "*pytest-*log plugin has been merged into the core*", "*1 passed, 1 warnings*", ]) + + +def test_pytest_plugins_in_non_top_level_conftest_deprecated(testdir): + from _pytest.deprecated import PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST + subdirectory = testdir.tmpdir.join("subdirectory") + subdirectory.mkdir() + # create the inner conftest with makeconftest and then move it to the subdirectory + testdir.makeconftest(""" + pytest_plugins=['capture'] + """) + testdir.tmpdir.join("conftest.py").move(subdirectory.join("conftest.py")) + # make the top level conftest + testdir.makeconftest(""" + import warnings + warnings.filterwarnings('always', category=DeprecationWarning) + """) + testdir.makepyfile(""" + def test_func(): + pass + """) + res = testdir.runpytest_subprocess() + assert res.ret == 0 + res.stderr.fnmatch_lines('*' + str(PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST).splitlines()[0]) + + +def test_pytest_plugins_in_non_top_level_conftest_deprecated_no_top_level_conftest(testdir): + from _pytest.deprecated import PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST + subdirectory = testdir.tmpdir.join('subdirectory') + subdirectory.mkdir() + testdir.makeconftest(""" + import warnings + warnings.filterwarnings('always', category=DeprecationWarning) + pytest_plugins=['capture'] + """) + testdir.tmpdir.join("conftest.py").move(subdirectory.join("conftest.py")) + + testdir.makepyfile(""" + def test_func(): + pass + """) + + res = testdir.runpytest_subprocess() + assert res.ret == 0 + res.stderr.fnmatch_lines('*' + str(PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST).splitlines()[0]) + + +def test_pytest_plugins_in_non_top_level_conftest_deprecated_no_false_positives(testdir): + from _pytest.deprecated import PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST + subdirectory = testdir.tmpdir.join('subdirectory') + subdirectory.mkdir() + testdir.makeconftest(""" + pass + """) + testdir.tmpdir.join("conftest.py").move(subdirectory.join("conftest.py")) + + testdir.makeconftest(""" + import warnings + warnings.filterwarnings('always', category=DeprecationWarning) + pytest_plugins=['capture'] + """) + testdir.makepyfile(""" + def test_func(): + pass + """) + res = testdir.runpytest_subprocess() + assert res.ret == 0 + assert str(PYTEST_PLUGINS_FROM_NON_TOP_LEVEL_CONFTEST).splitlines()[0] not in res.stderr.str() From d2e533b8a3636f29d8999b90fed1072ac4b5ee1b Mon Sep 17 00:00:00 2001 From: Brian Maissy Date: Sat, 10 Mar 2018 22:45:45 +0200 Subject: [PATCH 092/107] implemented --last-failed-no-failures --- _pytest/cacheprovider.py | 47 +++++++++++++++++++++-------------- changelog/3139.feature | 1 + doc/en/cache.rst | 10 ++++++++ testing/test_cacheprovider.py | 30 ++++++++++++++++++++++ 4 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 changelog/3139.feature diff --git a/_pytest/cacheprovider.py b/_pytest/cacheprovider.py index 0ac1b81023c..717c061d473 100755 --- a/_pytest/cacheprovider.py +++ b/_pytest/cacheprovider.py @@ -112,11 +112,12 @@ def __init__(self, config): self.active = any(config.getoption(key) for key in active_keys) self.lastfailed = config.cache.get("cache/lastfailed", {}) self._previously_failed_count = None + self._no_failures_behavior = self.config.getoption('last_failed_no_failures') def pytest_report_collectionfinish(self): if self.active: if not self._previously_failed_count: - mode = "run all (no recorded failures)" + mode = "run {} (no recorded failures)".format(self._no_failures_behavior) else: noun = 'failure' if self._previously_failed_count == 1 else 'failures' suffix = " first" if self.config.getoption( @@ -144,24 +145,28 @@ def pytest_collectreport(self, report): self.lastfailed[report.nodeid] = True def pytest_collection_modifyitems(self, session, config, items): - if self.active and self.lastfailed: - previously_failed = [] - previously_passed = [] - for item in items: - if item.nodeid in self.lastfailed: - previously_failed.append(item) + if self.active: + if self.lastfailed: + previously_failed = [] + previously_passed = [] + for item in items: + if item.nodeid in self.lastfailed: + previously_failed.append(item) + else: + previously_passed.append(item) + self._previously_failed_count = len(previously_failed) + if not previously_failed: + # running a subset of all tests with recorded failures outside + # of the set of tests currently executing + return + if self.config.getoption("lf"): + items[:] = previously_failed + config.hook.pytest_deselected(items=previously_passed) else: - previously_passed.append(item) - self._previously_failed_count = len(previously_failed) - if not previously_failed: - # running a subset of all tests with recorded failures outside - # of the set of tests currently executing - return - if self.config.getoption("lf"): - items[:] = previously_failed - config.hook.pytest_deselected(items=previously_passed) - else: - items[:] = previously_failed + previously_passed + items[:] = previously_failed + previously_passed + elif self._no_failures_behavior == 'none': + config.hook.pytest_deselected(items=items) + items[:] = [] def pytest_sessionfinish(self, session): config = self.config @@ -230,6 +235,12 @@ def pytest_addoption(parser): parser.addini( "cache_dir", default='.pytest_cache', help="cache directory path.") + group.addoption( + '--lfnf', '--last-failed-no-failures', action='store', + dest='last_failed_no_failures', choices=('all', 'none'), default='all', + help='change the behavior when no test failed in the last run or no ' + 'information about the last failures was found in the cache' + ) def pytest_cmdline_main(config): diff --git a/changelog/3139.feature b/changelog/3139.feature new file mode 100644 index 00000000000..39ac0bb75c4 --- /dev/null +++ b/changelog/3139.feature @@ -0,0 +1 @@ +New ``--last-failed-no-failures`` command-line option that allows to specify the behavior of the cache plugin's ```--last-failed`` feature when no tests failed in the last run (or no cache was found): ``none`` or ``all`` (the default). diff --git a/doc/en/cache.rst b/doc/en/cache.rst index db72249f9c0..48093ded7dd 100644 --- a/doc/en/cache.rst +++ b/doc/en/cache.rst @@ -156,6 +156,16 @@ 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. +Behavior when no tests failed in the last run +--------------------------------------------- + +When no tests failed in the last run, or when no cached ``lastfailed`` data was +found, ``pytest`` can be configured either to run all of the tests or no tests, +using the ``--last-failed-no-failures`` option, which takes one of the following values:: + + pytest --last-failed-no-failures all # run all tests (default behavior) + pytest --last-failed-no-failures none # run no tests and exit + The new config.cache object -------------------------------- diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index 4fb08862a8c..51e45dd48c7 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -604,6 +604,36 @@ def test_foo_4(): result.stdout.fnmatch_lines('*4 passed*') assert self.get_cached_last_failed(testdir) == [] + def test_lastfailed_no_failures_behavior_all_passed(self, testdir): + testdir.makepyfile(""" + def test_1(): + assert True + def test_2(): + assert True + """) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["*2 passed*"]) + result = testdir.runpytest("--lf") + result.stdout.fnmatch_lines(["*2 passed*"]) + result = testdir.runpytest("--lf", "--lfnf", "all") + result.stdout.fnmatch_lines(["*2 passed*"]) + result = testdir.runpytest("--lf", "--lfnf", "none") + result.stdout.fnmatch_lines(["*2 desel*"]) + + def test_lastfailed_no_failures_behavior_empty_cache(self, testdir): + testdir.makepyfile(""" + def test_1(): + assert True + def test_2(): + assert False + """) + result = testdir.runpytest("--lf", "--cache-clear") + result.stdout.fnmatch_lines(["*1 failed*1 passed*"]) + result = testdir.runpytest("--lf", "--cache-clear", "--lfnf", "all") + result.stdout.fnmatch_lines(["*1 failed*1 passed*"]) + result = testdir.runpytest("--lf", "--cache-clear", "--lfnf", "none") + result.stdout.fnmatch_lines(["*2 desel*"]) + class TestNewFirst(object): def test_newfirst_usecase(self, testdir): From 0302622310d026aa10544049ebfdf964744715c2 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 12 Mar 2018 18:22:12 -0300 Subject: [PATCH 093/107] Improve CHANGELOG entry a bit --- changelog/3291.feature | 1 - changelog/3291.trivial.rst | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 changelog/3291.feature create mode 100644 changelog/3291.trivial.rst diff --git a/changelog/3291.feature b/changelog/3291.feature deleted file mode 100644 index b011193f2e2..00000000000 --- a/changelog/3291.feature +++ /dev/null @@ -1 +0,0 @@ -move code around and ensure nodeids are computed eagerly diff --git a/changelog/3291.trivial.rst b/changelog/3291.trivial.rst new file mode 100644 index 00000000000..a2e65c2d71a --- /dev/null +++ b/changelog/3291.trivial.rst @@ -0,0 +1 @@ +``nodeids`` can now be passed explicitly to ``FSCollector`` and ``Node`` constructors. From 8035f6ccedac686aa774ceaf6cc5e71c6407d472 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 12 Mar 2018 18:32:51 -0300 Subject: [PATCH 094/107] Fix typo in docs --- doc/en/plugins.rst | 2 +- doc/en/writing_plugins.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/en/plugins.rst b/doc/en/plugins.rst index a918f634d74..efe5fd277a1 100644 --- a/doc/en/plugins.rst +++ b/doc/en/plugins.rst @@ -82,7 +82,7 @@ which will import the specified module as a ``pytest`` plugin. .. note:: Requiring plugins using a ``pytest_plugins`` variable in non-root ``conftest.py`` files is deprecated. See - :ref:`full explanation ` + :ref:`full explanation ` in the Writing plugins section. .. _`findpluginname`: diff --git a/doc/en/writing_plugins.rst b/doc/en/writing_plugins.rst index c4bfa092b21..42068818c80 100644 --- a/doc/en/writing_plugins.rst +++ b/doc/en/writing_plugins.rst @@ -254,7 +254,7 @@ application modules: if ``myapp.testsupport.myplugin`` also declares ``pytest_plugins``, the contents of the variable will also be loaded as plugins, and so on. -.. _`requiring plugins in non-noot conftests`: +.. _`requiring plugins in non-root conftests`: .. note:: Requiring plugins using a ``pytest_plugins`` variable in non-root From 37a52607c2f496340caea6936c3e24e9863b3558 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 12 Mar 2018 11:38:44 +0100 Subject: [PATCH 095/107] unify cli verbosity handling based on https://github.com/pytest-dev/pytest/issues/3294#issuecomment-372190084 we really shouldnt have N options we post mortem hack together to determine verbosity this change starts by unifying the data, we still need to handle deprecation/removal of config.quiet --- _pytest/terminal.py | 42 +++++++++++++++++++++++++++++++++++++----- changelog/3296.feature | 1 + changelog/3296.trivial | 1 + 3 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 changelog/3296.feature create mode 100644 changelog/3296.trivial diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 15492ad4bca..7dc36e7d7d7 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -19,12 +19,45 @@ EXIT_USAGEERROR, EXIT_NOTESTSCOLLECTED +import argparse + + +class MoreQuietAction(argparse.Action): + """ + a modified copy of the argparse count action which counts down and updates + the legacy quiet attribute at the same time + + used to unify verbosity handling + """ + def __init__(self, + option_strings, + dest, + default=None, + required=False, + help=None): + super(MoreQuietAction, self).__init__( + option_strings=option_strings, + dest=dest, + nargs=0, + default=default, + required=required, + help=help) + + def __call__(self, parser, namespace, values, option_string=None): + new_count = getattr(namespace, self.dest, 0) - 1 + setattr(namespace, self.dest, new_count) + # todo Deprecate config.quiet + namespace.quiet = getattr(namespace, 'quiet', 0) + 1 + + def pytest_addoption(parser): group = parser.getgroup("terminal reporting", "reporting", after="general") - group._addoption('-v', '--verbose', action="count", - dest="verbose", default=0, help="increase verbosity.") - group._addoption('-q', '--quiet', action="count", - dest="quiet", default=0, help="decrease verbosity.") + group._addoption('-v', '--verbose', action="count", default=0, + dest="verbose", help="increase verbosity."), + group._addoption('-q', '--quiet', action=MoreQuietAction, default=0, + dest="verbose", help="decrease verbosity."), + group._addoption("--verbosity", dest='verbose', type=int, default=0, + help="set verbosity") group._addoption('-r', action="store", dest="reportchars", default='', metavar="chars", help="show extra test summary info as specified by chars (f)ailed, " @@ -61,7 +94,6 @@ def pytest_addoption(parser): def pytest_configure(config): - config.option.verbose -= config.option.quiet reporter = TerminalReporter(config, sys.stdout) config.pluginmanager.register(reporter, 'terminalreporter') if config.option.debug or config.option.traceconfig: diff --git a/changelog/3296.feature b/changelog/3296.feature new file mode 100644 index 00000000000..dde6b78eaf1 --- /dev/null +++ b/changelog/3296.feature @@ -0,0 +1 @@ +New ``--verbosity`` flag to set verbosity level explicitly. \ No newline at end of file diff --git a/changelog/3296.trivial b/changelog/3296.trivial new file mode 100644 index 00000000000..7b5b4e1b478 --- /dev/null +++ b/changelog/3296.trivial @@ -0,0 +1 @@ +Refactoring to unify how verbosity is handled internally. \ No newline at end of file From 87f200324506d09a819b66d3af06ac4c9777480f Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 13 Mar 2018 18:08:26 +0100 Subject: [PATCH 096/107] remove CmdOptions since we can use argparse.Namespace() --- _pytest/config.py | 24 ++++++------------------ changelog/3304.trivial | 1 + 2 files changed, 7 insertions(+), 18 deletions(-) create mode 100644 changelog/3304.trivial diff --git a/_pytest/config.py b/_pytest/config.py index cdd996896fc..63c7dc3bba2 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -5,7 +5,7 @@ import traceback import types import warnings - +import copy import six import py # DON't import pytest here because it causes import cycle troubles @@ -68,7 +68,7 @@ def main(args=None, plugins=None): return 4 -class cmdline(object): # compatibility namespace +class cmdline(object): # NOQA compatibility namespace main = staticmethod(main) @@ -845,19 +845,6 @@ def _ensure_removed_sysmodule(modname): pass -class CmdOptions(object): - """ holds cmdline options as attributes.""" - - def __init__(self, values=()): - self.__dict__.update(values) - - def __repr__(self): - return "" % (self.__dict__,) - - def copy(self): - return CmdOptions(self.__dict__) - - class Notset(object): def __repr__(self): return "" @@ -885,7 +872,7 @@ class Config(object): def __init__(self, pluginmanager): #: access to command line option as attributes. #: (deprecated), use :py:func:`getoption() <_pytest.config.Config.getoption>` instead - self.option = CmdOptions() + self.option = argparse.Namespace() _a = FILE_OR_DIR self._parser = Parser( usage="%%(prog)s [options] [%s] [%s] [...]" % (_a, _a), @@ -989,7 +976,7 @@ def pytest_load_initial_conftests(self, early_config): self.pluginmanager._set_initial_conftests(early_config.known_args_namespace) def _initini(self, args): - ns, unknown_args = self._parser.parse_known_and_unknown_args(args, namespace=self.option.copy()) + ns, unknown_args = self._parser.parse_known_and_unknown_args(args, namespace=copy.copy(self.option)) r = determine_setup(ns.inifilename, ns.file_or_dir + unknown_args, warnfunc=self.warn, rootdir_cmd_arg=ns.rootdir or None) self.rootdir, self.inifile, self.inicfg = r @@ -1054,7 +1041,8 @@ def _preparse(self, args, addopts=True): self.pluginmanager.consider_preparse(args) self.pluginmanager.load_setuptools_entrypoints('pytest11') self.pluginmanager.consider_env() - self.known_args_namespace = ns = self._parser.parse_known_args(args, namespace=self.option.copy()) + self.known_args_namespace = ns = self._parser.parse_known_args( + args, namespace=copy.copy(self.option)) if self.known_args_namespace.confcutdir is None and self.inifile: confcutdir = py.path.local(self.inifile).dirname self.known_args_namespace.confcutdir = confcutdir diff --git a/changelog/3304.trivial b/changelog/3304.trivial new file mode 100644 index 00000000000..6e66a1e13cf --- /dev/null +++ b/changelog/3304.trivial @@ -0,0 +1 @@ +Internal refactoring to better integrate with argparse. \ No newline at end of file From c34dde7a3f582ddb57560ea08cfe00b84be31e16 Mon Sep 17 00:00:00 2001 From: Tadeu Manoel Date: Wed, 14 Mar 2018 15:29:40 -0300 Subject: [PATCH 097/107] Add support for pytest.approx comparisons between array and scalar --- _pytest/python_api.py | 25 +++++++++++++++++++++---- changelog/3312.feature | 1 + testing/python/approx.py | 11 +++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 changelog/3312.feature diff --git a/_pytest/python_api.py b/_pytest/python_api.py index 3dce7f6b40c..4b428322ae0 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -31,6 +31,10 @@ class ApproxBase(object): or sequences of numbers. """ + # Tell numpy to use our `__eq__` operator instead of its when left side in a numpy array but right side is + # an instance of ApproxBase + __array_ufunc__ = None + def __init__(self, expected, rel=None, abs=None, nan_ok=False): self.expected = expected self.abs = abs @@ -89,7 +93,7 @@ def __eq__(self, actual): except: # noqa raise TypeError("cannot compare '{0}' to numpy.ndarray".format(actual)) - if actual.shape != self.expected.shape: + if not np.isscalar(self.expected) and actual.shape != self.expected.shape: return False return ApproxBase.__eq__(self, actual) @@ -100,8 +104,13 @@ def _yield_comparisons(self, actual): # We can be sure that `actual` is a numpy array, because it's # casted in `__eq__` before being passed to `ApproxBase.__eq__`, # which is the only method that calls this one. - for i in np.ndindex(self.expected.shape): - yield actual[i], self.expected[i] + + if np.isscalar(self.expected): + for i in np.ndindex(actual.shape): + yield actual[i], self.expected + else: + for i in np.ndindex(self.expected.shape): + yield actual[i], self.expected[i] class ApproxMapping(ApproxBase): @@ -189,6 +198,8 @@ def __eq__(self, actual): Return true if the given value is equal to the expected value within the pre-specified tolerance. """ + if _is_numpy_array(actual): + return actual == ApproxNumpy(self.expected, self.abs, self.rel, self.nan_ok) # Short-circuit exact equality. if actual == self.expected: @@ -308,12 +319,18 @@ def approx(expected, rel=None, abs=None, nan_ok=False): >>> {'a': 0.1 + 0.2, 'b': 0.2 + 0.4} == approx({'a': 0.3, 'b': 0.6}) True - And ``numpy`` arrays:: + ``numpy`` arrays:: >>> import numpy as np # doctest: +SKIP >>> np.array([0.1, 0.2]) + np.array([0.2, 0.4]) == approx(np.array([0.3, 0.6])) # doctest: +SKIP True + And for a ``numpy`` array against a scalar:: + + >>> import numpy as np # doctest: +SKIP + >>> np.array([0.1, 0.2]) + np.array([0.2, 0.1]) == approx(0.3) # doctest: +SKIP + True + By default, ``approx`` considers numbers within a relative tolerance of ``1e-6`` (i.e. one part in a million) of its expected value to be equal. This treatment would lead to surprising results if the expected value was diff --git a/changelog/3312.feature b/changelog/3312.feature new file mode 100644 index 00000000000..ffb4df8e965 --- /dev/null +++ b/changelog/3312.feature @@ -0,0 +1 @@ +``pytest.approx`` now accepts comparing a numpy array with a scalar. diff --git a/testing/python/approx.py b/testing/python/approx.py index 341e5fcffeb..b9d28aadb1e 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -391,3 +391,14 @@ def test_comparison_operator_type_error(self, op): """ with pytest.raises(TypeError): op(1, approx(1, rel=1e-6, abs=1e-12)) + + def test_numpy_array_with_scalar(self): + np = pytest.importorskip('numpy') + + actual = np.array([1 + 1e-7, 1 - 1e-8]) + expected = 1.0 + + assert actual == approx(expected, rel=5e-7, abs=0) + assert actual != approx(expected, rel=5e-8, abs=0) + assert approx(expected, rel=5e-7, abs=0) == actual + assert approx(expected, rel=5e-8, abs=0) != actual From 161d4e5fe4730f46e1fb803595198068b20c94c5 Mon Sep 17 00:00:00 2001 From: Tadeu Manoel Date: Wed, 14 Mar 2018 16:29:04 -0300 Subject: [PATCH 098/107] Add support for pytest.approx comparisons between scalar and array (inverted order) --- _pytest/python_api.py | 15 ++++++++++----- testing/python/approx.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index 4b428322ae0..af4d7764458 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -88,12 +88,14 @@ def __repr__(self): def __eq__(self, actual): import numpy as np - try: - actual = np.asarray(actual) - except: # noqa - raise TypeError("cannot compare '{0}' to numpy.ndarray".format(actual)) + if not np.isscalar(actual): + try: + actual = np.asarray(actual) + except: # noqa + raise TypeError("cannot compare '{0}' to numpy.ndarray".format(actual)) - if not np.isscalar(self.expected) and actual.shape != self.expected.shape: + if (not np.isscalar(self.expected) and not np.isscalar(actual) + and actual.shape != self.expected.shape): return False return ApproxBase.__eq__(self, actual) @@ -108,6 +110,9 @@ def _yield_comparisons(self, actual): if np.isscalar(self.expected): for i in np.ndindex(actual.shape): yield actual[i], self.expected + elif np.isscalar(actual): + for i in np.ndindex(self.expected.shape): + yield actual, self.expected[i] else: for i in np.ndindex(self.expected.shape): yield actual[i], self.expected[i] diff --git a/testing/python/approx.py b/testing/python/approx.py index b9d28aadb1e..9ca21bdf8e9 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -402,3 +402,14 @@ def test_numpy_array_with_scalar(self): assert actual != approx(expected, rel=5e-8, abs=0) assert approx(expected, rel=5e-7, abs=0) == actual assert approx(expected, rel=5e-8, abs=0) != actual + + def test_numpy_scalar_with_array(self): + np = pytest.importorskip('numpy') + + actual = 1.0 + expected = np.array([1 + 1e-7, 1 - 1e-8]) + + assert actual == approx(expected, rel=5e-7, abs=0) + assert actual != approx(expected, rel=5e-8, abs=0) + assert approx(expected, rel=5e-7, abs=0) == actual + assert approx(expected, rel=5e-8, abs=0) != actual From 97f9a8bfdf25e051fbe58b3d645afbc6d9141fbd Mon Sep 17 00:00:00 2001 From: Tadeu Manoel Date: Wed, 14 Mar 2018 17:10:35 -0300 Subject: [PATCH 099/107] Add fixes to make `numpy.approx` array-scalar comparisons work with older numpy versions --- _pytest/python_api.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index af4d7764458..aa847d64944 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -31,9 +31,9 @@ class ApproxBase(object): or sequences of numbers. """ - # Tell numpy to use our `__eq__` operator instead of its when left side in a numpy array but right side is - # an instance of ApproxBase + # Tell numpy to use our `__eq__` operator instead of its __array_ufunc__ = None + __array_priority__ = 100 def __init__(self, expected, rel=None, abs=None, nan_ok=False): self.expected = expected @@ -73,9 +73,6 @@ class ApproxNumpy(ApproxBase): Perform approximate comparisons for numpy arrays. """ - # Tell numpy to use our `__eq__` operator instead of its. - __array_priority__ = 100 - def __repr__(self): # It might be nice to rewrite this function to account for the # shape of the array... @@ -109,13 +106,13 @@ def _yield_comparisons(self, actual): if np.isscalar(self.expected): for i in np.ndindex(actual.shape): - yield actual[i], self.expected + yield np.asscalar(actual[i]), self.expected elif np.isscalar(actual): for i in np.ndindex(self.expected.shape): - yield actual, self.expected[i] + yield actual, np.asscalar(self.expected[i]) else: for i in np.ndindex(self.expected.shape): - yield actual[i], self.expected[i] + yield np.asscalar(actual[i]), np.asscalar(self.expected[i]) class ApproxMapping(ApproxBase): @@ -145,9 +142,6 @@ class ApproxSequence(ApproxBase): Perform approximate comparisons for sequences of numbers. """ - # Tell numpy to use our `__eq__` operator instead of its. - __array_priority__ = 100 - def __repr__(self): seq_type = type(self.expected) if seq_type not in (tuple, list, set): From 42c84f4f30725e42b4ba3e0aa7480549666e2f01 Mon Sep 17 00:00:00 2001 From: Tadeu Manoel Date: Thu, 15 Mar 2018 13:41:58 -0300 Subject: [PATCH 100/107] Add fixes to `numpy.approx` array-scalar comparisons (from PR suggestions) --- _pytest/python_api.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index aa847d64944..e2c83aeabd1 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -76,8 +76,10 @@ class ApproxNumpy(ApproxBase): def __repr__(self): # It might be nice to rewrite this function to account for the # shape of the array... + import numpy as np + return "approx({0!r})".format(list( - self._approx_scalar(x) for x in self.expected)) + self._approx_scalar(x) for x in np.asarray(self.expected))) if sys.version_info[0] == 2: __cmp__ = _cmp_raises_type_error @@ -100,9 +102,11 @@ def __eq__(self, actual): def _yield_comparisons(self, actual): import numpy as np - # We can be sure that `actual` is a numpy array, because it's - # casted in `__eq__` before being passed to `ApproxBase.__eq__`, - # which is the only method that calls this one. + # For both `actual` and `self.expected`, they can independently be + # either a `numpy.array` or a scalar (but both can't be scalar, + # in this case an `ApproxScalar` is used). + # They are treated in `__eq__` before being passed to + # `ApproxBase.__eq__`, which is the only method that calls this one. if np.isscalar(self.expected): for i in np.ndindex(actual.shape): From a754f00ae7e815786ef917654c4106c7f39dad69 Mon Sep 17 00:00:00 2001 From: Tadeu Manoel Date: Fri, 16 Mar 2018 09:01:18 -0300 Subject: [PATCH 101/107] Improve `numpy.approx` array-scalar comparisons So that `self.expected` in ApproxNumpy is always a numpy array. --- _pytest/python_api.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/_pytest/python_api.py b/_pytest/python_api.py index e2c83aeabd1..9de4dd2a8c0 100644 --- a/_pytest/python_api.py +++ b/_pytest/python_api.py @@ -87,14 +87,15 @@ def __repr__(self): def __eq__(self, actual): import numpy as np + # self.expected is supposed to always be an array here + if not np.isscalar(actual): try: actual = np.asarray(actual) except: # noqa raise TypeError("cannot compare '{0}' to numpy.ndarray".format(actual)) - if (not np.isscalar(self.expected) and not np.isscalar(actual) - and actual.shape != self.expected.shape): + if not np.isscalar(actual) and actual.shape != self.expected.shape: return False return ApproxBase.__eq__(self, actual) @@ -102,16 +103,11 @@ def __eq__(self, actual): def _yield_comparisons(self, actual): import numpy as np - # For both `actual` and `self.expected`, they can independently be - # either a `numpy.array` or a scalar (but both can't be scalar, - # in this case an `ApproxScalar` is used). - # They are treated in `__eq__` before being passed to - # `ApproxBase.__eq__`, which is the only method that calls this one. + # `actual` can either be a numpy array or a scalar, it is treated in + # `__eq__` before being passed to `ApproxBase.__eq__`, which is the + # only method that calls this one. - if np.isscalar(self.expected): - for i in np.ndindex(actual.shape): - yield np.asscalar(actual[i]), self.expected - elif np.isscalar(actual): + if np.isscalar(actual): for i in np.ndindex(self.expected.shape): yield actual, np.asscalar(self.expected[i]) else: @@ -202,7 +198,7 @@ def __eq__(self, actual): the pre-specified tolerance. """ if _is_numpy_array(actual): - return actual == ApproxNumpy(self.expected, self.abs, self.rel, self.nan_ok) + return ApproxNumpy(actual, self.abs, self.rel, self.nan_ok) == self.expected # Short-circuit exact equality. if actual == self.expected: From 9e24b09a9f96908534e7bfb313df4a2c136b5094 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Mar 2018 20:59:10 -0300 Subject: [PATCH 102/107] Use re_match_lines in test_class_ordering "[1-a]" works fine using fnmatch_lines, but "[a-1]" breaks horribly inside `re`. --- testing/python/fixture.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/testing/python/fixture.py b/testing/python/fixture.py index 8638e361a6d..d1098799d91 100644 --- a/testing/python/fixture.py +++ b/testing/python/fixture.py @@ -2281,19 +2281,19 @@ def test_3(self): pass """) result = testdir.runpytest("-vs") - result.stdout.fnmatch_lines(""" - test_class_ordering.py::TestClass2::test_1[1-a] PASSED - test_class_ordering.py::TestClass2::test_1[2-a] PASSED - test_class_ordering.py::TestClass2::test_2[1-a] PASSED - test_class_ordering.py::TestClass2::test_2[2-a] PASSED - test_class_ordering.py::TestClass2::test_1[1-b] PASSED - test_class_ordering.py::TestClass2::test_1[2-b] PASSED - test_class_ordering.py::TestClass2::test_2[1-b] PASSED - test_class_ordering.py::TestClass2::test_2[2-b] PASSED - test_class_ordering.py::TestClass::test_3[1-a] PASSED - test_class_ordering.py::TestClass::test_3[2-a] PASSED - test_class_ordering.py::TestClass::test_3[1-b] PASSED - test_class_ordering.py::TestClass::test_3[2-b] PASSED + result.stdout.re_match_lines(r""" + test_class_ordering.py::TestClass2::test_1\[1-a\] PASSED + test_class_ordering.py::TestClass2::test_1\[2-a\] PASSED + test_class_ordering.py::TestClass2::test_2\[1-a\] PASSED + test_class_ordering.py::TestClass2::test_2\[2-a\] PASSED + test_class_ordering.py::TestClass2::test_1\[1-b\] PASSED + test_class_ordering.py::TestClass2::test_1\[2-b\] PASSED + test_class_ordering.py::TestClass2::test_2\[1-b\] PASSED + test_class_ordering.py::TestClass2::test_2\[2-b\] PASSED + test_class_ordering.py::TestClass::test_3\[1-a\] PASSED + test_class_ordering.py::TestClass::test_3\[2-a\] PASSED + test_class_ordering.py::TestClass::test_3\[1-b\] PASSED + test_class_ordering.py::TestClass::test_3\[2-b\] PASSED """) def test_parametrize_separated_order_higher_scope_first(self, testdir): From 59e7fd478eb7b3b1ab27f0eba1b8f5a8b8b08cd4 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 13 Mar 2018 21:08:21 -0300 Subject: [PATCH 103/107] Sort fixtures by scope when determining fixture closure Fix #2405 --- _pytest/fixtures.py | 13 ++- changelog/2405.feature.rst | 1 + doc/en/fixture.rst | 44 ++++++++ testing/acceptance_test.py | 24 +++++ testing/python/fixture.py | 211 ++++++++++++++++++++++++++++++++++--- 5 files changed, 277 insertions(+), 16 deletions(-) create mode 100644 changelog/2405.feature.rst diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index e2ea84e3009..2ac340e6f4f 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -1021,9 +1021,6 @@ def _getautousenames(self, nodeid): if nextchar and nextchar not in ":/": continue autousenames.extend(basenames) - # make sure autousenames are sorted by scope, scopenum 0 is session - autousenames.sort( - key=lambda x: self._arg2fixturedefs[x][-1].scopenum) return autousenames def getfixtureclosure(self, fixturenames, parentnode): @@ -1054,6 +1051,16 @@ def merge(otherlist): if fixturedefs: arg2fixturedefs[argname] = fixturedefs merge(fixturedefs[-1].argnames) + + def sort_by_scope(arg_name): + try: + fixturedefs = arg2fixturedefs[arg_name] + except KeyError: + return scopes.index('function') + else: + return fixturedefs[-1].scopenum + + fixturenames_closure.sort(key=sort_by_scope) return fixturenames_closure, arg2fixturedefs def pytest_generate_tests(self, metafunc): diff --git a/changelog/2405.feature.rst b/changelog/2405.feature.rst new file mode 100644 index 00000000000..b041c132899 --- /dev/null +++ b/changelog/2405.feature.rst @@ -0,0 +1 @@ +Fixtures are now instantiated based on their scopes, with higher-scoped fixtures (such as ``session``) being instantiated first than lower-scoped fixtures (such as ``function``). The relative order of fixtures of the same scope is kept unchanged, based in their declaration order and their dependencies. diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index d2b2865ef31..2cd554f7fc0 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -256,6 +256,50 @@ instance, you can simply declare it: Finally, the ``class`` scope will invoke the fixture once per test *class*. + +Higher-scoped fixtures are instantiated first +--------------------------------------------- + +.. versionadded:: 3.5 + +Within a function request for features, fixture of higher-scopes (such as ``session``) are instantiated first than +lower-scoped fixtures (such as ``function`` or ``class``). The relative order of fixtures of same scope follows +the declared order in the test function and honours dependencies between fixtures. + +Consider the code below: + +.. code-block:: python + + @pytest.fixture(scope="session") + def s1(): + pass + + @pytest.fixture(scope="module") + def m1(): + pass + + @pytest.fixture + def f1(tmpdir): + pass + + @pytest.fixture + def f2(): + pass + + def test_foo(f1, m1, f2, s1): + ... + + +The fixtures requested by ``test_foo`` will be instantiated in the following order: + +1. ``s1``: is the highest-scoped fixture (``session``). +2. ``m1``: is the second highest-scoped fixture (``module``). +3. ``tempdir``: is a ``function``-scoped fixture, required by ``f1``: it needs to be instantiated at this point + because it is a dependency of ``f1``. +4. ``f1``: is the first ``function``-scoped fixture in ``test_foo`` parameter list. +5. ``f2``: is the last ``function``-scoped fixture in ``test_foo`` parameter list. + + .. _`finalization`: Fixture finalization / executing teardown code diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 36b9536f352..89a44911f27 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -964,3 +964,27 @@ def test2(): """) result = testdir.runpytest() result.stdout.fnmatch_lines(['* 2 passed *']) + + +def test_fixture_order_respects_scope(testdir): + """Ensure that fixtures are created according to scope order, regression test for #2405 + """ + testdir.makepyfile(''' + import pytest + + data = {} + + @pytest.fixture(scope='module') + def clean_data(): + data.clear() + + @pytest.fixture(autouse=True) + def add_data(): + data.update(value=True) + + @pytest.mark.usefixtures('clean_data') + def test_value(): + assert data.get('value') + ''') + result = testdir.runpytest() + assert result.ret == 0 diff --git a/testing/python/fixture.py b/testing/python/fixture.py index d1098799d91..59c5266cb7b 100644 --- a/testing/python/fixture.py +++ b/testing/python/fixture.py @@ -3,7 +3,7 @@ import _pytest._code import pytest from _pytest.pytester import get_public_names -from _pytest.fixtures import FixtureLookupError +from _pytest.fixtures import FixtureLookupError, FixtureRequest from _pytest import fixtures @@ -2282,18 +2282,18 @@ def test_3(self): """) result = testdir.runpytest("-vs") result.stdout.re_match_lines(r""" - test_class_ordering.py::TestClass2::test_1\[1-a\] PASSED - test_class_ordering.py::TestClass2::test_1\[2-a\] PASSED - test_class_ordering.py::TestClass2::test_2\[1-a\] PASSED - test_class_ordering.py::TestClass2::test_2\[2-a\] PASSED - test_class_ordering.py::TestClass2::test_1\[1-b\] PASSED - test_class_ordering.py::TestClass2::test_1\[2-b\] PASSED - test_class_ordering.py::TestClass2::test_2\[1-b\] PASSED - test_class_ordering.py::TestClass2::test_2\[2-b\] PASSED - test_class_ordering.py::TestClass::test_3\[1-a\] PASSED - test_class_ordering.py::TestClass::test_3\[2-a\] PASSED - test_class_ordering.py::TestClass::test_3\[1-b\] PASSED - test_class_ordering.py::TestClass::test_3\[2-b\] PASSED + test_class_ordering.py::TestClass2::test_1\[a-1\] PASSED + test_class_ordering.py::TestClass2::test_1\[a-2\] PASSED + test_class_ordering.py::TestClass2::test_2\[a-1\] PASSED + test_class_ordering.py::TestClass2::test_2\[a-2\] PASSED + test_class_ordering.py::TestClass2::test_1\[b-1\] PASSED + test_class_ordering.py::TestClass2::test_1\[b-2\] PASSED + test_class_ordering.py::TestClass2::test_2\[b-1\] PASSED + test_class_ordering.py::TestClass2::test_2\[b-2\] PASSED + test_class_ordering.py::TestClass::test_3\[a-1\] PASSED + test_class_ordering.py::TestClass::test_3\[a-2\] PASSED + test_class_ordering.py::TestClass::test_3\[b-1\] PASSED + test_class_ordering.py::TestClass::test_3\[b-2\] PASSED """) def test_parametrize_separated_order_higher_scope_first(self, testdir): @@ -3245,3 +3245,188 @@ def test_func(my_fixture): "*TESTS finalizer hook called for my_fixture from test_func*", "*ROOT finalizer hook called for my_fixture from test_func*", ]) + + +class TestScopeOrdering(object): + """Class of tests that ensure fixtures are ordered based on their scopes (#2405)""" + + @pytest.mark.parametrize('use_mark', [True, False]) + def test_func_closure_module_auto(self, testdir, use_mark): + """Semantically identical to the example posted in #2405 when ``use_mark=True``""" + testdir.makepyfile(""" + import pytest + + @pytest.fixture(scope='module', autouse={autouse}) + def m1(): pass + + if {use_mark}: + pytestmark = pytest.mark.usefixtures('m1') + + @pytest.fixture(scope='function', autouse=True) + def f1(): pass + + def test_func(m1): + pass + """.format(autouse=not use_mark, use_mark=use_mark)) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + assert request.fixturenames == 'm1 f1'.split() + + def test_func_closure_with_native_fixtures(self, testdir, monkeypatch): + """Sanity check that verifies the order returned by the closures and the actual fixture execution order: + The execution order may differ because of fixture inter-dependencies. + """ + monkeypatch.setattr(pytest, 'FIXTURE_ORDER', [], raising=False) + testdir.makepyfile(""" + import pytest + + FIXTURE_ORDER = pytest.FIXTURE_ORDER + + @pytest.fixture(scope="session") + def s1(): + FIXTURE_ORDER.append('s1') + + @pytest.fixture(scope="module") + def m1(): + FIXTURE_ORDER.append('m1') + + @pytest.fixture(scope='session') + def my_tmpdir_factory(): + FIXTURE_ORDER.append('my_tmpdir_factory') + + @pytest.fixture + def my_tmpdir(my_tmpdir_factory): + FIXTURE_ORDER.append('my_tmpdir') + + @pytest.fixture + def f1(my_tmpdir): + FIXTURE_ORDER.append('f1') + + @pytest.fixture + def f2(): + FIXTURE_ORDER.append('f2') + + def test_foo(f1, m1, f2, s1): pass + """) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + # order of fixtures based on their scope and position in the parameter list + assert request.fixturenames == 's1 my_tmpdir_factory m1 f1 f2 my_tmpdir'.split() + testdir.runpytest() + # actual fixture execution differs: dependent fixtures must be created first ("my_tmpdir") + assert pytest.FIXTURE_ORDER == 's1 my_tmpdir_factory m1 my_tmpdir f1 f2'.split() + + def test_func_closure_module(self, testdir): + testdir.makepyfile(""" + import pytest + + @pytest.fixture(scope='module') + def m1(): pass + + @pytest.fixture(scope='function') + def f1(): pass + + def test_func(f1, m1): + pass + """) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + assert request.fixturenames == 'm1 f1'.split() + + def test_func_closure_scopes_reordered(self, testdir): + """Test ensures that fixtures are ordered by scope regardless of the order of the parameters, although + fixtures of same scope keep the declared order + """ + testdir.makepyfile(""" + import pytest + + @pytest.fixture(scope='session') + def s1(): pass + + @pytest.fixture(scope='module') + def m1(): pass + + @pytest.fixture(scope='function') + def f1(): pass + + @pytest.fixture(scope='function') + def f2(): pass + + class Test: + + @pytest.fixture(scope='class') + def c1(cls): pass + + def test_func(self, f2, f1, c1, m1, s1): + pass + """) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + assert request.fixturenames == 's1 m1 c1 f2 f1'.split() + + def test_func_closure_same_scope_closer_root_first(self, testdir): + """Auto-use fixtures of same scope are ordered by closer-to-root first""" + testdir.makeconftest(""" + import pytest + + @pytest.fixture(scope='module', autouse=True) + def m_conf(): pass + """) + testdir.makepyfile(**{ + 'sub/conftest.py': """ + import pytest + + @pytest.fixture(scope='module', autouse=True) + def m_sub(): pass + """, + 'sub/test_func.py': """ + import pytest + + @pytest.fixture(scope='module', autouse=True) + def m_test(): pass + + @pytest.fixture(scope='function') + def f1(): pass + + def test_func(m_test, f1): + pass + """}) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + assert request.fixturenames == 'm_conf m_sub m_test f1'.split() + + def test_func_closure_all_scopes_complex(self, testdir): + """Complex test involving all scopes and mixing autouse with normal fixtures""" + testdir.makeconftest(""" + import pytest + + @pytest.fixture(scope='session') + def s1(): pass + """) + testdir.makepyfile(""" + import pytest + + @pytest.fixture(scope='module', autouse=True) + def m1(): pass + + @pytest.fixture(scope='module') + def m2(s1): pass + + @pytest.fixture(scope='function') + def f1(): pass + + @pytest.fixture(scope='function') + def f2(): pass + + class Test: + + @pytest.fixture(scope='class', autouse=True) + def c1(self): + pass + + def test_func(self, f2, f1, m2): + pass + """) + items, _ = testdir.inline_genitems() + request = FixtureRequest(items[0]) + assert request.fixturenames == 's1 m1 m2 c1 f2 f1'.split() From b1487700666b58536ebfb58bf839d87682aa418c Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Mar 2018 20:45:28 +0000 Subject: [PATCH 104/107] Fix example in usage.rst --- doc/en/usage.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/usage.rst b/doc/en/usage.rst index 174954ce48b..68790e5c6f6 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -270,7 +270,7 @@ And in your tests: .. code-block:: python # content of test_function.py - + import pytest @pytest.mark.test_id(1501) def test_function(): assert True From beacecf29ba0b99511a4e5ae9b96ff2b0c42c775 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Mar 2018 20:46:07 +0000 Subject: [PATCH 105/107] Preparing release version 3.5.0 --- CHANGELOG.rst | 152 ++++++++++++++++++++++++++++++ doc/en/announce/index.rst | 1 + doc/en/announce/release-3.5.0.rst | 51 ++++++++++ doc/en/builtin.rst | 101 +++++++++++++++++++- doc/en/cache.rst | 5 +- doc/en/example/markers.rst | 24 ++--- doc/en/example/reportingdemo.rst | 2 +- doc/en/example/simple.rst | 10 +- doc/en/fixture.rst | 8 +- doc/en/tmpdir.rst | 1 - doc/en/usage.rst | 2 +- 11 files changed, 326 insertions(+), 31 deletions(-) create mode 100644 doc/en/announce/release-3.5.0.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a0e8897df80..9d8f96d1b81 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,158 @@ .. towncrier release notes start +Pytest 3.5.0 (2018-03-21) +========================= + +Deprecations and Removals +------------------------- + +- ``record_xml_property`` fixture is now deprecated in favor of the more + generic ``record_property``. (`#2770 + `_) + +- Defining ``pytest_plugins`` is now deprecated in non-top-level conftest.py + files, because they "leak" to the entire directory tree. (`#3084 + `_) + + +Features +-------- + +- New ``--show-capture`` command-line option that allows to specify how to + display captured output when tests fail: ``no``, ``stdout``, ``stderr``, + ``log`` or ``all`` (the default). (`#1478 + `_) + +- New ``--rootdir`` command-line option to override the rules for discovering + the root directory. See `customize + `_ in the documentation for + details. (`#1642 `_) + +- Fixtures are now instantiated based on their scopes, with higher-scoped + fixtures (such as ``session``) being instantiated first than lower-scoped + fixtures (such as ``function``). The relative order of fixtures of the same + scope is kept unchanged, based in their declaration order and their + dependencies. (`#2405 `_) + +- ``record_xml_property`` renamed to ``record_property`` and is now compatible + with xdist, markers and any reporter. ``record_xml_property`` name is now + deprecated. (`#2770 `_) + +- 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. (`#3034 + `_) + +- New ``--last-failed-no-failures`` command-line option that allows to specify + the behavior of the cache plugin's ```--last-failed`` feature when no tests + failed in the last run (or no cache was found): ``none`` or ``all`` (the + default). (`#3139 `_) + +- New ``--doctest-continue-on-failure`` command-line option to enable doctests + to show multiple failures for each snippet, instead of stopping at the first + failure. (`#3149 `_) + +- Captured log messages are added to the ```` tag in the generated + junit xml file if the ``junit_logging`` ini option is set to ``system-out``. + If the value of this ini option is ``system-err`, the logs are written to + ````. The default value for ``junit_logging`` is ``no``, meaning + captured logs are not written to the output file. (`#3156 + `_) + +- Allow the logging plugin to handle ``pytest_runtest_logstart`` and + ``pytest_runtest_logfinish`` hooks when live logs are enabled. (`#3189 + `_) + +- Passing `--log-cli-level` in the command-line now automatically activates + live logging. (`#3190 `_) + +- Add command line option ``--deselect`` to allow deselection of individual + tests at collection time. (`#3198 + `_) + +- Captured logs are printed before entering pdb. (`#3204 + `_) + +- Deselected item count is now shown before tests are run, e.g. ``collected X + items / Y deselected``. (`#3213 + `_) + +- The builtin module ``platform`` is now available for use in expressions in + ``pytest.mark``. (`#3236 + `_) + +- The *short test summary info* section now is displayed after tracebacks and + warnings in the terminal. (`#3255 + `_) + +- New ``--verbosity`` flag to set verbosity level explicitly. (`#3296 + `_) + +- ``pytest.approx`` now accepts comparing a numpy array with a scalar. (`#3312 + `_) + + +Bug Fixes +--------- + +- Suppress ``IOError`` when closing the temporary file used for capturing + streams in Python 2.7. (`#2370 + `_) + +- Fixed ``clear()`` method on ``caplog`` fixture which cleared ``records``, but + not the ``text`` property. (`#3297 + `_) + +- During test collection, when stdin is not allowed to be read, the + ``DontReadFromStdin`` object still allow itself to be iterable and resolved + to an iterator without crashing. (`#3314 + `_) + + +Improved Documentation +---------------------- + +- Added a `reference `_ page + to the docs. (`#1713 `_) + + +Trivial/Internal Changes +------------------------ + +- Change minimum requirement of ``attrs`` to ``17.4.0``. (`#3228 + `_) + +- Renamed example directories so all tests pass when ran from the base + directory. (`#3245 `_) + +- Internal ``mark.py`` module has been turned into a package. (`#3250 + `_) + +- ``pytest`` now depends on the `more_itertools + `_ package. (`#3265 + `_) + +- Added warning when ``[pytest]`` section is used in a ``.cfg`` file passed + with ``-c`` (`#3268 `_) + +- ``nodeids`` can now be passed explicitly to ``FSCollector`` and ``Node`` + constructors. (`#3291 `_) + +- Internal refactoring of ``FormattedExcinfo`` to use ``attrs`` facilities and + remove old support code for legacy Python versions. (`#3292 + `_) + +- Refactoring to unify how verbosity is handled internally. (`#3296 + `_) + +- Internal refactoring to better integrate with argparse. (`#3304 + `_) + +- Fix a python example when calling a fixture in doc/en/usage.rst (`#3308 + `_) + + Pytest 3.4.2 (2018-03-04) ========================= diff --git a/doc/en/announce/index.rst b/doc/en/announce/index.rst index 57921410145..b03e0f79d54 100644 --- a/doc/en/announce/index.rst +++ b/doc/en/announce/index.rst @@ -6,6 +6,7 @@ Release announcements :maxdepth: 2 + release-3.5.0 release-3.4.2 release-3.4.1 release-3.4.0 diff --git a/doc/en/announce/release-3.5.0.rst b/doc/en/announce/release-3.5.0.rst new file mode 100644 index 00000000000..54a05cea24d --- /dev/null +++ b/doc/en/announce/release-3.5.0.rst @@ -0,0 +1,51 @@ +pytest-3.5.0 +======================================= + +The pytest team is proud to announce the 3.5.0 release! + +pytest is a mature Python testing tool with more than a 1600 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: + + http://doc.pytest.org/en/latest/changelog.html + +For complete documentation, please visit: + + http://docs.pytest.org + +As usual, you can upgrade from pypi via: + + pip install -U pytest + +Thanks to all who contributed to this release, among them: + +* Allan Feldman +* Brian Maissy +* Bruno Oliveira +* Carlos Jenkins +* Daniel Hahler +* Florian Bruhin +* Jason R. Coombs +* Jeffrey Rackauckas +* Jordan Speicher +* Julien Palard +* Kale Kundert +* Kostis Anagnostopoulos +* Kyle Altendorf +* Maik Figura +* Pedro Algarvio +* Ronny Pfannschmidt +* Tadeu Manoel +* Tareq Alayan +* Thomas Hisch +* William Lee +* codetriage-readme-bot +* feuillemorte +* joshm91 +* mike + + +Happy testing, +The Pytest Development Team diff --git a/doc/en/builtin.rst b/doc/en/builtin.rst index a3bdb145eb2..7a71827e955 100644 --- a/doc/en/builtin.rst +++ b/doc/en/builtin.rst @@ -15,7 +15,106 @@ For information on the ``pytest.mark`` mechanism, see :ref:`mark`. For information about fixtures, see :ref:`fixtures`. To see a complete list of available fixtures, type:: $ pytest -q --fixtures - + cache + Return a cache object that can persist state between testing sessions. + + cache.get(key, default) + cache.set(key, value) + + Keys must be a ``/`` separated value, where the first part is usually the + name of your plugin or application to avoid clashes with other cache users. + + Values can be any object handled by the json stdlib module. + capsys + Enable capturing of writes to ``sys.stdout`` and ``sys.stderr`` and make + captured output available via ``capsys.readouterr()`` method calls + which return a ``(out, err)`` namedtuple. ``out`` and ``err`` will be ``text`` + objects. + capsysbinary + Enable capturing of writes to ``sys.stdout`` and ``sys.stderr`` and make + captured output available via ``capsys.readouterr()`` method calls + which return a ``(out, err)`` tuple. ``out`` and ``err`` will be ``bytes`` + objects. + capfd + Enable capturing of writes to file descriptors ``1`` and ``2`` and make + captured output available via ``capfd.readouterr()`` method calls + which return a ``(out, err)`` tuple. ``out`` and ``err`` will be ``text`` + objects. + capfdbinary + Enable capturing of write to file descriptors 1 and 2 and make + captured output available via ``capfdbinary.readouterr`` method calls + which return a ``(out, err)`` tuple. ``out`` and ``err`` will be + ``bytes`` objects. + doctest_namespace + Fixture that returns a :py:class:`dict` that will be injected into the namespace of doctests. + pytestconfig + Session-scoped fixture that returns the :class:`_pytest.config.Config` object. + + Example:: + + def test_foo(pytestconfig): + if pytestconfig.getoption("verbose"): + ... + record_property + Add an extra properties the calling test. + User properties become part of the test report and are available to the + configured reporters, like JUnit XML. + The fixture is callable with ``(name, value)``, with value being automatically + xml-encoded. + + Example:: + + def test_function(record_property): + record_property("example_key", 1) + record_xml_property + (Deprecated) use record_property. + record_xml_attribute + Add extra xml attributes to the tag for the calling test. + The fixture is callable with ``(name, value)``, with value being + automatically xml-encoded + caplog + Access and control log capturing. + + Captured logs are available through the following methods:: + + * caplog.text() -> string containing formatted log output + * caplog.records() -> list of logging.LogRecord instances + * caplog.record_tuples() -> list of (logger_name, level, message) tuples + * caplog.clear() -> clear captured records and formatted log output string + monkeypatch + The returned ``monkeypatch`` fixture provides these + helper methods to modify objects, dictionaries or os.environ:: + + monkeypatch.setattr(obj, name, value, raising=True) + monkeypatch.delattr(obj, name, raising=True) + monkeypatch.setitem(mapping, name, value) + monkeypatch.delitem(obj, name, raising=True) + monkeypatch.setenv(name, value, prepend=False) + monkeypatch.delenv(name, value, raising=True) + monkeypatch.syspath_prepend(path) + monkeypatch.chdir(path) + + All modifications will be undone after the requesting + test function or fixture has finished. The ``raising`` + parameter determines if a KeyError or AttributeError + will be raised if the set/deletion operation has no target. + recwarn + Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. + + See http://docs.python.org/library/warnings.html for information + on warning categories. + tmpdir_factory + Return a TempdirFactory instance for the test session. + tmpdir + Return a temporary directory path object + which is unique to each test function invocation, + created as a sub directory of the base temporary + directory. The returned object is a `py.path.local`_ + path object. + + .. _`py.path.local`: https://py.readthedocs.io/en/latest/path.html + + no tests ran in 0.12 seconds You can also interactively ask for help, e.g. by typing on the Python interactive prompt something like:: diff --git a/doc/en/cache.rst b/doc/en/cache.rst index 351e0102053..10543ef3b51 100644 --- a/doc/en/cache.rst +++ b/doc/en/cache.rst @@ -78,7 +78,7 @@ If you then run it with ``--lf``:: =========================== test session starts ============================ platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y rootdir: $REGENDOC_TMPDIR, inifile: - collected 50 items + collected 50 items / 48 deselected run-last-failure: rerun previous 2 failures test_50.py FF [100%] @@ -106,7 +106,6 @@ If you then run it with ``--lf``:: E Failed: bad luck test_50.py:6: Failed - =========================== 48 tests deselected ============================ ================= 2 failed, 48 deselected in 0.12 seconds ================== You have run only the two failing test from the last run, while 48 tests have @@ -243,6 +242,8 @@ You can always peek at the content of the cache using the ------------------------------- cache values ------------------------------- cache/lastfailed contains: {'test_caching.py::test_function': True} + cache/nodeids contains: + ['test_caching.py::test_function'] example/value contains: 42 diff --git a/doc/en/example/markers.rst b/doc/en/example/markers.rst index cbbb3463366..7b75c790035 100644 --- a/doc/en/example/markers.rst +++ b/doc/en/example/markers.rst @@ -34,11 +34,10 @@ You can then restrict a test run to only run tests marked with ``webtest``:: platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python3.5 cachedir: .pytest_cache rootdir: $REGENDOC_TMPDIR, inifile: - collecting ... collected 4 items + collecting ... collected 4 items / 3 deselected test_server.py::test_send_http PASSED [100%] - ============================ 3 tests deselected ============================ ================== 1 passed, 3 deselected in 0.12 seconds ================== Or the inverse, running all tests except the webtest ones:: @@ -48,13 +47,12 @@ Or the inverse, running all tests except the webtest ones:: platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python3.5 cachedir: .pytest_cache rootdir: $REGENDOC_TMPDIR, inifile: - collecting ... collected 4 items + collecting ... collected 4 items / 1 deselected test_server.py::test_something_quick PASSED [ 33%] test_server.py::test_another PASSED [ 66%] test_server.py::TestClass::test_method PASSED [100%] - ============================ 1 tests deselected ============================ ================== 3 passed, 1 deselected in 0.12 seconds ================== Selecting tests based on their node ID @@ -133,11 +131,10 @@ select tests based on their names:: platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python3.5 cachedir: .pytest_cache rootdir: $REGENDOC_TMPDIR, inifile: - collecting ... collected 4 items + collecting ... collected 4 items / 3 deselected test_server.py::test_send_http PASSED [100%] - ============================ 3 tests deselected ============================ ================== 1 passed, 3 deselected in 0.12 seconds ================== And you can also run all tests except the ones that match the keyword:: @@ -147,13 +144,12 @@ And you can also run all tests except the ones that match the keyword:: platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python3.5 cachedir: .pytest_cache rootdir: $REGENDOC_TMPDIR, inifile: - collecting ... collected 4 items + collecting ... collected 4 items / 1 deselected test_server.py::test_something_quick PASSED [ 33%] test_server.py::test_another PASSED [ 66%] test_server.py::TestClass::test_method PASSED [100%] - ============================ 1 tests deselected ============================ ================== 3 passed, 1 deselected in 0.12 seconds ================== Or to select "http" and "quick" tests:: @@ -163,12 +159,11 @@ Or to select "http" and "quick" tests:: platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y -- $PYTHON_PREFIX/bin/python3.5 cachedir: .pytest_cache rootdir: $REGENDOC_TMPDIR, inifile: - collecting ... collected 4 items + collecting ... collected 4 items / 2 deselected test_server.py::test_send_http PASSED [ 50%] test_server.py::test_something_quick PASSED [100%] - ============================ 2 tests deselected ============================ ================== 2 passed, 2 deselected in 0.12 seconds ================== .. note:: @@ -547,11 +542,10 @@ Note that if you specify a platform via the marker-command line option like this =========================== test session starts ============================ platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y rootdir: $REGENDOC_TMPDIR, inifile: - collected 4 items + collected 4 items / 3 deselected test_plat.py . [100%] - ============================ 3 tests deselected ============================ ================== 1 passed, 3 deselected in 0.12 seconds ================== then the unmarked-tests will not be run. It is thus a way to restrict the run to the specific tests. @@ -599,7 +593,7 @@ We can now use the ``-m option`` to select one set:: =========================== test session starts ============================ platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y rootdir: $REGENDOC_TMPDIR, inifile: - collected 4 items + collected 4 items / 2 deselected test_module.py FF [100%] @@ -612,7 +606,6 @@ We can now use the ``-m option`` to select one set:: test_module.py:6: in test_interface_complex assert 0 E assert 0 - ============================ 2 tests deselected ============================ ================== 2 failed, 2 deselected in 0.12 seconds ================== or to select both "event" and "interface" tests:: @@ -621,7 +614,7 @@ or to select both "event" and "interface" tests:: =========================== test session starts ============================ platform linux -- Python 3.x.y, pytest-3.x.y, py-1.x.y, pluggy-0.x.y rootdir: $REGENDOC_TMPDIR, inifile: - collected 4 items + collected 4 items / 1 deselected test_module.py FFF [100%] @@ -638,5 +631,4 @@ or to select both "event" and "interface" tests:: test_module.py:9: in test_event_simple assert 0 E assert 0 - ============================ 1 tests deselected ============================ ================== 3 failed, 1 deselected in 0.12 seconds ================== diff --git a/doc/en/example/reportingdemo.rst b/doc/en/example/reportingdemo.rst index 55626b257b2..6c8c1472353 100644 --- a/doc/en/example/reportingdemo.rst +++ b/doc/en/example/reportingdemo.rst @@ -358,7 +358,7 @@ get on the terminal - we are working on that):: > int(s) E ValueError: invalid literal for int() with base 10: 'qwe' - <0-codegen $PYTHON_PREFIX/lib/python3.5/site-packages/_pytest/python_api.py:595>:1: ValueError + <0-codegen $PYTHON_PREFIX/lib/python3.5/site-packages/_pytest/python_api.py:609>:1: ValueError ______________________ TestRaises.test_raises_doesnt _______________________ self = diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index 44e5726fb40..25d1225b55f 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -389,7 +389,7 @@ Now we can profile which test functions execute the slowest:: ========================= slowest 3 test durations ========================= 0.30s call test_some_are_slow.py::test_funcslow2 0.20s call test_some_are_slow.py::test_funcslow1 - 0.10s call test_some_are_slow.py::test_funcfast + 0.16s call test_some_are_slow.py::test_funcfast ========================= 3 passed in 0.12 seconds ========================= incremental testing - test steps @@ -451,9 +451,6 @@ If we run this:: collected 4 items test_step.py .Fx. [100%] - ========================= short test summary info ========================== - XFAIL test_step.py::TestUserHandling::()::test_deletion - reason: previous test failed (test_modification) ================================= FAILURES ================================= ____________________ TestUserHandling.test_modification ____________________ @@ -465,6 +462,9 @@ If we run this:: E assert 0 test_step.py:9: AssertionError + ========================= short test summary info ========================== + XFAIL test_step.py::TestUserHandling::()::test_deletion + reason: previous test failed (test_modification) ============== 1 failed, 2 passed, 1 xfailed in 0.12 seconds =============== We'll see that ``test_deletion`` was not executed because ``test_modification`` @@ -539,7 +539,7 @@ We can run this:: file $REGENDOC_TMPDIR/b/test_error.py, line 1 def test_root(db): # no db here, will error out E fixture 'db' not found - > available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, monkeypatch, pytestconfig, record_xml_attribute, record_property, recwarn, tmpdir, tmpdir_factory + > available fixtures: cache, capfd, capfdbinary, caplog, capsys, capsysbinary, doctest_namespace, monkeypatch, pytestconfig, record_property, record_xml_attribute, record_xml_property, recwarn, tmpdir, tmpdir_factory > use 'pytest --fixtures [testpath]' for help on them. $REGENDOC_TMPDIR/b/test_error.py:1 diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index 2cd554f7fc0..5bb877c90f2 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -740,11 +740,11 @@ Let's run the tests in verbose mode and with looking at the print-output:: test_module.py::test_1[mod1] SETUP modarg mod1 RUN test1 with modarg mod1 PASSED - test_module.py::test_2[1-mod1] SETUP otherarg 1 + test_module.py::test_2[mod1-1] SETUP otherarg 1 RUN test2 with otherarg 1 and modarg mod1 PASSED TEARDOWN otherarg 1 - test_module.py::test_2[2-mod1] SETUP otherarg 2 + test_module.py::test_2[mod1-2] SETUP otherarg 2 RUN test2 with otherarg 2 and modarg mod1 PASSED TEARDOWN otherarg 2 @@ -752,11 +752,11 @@ Let's run the tests in verbose mode and with looking at the print-output:: SETUP modarg mod2 RUN test1 with modarg mod2 PASSED - test_module.py::test_2[1-mod2] SETUP otherarg 1 + test_module.py::test_2[mod2-1] SETUP otherarg 1 RUN test2 with otherarg 1 and modarg mod2 PASSED TEARDOWN otherarg 1 - test_module.py::test_2[2-mod2] SETUP otherarg 2 + test_module.py::test_2[mod2-2] SETUP otherarg 2 RUN test2 with otherarg 2 and modarg mod2 PASSED TEARDOWN otherarg 2 TEARDOWN modarg mod2 diff --git a/doc/en/tmpdir.rst b/doc/en/tmpdir.rst index e01d359e016..2a53adad9fb 100644 --- a/doc/en/tmpdir.rst +++ b/doc/en/tmpdir.rst @@ -51,7 +51,6 @@ Running this would result in a passed test except for the last test_tmpdir.py:7: AssertionError ========================= 1 failed in 0.12 seconds ========================= - .. _`tmpdir factory example`: The 'tmpdir_factory' fixture diff --git a/doc/en/usage.rst b/doc/en/usage.rst index 68790e5c6f6..7274dccc965 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -482,7 +482,7 @@ Running it will show that ``MyPlugin`` was added and its hook was invoked:: $ python myinvoke.py - *** test run reporting finishing + . [100%]*** test run reporting finishing .. note:: From 4e717eb6261f61f6f4bef255522b835a85354594 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Mar 2018 20:20:46 -0300 Subject: [PATCH 106/107] Remove `terminal.flatten` function in favor of collapse from more_itertools --- _pytest/terminal.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 7dc36e7d7d7..f8ad33c1010 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -12,6 +12,7 @@ import pluggy import py import six +from more_itertools import collapse import pytest from _pytest import nodes @@ -442,7 +443,7 @@ def pytest_sessionstart(self, session): def _write_report_lines_from_hooks(self, lines): lines.reverse() - for line in flatten(lines): + for line in collapse(lines): self.write_line(line) def pytest_report_header(self, config): @@ -700,15 +701,6 @@ def repr_pythonversion(v=None): return str(v) -def flatten(values): - for x in values: - if isinstance(x, (list, tuple)): - for y in flatten(x): - yield y - else: - yield x - - def build_summary_stats_line(stats): keys = ("failed passed skipped deselected " "xfailed xpassed warnings error").split() From 6c2739d1e68c9a8da26a9dcae7645e4c793107bf Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 21 Mar 2018 20:23:17 -0300 Subject: [PATCH 107/107] Add CHANGELOG for #3330 --- changelog/3330.trivial.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3330.trivial.rst diff --git a/changelog/3330.trivial.rst b/changelog/3330.trivial.rst new file mode 100644 index 00000000000..ce5ec5882ec --- /dev/null +++ b/changelog/3330.trivial.rst @@ -0,0 +1 @@ +Remove internal ``_pytest.terminal.flatten`` function in favor of ``more_itertools.collapse``.