api-engine-xin 0.0.26__tar.gz → 0.0.29__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 (36) hide show
  1. api_engine_xin-0.0.29/ApiEngine/core.py +195 -0
  2. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/base_case.py +38 -7
  3. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/precondition.py +30 -7
  4. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/infra/__init__.py +0 -1
  5. api_engine_xin-0.0.29/ApiEngine/infra/suite_store.py +98 -0
  6. api_engine_xin-0.0.29/PKG-INFO +539 -0
  7. api_engine_xin-0.0.29/README.md +512 -0
  8. api_engine_xin-0.0.29/api_engine_xin.egg-info/PKG-INFO +539 -0
  9. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/api_engine_xin.egg-info/SOURCES.txt +2 -1
  10. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/setup.py +1 -1
  11. api_engine_xin-0.0.26/ApiEngine/core.py +0 -422
  12. api_engine_xin-0.0.26/PKG-INFO +0 -672
  13. api_engine_xin-0.0.26/README.md +0 -645
  14. api_engine_xin-0.0.26/api_engine_xin.egg-info/PKG-INFO +0 -672
  15. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/__init__.py +0 -0
  16. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/__init__.py +0 -0
  17. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/assertion.py +0 -0
  18. {api_engine_xin-0.0.26/ApiEngine/infra → api_engine_xin-0.0.29/ApiEngine/engine}/exceptions.py +0 -0
  19. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/extractor.py +0 -0
  20. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/replacer.py +0 -0
  21. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/script_runner.py +0 -0
  22. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/http/__init__.py +0 -0
  23. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/http/client.py +0 -0
  24. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/http/files.py +0 -0
  25. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/infra/case_log.py +0 -0
  26. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/infra/db_client.py +0 -0
  27. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/infra/global_func.py +0 -0
  28. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/infra/test_result.py +0 -0
  29. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/LICENSE +0 -0
  30. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/api_engine_xin.egg-info/dependency_links.txt +0 -0
  31. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/api_engine_xin.egg-info/requires.txt +0 -0
  32. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/api_engine_xin.egg-info/top_level.txt +0 -0
  33. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/setup.cfg +0 -0
  34. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/tests/Tools.py +0 -0
  35. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/tests/__init__.py +0 -0
  36. {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/tests/runTest.py +0 -0
@@ -0,0 +1,195 @@
1
+ import copy
2
+ from typing import Optional
3
+
4
+ from ApiEngine import log
5
+ from ApiEngine.infra import global_func
6
+ from ApiEngine.engine.exceptions import PreconditionChainError
7
+ from ApiEngine.infra.test_result import TestResult
8
+ from ApiEngine.infra.db_client import DBClient
9
+ from ApiEngine.infra.suite_store import RunRegistry, RunStore
10
+ from ApiEngine.engine.base_case import BaseCase
11
+
12
+
13
+ class TestRunner:
14
+ def __init__(self, env_data, run_id=None):
15
+ """
16
+ :param env_data: 执行测试时的环境数据
17
+ :param run_id: 套件运行ID(传入时启用套件级变量共享)
18
+ """
19
+ # 深拷贝环境数据,避免修改调用方原始数据
20
+ self.env_data = copy.deepcopy(env_data)
21
+ # debug_updates 保持原引用,变更对上层可见(仅无 run_id 的退化路径使用)
22
+ if "debug_updates" in env_data:
23
+ self.env_data["debug_updates"] = env_data["debug_updates"]
24
+ self.result = []
25
+ self._db = DBClient()
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
29
+
30
+ def execute_cases(self, testcases):
31
+ """执行测试用例的方法"""
32
+ # 根据数据库的配置初始化数据库的连接
33
+ db_config = self.env_data.pop("db", None)
34
+ if db_config:
35
+ self._db.init_connect(db_config)
36
+
37
+ # 统一初始化共享环境
38
+ self._shared_env.clear()
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
+ self._shared_env["_run_store"] = self._suite_store # 供 BaseCase 心跳 touch
47
+
48
+ self._load_global_func()
49
+
50
+ # 判断测试数据参数的类型
51
+ if isinstance(testcases, dict):
52
+ cases = testcases.get("cases")
53
+ if cases:
54
+ log.info_log("执行测试套件:", testcases["name"])
55
+ test_result = TestResult(all=len(testcases["cases"]), name=testcases["name"])
56
+ for case in testcases["cases"]:
57
+ log.info_log(case)
58
+ self.perform_case(case, test_result)
59
+ res = test_result.get_result_info()
60
+ self.result.append(res)
61
+ else:
62
+ log.info_log("调试单条接口用例:", testcases["title"])
63
+ test_result = TestResult(all=1)
64
+ self.perform_case(testcases, test_result)
65
+ res = test_result.get_result_info()
66
+ self.result = res["cases"][0]
67
+ elif isinstance(testcases, list):
68
+ results = []
69
+ for items in testcases:
70
+ log.info_log("执行测试套件:", items["name"])
71
+ test_result = TestResult(all=len(items["cases"]), name=items["name"])
72
+ for case in items["cases"]:
73
+ self.perform_case(case, test_result)
74
+ res = test_result.get_result_info()
75
+ results.append(res)
76
+ total_all = 0
77
+ total_success = 0
78
+ total_fail = 0
79
+ total_error = 0
80
+ for scence_result in results:
81
+ total_all += scence_result["all"]
82
+ total_success += scence_result["success"]
83
+ total_fail += scence_result["fail"]
84
+ total_error += scence_result["error"]
85
+ self.result = {
86
+ "results": results,
87
+ "all": total_all,
88
+ "success": total_success,
89
+ "fail": total_fail,
90
+ "error": total_error
91
+ }
92
+ else:
93
+ log.error_log("测试数据格式错误")
94
+
95
+ # 断开数据库连接
96
+ if db_config:
97
+ self._db.close_db_connect()
98
+ return self.result
99
+
100
+ def perform_case(self, case, test_result):
101
+ """执行单条用例"""
102
+ # 心跳:每开始一条用例就刷新活跃时间,防止长套件被 cleanup 误清
103
+ if self._run_id:
104
+ RunRegistry.touch(self._run_id)
105
+ # 每次创建新的 BaseCase 实例,传入共享环境引用
106
+ c = BaseCase(shared_env=self._shared_env)
107
+ c._db = self._db
108
+ try:
109
+ c.perform(case)
110
+ except PreconditionChainError:
111
+ test_result.add_fail(c)
112
+ except AssertionError:
113
+ test_result.add_fail(c)
114
+ except Exception as e:
115
+ test_result.add_error(c, e)
116
+ else:
117
+ test_result.add_success(c)
118
+
119
+ def _load_global_func(self):
120
+ """安全加载用户自定义函数,避免跨执行污染"""
121
+ _gf = self.env_data.get("global_func")
122
+ if not (_gf and isinstance(_gf, str)):
123
+ return
124
+ # 只保留 __name__ 等双下划线内置属性
125
+ builtins = {k: v for k, v in global_func.__dict__.items() if k.startswith('__')}
126
+ global_func.__dict__.clear()
127
+ global_func.__dict__.update(builtins)
128
+ exec(_gf, global_func.__dict__)
129
+
130
+ def get_env_snapshot(self) -> dict:
131
+ """获取执行后的环境变量快照(供上层平台读取)
132
+
133
+ 返回包含 envs、debug_updates 的字典,
134
+ 用于上层平台同步临时变量和持久化全局变量变更。
135
+
136
+ debug_updates 中的约定:
137
+ - value 非 None → 新增/更新全局变量
138
+ - value 为 None → 删除全局变量
139
+ """
140
+ return {
141
+ "envs": copy.deepcopy(dict(self._shared_env.get("envs") or {})),
142
+ "debug_updates": copy.deepcopy(self._shared_env.get("debug_updates") or {}),
143
+ }
144
+
145
+ # ==================== 套件级共享变量:生命周期管理 ====================
146
+
147
+ @classmethod
148
+ def register_run(cls, run_id: str, initial_envs: dict) -> None:
149
+ """套件开始前注册共享存储。
150
+
151
+ :param run_id: 唯一运行标识(建议格式: suite-{id}-{run_id}-{timestamp})
152
+ :param initial_envs: 初始环境变量(用户配置的基线值,含 DB 全局变量)
153
+ """
154
+ RunRegistry.register(run_id, initial_envs)
155
+
156
+ @classmethod
157
+ def unregister_run(cls, run_id: str) -> None:
158
+ """套件结束后清理共享存储。务必在 finally 中调用。"""
159
+ RunRegistry.unregister(run_id)
160
+
161
+ @classmethod
162
+ def get_debug_updates(cls, run_id: str) -> dict:
163
+ """获取需要持久化到 DB 的全局变量变更队列。
164
+
165
+ 返回 dict 副本:
166
+ - value 非 None → upsert
167
+ - value 为 None → delete
168
+ """
169
+ store = RunRegistry.get(run_id)
170
+ if not store:
171
+ return {}
172
+ with store.lock:
173
+ return dict(store.debug_updates)
174
+
175
+ @classmethod
176
+ def clear_debug_updates(cls, run_id: str) -> None:
177
+ """重置变更队列(平台写 DB 后调用,避免重复写入)。"""
178
+ store = RunRegistry.get(run_id)
179
+ if store:
180
+ with store.lock:
181
+ store.debug_updates.clear()
182
+
183
+ @classmethod
184
+ def get_run_globals(cls, run_id: str) -> dict:
185
+ """获取当前套件累积的全部环境变量(含初始基线 + 运行时设置)。"""
186
+ store = RunRegistry.get(run_id)
187
+ if not store:
188
+ return {}
189
+ with store.lock:
190
+ return dict(store.envs)
191
+
192
+ @classmethod
193
+ def cleanup_stale_runs(cls, max_age_seconds: float = 3600) -> None:
194
+ """清理超时的残留 store(防止异常中断导致内存泄漏)。"""
195
+ RunRegistry.cleanup_stale(max_age_seconds)
@@ -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()
@@ -244,8 +255,18 @@ class BaseCase(CaseLogHandler):
244
255
 
245
256
  # ==================== 环境变量操作 ====================
246
257
 
258
+ def _touch_store(self):
259
+ """心跳:刷新 RunStore 活跃时间,防止长运行被误清"""
260
+ try:
261
+ run_store = self._shared_env.get("_run_store")
262
+ if run_store:
263
+ run_store.touch()
264
+ except Exception:
265
+ pass
266
+
247
267
  def _save_env_variable(self, key, value):
248
268
  """保存临时环境变量"""
269
+ self._touch_store()
249
270
  self.info_log(f"保存(临时)环境变量:{key} = {value}")
250
271
  self.env[key] = value
251
272
  try:
@@ -261,11 +282,20 @@ class BaseCase(CaseLogHandler):
261
282
 
262
283
  def del_evn_variable(self, key):
263
284
  """删除测试运行环境变量"""
285
+ self._touch_store()
264
286
  self.info_log(f"删除(临时)环境变量:{key}")
265
- del self.env[key]
287
+ self.env.pop(key, None)
288
+ # 同时从套件级共享存储中删除,防止后续用例读到已删除的变量
289
+ try:
290
+ envs = self._shared_env.get("envs")
291
+ if isinstance(envs, dict):
292
+ envs.pop(key, None)
293
+ except Exception:
294
+ pass
266
295
 
267
296
  def save_global_variable(self, key, value):
268
297
  """保存测试运行环境的全局变量"""
298
+ self._touch_store()
269
299
  self.info_log(f"保存全局变量:{key} = {value}")
270
300
  envs = self._shared_env.get("envs")
271
301
  if isinstance(envs, dict):
@@ -280,6 +310,7 @@ class BaseCase(CaseLogHandler):
280
310
 
281
311
  def del_global_variable(self, key):
282
312
  """删除测试运行环境的全局变量"""
313
+ self._touch_store()
283
314
  self.info_log(f"删除全局变量:{key}")
284
315
  envs = self._shared_env.get("envs")
285
316
  if isinstance(envs, dict) and key in envs:
@@ -1,4 +1,4 @@
1
- from ApiEngine.infra.exceptions import PreconditionChainError
1
+ from ApiEngine.engine.exceptions import PreconditionChainError
2
2
  from ApiEngine.http.client import HttpClient
3
3
  from ApiEngine.engine.script_runner import ScriptRunner
4
4
  from ApiEngine.engine.extractor import Extractor
@@ -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:
@@ -1,3 +1,2 @@
1
1
  # ApiEngine 基础设施层
2
2
  from ApiEngine.infra.case_log import CaseLogHandler
3
- from ApiEngine.infra.exceptions import PreconditionChainError
@@ -0,0 +1,98 @@
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: 注册时间戳(仅用于诊断日志)
31
+ last_active_at: 最后一次变量读写的时间戳(cleanup 依据)
32
+ """
33
+ envs: dict = field(default_factory=dict)
34
+ debug_updates: dict = field(default_factory=dict)
35
+ temp_keys: Set[str] = field(default_factory=set)
36
+ lock: threading.Lock = field(default_factory=threading.Lock)
37
+ created_at: float = field(default_factory=time.time)
38
+ last_active_at: float = field(default_factory=time.time)
39
+
40
+ def touch(self) -> None:
41
+ """刷新活跃时间,防止长运行套件被 cleanup_stale 误清。"""
42
+ self.last_active_at = time.time()
43
+
44
+
45
+ class RunRegistry:
46
+ """类级别注册表:run_id → RunStore。
47
+
48
+ 串行场景:后续用例可读到前面用例设置的变量。
49
+ 并行场景:lock 保护 dict 操作,last-write-wins。
50
+ """
51
+ _stores: Dict[str, RunStore] = {}
52
+ _registry_lock = threading.Lock()
53
+
54
+ @classmethod
55
+ def register(cls, run_id: str, initial_envs: dict) -> RunStore:
56
+ """注册套件运行,创建共享存储。"""
57
+ with cls._registry_lock:
58
+ store = RunStore(envs=dict(initial_envs or {}))
59
+ cls._stores[run_id] = store
60
+ return store
61
+
62
+ @classmethod
63
+ def get(cls, run_id: str) -> Optional[RunStore]:
64
+ """获取指定运行的共享存储。"""
65
+ return cls._stores.get(run_id)
66
+
67
+ @classmethod
68
+ def unregister(cls, run_id: str) -> None:
69
+ """清理指定运行的共享存储。务必在 finally 中调用。"""
70
+ with cls._registry_lock:
71
+ cls._stores.pop(run_id, None)
72
+
73
+ @classmethod
74
+ def touch(cls, run_id: str) -> None:
75
+ """刷新指定运行的活跃时间。变量读写时调用。"""
76
+ store = cls._stores.get(run_id)
77
+ if store:
78
+ store.touch()
79
+
80
+ @classmethod
81
+ def cleanup_stale(cls, max_age_seconds: float = 3600) -> int:
82
+ """清理超时的残留 store(防止异常中断导致内存泄漏)。
83
+
84
+ 依据 last_active_at(最后一次变量读写)而非 created_at,
85
+ 因此长时间运行但仍在活跃的套件不会被误清。
86
+
87
+ :param max_age_seconds: 超时阈值(秒),默认1小时
88
+ :return: 清理数量
89
+ """
90
+ now = time.time()
91
+ with cls._registry_lock:
92
+ stale = [
93
+ rid for rid, s in cls._stores.items()
94
+ if now - s.last_active_at > max_age_seconds
95
+ ]
96
+ for rid in stale:
97
+ cls._stores.pop(rid, None)
98
+ return len(stale)