| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  | import dataclasses | 
					
						
							|  |  |  | import json | 
					
						
							|  |  |  | from typing import Any | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-09-11 09:02:35 +02:00
										 |  |  | from .utils import ( | 
					
						
							| 
									
										
										
										
											2023-09-11 05:27:37 +02:00
										 |  |  |     StrJSON, TestName, FilterTuple, | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  |     format_duration, normalize_test_name, print_warning) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-10-25 12:41:21 +03:00
										 |  |  | @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 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  | # 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" | 
					
						
							| 
									
										
										
										
											2023-09-30 22:48:26 +02:00
										 |  |  |     WORKER_FAILED = "WORKER_FAILED"   # non-zero worker process exit code | 
					
						
							|  |  |  |     WORKER_BUG = "WORKER_BUG"         # exception when running a worker | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  |     DID_NOT_RUN = "DID_NOT_RUN" | 
					
						
							|  |  |  |     TIMEOUT = "TIMEOUT" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     @staticmethod | 
					
						
							|  |  |  |     def is_failed(state): | 
					
						
							|  |  |  |         return state in { | 
					
						
							|  |  |  |             State.FAILED, | 
					
						
							|  |  |  |             State.UNCAUGHT_EXC, | 
					
						
							|  |  |  |             State.REFLEAK, | 
					
						
							| 
									
										
										
										
											2023-09-30 22:48:26 +02:00
										 |  |  |             State.WORKER_FAILED, | 
					
						
							|  |  |  |             State.WORKER_BUG, | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  |             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, | 
					
						
							| 
									
										
										
										
											2023-09-30 22:48:26 +02:00
										 |  |  |             State.WORKER_FAILED, | 
					
						
							|  |  |  |             State.WORKER_BUG, | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  |             State.DID_NOT_RUN} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     @staticmethod | 
					
						
							|  |  |  |     def must_stop(state): | 
					
						
							|  |  |  |         return state in { | 
					
						
							|  |  |  |             State.INTERRUPTED, | 
					
						
							| 
									
										
										
										
											2023-09-30 22:48:26 +02:00
										 |  |  |             State.WORKER_BUG, | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-11-10 18:17:45 +01:00
										 |  |  | FileName = str | 
					
						
							|  |  |  | LineNo = int | 
					
						
							|  |  |  | Location = tuple[FileName, LineNo] | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  | @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 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-11-10 18:17:45 +01:00
										 |  |  |     # partial coverage in a worker run; not used by sequential in-process runs | 
					
						
							|  |  |  |     covered_lines: list[Location] | None = None | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  |     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): | 
					
						
							|  |  |  |         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"{self.test_name} failed ({le} {error_s}, {lf} {failure_s})" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if self.errors: | 
					
						
							|  |  |  |             le = len(self.errors) | 
					
						
							|  |  |  |             error_s = "error" + ("s" if le > 1 else "") | 
					
						
							|  |  |  |             return f"{self.test_name} failed ({le} {error_s})" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if self.failures: | 
					
						
							|  |  |  |             lf = len(self.failures) | 
					
						
							|  |  |  |             failure_s = "failure" + ("s" if lf > 1 else "") | 
					
						
							|  |  |  |             return f"{self.test_name} failed ({lf} {failure_s})" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         return f"{self.test_name} failed" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def __str__(self) -> str: | 
					
						
							|  |  |  |         match self.state: | 
					
						
							|  |  |  |             case State.PASSED: | 
					
						
							|  |  |  |                 return f"{self.test_name} passed" | 
					
						
							|  |  |  |             case State.FAILED: | 
					
						
							|  |  |  |                 return self._format_failed() | 
					
						
							|  |  |  |             case State.SKIPPED: | 
					
						
							|  |  |  |                 return f"{self.test_name} skipped" | 
					
						
							|  |  |  |             case State.UNCAUGHT_EXC: | 
					
						
							|  |  |  |                 return f"{self.test_name} failed (uncaught exception)" | 
					
						
							|  |  |  |             case State.REFLEAK: | 
					
						
							|  |  |  |                 return f"{self.test_name} failed (reference leak)" | 
					
						
							|  |  |  |             case State.ENV_CHANGED: | 
					
						
							|  |  |  |                 return f"{self.test_name} failed (env changed)" | 
					
						
							|  |  |  |             case State.RESOURCE_DENIED: | 
					
						
							|  |  |  |                 return f"{self.test_name} skipped (resource denied)" | 
					
						
							|  |  |  |             case State.INTERRUPTED: | 
					
						
							|  |  |  |                 return f"{self.test_name} interrupted" | 
					
						
							| 
									
										
										
										
											2023-09-30 22:48:26 +02:00
										 |  |  |             case State.WORKER_FAILED: | 
					
						
							|  |  |  |                 return f"{self.test_name} worker non-zero exit code" | 
					
						
							|  |  |  |             case State.WORKER_BUG: | 
					
						
							|  |  |  |                 return f"{self.test_name} worker bug" | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  |             case State.DID_NOT_RUN: | 
					
						
							|  |  |  |                 return f"{self.test_name} ran no tests" | 
					
						
							|  |  |  |             case State.TIMEOUT: | 
					
						
							| 
									
										
										
										
											2024-11-04 13:15:57 +03:00
										 |  |  |                 assert self.duration is not None, "self.duration is None" | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  |                 return f"{self.test_name} timed out ({format_duration(self.duration)})" | 
					
						
							|  |  |  |             case _: | 
					
						
							|  |  |  |                 raise ValueError("unknown result state: {state!r}") | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def has_meaningful_duration(self): | 
					
						
							|  |  |  |         return State.has_meaningful_duration(self.state) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def set_env_changed(self): | 
					
						
							|  |  |  |         if self.state is None or self.state == State.PASSED: | 
					
						
							|  |  |  |             self.state = State.ENV_CHANGED | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     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) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-09-11 19:33:42 +02:00
										 |  |  |     def write_json_into(self, file) -> None: | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  |         json.dump(self, file, cls=_EncodeTestResult) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     @staticmethod | 
					
						
							| 
									
										
										
										
											2023-09-11 05:27:37 +02:00
										 |  |  |     def from_json(worker_json: StrJSON) -> 'TestResult': | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  |         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']) | 
					
						
							| 
									
										
										
										
											2023-11-10 18:17:45 +01:00
										 |  |  |         if data['covered_lines'] is not None: | 
					
						
							|  |  |  |             data['covered_lines'] = [ | 
					
						
							|  |  |  |                 tuple(loc) for loc in data['covered_lines'] | 
					
						
							|  |  |  |             ] | 
					
						
							| 
									
										
										
										
											2023-09-11 02:07:18 +02:00
										 |  |  |         return TestResult(**data) | 
					
						
							|  |  |  |     else: | 
					
						
							|  |  |  |         return data |