coverage-tool 1.0.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 (47) hide show
  1. coverage_tool/__init__.py +7 -0
  2. coverage_tool/analyzers/__init__.py +14 -0
  3. coverage_tool/analyzers/dependency.py +513 -0
  4. coverage_tool/converters/__init__.py +60 -0
  5. coverage_tool/converters/base.py +83 -0
  6. coverage_tool/converters/cunit_converter.py +47 -0
  7. coverage_tool/converters/factory.py +36 -0
  8. coverage_tool/converters/generic_converter.py +62 -0
  9. coverage_tool/converters/go_converter.py +178 -0
  10. coverage_tool/converters/gtest_converter.py +63 -0
  11. coverage_tool/core/__init__.py +18 -0
  12. coverage_tool/core/config.py +66 -0
  13. coverage_tool/core/reporter.py +270 -0
  14. coverage_tool/example.py +102 -0
  15. coverage_tool/handlers/__init__.py +13 -0
  16. coverage_tool/handlers/compilation.py +322 -0
  17. coverage_tool/handlers/env_checker.py +312 -0
  18. coverage_tool/main.py +559 -0
  19. coverage_tool/parsers/__init__.py +15 -0
  20. coverage_tool/parsers/junit.py +172 -0
  21. coverage_tool/runners/__init__.py +26 -0
  22. coverage_tool/runners/base.py +249 -0
  23. coverage_tool/runners/c_runner.py +66 -0
  24. coverage_tool/runners/cpp_runner.py +65 -0
  25. coverage_tool/runners/factory.py +41 -0
  26. coverage_tool/runners/go_runner.py +158 -0
  27. coverage_tool/runners/java_runner.py +54 -0
  28. coverage_tool/runners/python_runner.py +99 -0
  29. coverage_tool/test_removers/__init__.py +38 -0
  30. coverage_tool/test_removers/base.py +233 -0
  31. coverage_tool/test_removers/c_remover.py +210 -0
  32. coverage_tool/test_removers/cpp_remover.py +272 -0
  33. coverage_tool/test_removers/factory.py +45 -0
  34. coverage_tool/test_removers/go_remover.py +112 -0
  35. coverage_tool/test_removers/java_remover.py +216 -0
  36. coverage_tool/test_removers/python_remover.py +172 -0
  37. coverage_tool/utils/__init__.py +23 -0
  38. coverage_tool/utils/dependency_graph.py +52 -0
  39. coverage_tool/utils/file_backup.py +112 -0
  40. coverage_tool/utils/helpers.py +89 -0
  41. coverage_tool/utils/logger.py +54 -0
  42. coverage_tool/utils/progress.py +41 -0
  43. coverage_tool-1.0.0.dist-info/METADATA +484 -0
  44. coverage_tool-1.0.0.dist-info/RECORD +47 -0
  45. coverage_tool-1.0.0.dist-info/WHEEL +5 -0
  46. coverage_tool-1.0.0.dist-info/entry_points.txt +2 -0
  47. coverage_tool-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ JUnit XML 解析器
4
+ 解析各语言生成的JUnit XML测试报告
5
+ """
6
+
7
+ import xml.etree.ElementTree as ET
8
+ from dataclasses import dataclass, field
9
+ from typing import List, Dict, Optional
10
+ from enum import Enum
11
+ from pathlib import Path
12
+
13
+ class TestStatus(Enum):
14
+ PASSED = "passed"
15
+ FAILED = "failed"
16
+ ERROR = "error"
17
+ SKIPPED = "skipped"
18
+
19
+ @dataclass
20
+ class TestCase:
21
+ """单个测试用例"""
22
+ name: str
23
+ classname: str
24
+ time: float
25
+ status: TestStatus
26
+ failure_message: Optional[str] = None
27
+ failure_type: Optional[str] = None
28
+ error_message: Optional[str] = None
29
+ error_type: Optional[str] = None
30
+ skipped_message: Optional[str] = None
31
+
32
+ @dataclass
33
+ class TestSuite:
34
+ """测试套件"""
35
+ name: str
36
+ tests: int
37
+ failures: int
38
+ errors: int
39
+ skipped: int
40
+ time: float
41
+ test_cases: List[TestCase] = field(default_factory=list)
42
+
43
+ @dataclass
44
+ class TestReport:
45
+ """完整测试报告"""
46
+ suites: List[TestSuite]
47
+ total_tests: int = 0
48
+ total_failures: int = 0
49
+ total_errors: int = 0
50
+ total_skipped: int = 0
51
+ total_time: float = 0.0
52
+
53
+ def __post_init__(self):
54
+ for suite in self.suites:
55
+ self.total_tests += suite.tests
56
+ self.total_failures += suite.failures
57
+ self.total_errors += suite.errors
58
+ self.total_skipped += suite.skipped
59
+ self.total_time += suite.time
60
+
61
+ @property
62
+ def pass_rate(self) -> float:
63
+ if self.total_tests == 0:
64
+ return 0.0
65
+ passed = self.total_tests - self.total_failures - self.total_errors - self.total_skipped
66
+ return (passed / self.total_tests) * 100
67
+
68
+ class JunitXMLParser:
69
+ """JUnit XML解析器"""
70
+
71
+ @staticmethod
72
+ def parse(xml_path: str) -> TestReport:
73
+ """解析JUnit XML文件"""
74
+ tree = ET.parse(xml_path)
75
+ root = tree.getroot()
76
+
77
+ # 兼容不同格式的根节点
78
+ if root.tag == 'testsuites':
79
+ return JunitXMLParser._parse_testsuites(root)
80
+ elif root.tag == 'testsuite':
81
+ return JunitXMLParser._parse_testsuite(root)
82
+ else:
83
+ raise ValueError(f"未知的XML根节点: {root.tag}")
84
+
85
+ @staticmethod
86
+ def _parse_testsuites(root: ET.Element) -> TestReport:
87
+ """解析testsuites根节点"""
88
+ suites = []
89
+ for suite_elem in root.findall('testsuite'):
90
+ suite = JunitXMLParser._parse_single_testsuite(suite_elem)
91
+ suites.append(suite)
92
+ return TestReport(suites=suites)
93
+
94
+ @staticmethod
95
+ def _parse_testsuite(root: ET.Element) -> TestReport:
96
+ """解析testsuite根节点"""
97
+ suite = JunitXMLParser._parse_single_testsuite(root)
98
+ return TestReport(suites=[suite])
99
+
100
+ @staticmethod
101
+ def _parse_single_testsuite(suite_elem: ET.Element) -> TestSuite:
102
+ """解析单个testsuite"""
103
+ name = suite_elem.get('name', '')
104
+ tests = int(suite_elem.get('tests', 0))
105
+ failures = int(suite_elem.get('failures', 0))
106
+ errors = int(suite_elem.get('errors', 0))
107
+ skipped = int(suite_elem.get('skipped', 0))
108
+ time = float(suite_elem.get('time', 0.0))
109
+
110
+ test_cases = []
111
+ for case_elem in suite_elem.findall('testcase'):
112
+ test_case = JunitXMLParser._parse_testcase(case_elem)
113
+ test_cases.append(test_case)
114
+
115
+ return TestSuite(
116
+ name=name,
117
+ tests=tests,
118
+ failures=failures,
119
+ errors=errors,
120
+ skipped=skipped,
121
+ time=time,
122
+ test_cases=test_cases
123
+ )
124
+
125
+ @staticmethod
126
+ def _parse_testcase(case_elem: ET.Element) -> TestCase:
127
+ """解析单个testcase"""
128
+ name = case_elem.get('name', '')
129
+ classname = case_elem.get('classname', '')
130
+ time = float(case_elem.get('time', 0.0))
131
+
132
+ # 检查状态
133
+ failure_elem = case_elem.find('failure')
134
+ error_elem = case_elem.find('error')
135
+ skipped_elem = case_elem.find('skipped')
136
+
137
+ if failure_elem is not None:
138
+ status = TestStatus.FAILED
139
+ failure_message = failure_elem.get('message', '')
140
+ failure_type = failure_elem.get('type', '')
141
+ elif error_elem is not None:
142
+ status = TestStatus.ERROR
143
+ error_message = error_elem.get('message', '')
144
+ error_type = error_elem.get('type', '')
145
+ elif skipped_elem is not None:
146
+ status = TestStatus.SKIPPED
147
+ skipped_message = skipped_elem.get('message', '')
148
+ else:
149
+ status = TestStatus.PASSED
150
+
151
+ return TestCase(
152
+ name=name,
153
+ classname=classname,
154
+ time=time,
155
+ status=status,
156
+ failure_message=failure_elem.get('message') if failure_elem else None,
157
+ failure_type=failure_elem.get('type') if failure_elem else None,
158
+ error_message=error_elem.get('message') if error_elem else None,
159
+ error_type=error_elem.get('type') if error_elem else None,
160
+ skipped_message=skipped_elem.get('message') if skipped_elem else None
161
+ )
162
+
163
+ @staticmethod
164
+ def parse_string(xml_string: str) -> TestReport:
165
+ """从字符串解析JUnit XML"""
166
+ root = ET.fromstring(xml_string)
167
+ if root.tag == 'testsuites':
168
+ return JunitXMLParser._parse_testsuites(root)
169
+ elif root.tag == 'testsuite':
170
+ return JunitXMLParser._parse_testsuite(root)
171
+ else:
172
+ raise ValueError(f"未知的XML根节点: {root.tag}")
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 多语言单测运行器包
4
+ 支持 Python, Java, Go, C, C++
5
+ 统一覆盖率计算和增强错误处理
6
+ """
7
+
8
+ from .base import RunResult, BaseRunner, CoverageExtractor
9
+ from .factory import RunnerFactory
10
+ from .python_runner import PythonRunner
11
+ from .java_runner import JavaRunner
12
+ from .go_runner import GoRunner
13
+ from .c_runner import CRunner
14
+ from .cpp_runner import CPPRunner
15
+
16
+ __all__ = [
17
+ 'RunResult',
18
+ 'BaseRunner',
19
+ 'CoverageExtractor',
20
+ 'RunnerFactory',
21
+ 'PythonRunner',
22
+ 'JavaRunner',
23
+ 'GoRunner',
24
+ 'CRunner',
25
+ 'CPPRunner',
26
+ ]
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 运行器基类和共享组件
4
+ """
5
+
6
+ import subprocess
7
+ import os
8
+ import time
9
+ import re
10
+ from abc import ABC, abstractmethod
11
+ from typing import List, Dict, Optional, Tuple, Any
12
+ from pathlib import Path
13
+ from dataclasses import dataclass
14
+
15
+ from coverage_tool.core import CoverageConfig
16
+ from coverage_tool.utils import Logger
17
+
18
+
19
+ @dataclass
20
+ class RunResult:
21
+ """运行结果"""
22
+ success: bool
23
+ return_code: int
24
+ stdout: str
25
+ stderr: str
26
+ duration: float
27
+ junit_xml_path: Optional[str] = None
28
+ coverage_rate: float = 0.0
29
+
30
+
31
+ class CoverageExtractor:
32
+ """覆盖率提取器 - 统一处理不同语言的覆盖率"""
33
+
34
+ @staticmethod
35
+ def extract_from_coverage_out(coverage_out_path: str, language: str) -> float:
36
+ """从coverage.out文件提取覆盖率"""
37
+ if not os.path.exists(coverage_out_path):
38
+ return 0.0
39
+
40
+ try:
41
+ if language == 'go':
42
+ return CoverageExtractor._extract_go_coverage(coverage_out_path)
43
+ elif language in ['c', 'cpp']:
44
+ return CoverageExtractor._extract_gcov_coverage(coverage_out_path)
45
+ else:
46
+ return CoverageExtractor._extract_generic_coverage(coverage_out_path)
47
+ except Exception as e:
48
+ print(f"提取覆盖率失败: {e}")
49
+ return 0.0
50
+
51
+ @staticmethod
52
+ def _extract_go_coverage(coverage_out_path: str) -> float:
53
+ """提取Go覆盖率"""
54
+ # 使用 go tool cover
55
+ result = subprocess.run(
56
+ f"go tool cover -func={coverage_out_path}",
57
+ shell=True,
58
+ capture_output=True,
59
+ text=True
60
+ )
61
+
62
+ if result.returncode == 0:
63
+ for line in result.stdout.split('\n'):
64
+ if 'total:' in line.lower():
65
+ parts = line.split()
66
+ for part in reversed(parts):
67
+ if '%' in part:
68
+ try:
69
+ return float(part.replace('%', ''))
70
+ except:
71
+ pass
72
+
73
+ # 备用方案:直接解析文件
74
+ return CoverageExtractor._parse_go_coverage_file(coverage_out_path)
75
+
76
+ @staticmethod
77
+ def _parse_go_coverage_file(coverage_out_path: str) -> float:
78
+ """直接解析Go coverage.out文件"""
79
+ try:
80
+ with open(coverage_out_path, 'r') as f:
81
+ lines = f.readlines()
82
+
83
+ if not lines or not lines[0].startswith('mode:'):
84
+ return 0.0
85
+
86
+ total_statements = 0
87
+ executed_statements = 0
88
+
89
+ for line in lines[1:]:
90
+ parts = line.strip().split()
91
+ if len(parts) >= 3:
92
+ try:
93
+ hits = int(parts[-1])
94
+ total_statements += 1
95
+ if hits > 0:
96
+ executed_statements += 1
97
+ except:
98
+ continue
99
+
100
+ if total_statements > 0:
101
+ return (executed_statements / total_statements) * 100
102
+ except Exception as e:
103
+ print(f"解析Go覆盖率文件失败: {e}")
104
+
105
+ return 0.0
106
+
107
+ @staticmethod
108
+ def _extract_gcov_coverage(coverage_out_path: str) -> float:
109
+ """提取gcov覆盖率"""
110
+ try:
111
+ with open(coverage_out_path, 'r') as f:
112
+ content = f.read()
113
+
114
+ # 尝试多种模式匹配
115
+ patterns = [
116
+ r'lines[\s\w:]+(\d+\.?\d*)%',
117
+ r'Total[\s\w:]+(\d+\.?\d*)%',
118
+ r'(\d+\.?\d*)%',
119
+ ]
120
+
121
+ for pattern in patterns:
122
+ match = re.search(pattern, content, re.IGNORECASE)
123
+ if match:
124
+ try:
125
+ return float(match.group(1))
126
+ except:
127
+ continue
128
+ except Exception as e:
129
+ print(f"提取gcov覆盖率失败: {e}")
130
+
131
+ return 0.0
132
+
133
+ @staticmethod
134
+ def _extract_generic_coverage(coverage_out_path: str) -> float:
135
+ """通用覆盖率提取"""
136
+ try:
137
+ with open(coverage_out_path, 'r') as f:
138
+ content = f.read()
139
+
140
+ # 查找百分比
141
+ match = re.search(r'(\d+\.?\d*)%', content)
142
+ if match:
143
+ return float(match.group(1))
144
+ except Exception as e:
145
+ print(f"提取覆盖率失败: {e}")
146
+
147
+ return 0.0
148
+
149
+ @staticmethod
150
+ def extract_from_jacoco(jacoco_html_path: str) -> float:
151
+ """从JaCoCo HTML报告提取覆盖率"""
152
+ if not os.path.exists(jacoco_html_path):
153
+ return 0.0
154
+
155
+ try:
156
+ with open(jacoco_html_path, 'r') as f:
157
+ content = f.read()
158
+
159
+ # 查找Total行中的覆盖率
160
+ match = re.search(r'Total.*?(\d+)%<', content, re.DOTALL)
161
+ if match:
162
+ return float(match.group(1))
163
+ except Exception as e:
164
+ print(f"提取JaCoCo覆盖率失败: {e}")
165
+
166
+ return 0.0
167
+
168
+
169
+ class BaseRunner(ABC):
170
+ """单测运行器基类"""
171
+
172
+ def __init__(self, config: CoverageConfig, logger: Optional[Logger] = None):
173
+ self.config = config
174
+ self.logger = logger or Logger()
175
+
176
+ @abstractmethod
177
+ def run_tests(self, target_dir: str, output_file: str) -> RunResult:
178
+ """运行测试"""
179
+ pass
180
+
181
+ @abstractmethod
182
+ def get_coverage(self, output_file: str) -> float:
183
+ """获取覆盖率"""
184
+ pass
185
+
186
+ @abstractmethod
187
+ def compile(self, target_dir: str) -> Tuple[bool, List[str]]:
188
+ """编译项目"""
189
+ pass
190
+
191
+ def find_files(self, target_dir: str, pattern: str) -> List[str]:
192
+ """查找文件"""
193
+ # 清理模式:移除开头的 **/ 或 **,确保是相对模式
194
+ clean_pattern = pattern
195
+ if clean_pattern.startswith("**/"):
196
+ clean_pattern = clean_pattern[3:]
197
+ elif clean_pattern.startswith("**"):
198
+ clean_pattern = clean_pattern[2:]
199
+
200
+ # 确保模式不为空且是相对路径
201
+ if not clean_pattern or clean_pattern.startswith("/"):
202
+ clean_pattern = "*" + clean_pattern if clean_pattern else "*"
203
+
204
+ try:
205
+ return [str(p) for p in Path(target_dir).rglob(clean_pattern) if p.is_file()]
206
+ except Exception as e:
207
+ self.logger.error(f"查找文件失败: {e}")
208
+ return []
209
+
210
+ def _run_command(self, cmd: str, cwd: str, timeout: int = 300) -> RunResult:
211
+ """执行命令"""
212
+ start_time = time.time()
213
+ try:
214
+ result = subprocess.run(
215
+ cmd,
216
+ shell=True,
217
+ capture_output=True,
218
+ text=True,
219
+ cwd=cwd,
220
+ timeout=timeout
221
+ )
222
+ duration = time.time() - start_time
223
+ return RunResult(
224
+ success=result.returncode == 0,
225
+ return_code=result.returncode,
226
+ stdout=result.stdout,
227
+ stderr=result.stderr,
228
+ duration=duration
229
+ )
230
+ except subprocess.TimeoutExpired:
231
+ duration = time.time() - start_time
232
+ self.logger.error(f"命令执行超时: {cmd}")
233
+ return RunResult(
234
+ success=False,
235
+ return_code=-1,
236
+ stdout="",
237
+ stderr=f"命令执行超时({timeout}秒)",
238
+ duration=duration
239
+ )
240
+ except Exception as e:
241
+ duration = time.time() - start_time
242
+ self.logger.error(f"命令执行异常: {e}")
243
+ return RunResult(
244
+ success=False,
245
+ return_code=-1,
246
+ stdout="",
247
+ stderr=str(e),
248
+ duration=duration
249
+ )
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ C单测运行器
4
+ """
5
+
6
+ import os
7
+ from typing import List
8
+
9
+ from coverage_tool.converters import JUnitReportConverter
10
+ from .base import BaseRunner, RunResult, CoverageExtractor
11
+
12
+
13
+ class CRunner(BaseRunner):
14
+ """C单测运行器"""
15
+
16
+ def run_tests(self, target_dir: str, output_file: str) -> RunResult:
17
+ """运行C测试"""
18
+ # 尝试使用make运行测试
19
+ cmd = "make test"
20
+ result = self._run_command(cmd, target_dir)
21
+
22
+ # 尝试转换输出为JUnit XML
23
+ if result.stdout or result.stderr:
24
+ try:
25
+ xml_content = JUnitReportConverter.convert_generic_output(
26
+ result.stdout + result.stderr, 'cunit'
27
+ )
28
+ with open(output_file, 'w') as f:
29
+ f.write(xml_content)
30
+ result.junit_xml_path = output_file
31
+ except Exception as e:
32
+ self.logger.error(f"转换C测试输出失败: {e}")
33
+
34
+ result.coverage_rate = self.get_coverage(output_file)
35
+
36
+ return result
37
+
38
+ def get_coverage(self, output_file: str) -> float:
39
+ """获取C覆盖率(gcov)"""
40
+ # 查找gcov输出文件
41
+ temp_dir = os.path.dirname(output_file) if output_file else "."
42
+
43
+ coverage_files = [
44
+ os.path.join(temp_dir, "coverage.out"),
45
+ os.path.join(temp_dir, "coverage.txt"),
46
+ os.path.join(os.getcwd(), "coverage.out"),
47
+ ]
48
+
49
+ for cov_file in coverage_files:
50
+ if os.path.exists(cov_file):
51
+ return CoverageExtractor.extract_from_coverage_out(cov_file, 'c')
52
+
53
+ return 0.0
54
+
55
+ def compile(self, target_dir: str) -> tuple[bool, list[str]]:
56
+ """编译C项目"""
57
+ cmd = "make"
58
+ result = self._run_command(cmd, target_dir)
59
+
60
+ errors = []
61
+ if not result.success:
62
+ for line in result.stderr.split('\n'):
63
+ if 'error:' in line.lower() or 'undefined reference' in line.lower():
64
+ errors.append(line.strip())
65
+
66
+ return result.success, errors
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ C++单测运行器
4
+ """
5
+
6
+ import os
7
+ from typing import List
8
+
9
+ from coverage_tool.converters import JUnitReportConverter
10
+ from .base import BaseRunner, RunResult, CoverageExtractor
11
+
12
+
13
+ class CPPRunner(BaseRunner):
14
+ """C++单测运行器"""
15
+
16
+ def run_tests(self, target_dir: str, output_file: str) -> RunResult:
17
+ """运行C++测试"""
18
+ # 尝试使用make运行测试
19
+ cmd = "make test"
20
+ result = self._run_command(cmd, target_dir)
21
+
22
+ # 尝试转换输出为JUnit XML
23
+ if result.stdout or result.stderr:
24
+ try:
25
+ xml_content = JUnitReportConverter.convert_generic_output(
26
+ result.stdout + result.stderr, 'gtest'
27
+ )
28
+ with open(output_file, 'w') as f:
29
+ f.write(xml_content)
30
+ result.junit_xml_path = output_file
31
+ except Exception as e:
32
+ self.logger.error(f"转换C++测试输出失败: {e}")
33
+
34
+ result.coverage_rate = self.get_coverage(output_file)
35
+
36
+ return result
37
+
38
+ def get_coverage(self, output_file: str) -> float:
39
+ """获取C++覆盖率(gcov/lcov)"""
40
+ temp_dir = os.path.dirname(output_file) if output_file else "."
41
+
42
+ coverage_files = [
43
+ os.path.join(temp_dir, "coverage.out"),
44
+ os.path.join(temp_dir, "coverage.txt"),
45
+ os.path.join(os.getcwd(), "coverage.out"),
46
+ ]
47
+
48
+ for cov_file in coverage_files:
49
+ if os.path.exists(cov_file):
50
+ return CoverageExtractor.extract_from_coverage_out(cov_file, 'cpp')
51
+
52
+ return 0.0
53
+
54
+ def compile(self, target_dir: str) -> tuple[bool, list[str]]:
55
+ """编译C++项目"""
56
+ cmd = "make"
57
+ result = self._run_command(cmd, target_dir)
58
+
59
+ errors = []
60
+ if not result.success:
61
+ for line in result.stderr.split('\n'):
62
+ if 'error:' in line.lower() or 'undefined reference' in line.lower():
63
+ errors.append(line.strip())
64
+
65
+ return result.success, errors
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 运行器工厂
4
+ """
5
+
6
+ from typing import Optional
7
+
8
+ from coverage_tool.core import Language, LANGUAGE_CONFIGS
9
+ from coverage_tool.utils import Logger
10
+ from .base import BaseRunner
11
+ from .python_runner import PythonRunner
12
+ from .java_runner import JavaRunner
13
+ from .go_runner import GoRunner
14
+ from .c_runner import CRunner
15
+ from .cpp_runner import CPPRunner
16
+
17
+
18
+ class RunnerFactory:
19
+ """运行器工厂"""
20
+
21
+ _runners = {
22
+ Language.PYTHON: PythonRunner,
23
+ Language.JAVA: JavaRunner,
24
+ Language.GO: GoRunner,
25
+ Language.C: CRunner,
26
+ Language.CPP: CPPRunner,
27
+ }
28
+
29
+ @classmethod
30
+ def get_runner(cls, language: Language, logger: Optional[Logger] = None) -> BaseRunner:
31
+ """获取对应语言的运行器"""
32
+ config = LANGUAGE_CONFIGS[language]
33
+ runner_class = cls._runners.get(language)
34
+ if runner_class is None:
35
+ raise ValueError(f"不支持的语言: {language}")
36
+ return runner_class(config, logger)
37
+
38
+ @classmethod
39
+ def register_runner(cls, language: Language, runner_class: type):
40
+ """注册新的运行器"""
41
+ cls._runners[language] = runner_class