mm-qa-mcp 0.2.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.
- minimax_qa_mcp/__init__.py +14 -0
- minimax_qa_mcp/conf/__init__.py +6 -0
- minimax_qa_mcp/conf/conf.ini +86 -0
- minimax_qa_mcp/server.py +184 -0
- minimax_qa_mcp/src/__init__.py +0 -0
- minimax_qa_mcp/src/demo_langchain/__init__.py +6 -0
- minimax_qa_mcp/src/demo_langchain/langchain_demo.py +1 -0
- minimax_qa_mcp/src/gateway_case/__init__.py +0 -0
- minimax_qa_mcp/src/gateway_case/get_case.py +608 -0
- minimax_qa_mcp/src/generator_case/__init__.py +6 -0
- minimax_qa_mcp/src/generator_case/generator_case.py +1187 -0
- minimax_qa_mcp/src/generator_case/generator_case_langchain.py +1078 -0
- minimax_qa_mcp/src/get_weaviate_info/__init__.py +6 -0
- minimax_qa_mcp/src/get_weaviate_info/get_weaviate_info.py +298 -0
- minimax_qa_mcp/src/grafana/__init__.py +0 -0
- minimax_qa_mcp/src/grafana/service.py +104 -0
- minimax_qa_mcp/src/query_segments/__init__.py +6 -0
- minimax_qa_mcp/src/query_segments/query_segments.py +2848 -0
- minimax_qa_mcp/src/xmind2markdown/__init__.py +6 -0
- minimax_qa_mcp/src/xmind2markdown/xmind_to_markdown.py +976 -0
- minimax_qa_mcp/utils/__init__.py +0 -0
- minimax_qa_mcp/utils/logger.py +38 -0
- minimax_qa_mcp/utils/utils.py +246 -0
- mm_qa_mcp-0.2.0.dist-info/METADATA +167 -0
- mm_qa_mcp-0.2.0.dist-info/RECORD +28 -0
- mm_qa_mcp-0.2.0.dist-info/WHEEL +5 -0
- mm_qa_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- mm_qa_mcp-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1187 @@
|
|
1
|
+
"""
|
2
|
+
coding:utf-8
|
3
|
+
@Software: PyCharm
|
4
|
+
@Time: 2025/4/8 17:02
|
5
|
+
@Author: xingyun
|
6
|
+
@Desc: case自动生成tools
|
7
|
+
"""
|
8
|
+
import os
|
9
|
+
import json
|
10
|
+
import requests
|
11
|
+
import subprocess
|
12
|
+
import sys
|
13
|
+
import re
|
14
|
+
import shutil
|
15
|
+
from pathlib import Path
|
16
|
+
|
17
|
+
import concurrent.futures
|
18
|
+
import threading
|
19
|
+
from typing import Type, Union, List
|
20
|
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
21
|
+
|
22
|
+
from datetime import datetime, timedelta, timezone
|
23
|
+
from langchain_core.runnables import Runnable
|
24
|
+
from minimax_qa_mcp.src.query_segments.query_segments import main as query_segments_main
|
25
|
+
from minimax_qa_mcp.src.gateway_case.get_case import CaseGrafanaService
|
26
|
+
from minimax_qa_mcp.utils.utils import Utils
|
27
|
+
from minimax_qa_mcp.utils.logger import logger
|
28
|
+
|
29
|
+
# 添加项目根目录到sys.path
|
30
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
31
|
+
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(current_dir)))
|
32
|
+
sys.path.append(root_dir)
|
33
|
+
|
34
|
+
# 添加线程锁用于日志
|
35
|
+
log_lock = threading.Lock()
|
36
|
+
|
37
|
+
|
38
|
+
# 添加重试装饰器函数
|
39
|
+
def retry_on_exception(
|
40
|
+
max_attempts: int = 3,
|
41
|
+
retry_exceptions: Union[Type[Exception], List[Type[Exception]]] = (Exception,),
|
42
|
+
min_wait: float = 1,
|
43
|
+
max_wait: float = 10
|
44
|
+
):
|
45
|
+
"""为函数添加重试能力的装饰器
|
46
|
+
|
47
|
+
Args:
|
48
|
+
max_attempts: 最大重试次数
|
49
|
+
retry_exceptions: 需要重试的异常类型
|
50
|
+
min_wait: 最小等待时间(秒)
|
51
|
+
max_wait: 最大等待时间(秒)
|
52
|
+
"""
|
53
|
+
return retry(
|
54
|
+
stop=stop_after_attempt(max_attempts),
|
55
|
+
wait=wait_exponential(multiplier=1, min=min_wait, max=max_wait),
|
56
|
+
retry=retry_if_exception_type(retry_exceptions),
|
57
|
+
reraise=True,
|
58
|
+
before_sleep=lambda retry_state: logger.warning(
|
59
|
+
f"重试操作 ({retry_state.attempt_number}/{max_attempts}): "
|
60
|
+
f"{retry_state.fn.__name__} 因为 {retry_state.outcome.exception()}"
|
61
|
+
)
|
62
|
+
)
|
63
|
+
|
64
|
+
|
65
|
+
class ModuleClient:
|
66
|
+
"""模型调用客户端"""
|
67
|
+
|
68
|
+
def __init__(self):
|
69
|
+
# 从配置文件读取API URL,如果不存在则使用默认值
|
70
|
+
try:
|
71
|
+
self.api_url = Utils.get_conf("generator_case_conf", "module_api_url")
|
72
|
+
logger.info(f"从配置读取模型API地址: {self.api_url}")
|
73
|
+
except KeyError:
|
74
|
+
# 默认API URL
|
75
|
+
self.api_url = "http://swing-babel-ali-prod.xaminim.com/swing/api/get_module_result"
|
76
|
+
logger.info(f"未找到API地址配置,使用默认地址: {self.api_url}")
|
77
|
+
|
78
|
+
# 增加请求超时设置
|
79
|
+
self.timeout = int(Utils.get_conf("generator_case_conf", "model_timeout"))
|
80
|
+
logger.info(f"模型请求超时设置: {self.timeout}秒")
|
81
|
+
|
82
|
+
@retry_on_exception(
|
83
|
+
max_attempts=3,
|
84
|
+
retry_exceptions=(requests.RequestException, json.JSONDecodeError, KeyError),
|
85
|
+
min_wait=2,
|
86
|
+
max_wait=15
|
87
|
+
)
|
88
|
+
def call_model(self, params):
|
89
|
+
"""调用模型API,添加了重试机制
|
90
|
+
|
91
|
+
Args:
|
92
|
+
params: 模型输入参数
|
93
|
+
|
94
|
+
Returns:
|
95
|
+
模型返回的结果
|
96
|
+
"""
|
97
|
+
# 使用更简单的字符串替换处理,减少转义层级
|
98
|
+
clean_params = params.replace('\\"', "'") # 替换嵌套双引号为单引号
|
99
|
+
clean_params = clean_params.replace("\n", " ").strip()
|
100
|
+
|
101
|
+
payload = {
|
102
|
+
"scene": "qa_agent",
|
103
|
+
"params": {
|
104
|
+
"user_content": clean_params
|
105
|
+
}
|
106
|
+
}
|
107
|
+
|
108
|
+
# 使用线程锁保护日志
|
109
|
+
with log_lock:
|
110
|
+
# logger.info(f"==== 发送请求到API,payload: {json.dumps(payload, ensure_ascii=False)}")
|
111
|
+
logger.info(f"==== 发送请求调用模型 ======")
|
112
|
+
|
113
|
+
# 添加timeout参数,增加请求超时控制
|
114
|
+
response = requests.post(
|
115
|
+
self.api_url,
|
116
|
+
json=payload,
|
117
|
+
headers={'Content-Type': 'application/json'},
|
118
|
+
verify=False,
|
119
|
+
timeout=self.timeout
|
120
|
+
)
|
121
|
+
|
122
|
+
with log_lock:
|
123
|
+
logger.info(f"API响应状态码: {response.status_code}")
|
124
|
+
# logger.info(f"API响应内容: {response.text}")
|
125
|
+
|
126
|
+
# 检查响应状态
|
127
|
+
if response.status_code != 200:
|
128
|
+
raise requests.RequestException(f"API请求失败,状态码: {response.status_code}")
|
129
|
+
|
130
|
+
if response.status_code == 200:
|
131
|
+
# 尝试解析JSON响应
|
132
|
+
try:
|
133
|
+
resp_json = response.json()
|
134
|
+
# logger.info(f"解析后的JSON: {json.dumps(resp_json, ensure_ascii=False)}")
|
135
|
+
|
136
|
+
if 'response' in resp_json:
|
137
|
+
# 解析二层JSON
|
138
|
+
try:
|
139
|
+
model_response = json.loads(resp_json['response'])
|
140
|
+
# logger.info(f"解析二层JSON: {json.dumps(model_response, ensure_ascii=False)}")
|
141
|
+
|
142
|
+
# 从content中提取文本
|
143
|
+
if 'content' in model_response and isinstance(model_response['content'], list):
|
144
|
+
text_content = ""
|
145
|
+
for item in model_response['content']:
|
146
|
+
if item.get('type') == 'text':
|
147
|
+
text_content += item.get('text', '')
|
148
|
+
# logger.info(f"提取的文本内容: {text_content}")
|
149
|
+
return text_content
|
150
|
+
return str(model_response)
|
151
|
+
except Exception as e:
|
152
|
+
logger.error(f"解析二层JSON失败: {e}")
|
153
|
+
# 直接返回原始响应,避免额外解码可能导致的编码问题
|
154
|
+
return resp_json['response']
|
155
|
+
return response.text
|
156
|
+
except Exception as e:
|
157
|
+
logger.error(f"解析JSON失败: {e}")
|
158
|
+
return response.text
|
159
|
+
|
160
|
+
return response.text
|
161
|
+
|
162
|
+
|
163
|
+
class CustomRunnable(Runnable):
|
164
|
+
"""自定义Runnable接口,用于langchain调用"""
|
165
|
+
|
166
|
+
def __init__(self):
|
167
|
+
self.client = ModuleClient()
|
168
|
+
|
169
|
+
def invoke(self, input, config=None):
|
170
|
+
"""调用模型API
|
171
|
+
|
172
|
+
Args:
|
173
|
+
input: 可以是字符串或字典
|
174
|
+
config: 配置参数
|
175
|
+
|
176
|
+
Returns:
|
177
|
+
生成的文本
|
178
|
+
"""
|
179
|
+
if isinstance(input, dict):
|
180
|
+
# 如果输入是字典,尝试构建提示词
|
181
|
+
prompt = input.get("text", "")
|
182
|
+
for key, value in input.items():
|
183
|
+
if key != "text" and isinstance(value, str):
|
184
|
+
prompt = prompt.replace(f"{{{key}}}", value)
|
185
|
+
elif isinstance(input, str):
|
186
|
+
prompt = input
|
187
|
+
else:
|
188
|
+
prompt = str(input)
|
189
|
+
|
190
|
+
# 打印模型输入
|
191
|
+
logger.info(f"调用模型API,输入:{prompt}")
|
192
|
+
|
193
|
+
# 调用模型
|
194
|
+
response = self.client.call_model(prompt)
|
195
|
+
|
196
|
+
# 打印模型输出
|
197
|
+
logger.info(f"调用模型API,输出:{response}")
|
198
|
+
|
199
|
+
# 直接返回解析后的响应
|
200
|
+
return response
|
201
|
+
|
202
|
+
|
203
|
+
class GeneratorCase:
|
204
|
+
def __init__(self, input_data, pwd):
|
205
|
+
"""初始化GeneratorCase类
|
206
|
+
|
207
|
+
Args:
|
208
|
+
input_data: JSON格式的输入数据,包含Business、API和Case信息
|
209
|
+
pwd: 用户当前的目录地址
|
210
|
+
"""
|
211
|
+
# 初始化基本属性
|
212
|
+
self.model = CustomRunnable()
|
213
|
+
self.pwd = pwd
|
214
|
+
self.workspace_dir = Path(os.getcwd())
|
215
|
+
|
216
|
+
# 设置访问令牌
|
217
|
+
try:
|
218
|
+
self.access_token = Utils.get_conf("generator_case_conf", "git_access_token")
|
219
|
+
logger.info("从配置读取 git access token")
|
220
|
+
except KeyError:
|
221
|
+
logger.warning("未找到 git access token 配置,将使用无认证方式")
|
222
|
+
self.access_token = None
|
223
|
+
|
224
|
+
# 如果输入是字符串,尝试解析为JSON对象
|
225
|
+
if isinstance(input_data, str):
|
226
|
+
try:
|
227
|
+
self.input_data = json.loads(input_data)
|
228
|
+
except json.JSONDecodeError as e:
|
229
|
+
logger.error(f"输入数据不是有效的JSON格式: {e}")
|
230
|
+
raise ValueError(f"输入数据不是有效的JSON格式: {str(e)}")
|
231
|
+
else:
|
232
|
+
self.input_data = input_data
|
233
|
+
|
234
|
+
# 解析API和Case信息
|
235
|
+
self._parse_input_data()
|
236
|
+
|
237
|
+
# 初始化其他依赖于业务类型的属性
|
238
|
+
self.result_dir = self.workspace_dir / "case_results"
|
239
|
+
self.result_dir.mkdir(exist_ok=True)
|
240
|
+
self.repo_dir = self.workspace_dir / "git_repos" / self.business
|
241
|
+
|
242
|
+
# 设置默认的时间范围为1天
|
243
|
+
now = datetime.now(timezone(timedelta(hours=8)))
|
244
|
+
self.to_time = now.isoformat()
|
245
|
+
self.from_time = (now - timedelta(hours=3)).isoformat()
|
246
|
+
|
247
|
+
# 根据业务名称确定场景
|
248
|
+
self.scene_mapping = {
|
249
|
+
"xingye": "xingye_http_prod",
|
250
|
+
"hailuo": "hailuo_video_cn_prod",
|
251
|
+
"kaiping": "talkie_prod"
|
252
|
+
}
|
253
|
+
self.scene = self.scene_mapping.get(self.business, "xingye_prod")
|
254
|
+
|
255
|
+
# 设置默认值
|
256
|
+
self.is_need_module = True
|
257
|
+
self.case_type = "link"
|
258
|
+
self.is_need_save = True
|
259
|
+
|
260
|
+
# 增加并发配置
|
261
|
+
try:
|
262
|
+
self.max_workers = int(Utils.get_conf("generator_case_conf", "max_workers"))
|
263
|
+
logger.info(f"设置最大并发数: {self.max_workers}")
|
264
|
+
except (ValueError, KeyError):
|
265
|
+
self.max_workers = 4
|
266
|
+
logger.info(f"使用默认最大并发数: {self.max_workers}")
|
267
|
+
|
268
|
+
logger.info(
|
269
|
+
f"初始化GeneratorCase: api_name={self.api_name}, business={self.business}, case_type={self.case_type}")
|
270
|
+
|
271
|
+
def _parse_input_data(self):
|
272
|
+
"""解析输入的JSON数据,提取API和Case信息"""
|
273
|
+
# 检查输入数据结构是否符合预期
|
274
|
+
required_fields = ['Business', 'API', 'Case']
|
275
|
+
for field in required_fields:
|
276
|
+
if field not in self.input_data:
|
277
|
+
logger.error(f"输入数据格式错误,必须包含'{field}'字段")
|
278
|
+
raise ValueError(f"输入数据格式错误,必须包含'{field}'字段")
|
279
|
+
|
280
|
+
# 从JSON中提取业务类型
|
281
|
+
self.business = self.input_data.get('Business')
|
282
|
+
if not self.business:
|
283
|
+
logger.error("Business字段值为空")
|
284
|
+
raise ValueError("Business字段值为空,必须指定业务类型")
|
285
|
+
logger.info(f"从JSON中提取业务类型: {self.business}")
|
286
|
+
|
287
|
+
# 提取API信息
|
288
|
+
api_info = self.input_data['API']
|
289
|
+
if not api_info:
|
290
|
+
logger.error("API信息为空")
|
291
|
+
raise ValueError("API信息为空")
|
292
|
+
|
293
|
+
# 设置所有API列表
|
294
|
+
self.all_apis = list(api_info.keys())
|
295
|
+
if not self.all_apis:
|
296
|
+
logger.error("API列表为空")
|
297
|
+
raise ValueError("API列表为空,至少需要提供一个API")
|
298
|
+
|
299
|
+
# 所有API都是平等的,但仍需要一个api_name用于日志和文件名
|
300
|
+
self.api_name = self.all_apis[0] if self.all_apis else ""
|
301
|
+
self.api_descriptions = api_info
|
302
|
+
|
303
|
+
# 解析Case信息
|
304
|
+
case_info = self.input_data['Case']
|
305
|
+
if not case_info or not isinstance(case_info, list):
|
306
|
+
logger.error("Case信息为空或格式错误")
|
307
|
+
raise ValueError("Case信息为空或格式错误")
|
308
|
+
|
309
|
+
# 提取前置操作和测试场景
|
310
|
+
self.pre_apis = []
|
311
|
+
self.cases = []
|
312
|
+
self.detailed_pre_info = {}
|
313
|
+
self.detailed_case_info = {}
|
314
|
+
self.case_items = [] # 保存原始的case项
|
315
|
+
|
316
|
+
for case_item in case_info:
|
317
|
+
self.case_items.append(case_item) # 保存完整的case item
|
318
|
+
for case_name, case_data in case_item.items():
|
319
|
+
# 添加case名称
|
320
|
+
self.cases.append(case_name)
|
321
|
+
|
322
|
+
# 提取前置操作
|
323
|
+
if 'PRE' in case_data and isinstance(case_data['PRE'], dict):
|
324
|
+
for pre_name, pre_desc in case_data['PRE'].items():
|
325
|
+
if pre_name not in self.pre_apis:
|
326
|
+
self.pre_apis.append(pre_name)
|
327
|
+
# 保存详细前置信息
|
328
|
+
if pre_name not in self.detailed_pre_info:
|
329
|
+
self.detailed_pre_info[pre_name] = []
|
330
|
+
self.detailed_pre_info[pre_name].append(pre_desc)
|
331
|
+
|
332
|
+
# 提取测试步骤
|
333
|
+
if 'TEST' in case_data:
|
334
|
+
# 保存详细测试信息
|
335
|
+
self.detailed_case_info[case_name] = {
|
336
|
+
'steps': [],
|
337
|
+
'pre': list(case_data.get('PRE', {}).keys()),
|
338
|
+
'test': [case_data['TEST']]
|
339
|
+
}
|
340
|
+
|
341
|
+
# 设置API详细信息的格式
|
342
|
+
self.detailed_api_info = {}
|
343
|
+
for api_path, api_desc in self.api_descriptions.items():
|
344
|
+
# 提取API名称(可能作为分组)
|
345
|
+
api_name = api_path.split('/')[-1] if '/' in api_path else api_path
|
346
|
+
self.detailed_api_info[api_name] = [{
|
347
|
+
'path': api_path,
|
348
|
+
'description': api_desc
|
349
|
+
}]
|
350
|
+
|
351
|
+
logger.info(f"解析输入数据完成: API数量={len(self.all_apis)}, 前置操作={self.pre_apis}, Cases={self.cases}")
|
352
|
+
|
353
|
+
def _get_api_data_from_grafana(self, api_name):
|
354
|
+
"""从Grafana获取API的请求和响应数据
|
355
|
+
|
356
|
+
Args:
|
357
|
+
api_name: API名称
|
358
|
+
|
359
|
+
Returns:
|
360
|
+
dict: API的请求和响应数据
|
361
|
+
"""
|
362
|
+
logger.info(
|
363
|
+
f"从Grafana获取API数据: {api_name}, scene={self.scene}, 时间范围: {self.from_time} 至 {self.to_time}")
|
364
|
+
grafana_client = CaseGrafanaService(
|
365
|
+
scene=self.scene,
|
366
|
+
from_time=self.from_time,
|
367
|
+
to_time=self.to_time
|
368
|
+
)
|
369
|
+
api_data = grafana_client.process_api_path_with_service(api_name)
|
370
|
+
logger.info(f"== 从Grafana获取API数据完成: {api_data}")
|
371
|
+
|
372
|
+
# 不再在这里进行模型总结,而是由 _summarize_api_data_concurrently 并发处理
|
373
|
+
return {"raw_data": api_data}
|
374
|
+
|
375
|
+
def _get_code_repo(self):
|
376
|
+
"""获取业务对应的代码仓库,并获取link case和pre的demo
|
377
|
+
|
378
|
+
Returns:
|
379
|
+
dict: 代码仓库信息和demo case
|
380
|
+
"""
|
381
|
+
# 从配置文件获取git仓库地址
|
382
|
+
git_url = Utils.get_conf("generator_case_conf", f"{self.business}_git_url")
|
383
|
+
logger.info(f"获取代码仓库: {self.business}, git_url={git_url}")
|
384
|
+
|
385
|
+
# 从配置文件获取分支信息
|
386
|
+
branch = "main" # 默认使用main分支
|
387
|
+
try:
|
388
|
+
branch = Utils.get_conf("generator_case_conf", f"{self.business}_branch")
|
389
|
+
logger.info(f"获取代码分支: {branch}")
|
390
|
+
except KeyError:
|
391
|
+
logger.info(f"未找到分支配置,将使用默认分支: {branch}")
|
392
|
+
|
393
|
+
# 从配置文件获取demo case路径和pre demo路径
|
394
|
+
demo_case_path_key = f"{self.business}_link_case_demo_path"
|
395
|
+
pre_demo_path_key = f"{self.business}_pre_demo_case_path"
|
396
|
+
|
397
|
+
try:
|
398
|
+
demo_case_path = Utils.get_conf("generator_case_conf", demo_case_path_key)
|
399
|
+
logger.info(f"获取demo case路径: {demo_case_path}")
|
400
|
+
except KeyError:
|
401
|
+
logger.error(f"未找到demo case路径配置: {demo_case_path_key}")
|
402
|
+
raise ValueError(f"未指定{self.business}业务的demo case路径,请在conf.ini中配置{demo_case_path_key}")
|
403
|
+
|
404
|
+
try:
|
405
|
+
pre_demo_path = Utils.get_conf("generator_case_conf", pre_demo_path_key)
|
406
|
+
logger.info(f"获取pre demo路径: {pre_demo_path}")
|
407
|
+
except KeyError:
|
408
|
+
logger.warning(f"未找到pre demo路径配置: {pre_demo_path_key},将使用空内容")
|
409
|
+
pre_demo_path = None
|
410
|
+
|
411
|
+
# 删除并重新创建存放代码仓库的目录
|
412
|
+
if self.repo_dir.exists():
|
413
|
+
logger.info(f"删除已存在的仓库目录: {self.repo_dir}")
|
414
|
+
shutil.rmtree(str(self.repo_dir))
|
415
|
+
|
416
|
+
# 创建存放代码仓库的目录
|
417
|
+
self.repo_dir.parent.mkdir(exist_ok=True)
|
418
|
+
self.repo_dir.mkdir(exist_ok=True)
|
419
|
+
logger.info(f"创建新的仓库目录: {self.repo_dir}")
|
420
|
+
|
421
|
+
# 克隆或更新代码仓库
|
422
|
+
demo_cases = {}
|
423
|
+
pre_demos = {}
|
424
|
+
if not git_url:
|
425
|
+
error_msg = f"未找到 {self.business} 的git仓库地址配置"
|
426
|
+
logger.error(error_msg)
|
427
|
+
raise ValueError(error_msg)
|
428
|
+
|
429
|
+
try:
|
430
|
+
# 修改git URL格式,如果有访问令牌则使用HTTPS方式
|
431
|
+
clone_url = git_url
|
432
|
+
if self.access_token:
|
433
|
+
if git_url.startswith("git@"):
|
434
|
+
# 将SSH格式(git@gitlab.xaminim.com:qa/repo.git)转换为
|
435
|
+
# HTTPS格式(https://oauth2:token@gitlab.xaminim.com/qa/repo.git)
|
436
|
+
domain = git_url.split('@')[1].split(':')[0]
|
437
|
+
repo_path = git_url.split(':')[1]
|
438
|
+
clone_url = f"https://oauth2:{self.access_token}@{domain}/{repo_path}"
|
439
|
+
logger.info(f"已将SSH格式URL转换为HTTPS格式并添加access token")
|
440
|
+
elif git_url.startswith("http://") or git_url.startswith("https://"):
|
441
|
+
# 处理HTTPS格式URL: https://gitlab.xaminim.com/qa/repo.git
|
442
|
+
# 转换为: https://oauth2:token@gitlab.xaminim.com/qa/repo.git
|
443
|
+
protocol = git_url.split('://')[0]
|
444
|
+
rest_url = git_url.split('://')[1]
|
445
|
+
|
446
|
+
# 检查URL中是否已经包含认证信息
|
447
|
+
if '@' not in rest_url:
|
448
|
+
clone_url = f"{protocol}://oauth2:{self.access_token}@{rest_url}"
|
449
|
+
logger.info(f"已向HTTPS格式URL添加access token")
|
450
|
+
else:
|
451
|
+
logger.info(f"URL已包含认证信息,不再添加access token")
|
452
|
+
else:
|
453
|
+
logger.warning(f"无法识别的git URL格式: {git_url},将使用原始URL")
|
454
|
+
else:
|
455
|
+
logger.warning(f"未提供access token,将使用原始git URL进行克隆")
|
456
|
+
|
457
|
+
# 克隆仓库
|
458
|
+
logger.info(f"克隆代码仓库: {clone_url} -> {self.repo_dir}")
|
459
|
+
subprocess.run(
|
460
|
+
["git", "clone", clone_url, str(self.repo_dir)],
|
461
|
+
check=True,
|
462
|
+
stdout=subprocess.PIPE,
|
463
|
+
stderr=subprocess.PIPE
|
464
|
+
)
|
465
|
+
|
466
|
+
# 如果不是默认分支,需要切换分支
|
467
|
+
if branch != "main" and branch != "master":
|
468
|
+
logger.info(f"切换到分支: {branch}")
|
469
|
+
subprocess.run(
|
470
|
+
["git", "checkout", branch],
|
471
|
+
cwd=str(self.repo_dir),
|
472
|
+
check=True,
|
473
|
+
stdout=subprocess.PIPE,
|
474
|
+
stderr=subprocess.PIPE
|
475
|
+
)
|
476
|
+
|
477
|
+
# 构建demo case的完整路径(相对于仓库根目录)
|
478
|
+
full_demo_path = self.repo_dir / demo_case_path
|
479
|
+
logger.info(f"查找指定的demo case: {full_demo_path}")
|
480
|
+
|
481
|
+
# 读取指定的demo case文件
|
482
|
+
if os.path.isfile(full_demo_path):
|
483
|
+
with open(full_demo_path, "r", encoding="utf-8") as f:
|
484
|
+
content = f.read()
|
485
|
+
demo_cases[os.path.basename(full_demo_path)] = content
|
486
|
+
logger.info(f"找到demo case文件: {full_demo_path}")
|
487
|
+
else:
|
488
|
+
logger.warning(f"指定的demo case路径不存在或不是文件: {full_demo_path}")
|
489
|
+
# 直接抛出异常,不再尝试创建默认demo文件
|
490
|
+
error_msg = f"指定的demo case路径 '{demo_case_path}' 不存在或不是文件,请检查配置"
|
491
|
+
logger.error(error_msg)
|
492
|
+
raise FileNotFoundError(error_msg)
|
493
|
+
|
494
|
+
# 如果有pre demo path,也读取它
|
495
|
+
if pre_demo_path:
|
496
|
+
full_pre_demo_path = self.repo_dir / pre_demo_path
|
497
|
+
logger.info(f"查找指定的pre demo: {full_pre_demo_path}")
|
498
|
+
|
499
|
+
if os.path.isfile(full_pre_demo_path):
|
500
|
+
with open(full_pre_demo_path, "r", encoding="utf-8") as f:
|
501
|
+
content = f.read()
|
502
|
+
pre_demos[os.path.basename(full_pre_demo_path)] = content
|
503
|
+
logger.info(f"找到pre demo文件: {full_pre_demo_path}")
|
504
|
+
else:
|
505
|
+
logger.warning(f"指定的pre demo路径不存在或不是文件: {full_pre_demo_path}")
|
506
|
+
|
507
|
+
# 获取完demo后删除git仓库
|
508
|
+
logger.info(f"已读取必要的demo文件,删除git仓库: {self.repo_dir}")
|
509
|
+
shutil.rmtree(str(self.repo_dir))
|
510
|
+
|
511
|
+
# 检查父目录是否为空,如果为空也删除
|
512
|
+
parent_dir = self.repo_dir.parent
|
513
|
+
if parent_dir.exists() and not any(parent_dir.iterdir()):
|
514
|
+
logger.info(f"父目录为空,一并删除: {parent_dir}")
|
515
|
+
shutil.rmtree(str(parent_dir))
|
516
|
+
|
517
|
+
except Exception as e:
|
518
|
+
logger.error(f"获取代码仓库失败: {e}")
|
519
|
+
# 删除仓库目录
|
520
|
+
if self.repo_dir.exists():
|
521
|
+
logger.info(f"由于错误,删除仓库目录: {self.repo_dir}")
|
522
|
+
shutil.rmtree(str(self.repo_dir))
|
523
|
+
|
524
|
+
# 检查父目录是否为空,如果为空也删除
|
525
|
+
parent_dir = self.repo_dir.parent
|
526
|
+
if parent_dir.exists() and not any(parent_dir.iterdir()):
|
527
|
+
logger.info(f"父目录为空,一并删除: {parent_dir}")
|
528
|
+
shutil.rmtree(str(parent_dir))
|
529
|
+
# 直接抛出异常,不再尝试创建本地备用文件
|
530
|
+
error_msg = f"获取代码仓库失败: {str(e)},请检查git配置和网络连接"
|
531
|
+
logger.error(error_msg)
|
532
|
+
raise RuntimeError(error_msg)
|
533
|
+
|
534
|
+
return {
|
535
|
+
"repo_info": f"{self.business} repository at {git_url}, branch: {branch}",
|
536
|
+
"demo_cases": demo_cases,
|
537
|
+
"pre_demos": pre_demos
|
538
|
+
}
|
539
|
+
|
540
|
+
def _get_api_code_segments(self, api_name=None):
|
541
|
+
"""调用query_code_segments,获取API相关的出入参数以及业务逻辑
|
542
|
+
Args:
|
543
|
+
api_name: api名称
|
544
|
+
|
545
|
+
Returns:
|
546
|
+
dict: API相关的代码段信息
|
547
|
+
"""
|
548
|
+
if api_name is None:
|
549
|
+
api_name = self.api_name
|
550
|
+
|
551
|
+
# 确保API名称格式一致
|
552
|
+
if not api_name.startswith('/'):
|
553
|
+
api_name = f"/{api_name}"
|
554
|
+
|
555
|
+
logger.info(f"获取API代码段: {api_name}")
|
556
|
+
|
557
|
+
# 直接调用main函数,参照server.py中的调用方式
|
558
|
+
code_segments = query_segments_main(
|
559
|
+
input_content=api_name,
|
560
|
+
input_type="API",
|
561
|
+
limit=10,
|
562
|
+
exact=False,
|
563
|
+
depth=1,
|
564
|
+
direction="both",
|
565
|
+
advanced=False,
|
566
|
+
output=None
|
567
|
+
)
|
568
|
+
|
569
|
+
# 不再在这里进行模型总结,而是由_summarize_api_data_concurrently并发处理
|
570
|
+
return {"raw_segments": code_segments}
|
571
|
+
|
572
|
+
def _get_api_data_concurrently(self):
|
573
|
+
"""并发获取所有API的数据
|
574
|
+
|
575
|
+
Returns:
|
576
|
+
dict: 所有API的数据,包含Grafana信息和代码段
|
577
|
+
"""
|
578
|
+
logger.info(f"开始并发获取 {len(self.all_apis)} 个API的数据")
|
579
|
+
api_data = {}
|
580
|
+
|
581
|
+
def process_single_api(api):
|
582
|
+
"""处理单个API的数据获取
|
583
|
+
|
584
|
+
Args:
|
585
|
+
api: API名称
|
586
|
+
|
587
|
+
Returns:
|
588
|
+
tuple: (API名称, API数据)
|
589
|
+
"""
|
590
|
+
with log_lock:
|
591
|
+
logger.info(f"开始获取API数据: {api}")
|
592
|
+
|
593
|
+
api_result = {}
|
594
|
+
try:
|
595
|
+
# 获取Grafana信息
|
596
|
+
api_result["grafana_info"] = self._get_api_data_from_grafana(api)
|
597
|
+
# 获取代码段信息
|
598
|
+
api_result["code_segments"] = self._get_api_code_segments(api)
|
599
|
+
|
600
|
+
with log_lock:
|
601
|
+
logger.info(f"成功获取API数据: {api}")
|
602
|
+
|
603
|
+
return api, api_result
|
604
|
+
except Exception as e:
|
605
|
+
with log_lock:
|
606
|
+
logger.error(f"获取API {api} 数据失败: {e}")
|
607
|
+
return api, {"error": str(e)}
|
608
|
+
|
609
|
+
# 使用线程池并发处理
|
610
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
611
|
+
# 提交所有API数据获取任务
|
612
|
+
future_to_api = {executor.submit(process_single_api, api): api for api in self.all_apis}
|
613
|
+
|
614
|
+
# 处理完成的任务
|
615
|
+
for future in concurrent.futures.as_completed(future_to_api):
|
616
|
+
api = future_to_api[future]
|
617
|
+
try:
|
618
|
+
api_name, api_result = future.result()
|
619
|
+
api_data[api_name] = api_result
|
620
|
+
except Exception as e:
|
621
|
+
with log_lock:
|
622
|
+
logger.error(f"处理API {api} 结果时出错: {e}")
|
623
|
+
api_data[api] = {"error": f"处理结果时出错: {str(e)}"}
|
624
|
+
|
625
|
+
logger.info(f"并发获取所有API数据完成,共 {len(api_data)} 个API")
|
626
|
+
return api_data
|
627
|
+
|
628
|
+
def _summarize_api_data_concurrently(self, api_data):
|
629
|
+
"""并发使用模型总结API数据
|
630
|
+
|
631
|
+
Args:
|
632
|
+
api_data: 所有API的原始数据
|
633
|
+
|
634
|
+
Returns:
|
635
|
+
dict: 添加了模型总结的API数据
|
636
|
+
"""
|
637
|
+
if not self.is_need_module:
|
638
|
+
return api_data
|
639
|
+
|
640
|
+
logger.info(f"开始并发总结 {len(api_data)} 个API的数据")
|
641
|
+
|
642
|
+
def summarize_single_api(api_name, data):
|
643
|
+
"""使用模型总结单个API的数据
|
644
|
+
|
645
|
+
Args:
|
646
|
+
api_name: API名称
|
647
|
+
data: API数据
|
648
|
+
|
649
|
+
Returns:
|
650
|
+
tuple: (API名称, 总结结果)
|
651
|
+
"""
|
652
|
+
with log_lock:
|
653
|
+
logger.info(f"开始总结API数据: {api_name}")
|
654
|
+
|
655
|
+
try:
|
656
|
+
if "grafana_info" in data and "raw_data" in data["grafana_info"]:
|
657
|
+
raw_data = data["grafana_info"]["raw_data"]
|
658
|
+
|
659
|
+
# 使用模型总结API数据
|
660
|
+
summary_prompt = """请总结分析以下API的请求和响应数据,提取关键信息:
|
661
|
+
|
662
|
+
API数据:
|
663
|
+
{api_data}
|
664
|
+
|
665
|
+
请提供:
|
666
|
+
1. 请求参数的结构和关键字段
|
667
|
+
2. 响应数据的结构和关键字段
|
668
|
+
3. 可能的业务逻辑和数据流向
|
669
|
+
"""
|
670
|
+
|
671
|
+
# 使用简单的替换方式
|
672
|
+
summary = self.model.invoke(
|
673
|
+
summary_prompt.replace("{api_data}", json.dumps(raw_data, ensure_ascii=False))
|
674
|
+
)
|
675
|
+
|
676
|
+
# 添加总结到数据中
|
677
|
+
data["grafana_info"]["summary"] = summary
|
678
|
+
|
679
|
+
if "code_segments" in data and "raw_segments" in data["code_segments"]:
|
680
|
+
raw_segments = data["code_segments"]["raw_segments"]
|
681
|
+
|
682
|
+
# 使用模型总结代码段
|
683
|
+
code_prompt = """请分析以下API相关的代码段,提取关键信息:
|
684
|
+
|
685
|
+
代码段:
|
686
|
+
{code_segments}
|
687
|
+
|
688
|
+
请提供:
|
689
|
+
1. API的输入参数定义及其数据类型
|
690
|
+
2. API的输出参数定义及其数据类型
|
691
|
+
3. API的主要业务逻辑
|
692
|
+
4. 关键的依赖和调用关系
|
693
|
+
"""
|
694
|
+
|
695
|
+
# 使用简单的替换方式
|
696
|
+
summary = self.model.invoke(
|
697
|
+
code_prompt.replace("{code_segments}", json.dumps(raw_segments, ensure_ascii=False))
|
698
|
+
)
|
699
|
+
|
700
|
+
# 添加总结到数据中
|
701
|
+
data["code_segments"]["summary"] = summary
|
702
|
+
|
703
|
+
with log_lock:
|
704
|
+
logger.info(f"完成API数据总结: {api_name}")
|
705
|
+
|
706
|
+
return api_name, data
|
707
|
+
except Exception as e:
|
708
|
+
with log_lock:
|
709
|
+
logger.error(f"总结API {api_name} 数据失败: {e}")
|
710
|
+
return api_name, data # 返回原始数据,确保流程不中断
|
711
|
+
|
712
|
+
# 使用线程池并发处理
|
713
|
+
summarized_data = {}
|
714
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
715
|
+
# 提交所有API总结任务
|
716
|
+
future_to_api = {
|
717
|
+
executor.submit(summarize_single_api, api_name, data): api_name
|
718
|
+
for api_name, data in api_data.items()
|
719
|
+
}
|
720
|
+
|
721
|
+
# 处理完成的任务
|
722
|
+
for future in concurrent.futures.as_completed(future_to_api):
|
723
|
+
api_name = future_to_api[future]
|
724
|
+
try:
|
725
|
+
_, summarized = future.result()
|
726
|
+
summarized_data[api_name] = summarized
|
727
|
+
except Exception as e:
|
728
|
+
with log_lock:
|
729
|
+
logger.error(f"处理API {api_name} 总结结果时出错: {e}")
|
730
|
+
summarized_data[api_name] = api_data[api_name] # 保留原始数据
|
731
|
+
|
732
|
+
logger.info(f"并发总结所有API数据完成")
|
733
|
+
return summarized_data
|
734
|
+
|
735
|
+
def _generate_case_with_model(self, api_info, api_data, repo_info, case_item, case_index):
|
736
|
+
"""为单个case生成测试用例
|
737
|
+
|
738
|
+
Args:
|
739
|
+
api_info: API信息,包含所有API的基本信息
|
740
|
+
api_data: 所有API的数据,包含Grafana信息和代码段信息
|
741
|
+
repo_info: 代码仓库信息,包含demo cases和pre demos
|
742
|
+
case_item: 当前case的信息
|
743
|
+
case_index: case的索引
|
744
|
+
|
745
|
+
Returns:
|
746
|
+
dict: 包含case和pre代码的字典
|
747
|
+
"""
|
748
|
+
case_name = list(case_item.keys())[0]
|
749
|
+
case_data = case_item[case_name]
|
750
|
+
|
751
|
+
logger.info(f"为case {case_name} 生成测试用例, type={self.case_type}")
|
752
|
+
|
753
|
+
# 获取demo案例作为参考
|
754
|
+
demo_cases = repo_info.get("demo_cases", {})
|
755
|
+
pre_demos = repo_info.get("pre_demos", {})
|
756
|
+
|
757
|
+
demo_case_content = ""
|
758
|
+
pre_demo_content = ""
|
759
|
+
|
760
|
+
if demo_cases:
|
761
|
+
# 使用第一个demo作为参考
|
762
|
+
demo_case_content = list(demo_cases.values())[0]
|
763
|
+
logger.info(f"找到demo case作为参考,长度: {len(demo_case_content)}")
|
764
|
+
else:
|
765
|
+
logger.warning("未找到demo case作为参考,将生成标准格式的测试用例")
|
766
|
+
|
767
|
+
if pre_demos:
|
768
|
+
# 使用第一个pre demo作为参考
|
769
|
+
pre_demo_content = list(pre_demos.values())[0]
|
770
|
+
logger.info(f"找到pre demo作为参考,长度: {len(pre_demo_content)}")
|
771
|
+
else:
|
772
|
+
logger.warning("未找到pre demo作为参考,将使用标准格式生成前置操作")
|
773
|
+
|
774
|
+
# 提取当前case的前置操作信息
|
775
|
+
pre_apis = list(case_data.get('PRE', {}).keys())
|
776
|
+
pre_details = {}
|
777
|
+
for pre_name, pre_desc in case_data.get('PRE', {}).items():
|
778
|
+
pre_details[pre_name] = pre_desc
|
779
|
+
|
780
|
+
# 提取当前case的测试步骤
|
781
|
+
test_steps = case_data.get('TEST', '')
|
782
|
+
|
783
|
+
# 定义示例代码,避免在f-string中嵌套三重反引号
|
784
|
+
expect_example = """'expect': {
|
785
|
+
'base_resp': {
|
786
|
+
'status_code': 0,
|
787
|
+
'status_msg': 'success'
|
788
|
+
},
|
789
|
+
'user_card_list': {
|
790
|
+
'contain': {'card_choice_id': card_id} # 验证card_id存在于返回的卡牌列表中
|
791
|
+
}
|
792
|
+
}"""
|
793
|
+
|
794
|
+
# 根据JSON提取的信息构建场景化的模板
|
795
|
+
template = f"""你是一个资深的python代码工程师,请针对以下单个测试场景,生成两个独立的Python文件:一个是前置操作文件,一个是测试用例文件。
|
796
|
+
|
797
|
+
业务: {self.business}
|
798
|
+
所有API: {', '.join(api_info['all_apis'])}
|
799
|
+
|
800
|
+
当前需要实现的测试场景:
|
801
|
+
|
802
|
+
场景名称: {case_name}
|
803
|
+
前置操作: {json.dumps(pre_apis, ensure_ascii=False)}
|
804
|
+
前置操作详情: {json.dumps(pre_details, ensure_ascii=False)}
|
805
|
+
测试步骤: {test_steps}
|
806
|
+
|
807
|
+
API基本描述: {json.dumps(self.api_descriptions, ensure_ascii=False)}
|
808
|
+
所有API数据: {json.dumps(api_data, ensure_ascii=False)}
|
809
|
+
代码仓库信息: {json.dumps(repo_info, ensure_ascii=False)}
|
810
|
+
|
811
|
+
## 参考的demo case格式:
|
812
|
+
```python
|
813
|
+
{demo_case_content}
|
814
|
+
```
|
815
|
+
|
816
|
+
## 参考的pre demo格式:
|
817
|
+
```python
|
818
|
+
{pre_demo_content}
|
819
|
+
```
|
820
|
+
|
821
|
+
请生成以下两个文件:
|
822
|
+
|
823
|
+
1. 前置操作文件 (pre_file):
|
824
|
+
- 文件应该包含所有必要的前置操作函数实现,如daily_free(), vip_free()等
|
825
|
+
- 格式应参考pre demo
|
826
|
+
- 作为独立文件能被测试用例导入和使用
|
827
|
+
|
828
|
+
2. 测试用例文件 (case_file):
|
829
|
+
- 针对具体的测试场景"{case_name}"
|
830
|
+
- 实现该场景的完整测试流程
|
831
|
+
- 导入并使用前置操作文件中的函数
|
832
|
+
- 所有的初始化的参数 都应该放在 setup_class 里面
|
833
|
+
- 格式应参考demo case
|
834
|
+
- 【非常重要】必须为每个子测试场景单独创建独立的test_方法,例如:
|
835
|
+
* 如果有多种类型测试,应该为每种类型单独创建一个test_方法
|
836
|
+
* 每个test_方法应该有明确的命名、注释和完整测试流程
|
837
|
+
* 不要在一个test_方法中循环执行多个测试场景
|
838
|
+
- 【非常重要】确保API之间的数据关联性和验证:
|
839
|
+
* 每个API方法不仅要检查base_resp成功状态,还应从响应中提取关键数据并返回
|
840
|
+
* 例如,get_all_direct_card方法应该提取并返回card_choice_id或card_id
|
841
|
+
* list_my_story_card_by_npc方法应该返回完整的卡牌列表响应
|
842
|
+
* 测试方法中,必须验证前一个API返回的数据在后续API中是否正确使用或出现
|
843
|
+
-【非常重要】验证应放在API调用的expect字典中,demo_case 里面有expect支持的所有类型,切勿自己自定义类型,如果不支持的expect,可以允许自己写 assert进行校验
|
844
|
+
|
845
|
+
3. 关于测试链路的实现 (LINK链路):
|
846
|
+
- 【非常重要】请严格按照测试步骤中指定的API链路顺序执行
|
847
|
+
- 例如,如果测试步骤为 "API1 -> API2 -> API3",则在同一个测试方法中依次调用这些API
|
848
|
+
- 确保在每个单独的test_方法中都实现完整的API调用链路
|
849
|
+
- 测试方法应该实现完整的业务流程,从获取用户ID,到依次调用各个API,再到最终验证整个流程
|
850
|
+
|
851
|
+
4. 数据验证要求:
|
852
|
+
-【非常重要】验证应放在API调用的expect字典中,demo_case里面有expect支持的所有类型,切勿自己自定义类型,如果不支持的expect,可以允许自己写 assert进行校验
|
853
|
+
-【非常重要】你需要结合IDL和API相关描述等有关于API的相关信息 来进行合理的数据校验,而不是自己去杜撰接口的返回参数!!并且尽可能多的对返回的数据的字段进行assert校验!!
|
854
|
+
- 如果API调用生成了重要数据(如card_id),后续API必须验证这些数据
|
855
|
+
- 所有的校验都应该放在请求以后
|
856
|
+
- 例如,验证card_id存在于卡牌列表中应该这样实现:
|
857
|
+
|
858
|
+
{expect_example}
|
859
|
+
|
860
|
+
|
861
|
+
请确保生成的两个文件互相配合,测试用例文件能够正确导入和使用前置操作文件中的功能。
|
862
|
+
前置操作方法应参考pre demo的格式实现,如果pre demo为空,则使用标准格式实现前置操作。
|
863
|
+
保持相同的代码风格、错误处理方式和断言格式。
|
864
|
+
|
865
|
+
请遵循以下Python代码格式规范:
|
866
|
+
1. 使用四个空格进行缩进,不要使用制表符
|
867
|
+
2. 类定义后空两行,方法定义后空一行
|
868
|
+
3. 使用单引号表示字符串,除非字符串中包含单引号
|
869
|
+
4. 注释应该是完整的句子,并以句号结尾
|
870
|
+
5. 导入模块应该一行一个,并按照标准库、第三方库、本地库的顺序排列
|
871
|
+
6. 确保代码中没有过长的行(最好不超过100个字符)
|
872
|
+
7. 变量和方法使用小写字母和下划线命名法
|
873
|
+
|
874
|
+
在你的回复中,请清晰地用markdown代码块分别标记这两个文件的内容,例如:
|
875
|
+
|
876
|
+
# 前置操作文件(pre_{case_name}.py):
|
877
|
+
```python
|
878
|
+
# 前置操作文件内容
|
879
|
+
```
|
880
|
+
|
881
|
+
# 测试用例文件(test_{case_name}.py):
|
882
|
+
```python
|
883
|
+
# 测试用例文件内容
|
884
|
+
```
|
885
|
+
"""
|
886
|
+
|
887
|
+
# 直接使用模型生成
|
888
|
+
raw_response = self.model.invoke(template)
|
889
|
+
# logger.info(f"模型返回的响应: {raw_response}")
|
890
|
+
|
891
|
+
# 如果响应为空,返回空字符串
|
892
|
+
if not raw_response:
|
893
|
+
logger.warning("模型返回的响应为空")
|
894
|
+
return {"case_code": "", "pre_code": ""}
|
895
|
+
|
896
|
+
# 提取前置操作文件的Python代码块
|
897
|
+
pre_code_blocks = re.findall(r'# 前置操作文件.*?```python\s*(.*?)\s*```', raw_response, re.DOTALL)
|
898
|
+
# 提取测试用例文件的Python代码块
|
899
|
+
case_code_blocks = re.findall(r'# 测试用例文件.*?```python\s*(.*?)\s*```', raw_response, re.DOTALL)
|
900
|
+
|
901
|
+
# 如果没有找到明确标记的代码块,尝试提取所有Python代码块
|
902
|
+
if not pre_code_blocks or not case_code_blocks:
|
903
|
+
all_code_blocks = re.findall(r'```python\s*(.*?)\s*```', raw_response, re.DOTALL)
|
904
|
+
if len(all_code_blocks) >= 2:
|
905
|
+
pre_code_blocks = [all_code_blocks[0]]
|
906
|
+
case_code_blocks = [all_code_blocks[1]]
|
907
|
+
elif len(all_code_blocks) == 1:
|
908
|
+
# 只有一个代码块,假设是测试用例
|
909
|
+
case_code_blocks = [all_code_blocks[0]]
|
910
|
+
pre_code_blocks = ["# 未找到前置操作代码"]
|
911
|
+
|
912
|
+
pre_code = pre_code_blocks[0] if pre_code_blocks else "# 未找到前置操作代码"
|
913
|
+
case_code = case_code_blocks[0] if case_code_blocks else "# 未找到测试用例代码"
|
914
|
+
|
915
|
+
logger.info(f"为case {case_name} 生成的前置操作代码长度: {len(pre_code)}")
|
916
|
+
logger.info(f"为case {case_name} 生成的测试用例代码长度: {len(case_code)}")
|
917
|
+
|
918
|
+
return {
|
919
|
+
"pre_code": pre_code,
|
920
|
+
"case_code": case_code,
|
921
|
+
"case_name": case_name,
|
922
|
+
"case_index": case_index
|
923
|
+
}
|
924
|
+
|
925
|
+
def _format_output_files(self, pre_code, case_code, case_name):
|
926
|
+
"""格式化输出的文件内容,修复常见格式问题
|
927
|
+
|
928
|
+
Args:
|
929
|
+
pre_code: 前置操作代码
|
930
|
+
case_code: 测试用例代码
|
931
|
+
case_name: 测试用例名称
|
932
|
+
|
933
|
+
Returns:
|
934
|
+
tuple: 修复后的(pre_code, case_code)
|
935
|
+
"""
|
936
|
+
logger.info(f"开始格式化生成的代码文件: {case_name}")
|
937
|
+
|
938
|
+
# 修复开头的转义问题
|
939
|
+
if pre_code.startswith('\\n'):
|
940
|
+
pre_code = pre_code[2:]
|
941
|
+
if case_code.startswith('\\n'):
|
942
|
+
case_code = case_code[2:]
|
943
|
+
|
944
|
+
# 直接处理常见转义字符,避免unicode_escape造成中文乱码
|
945
|
+
# 修复引号的转义问题
|
946
|
+
pre_code = pre_code.replace("\\'\\'\\'", "'''") # 三引号
|
947
|
+
case_code = case_code.replace("\\'\\'\\'", "'''")
|
948
|
+
pre_code = pre_code.replace("\\'", "'") # 单引号
|
949
|
+
case_code = case_code.replace("\\'", "'")
|
950
|
+
pre_code = pre_code.replace('\\"', '"') # 双引号
|
951
|
+
case_code = case_code.replace('\\"', '"')
|
952
|
+
|
953
|
+
# 修复换行符的转义问题
|
954
|
+
pre_code = pre_code.replace("\\n", "\n")
|
955
|
+
case_code = case_code.replace("\\n", "\n")
|
956
|
+
|
957
|
+
# 修复制表符的转义问题
|
958
|
+
pre_code = pre_code.replace("\\t", "\t")
|
959
|
+
case_code = case_code.replace("\\t", "\t")
|
960
|
+
|
961
|
+
# 修复反斜杠的转义问题
|
962
|
+
pre_code = pre_code.replace("\\\\", "\\")
|
963
|
+
case_code = case_code.replace("\\\\", "\\")
|
964
|
+
|
965
|
+
# 修复日志输出中的换行符问题
|
966
|
+
# 匹配类似 logger.info('文本: \n' + xxx) 这样的模式
|
967
|
+
case_code = re.sub(r"logger\.info\('([^']*?):\\s*\\n'\s*\+", r"logger.info('\1: ' +", case_code)
|
968
|
+
case_code = re.sub(r"logger\.info\(\"([^\"]*?):\\s*\\n\"\s*\+", r"logger.info(\"\1: \" +", case_code)
|
969
|
+
|
970
|
+
# 修复代码中使用反斜杠续行导致的换行问题
|
971
|
+
# 匹配类似 logger.info('文本:\' + xxx) 这样的模式
|
972
|
+
case_code = re.sub(r"logger\.info\('([^']*?):\\\s*'\s*\+", r"logger.info('\1: ' +", case_code)
|
973
|
+
case_code = re.sub(r"logger\.info\(\"([^\"]*?):\\\s*\"\s*\+", r"logger.info(\"\1: \" +", case_code)
|
974
|
+
|
975
|
+
# 修复日志输出中直接使用\n换行符的问题
|
976
|
+
# 匹配类似 logger.info('文本:
|
977
|
+
# ' + xxx) 这样的模式
|
978
|
+
case_code = re.sub(r"logger\.info\('([^']*?):\s*\n'\s*\+", r"logger.info('\1: ' +", case_code)
|
979
|
+
case_code = re.sub(r"logger\.info\(\"([^\"]*?):\s*\n\"\s*\+", r"logger.info(\"\1: \" +", case_code)
|
980
|
+
|
981
|
+
# 修复导入名称问题
|
982
|
+
from_import = f"from card_operations_pre import"
|
983
|
+
if from_import in case_code:
|
984
|
+
file_prefix = f"pre_{case_name}"
|
985
|
+
new_import = f"from {file_prefix} import"
|
986
|
+
case_code = case_code.replace(from_import, new_import)
|
987
|
+
|
988
|
+
logger.info(f"完成格式化生成的代码文件: {case_name}")
|
989
|
+
return pre_code, case_code
|
990
|
+
|
991
|
+
def _save_cases_to_files(self, case_results):
|
992
|
+
"""将生成的cases和pres保存到本地
|
993
|
+
|
994
|
+
Args:
|
995
|
+
case_results: 包含case和pre代码的列表
|
996
|
+
|
997
|
+
Returns:
|
998
|
+
list: 保存的文件路径列表
|
999
|
+
"""
|
1000
|
+
file_result = []
|
1001
|
+
# 创建案例根目录
|
1002
|
+
case_root_dir = self.result_dir / self.case_type / f"{self.api_name.replace('.', '_').replace('/', '_')}_{self.business}"
|
1003
|
+
case_root_dir.mkdir(exist_ok=True, parents=True)
|
1004
|
+
|
1005
|
+
for result in case_results:
|
1006
|
+
file_result_one = {} # 在循环内创建新的字典,避免复用同一个引用
|
1007
|
+
case_name = result.get("case_name", "unknown")
|
1008
|
+
case_index = result.get("case_index", 0)
|
1009
|
+
pre_code = result.get("pre_code", "")
|
1010
|
+
case_code = result.get("case_code", "")
|
1011
|
+
|
1012
|
+
# 格式化输出的代码内容
|
1013
|
+
pre_code, case_code = self._format_output_files(pre_code, case_code, case_name)
|
1014
|
+
|
1015
|
+
# 为每个case创建单独的目录
|
1016
|
+
# 将字符串转换为Path对象,然后进行路径拼接
|
1017
|
+
case_dir = Path(self.pwd) / f"{case_name}"
|
1018
|
+
|
1019
|
+
pre_file_path = case_dir / f"pre_{case_name}.py"
|
1020
|
+
file_result_one["pre_case_dir"] = str(pre_file_path)
|
1021
|
+
file_result_one['pre_case_result'] = pre_code
|
1022
|
+
case_file_path = case_dir / f"test_{case_name}.py"
|
1023
|
+
file_result_one["case_dir"] = str(case_file_path)
|
1024
|
+
file_result_one['case_result'] = case_code
|
1025
|
+
|
1026
|
+
file_result.append(file_result_one)
|
1027
|
+
|
1028
|
+
# case_dir.mkdir(exist_ok=True)
|
1029
|
+
|
1030
|
+
# # 保存前置操作文件,明确使用UTF-8 编码
|
1031
|
+
# pre_file_path = case_dir / f"pre_{case_name}.py"
|
1032
|
+
# with open(pre_file_path, "w", encoding="utf-8") as f:
|
1033
|
+
# f.write(pre_code)
|
1034
|
+
# logger.info(f"前置操作已保存到: {pre_file_path}")
|
1035
|
+
# saved_paths.append(str(pre_file_path))
|
1036
|
+
#
|
1037
|
+
# # 保存测试用例文件,明确使用UTF-8 编码
|
1038
|
+
# case_file_path = case_dir / f"test_{case_name}.py"
|
1039
|
+
# with open(case_file_path, "w", encoding="utf-8") as f:
|
1040
|
+
# f.write(case_code)
|
1041
|
+
# logger.info(f"测试用例已保存到: {case_file_path}")
|
1042
|
+
# saved_paths.append(str(case_file_path))
|
1043
|
+
|
1044
|
+
return file_result
|
1045
|
+
|
1046
|
+
def _process_case_concurrently(self, api_info, api_data, repo_info):
|
1047
|
+
"""并发处理多个case任务
|
1048
|
+
|
1049
|
+
Args:
|
1050
|
+
api_info: API信息
|
1051
|
+
api_data: API数据
|
1052
|
+
repo_info: 仓库信息
|
1053
|
+
|
1054
|
+
Returns:
|
1055
|
+
list: 包含所有case结果的列表
|
1056
|
+
"""
|
1057
|
+
logger.info(f"启动并发处理 {len(self.case_items)} 个case任务,最大并发数: {self.max_workers}")
|
1058
|
+
results = []
|
1059
|
+
|
1060
|
+
# 创建线程池
|
1061
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
1062
|
+
# 提交所有case生成任务到线程池
|
1063
|
+
future_to_case = {
|
1064
|
+
executor.submit(
|
1065
|
+
self._generate_case_with_model,
|
1066
|
+
api_info,
|
1067
|
+
api_data,
|
1068
|
+
repo_info,
|
1069
|
+
case_item,
|
1070
|
+
i + 1
|
1071
|
+
): (case_item, i + 1) for i, case_item in enumerate(self.case_items)
|
1072
|
+
}
|
1073
|
+
|
1074
|
+
# 处理完成的任务
|
1075
|
+
for future in concurrent.futures.as_completed(future_to_case):
|
1076
|
+
case_item, case_index = future_to_case[future]
|
1077
|
+
try:
|
1078
|
+
case_result = future.result()
|
1079
|
+
with log_lock:
|
1080
|
+
logger.info(f"Case {case_index}/{len(self.case_items)} 生成完成")
|
1081
|
+
results.append(case_result)
|
1082
|
+
except Exception as e:
|
1083
|
+
case_name = list(case_item.keys())[0] if case_item else "unknown"
|
1084
|
+
with log_lock:
|
1085
|
+
logger.error(f"处理case {case_name} (索引: {case_index}) 时出错: {e}")
|
1086
|
+
# 创建部分结果记录错误
|
1087
|
+
results.append({
|
1088
|
+
"case_name": case_name,
|
1089
|
+
"case_index": case_index,
|
1090
|
+
"pre_code": f"# Error generating pre code: {str(e)}",
|
1091
|
+
"case_code": f"# Error generating case code: {str(e)}"
|
1092
|
+
})
|
1093
|
+
|
1094
|
+
# 按原始顺序排序结果
|
1095
|
+
results.sort(key=lambda x: x.get("case_index", 0))
|
1096
|
+
return results
|
1097
|
+
|
1098
|
+
def generator_case(self):
|
1099
|
+
"""
|
1100
|
+
desc:
|
1101
|
+
使用langchain进行生成,生成case,完全基于JSON解析的内容
|
1102
|
+
:return: dict: 生成结果
|
1103
|
+
"""
|
1104
|
+
try:
|
1105
|
+
logger.info(f"开始生成测试用例: APIs={self.all_apis}, business={self.business}, type={self.case_type}")
|
1106
|
+
|
1107
|
+
# step1: 并发获取所有API的出入参数和业务逻辑
|
1108
|
+
api_data = self._get_api_data_concurrently()
|
1109
|
+
|
1110
|
+
# step1.1: 并发使用模型总结API数据
|
1111
|
+
if self.is_need_module:
|
1112
|
+
api_data = self._summarize_api_data_concurrently(api_data)
|
1113
|
+
|
1114
|
+
# step2: 获取业务对应的代码仓库,并获取link case和pre的demo
|
1115
|
+
repo_info = self._get_code_repo()
|
1116
|
+
# logger.info(f"获取到业务对应的代码仓库信息: {repo_info}")
|
1117
|
+
logger.info(f"获取到业务对应的代码仓库信息成功===")
|
1118
|
+
|
1119
|
+
# 准备API信息,包含所有API
|
1120
|
+
api_info = {
|
1121
|
+
"all_apis": self.all_apis,
|
1122
|
+
"api_descriptions": self.api_descriptions,
|
1123
|
+
"business": self.business
|
1124
|
+
}
|
1125
|
+
|
1126
|
+
# step3: 使用并发方式为每个case生成测试用例和前置操作
|
1127
|
+
case_results = self._process_case_concurrently(api_info, api_data, repo_info)
|
1128
|
+
|
1129
|
+
# step4: 将生成的cases和pres保存到对应的文件夹中
|
1130
|
+
if self.is_need_save and case_results:
|
1131
|
+
file_result = self._save_cases_to_files(case_results)
|
1132
|
+
|
1133
|
+
return file_result
|
1134
|
+
|
1135
|
+
if not case_results:
|
1136
|
+
return [{
|
1137
|
+
"status": "error",
|
1138
|
+
"message": "没有生成任何测试用例"
|
1139
|
+
}]
|
1140
|
+
|
1141
|
+
return []
|
1142
|
+
|
1143
|
+
except Exception as e:
|
1144
|
+
logger.error(f"生成测试用例失败: {e}")
|
1145
|
+
return {
|
1146
|
+
"status": "error",
|
1147
|
+
"message": f"生成测试用例失败: {str(e)}"
|
1148
|
+
}
|
1149
|
+
|
1150
|
+
|
1151
|
+
if __name__ == "__main__":
|
1152
|
+
# 使用JSON格式的输入
|
1153
|
+
input_data = {
|
1154
|
+
"Business": "xingye",
|
1155
|
+
"API": {
|
1156
|
+
"/weaver/api/v1/collection/card/get_all_direct_card": "- 直抽接口,抽卡并直接收下",
|
1157
|
+
"weaver/api/v1/collection/card/list/list_my_story_card_by_npc": "- 许愿池,我的卡牌",
|
1158
|
+
"/weaver/api/v1/collection/card/query_card_choice_history": "- 抽卡记录列表,我所有抽卡记录,按时间倒序排列"
|
1159
|
+
},
|
1160
|
+
"Case": [
|
1161
|
+
{
|
1162
|
+
"许愿池抽卡": {
|
1163
|
+
"PRE": {
|
1164
|
+
"每日免费": "def daily_free(), return uid,使用该uid,则享受每日免费权益",
|
1165
|
+
"月卡免费次数": "def vip_free(), return uid,使用该uid,则享受月卡免费权益",
|
1166
|
+
"星炉熔卡次数": "def reback_free(), return uid,使用该uid,则享受熔炉抽卡权益"
|
1167
|
+
},
|
1168
|
+
"TEST": "/weaver/api/v1/collection/card/get_all_direct_card -> weaver/api/v1/collection/card/list/list_my_story_card_by_npc"
|
1169
|
+
}
|
1170
|
+
},
|
1171
|
+
{
|
1172
|
+
"E卡增发": {
|
1173
|
+
"PRE": {
|
1174
|
+
"每日免费": "def daily_free(), return uid,使用该uid,则享受每日免费权益",
|
1175
|
+
"月卡免费次数": "def vip_free(), return uid,使用该uid,则享受月卡免费权益",
|
1176
|
+
"星炉熔卡次数": "def reback_free(), return uid,使用该uid,则享受熔炉抽卡权益"
|
1177
|
+
},
|
1178
|
+
"TEST": "/weaver/api/v1/collection/card/get_all_direct_card -> weaver/api/v1/collection/card/list/list_my_story_card_by_npc -> weaver/api/v1/collection/card/list/list_my_story_card_by_npc"
|
1179
|
+
}
|
1180
|
+
}
|
1181
|
+
]
|
1182
|
+
}
|
1183
|
+
pwd = "/Users/xingyun/PycharmProjects/qa_tools/agent_mcp_link_case/agent_service/demo_file"
|
1184
|
+
|
1185
|
+
generator_case = GeneratorCase(input_data, pwd)
|
1186
|
+
result = generator_case.generator_case()
|
1187
|
+
logger.info(f"生成结果: {result}")
|