CTS: Python script to replace the description and plan generator

This will eventually replace the very complex and hard to maintain
genDefaultTestPlan.sh. In this initial step, the functionality is
kept identical to original script.

A separate change in build/core/tasks/cts.mk is needed to make the
switch to the new script.
diff --git a/tools/utils/DescriptionGenerator.java b/tools/utils/DescriptionGenerator.java
index d629fb8..467263d 100644
--- a/tools/utils/DescriptionGenerator.java
+++ b/tools/utils/DescriptionGenerator.java
@@ -85,6 +85,8 @@
 
     static final String XML_OUTPUT_PATH = "./description.xml";
 
+    static final String OUTPUT_PATH_OPTION = "-o";
+
     /**
      * Start to parse the classes passed in by javadoc, and generate
      * the xml file needed by CTS packer.
@@ -99,9 +101,17 @@
             return true;
         }
 
+        String outputPath = XML_OUTPUT_PATH;
+        String[][] options = root.options();
+        for (String[] option : options) {
+            if (option.length == 2 && option[0].equals(OUTPUT_PATH_OPTION)) {
+                outputPath = option[1];
+            }
+        }
+
         XMLGenerator xmlGenerator = null;
         try {
-            xmlGenerator = new XMLGenerator(XML_OUTPUT_PATH);
+            xmlGenerator = new XMLGenerator(outputPath);
         } catch (ParserConfigurationException e) {
             Log.e("Cant initialize XML Generator!", e);
             return true;
@@ -123,6 +133,19 @@
     }
 
     /**
+     * Return the length of any doclet options we recognize
+     * @param option The option name
+     * @return The number of words this option takes (including the option) or 0 if the option
+     * is not recognized.
+     */
+    public static int optionLength(String option) {
+        if (option.equals(OUTPUT_PATH_OPTION)) {
+            return 2;
+        }
+        return 0;
+    }
+
+    /**
      * Check if the class is valid test case inherited from JUnit TestCase.
      *
      * @param clazz The class to be checked.
diff --git a/tools/utils/buildCts.py b/tools/utils/buildCts.py
new file mode 100755
index 0000000..cae39f5
--- /dev/null
+++ b/tools/utils/buildCts.py
@@ -0,0 +1,254 @@
+#!/usr/bin/python2.4
+
+# Copyright (C) 2009 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Module for generating CTS test descriptions and test plans."""
+
+import glob
+import os
+import re
+import subprocess
+import sys
+import xml.dom.minidom as dom
+from cts import tools
+
+
+def GetSubDirectories(root):
+  """Return all directories under the given root directory."""
+  return [x for x in os.listdir(root) if os.path.isdir(os.path.join(root, x))]
+
+
+def GetMakeFileVars(makefile_path):
+  """Extracts variable definitions from the given make file.
+
+  Args:
+    makefile_path: Path to the make file.
+
+  Returns:
+    A dictionary mapping variable names to their assigned value.
+  """
+  result = {}
+  pattern = re.compile(r'^\s*([^:#=\s]+)\s*:=\s*(.*?[^\\])$', re.MULTILINE + re.DOTALL)
+  stream = open(makefile_path, 'r')
+  content = stream.read()
+  for match in pattern.finditer(content):
+    result[match.group(1)] = match.group(2)
+  stream.close()
+  return result
+
+
+class CtsBuilder(object):
+  """Main class for generating test descriptions and test plans."""
+
+  def __init__(self, argv):
+    """Initialize the CtsBuilder from command line arguments."""
+    if not len(argv) == 6:
+      print 'Usage: %s <testRoot> <ctsOutputDir> <tempDir> <androidRootDir> <docletPath>' % argv[0]
+      print ''
+      print 'testRoot:       Directory under which to search for CTS tests.'
+      print 'ctsOutputDir:   Directory in which the CTS repository should be created.'
+      print 'tempDir:        Directory to use for storing temporary files.'
+      print 'androidRootDir: Root directory of the Android source tree.'
+      print 'docletPath:     Class path where the DescriptionGenerator doclet can be found.'
+      sys.exit(1)
+    self.test_root = sys.argv[1]
+    self.out_dir = sys.argv[2]
+    self.temp_dir = sys.argv[3]
+    self.android_root = sys.argv[4]
+    self.doclet_path = sys.argv[5]
+
+    self.test_repository = os.path.join(self.out_dir, 'repository/testcases')
+    self.plan_repository = os.path.join(self.out_dir, 'repository/plans')
+
+  def __LogGenerateDescription(self, name):
+    print 'Generating test description for package %s' % name
+
+  def RunDescriptionGeneratorDoclet(self, source_root, output_file):
+    """Generate a test package description by running the DescriptionGenerator doclet.
+
+    Args:
+      source_root: Directory under which tests should be searched.
+      output_file: Name of the file where the description gets written.
+
+    Returns:
+      The exit code of the DescriptionGenerator doclet run.
+    """
+    # Make sure sourceRoot is relative to  self.android_root
+    source_root = self.RelPath(source_root, self.android_root)
+
+    # To determine whether a class is a JUnit test, the Doclet needs to have all intermediate
+    # subclasses of TestCase as well as the JUnit framework itself on the source path.
+    # Annotation classes are also required, since test annotations go into the description.
+    source_path = [
+        'frameworks/base/core/java',            # android test classes
+        'frameworks/base/test-runner',          # test runner
+        'dalvik/libcore/junit/src/main/java',   # junit classes
+        'dalvik/libcore/dalvik/src/main/java',  # test annotations
+        'cts/tests/src',                        # cts test stubs
+        source_root                             # the source for this package
+    ]
+    source_path = [os.path.join(self.android_root, x) for x in source_path]
+    cmd = ('javadoc -o %s -J-Xmx512m -quiet -doclet DescriptionGenerator -docletpath %s'
+           ' -sourcepath %s ') % (output_file, self.doclet_path, ':'.join(source_path))
+    sources = []
+
+    def AddFile(sources, folder, names):
+      """Find *.java."""
+      sources.extend([os.path.join(folder, name) for name in names if name.endswith('.java')])
+
+    os.path.walk(os.path.join(self.android_root, source_root), AddFile, sources)
+    cmd += ' '.join(sources)
+    proc = subprocess.Popen(cmd, shell=True, stderr=subprocess.STDOUT, stdout=subprocess.PIPE)
+    # read and discard any output
+    proc.communicate()
+    # wait for process to terminate and return exit value
+    return proc.wait()
+
+  def GenerateSignatureCheckDescription(self):
+    """Generate the test description for the signature check."""
+    self.__LogGenerateDescription('android.tests.sigtest')
+    package = tools.TestPackage('SignatureTest', 'android.tests.sigtest')
+    package.AddAttribute('signatureCheck', 'true')
+    package.AddAttribute('runner', '.InstrumentationRunner')
+    package.AddTest('android.tests.sigtest.SignatureTest.signatureTest')
+    description = open(os.path.join(self.test_repository, 'SignatureTest.xml'), 'w')
+    package.WriteDescription(description)
+    description.close()
+
+  def GenerateReferenceAppDescription(self):
+    """Generate the test description for the reference app tests."""
+    self.__LogGenerateDescription('android.apidemos.cts')
+    package = tools.TestPackage('ApiDemosReferenceTest', 'android.apidemos.cts')
+    package.AddAttribute('packageToTest', 'com.example.android.apis')
+    package.AddAttribute('apkToTestName', 'ApiDemos')
+    package.AddAttribute('runner', 'android.test.InstrumentationTestRunner')
+    package.AddAttribute('referenceAppTest', 'true')
+    package.AddTest('android.apidemos.cts.ApiDemosTest.testNumberOfItemsInListView')
+    description = open(os.path.join(self.test_repository, 'ApiDemosReferenceTest.xml'), 'w')
+    package.WriteDescription(description)
+    description.close()
+
+  @staticmethod
+  def RelPath(path, start=os.getcwd()):
+    """Get a relative version of a path.
+
+    This is equivalent to os.path.relpath, which is only available since Python 2.6.
+
+    Args:
+      path: The path to transform.
+      start: The base path. Defaults to the current working directory.
+
+    Returns:
+      A transformed path that is relative to start.
+    """
+    path_dirs = os.path.abspath(path).split(os.path.sep)
+    start_dirs = os.path.abspath(start).split(os.path.sep)
+
+    num_common = len(os.path.commonprefix([start_dirs, path_dirs]))
+
+    result_dirs = ['..'] * (len(start_dirs) - num_common) + path_dirs[num_common:]
+    if result_dirs:
+      return os.path.join(*result_dirs)
+    return start
+
+  def GenerateTestDescriptions(self):
+    """Generate test descriptions for all packages."""
+    # individually generate descriptions not following conventions
+    self.GenerateSignatureCheckDescription()
+    self.GenerateReferenceAppDescription()
+
+    # generate test descriptions for android tests
+    android_packages = GetSubDirectories(self.test_root)
+    for package in android_packages:
+      app_package_name = 'android.' + package
+      self.__LogGenerateDescription(app_package_name)
+
+      # Run the description generator doclet to get the test package structure
+      # TODO: The Doclet does not currently add all required attributes. Instead of rewriting
+      # the document below, additional attributes should be passed to the Doclet as arguments.
+      temp_desc = os.path.join(self.temp_dir, 'description.xml')
+      self.RunDescriptionGeneratorDoclet(os.path.join(self.test_root, package), temp_desc)
+
+      makefile_name = os.path.join(self.test_root, package, 'Android.mk')
+      makefile_vars = GetMakeFileVars(makefile_name)
+      manifest = tools.XmlFile(os.path.join(self.test_root, package, 'AndroidManifest.xml'))
+
+      # obtain missing attribute values from the makefile and manifest
+      package_name = makefile_vars['LOCAL_PACKAGE_NAME']
+      runner = manifest.GetAndroidAttr('instrumentation', 'name')
+      target_package = manifest.GetAndroidAttr('instrumentation', 'targetPackage')
+      target_binary_name = makefile_vars.get('LOCAL_INSTRUMENTATION_FOR')
+
+      # add them to the document
+      doc = dom.parse(temp_desc)
+      test_description = doc.getElementsByTagName('TestPackage')[0]
+      test_description.setAttribute('name', package_name)
+      test_description.setAttribute('runner', runner)
+      test_package = manifest.GetAttr('manifest', 'package')
+      test_description.setAttribute('appNameSpace', test_package)
+      test_description.setAttribute('appPackageName', app_package_name)
+      if not test_package == target_package:
+        test_description.setAttribute('targetNameSpace', target_package)
+        test_description.setAttribute('targetBinaryName', target_binary_name)
+      description = open(os.path.join(self.test_repository, package_name + '.xml'), 'w')
+      doc.writexml(description, addindent='    ', encoding='UTF-8')
+      description.close()
+
+  def __WritePlan(self, plan, plan_name):
+    print 'Generating test plan %s' % plan_name
+    plan.Write(os.path.join(self.plan_repository, plan_name + '.xml'))
+
+  def GenerateTestPlans(self):
+    """Generate default test plans."""
+    # TODO: Instead of hard-coding the plans here, use a configuration file,
+    # such as test_defs.xml
+    packages = []
+    descriptions = glob.glob(os.path.join(self.test_repository, '*.xml'))
+    for description in descriptions:
+      doc = tools.XmlFile(description)
+      packages.append(doc.GetAttr('TestPackage', 'appPackageName'))
+
+    plan = tools.TestPlan(packages)
+    plan.Exclude('android\.performance.*')
+    self.__WritePlan(plan, 'CTS')
+    plan.Exclude(r'android\.tests\.sigtest')
+    plan.Exclude(r'android\.core.*')
+    self.__WritePlan(plan, 'Android')
+
+    plan = tools.TestPlan(packages)
+    plan.Include(r'android\.core\.tests.*')
+    self.__WritePlan(plan, 'Java')
+
+    plan = tools.TestPlan(packages)
+    plan.Include(r'android\.core\.vm-tests')
+    self.__WritePlan(plan, 'VM')
+
+    plan = tools.TestPlan(packages)
+    plan.Include(r'android\.tests\.sigtest')
+    self.__WritePlan(plan, 'Signature')
+
+    plan = tools.TestPlan(packages)
+    plan.Include(r'android\.apidemos\.cts')
+    self.__WritePlan(plan, 'RefApp')
+
+    plan = tools.TestPlan(packages)
+    plan.Include(r'android\.performance.*')
+    self.__WritePlan(plan, 'Performance')
+
+
+if __name__ == '__main__':
+  builder = CtsBuilder(sys.argv)
+  builder.GenerateTestDescriptions()
+  builder.GenerateTestPlans()
diff --git a/tools/utils/cts/__init__.py b/tools/utils/cts/__init__.py
new file mode 100644
index 0000000..6e259f9
--- /dev/null
+++ b/tools/utils/cts/__init__.py
@@ -0,0 +1,19 @@
+#!/usr/bin/python2.4
+
+# Copyright (C) 2009 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Package initialization for the cts package."""
+
+__all__ = ['tools']
diff --git a/tools/utils/cts/tools.py b/tools/utils/cts/tools.py
new file mode 100644
index 0000000..6cd1b95
--- /dev/null
+++ b/tools/utils/cts/tools.py
@@ -0,0 +1,207 @@
+#!/usr/bin/python2.4
+
+# Copyright (C) 2009 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Utility classes for CTS."""
+
+import re
+import xml.dom.minidom as minidom
+
+
+class TestPackage(object):
+  """This class represents a test package.
+
+  Each test package consists of one or more suites, each containing one or more subsuites and/or
+  one or more test cases. Each test case contains one or more tests.
+
+  The package structure is currently stored using Python dictionaries and lists. Translation
+  to XML is done via a DOM tree that gets created on demand.
+
+  TODO: Instead of using an internal data structure, using a DOM tree directly would increase
+  the usability. For example, one could easily create an instance by parsing an existing XML.
+  """
+
+  class TestSuite(object):
+    """A test suite."""
+
+    def __init__(self, is_root=False):
+      self.is_root = is_root
+      self.test_cases = {}
+      self.test_suites = {}
+
+    def Add(self, names):
+      if len(names) == 2:
+        # names contains the names of the test case and the test
+        test_case = self.test_cases.setdefault(names[0], [])
+        test_case.append(names[1])
+      else:
+        sub_suite = self.test_suites.setdefault(names[0], TestPackage.TestSuite())
+        sub_suite.Add(names[1:])
+
+    def WriteDescription(self, doc, parent):
+      """Recursively append all suites and testcases to the parent tag."""
+      for (suite_name, suite) in self.test_suites.iteritems():
+        child = doc.createElement('TestSuite')
+        child.setAttribute('name', suite_name)
+        parent.appendChild(child)
+        # recurse into child suites
+        suite.WriteDescription(doc, child)
+      for (case_name, test_list) in self.test_cases.iteritems():
+        child = doc.createElement('TestCase')
+        child.setAttribute('name', case_name)
+        parent.appendChild(child)
+        for test_name in test_list:
+          test = doc.createElement('Test')
+          test.setAttribute('name', test_name)
+          child.appendChild(test)
+
+  def __init__(self, package_name, app_package_name=''):
+    self.encoding = 'UTF-8'
+    self.attributes = {'name': package_name, 'AndroidFramework': 'Android 1.0',
+                       'version': '1.0', 'targetNameSpace': '', 'targetBinaryName': '',
+                       'jarPath': '', 'appPackageName': app_package_name}
+    self.root_suite = self.TestSuite(is_root=True)
+
+  def AddTest(self, name):
+    """Add a test to the package.
+
+    Test names are given in the form "testSuiteName.testSuiteName.TestCaseName.testName".
+    Test suites can be nested to any depth.
+
+    Args:
+      name: The name of the test to add.
+    """
+    parts = name.split('.')
+    self.root_suite.Add(parts)
+
+  def AddAttribute(self, name, value):
+    """Add an attribute to the test package itself."""
+    self.attributes[name] = value
+
+  def GetDocument(self):
+    """Returns a minidom Document representing the test package structure."""
+    doc = minidom.Document()
+    package = doc.createElement('TestPackage')
+    for (attr, value) in self.attributes.iteritems():
+      package.setAttribute(attr, value)
+    self.root_suite.WriteDescription(doc, package)
+    doc.appendChild(package)
+    return doc
+
+  def WriteDescription(self, writer):
+    """Write the description as XML to the given writer."""
+    doc = self.GetDocument()
+    doc.writexml(writer, addindent='  ', newl='\n', encoding=self.encoding)
+    doc.unlink()
+
+
+class TestPlan(object):
+  """A CTS test plan generator."""
+
+  def __init__(self, all_packages):
+    """Instantiate a test plan with a list of available package names.
+
+    Args:
+      all_packages: The full list of available packages. Subsequent calls to Exclude() and
+          Include() select from the packages given here.
+    """
+    self.all_packages = all_packages
+    self.map = None
+
+  def Exclude(self, pattern):
+    """Exclude all packages matching the given regular expression from the plan.
+
+    If this is the first call to Exclude() or Include(), all packages are selected before applying
+    the exclusion.
+
+    Args:
+      pattern: A regular expression selecting the package names to exclude.
+    """
+    if not self.map:
+      self.Include('.*')
+    exp = re.compile(pattern)
+    for package in self.all_packages:
+      if exp.match(package):
+        self.map[package] = False
+
+  def Include(self, pattern):
+    """Include all packages matching the given regular expressions in the plan.
+
+    Args:
+      pattern: A regular expression selecting the package names to include.
+    """
+    if not self.map:
+      self.map = {}
+      for package in self.all_packages:
+        self.map[package] = False
+    exp = re.compile(pattern)
+    for package in self.all_packages:
+      if exp.match(package):
+        self.map[package] = True
+
+  def Write(self, file_name):
+    """Write the test plan to the given file.
+
+    Requires Include() or Exclude() to be called prior to calling this method.
+
+    Args:
+      file_name: The name of the file into which the test plan should be written.
+    """
+    doc = minidom.Document()
+    plan = doc.createElement('TestPlan')
+    plan.setAttribute('version', '1.0')
+    doc.appendChild(plan)
+    for package in self.all_packages:
+      if self.map[package]:
+        entry = doc.createElement('Entry')
+        entry.setAttribute('uri', package)
+        plan.appendChild(entry)
+    stream = open(file_name, 'w')
+    doc.writexml(stream, addindent='  ', newl='\n', encoding='UTF-8')
+    stream.close()
+
+
+class XmlFile(object):
+  """This class parses Xml files and allows reading attribute values by tag and attribute name."""
+
+  def __init__(self, path):
+    """Instantiate the class using the manifest file denoted by path."""
+    self.doc = minidom.parse(path)
+
+  def GetAndroidAttr(self, tag, attr_name):
+    """Get the value of the given attribute in the first matching tag.
+
+    Args:
+      tag: The name of the tag to search.
+      attr_name: An attribute name in the android manifest namespace.
+
+    Returns:
+      The value of the given attribute in the first matching tag.
+    """
+    element = self.doc.getElementsByTagName(tag)[0]
+    return element.getAttributeNS('http://schemas.android.com/apk/res/android', attr_name)
+
+  def GetAttr(self, tag, attr_name):
+    """Return the value of the given attribute in the first matching tag.
+
+    Args:
+      tag: The name of the tag to search.
+      attr_name: An attribute name in the default namespace.
+
+    Returns:
+      The value of the given attribute in the first matching tag.
+    """
+    element = self.doc.getElementsByTagName(tag)[0]
+    return element.getAttribute(attr_name)