symtest-cli 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- symtest/__init__.py +45 -0
- symtest/cli.py +549 -0
- symtest/commands/__init__.py +9 -0
- symtest/commands/compare.py +221 -0
- symtest/config/__init__.py +7 -0
- symtest/config/config_io.py +346 -0
- symtest/config/config_schema.py +330 -0
- symtest/config/import_expander.py +149 -0
- symtest/config/inheritance_expander.py +197 -0
- symtest/core/__init__.py +15 -0
- symtest/core/assertions.py +253 -0
- symtest/core/base_runner.py +299 -0
- symtest/core/config_loader.py +536 -0
- symtest/core/execution.py +498 -0
- symtest/core/history_store.py +96 -0
- symtest/core/last_run_store.py +109 -0
- symtest/core/parallel_runner.py +251 -0
- symtest/core/process_worker.py +93 -0
- symtest/core/sequence_state.py +143 -0
- symtest/core/setup.py +137 -0
- symtest/core/test_case.py +76 -0
- symtest/core/types.py +92 -0
- symtest/file_comparator/__init__.py +10 -0
- symtest/file_comparator/base_comparator.py +109 -0
- symtest/file_comparator/binary_comparator.py +399 -0
- symtest/file_comparator/csv_comparator.py +241 -0
- symtest/file_comparator/factory.py +191 -0
- symtest/file_comparator/h5_comparator.py +777 -0
- symtest/file_comparator/json_comparator.py +323 -0
- symtest/file_comparator/result.py +213 -0
- symtest/file_comparator/script_comparator.py +182 -0
- symtest/file_comparator/text_comparator.py +182 -0
- symtest/file_comparator/xml_comparator.py +150 -0
- symtest/logging_config.py +66 -0
- symtest/runners/__init__.py +15 -0
- symtest/runners/config_runner.py +96 -0
- symtest/runners/json_runner.py +21 -0
- symtest/runners/parallel_config_runner.py +278 -0
- symtest/runners/parallel_json_runner.py +26 -0
- symtest/runners/parallel_yaml_runner.py +31 -0
- symtest/runners/yaml_runner.py +26 -0
- symtest/tui/__init__.py +11 -0
- symtest/tui/app.py +90 -0
- symtest/tui/controllers/__init__.py +0 -0
- symtest/tui/controllers/case_controller.py +322 -0
- symtest/tui/screens/__init__.py +0 -0
- symtest/tui/screens/case_editor.py +244 -0
- symtest/tui/screens/case_list.py +255 -0
- symtest/tui/widgets/__init__.py +0 -0
- symtest/tui/widgets/case_table.py +113 -0
- symtest/tui/widgets/expected_editor.py +159 -0
- symtest/tui/widgets/search_bar.py +160 -0
- symtest/tui/widgets/steps_editor.py +243 -0
- symtest/utils/__init__.py +21 -0
- symtest/utils/junit_xml_writer.py +137 -0
- symtest/utils/path_resolver.py +124 -0
- symtest/utils/report_generator.py +208 -0
- symtest_cli-1.3.0.dist-info/METADATA +316 -0
- symtest_cli-1.3.0.dist-info/RECORD +63 -0
- symtest_cli-1.3.0.dist-info/WHEEL +5 -0
- symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
- symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- symtest_cli-1.3.0.dist-info/top_level.txt +1 -0
symtest/core/setup.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("symtest.core.setup")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseSetup(ABC):
|
|
10
|
+
"""测试前置任务的基类,允许用户以插件形式自定义"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
13
|
+
"""
|
|
14
|
+
初始化Setup
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
config: 配置参数字典
|
|
18
|
+
"""
|
|
19
|
+
self.config = config or {}
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def setup(self) -> None:
|
|
23
|
+
"""
|
|
24
|
+
执行前置任务设置
|
|
25
|
+
子类必须实现此方法
|
|
26
|
+
"""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def teardown(self) -> None:
|
|
31
|
+
"""
|
|
32
|
+
执行后置清理任务
|
|
33
|
+
子类必须实现此方法
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
def get_name(self) -> str:
|
|
38
|
+
"""返回Setup的名称"""
|
|
39
|
+
return self.__class__.__name__
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EnvironmentSetup(BaseSetup):
|
|
43
|
+
"""内置的环境变量设置插件"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
46
|
+
super().__init__(config)
|
|
47
|
+
self._original_env = {} # 存储原始环境变量值
|
|
48
|
+
self._added_env = set() # 存储新添加的环境变量
|
|
49
|
+
|
|
50
|
+
def setup(self) -> None:
|
|
51
|
+
"""设置环境变量"""
|
|
52
|
+
env_vars = self.config.get('environment_variables', {})
|
|
53
|
+
|
|
54
|
+
if not env_vars:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
logger.info("Setting up environment variables...")
|
|
58
|
+
|
|
59
|
+
for key, value in env_vars.items():
|
|
60
|
+
# 保存原始值
|
|
61
|
+
if key in os.environ:
|
|
62
|
+
self._original_env[key] = os.environ[key]
|
|
63
|
+
else:
|
|
64
|
+
self._added_env.add(key)
|
|
65
|
+
|
|
66
|
+
# 设置新值
|
|
67
|
+
os.environ[key] = str(value)
|
|
68
|
+
logger.info(" %s = %s", key, value)
|
|
69
|
+
|
|
70
|
+
def teardown(self) -> None:
|
|
71
|
+
"""恢复环境变量"""
|
|
72
|
+
if not self._original_env and not self._added_env:
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
logger.info("Restoring environment variables...")
|
|
76
|
+
|
|
77
|
+
# 恢复原始值
|
|
78
|
+
for key, value in self._original_env.items():
|
|
79
|
+
os.environ[key] = value
|
|
80
|
+
logger.info(" Restored %s", key)
|
|
81
|
+
|
|
82
|
+
# 删除新添加的环境变量
|
|
83
|
+
for key in self._added_env:
|
|
84
|
+
if key in os.environ:
|
|
85
|
+
del os.environ[key]
|
|
86
|
+
logger.info(" Removed %s", key)
|
|
87
|
+
|
|
88
|
+
# 清空记录
|
|
89
|
+
self._original_env.clear()
|
|
90
|
+
self._added_env.clear()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SetupManager:
|
|
94
|
+
"""Setup管理器,负责管理多个Setup插件"""
|
|
95
|
+
|
|
96
|
+
def __init__(self):
|
|
97
|
+
self.setups = []
|
|
98
|
+
|
|
99
|
+
def add_setup(self, setup: BaseSetup) -> None:
|
|
100
|
+
"""添加Setup插件"""
|
|
101
|
+
self.setups.append(setup)
|
|
102
|
+
|
|
103
|
+
def setup_all(self) -> None:
|
|
104
|
+
"""执行所有Setup的前置任务"""
|
|
105
|
+
if not self.setups:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
logger.info("=" * 50)
|
|
109
|
+
logger.info("Executing setup tasks...")
|
|
110
|
+
logger.info("=" * 50)
|
|
111
|
+
|
|
112
|
+
for setup in self.setups:
|
|
113
|
+
try:
|
|
114
|
+
logger.info("Running setup: %s", setup.get_name())
|
|
115
|
+
setup.setup()
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error("Error in setup %s: %s", setup.get_name(), e)
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
def teardown_all(self) -> None:
|
|
121
|
+
"""执行所有Setup的后置清理任务(逆序执行)"""
|
|
122
|
+
if not self.setups:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
logger.info("=" * 50)
|
|
126
|
+
logger.info("Executing teardown tasks...")
|
|
127
|
+
logger.info("=" * 50)
|
|
128
|
+
|
|
129
|
+
# 逆序执行teardown
|
|
130
|
+
for setup in reversed(self.setups):
|
|
131
|
+
try:
|
|
132
|
+
logger.info("Running teardown: %s", setup.get_name())
|
|
133
|
+
setup.teardown()
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.error("Error in teardown %s: %s", setup.get_name(), e)
|
|
136
|
+
# teardown错误不应该阻止其他teardown的执行
|
|
137
|
+
continue
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import List, Dict, Any, Optional
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class TestCaseStep:
|
|
6
|
+
"""A single step within a sequence test case."""
|
|
7
|
+
__test__ = False
|
|
8
|
+
command: str
|
|
9
|
+
args: List[str]
|
|
10
|
+
expected: Dict[str, Any]
|
|
11
|
+
timeout: Optional[float] = None
|
|
12
|
+
retry_count: int = 0
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TestCase:
|
|
16
|
+
__test__ = False
|
|
17
|
+
name: str
|
|
18
|
+
command: str = ""
|
|
19
|
+
args: List[str] = field(default_factory=list)
|
|
20
|
+
expected: Dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
description: str = ""
|
|
22
|
+
timeout: Optional[float] = None
|
|
23
|
+
resources: Optional[Dict[str, Any]] = None
|
|
24
|
+
steps: Optional[List[TestCaseStep]] = None
|
|
25
|
+
tags: List[str] = field(default_factory=list)
|
|
26
|
+
retry_count: int = 0
|
|
27
|
+
expected_failure: bool = False
|
|
28
|
+
xfail_reason: str = ""
|
|
29
|
+
xfail_quiet: bool = False
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
32
|
+
"""Convert test case to dictionary format"""
|
|
33
|
+
result = {
|
|
34
|
+
"name": self.name,
|
|
35
|
+
"command": self.command,
|
|
36
|
+
"args": self.args,
|
|
37
|
+
"expected": self.expected,
|
|
38
|
+
"timeout": self.timeout,
|
|
39
|
+
"resources": self.resources,
|
|
40
|
+
"tags": self.tags,
|
|
41
|
+
"retry_count": self.retry_count,
|
|
42
|
+
}
|
|
43
|
+
if self.expected_failure:
|
|
44
|
+
result["expected_failure"] = self.expected_failure
|
|
45
|
+
result["xfail_reason"] = self.xfail_reason
|
|
46
|
+
if self.xfail_quiet:
|
|
47
|
+
result["xfail_quiet"] = self.xfail_quiet
|
|
48
|
+
if self.steps is not None:
|
|
49
|
+
result["steps"] = [
|
|
50
|
+
{
|
|
51
|
+
"command": s.command,
|
|
52
|
+
"args": s.args,
|
|
53
|
+
"expected": s.expected,
|
|
54
|
+
"timeout": s.timeout,
|
|
55
|
+
"retry_count": s.retry_count,
|
|
56
|
+
}
|
|
57
|
+
for s in self.steps
|
|
58
|
+
]
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
def to_execution_dict(self) -> Dict[str, Any]:
|
|
62
|
+
"""Convert to the dict format expected by ``execute_single_test_case``.
|
|
63
|
+
|
|
64
|
+
Only for single-command mode; sequence cases should use
|
|
65
|
+
``execute_sequence()`` instead.
|
|
66
|
+
"""
|
|
67
|
+
return {
|
|
68
|
+
"name": self.name,
|
|
69
|
+
"command": self.command,
|
|
70
|
+
"args": self.args,
|
|
71
|
+
"expected": self.expected,
|
|
72
|
+
"description": self.description or None,
|
|
73
|
+
"timeout": self.timeout,
|
|
74
|
+
"resources": self.resources,
|
|
75
|
+
"retry_count": self.retry_count,
|
|
76
|
+
}
|
symtest/core/types.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, TypedDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExpectedResult(TypedDict, total=False):
|
|
5
|
+
"""Expectation configuration for a single test case."""
|
|
6
|
+
|
|
7
|
+
return_code: Optional[int]
|
|
8
|
+
output_contains: List[str]
|
|
9
|
+
output_matches: Optional[str]
|
|
10
|
+
compare_files: List[Dict[str, Any]]
|
|
11
|
+
"""List of file comparison specs.
|
|
12
|
+
Each spec is a dict with:
|
|
13
|
+
actual (str): path to the file produced by the test command
|
|
14
|
+
baseline (str): path to the golden/reference file
|
|
15
|
+
type (str, optional): comparator type (e.g. 'h5','json','csv','xml','text','binary')
|
|
16
|
+
auto-detected from extension if omitted
|
|
17
|
+
Additional kwargs are forwarded to the comparator (e.g. rtol, atol, encoding, ...)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ResourceRequirements(TypedDict, total=False):
|
|
22
|
+
"""Optional resource hints for scheduling."""
|
|
23
|
+
|
|
24
|
+
estimated_time: float # seconds, used for ordering (LPT)
|
|
25
|
+
min_memory_mb: float # soft hint to avoid OOM
|
|
26
|
+
priority: int # higher value => higher priority
|
|
27
|
+
cpu_cores: int # number of CPU cores required by this task
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestCaseData(TypedDict, total=False):
|
|
31
|
+
"""Input data shape for a test case after解析/路径处理."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
command: str
|
|
35
|
+
args: List[str]
|
|
36
|
+
expected: ExpectedResult
|
|
37
|
+
description: Optional[str]
|
|
38
|
+
timeout: Optional[float]
|
|
39
|
+
resources: Optional[ResourceRequirements]
|
|
40
|
+
retry_count: int
|
|
41
|
+
expected_failure: bool
|
|
42
|
+
xfail_reason: Optional[str]
|
|
43
|
+
xfail_quiet: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SetupConfig(TypedDict):
|
|
47
|
+
"""Setup configuration (currently environment variables only)."""
|
|
48
|
+
|
|
49
|
+
environment_variables: Dict[str, str]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestSuiteConfig(TypedDict):
|
|
53
|
+
"""Top-level configuration for a suite loaded from JSON/YAML."""
|
|
54
|
+
|
|
55
|
+
setup: Optional[SetupConfig]
|
|
56
|
+
test_cases: List[TestCaseData]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestResultData(TypedDict):
|
|
60
|
+
"""Normalized result produced by executing a single test case."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
status: str # 'passed', 'failed', 'timeout'
|
|
64
|
+
message: str
|
|
65
|
+
command: str
|
|
66
|
+
output: str
|
|
67
|
+
return_code: Optional[int]
|
|
68
|
+
duration: float
|
|
69
|
+
# ── New fields (optional, backward-compatible) ──
|
|
70
|
+
expected: Optional[Dict[str, Any]]
|
|
71
|
+
description: Optional[str]
|
|
72
|
+
tags: List[str]
|
|
73
|
+
failure_kind: Optional[str] # 'return_code' | 'output_contains' | 'output_matches' | 'file_compare' | 'timeout' | 'execution_error'
|
|
74
|
+
attempts: int
|
|
75
|
+
flaky: bool
|
|
76
|
+
attempt_history: List[Dict[str, Any]]
|
|
77
|
+
step_results: List[Dict[str, Any]]
|
|
78
|
+
compare_failures: List[Dict[str, Any]]
|
|
79
|
+
baseline_updated: List[str]
|
|
80
|
+
failed_step: Optional[int]
|
|
81
|
+
# ── Structured output channels (split from combined ``output``) ──
|
|
82
|
+
stdout: str
|
|
83
|
+
stderr: str
|
|
84
|
+
# ── Per-assertion pass/fail detail (populated on both pass and failure) ──
|
|
85
|
+
assertion_results: List[Dict[str, Any]]
|
|
86
|
+
xfail_reason: Optional[str]
|
|
87
|
+
# ── Structured remediation suggestion (populated on failure) ──
|
|
88
|
+
# Dict with: action ('update_baseline' | 'update_expected' | 'increase_timeout'
|
|
89
|
+
# | 'investigate'), command (concrete symtest command, filled by runners),
|
|
90
|
+
# reason (human/AI-readable explanation).
|
|
91
|
+
next_action_hint: Optional[Dict[str, Any]]
|
|
92
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File comparison module for symtest.
|
|
3
|
+
This module provides functionality for comparing different types of files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .factory import ComparatorFactory
|
|
7
|
+
from .result import ComparisonResult
|
|
8
|
+
from .base_comparator import BaseComparator
|
|
9
|
+
|
|
10
|
+
__all__ = ['ComparatorFactory', 'ComparisonResult', 'BaseComparator']
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
@file base_comparator.py
|
|
6
|
+
@brief Base abstract class for file comparison operations
|
|
7
|
+
@author Xiaotong Wang
|
|
8
|
+
@date 2025
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from .result import ComparisonResult, Difference
|
|
15
|
+
|
|
16
|
+
class BaseComparator(ABC):
|
|
17
|
+
"""
|
|
18
|
+
@brief Base abstract class for all file comparators
|
|
19
|
+
@details This class defines the interface and common functionality for all file comparators.
|
|
20
|
+
It provides basic file comparison operations and logging capabilities.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, encoding="utf-8", chunk_size=8192, verbose=False, **kwargs):
|
|
24
|
+
"""
|
|
25
|
+
@brief Initialize the base comparator
|
|
26
|
+
@param encoding str: File encoding to use (default: "utf-8")
|
|
27
|
+
@param chunk_size int: Size of chunks for reading large files (default: 8192)
|
|
28
|
+
@param verbose bool: Enable verbose logging (default: False)
|
|
29
|
+
"""
|
|
30
|
+
self.encoding = encoding
|
|
31
|
+
self.chunk_size = chunk_size
|
|
32
|
+
self.logger = logging.getLogger(f"file_comparator.{self.__class__.__name__}")
|
|
33
|
+
if verbose:
|
|
34
|
+
self.logger.setLevel(logging.DEBUG)
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def read_content(self, file_path, start_line=0, end_line=None, start_column=0, end_column=None):
|
|
38
|
+
"""
|
|
39
|
+
@brief Read file content with specified range
|
|
40
|
+
@param file_path Path: Path to the file to read
|
|
41
|
+
@param start_line int: Starting line number (0-based)
|
|
42
|
+
@param end_line int: Ending line number (0-based, None for end of file)
|
|
43
|
+
@param start_column int: Starting column number (0-based)
|
|
44
|
+
@param end_column int: Ending column number (0-based, None for end of line)
|
|
45
|
+
@return object: File content in a format suitable for comparison
|
|
46
|
+
"""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def compare_content(self, content1, content2):
|
|
51
|
+
"""
|
|
52
|
+
@brief Compare two content objects and return comparison details
|
|
53
|
+
@param content1 object: First content object to compare
|
|
54
|
+
@param content2 object: Second content object to compare
|
|
55
|
+
@return tuple: (bool, list, bool) - (identical, differences, truncated)
|
|
56
|
+
"""
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def compare_files(self, file1, file2, start_line=0, end_line=None, start_column=0, end_column=None):
|
|
60
|
+
"""
|
|
61
|
+
@brief Compare two files with the specified parameters
|
|
62
|
+
@param file1 Path: Path to the first file
|
|
63
|
+
@param file2 Path: Path to the second file
|
|
64
|
+
@param start_line int: Starting line number (0-based)
|
|
65
|
+
@param end_line int: Ending line number (0-based, None for end of file)
|
|
66
|
+
@param start_column int: Starting column number (0-based)
|
|
67
|
+
@param end_column int: Ending column number (0-based, None for end of line)
|
|
68
|
+
@return ComparisonResult: Result object containing comparison details
|
|
69
|
+
"""
|
|
70
|
+
result = ComparisonResult(
|
|
71
|
+
file1=str(file1),
|
|
72
|
+
file2=str(file2),
|
|
73
|
+
start_line=start_line,
|
|
74
|
+
end_line=end_line,
|
|
75
|
+
start_column=start_column,
|
|
76
|
+
end_column=end_column
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
self.logger.info(f"Comparing files: {file1} and {file2}")
|
|
81
|
+
|
|
82
|
+
# Record file metadata
|
|
83
|
+
file1_path = Path(file1)
|
|
84
|
+
file2_path = Path(file2)
|
|
85
|
+
result.file1_size = file1_path.stat().st_size
|
|
86
|
+
result.file2_size = file2_path.stat().st_size
|
|
87
|
+
|
|
88
|
+
# Read content with specified ranges
|
|
89
|
+
self.logger.debug(f"Reading content from files")
|
|
90
|
+
content1 = self.read_content(file1, start_line, end_line, start_column, end_column)
|
|
91
|
+
content2 = self.read_content(file2, start_line, end_line, start_column, end_column)
|
|
92
|
+
|
|
93
|
+
# Compare content
|
|
94
|
+
self.logger.debug(f"Comparing content")
|
|
95
|
+
identical, differences, truncated = self.compare_content(content1, content2)
|
|
96
|
+
|
|
97
|
+
# Update result
|
|
98
|
+
result.identical = identical
|
|
99
|
+
result.differences = differences
|
|
100
|
+
result.truncated = truncated
|
|
101
|
+
result.error_stats = getattr(self, "_error_stats", None)
|
|
102
|
+
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
except Exception as e:
|
|
106
|
+
self.logger.error(f"Error during comparison: {str(e)}")
|
|
107
|
+
result.error = str(e)
|
|
108
|
+
result.identical = False
|
|
109
|
+
return result
|