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 Frame # noqa
from .code import Traceback # noqa
from .code import filter_traceback # noqa
from .code import getrawcode # noqa
from .source import Source # noqa
from .source import compile_ as compile # noqa

View File

@ -6,8 +6,10 @@ import traceback
from inspect import CO_VARARGS, CO_VARKEYWORDS
import attr
import pluggy
import re
from weakref import ref
import _pytest
from _pytest.compat import _PY2, _PY3, PY35, safe_str
from six import text_type
import py
@ -451,13 +453,35 @@ class ExceptionInfo(object):
tbfilter=True,
funcargs=False,
truncate_locals=True,
chain=True,
):
""" return str()able representation of this exception info.
showlocals: show locals per traceback entry
style: long|short|no|native traceback style
tbfilter: hide entries (where __tracebackhide__ is true)
"""
Return str()able representation of this exception info.
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":
return ReprExceptionInfo(
@ -476,6 +500,7 @@ class ExceptionInfo(object):
tbfilter=tbfilter,
funcargs=funcargs,
truncate_locals=truncate_locals,
chain=chain,
)
return fmt.repr_excinfo(self)
@ -516,6 +541,7 @@ class FormattedExcinfo(object):
tbfilter = attr.ib(default=True)
funcargs = attr.ib(default=False)
truncate_locals = attr.ib(default=True)
chain = attr.ib(default=True)
astcache = attr.ib(default=attr.Factory(dict), init=False, repr=False)
def _getindent(self, source):
@ -735,7 +761,7 @@ class FormattedExcinfo(object):
reprcrash = None
repr_chain += [(reprtraceback, reprcrash, descr)]
if e.__cause__ is not None:
if e.__cause__ is not None and self.chain:
e = e.__cause__
excinfo = (
ExceptionInfo((type(e), e, e.__traceback__))
@ -743,7 +769,11 @@ class FormattedExcinfo(object):
else None
)
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__
excinfo = (
ExceptionInfo((type(e), e, e.__traceback__))
@ -979,3 +1009,36 @@ else:
return "maximum recursion depth exceeded" in str(excinfo.value)
except UnicodeError:
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 inspect
import shlex
import traceback
import types
import warnings
import copy
@ -19,6 +18,7 @@ import _pytest._code
import _pytest.hookspec # the extension point definitions
import _pytest.assertion
from pluggy import PluginManager, HookimplMarker, HookspecMarker
from _pytest._code import ExceptionInfo, filter_traceback
from _pytest.compat import safe_str
from .exceptions import UsageError, PrintHelp
from .findpaths import determine_setup, exists
@ -26,9 +26,6 @@ from .findpaths import determine_setup, exists
hookimpl = HookimplMarker("pytest")
hookspec = HookspecMarker("pytest")
# pytest startup
#
class ConftestImportFailure(Exception):
def __init__(self, path, excinfo):
@ -36,12 +33,6 @@ class ConftestImportFailure(Exception):
self.path = path
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):
""" return exit code, after performing an in-process test run.
@ -57,10 +48,20 @@ def main(args=None, plugins=None):
try:
config = _prepareconfig(args, plugins)
except ConftestImportFailure as e:
exc_info = ExceptionInfo(e.excinfo)
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("ERROR: could not load %s\n" % (e.path,), red=True)
return 4
else:
try:

View File

@ -16,7 +16,7 @@ from _pytest.main import FSHookProxy
from _pytest.config import hookimpl
import _pytest
import pluggy
from _pytest._code import filter_traceback
from _pytest import fixtures
from _pytest import nodes
from _pytest import deprecated
@ -46,37 +46,6 @@ from _pytest.mark.structures import (
)
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 get(self):

View File

@ -133,9 +133,16 @@ class TestGeneralUsage(object):
assert result.ret
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.makeconftest("import qwerty")
testdir.makeconftest(
"""
def foo():
import qwerty
foo()
"""
)
result = testdir.runpytest("--help")
result.stdout.fnmatch_lines(
"""
@ -144,10 +151,23 @@ class TestGeneralUsage(object):
"""
)
result = testdir.runpytest()
dirname = request.node.name + "0"
exc_name = (
"ModuleNotFoundError" if sys.version_info >= (3, 6) else "ImportError"
)
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):

View File

@ -1184,20 +1184,28 @@ raise ValueError()
assert tw.lines[47] == ":15: AttributeError"
@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(
"""
def f():
try:
g()
except Exception:
raise AttributeError() from None
raise AttributeError(){raise_suffix}
def g():
raise ValueError()
"""
""".format(
raise_suffix=raise_suffix
)
)
excinfo = pytest.raises(AttributeError, mod.f)
r = excinfo.getrepr(style="long")
r = excinfo.getrepr(style="long", chain=mode != "explicit_suppress")
tw = TWMock()
r.toterminal(tw)
for line in tw.lines:
@ -1207,7 +1215,9 @@ raise ValueError()
assert tw.lines[2] == " try:"
assert tw.lines[3] == " g()"
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[7] == ""
line = tw.get_write_msg(8)