dsh-origin-bridge 0.4.4 → 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/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 = json.loads(raw)
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 = json.loads(text)
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())