Merge pull request #4124 from nicoddemus/traceback-import-error-3332

Improve tracebacks for ImportErrors in conftest
This commit is contained in:
Bruno Oliveira 2018-10-13 09:25:10 -03:00 committed by GitHub
commit ed42ada373
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 129 additions and 61 deletions

View File

@ -0,0 +1,4 @@
Improve the error displayed when a ``conftest.py`` file could not be imported.
In order to implement this, a new ``chain`` parameter was added to ``ExceptionInfo.getrepr``
to show or hide chained tracebacks in Python 3 (defaults to ``True``).

View File

@ -4,6 +4,7 @@ from .code import Code # noqa
from .code import ExceptionInfo # noqa from .code import ExceptionInfo # noqa
from .code import Frame # noqa from .code import Frame # noqa
from .code import Traceback # noqa from .code import Traceback # noqa
from .code import filter_traceback # noqa
from .code import getrawcode # noqa from .code import getrawcode # noqa
from .source import Source # noqa from .source import Source # noqa
from .source import compile_ as compile # noqa from .source import compile_ as compile # noqa

View File

@ -6,8 +6,10 @@ import traceback
from inspect import CO_VARARGS, CO_VARKEYWORDS from inspect import CO_VARARGS, CO_VARKEYWORDS
import attr import attr
import pluggy
import re import re
from weakref import ref from weakref import ref
import _pytest
from _pytest.compat import _PY2, _PY3, PY35, safe_str from _pytest.compat import _PY2, _PY3, PY35, safe_str
from six import text_type from six import text_type
import py import py
@ -451,13 +453,35 @@ class ExceptionInfo(object):
tbfilter=True, tbfilter=True,
funcargs=False, funcargs=False,
truncate_locals=True, truncate_locals=True,
chain=True,
): ):
""" return str()able representation of this exception info. """
showlocals: show locals per traceback entry Return str()able representation of this exception info.
style: long|short|no|native traceback style
tbfilter: hide entries (where __tracebackhide__ is true)
in case of style==native, tbfilter and showlocals is ignored. :param bool showlocals:
Show locals per traceback entry.
Ignored if ``style=="native"``.
:param str style: long|short|no|native traceback style
:param bool abspath:
If paths should be changed to absolute or left unchanged.
:param bool tbfilter:
Hide entries that contain a local variable ``__tracebackhide__==True``.
Ignored if ``style=="native"``.
:param bool funcargs:
Show fixtures ("funcargs" for legacy purposes) per traceback entry.
:param bool truncate_locals:
With ``showlocals==True``, make sure locals can be safely represented as strings.
:param bool chain: if chained exceptions in Python 3 should be shown.
.. versionchanged:: 3.9
Added the ``chain`` parameter.
""" """
if style == "native": if style == "native":
return ReprExceptionInfo( return ReprExceptionInfo(
@ -476,6 +500,7 @@ class ExceptionInfo(object):
tbfilter=tbfilter, tbfilter=tbfilter,
funcargs=funcargs, funcargs=funcargs,
truncate_locals=truncate_locals, truncate_locals=truncate_locals,
chain=chain,
) )
return fmt.repr_excinfo(self) return fmt.repr_excinfo(self)
@ -516,6 +541,7 @@ class FormattedExcinfo(object):
tbfilter = attr.ib(default=True) tbfilter = attr.ib(default=True)
funcargs = attr.ib(default=False) funcargs = attr.ib(default=False)
truncate_locals = attr.ib(default=True) truncate_locals = attr.ib(default=True)
chain = attr.ib(default=True)
astcache = attr.ib(default=attr.Factory(dict), init=False, repr=False) astcache = attr.ib(default=attr.Factory(dict), init=False, repr=False)
def _getindent(self, source): def _getindent(self, source):
@ -735,7 +761,7 @@ class FormattedExcinfo(object):
reprcrash = None reprcrash = None
repr_chain += [(reprtraceback, reprcrash, descr)] repr_chain += [(reprtraceback, reprcrash, descr)]
if e.__cause__ is not None: if e.__cause__ is not None and self.chain:
e = e.__cause__ e = e.__cause__
excinfo = ( excinfo = (
ExceptionInfo((type(e), e, e.__traceback__)) ExceptionInfo((type(e), e, e.__traceback__))
@ -743,7 +769,11 @@ class FormattedExcinfo(object):
else None else None
) )
descr = "The above exception was the direct cause of the following exception:" descr = "The above exception was the direct cause of the following exception:"
elif e.__context__ is not None and not e.__suppress_context__: elif (
e.__context__ is not None
and not e.__suppress_context__
and self.chain
):
e = e.__context__ e = e.__context__
excinfo = ( excinfo = (
ExceptionInfo((type(e), e, e.__traceback__)) ExceptionInfo((type(e), e, e.__traceback__))
@ -979,3 +1009,36 @@ else:
return "maximum recursion depth exceeded" in str(excinfo.value) return "maximum recursion depth exceeded" in str(excinfo.value)
except UnicodeError: except UnicodeError:
return False return False
# relative paths that we use to filter traceback entries from appearing to the user;
# see filter_traceback
# note: if we need to add more paths than what we have now we should probably use a list
# for better maintenance
_PLUGGY_DIR = py.path.local(pluggy.__file__.rstrip("oc"))
# pluggy is either a package or a single module depending on the version
if _PLUGGY_DIR.basename == "__init__.py":
_PLUGGY_DIR = _PLUGGY_DIR.dirpath()
_PYTEST_DIR = py.path.local(_pytest.__file__).dirpath()
_PY_DIR = 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
raw_filename = entry.frame.code.raw.co_filename
is_generated = "<" in raw_filename and ">" in raw_filename
if is_generated:
return False
# entry.path might point to a non-existing file, in which case it will
# also return a str object. see #1133
p = py.path.local(entry.path)
return (
not p.relto(_PLUGGY_DIR) and not p.relto(_PYTEST_DIR) and not p.relto(_PY_DIR)
)

View File

@ -3,7 +3,6 @@ from __future__ import absolute_import, division, print_function
import argparse import argparse
import inspect import inspect
import shlex import shlex
import traceback
import types import types
import warnings import warnings
import copy import copy
@ -19,6 +18,7 @@ import _pytest._code
import _pytest.hookspec # the extension point definitions import _pytest.hookspec # the extension point definitions
import _pytest.assertion import _pytest.assertion
from pluggy import PluginManager, HookimplMarker, HookspecMarker from pluggy import PluginManager, HookimplMarker, HookspecMarker
from _pytest._code import ExceptionInfo, filter_traceback
from _pytest.compat import safe_str from _pytest.compat import safe_str
from .exceptions import UsageError, PrintHelp from .exceptions import UsageError, PrintHelp
from .findpaths import determine_setup, exists from .findpaths import determine_setup, exists
@ -26,9 +26,6 @@ from .findpaths import determine_setup, exists
hookimpl = HookimplMarker("pytest") hookimpl = HookimplMarker("pytest")
hookspec = HookspecMarker("pytest") hookspec = HookspecMarker("pytest")
# pytest startup
#
class ConftestImportFailure(Exception): class ConftestImportFailure(Exception):
def __init__(self, path, excinfo): def __init__(self, path, excinfo):
@ -36,12 +33,6 @@ class ConftestImportFailure(Exception):
self.path = path self.path = path
self.excinfo = excinfo self.excinfo = excinfo
def __str__(self):
etype, evalue, etb = self.excinfo
formatted = traceback.format_tb(etb)
# The level of the tracebacks we want to print is hand crafted :(
return repr(evalue) + "\n" + "".join(formatted[2:])
def main(args=None, plugins=None): def main(args=None, plugins=None):
""" return exit code, after performing an in-process test run. """ return exit code, after performing an in-process test run.
@ -57,10 +48,20 @@ def main(args=None, plugins=None):
try: try:
config = _prepareconfig(args, plugins) config = _prepareconfig(args, plugins)
except ConftestImportFailure as e: except ConftestImportFailure as e:
exc_info = ExceptionInfo(e.excinfo)
tw = py.io.TerminalWriter(sys.stderr) tw = py.io.TerminalWriter(sys.stderr)
for line in traceback.format_exception(*e.excinfo): tw.line(
"ImportError while loading conftest '{e.path}'.".format(e=e), red=True
)
exc_info.traceback = exc_info.traceback.filter(filter_traceback)
exc_repr = (
exc_info.getrepr(style="short", chain=False)
if exc_info.traceback
else exc_info.exconly()
)
formatted_tb = safe_str(exc_repr)
for line in formatted_tb.splitlines():
tw.line(line.rstrip(), red=True) tw.line(line.rstrip(), red=True)
tw.line("ERROR: could not load %s\n" % (e.path,), red=True)
return 4 return 4
else: else:
try: try:

View File

@ -16,7 +16,7 @@ from _pytest.main import FSHookProxy
from _pytest.config import hookimpl from _pytest.config import hookimpl
import _pytest import _pytest
import pluggy from _pytest._code import filter_traceback
from _pytest import fixtures from _pytest import fixtures
from _pytest import nodes from _pytest import nodes
from _pytest import deprecated from _pytest import deprecated
@ -46,37 +46,6 @@ from _pytest.mark.structures import (
) )
from _pytest.warning_types import RemovedInPytest4Warning, PytestWarning from _pytest.warning_types import RemovedInPytest4Warning, PytestWarning
# relative paths that we use to filter traceback entries from appearing to the user;
# see filter_traceback
# note: if we need to add more paths than what we have now we should probably use a list
# for better maintenance
_pluggy_dir = py.path.local(pluggy.__file__.rstrip("oc"))
# pluggy is either a package or a single module depending on the version
if _pluggy_dir.basename == "__init__.py":
_pluggy_dir = _pluggy_dir.dirpath()
_pytest_dir = py.path.local(_pytest.__file__).dirpath()
_py_dir = 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
raw_filename = entry.frame.code.raw.co_filename
is_generated = "<" in raw_filename and ">" in raw_filename
if is_generated:
return False
# entry.path might point to a non-existing file, in which case it will
# also return a str object. see #1133
p = py.path.local(entry.path)
return (
not p.relto(_pluggy_dir) and not p.relto(_pytest_dir) and not p.relto(_py_dir)
)
def pyobj_property(name): def pyobj_property(name):
def get(self): def get(self):

View File

@ -133,9 +133,16 @@ class TestGeneralUsage(object):
assert result.ret assert result.ret
result.stderr.fnmatch_lines(["*ERROR: not found:*{}".format(p2.basename)]) result.stderr.fnmatch_lines(["*ERROR: not found:*{}".format(p2.basename)])
def test_issue486_better_reporting_on_conftest_load_failure(self, testdir): def test_better_reporting_on_conftest_load_failure(self, testdir, request):
"""Show a user-friendly traceback on conftest import failures (#486, #3332)"""
testdir.makepyfile("") testdir.makepyfile("")
testdir.makeconftest("import qwerty") testdir.makeconftest(
"""
def foo():
import qwerty
foo()
"""
)
result = testdir.runpytest("--help") result = testdir.runpytest("--help")
result.stdout.fnmatch_lines( result.stdout.fnmatch_lines(
""" """
@ -144,10 +151,23 @@ class TestGeneralUsage(object):
""" """
) )
result = testdir.runpytest() result = testdir.runpytest()
dirname = request.node.name + "0"
exc_name = (
"ModuleNotFoundError" if sys.version_info >= (3, 6) else "ImportError"
)
result.stderr.fnmatch_lines( result.stderr.fnmatch_lines(
""" [
*ERROR*could not load*conftest.py* "ImportError while loading conftest '*{sep}{dirname}{sep}conftest.py'.".format(
""" dirname=dirname, sep=os.sep
),
"conftest.py:3: in <module>",
" foo()",
"conftest.py:2: in foo",
" import qwerty",
"E {}: No module named {q}qwerty{q}".format(
exc_name, q="'" if six.PY3 else ""
),
]
) )
def test_early_skip(self, testdir): def test_early_skip(self, testdir):

View File

@ -1184,20 +1184,28 @@ raise ValueError()
assert tw.lines[47] == ":15: AttributeError" assert tw.lines[47] == ":15: AttributeError"
@pytest.mark.skipif("sys.version_info[0] < 3") @pytest.mark.skipif("sys.version_info[0] < 3")
def test_exc_repr_with_raise_from_none_chain_suppression(self, importasmod): @pytest.mark.parametrize("mode", ["from_none", "explicit_suppress"])
def test_exc_repr_chain_suppression(self, importasmod, mode):
"""Check that exc repr does not show chained exceptions in Python 3.
- When the exception is raised with "from None"
- Explicitly suppressed with "chain=False" to ExceptionInfo.getrepr().
"""
raise_suffix = " from None" if mode == "from_none" else ""
mod = importasmod( mod = importasmod(
""" """
def f(): def f():
try: try:
g() g()
except Exception: except Exception:
raise AttributeError() from None raise AttributeError(){raise_suffix}
def g(): def g():
raise ValueError() raise ValueError()
""" """.format(
raise_suffix=raise_suffix
)
) )
excinfo = pytest.raises(AttributeError, mod.f) excinfo = pytest.raises(AttributeError, mod.f)
r = excinfo.getrepr(style="long") r = excinfo.getrepr(style="long", chain=mode != "explicit_suppress")
tw = TWMock() tw = TWMock()
r.toterminal(tw) r.toterminal(tw)
for line in tw.lines: for line in tw.lines:
@ -1207,7 +1215,9 @@ raise ValueError()
assert tw.lines[2] == " try:" assert tw.lines[2] == " try:"
assert tw.lines[3] == " g()" assert tw.lines[3] == " g()"
assert tw.lines[4] == " except Exception:" assert tw.lines[4] == " except Exception:"
assert tw.lines[5] == "> raise AttributeError() from None" assert tw.lines[5] == "> raise AttributeError(){}".format(
raise_suffix
)
assert tw.lines[6] == "E AttributeError" assert tw.lines[6] == "E AttributeError"
assert tw.lines[7] == "" assert tw.lines[7] == ""
line = tw.get_write_msg(8) line = tw.get_write_msg(8)