| #!/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 abc | |
| import os | |
| import subprocess | |
| import tempfile | |
| from binascii import unhexlify | |
| from typing import Any, List | |
| from google.protobuf import text_format | |
| from python.generators.diff_tests.testing import (BinaryProto, Metric, | |
| MetricV2SpecTextproto, | |
| StructuredQuery) | |
| from python.generators.diff_tests.models import TestCase, TestResult | |
| from python.generators.diff_tests.utils import ProtoManager, get_env | |
| ROOT_DIR = os.path.dirname( | |
| os.path.dirname( | |
| os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) | |
| class TestExecutor(abc.ABC): | |
| """Abstract base class for test executors.""" | |
| def __init__(self, trace_processor_path: str, | |
| override_sql_package_paths: List[str]): | |
| self.trace_processor_path = trace_processor_path | |
| self.override_sql_package_paths = override_sql_package_paths | |
| def _execute_trace_processor(self, cmd: List[str]): | |
| tp = subprocess.Popen( | |
| cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| env=get_env(ROOT_DIR)) | |
| return tp.communicate(), tp.returncode | |
| @abc.abstractmethod | |
| def run(self, test: TestCase, trace_path: str) -> TestResult: | |
| pass | |
| def _output_to_text_proto(trace_processor_path: str, actual: str, | |
| out: BinaryProto) -> str: | |
| """Deserializes a binary proto and returns its text representation. | |
| Args: | |
| actual: (string) HEX encoded serialized proto message | |
| message_type: (string) Message type | |
| Returns: | |
| Text proto | |
| """ | |
| try: | |
| protos_dir = os.path.join( | |
| ROOT_DIR, | |
| os.path.dirname(trace_processor_path), | |
| 'gen', | |
| 'protos', | |
| ) | |
| raw_data = unhexlify(actual.splitlines()[-1][1:-1]) | |
| descriptor_paths = [ | |
| f.path | |
| for f in os.scandir( | |
| os.path.join(protos_dir, 'perfetto', 'trace_processor')) | |
| if f.is_file() and os.path.splitext(f.name)[1] == '.descriptor' | |
| ] | |
| descriptor_paths.append( | |
| os.path.join(protos_dir, 'third_party', 'pprof', 'profile.descriptor')) | |
| proto = ProtoManager(descriptor_paths).create_message(out.message_type)() | |
| proto.ParseFromString(raw_data) | |
| try: | |
| return out.post_processing(proto) | |
| except: | |
| return '<Proto post processing failed>' | |
| except: | |
| return '<Invalid input for proto deserializaiton>' | |
| class QueryTestExecutor(TestExecutor): | |
| """Executor for query-based tests.""" | |
| def _execute_and_analyze(self, test: TestCase, trace_path: str, | |
| cmd: List[str], perf_file_path: str): | |
| (stdout, stderr), returncode = self._execute_trace_processor(cmd) | |
| actual = stdout.decode('utf8') | |
| if test.blueprint.is_out_binaryproto(): | |
| assert isinstance(test.blueprint.out, BinaryProto) | |
| actual = _output_to_text_proto(self.trace_processor_path, actual, | |
| test.blueprint.out) | |
| with open(perf_file_path, 'r') as f: | |
| return TestResult(test, trace_path, cmd, test.expected_str, actual, | |
| stderr.decode('utf8'), returncode, | |
| [line for line in f.readlines()]) | |
| def run(self, test: TestCase, trace_path: str) -> TestResult: | |
| with tempfile.NamedTemporaryFile(mode='w+', delete=False) as tmp_perf_file: | |
| cmd = [ | |
| self.trace_processor_path, | |
| '--analyze-trace-proto-content', | |
| '--crop-track-events', | |
| '--extra-checks', | |
| '--perf-file', | |
| tmp_perf_file.name, | |
| trace_path, | |
| ] | |
| if test.blueprint.is_query_file(): | |
| cmd += ['-q', test.query_path] | |
| else: | |
| assert isinstance(test.blueprint.query, str) | |
| cmd += ['-Q', test.blueprint.query] | |
| if test.register_files_dir: | |
| cmd += ['--register-files-dir', test.register_files_dir] | |
| for sql_package_path in self.override_sql_package_paths: | |
| cmd += ['--override-sql-package', sql_package_path] | |
| result = self._execute_and_analyze(test, trace_path, cmd, | |
| tmp_perf_file.name) | |
| os.remove(tmp_perf_file.name) | |
| return result | |
| class MetricTestExecutor(TestExecutor): | |
| """Executor for metric-based tests.""" | |
| def __init__(self, trace_processor_path: str, | |
| override_sql_package_paths: List[str], | |
| metrics_message_factory: Any): | |
| super().__init__(trace_processor_path, override_sql_package_paths) | |
| self.metrics_message_factory = metrics_message_factory | |
| def _execute_and_analyze(self, test: TestCase, trace_path: str, | |
| cmd: List[str], is_json_output: bool, | |
| perf_file_path: str): | |
| (stdout, stderr), returncode = self._execute_trace_processor(cmd) | |
| if is_json_output: | |
| expected_text = test.expected_str | |
| 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 = self.metrics_message_factory( | |
| 'perfetto.protos.TraceMetrics')() | |
| text_format.Merge(test.expected_str, expected_message) | |
| # Actual will be the raw bytes of the proto and we'll need to parse it | |
| # into a message. | |
| actual_message = self.metrics_message_factory( | |
| 'perfetto.protos.TraceMetrics')() | |
| actual_message.ParseFromString(stdout) | |
| # Convert both back to text format. | |
| expected_text = text_format.MessageToString(expected_message) | |
| actual_text = text_format.MessageToString(actual_message) | |
| with open(perf_file_path, 'r') as f: | |
| return TestResult(test, trace_path, cmd, expected_text, actual_text, | |
| stderr.decode('utf8'), returncode, | |
| [line for line in f.readlines()]) | |
| def run(self, test: TestCase, trace_path: str) -> TestResult: | |
| with tempfile.NamedTemporaryFile(mode='w+', delete=False) as tmp_perf_file: | |
| assert isinstance(test.blueprint.query, Metric) | |
| is_json_output_file = test.blueprint.is_out_file() and os.path.basename( | |
| test.expected_path or '').endswith('.json.out') | |
| is_json_output = is_json_output_file or test.blueprint.is_out_json() | |
| cmd = [ | |
| self.trace_processor_path, | |
| '--analyze-trace-proto-content', | |
| '--crop-track-events', | |
| '--extra-checks', | |
| '--run-metrics', | |
| test.blueprint.query.name, | |
| '--metrics-output=%s' % ('json' if is_json_output else 'binary'), | |
| '--perf-file', | |
| tmp_perf_file.name, | |
| trace_path, | |
| ] | |
| if test.register_files_dir: | |
| cmd += ['--register-files-dir', test.register_files_dir] | |
| for sql_package_path in self.override_sql_package_paths: | |
| cmd += ['--override-sql-package', sql_package_path] | |
| result = self._execute_and_analyze(test, trace_path, cmd, is_json_output, | |
| tmp_perf_file.name) | |
| os.remove(tmp_perf_file.name) | |
| return result | |
| class MetricV2TestExecutor(TestExecutor): | |
| """Executor for Metric v2 tests.""" | |
| def __init__(self, trace_processor_path: str, | |
| override_sql_package_paths: List[str], keep_input: bool, | |
| summary_spec_message_factory: Any, summary_message_factory: Any): | |
| super().__init__(trace_processor_path, override_sql_package_paths) | |
| self.keep_input = keep_input | |
| self.summary_spec_message_factory = summary_spec_message_factory | |
| self.summary_message_factory = summary_message_factory | |
| def _execute_and_analyze(self, test: TestCase, trace_path: str, | |
| cmd: List[str], perf_file_path: str): | |
| (stdout, stderr), returncode = self._execute_trace_processor(cmd) | |
| # Expected will be in text proto format and we'll need to parse it to | |
| # a real proto. | |
| expected_summary = self.summary_message_factory( | |
| 'perfetto.protos.TraceSummary')() | |
| text_format.Merge(test.expected_str, expected_summary.metric_bundles.add()) | |
| # Actual will be the raw bytes of the proto and we'll need to parse it | |
| # into a message. | |
| actual_summary = self.summary_message_factory( | |
| 'perfetto.protos.TraceSummary')() | |
| actual_summary.ParseFromString(stdout) | |
| actual = text_format.MessageToString( | |
| actual_summary.metric_bundles[0]) if len( | |
| actual_summary.metric_bundles) > 0 else '' | |
| with open(perf_file_path, 'r') as f: | |
| return TestResult( | |
| test, trace_path, cmd, | |
| text_format.MessageToString(expected_summary.metric_bundles[0]), | |
| actual, stderr.decode('utf8'), returncode, | |
| [line for line in f.readlines()]) | |
| def run(self, test: TestCase, trace_path: str) -> TestResult: | |
| with tempfile.NamedTemporaryFile(mode='w+', delete=False) as tmp_perf_file, \ | |
| tempfile.NamedTemporaryFile(delete=False) as tmp_spec_file: | |
| assert isinstance(test.blueprint.query, MetricV2SpecTextproto) | |
| spec_message = self.summary_spec_message_factory( | |
| 'perfetto.protos.TraceSummarySpec')() | |
| text_format.Merge(test.blueprint.query.contents, | |
| spec_message.metric_spec.add()) | |
| tmp_spec_file.write(spec_message.SerializeToString()) | |
| tmp_spec_file.flush() | |
| cmd = [ | |
| self.trace_processor_path, | |
| '--analyze-trace-proto-content', | |
| '--crop-track-events', | |
| '--extra-checks', | |
| '--perf-file', | |
| tmp_perf_file.name, | |
| '--summary', | |
| '--summary-spec', | |
| tmp_spec_file.name, | |
| '--summary-metrics-v2', | |
| spec_message.metric_spec[0].id, | |
| '--summary-format', | |
| 'binary', | |
| trace_path, | |
| ] | |
| for sql_package_path in self.override_sql_package_paths: | |
| cmd += ['--override-sql-package', sql_package_path] | |
| result = self._execute_and_analyze(test, trace_path, cmd, | |
| tmp_perf_file.name) | |
| if not self.keep_input: | |
| os.remove(tmp_spec_file.name) | |
| os.remove(tmp_perf_file.name) | |
| return result | |
| class StructuredQueryTestExecutor(TestExecutor): | |
| """Executor for structured query tests.""" | |
| def __init__(self, trace_processor_path: str, | |
| override_sql_package_paths: List[str], keep_input: bool): | |
| super().__init__(trace_processor_path, override_sql_package_paths) | |
| self.keep_input = keep_input | |
| def _execute_and_analyze(self, test: TestCase, trace_path: str, | |
| cmd: List[str], perf_file_path: str): | |
| (stdout, stderr), returncode = self._execute_trace_processor(cmd) | |
| actual = stdout.decode('utf8') | |
| with open(perf_file_path, 'r') as f: | |
| return TestResult(test, trace_path, cmd, test.expected_str, actual, | |
| stderr.decode('utf8'), returncode, | |
| [line for line in f.readlines()]) | |
| def run(self, test: TestCase, trace_path: str) -> TestResult: | |
| with tempfile.NamedTemporaryFile(mode='w+', delete=False) as tmp_perf_file: | |
| assert isinstance(test.blueprint.query, StructuredQuery) | |
| # Handle spec file vs textproto | |
| tmp_spec_file = None | |
| if test.spec_textproto is not None: | |
| # Write textproto to a temporary file | |
| tmp_spec_file = tempfile.NamedTemporaryFile( | |
| mode='w+', suffix='.textproto', delete=False) | |
| tmp_spec_file.write(test.spec_textproto) | |
| tmp_spec_file.close() | |
| spec_file_path = tmp_spec_file.name | |
| else: | |
| assert test.spec_file_path is not None | |
| spec_file_path = test.spec_file_path | |
| try: | |
| cmd = [ | |
| self.trace_processor_path, | |
| 'query', | |
| '--analyze-trace-proto-content', | |
| '--crop-track-events', | |
| '--extra-checks', | |
| '--summary-spec', | |
| spec_file_path, | |
| '--structured-query-id', | |
| test.blueprint.query.query_id, | |
| '--perf-file', | |
| tmp_perf_file.name, | |
| trace_path, | |
| ] | |
| if test.register_files_dir: | |
| cmd += ['--register-files-dir', test.register_files_dir] | |
| for sql_package_path in self.override_sql_package_paths: | |
| cmd += ['--override-sql-package', sql_package_path] | |
| result = self._execute_and_analyze(test, trace_path, cmd, | |
| tmp_perf_file.name) | |
| finally: | |
| # Clean up temporary spec file if we created one | |
| if tmp_spec_file is not None and not self.keep_input: | |
| os.remove(tmp_spec_file.name) | |
| os.remove(tmp_perf_file.name) | |
| return result |