Skip to content

Improve tracebacks for ImportErrors in conftest #4124

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/3332.feature.rst
Original file line number Diff line number Diff line change
@@ -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``).
1 change: 1 addition & 0 deletions src/_pytest/_code/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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
Expand Down
77 changes: 70 additions & 7 deletions src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
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
Expand Down Expand Up @@ -451,13 +453,35 @@ def getrepr(
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.

: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

in case of style==native, tbfilter and showlocals is ignored.
Added the ``chain`` parameter.
"""
if style == "native":
return ReprExceptionInfo(
Expand All @@ -476,6 +500,7 @@ def getrepr(
tbfilter=tbfilter,
funcargs=funcargs,
truncate_locals=truncate_locals,
chain=chain,
)
return fmt.repr_excinfo(self)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -735,15 +761,19 @@ def repr_excinfo(self, excinfo):
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__))
if e.__traceback__
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__))
Expand Down Expand Up @@ -979,3 +1009,36 @@ def is_recursion_error(excinfo):
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<> would be a valid filename, no? Similar to foo><bar - so maybe use startswith/endswith at least?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that it is just moved code though.

Copy link
Member Author

@nicoddemus nicoddemus Oct 12, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, but I'm not sure I would like to touch that now, there might be some corner case which is covered by that, plus <> are not valid characters for filenames AFAIK so I think we're safe. 😁

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)
)
25 changes: 13 additions & 12 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import argparse
import inspect
import shlex
import traceback
import types
import warnings
import copy
Expand All @@ -19,29 +18,21 @@
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

hookimpl = HookimplMarker("pytest")
hookspec = HookspecMarker("pytest")

# pytest startup
#


class ConftestImportFailure(Exception):
def __init__(self, path, excinfo):
Exception.__init__(self, path, excinfo)
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.
Expand All @@ -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:
Expand Down
33 changes: 1 addition & 32 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
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
Expand Down Expand Up @@ -46,37 +46,6 @@
)
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):
Expand Down
30 changes: 25 additions & 5 deletions testing/acceptance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,16 @@ def test_not_collectable_arguments(self, testdir):
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(
"""
Expand All @@ -144,10 +151,23 @@ def test_issue486_better_reporting_on_conftest_load_failure(self, testdir):
"""
)
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):
Expand Down
20 changes: 15 additions & 5 deletions testing/code/test_excinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1184,20 +1184,28 @@ def h():
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:
Expand All @@ -1207,7 +1215,9 @@ def g():
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)
Expand Down