api-engine-xin 0.0.25__tar.gz → 0.0.28__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 (33) hide show
  1. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/core.py +81 -2
  2. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/engine/base_case.py +28 -10
  3. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/engine/precondition.py +29 -6
  4. api_engine_xin-0.0.28/ApiEngine/infra/suite_store.py +82 -0
  5. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/PKG-INFO +1 -1
  6. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/api_engine_xin.egg-info/PKG-INFO +1 -1
  7. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/api_engine_xin.egg-info/SOURCES.txt +1 -1
  8. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/setup.py +1 -1
  9. api_engine_xin-0.0.25/ApiEngine/BaseCase.py +0 -2
  10. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/__init__.py +0 -0
  11. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/engine/__init__.py +0 -0
  12. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/engine/assertion.py +0 -0
  13. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/engine/extractor.py +0 -0
  14. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/engine/replacer.py +0 -0
  15. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/engine/script_runner.py +0 -0
  16. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/http/__init__.py +0 -0
  17. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/http/client.py +0 -0
  18. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/http/files.py +0 -0
  19. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/infra/__init__.py +0 -0
  20. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/infra/case_log.py +0 -0
  21. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/infra/db_client.py +0 -0
  22. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/infra/exceptions.py +0 -0
  23. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/infra/global_func.py +0 -0
  24. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/ApiEngine/infra/test_result.py +0 -0
  25. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/LICENSE +0 -0
  26. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/README.md +0 -0
  27. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/api_engine_xin.egg-info/dependency_links.txt +0 -0
  28. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/api_engine_xin.egg-info/requires.txt +0 -0
  29. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/api_engine_xin.egg-info/top_level.txt +0 -0
  30. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/setup.cfg +0 -0
  31. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/tests/Tools.py +0 -0
  32. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/tests/__init__.py +0 -0
  33. {api_engine_xin-0.0.25 → api_engine_xin-0.0.28}/tests/runTest.py +0 -0
@@ -1,26 +1,31 @@
1
1
  import copy
2
+ from typing import Optional
2
3
 
3
4
  from ApiEngine import log
4
5
  from ApiEngine.infra import global_func
5
6
  from ApiEngine.infra.exceptions import PreconditionChainError
6
7
  from ApiEngine.infra.test_result import TestResult
7
8
  from ApiEngine.infra.db_client import DBClient
9
+ from ApiEngine.infra.suite_store import RunRegistry, RunStore
8
10
  from ApiEngine.engine.base_case import BaseCase
9
11
 
10
12
 
11
13
  class TestRunner:
12
- def __init__(self, env_data):
14
+ def __init__(self, env_data, run_id=None):
13
15
  """
14
16
  :param env_data: 执行测试时的环境数据
17
+ :param run_id: 套件运行ID(传入时启用套件级变量共享)
15
18
  """
16
19
  # 深拷贝环境数据,避免修改调用方原始数据
17
20
  self.env_data = copy.deepcopy(env_data)
18
- # debug_updates 保持原引用,变更对上层可见
21
+ # debug_updates 保持原引用,变更对上层可见(仅无 run_id 的退化路径使用)
19
22
  if "debug_updates" in env_data:
20
23
  self.env_data["debug_updates"] = env_data["debug_updates"]
21
24
  self.result = []
22
25
  self._db = DBClient()
23
26
  self._shared_env = {} # 实例级共享环境
27
+ self._run_id = run_id
28
+ self._suite_store: Optional[RunStore] = RunRegistry.get(run_id) if run_id else None
24
29
 
25
30
  def execute_cases(self, testcases):
26
31
  """执行测试用例的方法"""
@@ -32,6 +37,13 @@ class TestRunner:
32
37
  # 统一初始化共享环境
33
38
  self._shared_env.clear()
34
39
  self._shared_env.update(self.env_data)
40
+
41
+ # 如果有 run_id,将 _shared_env["envs"] 和 debug_updates 指向套件级存储
42
+ # 这样所有变量读写(包括 Replacer、用户脚本、前置用例提取)自动走 _suite_stores
43
+ if self._suite_store:
44
+ self._shared_env["envs"] = self._suite_store.envs
45
+ self._shared_env["debug_updates"] = self._suite_store.debug_updates
46
+
35
47
  self._load_global_func()
36
48
 
37
49
  # 判断测试数据参数的类型
@@ -111,6 +123,73 @@ class TestRunner:
111
123
  global_func.__dict__.update(builtins)
112
124
  exec(_gf, global_func.__dict__)
113
125
 
126
+ def get_env_snapshot(self) -> dict:
127
+ """获取执行后的环境变量快照(供上层平台读取)
128
+
129
+ 返回包含 envs、debug_updates 的字典,
130
+ 用于上层平台同步临时变量和持久化全局变量变更。
131
+
132
+ debug_updates 中的约定:
133
+ - value 非 None → 新增/更新全局变量
134
+ - value 为 None → 删除全局变量
135
+ """
136
+ return {
137
+ "envs": copy.deepcopy(dict(self._shared_env.get("envs") or {})),
138
+ "debug_updates": copy.deepcopy(self._shared_env.get("debug_updates") or {}),
139
+ }
140
+
141
+ # ==================== 套件级共享变量:生命周期管理 ====================
142
+
143
+ @classmethod
144
+ def register_run(cls, run_id: str, initial_envs: dict) -> None:
145
+ """套件开始前注册共享存储。
146
+
147
+ :param run_id: 唯一运行标识(建议格式: suite-{id}-{run_id}-{timestamp})
148
+ :param initial_envs: 初始环境变量(用户配置的基线值,含 DB 全局变量)
149
+ """
150
+ RunRegistry.register(run_id, initial_envs)
151
+
152
+ @classmethod
153
+ def unregister_run(cls, run_id: str) -> None:
154
+ """套件结束后清理共享存储。务必在 finally 中调用。"""
155
+ RunRegistry.unregister(run_id)
156
+
157
+ @classmethod
158
+ def get_debug_updates(cls, run_id: str) -> dict:
159
+ """获取需要持久化到 DB 的全局变量变更队列。
160
+
161
+ 返回 dict 副本:
162
+ - value 非 None → upsert
163
+ - value 为 None → delete
164
+ """
165
+ store = RunRegistry.get(run_id)
166
+ if not store:
167
+ return {}
168
+ with store.lock:
169
+ return dict(store.debug_updates)
170
+
171
+ @classmethod
172
+ def clear_debug_updates(cls, run_id: str) -> None:
173
+ """重置变更队列(平台写 DB 后调用,避免重复写入)。"""
174
+ store = RunRegistry.get(run_id)
175
+ if store:
176
+ with store.lock:
177
+ store.debug_updates.clear()
178
+
179
+ @classmethod
180
+ def get_run_globals(cls, run_id: str) -> dict:
181
+ """获取当前套件累积的全部环境变量(含初始基线 + 运行时设置)。"""
182
+ store = RunRegistry.get(run_id)
183
+ if not store:
184
+ return {}
185
+ with store.lock:
186
+ return dict(store.envs)
187
+
188
+ @classmethod
189
+ def cleanup_stale_runs(cls, max_age_seconds: float = 3600) -> None:
190
+ """清理超时的残留 store(防止异常中断导致内存泄漏)。"""
191
+ RunRegistry.cleanup_stale(max_age_seconds)
192
+
114
193
 
115
194
  if __name__ == '__main__':
116
195
  import os
@@ -41,9 +41,13 @@ class BaseCase(CaseLogHandler):
41
41
 
42
42
  # ==================== HTTP 请求 ====================
43
43
 
44
- def _send_request(self, data):
44
+ def _send_request(self, data, http_client=None):
45
45
  """构建并发送请求"""
46
- http_client = HttpClient()
46
+ if http_client is None:
47
+ http_client = HttpClient()
48
+ self._owns_http_client = True
49
+ else:
50
+ self._owns_http_client = False
47
51
  self._http_client = http_client
48
52
  replacer = Replacer(self._shared_env, self.env, self.info_log)
49
53
  request_data = http_client.build_request(data, self._shared_env, replacer)
@@ -182,12 +186,18 @@ class BaseCase(CaseLogHandler):
182
186
  self._assert_results = []
183
187
  case_name = data.get('title')
184
188
  has_failure = False
189
+ shared_http_client = None
185
190
 
186
191
  try:
192
+ # 创建共享 HTTP session(前置步骤 + 主用例共用 Cookie)
193
+ shared_http_client = HttpClient()
194
+
187
195
  # 1、前置条件链
188
196
  if data.get("preconditions"):
189
197
  self.info_log("========== 开始执行前置步骤链 ==========")
190
- precond_executor = PreconditionExecutor(self._shared_env, self._db)
198
+ precond_executor = PreconditionExecutor(
199
+ self._shared_env, self._db, http_client=shared_http_client
200
+ )
191
201
  self._precondition_errors, self._precondition_results = precond_executor.execute(
192
202
  data.get("preconditions"), depth=1, log_handler=self
193
203
  )
@@ -203,7 +213,7 @@ class BaseCase(CaseLogHandler):
203
213
  # 2、前置脚本 + 发送请求
204
214
  self.info_log(f"开始执行用例步骤:{case_name}")
205
215
  self._setup_script(data)
206
- response = self._send_request(data)
216
+ response = self._send_request(data, http_client=shared_http_client)
207
217
 
208
218
  # 3、数据提取
209
219
  if data.get("extract"):
@@ -232,9 +242,10 @@ class BaseCase(CaseLogHandler):
232
242
  raise
233
243
 
234
244
  finally:
235
- # 关闭 http client
245
+ # 关闭共享 http client
246
+ if shared_http_client:
247
+ shared_http_client.close()
236
248
  if hasattr(self, '_http_client'):
237
- self._http_client.close()
238
249
  delattr(self, '_http_client')
239
250
 
240
251
  end_time = time.time()
@@ -262,7 +273,14 @@ class BaseCase(CaseLogHandler):
262
273
  def del_evn_variable(self, key):
263
274
  """删除测试运行环境变量"""
264
275
  self.info_log(f"删除(临时)环境变量:{key}")
265
- del self.env[key]
276
+ self.env.pop(key, None)
277
+ # 同时从套件级共享存储中删除,防止后续用例读到已删除的变量
278
+ try:
279
+ envs = self._shared_env.get("envs")
280
+ if isinstance(envs, dict):
281
+ envs.pop(key, None)
282
+ except Exception:
283
+ pass
266
284
 
267
285
  def save_global_variable(self, key, value):
268
286
  """保存测试运行环境的全局变量"""
@@ -284,11 +302,11 @@ class BaseCase(CaseLogHandler):
284
302
  envs = self._shared_env.get("envs")
285
303
  if isinstance(envs, dict) and key in envs:
286
304
  del envs[key]
287
- # debug_updates 中移除,上层不再同步该变量到DB
305
+ # debug_updates 中标记为 None,通知上层从数据库删除
288
306
  try:
289
307
  debug_updates = self._shared_env.get("debug_updates")
290
- if isinstance(debug_updates, dict) and key in debug_updates:
291
- del debug_updates[key]
308
+ if isinstance(debug_updates, dict):
309
+ debug_updates[key] = None
292
310
  except Exception:
293
311
  pass
294
312
 
@@ -8,11 +8,12 @@ from ApiEngine.engine.assertion import AssertionEngine
8
8
  class PreconditionExecutor:
9
9
  """前置条件递归执行器(深度优先 DFS)"""
10
10
 
11
- def __init__(self, shared_env, db):
11
+ def __init__(self, shared_env, db, http_client=None):
12
12
  self._shared_env = shared_env
13
13
  self._db = db
14
14
  self._extractor = Extractor()
15
15
  self._precondition_results = []
16
+ self._shared_http_client = http_client # 共享 HTTP session
16
17
 
17
18
  def execute(self, steps, depth=1, failure_mode="continue", log_handler=None):
18
19
  """
@@ -47,6 +48,7 @@ class PreconditionExecutor:
47
48
 
48
49
  # 单步执行
49
50
  step_error = None
51
+ has_assertion_error = False
50
52
  response = None
51
53
  step_assert_results = []
52
54
  step_extract_results = []
@@ -65,9 +67,12 @@ class PreconditionExecutor:
65
67
  )
66
68
  next(script_hook)
67
69
 
68
- # 发送请求
70
+ # 发送请求:使用共享 session,无共享则创建临时实例
69
71
  from ApiEngine.engine.replacer import Replacer
70
- http_client = HttpClient()
72
+ if self._shared_http_client:
73
+ http_client = self._shared_http_client
74
+ else:
75
+ http_client = HttpClient()
71
76
  temp_env = {}
72
77
  replacer = Replacer(self._shared_env, temp_env, log_handler.info_log)
73
78
  request_data = http_client.build_request(step, self._shared_env, replacer)
@@ -180,6 +185,13 @@ class PreconditionExecutor:
180
185
  log_handler.info_log(f"{prefix} 断言汇总:{total} 个全部通过 ✅")
181
186
 
182
187
  except AssertionError as e:
188
+ has_assertion_error = True
189
+ step_error = {
190
+ "level": depth,
191
+ "step_title": title,
192
+ "error_type": "ASSERTION_FAILED",
193
+ "message": str(e)
194
+ }
183
195
  log_handler.warning_log(f"{prefix}❌ [L{depth}] 存在断言失败: {title} — {e}")
184
196
 
185
197
  except Exception as e:
@@ -199,8 +211,8 @@ class PreconditionExecutor:
199
211
  except StopIteration:
200
212
  pass
201
213
 
202
- # 关闭 http client
203
- if http_client:
214
+ # 仅关闭非共享的 http client
215
+ if http_client and not self._shared_http_client:
204
216
  http_client.close()
205
217
 
206
218
  # 构建步骤结果
@@ -213,8 +225,16 @@ class PreconditionExecutor:
213
225
  _elapsed = "{} ms".format(int(response.elapsed.total_seconds() * 1000))
214
226
  except Exception:
215
227
  pass
228
+ # 推断步骤状态
229
+ if step_error:
230
+ step_status = "fail"
231
+ elif has_assertion_error:
232
+ step_status = "fail"
233
+ else:
234
+ step_status = "success"
216
235
  step_result = {
217
236
  "title": title,
237
+ "status": step_status,
218
238
  "status_code": getattr(log_handler, 'status_code', ''),
219
239
  "response_headers": dict(getattr(log_handler, 'response_headers', {}) or {}),
220
240
  "response_body": getattr(log_handler, 'response_body', ''),
@@ -227,7 +247,10 @@ class PreconditionExecutor:
227
247
  "extract_info": list(step_extract_results),
228
248
  }
229
249
  self._precondition_results.append(step_result)
230
- log_handler.info_log(f"{prefix}✅ [L{depth}] 前置完成: {title}")
250
+ if step_error or has_assertion_error:
251
+ log_handler.warning_log(f"{prefix}❌ [L{depth}] 前置失败: {title}")
252
+ else:
253
+ log_handler.info_log(f"{prefix}✅ [L{depth}] 前置完成: {title}")
231
254
 
232
255
  # 根据 failure_mode 决定是否继续
233
256
  if step_error:
@@ -0,0 +1,82 @@
1
+ """套件级共享变量存储(跨 TestRunner 实例)。
2
+
3
+ 设计目的:
4
+ 同一个套件内的多个用例各自创建独立的 TestRunner 实例,
5
+ 但需要共享变量(save_env_variable / save_global_variable 设置的值)。
6
+ 本模块提供类级别的注册表,用 run_id 关联同一套件的所有实例。
7
+
8
+ 线程安全:
9
+ 所有 dict 写操作通过 threading.Lock 保护,支持并行用例场景。
10
+
11
+ 生命周期:
12
+ 平台层在套件开始前调用 register_run(),结束后调用 unregister_run()。
13
+ cleanup_stale() 兜底清理异常中断导致的残留。
14
+ """
15
+
16
+ import threading
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from typing import Dict, Optional, Set
20
+
21
+
22
+ @dataclass
23
+ class RunStore:
24
+ """单次套件运行的共享变量存储。
25
+
26
+ envs: 套件级累积环境变量(含初始基线 + 运行中写入)
27
+ debug_updates: 需要持久化到 DB 的全局变量变更队列
28
+ temp_keys: 标记哪些 key 是 save_env_variable 写入的(区分 temp 与 global)
29
+ lock: 保护并发写操作
30
+ created_at: 注册时间戳(用于 TTL 清理)
31
+ """
32
+ envs: dict = field(default_factory=dict)
33
+ debug_updates: dict = field(default_factory=dict)
34
+ temp_keys: Set[str] = field(default_factory=set)
35
+ lock: threading.Lock = field(default_factory=threading.Lock)
36
+ created_at: float = field(default_factory=time.time)
37
+
38
+
39
+ class RunRegistry:
40
+ """类级别注册表:run_id → RunStore。
41
+
42
+ 串行场景:后续用例可读到前面用例设置的变量。
43
+ 并行场景:lock 保护 dict 操作,last-write-wins。
44
+ """
45
+ _stores: Dict[str, RunStore] = {}
46
+ _registry_lock = threading.Lock()
47
+
48
+ @classmethod
49
+ def register(cls, run_id: str, initial_envs: dict) -> RunStore:
50
+ """注册套件运行,创建共享存储。"""
51
+ with cls._registry_lock:
52
+ store = RunStore(envs=dict(initial_envs or {}))
53
+ cls._stores[run_id] = store
54
+ return store
55
+
56
+ @classmethod
57
+ def get(cls, run_id: str) -> Optional[RunStore]:
58
+ """获取指定运行的共享存储。"""
59
+ return cls._stores.get(run_id)
60
+
61
+ @classmethod
62
+ def unregister(cls, run_id: str) -> None:
63
+ """清理指定运行的共享存储。务必在 finally 中调用。"""
64
+ with cls._registry_lock:
65
+ cls._stores.pop(run_id, None)
66
+
67
+ @classmethod
68
+ def cleanup_stale(cls, max_age_seconds: float = 3600) -> int:
69
+ """清理超时的残留 store(防止异常中断导致内存泄漏)。
70
+
71
+ :param max_age_seconds: 超时阈值(秒),默认1小时
72
+ :return: 清理数量
73
+ """
74
+ now = time.time()
75
+ with cls._registry_lock:
76
+ stale = [
77
+ rid for rid, s in cls._stores.items()
78
+ if now - s.created_at > max_age_seconds
79
+ ]
80
+ for rid in stale:
81
+ cls._stores.pop(rid, None)
82
+ return len(stale)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: api_engine_xin
3
- Version: 0.0.25
3
+ Version: 0.0.28
4
4
  Summary: 接口测试平台测试用例执行引擎
5
5
  Home-page: https://pypi.org/project/api_engine_xin/
6
6
  Author: Shawn
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: api-engine-xin
3
- Version: 0.0.25
3
+ Version: 0.0.28
4
4
  Summary: 接口测试平台测试用例执行引擎
5
5
  Home-page: https://pypi.org/project/api_engine_xin/
6
6
  Author: Shawn
@@ -1,7 +1,6 @@
1
1
  LICENSE
2
2
  README.md
3
3
  setup.py
4
- ApiEngine/BaseCase.py
5
4
  ApiEngine/__init__.py
6
5
  ApiEngine/core.py
7
6
  ApiEngine/engine/__init__.py
@@ -19,6 +18,7 @@ ApiEngine/infra/case_log.py
19
18
  ApiEngine/infra/db_client.py
20
19
  ApiEngine/infra/exceptions.py
21
20
  ApiEngine/infra/global_func.py
21
+ ApiEngine/infra/suite_store.py
22
22
  ApiEngine/infra/test_result.py
23
23
  api_engine_xin.egg-info/PKG-INFO
24
24
  api_engine_xin.egg-info/SOURCES.txt
@@ -9,7 +9,7 @@ with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh:
9
9
 
10
10
  setup(
11
11
  name="api_engine_xin",
12
- version="0.0.25",
12
+ version="0.0.28",
13
13
  author="Shawn",
14
14
  author_email="xiaoh0525@xiaoh.com",
15
15
  description="接口测试平台测试用例执行引擎",
@@ -1,2 +0,0 @@
1
- # 向后兼容层 — 旧代码可通过 from ApiEngine.BaseCase import BaseCase 导入
2
- from ApiEngine.engine.base_case import BaseCase
File without changes