Skip to content

Commit

Permalink
Merge pull request #3257 from will133/continue-on-failure
Browse files Browse the repository at this point in the history
Continue on doctest failure
  • Loading branch information
nicoddemus authored Feb 27, 2018
2 parents d196ab4 + e8f9a91 commit 5cb72b6
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 45 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ Victor Uriarte
Vidar T. Fauske
Vitaly Lashmanov
Vlad Dragos
William Lee
Wouter van Ackooy
Xuan Luong
Xuecong Liao
Expand Down
173 changes: 128 additions & 45 deletions _pytest/doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
DOCTEST_REPORT_CHOICE_ONLY_FIRST_FAILURE,
)

# Lazy definiton of runner class
RUNNER_CLASS = None


def pytest_addoption(parser):
parser.addini('doctest_optionflags', 'option flags for doctests',
Expand All @@ -47,6 +50,10 @@ def pytest_addoption(parser):
action="store_true", default=False,
help="ignore doctest ImportErrors",
dest="doctest_ignore_import_errors")
group.addoption("--doctest-continue-on-failure",
action="store_true", default=False,
help="for a given doctest, continue to run after the first failure",
dest="doctest_continue_on_failure")


def pytest_collect_file(path, parent):
Expand Down Expand Up @@ -77,14 +84,63 @@ def _is_doctest(config, path, parent):

class ReprFailDoctest(TerminalRepr):

def __init__(self, reprlocation, lines):
self.reprlocation = reprlocation
self.lines = lines
def __init__(self, reprlocation_lines):
# List of (reprlocation, lines) tuples
self.reprlocation_lines = reprlocation_lines

def toterminal(self, tw):
for line in self.lines:
tw.line(line)
self.reprlocation.toterminal(tw)
for reprlocation, lines in self.reprlocation_lines:
for line in lines:
tw.line(line)
reprlocation.toterminal(tw)


class MultipleDoctestFailures(Exception):
def __init__(self, failures):
super(MultipleDoctestFailures, self).__init__()
self.failures = failures


def _init_runner_class():
import doctest

class PytestDoctestRunner(doctest.DebugRunner):
"""
Runner to collect failures. Note that the out variable in this case is
a list instead of a stdout-like object
"""
def __init__(self, checker=None, verbose=None, optionflags=0,
continue_on_failure=True):
doctest.DebugRunner.__init__(
self, checker=checker, verbose=verbose, optionflags=optionflags)
self.continue_on_failure = continue_on_failure

def report_failure(self, out, test, example, got):
failure = doctest.DocTestFailure(test, example, got)
if self.continue_on_failure:
out.append(failure)
else:
raise failure

def report_unexpected_exception(self, out, test, example, exc_info):
failure = doctest.UnexpectedException(test, example, exc_info)
if self.continue_on_failure:
out.append(failure)
else:
raise failure

return PytestDoctestRunner


def _get_runner(checker=None, verbose=None, optionflags=0,
continue_on_failure=True):
# We need this in order to do a lazy import on doctest
global RUNNER_CLASS
if RUNNER_CLASS is None:
RUNNER_CLASS = _init_runner_class()
return RUNNER_CLASS(
checker=checker, verbose=verbose, optionflags=optionflags,
continue_on_failure=continue_on_failure)


class DoctestItem(pytest.Item):
Expand All @@ -106,7 +162,10 @@ def setup(self):
def runtest(self):
_check_all_skipped(self.dtest)
self._disable_output_capturing_for_darwin()
self.runner.run(self.dtest)
failures = []
self.runner.run(self.dtest, out=failures)
if failures:
raise MultipleDoctestFailures(failures)

def _disable_output_capturing_for_darwin(self):
"""
Expand All @@ -122,42 +181,51 @@ def _disable_output_capturing_for_darwin(self):

def repr_failure(self, excinfo):
import doctest
failures = None
if excinfo.errisinstance((doctest.DocTestFailure,
doctest.UnexpectedException)):
doctestfailure = excinfo.value
example = doctestfailure.example
test = doctestfailure.test
filename = test.filename
if test.lineno is None:
lineno = None
else:
lineno = test.lineno + example.lineno + 1
message = excinfo.type.__name__
reprlocation = ReprFileLocation(filename, lineno, message)
checker = _get_checker()
report_choice = _get_report_choice(self.config.getoption("doctestreport"))
if lineno is not None:
lines = doctestfailure.test.docstring.splitlines(False)
# add line numbers to the left of the error message
lines = ["%03d %s" % (i + test.lineno + 1, x)
for (i, x) in enumerate(lines)]
# trim docstring error lines to 10
lines = lines[max(example.lineno - 9, 0):example.lineno + 1]
else:
lines = ['EXAMPLE LOCATION UNKNOWN, not showing all tests of that example']
indent = '>>>'
for line in example.source.splitlines():
lines.append('??? %s %s' % (indent, line))
indent = '...'
if excinfo.errisinstance(doctest.DocTestFailure):
lines += checker.output_difference(example,
doctestfailure.got, report_choice).split("\n")
else:
inner_excinfo = ExceptionInfo(excinfo.value.exc_info)
lines += ["UNEXPECTED EXCEPTION: %s" %
repr(inner_excinfo.value)]
lines += traceback.format_exception(*excinfo.value.exc_info)
return ReprFailDoctest(reprlocation, lines)
failures = [excinfo.value]
elif excinfo.errisinstance(MultipleDoctestFailures):
failures = excinfo.value.failures

if failures is not None:
reprlocation_lines = []
for failure in failures:
example = failure.example
test = failure.test
filename = test.filename
if test.lineno is None:
lineno = None
else:
lineno = test.lineno + example.lineno + 1
message = type(failure).__name__
reprlocation = ReprFileLocation(filename, lineno, message)
checker = _get_checker()
report_choice = _get_report_choice(self.config.getoption("doctestreport"))
if lineno is not None:
lines = failure.test.docstring.splitlines(False)
# add line numbers to the left of the error message
lines = ["%03d %s" % (i + test.lineno + 1, x)
for (i, x) in enumerate(lines)]
# trim docstring error lines to 10
lines = lines[max(example.lineno - 9, 0):example.lineno + 1]
else:
lines = ['EXAMPLE LOCATION UNKNOWN, not showing all tests of that example']
indent = '>>>'
for line in example.source.splitlines():
lines.append('??? %s %s' % (indent, line))
indent = '...'
if isinstance(failure, doctest.DocTestFailure):
lines += checker.output_difference(example,
failure.got,
report_choice).split("\n")
else:
inner_excinfo = ExceptionInfo(failure.exc_info)
lines += ["UNEXPECTED EXCEPTION: %s" %
repr(inner_excinfo.value)]
lines += traceback.format_exception(*failure.exc_info)
reprlocation_lines.append((reprlocation, lines))
return ReprFailDoctest(reprlocation_lines)
else:
return super(DoctestItem, self).repr_failure(excinfo)

Expand Down Expand Up @@ -187,6 +255,16 @@ def get_optionflags(parent):
return flag_acc


def _get_continue_on_failure(config):
continue_on_failure = config.getvalue('doctest_continue_on_failure')
if continue_on_failure:
# We need to turn off this if we use pdb since we should stop at
# the first failure
if config.getvalue("usepdb"):
continue_on_failure = False
return continue_on_failure


class DoctestTextfile(pytest.Module):
obj = None

Expand All @@ -202,8 +280,11 @@ def collect(self):
globs = {'__name__': '__main__'}

optionflags = get_optionflags(self)
runner = doctest.DebugRunner(verbose=0, optionflags=optionflags,
checker=_get_checker())

runner = _get_runner(
verbose=0, optionflags=optionflags,
checker=_get_checker(),
continue_on_failure=_get_continue_on_failure(self.config))
_fix_spoof_python2(runner, encoding)

parser = doctest.DocTestParser()
Expand Down Expand Up @@ -238,8 +319,10 @@ def collect(self):
# uses internal doctest module parsing mechanism
finder = doctest.DocTestFinder()
optionflags = get_optionflags(self)
runner = doctest.DebugRunner(verbose=0, optionflags=optionflags,
checker=_get_checker())
runner = _get_runner(
verbose=0, optionflags=optionflags,
checker=_get_checker(),
continue_on_failure=_get_continue_on_failure(self.config))

for test in finder.find(module, module.__name__):
if test.examples: # skip empty doctests
Expand Down
1 change: 1 addition & 0 deletions changelog/3149.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
New ``--doctest-continue-on-failure`` command-line option to enable doctests to show multiple failures for each snippet, instead of stopping at the first failure.
5 changes: 5 additions & 0 deletions doc/en/doctest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ itself::
>>> get_unicode_greeting() # doctest: +ALLOW_UNICODE
'Hello'

By default, pytest would report only the first failure for a given doctest. If
you want to continue the test even when you have failures, do::

pytest --doctest-modules --doctest-continue-on-failure


The 'doctest_namespace' fixture
-------------------------------
Expand Down
21 changes: 21 additions & 0 deletions testing/test_doctest.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,27 @@ def test_vacuous_all_skipped(self, testdir, makedoctest):
reprec = testdir.inline_run("--doctest-modules")
reprec.assertoutcome(passed=0, skipped=0)

def test_continue_on_failure(self, testdir):
testdir.maketxtfile(test_something="""
>>> i = 5
>>> def foo():
... raise ValueError('error1')
>>> foo()
>>> i
>>> i + 2
7
>>> i + 1
""")
result = testdir.runpytest("--doctest-modules", "--doctest-continue-on-failure")
result.assert_outcomes(passed=0, failed=1)
# The lines that contains the failure are 4, 5, and 8. The first one
# is a stack trace and the other two are mismatches.
result.stdout.fnmatch_lines([
"*4: UnexpectedException*",
"*5: DocTestFailure*",
"*8: DocTestFailure*",
])


class TestDoctestAutoUseFixtures(object):

Expand Down

0 comments on commit 5cb72b6

Please sign in to comment.