mirror of
https://github.com/sphinx-doc/sphinx.git
synced 2025-02-25 18:55:22 -06:00
Merge pull request #4584 from Zac-HD/doctest
Report true file location of doctests
This commit is contained in:
commit
acee7b8a55
1
AUTHORS
1
AUTHORS
@ -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
|
||||
|
5
CHANGES
5
CHANGES
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
8
tests/roots/test-ext-doctest-with-autodoc/conf.py
Normal file
8
tests/roots/test-ext-doctest-with-autodoc/conf.py
Normal 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'
|
4
tests/roots/test-ext-doctest-with-autodoc/dir/bar.py
Normal file
4
tests/roots/test-ext-doctest-with-autodoc/dir/bar.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""
|
||||
>>> 'dir/bar.py:2'
|
||||
|
||||
"""
|
4
tests/roots/test-ext-doctest-with-autodoc/dir/inner.rst
Normal file
4
tests/roots/test-ext-doctest-with-autodoc/dir/inner.rst
Normal file
@ -0,0 +1,4 @@
|
||||
>>> 'dir/inner.rst:1'
|
||||
|
||||
.. automodule:: dir.bar
|
||||
:members:
|
5
tests/roots/test-ext-doctest-with-autodoc/foo.py
Normal file
5
tests/roots/test-ext-doctest-with-autodoc/foo.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""
|
||||
|
||||
>>> 'foo.py:3'
|
||||
|
||||
"""
|
4
tests/roots/test-ext-doctest-with-autodoc/index.rst
Normal file
4
tests/roots/test-ext-doctest-with-autodoc/index.rst
Normal file
@ -0,0 +1,4 @@
|
||||
.. automodule:: foo
|
||||
:members:
|
||||
|
||||
>>> 'index.rst:4'
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user