Skip to content

Commit 5831b3e

Browse files
Add doctest_fail_fast option to exit after the first failed test (#13332)
Co-authored-by: Adam Turner <9087854+aa-turner@users.noreply.github.com>
1 parent d9b20d0 commit 5831b3e

File tree

7 files changed

+88
-12
lines changed

7 files changed

+88
-12
lines changed

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ Contributors
106106
* Taku Shimizu -- epub3 builder
107107
* Thomas Lamb -- linkcheck builder
108108
* Thomas Waldmann -- apidoc module fixes
109+
* Till Hoffmann -- doctest option to exit after first failed test
109110
* Tim Hoffmann -- theme improvements
110111
* Victor Wheeler -- documentation improvements
111112
* Vince Salvino -- JavaScript search improvements

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Deprecated
1313
Features added
1414
--------------
1515

16+
* #13332: Add :confval:`doctest_fail_fast` option to exit after the first failed
17+
test.
18+
Patch by Till Hoffmann.
19+
1620
Bugs fixed
1721
----------
1822

doc/usage/extensions/doctest.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,3 +452,11 @@ The doctest extension uses the following configuration values:
452452
Also, removal of ``<BLANKLINE>`` and ``# doctest:`` options only works in
453453
:rst:dir:`doctest` blocks, though you may set :confval:`trim_doctest_flags`
454454
to achieve that in all code blocks with Python console content.
455+
456+
.. confval:: doctest_fail_fast
457+
:type: :code-py:`bool`
458+
:default: :code-py:`False`
459+
460+
Exit when the first failure is encountered.
461+
462+
.. versionadded:: 8.3

sphinx/ext/doctest.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -358,10 +358,17 @@ def finish(self) -> None:
358358
def s(v: int) -> str:
359359
return 's' if v != 1 else ''
360360

361+
header = 'Doctest summary'
362+
if self.total_failures or self.setup_failures or self.cleanup_failures:
363+
self.app.statuscode = 1
364+
if self.config.doctest_fail_fast:
365+
header = f'{header} (exiting after first failed test)'
366+
underline = '=' * len(header)
367+
361368
self._out(
362369
f"""
363-
Doctest summary
364-
===============
370+
{header}
371+
{underline}
365372
{self.total_tries:5} test{s(self.total_tries)}
366373
{self.total_failures:5} failure{s(self.total_failures)} in tests
367374
{self.setup_failures:5} failure{s(self.setup_failures)} in setup code
@@ -370,15 +377,14 @@ def s(v: int) -> str:
370377
)
371378
self.outfile.close()
372379

373-
if self.total_failures or self.setup_failures or self.cleanup_failures:
374-
self.app.statuscode = 1
375-
376380
def write_documents(self, docnames: Set[str]) -> None:
377381
logger.info(bold('running tests...'))
378382
for docname in sorted(docnames):
379383
# no need to resolve the doctree
380384
doctree = self.env.get_doctree(docname)
381-
self.test_doc(docname, doctree)
385+
success = self.test_doc(docname, doctree)
386+
if not success and self.config.doctest_fail_fast:
387+
break
382388

383389
def get_filename_for_node(self, node: Node, docname: str) -> str:
384390
"""Try to get the file which actually contains the doctest, not the
@@ -419,7 +425,7 @@ def skipped(self, node: Element) -> bool:
419425
exec(self.config.doctest_global_cleanup, context) # NoQA: S102
420426
return should_skip
421427

422-
def test_doc(self, docname: str, doctree: Node) -> None:
428+
def test_doc(self, docname: str, doctree: Node) -> bool:
423429
groups: dict[str, TestGroup] = {}
424430
add_to_all_groups = []
425431
self.setup_runner = SphinxDocTestRunner(verbose=False, optionflags=self.opt)
@@ -496,13 +502,17 @@ def condition(node: Node) -> bool:
496502
for group in groups.values():
497503
group.add_code(code)
498504
if not groups:
499-
return
505+
return True
500506

501507
show_successes = self.config.doctest_show_successes
502508
if show_successes:
503509
self._out(f'\nDocument: {docname}\n----------{"-" * len(docname)}\n')
510+
success = True
504511
for group in groups.values():
505-
self.test_group(group)
512+
if not self.test_group(group):
513+
success = False
514+
if self.config.doctest_fail_fast:
515+
break
506516
# Separately count results from setup code
507517
res_f, res_t = self.setup_runner.summarize(self._out, verbose=False)
508518
self.setup_failures += res_f
@@ -517,13 +527,14 @@ def condition(node: Node) -> bool:
517527
)
518528
self.cleanup_failures += res_f
519529
self.cleanup_tries += res_t
530+
return success
520531

521532
def compile(
522533
self, code: str, name: str, type: str, flags: Any, dont_inherit: bool
523534
) -> Any:
524535
return compile(code, name, self.type, flags, dont_inherit)
525536

526-
def test_group(self, group: TestGroup) -> None:
537+
def test_group(self, group: TestGroup) -> bool:
527538
ns: dict[str, Any] = {}
528539

529540
def run_setup_cleanup(
@@ -553,9 +564,10 @@ def run_setup_cleanup(
553564
# run the setup code
554565
if not run_setup_cleanup(self.setup_runner, group.setup, 'setup'):
555566
# if setup failed, don't run the group
556-
return
567+
return False
557568

558569
# run the tests
570+
success = True
559571
for code in group.tests:
560572
if len(code) == 1:
561573
# ordinary doctests (code/output interleaved)
@@ -608,11 +620,19 @@ def run_setup_cleanup(
608620
self.type = 'exec' # multiple statements again
609621
# DocTest.__init__ copies the globs namespace, which we don't want
610622
test.globs = ns
623+
old_f = self.test_runner.failures
611624
# also don't clear the globs namespace after running the doctest
612625
self.test_runner.run(test, out=self._warn_out, clear_globs=False)
626+
if self.test_runner.failures > old_f:
627+
success = False
628+
if self.config.doctest_fail_fast:
629+
break
613630

614631
# run the cleanup
615-
run_setup_cleanup(self.cleanup_runner, group.cleanup, 'cleanup')
632+
if not run_setup_cleanup(self.cleanup_runner, group.cleanup, 'cleanup'):
633+
return False
634+
635+
return success
616636

617637

618638
def setup(app: Sphinx) -> ExtensionMetadata:
@@ -638,6 +658,7 @@ def setup(app: Sphinx) -> ExtensionMetadata:
638658
'',
639659
types=frozenset({int}),
640660
)
661+
app.add_config_value('doctest_fail_fast', False, '', types=frozenset({bool}))
641662
return {
642663
'version': sphinx.__display_version__,
643664
'parallel_read_safe': True,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
extensions = ['sphinx.ext.doctest']
2+
3+
project = 'test project for doctest'
4+
root_doc = 'fail-fast'
5+
source_suffix = {
6+
'.txt': 'restructuredtext',
7+
}
8+
exclude_patterns = ['_build']
9+
10+
# Set in tests.
11+
# doctest_fail_fast = ...
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Testing fast failure in the doctest extension
2+
=============================================
3+
4+
>>> 1 + 1
5+
2
6+
7+
>>> 1 + 1
8+
3
9+
10+
>>> 1 + 1
11+
3

tests/test_extensions/test_ext_doctest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,23 @@ def test_reporting_with_autodoc(app, capfd):
147147
assert 'File "dir/bar.py", line ?, in default' in failures
148148
assert 'File "foo.py", line ?, in default' in failures
149149
assert 'File "index.rst", line 4, in default' in failures
150+
151+
152+
@pytest.mark.sphinx('doctest', testroot='ext-doctest-fail-fast')
153+
@pytest.mark.parametrize('fail_fast', [False, True, None])
154+
def test_fail_fast(app, fail_fast, capsys):
155+
if fail_fast is not None:
156+
app.config.doctest_fail_fast = fail_fast
157+
# Patch builder to get a copy of the output
158+
written = []
159+
app.builder._out = written.append
160+
app.build(force_all=True)
161+
assert app.statuscode
162+
163+
written = ''.join(written)
164+
if fail_fast:
165+
assert 'Doctest summary (exiting after first failed test)' in written
166+
assert '1 failure in tests' in written
167+
else:
168+
assert 'Doctest summary\n' in written
169+
assert '2 failures in tests' in written

0 commit comments

Comments
 (0)