From a060b8ff738c08b837c89d87eae8a6bba54cf357 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 25 Jul 2015 10:44:18 +0200 Subject: [PATCH 01/38] use setuptools_scm to determine the version --- .gitignore | 5 +++++ _pytest/__init__.py | 2 -- setup.py | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) delete mode 100644 _pytest/__init__.py diff --git a/.gitignore b/.gitignore index 2b7c267b0..cd6a7fc9e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ include/ *.orig *~ +.eggs/ + +# this file is managed by setuptools_scm +_pytest/__init__.py + doc/*/_build build/ dist/ diff --git a/_pytest/__init__.py b/_pytest/__init__.py deleted file mode 100644 index 5b3715e54..000000000 --- a/_pytest/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# -__version__ = '2.8.0.dev4' diff --git a/setup.py b/setup.py index 218c14da8..69b6f5e5c 100644 --- a/setup.py +++ b/setup.py @@ -63,7 +63,7 @@ def main(): name='pytest', description='pytest: simple powerful testing with Python', long_description=long_description, - version=get_version(), + use_scm_version={'write_to': '_pytest/__init__.py'}, url='http://pytest.org', license='MIT license', platforms=['unix', 'linux', 'osx', 'cygwin', 'win32'], @@ -75,6 +75,7 @@ def main(): # the following should be enabled for release install_requires=install_requires, extras_require=extras_require, + setup_requires=['setuptools_scm'], packages=['_pytest', '_pytest.assertion'], py_modules=['pytest'], zip_safe=False, From 5098e5fc47d0d7531a7501d0e8e72ea4c629de79 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sun, 26 Jul 2015 14:04:52 +0200 Subject: [PATCH 02/38] fix version import --- pytest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pytest.py b/pytest.py index 8549ba781..161c44822 100644 --- a/pytest.py +++ b/pytest.py @@ -15,7 +15,7 @@ from _pytest.config import ( main, UsageError, _preloadplugins, cmdline, hookspec, hookimpl ) -from _pytest import __version__ +from _pytest import version as __version__ _preloadplugins() # to populate pytest.* namespace so help(pytest) works From 235f9da432310c7084af08c2fddefd9a3a0bf223 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 27 Jul 2015 11:33:27 +0200 Subject: [PATCH 03/38] special-case _pytest.__init__ in genscript to avoid a python3 bug --- _pytest/genscript.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/_pytest/genscript.py b/_pytest/genscript.py index d6f452370..fbaef4c2d 100755 --- a/_pytest/genscript.py +++ b/_pytest/genscript.py @@ -31,7 +31,12 @@ def pkg_to_mapping(name): else: # package for pyfile in toplevel.visit('*.py'): pkg = pkgname(name, toplevel, pyfile) - name2src[pkg] = pyfile.read() + if pkg == '_pytest.__init__': + # remove the coding comment line to avoid python bug + lines = pyfile.read().splitlines(True) + name2src[pkg] = ''.join(lines[1:]) + else: + name2src[pkg] = pyfile.read() # with wheels py source code might be not be installed # and the resulting genscript is useless, just bail out. assert name2src, "no source code found for %r at %r" %(name, toplevel) From 13c545686844f2e09e6e459ec1d291bd8605d3b2 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 7 Aug 2015 07:31:04 +0200 Subject: [PATCH 04/38] Generate parametrize IDs for enum/re/class objects. --- _pytest/python.py | 15 +++++++++++++++ testing/python/metafunc.py | 18 +++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/_pytest/python.py b/_pytest/python.py index fe93c938e..c5a479aeb 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -1,4 +1,5 @@ """ Python test discovery, setup and run of test functions. """ +import re import fnmatch import functools import py @@ -976,8 +977,22 @@ def _idval(val, argname, idx, idfn): return s except Exception: pass + + try: + import enum + except ImportError: + # Only available in Python 3.4+ + enum = None + if isinstance(val, (float, int, str, bool, NoneType)): return str(val) + elif isinstance(val, type(re.compile(''))): + # The type of re.compile objects is not exposed in Python. + return val.pattern + elif enum is not None and isinstance(val, enum.Enum): + return str(val) + elif isinstance(val, type) and hasattr(val, '__name__'): + return val.__name__ return str(argname)+str(idx) def _idvalset(idx, valset, argnames, idfn): diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index 7a6d1eebf..90346a5b6 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -1,3 +1,4 @@ +import re import pytest, py from _pytest import python as funcargs @@ -138,6 +139,8 @@ class TestMetafunc: ("three", "three hundred"), (True, False), (None, None), + (re.compile('foo'), re.compile('bar')), + (str, int), (list("six"), [66, 66]), (set([7]), set("seven")), (tuple("eight"), (8, -8, 8)) @@ -147,9 +150,18 @@ class TestMetafunc: "three-three hundred", "True-False", "None-None", - "a5-b5", - "a6-b6", - "a7-b7"] + "foo-bar", + "str-int", + "a7-b7", + "a8-b8", + "a9-b9"] + + def test_idmaker_enum(self): + from _pytest.python import idmaker + enum = pytest.importorskip("enum") + e = enum.Enum("Foo", "one, two") + result = idmaker(("a", "b"), [(e.one, e.two)]) + assert result == ["Foo.one-Foo.two"] @pytest.mark.issue351 def test_idmaker_idfn(self): From 91a29932a6610cf9097d8bfd11aa6ea5b1247e7e Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 7 Aug 2015 07:58:13 +0200 Subject: [PATCH 05/38] Add CHANGELOG entry. --- CHANGELOG | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index bc4c74aa3..83376c44a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,9 @@ 2.8.0.dev (compared to 2.7.X) ----------------------------- +- parametrize now also generates meaningful test IDs for enum, regex and class + objects. + - Add 'warns' to assert that warnings are thrown (like 'raises'). Thanks to Eric Hunsberger for the PR. From 03d8a6c05ddaf754c02a8e317fa235c4b5cc4cc7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 7 Aug 2015 23:08:22 +0200 Subject: [PATCH 06/38] Update CHANGELOG. --- CHANGELOG | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 83376c44a..ce75fced1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,7 +2,8 @@ ----------------------------- - parametrize now also generates meaningful test IDs for enum, regex and class - objects. + objects (as opposed to class instances). + Thanks to Florian Bruhin for the PR. - Add 'warns' to assert that warnings are thrown (like 'raises'). Thanks to Eric Hunsberger for the PR. From 18125c7d1f31771d33cb0210b156f729b37eb280 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Fri, 7 Aug 2015 23:10:22 +0200 Subject: [PATCH 07/38] Clean up type comparisons. --- _pytest/python.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/_pytest/python.py b/_pytest/python.py index c5a479aeb..36f3fb6ba 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -983,15 +983,16 @@ def _idval(val, argname, idx, idfn): except ImportError: # Only available in Python 3.4+ enum = None + # The type of re.compile objects is not exposed in Python. + RegexType = type(re.compile('')) if isinstance(val, (float, int, str, bool, NoneType)): return str(val) - elif isinstance(val, type(re.compile(''))): - # The type of re.compile objects is not exposed in Python. + elif isinstance(val, RegexType): return val.pattern elif enum is not None and isinstance(val, enum.Enum): return str(val) - elif isinstance(val, type) and hasattr(val, '__name__'): + elif inspect.isclass(val) and hasattr(val, '__name__'): return val.__name__ return str(argname)+str(idx) From 84fdba129a12ec9993e19b3a5636803c60848d81 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 8 Aug 2015 12:57:54 +0200 Subject: [PATCH 08/38] More style changes. --- _pytest/python.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/_pytest/python.py b/_pytest/python.py index 36f3fb6ba..b7c102a69 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -23,6 +23,8 @@ isclass = inspect.isclass callable = py.builtin.callable # used to work around a python2 exception info leak exc_clear = getattr(sys, 'exc_clear', lambda: None) +# The type of re.compile objects is not exposed in Python. +REGEX_TYPE = type(re.compile('')) def filter_traceback(entry): return entry.path != cutdir1 and not entry.path.relto(cutdir2) @@ -983,16 +985,14 @@ def _idval(val, argname, idx, idfn): except ImportError: # Only available in Python 3.4+ enum = None - # The type of re.compile objects is not exposed in Python. - RegexType = type(re.compile('')) if isinstance(val, (float, int, str, bool, NoneType)): return str(val) - elif isinstance(val, RegexType): + elif isinstance(val, REGEX_TYPE): return val.pattern elif enum is not None and isinstance(val, enum.Enum): return str(val) - elif inspect.isclass(val) and hasattr(val, '__name__'): + elif isclass(val) and hasattr(val, '__name__'): return val.__name__ return str(argname)+str(idx) From 4e21d1d77b5fc42cc9e9477190d4b1bf29223d25 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Sat, 8 Aug 2015 15:25:10 +0200 Subject: [PATCH 09/38] Move enum import and adjust comments. --- _pytest/python.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/_pytest/python.py b/_pytest/python.py index b7c102a69..cdd51a810 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -9,6 +9,12 @@ import pytest from _pytest.mark import MarkDecorator, MarkerError from py._code.code import TerminalRepr +try: + import enum +except ImportError: # pragma: no cover + # Only available in Python 3.4+ or as a backport + enum = None + import _pytest import pluggy @@ -980,12 +986,6 @@ def _idval(val, argname, idx, idfn): except Exception: pass - try: - import enum - except ImportError: - # Only available in Python 3.4+ - enum = None - if isinstance(val, (float, int, str, bool, NoneType)): return str(val) elif isinstance(val, REGEX_TYPE): From e130a0257d883a46c623e30a6ed7408a5d38155e Mon Sep 17 00:00:00 2001 From: TomV Date: Thu, 6 Aug 2015 09:51:18 +0100 Subject: [PATCH 10/38] add test for @nose.tools.istest --- testing/test_nose.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/testing/test_nose.py b/testing/test_nose.py index 76873a834..6260aae47 100644 --- a/testing/test_nose.py +++ b/testing/test_nose.py @@ -347,3 +347,49 @@ def test_SkipTest_in_test(testdir): """) reprec = testdir.inline_run() reprec.assertoutcome(skipped=1) + +def test_istest_function_decorator(testdir): + p = testdir.makepyfile(""" + import nose.tools + @nose.tools.istest + def not_test_prefix(): + pass + """) + result = testdir.runpytest(p) + result.assert_outcomes(passed=1) + +def test_nottest_function_decorator(testdir): + testdir.makepyfile(""" + import nose.tools + @nose.tools.nottest + def test_prefix(): + pass + """) + reprec = testdir.inline_run() + assert not reprec.getfailedcollections() + calls = reprec.getreports("pytest_runtest_logreport") + assert not calls + +def test_istest_class_decorator(testdir): + p = testdir.makepyfile(""" + import nose.tools + @nose.tools.istest + class NotTestPrefix: + def test_method(self): + pass + """) + result = testdir.runpytest(p) + result.assert_outcomes(passed=1) + +def test_nottest_class_decorator(testdir): + testdir.makepyfile(""" + import nose.tools + @nose.tools.nottest + class TestPrefix: + def test_method(self): + pass + """) + reprec = testdir.inline_run() + assert not reprec.getfailedcollections() + calls = reprec.getreports("pytest_runtest_logreport") + assert not calls From e8583f01a0ee6fc4af06059797723b5c56c9bc51 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 8 Aug 2015 19:07:27 -0300 Subject: [PATCH 11/38] Replaced __multicall__ examples in docs by hookwrapper Fix #929 --- doc/en/example/simple.rst | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index 34211d055..e02327539 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -534,23 +534,24 @@ case we just write some informations out to a ``failures`` file:: import pytest import os.path - @pytest.hookimpl(tryfirst=True) - def pytest_runtest_makereport(item, call, __multicall__): + @pytest.hookimpl(tryfirst=True, hookwrapper=True) + def pytest_runtest_makereport(item, call): # execute all other hooks to obtain the report object - rep = __multicall__.execute() + outcome = yield + rep = outcome.get_result() # we only look at actual failing test calls, not setup/teardown if rep.when == "call" and rep.failed: mode = "a" if os.path.exists("failures") else "w" with open("failures", mode) as f: # let's also access a fixture for the fun of it - if "tmpdir" in item.funcargs: + if "tmpdir" in item.fixturenames: extra = " (%s)" % item.funcargs["tmpdir"] else: extra = "" f.write(rep.nodeid + extra + "\n") - return rep + if you then have failing tests:: @@ -606,16 +607,16 @@ here is a little example implemented via a local plugin:: import pytest - @pytest.hookimpl(tryfirst=True) - def pytest_runtest_makereport(item, call, __multicall__): + @pytest.hookimpl(tryfirst=True, hookwrapper=True) + def pytest_runtest_makereport(item, call): # execute all other hooks to obtain the report object - rep = __multicall__.execute() + outcome = yield + rep = outcome.get_result() # set an report attribute for each phase of a call, which can # be "setup", "call", "teardown" setattr(item, "rep_" + rep.when, rep) - return rep @pytest.fixture From d49fb8a2d589326578bf50811401dcaebdf97719 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 8 Aug 2015 19:17:50 -0300 Subject: [PATCH 12/38] Change cx_freeze example to not use regendoc --- doc/en/example/simple.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/en/example/simple.rst b/doc/en/example/simple.rst index e02327539..3c770eed9 100644 --- a/doc/en/example/simple.rst +++ b/doc/en/example/simple.rst @@ -743,5 +743,4 @@ over to ``pytest`` instead. For example:: This makes it convenient to execute your tests from within your frozen application, using standard ``py.test`` command-line options:: - $ ./app_main --pytest --verbose --tb=long --junit-xml=results.xml test-suite/ - /bin/sh: ./app_main: No such file or directory + ./app_main --pytest --verbose --tb=long --junit-xml=results.xml test-suite/ From 3497aa0766ac6f2f2cd66cc483c86f2a67a738aa Mon Sep 17 00:00:00 2001 From: TomV Date: Thu, 6 Aug 2015 19:36:59 +0100 Subject: [PATCH 13/38] check nose.istest __test__ attr issue526 --- _pytest/python.py | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/_pytest/python.py b/_pytest/python.py index 68ffda308..51a560f94 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -37,7 +37,7 @@ def filter_traceback(entry): def get_real_func(obj): - """gets the real function object of the (possibly) wrapped object by + """ gets the real function object of the (possibly) wrapped object by functools.wraps or functools.partial. """ while hasattr(obj, "__wrapped__"): @@ -64,6 +64,17 @@ def getimfunc(func): except AttributeError: return func +def safe_getattr(object, name, default): + """ Like getattr but return default upon any Exception. + + Attribute access can potentially fail for 'evil' Python objects. + See issue214 + """ + try: + return getattr(object, name, default) + except Exception: + return default + class FixtureFunctionMarker: def __init__(self, scope, params, @@ -257,11 +268,10 @@ def pytest_pycollect_makeitem(collector, name, obj): raise StopIteration # nothing was collected elsewhere, let's do it here if isclass(obj): - if collector.classnamefilter(name): + if collector.istestclass(obj, name): Class = collector._getcustomclass("Class") outcome.force_result(Class(name, parent=collector)) - elif collector.funcnamefilter(name) and hasattr(obj, "__call__") and\ - getfixturemarker(obj) is None: + elif collector.istestfunction(obj, name): # mock seems to store unbound methods (issue473), normalize it obj = getattr(obj, "__func__", obj) if not isfunction(obj): @@ -347,9 +357,24 @@ class PyCollector(PyobjMixin, pytest.Collector): def funcnamefilter(self, name): return self._matches_prefix_or_glob_option('python_functions', name) + def isnosetest(self, obj): + """ Look for the __test__ attribute, which is applied by the + @nose.tools.istest decorator + """ + return safe_getattr(obj, '__test__', False) + def classnamefilter(self, name): return self._matches_prefix_or_glob_option('python_classes', name) + def istestfunction(self, obj, name): + return ( + (self.funcnamefilter(name) or self.isnosetest(obj)) + and safe_getattr(obj, "__call__", False) and getfixturemarker(obj) is None + ) + + def istestclass(self, obj, name): + return self.classnamefilter(name) or self.isnosetest(obj) + def _matches_prefix_or_glob_option(self, option_name, name): """ checks if the given name matches the prefix or glob-pattern defined @@ -494,7 +519,7 @@ class FuncFixtureInfo: def _marked(func, mark): - """Returns True if :func: is already marked with :mark:, False orherwise. + """ 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. """ @@ -1379,7 +1404,7 @@ class FixtureRequest(FuncargnamesCompatAttr): return self._pyfuncitem.session def addfinalizer(self, finalizer): - """add finalizer/teardown function to be called after the + """ add finalizer/teardown function to be called after the last test within the requesting test context finished execution. """ # XXX usually this method is shadowed by fixturedef specific ones From 353360dbe5b4b13406dd3b582cfdedd9558377bb Mon Sep 17 00:00:00 2001 From: TomV Date: Sat, 8 Aug 2015 17:10:14 +0100 Subject: [PATCH 14/38] use local isclass --- _pytest/python.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/_pytest/python.py b/_pytest/python.py index 51a560f94..8438ca428 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -1155,9 +1155,9 @@ def raises(expected_exception, *args, **kwargs): " derived from BaseException, not %s") if isinstance(expected_exception, tuple): for exc in expected_exception: - if not inspect.isclass(exc): + if not isclass(exc): raise TypeError(msg % type(exc)) - elif not inspect.isclass(expected_exception): + elif not isclass(expected_exception): raise TypeError(msg % type(expected_exception)) if not args: From 14625907aeb0a6574c86bf1b00e2e7a16c07bba8 Mon Sep 17 00:00:00 2001 From: TomV Date: Sat, 8 Aug 2015 17:55:48 +0100 Subject: [PATCH 15/38] update changelog --- CHANGELOG | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index ce75fced1..cec72c229 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,8 @@ 2.8.0.dev (compared to 2.7.X) ----------------------------- +- Fix #562: @nose.tools.istest now fully respected. + - parametrize now also generates meaningful test IDs for enum, regex and class objects (as opposed to class instances). Thanks to Florian Bruhin for the PR. From e103932aad78c3072ee3b81d5e4f7c801ef1d23a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sun, 9 Aug 2015 19:30:49 -0300 Subject: [PATCH 16/38] Reintroduce hasplugin to PytestPluginManager Fix #932 --- _pytest/config.py | 4 ++++ testing/acceptance_test.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/_pytest/config.py b/_pytest/config.py index 85c892a51..e2afaa976 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -211,6 +211,10 @@ class PytestPluginManager(PluginManager): # support deprecated naming because plugins (xdist e.g.) use it return self.get_plugin(name) + def hasplugin(self, name): + """Return True if the plugin with the given name is registered.""" + return bool(self.get_plugin(name)) + def pytest_configure(self, config): # XXX now that the pluginmanager exposes hookimpl(tryfirst...) # we should remove tryfirst/trylast as markers diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index d845fd46a..2e87ac466 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -586,6 +586,11 @@ class TestInvocationVariants: assert type(_pytest.config.get_plugin_manager()) is _pytest.config.PytestPluginManager + def test_has_plugin(self, request): + """Test hasplugin function of the plugin manager (#932).""" + assert request.config.pluginmanager.hasplugin('python') + + class TestDurations: source = """ import time From 681e502c12f5baa3f2da9c9de4a848813a872945 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 10 Aug 2015 19:27:22 -0300 Subject: [PATCH 17/38] Use "union" merge strategy for CHANGELOG Credits: @The-Compiler :smile: --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..242d3da0d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +CHANGELOG merge=union From 41cef6f5f2a20b918bb9b9204ca6c91b47442d15 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Sat, 8 Aug 2015 02:09:08 +0200 Subject: [PATCH 18/38] Don't skip fixtures that are substrings of params Bug introduced with https://bitbucket.org/pytest-dev/pytest/pull-requests/257/allow-to-override-parametrized-fixtures Fix #736 --- _pytest/python.py | 2 +- testing/python/collect.py | 12 +++++++++++- testing/python/fixture.py | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/_pytest/python.py b/_pytest/python.py index 8438ca428..88c7d1a39 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -1818,7 +1818,7 @@ class FixtureManager: if fixturedef.params is not None: func_params = getattr(getattr(metafunc.function, 'parametrize', None), 'args', [[None]]) # skip directly parametrized arguments - if argname not in func_params and argname not in func_params[0]: + if argname not in func_params: metafunc.parametrize(argname, fixturedef.params, indirect=True, scope=fixturedef.scope, ids=fixturedef.ids) diff --git a/testing/python/collect.py b/testing/python/collect.py index 97250028a..029b0b693 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -412,9 +412,19 @@ class TestFunction: ['overridden']) def test_overridden_via_param(value): assert value == 'overridden' + + @pytest.mark.parametrize('somevalue', ['overridden']) + def test_not_overridden(value, somevalue): + assert value == 'value' + assert somevalue == 'overridden' + + @pytest.mark.parametrize('other,value', [('foo', 'overridden')]) + def test_overridden_via_multiparam(other, value): + assert other == 'foo' + assert value == 'overridden' """) rec = testdir.inline_run() - rec.assertoutcome(passed=1) + rec.assertoutcome(passed=3) def test_parametrize_overrides_parametrized_fixture(self, testdir): diff --git a/testing/python/fixture.py b/testing/python/fixture.py index 8bf738c5d..48f52d2a0 100644 --- a/testing/python/fixture.py +++ b/testing/python/fixture.py @@ -1598,6 +1598,22 @@ class TestFixtureMarker: reprec = testdir.inline_run() reprec.assertoutcome(passed=4) + def test_multiple_parametrization_issue_736(self, testdir): + testdir.makepyfile(""" + import pytest + + @pytest.fixture(params=[1,2,3]) + def foo(request): + return request.param + + @pytest.mark.parametrize('foobar', [4,5,6]) + def test_issue(foo, foobar): + assert foo in [1,2,3] + assert foobar in [4,5,6] + """) + reprec = testdir.inline_run() + reprec.assertoutcome(passed=9) + def test_scope_session(self, testdir): testdir.makepyfile(""" import pytest From cafd71eb290f39bac05c8c9e504953a3f1ddfd72 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Sat, 8 Aug 2015 03:05:15 +0200 Subject: [PATCH 19/38] Update changelog for #926 Also fix some inconsistencies in the changelog on the way. --- CHANGELOG | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index cec72c229..6c9c1adbc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,10 @@ - Fix #562: @nose.tools.istest now fully respected. +- Fix issue736: Fix a bug where fixture params would be discarded when combined + with parametrization markers. + Thanks to Markus Unterwaditzer for the PR. + - parametrize now also generates meaningful test IDs for enum, regex and class objects (as opposed to class instances). Thanks to Florian Bruhin for the PR. @@ -10,7 +14,7 @@ - Add 'warns' to assert that warnings are thrown (like 'raises'). Thanks to Eric Hunsberger for the PR. -- Fix #683: Do not apply an already applied mark. Thanks ojake for the PR. +- Fix issue683: Do not apply an already applied mark. Thanks ojake for the PR. - Deal with capturing failures better so fewer exceptions get lost to /dev/null. Thanks David Szotten for the PR. @@ -36,7 +40,7 @@ deprecated. Thanks Bruno Oliveira for the PR. -- fix issue 808: pytest's internal assertion rewrite hook now implements the +- fix issue808: pytest's internal assertion rewrite hook now implements the optional PEP302 get_data API so tests can access data files next to them. Thanks xmo-odoo for request and example and Bruno Oliveira for the PR. From 1d5215ab4f2323c225e6a8d1890f138aef518c9f Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Sat, 8 Aug 2015 03:24:30 +0200 Subject: [PATCH 20/38] Add myself to authors --- AUTHORS | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index b0073c54f..2fdc39fc2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -25,11 +25,11 @@ Daniel Grana Daniel Nuri Dave Hunt David Mohr +Edison Gustavo Muenz Eduardo Schettino +Eric Hunsberger Eric Siegerman Florian Bruhin -Edison Gustavo Muenz -Eric Hunsberger Floris Bruynooghe Graham Horler Grig Gheorghiu @@ -47,6 +47,7 @@ Maciek Fijalkowski Maho Marc Schlaich Mark Abramowitz +Markus Unterwaditzer Martijn Faassen Nicolas Delaby Pieter Mulder From 2174f3ce37c8f4c502e4416c838a67eb25d7ade2 Mon Sep 17 00:00:00 2001 From: Christian Theune Date: Wed, 12 Aug 2015 07:38:04 +0200 Subject: [PATCH 21/38] Fix accidental inversion in skip example. --- doc/en/skipping.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/en/skipping.rst b/doc/en/skipping.rst index e8a36186a..77456e2de 100644 --- a/doc/en/skipping.rst +++ b/doc/en/skipping.rst @@ -83,7 +83,7 @@ As with all function :ref:`marking ` you can skip test functions at the `whole class- or module level`_. If your code targets python2.6 or above you use the skipif decorator (and any other marker) on classes:: - @pytest.mark.skipif(sys.platform == 'win32', + @pytest.mark.skipif(sys.platform != 'win32', reason="requires windows") class TestPosixCalls: @@ -97,7 +97,7 @@ If your code targets python2.5 where class-decorators are not available, you can set the ``pytestmark`` attribute of a class:: class TestPosixCalls: - pytestmark = pytest.mark.skipif(sys.platform == 'win32', + pytestmark = pytest.mark.skipif(sys.platform != 'win32', reason="requires Windows") def test_function(self): From 420823070b9e2a898a07201466c82d763d94b752 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 12 Aug 2015 21:41:31 -0300 Subject: [PATCH 22/38] Add ALLOW_UNICODE doctest option When enabled, the ``u`` prefix is stripped from unicode strings in expected doctest output. This allows doctests which use unicode to run in Python 2 and 3 unchanged. Fix #710 --- _pytest/doctest.py | 84 +++++++++++++++++++++++++++++++++++++---- testing/test_doctest.py | 44 +++++++++++++++++++++ 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/_pytest/doctest.py b/_pytest/doctest.py index c6c60b9c2..ba5c082ec 100644 --- a/_pytest/doctest.py +++ b/_pytest/doctest.py @@ -63,7 +63,7 @@ class DoctestItem(pytest.Item): lineno = test.lineno + example.lineno + 1 message = excinfo.type.__name__ reprlocation = ReprFileLocation(filename, lineno, message) - checker = doctest.OutputChecker() + checker = _get_unicode_checker() REPORT_UDIFF = doctest.REPORT_UDIFF filelines = py.path.local(filename).readlines(cr=0) lines = [] @@ -100,7 +100,8 @@ def _get_flag_lookup(): NORMALIZE_WHITESPACE=doctest.NORMALIZE_WHITESPACE, ELLIPSIS=doctest.ELLIPSIS, IGNORE_EXCEPTION_DETAIL=doctest.IGNORE_EXCEPTION_DETAIL, - COMPARISON_FLAGS=doctest.COMPARISON_FLAGS) + COMPARISON_FLAGS=doctest.COMPARISON_FLAGS, + ALLOW_UNICODE=_get_allow_unicode_flag()) def get_optionflags(parent): optionflags_str = parent.config.getini("doctest_optionflags") @@ -110,15 +111,30 @@ def get_optionflags(parent): flag_acc |= flag_lookup_table[flag] return flag_acc + class DoctestTextfile(DoctestItem, pytest.File): + def runtest(self): import doctest fixture_request = _setup_fixtures(self) - failed, tot = doctest.testfile( - str(self.fspath), module_relative=False, - optionflags=get_optionflags(self), - extraglobs=dict(getfixture=fixture_request.getfuncargvalue), - raise_on_error=True, verbose=0) + + # inspired by doctest.testfile; ideally we would use it directly, + # but it doesn't support passing a custom checker + text = self.fspath.read() + filename = str(self.fspath) + name = self.fspath.basename + globs = dict(getfixture=fixture_request.getfuncargvalue) + if '__name__' not in globs: + globs['__name__'] = '__main__' + + optionflags = get_optionflags(self) + runner = doctest.DebugRunner(verbose=0, optionflags=optionflags, + checker=_get_unicode_checker()) + + parser = doctest.DocTestParser() + test = parser.get_doctest(text, globs, name, filename, 0) + runner.run(test) + class DoctestModule(pytest.File): def collect(self): @@ -139,7 +155,8 @@ class DoctestModule(pytest.File): # uses internal doctest module parsing mechanism finder = doctest.DocTestFinder() optionflags = get_optionflags(self) - runner = doctest.DebugRunner(verbose=0, optionflags=optionflags) + runner = doctest.DebugRunner(verbose=0, optionflags=optionflags, + checker=_get_unicode_checker()) for test in finder.find(module, module.__name__, extraglobs=doctest_globals): if test.examples: # skip empty doctests @@ -160,3 +177,54 @@ def _setup_fixtures(doctest_item): fixture_request = FixtureRequest(doctest_item) fixture_request._fillfixtures() return fixture_request + + +def _get_unicode_checker(): + """ + Returns a doctest.OutputChecker subclass that takes in account the + ALLOW_UNICODE option to ignore u'' prefixes in strings. Useful + when the same doctest should run in Python 2 and Python 3. + + An inner class is used to avoid importing "doctest" at the module + level. + """ + if hasattr(_get_unicode_checker, 'UnicodeOutputChecker'): + return _get_unicode_checker.UnicodeOutputChecker() + + import doctest + import re + + class UnicodeOutputChecker(doctest.OutputChecker): + """ + Copied from doctest_nose_plugin.py from the nltk project: + https://github.com/nltk/nltk + """ + + _literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE) + + def _remove_u_prefixes(self, txt): + return re.sub(self._literal_re, r'\1\2', txt) + + def check_output(self, want, got, optionflags): + res = doctest.OutputChecker.check_output(self, want, got, optionflags) + if res: + return True + + if not (optionflags & _get_allow_unicode_flag()): + return False + + cleaned_want = self._remove_u_prefixes(want) + cleaned_got = self._remove_u_prefixes(got) + res = doctest.OutputChecker.check_output(self, cleaned_want, cleaned_got, optionflags) + return res + + _get_unicode_checker.UnicodeOutputChecker = UnicodeOutputChecker + return _get_unicode_checker.UnicodeOutputChecker() + + +def _get_allow_unicode_flag(): + """ + Registers and returns the ALLOW_UNICODE flag. + """ + import doctest + return doctest.register_optionflag('ALLOW_UNICODE') diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 882747b9e..a5650afcf 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1,5 +1,7 @@ +import sys from _pytest.doctest import DoctestItem, DoctestModule, DoctestTextfile import py +import pytest class TestDoctests: @@ -401,3 +403,45 @@ class TestDoctests: result = testdir.runpytest("--doctest-modules") result.stdout.fnmatch_lines('*2 passed*') + @pytest.mark.parametrize('config_mode', ['ini', 'comment']) + def test_allow_unicode(self, testdir, config_mode): + """Test that doctests which output unicode work in all python versions + tested by pytest when the ALLOW_UNICODE option is used (either in + the ini file or by an inline comment). + """ + if config_mode == 'ini': + testdir.makeini(''' + [pytest] + doctest_optionflags = ALLOW_UNICODE + ''') + comment = '' + else: + comment = '#doctest: +ALLOW_UNICODE' + + testdir.maketxtfile(test_doc=""" + >>> b'12'.decode('ascii') {comment} + '12' + """.format(comment=comment)) + testdir.makepyfile(foo=""" + def foo(): + ''' + >>> b'12'.decode('ascii') {comment} + '12' + ''' + """.format(comment=comment)) + reprec = testdir.inline_run("--doctest-modules") + reprec.assertoutcome(passed=2) + + @pytest.mark.skipif(sys.version_info[0] >= 3, reason='Python 2 only') + def test_unicode_string_fails(self, testdir): + """Test that doctests which output unicode fail in Python 2 when + the ALLOW_UNICODE option is not used. + """ + testdir.maketxtfile(test_doc=""" + >>> b'12'.decode('ascii') {comment} + '12' + """) + reprec = testdir.inline_run() + reprec.assertoutcome(failed=1) + + From 93aee0f8146922eed473b9f02997be4404bcb070 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 12 Aug 2015 22:13:42 -0300 Subject: [PATCH 23/38] Add docs and CHANGELOG for ALLOW_UNICODE option --- CHANGELOG | 5 +++++ doc/en/doctest.rst | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 6c9c1adbc..7d96418c8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,11 @@ with parametrization markers. Thanks to Markus Unterwaditzer for the PR. +- fix issue710: introduce ALLOW_UNICODE doctest option: when enabled, the + ``u`` prefix is stripped from unicode strings in expected doctest output. This + allows doctests which use unicode to run in Python 2 and 3 unchanged. + Thanks Jason R. Coombs for the report and Bruno Oliveira for the PR. + - parametrize now also generates meaningful test IDs for enum, regex and class objects (as opposed to class instances). Thanks to Florian Bruhin for the PR. diff --git a/doc/en/doctest.rst b/doc/en/doctest.rst index e33fed676..a456488e3 100644 --- a/doc/en/doctest.rst +++ b/doc/en/doctest.rst @@ -72,3 +72,18 @@ ignore lengthy exception stack traces you can just write:: # content of pytest.ini [pytest] doctest_optionflags= NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL + + +py.test also introduces a new ``ALLOW_UNICODE`` option flag: when enabled, the +``u`` prefix is stripped from unicode strings in expected doctest output. This +allows doctests which use unicode to run in Python 2 and 3 unchanged. + +As with any other option flag, this flag can be enabled in ``pytest.ini`` using +the ``doctest_optionflags`` ini option or by an inline comment in the doc test +itself:: + + # content of example.rst + >>> get_unicode_greeting() # doctest: +ALLOW_UNICODE + 'Hello' + + From d749021a31f76167a2305cb81d21f70613867390 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 12 Aug 2015 22:46:13 -0300 Subject: [PATCH 24/38] Fix coverage Also make sure a test that doesn't set ALLOW_UNICODE fails on Python 2 and passes Python 3. --- _pytest/doctest.py | 21 +++++++++++++-------- testing/test_doctest.py | 11 ++++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/_pytest/doctest.py b/_pytest/doctest.py index ba5c082ec..fe71c8284 100644 --- a/_pytest/doctest.py +++ b/_pytest/doctest.py @@ -202,21 +202,26 @@ def _get_unicode_checker(): _literal_re = re.compile(r"(\W|^)[uU]([rR]?[\'\"])", re.UNICODE) - def _remove_u_prefixes(self, txt): - return re.sub(self._literal_re, r'\1\2', txt) - def check_output(self, want, got, optionflags): - res = doctest.OutputChecker.check_output(self, want, got, optionflags) + res = doctest.OutputChecker.check_output(self, want, got, + optionflags) if res: return True if not (optionflags & _get_allow_unicode_flag()): return False - cleaned_want = self._remove_u_prefixes(want) - cleaned_got = self._remove_u_prefixes(got) - res = doctest.OutputChecker.check_output(self, cleaned_want, cleaned_got, optionflags) - return res + else: # pragma: no cover + # the code below will end up executed only in Python 2 in + # our tests, and our coverage check runs in Python 3 only + def remove_u_prefixes(txt): + return re.sub(self._literal_re, r'\1\2', txt) + + want = remove_u_prefixes(want) + got = remove_u_prefixes(got) + res = doctest.OutputChecker.check_output(self, want, got, + optionflags) + return res _get_unicode_checker.UnicodeOutputChecker = UnicodeOutputChecker return _get_unicode_checker.UnicodeOutputChecker() diff --git a/testing/test_doctest.py b/testing/test_doctest.py index a5650afcf..6975ecc2c 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -432,16 +432,17 @@ class TestDoctests: reprec = testdir.inline_run("--doctest-modules") reprec.assertoutcome(passed=2) - @pytest.mark.skipif(sys.version_info[0] >= 3, reason='Python 2 only') - def test_unicode_string_fails(self, testdir): + def test_unicode_string(self, testdir): """Test that doctests which output unicode fail in Python 2 when - the ALLOW_UNICODE option is not used. + the ALLOW_UNICODE option is not used. The same test should pass + in Python 3. """ testdir.maketxtfile(test_doc=""" - >>> b'12'.decode('ascii') {comment} + >>> b'12'.decode('ascii') '12' """) reprec = testdir.inline_run() - reprec.assertoutcome(failed=1) + passed = int(sys.version_info[0] >= 3) + reprec.assertoutcome(passed=passed, failed=int(not passed)) From 2c42f15e004226eb7b3fff917affb1cfbb8ca4aa Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Sat, 8 Aug 2015 09:27:16 +0200 Subject: [PATCH 25/38] adapt plugin printing * print each distribution only once(xdist now has 3 entrypoints) * include the distribution version --- _pytest/terminal.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 5365b4300..54d2660ff 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -300,10 +300,15 @@ class TerminalReporter: if plugininfo: l = [] for plugin, dist in plugininfo: - name = dist.project_name + # gets us name and version! + name = str(dist) + # questionable convenience, but it keeps things short if name.startswith("pytest-"): name = name[7:] - l.append(name) + # we decided to print python package names + # they can have more than one plugin + if name not in l: + l.append(name) lines.append("plugins: %s" % ", ".join(l)) return lines From 740a97a8cc185df9f5903bfaf141d725be3dde6c Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 17 Aug 2015 08:48:38 +0200 Subject: [PATCH 26/38] terinalwriter: use dash between plugin name and version --- _pytest/terminal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 54d2660ff..0d8261e83 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -301,7 +301,7 @@ class TerminalReporter: l = [] for plugin, dist in plugininfo: # gets us name and version! - name = str(dist) + name = '{dist.project_name}-{dist.version}'.format(dist=dist) # questionable convenience, but it keeps things short if name.startswith("pytest-"): name = name[7:] From 7758bcd141275bb1856dba2fa0ed09e2feacacd8 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 17 Aug 2015 09:10:01 +0200 Subject: [PATCH 27/38] terminalwriter: extract plugin printing logic and add positive unittests --- _pytest/terminal.py | 30 ++++++++++++++++++------------ testing/test_terminal.py | 22 ++++++++++++++++++++-- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 0d8261e83..e3ca23533 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -298,18 +298,9 @@ class TerminalReporter: plugininfo = config.pluginmanager.list_plugin_distinfo() if plugininfo: - l = [] - for plugin, dist in plugininfo: - # gets us name and version! - name = '{dist.project_name}-{dist.version}'.format(dist=dist) - # questionable convenience, but it keeps things short - if name.startswith("pytest-"): - name = name[7:] - # we decided to print python package names - # they can have more than one plugin - if name not in l: - l.append(name) - lines.append("plugins: %s" % ", ".join(l)) + + lines.append( + "plugins: %s" % ", ".join(_plugin_nameversions(plugininfo))) return lines def pytest_collection_finish(self, session): @@ -554,3 +545,18 @@ def build_summary_stats_line(stats): color = 'yellow' return (line, color) + + +def _plugin_nameversions(plugininfo): + l = [] + for plugin, dist in plugininfo: + # gets us name and version! + name = '{dist.project_name}-{dist.version}'.format(dist=dist) + # questionable convenience, but it keeps things short + if name.startswith("pytest-"): + name = name[7:] + # we decided to print python package names + # they can have more than one plugin + if name not in l: + l.append(name) + return l diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 7ad74a921..50c27509f 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -1,19 +1,23 @@ """ terminal reporting of the full testing process. """ +import collections import pytest import py import pluggy import sys from _pytest.terminal import TerminalReporter, repr_pythonversion, getreportopt -from _pytest.terminal import build_summary_stats_line +from _pytest.terminal import build_summary_stats_line, _plugin_nameversions from _pytest import runner def basic_run_report(item): runner.call_and_report(item, "setup", log=False) return runner.call_and_report(item, "call", log=False) +DistInfo = collections.namedtuple('DistInfo', ['project_name', 'version']) + + class Option: def __init__(self, verbose=False, fulltrace=False): self.verbose = verbose @@ -40,6 +44,21 @@ def pytest_generate_tests(metafunc): funcargs={'option': Option(fulltrace=True)}) +@pytest.mark.parametrize('input,expected', [ + ([DistInfo(project_name='test', version=1)], ['test-1']), + ([DistInfo(project_name='pytest-test', version=1)], ['test-1']), + ([ + DistInfo(project_name='test', version=1), + DistInfo(project_name='test', version=1) + ], ['test-1']), +], ids=['normal', 'prefix-strip', 'deduplicate']) + +def test_plugin_nameversion(input, expected): + pluginlist = [(None, x) for x in input] + result = _plugin_nameversions(pluginlist) + assert result == expected + + class TestTerminal: def test_pass_skip_fail(self, testdir, option): testdir.makepyfile(""" @@ -783,4 +802,3 @@ def test_summary_stats(exp_line, exp_color, stats_arg): print("Actually got: \"%s\"; with color \"%s\"" % (line, color)) assert line == exp_line assert color == exp_color - From 2ffd37b816a603ff05ff8614625acb90e63a25e8 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 4 Jul 2015 14:42:22 -0300 Subject: [PATCH 28/38] return non-zero exit code if no tests are collected Fix #812 Fix #500 --- AUTHORS | 1 + CHANGELOG | 9 +++++++++ _pytest/main.py | 5 +++++ _pytest/terminal.py | 9 +++++++-- testing/acceptance_test.py | 16 +++++++++------- testing/python/collect.py | 4 +++- testing/test_assertrewrite.py | 5 +++-- testing/test_capture.py | 3 ++- testing/test_collection.py | 10 +++++----- testing/test_config.py | 3 ++- testing/test_conftest.py | 6 +++++- testing/test_helpconfig.py | 7 ++++--- testing/test_junitxml.py | 3 ++- testing/test_pluginmanager.py | 3 ++- testing/test_runner.py | 21 +++++++++++++++++++++ testing/test_session.py | 4 +++- testing/test_terminal.py | 3 ++- testing/test_unittest.py | 5 +++-- 18 files changed, 88 insertions(+), 29 deletions(-) diff --git a/AUTHORS b/AUTHORS index 2fdc39fc2..7a4674388 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ Bob Ippolito Brian Dorsey Brian Okken Brianna Laugher +Bruno Oliveira Carl Friedrich Bolz Charles Cloud Chris Lamb diff --git a/CHANGELOG b/CHANGELOG index 7d96418c8..c375c9141 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -58,6 +58,15 @@ - Summary bar now is colored yellow for warning situations such as: all tests either were skipped or xpass/xfailed, or no tests were run at all (this is a partial fix for issue500). +- fix issue812: pytest now exits with status code 5 in situations where no + tests were run at all, such as the directory given in the command line does + not contain any tests or as result of a command line option filters + all out all tests (-k for example). + Thanks Eric Siegerman (issue812) and Bruno Oliveira for the PR. + +- Summary bar now is colored yellow for warning + situations such as: all tests either were skipped or xpass/xfailed, + or no tests were run at all (related to issue500). Thanks Eric Siegerman. - New `testpaths` ini option: list of directories to search for tests diff --git a/_pytest/main.py b/_pytest/main.py index fc9d64cf6..f9f3584c8 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -19,6 +19,7 @@ EXIT_TESTSFAILED = 1 EXIT_INTERRUPTED = 2 EXIT_INTERNALERROR = 3 EXIT_USAGEERROR = 4 +EXIT_NOTESTSCOLLECTED = 5 name_re = re.compile("^[a-zA-Z_]\w*$") @@ -102,6 +103,8 @@ def wrap_session(config, doit): else: if session._testsfailed: session.exitstatus = EXIT_TESTSFAILED + elif session._testscollected == 0: + session.exitstatus = EXIT_NOTESTSCOLLECTED finally: excinfo = None # Explicitly break reference cycle. session.startdir.chdir() @@ -510,6 +513,7 @@ class Session(FSCollector): config=config, session=self) self._fs2hookproxy = {} self._testsfailed = 0 + self._testscollected = 0 self.shouldstop = False self.trace = config.trace.root.get("collection") self._norecursepatterns = config.getini("norecursedirs") @@ -564,6 +568,7 @@ class Session(FSCollector): config=self.config, items=items) finally: hook.pytest_collection_finish(session=self) + self._testscollected = len(items) return items def _perform_collect(self, args, genitems): diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 5365b4300..7c51d0835 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -2,6 +2,8 @@ This is a good source for looking at the various reporting hooks. """ +from _pytest.main import EXIT_OK, EXIT_TESTSFAILED, EXIT_INTERRUPTED, \ + EXIT_USAGEERROR, EXIT_NOTESTSCOLLECTED import pytest import pluggy import py @@ -359,12 +361,15 @@ class TerminalReporter: outcome = yield outcome.get_result() self._tw.line("") - if exitstatus in (0, 1, 2, 4): + summary_exit_codes = ( + EXIT_OK, EXIT_TESTSFAILED, EXIT_INTERRUPTED, EXIT_USAGEERROR, + EXIT_NOTESTSCOLLECTED) + if exitstatus in summary_exit_codes: self.summary_errors() self.summary_failures() self.summary_warnings() self.config.hook.pytest_terminal_summary(terminalreporter=self) - if exitstatus == 2: + if exitstatus == EXIT_INTERRUPTED: self._report_keyboardinterrupt() del self._keyboardinterrupt_memo self.summary_deselected() diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index 2e87ac466..b9a3fa381 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -1,5 +1,7 @@ import sys import py, pytest +from _pytest.main import EXIT_NOTESTSCOLLECTED, EXIT_USAGEERROR + class TestGeneralUsage: def test_config_error(self, testdir): @@ -147,7 +149,7 @@ class TestGeneralUsage: pytest.skip("early") """) result = testdir.runpytest() - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED result.stdout.fnmatch_lines([ "*1 skip*" ]) @@ -177,7 +179,7 @@ class TestGeneralUsage: sys.stderr.write("stder42\\n") """) result = testdir.runpytest() - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED assert "should not be seen" not in result.stdout.str() assert "stderr42" not in result.stderr.str() @@ -212,13 +214,13 @@ class TestGeneralUsage: sub2 = testdir.tmpdir.mkdir("sub2") sub1.join("conftest.py").write("assert 0") result = testdir.runpytest(sub2) - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED sub2.ensure("__init__.py") p = sub2.ensure("test_hello.py") result = testdir.runpytest(p) - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED result = testdir.runpytest(sub1) - assert result.ret != 0 + assert result.ret == EXIT_USAGEERROR def test_directory_skipped(self, testdir): testdir.makeconftest(""" @@ -228,7 +230,7 @@ class TestGeneralUsage: """) testdir.makepyfile("def test_hello(): pass") result = testdir.runpytest() - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED result.stdout.fnmatch_lines([ "*1 skipped*" ]) @@ -479,7 +481,7 @@ class TestInvocationVariants: def test_invoke_with_path(self, tmpdir, capsys): retcode = pytest.main(tmpdir) - assert not retcode + assert retcode == EXIT_NOTESTSCOLLECTED out, err = capsys.readouterr() def test_invoke_plugin_api(self, testdir, capsys): diff --git a/testing/python/collect.py b/testing/python/collect.py index 029b0b693..6a302f291 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1,6 +1,8 @@ import sys from textwrap import dedent import pytest, py +from _pytest.main import EXIT_NOTESTSCOLLECTED + class TestModule: def test_failing_import(self, testdir): @@ -906,7 +908,7 @@ def test_unorderable_types(testdir): """) result = testdir.runpytest() assert "TypeError" not in result.stdout.str() - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED def test_collect_functools_partial(testdir): diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index fbac2b9c1..544250ad5 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -12,6 +12,7 @@ if sys.platform.startswith("java"): from _pytest.assertion import util from _pytest.assertion.rewrite import rewrite_asserts, PYTEST_TAG +from _pytest.main import EXIT_NOTESTSCOLLECTED def setup_module(mod): @@ -429,7 +430,7 @@ class TestRewriteOnImport: import sys sys.path.append(%r) import test_gum.test_lizard""" % (z_fn,)) - assert testdir.runpytest().ret == 0 + assert testdir.runpytest().ret == EXIT_NOTESTSCOLLECTED def test_readonly(self, testdir): sub = testdir.mkdir("testing") @@ -497,7 +498,7 @@ def test_rewritten(): pkg = testdir.mkdir('a_package_without_init_py') pkg.join('module.py').ensure() testdir.makepyfile("import a_package_without_init_py.module") - assert testdir.runpytest().ret == 0 + assert testdir.runpytest().ret == EXIT_NOTESTSCOLLECTED class TestAssertionRewriteHookDetails(object): def test_loader_is_package_false_for_module(self, testdir): diff --git a/testing/test_capture.py b/testing/test_capture.py index 81238432a..539333525 100644 --- a/testing/test_capture.py +++ b/testing/test_capture.py @@ -10,6 +10,7 @@ import contextlib from _pytest import capture from _pytest.capture import CaptureManager +from _pytest.main import EXIT_NOTESTSCOLLECTED from py.builtin import print_ needsosdup = pytest.mark.xfail("not hasattr(os, 'dup')") @@ -365,7 +366,7 @@ class TestLoggingInteraction: """) # make sure that logging is still captured in tests result = testdir.runpytest_subprocess("-s", "-p", "no:capturelog") - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED result.stderr.fnmatch_lines([ "WARNING*hello435*", ]) diff --git a/testing/test_collection.py b/testing/test_collection.py index a7cb8a8c4..749c5b7ce 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -1,6 +1,6 @@ import pytest, py -from _pytest.main import Session +from _pytest.main import Session, EXIT_NOTESTSCOLLECTED class TestCollector: def test_collect_versus_item(self): @@ -247,10 +247,10 @@ class TestCustomConftests: p = testdir.makepyfile("def test_hello(): pass") result = testdir.runpytest(p) assert result.ret == 0 - assert "1 passed" in result.stdout.str() + result.stdout.fnmatch_lines("*1 passed*") result = testdir.runpytest() - assert result.ret == 0 - assert "1 passed" not in result.stdout.str() + assert result.ret == EXIT_NOTESTSCOLLECTED + result.stdout.fnmatch_lines("*collected 0 items*") def test_collectignore_exclude_on_option(self, testdir): testdir.makeconftest(""" @@ -264,7 +264,7 @@ class TestCustomConftests: testdir.mkdir("hello") testdir.makepyfile(test_world="def test_hello(): pass") result = testdir.runpytest() - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED assert "passed" not in result.stdout.str() result = testdir.runpytest("--XX") assert result.ret == 0 diff --git a/testing/test_config.py b/testing/test_config.py index 490fa96d0..9d3f7632c 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1,6 +1,7 @@ import py, pytest from _pytest.config import getcfg, get_common_ancestor, determine_setup +from _pytest.main import EXIT_NOTESTSCOLLECTED class TestParseIni: def test_getcfg_and_config(self, testdir, tmpdir): @@ -343,7 +344,7 @@ def test_invalid_options_show_extra_information(testdir): @pytest.mark.skipif("sys.platform == 'win32'") def test_toolongargs_issue224(testdir): result = testdir.runpytest("-m", "hello" * 500) - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED def test_notify_exception(testdir, capfd): config = testdir.parseconfig() diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 64fa6d5e5..6700502c4 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -1,6 +1,7 @@ from textwrap import dedent import py, pytest from _pytest.config import PytestPluginManager +from _pytest.main import EXIT_NOTESTSCOLLECTED, EXIT_USAGEERROR @pytest.fixture(scope="module", params=["global", "inpackage"]) @@ -166,7 +167,10 @@ def test_conftest_confcutdir(testdir): def test_no_conftest(testdir): testdir.makeconftest("assert 0") result = testdir.runpytest("--noconftest") - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED + + result = testdir.runpytest() + assert result.ret == EXIT_USAGEERROR def test_conftest_existing_resultlog(testdir): x = testdir.mkdir("tests") diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index d9cb52bcb..9f8d87b7c 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -1,3 +1,4 @@ +from _pytest.main import EXIT_NOTESTSCOLLECTED import pytest def test_version(testdir, pytestconfig): @@ -43,7 +44,7 @@ def test_hookvalidation_optional(testdir): pass """) result = testdir.runpytest() - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED def test_traceconfig(testdir): result = testdir.runpytest("--traceconfig") @@ -54,14 +55,14 @@ def test_traceconfig(testdir): def test_debug(testdir, monkeypatch): result = testdir.runpytest_subprocess("--debug") - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED p = testdir.tmpdir.join("pytestdebug.log") assert "pytest_sessionstart" in p.read() def test_PYTEST_DEBUG(testdir, monkeypatch): monkeypatch.setenv("PYTEST_DEBUG", "1") result = testdir.runpytest_subprocess() - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED result.stderr.fnmatch_lines([ "*pytest_plugin_registered*", "*manager*PluginManager*" diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index f8acd1576..d8d0c17c7 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from xml.dom import minidom +from _pytest.main import EXIT_NOTESTSCOLLECTED import py, sys, os from _pytest.junitxml import LogXML @@ -298,7 +299,7 @@ class TestPython: def test_collect_skipped(self, testdir): testdir.makepyfile("import pytest; pytest.skip('xyz')") result, dom = runandparse(testdir) - assert not result.ret + assert result.ret == EXIT_NOTESTSCOLLECTED node = dom.getElementsByTagName("testsuite")[0] assert_attr(node, skips=1, tests=0) tnode = node.getElementsByTagName("testcase")[0] diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 62ecc544f..92afba9bc 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -3,6 +3,7 @@ import py import os from _pytest.config import get_config, PytestPluginManager +from _pytest.main import EXIT_NOTESTSCOLLECTED @pytest.fixture def pytestpm(): @@ -223,7 +224,7 @@ class TestPytestPluginManager: p.copy(p.dirpath("skipping2.py")) monkeypatch.setenv("PYTEST_PLUGINS", "skipping2") result = testdir.runpytest("-rw", "-p", "skipping1", syspathinsert=True) - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED result.stdout.fnmatch_lines([ "WI1*skipped plugin*skipping1*hello*", "WI1*skipped plugin*skipping2*hello*", diff --git a/testing/test_runner.py b/testing/test_runner.py index 167ddc57b..0245ff627 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -431,6 +431,27 @@ def test_pytest_fail_notrace(testdir): ]) assert 'def teardown_function' not in result.stdout.str() + +def test_pytest_no_tests_collected_exit_status(testdir): + result = testdir.runpytest() + result.stdout.fnmatch_lines('*collected 0 items*') + assert result.ret == main.EXIT_NOTESTSCOLLECTED + + testdir.makepyfile(test_foo=""" + def test_foo(): + assert 1 + """) + result = testdir.runpytest() + result.stdout.fnmatch_lines('*collected 1 items*') + result.stdout.fnmatch_lines('*1 passed*') + assert result.ret == main.EXIT_OK + + result = testdir.runpytest('-k nonmatch') + result.stdout.fnmatch_lines('*collected 1 items*') + result.stdout.fnmatch_lines('*1 deselected*') + assert result.ret == main.EXIT_NOTESTSCOLLECTED + + def test_exception_printing_skip(): try: pytest.skip("hello") diff --git a/testing/test_session.py b/testing/test_session.py index 0ddb92ac1..76f804b4f 100644 --- a/testing/test_session.py +++ b/testing/test_session.py @@ -1,5 +1,7 @@ import pytest +from _pytest.main import EXIT_NOTESTSCOLLECTED + class SessionTests: def test_basic_testitem_events(self, testdir): tfile = testdir.makepyfile(""" @@ -239,4 +241,4 @@ def test_sessionfinish_with_start(testdir): """) res = testdir.runpytest("--collect-only") - assert res.ret == 0 + assert res.ret == EXIT_NOTESTSCOLLECTED diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 7ad74a921..de7f2292d 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -6,6 +6,7 @@ import py import pluggy import sys +from _pytest.main import EXIT_NOTESTSCOLLECTED from _pytest.terminal import TerminalReporter, repr_pythonversion, getreportopt from _pytest.terminal import build_summary_stats_line from _pytest import runner @@ -577,7 +578,7 @@ def test_traceconfig(testdir, monkeypatch): result.stdout.fnmatch_lines([ "*active plugins*" ]) - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED class TestGenericReporting: diff --git a/testing/test_unittest.py b/testing/test_unittest.py index b9ce7b5fa..aa055f89c 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1,3 +1,4 @@ +from _pytest.main import EXIT_NOTESTSCOLLECTED import pytest def test_simple_unittest(testdir): @@ -41,7 +42,7 @@ def test_isclasscheck_issue53(testdir): E = _E() """) result = testdir.runpytest(testpath) - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED def test_setup(testdir): testpath = testdir.makepyfile(""" @@ -572,7 +573,7 @@ def test_unorderable_types(testdir): """) result = testdir.runpytest() assert "TypeError" not in result.stdout.str() - assert result.ret == 0 + assert result.ret == EXIT_NOTESTSCOLLECTED def test_unittest_typerror_traceback(testdir): testdir.makepyfile(""" From f730291904045508128b1bdeea0eb68ad6c7c4ee Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Mon, 6 Jul 2015 20:25:16 -0300 Subject: [PATCH 29/38] Make testsfailed and testscollected public Session attributes As suggested by @flub in review --- _pytest/main.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/_pytest/main.py b/_pytest/main.py index f9f3584c8..4f3d2625f 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -101,9 +101,9 @@ def wrap_session(config, doit): if excinfo.errisinstance(SystemExit): sys.stderr.write("mainloop: caught Spurious SystemExit!\n") else: - if session._testsfailed: + if session.testsfailed: session.exitstatus = EXIT_TESTSFAILED - elif session._testscollected == 0: + elif session.testscollected == 0: session.exitstatus = EXIT_NOTESTSCOLLECTED finally: excinfo = None # Explicitly break reference cycle. @@ -512,8 +512,8 @@ class Session(FSCollector): FSCollector.__init__(self, config.rootdir, parent=None, config=config, session=self) self._fs2hookproxy = {} - self._testsfailed = 0 - self._testscollected = 0 + self.testsfailed = 0 + self.testscollected = 0 self.shouldstop = False self.trace = config.trace.root.get("collection") self._norecursepatterns = config.getini("norecursedirs") @@ -531,11 +531,11 @@ class Session(FSCollector): @pytest.hookimpl(tryfirst=True) def pytest_runtest_logreport(self, report): if report.failed and not hasattr(report, 'wasxfail'): - self._testsfailed += 1 + self.testsfailed += 1 maxfail = self.config.getvalue("maxfail") - if maxfail and self._testsfailed >= maxfail: + if maxfail and self.testsfailed >= maxfail: self.shouldstop = "stopping after %d failures" % ( - self._testsfailed) + self.testsfailed) pytest_collectreport = pytest_runtest_logreport def isinitpath(self, path): @@ -568,7 +568,7 @@ class Session(FSCollector): config=self.config, items=items) finally: hook.pytest_collection_finish(session=self) - self._testscollected = len(items) + self.testscollected = len(items) return items def _perform_collect(self, args, genitems): From 359f248729b8e44c32f8bc1f11155f2604f0a773 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 18 Aug 2015 07:32:33 -0300 Subject: [PATCH 30/38] Force to use xdist >= 1.13 --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index c5dfa03f9..73ecdfb45 100644 --- a/tox.ini +++ b/tox.ini @@ -22,7 +22,7 @@ deps= [testenv:py27-subprocess] changedir=. basepython=python2.7 -deps=pytest-xdist +deps=pytest-xdist>=1.13 mock nose commands= @@ -37,7 +37,7 @@ deps = pytest-flakes>=0.2 commands = py.test --flakes -m flakes _pytest testing [testenv:py27-xdist] -deps=pytest-xdist +deps=pytest-xdist>=1.13 mock nose commands= @@ -63,7 +63,7 @@ commands= py.test -rfsxX test_pdb.py test_terminal.py test_unittest.py [testenv:py27-nobyte] -deps=pytest-xdist +deps=pytest-xdist>=1.13 distribute=true setenv= PYTHONDONTWRITEBYTECODE=1 From 3e41c3cbb39a7e6a65e4a0d00b1566ddb422be3f Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 18 Aug 2015 09:14:17 -0300 Subject: [PATCH 31/38] Using packages from pypi --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 89ed8d187..e83220105 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,7 +28,7 @@ env: - TESTENV=py35 - TESTENV=pypy -script: tox --recreate -i ALL=https://devpi.net/hpk/dev/ -e $TESTENV +script: tox --recreate -e $TESTENV notifications: irc: From 24212fd97f6f0720f35a5c944bbf7a92f9abdf79 Mon Sep 17 00:00:00 2001 From: David Diaz Date: Wed, 19 Aug 2015 15:36:42 -0600 Subject: [PATCH 32/38] Add support to record custom properties on xml output --- _pytest/junitxml.py | 21 +++++++++++++++++++++ doc/en/usage.rst | 9 +++++++++ testing/test_junitxml.py | 11 ++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/_pytest/junitxml.py b/_pytest/junitxml.py index c12fa084a..0c79d2951 100644 --- a/_pytest/junitxml.py +++ b/_pytest/junitxml.py @@ -53,6 +53,13 @@ def bin_xml_escape(arg): return unicode('#x%04X') % i return py.xml.raw(illegal_xml_re.sub(repl, py.xml.escape(arg))) +def record_property(name, value): + if hasattr(record_property, 'binding'): + record_property.binding(name, value) + +def pytest_namespace(): + return dict(record_property=record_property) + def pytest_addoption(parser): group = parser.getgroup("terminal reporting") group.addoption('--junitxml', '--junit-xml', action="store", @@ -69,12 +76,17 @@ def pytest_configure(config): config._xml = LogXML(xmlpath, config.option.junitprefix) config.pluginmanager.register(config._xml) + def binding(name, value): + config._xml.record_property(name, value) + record_property.binding = binding + def pytest_unconfigure(config): xml = getattr(config, '_xml', None) if xml: del config._xml config.pluginmanager.unregister(xml) + del record_property.binding def mangle_testnames(names): names = [x.replace(".py", "") for x in names if x != '()'] @@ -89,6 +101,10 @@ class LogXML(object): self.tests = [] self.passed = self.skipped = 0 self.failed = self.errors = 0 + self.custom_properties = {} + + def record_property(self, name, value): + self.custom_properties[str(name)] = bin_xml_escape(str(value)) def _opentestcase(self, report): names = mangle_testnames(report.nodeid.split("::")) @@ -118,6 +134,10 @@ class LogXML(object): def append(self, obj): self.tests[-1].append(obj) + def append_custom_properties(self): + self.tests[-1].attr.__dict__.update(self.custom_properties) + self.custom_properties.clear() + def append_pass(self, report): self.passed += 1 self._write_captured_output(report) @@ -179,6 +199,7 @@ class LogXML(object): if report.when == "setup": self._opentestcase(report) self.tests[-1].attr.time += getattr(report, 'duration', 0) + self.append_custom_properties() if report.passed: if report.when == "call": # ignore setup/teardown self.append_pass(report) diff --git a/doc/en/usage.rst b/doc/en/usage.rst index 9984a2ac9..baa625bdb 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -153,6 +153,15 @@ integration servers, use this invocation:: to create an XML file at ``path``. +If you want to log additional information for a test, you can use +record_property("key", value):: + + import pytest + def test_function(): + ... + pytest.record_property("example_key", 1) + ... + Creating resultlog format files ---------------------------------------------------- diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index f8acd1576..2ff9ca6ed 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -552,4 +552,13 @@ def test_unicode_issue368(testdir): log.append_skipped(report) log.pytest_sessionfinish() - +def test_record_property(testdir): + testdir.makepyfile(""" + from pytest import record_property + def test_record(): + record_property("foo", "<1"); + """) + result, dom = runandparse(testdir) + node = dom.getElementsByTagName("testsuite")[0] + tnode = node.getElementsByTagName("testcase")[0] + assert_attr(tnode, foo="<1") From d7d418cd471cddc14295fb7b7dcd1c211c96e94b Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Thu, 20 Aug 2015 21:17:05 -0400 Subject: [PATCH 33/38] Fix forked_run_report in pytest.xdist 1.13 --- testing/test_runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/test_runner.py b/testing/test_runner.py index 0245ff627..3641ab8ca 100644 --- a/testing/test_runner.py +++ b/testing/test_runner.py @@ -293,8 +293,8 @@ class TestExecutionForked(BaseFunctionalTests): def getrunner(self): # XXX re-arrange this test to live in pytest-xdist - xplugin = pytest.importorskip("xdist.plugin") - return xplugin.forked_run_report + boxed = pytest.importorskip("xdist.boxed") + return boxed.forked_run_report def test_suicide(self, testdir): reports = testdir.runitem(""" From 2ddbac1f98f0a10000a390eb34ea8af5fa6c6bc4 Mon Sep 17 00:00:00 2001 From: David Diaz Date: Fri, 21 Aug 2015 14:31:20 -0600 Subject: [PATCH 34/38] Correcting implementation based on pull request feed back --- AUTHORS | 1 + CHANGELOG | 3 +++ _pytest/junitxml.py | 20 ++++++++------------ doc/en/usage.rst | 2 ++ testing/test_junitxml.py | 5 ++--- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/AUTHORS b/AUTHORS index 2fdc39fc2..5d9a162eb 100644 --- a/AUTHORS +++ b/AUTHORS @@ -60,3 +60,4 @@ Samuele Pedroni Tom Viner Trevor Bekolay Wouter van Ackooy +David Díaz-Barquero diff --git a/CHANGELOG b/CHANGELOG index 7d96418c8..b66eeb95f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -138,6 +138,9 @@ - fix issue890: changed extension of all documentation files from ``txt`` to ``rst``. Thanks to Abhijeet for the PR. +- implement feature on issue951: support to log additional information on xml + output. + 2.7.3 (compared to 2.7.2) ----------------------------- diff --git a/_pytest/junitxml.py b/_pytest/junitxml.py index 0c79d2951..c481fc1f0 100644 --- a/_pytest/junitxml.py +++ b/_pytest/junitxml.py @@ -9,6 +9,7 @@ import os import re import sys import time +import pytest # Python 2.X and 3.X compatibility if sys.version_info[0] < 3: @@ -53,12 +54,13 @@ def bin_xml_escape(arg): return unicode('#x%04X') % i return py.xml.raw(illegal_xml_re.sub(repl, py.xml.escape(arg))) -def record_property(name, value): - if hasattr(record_property, 'binding'): - record_property.binding(name, value) - -def pytest_namespace(): - return dict(record_property=record_property) +@pytest.fixture +def record_xml_property(request): + if hasattr(request.config, "_xml"): + return request.config._xml.record_property + def dummy(*args, **kwargs): + pass + return dummy def pytest_addoption(parser): group = parser.getgroup("terminal reporting") @@ -76,18 +78,12 @@ def pytest_configure(config): config._xml = LogXML(xmlpath, config.option.junitprefix) config.pluginmanager.register(config._xml) - def binding(name, value): - config._xml.record_property(name, value) - record_property.binding = binding - def pytest_unconfigure(config): xml = getattr(config, '_xml', None) if xml: del config._xml config.pluginmanager.unregister(xml) - del record_property.binding - def mangle_testnames(names): names = [x.replace(".py", "") for x in names if x != '()'] names[0] = names[0].replace("/", '.') diff --git a/doc/en/usage.rst b/doc/en/usage.rst index baa625bdb..6c79d5eb0 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -162,6 +162,8 @@ record_property("key", value):: pytest.record_property("example_key", 1) ... +Warning: using this feature will break any schema verification. + Creating resultlog format files ---------------------------------------------------- diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 2ff9ca6ed..706b4e639 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -554,9 +554,8 @@ def test_unicode_issue368(testdir): def test_record_property(testdir): testdir.makepyfile(""" - from pytest import record_property - def test_record(): - record_property("foo", "<1"); + def test_record(record_xml_property): + record_xml_property("foo", "<1"); """) result, dom = runandparse(testdir) node = dom.getElementsByTagName("testsuite")[0] From 44d9365da045d9a7c494cb5edf000a90a25f8da2 Mon Sep 17 00:00:00 2001 From: David Diaz Date: Fri, 21 Aug 2015 15:21:12 -0600 Subject: [PATCH 35/38] Add warning of "preliminary feature" to record_xml_property --- doc/en/usage.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/en/usage.rst b/doc/en/usage.rst index 6c79d5eb0..c88b2b3c2 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -162,7 +162,9 @@ record_property("key", value):: pytest.record_property("example_key", 1) ... -Warning: using this feature will break any schema verification. +Warning: + - This is a preliminary feature. + - Using this feature will break any schema verification. Creating resultlog format files ---------------------------------------------------- From 06585f5bddd5bd071a74ed086e495dd8c8210030 Mon Sep 17 00:00:00 2001 From: elizabeth Date: Sun, 23 Aug 2015 13:42:40 +0300 Subject: [PATCH 36/38] Always report error about parametrize data that doesn't correspond to fixtures in test functions. --- _pytest/python.py | 9 ++++----- testing/python/metafunc.py | 5 ++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/_pytest/python.py b/_pytest/python.py index bf9be9f12..e50c7909d 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -893,15 +893,14 @@ class Metafunc(FuncargnamesCompatAttr): scope = "function" scopenum = scopes.index(scope) valtypes = {} + for arg in argnames: + if arg not in self.fixturenames: + raise ValueError("%r uses no fixture %r" %(self.function, arg)) + if indirect is True: valtypes = dict.fromkeys(argnames, "params") elif indirect is False: valtypes = dict.fromkeys(argnames, "funcargs") - #XXX should we also check for the opposite case? - for arg in argnames: - if arg not in self.fixturenames: - raise ValueError("%r uses no fixture %r" %( - self.function, arg)) elif isinstance(indirect, (tuple, list)): valtypes = dict.fromkeys(argnames, "funcargs") for arg in indirect: diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index b4395c2c8..6ef80eeea 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -214,12 +214,11 @@ class TestMetafunc: metafunc = self.Metafunc(func) metafunc.parametrize('x', [1], indirect=True) metafunc.parametrize('y', [2,3], indirect=True) - metafunc.parametrize('unnamed', [1], indirect=True) assert len(metafunc._calls) == 2 assert metafunc._calls[0].funcargs == {} assert metafunc._calls[1].funcargs == {} - assert metafunc._calls[0].params == dict(x=1,y=2, unnamed=1) - assert metafunc._calls[1].params == dict(x=1,y=3, unnamed=1) + assert metafunc._calls[0].params == dict(x=1,y=2) + assert metafunc._calls[1].params == dict(x=1,y=3) @pytest.mark.issue714 def test_parametrize_indirect_list(self): From a20c6d072d70c535ed1f116fc04016c834ea9c14 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sun, 23 Aug 2015 11:20:34 -0300 Subject: [PATCH 37/38] Fix getdoctarget to ignore comment lines --- doc/en/_getdoctarget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/_getdoctarget.py b/doc/en/_getdoctarget.py index 70427f745..20e487bb7 100755 --- a/doc/en/_getdoctarget.py +++ b/doc/en/_getdoctarget.py @@ -6,7 +6,7 @@ def get_version_string(): fn = py.path.local(__file__).join("..", "..", "..", "_pytest", "__init__.py") for line in fn.readlines(): - if "version" in line: + if "version" in line and not line.strip().startswith('#'): return eval(line.split("=")[-1]) def get_minor_version_string(): From 70da93145d1b821380315c39be1c052a4c8d3c54 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sun, 23 Aug 2015 11:45:39 -0300 Subject: [PATCH 38/38] Improve docs and using warning system for record_xml_property fixture --- CHANGELOG | 4 ++-- _pytest/junitxml.py | 18 ++++++++++++------ doc/en/usage.rst | 37 +++++++++++++++++++++++++++---------- testing/test_junitxml.py | 3 ++- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b66eeb95f..23a67e5e5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -138,8 +138,8 @@ - fix issue890: changed extension of all documentation files from ``txt`` to ``rst``. Thanks to Abhijeet for the PR. -- implement feature on issue951: support to log additional information on xml - output. +- issue951: add new record_xml_property fixture, that supports logging + additional information on xml output. Thanks David Diaz for the PR. 2.7.3 (compared to 2.7.2) ----------------------------- diff --git a/_pytest/junitxml.py b/_pytest/junitxml.py index c481fc1f0..8b75b139a 100644 --- a/_pytest/junitxml.py +++ b/_pytest/junitxml.py @@ -56,11 +56,17 @@ def bin_xml_escape(arg): @pytest.fixture def record_xml_property(request): - if hasattr(request.config, "_xml"): - return request.config._xml.record_property - def dummy(*args, **kwargs): - pass - return dummy + """Fixture that adds extra xml properties to the tag for the calling test. + The fixture is callable with (name, value), with value being automatically + xml-encoded. + """ + def inner(name, value): + if hasattr(request.config, "_xml"): + request.config._xml.add_custom_property(name, value) + msg = 'record_xml_property is an experimental feature' + request.config.warn(code='C3', message=msg, + fslocation=request.node.location[:2]) + return inner def pytest_addoption(parser): group = parser.getgroup("terminal reporting") @@ -99,7 +105,7 @@ class LogXML(object): self.failed = self.errors = 0 self.custom_properties = {} - def record_property(self, name, value): + def add_custom_property(self, name, value): self.custom_properties[str(name)] = bin_xml_escape(str(value)) def _opentestcase(self, report): diff --git a/doc/en/usage.rst b/doc/en/usage.rst index c88b2b3c2..85478d51c 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -153,18 +153,35 @@ integration servers, use this invocation:: to create an XML file at ``path``. -If you want to log additional information for a test, you can use -record_property("key", value):: +record_xml_property +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - import pytest - def test_function(): - ... - pytest.record_property("example_key", 1) - ... +.. versionadded:: 2.8 -Warning: - - This is a preliminary feature. - - Using this feature will break any schema verification. +If you want to log additional information for a test, you can use the +``record_xml_property`` fixture: + +.. code-block:: python + + def test_function(record_xml_property): + record_xml_property("example_key", 1) + assert 0 + +This will add an extra property ``example_key="1"`` to the generated +``testcase`` tag: + +.. code-block:: xml + + + +.. warning:: + + This 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. + + Also please note that using this feature will break any schema verification. + This might be a problem when used with some CI servers. Creating resultlog format files ---------------------------------------------------- diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 706b4e639..9e819f756 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -557,7 +557,8 @@ def test_record_property(testdir): def test_record(record_xml_property): record_xml_property("foo", "<1"); """) - result, dom = runandparse(testdir) + result, dom = runandparse(testdir, '-rw') node = dom.getElementsByTagName("testsuite")[0] tnode = node.getElementsByTagName("testcase")[0] assert_attr(tnode, foo="<1") + result.stdout.fnmatch_lines('*C3*test_record_property.py*experimental*')