#!/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()