summaryrefslogtreecommitdiffstats
path: root/sphinx/ext/doctest.py
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/ext/doctest.py')
-rw-r--r--sphinx/ext/doctest.py575
1 files changed, 575 insertions, 0 deletions
diff --git a/sphinx/ext/doctest.py b/sphinx/ext/doctest.py
new file mode 100644
index 0000000..c55ef2f
--- /dev/null
+++ b/sphinx/ext/doctest.py
@@ -0,0 +1,575 @@
+"""Mimic doctest in Sphinx.
+
+The extension automatically execute code snippets and checks their results.
+"""
+
+from __future__ import annotations
+
+import doctest
+import re
+import sys
+import time
+from io import StringIO
+from os import path
+from typing import TYPE_CHECKING, Any, Callable
+
+from docutils import nodes
+from docutils.parsers.rst import directives
+from packaging.specifiers import InvalidSpecifier, SpecifierSet
+from packaging.version import Version
+
+import sphinx
+from sphinx.builders import Builder
+from sphinx.locale import __
+from sphinx.util import logging
+from sphinx.util.console import bold # type: ignore[attr-defined]
+from sphinx.util.docutils import SphinxDirective
+from sphinx.util.osutil import relpath
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Sequence
+
+ from docutils.nodes import Element, Node, TextElement
+
+ from sphinx.application import Sphinx
+ from sphinx.util.typing import OptionSpec
+
+
+logger = logging.getLogger(__name__)
+
+blankline_re = re.compile(r'^\s*<BLANKLINE>', re.MULTILINE)
+doctestopt_re = re.compile(r'#\s*doctest:.+$', re.MULTILINE)
+
+
+def is_allowed_version(spec: str, version: str) -> bool:
+ """Check `spec` satisfies `version` or not.
+
+ This obeys PEP-440 specifiers:
+ https://peps.python.org/pep-0440/#version-specifiers
+
+ Some examples:
+
+ >>> is_allowed_version('<=3.5', '3.3')
+ True
+ >>> is_allowed_version('<=3.2', '3.3')
+ False
+ >>> is_allowed_version('>3.2, <4.0', '3.3')
+ True
+ """
+ return Version(version) in SpecifierSet(spec)
+
+
+# set up the necessary directives
+
+class TestDirective(SphinxDirective):
+ """
+ Base class for doctest-related directives.
+ """
+
+ has_content = True
+ required_arguments = 0
+ optional_arguments = 1
+ final_argument_whitespace = True
+
+ def run(self) -> list[Node]:
+ # use ordinary docutils nodes for test code: they get special attributes
+ # so that our builder recognizes them, and the other builders are happy.
+ code = '\n'.join(self.content)
+ test = None
+ if self.name == 'doctest':
+ if '<BLANKLINE>' in code:
+ # convert <BLANKLINE>s to ordinary blank lines for presentation
+ test = code
+ code = blankline_re.sub('', code)
+ if doctestopt_re.search(code) and 'no-trim-doctest-flags' not in self.options:
+ if not test:
+ test = code
+ code = doctestopt_re.sub('', code)
+ nodetype: type[TextElement] = nodes.literal_block
+ if self.name in ('testsetup', 'testcleanup') or 'hide' in self.options:
+ nodetype = nodes.comment
+ if self.arguments:
+ groups = [x.strip() for x in self.arguments[0].split(',')]
+ else:
+ groups = ['default']
+ node = nodetype(code, code, testnodetype=self.name, groups=groups)
+ self.set_source_info(node)
+ if test is not None:
+ # only save if it differs from code
+ node['test'] = test
+ if self.name == 'doctest':
+ node['language'] = 'pycon'
+ elif self.name == 'testcode':
+ node['language'] = 'python'
+ elif self.name == 'testoutput':
+ # don't try to highlight output
+ node['language'] = 'none'
+ node['options'] = {}
+ if self.name in ('doctest', 'testoutput') and 'options' in self.options:
+ # parse doctest-like output comparison flags
+ option_strings = self.options['options'].replace(',', ' ').split()
+ for option in option_strings:
+ prefix, option_name = option[0], option[1:]
+ if prefix not in '+-':
+ self.state.document.reporter.warning(
+ __("missing '+' or '-' in '%s' option.") % option,
+ line=self.lineno)
+ continue
+ if option_name not in doctest.OPTIONFLAGS_BY_NAME:
+ self.state.document.reporter.warning(
+ __("'%s' is not a valid option.") % option_name,
+ line=self.lineno)
+ continue
+ flag = doctest.OPTIONFLAGS_BY_NAME[option[1:]]
+ node['options'][flag] = (option[0] == '+')
+ if self.name == 'doctest' and 'pyversion' in self.options:
+ try:
+ spec = self.options['pyversion']
+ python_version = '.'.join([str(v) for v in sys.version_info[:3]])
+ if not is_allowed_version(spec, python_version):
+ flag = doctest.OPTIONFLAGS_BY_NAME['SKIP']
+ node['options'][flag] = True # Skip the test
+ except InvalidSpecifier:
+ self.state.document.reporter.warning(
+ __("'%s' is not a valid pyversion option") % spec,
+ line=self.lineno)
+ if 'skipif' in self.options:
+ node['skipif'] = self.options['skipif']
+ if 'trim-doctest-flags' in self.options:
+ node['trim_flags'] = True
+ elif 'no-trim-doctest-flags' in self.options:
+ node['trim_flags'] = False
+ return [node]
+
+
+class TestsetupDirective(TestDirective):
+ option_spec: OptionSpec = {
+ 'skipif': directives.unchanged_required,
+ }
+
+
+class TestcleanupDirective(TestDirective):
+ option_spec: OptionSpec = {
+ 'skipif': directives.unchanged_required,
+ }
+
+
+class DoctestDirective(TestDirective):
+ option_spec: OptionSpec = {
+ 'hide': directives.flag,
+ 'no-trim-doctest-flags': directives.flag,
+ 'options': directives.unchanged,
+ 'pyversion': directives.unchanged_required,
+ 'skipif': directives.unchanged_required,
+ 'trim-doctest-flags': directives.flag,
+ }
+
+
+class TestcodeDirective(TestDirective):
+ option_spec: OptionSpec = {
+ 'hide': directives.flag,
+ 'no-trim-doctest-flags': directives.flag,
+ 'pyversion': directives.unchanged_required,
+ 'skipif': directives.unchanged_required,
+ 'trim-doctest-flags': directives.flag,
+ }
+
+
+class TestoutputDirective(TestDirective):
+ option_spec: OptionSpec = {
+ 'hide': directives.flag,
+ 'no-trim-doctest-flags': directives.flag,
+ 'options': directives.unchanged,
+ 'pyversion': directives.unchanged_required,
+ 'skipif': directives.unchanged_required,
+ 'trim-doctest-flags': directives.flag,
+ }
+
+
+parser = doctest.DocTestParser()
+
+
+# helper classes
+
+class TestGroup:
+ def __init__(self, name: str) -> None:
+ self.name = name
+ self.setup: list[TestCode] = []
+ self.tests: list[list[TestCode] | tuple[TestCode, None]] = []
+ self.cleanup: list[TestCode] = []
+
+ def add_code(self, code: TestCode, prepend: bool = False) -> None:
+ if code.type == 'testsetup':
+ if prepend:
+ self.setup.insert(0, code)
+ else:
+ self.setup.append(code)
+ elif code.type == 'testcleanup':
+ self.cleanup.append(code)
+ elif code.type == 'doctest':
+ self.tests.append([code])
+ elif code.type == 'testcode':
+ # "testoutput" may replace the second element
+ self.tests.append((code, None))
+ elif code.type == 'testoutput':
+ if self.tests:
+ latest_test = self.tests[-1]
+ if len(latest_test) == 2:
+ self.tests[-1] = [latest_test[0], code]
+ else:
+ raise RuntimeError(__('invalid TestCode type'))
+
+ def __repr__(self) -> str:
+ return (f'TestGroup(name={self.name!r}, setup={self.setup!r}, '
+ f'cleanup={self.cleanup!r}, tests={self.tests!r})')
+
+
+class TestCode:
+ def __init__(self, code: str, type: str, filename: str,
+ lineno: int, options: dict | None = None) -> None:
+ self.code = code
+ self.type = type
+ self.filename = filename
+ self.lineno = lineno
+ self.options = options or {}
+
+ def __repr__(self) -> str:
+ return (f'TestCode({self.code!r}, {self.type!r}, filename={self.filename!r}, '
+ f'lineno={self.lineno!r}, options={self.options!r})')
+
+
+class SphinxDocTestRunner(doctest.DocTestRunner):
+ def summarize(self, out: Callable, verbose: bool | None = None, # type: ignore[override]
+ ) -> tuple[int, int]:
+ string_io = StringIO()
+ old_stdout = sys.stdout
+ sys.stdout = string_io
+ try:
+ res = super().summarize(verbose)
+ finally:
+ sys.stdout = old_stdout
+ out(string_io.getvalue())
+ return res
+
+ def _DocTestRunner__patched_linecache_getlines(self, filename: str,
+ module_globals: Any = None) -> Any:
+ # this is overridden from DocTestRunner adding the try-except below
+ m = self._DocTestRunner__LINECACHE_FILENAME_RE.match( # type: ignore[attr-defined]
+ filename)
+ if m and m.group('name') == self.test.name:
+ try:
+ example = self.test.examples[int(m.group('examplenum'))]
+ # because we compile multiple doctest blocks with the same name
+ # (viz. the group name) this might, for outer stack frames in a
+ # traceback, get the wrong test which might not have enough examples
+ except IndexError:
+ pass
+ else:
+ return example.source.splitlines(True)
+ return self.save_linecache_getlines( # type: ignore[attr-defined]
+ filename, module_globals)
+
+
+# the new builder -- use sphinx-build.py -b doctest to run
+
+class DocTestBuilder(Builder):
+ """
+ Runs test snippets in the documentation.
+ """
+ name = 'doctest'
+ epilog = __('Testing of doctests in the sources finished, look at the '
+ 'results in %(outdir)s/output.txt.')
+
+ def init(self) -> None:
+ # default options
+ self.opt = self.config.doctest_default_flags
+
+ # HACK HACK HACK
+ # doctest compiles its snippets with type 'single'. That is nice
+ # for doctest examples but unusable for multi-statement code such
+ # as setup code -- to be able to use doctest error reporting with
+ # that code nevertheless, we monkey-patch the "compile" it uses.
+ doctest.compile = self.compile # type: ignore[attr-defined]
+
+ sys.path[0:0] = self.config.doctest_path
+
+ self.type = 'single'
+
+ self.total_failures = 0
+ self.total_tries = 0
+ self.setup_failures = 0
+ self.setup_tries = 0
+ self.cleanup_failures = 0
+ self.cleanup_tries = 0
+
+ date = time.strftime('%Y-%m-%d %H:%M:%S')
+
+ outpath = self.outdir.joinpath('output.txt')
+ self.outfile = outpath.open('w', encoding='utf-8') # NoQA: SIM115
+ self.outfile.write(('Results of doctest builder run on %s\n'
+ '==================================%s\n') %
+ (date, '=' * len(date)))
+
+ def _out(self, text: str) -> None:
+ logger.info(text, nonl=True)
+ self.outfile.write(text)
+
+ def _warn_out(self, text: str) -> None:
+ if self.app.quiet or self.app.warningiserror:
+ logger.warning(text)
+ else:
+ logger.info(text, nonl=True)
+ self.outfile.write(text)
+
+ def get_target_uri(self, docname: str, typ: str | None = None) -> str:
+ return ''
+
+ def get_outdated_docs(self) -> set[str]:
+ return self.env.found_docs
+
+ def finish(self) -> None:
+ # write executive summary
+ def s(v: int) -> str:
+ return 's' if v != 1 else ''
+ repl = (self.total_tries, s(self.total_tries),
+ self.total_failures, s(self.total_failures),
+ self.setup_failures, s(self.setup_failures),
+ self.cleanup_failures, s(self.cleanup_failures))
+ self._out('''
+Doctest summary
+===============
+%5d test%s
+%5d failure%s in tests
+%5d failure%s in setup code
+%5d failure%s in cleanup code
+''' % repl)
+ self.outfile.close()
+
+ if self.total_failures or self.setup_failures or self.cleanup_failures:
+ self.app.statuscode = 1
+
+ def write(self, build_docnames: Iterable[str] | None, updated_docnames: Sequence[str],
+ method: str = 'update') -> None:
+ if build_docnames is None:
+ build_docnames = sorted(self.env.all_docs)
+
+ logger.info(bold('running tests...'))
+ for docname in build_docnames:
+ # no need to resolve the doctree
+ doctree = self.env.get_doctree(docname)
+ self.test_doc(docname, doctree)
+
+ def get_filename_for_node(self, node: Node, docname: str) -> str:
+ """Try to get the file which actually contains the doctest, not the
+ filename of the document it's included in."""
+ try:
+ filename = relpath(node.source, self.env.srcdir)\
+ .rsplit(':docstring of ', maxsplit=1)[0]
+ except Exception:
+ filename = self.env.doc2path(docname, False)
+ return filename
+
+ @staticmethod
+ def get_line_number(node: Node) -> 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 # type: ignore[return-value]
+ if node.line is not None:
+ # TODO: find the root cause of this off by one error.
+ return node.line - 1
+ return None
+
+ def skipped(self, node: Element) -> bool:
+ if 'skipif' not in node:
+ return False
+ else:
+ condition = node['skipif']
+ context: dict[str, Any] = {}
+ if self.config.doctest_global_setup:
+ exec(self.config.doctest_global_setup, context) # NoQA: S102
+ should_skip = eval(condition, context) # NoQA: PGH001
+ if self.config.doctest_global_cleanup:
+ exec(self.config.doctest_global_cleanup, context) # NoQA: S102
+ return should_skip
+
+ def test_doc(self, docname: str, doctree: Node) -> None:
+ groups: dict[str, TestGroup] = {}
+ add_to_all_groups = []
+ self.setup_runner = SphinxDocTestRunner(verbose=False,
+ optionflags=self.opt)
+ self.test_runner = SphinxDocTestRunner(verbose=False,
+ optionflags=self.opt)
+ self.cleanup_runner = SphinxDocTestRunner(verbose=False,
+ optionflags=self.opt)
+
+ self.test_runner._fakeout = self.setup_runner._fakeout # type: ignore[attr-defined]
+ self.cleanup_runner._fakeout = self.setup_runner._fakeout # type: ignore[attr-defined]
+
+ if self.config.doctest_test_doctest_blocks:
+ def condition(node: Node) -> bool:
+ return (isinstance(node, (nodes.literal_block, nodes.comment)) and
+ 'testnodetype' in node) or \
+ isinstance(node, nodes.doctest_block)
+ else:
+ def condition(node: Node) -> bool:
+ return isinstance(node, (nodes.literal_block, nodes.comment)) \
+ and 'testnodetype' in node
+ for node in doctree.findall(condition): # type: Element
+ if self.skipped(node):
+ continue
+
+ 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'),
+ filename, line_number)
+ code = TestCode(source, type=node.get('testnodetype', 'doctest'),
+ 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)
+ continue
+ for groupname in node_groups:
+ if groupname not in groups:
+ groups[groupname] = TestGroup(groupname)
+ groups[groupname].add_code(code)
+ for code in add_to_all_groups:
+ for group in groups.values():
+ group.add_code(code)
+ if self.config.doctest_global_setup:
+ code = TestCode(self.config.doctest_global_setup,
+ 'testsetup', filename='<global_setup>', lineno=0)
+ for group in groups.values():
+ group.add_code(code, prepend=True)
+ if self.config.doctest_global_cleanup:
+ code = TestCode(self.config.doctest_global_cleanup,
+ 'testcleanup', filename='<global_cleanup>', lineno=0)
+ for group in groups.values():
+ group.add_code(code)
+ if not groups:
+ return
+
+ show_successes = self.config.doctest_show_successes
+ if show_successes:
+ self._out('\n'
+ f'Document: {docname}\n'
+ f'----------{"-" * len(docname)}\n')
+ for group in groups.values():
+ 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
+ self.setup_tries += res_t
+ if self.test_runner.tries:
+ res_f, res_t = self.test_runner.summarize(
+ self._out, verbose=show_successes)
+ self.total_failures += res_f
+ self.total_tries += res_t
+ if self.cleanup_runner.tries:
+ res_f, res_t = self.cleanup_runner.summarize(
+ self._out, verbose=show_successes)
+ self.cleanup_failures += res_f
+ self.cleanup_tries += res_t
+
+ def compile(self, code: str, name: str, type: str, flags: Any, dont_inherit: bool) -> Any:
+ return compile(code, name, self.type, flags, dont_inherit)
+
+ def test_group(self, group: TestGroup) -> None:
+ ns: dict = {}
+
+ def run_setup_cleanup(runner: Any, testcodes: list[TestCode], what: Any) -> bool:
+ examples = []
+ for testcode in testcodes:
+ example = doctest.Example(testcode.code, '', lineno=testcode.lineno)
+ examples.append(example)
+ if not examples:
+ return True
+ # simulate a doctest with the code
+ sim_doctest = doctest.DocTest(examples, {},
+ f'{group.name} ({what} code)',
+ testcodes[0].filename, 0, None)
+ sim_doctest.globs = ns
+ old_f = runner.failures
+ self.type = 'exec' # the snippet may contain multiple statements
+ runner.run(sim_doctest, out=self._warn_out, clear_globs=False)
+ if runner.failures > old_f:
+ return False
+ return True
+
+ # run the setup code
+ if not run_setup_cleanup(self.setup_runner, group.setup, 'setup'):
+ # if setup failed, don't run the group
+ return
+
+ # run the tests
+ for code in group.tests:
+ if len(code) == 1:
+ # ordinary doctests (code/output interleaved)
+ try:
+ test = parser.get_doctest(code[0].code, {}, group.name,
+ code[0].filename, code[0].lineno)
+ except Exception:
+ logger.warning(__('ignoring invalid doctest code: %r'), code[0].code,
+ location=(code[0].filename, code[0].lineno))
+ continue
+ if not test.examples:
+ continue
+ for example in test.examples:
+ # apply directive's comparison options
+ new_opt = code[0].options.copy()
+ new_opt.update(example.options)
+ example.options = new_opt
+ self.type = 'single' # as for ordinary doctests
+ else:
+ # testcode and output separate
+ output = code[1].code if code[1] else ''
+ options = code[1].options if code[1] else {}
+ # disable <BLANKLINE> processing as it is not needed
+ options[doctest.DONT_ACCEPT_BLANKLINE] = True
+ # find out if we're testing an exception
+ m = parser._EXCEPTION_RE.match(output) # type: ignore[attr-defined]
+ if m:
+ exc_msg = m.group('msg')
+ else:
+ exc_msg = None
+ example = doctest.Example(code[0].code, output, exc_msg=exc_msg,
+ lineno=code[0].lineno, options=options)
+ test = doctest.DocTest([example], {}, group.name,
+ 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
+ # also don't clear the globs namespace after running the doctest
+ self.test_runner.run(test, out=self._warn_out, clear_globs=False)
+
+ # run the cleanup
+ run_setup_cleanup(self.cleanup_runner, group.cleanup, 'cleanup')
+
+
+def setup(app: Sphinx) -> dict[str, Any]:
+ app.add_directive('testsetup', TestsetupDirective)
+ app.add_directive('testcleanup', TestcleanupDirective)
+ app.add_directive('doctest', DoctestDirective)
+ app.add_directive('testcode', TestcodeDirective)
+ app.add_directive('testoutput', TestoutputDirective)
+ app.add_builder(DocTestBuilder)
+ # this config value adds to sys.path
+ app.add_config_value('doctest_show_successes', True, False, (bool,))
+ app.add_config_value('doctest_path', [], False)
+ app.add_config_value('doctest_test_doctest_blocks', 'default', False)
+ app.add_config_value('doctest_global_setup', '', False)
+ app.add_config_value('doctest_global_cleanup', '', False)
+ app.add_config_value(
+ 'doctest_default_flags',
+ doctest.DONT_ACCEPT_TRUE_FOR_1 | doctest.ELLIPSIS | doctest.IGNORE_EXCEPTION_DETAIL,
+ False)
+ return {'version': sphinx.__display_version__, 'parallel_read_safe': True}