misc.python.materialize.mzcompose.test_result
1# Copyright Materialize, Inc. and contributors. All rights reserved. 2# 3# Use of this software is governed by the Business Source License 4# included in the LICENSE file at the root of this repository. 5# 6# As of the Change Date specified in that file, in accordance with 7# the Business Source License, use of this software will be governed 8# by the Apache License, Version 2.0. 9 10from __future__ import annotations 11 12import re 13from dataclasses import dataclass 14 15from materialize import MZ_ROOT 16from materialize.ui import CommandFailureCausedUIError, UIError 17from materialize.util import filter_cmd 18 19PEM_CONTENT_RE = r"-----BEGIN ([A-Z ]+)-----[^-]+-----END [A-Z ]+-----" 20PEM_CONTENT_REPLACEMENT = r"<\1>" 21 22 23@dataclass 24class TestResult: 25 __test__ = False 26 27 duration: float 28 errors: list[TestFailureDetails] 29 30 def is_successful(self) -> bool: 31 return len(self.errors) == 0 32 33 34@dataclass 35class TestFailureDetails: 36 __test__ = False 37 38 message: str 39 details: str | None 40 additional_details_header: str | None = None 41 additional_details: str | None = None 42 test_class_name_override: str | None = None 43 """The test class usually describes the framework.""" 44 test_case_name_override: str | None = None 45 """The test case usually describes the workflow, unless more fine-grained information is available.""" 46 location: str | None = None 47 """depending on the check, this may either be a file name or a path""" 48 line_number: int | None = None 49 50 def location_as_file_name(self) -> str | None: 51 if self.location is None: 52 return None 53 54 if "/" in self.location: 55 return self.location[self.location.rindex("/") + 1 :] 56 57 return self.location 58 59 60class FailedTestExecutionError(UIError): 61 """ 62 An UIError that is caused by a failing test. 63 """ 64 65 def __init__( 66 self, 67 errors: list[TestFailureDetails], 68 error_summary: str = "At least one test failed", 69 ): 70 super().__init__(error_summary) 71 self.errors = errors 72 73 74def try_determine_errors_from_cmd_execution( 75 e: CommandFailureCausedUIError, test_context: str | None 76) -> list[TestFailureDetails]: 77 output = e.stderr or e.stdout 78 79 if "running docker compose failed" in str(e): 80 return [determine_error_from_docker_compose_failure(e, output, test_context)] 81 82 if output is None: 83 return [] 84 85 error_chunks = extract_error_chunks_from_output(output) 86 87 fallback_file_path = try_determine_error_location_from_cmd(e.cmd) 88 if fallback_file_path is not None and ":" in fallback_file_path: 89 parts = fallback_file_path.split(":") 90 fallback_file_path, fallback_line_number = parts[0], int(parts[1]) 91 else: 92 fallback_line_number = None 93 94 collected_errors = [] 95 for chunk in error_chunks: 96 match = re.search(r"([^.]+\.td):(\d+):\d+:", chunk) 97 if match is not None: 98 # for .td files like Postgres CDC, file_path will just contain the file name 99 file_path = match.group(1) 100 line_number = int(match.group(2)) 101 else: 102 # for .py files like platform checks, file_path will be a path 103 file_path = fallback_file_path 104 line_number = fallback_line_number 105 106 message = ( 107 f"Executing {file_path if file_path is not None else 'command'} failed!" 108 ) 109 110 failure_details = TestFailureDetails( 111 message, 112 details=chunk, 113 test_case_name_override=test_context, 114 location=file_path, 115 line_number=line_number, 116 ) 117 118 if failure_details not in collected_errors: 119 collected_errors.append(failure_details) 120 121 return collected_errors 122 123 124def determine_error_from_docker_compose_failure( 125 e: CommandFailureCausedUIError, output: str | None, test_context: str | None 126) -> TestFailureDetails: 127 command = to_sanitized_command_str(e.cmd) 128 context_prefix = f"{test_context}: " if test_context is not None else "" 129 return TestFailureDetails( 130 f"{context_prefix}Docker compose failed: {command}", 131 details=output, 132 test_case_name_override=test_context, 133 location=None, 134 line_number=None, 135 ) 136 137 138def try_determine_error_location_from_cmd(cmd: list[str]) -> str | None: 139 root_path_as_string = f"{MZ_ROOT}/" 140 for cmd_part in cmd: 141 if type(cmd_part) == str and cmd_part.startswith("--source="): 142 return cmd_part.removeprefix("--source=").replace(root_path_as_string, "") 143 144 return None 145 146 147def extract_error_chunks_from_output(output: str) -> list[str]: 148 pos = output.find("+++ !!! Error Report") 149 if pos == -1: 150 return [] 151 152 error_output = output[: pos - 1] 153 error_chunks = error_output.split("^^^ +++") 154 155 return [chunk.strip() for chunk in error_chunks if len(chunk.strip()) > 0] 156 157 158def to_sanitized_command_str(cmd: list[str]) -> str: 159 # Ensure all elements are strings (cmd may contain Path objects) 160 str_cmd = [str(x) for x in cmd] 161 command_str = " ".join(filter_cmd(str_cmd)) 162 return re.sub(PEM_CONTENT_RE, PEM_CONTENT_REPLACEMENT, command_str)
PEM_CONTENT_RE =
'-----BEGIN ([A-Z ]+)-----[^-]+-----END [A-Z ]+-----'
PEM_CONTENT_REPLACEMENT =
'<\\1>'
@dataclass
class
TestResult:
24@dataclass 25class TestResult: 26 __test__ = False 27 28 duration: float 29 errors: list[TestFailureDetails] 30 31 def is_successful(self) -> bool: 32 return len(self.errors) == 0
TestResult( duration: float, errors: list[TestFailureDetails])
errors: list[TestFailureDetails]
@dataclass
class
TestFailureDetails:
35@dataclass 36class TestFailureDetails: 37 __test__ = False 38 39 message: str 40 details: str | None 41 additional_details_header: str | None = None 42 additional_details: str | None = None 43 test_class_name_override: str | None = None 44 """The test class usually describes the framework.""" 45 test_case_name_override: str | None = None 46 """The test case usually describes the workflow, unless more fine-grained information is available.""" 47 location: str | None = None 48 """depending on the check, this may either be a file name or a path""" 49 line_number: int | None = None 50 51 def location_as_file_name(self) -> str | None: 52 if self.location is None: 53 return None 54 55 if "/" in self.location: 56 return self.location[self.location.rindex("/") + 1 :] 57 58 return self.location
TestFailureDetails( message: str, details: str | None, additional_details_header: str | None = None, additional_details: str | None = None, test_class_name_override: str | None = None, test_case_name_override: str | None = None, location: str | None = None, line_number: int | None = None)
class
FailedTestExecutionError(materialize.ui.UIError):
61class FailedTestExecutionError(UIError): 62 """ 63 An UIError that is caused by a failing test. 64 """ 65 66 def __init__( 67 self, 68 errors: list[TestFailureDetails], 69 error_summary: str = "At least one test failed", 70 ): 71 super().__init__(error_summary) 72 self.errors = errors
An UIError that is caused by a failing test.
FailedTestExecutionError( errors: list[TestFailureDetails], error_summary: str = 'At least one test failed')
def
try_determine_errors_from_cmd_execution( e: materialize.ui.CommandFailureCausedUIError, test_context: str | None) -> list[TestFailureDetails]:
75def try_determine_errors_from_cmd_execution( 76 e: CommandFailureCausedUIError, test_context: str | None 77) -> list[TestFailureDetails]: 78 output = e.stderr or e.stdout 79 80 if "running docker compose failed" in str(e): 81 return [determine_error_from_docker_compose_failure(e, output, test_context)] 82 83 if output is None: 84 return [] 85 86 error_chunks = extract_error_chunks_from_output(output) 87 88 fallback_file_path = try_determine_error_location_from_cmd(e.cmd) 89 if fallback_file_path is not None and ":" in fallback_file_path: 90 parts = fallback_file_path.split(":") 91 fallback_file_path, fallback_line_number = parts[0], int(parts[1]) 92 else: 93 fallback_line_number = None 94 95 collected_errors = [] 96 for chunk in error_chunks: 97 match = re.search(r"([^.]+\.td):(\d+):\d+:", chunk) 98 if match is not None: 99 # for .td files like Postgres CDC, file_path will just contain the file name 100 file_path = match.group(1) 101 line_number = int(match.group(2)) 102 else: 103 # for .py files like platform checks, file_path will be a path 104 file_path = fallback_file_path 105 line_number = fallback_line_number 106 107 message = ( 108 f"Executing {file_path if file_path is not None else 'command'} failed!" 109 ) 110 111 failure_details = TestFailureDetails( 112 message, 113 details=chunk, 114 test_case_name_override=test_context, 115 location=file_path, 116 line_number=line_number, 117 ) 118 119 if failure_details not in collected_errors: 120 collected_errors.append(failure_details) 121 122 return collected_errors
def
determine_error_from_docker_compose_failure( e: materialize.ui.CommandFailureCausedUIError, output: str | None, test_context: str | None) -> TestFailureDetails:
125def determine_error_from_docker_compose_failure( 126 e: CommandFailureCausedUIError, output: str | None, test_context: str | None 127) -> TestFailureDetails: 128 command = to_sanitized_command_str(e.cmd) 129 context_prefix = f"{test_context}: " if test_context is not None else "" 130 return TestFailureDetails( 131 f"{context_prefix}Docker compose failed: {command}", 132 details=output, 133 test_case_name_override=test_context, 134 location=None, 135 line_number=None, 136 )
def
try_determine_error_location_from_cmd(cmd: list[str]) -> str | None:
139def try_determine_error_location_from_cmd(cmd: list[str]) -> str | None: 140 root_path_as_string = f"{MZ_ROOT}/" 141 for cmd_part in cmd: 142 if type(cmd_part) == str and cmd_part.startswith("--source="): 143 return cmd_part.removeprefix("--source=").replace(root_path_as_string, "") 144 145 return None
def
extract_error_chunks_from_output(output: str) -> list[str]:
148def extract_error_chunks_from_output(output: str) -> list[str]: 149 pos = output.find("+++ !!! Error Report") 150 if pos == -1: 151 return [] 152 153 error_output = output[: pos - 1] 154 error_chunks = error_output.split("^^^ +++") 155 156 return [chunk.strip() for chunk in error_chunks if len(chunk.strip()) > 0]
def
to_sanitized_command_str(cmd: list[str]) -> str: