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])
duration: float
errors: list[TestFailureDetails]
def is_successful(self) -> bool:
31    def is_successful(self) -> bool:
32        return len(self.errors) == 0
@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)
message: str
details: str | None
additional_details_header: str | None = None
additional_details: str | None = None
test_class_name_override: str | None = None

The test class usually describes the framework.

test_case_name_override: str | None = None

The test case usually describes the workflow, unless more fine-grained information is available.

location: str | None = None

depending on the check, this may either be a file name or a path

line_number: int | None = None
def location_as_file_name(self) -> str | None:
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
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')
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
errors
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:
159def to_sanitized_command_str(cmd: list[str]) -> str:
160    # Ensure all elements are strings (cmd may contain Path objects)
161    str_cmd = [str(x) for x in cmd]
162    command_str = " ".join(filter_cmd(str_cmd))
163    return re.sub(PEM_CONTENT_RE, PEM_CONTENT_REPLACEMENT, command_str)