metadata: create MD from link to zipped source

BUG=None
TEST=python compile_mobly_metadata.py --output_file mobly.pb --sources ...

Change-Id: Ia9d65ebe22ee7432b4b6bc9e973403ab65f870cd
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/5923129
Reviewed-by: Derek Beckett <[email protected]>
Tested-by: Chris DeLaGarza <[email protected]>
Commit-Queue: Chris DeLaGarza <[email protected]>
Commit-Queue: ChromeOS Auto Runner <[email protected]>
diff --git a/src/chromiumos/test/python/src/tools/compile_mobly_metadata.py b/src/chromiumos/test/python/src/tools/compile_mobly_metadata.py
new file mode 100755
index 0000000..8e80057
--- /dev/null
+++ b/src/chromiumos/test/python/src/tools/compile_mobly_metadata.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+# Copyright 2024 The ChromiumOS Authors
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Generate mobly metadata proto from parsed repositories."""
+
+import argparse
+import ast
+import io
+import os
+import pathlib
+import sys
+import tempfile
+from typing import List
+import urllib.request as urllib
+import zipfile
+
+
+# Used to import the proto stack.
+if "CONFIG_REPO_ROOT" in os.environ:
+    sys.path.insert(1, os.path.join(os.getenv("CONFIG_REPO_ROOT"), "python"))
+else:
+    sys.path.insert(
+        1,
+        str(
+            pathlib.Path(__file__).parent.resolve()
+            / "../../../../../../../../config/python"
+        ),
+    )
+
+# pylint: disable=import-error,wrong-import-position
+from chromiumos.test.api import test_case_metadata_pb2 as tc_metadata_pb
+from chromiumos.test.api import test_case_pb2 as tc_pb
+from chromiumos.test.api import test_harness_pb2 as th_pb
+from google.protobuf import text_format
+
+
+class TestMethod:
+    """Methods on a class and its method as an ast."""
+
+    def __init__(self, parentClass: ast.ClassDef, method: ast.FunctionDef):
+        self.parentClass = parentClass
+        self.method = method
+
+    def to_metadata(self) -> tc_metadata_pb.TestCaseMetadata:
+        name = f"{self.parentClass.name}.{self.method.name}"
+        tc_id = tc_pb.TestCase.Id(value=f"mobly.{name}")
+        params = []
+        params.extend(
+            [
+                tc_pb.TestCase.Tag(value=f"module:{self.parentClass.name}"),
+                # TODO: Parse out a true suite name.
+                tc_pb.TestCase.Tag(value="suite:betocq"),
+            ]
+        )
+        deps = []
+        test_case = tc_pb.TestCase(
+            id=tc_id, name=name, tags=params, dependencies=deps
+        )
+        bug_component = ""
+        case_info = tc_metadata_pb.TestCaseInfo(
+            owners=[],
+            bug_component=tc_metadata_pb.BugComponent(value=str(bug_component)),
+        )
+        case_exec = tc_metadata_pb.TestCaseExec(
+            test_harness=th_pb.TestHarness(mobly=th_pb.TestHarness.Mobly())
+        )
+        return tc_metadata_pb.TestCaseMetadata(
+            test_case=test_case,
+            test_case_exec=case_exec,
+            test_case_info=case_info,
+        )
+
+
+def findTestMethodsFromSource(source: str) -> List[TestMethod]:
+    with tempfile.TemporaryDirectory() as tmpdir:
+        pullFromSource(source, tmpdir)
+        return findTestMethodsFromDirectory(tmpdir)
+
+
+def findTestMethodsFromDirectory(directory: str) -> List[TestMethod]:
+    testMethods = []
+    for dirpath, _, filenames in os.walk(directory):
+        for filename in filenames:
+            if filename.endswith(".py"):
+                testMethods.extend(
+                    findTestMethodsFromFile(os.path.join(dirpath, filename))
+                )
+    return testMethods
+
+
+def findTestMethodsFromFile(filepath: str) -> List[TestMethod]:
+    testMethods = []
+    with open(filepath, encoding="utf-8") as file:
+        node = ast.parse(file.read())
+
+    classes = [n for n in node.body if isinstance(n, ast.ClassDef)]
+    for klass in classes:
+        methods = [n for n in klass.body if isinstance(n, ast.FunctionDef)]
+        testMethods = [
+            TestMethod(klass, method)
+            for method in methods
+            if method.name.startswith("test_")
+        ]
+
+    return testMethods
+
+
+def writeTestCaseMetadata(
+    testMethods: List[TestMethod], output_file: pathlib.Path, dump: bool
+):
+    test_case_metadata_list = tc_metadata_pb.TestCaseMetadataList(
+        values=[testMethod.to_metadata() for testMethod in testMethods]
+    )
+
+    # Make sure the appropriate directory structure is created.
+    output_file.parent.mkdir(parents=True, exist_ok=True)
+    # Write the protobuf message to the output file
+    output_file.write_bytes(test_case_metadata_list.SerializeToString())
+
+    if dump:
+        print(text_format.MessageToString(test_case_metadata_list))
+
+
+def pullFromSource(url: str, tmpdir: str):
+    with urllib.urlopen(url) as response:
+        with zipfile.ZipFile(io.BytesIO(response.read())) as zipped_file:
+            zipped_file.extractall(path=tmpdir)
+
+
+def main(output_file: pathlib.Path, sources: List[str], dump: bool):
+    testMethods = []
+    for source in sources:
+        testMethods.extend(findTestMethodsFromSource(source))
+    writeTestCaseMetadata(testMethods, output_file, dump)
+
+
+def _argparse_file_factory(path: str) -> pathlib.Path:
+    """Factory method that builds a pathlib.Path object"""
+    return pathlib.Path(path)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(
+        description="Generate CTP metadata from \
+            extracted mobly test source code"
+    )
+    parser.add_argument(
+        "--output_file",
+        help="Output file to write proto metadata",
+        type=_argparse_file_factory,
+        required=True,
+    )
+    parser.add_argument(
+        "--sources",
+        help="",
+        required=True,
+        nargs="+",
+    )
+    parser.add_argument(
+        "--dump",
+        help="Dump pretty printed protobuf to stdout. For debugging purposes",
+        action="store_true",
+        default=False,
+        required=False,
+    )
+
+    args = parser.parse_args()
+    main(args.output_file, args.sources, args.dump)