| import dataclasses | |
| import json | |
| from _colorize import get_colors # type: ignore[import-not-found] | |
| from typing import Any | |
| from .utils import ( | |
| StrJSON, TestName, FilterTuple, | |
| format_duration, normalize_test_name, print_warning) | |
| @dataclasses.dataclass(slots=True) | |
| class TestStats: | |
| tests_run: int = 0 | |
| failures: int = 0 | |
| skipped: int = 0 | |
| @staticmethod | |
| def from_unittest(result): | |
| return TestStats(result.testsRun, | |
| len(result.failures), | |
| len(result.skipped)) | |
| @staticmethod | |
| def from_doctest(results): | |
| return TestStats(results.attempted, | |
| results.failed, | |
| results.skipped) | |
| def accumulate(self, stats): | |
| self.tests_run += stats.tests_run | |
| self.failures += stats.failures | |
| self.skipped += stats.skipped | |
| # Avoid enum.Enum to reduce the number of imports when tests are run | |
| class State: | |
| PASSED = "PASSED" | |
| FAILED = "FAILED" | |
| SKIPPED = "SKIPPED" | |
| UNCAUGHT_EXC = "UNCAUGHT_EXC" | |
| REFLEAK = "REFLEAK" | |
| ENV_CHANGED = "ENV_CHANGED" | |
| RESOURCE_DENIED = "RESOURCE_DENIED" | |
| INTERRUPTED = "INTERRUPTED" | |
| WORKER_FAILED = "WORKER_FAILED" # non-zero worker process exit code | |
| WORKER_BUG = "WORKER_BUG" # exception when running a worker | |
| DID_NOT_RUN = "DID_NOT_RUN" | |
| TIMEOUT = "TIMEOUT" | |
| @staticmethod | |
| def is_failed(state): | |
| return state in { | |
| State.FAILED, | |
| State.UNCAUGHT_EXC, | |
| State.REFLEAK, | |
| State.WORKER_FAILED, | |
| State.WORKER_BUG, | |
| State.TIMEOUT} | |
| @staticmethod | |
| def has_meaningful_duration(state): | |
| # Consider that the duration is meaningless for these cases. | |
| # For example, if a whole test file is skipped, its duration | |
| # is unlikely to be the duration of executing its tests, | |
| # but just the duration to execute code which skips the test. | |
| return state not in { | |
| State.SKIPPED, | |
| State.RESOURCE_DENIED, | |
| State.INTERRUPTED, | |
| State.WORKER_FAILED, | |
| State.WORKER_BUG, | |
| State.DID_NOT_RUN} | |
| @staticmethod | |
| def must_stop(state): | |
| return state in { | |
| State.INTERRUPTED, | |
| State.WORKER_BUG, | |
| } | |
| FileName = str | |
| LineNo = int | |
| Location = tuple[FileName, LineNo] | |
| @dataclasses.dataclass(slots=True) | |
| class TestResult: | |
| test_name: TestName | |
| state: str | None = None | |
| # Test duration in seconds | |
| duration: float | None = None | |
| xml_data: list[str] | None = None | |
| stats: TestStats | None = None | |
| # errors and failures copied from support.TestFailedWithDetails | |
| errors: list[tuple[str, str]] | None = None | |
| failures: list[tuple[str, str]] | None = None | |
| # partial coverage in a worker run; not used by sequential in-process runs | |
| covered_lines: list[Location] | None = None | |
| # short descriptions of how the test altered the execution environment | |
| env_changed_reasons: list[str] | None = None | |
| def is_failed(self, fail_env_changed: bool) -> bool: | |
| if self.state == State.ENV_CHANGED: | |
| return fail_env_changed | |
| return State.is_failed(self.state) | |
| def _format_failed(self): | |
| ansi = get_colors() | |
| red, reset = ansi.BOLD_RED, ansi.RESET | |
| if self.errors and self.failures: | |
| le = len(self.errors) | |
| lf = len(self.failures) | |
| error_s = "error" + ("s" if le > 1 else "") | |
| failure_s = "failure" + ("s" if lf > 1 else "") | |
| return ( | |
| f"{red}{self.test_name} failed " | |
| f"({le} {error_s}, {lf} {failure_s}){reset}" | |
| ) | |
| if self.errors: | |
| le = len(self.errors) | |
| error_s = "error" + ("s" if le > 1 else "") | |
| return f"{red}{self.test_name} failed ({le} {error_s}){reset}" | |
| if self.failures: | |
| lf = len(self.failures) | |
| failure_s = "failure" + ("s" if lf > 1 else "") | |
| return f"{red}{self.test_name} failed ({lf} {failure_s}){reset}" | |
| return f"{red}{self.test_name} failed{reset}" | |
| def __str__(self) -> str: | |
| ansi = get_colors() | |
| green = ansi.GREEN | |
| red = ansi.BOLD_RED | |
| reset = ansi.RESET | |
| yellow = ansi.YELLOW | |
| match self.state: | |
| case State.PASSED: | |
| return f"{green}{self.test_name} passed{reset}" | |
| case State.FAILED: | |
| return f"{red}{self._format_failed()}{reset}" | |
| case State.SKIPPED: | |
| return f"{yellow}{self.test_name} skipped{reset}" | |
| case State.UNCAUGHT_EXC: | |
| return ( | |
| f"{red}{self.test_name} failed (uncaught exception){reset}" | |
| ) | |
| case State.REFLEAK: | |
| return f"{red}{self.test_name} failed (reference leak){reset}" | |
| case State.ENV_CHANGED: | |
| return f"{red}{self.test_name} failed (env changed){reset}" | |
| case State.RESOURCE_DENIED: | |
| return f"{yellow}{self.test_name} skipped (resource denied){reset}" | |
| case State.INTERRUPTED: | |
| return f"{yellow}{self.test_name} interrupted{reset}" | |
| case State.WORKER_FAILED: | |
| return ( | |
| f"{red}{self.test_name} worker non-zero exit code{reset}" | |
| ) | |
| case State.WORKER_BUG: | |
| return f"{red}{self.test_name} worker bug{reset}" | |
| case State.DID_NOT_RUN: | |
| return f"{yellow}{self.test_name} ran no tests{reset}" | |
| case State.TIMEOUT: | |
| assert self.duration is not None, "self.duration is None" | |
| return f"{self.test_name} timed out ({format_duration(self.duration)})" | |
| case _: | |
| raise ValueError( | |
| f"{red}unknown result state: {{state!r}}{reset}" | |
| ) | |
| def has_meaningful_duration(self): | |
| return State.has_meaningful_duration(self.state) | |
| def set_env_changed(self, *reasons): | |
| if self.state is None or self.state == State.PASSED: | |
| self.state = State.ENV_CHANGED | |
| if reasons: | |
| if self.env_changed_reasons is None: | |
| self.env_changed_reasons = [] | |
| for reason in reasons: | |
| if reason not in self.env_changed_reasons: | |
| self.env_changed_reasons.append(reason) | |
| def must_stop(self, fail_fast: bool, fail_env_changed: bool) -> bool: | |
| if State.must_stop(self.state): | |
| return True | |
| if fail_fast and self.is_failed(fail_env_changed): | |
| return True | |
| return False | |
| def get_rerun_match_tests(self) -> FilterTuple | None: | |
| match_tests = [] | |
| errors = self.errors or [] | |
| failures = self.failures or [] | |
| for error_list, is_error in ( | |
| (errors, True), | |
| (failures, False), | |
| ): | |
| for full_name, *_ in error_list: | |
| match_name = normalize_test_name(full_name, is_error=is_error) | |
| if match_name is None: | |
| # 'setUpModule (test.test_sys)': don't filter tests | |
| return None | |
| if not match_name: | |
| error_type = "ERROR" if is_error else "FAIL" | |
| print_warning(f"rerun failed to parse {error_type} test name: " | |
| f"{full_name!r}: don't filter tests") | |
| return None | |
| match_tests.append(match_name) | |
| if not match_tests: | |
| return None | |
| return tuple(match_tests) | |
| def write_json_into(self, file) -> None: | |
| json.dump(self, file, cls=_EncodeTestResult) | |
| @staticmethod | |
| def from_json(worker_json: StrJSON) -> 'TestResult': | |
| return json.loads(worker_json, object_hook=_decode_test_result) | |
| class _EncodeTestResult(json.JSONEncoder): | |
| def default(self, o: Any) -> dict[str, Any]: | |
| if isinstance(o, TestResult): | |
| result = dataclasses.asdict(o) | |
| result["__test_result__"] = o.__class__.__name__ | |
| return result | |
| else: | |
| return super().default(o) | |
| def _decode_test_result(data: dict[str, Any]) -> TestResult | dict[str, Any]: | |
| if "__test_result__" in data: | |
| data.pop('__test_result__') | |
| if data['stats'] is not None: | |
| data['stats'] = TestStats(**data['stats']) | |
| if data['covered_lines'] is not None: | |
| data['covered_lines'] = [ | |
| tuple(loc) for loc in data['covered_lines'] | |
| ] | |
| return TestResult(**data) | |
| else: | |
| return data |