blob: 28989ecb3eda7935e591e3571d739ede81ff0815 [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2018 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 inspect
import logging
import os
import re
import StringIO
import factory_common # pylint: disable=unused-import
from cros.factory.probe import function as probe_function
from cros.factory.test.env import paths
from cros.factory.test.test_lists import manager
from cros.factory.test.utils import pytest_utils
from cros.factory.utils import file_utils
from cros.factory.utils import json_utils
from cros.factory.utils.type_utils import Enum
DOC_GENERATORS = {}
def DocGenerator(dir_name):
def Decorator(func):
assert dir_name not in DOC_GENERATORS
DOC_GENERATORS[dir_name] = func
return func
return Decorator
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 LinkToDoc(name, path):
"""Create a hyper-link tag which links to another document file.
Args:
name: The tag name.
path: Path of the target document, either absolute or relative.
"""
return ':doc:`%s <%s>`' % (Escape(name), Escape(path))
class RSTWriter(object):
def __init__(self, io):
self.io = io
def WriteTitle(self, title, mark, ref_label=None):
if ref_label:
self.io.write('.. _%s:\n\n' % Escape(ref_label))
self.io.write(title + '\n')
self.io.write(mark * len(title) + '\n')
def WriteParagraph(self, text):
self.io.write(text + '\n\n')
def WriteListItem(self, content):
self.WriteParagraph('- ' + content)
def WriteListTableHeader(self, widths=None, header_rows=None):
self.io.write('.. list-table::\n')
if widths is not None:
self.io.write(' :widths: %s\n' % ' '.join(map(str, widths)))
if header_rows is not None:
self.io.write(' :header-rows: %d\n' % header_rows)
self.io.write('\n')
def WriteListTableRow(self, row):
for i, cell in enumerate(row):
# Indent the first line with " - " (or " * - " if it's the first
# column)
self.io.write(Indent(cell, ' ' * 7, ' * - ' if i == 0 else ' - '))
self.io.write('\n')
self.io.write('\n')
def WriteArgsTable(rst, title, args):
"""Writes a table describing arguments.
Args:
rst: An instance of RSTWriter for writting RST context.
title: The title of the arguments section.
args: A list of Arg objects, as in the ARGS attribute of a pytest.
"""
rst.WriteTitle(title, '-')
if not args:
rst.WriteParagraph('This test does not have any arguments.')
return
rst.WriteListTableHeader(widths=(20, 10, 60), header_rows=1)
rst.WriteListTableRow(('Name', 'Type', 'Description'))
for arg in args:
description = arg.help.strip()
annotations = []
if arg.IsOptional():
annotations.append('optional')
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 repr(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)
rst.WriteListTableRow((arg.name, arg_types, description))
def GenerateTestDocs(rst, pytest_name):
"""Generates test docs for a pytest.
Args:
rst: A stream to write to.
pytest_name: The name of pytest under package cros.factory.test.pytests.
Returns:
The first line of the docstring.
"""
module = pytest_utils.LoadPytestModule(pytest_name)
test_case = pytest_utils.FindTestCase(module)
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')
rst.WriteTitle(pytest_name, '=')
rst.WriteParagraph(doc)
WriteArgsTable(rst, 'Test Arguments', args)
# Remove everything after the first pair of newlines.
return re.sub(r'(?s)\n\s*\n.+', '', doc).strip()
@DocGenerator('pytests')
def GeneratePyTestsDoc(pytests_output_dir):
# Map of pytest name to info returned by GenerateTestDocs.
pytest_info = {}
for relpath in pytest_utils.GetPytestList(paths.FACTORY_DIR):
pytest_name = pytest_utils.RelpathToPytestName(relpath)
with codecs.open(
os.path.join(pytests_output_dir, pytest_name + '.rst'),
'w', 'utf-8') as out:
try:
pytest_info[pytest_name] = GenerateTestDocs(RSTWriter(out), pytest_name)
except Exception:
logging.warn('Failed to generate document for pytest %s.', pytest_name)
index_rst = os.path.join(pytests_output_dir, 'index.rst')
with open(index_rst, 'a') as f:
rst = RSTWriter(f)
for k, v in sorted(pytest_info.iteritems()):
rst.WriteListTableRow((LinkToDoc(k, k), v))
def WriteTestObjectDetail(
rst,
test_object_name,
test_object):
"""Writes a test_object to output stream.
Args:
rst: An instance of RSTWriter for writing RST context.
test_object_name: name of the test object (string).
test_object: a test_object defined by JSON test list.
"""
rst.WriteTitle(Escape(test_object_name), '-')
if '__comment' in test_object:
rst.WriteParagraph(Escape(test_object['__comment']))
if test_object.get('args'):
rst.WriteTitle('args', '`')
for key, value in test_object['args'].iteritems():
formatted_value = json_utils.DumpStr(value, pretty=True)
formatted_value = '::\n\n' + Indent(formatted_value, ' ')
formatted_value = Indent(formatted_value, ' ')
rst.WriteParagraph('``{key}``\n{value}'.format(
key=key, value=formatted_value))
@DocGenerator('test_lists')
def GenerateTestListDoc(output_dir):
manager_ = manager.Manager()
manager_.BuildAllTestLists()
for test_list_id in manager_.GetTestListIDs():
out_path = os.path.join(output_dir, test_list_id + '.test_list.rst')
with open(out_path, 'w') as out:
rst = RSTWriter(out)
logging.warn('processing test list %s', test_list_id)
test_list = manager_.GetTestListByID(test_list_id)
config = test_list.ToTestListConfig()
raw_config = manager_.loader.Load(test_list_id, allow_inherit=False)
rst.WriteTitle(test_list_id, '=')
if raw_config.get('__comment'):
rst.WriteParagraph(Escape(raw_config['__comment']))
rst.WriteTitle('Inherit', '-')
for parent in raw_config.get('inherit', []):
rst.WriteListItem(LinkToDoc(parent, parent))
rst.WriteTitle('Definitions', '-')
rst.WriteParagraph('Only pytest definitions are listed.')
rst_define = RSTWriter(StringIO.StringIO())
rst_detail = RSTWriter(StringIO.StringIO())
rst_define.WriteListTableHeader(header_rows=1)
rst_define.WriteListTableRow(('Defined Name', 'Pytest Name'))
has_definitions = False
for test_object_name in sorted(raw_config['definitions'].keys()):
test_object = config['definitions'][test_object_name]
test_object = test_list.ResolveTestObject(
test_object, test_object_name, cache={})
if test_object.get('pytest_name'):
has_definitions = True
pytest_name = test_object['pytest_name']
doc_path = os.path.join('..', 'pytests', pytest_name)
rst_define.WriteListTableRow(('`%s`_' % Escape(test_object_name),
LinkToDoc(pytest_name, doc_path)))
WriteTestObjectDetail(rst_detail, test_object_name, test_object)
if has_definitions:
rst.WriteParagraph(rst_define.io.getvalue())
rst.WriteParagraph(rst_detail.io.getvalue())
def FinishTemplate(path, **kwargs):
template = file_utils.ReadFile(path)
file_utils.WriteFile(path, template.format(**kwargs))
def GetModuleClassDoc(cls):
# Remove the indent.
s = re.sub(r'^ ', '', cls.__doc__ or '', flags=re.M)
return tuple(t.strip('\n')
for t in re.split(r'\n\s*\n', s + '\n\n', maxsplit=1))
def GenerateProbeFunctionDoc(functions_path, func_name, func_cls):
short_desc, main_desc = GetModuleClassDoc(func_cls)
with open(os.path.join(functions_path, func_name + '.rst'), 'w') as f:
rst = RSTWriter(f)
rst.WriteTitle(func_name, '=')
rst.WriteParagraph(short_desc)
WriteArgsTable(rst, 'Function Arguments', func_cls.ARGS)
rst.WriteParagraph(main_desc)
return short_desc, os.path.join(os.path.basename(functions_path), func_name)
@DocGenerator('probe')
def GenerateProbeDoc(output_dir):
func_tables = {}
def _AppendToFunctionTable(func_cls, row):
all_base_cls = inspect.getmro(func_cls)
base_cls_index = all_base_cls.index(probe_function.Function) - 1
if base_cls_index == 0:
func_type = 'Misc'
type_desc = ''
else:
func_type = all_base_cls[base_cls_index].__name__
type_desc = GetModuleClassDoc(all_base_cls[base_cls_index])[1]
if func_type not in func_tables:
rst = func_tables[func_type] = RSTWriter(StringIO.StringIO())
rst.WriteTitle(func_type, '`', ref_label=func_type)
rst.WriteParagraph(type_desc)
rst.WriteListTableHeader(header_rows=1)
rst.WriteListTableRow(('Function Name', 'Short Description'))
func_tables[func_type].WriteListTableRow(row)
functions_path = os.path.join(output_dir, 'functions')
file_utils.TryMakeDirs(functions_path)
# Parse all functions.
probe_function.LoadFunctions()
for func_name in sorted(probe_function.GetRegisteredFunctions()):
func_cls = probe_function.GetFunctionClass(func_name)
short_desc, doc_path = GenerateProbeFunctionDoc(
functions_path, func_name, func_cls)
_AppendToFunctionTable(
func_cls, (LinkToDoc(func_name, doc_path), short_desc))
# Generate list tables of all functions, category by the function type.
functions_section_rst = RSTWriter(StringIO.StringIO())
# Always render `Misc` section at the end.
func_types = sorted(func_tables.keys())
if 'Misc' in func_types:
func_types.remove('Misc')
func_types.append('Misc')
for func_type in func_types:
func_table_rst = func_tables[func_type]
functions_section_rst.WriteParagraph(func_table_rst.io.getvalue())
# Generate the index file.
FinishTemplate(os.path.join(output_dir, 'index.rst'),
functions_section=functions_section_rst.io.getvalue())
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()
for dir_name, func in DOC_GENERATORS.iteritems():
full_path = os.path.join(args.output_dir, dir_name)
file_utils.TryMakeDirs(full_path)
func(full_path)
if __name__ == '__main__':
main()