[Python-checkins] r61689 - in doctools/trunk/sphinx: ext/doctest.py htmlwriter.py

georg.brandl python-checkins at python.org
Fri Mar 21 14:17:05 CET 2008


Author: georg.brandl
Date: Fri Mar 21 14:17:04 2008
New Revision: 61689

Added:
   doctools/trunk/sphinx/ext/doctest.py
Modified:
   doctools/trunk/sphinx/htmlwriter.py
Log:
Add the doctest extension.  It provides a builder that runs
test snippets in the documentation, marked up as doctest snippets
or separate code and output blocks.  Providing setup code that
is run but not shown in HTML output is supported.


Added: doctools/trunk/sphinx/ext/doctest.py
==============================================================================
--- (empty file)
+++ doctools/trunk/sphinx/ext/doctest.py	Fri Mar 21 14:17:04 2008
@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+"""
+    sphinx.ext.doctest
+    ~~~~~~~~~~~~~~~~~~
+
+    Mimic doctest by automatically executing code snippets and checking
+    their results.
+
+    :copyright: 2008 by Georg Brandl.
+    :license: BSD.
+"""
+
+import sys
+import time
+import StringIO
+from os import path
+# circumvent relative import
+doctest = __import__('doctest')
+
+from docutils import nodes
+from docutils.parsers.rst import directives
+
+from sphinx.builder import Builder
+
+
+# set up the necessary directives
+
+def test_directive(name, arguments, options, content, lineno,
+                   content_offset, block_text, state, state_machine):
+    # 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(content)
+    nodetype = nodes.literal_block
+    if name == 'testsetup' or \
+       (name == 'testoutput' and 'hide' in options):
+        nodetype = nodes.comment
+    node = nodetype(code, code)
+    node.line = lineno
+    if arguments:
+        groups = [x.strip() for x in arguments[0].split(',')]
+    else:
+        groups = ['default']
+    node['testnodetype'] = name
+    node['groups'] = groups
+    if name == 'testoutput':
+        # don't try to highlight output
+        node['language'] = 'none'
+    node['options'] = {}
+    if name == 'testoutput' and 'options' in options:
+        # parse doctest-like output comparison flags
+        option_strings = options['options'].replace(',', ' ').split()
+        for option in option_strings:
+            if (option[0] not in '+-' or option[1:] not in
+                doctest.OPTIONFLAGS_BY_NAME):
+                # XXX warn?
+                continue
+            flag = doctest.OPTIONFLAGS_BY_NAME[option[1:]]
+            node['options'][flag] = (option[0] == '+')
+    return [node]
+
+# need to have individual functions for each directive due to different
+# options they accept
+
+def testsetup_directive(*args):
+    return test_directive(*args)
+
+def doctest_directive(*args):
+    return test_directive(*args)
+
+def testcode_directive(*args):
+    return test_directive(*args)
+
+def testoutput_directive(*args):
+    return test_directive(*args)
+
+
+parser = doctest.DocTestParser()
+
+# helper classes
+
+class TestGroup(object):
+    def __init__(self, name):
+        self.name = name
+        self.setup = []
+        self.tests = []
+
+    def add_code(self, code):
+        if code.type == 'testsetup':
+            self.setup.append(code)
+        elif code.type == 'doctest':
+            self.tests.append([code])
+        elif code.type == 'testcode':
+            self.tests.append([code, None])
+        elif code.type == 'testoutput':
+            if self.tests and len(self.tests[-1]) == 2:
+                self.tests[-1][1] = code
+        else:
+            raise RuntimeError('invalid TestCode type')
+
+    def __repr__(self):
+        return 'TestGroup(name=%r, setup=%r, code=%r)' % (
+            self.name, self.setup, self.code)
+
+
+class TestCode(object):
+    def __init__(self, code, type, lineno, options=None):
+        self.code = code
+        self.type = type
+        self.lineno = lineno
+        self.options = options or {}
+
+    def __repr__(self):
+        return 'TestCode(%r, %r, %r, options=%r)' % (
+            self.code, self.type, self.lineno, self.options)
+
+
+class SphinxDocTestRunner(doctest.DocTestRunner):
+    def summarize(self, out, verbose=None):
+        io = StringIO.StringIO()
+        old_stdout = sys.stdout
+        sys.stdout = io
+        try:
+            res = doctest.DocTestRunner.summarize(self, verbose)
+        finally:
+            sys.stdout = old_stdout
+        out(io.getvalue())
+        return res
+
+# the new builder -- use sphinx-build.py -b doctest to run
+
+class DocTestBuilder(Builder):
+    """
+    Runs test snippets in the documentation.
+    """
+    name = 'doctest'
+
+    def init(self):
+        # default options
+        self.opt = doctest.DONT_ACCEPT_TRUE_FOR_1 | doctest.ELLIPSIS | \
+                   doctest.IGNORE_EXCEPTION_DETAIL
+
+        self.total_failures = 0
+        self.total_tries = 0
+        self.setup_failures = 0
+        self.setup_tries = 0
+
+        date = time.strftime('%Y-%m-%d %H:%M:%S')
+
+        self.outfile = file(path.join(self.outdir, 'output.txt'), 'w')
+        self.outfile.write('''\
+Results of doctest builder run on %s
+==================================%s
+''' % (date, '='*len(date)))
+
+    def _out(self, text):
+        self.info(text, nonl=True)
+        self.outfile.write(text)
+
+    def get_target_uri(self, docname, typ=None):
+        return ''
+
+    def get_outdated_docs(self):
+        return self.env.all_docs
+
+    def prepare_writing(self, docnames):
+        return
+
+    def finish(self):
+        # write executive summary
+        def s(v):
+            return v != 1 and 's' or ''
+        self._out('''
+Doctest summary
+===============
+%5d test%s
+%5d failure%s in tests
+%5d failure%s in setup code
+''' % (self.total_tries, s(self.total_tries),
+       self.total_failures, s(self.total_failures),
+       self.setup_failures, s(self.setup_failures)))
+        self.outfile.close()
+
+    def write_doc(self, docname, doctree):
+        groups = {}
+        add_to_all_groups = []
+        self.setup_runner = SphinxDocTestRunner(verbose=False,
+                                                optionflags=self.opt)
+        self.test_runner = SphinxDocTestRunner(verbose=False,
+                                               optionflags=self.opt)
+        self.info()
+        if self.config.doctest_test_doctest_blocks:
+            def condition(node):
+                return (isinstance(node, (nodes.literal_block, nodes.comment))
+                        and node.has_key('testnodetype')) or \
+                       isinstance(node, nodes.doctest_block)
+        else:
+            def condition(node):
+                return isinstance(node, (nodes.literal_block, nodes.comment)) \
+                        and node.has_key('testnodetype')
+        for node in doctree.traverse(condition):
+            code = TestCode(node.astext(),
+                            type=node.get('testnodetype', 'doctest'),
+                            lineno=node.line, 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.itervalues():
+                group.add_code(code)
+        if not groups:
+            return
+
+        self.outfile.write('\nDocument: %s\n----------%s\n' %
+                           (docname, '-'*len(docname)))
+        for group in groups.itervalues():
+            self.test_group(group, self.env.doc2path(docname, base=None))
+        # 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=True)
+            self.total_failures += res_f
+            self.total_tries += res_t
+
+    def test_group(self, group, filename):
+        ns = {}
+        examples = []
+        for setup in group.setup:
+            examples.append(doctest.Example(setup.code, '', lineno=setup.lineno))
+        if examples:
+            # simulate a doctest with the setup code
+            setup_doctest = doctest.DocTest(examples, {},
+                                            '%s (setup code)' % group.name,
+                                            filename, 0, None)
+            setup_doctest.globs = ns
+            old_f = self.setup_runner.failures
+            self.setup_runner.run(setup_doctest, out=self._out,
+                                  clear_globs=False)
+            if self.setup_runner.failures > old_f:
+                # don't run the group
+                return
+        for code in group.tests:
+            if len(code) == 1:
+                test = parser.get_doctest(code[0].code, {},
+                                          group.name, filename, code[0].lineno)
+                if not test.examples:
+                    self._out('WARNING: no examples in doctest block at '
+                              + filename + ', line %s' % code[0].lineno)
+                    continue
+            else:
+                output = code[1] and code[1].code or ''
+                options = code[1] and code[1].options or None
+                example = doctest.Example(code[0].code, output,
+                                          lineno=code[0].lineno,
+                                          options=options)
+                test = doctest.DocTest([example], {}, group.name,
+                                       filename, code[0].lineno, None)
+            # 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._out, clear_globs=False)
+
+
+def setup(app):
+    app.add_directive('testsetup', testsetup_directive, 1, (0, 1, 1))
+    app.add_directive('doctest', doctest_directive, 1, (0, 1, 1))
+    app.add_directive('testcode', testcode_directive, 1, (0, 1, 1))
+    app.add_directive('testoutput', testoutput_directive, 1, (0, 1, 1),
+                      hide=directives.flag, options=directives.unchanged)
+    app.add_builder(DocTestBuilder)
+    # this config value adds to sys.path
+    app.add_config_value('doctest_path', [], False)
+    app.add_config_value('doctest_test_doctest_blocks', True, False)
+

Modified: doctools/trunk/sphinx/htmlwriter.py
==============================================================================
--- doctools/trunk/sphinx/htmlwriter.py	(original)
+++ doctools/trunk/sphinx/htmlwriter.py	Fri Mar 21 14:17:04 2008
@@ -185,7 +185,8 @@
             lang = node['language']
         if node.has_key('linenos'):
             linenos = node['linenos']
-        self.body.append(self.highlighter.highlight_block(node.rawsource, lang, linenos))
+        self.body.append(self.highlighter.highlight_block(node.rawsource,
+                                                          lang, linenos))
         raise nodes.SkipNode
 
     def visit_doctest_block(self, node):


More information about the Python-checkins mailing list