@alotop/dsh-matlab-bridge 0.1.1 → 0.1.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 +3 -3
- package/README.zh-CN.md +2 -2
- package/package.json +13 -12
- package/python/ml_driver.py +27 -9
- package/python/selftest.py +25 -6
- package/scripts/inspect-tarball.mjs +105 -0
package/README.md
CHANGED
|
@@ -206,8 +206,8 @@ such as `disp('hi')`.
|
|
|
206
206
|
## Development
|
|
207
207
|
|
|
208
208
|
```sh
|
|
209
|
-
git clone https://github.com/alotop/dsh-matlab
|
|
210
|
-
cd dsh-matlab
|
|
209
|
+
git clone https://github.com/alotop/dsh-matlab.git
|
|
210
|
+
cd dsh-matlab
|
|
211
211
|
npm run setup # lay out the engine runtime from your local MATLAB
|
|
212
212
|
npm run selftest # end-to-end checks against a real MATLAB (starts one)
|
|
213
213
|
npm run check # syntax-check the plugin and scripts
|
|
@@ -235,7 +235,7 @@ repository. One-time setup on npmjs.com:
|
|
|
235
235
|
|
|
236
236
|
1. The package must exist — do a first manual `npm publish` from a checkout.
|
|
237
237
|
2. Package → Settings → **Trusted Publisher** → GitHub Actions, with
|
|
238
|
-
repository `alotop/dsh-matlab
|
|
238
|
+
repository `alotop/dsh-matlab` and workflow `release.yml`.
|
|
239
239
|
|
|
240
240
|
After that, `id-token: write` in the workflow is the only credential needed, and
|
|
241
241
|
every published version carries a signed provenance attestation.
|
package/README.zh-CN.md
CHANGED
|
@@ -156,8 +156,8 @@ MATLAB Engine 会把 MATLAB 命令窗口输出转发到驱动的 stdout,裸 JS
|
|
|
156
156
|
## 开发
|
|
157
157
|
|
|
158
158
|
```sh
|
|
159
|
-
git clone https://github.com/alotop/dsh-matlab
|
|
160
|
-
cd dsh-matlab
|
|
159
|
+
git clone https://github.com/alotop/dsh-matlab.git
|
|
160
|
+
cd dsh-matlab
|
|
161
161
|
npm run setup # 从本地 MATLAB 铺开引擎运行库
|
|
162
162
|
npm run selftest # 对真实 MATLAB 的端到端检查(会启动一个)
|
|
163
163
|
npm run check # 语法检查插件与脚本
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alotop/dsh-matlab-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Run and interactively debug MATLAB code from a DSH session, through a persistent MATLAB Engine session.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/alotop/dsh-matlab.git"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public",
|
|
11
|
+
"provenance": true
|
|
12
|
+
},
|
|
5
13
|
"keywords": [
|
|
6
14
|
"dsh",
|
|
7
15
|
"dsh-plugin",
|
|
@@ -36,23 +44,16 @@
|
|
|
36
44
|
"scripts": {
|
|
37
45
|
"setup": "node scripts/setup-engine.mjs",
|
|
38
46
|
"selftest": "node scripts/run-selftest.mjs",
|
|
39
|
-
"check": "node --check src/plugin.mjs && node --check scripts/setup-engine.mjs"
|
|
40
|
-
|
|
41
|
-
"repository": {
|
|
42
|
-
"type": "git",
|
|
43
|
-
"url": "git+https://github.com/alotop/dsh-matlab-bridge.git"
|
|
47
|
+
"check": "node --check src/plugin.mjs && node --check scripts/setup-engine.mjs && node --check scripts/run-selftest.mjs && node --check scripts/inspect-tarball.mjs",
|
|
48
|
+
"pack:check": "npm pack --dry-run --json > pack.json && node scripts/inspect-tarball.mjs"
|
|
44
49
|
},
|
|
45
50
|
"bugs": {
|
|
46
|
-
"url": "https://github.com/alotop/dsh-matlab
|
|
51
|
+
"url": "https://github.com/alotop/dsh-matlab/issues"
|
|
47
52
|
},
|
|
48
|
-
"homepage": "https://github.com/alotop/dsh-matlab
|
|
53
|
+
"homepage": "https://github.com/alotop/dsh-matlab#readme",
|
|
49
54
|
"dsh": {
|
|
50
55
|
"bundle": {
|
|
51
56
|
"patch": "./cordis.patch.yml"
|
|
52
57
|
}
|
|
53
|
-
},
|
|
54
|
-
"publishConfig": {
|
|
55
|
-
"access": "public",
|
|
56
|
-
"provenance": true
|
|
57
58
|
}
|
|
58
59
|
}
|
package/python/ml_driver.py
CHANGED
|
@@ -70,6 +70,14 @@ _STDOUT = sys.stdout.buffer
|
|
|
70
70
|
|
|
71
71
|
PROTOCOL_PREFIX = "@@DSH:"
|
|
72
72
|
|
|
73
|
+
# Every action op_debug implements. Kept here so an unknown action can be
|
|
74
|
+
# rejected before the engine is touched -- see op_debug.
|
|
75
|
+
DEBUG_ACTIONS = (
|
|
76
|
+
"break", "breakError", "clearBreaks", "run", "status", "stack",
|
|
77
|
+
"vars", "get", "eval", "step", "stepIn", "stepOut", "continue",
|
|
78
|
+
"quit", "finish",
|
|
79
|
+
)
|
|
80
|
+
|
|
73
81
|
_engine = None
|
|
74
82
|
_future = None
|
|
75
83
|
_last_run_error = None
|
|
@@ -172,6 +180,11 @@ def op_eval(req):
|
|
|
172
180
|
def op_debug(req):
|
|
173
181
|
global _future, _last_run_error
|
|
174
182
|
action = req.get("action", "")
|
|
183
|
+
# Validate before touching the engine: a typo must not pay a ~20s MATLAB
|
|
184
|
+
# launch, which is otherwise a very expensive way to learn you misspelled
|
|
185
|
+
# an action.
|
|
186
|
+
if action not in DEBUG_ACTIONS:
|
|
187
|
+
return {"ok": False, "err": "unknown debug action: %s" % action}
|
|
175
188
|
eng = engine()
|
|
176
189
|
|
|
177
190
|
if action == "break":
|
|
@@ -277,18 +290,23 @@ def op_debug(req):
|
|
|
277
290
|
|
|
278
291
|
|
|
279
292
|
def _format_value(value):
|
|
280
|
-
"""Render a MATLAB value for the model
|
|
293
|
+
"""Render a MATLAB value as text for the model.
|
|
294
|
+
|
|
295
|
+
Formatting happens in Python because that is where the value already lives.
|
|
296
|
+
Calling back into MATLAB with `evalc('disp(value)')` would name a MATLAB
|
|
297
|
+
variable `value` that does not exist, so it merely leaked a spurious
|
|
298
|
+
"unrecognized function or variable 'value'" onto stderr beside a value that
|
|
299
|
+
was on its way out anyway. Engine array wrappers stringify to a readable
|
|
300
|
+
nested list, which is a fine rendering for a debugger readout.
|
|
301
|
+
"""
|
|
281
302
|
if value is None:
|
|
282
303
|
return ""
|
|
304
|
+
if isinstance(value, bool):
|
|
305
|
+
return "true" if value else "false"
|
|
283
306
|
if isinstance(value, float):
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
try:
|
|
288
|
-
text = engine().eval("evalc('disp(value)')", nargout=1)
|
|
289
|
-
return text or str(value)
|
|
290
|
-
except Exception: # noqa: BLE001
|
|
291
|
-
return str(value)
|
|
307
|
+
# MATLAB prints an integral double without a trailing ".0".
|
|
308
|
+
return str(int(value)) if value.is_integer() else repr(value)
|
|
309
|
+
return str(value)
|
|
292
310
|
|
|
293
311
|
|
|
294
312
|
def _load_json(payload, label):
|
package/python/selftest.py
CHANGED
|
@@ -157,9 +157,8 @@ def run_figure_checks(driver, out_dir):
|
|
|
157
157
|
|
|
158
158
|
resp = driver.call("figure", action="list")
|
|
159
159
|
check("no figures remain after close", resp.get("ok") is True and resp.get("figures") == [], repr(resp))
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
check("an invalid figure action is rejected", resp.get("ok") is False, repr(resp))
|
|
160
|
+
# Rejecting an unknown action is asserted in main(), before MATLAB starts,
|
|
161
|
+
# so that the same check also proves a typo does not launch the engine.
|
|
163
162
|
|
|
164
163
|
|
|
165
164
|
def run_debug_checks(driver, fixture_dir):
|
|
@@ -179,7 +178,7 @@ def run_debug_checks(driver, fixture_dir):
|
|
|
179
178
|
check("paused frame exposes locals", "a" in out and "b" in out, repr(out))
|
|
180
179
|
|
|
181
180
|
resp = driver.call("debug", action="get", name="a")
|
|
182
|
-
check("reads a local by name", resp.get("ok") is True and resp.get("out", "").
|
|
181
|
+
check("reads a local by name", resp.get("ok") is True and resp.get("out", "").strip() == "1", repr(resp))
|
|
183
182
|
|
|
184
183
|
resp = driver.call("debug", action="get", name="c")
|
|
185
184
|
check("unassigned local is reported as an error", resp.get("ok") is False, repr(resp))
|
|
@@ -188,10 +187,20 @@ def run_debug_checks(driver, fixture_dir):
|
|
|
188
187
|
check("step keeps the session paused", resp.get("state") == "paused", repr(resp.get("state")))
|
|
189
188
|
|
|
190
189
|
resp = driver.call("debug", action="get", name="c")
|
|
191
|
-
check("stepping makes the next local visible", resp.get("ok") is True and resp.get("out", "").
|
|
190
|
+
check("stepping makes the next local visible", resp.get("ok") is True and resp.get("out", "").strip() == "3", repr(resp))
|
|
192
191
|
|
|
193
192
|
resp = driver.call("debug", action="eval", code="a + b")
|
|
194
|
-
check("evaluates an expression in the paused frame", resp.get("ok") is True and resp.get("out", "").
|
|
193
|
+
check("evaluates an expression in the paused frame", resp.get("ok") is True and resp.get("out", "").strip() == "3", repr(resp))
|
|
194
|
+
|
|
195
|
+
# Regression: _format_value once called back into MATLAB with an expression
|
|
196
|
+
# naming `value`, which is a PYTHON variable -- MATLAB has no such name, so
|
|
197
|
+
# every non-scalar readout leaked an error onto stderr beside its value.
|
|
198
|
+
before = len(driver.chatter)
|
|
199
|
+
resp = driver.call("debug", action="eval", code="[a b]")
|
|
200
|
+
leaked = driver.chatter[before:]
|
|
201
|
+
check("evaluating a vector in the paused frame succeeds", resp.get("ok") is True, repr(resp))
|
|
202
|
+
check("a frame value readout leaks no MATLAB error to stderr",
|
|
203
|
+
not any("value" in line for line in leaked), repr(leaked))
|
|
195
204
|
|
|
196
205
|
resp = driver.call("debug", action="stack")
|
|
197
206
|
check("stack is available while paused", resp.get("ok") is True and bool(resp.get("out")), repr(resp.get("out")))
|
|
@@ -225,6 +234,16 @@ def main():
|
|
|
225
234
|
check("driver answers ping", resp.get("ok") is True, repr(resp))
|
|
226
235
|
check("engine is lazy before first use", resp.get("engineRunning") is False, repr(resp))
|
|
227
236
|
|
|
237
|
+
# Rejecting an unknown action must not pay a MATLAB launch. These
|
|
238
|
+
# run before any check that starts the engine, so "still lazy" is a
|
|
239
|
+
# meaningful assertion rather than a restatement of "already up".
|
|
240
|
+
resp = driver.call("debug", action="nonsense")
|
|
241
|
+
check("an invalid debug action is rejected", resp.get("ok") is False, repr(resp))
|
|
242
|
+
resp = driver.call("figure", action="nonsense")
|
|
243
|
+
check("an invalid figure action is rejected", resp.get("ok") is False, repr(resp))
|
|
244
|
+
resp = driver.call("ping")
|
|
245
|
+
check("a rejected action does not launch MATLAB", resp.get("engineRunning") is False, repr(resp))
|
|
246
|
+
|
|
228
247
|
run_eval_checks(driver)
|
|
229
248
|
run_figure_checks(driver, tmp)
|
|
230
249
|
if not args.no_debug:
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Validate what `npm pack` is about to publish.
|
|
4
|
+
*
|
|
5
|
+
* Reads the JSON that `npm pack --dry-run --json` writes to pack.json, so the
|
|
6
|
+
* assertions live in one file instead of being duplicated as inline `node -e`
|
|
7
|
+
* snippets in ci.yml and release.yml.
|
|
8
|
+
*
|
|
9
|
+
* WHY NOT AN INLINE `node -e`
|
|
10
|
+
* This package is `"type": "module"`, and a recent Node 22.x began evaluating
|
|
11
|
+
* `-e` input according to that field. Under ESM there is no `require`, so the
|
|
12
|
+
* previous inline version died with "require is not a function" -- on CI but
|
|
13
|
+
* not on the older Node used locally, which is the worst way for a check to
|
|
14
|
+
* fail. A script file has an unambiguous module type and needs no shell
|
|
15
|
+
* quoting either.
|
|
16
|
+
*
|
|
17
|
+
* WHY THE OUTPUT SHAPE IS NORMALIZED
|
|
18
|
+
* `npm pack --json` changed shape without a major-version signal in the CLI:
|
|
19
|
+
*
|
|
20
|
+
* npm <= 11 [ { name, entryCount, files: [{ path, size, mode }] } ]
|
|
21
|
+
* npm >= 12 { "<name>": { name, entryCount, files: [...] } }
|
|
22
|
+
*
|
|
23
|
+
* npm 12 passes `key: tar.name` to its logger, which wraps the record in an
|
|
24
|
+
* object keyed by package name (lib/utils/tar.js). A check that only handled
|
|
25
|
+
* the array form passed locally and failed on CI, which installs npm@latest.
|
|
26
|
+
* findPackRecord() accepts either, and the failure path prints the keys it did
|
|
27
|
+
* see so the next shape change is diagnosable from one CI log rather than a
|
|
28
|
+
* source-reading session.
|
|
29
|
+
*
|
|
30
|
+
* npm pack --dry-run --json > pack.json
|
|
31
|
+
* node scripts/inspect-tarball.mjs
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readFileSync } from 'node:fs'
|
|
35
|
+
import process from 'node:process'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Files the runtime needs at run time. A `files` entry that drops one of these
|
|
39
|
+
* installs cleanly and then fails in the user's session, which is exactly the
|
|
40
|
+
* regression this list exists to catch.
|
|
41
|
+
*/
|
|
42
|
+
const REQUIRED_FILES = [
|
|
43
|
+
'src/plugin.mjs',
|
|
44
|
+
'cordis.patch.yml',
|
|
45
|
+
'python/ml_driver.py',
|
|
46
|
+
'python/mfiles/dsh_evalbase.m',
|
|
47
|
+
'python/mfiles/dsh_figure_info.m',
|
|
48
|
+
'python/mfiles/dsh_figure_save.m',
|
|
49
|
+
'scripts/setup-engine.mjs',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
/** Find the package record inside either the array or the keyed-object shape. */
|
|
53
|
+
function findPackRecord(pack) {
|
|
54
|
+
const hasFiles = (value) => value !== null && typeof value === 'object' && Array.isArray(value.files)
|
|
55
|
+
if (Array.isArray(pack)) return pack.find(hasFiles) ?? pack[0]
|
|
56
|
+
if (pack !== null && typeof pack === 'object') {
|
|
57
|
+
if (hasFiles(pack)) return pack
|
|
58
|
+
return Object.values(pack).find(hasFiles)
|
|
59
|
+
}
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let pack
|
|
64
|
+
try {
|
|
65
|
+
pack = JSON.parse(readFileSync('pack.json', 'utf8'))
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('error: could not read pack.json')
|
|
68
|
+
console.error(' run `npm pack --dry-run --json > pack.json` first')
|
|
69
|
+
console.error(' ' + String((error && error.message) || error))
|
|
70
|
+
process.exit(1)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const entry = findPackRecord(pack)
|
|
74
|
+
if (entry === undefined) {
|
|
75
|
+
console.error('error: no package record with a file list in pack.json')
|
|
76
|
+
console.error(' top-level: ' + (Array.isArray(pack) ? 'array' : typeof pack))
|
|
77
|
+
const keys = Array.isArray(pack) ? Object.keys(pack[0] ?? {}) : Object.keys(pack ?? {})
|
|
78
|
+
console.error(' keys seen: ' + JSON.stringify(keys))
|
|
79
|
+
process.exit(1)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const paths = entry.files.map((file) => (typeof file === 'string' ? file : file.path))
|
|
83
|
+
const failures = []
|
|
84
|
+
|
|
85
|
+
// The engine runtime is MathWorks code laid out from the user's own MATLAB
|
|
86
|
+
// installation. Redistributing it through the registry would be a licence
|
|
87
|
+
// problem, so this is a hard stop rather than a warning.
|
|
88
|
+
const leaked = paths.filter((path) => typeof path === 'string' && path.includes('pylibs'))
|
|
89
|
+
if (leaked.length > 0) {
|
|
90
|
+
failures.push('engine runtime must not be published: ' + leaked.join(', '))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const missing = REQUIRED_FILES.filter((path) => !paths.includes(path))
|
|
94
|
+
if (missing.length > 0) {
|
|
95
|
+
failures.push('package is missing required files: ' + missing.join(', '))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (failures.length > 0) {
|
|
99
|
+
for (const failure of failures) console.error('error: ' + failure)
|
|
100
|
+
process.exit(1)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fileCount = entry.entryCount ?? paths.length
|
|
104
|
+
const sizeKb = Math.round((entry.size ?? 0) / 1024)
|
|
105
|
+
console.log('tarball ok: ' + fileCount + ' files, ' + sizeKb + ' kB, no engine runtime')
|