iflow-mcp_galaxyxieyu_api-auto-test 0.1.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.
atf/mcp/executor.py ADDED
@@ -0,0 +1,469 @@
1
+ """
2
+ Test Executor
3
+ 测试执行逻辑
4
+ """
5
+
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from atf.core.log_manager import log
18
+ from atf.mcp.models import (
19
+ AssertionResultModel,
20
+ TestResultModel,
21
+ )
22
+ from atf.mcp.utils import (
23
+ PYTEST_TIMEOUT,
24
+ MAX_ERROR_LENGTH,
25
+ MAX_HISTORY_SIZE,
26
+ get_roots,
27
+ resolve_yaml_path,
28
+ expected_py_path,
29
+ load_yaml_file,
30
+ parse_testcase_input,
31
+ parse_unittest_input,
32
+ detect_testcase_type,
33
+ truncate_text,
34
+ )
35
+
36
+
37
+ # 测试执行结果存储(内存中)
38
+ _test_execution_history: list[dict] = []
39
+ _history_lock = threading.Lock()
40
+
41
+ # api-auto-test 包的安装目录(用于获取依赖列表)
42
+ _ATF_ROOT = Path(__file__).parent.parent.parent.parent
43
+ _AUTO_INSTALL_DEPS = os.getenv("ATF_AUTO_INSTALL_DEPS", "1").lower() in {"1", "true", "yes"}
44
+
45
+
46
+ def _get_report_path(repo_root: Path) -> Path:
47
+ """获取 HTML 报告路径"""
48
+ report_dir = repo_root / "tests" / "reports"
49
+ report_dir.mkdir(parents=True, exist_ok=True)
50
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
51
+ return report_dir / f"report_{timestamp}.html"
52
+
53
+
54
+ def _check_python_has_dependencies(python_path: str, required_modules: list[str]) -> tuple[bool, list[str]]:
55
+ """检查 Python 环境是否包含必要的依赖模块
56
+
57
+ Args:
58
+ python_path: Python 解释器路径
59
+ required_modules: 需要检查的模块列表
60
+
61
+ Returns:
62
+ (是否全部存在, 缺失的模块列表)
63
+ """
64
+ missing = []
65
+ for module in required_modules:
66
+ result = subprocess.run(
67
+ [python_path, "-c", f"import {module}"],
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=10,
71
+ )
72
+ if result.returncode != 0:
73
+ missing.append(module)
74
+ return len(missing) == 0, missing
75
+
76
+
77
+ def _load_required_modules_from_requirements() -> list[str]:
78
+ """返回需要检测的模块列表(写死配置)
79
+
80
+ Returns:
81
+ list[str]: 需要检测的模块名列表
82
+ """
83
+ # 直接定义需要检测的模块(运行测试脚本需要的基础依赖)
84
+ # 注意:urllib3<2.0 因为 macOS 系统 Python 使用 LibreSSL,不兼容 v2 的 OpenSSL 要求
85
+ return ["loguru", "yaml", "requests", "urllib3", "pytest", "mysql", "Crypto"]
86
+
87
+
88
+ def _install_missing_dependencies(python_path: str, missing_modules: list[str]) -> bool:
89
+ """为指定的 Python 环境安装缺失的依赖
90
+
91
+ Args:
92
+ python_path: Python 解释器路径
93
+ missing_modules: 缺失的模块列表
94
+
95
+ Returns:
96
+ bool: 安装是否成功
97
+ """
98
+ if not missing_modules:
99
+ return True
100
+
101
+ log.info(f"正在为 {python_path} 安装缺失依赖: {missing_modules}")
102
+
103
+ # 包名到模块名的映射
104
+ module_to_package = {
105
+ "atf": "-e /Volumes/DATABASE/code/api-auto-test",
106
+ "loguru": "loguru",
107
+ "yaml": "pyyaml",
108
+ "requests": "requests",
109
+ "urllib3": "urllib3<2", # urllib3 v2 需要 OpenSSL 1.1.1+,macOS 使用 LibreSSL 不兼容
110
+ "pytest": "pytest",
111
+ "allure_python_commons": "allure-python-commons",
112
+ "mysql": "mysql-connector-python",
113
+ "crypto": "pycryptodome",
114
+ "pytest_html": "pytest-html",
115
+ "dingtalkchatbot": "DingtalkChatbot",
116
+ }
117
+
118
+ # 映射模块名到包名
119
+ packages = []
120
+ for module in missing_modules:
121
+ if module in module_to_package:
122
+ packages.append(module_to_package[module])
123
+ else:
124
+ packages.append(module)
125
+
126
+ if not packages:
127
+ return True
128
+
129
+ try:
130
+ # 优先升级 pip 以支持现代安装方式
131
+ log.info("升级 pip 以支持现代安装...")
132
+ result = subprocess.run(
133
+ [python_path, "-m", "pip", "install", "--upgrade", "pip"],
134
+ capture_output=True,
135
+ text=True,
136
+ timeout=120,
137
+ )
138
+ if result.returncode != 0:
139
+ log.warning(f"pip 升级失败,继续尝试安装: {result.stderr}")
140
+
141
+ # 安装依赖
142
+ result = subprocess.run(
143
+ [python_path, "-m", "pip", "install"] + packages,
144
+ capture_output=True,
145
+ text=True,
146
+ timeout=180,
147
+ )
148
+ if result.returncode == 0:
149
+ log.info(f"✅ 依赖安装成功: {packages}")
150
+ return True
151
+ else:
152
+ log.warning(f"❌ 依赖安装失败: {result.stderr}")
153
+ return False
154
+ except Exception as exc:
155
+ log.warning(f"安装依赖时出错: {exc}")
156
+ return False
157
+
158
+
159
+ def get_python_path(repo_root: Path) -> str:
160
+ """
161
+ 获取项目可用的 Python 解释器路径。
162
+
163
+ 优先级顺序:
164
+ 1. 检测项目 venv 依赖,缺失则自动安装到项目 venv
165
+ 2. 使用项目自身的 venv
166
+ 3. uv run (当项目包含 pyproject.toml 且 uv 可用时)
167
+ 4. 系统 Python 解释器作为回退
168
+
169
+ Args:
170
+ repo_root: 项目根目录路径
171
+
172
+ Returns:
173
+ str: Python 解释器路径
174
+ """
175
+ # 从 api-auto-test/requirements.txt 读取需要检测的依赖
176
+ required_modules = _load_required_modules_from_requirements()
177
+
178
+ # 查找项目 venv
179
+ venv_paths = [
180
+ repo_root / ".venv" / "bin" / "python",
181
+ repo_root / "venv" / "bin" / "python",
182
+ ]
183
+ conda_paths = [
184
+ repo_root / ".conda" / "bin" / "python",
185
+ repo_root / "conda" / "bin" / "python",
186
+ ]
187
+ project_pythons = [p for p in venv_paths + conda_paths if p.exists() and os.access(p, os.X_OK)]
188
+
189
+ # api-auto-test 的 venv(包含 atf 包)
190
+ api_auto_test_venv = _ATF_ROOT / ".venv" / "bin" / "python"
191
+
192
+ # 优先尝试项目自身的 venv
193
+ for venv_python in project_pythons:
194
+ has_deps, missing = _check_python_has_dependencies(str(venv_python), required_modules)
195
+ if has_deps:
196
+ log.info(f"使用项目 venv: {venv_python}")
197
+ return str(venv_python)
198
+ else:
199
+ log.warning(f"项目 venv 缺少依赖: {missing},正在自动安装...")
200
+ if _AUTO_INSTALL_DEPS:
201
+ # 自动安装缺失的依赖到项目 venv
202
+ if _install_missing_dependencies(str(venv_python), missing):
203
+ # 再次验证
204
+ has_deps, _ = _check_python_has_dependencies(str(venv_python), required_modules)
205
+ if has_deps:
206
+ log.info(f"✅ 依赖安装成功,使用项目 venv: {venv_python}")
207
+ return str(venv_python)
208
+ else:
209
+ log.warning("已禁用自动依赖安装(ATF_AUTO_INSTALL_DEPS=0),将继续使用当前 venv")
210
+
211
+ # 如果安装失败,继续使用项目 venv(至少其他项目依赖可用)
212
+ log.warning(f"⚠️ 部分依赖安装失败,继续使用项目 venv: {venv_python}")
213
+ return str(venv_python)
214
+
215
+ # 检查 api-auto-test 的 venv 是否可用(当项目没有 venv 时)
216
+ if api_auto_test_venv.exists() and os.access(api_auto_test_venv, os.X_OK):
217
+ has_deps, _ = _check_python_has_dependencies(str(api_auto_test_venv), required_modules)
218
+ if has_deps:
219
+ log.info(f"项目无 venv,使用 api-auto-test venv: {api_auto_test_venv}")
220
+ return str(api_auto_test_venv)
221
+
222
+ # 优先使用 uv run (需要 pyproject.toml 且 uv 可用时)
223
+ if (repo_root / "pyproject.toml").exists():
224
+ uv_path = shutil.which("uv")
225
+ if uv_path:
226
+ log.info(f"使用 uv 运行测试")
227
+ return "uv"
228
+ log.warning("pyproject.toml 存在但 uv 未安装")
229
+
230
+ # 回退到系统 Python
231
+ log.warning(f"未找到项目 Python 解释器,回退到系统 Python: {sys.executable}")
232
+ return sys.executable
233
+
234
+
235
+ def run_pytest(pytest_path: str, repo_root: Path, python_path: str | None = None) -> dict:
236
+ """执行 pytest 并返回结果
237
+
238
+ Args:
239
+ pytest_path: pytest 文件路径
240
+ repo_root: 项目根目录
241
+ python_path: 可选的 Python 解释器路径,如果不指定则自动检测
242
+ """
243
+ start_time = time.time()
244
+ result_data = {
245
+ "test_name": "",
246
+ "status": "error",
247
+ "duration": 0.0,
248
+ "assertions": [],
249
+ "error_message": None,
250
+ "traceback": None,
251
+ "report_path": None,
252
+ }
253
+
254
+ try:
255
+ # 如果指定了 Python 路径则使用它,否则自动检测
256
+ if python_path:
257
+ log.info(f"使用指定的 Python: {python_path}")
258
+ else:
259
+ python_path = get_python_path(repo_root)
260
+
261
+ # 检查项目 venv 是否包含 atf 模块
262
+ env = os.environ.copy()
263
+ if python_path != "uv":
264
+ has_atf, _ = _check_python_has_dependencies(python_path, ["atf"])
265
+ if not has_atf:
266
+ # 添加 api-auto-test 到 PYTHONPATH,让测试脚本能导入 atf 模块
267
+ env["PYTHONPATH"] = f"{_ATF_ROOT}:{env.get('PYTHONPATH', '')}"
268
+ log.info(f"项目 venv 缺少 atf 模块,通过 PYTHONPATH 添加 api-auto-test")
269
+
270
+ # 构建 pytest 命令(使用 pytest-html 生成报告,无需 Java)
271
+ report_path = _get_report_path(repo_root)
272
+ css_path = _ATF_ROOT / "atf" / "assets" / "report.css"
273
+
274
+ base_args = [pytest_path, "-v", "--tb=short", f"--html={report_path}", "--self-contained-html"]
275
+ if css_path.exists():
276
+ base_args.append(f"--css={css_path}")
277
+
278
+ if python_path == "uv":
279
+ cmd = ["uv", "run", "pytest"] + base_args
280
+ else:
281
+ cmd = [python_path, "-m", "pytest"] + base_args
282
+
283
+ log.info(f"执行测试命令: {' '.join(cmd)}")
284
+
285
+ process = subprocess.Popen(
286
+ cmd,
287
+ cwd=str(repo_root),
288
+ stdout=subprocess.PIPE,
289
+ stderr=subprocess.PIPE,
290
+ text=True,
291
+ env=env,
292
+ )
293
+ try:
294
+ stdout, stderr = process.communicate(timeout=PYTEST_TIMEOUT)
295
+ except subprocess.TimeoutExpired:
296
+ process.kill()
297
+ stdout, stderr = process.communicate()
298
+ result_data["error_message"] = "测试执行超时(超过5分钟)"
299
+ result_data["traceback"] = "进程被强制终止"
300
+ end_time = time.time()
301
+ result_data["duration"] = round(end_time - start_time, 2)
302
+ return result_data
303
+ finally:
304
+ # 确保进程已终止并清理资源
305
+ if process.returncode is None:
306
+ process.kill()
307
+ process.wait()
308
+ if process.stdout:
309
+ process.stdout.close()
310
+ if process.stderr:
311
+ process.stderr.close()
312
+
313
+ end_time = time.time()
314
+ duration = round(end_time - start_time, 2)
315
+
316
+ # 从路径提取测试名称
317
+ test_name = Path(pytest_path).stem.replace("test_", "")
318
+
319
+ result_data["test_name"] = test_name
320
+ result_data["duration"] = duration
321
+
322
+ # 解析测试结果
323
+ if process.returncode == 0:
324
+ result_data["status"] = "passed"
325
+ else:
326
+ result_data["status"] = "failed"
327
+ # 提取错误信息(使用常量截断)
328
+ result_data["error_message"] = truncate_text(stderr)
329
+ result_data["traceback"] = truncate_text(stdout)
330
+
331
+ # 尝试解析断言失败信息
332
+ if "FAILED" in stdout or "AssertionError" in stderr:
333
+ result_data["assertions"] = [
334
+ AssertionResultModel(
335
+ assertion_type="unknown",
336
+ passed=False,
337
+ message=f"测试失败,返回码: {process.returncode}"
338
+ ).model_dump()
339
+ ]
340
+
341
+ # 尝试从 stdout 提取统计信息
342
+ if "passed" in stdout.lower() or "failed" in stdout.lower():
343
+ # 简化处理:创建一个通用的断言结果
344
+ result_data["assertions"] = [
345
+ AssertionResultModel(
346
+ assertion_type="execution",
347
+ passed=process.returncode == 0,
348
+ message=stdout.strip()[-200:] if stdout else "执行完成"
349
+ ).model_dump()
350
+ ]
351
+
352
+ # 报告路径信息
353
+ if report_path.exists():
354
+ result_data["report_path"] = str(report_path)
355
+ log.info(f"HTML 报告已生成: {report_path}")
356
+
357
+ except Exception as exc:
358
+ result_data["error_message"] = str(exc)
359
+ log.error(f"执行 pytest 失败: {exc}")
360
+
361
+ return result_data
362
+
363
+
364
+ def execute_single_test(yaml_path: str, repo_root: Path, python_path: str | None = None) -> TestResultModel:
365
+ """执行单个测试用例并返回结果
366
+
367
+ Args:
368
+ yaml_path: YAML 文件路径
369
+ repo_root: 项目根目录
370
+ python_path: 可选的 Python 解释器路径
371
+ """
372
+ try:
373
+ # 必须传递 workspace 参数,确保路径解析正确
374
+ workspace = str(repo_root)
375
+ yaml_full_path, yaml_relative_path, _ = resolve_yaml_path(yaml_path, workspace)
376
+
377
+ log.info(f"[execute_single_test] yaml_path={yaml_path}, workspace={workspace}")
378
+ log.info(f"[execute_single_test] yaml_full_path={yaml_full_path}")
379
+
380
+ data = load_yaml_file(yaml_full_path)
381
+
382
+ # 使用统一的类型检测函数
383
+ testcase_type = detect_testcase_type(data)
384
+
385
+ # 解析测试用例
386
+ if testcase_type == "unittest":
387
+ testcase_model = parse_unittest_input(data)
388
+ else:
389
+ testcase_model = parse_testcase_input(data)
390
+ test_name = testcase_model.name
391
+
392
+ py_full_path, _ = expected_py_path(yaml_full_path, test_name, workspace)
393
+ log.info(f"[execute_single_test] py_full_path={py_full_path}")
394
+
395
+ if not py_full_path.exists():
396
+ return TestResultModel(
397
+ test_name=test_name,
398
+ status="error",
399
+ duration=0.0,
400
+ assertions=[],
401
+ error_message="pytest 文件不存在,请先生成",
402
+ )
403
+
404
+ # 执行测试(传入自定义 Python 路径)
405
+ result_data = run_pytest(str(py_full_path), repo_root, python_path)
406
+
407
+ return TestResultModel(
408
+ test_name=result_data["test_name"],
409
+ status=result_data["status"],
410
+ duration=result_data["duration"],
411
+ assertions=[
412
+ AssertionResultModel(**a) for a in result_data.get("assertions", [])
413
+ ],
414
+ error_message=result_data.get("error_message"),
415
+ traceback=result_data.get("traceback"),
416
+ report_path=result_data.get("report_path"),
417
+ )
418
+ except Exception as exc:
419
+ log.error(f"执行单个测试失败: {exc}")
420
+ return TestResultModel(
421
+ test_name=Path(yaml_path).stem,
422
+ status="error",
423
+ duration=0.0,
424
+ assertions=[],
425
+ error_message=str(exc),
426
+ )
427
+
428
+
429
+ def save_to_history(
430
+ run_id: str,
431
+ total: int,
432
+ passed: int,
433
+ failed: int,
434
+ skipped: int,
435
+ duration: float,
436
+ test_names: list[str],
437
+ ) -> None:
438
+ """保存执行结果到历史记录"""
439
+ global _test_execution_history
440
+ with _history_lock:
441
+ _test_execution_history.append({
442
+ "run_id": run_id,
443
+ "timestamp": datetime.now().isoformat(),
444
+ "total": total,
445
+ "passed": passed,
446
+ "failed": failed,
447
+ "skipped": skipped,
448
+ "duration": duration,
449
+ "test_names": test_names,
450
+ })
451
+ # 使用常量添加容量限制,防止内存溢出
452
+ if len(_test_execution_history) > MAX_HISTORY_SIZE:
453
+ _test_execution_history = _test_execution_history[-MAX_HISTORY_SIZE:]
454
+
455
+
456
+ def get_history(limit: int = 10) -> list[dict]:
457
+ """获取历史记录"""
458
+ global _test_execution_history
459
+ with _history_lock:
460
+ return _test_execution_history[-limit:] if limit > 0 else _test_execution_history
461
+
462
+
463
+ __all__ = [
464
+ "get_python_path",
465
+ "run_pytest",
466
+ "execute_single_test",
467
+ "save_to_history",
468
+ "get_history",
469
+ ]