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.
Files changed (63) hide show
  1. symtest/__init__.py +45 -0
  2. symtest/cli.py +549 -0
  3. symtest/commands/__init__.py +9 -0
  4. symtest/commands/compare.py +221 -0
  5. symtest/config/__init__.py +7 -0
  6. symtest/config/config_io.py +346 -0
  7. symtest/config/config_schema.py +330 -0
  8. symtest/config/import_expander.py +149 -0
  9. symtest/config/inheritance_expander.py +197 -0
  10. symtest/core/__init__.py +15 -0
  11. symtest/core/assertions.py +253 -0
  12. symtest/core/base_runner.py +299 -0
  13. symtest/core/config_loader.py +536 -0
  14. symtest/core/execution.py +498 -0
  15. symtest/core/history_store.py +96 -0
  16. symtest/core/last_run_store.py +109 -0
  17. symtest/core/parallel_runner.py +251 -0
  18. symtest/core/process_worker.py +93 -0
  19. symtest/core/sequence_state.py +143 -0
  20. symtest/core/setup.py +137 -0
  21. symtest/core/test_case.py +76 -0
  22. symtest/core/types.py +92 -0
  23. symtest/file_comparator/__init__.py +10 -0
  24. symtest/file_comparator/base_comparator.py +109 -0
  25. symtest/file_comparator/binary_comparator.py +399 -0
  26. symtest/file_comparator/csv_comparator.py +241 -0
  27. symtest/file_comparator/factory.py +191 -0
  28. symtest/file_comparator/h5_comparator.py +777 -0
  29. symtest/file_comparator/json_comparator.py +323 -0
  30. symtest/file_comparator/result.py +213 -0
  31. symtest/file_comparator/script_comparator.py +182 -0
  32. symtest/file_comparator/text_comparator.py +182 -0
  33. symtest/file_comparator/xml_comparator.py +150 -0
  34. symtest/logging_config.py +66 -0
  35. symtest/runners/__init__.py +15 -0
  36. symtest/runners/config_runner.py +96 -0
  37. symtest/runners/json_runner.py +21 -0
  38. symtest/runners/parallel_config_runner.py +278 -0
  39. symtest/runners/parallel_json_runner.py +26 -0
  40. symtest/runners/parallel_yaml_runner.py +31 -0
  41. symtest/runners/yaml_runner.py +26 -0
  42. symtest/tui/__init__.py +11 -0
  43. symtest/tui/app.py +90 -0
  44. symtest/tui/controllers/__init__.py +0 -0
  45. symtest/tui/controllers/case_controller.py +322 -0
  46. symtest/tui/screens/__init__.py +0 -0
  47. symtest/tui/screens/case_editor.py +244 -0
  48. symtest/tui/screens/case_list.py +255 -0
  49. symtest/tui/widgets/__init__.py +0 -0
  50. symtest/tui/widgets/case_table.py +113 -0
  51. symtest/tui/widgets/expected_editor.py +159 -0
  52. symtest/tui/widgets/search_bar.py +160 -0
  53. symtest/tui/widgets/steps_editor.py +243 -0
  54. symtest/utils/__init__.py +21 -0
  55. symtest/utils/junit_xml_writer.py +137 -0
  56. symtest/utils/path_resolver.py +124 -0
  57. symtest/utils/report_generator.py +208 -0
  58. symtest_cli-1.3.0.dist-info/METADATA +316 -0
  59. symtest_cli-1.3.0.dist-info/RECORD +63 -0
  60. symtest_cli-1.3.0.dist-info/WHEEL +5 -0
  61. symtest_cli-1.3.0.dist-info/entry_points.txt +4 -0
  62. symtest_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
  63. 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