diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 198780a2a..1191fad27 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,6 +3,9 @@ Thanks for submitting a PR, your contribution is really appreciated! Here's a quick checklist that should be present in PRs: - [ ] Target: for bug or doc fixes, target `master`; for new features, target `features`; + +Unless your change is trivial documentation fix (e.g., a typo or reword of a small section) please: + - [ ] Make sure to include one or more tests for your change; - [ ] Add yourself to `AUTHORS`; - [ ] Add a new entry to `CHANGELOG.rst` diff --git a/.travis.yml b/.travis.yml index 2f282e94b..8c36a298c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,7 +22,8 @@ env: - TESTENV=py27-trial - TESTENV=py35-pexpect - TESTENV=py35-xdist - - TESTENV=py35-trial + # Disable py35-trial temporarily: #1989 + #- TESTENV=py35-trial - TESTENV=py27-nobyte - TESTENV=doctesting - TESTENV=freeze diff --git a/AUTHORS b/AUTHORS index b4f33914a..04fa9bfad 100644 --- a/AUTHORS +++ b/AUTHORS @@ -59,6 +59,7 @@ Georgy Dyuldin Graham Horler Greg Price Grig Gheorghiu +Grigorii Eremeev (budulianin) Guido Wesdorp Harald Armin Massa Ian Bicking diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 10ccd628a..57cb3df44 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -41,11 +41,25 @@ Changes * -* +* Import errors when collecting test modules now display the full traceback (`#1976`_). + Thanks `@cwitty`_ for the report and `@nicoddemus`_ for the PR. + +* Fix confusing command-line help message for custom options with two or more `metavar` properties (`#2004`_). + Thanks `@okulynyak`_ and `@davehunt`_ for the report and `@nicoddemus`_ for the PR. + +* When loading plugins, import errors which contain non-ascii messages are now properly handled in Python 2 (`#1998`_). + Thanks `@nicoddemus`_ for the PR. * -* + +.. _@cwitty: https://github.com/cwitty +.. _@okulynyak: https://github.com/okulynyak + +.. _#1976: https://github.com/pytest-dev/pytest/issues/1976 +.. _#1998: https://github.com/pytest-dev/pytest/issues/1998 +.. _#2004: https://github.com/pytest-dev/pytest/issues/2004 + 3.0.3 diff --git a/_pytest/compat.py b/_pytest/compat.py index 16505c31c..d5fa7aa84 100644 --- a/_pytest/compat.py +++ b/_pytest/compat.py @@ -216,3 +216,17 @@ def _is_unittest_unexpected_success_a_failure(): unexpectedSuccesses from tests marked with the expectedFailure() decorator. """ return sys.version_info >= (3, 4) + + +if _PY3: + def safe_str(v): + """returns v as string""" + return str(v) +else: + def safe_str(v): + """returns v as string, converting to ascii if necessary""" + try: + return str(v) + except UnicodeError: + errors = 'replace' + return v.encode('ascii', errors) diff --git a/_pytest/config.py b/_pytest/config.py index 661a8513d..5df198e21 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -12,6 +12,7 @@ import _pytest._code import _pytest.hookspec # the extension point definitions import _pytest.assertion from _pytest._pluggy import PluginManager, HookimplMarker, HookspecMarker +from _pytest.compat import safe_str hookimpl = HookimplMarker("pytest") hookspec = HookspecMarker("pytest") @@ -405,7 +406,7 @@ class PytestPluginManager(PluginManager): try: __import__(importspec) except ImportError as e: - new_exc = ImportError('Error importing plugin "%s": %s' % (modname, e)) + new_exc = ImportError('Error importing plugin "%s": %s' % (modname, safe_str(e.args[0]))) # copy over name and path attributes for attr in ('name', 'path'): if hasattr(e, attr): @@ -792,7 +793,7 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter): if len(option) == 2 or option[2] == ' ': return_list.append(option) if option[2:] == short_long.get(option.replace('-', '')): - return_list.append(option.replace(' ', '=')) + return_list.append(option.replace(' ', '=', 1)) action._formatted_action_invocation = ', '.join(return_list) return action._formatted_action_invocation diff --git a/_pytest/helpconfig.py b/_pytest/helpconfig.py index 84f419a08..538a763ca 100644 --- a/_pytest/helpconfig.py +++ b/_pytest/helpconfig.py @@ -71,8 +71,8 @@ def showhelp(config): tw.write(config._parser.optparser.format_help()) tw.line() tw.line() - tw.line("[pytest] ini-options in the next " - "pytest.ini|tox.ini|setup.cfg file:") + tw.line("[pytest] ini-options in the first " + "pytest.ini|tox.ini|setup.cfg file found:") tw.line() for name in config._parser._ininames: diff --git a/_pytest/python.py b/_pytest/python.py index b78d36881..db345fe27 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -22,11 +22,16 @@ from _pytest.compat import ( getlocation, enum, ) -cutdir2 = py.path.local(_pytest.__file__).dirpath() cutdir1 = py.path.local(pluggy.__file__.rstrip("oc")) +cutdir2 = py.path.local(_pytest.__file__).dirpath() +cutdir3 = py.path.local(py.__file__).dirpath() def filter_traceback(entry): + """Return True if a TracebackEntry instance should be removed from tracebacks: + * dynamically generated code (no code to show up for it); + * internal traceback from pytest or its internal libraries, py and pluggy. + """ # entry.path might sometimes return a str object when the entry # points to dynamically generated code # see https://bitbucket.org/pytest-dev/py/issues/71 @@ -37,7 +42,7 @@ def filter_traceback(entry): # entry.path might point to an inexisting file, in which case it will # alsso return a str object. see #1133 p = py.path.local(entry.path) - return p != cutdir1 and not p.relto(cutdir2) + return p != cutdir1 and not p.relto(cutdir2) and not p.relto(cutdir3) @@ -425,20 +430,25 @@ class Module(pytest.File, PyCollector): % e.args ) except ImportError: - exc_class, exc, _ = sys.exc_info() + from _pytest._code.code import ExceptionInfo + exc_info = ExceptionInfo() + if self.config.getoption('verbose') < 2: + exc_info.traceback = exc_info.traceback.filter(filter_traceback) + exc_repr = exc_info.getrepr(style='short') if exc_info.traceback else exc_info.exconly() + formatted_tb = py._builtin._totext(exc_repr) raise self.CollectError( - "ImportError while importing test module '%s'.\n" - "Original error message:\n'%s'\n" - "Make sure your test modules/packages have valid Python names." - % (self.fspath, exc or exc_class) + "ImportError while importing test module '{fspath}'.\n" + "Hint: make sure your test modules/packages have valid Python names.\n" + "Traceback:\n" + "{traceback}".format(fspath=self.fspath, traceback=formatted_tb) ) except _pytest.runner.Skipped as e: if e.allow_module_level: raise raise self.CollectError( - "Using @pytest.skip outside of a test (e.g. as a test " - "function decorator) is not allowed. Use @pytest.mark.skip or " - "@pytest.mark.skipif instead." + "Using pytest.skip outside of a test is not allowed. If you are " + "trying to decorate a test function, use the @pytest.mark.skip " + "or @pytest.mark.skipif decorators instead." ) self.config.pluginmanager.consider_module(mod) return mod diff --git a/doc/en/fixture.rst b/doc/en/fixture.rst index d16f49e12..443e6b6be 100644 --- a/doc/en/fixture.rst +++ b/doc/en/fixture.rst @@ -701,7 +701,7 @@ Using fixtures from classes, modules or projects Sometimes test functions do not directly need access to a fixture object. For example, tests may require to operate with an empty directory as the current working directory but otherwise do not care for the concrete -directory. Here is how you can can use the standard `tempfile +directory. Here is how you can use the standard `tempfile `_ and pytest fixtures to achieve it. We separate the creation of the fixture into a conftest.py file:: diff --git a/doc/en/goodpractices.rst b/doc/en/goodpractices.rst index 7c2fdccf2..2a5d4d7c8 100644 --- a/doc/en/goodpractices.rst +++ b/doc/en/goodpractices.rst @@ -236,9 +236,10 @@ your own setuptools Test command for invoking pytest. self.pytest_args = [] def run_tests(self): + import shlex #import here, cause outside the eggs aren't loaded import pytest - errno = pytest.main(self.pytest_args) + errno = pytest.main(shlex.split(self.pytest_args)) sys.exit(errno) diff --git a/doc/en/monkeypatch.rst b/doc/en/monkeypatch.rst index e85d94d6d..806e910bd 100644 --- a/doc/en/monkeypatch.rst +++ b/doc/en/monkeypatch.rst @@ -55,6 +55,14 @@ will delete the method ``request.session.Session.request`` so that any attempts within tests to create http requests will fail. +.. note:: + + Be advised that it is not recommended to patch builtin functions such as ``open``, + ``compile``, etc., because it might break pytest's internals. If that's + unavoidable, passing ``--tb=native``, ``--assert=plain`` and ``--capture=no`` might + help althought there's no guarantee. + + Method reference of the monkeypatch fixture ------------------------------------------- diff --git a/doc/en/usage.rst b/doc/en/usage.rst index f87e1496d..ef63a8e06 100644 --- a/doc/en/usage.rst +++ b/doc/en/usage.rst @@ -310,10 +310,6 @@ You can pass in options and arguments:: pytest.main(['-x', 'mytestdir']) -or pass in a string:: - - pytest.main("-x mytestdir") - You can specify additional plugins to ``pytest.main``:: # content of myinvoke.py diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index b03f7fe4c..88e3fa449 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -120,7 +120,7 @@ class TestGeneralUsage: result.stdout.fnmatch_lines([ #XXX on jython this fails: "> import import_fails", "ImportError while importing test module*", - "'No module named *does_not_work*", + "*No module named *does_not_work*", ]) assert result.ret == 2 diff --git a/testing/python/collect.py b/testing/python/collect.py index 843f26a73..2913b11a4 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import os import sys from textwrap import dedent @@ -68,9 +69,41 @@ class TestModule: result = testdir.runpytest("-rw") result.stdout.fnmatch_lines([ "ImportError while importing test module*test_one.part1*", - "Make sure your test modules/packages have valid Python names.", + "Hint: make sure your test modules/packages have valid Python names.", ]) + @pytest.mark.parametrize('verbose', [0, 1, 2]) + def test_show_traceback_import_error(self, testdir, verbose): + """Import errors when collecting modules should display the traceback (#1976). + + With low verbosity we omit pytest and internal modules, otherwise show all traceback entries. + """ + testdir.makepyfile( + foo_traceback_import_error=""" + from bar_traceback_import_error import NOT_AVAILABLE + """, + bar_traceback_import_error="", + ) + testdir.makepyfile(""" + import foo_traceback_import_error + """) + args = ('-v',) * verbose + result = testdir.runpytest(*args) + result.stdout.fnmatch_lines([ + "ImportError while importing test module*", + "Traceback:", + "*from bar_traceback_import_error import NOT_AVAILABLE", + "*cannot import name *NOT_AVAILABLE*", + ]) + assert result.ret == 2 + + stdout = result.stdout.str() + for name in ('_pytest', os.path.join('py', '_path')): + if verbose == 2: + assert name in stdout + else: + assert name not in stdout + class TestClass: def test_class_with_init_warning(self, testdir): diff --git a/testing/test_collection.py b/testing/test_collection.py index 8e44ba55d..71a64c3c9 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -172,17 +172,6 @@ class TestCollectPluginHookRelay: assert "world" in wascalled class TestPrunetraceback: - def test_collection_error(self, testdir): - p = testdir.makepyfile(""" - import not_exists - """) - result = testdir.runpytest(p) - assert "__import__" not in result.stdout.str(), "too long traceback" - result.stdout.fnmatch_lines([ - "*ERROR collecting*", - "ImportError while importing test module*", - "'No module named *not_exists*", - ]) def test_custom_repr_failure(self, testdir): p = testdir.makepyfile(""" diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index cc9aa23cd..fc9ded488 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -248,7 +248,19 @@ class TestParser: help="show help message and configuration info") parser.parse(['-h']) help = parser.optparser.format_help() - assert '-doit, --func-args foo' in help + assert '-doit, --func-args foo' in help + + def test_multiple_metavar_help(self, parser): + """ + Help text for options with a metavar tuple should display help + in the form "--preferences=value1 value2 value3" (#2004). + """ + group = parser.getgroup("general") + group.addoption('--preferences', metavar=('value1', 'value2', 'value3'), nargs=3) + group._addoption("-h", "--help", action="store_true", dest="help") + parser.parse(['-h']) + help = parser.optparser.format_help() + assert '--preferences=value1 value2 value3' in help def test_argcomplete(testdir, monkeypatch): diff --git a/testing/test_pluginmanager.py b/testing/test_pluginmanager.py index 36847638d..e61c84247 100644 --- a/testing/test_pluginmanager.py +++ b/testing/test_pluginmanager.py @@ -1,3 +1,4 @@ +# encoding: UTF-8 import pytest import py import os @@ -179,15 +180,20 @@ def test_default_markers(testdir): ]) -def test_importplugin_issue375(testdir, pytestpm): +def test_importplugin_error_message(testdir, pytestpm): """Don't hide import errors when importing plugins and provide an easy to debug message. + + See #375 and #1998. """ testdir.syspathinsert(testdir.tmpdir) - testdir.makepyfile(qwe="import aaaa") + testdir.makepyfile(qwe=""" + # encoding: UTF-8 + raise ImportError(u'Not possible to import: ☺') + """) with pytest.raises(ImportError) as excinfo: pytestpm.import_plugin("qwe") - expected = '.*Error importing plugin "qwe": No module named \'?aaaa\'?' + expected = '.*Error importing plugin "qwe": Not possible to import: .' assert py.std.re.match(expected, str(excinfo.value)) diff --git a/testing/test_skipping.py b/testing/test_skipping.py index 12b18ca33..2e7868d3a 100644 --- a/testing/test_skipping.py +++ b/testing/test_skipping.py @@ -967,5 +967,5 @@ def test_module_level_skip_error(testdir): """) result = testdir.runpytest() result.stdout.fnmatch_lines( - "*Using @pytest.skip outside of a test * is not allowed*" + "*Using pytest.skip outside of a test is not allowed*" ) diff --git a/testing/test_terminal.py b/testing/test_terminal.py index fc6f3b7b1..66214c80e 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -667,7 +667,7 @@ class TestGenericReporting: result = testdir.runpytest(*option.args) result.stdout.fnmatch_lines([ "ImportError while importing*", - "'No module named *xyz*", + "*No module named *xyz*", "*1 error*", ])