dsh-origin-bridge 0.4.6 → 0.4.8
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 +25 -12
- package/bin/origin-bridge.mjs +17 -3
- package/cordis.patch.yml +6 -2
- package/install_dsh.ps1 +17 -3
- package/originlab.py +136 -12
- package/package.json +3 -1
- package/requirements.txt +2 -0
- package/server.py +71 -14
- package/test_json_safety.py +54 -0
- package/test_labtalk_guard.py +88 -0
- package/test_lifecycle.py +14 -0
- package/test_paths.py +11 -0
- package/test_readfile.py +104 -0
package/README.md
CHANGED
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
13
|
<p align="center">
|
|
14
|
-
<sub>27 个工具 · <b>
|
|
14
|
+
<sub>27 个工具 · <b>1126 例全链路测试全部通过</b> · 数据不出本机 · 支持工作站 <code>.txt</code> / <code>.bin</code></sub>
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
18
|
<a href="#安装"><img alt="platform" src="https://img.shields.io/badge/platform-Windows%20%2B%20Origin%202021%2B-blue"></a>
|
|
19
|
-
<img alt="tests" src="https://img.shields.io/badge/tests-
|
|
19
|
+
<img alt="tests" src="https://img.shields.io/badge/tests-1126%2F1126%20passing-brightgreen">
|
|
20
20
|
<img alt="license" src="https://img.shields.io/badge/license-MIT-lightgrey">
|
|
21
|
+
<a href="https://github.com/caob23/dsh-origin-bridge/actions/workflows/offline-checks.yml"><img alt="offline checks" src="https://github.com/caob23/dsh-origin-bridge/actions/workflows/offline-checks.yml/badge.svg"></a>
|
|
21
22
|
</p>
|
|
22
23
|
|
|
23
24
|
<p align="center">
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
- **数据不出本机。** 没有 API key、没有云端组件,dsh 拉起一个本地 Python 进程,通过 OriginLab 官方的 [`originpro`](https://pypi.org/project/originpro/) 驱动你机器上的 Origin。
|
|
36
37
|
- **每一步都有证据。** Origin 的 X-Function 普遍"返回成功但什么都没发生"。这里画图后读回轴范围与数据跨度比对,导出后校验文件存在/体积/文件头魔数,样式逐项读回——读不回的进 `failed` 列表,不会假装成功。
|
|
37
38
|
- **不是 74 个工具,是 27 个。** 每个都有实测用途,工具描述里写着实测限制,模型不会踩坑。
|
|
38
|
-
- **1000+ 次真实测试,不是自称。** **
|
|
39
|
+
- **1000+ 次真实测试,不是自称。** **1126 例**全链路用例跑在真实 stdio 服务器 + 本机 OriginPro 上,**1126 通过 / 0 失败**:27 个工具每个至少 15 例、最多 134 例,按副作用分五层(L0 纯解析 301、L1 只读 COM 63、L2 改会话 521、L3 落盘 163、L4 进程级 78)。提示词全部按人话写,跑之前先用在线 `tools/list` schema 校验用例本身,所以失败只会是产品问题,不会是题目写错。另有 **417 项**离线/单元/边界检查(asciiio 84、binio 30、lifecycle 42、smoke 129、edge 26、paths 35、json 26、readfile 12、labtalk 33)**随包发布,你可以直接复跑**;那 1126 例题库含提示词与模拟数据,是开发侧资产,不随包分发。originpro 的十几个文档陷阱全部记录在案并绕开。
|
|
39
40
|
|
|
40
41
|
## 它能做什么
|
|
41
42
|
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
| "做一张 XPS 图" | `origin_chart_render`(40 个科研预设:轴标题/图类型/建议拟合) |
|
|
51
52
|
| "热力图显示这个矩阵" | `origin_matrix`(heat_map / cmap / mesh / contline,Origin 自带图模) |
|
|
52
53
|
|
|
53
|
-
数据源支持:`.dat` `.csv` `.txt` `.tsv
|
|
54
|
+
数据源支持:`.dat` `.csv` `.txt` `.tsv`(`origin_read_file` 只读这些文本类;`.xls/.xlsx/.wks` 会明确报 `unsupported_file_type` 让你改用 `origin_import`。自动嗅探分隔符;编码覆盖 UTF-8 / GBK / **UTF-16(有无 BOM 都行)** / cp1252;多行表头、Fortran 指数、`NA`/`-`;还有 `<0.001` 检出限、`2.5±0.1`、`25 mV`、`1,234.5`、`(500)`、`2.5*` 这类被装饰过的数值——每解一种都会在 `warnings` 里写清代入了什么)、`.xls` `.xlsx`、电化学工作站(CH Instruments 等)导出的 `.txt`(几十行仪器说明自动收进 `metadata`,不会把首行日期当成列名),以及头部为 `80 F2 1B 00` 的电化学工作站二进制 `.bin`。
|
|
54
55
|
|
|
55
56
|
## 安装
|
|
56
57
|
|
|
@@ -87,6 +88,12 @@ dsh plugin --profile desktop add @caob23/dsh-origin-bridge # 转发壳,依赖
|
|
|
87
88
|
pip install originpro numpy # 国内网络慢就加 -i https://pypi.tuna.tsinghua.edu.cn/simple
|
|
88
89
|
```
|
|
89
90
|
|
|
91
|
+
> **前置缺了会怎样,两种情况不一样**(2026-10-19 实测,别猜):
|
|
92
|
+
> - **解释器在、`originpro` 不在**:27 个工具**照常注册**,每一次调用都返回 `code=missing_originpro` 和原因(`No module named 'originpro'`),`isError: true`——看得见、知道修什么。
|
|
93
|
+
> - **PATH 上没有 `python`**(补丁默认命令就是 `python`):桥进程起不来,`failOnStartupError: false` 保证 dsh 照常启动,代价是那一轮**工具完全不注册**、也没有报错。这时先设 `ORIGIN_BRIDGE_PYTHON`。
|
|
94
|
+
>
|
|
95
|
+
> Origin 没开**不影响**注册:COM 连接是第一次工具调用时才建立的。
|
|
96
|
+
|
|
90
97
|
包内补丁的默认取值是 `python`(PATH 上那个)和 `profiles/desktop`。依赖装在 venv 里、或者你用的是别的 profile,就设这两个用户环境变量(设完要重开终端、再彻底重启 dsh):
|
|
91
98
|
|
|
92
99
|
```powershell
|
|
@@ -124,12 +131,14 @@ pip install -r requirements.txt # 国内网络慢就加 -i https://pypi.tun
|
|
|
124
131
|
.\.venv\Scripts\python.exe edge_checks.py # 26 项边界
|
|
125
132
|
.\.venv\Scripts\python.exe test_asciiio.py # 84 项,不需要 Origin
|
|
126
133
|
.\.venv\Scripts\python.exe test_binio.py # 30 项,不需要 Origin;有真实 .bin 时会跟厂商 .txt 逐点对账
|
|
127
|
-
.\.venv\Scripts\python.exe test_lifecycle.py #
|
|
128
|
-
.\.venv\Scripts\python.exe test_paths.py #
|
|
129
|
-
.\.venv\Scripts\python.exe test_json_safety.py #
|
|
134
|
+
.\.venv\Scripts\python.exe test_lifecycle.py # 42 项,不需要 Origin:实例分类/回收/掉线重连的安全边界
|
|
135
|
+
.\.venv\Scripts\python.exe test_paths.py # 35 项,不需要 Origin:导出/保存目标路径的规范化与拒绝
|
|
136
|
+
.\.venv\Scripts\python.exe test_json_safety.py # 26 项,不需要 Origin:返回值必须是严格 JSON
|
|
137
|
+
.\.venv\Scripts\python.exe test_readfile.py # 12 项,不需要 Origin:read_file 的 sample 与二进制表格拒绝
|
|
138
|
+
.\.venv\Scripts\python.exe test_labtalk_guard.py # 33 项,不需要 Origin:LabTalk 黑名单匹配
|
|
130
139
|
```
|
|
131
140
|
|
|
132
|
-
上面这
|
|
141
|
+
上面这 417 项随包发布,你可以自己复跑。另外那 **1126 例全链路题库是开发侧资产**(`cases/` + `run_cases.py`),跑的是真实 stdio 服务器 + 本机 Origin,用它的提示词和你的原始数据都不适合公开,所以没有进仓库、也没有进 npm 包;本文里的覆盖数字都来自它的实际运行结果。
|
|
133
142
|
|
|
134
143
|
### 不是 dsh,是别的 MCP 客户端
|
|
135
144
|
|
|
@@ -179,7 +188,7 @@ powershell -ExecutionPolicy Bypass -File .\install_dsh.ps1 -Profile desktop
|
|
|
179
188
|
| 工具 | 用途 |
|
|
180
189
|
|---|---|
|
|
181
190
|
| `origin_status` | 连接检查、Origin 版本、已开页面。**动手前先调它** |
|
|
182
|
-
| `origin_read_file` | 只解析文件不碰 Origin
|
|
191
|
+
| `origin_read_file` | 只解析文件不碰 Origin:编码、分隔符、表头行、每列长名/单位/有效行数、**前 3 个原始值(`sample`,缺值为 null,用来抽查没串列)**;`.xls/.xlsx/.wks` 明确拒绝并让你改用 `origin_import`;`.bin` 走另一条路线给出技术/点数/方法参数/轴来源 |
|
|
183
192
|
| `origin_import` | 导入 `.dat/.csv/.txt/.tsv/.xls/.xlsx/.wks` 和工作站二进制 `.bin`,返回 `ws-N` 句柄和列清单 |
|
|
184
193
|
| `origin_write` | 内联数据建表:窄表 `columns={列名:[值…]}`,宽表 `headers`+`rows`(一次写完) |
|
|
185
194
|
| `origin_formula` | 给列设计算公式(Fx)做派生列,读回算出的行数与首末值 |
|
|
@@ -267,6 +276,10 @@ Origin Bridge 的做法:
|
|
|
267
276
|
| 编码候选顺序 | 仪器"另存为 Unicode 文本"导出的 UTF-16(有/无 BOM)此前直接报 `找不到数值数据行`;而 cp1252 写的 `µ`/`°` 会被 gb18030 **抢先**解成 `祍`/`癈`——它不报错,所以错误一路静默到列名 | `read_text` 先认 BOM,无 BOM 时按 NUL 占比猜 utf-16 并在 `encoding` 里写明是猜的;再用"高位字节成对=GBK、孤立单字节=cp1252"决定候选顺序(`high_byte_runs`) |
|
|
268
277
|
| 装饰过的数值格子 | `<0.001`、`2.5±0.1`、`25 mV`、`1,234.5`、`2.5*`、`(500)`、`1e400` 这些形态此前让整列被判"全为空"直接丢弃——用户看到的是少一列数据,不是报错 | `cell_number` 逐形态解开并回一条 `warnings` 说明代入了什么;溢出/Infinity 一律按缺值,绝不把 inf 送出桥 |
|
|
269
278
|
| 分隔符嗅探 | 欧陆 `Zeit;Signal / 0,0;1,5` 会被逗号拆开,而且错的拆法因为"列更多"在评分里胜出 | 评分改成:数值列数 → 行宽一致性 → 列数更少者优先;表头行不参与数值比 |
|
|
279
|
+
| `origin_open` 与句柄 | 开工程会替换所有页面,之前发出去的 `ws-/gr-` 句柄全部指向已销毁的对象;死对象上的 `save_fig` 会"成功"但什么都不写(1124 例跑里正是它让连续三个导出失败) | `open_project` 成功与失败都清空句柄表并在返回里给 `handles_reset: true`;旧句柄再用会得到 `worksheet_not_found`/`graph_not_found`,不会静默操作死页面 |
|
|
280
|
+
| LabTalk 黑名单 | 原来按子串匹配,2026-10-20 用 45 条高危语句实测 **37 条能过**:`win -cd` 销毁页面、`doc -n` 新建工程丢当前项目、`del/delete` 删数据集、`run -p`/`run.file` 执行外部程序、`exit`、以及能绕过句柄重置的 `doc -o` | 改成按语句(`;`/换行/`{}` 切分)+ 词边界规则:`doc -`、`win -c`、`del/delete`、`layer -d`、`page -d`、`quit/exit`、`run.`/`run -`、`system/shell`、`rmdir/mkdir`、`type -`、`close`、`saveas` 全拦;`test_labtalk_guard.py` 32 项锁住"该拦的拦、该放的放"。仍是劝告式护栏,不是沙箱 |
|
|
281
|
+
| dsh 新 profile 的补丁模板 | `cordis.patch.yml` 模板内容就是一行 `[]`(顶层数组占位)。往它后面**追加** `- insert:` 会得到两个 YAML 文档,dsh 直接 `failed to parse overlay` 起不来——`install_dsh.ps1` 2026-10-20 前正是这样,全新环境按 README 用脚本装会把配置写坏 | 安装器现在把"只有注释/只有一个 `[]`"的文件当空文件处理,插块成为数组本身;装完会用 PyYAML 校验一次,解析失败就提示从备份恢复 |
|
|
282
|
+
| `save_fig` 的 `width` | 传了也不生效:`save_fig(path)` 只有 path 时用页面尺寸,**必须同时给 type** 才认 width (实测 400 和 12000 导出都是 3216×2461、字节数相同)。我们的 `origin_export` 以前恰好只传 path | `save_fig(path, fmt, width=N)` + 缺文件时回退纯 path;PNG 导完比对 `dimensions.0` 与请求值,不一致就回 warning。题库宽度用例从"有 dimensions"改成**断言等于请求宽度** |
|
|
270
283
|
| 非标准 JSON | `json.dumps` 默认把 inf/nan 写成裸 `Infinity`/`NaN`,那不是合法 JSON——dsh 侧整条响应解析失败,看起来像"桥挂了"而不是"一个数不对" | `sanitize()` 把 ±inf/nan 归 None;`server.py` 写出走 `allow_nan=False`,兜不住就降级成带原 id 的 JSON-RPC 错误;`smoke_test` 客户端用 `strict_loads` 拒绝,测试不会替产品掩盖它(`test_json_safety.py` 18 项) |
|
|
271
284
|
|
|
272
285
|
## 已知边界
|
|
@@ -278,7 +291,7 @@ Origin Bridge 的做法:
|
|
|
278
291
|
- 交付路径:中文目录、中文文件名、含空格、200+ 字符长路径、自动建多级目录、`tiff→tif`/`jpeg→jpg` 改名——2026-10-12 在真机上逐条实测可用(PNG/TIF/PDF/SVG/EMF 和 `.opju` 都试过)。**不行的是网络路径**:UNC 与未映射盘返回 `bad_path`,不去猜认证。另有两条 Origin 自己的怪脾气已经兜住:文件名里的 `#` 会被导出滤镜换成编号(桥发现后会改名回你要的名字并在 `warnings` 里说明),路径组件尾部的空格/点会被 Windows 悄悄截掉(桥先截一遍再比较,不会报"文件不存在")。
|
|
279
292
|
- 千分位和逗号分隔符同形的文件(不加引号的 `0,1,234.5`)没法机械还原:桥按最宽行补齐并报"行长度不一致",不会假装列数对得上。欧陆小数点(`0,5`)只在分隔符是 `;` 或 tab 时才认。
|
|
280
293
|
- Origin 以管理员运行而 Python 不是(或反之)时 COM 连不上,两侧权限要一致。
|
|
281
|
-
- 长会话攒太多页面会让 COM 桥报"无效指针"。`origin_close_pages` 能清,根治是 `origin_exit` 后重连。
|
|
294
|
+
- 长会话攒太多页面会让 COM 桥报"无效指针"。`origin_close_pages` 能清,根治是 `origin_exit` 后重连。2026-10-20 实测到 300 页仍不崩,但每张图的耗时从 0.7 秒(前 100 页)涨到 2.8 秒(200–300 页)——所以 `origin_plot`/`origin_matrix` 每新建 50 个页面会在 `warnings` 里提醒一次(计数不花额外 COM 调用,`origin_close_pages`/`origin_exit`/`origin_open` 会清零)。
|
|
282
295
|
- **Origin 实例不会跟着客户端退出**:originpro 需要时自己拉一个 Origin,Python 进程结束那个实例还在。攒够就会报"实例超过上限"。现在插件在进程退出时回收**自己起的、且没有窗口的**实例(`atexit`),历史残留用 `origin_reclaim` 清;有窗口的实例永远不动,因为那可能是你正开着的项目。
|
|
283
296
|
- 统计批处理(t 检验 / ANOVA / PCA / 生存分析)没有,originpro 本身也不提供。
|
|
284
297
|
- **无并发保护**:两个会话同时驱动 Origin 会互相踩。
|
|
@@ -315,10 +328,10 @@ dsh-origin-bridge/
|
|
|
315
328
|
├── icon.svg # 卡片图标(scripts/make_icon.py 生成)
|
|
316
329
|
├── locale/*.json # dsh 卡片显示名与描述
|
|
317
330
|
├── scripts/make_preview.py # 重新生成 docs/preview.png(关掉图例后才导出)
|
|
318
|
-
└── test_*.py / smoke_test.py / edge_checks.py #
|
|
331
|
+
└── test_*.py / smoke_test.py / edge_checks.py # 417 项离线与单元检查(随包发布)
|
|
319
332
|
```
|
|
320
333
|
|
|
321
|
-
开发侧另有 `cases/` + `run_cases.py` 的
|
|
334
|
+
开发侧另有 `cases/` + `run_cases.py` 的 1126 例全链路题库和 `render_realdata.py` 的真实数据逐文件出图脚本,两者都不随包发布(见上文)。
|
|
322
335
|
|
|
323
336
|
重新生成图标:`python scripts/make_icon.py`(只需要 numpy,PNG 由 stdlib 手写)。
|
|
324
337
|
|
package/bin/origin-bridge.mjs
CHANGED
|
@@ -22,10 +22,24 @@ const candidates = [
|
|
|
22
22
|
'python',
|
|
23
23
|
].filter(Boolean);
|
|
24
24
|
|
|
25
|
+
// Same environment the dsh patch sets, so logs are UTF-8 instead of the console
|
|
26
|
+
// code page and the child flushes line by line.
|
|
27
|
+
const env = { ...process.env };
|
|
28
|
+
if (!env.PYTHONIOENCODING) env.PYTHONIOENCODING = 'utf-8';
|
|
29
|
+
if (!env.PYTHONUNBUFFERED) env.PYTHONUNBUFFERED = '1';
|
|
30
|
+
|
|
25
31
|
const child = spawn(
|
|
26
32
|
candidates.find((c) => c === 'python' || existsSync(c)) || 'python',
|
|
27
|
-
['-u', server],
|
|
28
|
-
{ stdio: 'inherit', cwd: root },
|
|
33
|
+
['-u', '-X', 'utf8', server],
|
|
34
|
+
{ stdio: 'inherit', cwd: root, env },
|
|
29
35
|
);
|
|
30
36
|
|
|
31
|
-
child
|
|
37
|
+
// A child killed by a signal is a crash, not a clean exit: reporting 0 here would
|
|
38
|
+
// make an MCP client treat a dead server as "finished normally".
|
|
39
|
+
child.on('exit', (code, signal) => {
|
|
40
|
+
if (signal) {
|
|
41
|
+
console.error(`[origin-bridge] python exited on ${signal}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
process.exit(code ?? 1);
|
|
45
|
+
});
|
package/cordis.patch.yml
CHANGED
|
@@ -17,8 +17,12 @@
|
|
|
17
17
|
# manual install -> set ORIGIN_BRIDGE_HOME to the folder holding server.py
|
|
18
18
|
# interpreter -> set ORIGIN_BRIDGE_PYTHON to your venv python.exe
|
|
19
19
|
#
|
|
20
|
-
# failOnStartupError: false keeps dsh bootable when
|
|
21
|
-
#
|
|
20
|
+
# failOnStartupError: false keeps dsh bootable when this entry cannot start at all —
|
|
21
|
+
# measured (2026-10-19) that means only a missing interpreter: the default command is
|
|
22
|
+
# `python` from PATH. A missing originpro does NOT hide the tools: all 27 still
|
|
23
|
+
# register and every call answers `missing_originpro` with the fix to run. Origin
|
|
24
|
+
# itself being closed is irrelevant to startup — the COM connection is made lazily
|
|
25
|
+
# inside the first tool call, not here.
|
|
22
26
|
|
|
23
27
|
- insert:
|
|
24
28
|
- id: mcp-origin-bridge
|
package/install_dsh.ps1
CHANGED
|
@@ -90,18 +90,25 @@ $block += " toolCallTimeoutMs: 180000`r`n"
|
|
|
90
90
|
# second copy of the server to drift out of sync with C:\...\origin-bridge.
|
|
91
91
|
$pkgName = 'dsh-origin-bridge'
|
|
92
92
|
$pkgDir = Join-Path $profileDir "node_modules\$pkgName"
|
|
93
|
+
# Read the real version instead of hard-coding it: the card in dsh shows this
|
|
94
|
+
# value, and a stale 0.4.0 makes the installed plugin look ancient.
|
|
95
|
+
$pkgVersion = '0.0.0'
|
|
96
|
+
$manifest = Join-Path $src 'package.json'
|
|
97
|
+
if (Test-Path $manifest) {
|
|
98
|
+
try { $pkgVersion = (Get-Content $manifest -Raw -Encoding UTF8 | ConvertFrom-Json).version } catch { }
|
|
99
|
+
}
|
|
93
100
|
if (-not (Test-Path (Join-Path $pkgDir 'index.js'))) {
|
|
94
101
|
New-Item -ItemType Directory -Force -Path $pkgDir | Out-Null
|
|
95
|
-
@
|
|
102
|
+
@"
|
|
96
103
|
{
|
|
97
104
|
"name": "dsh-origin-bridge",
|
|
98
|
-
"version": "
|
|
105
|
+
"version": "$pkgVersion",
|
|
99
106
|
"description": "Origin Bridge: natural language" -> local Origin/OriginPro -> editable .opju + publication images",
|
|
100
107
|
"main": "./index.js",
|
|
101
108
|
"type": "module",
|
|
102
109
|
"license": "MIT"
|
|
103
110
|
}
|
|
104
|
-
|
|
111
|
+
"@ | Set-Content -Path (Join-Path $pkgDir 'package.json') -Encoding ASCII
|
|
105
112
|
@'
|
|
106
113
|
// Registration-only bundle. The Origin tools come from server.py in this
|
|
107
114
|
// package's own folder, registered by the mcp-origin-bridge insert in the
|
|
@@ -126,6 +133,13 @@ $block += " name: $pkgName`r`n"
|
|
|
126
133
|
|
|
127
134
|
$existing = if (Test-Path $patch) { Get-Content $patch -Raw -Encoding UTF8 } else { '' }
|
|
128
135
|
|
|
136
|
+
# A fresh dsh profile ships `cordis.patch.yml` containing just `[]` (plus comments).
|
|
137
|
+
# Appending a `- insert:` entry after that produces TWO YAML documents and dsh
|
|
138
|
+
# refuses to parse the overlay — the installer used to break a brand-new profile.
|
|
139
|
+
# Treat a comments-only / empty-array file as empty so the block becomes the array.
|
|
140
|
+
$meaningful = ($existing -split "`r?`n" | Where-Object { $_ -notmatch '^\s*#' -and $_.Trim() -ne '' }) -join "`n"
|
|
141
|
+
if ($meaningful.Trim() -eq '[]' -or $meaningful.Trim() -eq '') { $existing = '' }
|
|
142
|
+
|
|
129
143
|
# -RemoveOld: drop the previous plugin's insert block. Both entries pointed at
|
|
130
144
|
# C:\...\originlab-bridge, and a dead stdio server still shows up as a tool
|
|
131
145
|
# provider, so leaving it in place would mean two overlapping tool sets.
|
package/originlab.py
CHANGED
|
@@ -351,6 +351,28 @@ atexit.register(_release_on_exit)
|
|
|
351
351
|
|
|
352
352
|
_handles = {}
|
|
353
353
|
_names = {}
|
|
354
|
+
|
|
355
|
+
# Measured 2026-10-20: 300 graph pages do not break the bridge, but each plot slows
|
|
356
|
+
# from ~0.7 s (first 100) to ~2.8 s (200-300), because Origin's COM surface grows with
|
|
357
|
+
# the project. Counting pages we create costs nothing (no extra COM call), so we can
|
|
358
|
+
# tell the caller before it becomes painful.
|
|
359
|
+
_page_growth = [0]
|
|
360
|
+
_GROWTH_HINT_EVERY = 50
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _growth_warning(created=True):
|
|
364
|
+
if not created:
|
|
365
|
+
return None
|
|
366
|
+
_page_growth[0] += 1
|
|
367
|
+
n = _page_growth[0]
|
|
368
|
+
if n % _GROWTH_HINT_EVERY:
|
|
369
|
+
return None
|
|
370
|
+
return ("本次会话已新建约 %d 个图页且未清理:实测 300 页时每张图约 2.8 秒(100 页时约 0.7 秒)。"
|
|
371
|
+
"长会话建议 origin_close_pages(kind='graph'),或 origin_exit 后重开" % n)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _reset_growth():
|
|
375
|
+
_page_growth[0] = 0
|
|
354
376
|
_seq = [0]
|
|
355
377
|
_project_file = ""
|
|
356
378
|
|
|
@@ -669,7 +691,16 @@ def read_file(path, comment=None):
|
|
|
669
691
|
path = os.path.abspath(os.path.expanduser(path))
|
|
670
692
|
if not os.path.isfile(path):
|
|
671
693
|
raise OriginError("file_not_found", "文件不存在:%s" % path)
|
|
672
|
-
|
|
694
|
+
ext = os.path.splitext(path)[1].lower()
|
|
695
|
+
if ext in (".xls", ".xlsx", ".wks"):
|
|
696
|
+
# Decoding a zip/OLE container as text "succeeds" into mojibake and then reports
|
|
697
|
+
# "只有表头?", blaming the user's file for our mistake (measured 2026-10-19).
|
|
698
|
+
raise OriginError(
|
|
699
|
+
"unsupported_file_type",
|
|
700
|
+
"%s 是二进制表格,origin_read_file 只读文本类数据" % ext,
|
|
701
|
+
["用 origin_import 直接导入(走 Origin 原生导入)",
|
|
702
|
+
"或先在 Excel 里另存为 .csv,再用 origin_read_file 预览结构"])
|
|
703
|
+
if ext == ".bin":
|
|
673
704
|
try:
|
|
674
705
|
decoded = binio.read_bin(path)
|
|
675
706
|
except binio.BinFormatError as exc:
|
|
@@ -689,8 +720,14 @@ def read_file(path, comment=None):
|
|
|
689
720
|
except ValueError as exc:
|
|
690
721
|
raise OriginError("parse_failed", str(exc),
|
|
691
722
|
["确认文件里有数值行", "给 comment 指定实际的注释前缀"])
|
|
692
|
-
|
|
693
|
-
|
|
723
|
+
# Three raw values per column: enough for the model to spot a shifted column or a
|
|
724
|
+
# column that parsed to nothing, without putting the whole dataset in context.
|
|
725
|
+
parsed["columns"] = [
|
|
726
|
+
dict({k: v for k, v in c.items() if k != "values"},
|
|
727
|
+
sample=(c.get("values") or [])[:3])
|
|
728
|
+
for c in parsed["columns"]]
|
|
729
|
+
parsed["sample_note"] = ("sample 是每列前 3 个原始值(缺值为 null),只用来抽查解析是否正确;"
|
|
730
|
+
"完整数据用 origin_import 进 Origin")
|
|
694
731
|
parsed["proof_level"] = "verified"
|
|
695
732
|
return sanitize(parsed)
|
|
696
733
|
|
|
@@ -1016,6 +1053,9 @@ def plot(worksheet, x=1, y=2, plot_type="line", graph_name="", title="",
|
|
|
1016
1053
|
if review:
|
|
1017
1054
|
span["warnings"] = [review]
|
|
1018
1055
|
span["next_actions"] = list(REVIEW_OPTIONS)
|
|
1056
|
+
grown = _growth_warning(created=(int(layer) == 0))
|
|
1057
|
+
if grown:
|
|
1058
|
+
span.setdefault("warnings", []).append(grown)
|
|
1019
1059
|
return sanitize({"graph": _bind("gr", gr, gr.name), "plot_type": plot_type,
|
|
1020
1060
|
"channel": channel, "template": tpl,
|
|
1021
1061
|
"source_sheet": _safe(lambda: ws.name, ""),
|
|
@@ -1355,8 +1395,25 @@ def _layer_count(gr):
|
|
|
1355
1395
|
return n
|
|
1356
1396
|
|
|
1357
1397
|
|
|
1398
|
+
def _image_width(value, default, hi):
|
|
1399
|
+
"""Validate a rendering width. Both ends matter: 0 or negative is nonsense, and
|
|
1400
|
+
a huge number makes Origin render a file too big to hand back."""
|
|
1401
|
+
try:
|
|
1402
|
+
width = int(value)
|
|
1403
|
+
except (TypeError, ValueError):
|
|
1404
|
+
width = default
|
|
1405
|
+
if width <= 0:
|
|
1406
|
+
raise OriginError("bad_arguments", "width 必须是正整数,收到 %r" % (value,))
|
|
1407
|
+
if width > hi:
|
|
1408
|
+
raise OriginError("bad_arguments", "width 太大:上限 %d,收到 %d" % (hi, width),
|
|
1409
|
+
["出版图给 1600–3000 就够;预览图给 900"])
|
|
1410
|
+
return max(100, width)
|
|
1411
|
+
|
|
1412
|
+
|
|
1358
1413
|
def view(graph, width=900):
|
|
1359
1414
|
"""Render the graph to a small PNG and hand the bytes back for visual check."""
|
|
1415
|
+
width = _image_width(width, 900, 2400)
|
|
1416
|
+
|
|
1360
1417
|
def run():
|
|
1361
1418
|
import base64
|
|
1362
1419
|
import tempfile
|
|
@@ -1389,8 +1446,42 @@ def view(graph, width=900):
|
|
|
1389
1446
|
return call(run)
|
|
1390
1447
|
|
|
1391
1448
|
|
|
1392
|
-
|
|
1393
|
-
|
|
1449
|
+
# Substring matching let 37 of 45 dangerous statements through (measured 2026-10-20):
|
|
1450
|
+
# `win -cd` destroyed a page, `doc -n` replaced the project, `run -p`/`run.file` run
|
|
1451
|
+
# external code, and `doc -o` bypassed the handle reset that origin_open performs.
|
|
1452
|
+
# These are anchored per statement (split on ';', newline and '{') with word
|
|
1453
|
+
# boundaries, so option spelling and whitespace no longer decide the outcome.
|
|
1454
|
+
LABTALK_DENY = ("saveas", "openb", "fopen", "exitloop")
|
|
1455
|
+
LABTALK_DENY_STATEMENTS = (
|
|
1456
|
+
# Only `doc -a` (activate) is harmless; every other switch can save, replace,
|
|
1457
|
+
# open or close the project. Inverting the match keeps unknown switches blocked.
|
|
1458
|
+
(r"doc\s+-(?)", "doc 开关(-s 存、-n 新建、-o 打开都会丢当前工程;只放 -a)"),
|
|
1459
|
+
(r"win\s+-c", "关闭/销毁页面(-c、-cd)"),
|
|
1460
|
+
(r"(?:del|delete)(?:\s|$)", "删除数据集"),
|
|
1461
|
+
(r"layer\s+-d", "删除图层"),
|
|
1462
|
+
(r"page\s+-d", "删除页面"),
|
|
1463
|
+
(r"(?:quit|exit)(?:\s|$)", "退出 Origin"),
|
|
1464
|
+
(r"run(?:\.|\s+-)", "执行外部脚本或程序"),
|
|
1465
|
+
(r"(?:system|shell)(?:\s|$)", "调用系统命令"),
|
|
1466
|
+
(r"(?:rmdir|mkdir)(?:\s|$)", "创建/删除目录"),
|
|
1467
|
+
(r"type\s+-", "type 开关(-a 会用外部程序打开文件)"),
|
|
1468
|
+
(r"close(?:\s|$)", "关闭窗口"),
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def labtalk_denied(script):
|
|
1473
|
+
"""Return the reasons a LabTalk script is refused (empty list = allowed).
|
|
1474
|
+
|
|
1475
|
+
Exposed as a function so the test suite exercises the same matcher the tool uses
|
|
1476
|
+
instead of a copy that silently drifts.
|
|
1477
|
+
"""
|
|
1478
|
+
low = re.sub(r"\s+", " ", str(script).lower())
|
|
1479
|
+
blocked = [p for p in LABTALK_DENY if p in low]
|
|
1480
|
+
statements = [st.strip() for st in re.split('[;\\n{}]', low) if st.strip()]
|
|
1481
|
+
for rule, why in LABTALK_DENY_STATEMENTS:
|
|
1482
|
+
if any(re.search(r"(?:^|[\s,(=])" + rule, st) for st in statements):
|
|
1483
|
+
blocked.append(why)
|
|
1484
|
+
return blocked
|
|
1394
1485
|
|
|
1395
1486
|
|
|
1396
1487
|
def labtalk(script, readback=(), numeric=()):
|
|
@@ -1405,11 +1496,11 @@ def labtalk(script, readback=(), numeric=()):
|
|
|
1405
1496
|
# Advisory guardrail, not a sandbox: it stops the model from wiping the
|
|
1406
1497
|
# session by accident, not from trying hard. Whitespace is collapsed so
|
|
1407
1498
|
# 'doc -s' does not slip past the literal match.
|
|
1408
|
-
|
|
1409
|
-
blocked = [p for p in LABTALK_DENY if p in low]
|
|
1499
|
+
blocked = labtalk_denied(script)
|
|
1410
1500
|
if blocked:
|
|
1411
1501
|
raise OriginError("labtalk_denied", "这些命令不允许通过 origin_labtalk 执行:%s" % blocked,
|
|
1412
|
-
["关工程/退 Origin 请用 origin_exit 或 origin_save_project"
|
|
1502
|
+
["关工程/退 Origin 请用 origin_exit 或 origin_save_project",
|
|
1503
|
+
"句柄/页面管理用 origin_close_pages、保存用 origin_save_project"])
|
|
1413
1504
|
|
|
1414
1505
|
def run():
|
|
1415
1506
|
op = origin()
|
|
@@ -1554,6 +1645,7 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
|
|
|
1554
1645
|
|
|
1555
1646
|
|
|
1556
1647
|
def export(graph, path="", fmt="png", width=1600):
|
|
1648
|
+
width = _image_width(width, 1600, 12000)
|
|
1557
1649
|
fmt = str(fmt).lstrip(".").lower()
|
|
1558
1650
|
# Origin's export filter only recognises the short names: asking for a.tiff
|
|
1559
1651
|
# leaves no file behind, which reads as a failed export.
|
|
@@ -1573,7 +1665,13 @@ def export(graph, path="", fmt="png", width=1600):
|
|
|
1573
1665
|
raw_path = str(path or "").strip()
|
|
1574
1666
|
target_path = _norm_target(raw_path or os.path.join(folder, "%s.%s" % (name, fmt)), fmt)
|
|
1575
1667
|
_prepare_write(target_path)
|
|
1576
|
-
|
|
1668
|
+
# width only applies when a type is passed as well; the old call passed
|
|
1669
|
+
# neither, so `width` never did anything (measured: 400 and 12000 both came
|
|
1670
|
+
# back as the page size, 3216 px). Fall back to the plain call if the typed
|
|
1671
|
+
# one does not produce a file.
|
|
1672
|
+
_safe(lambda: page.save_fig(target_path, fmt, width=int(width)))
|
|
1673
|
+
if not os.path.isfile(target_path):
|
|
1674
|
+
_safe(lambda: page.save_fig(target_path))
|
|
1577
1675
|
written, note = _written_or_mangled(target_path)
|
|
1578
1676
|
if not written:
|
|
1579
1677
|
raise OriginError("export_failed", "Origin 说导出了但文件不存在:%s" % target_path,
|
|
@@ -1585,6 +1683,11 @@ def export(graph, path="", fmt="png", width=1600):
|
|
|
1585
1683
|
["图页可能是空的:origin_inspect 看 plots 数"])
|
|
1586
1684
|
if note:
|
|
1587
1685
|
info["warnings"] = [note]
|
|
1686
|
+
dims = info.get("dimensions")
|
|
1687
|
+
if dims and fmt == "png" and abs(int(dims[0]) - int(width)) > max(2, int(width) * 0.02):
|
|
1688
|
+
# Tell the caller instead of pretending the request was honoured.
|
|
1689
|
+
info["warnings"] = (info.get("warnings") or []) + [
|
|
1690
|
+
"width=%d 没有生效:实际导出 %dx%d(Origin 对该图/滤镜接受了别的尺寸)" % (width, dims[0], dims[1])]
|
|
1588
1691
|
rel = _rel_note(path)
|
|
1589
1692
|
if rel:
|
|
1590
1693
|
info["warnings"] = (info.get("warnings") or []) + [rel]
|
|
@@ -1662,12 +1765,27 @@ def open_project(path):
|
|
|
1662
1765
|
def run():
|
|
1663
1766
|
global _project_file
|
|
1664
1767
|
op = origin()
|
|
1665
|
-
|
|
1768
|
+
# Opening a project replaces every page, so every handle handed out earlier is
|
|
1769
|
+
# dead — and a *refused* open can still have closed the current project. Both
|
|
1770
|
+
# ways the table has to go: leaving it behind makes later calls act on dead
|
|
1771
|
+
# pages, where originpro's wrapper "succeeds" and Origin quietly writes nothing
|
|
1772
|
+
# (that is how three exports failed in the 1124-case run).
|
|
1773
|
+
try:
|
|
1774
|
+
ok = bool(op.open(path))
|
|
1775
|
+
finally:
|
|
1776
|
+
_handles.clear()
|
|
1777
|
+
_names.clear()
|
|
1778
|
+
if not ok:
|
|
1779
|
+
_project_file = ""
|
|
1666
1780
|
raise OriginError("open_failed", "Origin 拒绝打开这个工程:%s" % path,
|
|
1667
1781
|
["确认文件是 .opju/.opj 且没被另一个 Origin 实例锁住",
|
|
1668
|
-
"Origin 版本低于该工程保存版本时也打不开"
|
|
1782
|
+
"Origin 版本低于该工程保存版本时也打不开",
|
|
1783
|
+
"打开失败后旧句柄已作废,请重新 origin_inspect 看当前页面"])
|
|
1669
1784
|
_project_file = path
|
|
1670
|
-
|
|
1785
|
+
_reset_growth()
|
|
1786
|
+
return sanitize({"opened": path, "open_pages": [p.name for p in op.pages()],
|
|
1787
|
+
"handles_reset": True,
|
|
1788
|
+
"note": "之前拿到的 ws-/gr- 句柄全部作废(工程换了页面),请用这里的页面名或重新导入"})
|
|
1671
1789
|
|
|
1672
1790
|
return call(run)
|
|
1673
1791
|
|
|
@@ -1712,6 +1830,7 @@ def exit_origin(save_to=""):
|
|
|
1712
1830
|
_names.clear()
|
|
1713
1831
|
_project_file = ""
|
|
1714
1832
|
_reset_connection()
|
|
1833
|
+
_reset_growth()
|
|
1715
1834
|
return sanitize({"exited": True, "saved": saved})
|
|
1716
1835
|
|
|
1717
1836
|
return call(run)
|
|
@@ -1844,6 +1963,9 @@ def matrix_plot(rows, template="heat_map", book_name="", graph_name="",
|
|
|
1844
1963
|
"xlim": _safe(lambda: list(lay.xlim)), "ylim": _safe(lambda: list(lay.ylim)),
|
|
1845
1964
|
"zlim": _safe(lambda: list(lay.zlim)),
|
|
1846
1965
|
"proof_level": "verified"}
|
|
1966
|
+
grown = _growth_warning()
|
|
1967
|
+
if grown:
|
|
1968
|
+
out.setdefault("warnings", []).append(grown)
|
|
1847
1969
|
if export_path:
|
|
1848
1970
|
out["export"] = _export_on_com_thread(gr, export_path)
|
|
1849
1971
|
if project_path:
|
|
@@ -1950,6 +2072,8 @@ def close_pages(kind="graph", names=None, dry_run=False):
|
|
|
1950
2072
|
for ref in [r for r, nm in _names.items() if nm and nm in closed]:
|
|
1951
2073
|
_handles.pop(ref, None)
|
|
1952
2074
|
_names.pop(ref, None)
|
|
2075
|
+
if closed:
|
|
2076
|
+
_reset_growth()
|
|
1953
2077
|
return sanitize({"asked": wanted, "closed": closed,
|
|
1954
2078
|
"refused": [n for n in wanted if n not in closed],
|
|
1955
2079
|
"errors": errors,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-origin-bridge",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "在对话里驱动本机 Origin / OriginPro:导入 .dat/.csv/.txt 与电化学工作站 .bin,绘图、拟合、改样式,导出图片并保存可在 Origin 里继续编辑的 .opju 工程。dsh 插件,27 个 MCP 工具。第三方工具,与 OriginLab Corporation 无关联、未获认可。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
"edge_checks.py",
|
|
53
53
|
"test_paths.py",
|
|
54
54
|
"test_json_safety.py",
|
|
55
|
+
"test_readfile.py",
|
|
56
|
+
"test_labtalk_guard.py",
|
|
55
57
|
"test_asciiio.py",
|
|
56
58
|
"test_binio.py",
|
|
57
59
|
"test_lifecycle.py",
|
package/requirements.txt
CHANGED
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.8"
|
|
24
24
|
PROTOCOL = "2024-11-05"
|
|
25
25
|
|
|
26
26
|
|
|
@@ -106,7 +106,7 @@ TOOLS = [
|
|
|
106
106
|
},
|
|
107
107
|
{
|
|
108
108
|
"name": "origin_read_file",
|
|
109
|
-
"description": "只解析数据文件、不碰 Origin:返回编码、分隔符、表头行数、每列长名/单位/有效行数,以及仪器参数 metadata。拿到陌生 .dat/.csv/.txt 先用它看清结构,再决定 origin_import 用哪几列。支持 #、//、!、; 注释、BOM、GBK、空格对齐、Fortran 的 1.5d+00 指数、NA/- 缺失值、Origin 三行表头(Long Name/Units/Comments),也支持电化学工作站(CH Instruments 等)导出的 txt:前面几十行参数说明会被跳进 metadata,紧贴数据的 Potential/V, Current/A
|
|
109
|
+
"description": "只解析数据文件、不碰 Origin:返回编码、分隔符、表头行数、每列长名/单位/有效行数,以及仪器参数 metadata。拿到陌生 .dat/.csv/.txt 先用它看清结构,再决定 origin_import 用哪几列。支持 #、//、!、; 注释、BOM、GBK、空格对齐、Fortran 的 1.5d+00 指数、NA/- 缺失值、Origin 三行表头(Long Name/Units/Comments),也支持电化学工作站(CH Instruments 等)导出的 txt:前面几十行参数说明会被跳进 metadata,紧贴数据的 Potential/V, Current/A 这类表头按斜杠拆出列名和单位。编码覆盖 UTF-8 / GBK / UTF-16(有无 BOM)/ cp1252;<0.001、2.5±0.1、25 mV、1,234.5、(500)、2.5* 这类被装饰的数值会解出来并在 warnings 里说明代入了什么。每列还带 sample(前 3 个原始值,缺值为 null),用来抽查解析对不对——完整数据仍然只进 Origin。二进制表格 .xls/.xlsx/.wks 不在它的范围内,会明确返回 unsupported_file_type,请用 origin_import。.bin 走另一条解析路线:返回技术标签(CV/LSV/IMP/i-t)、点数、两个 float32 数组的范围和 role_confidence。",
|
|
110
110
|
"inputSchema": {
|
|
111
111
|
"type": "object",
|
|
112
112
|
"properties": {
|
|
@@ -145,7 +145,7 @@ TOOLS = [
|
|
|
145
145
|
"symbol_interior": {"type": "integer", "enum": [0, 1, 2, 3],
|
|
146
146
|
"description": "0 无符号 1 实心 2 空心 3 点心"},
|
|
147
147
|
"transparency": {"type": "number", "description": "0-100 百分比"},
|
|
148
|
-
"fill_area": {"type": "boolean", "description": "
|
|
148
|
+
"fill_area": {"type": ["boolean", "integer"], "description": "线下填充(面积图效果,1/true 都行)"},
|
|
149
149
|
},
|
|
150
150
|
"required": ["graph"],
|
|
151
151
|
},
|
|
@@ -168,7 +168,7 @@ TOOLS = [
|
|
|
168
168
|
"properties": {
|
|
169
169
|
"worksheet": {"type": "string"},
|
|
170
170
|
"x": {"type": ["integer", "string"], "default": 1},
|
|
171
|
-
"y": {"type": ["integer", "string"], "default": 2},
|
|
171
|
+
"y": {"type": ["integer", "string", "array"], "default": 2},
|
|
172
172
|
"kind": {"type": "string", "enum": ["linear", "nlfitsing", "gauss", "lorentz",
|
|
173
173
|
"voigt", "expdec1", "expdec2", "sine", "power",
|
|
174
174
|
"logistic", "boltzmann", "doseresp", "cubic",
|
|
@@ -200,7 +200,7 @@ TOOLS = [
|
|
|
200
200
|
"path": {"type": "string", "description": "输出文件绝对路径;省略则用图页名放到当前目录"},
|
|
201
201
|
"fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
|
|
202
202
|
"default": "png"},
|
|
203
|
-
"width": {"type": "integer", "default": 1600},
|
|
203
|
+
"width": {"type": "integer", "minimum": 100, "maximum": 12000, "default": 1600},
|
|
204
204
|
},
|
|
205
205
|
},
|
|
206
206
|
"run": lambda a: originlab.export(a.get("graph", ""), a.get("path", ""),
|
|
@@ -217,7 +217,7 @@ TOOLS = [
|
|
|
217
217
|
},
|
|
218
218
|
{
|
|
219
219
|
"name": "origin_open",
|
|
220
|
-
"description": "打开已有的 .opju/.opj
|
|
220
|
+
"description": "打开已有的 .opju/.opj 工程,返回页面清单。打开会替换工程里的所有页面,所以之前拿到的 ws-/gr- 句柄全部作废(返回 handles_reset: true)——要用就用返回的页面名,或重新 origin_import/origin_plot 拿新句柄;打开失败时旧句柄同样作废。",
|
|
221
221
|
"inputSchema": {
|
|
222
222
|
"type": "object",
|
|
223
223
|
"properties": {"path": {"type": "string"}},
|
|
@@ -243,14 +243,14 @@ TOOLS = [
|
|
|
243
243
|
"source": {"type": "string", "description": "数据文件路径(与 columns 二选一)"},
|
|
244
244
|
"columns": {"type": "object", "description": "内联数据 {列名: [值...]},优先用 source"},
|
|
245
245
|
"x": {"type": ["integer", "string"], "default": 1},
|
|
246
|
-
"y": {"type": ["integer", "string"], "default": 2},
|
|
246
|
+
"y": {"type": ["integer", "string", "array"], "default": 2},
|
|
247
247
|
"plot_type": {"type": "string", "enum": ["line", "scatter", "line_symbol", "column"], "default": "line"},
|
|
248
248
|
"title": {"type": "string"},
|
|
249
249
|
"x_title": {"type": "string"},
|
|
250
250
|
"y_title": {"type": "string"},
|
|
251
251
|
"fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
|
|
252
252
|
"default": "png"},
|
|
253
|
-
"width": {"type": "integer", "default": 1600},
|
|
253
|
+
"width": {"type": "integer", "minimum": 100, "maximum": 12000, "default": 1600},
|
|
254
254
|
"output_dir": {"type": "string", "description": "交付目录:图片 + opju 都写到这。不给则只建图、不落盘"},
|
|
255
255
|
"export_path": {"type": "string"},
|
|
256
256
|
"project_path": {"type": "string"},
|
|
@@ -394,7 +394,7 @@ TOOLS = [
|
|
|
394
394
|
"type": "object",
|
|
395
395
|
"properties": {
|
|
396
396
|
"name": {"type": "string"},
|
|
397
|
-
"data": {"type": "string", "description": "
|
|
397
|
+
"data": {"type": ["string", "object", "array"], "description": "数据文件路径;也接受内联的 {列名:[值]} 或列名列表"},
|
|
398
398
|
"columns": {"type": "array", "description": "或者直接给列名列表"},
|
|
399
399
|
},
|
|
400
400
|
"required": ["name"],
|
|
@@ -421,7 +421,7 @@ TOOLS = [
|
|
|
421
421
|
"with_legend": {"type": "boolean", "default": True},
|
|
422
422
|
"fmt": {"type": "string", "enum": ["png", "tif", "jpg", "jpeg", "svg", "pdf", "emf"],
|
|
423
423
|
"default": "png"},
|
|
424
|
-
"width": {"type": "integer", "default": 1600},
|
|
424
|
+
"width": {"type": "integer", "minimum": 100, "maximum": 12000, "default": 1600},
|
|
425
425
|
},
|
|
426
426
|
"required": ["name"],
|
|
427
427
|
},
|
|
@@ -452,14 +452,14 @@ TOOLS = [
|
|
|
452
452
|
"type": "object",
|
|
453
453
|
"properties": {
|
|
454
454
|
"graph": {"type": "string", "description": "省略则用当前活动图页"},
|
|
455
|
-
"width": {"type": "integer", "default": 900},
|
|
455
|
+
"width": {"type": "integer", "minimum": 100, "maximum": 2400, "default": 900},
|
|
456
456
|
},
|
|
457
457
|
},
|
|
458
458
|
"run": lambda a: originlab.view(a.get("graph", ""), a.get("width", 900)),
|
|
459
459
|
},
|
|
460
460
|
{
|
|
461
461
|
"name": "origin_labtalk",
|
|
462
|
-
"description": "逃生舱:执行原始 LabTalk,并读回你点名的变量(numeric 读数值表达式,readback 读字符串变量)。实测限制:数值/表达式类语句可用(如 layer.x.type=2、win -a Graph1),但字符串赋值类语句(layer.x.title$=... 、set c1 -l ...)在这台机器上 lt_exec 会返回 False,轴标题等请用 origin_style。returned=false
|
|
462
|
+
"description": "逃生舱:执行原始 LabTalk,并读回你点名的变量(numeric 读数值表达式,readback 读字符串变量)。实测限制:数值/表达式类语句可用(如 layer.x.type=2、win -a Graph1),但字符串赋值类语句(layer.x.title$=... 、set c1 -l ...)在这台机器上 lt_exec 会返回 False,轴标题等请用 origin_style。returned=false 就是真失败,别重试同一条。破坏性命令会被拦下并返回 labtalk_denied:doc -s/-n/-o(存/新建/打开工程)、win -c/-cd(关/销毁页面)、del/delete、layer -d、page -d、run./run -、system/shell、type -、rmdir/mkdir、quit/exit、close、saveas。这是劝告式护栏、不是沙箱(变量拼接仍可能绕过),别拿它做破坏性操作;页面/句柄管理用 origin_close_pages,保存用 origin_save_project。",
|
|
463
463
|
"inputSchema": {
|
|
464
464
|
"type": "object",
|
|
465
465
|
"properties": {
|
|
@@ -494,6 +494,44 @@ def _tool_result(call_id, payload, is_error=False):
|
|
|
494
494
|
return {"jsonrpc": "2.0", "id": call_id, "result": {"content": content, "isError": is_error}}
|
|
495
495
|
|
|
496
496
|
|
|
497
|
+
_JSON_TYPES = {"string": str, "integer": int, "number": (int, float), "boolean": bool,
|
|
498
|
+
"array": list, "object": dict}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _validate_args(name, args, schema):
|
|
502
|
+
"""Reject wrong-shaped arguments as bad_arguments before the tool runs.
|
|
503
|
+
|
|
504
|
+
Only *types* are checked here. Enums and ranges stay with the engine, which
|
|
505
|
+
answers with a more specific code (`bad_position`, `bad_format`, `bad_layer_type`)
|
|
506
|
+
than a generic bad_arguments would. What this fixes is the type confusion: a
|
|
507
|
+
`path: 123` used to reach the engine and surface as `origin_unavailable` with a
|
|
508
|
+
raw TypeError, i.e. the model was told Origin was missing. Extra keys are fine.
|
|
509
|
+
"""
|
|
510
|
+
props = (schema or {}).get("properties") or {}
|
|
511
|
+
problems = []
|
|
512
|
+
for key, spec in props.items():
|
|
513
|
+
if key not in args or args[key] is None:
|
|
514
|
+
continue
|
|
515
|
+
value = args[key]
|
|
516
|
+
types = spec.get("type")
|
|
517
|
+
types = types if isinstance(types, list) else ([types] if types else [])
|
|
518
|
+
if types:
|
|
519
|
+
def fits(t):
|
|
520
|
+
py = _JSON_TYPES.get(t)
|
|
521
|
+
if py is None:
|
|
522
|
+
return True
|
|
523
|
+
if t in ("integer", "number") and isinstance(value, bool):
|
|
524
|
+
return False
|
|
525
|
+
return isinstance(value, py)
|
|
526
|
+
if not any(fits(t) for t in types):
|
|
527
|
+
problems.append("%s 应为 %s,收到 %s"
|
|
528
|
+
% (key, "/".join(types), type(value).__name__))
|
|
529
|
+
continue
|
|
530
|
+
if problems:
|
|
531
|
+
raise originlab.OriginError("bad_arguments", "%s 参数不合法:%s" % (name, ";".join(problems)),
|
|
532
|
+
["按工具的 inputSchema 修正参数类型/范围后再调用"])
|
|
533
|
+
|
|
534
|
+
|
|
497
535
|
def _handle(msg):
|
|
498
536
|
if not isinstance(msg, dict):
|
|
499
537
|
return {"jsonrpc": "2.0", "id": None,
|
|
@@ -532,6 +570,7 @@ def _handle(msg):
|
|
|
532
570
|
return _tool_result(call_id, {"ok": False, "code": "bad_arguments",
|
|
533
571
|
"message": "arguments 必须是对象"}, True)
|
|
534
572
|
try:
|
|
573
|
+
_validate_args(name, args, tool.get("inputSchema"))
|
|
535
574
|
out = tool["run"](args)
|
|
536
575
|
if isinstance(out, dict):
|
|
537
576
|
out = {"ok": True, **out}
|
|
@@ -579,13 +618,31 @@ def main():
|
|
|
579
618
|
|
|
580
619
|
stdin = sys.stdin.buffer
|
|
581
620
|
stdout = sys.stdout.buffer
|
|
582
|
-
|
|
621
|
+
# One JSON line is one request; an unbounded readline let a single huge
|
|
622
|
+
# `columns` payload allocate whatever the client sent. The cap only needs to be
|
|
623
|
+
# far above any real dataset (a 1M-point two-column write is ~30 MB of JSON).
|
|
624
|
+
max_line = int(os.environ.get("ORIGIN_BRIDGE_MAX_LINE", 64 * 1024 * 1024))
|
|
625
|
+
_log("ready; python=%s | max line %d" % (sys.executable, max_line))
|
|
583
626
|
|
|
584
627
|
while True:
|
|
585
|
-
raw = stdin.readline()
|
|
628
|
+
raw = stdin.readline(max_line + 1)
|
|
586
629
|
if not raw:
|
|
587
630
|
_log("stdin closed; exiting")
|
|
588
631
|
return 0
|
|
632
|
+
if len(raw) > max_line and not raw.endswith(b"\n"):
|
|
633
|
+
# Drain the rest of the oversized line so the next request is not read
|
|
634
|
+
# from the middle of this one, then answer with a clean error.
|
|
635
|
+
while True:
|
|
636
|
+
chunk = stdin.readline(max_line + 1)
|
|
637
|
+
if not chunk or chunk.endswith(b"\n"):
|
|
638
|
+
break
|
|
639
|
+
_log("request over %d bytes refused" % max_line)
|
|
640
|
+
stdout.write((json.dumps({"jsonrpc": "2.0", "id": None,
|
|
641
|
+
"error": {"code": -32600,
|
|
642
|
+
"message": "请求太大:单行上限 %d 字节" % max_line}},
|
|
643
|
+
ensure_ascii=False) + "\n").encode("utf-8"))
|
|
644
|
+
stdout.flush()
|
|
645
|
+
continue
|
|
589
646
|
raw = raw.strip()
|
|
590
647
|
if not raw:
|
|
591
648
|
continue
|
package/test_json_safety.py
CHANGED
|
@@ -91,6 +91,60 @@ def main():
|
|
|
91
91
|
ok("answered with a JSON-RPC error", payload.get("error", {}).get("code") == -32603, payload)
|
|
92
92
|
ok("error keeps the request id", payload.get("id") == 7, payload)
|
|
93
93
|
|
|
94
|
+
# ------------------------------------------------ oversized request line
|
|
95
|
+
# A single enormous request must be refused without allocating it whole, and the
|
|
96
|
+
# stream has to resync so the NEXT request still works.
|
|
97
|
+
child2 = (
|
|
98
|
+
"import io, json, os, sys\n"
|
|
99
|
+
"sys.path.insert(0, os.environ['BRIDGE_DIR'])\n"
|
|
100
|
+
"import server\n"
|
|
101
|
+
"big = json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list',"
|
|
102
|
+
" 'pad': 'x' * 9000}) + chr(10)\n"
|
|
103
|
+
"good = json.dumps({'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}) + chr(10)\n"
|
|
104
|
+
"sys.stdin = io.TextIOWrapper(io.BytesIO((big + good).encode('utf-8')), encoding='utf-8')\n"
|
|
105
|
+
"sys.exit(server.main() or 0)\n"
|
|
106
|
+
)
|
|
107
|
+
env2 = dict(env, ORIGIN_BRIDGE_MAX_LINE="4096")
|
|
108
|
+
proc2 = subprocess.run([sys.executable, "-c", child2], capture_output=True, env=env2, timeout=120)
|
|
109
|
+
lines2 = proc2.stdout.decode("utf-8", "replace").strip().splitlines()
|
|
110
|
+
ok("oversized request produced two replies", len(lines2) == 2, len(lines2))
|
|
111
|
+
first = {}
|
|
112
|
+
try:
|
|
113
|
+
first = strict(lines2[0])
|
|
114
|
+
except Exception as exc: # noqa: BLE001
|
|
115
|
+
ok("refusal is parseable", False, exc)
|
|
116
|
+
ok("oversized request refused with -32600",
|
|
117
|
+
first.get("error", {}).get("code") == -32600, first)
|
|
118
|
+
second = {}
|
|
119
|
+
try:
|
|
120
|
+
second = strict(lines2[1])
|
|
121
|
+
except Exception: # noqa: BLE001
|
|
122
|
+
pass
|
|
123
|
+
ok("stream resynced: the next request is answered",
|
|
124
|
+
bool((second.get("result") or {}).get("tools")), second)
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------ argument validation
|
|
127
|
+
# A wrong-typed argument used to reach the engine and surface as
|
|
128
|
+
# "origin_unavailable: TypeError", i.e. it blamed a missing Origin install.
|
|
129
|
+
import server as srv
|
|
130
|
+
|
|
131
|
+
def code_for(tool, args):
|
|
132
|
+
try:
|
|
133
|
+
srv._validate_args(tool, args, srv._BY_NAME[tool]["inputSchema"])
|
|
134
|
+
except srv.originlab.OriginError as exc:
|
|
135
|
+
return exc.code
|
|
136
|
+
return "no-raise"
|
|
137
|
+
|
|
138
|
+
ok("wrong-typed path is bad_arguments",
|
|
139
|
+
code_for("origin_import", {"path": 123}) == "bad_arguments")
|
|
140
|
+
ok("wrong-typed worksheet is bad_arguments",
|
|
141
|
+
code_for("origin_plot", {"worksheet": ["ws-1"]}) == "bad_arguments")
|
|
142
|
+
ok("boolean is not an integer",
|
|
143
|
+
code_for("origin_export", {"width": True}) == "bad_arguments")
|
|
144
|
+
ok("valid args pass", code_for("origin_import", {"path": "x.csv"}) == "no-raise")
|
|
145
|
+
ok("extra keys are tolerated",
|
|
146
|
+
code_for("origin_import", {"path": "x.csv", "bogus": 1}) == "no-raise")
|
|
147
|
+
|
|
94
148
|
print("\n%d/%d json-safety checks passed" % (PASSED, PASSED + len(FAILED)))
|
|
95
149
|
if FAILED:
|
|
96
150
|
for f in FAILED:
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Checks for the LabTalk deny matcher (no Origin needed).
|
|
2
|
+
|
|
3
|
+
The guard is advisory — a variable can still assemble a blocked command at runtime —
|
|
4
|
+
but everything a model would plausibly type, including option variants and extra
|
|
5
|
+
whitespace, has to be refused. Measured before the rewrite: 37 of 45 destructive
|
|
6
|
+
statements went straight through.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
13
|
+
import originlab as ol # noqa: E402
|
|
14
|
+
|
|
15
|
+
PASSED = 0
|
|
16
|
+
FAILED = []
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ok(label, cond, detail=""):
|
|
20
|
+
global PASSED
|
|
21
|
+
if cond:
|
|
22
|
+
PASSED += 1
|
|
23
|
+
else:
|
|
24
|
+
FAILED.append("%s %s" % (label, detail))
|
|
25
|
+
print("%s %s%s" % ("PASS" if cond else "FAIL", label,
|
|
26
|
+
(" | " + str(detail)[:100]) if detail else ""))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
BLOCKED = [
|
|
30
|
+
("doc -s", "保存工程"),
|
|
31
|
+
("doc -n", "新建工程会丢当前项目"),
|
|
32
|
+
("doc -o C:\\x.opju", "打开工程绕过句柄重置"),
|
|
33
|
+
("win -c Graph1", "关窗口"),
|
|
34
|
+
("win -cd Graph1", "销毁窗口"),
|
|
35
|
+
("del Book1", "删数据集"),
|
|
36
|
+
("delete Book1", "删数据集"),
|
|
37
|
+
("layer -d", "删图层"),
|
|
38
|
+
("page -d", "删页面"),
|
|
39
|
+
("rmdir C:\\temp\\x", "删目录"),
|
|
40
|
+
("mkdir C:\\temp\\x", "建目录"),
|
|
41
|
+
("run -p", "执行外部程序"),
|
|
42
|
+
("run.file x", "执行外部脚本"),
|
|
43
|
+
("type -a", "用外部程序打开文件"),
|
|
44
|
+
("exit", "退出"),
|
|
45
|
+
("quit", "退出"),
|
|
46
|
+
("system dir", "系统命令"),
|
|
47
|
+
("shell dir", "系统命令"),
|
|
48
|
+
("close", "裸 close 以前漏过"),
|
|
49
|
+
("DOC -N", "大小写与多空格"),
|
|
50
|
+
("win\t-cd", "制表符"),
|
|
51
|
+
("xx = 1; win -cd Graph1;", "分号后的危险命令"),
|
|
52
|
+
("if(1) del Book1;", "条件语句里的危险命令"),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
ALLOWED = [
|
|
56
|
+
"xx = 1;",
|
|
57
|
+
"layer.x.title$ = \"Time (s)\";",
|
|
58
|
+
"win -a Graph1;",
|
|
59
|
+
"layer.x.type = 2;",
|
|
60
|
+
"doc.echo = 0;",
|
|
61
|
+
"doc -a;",
|
|
62
|
+
"layer.x.label$ = \"delivery time\";",
|
|
63
|
+
"label$ = \"exit\";",
|
|
64
|
+
"legend -r;",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main():
|
|
69
|
+
for script, why in BLOCKED:
|
|
70
|
+
reasons = ol.labtalk_denied(script)
|
|
71
|
+
ok("blocked: %s(%s)" % (script, why), bool(reasons), reasons)
|
|
72
|
+
for script in ALLOWED:
|
|
73
|
+
reasons = ol.labtalk_denied(script)
|
|
74
|
+
ok("allowed: %s" % script, not reasons, reasons)
|
|
75
|
+
# the same matcher the tool calls
|
|
76
|
+
import inspect
|
|
77
|
+
src = inspect.getsource(ol.labtalk)
|
|
78
|
+
ok("tool uses the tested matcher", "labtalk_denied(script)" in src)
|
|
79
|
+
print("\n%d/%d deny-matcher checks passed" % (PASSED, PASSED + len(FAILED)))
|
|
80
|
+
if FAILED:
|
|
81
|
+
for f in FAILED:
|
|
82
|
+
print(" FAILED:", f)
|
|
83
|
+
return 1
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
sys.exit(main())
|
package/test_lifecycle.py
CHANGED
|
@@ -225,6 +225,20 @@ for tool in server.TOOLS:
|
|
|
225
225
|
check("%s 有描述和 schema" % tool["name"],
|
|
226
226
|
bool(tool.get("description")) and isinstance(tool.get("inputSchema"), dict))
|
|
227
227
|
|
|
228
|
+
print()
|
|
229
|
+
print("page-growth hint(不碰 COM):")
|
|
230
|
+
originlab._reset_growth()
|
|
231
|
+
early = [originlab._growth_warning() for _ in range(49)]
|
|
232
|
+
check("前 49 个页面不出提示", all(h is None for h in early))
|
|
233
|
+
h50 = originlab._growth_warning()
|
|
234
|
+
check("第 50 个页面给出提示", bool(h50) and "50" in h50, h50)
|
|
235
|
+
check("提示带实测耗时", bool(h50) and "2.8" in h50, h50)
|
|
236
|
+
originlab._reset_growth()
|
|
237
|
+
check("清理后计数归零", originlab._page_growth[0] == 0, originlab._page_growth[0])
|
|
238
|
+
check("非新建不计数", originlab._growth_warning(created=False) is None
|
|
239
|
+
and originlab._page_growth[0] == 0)
|
|
240
|
+
originlab._reset_growth()
|
|
241
|
+
|
|
228
242
|
print()
|
|
229
243
|
if fails:
|
|
230
244
|
print("%d 项失败: %s" % (len(fails), ", ".join(fails)))
|
package/test_paths.py
CHANGED
|
@@ -129,6 +129,17 @@ def main():
|
|
|
129
129
|
ok("blank path silent", ol._rel_note(" ") is None)
|
|
130
130
|
ok("no file written into the package dir by these checks",
|
|
131
131
|
set(os.listdir(HERE)) == before, sorted(set(os.listdir(HERE)) - before))
|
|
132
|
+
print()
|
|
133
|
+
print("width guards (shared by export and view):")
|
|
134
|
+
ok("0 is refused", code_of(lambda: ol._image_width(0, 900, 2400)) == "bad_arguments")
|
|
135
|
+
ok("negative is refused", code_of(lambda: ol._image_width(-5, 900, 2400)) == "bad_arguments")
|
|
136
|
+
ok("absurd is refused", code_of(lambda: ol._image_width(100000, 900, 2400)) == "bad_arguments")
|
|
137
|
+
ok("the ceiling itself is allowed", ol._image_width(2400, 900, 2400) == 2400)
|
|
138
|
+
ok("a tiny value is lifted to the floor", ol._image_width(20, 900, 2400) == 100)
|
|
139
|
+
ok("garbage falls back to the default", ol._image_width("wide", 900, 2400) == 900)
|
|
140
|
+
ok("export ceiling is higher than view",
|
|
141
|
+
ol._image_width(12000, 1600, 12000) == 12000)
|
|
142
|
+
|
|
132
143
|
|
|
133
144
|
print("\n%d/%d path checks passed" % (PASSED, PASSED + len(FAILED)))
|
|
134
145
|
if FAILED:
|
package/test_readfile.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Offline checks for origin_read_file's shaping (no Origin needed).
|
|
2
|
+
|
|
3
|
+
Two behaviours that cost a real debugging cycle:
|
|
4
|
+
- a `.xlsx` used to be decoded as text and reported as "找不到数值数据行(只有表头?)",
|
|
5
|
+
which blames the user's file for our mistake;
|
|
6
|
+
- the response used to carry no values at all, so a model could not tell a correctly
|
|
7
|
+
parsed column from one whose values slid into the neighbouring column.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
16
|
+
import originlab as ol # noqa: E402
|
|
17
|
+
|
|
18
|
+
PASSED = 0
|
|
19
|
+
FAILED = []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ok(label, cond, detail=""):
|
|
23
|
+
global PASSED
|
|
24
|
+
if cond:
|
|
25
|
+
PASSED += 1
|
|
26
|
+
else:
|
|
27
|
+
FAILED.append("%s %s" % (label, detail))
|
|
28
|
+
print("%s %s%s" % ("PASS" if cond else "FAIL", label,
|
|
29
|
+
(" | " + str(detail)[:110]) if detail else ""))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write(dirname, name, text, encoding="utf-8"):
|
|
33
|
+
path = os.path.join(dirname, name)
|
|
34
|
+
with open(path, "w", encoding=encoding, newline="") as fh:
|
|
35
|
+
fh.write(text)
|
|
36
|
+
return path
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def code_of(fn):
|
|
40
|
+
try:
|
|
41
|
+
fn()
|
|
42
|
+
except ol.OriginError as exc:
|
|
43
|
+
return exc.code
|
|
44
|
+
except Exception as exc: # noqa: BLE001 — an unwrapped crash is the failure
|
|
45
|
+
return "UNWRAPPED:" + type(exc).__name__
|
|
46
|
+
return "no-raise"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main():
|
|
50
|
+
work = tempfile.mkdtemp(prefix="readfile_checks_")
|
|
51
|
+
try:
|
|
52
|
+
csv = write(work, "a.csv", "x,y\n1,2\n3,\n5,6\n")
|
|
53
|
+
res = ol.read_file(csv)
|
|
54
|
+
ok("csv parses", res.get("rows") == 3, res.get("rows"))
|
|
55
|
+
ok("every column carries a 3-value sample",
|
|
56
|
+
[c["sample"] for c in res["columns"]] == [[1.0, 3.0, 5.0], [2.0, None, 6.0]],
|
|
57
|
+
[c["sample"] for c in res["columns"]])
|
|
58
|
+
ok("sample keeps nulls so a gap is visible", None in res["columns"][1]["sample"])
|
|
59
|
+
ok("sample is explained", "原始值" in (res.get("sample_note") or ""),
|
|
60
|
+
res.get("sample_note"))
|
|
61
|
+
ok("full values still stay out of the payload",
|
|
62
|
+
all("values" not in c for c in res["columns"]))
|
|
63
|
+
|
|
64
|
+
# the binary-spreadsheet guard
|
|
65
|
+
for name, head in (("b.xlsx", b"PK\x03\x04"), ("c.xls", b"\xd0\xcf\x11\xe0"),
|
|
66
|
+
("d.wks", b"\x00\x00\x02\x00")):
|
|
67
|
+
path = os.path.join(work, name)
|
|
68
|
+
with open(path, "wb") as fh:
|
|
69
|
+
fh.write(head + b"\x00" * 64)
|
|
70
|
+
ok("%s is refused as a binary sheet" % name,
|
|
71
|
+
code_of(lambda p=path: ol.read_file(p)) == "unsupported_file_type",
|
|
72
|
+
code_of(lambda p=path: ol.read_file(p)))
|
|
73
|
+
|
|
74
|
+
# and the guard says what to do instead, with the file name in the message
|
|
75
|
+
path = os.path.join(work, "b.xlsx")
|
|
76
|
+
try:
|
|
77
|
+
ol.read_file(path)
|
|
78
|
+
except ol.OriginError as exc:
|
|
79
|
+
ok("message names the format", ".xlsx" in exc.message, exc.message)
|
|
80
|
+
ok("next action points at origin_import",
|
|
81
|
+
any("origin_import" in a for a in exc.next_actions), exc.next_actions)
|
|
82
|
+
|
|
83
|
+
# import_file must still accept Excel: only read_file refuses it
|
|
84
|
+
import inspect
|
|
85
|
+
src = inspect.getsource(ol.import_file)
|
|
86
|
+
ok("import_file still allows .xlsx", "二进制表格" not in src)
|
|
87
|
+
|
|
88
|
+
ok("a .bin is still routed to binio, not refused",
|
|
89
|
+
code_of(lambda: ol.read_file(os.path.join(
|
|
90
|
+
os.path.dirname(os.path.abspath(__file__)), "fixtures", "not_a_bin.bin")))
|
|
91
|
+
in ("parse_failed", "no-raise"), "binio path")
|
|
92
|
+
|
|
93
|
+
print("\n%d/%d read_file checks passed" % (PASSED, PASSED + len(FAILED)))
|
|
94
|
+
if FAILED:
|
|
95
|
+
for f in FAILED:
|
|
96
|
+
print(" FAILED:", f)
|
|
97
|
+
return 1
|
|
98
|
+
return 0
|
|
99
|
+
finally:
|
|
100
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
sys.exit(main())
|