tp: Refactor diff_test script to standalone library
Bug:255535171
Change-Id: Ib4f12b7034feb9c8aa4fd183816ffc78a4cfd402
diff --git a/python/BUILD.gn b/python/BUILD.gn
index 6dcf9ed..06ebd9c 100644
--- a/python/BUILD.gn
+++ b/python/BUILD.gn
@@ -31,6 +31,14 @@
]
}
+perfetto_py_library("trace_processor_diff_tests") {
+ sources = [
+ "generators/diff_tests/runner.py",
+ "generators/diff_tests/testing.py",
+ "generators/diff_tests/utils.py",
+ ]
+}
+
perfetto_py_binary("trace_processor_py_example") {
sources = [ "example.py" ]
deps = [
diff --git a/python/generators/diff_tests/runner.py b/python/generators/diff_tests/runner.py
new file mode 100644
index 0000000..56465b3
--- /dev/null
+++ b/python/generators/diff_tests/runner.py
@@ -0,0 +1,357 @@
+#!/usr/bin/env python3
+# Copyright (C) 2023 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.
+
+import argparse
+import difflib
+import os
+import subprocess
+import sys
+import tempfile
+from dataclasses import dataclass
+from typing import List, Tuple
+import concurrent.futures
+
+from google.protobuf import text_format
+from python.generators.diff_tests.testing import DiffTest
+from python.generators.diff_tests.utils import (create_message_factory,
+ end_color, get_env, green, red,
+ yellow)
+from tools.proto_utils import serialize_python_trace, serialize_textproto_trace
+
+ROOT_DIR = os.path.dirname(
+ os.path.dirname(
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+
+
+# Performance result of running the test.
+@dataclass
+class PerfResult:
+ test_type: DiffTest.TestType
+ trace_path: str
+ query_path_or_metric: str
+ ingest_time_ns: int
+ real_time_ns: int
+
+ def __init__(self, test: DiffTest, perf_lines: List[str]):
+ self.test_type = test.type
+ self.trace_path = test.trace_path
+ self.query_path_or_metric = test.query_path
+
+ assert len(perf_lines) == 1
+ perf_numbers = perf_lines[0].split(',')
+
+ assert len(perf_numbers) == 2
+ self.ingest_time_ns = int(perf_numbers[0])
+ self.real_time_ns = int(perf_numbers[1])
+
+
+# Data gathered from running the test.
+@dataclass
+class TestResult:
+ test_type: DiffTest.TestType
+ input_name: str
+ trace: str
+ cmd: List[str]
+ expected: str
+ actual: str
+ passed: bool
+ stderr: str
+ exit_code: int
+ perf_lines: List[str]
+
+ def __init__(self, type: DiffTest.TestType, query: str, gen_trace_path: str,
+ cmd: List[str], expected_text: str, actual_text: str,
+ stderr: str, exit_code: int, perf_lines: List[str]) -> None:
+ self.test_type = type
+ self.input_name = query
+ self.trace = gen_trace_path
+ self.cmd = cmd
+ self.stderr = stderr
+ self.exit_code = exit_code
+ self.perf_lines = perf_lines
+ self.expected = expected_text
+ self.actual = actual_text
+
+ expected_content = expected_text.replace('\r\n', '\n')
+ actual_content = actual_text.replace('\r\n', '\n')
+ self.passed = (expected_content == actual_content)
+
+ def write_diff(self):
+ expected_lines = self.expected.splitlines(True)
+ actual_lines = self.actual.splitlines(True)
+ diff = difflib.unified_diff(
+ expected_lines, actual_lines, fromfile='expected', tofile='actual')
+ return "".join(list(diff))
+
+
+# Run a metrics based DiffTest.
+def run_metrics_test(test: DiffTest, trace_processor_path: str,
+ gen_trace_path: str,
+ metrics_message_factory) -> TestResult:
+ if test.expected_path:
+ with open(test.expected_path, 'r') as expected_file:
+ expected = expected_file.read()
+ else:
+ expected = test.blueprint.out
+ tmp_perf_file = tempfile.NamedTemporaryFile(delete=False)
+ json_output = os.path.basename(test.expected_path).endswith('.json.out')
+ cmd = [
+ trace_processor_path,
+ '--analyze-trace-proto-content',
+ '--crop-track-events',
+ '--run-metrics',
+ test.query_path,
+ '--metrics-output=%s' % ('json' if json_output else 'binary'),
+ '--perf-file',
+ tmp_perf_file.name,
+ gen_trace_path,
+ ]
+ tp = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=get_env(ROOT_DIR))
+ (stdout, stderr) = tp.communicate()
+
+ if json_output:
+ expected_text = expected
+ actual_text = stdout.decode('utf8')
+ else:
+ # Expected will be in text proto format and we'll need to parse it to
+ # a real proto.
+ expected_message = metrics_message_factory()
+ text_format.Merge(expected, expected_message)
+
+ # Actual will be the raw bytes of the proto and we'll need to parse it
+ # into a message.
+ actual_message = metrics_message_factory()
+ actual_message.ParseFromString(stdout)
+
+ # Convert both back to text format.
+ expected_text = text_format.MessageToString(expected_message)
+ actual_text = text_format.MessageToString(actual_message)
+
+ perf_lines = [line.decode('utf8') for line in tmp_perf_file.readlines()]
+ tmp_perf_file.close()
+ os.remove(tmp_perf_file.name)
+ return TestResult(test.type, test.query_path,
+ gen_trace_path, cmd, expected_text, actual_text,
+ stderr.decode('utf8'), tp.returncode, perf_lines)
+
+
+# Run a query based Diff Test.
+def run_query_test(test: DiffTest, trace_processor_path: str,
+ gen_trace_path: str) -> TestResult:
+ with open(test.expected_path, 'r') as expected_file:
+ expected = expected_file.read()
+ tmp_perf_file = tempfile.NamedTemporaryFile(delete=False)
+ cmd = [
+ trace_processor_path,
+ '--analyze-trace-proto-content',
+ '--crop-track-events',
+ '-q',
+ test.query_path if test.query_path else test.blueprint.query,
+ '--perf-file',
+ tmp_perf_file.name,
+ gen_trace_path,
+ ]
+ tp = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=get_env(ROOT_DIR))
+ (stdout, stderr) = tp.communicate()
+
+ perf_lines = [line.decode('utf8') for line in tmp_perf_file.readlines()]
+ tmp_perf_file.close()
+ os.remove(tmp_perf_file.name)
+
+ return TestResult(test.type, test.query_path, gen_trace_path, cmd, expected,
+ stdout.decode('utf8'), stderr.decode('utf8'), tp.returncode,
+ perf_lines)
+
+
+# Run a DiffTest
+def run_test(trace_descriptor_path: str, extension_descriptor_paths: List[str],
+ args: argparse.Namespace,
+ test: DiffTest) -> Tuple[str, bool, str, PerfResult]:
+ out_path = os.path.dirname(args.trace_processor)
+ if args.metrics_descriptor:
+ metrics_descriptor_paths = [args.metrics_descriptor]
+ else:
+ metrics_protos_path = os.path.join(out_path, 'gen', 'protos', 'perfetto',
+ 'metrics')
+ metrics_descriptor_paths = [
+ os.path.join(metrics_protos_path, 'metrics.descriptor'),
+ os.path.join(metrics_protos_path, 'chrome',
+ 'all_chrome_metrics.descriptor')
+ ]
+ metrics_message_factory = create_message_factory(
+ metrics_descriptor_paths, 'perfetto.protos.TraceMetrics')
+ result_str = ""
+ red_str = red(args.no_colors)
+ green_str = green(args.no_colors)
+ end_color_str = end_color(args.no_colors)
+ expected_path = test.expected_path
+ test_name = f"{test.name}"
+
+ if not os.path.exists(test.trace_path):
+ result_str += f"Trace file not found {test.trace_path}\n"
+ return test_name, False, result_str, None
+ elif not os.path.exists(expected_path):
+ result_str = f"Expected file not found {expected_path}"
+ return test_name, False, result_str, None
+
+ is_generated_trace = test.trace_path.endswith(
+ '.py') or test.trace_path.endswith('.textproto')
+ if test.trace_path.endswith('.py'):
+ gen_trace_file = tempfile.NamedTemporaryFile(delete=False)
+ serialize_python_trace(trace_descriptor_path, test.trace_path,
+ gen_trace_file)
+ gen_trace_path = os.path.realpath(gen_trace_file.name)
+ elif test.trace_path.endswith('.textproto'):
+ gen_trace_file = tempfile.NamedTemporaryFile(delete=False)
+ serialize_textproto_trace(trace_descriptor_path, extension_descriptor_paths,
+ test.trace_path, gen_trace_file)
+ gen_trace_path = os.path.realpath(gen_trace_file.name)
+ else:
+ gen_trace_file = None
+ gen_trace_path = test.trace_path
+
+ result_str += f"{yellow(args.no_colors)}[ RUN ]{end_color_str} "
+ result_str += f"{test_name}\n"
+
+ # We can't use delete=True here. When using that on Windows, the
+ # resulting file is opened in exclusive mode (in turn that's a subtle
+ # side-effect of the underlying CreateFile(FILE_ATTRIBUTE_TEMPORARY))
+ # and TP fails to open the passed path.
+ if test.type == DiffTest.TestType.QUERY:
+
+ if not os.path.exists(test.query_path):
+ result_str += f"Query file not found {test.query_path}"
+ return test_name, False, result_str, None
+
+ result = run_query_test(test, args.trace_processor, gen_trace_path)
+ elif test.type == DiffTest.TestType.METRIC:
+ result = run_metrics_test(test, args.trace_processor, gen_trace_path,
+ metrics_message_factory)
+ else:
+ assert False
+
+ if gen_trace_file:
+ if args.keep_input:
+ result_str += f"Saving generated input trace: {gen_trace_path}\n"
+ else:
+ gen_trace_file.close()
+ os.remove(gen_trace_path)
+
+ def write_cmdlines():
+ res = ""
+ if is_generated_trace:
+ res += 'Command to generate trace:\n'
+ res += 'tools/serialize_test_trace.py '
+ res += '--descriptor {} {} > {}\n'.format(
+ os.path.relpath(trace_descriptor_path, ROOT_DIR),
+ os.path.relpath(test.trace_path, ROOT_DIR),
+ os.path.relpath(gen_trace_path, ROOT_DIR))
+ res += f"Command line:\n{' '.join(result.cmd)}\n"
+ return res
+
+ if result.exit_code != 0 or not result.passed:
+ result_str += result.stderr
+
+ if result.exit_code == 0:
+ result_str += (
+ f"Expected did not match actual for trace "
+ f"{test.trace_path} and {result.test_type} {result.input_name}\n"
+ f"Expected file: {expected_path}\n")
+ result_str += write_cmdlines()
+ result_str += result.write_diff()
+ else:
+ result_str += write_cmdlines()
+
+ result_str += f"{red_str}[ FAILED ]{end_color_str} {test_name} "
+ result_str += f"{os.path.basename(test.trace_path)}\n"
+
+ if args.rebase:
+ if result.exit_code == 0:
+ result_str += f"Rebasing {expected_path}\n"
+ with open(expected_path, 'w') as f:
+ f.write(result.actual)
+ else:
+ result_str += f"Rebase failed for {expected_path} as query failed\n"
+
+ return test_name, False, result_str, None
+ else:
+ perf_result = PerfResult(test, result.perf_lines)
+
+ result_str += (f"{green_str}[ OK ]{end_color_str} {test.name} "
+ f"(ingest: {perf_result.ingest_time_ns / 1000000:.2f} ms "
+ f"query: {perf_result.real_time_ns / 1000000:.2f} ms)\n")
+ return test_name, True, result_str, perf_result
+
+
+# Run all DiffTests.
+def run_all_tests(trace_descriptor_path: str,
+ extension_descriptor_paths: List['str'],
+ args: argparse.Namespace, tests: List[DiffTest]
+ ) -> Tuple[List[str], List[PerfResult], List[str]]:
+ perf_data = []
+ test_failure = []
+ rebased = []
+ with concurrent.futures.ProcessPoolExecutor() as e:
+ fut = [
+ e.submit(run_test, trace_descriptor_path, extension_descriptor_paths,
+ args, test) for test in tests
+ ]
+ for res in concurrent.futures.as_completed(fut):
+ test_name, test_passed, res_str, perf_result = res.result()
+ sys.stderr.write(res_str)
+ if test_passed:
+ perf_data.append(perf_result)
+ else:
+ if args.rebase:
+ rebased.append(test_name)
+ test_failure.append(test_name)
+
+ return test_failure, perf_data, rebased
+
+
+# Load all DiffTests matching the patterns.
+def read_all_tests(query_metric_pattern, trace_pattern):
+ include_index_dir = os.path.join(ROOT_DIR, 'test', 'trace_processor')
+ tests = []
+
+ INCLUDE_PATH = os.path.join(ROOT_DIR, 'test', 'trace_processor')
+ sys.path.append(INCLUDE_PATH)
+ from include_index import fetch_all_diff_tests
+ sys.path.pop()
+ diff_tests = fetch_all_diff_tests(include_index_dir)
+
+ for test in diff_tests:
+ # Temporary assertion until string passing is supported.
+ if not (test.blueprint.is_out_file() and test.blueprint.is_query_file() and
+ test.blueprint.is_trace_file()):
+ raise AssertionError("Test parameters should be passed as files.")
+ if test.query_path and not query_metric_pattern.match(
+ os.path.basename(test.name)):
+ continue
+
+ if test.trace_path and not trace_pattern.match(
+ os.path.basename(test.trace_path)):
+ continue
+
+ tests.append(test)
+ return tests
diff --git a/python/generators/diff_tests/testing.py b/python/generators/diff_tests/testing.py
index 6eaba77..49d4ba0 100644
--- a/python/generators/diff_tests/testing.py
+++ b/python/generators/diff_tests/testing.py
@@ -20,11 +20,6 @@
from enum import Enum
-class TestType(Enum):
- QUERY = 1
- METRIC = 2
-
-
@dataclass
class Path:
filename: str
@@ -55,6 +50,10 @@
# script.
class DiffTest:
+ class TestType(Enum):
+ QUERY = 1
+ METRIC = 2
+
def __init__(self, name: str, blueprint: DiffTestBlueprint,
index_dir: str) -> None:
self.name = name
@@ -62,14 +61,14 @@
if blueprint.is_query_file():
if blueprint.query.filename.endswith('.sql'):
- self.type = TestType.QUERY
+ self.type = DiffTest.TestType.QUERY
self.query_path = os.path.abspath(
os.path.join(index_dir, blueprint.query.filename))
else:
- self.type = TestType.METRIC
+ self.type = DiffTest.TestType.METRIC
self.query_path = blueprint.query.filename
else:
- self.type = TestType.METRIC
+ self.type = DiffTest.TestType.METRIC
self.query_path = None
if blueprint.is_trace_file():
diff --git a/python/generators/diff_tests/utils.py b/python/generators/diff_tests/utils.py
new file mode 100644
index 0000000..d381f43
--- /dev/null
+++ b/python/generators/diff_tests/utils.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+# Copyright (C) 2023 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.
+
+import sys
+import os
+import signal
+from typing import List
+
+from tools.proto_utils import create_message_factory
+
+USE_COLOR_CODES = sys.stderr.isatty()
+
+
+def red(no_colors):
+ return "\u001b[31m" if USE_COLOR_CODES and not no_colors else ""
+
+
+def green(no_colors):
+ return "\u001b[32m" if USE_COLOR_CODES and not no_colors else ""
+
+
+def yellow(no_colors):
+ return "\u001b[33m" if USE_COLOR_CODES and not no_colors else ""
+
+
+def end_color(no_colors):
+ return "\u001b[0m" if USE_COLOR_CODES and not no_colors else ""
+
+
+def get_env(root_dir):
+ env = {
+ 'PERFETTO_BINARY_PATH': os.path.join(root_dir, 'test', 'data'),
+ }
+ if sys.platform.startswith('linux'):
+ env['PATH'] = os.path.join(root_dir, 'buildtools', 'linux64', 'clang',
+ 'bin')
+ elif sys.platform.startswith('darwin'):
+ # Sadly, on macOS we need to check out the Android deps to get
+ # llvm symbolizer.
+ env['PATH'] = os.path.join(root_dir, 'buildtools', 'ndk', 'toolchains',
+ 'llvm', 'prebuilt', 'darwin-x86_64', 'bin')
+ elif sys.platform.startswith('win32'):
+ env['PATH'] = os.path.join(root_dir, 'buildtools', 'win', 'clang', 'bin')
+ return env
+
+
+def ctrl_c_handler(_num, _frame):
+ # Send a sigkill to the whole process group. Our process group looks like:
+ # - Main python interpreter running the main()
+ # - N python interpreters coming from ProcessPoolExecutor workers.
+ # - 1 trace_processor_shell subprocess coming from the subprocess.Popen().
+ # We don't need any graceful termination as the diff tests are stateless and
+ # don't write any file. Just kill them all immediately.
+ os.killpg(os.getpid(), signal.SIGKILL)
+
+
+def create_metrics_message_factory(metrics_descriptor_paths):
+ return create_message_factory(metrics_descriptor_paths,
+ 'perfetto.protos.TraceMetrics')
+
+
+def find_trace_descriptor(parent):
+ trace_protos_path = os.path.join(parent, 'gen', 'protos', 'perfetto', 'trace')
+ return os.path.join(trace_protos_path, 'trace.descriptor')