blob: 03de60bdd73b272283a263a978161674b42ba13c [file] [log] [blame]
#!/usr/bin/python
#
# Copyright (c) 2014 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.
"""Generates some .rst files used to document parts of the factory toolkit.
Currently this generates a list of all available pytests, in the
doc/pytests directory. In the future it may be extended to generate,
for example, a test list tree.
(Ideally these could be generated via Sphinx extensions, but this is not
always practical.)
"""
import argparse
import codecs
import importlib
import inspect
import logging
import os
import re
import unittest
import factory_common # pylint: disable=W0611
from cros.factory.test import pytests
from cros.factory.test.utils import Enum
from cros.factory.utils import file_utils
def Escape(text):
r"""Escapes characters that must be escaped in a raw tag (*, `, and \)."""
return re.sub(r'([*`\\])', '\\\\\\1', text)
def Indent(text, prefix, first_line_prefix=None):
"""Indents a string.
Args:
text: The input string.
prefix: The string to insert at the beginning of each line.
first_line_prefix: The string to insert at the beginning of the first line.
Defaults to prefix if unspecified.
"""
if first_line_prefix is None:
first_line_prefix = prefix
return re.sub(
'(?m)^',
lambda match: first_line_prefix if match.start() == 0 else prefix,
text)
def WriteTestArgs(args, out):
"""Writes a table describing test arguments.
Args:
args: A list of Arg objects, as in the ARGS attribute of a pytest.
out: A stream to write to.
"""
if not args:
out.write('This test does not have any arguments.')
return
out.write(
'Test Arguments\n'
'--------------\n'
'.. list-table::\n'
' :widths: 20 10 60\n'
' :header-rows: 1\n'
'\n')
def WriteRow(cols):
for i, col in enumerate(cols):
# Indent the first line with " - " (or " * - " if it's the first
# column)
out.write(Indent(col, ' ' * 7, ' * - ' if i == 0 else ' - '))
out.write('\n')
WriteRow(('Name', 'Type', 'Description'))
for arg in args:
description = arg.help.strip()
annotations = []
if arg.optional:
annotations.append('optional')
if arg.default is not None:
annotations.append('default: ``%s``' % Escape(repr(arg.default)))
if annotations:
description = '(%s) %s' % ('; '.join(annotations), description)
def FormatArgType(arg_type):
if isinstance(arg_type, Enum):
return '[' + ', '.join(str(x) for x in sorted(arg_type)) + ']'
elif arg_type == type(None):
return 'None'
else:
return arg_type.__name__
arg_types = ', '.join(FormatArgType(x) for x in arg.type)
WriteRow((arg.name, arg_types, description))
def GenerateTestDocs(pytest_name, module, out):
"""Generates test docs for a pytest.
Args:
pytest_name: The name of the pytest (e.g., 'foo' for foo.py).
module: The module of the pytest. This may be the module itself,
or the name of the module (e.g., cros.factory.test.pytest.foo).
out: A stream to write to.
Returns:
A dictionary of information about the test. Currently this is just
dict(short_docstring=short_docstring)
where short_docstring is the first line of the docstring.
"""
if isinstance(module, str):
module_name = module
try:
module = importlib.import_module(module_name)
except ImportError:
logging.warn('Unable to import %s', module_name)
return
# Find the TestCase object.
test_cases = [v for v in module.__dict__.itervalues()
if inspect.isclass(v) and issubclass(v, unittest.TestCase)]
if not test_cases:
logging.warn('No test cases found in %s', module.__name__)
return
if len(test_cases) > 1:
logging.warn('Expected only one test case in %s but found %r',
module.__name__, test_cases)
return
test_case = test_cases[0]
args = getattr(test_case, 'ARGS', [])
doc = getattr(module, '__doc__', None)
if doc is None:
doc = 'No test-level description available for pytest %s.' % pytest_name
if isinstance(doc, str):
doc = doc.decode('utf-8')
out.write(pytest_name + '\n')
out.write('=' * len(pytest_name) + '\n')
out.write(doc)
out.write('\n\n')
WriteTestArgs(args, out)
# Remove everything after the first pair of newlines.
short_docstring = re.sub(r'(?s)\n\s*\n.+', '', doc).strip()
return dict(short_docstring=short_docstring)
def main():
parser = argparse.ArgumentParser(
description='Generate .rst files for the factory toolkit')
parser.add_argument('--output-dir', '-o',
help='Output directory (default: %default)', default='.')
args = parser.parse_args()
pytests_output_dir = os.path.join(args.output_dir, 'pytests')
file_utils.TryMakeDirs(pytests_output_dir)
pytest_module_dir = os.path.dirname(pytests.__file__)
# Map of pytest name to info returned by GenerateTestDocs.
pytest_info = {}
for root, unused_dirs, files in os.walk(pytest_module_dir):
for f in files:
if (not f.endswith('.py') or
f.startswith('__') or
f == 'factory_common.py' or
f.endswith('_automator.py') or
f.endswith('_e2etest.py') or
f.endswith('_unittest.py') or
f.endswith('_impl.py')):
continue
# E.g., "foo.py" or "foo/foo.py"
relpath = os.path.relpath(os.path.join(root, f),
pytest_module_dir)
# E.g., "foo" or "foo/foo"
base = os.path.splitext(relpath)[0]
if '/' in relpath:
# It's in a subpackage. Accept it only if it looks like
# foo/foo.py.
dirname, filename = os.path.split(base)
if dirname != filename:
continue
module_name = ('cros.factory.test.pytests.' +
base.replace('/', '.'))
pytest_name = os.path.basename(base)
with codecs.open(os.path.join(pytests_output_dir, pytest_name + '.rst'),
'w', 'utf-8') as out:
pytest_info[pytest_name] = GenerateTestDocs(
pytest_name, module_name, out)
index_rst = os.path.join(pytests_output_dir, 'index.rst')
with open(index_rst, 'a') as f:
for k, v in sorted(pytest_info.items()):
if v is not None:
f.write(' * - `%s <%s.html>`_\n' % (k, k))
f.write(Indent(v['short_docstring'], ' ' * 7, ' - '))
f.write('\n')
if __name__ == '__main__':
main()