dsh-origin-bridge 0.4.0 → 0.4.2
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/README.md +75 -14
- package/asciiio.py +83 -13
- package/cases/catalogue.py +636 -0
- package/cases/data.py +120 -0
- package/docs/preview.png +0 -0
- package/edge_checks.py +11 -1
- package/originlab.py +415 -48
- package/package.json +80 -74
- package/render_realdata.py +99 -0
- package/run_cases.py +304 -0
- package/scripts/make_preview.py +77 -0
- package/server.py +604 -577
- package/smoke_test.py +28 -6
- package/templates_catalog.py +2 -2
- package/test_lifecycle.py +232 -0
package/smoke_test.py
CHANGED
|
@@ -13,12 +13,17 @@ import sys
|
|
|
13
13
|
import threading
|
|
14
14
|
import time
|
|
15
15
|
|
|
16
|
+
import originlab # only for the instance-table cleanup at the end; no COM until called
|
|
17
|
+
|
|
16
18
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
17
19
|
OUT = os.path.join(HERE, "out")
|
|
18
20
|
# Point this at the installed copy (…/node_modules/dsh-origin-bridge/server.py)
|
|
19
21
|
# to regression-test what dsh actually runs, without littering that folder.
|
|
20
22
|
SERVER = os.environ.get("ORIGIN_BRIDGE_SERVER") or os.path.join(HERE, "server.py")
|
|
21
23
|
PY = sys.executable
|
|
24
|
+
# ISO-8601 local timestamp of this run, used to tell our own leftover Origin
|
|
25
|
+
# instances from a session that was already open.
|
|
26
|
+
STARTED_AT = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
22
27
|
|
|
23
28
|
|
|
24
29
|
class Rpc:
|
|
@@ -112,11 +117,16 @@ def main():
|
|
|
112
117
|
|
|
113
118
|
tools = rpc.request("tools/list").get("result", {}).get("tools", [])
|
|
114
119
|
names = sorted(t["name"] for t in tools)
|
|
115
|
-
check("tools/list returns
|
|
120
|
+
check("tools/list returns 27", len(tools) == 27, len(tools))
|
|
121
|
+
check("实例治理工具已暴露", {"origin_instances", "origin_reclaim"} <= set(names),
|
|
122
|
+
[n for n in names if "instance" in n or "reclaim" in n])
|
|
123
|
+
# Tools that genuinely take no arguments are the only ones allowed an empty
|
|
124
|
+
# properties block; everything else must describe its inputs.
|
|
125
|
+
no_arg = {"origin_status", "origin_instances"}
|
|
116
126
|
for t in tools:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
props = t.get("inputSchema", {}).get("properties") or {}
|
|
128
|
+
check("schema present: %s" % t["name"], bool(props) or t["name"] in no_arg, len(props))
|
|
129
|
+
check("无参工具确实无参: %s" % t["name"], t["name"] not in no_arg or not props, sorted(props))
|
|
120
130
|
print(" %s" % ", ".join(names))
|
|
121
131
|
|
|
122
132
|
# origin_read_file is pure Python, so it is checked in both modes.
|
|
@@ -241,7 +251,7 @@ def main():
|
|
|
241
251
|
lyr.get("layer_name_from_origin"))
|
|
242
252
|
check("add_layer proof", lyr.get("proof_level") == "verified", lyr.get("proof_level"))
|
|
243
253
|
|
|
244
|
-
# --- merged
|
|
254
|
+
# --- merged features: multi-series, dual-Y, legend, matrix, presets ---
|
|
245
255
|
ms = rpc.tool("origin_plot", {"worksheet": wb["worksheet"], "x": 1, "y": [2, 3],
|
|
246
256
|
"plot_type": "line_symbol"})
|
|
247
257
|
check("multi-series in one layer", ms.get("ok") and ms.get("plots_in_layer") == 2
|
|
@@ -318,7 +328,7 @@ def main():
|
|
|
318
328
|
|
|
319
329
|
import tempfile as _tf
|
|
320
330
|
def _previews():
|
|
321
|
-
return len([f for f in os.listdir(_tf.gettempdir()) if f.startswith("
|
|
331
|
+
return len([f for f in os.listdir(_tf.gettempdir()) if f.startswith("origin_bridge_view_")])
|
|
322
332
|
before = _previews()
|
|
323
333
|
vw = rpc.tool("origin_view", {"graph": gr_handle, "width": 700})
|
|
324
334
|
check("view returns image content", "image" in vw.get("_content_types", []) and vw.get("_image_len", 0) > 2000,
|
|
@@ -410,6 +420,18 @@ def main():
|
|
|
410
420
|
check("tools still work after abuse", still.get("connected") is True, still.get("code"))
|
|
411
421
|
proto.close()
|
|
412
422
|
|
|
423
|
+
# The child server processes this suite drives can be terminated instead of
|
|
424
|
+
# exiting cleanly, which leaves their Origin instance running. Reclaim only
|
|
425
|
+
# windowless instances that started after this run: an older one may belong
|
|
426
|
+
# to a live dsh session.
|
|
427
|
+
late = [r for r in originlab._process_rows()
|
|
428
|
+
if not r["window"] and str(r.get("started") or "") >= STARTED_AT]
|
|
429
|
+
for row in late:
|
|
430
|
+
originlab._close_pid(row["pid"])
|
|
431
|
+
left = [r for r in originlab._process_rows()
|
|
432
|
+
if not r["window"] and str(r.get("started") or "") >= STARTED_AT]
|
|
433
|
+
check("run leaves no Origin instance of its own", not left, left)
|
|
434
|
+
|
|
413
435
|
print("\nartifacts:")
|
|
414
436
|
for f in sorted(os.listdir(OUT)):
|
|
415
437
|
print(" %s (%d bytes)" % (f, os.path.getsize(os.path.join(OUT, f))))
|
package/templates_catalog.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
Origin Bridge Template Catalog.
|
|
3
3
|
|
|
4
4
|
A curated library of 30+ scientific chart templates inspired by the
|
|
5
5
|
editaplot project. Each template declares:
|
|
@@ -744,7 +744,7 @@ def render_template(name: str, data_path: str, output_dir: str,
|
|
|
744
744
|
|
|
745
745
|
# Make available at import time
|
|
746
746
|
if __name__ == '__main__':
|
|
747
|
-
print(f'
|
|
747
|
+
print(f'Origin Bridge has {len(TEMPLATES)} templates:')
|
|
748
748
|
by_cat = {}
|
|
749
749
|
for t in TEMPLATES:
|
|
750
750
|
by_cat.setdefault(t['category'], []).append(t['name'])
|
|
@@ -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")
|