pytest-api-framework-alpha 0.3.4__tar.gz → 0.3.5__tar.gz

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 (31) hide show
  1. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/PKG-INFO +1 -1
  2. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/conftest.py +336 -336
  3. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/db/mysql_db.py +12 -5
  4. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/global_attribute.py +4 -1
  5. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/utils/mock_util.py +2 -1
  6. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/validate.py +1 -1
  7. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/pytest_api_framework_alpha.egg-info/PKG-INFO +1 -1
  8. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/setup.py +1 -1
  9. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/__init__.py +0 -0
  10. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/base_class.py +0 -0
  11. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/db/__init__.py +0 -0
  12. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/db/redis_db.py +0 -0
  13. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/exceptions.py +0 -0
  14. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/exit_code.py +0 -0
  15. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/extract.py +0 -0
  16. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/http_client.py +0 -0
  17. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/render_data.py +0 -0
  18. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/report.py +0 -0
  19. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/script.py +0 -0
  20. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/startapp.py +0 -0
  21. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/utils/__init__.py +0 -0
  22. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/utils/common.py +0 -0
  23. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/utils/date_util.py +0 -0
  24. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/utils/encrypt.py +0 -0
  25. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/utils/log_util.py +0 -0
  26. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/framework/utils/yaml_util.py +0 -0
  27. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/pytest_api_framework_alpha.egg-info/SOURCES.txt +0 -0
  28. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/pytest_api_framework_alpha.egg-info/dependency_links.txt +0 -0
  29. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/pytest_api_framework_alpha.egg-info/requires.txt +0 -0
  30. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/pytest_api_framework_alpha.egg-info/top_level.txt +0 -0
  31. {pytest_api_framework_alpha-0.3.4 → pytest_api_framework_alpha-0.3.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-api-framework-alpha
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Author: alpha
5
5
  Author-email:
6
6
  Requires-Python: >=3.6
@@ -13,6 +13,7 @@ from collections import OrderedDict
13
13
  from datetime import datetime, timedelta
14
14
  from concurrent.futures import ThreadPoolExecutor, as_completed
15
15
 
16
+ import dill
16
17
  import json
17
18
  import retry
18
19
  import allure
@@ -64,220 +65,15 @@ def context():
64
65
  yield context
65
66
 
66
67
 
68
+ class Http(object): pass
69
+
70
+
67
71
  @retry.retry(tries=3, delay=1)
68
72
  @pytest.fixture(scope="function", autouse=True)
69
73
  def http():
70
74
  yield _FRAMEWORK_CONTEXT.get("_http")
71
75
 
72
76
 
73
- class Http(object):
74
- ...
75
-
76
-
77
- def init_mysql():
78
- """初始化 MySQL 连接池"""
79
- try:
80
- mysql_config = CONFIG.get(app=all_app[0], key="mysql")
81
- mysql_conns = {item: MysqlDB(**mysql_config[item]) for item in mysql_config}
82
- for app in all_app:
83
- _FRAMEWORK_CONTEXT.set(app=app, key="mysql", value=mysql_conns)
84
- except Exception as e:
85
- raise MysqlDBError(e)
86
-
87
-
88
- def init_redis():
89
- """初始化 Redis 连接池(16个db)"""
90
- try:
91
- redis_config = CONFIG.get(app=all_app[0], key="redis")
92
- redis_conns = {
93
- db: [RedisDB(**{**db_info, "db": i}) for i in range(16)]
94
- for db, db_info in redis_config.items()
95
- }
96
- for app in all_app:
97
- _FRAMEWORK_CONTEXT.set(app=app, key="redis", value=redis_conns)
98
- except Exception as e:
99
- raise RedisDBError(e)
100
-
101
-
102
- def inner_login(app):
103
- """单个系统的登录和资源初始化"""
104
-
105
- login_cls = getattr(module, f"{snake_to_pascal(app)}Login")
106
- setattr(Http, app, login_cls(app))
107
- # Token 过期时间写入上下文
108
- token_expiry = CONTEXT.get(app).get("token_expiry")
109
- if token_expiry:
110
- expire_time = datetime.now() + timedelta(seconds=token_expiry)
111
- _FRAMEWORK_CONTEXT.set(app=app, key="expire_time", value=expire_time)
112
-
113
-
114
- def login():
115
- def safe_call(func, name):
116
- try:
117
- logger.info(f"初始化{name}连接...")
118
- func()
119
- logger.info(f"{name}连接初始化成功")
120
- except (MysqlDBError, RedisDBError) as e:
121
- logger.error(f"{name}连接初始化异常: {e}")
122
-
123
- # 启动 MySQL 和 Redis 初始化线程
124
- threads = [
125
- threading.Thread(target=safe_call, args=(init_mysql, "MySQL")),
126
- threading.Thread(target=safe_call, args=(init_redis, "Redis"))
127
- ]
128
-
129
- for t in threads:
130
- t.start()
131
- for t in threads:
132
- t.join()
133
-
134
- logger.info("登录账号".center(80, "*"))
135
- with ThreadPoolExecutor(max_workers=len(all_app)) as executor:
136
- futures = {executor.submit(inner_login, app): app for app in all_app}
137
- for future in as_completed(futures):
138
- future.result()
139
-
140
- logger.info("登录完成".center(80, "*"))
141
- return Http
142
-
143
-
144
- def find_data_path_by_case(app, case_file_name):
145
- """
146
- 基于case文件名称查找与之对应的yml文件路径
147
- :param app:
148
- :param case_file_name:
149
- :return:
150
- """
151
- env = CONTEXT.get("env")
152
- for file_path in Path(os.path.join(settings.DATA_DIR, env, app)).rglob(f"{case_file_name}.y*"):
153
- if file_path:
154
- return file_path
155
-
156
-
157
- def subset_and_diff(small: set, big: set):
158
- """
159
- 判断 small 是否为 big 的子集,并返回差集
160
- """
161
- return small.issubset(big), small - big
162
-
163
-
164
- def disable_field(scenario, data):
165
- if not scenario:
166
- return data
167
-
168
- def _clean(obj):
169
- if isinstance(obj, dict):
170
- keys_to_delete = []
171
- for k, v in obj.items():
172
- if isinstance(v, (dict, list)):
173
- _clean(v)
174
- elif isinstance(v, str):
175
- for ak, av in scenario.items():
176
- if av == "disable" and v == f"${{{ak}}}":
177
- keys_to_delete.append(k)
178
- break
179
- # 统一删除,避免边遍历边删
180
- for k in keys_to_delete:
181
- del obj[k]
182
-
183
- elif isinstance(obj, list):
184
- for item in obj:
185
- if isinstance(item, (dict, list)):
186
- _clean(item)
187
-
188
- _clean(data)
189
- return data
190
-
191
-
192
- def match_keyword(keyword_expr: str, target_str: str) -> bool:
193
- """
194
- 按规则匹配字符串:
195
- - 空表达式:直接返回 True
196
- - 单个关键词:直接 in 判断
197
- - 多关键词:支持 and / or / not / 括号
198
- """
199
- if not keyword_expr.strip():
200
- return True # 空关键字,默认匹配所有
201
-
202
- keyword_expr = keyword_expr.strip().lower()
203
- target_str = target_str.lower()
204
-
205
- # 单个关键词直接处理
206
- words = re.findall(r"[^\s()]+", keyword_expr) # 提取非括号非空格的词
207
- if len(words) == 1:
208
- return words[0] in target_str
209
-
210
- # 带逻辑表达式
211
- # tokens 保留括号,分离 and/or/not/关键词
212
- tokens = re.findall(r"\(|\)|and|or|not|[^\s()]+", keyword_expr)
213
-
214
- expr_list = []
215
- for t in tokens:
216
- if t in {"and", "or", "not", "(", ")"}:
217
- expr_list.append(t)
218
- else:
219
- # 替换为 True/False
220
- expr_list.append(str(t in target_str))
221
-
222
- expr_str = " ".join(expr_list)
223
- try:
224
- return eval(expr_str)
225
- except Exception as e:
226
- logger.error(f"表达式解析错误: {e}, 表达式: {expr_str}")
227
- return False
228
-
229
-
230
- def match_mark(keyword_expr: str, target_list: list[str]) -> bool:
231
- """
232
- 按规则匹配目标列表:
233
- - 空表达式:直接返回 True
234
- - 单个关键词:精准匹配 target_list 中某个元素
235
- - 多个关键词:支持 and/or/not/括号
236
- """
237
- if not keyword_expr.strip():
238
- return True # 空关键字,默认匹配所有
239
-
240
- keyword_expr = keyword_expr.strip().lower()
241
- target_list = [s.lower() for s in target_list]
242
-
243
- def contains(word: str) -> bool:
244
- """精准匹配:word 必须等于 target_list 的某个元素"""
245
- return word in target_list
246
-
247
- # tokens:保留括号,拆分 and/or/not/关键词
248
- tokens = re.findall(r"\(|\)|and|or|not|[^\s()]+", keyword_expr)
249
-
250
- expr_list = []
251
- for t in tokens:
252
- if t in {"and", "or", "not", "(", ")"}:
253
- expr_list.append(t)
254
- else:
255
- expr_list.append(str(contains(t)))
256
-
257
- expr_str = " ".join(expr_list)
258
- try:
259
- return eval(expr_str)
260
- except Exception as e:
261
- logger.error(f"表达式解析错误: {e}, 表达式: {expr_str}")
262
- return False
263
-
264
-
265
- def init_allure(params):
266
- """设置allure中case的 title, description, level"""
267
- case_level_map = {
268
- "p0": allure.severity_level.BLOCKER,
269
- "p1": allure.severity_level.CRITICAL,
270
- "p2": allure.severity_level.NORMAL,
271
- "p3": allure.severity_level.MINOR,
272
- "p4": allure.severity_level.TRIVIAL,
273
- }
274
- allure.dynamic.title(params.get("title"))
275
- allure.dynamic.description(params.get("describe"))
276
- allure.dynamic.severity(case_level_map.get(params.get("level")))
277
- allure.dynamic.feature(params.get("module"))
278
- allure.dynamic.story(params.get("describe"))
279
-
280
-
281
77
  def pytest_configure(config):
282
78
  """
283
79
  初始化时被调用,可以用于设置全局状态或配置
@@ -294,32 +90,13 @@ def pytest_configure(config):
294
90
  sys.path.append(settings.CASES_DIR)
295
91
 
296
92
 
297
- def pytest_unconfigure(config):
298
- app = CONTEXT.get("app")
299
- app_handlers = getattr(settings, "APP_FINISH_HANDLER_CLASSES", {})
300
- if app:
301
- handlers = app_handlers.get(app) or []
302
- else:
303
- handlers = [h for value in app_handlers.values() for h in value] or []
304
- global_handlers = getattr(settings, "GLOBAL_FINISH_HANDLER_CLASSES", [])
305
- handlers.extend(global_handlers)
306
- for handler in handlers:
307
- try:
308
- module_path, class_name = handler.rsplit(".", 1)
309
- cls = getattr(importlib.import_module(module_path), class_name)
310
- cls().run()
311
- except Exception as e:
312
- logger.error(str(e))
313
- traceback.print_exc()
314
- pytest.exit(ExitCode.GLOBAL_SCRIPT_ERROR)
315
-
316
-
317
93
  def pytest_generate_tests(metafunc):
318
94
  """
319
95
  生成(多个)对测试函数的参数化调用
320
96
  :param metafunc:
321
97
  :return:
322
98
  """
99
+
323
100
  keyword_expr = metafunc.config.getoption("keyword")
324
101
  mark_expr = metafunc.config.getoption("markexpr")
325
102
  node_id = metafunc.definition.keywords.node.nodeid
@@ -513,115 +290,16 @@ def pytest_collection_modifyitems(items):
513
290
  item.add_marker(mark)
514
291
 
515
292
 
516
- # UPDATE 将用例 按类名进行分组的核心方法
517
- def filtered_groupby(iterable, key_func):
518
- """生成器:过滤key_func返回None的元素,并按key_func分组"""
519
- current_key = None
520
- current_group = []
521
-
522
- for item in iterable:
523
- key = key_func(item)
524
- if key is None:
525
- continue # 跳过key为None的元素
293
+ def pytest_collection_finish(session):
294
+ """获取最终排序后的 items 列表"""
295
+ logger.info(f"共收集到 {len(session.items)} 个测试用例")
296
+ if not session.items:
297
+ pytest.exit("未收集到用例")
298
+ # 过滤掉item名称是test_setup或test_teardown的
299
+ session.items = [item for item in session.items if item.name not in ["test_setup", "test_teardown"]]
526
300
 
527
- # 处理分组逻辑(类似groupby)
528
- if key != current_key:
529
- if current_group:
530
- yield current_key, current_group
531
- current_key = key
532
- current_group = [item]
533
- else:
534
- current_group.append(item)
535
-
536
- # 输出最后一组
537
- if current_group:
538
- yield current_key, current_group
539
-
540
-
541
- # UPDATE:按类的全路径 进行用例分组 同一个类的test方法分到一组
542
- def __get_group_key__(item):
543
- return '::'.join(item.nodeid.split('::')[:2])
544
-
545
-
546
- # UPDATE:改变pytest的原始排序规则
547
- def sort(case_items):
548
- # 按测试类全路径分类,同一个类文件的用例归集到一起
549
- # 使用 groupby 函数进行分组
550
- item_group_list = [list(group) for _, group in filtered_groupby(case_items, __get_group_key__)]
551
-
552
- all_item_list = []
553
- clase_id = None
554
- for items in item_group_list:
555
- # 未被test_setup/test_teardown 标记的test方法
556
- non_custom_scope_items = [item for item in items if
557
- 'test_setup' != item.originalname and 'test_teardown' != item.originalname]
558
- item_list = []
559
- # 用例的组数
560
- case_suite_num = 0
561
- # 生成每个组当前的索引
562
- ori_name_temp = None
563
- ori_name_list = []
564
-
565
- for item in non_custom_scope_items:
566
- clase_id = item.cls.__name__
567
- original_name = item.originalname
568
-
569
- if ori_name_temp is None or ori_name_temp == original_name:
570
- ori_name_temp = original_name
571
- case_suite_num += 1
572
- ori_name_list.append([original_name, item])
573
- else:
574
- break
575
-
576
- # 根据组数 创建各组的数组 并插入第一个case
577
- case_dict = dict()
578
- try:
579
- for i in range(case_suite_num):
580
- item = ori_name_list[i][1]
581
- id = item.callspec.id
582
-
583
- first_part = id.split('#', 1)[-1]
584
- index = first_part.split(']')[0]
585
- case_dict[index] = [item]
586
- except:
587
- pass
588
-
589
- new_start_index = case_suite_num
590
- # 以new_start_index为起点 重新遍历items
591
- try:
592
- for i in range(new_start_index, len(non_custom_scope_items)):
593
- item = non_custom_scope_items[i]
594
- id = item.callspec.id
595
- first_part = id.split('#', 1)[-1]
596
- index = first_part.split(']')[0]
597
- case_dict.get(index).append(item)
598
- except:
599
- pass
600
-
601
- index = 0
602
- for id in case_dict:
603
- index += 1
604
- case_item_list = case_dict.get(id)
605
- for item in case_item_list:
606
- allure_suite_mark = f'{clase_id}#{index}'
607
- setattr(item, 'allure_suite_mark', allure_suite_mark)
608
- item_list += case_item_list
609
-
610
- all_item_list += item_list
611
-
612
- return all_item_list
613
-
614
-
615
- def pytest_collection_finish(session):
616
- """获取最终排序后的 items 列表"""
617
- logger.info(f"共收集到 {len(session.items)} 个测试用例")
618
- if not session.items:
619
- pytest.exit("未收集到用例")
620
- # 过滤掉item名称是test_setup或test_teardown的
621
- session.items = [item for item in session.items if item.name not in ["test_setup", "test_teardown"]]
622
-
623
- # 1. 筛选出带井号 名称带'#' 的item,并记录原始索引
624
- hash_items_with_index = [(index, item) for index, item in enumerate(session.items) if "#" in item.name]
301
+ # 1. 筛选出带井号 名称带'#' 的item,并记录原始索引
302
+ hash_items_with_index = [(index, item) for index, item in enumerate(session.items) if "#" in item.name]
625
303
 
626
304
  # 2. 按照 'cls' 对带井号的元素进行分组
627
305
  grouped_by_cls = {}
@@ -678,6 +356,26 @@ def pytest_runtestloop(session):
678
356
  pytest.exit(ExitCode.GLOBAL_SCRIPT_ERROR)
679
357
 
680
358
 
359
+ def pytest_sessionfinish(session, exitstatus):
360
+ app = CONTEXT.get("app")
361
+ app_handlers = getattr(settings, "APP_FINISH_HANDLER_CLASSES", {})
362
+ if app:
363
+ handlers = app_handlers.get(app) or []
364
+ else:
365
+ handlers = [h for value in app_handlers.values() for h in value] or []
366
+ global_handlers = getattr(settings, "GLOBAL_FINISH_HANDLER_CLASSES", [])
367
+ handlers.extend(global_handlers)
368
+ for handler in handlers:
369
+ try:
370
+ module_path, class_name = handler.rsplit(".", 1)
371
+ cls = getattr(importlib.import_module(module_path), class_name)
372
+ cls().run()
373
+ except Exception as e:
374
+ logger.error(str(e))
375
+ traceback.print_exc()
376
+ pytest.exit(ExitCode.GLOBAL_SCRIPT_ERROR)
377
+
378
+
681
379
  def pytest_runtest_setup(item):
682
380
  allure.dynamic.sub_suite(item.allure_suite_mark)
683
381
  if item.funcargs.get("first"):
@@ -841,3 +539,305 @@ def pytest_exception_interact(node, call, report):
841
539
  """
842
540
  if call.excinfo.type is AssertionError:
843
541
  logger.error(f"{node.nodeid} failed: {call.excinfo.value}\n")
542
+
543
+
544
+ def init_mysql():
545
+ """初始化 MySQL 连接池"""
546
+ try:
547
+ mysql_config = CONFIG.get(app=all_app[0], key="mysql")
548
+ mysql_conns = {item: MysqlDB(**mysql_config[item]) for item in mysql_config}
549
+ for app in all_app:
550
+ _FRAMEWORK_CONTEXT.set(app=app, key="mysql", value=mysql_conns)
551
+ except Exception as e:
552
+ raise MysqlDBError(e)
553
+
554
+
555
+ def init_redis():
556
+ """初始化 Redis 连接池(16个db)"""
557
+ try:
558
+ redis_config = CONFIG.get(app=all_app[0], key="redis")
559
+ redis_conns = {
560
+ db: [RedisDB(**{**db_info, "db": i}) for i in range(16)]
561
+ for db, db_info in redis_config.items()
562
+ }
563
+ for app in all_app:
564
+ _FRAMEWORK_CONTEXT.set(app=app, key="redis", value=redis_conns)
565
+ except Exception as e:
566
+ raise RedisDBError(e)
567
+
568
+
569
+ def inner_login(app):
570
+ """单个系统的登录和资源初始化"""
571
+
572
+ login_cls = getattr(module, f"{snake_to_pascal(app)}Login")
573
+ setattr(Http, app, login_cls(app))
574
+ # Token 过期时间写入上下文
575
+ token_expiry = CONTEXT.get(app).get("token_expiry")
576
+ if token_expiry:
577
+ expire_time = datetime.now() + timedelta(seconds=token_expiry)
578
+ _FRAMEWORK_CONTEXT.set(app=app, key="expire_time", value=expire_time)
579
+
580
+
581
+ def login():
582
+ def safe_call(func, name):
583
+ try:
584
+ logger.info(f"初始化{name}连接...")
585
+ func()
586
+ logger.info(f"{name}连接初始化成功")
587
+ except (MysqlDBError, RedisDBError) as e:
588
+ logger.error(f"{name}连接初始化异常: {e}")
589
+
590
+ # 启动 MySQL 和 Redis 初始化线程
591
+ threads = [
592
+ threading.Thread(target=safe_call, args=(init_mysql, "MySQL")),
593
+ threading.Thread(target=safe_call, args=(init_redis, "Redis"))
594
+ ]
595
+
596
+ for t in threads:
597
+ t.start()
598
+ for t in threads:
599
+ t.join()
600
+ logger.info("登录账号".center(80, "*"))
601
+ with ThreadPoolExecutor(max_workers=len(all_app)) as executor:
602
+ futures = {executor.submit(inner_login, app): app for app in all_app}
603
+ for future in as_completed(futures):
604
+ future.result()
605
+
606
+ logger.info("登录完成".center(80, "*"))
607
+ return Http
608
+
609
+
610
+ def find_data_path_by_case(app, case_file_name):
611
+ """
612
+ 基于case文件名称查找与之对应的yml文件路径
613
+ :param app:
614
+ :param case_file_name:
615
+ :return:
616
+ """
617
+ env = CONTEXT.get("env")
618
+ for file_path in Path(os.path.join(settings.DATA_DIR, env, app)).rglob(f"{case_file_name}.y*"):
619
+ if file_path:
620
+ return file_path
621
+
622
+
623
+ def subset_and_diff(small: set, big: set):
624
+ """
625
+ 判断 small 是否为 big 的子集,并返回差集
626
+ """
627
+ return small.issubset(big), small - big
628
+
629
+
630
+ def disable_field(scenario, data):
631
+ if not scenario:
632
+ return data
633
+
634
+ def _clean(obj):
635
+ if isinstance(obj, dict):
636
+ keys_to_delete = []
637
+ for k, v in obj.items():
638
+ if isinstance(v, (dict, list)):
639
+ _clean(v)
640
+ elif isinstance(v, str):
641
+ for ak, av in scenario.items():
642
+ if av == "disable" and v == f"${{{ak}}}":
643
+ keys_to_delete.append(k)
644
+ break
645
+ # 统一删除,避免边遍历边删
646
+ for k in keys_to_delete:
647
+ del obj[k]
648
+
649
+ elif isinstance(obj, list):
650
+ for item in obj:
651
+ if isinstance(item, (dict, list)):
652
+ _clean(item)
653
+
654
+ _clean(data)
655
+ return data
656
+
657
+
658
+ def match_keyword(keyword_expr: str, target_str: str) -> bool:
659
+ """
660
+ 按规则匹配字符串:
661
+ - 空表达式:直接返回 True
662
+ - 单个关键词:直接 in 判断
663
+ - 多关键词:支持 and / or / not / 括号
664
+ """
665
+ if not keyword_expr.strip():
666
+ return True # 空关键字,默认匹配所有
667
+
668
+ keyword_expr = keyword_expr.strip().lower()
669
+ target_str = target_str.lower()
670
+
671
+ # 单个关键词直接处理
672
+ words = re.findall(r"[^\s()]+", keyword_expr) # 提取非括号非空格的词
673
+ if len(words) == 1:
674
+ return words[0] in target_str
675
+
676
+ # 带逻辑表达式
677
+ # tokens 保留括号,分离 and/or/not/关键词
678
+ tokens = re.findall(r"\(|\)|and|or|not|[^\s()]+", keyword_expr)
679
+
680
+ expr_list = []
681
+ for t in tokens:
682
+ if t in {"and", "or", "not", "(", ")"}:
683
+ expr_list.append(t)
684
+ else:
685
+ # 替换为 True/False
686
+ expr_list.append(str(t in target_str))
687
+
688
+ expr_str = " ".join(expr_list)
689
+ try:
690
+ return eval(expr_str)
691
+ except Exception as e:
692
+ logger.error(f"表达式解析错误: {e}, 表达式: {expr_str}")
693
+ return False
694
+
695
+
696
+ def match_mark(keyword_expr: str, target_list: list[str]) -> bool:
697
+ """
698
+ 按规则匹配目标列表:
699
+ - 空表达式:直接返回 True
700
+ - 单个关键词:精准匹配 target_list 中某个元素
701
+ - 多个关键词:支持 and/or/not/括号
702
+ """
703
+ if not keyword_expr.strip():
704
+ return True # 空关键字,默认匹配所有
705
+
706
+ keyword_expr = keyword_expr.strip().lower()
707
+ target_list = [s.lower() for s in target_list]
708
+
709
+ def contains(word: str) -> bool:
710
+ """精准匹配:word 必须等于 target_list 的某个元素"""
711
+ return word in target_list
712
+
713
+ # tokens:保留括号,拆分 and/or/not/关键词
714
+ tokens = re.findall(r"\(|\)|and|or|not|[^\s()]+", keyword_expr)
715
+
716
+ expr_list = []
717
+ for t in tokens:
718
+ if t in {"and", "or", "not", "(", ")"}:
719
+ expr_list.append(t)
720
+ else:
721
+ expr_list.append(str(contains(t)))
722
+
723
+ expr_str = " ".join(expr_list)
724
+ try:
725
+ return eval(expr_str)
726
+ except Exception as e:
727
+ logger.error(f"表达式解析错误: {e}, 表达式: {expr_str}")
728
+ return False
729
+
730
+
731
+ def init_allure(params):
732
+ """设置allure中case的 title, description, level"""
733
+ case_level_map = {
734
+ "p0": allure.severity_level.BLOCKER,
735
+ "p1": allure.severity_level.CRITICAL,
736
+ "p2": allure.severity_level.NORMAL,
737
+ "p3": allure.severity_level.MINOR,
738
+ "p4": allure.severity_level.TRIVIAL,
739
+ }
740
+ allure.dynamic.title(params.get("title"))
741
+ allure.dynamic.description(params.get("describe"))
742
+ allure.dynamic.severity(case_level_map.get(params.get("level")))
743
+ allure.dynamic.feature(params.get("module"))
744
+ allure.dynamic.story(params.get("describe"))
745
+
746
+
747
+ # UPDATE 将用例 按类名进行分组的核心方法
748
+ def filtered_groupby(iterable, key_func):
749
+ """生成器:过滤key_func返回None的元素,并按key_func分组"""
750
+ current_key = None
751
+ current_group = []
752
+
753
+ for item in iterable:
754
+ key = key_func(item)
755
+ if key is None:
756
+ continue # 跳过key为None的元素
757
+
758
+ # 处理分组逻辑(类似groupby)
759
+ if key != current_key:
760
+ if current_group:
761
+ yield current_key, current_group
762
+ current_key = key
763
+ current_group = [item]
764
+ else:
765
+ current_group.append(item)
766
+
767
+ # 输出最后一组
768
+ if current_group:
769
+ yield current_key, current_group
770
+
771
+
772
+ # UPDATE:按类的全路径 进行用例分组 同一个类的test方法分到一组
773
+ def __get_group_key__(item):
774
+ return '::'.join(item.nodeid.split('::')[:2])
775
+
776
+
777
+ # UPDATE:改变pytest的原始排序规则
778
+ def sort(case_items):
779
+ # 按测试类全路径分类,同一个类文件的用例归集到一起
780
+ # 使用 groupby 函数进行分组
781
+ item_group_list = [list(group) for _, group in filtered_groupby(case_items, __get_group_key__)]
782
+
783
+ all_item_list = []
784
+ clase_id = None
785
+ for items in item_group_list:
786
+ # 未被test_setup/test_teardown 标记的test方法
787
+ non_custom_scope_items = [item for item in items if
788
+ 'test_setup' != item.originalname and 'test_teardown' != item.originalname]
789
+ item_list = []
790
+ # 用例的组数
791
+ case_suite_num = 0
792
+ # 生成每个组当前的索引
793
+ ori_name_temp = None
794
+ ori_name_list = []
795
+
796
+ for item in non_custom_scope_items:
797
+ clase_id = item.cls.__name__
798
+ original_name = item.originalname
799
+
800
+ if ori_name_temp is None or ori_name_temp == original_name:
801
+ ori_name_temp = original_name
802
+ case_suite_num += 1
803
+ ori_name_list.append([original_name, item])
804
+ else:
805
+ break
806
+
807
+ # 根据组数 创建各组的数组 并插入第一个case
808
+ case_dict = dict()
809
+ try:
810
+ for i in range(case_suite_num):
811
+ item = ori_name_list[i][1]
812
+ id = item.callspec.id
813
+
814
+ first_part = id.split('#', 1)[-1]
815
+ index = first_part.split(']')[0]
816
+ case_dict[index] = [item]
817
+ except:
818
+ pass
819
+
820
+ new_start_index = case_suite_num
821
+ # 以new_start_index为起点 重新遍历items
822
+ try:
823
+ for i in range(new_start_index, len(non_custom_scope_items)):
824
+ item = non_custom_scope_items[i]
825
+ id = item.callspec.id
826
+ first_part = id.split('#', 1)[-1]
827
+ index = first_part.split(']')[0]
828
+ case_dict.get(index).append(item)
829
+ except:
830
+ pass
831
+
832
+ index = 0
833
+ for id in case_dict:
834
+ index += 1
835
+ case_item_list = case_dict.get(id)
836
+ for item in case_item_list:
837
+ allure_suite_mark = f'{clase_id}#{index}'
838
+ setattr(item, 'allure_suite_mark', allure_suite_mark)
839
+ item_list += case_item_list
840
+
841
+ all_item_list += item_list
842
+
843
+ return all_item_list
@@ -5,8 +5,9 @@ from datetime import datetime
5
5
  import retry
6
6
  import pymysql
7
7
  from pymysql import MySQLError
8
-
9
8
  from dbutils.pooled_db import PooledDB
9
+
10
+ import config.settings as settings
10
11
  from framework.utils.log_util import logger
11
12
  from framework.exceptions import MysqlDBError
12
13
 
@@ -15,10 +16,16 @@ def safe_mysql_call(func):
15
16
  """装饰器:捕获 Mysql 异常并转成 MysqlDBError"""
16
17
 
17
18
  def wrapper(self, *args, **kwargs):
19
+ sql = None
20
+ # 从参数中尝试提取 SQL
21
+ if len(args) > 0 and isinstance(args[0], str):
22
+ sql = args[0]
23
+ elif 'sql' in kwargs:
24
+ sql = kwargs['sql']
18
25
  try:
19
26
  return func(self, *args, **kwargs)
20
27
  except MySQLError as e:
21
- logger.error(f"{func.__name__} 出错: {e}")
28
+ logger.error(f"{func.__name__}执行SQL出错: {sql}: {e}")
22
29
  raise MysqlDBError(e)
23
30
  except Exception as e:
24
31
  logger.error(f"{func.__name__} 未知错误: {e}")
@@ -31,11 +38,11 @@ class MysqlDB:
31
38
  def __init__(self, host, username, password, port, db, max_connections=5):
32
39
  self.__create_pool(host, username, password, port, db, max_connections)
33
40
 
34
- @retry.retry(tries=5, delay=3)
41
+ @retry.retry(tries=getattr(settings, "MYSQL_RETRIES", 5), delay=getattr(settings, "MYSQL_DELAY", 3))
35
42
  def __create_pool(self, host, username, password, port, db, max_connections):
36
43
  self.pool = PooledDB(
37
44
  creator=pymysql,
38
- maxconnections=max_connections,
45
+ maxconnections= getattr(settings, "MYSQL_MAX_CONNECTIONS", 5),
39
46
  mincached=1,
40
47
  maxcached=max_connections,
41
48
  blocking=True,
@@ -136,4 +143,4 @@ class MysqlDB:
136
143
  return obj.strftime('%Y-%m-%d %H:%M:%S') # 转换时间格式
137
144
  elif isinstance(obj, Decimal):
138
145
  return float(obj) # 转换 Decimal 为 float
139
- raise TypeError(f"Type {type(obj)} not serializable")
146
+ raise TypeError(f"Type {type(obj)} not serializable")
@@ -91,6 +91,7 @@ class GlobalAttribute(object):
91
91
  self.set(k, v)
92
92
 
93
93
  def set_from_yaml(self, filename, env, app=None):
94
+ file = None
94
95
  try:
95
96
  file = os.path.join(ROOT_DIR, filename)
96
97
  if not os.path.exists(file):
@@ -99,9 +100,11 @@ class GlobalAttribute(object):
99
100
  self.set_from_dict(dict(Box().from_yaml(filename=file, Loader=NoDatesSafeLoader).get(env)), app)
100
101
  except BoxError as e:
101
102
  logger.error(f"{file}文件内容不是字典类型:{e}")
102
- traceback.print_exc()
103
103
  pytest.exit(ExitCode.CONTEXT_YAML_DATA_FORMAT_ERROR)
104
104
 
105
+ except Exception as e:
106
+ logger.warning(f"{filename}获取{env}环境信息异常: {e}")
107
+
105
108
  def delete(self, key):
106
109
  delattr(self, key)
107
110
 
@@ -9,7 +9,7 @@ from config.settings import AVAILABLE_ENVS, ENV_CONFIG
9
9
  class PaymentType(Enum):
10
10
  """支付类型枚举"""
11
11
  DEPOSIT = 0 # 入金
12
- WITHDRAWAL = 1 # 出金
12
+ WALLET_SCANNING = 1 # 出金
13
13
  CHECKOUT = 2 # 结账
14
14
 
15
15
 
@@ -22,6 +22,7 @@ class RiskLevel(Enum):
22
22
  MEDIUM_HIGH = 3 # 中高风险
23
23
  HIGH = 4 # 高风险
24
24
  SEVERE = 5 # 严重风险
25
+ PENDING_KYT = 6 # 严重风险
25
26
 
26
27
 
27
28
  @unique
@@ -40,7 +40,7 @@ class Validate(object):
40
40
  def valid(self, validates):
41
41
  for valid_item in validates:
42
42
  key = list(valid_item.keys())[0]
43
- valid_list = [i.strip() for i in valid_item.get(key).split(",")]
43
+ valid_list = [i.strip() for i in valid_item.get(key).split(",", 1)]
44
44
  expression = valid_list[0]
45
45
  try:
46
46
  expectant_result = valid_list[1]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-api-framework-alpha
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Author: alpha
5
5
  Author-email:
6
6
  Requires-Python: >=3.6
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="pytest-api-framework-alpha", # 包名(必须唯一)
5
- version="0.3.4",
5
+ version="0.3.5",
6
6
  packages=find_packages(),
7
7
  author="alpha",
8
8
  author_email="",