#!/usr/bin/python # Copyright (c) 2011 The Chromium OS Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """ Parse suite control files and make HTML documentation from included tests. This program will create a list of test cases found in suite files by parsing through each suite control file and making a list of all of the jobs called from it. Once it has a list of tests, it will parse the AutoTest control file for each test and grab the doc strings. These doc strings, along with any constraints in the suite control file, will be added to the original test script. These new scripts will be placed in a stand alone directory. Doxygen will then use these files for the sole purpose of producing HTML documentation for all of the tests. Once HTML docs are created some post processing will be done against the docs to change a few strings. If this script is executed without a --src argument, it will assume it is being executed from <ChromeOS>/src/third_party/autotest/files/utils/docgen/ directory. Classes: DocCreator This class is responsible for all processing. It requires the following: - Absolute path of suite control files. - Absolute path of where to place temporary files it constructs from the control files and test scripts. This class makes the following assumptions: - Each master suite has a README.txt file with general instructions on test preparation and usage. - The control file for each test has doc strings with labels of: - PURPOSE: one line description of why this test exists. - CRITERIA: Pass/Failure conditions. - DOC: additional test details. ReadNode This class parses a node from a control file into a key/value pair. In this context, a node represents a syntactic construct of an abstract syntax tree. The root of the tree is the module object (in this case a control file). If suite=True, it will assume the node is from a suite control file. Doxygen should already be configured with a configuration file called: doxygen.conf. This file should live in the same directory with this program. If you haven't installed doxygen, you'll need to install this program before this script is executed. This program will automatically update the doxygen.conf file to match self.src_tests and self.html. TODO: (kdlucas@google.com) Update ReadNode class to use the replacement module for the compiler module, as that has been deprecated. """ __author__ = 'kdlucas@google.com (Kelly Lucas)' __version__ = '0.9.1' import compiler import fileinput import glob import logging import optparse import os import shutil import subprocess import sys import fs_find_tests class DocCreator(object): """Process suite control files to combine docstrings and create HTML docs. The DocCreator class is designed to parse AutoTest suite control files to find all of the tests referenced, and build HTML documentation based on the docstrings in those files. It will cross reference the test control file and any parameters passed through the suite file, with the original test case. DocCreator relies on doxygen to actually generate the HTML documents. The workflow is as follows: - Parse the suite file(s) and generate a test list. - Locate the test source, and grab the docstrings from the associated AutoTest control file. - Combine the docstring from the control file with any parameters passed in from the suite control file, with the original test case. - Write a new test file with the combined docstrings to src_tests. - Create HTML documentation by running doxygen against the tests stored in self.src_tests. Implements the following methods: - GetTests() - Parse suite control files, create a dictionary of tests. - ParseControlFiles() - Runs through all tests and parses control files - _CleanDir() - Remove any files in a direcory and create an empty one. - _GetDoctString() - Parses docstrings and joins it with constraints. - _CreateTest() - Add docstrings and constraints to existing test script to form a new test script. - CreateMainPage() - Create a mainpage.txt file based on contents of the suite README file. - _ConfigDoxygen - Updates doxygen.conf to match some attributes this script was run with. - RunDoxygen() - Executes the doxygen program. - CleanDocs() - Changes some text in the HTML files to conform to our naming conventions and style. Depends upon class ReadNode. """ def __init__(self, options, args, logger): """Parse command line arguments and set some initial variables.""" self.options = options self.args = args self.logger = logger # Make parameters a little shorter by making the following assignments. if options.all_tests: self.suite = 'suite_All' else: self.suite = self.options.suite self.autotest_root = self.options.autotest_dir self.debug = self.options.debug self.docversion = self.options.docversion self.doxyconf = self.options.doxyconf self.html = '%s_%s' % (self.suite, self.options.html) self.latex = self.options.latex self.layout = self.options.layout self.logfile = self.options.logfile self.readme = self.options.readme self.src_tests = '%s_%s' % (self.suite, self.options.src_tests) self.testcase = {} self.testcase_src = {} self.site_dir = os.path.join(self.autotest_root, 'client', 'site_tests') self.test_dir = os.path.join(self.autotest_root, 'client', 'tests') self.suite_dir = os.path.join(self.site_dir, self.suite) self.logger.debug('Executing with debug level: %s', self.debug) self.logger.debug('Writing to logfile: %s', self.logfile) self.logger.debug('New test directory: %s', self.src_tests) self.logger.debug('Test suite: %s', self.suite) self.suitename = { 'suite_All': 'All Existing Autotest Tests', 'suite_Factory': 'Factory Testing', 'suite_HWConfig': 'Hardware Configuration', 'suite_HWQual': 'Hardware Qualification', } def GetAllTests(self): """Create list of all discovered tests.""" for path in [ 'server/tests', 'server/site_tests', 'client/tests', 'client/site_tests']: test_path = os.path.join(self.autotest_root, path) if not os.path.exists(test_path): continue self.logger.info("Scanning %s", test_path) tests, tests_src = fs_find_tests.GetTestsFromFS(test_path, self.logger) test_intersection = set(self.testcase) & set(tests) if test_intersection: self.logger.warning("Duplicates found: %s", test_intersection) self.testcase.update(tests) self.testcase_src.update(tests_src) def GetTestsFromSuite(self): """Create list of tests invoked by a suite.""" suite_search = os.path.join(self.suite_dir, 'control.*') for suitefile in glob.glob(suite_search): self.logger.debug('Scanning %s for tests', suitefile) if os.path.isfile(suitefile): try: suite = compiler.parseFile(suitefile) except SyntaxError, e: self.logger.error('Error parsing (gettests): %s\n%s', suitefile, e) raise SystemExit # Walk through each node found in the control file, which in our # case will be a call to a test. compiler.walk() will walk through # each component node, and call the appropriate function in class # ReadNode. The returned key should be a string, and the name of a # test. visitor.value should be any extra arguments found in the # suite file that are used with that test case. for n in suite.node.nodes: visitor = ReadNode(suite=True) compiler.walk(n, visitor) if len(visitor.key) > 1: filtered_input = '' # Lines in value should start with ' -' for bullet item. if visitor.value: lines = visitor.value.split('\n') for line in lines: if line.startswith(' -'): filtered_input += line + '\n' # A test could be called multiple times, so see if the key # already exists, and if so append the new value. if visitor.key in self.testcase: s = self.testcase[visitor.key] + filtered_input self.testcase[visitor.key] = s else: self.testcase[visitor.key] = filtered_input def GetTests(self): """Create dictionary of tests based on suite control file contents.""" if self.options.all_tests: self.GetAllTests() else: self.GetTestsFromSuite() def _CleanDir(self, directory): """Ensure the directory is available and empty. Args: directory: string, path of directory """ if os.path.isdir(directory): try: shutil.rmtree(directory) except IOError, err: self.logger.error('Error cleaning %s\n%s', directory, err) try: os.makedirs(directory) except IOError, err: self.logger.error('Error creating %s\n%s', directory, err) self.logger.error('Check your permissions of %s', directory) raise SystemExit def LocateTest(self, test_name): """Determine the full path location of the test.""" if test_name in self.testcase_src: return os.path.join(self.testcase_src[test_name], test_name) test_dir = os.path.join(self.site_dir, test_name) if not os.path.isdir(test_dir): test_dir = os.path.join(self.test_dir, test_name) if os.path.isdir(test_dir): return test_dir self.logger.warning('Cannot find test: %s', test) return None def ParseControlFiles(self): """Get docstrings from control files and add them to new test scripts. This method will cycle through all of the tests and attempt to find their control file. If found, it will parse the docstring from the control file, add this to any parameters found in the suite file, and add this combined docstring to the original test. These new tests will be written in the self.src_tests directory. """ # Clean some target directories. for d in [self.src_tests, self.html]: self._CleanDir(d) for test in self.testcase: test_dir = self.LocateTest(test) if test_dir: control_file = os.path.join(test_dir, 'control') test_file = os.path.join(test_dir, test + '.py') docstring = self._GetDocString(control_file, test) self._CreateTest(test_file, docstring, test) def _GetDocString(self, control_file, test): """Get the docstrings from control file and join to suite file params. Args: control_file: string, absolute path to test control file. test: string, name of test. Returns: string: combined docstring with needed markup language for doxygen. """ # Doxygen needs the @package marker. package_doc = '## @package ' # To allow doxygen to use special commands, we must use # for comments. comment = '# ' endlist = ' .\n' control_dict = {} output = [] temp = [] tempstring = '' docstring = '' keys = ['\\brief\n', '<H3>Pass/Fail Criteria:</H3>\n', '<H3>Author</H3>\n', '<H3>Test Duration</H3>\n', '<H3>Category</H3>\n', '<H3>Test Type</H3>\n', '<H3>Test Class</H3>\n', '<H3>Notest</H3>\n', ] if not os.path.isfile(control_file): self.logger.error('Cannot find: %s', control_file) return None try: control = compiler.parseFile(control_file) except SyntaxError, e: self.logger.error('Error parsing (docstring): %s\n%s', control_file, e) return None for n in control.node.nodes: visitor = ReadNode() compiler.walk(n, visitor) control_dict[visitor.key] = visitor.value for k in keys: if k in control_dict: if len(control_dict[k]) > 1: if k != test: temp.append(k) temp.append(control_dict[k]) if control_dict[k]: temp.append(endlist) # Add constraints and extra args after the Criteria section. if 'Criteria:' in k: if self.testcase[test]: temp.append('<H3>Arguments:</H3>\n') temp.append(self.testcase[test]) # '.' character at the same level as the '-' tells # doxygen this is the end of the list. temp.append(endlist) output.append(package_doc + test + '\n') tempstring = "".join(temp) lines = tempstring.split('\n') for line in lines: # Doxygen requires a '#' character to add special doxygen commands. comment_line = comment + line + '\n' output.append(comment_line) docstring = "".join(output) return docstring def _CreateTest(self, test_file, docstring, test): """Create a new test with the combined docstrings from multiple sources. Args: test_file: string, file name of new test to write. docstring: string, the docstring to add to the existing test. test: string, name of the test. This method is used to create a temporary copy of a new test, that will be a combination of the original test plus the docstrings from the control file, and any constraints from the suite control file. """ class_def = 'class ' + test pathname = os.path.join(self.src_tests, test + '.py') # Open the test and write out new test with added docstrings try: f = open(test_file, 'r') except IOError, err: self.logger.error('Error while reading %s\n%s', test_file, err) return lines = f.readlines() f.close() try: f = open(pathname, 'w') except IOError, err: self.logger.error('Error creating %s\n%s', pathname, err) return for line in lines: if class_def in line and docstring: f.write(docstring) f.write('\n') f.write(line) f.close() def CreateMainPage(self, current_dir): """Create a main page to provide content for index.html. This method assumes a file named README.txt is located in your suite directory with general instructions on setting up and using the suite. If your README file is in another file, ensure you pass a --readme option with the correct filename. To produce a better looking landing page, use the '-' character for list items. This method assumes os commands start with '$'. """ # Define some strings that Doxygen uses for specific formatting. cstart = '/**' cend = '**/' mp = '@mainpage' section_begin = '@section ' vstart = '@verbatim ' vend = ' @endverbatim\n' # Define some characters we expect to delineate sections in the README. sec_char = '==========' command_prompt = '$ ' crosh_prompt = 'crosh>' command_cont = '\\' command = False comment = False section = False sec_ctr = 0 if self.options.all_tests: readme_file = os.path.join(current_dir, self.readme) else: readme_file = os.path.join(self.suite_dir, self.readme) mainpage_file = os.path.join(self.src_tests, 'mainpage.txt') try: f = open(readme_file, 'r') except IOError, err: self.logger.error('Error opening %s\n%s', readme_file, err) return try: fw = open(mainpage_file, 'w') except IOError, err: self.logger.error('Error opening %s\n%s', mainpage_file, err) return lines = f.readlines() f.close() fw.write(cstart) fw.write('\n') fw.write(mp) fw.write('\n') for line in lines: if sec_char in line: comment = True section = not section elif section: sec_ctr += 1 section_name = ' section%d ' % sec_ctr fw.write(section_begin + section_name + line) else: # comment is used to denote when we should start recording text # from the README file. Some of the initial text is not needed. if comment: if command_prompt in line or crosh_prompt in line: line = line.rstrip() if line[-1] == command_cont: fw.write(vstart + line[:-1]) command = True else: fw.write(vstart + line + vend) elif command: line = line.strip() if line[-1] == command_cont: fw.write(line) else: fw.write(line + vend) command = False else: fw.write(line) fw.write('\n') fw.write(cend) fw.close() def _ConfigDoxygen(self): """Set Doxygen configuration to match our options.""" doxy_config = { 'ALPHABETICAL_INDEX': 'YES', 'EXTRACT_ALL': 'YES', 'EXTRACT_LOCAL_METHODS': 'YES', 'EXTRACT_PRIVATE': 'YES', 'EXTRACT_STATIC': 'YES', 'FILE_PATTERNS': '*.py *.txt', 'FULL_PATH_NAMES ': 'YES', 'GENERATE_TREEVIEW': 'YES', 'HTML_DYNAMIC_SECTIONS': 'YES', 'HTML_FOOTER': 'footer.html', 'HTML_HEADER': 'header.html', 'HTML_OUTPUT ': self.html, 'INLINE_SOURCES': 'YES', 'INPUT ': self.src_tests, 'JAVADOC_AUTOBRIEF': 'YES', 'LATEX_OUTPUT ': self.latex, 'LAYOUT_FILE ': self.layout, 'OPTIMIZE_OUTPUT_JAVA': 'YES', 'PROJECT_NAME ': self.suitename[self.suite], 'PROJECT_NUMBER': self.docversion, 'SOURCE_BROWSER': 'YES', 'STRIP_CODE_COMMENTS': 'NO', 'TAB_SIZE': '4', 'USE_INLINE_TREES': 'YES', } doxy_layout = { 'tab type="mainpage"': 'title="%s"' % self.suitename[self.suite], 'tab type="namespaces"': 'title="Tests"', 'tab type="namespacemembers"': 'title="Test Functions"', } for line in fileinput.input(self.doxyconf, inplace=1): for k in doxy_config: if line.startswith(k): line = '%s = %s\n' % (k, doxy_config[k]) sys.stdout.write(line) for line in fileinput.input('header.html', inplace=1): if line.startswith('<H2>'): line = '<H2>%s</H2>\n' % self.suitename[self.suite] sys.stdout.write(line) for line in fileinput.input(self.layout, inplace=1): for k in doxy_layout: if line.find(k) != -1: line = line.replace('title=""', doxy_layout[k]) sys.stdout.write(line.rstrip() + '\n') def RunDoxygen(self, doxyargs): """Execute Doxygen on the files in the self.src_tests directory. Args: doxyargs: string, any command line args to be passed to doxygen. """ doxycmd = 'doxygen %s' % doxyargs p = subprocess.Popen(doxycmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = p.communicate() if p.returncode: self.logger.error('Error while running %s', doxycmd) self.logger.error(stdout) self.logger.error(stderr) else: self.logger.info('%s successfully ran', doxycmd) def CreateDocs(self): """Configure and execute Doxygen to create HTML docuements.""" # First run doxygen with args to create default configuration files. # Create layout xml file. doxyargs = '-l %s' % self.layout self.RunDoxygen(doxyargs) # Create doxygen configuration file. doxyargs = '-g %s' % self.doxyconf self.RunDoxygen(doxyargs) # Edit the configuration files to match our options. self._ConfigDoxygen() # Run doxygen with configuration file as argument. self.RunDoxygen(self.doxyconf) def PostProcessDocs(self, current_dir): """Run some post processing on the newly created docs.""" # Key = original string, value = replacement string. replace = { '>Package': '>Test', } docpages = os.path.join(self.html, '*.html') files = glob.glob(docpages) for file in files: for line in fileinput.input(file, inplace=1): for k in replace: if line.find(k) != -1: line = line.replace(k, replace[k]) print line, logo_image = 'customLogo.gif' html_root = os.path.join(current_dir, self.html) shutil.copy(os.path.join(current_dir, logo_image), html_root) # Copy under dashboard. if self.options.dashboard: dashboard_root = os.path.join(self.autotest_root, 'results', 'dashboard', 'testdocs') if not os.path.isdir(dashboard_root): try: os.makedirs(dashboard_root) except e: self.logger.error('Error creating %s:%s', dashboard_root, e) return os.system('cp -r %s/* %s' % (html_root, dashboard_root)) os.system('find %s -type d -exec chmod 755 {} \;' % dashboard_root) os.system('find %s -type f -exec chmod 644 {} \;' % dashboard_root) self.logger.info('Sanitized documentation completed.') class ReadNode(object): """Parse a compiler node object from a control file. Args: suite: boolean, set to True if parsing nodes from a suite control file. """ def __init__(self, suite=False): self.key = '' self.value = '' self.testdef = False self.suite = suite self.bullet = ' - ' def visitName(self, n): if n.name == 'job': self.testdef = True def visitConst(self, n): if self.testdef: self.key = str(n.value) self.testdef = False else: self.value += str(n.value) + '\n' def visitKeyword(self, n): if n.name != 'constraints': self.value += self.bullet + n.name + ': ' for item in n.expr: if isinstance(item, compiler.ast.Const): for i in item: self.value += self.bullet + str(i) + '\n' self.value += ' .\n' else: self.value += str(item) + '\n' def visitAssName(self, n): # To remove section from appearing in the documentation, set value = ''. sections = { 'AUTHOR': '', 'CRITERIA': '<H3>Pass/Fail Criteria:</H3>\n', 'DOC': '<H3>Notes</H3>\n', 'NAME': '', 'PURPOSE': '\\brief\n', 'TIME': '<H3>Test Duration</H3>\n', 'TEST_CATEGORY': '<H3>Category</H3>\n', 'TEST_CLASS': '<H3>Test Class</H3>\n', 'TEST_TYPE': '<H3>Test Type</H3>\n', } if not self.suite: self.key = sections.get(n.name, n.name) def ParseOptions(current_dir): """Common processing of command line options.""" desc="""%prog will scan AutoTest suite control files to build a list of test cases called in the suite, and build HTML documentation based on the docstrings it finds in the tests, control files, and suite control files. """ parser = optparse.OptionParser(description=desc, prog='CreateDocs', version=__version__, usage='%prog') parser.add_option('--alltests', help='Scan for all tests', action='store_true', default=False, dest='all_tests') parser.add_option('--autotest_dir', help='path to autotest root directory' ' [default: %default]', default=None, dest='autotest_dir') parser.add_option('--dashboard', help='Copy output under dashboard', action='store_true', default=False, dest='dashboard') parser.add_option('--debug', help='Debug level [default: %default]', default='debug', dest='debug') parser.add_option('--docversion', help='Specify a version for the documentation' '[default: %default]', default=None, dest='docversion') parser.add_option('--doxy', help='doxygen configuration file [default: %default]', default=os.path.join(current_dir, 'doxygen.conf'), dest='doxyconf') parser.add_option('--html', help='path to store html docs [default: %default]', default='html', dest='html') parser.add_option('--latex', help='path to store latex docs [default: %default]', default='latex', dest='latex') parser.add_option('--layout', help='doxygen layout file [default: %default]', default=os.path.join(current_dir, 'doxygenLayout.xml'), dest='layout') parser.add_option('--log', help='Logfile for program output [default: %default]', default=os.path.join(current_dir, 'docCreator.log'), dest='logfile') parser.add_option('--readme', help='filename of suite documentation' '[default: %default]', default='README.txt', dest='readme') parser.add_option('--suite', help='Directory name of suite [default: %default]', type='choice', default='suite_HWQual', choices = [ 'suite_Factory', 'suite_HWConfig', 'suite_HWQual', ], dest='suite') parser.add_option('--tests', help='Absolute path of temporary test files' ' [default: %default]', default='testsource', dest='src_tests') return parser.parse_args() def CheckOptions(options, logger): """Verify required command line options.""" if not options.autotest_dir: logger.error('You must supply --autotest_dir') raise SystemExit if not os.path.isfile(options.doxyconf): logger.error('Unable to locate --doxy: %s', options.doxyconf) raise SystemExit if not os.path.isfile(options.layout): logger.error('Unable to locate --layout: %s', options.layout) raise SystemExit def SetLogger(namespace, options): """Create a logger with some good formatting options. Args: namespace: string, name associated with this logger. Returns: Logger object. This method assumes logfile and debug are already set. This logger will write to stdout as well as a log file. """ loglevel = {'debug': logging.DEBUG, 'info': logging.INFO, 'warning': logging.WARNING, 'error': logging.ERROR, 'critical': logging.CRITICAL, } logger = logging.getLogger(namespace) c = logging.StreamHandler() h = logging.FileHandler( os.path.join(os.path.abspath('.'), options.logfile)) hf = logging.Formatter( '%(asctime)s %(process)d %(levelname)s: %(message)s') cf = logging.Formatter('%(levelname)s: %(message)s') logger.addHandler(h) logger.addHandler(c) h.setFormatter(hf) c.setFormatter(cf) logger.setLevel(loglevel.get(options.debug, logging.INFO)) return logger def main(): current_dir = os.path.dirname(sys.argv[0]) options, args = ParseOptions(current_dir) logger = SetLogger('docCreator', options) CheckOptions(options, logger) doc = DocCreator(options, args, logger) doc.GetTests() doc.ParseControlFiles() doc.CreateMainPage(current_dir) doc.CreateDocs() doc.PostProcessDocs(current_dir) if __name__ == '__main__': main()