dsh-origin-bridge 0.4.1 → 0.4.3

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.
package/smoke_test.py CHANGED
@@ -7,18 +7,24 @@ Exits non-zero on the first hard failure. Writes artifacts to ./out/.
7
7
  """
8
8
 
9
9
  import json
10
+ import math
10
11
  import os
11
12
  import subprocess
12
13
  import sys
13
14
  import threading
14
15
  import time
15
16
 
17
+ import originlab # only for the instance-table cleanup at the end; no COM until called
18
+
16
19
  HERE = os.path.dirname(os.path.abspath(__file__))
17
20
  OUT = os.path.join(HERE, "out")
18
21
  # Point this at the installed copy (…/node_modules/dsh-origin-bridge/server.py)
19
22
  # to regression-test what dsh actually runs, without littering that folder.
20
23
  SERVER = os.environ.get("ORIGIN_BRIDGE_SERVER") or os.path.join(HERE, "server.py")
21
24
  PY = sys.executable
25
+ # ISO-8601 local timestamp of this run, used to tell our own leftover Origin
26
+ # instances from a session that was already open.
27
+ STARTED_AT = time.strftime("%Y-%m-%dT%H:%M:%S")
22
28
 
23
29
 
24
30
  class Rpc:
@@ -112,11 +118,16 @@ def main():
112
118
 
113
119
  tools = rpc.request("tools/list").get("result", {}).get("tools", [])
114
120
  names = sorted(t["name"] for t in tools)
115
- check("tools/list returns 25", len(tools) == 25, len(tools))
121
+ check("tools/list returns 27", len(tools) == 27, len(tools))
122
+ check("实例治理工具已暴露", {"origin_instances", "origin_reclaim"} <= set(names),
123
+ [n for n in names if "instance" in n or "reclaim" in n])
124
+ # Tools that genuinely take no arguments are the only ones allowed an empty
125
+ # properties block; everything else must describe its inputs.
126
+ no_arg = {"origin_status", "origin_instances"}
116
127
  for t in tools:
117
- check("schema present: %s" % t["name"], bool(t.get("inputSchema", {}).get("properties")
118
- or t["name"] in ("origin_status",)),
119
- len(t.get("inputSchema", {}).get("properties", {})))
128
+ props = t.get("inputSchema", {}).get("properties") or {}
129
+ check("schema present: %s" % t["name"], bool(props) or t["name"] in no_arg, len(props))
130
+ check("无参工具确实无参: %s" % t["name"], t["name"] not in no_arg or not props, sorted(props))
120
131
  print(" %s" % ", ".join(names))
121
132
 
122
133
  # origin_read_file is pure Python, so it is checked in both modes.
@@ -299,15 +310,22 @@ def main():
299
310
  "data": os.path.join(HERE, "sample.dat")})
300
311
  check("chart_check reads columns from the file", pv.get("ok") and
301
312
  pv.get("columns_detected") == ["X", "Y"], (pv.get("columns_detected"), pv.get("issues")))
302
- cr = rpc.tool("origin_chart_render", {"name": preset_name,
303
- "source": os.path.join(HERE, "sample.dat"),
304
- "x": 1, "y": 2, "output_dir": OUT})
313
+ xrd = {"two_theta": [round(20 + i * 1.5, 2) for i in range(12)],
314
+ "intensity": [round(120 + 900 * math.exp(-((i - 5) ** 2) / 3.0)) for i in range(12)]}
315
+ cr = rpc.tool("origin_chart_render", {"name": preset_name, "columns": xrd,
316
+ "output_dir": OUT})
305
317
  check("chart_render delivers image+opju", cr.get("ok") and
306
318
  bool((cr.get("delivered") or {}).get("image")) and bool((cr.get("delivered") or {}).get("project")),
307
319
  (cr.get("delivered"), cr.get("code"), cr.get("message")))
308
320
  check("chart_render applied the preset titles",
309
321
  (cr.get("preset") or {}).get("x_title") and (cr.get("preset") or {}).get("y_title"),
310
322
  cr.get("preset"))
323
+ bad = rpc.tool("origin_chart_render", {"name": preset_name,
324
+ "source": os.path.join(HERE, "sample.dat"),
325
+ "x": 1, "y": 2, "output_dir": OUT})
326
+ check("chart_render refuses data missing the preset's columns",
327
+ not bad.get("ok") and bad.get("code") == "preset_columns_mismatch",
328
+ (bad.get("code"), bad.get("message")))
311
329
 
312
330
  gp = rpc.tool("origin_fit", {"worksheet": ws, "x": 1, "y": 2, "kind": "gauss"})
313
331
  check("fit preset resolves to an installed function", gp.get("ok") and gp.get("func") == "Gauss",
@@ -410,6 +428,18 @@ def main():
410
428
  check("tools still work after abuse", still.get("connected") is True, still.get("code"))
411
429
  proto.close()
412
430
 
431
+ # The child server processes this suite drives can be terminated instead of
432
+ # exiting cleanly, which leaves their Origin instance running. Reclaim only
433
+ # windowless instances that started after this run: an older one may belong
434
+ # to a live dsh session.
435
+ late = [r for r in originlab._process_rows()
436
+ if not r["window"] and str(r.get("started") or "") >= STARTED_AT]
437
+ for row in late:
438
+ originlab._close_pid(row["pid"])
439
+ left = [r for r in originlab._process_rows()
440
+ if not r["window"] and str(r.get("started") or "") >= STARTED_AT]
441
+ check("run leaves no Origin instance of its own", not left, left)
442
+
413
443
  print("\nartifacts:")
414
444
  for f in sorted(os.listdir(OUT)):
415
445
  print(" %s (%d bytes)" % (f, os.path.getsize(os.path.join(OUT, f))))
@@ -0,0 +1,232 @@
1
+ """Checks for the Origin instance-lifecycle layer: classification and reclaim.
2
+
3
+ These run without Origin: the process table and taskkill are stubbed, so the
4
+ safety property that matters -- a windowed instance is never killed -- is
5
+ asserted against a synthetic set of instances.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ import originlab # noqa: E402
14
+
15
+ fails = []
16
+
17
+
18
+ def check(label, cond, got=""):
19
+ if cond:
20
+ print(" ok %s" % label)
21
+ else:
22
+ print(" FAIL %s -> %r" % (label, got))
23
+ fails.append(label)
24
+
25
+
26
+ class Fake(object):
27
+ """Stands in for the process table and taskkill."""
28
+
29
+ def __init__(self, rows):
30
+ self.rows = rows
31
+ self.killed = []
32
+ self.forced = []
33
+
34
+ def process_rows(self):
35
+ return [dict(r) for r in self.rows]
36
+
37
+ def terminate(self, pid, force=False):
38
+ (self.forced if force else self.killed).append(pid)
39
+ if force:
40
+ self.rows = [r for r in self.rows if r["pid"] != pid]
41
+ return None
42
+
43
+ def sleep(self, _seconds):
44
+ return None
45
+
46
+
47
+ def with_fake(rows, fn):
48
+ fake = Fake(rows)
49
+ saved = (originlab._process_rows, originlab._terminate, originlab._close_pid,
50
+ originlab.time.sleep, set(originlab._started_pids))
51
+ originlab._process_rows = fake.process_rows
52
+ originlab._terminate = fake.terminate
53
+ originlab._close_pid = lambda pid: (fake.terminate(pid), fake.terminate(pid, force=True),
54
+ "force_closed")[2]
55
+ originlab.time.sleep = fake.sleep
56
+ try:
57
+ return fn(fake)
58
+ finally:
59
+ originlab._process_rows, originlab._terminate, originlab._close_pid = saved[0], saved[1], saved[2]
60
+ originlab.time.sleep = saved[3]
61
+ originlab._started_pids.clear()
62
+ originlab._started_pids.update(saved[4])
63
+
64
+
65
+ BACK = {"pid": 1001, "started": "2026-10-10T12:00:00", "window": 0}
66
+ BACK2 = {"pid": 1002, "started": "2026-10-10T12:05:00", "window": 0}
67
+ FRONT = {"pid": 2001, "started": "2026-10-10T11:00:00", "window": 131072}
68
+
69
+ print("instances() 分类")
70
+ rows = [dict(BACK), dict(BACK2), dict(FRONT)]
71
+
72
+
73
+ def _classify(_fake):
74
+ originlab._started_pids.add(1002)
75
+ info = originlab.instances()
76
+ check("后台两个", [r["pid"] for r in info["background"]] == [1001, 1002], info["background"])
77
+ check("前台一个", [r["pid"] for r in info["foreground"]] == [2001], info["foreground"])
78
+ check("count 对", info["count"] == 3, info["count"])
79
+ back_by_pid = {r["pid"]: r for r in info["background"]}
80
+ check("标出我们起的", back_by_pid[1002]["ours"] and not back_by_pid[1001]["ours"],
81
+ {p: b["ours"] for p, b in back_by_pid.items()})
82
+ check("前台窗口标记为 foreground", info["foreground"][0]["foreground"] is True)
83
+
84
+
85
+ with_fake(rows, _classify)
86
+
87
+ print("reclaim() 只动无窗口的")
88
+
89
+
90
+ def _reclaim_closes_back(_fake):
91
+ out = originlab.reclaim()
92
+ check("关掉了两个后台", sorted(_fake.forced) == [1001, 1002], _fake.forced)
93
+ check("没碰前台 2001", 2001 not in _fake.killed and 2001 not in _fake.forced,
94
+ (_fake.killed, _fake.forced))
95
+ check("left_running 里是前台", out["left_running"] == [2001], out["left_running"])
96
+ check("通知文案提到窗口", "有窗口" in out["notify"], out["notify"])
97
+
98
+
99
+ with_fake([dict(BACK), dict(BACK2), dict(FRONT)], _reclaim_closes_back)
100
+
101
+
102
+ def _reclaim_dry_run(_fake):
103
+ out = originlab.reclaim(close_background=False)
104
+ check("预览模式没杀任何进程", not _fake.killed and not _fake.forced, (_fake.killed, _fake.forced))
105
+ check("预览模式列出了待关的", sorted(out["skipped"]) == [1001, 1002], out.get("skipped"))
106
+
107
+
108
+ with_fake([dict(BACK), dict(BACK2), dict(FRONT)], _reclaim_dry_run)
109
+
110
+
111
+ def _reclaim_all_background(_fake):
112
+ out = originlab.reclaim()
113
+ check("全是后台时通知说没有前台", "没有发现前台窗口" in out["notify"], out["notify"])
114
+ check("left_running 为空", out["left_running"] == [], out["left_running"])
115
+
116
+
117
+ with_fake([dict(BACK)], _reclaim_all_background)
118
+
119
+ print("_release_on_exit() 只回收自己起的")
120
+
121
+
122
+ def _atexit_scope(_fake):
123
+ originlab._started_pids.add(1002)
124
+ killed = []
125
+ originlab._close_pid = lambda pid: killed.append(pid) or "closed"
126
+ originlab._release_on_exit()
127
+ check("只关了 1002", killed == [1002], killed)
128
+ check("1001 是别人的孤儿,没在退出时动它", 1001 not in killed, killed)
129
+
130
+
131
+ with_fake([dict(BACK), dict(BACK2), dict(FRONT)], _atexit_scope)
132
+
133
+
134
+ def _atexit_noop(_fake):
135
+ originlab._started_pids.clear()
136
+ killed = []
137
+ originlab._close_pid = lambda pid: killed.append(pid) or "closed"
138
+ originlab._release_on_exit()
139
+ check("自己没起实例时什么都不做", killed == [], killed)
140
+
141
+
142
+ with_fake([dict(BACK), dict(FRONT)], _atexit_noop)
143
+
144
+ print("探测失败时不能谎报干净")
145
+
146
+
147
+ def _probe_unavailable(_fake):
148
+ originlab._process_rows = lambda: None # powershell 不可用 / 超时
149
+ killed = []
150
+ originlab._close_pid = lambda pid: killed.append(pid) or "closed"
151
+
152
+ info = originlab.instances()
153
+ check("instances 报告探测失败", info.get("probe") == "unavailable", info)
154
+ check("instances 不假装数量为 0", info.get("count") is None, info.get("count"))
155
+ check("instances 给了下一步建议", bool(info.get("next_actions")), info.get("next_actions"))
156
+
157
+ out = originlab.reclaim()
158
+ check("reclaim 什么都没杀", not killed, killed)
159
+ check("reclaim 标出探测失败", out.get("probe") == "unavailable", out)
160
+ check("reclaim 不说『已回收全部』", "没关" in out.get("notify", "")
161
+ or "探测失败" in out.get("notify", ""), out.get("notify"))
162
+
163
+ originlab._started_pids.add(1002)
164
+ originlab._release_on_exit()
165
+ check("退出钩子在探测失败时不动手", killed == [], killed)
166
+
167
+
168
+ with_fake([dict(BACK), dict(FRONT)], _probe_unavailable)
169
+
170
+ print("掉线重连(call 层)")
171
+ _real_reset = originlab._reset_connection
172
+
173
+ check("SystemError 算掉线", originlab._dead_app(
174
+ SystemError("<built-in function ApplicationBase_Save> returned a result with an exception set")))
175
+ check("originpro 报的连接失败算掉线", originlab._dead_app(
176
+ originlab.OriginError("connection_error", "连不上 Origin 自动化服务器:"
177
+ "<built-in function ApplicationBase_Path> returned a result"
178
+ " with an exception set")))
179
+ check("参数错误不算掉线", not originlab._dead_app(
180
+ originlab.OriginError("bad_column", "列号从 1 开始,收到 0")))
181
+ check("被包成 internal_error 的 SystemError 也算掉线", originlab._dead_app(
182
+ originlab.OriginError("internal_error",
183
+ "SystemError: <built-in function ApplicationBase_Save> returned a "
184
+ "result with an exception set")))
185
+ check("普通 ValueError 不算掉线", not originlab._dead_app(ValueError("识别不出分隔符")))
186
+
187
+ tries = []
188
+
189
+
190
+ def _dies_once(*a, **k):
191
+ tries.append(1)
192
+ if len(tries) == 1:
193
+ raise SystemError("<built-in function ApplicationBase_Save> returned a result "
194
+ "with an exception set")
195
+ return "recovered"
196
+
197
+
198
+ resets = []
199
+ originlab._reset_connection = lambda: resets.append(1) or True
200
+ try:
201
+ check("掉线后自动重连并完成任务", originlab.call(_dies_once) == "recovered", tries)
202
+ check("只重试一次", len(tries) == 2 and len(resets) == 1, (tries, resets))
203
+
204
+ tries[:] = []
205
+ resety = []
206
+ originlab._reset_connection = lambda: resety.append(1) or False
207
+ try:
208
+ originlab.call(_dies_once)
209
+ check("重连不可用时不无限重试", False, "本该抛错")
210
+ except SystemError as exc:
211
+ check("重连不可用时把原始错误交出去", "ApplicationBase_Save" in str(exc), str(exc))
212
+ finally:
213
+ originlab._reset_connection = _real_reset
214
+
215
+ print("server 工具注册")
216
+ import server # noqa: E402
217
+
218
+ names = [t["name"] for t in server.TOOLS]
219
+ check("origin_instances 已注册", "origin_instances" in names, names[-3:])
220
+ check("origin_reclaim 已注册", "origin_reclaim" in names, names[-3:])
221
+ check("工具总数 27", len(names) == 27, len(names))
222
+ check("工具名唯一", len(set(names)) == len(names))
223
+ for tool in server.TOOLS:
224
+ if tool["name"] in ("origin_instances", "origin_reclaim"):
225
+ check("%s 有描述和 schema" % tool["name"],
226
+ bool(tool.get("description")) and isinstance(tool.get("inputSchema"), dict))
227
+
228
+ print()
229
+ if fails:
230
+ print("%d 项失败: %s" % (len(fails), ", ".join(fails)))
231
+ raise SystemExit(1)
232
+ print("all lifecycle checks passed")