Merge pull request #4584 from Zac-HD/doctest

Report true file location of doctests
This commit is contained in:
Takeshi KOMIYA 2018-02-09 22:12:55 +09:00 committed by GitHub
commit acee7b8a55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 108 additions and 34 deletions

View File

@ -34,6 +34,7 @@ Other contributors, listed alphabetically, are:
* Hernan Grecco -- search improvements
* Horst Gutmann -- internationalization support
* Martin Hans -- autodoc improvements
* Zac Hatfield-Dodds -- doctest reporting improvements
* Doug Hellmann -- graphviz improvements
* Tim Hoffmann -- theme improvements
* Timotheus Kampik - JS theme & search enhancements

View File

@ -20,6 +20,7 @@ Bugs fixed
* #4531: autosummary: methods are not treated as attributes
* #4538: autodoc: ``sphinx.ext.autodoc.Options`` has been moved
* #4539: autodoc emits warnings for partialmethods
* #4223: doctest: failing tests reported in wrong file, at wrong line
Testing
--------
@ -190,7 +191,7 @@ Bugs fixed
* #3962: sphinx-apidoc does not recognize implicit namespace packages correctly
* #4094: C++, allow empty template argument lists.
* C++, also hyperlink types in the name of declarations with qualified names.
* C++, do not add index entries for declarations inside concepts.
* C++, do not add index entries for declarations inside concepts.
* C++, support the template disambiguator for dependent names.
* #4314: For PDF 'howto' documents, numbering of code-blocks differs from the
one of figures and tables
@ -284,7 +285,7 @@ Bugs fixed
* #1421: Respect the quiet flag in sphinx-quickstart
* #4281: Race conditions when creating output directory
* #4315: For PDF 'howto' documents, ``latex_toplevel_sectioning='part'`` generates
``\chapter`` commands
``\chapter`` commands
* #4214: Two todolist directives break sphinx-1.6.5
* Fix links to external option docs with intersphinx (refs: #3769)
* #4091: Private members not documented without :undoc-members:

View File

@ -9,7 +9,6 @@
:license: BSD, see LICENSE for details.
"""
import fnmatch
import os
import re
import sys
@ -45,7 +44,7 @@ from sphinx.util.websupport import is_commentable
if False:
# For type annotation
from typing import Any, Callable, Dict, IO, Iterator, List, Pattern, Set, Tuple, Type, Union, Generator # NOQA
from typing import Any, Callable, Dict, IO, Iterator, List, Optional, Pattern, Set, Tuple, Type, Union, Generator # NOQA
from docutils import nodes # NOQA
from sphinx.application import Sphinx # NOQA
from sphinx.builders import Builder # NOQA
@ -341,17 +340,16 @@ class BuildEnvironment(object):
app.emit('env-merge-info', self, docnames, other)
def path2doc(self, filename):
# type: (unicode) -> unicode
# type: (unicode) -> Optional[unicode]
"""Return the docname for the filename if the file is document.
*filename* should be absolute or relative to the source directory.
"""
if filename.startswith(self.srcdir):
filename = filename[len(self.srcdir) + 1:]
filename = os.path.relpath(filename, self.srcdir)
for suffix in self.config.source_suffix:
if fnmatch.fnmatch(filename, '*' + suffix):
if filename.endswith(suffix):
return filename[:-len(suffix)]
# the file does not have docname
return None
def doc2path(self, docname, base=True, suffix=None):
@ -365,15 +363,13 @@ class BuildEnvironment(object):
"""
docname = docname.replace(SEP, path.sep)
if suffix is None:
candidate_suffix = None # type: unicode
# Use first candidate if there is not a file for any suffix
suffix = next(iter(self.config.source_suffix))
for candidate_suffix in self.config.source_suffix:
if path.isfile(path.join(self.srcdir, docname) +
candidate_suffix):
suffix = candidate_suffix
break
else:
# document does not exist
suffix = self.config.source_suffix[0]
if base is True:
return path.join(self.srcdir, docname) + suffix
elif base is None:

View File

@ -34,7 +34,7 @@ from sphinx.util.osutil import fs_encoding
if False:
# For type annotation
from typing import Any, Callable, Dict, IO, Iterable, List, Sequence, Set, Tuple # NOQA
from typing import Any, Callable, Dict, IO, Iterable, List, Optional, Sequence, Set, Tuple # NOQA
from sphinx.application import Sphinx # NOQA
logger = logging.getLogger(__name__)
@ -223,17 +223,18 @@ class TestGroup(object):
class TestCode(object):
def __init__(self, code, type, lineno, options=None):
# type: (unicode, unicode, int, Dict) -> None
def __init__(self, code, type, filename, lineno, options=None):
# type: (unicode, unicode, Optional[str], int, Optional[Dict]) -> None
self.code = code
self.type = type
self.filename = filename
self.lineno = lineno
self.options = options or {}
def __repr__(self): # type: ignore
# type: () -> unicode
return 'TestCode(%r, %r, %r, options=%r)' % (
self.code, self.type, self.lineno, self.options)
return 'TestCode(%r, %r, filename=%r, lineno=%r, options=%r)' % (
self.code, self.type, self.filename, self.lineno, self.options)
class SphinxDocTestRunner(doctest.DocTestRunner):
@ -366,6 +367,36 @@ Doctest summary
doctree = self.env.get_doctree(docname)
self.test_doc(docname, doctree)
def get_filename_for_node(self, node, docname):
# type: (nodes.Node, unicode) -> str
"""Try to get the file which actually contains the doctest, not the
filename of the document it's included in."""
try:
filename = path.relpath(node.source, self.env.srcdir)\
.rsplit(':docstring of ', maxsplit=1)[0]
except Exception:
filename = self.env.doc2path(docname, base=None)
if PY2:
return filename.encode(fs_encoding)
return filename
@staticmethod
def get_line_number(node):
# type: (nodes.Node) -> Optional[int]
"""Get the real line number or admit we don't know."""
# TODO: Work out how to store or calculate real (file-relative)
# line numbers for doctest blocks in docstrings.
if ':docstring of ' in path.basename(node.source or ''):
# The line number is given relative to the stripped docstring,
# not the file. This is correct where it is set, in
# `docutils.nodes.Node.setup_child`, but Sphinx should report
# relative to the file, not the docstring.
return None
if node.line is not None:
# TODO: find the root cause of this off by one error.
return node.line - 1
return None
def test_doc(self, docname, doctree):
# type: (unicode, nodes.Node) -> None
groups = {} # type: Dict[unicode, TestGroup]
@ -392,13 +423,16 @@ Doctest summary
return isinstance(node, (nodes.literal_block, nodes.comment)) \
and 'testnodetype' in node
for node in doctree.traverse(condition):
source = 'test' in node and node['test'] or node.astext()
source = node['test'] if 'test' in node else node.astext()
filename = self.get_filename_for_node(node, docname)
line_number = self.get_line_number(node)
if not source:
logger.warning('no code/output in %s block at %s:%s',
node.get('testnodetype', 'doctest'),
self.env.doc2path(docname), node.line)
filename, line_number)
code = TestCode(source, type=node.get('testnodetype', 'doctest'),
lineno=node.line, options=node.get('options'))
filename=filename, lineno=line_number,
options=node.get('options'))
node_groups = node.get('groups', ['default'])
if '*' in node_groups:
add_to_all_groups.append(code)
@ -412,12 +446,12 @@ Doctest summary
group.add_code(code)
if self.config.doctest_global_setup:
code = TestCode(self.config.doctest_global_setup,
'testsetup', lineno=0)
'testsetup', filename=None, lineno=0)
for group in itervalues(groups):
group.add_code(code, prepend=True)
if self.config.doctest_global_cleanup:
code = TestCode(self.config.doctest_global_cleanup,
'testcleanup', lineno=0)
'testcleanup', filename=None, lineno=0)
for group in itervalues(groups):
group.add_code(code)
if not groups:
@ -426,7 +460,7 @@ Doctest summary
self._out('\nDocument: %s\n----------%s\n' %
(docname, '-' * len(docname)))
for group in itervalues(groups):
self.test_group(group, self.env.doc2path(docname, base=None))
self.test_group(group)
# Separately count results from setup code
res_f, res_t = self.setup_runner.summarize(self._out, verbose=False)
self.setup_failures += res_f
@ -445,13 +479,8 @@ Doctest summary
# type: (unicode, unicode, unicode, Any, bool) -> Any
return compile(code, name, self.type, flags, dont_inherit)
def test_group(self, group, filename):
# type: (TestGroup, unicode) -> None
if PY2:
filename_str = filename.encode(fs_encoding)
else:
filename_str = filename
def test_group(self, group):
# type: (TestGroup) -> None
ns = {} # type: Dict
def run_setup_cleanup(runner, testcodes, what):
@ -466,7 +495,7 @@ Doctest summary
# simulate a doctest with the code
sim_doctest = doctest.DocTest(examples, {},
'%s (%s code)' % (group.name, what),
filename_str, 0, None)
testcodes[0].filename, 0, None)
sim_doctest.globs = ns
old_f = runner.failures
self.type = 'exec' # the snippet may contain multiple statements
@ -487,10 +516,10 @@ Doctest summary
try:
test = parser.get_doctest( # type: ignore
doctest_encode(code[0].code, self.env.config.source_encoding), {}, # type: ignore # NOQA
group.name, filename_str, code[0].lineno)
group.name, code[0].filename, code[0].lineno)
except Exception:
logger.warning('ignoring invalid doctest code: %r', code[0].code,
location=(filename, code[0].lineno))
location=(code[0].filename, code[0].lineno))
continue
if not test.examples:
continue
@ -518,7 +547,7 @@ Doctest summary
lineno=code[0].lineno,
options=options)
test = doctest.DocTest([example], {}, group.name, # type: ignore
filename_str, code[0].lineno, None)
code[0].filename, code[0].lineno, None)
self.type = 'exec' # multiple statements again
# DocTest.__init__ copies the globs namespace, which we don't want
test.globs = ns

View File

@ -0,0 +1,8 @@
from os import path
import sys
sys.path.insert(0, path.abspath(path.dirname(__file__)))
project = 'test project for doctest + autodoc reporting'
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest']
master_doc = 'index'

View File

@ -0,0 +1,4 @@
"""
>>> 'dir/bar.py:2'
"""

View File

@ -0,0 +1,4 @@
>>> 'dir/inner.rst:1'
.. automodule:: dir.bar
:members:

View File

@ -0,0 +1,5 @@
"""
>>> 'foo.py:3'
"""

View File

@ -0,0 +1,4 @@
.. automodule:: foo
:members:
>>> 'index.rst:4'

View File

@ -9,6 +9,7 @@
:license: BSD, see LICENSE for details.
"""
import pytest
from six import PY2
from sphinx.ext.doctest import is_allowed_version
from packaging.version import InvalidVersion
from packaging.specifiers import InvalidSpecifier
@ -55,3 +56,24 @@ def test_is_allowed_version():
def cleanup_call():
global cleanup_called
cleanup_called += 1
@pytest.mark.xfail(
PY2, reason='node.source points to document instead of filename',
)
@pytest.mark.sphinx('doctest', testroot='ext-doctest-with-autodoc')
def test_reporting_with_autodoc(app, status, warning, capfd):
# Patch builder to get a copy of the output
written = []
app.builder._warn_out = written.append
app.builder.build_all()
lines = '\n'.join(written).split('\n')
failures = [l for l in lines if l.startswith('File')]
expected = [
'File "dir/inner.rst", line 1, in default',
'File "dir/bar.py", line ?, in default',
'File "foo.py", line ?, in default',
'File "index.rst", line 4, in default',
]
for location in expected:
assert location in failures