Example #1
0
 def configure(self, options, conf):
     super(SphinxDocPlugin, self).configure(options, conf)
     self.doc_dir_name = options.sphinx_doc_dir
     self.draw_graph = options.sphinx_doc_graph
     self.enable_webapp_chats = options.enable_webapp_chats
     if self.enable_webapp_chats:
         from monkeypatch import Recorder
         self.webapp_chats_recorder = Recorder()
         self.webapp_chats = {}
Example #2
0
class SphinxDocPlugin(Plugin):
    """
    Generate documentation of tests in sphinx rest format.

    Create documentation of tests, in a form of a
    sphinx .rst file with references to all tests.
    """

    name = 'sphinx_doc' # plugin name
    enableOpt = 'sphinx_doc' # default name for ouput file
    enable_webapp_chats = False
    has_errors = False

    #custom methods of SphinxDocPlugin

    def storeTest(self, test):
        """
        Add test to list of tests stored in self.tests.

        :param test:
            an instance of :py:class:`nose.case.Test`
        """
        self.tests.append(test)

    def testToDict(self, test_dict, test_info):
        """
        For given test create proper entries in test_dict.

        :param test_info:
            python dictionary with information about a test,
            contains keys:
            * module: module name
            * name: test name
            * test: an instance of :py:class:`nose.case.Test`
            * type: either "DocTestCase", "FunctionTestCase" or "TestCase"
        :param test_dict:
            python dictionary, will be modified
        """
        modules = test_info['module'].split('.')
        current = test_dict
        for submodule in modules:
            if submodule in current:
                pass
            else:
                current[submodule] = {}
            current = current[submodule]
        if '__tests__' in current:
            current['__tests__'].append(test_info)
        else:
            current['__tests__'] = [test_info]

    def extractTestInfo(self, test):
        """
        Extract usefull information from a test.

        :param test:
            an instance of :py:class:`nose.case.Test`
        :returns:
            dictionary with following keys
                * module
                    module name
                * name
                    test name
                * test
                    :py:class:`nose.case.Test` instance
                * type
                    either 'DocTestCase', 'FunctionTestCase' or 'TestCase'
        """
        chats = []
        if self.enable_webapp_chats:
           chats = self.webapp_chats[test.test]
        if isinstance(test.test, nose.plugins.doctests.DocTestCase):
            address = test.test.address()  # tuple: (file, module, name)
            module = address[1]
            name = test.test.id().replace(module+'.', '', 1)
            return {'module': module, 'name': name,
                'test': test, 'type': 'DocTestCase', 'webapp_chats': chats}

        elif isinstance(test.test, nose.case.FunctionTestCase):
            real_test = test.test.test  # get unwrapped test function
            module = real_test.__module__
            name = real_test.__name__
            return {'module': module, 'name': name,
                'test': test, 'type': 'FunctionTestCase', 'webapp_chats': chats}

        elif isinstance(test.test, unittest.TestCase):
            module = test.test.__module__
            name = type(test.test).__name__
            return {'module': module, 'name': name,
                'test': test, 'type': 'TestCase', 'webapp_chats': chats}
        else:
            raise Exception('unsupported test type:' + str(test.test))

    def processTests(self, tests):
        """
        Convert list of tests into a dictionary representing nested structure
        of tests.

        For example for given module structure:

        .. code-block :: none

            top_level_module
                -> sub_module
                    -> def test_me() ...
                    -> class MyTest(TestCase) ...


        result will look like this:

        .. code-block :: javascript

            {
                'top_level_module': {
                    'sub_module': {
                        '__tests__: {
                            'test_me': ,
                            'MyTest': ,
                     }
                }
            }

        :param tests:
            list of istances of :py:class:`nose.case.Test`
        :returns:
            dictionary
        """
        test_dict = {}  # dict for storing test structure

        for test in tests:
            test_info = self.extractTestInfo(test)
            self.testToDict(test_dict, test_info)
        return test_dict

    def sphinxSection(self, name, section_char='-'):
        """
        Generate sphinx-formatted header.

        :param name:
            Section name
        :param section_char:
            character used for marging section type
        :returns:
            Sphinx section header.
        """
        return '{0}\n{1}\n{0}\n'.format(section_char * len(name), name)

    @classmethod
    def _makedirs(cls, dirname):
        """
        Create directory structure without failing on existing dirs.

        :param dirname:
            directory name
        :raises:
            :py:exc:``OSError``
        :returns:
            None
        """
        try:
            os.makedirs(dirname)
        except OSError as exc:
            if exc.errno == errno.EEXIST:
                pass
            else:
                raise

    def _gen_header(self, module_path):
        """
        Generate header.

        :param module_path:
            list of module names
        :returns:
            text of section header
        """
        header = 'Tests'
        if module_path:
            header = '{0}'.format(module_path[-1])
        return header

    def _document_test_case(self, test_info):
        """
        Return sphinx-formatted documentation of a test case.

        Generate documentation for a :py:class:``nose.case.TestCase``
        type of test.

        :param test_info:
            dictionary
        :returns:
            sphinx-formatted text
        """
        lines = []
        test = test_info['test'].test
        name, doc = test._testMethodName, test._testMethodDoc
        classname = test.__class__.__name__
        lines.append(".. _%s.%s:\n" % (classname,name) )
        lines.append(name)
        lines.append('='*len(name))
        lines.append('')
        if doc:
            lines.append(textwrap.dedent(doc))
            lines.append('')
        if self.enable_webapp_chats:
            chat_texts = []
            for req, resp in test_info['webapp_chats']:
                chat_lines = ["\n:Request:\n"]
                chat_lines.append(self.format_chat(req))
                chat_lines.append(":Response:\n")
                chat_lines.append(self.format_chat(resp, is_resp=True))
                chat_texts.append('\n'.join(chat_lines))
            lines.append('\n--------------\n\n'.join(chat_texts))
        return '\n'.join(lines)

    def format_chat(self, chat, is_resp=False):
        lines = []
        lines.append(".. code-block:: http")
        lines.append('')
        if is_resp:
            text = response_to_string(chat)
        else:
            try:
                text = str(chat)
            except (UnicodeDecodeError, UnicodeEncodeError):
                text = chat.as_bytes().encode("string_escape")
                text = text.replace(r'\r\n', '\n').replace(r'\n', '\n')
        for line in text.split("\n"):
            lines.append(' ' * 2 + line)
        lines.append('\n')
        return '\n'.join(lines)

    def _document_doc_test_case(self, test_info):
        """
        Return sphinx-formatted documentation of a doctest case.

        Generate documentation for a
        :py:class:``nose.plugins.doctest.DocTestCase``
        type of test.

        :param test_info:
            dictionary
        :returns:
            sphinx-formatted text
        """
        lines = []
        lines.append('{0}Doctest in {1}.{2}:\n{3}.. code-block:: python\n\n'.format(
                ' ' * 4, test_info['module'], test_info['name'], ' ' * 8))
        docstring = test_info['test'].test._dt_test.docstring
        docstring_lines = self._lstrip_common_spaces(docstring.split('\n'))
        lines.extend(['{0}{1}\n'.format(' ' * 12, line) for line in docstring_lines])
        lines.append( ' ' * 8 + '\n')
        return ''.join(lines)

    def _lstrip_common_spaces(self, lines):
        """
        Remove prefixing spaces, without affecting text indentation.
        I.e. if first line contains 4 prefixing spaces, and second
        contains 8 spaces, return first line empty, and second with 4 prefixing spaces.

        :param lines:
            lines of text
        :returns:
            lines of text with
        """
        result = lines
        space_counters = [len(re.findall(r'^ *', line)[0])
                            for line in result
                            if len(line) > 0]
        if space_counters:
            min_val = min(space_counters)
            if min_val > 0:
                result = [re.sub('^\\ {{{0}}}'.format(min_val), '', line, count=1) for line in lines]
        return result

    def _document_function_test_case(self, test_info):
        """
        Return sphinx-formatted documentation of a test function.

        Generate documentation for a :py:class:``nose.case.FunctionTestCase``
        type of test.

        :param test_info:
            dictionary
        :returns:
            sphinx-formatted text
        """
        return('{0}.. autofunction:: {1}.{2}\n\n'.format(
            ' ' * 4, test_info['module'], test_info['name']))

    def _document_tests(self, test_info_list, module_path=''):
        """
        Generate sphinx section with a list of references to tests.

        :param test_info_list:
            List of ``test_info`` dictionaries
        :returns:
            sphinx-formatted documentation of test
        """
        if not test_info_list:
            return ''
        lines = []
        lines.append(self.sphinxSection(
            '%s tests' % '.'.join(module_path)
        ))

        for test_info in test_info_list:
            if test_info['type'] == 'TestCase':
                lines.append(self._document_test_case(test_info))
            elif test_info['type'] == 'DocTestCase':
                lines.append(self._document_doc_test_case(test_info))
            elif test_info['type'] == 'FunctionTestCase':
                lines.append(self._document_function_test_case(test_info))
            else:
                raise Exception('unknown test type')
        lines.append('\n')
        return '\n'.join(lines)

    def _get_toc(self, test_dict):
        """
        Generate TOC for submodules.
        """
        lines = []
        submodules = sorted(test_dict.keys())
        if '__tests__' in submodules:
            submodules.remove('__tests__')
        if submodules:
            lines.append('.. toctree::\n')
            lines.append('    :maxdepth: 1\n')
            lines.append('\n')
            for submodule in submodules:
                lines.append('    {0}<./{0}/index>\n'.format(submodule))
            lines.append('\n')
        return ''.join(lines)

    def _traverse(self, test_dict, dirname, module_path):
        """
        """
        self._makedirs(dirname)
        docfile = open(os.path.join(dirname, 'index.rst'), 'w')
        header = self._gen_header(module_path)

        docfile.write(self.sphinxSection(header, section_char='='))
        if module_path:
            docfile.write('    Tests in ``{0}``:\n\n'.format(
                '.'.join(module_path)))
        else:
            docfile.write('    Tests in this project:\n\n')

        docfile.write(self._get_toc(test_dict))

        if '__tests__' in test_dict:
            docfile.write(".. _%s:\n\n" % '.'.join(module_path) )
            docfile.write(self._document_tests(
                test_dict['__tests__'],
                module_path=module_path
            ))

        submodules = sorted(test_dict.keys())
        if '__tests__' in submodules:
            submodules.remove('__tests__')

        #recursive calls
        for m in submodules:
            new_module_path = module_path[:]
            new_module_path.append(m)
            self._traverse(test_dict[m], os.path.join(dirname, m),
                 new_module_path)
        if module_path == []:  # top-level
            if self.draw_graph:
                docfile.write(self.sphinxSection('Test graph'))
                docfile.write('.. graphviz:: tests.dot\n')

        docfile.close()

    def _drawGraph(self, test_dict, fname):
        """
        Draw graph for all tests.
        """
        def _traverse(test_dict, module_path):
            """
            """
            lines = []
            submodules = sorted(test_dict.keys())
            if '__tests__' in test_dict:
                submodules.remove('__tests__')
                for test in test_dict['__tests__']:
                    lines.append('        "{0}.{1}" [label="{1}"];\n'.format(
                        '.'.join(module_path), test['name']))
                    lines.append('        "{0}" -- "{0}.{1}";\n'.format(
                        '.'.join(module_path), test['name']))

            for submodule in submodules:
                node_id = '{0}.{1}'.format('.'.join(module_path), submodule)
                if not module_path:
                    node_id = submodule
                lines.append('        "{0}" [label="{1}"];\n'.format(
                    node_id, submodule))
                if module_path:
                    lines.append('        "{0}" -- "{0}.{1}";\n'.format(
                        '.'.join(module_path), submodule))
                new_module_path = module_path[:]
                new_module_path.append(submodule)
                lines.append(_traverse(test_dict[submodule], new_module_path))
            return ''.join(lines)

        graphfile = open(fname, 'w')

        graphfile.write('graph {\n')
        graphfile.write('    label="Tests";\n')
        graphfile.write(_traverse(test_dict, []))

        graphfile.write('}\n')

        graphfile.close()

    def genSphinxDoc(self, test_dict, dirname):
        """
        For given test_dict create nested set .rst files for sphinx.

        :param test_dict:
            python dictionary representing structure of tests

        :param: dirname:
            name of output directory
        """
        self._traverse(test_dict, dirname, [])
        if self.draw_graph:
            self._drawGraph(test_dict, os.path.join(dirname, 'tests.dot'))

    #methods inherited from Plugin

    def __init__(self, *args, **kwargs):
        super(SphinxDocPlugin, self).__init__(*args, **kwargs)
        self.tests = []  # list of all tests
        self.draw_graph = False  # draw test graph

    def prepareTestCase(self, test):
        self.storeTest(test)

    def begin(self):
        pass

    def options(self, parser, env=os.environ):
        #skip super call to avoid adding --with-* option.
        #super(SphinxDocPlugin, self).options(parser, env=env)
        parser.add_option('--sphinx-doc',
                      action='store_true',
                      dest=self.enableOpt,
                      default=env.get('NOSE_SPHINX_DOC', False),
                      help="Enable sphinx-doc: %s [NOSE_SPHINX_DOC]" %
                          (self.help()))
        parser.add_option('--sphinx-doc-dir',
                      dest='sphinx_doc_dir',
                      default=env.get('NOSE_SPHINX_DOC_DIR', '_test_doc'),
                      help="Output directory name for sphinx_doc,"
                           " use with sphinx_doc option"
                           " [NOSE_SPHINX_DOC_DIR]")
        parser.add_option('--sphinx-doc-graph',
                      action='store_true',
                      dest='sphinx_doc_graph',
                      default=env.get('NOSE_SPHINX_DOC_GRAPH', False),
                      help="Create test graph using sphinx grapviz extension,"
                           " use with sphinx_doc option"
                           " [NOSE_SPHINX_DOC_GRAPH]")
        parser.add_option('--webapp-chats',
                      action='store_true',
                      dest='enable_webapp_chats',
                      default=env.get('NOSE_SPHINX_WEBAPP_CHATS', False),
                      help="Include chats originating from webtest.TestApp"
                      )

    def configure(self, options, conf):
        super(SphinxDocPlugin, self).configure(options, conf)
        self.doc_dir_name = options.sphinx_doc_dir
        self.draw_graph = options.sphinx_doc_graph
        self.enable_webapp_chats = options.enable_webapp_chats
        if self.enable_webapp_chats:
            from monkeypatch import Recorder
            self.webapp_chats_recorder = Recorder()
            self.webapp_chats = {}

    def stopTest(self, test):
        if self.enable_webapp_chats:
            self.webapp_chats[test] = self.webapp_chats_recorder.chats
            self.webapp_chats_recorder.reset()

    def finalize(self, result):
        if not result.errors:
            test_dict = self.processTests(self.tests)
            self.genSphinxDoc(test_dict, self.doc_dir_name)
        else:
            self.has_errors = True

    def report(self, stream):
        if self.has_errors:
            stream.writeln("Not writing sphinx documentation since tests had errors.")