Merge pull request #4124 from nicoddemus/traceback-import-error-3332
Improve tracebacks for ImportErrors in conftest
This commit is contained in:
commit
ed42ada373
|
@ -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``).
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue