Skip to content

Commit ed42ada

Browse files
authored
Merge pull request #4124 from nicoddemus/traceback-import-error-3332
Improve tracebacks for ImportErrors in conftest
2 parents e266710 + ef97121 commit ed42ada

File tree

7 files changed

+129
-61
lines changed

7 files changed

+129
-61
lines changed

changelog/3332.feature.rst

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

src/_pytest/_code/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .code import ExceptionInfo # noqa
55
from .code import Frame # noqa
66
from .code import Traceback # noqa
7+
from .code import filter_traceback # noqa
78
from .code import getrawcode # noqa
89
from .source import Source # noqa
910
from .source import compile_ as compile # noqa

src/_pytest/_code/code.py

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
from inspect import CO_VARARGS, CO_VARKEYWORDS
77

88
import attr
9+
import pluggy
910
import re
1011
from weakref import ref
12+
import _pytest
1113
from _pytest.compat import _PY2, _PY3, PY35, safe_str
1214
from six import text_type
1315
import py
@@ -451,13 +453,35 @@ def getrepr(
451453
tbfilter=True,
452454
funcargs=False,
453455
truncate_locals=True,
456+
chain=True,
454457
):
455-
""" return str()able representation of this exception info.
456-
showlocals: show locals per traceback entry
457-
style: long|short|no|native traceback style
458-
tbfilter: hide entries (where __tracebackhide__ is true)
458+
"""
459+
Return str()able representation of this exception info.
460+
461+
:param bool showlocals:
462+
Show locals per traceback entry.
463+
Ignored if ``style=="native"``.
464+
465+
:param str style: long|short|no|native traceback style
466+
467+
:param bool abspath:
468+
If paths should be changed to absolute or left unchanged.
469+
470+
:param bool tbfilter:
471+
Hide entries that contain a local variable ``__tracebackhide__==True``.
472+
Ignored if ``style=="native"``.
473+
474+
:param bool funcargs:
475+
Show fixtures ("funcargs" for legacy purposes) per traceback entry.
476+
477+
:param bool truncate_locals:
478+
With ``showlocals==True``, make sure locals can be safely represented as strings.
479+
480+
:param bool chain: if chained exceptions in Python 3 should be shown.
481+
482+
.. versionchanged:: 3.9
459483
460-
in case of style==native, tbfilter and showlocals is ignored.
484+
Added the ``chain`` parameter.
461485
"""
462486
if style == "native":
463487
return ReprExceptionInfo(
@@ -476,6 +500,7 @@ def getrepr(
476500
tbfilter=tbfilter,
477501
funcargs=funcargs,
478502
truncate_locals=truncate_locals,
503+
chain=chain,
479504
)
480505
return fmt.repr_excinfo(self)
481506

@@ -516,6 +541,7 @@ class FormattedExcinfo(object):
516541
tbfilter = attr.ib(default=True)
517542
funcargs = attr.ib(default=False)
518543
truncate_locals = attr.ib(default=True)
544+
chain = attr.ib(default=True)
519545
astcache = attr.ib(default=attr.Factory(dict), init=False, repr=False)
520546

521547
def _getindent(self, source):
@@ -735,15 +761,19 @@ def repr_excinfo(self, excinfo):
735761
reprcrash = None
736762

737763
repr_chain += [(reprtraceback, reprcrash, descr)]
738-
if e.__cause__ is not None:
764+
if e.__cause__ is not None and self.chain:
739765
e = e.__cause__
740766
excinfo = (
741767
ExceptionInfo((type(e), e, e.__traceback__))
742768
if e.__traceback__
743769
else None
744770
)
745771
descr = "The above exception was the direct cause of the following exception:"
746-
elif e.__context__ is not None and not e.__suppress_context__:
772+
elif (
773+
e.__context__ is not None
774+
and not e.__suppress_context__
775+
and self.chain
776+
):
747777
e = e.__context__
748778
excinfo = (
749779
ExceptionInfo((type(e), e, e.__traceback__))
@@ -979,3 +1009,36 @@ def is_recursion_error(excinfo):
9791009
return "maximum recursion depth exceeded" in str(excinfo.value)
9801010
except UnicodeError:
9811011
return False
1012+
1013+
1014+
# relative paths that we use to filter traceback entries from appearing to the user;
1015+
# see filter_traceback
1016+
# note: if we need to add more paths than what we have now we should probably use a list
1017+
# for better maintenance
1018+
1019+
_PLUGGY_DIR = py.path.local(pluggy.__file__.rstrip("oc"))
1020+
# pluggy is either a package or a single module depending on the version
1021+
if _PLUGGY_DIR.basename == "__init__.py":
1022+
_PLUGGY_DIR = _PLUGGY_DIR.dirpath()
1023+
_PYTEST_DIR = py.path.local(_pytest.__file__).dirpath()
1024+
_PY_DIR = py.path.local(py.__file__).dirpath()
1025+
1026+
1027+
def filter_traceback(entry):
1028+
"""Return True if a TracebackEntry instance should be removed from tracebacks:
1029+
* dynamically generated code (no code to show up for it);
1030+
* internal traceback from pytest or its internal libraries, py and pluggy.
1031+
"""
1032+
# entry.path might sometimes return a str object when the entry
1033+
# points to dynamically generated code
1034+
# see https://bitbucket.org/pytest-dev/py/issues/71
1035+
raw_filename = entry.frame.code.raw.co_filename
1036+
is_generated = "<" in raw_filename and ">" in raw_filename
1037+
if is_generated:
1038+
return False
1039+
# entry.path might point to a non-existing file, in which case it will
1040+
# also return a str object. see #1133
1041+
p = py.path.local(entry.path)
1042+
return (
1043+
not p.relto(_PLUGGY_DIR) and not p.relto(_PYTEST_DIR) and not p.relto(_PY_DIR)
1044+
)

src/_pytest/config/__init__.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import argparse
44
import inspect
55
import shlex
6-
import traceback
76
import types
87
import warnings
98
import copy
@@ -19,29 +18,21 @@
1918
import _pytest.hookspec # the extension point definitions
2019
import _pytest.assertion
2120
from pluggy import PluginManager, HookimplMarker, HookspecMarker
21+
from _pytest._code import ExceptionInfo, filter_traceback
2222
from _pytest.compat import safe_str
2323
from .exceptions import UsageError, PrintHelp
2424
from .findpaths import determine_setup, exists
2525

2626
hookimpl = HookimplMarker("pytest")
2727
hookspec = HookspecMarker("pytest")
2828

29-
# pytest startup
30-
#
31-
3229

3330
class ConftestImportFailure(Exception):
3431
def __init__(self, path, excinfo):
3532
Exception.__init__(self, path, excinfo)
3633
self.path = path
3734
self.excinfo = excinfo
3835

39-
def __str__(self):
40-
etype, evalue, etb = self.excinfo
41-
formatted = traceback.format_tb(etb)
42-
# The level of the tracebacks we want to print is hand crafted :(
43-
return repr(evalue) + "\n" + "".join(formatted[2:])
44-
4536

4637
def main(args=None, plugins=None):
4738
""" return exit code, after performing an in-process test run.
@@ -57,10 +48,20 @@ def main(args=None, plugins=None):
5748
try:
5849
config = _prepareconfig(args, plugins)
5950
except ConftestImportFailure as e:
51+
exc_info = ExceptionInfo(e.excinfo)
6052
tw = py.io.TerminalWriter(sys.stderr)
61-
for line in traceback.format_exception(*e.excinfo):
53+
tw.line(
54+
"ImportError while loading conftest '{e.path}'.".format(e=e), red=True
55+
)
56+
exc_info.traceback = exc_info.traceback.filter(filter_traceback)
57+
exc_repr = (
58+
exc_info.getrepr(style="short", chain=False)
59+
if exc_info.traceback
60+
else exc_info.exconly()
61+
)
62+
formatted_tb = safe_str(exc_repr)
63+
for line in formatted_tb.splitlines():
6264
tw.line(line.rstrip(), red=True)
63-
tw.line("ERROR: could not load %s\n" % (e.path,), red=True)
6465
return 4
6566
else:
6667
try:

src/_pytest/python.py

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from _pytest.config import hookimpl
1717

1818
import _pytest
19-
import pluggy
19+
from _pytest._code import filter_traceback
2020
from _pytest import fixtures
2121
from _pytest import nodes
2222
from _pytest import deprecated
@@ -46,37 +46,6 @@
4646
)
4747
from _pytest.warning_types import RemovedInPytest4Warning, PytestWarning
4848

49-
# relative paths that we use to filter traceback entries from appearing to the user;
50-
# see filter_traceback
51-
# note: if we need to add more paths than what we have now we should probably use a list
52-
# for better maintenance
53-
_pluggy_dir = py.path.local(pluggy.__file__.rstrip("oc"))
54-
# pluggy is either a package or a single module depending on the version
55-
if _pluggy_dir.basename == "__init__.py":
56-
_pluggy_dir = _pluggy_dir.dirpath()
57-
_pytest_dir = py.path.local(_pytest.__file__).dirpath()
58-
_py_dir = py.path.local(py.__file__).dirpath()
59-
60-
61-
def filter_traceback(entry):
62-
"""Return True if a TracebackEntry instance should be removed from tracebacks:
63-
* dynamically generated code (no code to show up for it);
64-
* internal traceback from pytest or its internal libraries, py and pluggy.
65-
"""
66-
# entry.path might sometimes return a str object when the entry
67-
# points to dynamically generated code
68-
# see https://bitbucket.org/pytest-dev/py/issues/71
69-
raw_filename = entry.frame.code.raw.co_filename
70-
is_generated = "<" in raw_filename and ">" in raw_filename
71-
if is_generated:
72-
return False
73-
# entry.path might point to a non-existing file, in which case it will
74-
# also return a str object. see #1133
75-
p = py.path.local(entry.path)
76-
return (
77-
not p.relto(_pluggy_dir) and not p.relto(_pytest_dir) and not p.relto(_py_dir)
78-
)
79-
8049

8150
def pyobj_property(name):
8251
def get(self):

testing/acceptance_test.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,16 @@ def test_not_collectable_arguments(self, testdir):
133133
assert result.ret
134134
result.stderr.fnmatch_lines(["*ERROR: not found:*{}".format(p2.basename)])
135135

136-
def test_issue486_better_reporting_on_conftest_load_failure(self, testdir):
136+
def test_better_reporting_on_conftest_load_failure(self, testdir, request):
137+
"""Show a user-friendly traceback on conftest import failures (#486, #3332)"""
137138
testdir.makepyfile("")
138-
testdir.makeconftest("import qwerty")
139+
testdir.makeconftest(
140+
"""
141+
def foo():
142+
import qwerty
143+
foo()
144+
"""
145+
)
139146
result = testdir.runpytest("--help")
140147
result.stdout.fnmatch_lines(
141148
"""
@@ -144,10 +151,23 @@ def test_issue486_better_reporting_on_conftest_load_failure(self, testdir):
144151
"""
145152
)
146153
result = testdir.runpytest()
154+
dirname = request.node.name + "0"
155+
exc_name = (
156+
"ModuleNotFoundError" if sys.version_info >= (3, 6) else "ImportError"
157+
)
147158
result.stderr.fnmatch_lines(
148-
"""
149-
*ERROR*could not load*conftest.py*
150-
"""
159+
[
160+
"ImportError while loading conftest '*{sep}{dirname}{sep}conftest.py'.".format(
161+
dirname=dirname, sep=os.sep
162+
),
163+
"conftest.py:3: in <module>",
164+
" foo()",
165+
"conftest.py:2: in foo",
166+
" import qwerty",
167+
"E {}: No module named {q}qwerty{q}".format(
168+
exc_name, q="'" if six.PY3 else ""
169+
),
170+
]
151171
)
152172

153173
def test_early_skip(self, testdir):

testing/code/test_excinfo.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,20 +1184,28 @@ def h():
11841184
assert tw.lines[47] == ":15: AttributeError"
11851185

11861186
@pytest.mark.skipif("sys.version_info[0] < 3")
1187-
def test_exc_repr_with_raise_from_none_chain_suppression(self, importasmod):
1187+
@pytest.mark.parametrize("mode", ["from_none", "explicit_suppress"])
1188+
def test_exc_repr_chain_suppression(self, importasmod, mode):
1189+
"""Check that exc repr does not show chained exceptions in Python 3.
1190+
- When the exception is raised with "from None"
1191+
- Explicitly suppressed with "chain=False" to ExceptionInfo.getrepr().
1192+
"""
1193+
raise_suffix = " from None" if mode == "from_none" else ""
11881194
mod = importasmod(
11891195
"""
11901196
def f():
11911197
try:
11921198
g()
11931199
except Exception:
1194-
raise AttributeError() from None
1200+
raise AttributeError(){raise_suffix}
11951201
def g():
11961202
raise ValueError()
1197-
"""
1203+
""".format(
1204+
raise_suffix=raise_suffix
1205+
)
11981206
)
11991207
excinfo = pytest.raises(AttributeError, mod.f)
1200-
r = excinfo.getrepr(style="long")
1208+
r = excinfo.getrepr(style="long", chain=mode != "explicit_suppress")
12011209
tw = TWMock()
12021210
r.toterminal(tw)
12031211
for line in tw.lines:
@@ -1207,7 +1215,9 @@ def g():
12071215
assert tw.lines[2] == " try:"
12081216
assert tw.lines[3] == " g()"
12091217
assert tw.lines[4] == " except Exception:"
1210-
assert tw.lines[5] == "> raise AttributeError() from None"
1218+
assert tw.lines[5] == "> raise AttributeError(){}".format(
1219+
raise_suffix
1220+
)
12111221
assert tw.lines[6] == "E AttributeError"
12121222
assert tw.lines[7] == ""
12131223
line = tw.get_write_msg(8)

0 commit comments

Comments
 (0)