Skip to content

Commit 18fb721

Browse files
committed
Add doctest_fail_fast option to exit after the first failed test.
1 parent c49d925 commit 18fb721

File tree

7 files changed

+85
-12
lines changed

7 files changed

+85
-12
lines changed

Diff for: AUTHORS.rst

+1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Contributors
103103
* Taku Shimizu -- epub3 builder
104104
* Thomas Lamb -- linkcheck builder
105105
* Thomas Waldmann -- apidoc module fixes
106+
* Till Hoffmann -- doctest option to exit after first failed test
106107
* Tim Hoffmann -- theme improvements
107108
* Vince Salvino -- JavaScript search improvements
108109
* Will Maier -- directory HTML builder

Diff for: CHANGES.rst

+2
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ Features added
111111
* #13326: Remove hardcoding from handling :class:`~sphinx.addnodes.productionlist`
112112
nodes in all writers, to improve flexibility.
113113
Patch by Adam Turner.
114+
* #13332: Add :confval:`doctest_fail_fast` option to exit after the first failed
115+
test. Patch by Till Hoffmann.
114116

115117
Bugs fixed
116118
----------

Diff for: doc/usage/extensions/doctest.rst

+8
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.2.0

Diff for: sphinx/ext/doctest.py

+32-12
Original file line numberDiff line numberDiff line change
@@ -358,10 +358,16 @@ 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+
361367
self._out(
362368
f"""
363-
Doctest summary
364-
===============
369+
{header}
370+
{'=' * len(header)}
365371
{self.total_tries:5} test{s(self.total_tries)}
366372
{self.total_failures:5} failure{s(self.total_failures)} in tests
367373
{self.setup_failures:5} failure{s(self.setup_failures)} in setup code
@@ -370,15 +376,14 @@ def s(v: int) -> str:
370376
)
371377
self.outfile.close()
372378

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

383388
def get_filename_for_node(self, node: Node, docname: str) -> str:
384389
"""Try to get the file which actually contains the doctest, not the
@@ -419,7 +424,7 @@ def skipped(self, node: Element) -> bool:
419424
exec(self.config.doctest_global_cleanup, context) # NoQA: S102
420425
return should_skip
421426

422-
def test_doc(self, docname: str, doctree: Node) -> None:
427+
def test_doc(self, docname: str, doctree: Node) -> bool:
423428
groups: dict[str, TestGroup] = {}
424429
add_to_all_groups = []
425430
self.setup_runner = SphinxDocTestRunner(verbose=False, optionflags=self.opt)
@@ -496,13 +501,17 @@ def condition(node: Node) -> bool:
496501
for group in groups.values():
497502
group.add_code(code)
498503
if not groups:
499-
return
504+
return True
500505

501506
show_successes = self.config.doctest_show_successes
502507
if show_successes:
503508
self._out(f'\nDocument: {docname}\n----------{"-" * len(docname)}\n')
509+
success = True
504510
for group in groups.values():
505-
self.test_group(group)
511+
if not self.test_group(group):
512+
success = False
513+
if self.config.doctest_fail_fast:
514+
break
506515
# Separately count results from setup code
507516
res_f, res_t = self.setup_runner.summarize(self._out, verbose=False)
508517
self.setup_failures += res_f
@@ -517,13 +526,14 @@ def condition(node: Node) -> bool:
517526
)
518527
self.cleanup_failures += res_f
519528
self.cleanup_tries += res_t
529+
return success
520530

521531
def compile(
522532
self, code: str, name: str, type: str, flags: Any, dont_inherit: bool
523533
) -> Any:
524534
return compile(code, name, self.type, flags, dont_inherit)
525535

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

529539
def run_setup_cleanup(
@@ -553,9 +563,10 @@ def run_setup_cleanup(
553563
# run the setup code
554564
if not run_setup_cleanup(self.setup_runner, group.setup, 'setup'):
555565
# if setup failed, don't run the group
556-
return
566+
return False
557567

558568
# run the tests
569+
success = True
559570
for code in group.tests:
560571
if len(code) == 1:
561572
# ordinary doctests (code/output interleaved)
@@ -608,11 +619,19 @@ def run_setup_cleanup(
608619
self.type = 'exec' # multiple statements again
609620
# DocTest.__init__ copies the globs namespace, which we don't want
610621
test.globs = ns
622+
old_f = self.test_runner.failures
611623
# also don't clear the globs namespace after running the doctest
612624
self.test_runner.run(test, out=self._warn_out, clear_globs=False)
625+
if self.test_runner.failures > old_f:
626+
success = False
627+
if self.config.doctest_fail_fast:
628+
break
613629

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

617636

618637
def setup(app: Sphinx) -> ExtensionMetadata:
@@ -638,6 +657,7 @@ def setup(app: Sphinx) -> ExtensionMetadata:
638657
'',
639658
types=frozenset({int}),
640659
)
660+
app.add_config_value('doctest_fail_fast', False, '', types=frozenset({bool}))
641661
return {
642662
'version': sphinx.__display_version__,
643663
'parallel_read_safe': True,

Diff for: tests/roots/test-ext-doctest-fail-fast/conf.py

+11
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 = ...

Diff for: tests/roots/test-ext-doctest-fail-fast/fail-fast.txt

+11
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

Diff for: tests/test_extensions/test_ext_doctest.py

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

0 commit comments

Comments
 (0)