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 = {}
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.")