dsh-origin-bridge 0.4.5 → 0.4.6
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 +329 -303
- package/asciiio.py +184 -17
- package/fixtures/j_utf16_bom.dat +0 -0
- package/fixtures/k_utf16_nobom.dat +0 -0
- package/fixtures/l_cp1252_micro.dat +3 -0
- package/fixtures/m_cp1252_degree.dat +3 -0
- package/fixtures/make_fixtures.py +15 -0
- package/fixtures/n_detection_limit.dat +4 -0
- package/fixtures/o_plusminus.dat +3 -0
- package/fixtures/p_unit_in_cell.dat +3 -0
- package/fixtures/q_thousands.csv +3 -0
- package/fixtures/r_decimal_comma.txt +3 -0
- package/fixtures/s_star_flag.dat +3 -0
- package/fixtures/t_paren_negative.dat +3 -0
- package/fixtures/u_overflow.dat +3 -0
- package/fixtures/v_row_labels.dat +3 -0
- package/fixtures/w_two_numbers_cell.csv +3 -0
- package/originlab.py +164 -22
- package/package.json +12 -5
- package/server.py +24 -8
- package/smoke_test.py +24 -2
- package/test_asciiio.py +118 -0
- package/test_json_safety.py +103 -0
- package/test_paths.py +142 -0
package/server.py
CHANGED
|
@@ -20,7 +20,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
20
20
|
import originlab # noqa: E402
|
|
21
21
|
|
|
22
22
|
SERVER_NAME = "origin-bridge"
|
|
23
|
-
SERVER_VERSION = "0.4.
|
|
23
|
+
SERVER_VERSION = "0.4.6"
|
|
24
24
|
PROTOCOL = "2024-11-05"
|
|
25
25
|
|
|
26
26
|
|
|
@@ -192,13 +192,14 @@ TOOLS = [
|
|
|
192
192
|
},
|
|
193
193
|
{
|
|
194
194
|
"name": "origin_export",
|
|
195
|
-
"description": "把图页导出为图片文件,并校验文件确实存在、够大、文件头正确(Origin 有静默失败的历史)。fmt: png/tif/jpg/svg/pdf/emf(tiff→tif、jpeg→jpg 会自动改写扩展名)。",
|
|
195
|
+
"description": "把图页导出为图片文件,并校验文件确实存在、够大、文件头正确(Origin 有静默失败的历史)。fmt: png/tif/jpg/svg/pdf/emf(tiff→tif、jpeg→jpg 会自动改写扩展名)。path 给绝对路径:中文、空格、长路径实测可用,未映射盘/UNC/把目录当文件名返回 bad_path,已存在但只读或被占用返回 target_locked(不覆盖)。文件名里的 # 会被 Origin 换成编号,桥认出后改名回你要的名字并在 warnings 里说明——那是成功,不是失败。",
|
|
196
196
|
"inputSchema": {
|
|
197
197
|
"type": "object",
|
|
198
198
|
"properties": {
|
|
199
199
|
"graph": {"type": "string"},
|
|
200
200
|
"path": {"type": "string", "description": "输出文件绝对路径;省略则用图页名放到当前目录"},
|
|
201
|
-
"fmt": {"type": "string", "enum": ["png", "tif", "
|
|
201
|
+
"fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
|
|
202
|
+
"default": "png"},
|
|
202
203
|
"width": {"type": "integer", "default": 1600},
|
|
203
204
|
},
|
|
204
205
|
},
|
|
@@ -207,7 +208,7 @@ TOOLS = [
|
|
|
207
208
|
},
|
|
208
209
|
{
|
|
209
210
|
"name": "origin_save_project",
|
|
210
|
-
"description": "保存为可在 Origin 里继续编辑的 .opju 工程文件(不是图片快照)。省略 path 会自动命名。",
|
|
211
|
+
"description": "保存为可在 Origin 里继续编辑的 .opju 工程文件(不是图片快照)。省略 path 会自动命名。path 给绝对路径,中文/空格/长路径实测可用;未映射盘、UNC、把目录当文件名一律 bad_path,不会假装存好了。",
|
|
211
212
|
"inputSchema": {
|
|
212
213
|
"type": "object",
|
|
213
214
|
"properties": {"path": {"type": "string"}},
|
|
@@ -235,7 +236,7 @@ TOOLS = [
|
|
|
235
236
|
},
|
|
236
237
|
{
|
|
237
238
|
"name": "origin_figure",
|
|
238
|
-
"description": "主路径:一次调用完成 导入/写数 → 画图 → 轴标题 → 导出图片 → 保存可编辑 .opju。用户只想“把这份数据画成图并给我文件”时用它,不要拆成多步。返回里若有 warnings
|
|
239
|
+
"description": "主路径:一次调用完成 导入/写数 → 画图 → 轴标题 → 导出图片 → 保存可编辑 .opju。用户只想“把这份数据画成图并给我文件”时用它,不要拆成多步。返回里若有 warnings,可能是两类:图不可读(例如线性横轴跨了几个数量级,点全挤在最左边)——把 next_actions 里的方案讲给用户让他选,不要自己擅自改轴;或是路径被代劳了(文件名里的 # 被 Origin 换成编号后改回、相对路径落到进程工作目录)——转达给用户即可,交付本身是成功的。",
|
|
239
240
|
"inputSchema": {
|
|
240
241
|
"type": "object",
|
|
241
242
|
"properties": {
|
|
@@ -247,7 +248,8 @@ TOOLS = [
|
|
|
247
248
|
"title": {"type": "string"},
|
|
248
249
|
"x_title": {"type": "string"},
|
|
249
250
|
"y_title": {"type": "string"},
|
|
250
|
-
"fmt": {"type": "string", "enum": ["png", "tif", "
|
|
251
|
+
"fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
|
|
252
|
+
"default": "png"},
|
|
251
253
|
"width": {"type": "integer", "default": 1600},
|
|
252
254
|
"output_dir": {"type": "string", "description": "交付目录:图片 + opju 都写到这。不给则只建图、不落盘"},
|
|
253
255
|
"export_path": {"type": "string"},
|
|
@@ -417,7 +419,8 @@ TOOLS = [
|
|
|
417
419
|
"title": {"type": "string"},
|
|
418
420
|
"plot_type": {"type": "string", "enum": ["line", "scatter", "line_symbol", "column"]},
|
|
419
421
|
"with_legend": {"type": "boolean", "default": True},
|
|
420
|
-
"fmt": {"type": "string", "enum": ["png", "tif", "
|
|
422
|
+
"fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
|
|
423
|
+
"default": "png"},
|
|
421
424
|
"width": {"type": "integer", "default": 1600},
|
|
422
425
|
},
|
|
423
426
|
"required": ["name"],
|
|
@@ -604,7 +607,20 @@ def main():
|
|
|
604
607
|
"error": {"code": -32603, "message": "internal error: %s" % exc}}
|
|
605
608
|
if reply is None:
|
|
606
609
|
continue
|
|
607
|
-
|
|
610
|
+
# allow_nan=False is the last line of defence. A bare Infinity/NaN in a
|
|
611
|
+
# reply is not valid JSON, so a strict client (dsh) fails to parse the
|
|
612
|
+
# *entire* result rather than one field. If anything still slips through,
|
|
613
|
+
# answer with a clean JSON-RPC error instead of either killing the session
|
|
614
|
+
# or writing bytes no client can read.
|
|
615
|
+
try:
|
|
616
|
+
line = json.dumps(reply, ensure_ascii=False, default=str, allow_nan=False)
|
|
617
|
+
except ValueError as exc:
|
|
618
|
+
_log("reply was not strict JSON (%r) for id=%r" % (exc, reply.get("id")))
|
|
619
|
+
reply = {"jsonrpc": "2.0", "id": reply.get("id"),
|
|
620
|
+
"error": {"code": -32603,
|
|
621
|
+
"message": "internal error: 返回值含非 JSON 数值(%s)" % exc}}
|
|
622
|
+
line = json.dumps(reply, ensure_ascii=False, default=str)
|
|
623
|
+
stdout.write((line + "\n").encode("utf-8"))
|
|
608
624
|
stdout.flush()
|
|
609
625
|
|
|
610
626
|
|
package/smoke_test.py
CHANGED
|
@@ -27,6 +27,21 @@ PY = sys.executable
|
|
|
27
27
|
STARTED_AT = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
class NonStrictJSON(ValueError):
|
|
31
|
+
"""The server wrote bare Infinity/NaN/-Infinity.
|
|
32
|
+
|
|
33
|
+
Python's json accepts those, RFC 8259 does not: dsh's client would fail on the
|
|
34
|
+
whole response and the tool would look dead rather than slightly wrong. A test
|
|
35
|
+
harness that tolerates it hides a real protocol defect, so we reject it here.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def strict_loads(text):
|
|
40
|
+
def _reject(const):
|
|
41
|
+
raise NonStrictJSON("非标准 JSON 常量: %s" % const)
|
|
42
|
+
return json.loads(text, parse_constant=_reject)
|
|
43
|
+
|
|
44
|
+
|
|
30
45
|
class Rpc:
|
|
31
46
|
def __init__(self, python, script):
|
|
32
47
|
self.p = subprocess.Popen([python, "-u", script], cwd=HERE,
|
|
@@ -64,7 +79,11 @@ class Rpc:
|
|
|
64
79
|
with self.lock:
|
|
65
80
|
for raw in self.lines:
|
|
66
81
|
try:
|
|
67
|
-
msg =
|
|
82
|
+
msg = strict_loads(raw)
|
|
83
|
+
except NonStrictJSON:
|
|
84
|
+
# A reply carrying bare Infinity/NaN parses in Python but not
|
|
85
|
+
# in a strict client, so dsh would report the tool as dead.
|
|
86
|
+
raise SystemExit("服务器写出了非法 JSON:%s" % raw[:160])
|
|
68
87
|
except ValueError:
|
|
69
88
|
continue
|
|
70
89
|
if msg.get("id") == mine:
|
|
@@ -82,7 +101,10 @@ class Rpc:
|
|
|
82
101
|
blocks = res.get("content", [])
|
|
83
102
|
text = "".join(c.get("text", "") for c in blocks if c.get("type") == "text")
|
|
84
103
|
try:
|
|
85
|
-
payload =
|
|
104
|
+
payload = strict_loads(text)
|
|
105
|
+
except NonStrictJSON as exc:
|
|
106
|
+
payload = {"ok": False, "code": "bad_payload",
|
|
107
|
+
"message": "工具返回体不是合法 JSON(%s):%s" % (exc, text[:300])}
|
|
86
108
|
except ValueError:
|
|
87
109
|
payload = {"ok": False, "code": "bad_payload", "message": text[:400]}
|
|
88
110
|
payload["_isError"] = res.get("isError", False)
|
package/test_asciiio.py
CHANGED
|
@@ -82,6 +82,124 @@ d = load("i_bom_utf8.csv")
|
|
|
82
82
|
check("BOM stripped from first name", d["columns"][0]["name"] == "波长", view(d))
|
|
83
83
|
check("utf-8-sig reported", d["encoding"] == "utf-8-sig", d["encoding"])
|
|
84
84
|
|
|
85
|
+
# ---------------------------------------------------------------- other encodings
|
|
86
|
+
print()
|
|
87
|
+
print("encodings a real instrument writes")
|
|
88
|
+
|
|
89
|
+
d = load("j_utf16_bom.dat")
|
|
90
|
+
check("utf-16 with BOM parses", [(c["name"], c["numeric_rows"]) for c in d["columns"]]
|
|
91
|
+
== [("X", 2), ("Y", 2)], view(d))
|
|
92
|
+
check("utf-16 encoding named", d["encoding"] == "utf-16-le", d["encoding"])
|
|
93
|
+
|
|
94
|
+
d = load("k_utf16_nobom.dat")
|
|
95
|
+
check("utf-16 without BOM still parses", d["columns"][1]["values"] == [1.5, 2.5], d["columns"])
|
|
96
|
+
check("the guess is admitted", "无 BOM" in d["encoding"], d["encoding"])
|
|
97
|
+
|
|
98
|
+
d = load("l_cp1252_micro.dat")
|
|
99
|
+
check("cp1252 µ survives", d["columns"][1]["units"] == "µs", view(d))
|
|
100
|
+
check("not mojibaked into CJK", "祍" not in str(view(d)), view(d))
|
|
101
|
+
check("cp1252 named", d["encoding"] == "cp1252", d["encoding"])
|
|
102
|
+
|
|
103
|
+
d = load("m_cp1252_degree.dat")
|
|
104
|
+
check("cp1252 ° survives", d["columns"][0]["units"] == "°C", view(d))
|
|
105
|
+
check("° not read as 癈", "癈" not in str(view(d)), view(d))
|
|
106
|
+
|
|
107
|
+
# the discriminator itself
|
|
108
|
+
check("paired high bytes read as GBK",
|
|
109
|
+
all(r % 2 == 0 for r in asciiio.high_byte_runs("温度\t电导".encode("gbk"))),
|
|
110
|
+
asciiio.high_byte_runs("温度\t电导".encode("gbk")))
|
|
111
|
+
check("single high bytes read as cp1252",
|
|
112
|
+
asciiio.high_byte_runs("T/°C".encode("cp1252")) == [1],
|
|
113
|
+
asciiio.high_byte_runs("T/°C".encode("cp1252")))
|
|
114
|
+
check("gbk is not first when singles appear",
|
|
115
|
+
asciiio.candidate_encodings("T/°C".encode("cp1252")).index("cp1252")
|
|
116
|
+
< asciiio.candidate_encodings("T/°C".encode("cp1252")).index("gb18030"),
|
|
117
|
+
asciiio.candidate_encodings("T/°C".encode("cp1252")))
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------ decorated numbers
|
|
120
|
+
print()
|
|
121
|
+
print("decorated numbers, unwrapped and reported")
|
|
122
|
+
|
|
123
|
+
d = load("n_detection_limit.dat")
|
|
124
|
+
check("detection limits become their bound", d["columns"][1]["values"] == [0.001, 0.5, 1e6],
|
|
125
|
+
d["columns"][1]["values"])
|
|
126
|
+
check("and say so", any("检测限" in w for w in d["warnings"]), d["warnings"])
|
|
127
|
+
|
|
128
|
+
d = load("o_plusminus.dat")
|
|
129
|
+
check("mean±sd takes the mean", d["columns"][1]["values"] == [2.5, 3.5], d["columns"][1]["values"])
|
|
130
|
+
check("both spellings of +/-", any("± 写法" in w for w in d["warnings"]), d["warnings"])
|
|
131
|
+
|
|
132
|
+
d = load("p_unit_in_cell.dat")
|
|
133
|
+
check("unit in the cell does not kill the column",
|
|
134
|
+
d["columns"][1]["values"] == [25.0, 30.0], d["columns"][1]["values"])
|
|
135
|
+
check("and is reported", any("单位写在同一格" in w for w in d["warnings"]), d["warnings"])
|
|
136
|
+
|
|
137
|
+
d = load("q_thousands.csv")
|
|
138
|
+
check("quoted thousands stay one column", [c["name"] for c in d["columns"]] == ["time", "signal"],
|
|
139
|
+
view(d))
|
|
140
|
+
check("and keep their value", d["columns"][1]["values"] == [1234.5, 2345.6],
|
|
141
|
+
d["columns"][1]["values"])
|
|
142
|
+
check("thousands reported", any("千分位" in w for w in d["warnings"]), d["warnings"])
|
|
143
|
+
|
|
144
|
+
d = load("r_decimal_comma.txt")
|
|
145
|
+
check("European file sniffs ';'", d["delimiter"] == ";", d["delimiter"])
|
|
146
|
+
check("header split on the right delimiter",
|
|
147
|
+
[c["name"] for c in d["columns"]] == ["Zeit", "Signal"], view(d))
|
|
148
|
+
check("comma decimal parsed", d["columns"][1]["values"] == [1.5, 2.5], d["columns"][1]["values"])
|
|
149
|
+
check("decimal comma reported", any("小数点" in w for w in d["warnings"]), d["warnings"])
|
|
150
|
+
|
|
151
|
+
d = load("s_star_flag.dat")
|
|
152
|
+
check("star-flagged points keep their number",
|
|
153
|
+
d["columns"][1]["values"] == [2.5, 3.5], d["columns"][1]["values"])
|
|
154
|
+
check("flag reported", any("标记的值" in w for w in d["warnings"]), d["warnings"])
|
|
155
|
+
|
|
156
|
+
d = load("t_paren_negative.dat")
|
|
157
|
+
check("parenthesised negative goes negative",
|
|
158
|
+
d["columns"][1]["values"] == [-500.0, 250.0], d["columns"][1]["values"])
|
|
159
|
+
check("paren reported", any("括号表示负数" in w for w in d["warnings"]), d["warnings"])
|
|
160
|
+
|
|
161
|
+
d = load("u_overflow.dat")
|
|
162
|
+
check("overflowed cell becomes missing", d["columns"][1]["values"] == [None, 1.2e308],
|
|
163
|
+
d["columns"][1]["values"])
|
|
164
|
+
check("overflow reported", any("超出浮点范围" in w for w in d["warnings"]), d["warnings"])
|
|
165
|
+
check("nothing infinite escapes the parser",
|
|
166
|
+
all(v is None or (v == v and abs(v) != float("inf"))
|
|
167
|
+
for c in d["columns"] for v in c["values"]), d["columns"])
|
|
168
|
+
|
|
169
|
+
d = load("v_row_labels.dat")
|
|
170
|
+
check("row-label column skipped", [c["name"] for c in d["columns"]] == ["V", "I"], view(d))
|
|
171
|
+
check("and explained as text", any("文本" in w for w in d["warnings"]), d["warnings"])
|
|
172
|
+
|
|
173
|
+
# cell_number on its own, so a regression in one shape is not hidden by a file
|
|
174
|
+
print()
|
|
175
|
+
print("cell_number shapes")
|
|
176
|
+
for text, want, kind in (
|
|
177
|
+
("1.5", 1.5, None), ("1.5D+03", 1500.0, None), (".5", 0.5, None),
|
|
178
|
+
("NA", None, None), ("--", None, None), ("#N/A", None, None),
|
|
179
|
+
("<0.001", 0.001, "检测限"), ("≥ 2.5", 2.5, "检测限"), (">1e6", 1e6, "检测限"),
|
|
180
|
+
("2.5±0.1", 2.5, "不确定度"), ("2.5 +/- 0.1", 2.5, "不确定度"),
|
|
181
|
+
("25 mV", 25.0, "带单位的数"), ("1,234.5", 1234.5, "千分位"),
|
|
182
|
+
("2.5*", 2.5, "标记后缀"), ("(500)", -500.0, "括号负数"),
|
|
183
|
+
("1e400", None, "溢出"), ("Infinity", None, "溢出"),
|
|
184
|
+
("0,5", None, None), ("0,5;1,5", None, None), ("text", None, None)):
|
|
185
|
+
got, got_kind = asciiio.cell_number(text)
|
|
186
|
+
check("cell %r" % text, got == want and got_kind == kind, (got, got_kind))
|
|
187
|
+
check("cell '0,5' with decimal_comma", asciiio.cell_number("0,5", decimal_comma=True) == (0.5, "小数逗号"),
|
|
188
|
+
asciiio.cell_number("0,5", decimal_comma=True))
|
|
189
|
+
check("cell '\"2,5\"' quoted", asciiio.cell_number('"2,5"', decimal_comma=True) == (2.5, "小数逗号"),
|
|
190
|
+
asciiio.cell_number('"2,5"', decimal_comma=True))
|
|
191
|
+
check("cell '2.5 3.5' is two numbers, not a unit",
|
|
192
|
+
asciiio.cell_number("2.5 3.5") == (2.5, "一格两个数"), asciiio.cell_number("2.5 3.5"))
|
|
193
|
+
check("cell '25 mV' stays a unit",
|
|
194
|
+
asciiio.cell_number("25 mV") == (25.0, "带单位的数"), asciiio.cell_number("25 mV"))
|
|
195
|
+
|
|
196
|
+
# a mis-split column has to say so instead of quietly dropping every second value
|
|
197
|
+
d = load("w_two_numbers_cell.csv")
|
|
198
|
+
check("two numbers in one cell are reported", any("挤了两个数" in w for w in d["warnings"]),
|
|
199
|
+
d["warnings"])
|
|
200
|
+
check("the first number of each cell survives",
|
|
201
|
+
d["columns"][1]["values"] == [2.5, 4.0], d["columns"][1]["values"])
|
|
202
|
+
|
|
85
203
|
print()
|
|
86
204
|
if fails:
|
|
87
205
|
print("FAILED (%d): %s" % (len(fails), ", ".join(fails)))
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Offline checks that the bridge can never emit non-standard JSON.
|
|
2
|
+
|
|
3
|
+
json.dumps writes a bare Infinity / NaN by default, which RFC 8259 does not allow:
|
|
4
|
+
a strict client (dsh, JS JSON.parse) fails on the whole response, so one overflowed
|
|
5
|
+
number looks like "the bridge is dead". Two guards, both checked here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
import originlab as ol # noqa: E402
|
|
15
|
+
|
|
16
|
+
PASSED = 0
|
|
17
|
+
FAILED = []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ok(label, cond, detail=""):
|
|
21
|
+
global PASSED
|
|
22
|
+
if cond:
|
|
23
|
+
PASSED += 1
|
|
24
|
+
else:
|
|
25
|
+
FAILED.append("%s %s" % (label, detail))
|
|
26
|
+
print("%s %s%s" % ("PASS" if cond else "FAIL", label, (" | " + str(detail)[:90]) if detail else ""))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
INF = float("inf")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def strict(text):
|
|
33
|
+
"""Parse the way a JS client would: no Infinity, no NaN."""
|
|
34
|
+
return json.loads(text, parse_constant=lambda c: (_ for _ in ()).throw(ValueError(c)))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main():
|
|
38
|
+
# ------------------------------------------------------- sanitize() mapping
|
|
39
|
+
ok("inf -> None", ol.sanitize(INF) is None, ol.sanitize(INF))
|
|
40
|
+
ok("-inf -> None", ol.sanitize(-INF) is None)
|
|
41
|
+
ok("nan -> None", ol.sanitize(float("nan")) is None)
|
|
42
|
+
ok("missing sentinel -> None", ol.sanitize(ol.MISSING_SENTINEL) is None)
|
|
43
|
+
ok("big finite stays", ol.sanitize(1.7976931348623157e308) == 1.7976931348623157e308)
|
|
44
|
+
ok("zero stays", ol.sanitize(0.0) == 0.0)
|
|
45
|
+
ok("int stays", ol.sanitize(42) == 42)
|
|
46
|
+
ok("string stays", ol.sanitize("ok") == "ok")
|
|
47
|
+
nested = ol.sanitize({"a": [1.0, INF, {"b": -INF, "c": float("nan")}], "d": "x"})
|
|
48
|
+
ok("nested cleaned",
|
|
49
|
+
json.dumps(nested, allow_nan=False) == '{"a": [1.0, null, {"b": null, "c": null}], "d": "x"}',
|
|
50
|
+
nested)
|
|
51
|
+
ok("tuple cleaned", ol.sanitize((INF, 1.0)) == [None, 1.0])
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------- the reply guard
|
|
54
|
+
# Simulate a handler that forgot to sanitize. The child server must still never
|
|
55
|
+
# write bytes a strict parser rejects, and must not die on the way.
|
|
56
|
+
import subprocess
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
json.dumps({"value": math.inf}, allow_nan=False)
|
|
60
|
+
raised = False
|
|
61
|
+
except ValueError:
|
|
62
|
+
raised = True
|
|
63
|
+
ok("allow_nan=False really rejects Infinity", raised)
|
|
64
|
+
|
|
65
|
+
child = (
|
|
66
|
+
"import io, json, math, os, sys\n"
|
|
67
|
+
"sys.path.insert(0, os.environ['BRIDGE_DIR'])\n"
|
|
68
|
+
"import server\n"
|
|
69
|
+
"server._handle = lambda msg: {'jsonrpc': '2.0', 'id': msg.get('id'),\n"
|
|
70
|
+
" 'result': {'value': math.inf}}\n"
|
|
71
|
+
"req = json.dumps({'jsonrpc': '2.0', 'id': 7, 'method': 'tools/list'}) + '\\n'\n"
|
|
72
|
+
"sys.stdin = io.TextIOWrapper(io.BytesIO(req.encode('utf-8')), encoding='utf-8')\n"
|
|
73
|
+
"sys.exit(server.main() or 0)\n"
|
|
74
|
+
)
|
|
75
|
+
env = dict(os.environ, BRIDGE_DIR=os.path.dirname(os.path.abspath(__file__)),
|
|
76
|
+
PYTHONIOENCODING="utf-8")
|
|
77
|
+
proc = subprocess.run([sys.executable, "-c", child], capture_output=True, env=env, timeout=120)
|
|
78
|
+
written = proc.stdout.decode("utf-8", "replace")
|
|
79
|
+
ok("child exited cleanly", proc.returncode == 0,
|
|
80
|
+
(proc.stderr.decode("utf-8", "replace") or "")[:160])
|
|
81
|
+
ok("guard logged why it downgraded", b"not strict JSON" in proc.stderr,
|
|
82
|
+
proc.stderr.decode("utf-8", "replace")[-160:])
|
|
83
|
+
ok("poisoned reply produced exactly one line", written.count("\n") == 1, repr(written[:160]))
|
|
84
|
+
ok("no bare Infinity on the wire", "Infinity" not in written, written[:160])
|
|
85
|
+
payload = {}
|
|
86
|
+
try:
|
|
87
|
+
payload = strict(written)
|
|
88
|
+
ok("client still gets a parseable answer", True)
|
|
89
|
+
except ValueError as exc:
|
|
90
|
+
ok("client still gets a parseable answer", False, "%s | %s" % (exc, written[:160]))
|
|
91
|
+
ok("answered with a JSON-RPC error", payload.get("error", {}).get("code") == -32603, payload)
|
|
92
|
+
ok("error keeps the request id", payload.get("id") == 7, payload)
|
|
93
|
+
|
|
94
|
+
print("\n%d/%d json-safety checks passed" % (PASSED, PASSED + len(FAILED)))
|
|
95
|
+
if FAILED:
|
|
96
|
+
for f in FAILED:
|
|
97
|
+
print(" FAILED:", f)
|
|
98
|
+
return 1
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
sys.exit(main())
|
package/test_paths.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Offline checks for the export/save target-path helpers (no Origin needed).
|
|
2
|
+
|
|
3
|
+
Everything here was measured against the live bridge first (2026-10-12): Origin's
|
|
4
|
+
export filter rewrites '#' into a numbering digit, Win32 strips trailing spaces
|
|
5
|
+
and dots out of path components, and a path with no real filename used to turn
|
|
6
|
+
into a dotfile named ".png" inside the plugin's own install folder.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import originlab as ol
|
|
15
|
+
|
|
16
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
17
|
+
BASE = os.path.join(os.environ.get("TEMP", HERE), "origin_bridge_path_checks")
|
|
18
|
+
|
|
19
|
+
PASSED = 0
|
|
20
|
+
FAILED = []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ok(label, cond, detail=""):
|
|
24
|
+
global PASSED
|
|
25
|
+
if cond:
|
|
26
|
+
PASSED += 1
|
|
27
|
+
else:
|
|
28
|
+
FAILED.append("%s %s" % (label, detail))
|
|
29
|
+
print("%s %s%s" % ("PASS" if cond else "FAIL", label, (" | " + str(detail)) if detail else ""))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def code_of(fn):
|
|
33
|
+
try:
|
|
34
|
+
fn()
|
|
35
|
+
except ol.OriginError as exc:
|
|
36
|
+
return exc.code
|
|
37
|
+
except Exception as exc: # noqa: BLE001 — an unwrapped crash is itself the failure
|
|
38
|
+
return "UNWRAPPED:" + type(exc).__name__
|
|
39
|
+
return "no-raise"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def fresh_dir(rel=""):
|
|
43
|
+
path = os.path.join(BASE, rel) if rel else BASE
|
|
44
|
+
if os.path.isdir(path):
|
|
45
|
+
for root, dirs, files in os.walk(path):
|
|
46
|
+
for name in files + dirs:
|
|
47
|
+
try:
|
|
48
|
+
os.chmod(os.path.join(root, name), stat.S_IWRITE)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
52
|
+
os.makedirs(path, exist_ok=True)
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
fresh_dir()
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------- naming
|
|
60
|
+
ok("blank path means default", ol._norm_target(" ", "png") == "")
|
|
61
|
+
ok("dot is a directory", code_of(lambda: ol._norm_target(".", "png")) == "bad_path")
|
|
62
|
+
ok("bare repo dir rejected",
|
|
63
|
+
code_of(lambda: ol._norm_target(HERE, "png")) == "bad_path")
|
|
64
|
+
ok("dotfile name rejected",
|
|
65
|
+
code_of(lambda: ol._norm_target(os.path.join(BASE, " .png "), "png")) == "bad_path")
|
|
66
|
+
ok("drive root rejected", code_of(lambda: ol._norm_target("C:\\", "png")) == "bad_path")
|
|
67
|
+
ok("trailing space stripped from component",
|
|
68
|
+
os.path.basename(os.path.dirname(
|
|
69
|
+
ol._norm_target(os.path.join(BASE, "trailsp ", "y.png")))) == "trailsp")
|
|
70
|
+
ok("trailing dot stripped from file",
|
|
71
|
+
ol._norm_target(os.path.join(BASE, "a.png.")) == os.path.join(BASE, "a.png"))
|
|
72
|
+
ok("fmt wins over the extension",
|
|
73
|
+
ol._norm_target(os.path.join(BASE, "x.tiff"), "tif") == os.path.join(BASE, "x.tif"))
|
|
74
|
+
ok("no fmt keeps the name",
|
|
75
|
+
ol._norm_target(os.path.join(BASE, "x.opju")) == os.path.join(BASE, "x.opju"))
|
|
76
|
+
ok("chinese preserved",
|
|
77
|
+
ol._norm_target(os.path.join(BASE, "中文测试", "图谱.png"))
|
|
78
|
+
== os.path.join(BASE, "中文测试", "图谱.png"))
|
|
79
|
+
ok("tilde expanded", os.path.expanduser("~") in ol._norm_target("~/a.png"))
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------- writability
|
|
82
|
+
ok("unmapped drive is bad_path",
|
|
83
|
+
code_of(lambda: ol._prepare_write(ol._norm_target(r"Q:\nope\x.png"))) == "bad_path")
|
|
84
|
+
ok("dead UNC host is bad_path",
|
|
85
|
+
code_of(lambda: ol._prepare_write(ol._norm_target(r"\\nope-not-real\x.png"))) == "bad_path")
|
|
86
|
+
adir = fresh_dir("adir")
|
|
87
|
+
ok("directory target is bad_path", code_of(lambda: ol._prepare_write(adir)) == "bad_path")
|
|
88
|
+
ro = os.path.join(fresh_dir("ro"), "locked.png")
|
|
89
|
+
with open(ro, "wb") as fh:
|
|
90
|
+
fh.write(b"marker")
|
|
91
|
+
os.chmod(ro, 0o444)
|
|
92
|
+
ok("read-only target is target_locked",
|
|
93
|
+
code_of(lambda: ol._prepare_write(ro)) == "target_locked")
|
|
94
|
+
ok("read-only file untouched", open(ro, "rb").read() == b"marker")
|
|
95
|
+
os.chmod(ro, 0o666)
|
|
96
|
+
ok("writable existing passes", code_of(lambda: ol._prepare_write(ro)) == "no-raise")
|
|
97
|
+
deep = os.path.join(BASE, "made", "by", "us")
|
|
98
|
+
ol._prepare_write(os.path.join(deep, "x.png"))
|
|
99
|
+
ok("missing dirs get created", os.path.isdir(deep))
|
|
100
|
+
|
|
101
|
+
# -------------------------------------------------- Origin's '#' rewriting
|
|
102
|
+
real = os.path.join(BASE, "100%_a1b[1].png")
|
|
103
|
+
want = os.path.join(BASE, "100%_a#b[1].png")
|
|
104
|
+
with open(real, "wb") as fh:
|
|
105
|
+
fh.write(b"PNGDATA")
|
|
106
|
+
got, note = ol._written_or_mangled(want)
|
|
107
|
+
ok("# result renamed back to what was asked", got == want and os.path.isfile(want))
|
|
108
|
+
ok("mangled sibling is gone", not os.path.exists(real))
|
|
109
|
+
ok("warning explains the filter", bool(note) and "滤镜" in note, note)
|
|
110
|
+
two_real = os.path.join(BASE, "graph01final02.png")
|
|
111
|
+
two_want = os.path.join(BASE, "graph##final###.png")
|
|
112
|
+
with open(two_real, "wb") as fh:
|
|
113
|
+
fh.write(b"PNGDATA")
|
|
114
|
+
got2, _n2 = ol._written_or_mangled(two_want)
|
|
115
|
+
ok("multi-digit # runs handled", got2 == two_want and os.path.isfile(two_want))
|
|
116
|
+
amb = fresh_dir("amb")
|
|
117
|
+
for name in ("g1.png", "g2.png"):
|
|
118
|
+
with open(os.path.join(amb, name), "wb") as fh:
|
|
119
|
+
fh.write(b"x")
|
|
120
|
+
got3, _n3 = ol._written_or_mangled(os.path.join(amb, "g#.png"))
|
|
121
|
+
ok("ambiguous match is not guessed", got3 is None)
|
|
122
|
+
ok("plain missing file stays a failure",
|
|
123
|
+
ol._written_or_mangled(os.path.join(BASE, "never.png")) == (None, None))
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------ CWD litter
|
|
126
|
+
before = set(os.listdir(HERE))
|
|
127
|
+
ok("relative path warns", bool(ol._rel_note("out.png")))
|
|
128
|
+
ok("absolute path silent", ol._rel_note(os.path.join(BASE, "a.png")) is None)
|
|
129
|
+
ok("blank path silent", ol._rel_note(" ") is None)
|
|
130
|
+
ok("no file written into the package dir by these checks",
|
|
131
|
+
set(os.listdir(HERE)) == before, sorted(set(os.listdir(HERE)) - before))
|
|
132
|
+
|
|
133
|
+
print("\n%d/%d path checks passed" % (PASSED, PASSED + len(FAILED)))
|
|
134
|
+
if FAILED:
|
|
135
|
+
for f in FAILED:
|
|
136
|
+
print(" FAILED:", f)
|
|
137
|
+
return 1
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
sys.exit(main())
|