@alotop/dsh-matlab-bridge 0.1.1
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/LICENSE +29 -0
- package/README.md +263 -0
- package/README.zh-CN.md +177 -0
- package/cordis.patch.yml +14 -0
- package/package.json +58 -0
- package/python/mfiles/dsh_evalbase.m +31 -0
- package/python/mfiles/dsh_figure_info.m +29 -0
- package/python/mfiles/dsh_figure_save.m +37 -0
- package/python/ml_driver.py +419 -0
- package/python/selftest.py +244 -0
- package/scripts/run-selftest.mjs +61 -0
- package/scripts/setup-engine.mjs +242 -0
- package/src/plugin.mjs +511 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""DSH MATLAB bridge driver.
|
|
3
|
+
|
|
4
|
+
Owns exactly one persistent MATLAB Engine session and speaks newline-delimited
|
|
5
|
+
JSON on stdin/stdout.
|
|
6
|
+
|
|
7
|
+
Why every protocol line carries an `@@DSH:` prefix: the MATLAB Engine forwards
|
|
8
|
+
the MATLAB command window to this process's stdout, so a bare JSON protocol
|
|
9
|
+
would race with MATLAB's own output. Lines carrying the prefix are protocol;
|
|
10
|
+
every other line is MATLAB chatter that the caller can surface as diagnostics.
|
|
11
|
+
|
|
12
|
+
Request: {"id": <int>, "op": "<name>", ...}
|
|
13
|
+
Response: {"id": <int>, "ok": <bool>, ...}
|
|
14
|
+
|
|
15
|
+
Ops
|
|
16
|
+
ping liveness + whether MATLAB is up
|
|
17
|
+
start launch MATLAB (idempotent)
|
|
18
|
+
eval {code} run code in the base workspace
|
|
19
|
+
debug {action, ...} breakpoints, stepping, frame inspection
|
|
20
|
+
figure {action, ...} list, export and close figures
|
|
21
|
+
shutdown quit MATLAB and exit
|
|
22
|
+
|
|
23
|
+
Figure actions
|
|
24
|
+
list every open figure, in creation order
|
|
25
|
+
save {figure, dir} export to PNG; figure=0 means all
|
|
26
|
+
close {figure} close one figure, or all when 0
|
|
27
|
+
|
|
28
|
+
Debug actions
|
|
29
|
+
break {file, line} dbstop in <file> at <line>
|
|
30
|
+
breakError dbstop if error
|
|
31
|
+
clearBreaks dbclear all
|
|
32
|
+
run {code, waitMs} launch code; stop when it pauses or ends
|
|
33
|
+
status paused?, stack text, future running?
|
|
34
|
+
stack dbstack text of the paused frame
|
|
35
|
+
vars whos text of the paused frame
|
|
36
|
+
get {name} value of NAME in the paused frame
|
|
37
|
+
eval {code} evaluate CODE in the paused frame
|
|
38
|
+
step | stepIn | stepOut | continue dbstep / dbcont
|
|
39
|
+
quit dbquit
|
|
40
|
+
finish {waitMs} await the outstanding run
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import sys
|
|
46
|
+
import time
|
|
47
|
+
import warnings
|
|
48
|
+
|
|
49
|
+
# The bundled engine advertises Python 3.9-3.12 and warns on 3.13. The shipped
|
|
50
|
+
# abi3 module loads and runs correctly on 3.13, so the warning is pure noise on
|
|
51
|
+
# the stderr channel the caller surfaces as diagnostics.
|
|
52
|
+
warnings.filterwarnings("ignore", message=".*MATLAB Engine for Python supports Python version.*")
|
|
53
|
+
warnings.filterwarnings("ignore", message=".*Python versions .* are supported.*")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Import the engine without depending on the caller setting PYTHONPATH. The
|
|
57
|
+
# Cordis plugin spawns this file through the subprocess seam, where building a
|
|
58
|
+
# full environment is awkward; a self-contained driver removes that coupling.
|
|
59
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
60
|
+
_PYLIBS = os.path.join(_HERE, "pylibs")
|
|
61
|
+
if os.path.isdir(_PYLIBS) and _PYLIBS not in sys.path:
|
|
62
|
+
sys.path.insert(0, _PYLIBS)
|
|
63
|
+
|
|
64
|
+
# Speak protocol over the raw byte streams with an explicit encoding. The
|
|
65
|
+
# Windows console code page is not UTF-8, and MATLAB output here is routinely
|
|
66
|
+
# non-ASCII (localized error messages), so relying on the locale would corrupt
|
|
67
|
+
# both the JSON and the captured text.
|
|
68
|
+
_STDIN = sys.stdin.buffer
|
|
69
|
+
_STDOUT = sys.stdout.buffer
|
|
70
|
+
|
|
71
|
+
PROTOCOL_PREFIX = "@@DSH:"
|
|
72
|
+
|
|
73
|
+
_engine = None
|
|
74
|
+
_future = None
|
|
75
|
+
_last_run_error = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def emit(payload):
|
|
79
|
+
"""Write one protocol line. A dead pipe ends the process quietly."""
|
|
80
|
+
try:
|
|
81
|
+
line = PROTOCOL_PREFIX + json.dumps(payload, ensure_ascii=False)
|
|
82
|
+
_STDOUT.write(line.encode("utf-8", "replace") + b"\n")
|
|
83
|
+
_STDOUT.flush()
|
|
84
|
+
except Exception:
|
|
85
|
+
raise SystemExit(0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def engine():
|
|
89
|
+
"""Return the live MATLAB engine, starting it on first use."""
|
|
90
|
+
global _engine
|
|
91
|
+
if _engine is None:
|
|
92
|
+
try:
|
|
93
|
+
import matlab.engine
|
|
94
|
+
except ImportError as exc:
|
|
95
|
+
# A bare ImportError here is the single most likely first-run
|
|
96
|
+
# failure, and it names neither the cause nor the fix.
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
"the MATLAB Engine for Python runtime is not available (%s). "
|
|
99
|
+
"Lay it out from your MATLAB installation first: "
|
|
100
|
+
"`npx @alotop/dsh-matlab-bridge` (or `npm run setup` in a "
|
|
101
|
+
"checkout). It was expected under %s." % (exc, _PYLIBS)
|
|
102
|
+
)
|
|
103
|
+
_engine = matlab.engine.start_matlab("-nodesktop")
|
|
104
|
+
# dsh_evalbase lives beside this driver; MATLAB must be able to see it.
|
|
105
|
+
helpers = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mfiles")
|
|
106
|
+
_engine.addpath(helpers, nargout=0)
|
|
107
|
+
return _engine
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def capture(code):
|
|
111
|
+
"""Run CODE in the MATLAB base workspace; return (out, err, stack)."""
|
|
112
|
+
result = engine().dsh_evalbase(code, nargout=1)
|
|
113
|
+
out = result.get("out", "") or ""
|
|
114
|
+
err = result.get("err", "") or ""
|
|
115
|
+
stack = result.get("stack", "") or ""
|
|
116
|
+
return out, err, stack
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def control(command):
|
|
120
|
+
"""Run a debugger control statement; return (out, err).
|
|
121
|
+
|
|
122
|
+
These are command-syntax statements (`dbstop in f at 4`), never expressions,
|
|
123
|
+
so they bypass the base-workspace capture helper and go straight to the
|
|
124
|
+
engine, which runs them with no output argument.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
engine().eval(command, nargout=0)
|
|
128
|
+
return "", ""
|
|
129
|
+
except Exception as exc: # noqa: BLE001
|
|
130
|
+
return "", str(exc)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def dbstack_text():
|
|
134
|
+
"""Command-window text of the current stack, empty when not debugging."""
|
|
135
|
+
try:
|
|
136
|
+
return engine().eval("evalc('dbstack')", nargout=1) or ""
|
|
137
|
+
except Exception as exc: # noqa: BLE001
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
def is_paused():
|
|
141
|
+
return dbstack_text().strip() != ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def await_future(wait_ms):
|
|
145
|
+
"""Wait up to WAIT_MS for the outstanding run; return its outcome string."""
|
|
146
|
+
global _future, _last_run_error
|
|
147
|
+
if _future is None:
|
|
148
|
+
return "no-run"
|
|
149
|
+
deadline = time.time() + (wait_ms / 1000.0)
|
|
150
|
+
while time.time() < deadline:
|
|
151
|
+
if _future.done():
|
|
152
|
+
try:
|
|
153
|
+
_future.result()
|
|
154
|
+
_future = None
|
|
155
|
+
return "completed"
|
|
156
|
+
except Exception as exc: # noqa: BLE001
|
|
157
|
+
_last_run_error = str(exc)
|
|
158
|
+
_future = None
|
|
159
|
+
return "failed"
|
|
160
|
+
time.sleep(0.05)
|
|
161
|
+
return "running"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def op_eval(req):
|
|
165
|
+
code = req.get("code", "")
|
|
166
|
+
if not code.strip():
|
|
167
|
+
return {"ok": True, "out": "", "err": "", "stack": ""}
|
|
168
|
+
out, err, stack = capture(code)
|
|
169
|
+
return {"ok": err == "", "out": out, "err": err, "stack": stack}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def op_debug(req):
|
|
173
|
+
global _future, _last_run_error
|
|
174
|
+
action = req.get("action", "")
|
|
175
|
+
eng = engine()
|
|
176
|
+
|
|
177
|
+
if action == "break":
|
|
178
|
+
name = (req.get("file") or "").strip()
|
|
179
|
+
if not name:
|
|
180
|
+
return {"ok": False, "err": "debug break requires 'file'"}
|
|
181
|
+
line = req.get("line")
|
|
182
|
+
command = "dbstop in %s at %d" % (name, int(line)) if line else "dbstop in %s" % name
|
|
183
|
+
out, err = control(command)
|
|
184
|
+
return {"ok": err == "", "out": out, "err": err}
|
|
185
|
+
|
|
186
|
+
if action == "breakError":
|
|
187
|
+
out, err = control("dbstop if error")
|
|
188
|
+
return {"ok": err == "", "out": out, "err": err}
|
|
189
|
+
|
|
190
|
+
if action == "clearBreaks":
|
|
191
|
+
out, err = control("dbclear all")
|
|
192
|
+
return {"ok": err == "", "out": out, "err": err}
|
|
193
|
+
|
|
194
|
+
if action == "run":
|
|
195
|
+
code = (req.get("code") or "").strip()
|
|
196
|
+
if not code:
|
|
197
|
+
return {"ok": False, "err": "debug run requires 'code'"}
|
|
198
|
+
wait_ms = int(req.get("waitMs") or 8000)
|
|
199
|
+
_last_run_error = None
|
|
200
|
+
_future = eng.eval(code, background=True, nargout=0)
|
|
201
|
+
deadline = time.time() + (wait_ms / 1000.0)
|
|
202
|
+
while time.time() < deadline:
|
|
203
|
+
# Check completion before probing the stack: a query issued while
|
|
204
|
+
# MATLAB is busy running the background call blocks until that call
|
|
205
|
+
# yields, so testing `done` first reports a fast run immediately.
|
|
206
|
+
if _future.done():
|
|
207
|
+
try:
|
|
208
|
+
_future.result()
|
|
209
|
+
_future = None
|
|
210
|
+
return {"ok": True, "state": "completed", "out": ""}
|
|
211
|
+
except Exception as exc: # noqa: BLE001
|
|
212
|
+
_future = None
|
|
213
|
+
return {"ok": False, "state": "failed", "err": str(exc)}
|
|
214
|
+
if is_paused():
|
|
215
|
+
return {"ok": True, "state": "paused", "stack": dbstack_text()}
|
|
216
|
+
time.sleep(0.1)
|
|
217
|
+
return {"ok": True, "state": "running", "stack": dbstack_text()}
|
|
218
|
+
|
|
219
|
+
if action == "status":
|
|
220
|
+
paused = is_paused()
|
|
221
|
+
stack = dbstack_text() if paused else ""
|
|
222
|
+
running = _future is not None and not _future.done()
|
|
223
|
+
payload = {"ok": True, "paused": paused, "running": running, "stack": stack}
|
|
224
|
+
if _future is not None and _future.done():
|
|
225
|
+
payload["finished"] = True
|
|
226
|
+
return payload
|
|
227
|
+
|
|
228
|
+
if action == "stack":
|
|
229
|
+
if not is_paused():
|
|
230
|
+
return {"ok": False, "err": "MATLAB is not paused at a breakpoint"}
|
|
231
|
+
return {"ok": True, "out": dbstack_text()}
|
|
232
|
+
|
|
233
|
+
if action == "vars":
|
|
234
|
+
if not is_paused():
|
|
235
|
+
return {"ok": False, "err": "MATLAB is not paused at a breakpoint"}
|
|
236
|
+
return {"ok": True, "out": eng.eval("evalc('whos')", nargout=1) or ""}
|
|
237
|
+
|
|
238
|
+
if action in ("get", "eval"):
|
|
239
|
+
if not is_paused():
|
|
240
|
+
return {"ok": False, "err": "MATLAB is not paused at a breakpoint"}
|
|
241
|
+
code = req.get("name") if action == "get" else req.get("code")
|
|
242
|
+
if not code:
|
|
243
|
+
return {"ok": False, "err": "debug %s requires a value" % action}
|
|
244
|
+
try:
|
|
245
|
+
value = eng.eval(str(code))
|
|
246
|
+
except Exception as exc: # noqa: BLE001
|
|
247
|
+
return {"ok": False, "err": str(exc)}
|
|
248
|
+
return {"ok": True, "out": _format_value(value)}
|
|
249
|
+
|
|
250
|
+
if action in ("step", "stepIn", "stepOut", "continue", "quit"):
|
|
251
|
+
if not is_paused():
|
|
252
|
+
return {"ok": False, "err": "MATLAB is not paused at a breakpoint"}
|
|
253
|
+
command = {
|
|
254
|
+
"step": "dbstep",
|
|
255
|
+
"stepIn": "dbstep in",
|
|
256
|
+
"stepOut": "dbstep out",
|
|
257
|
+
"continue": "dbcont",
|
|
258
|
+
"quit": "dbquit",
|
|
259
|
+
}[action]
|
|
260
|
+
try:
|
|
261
|
+
eng.eval(command, nargout=0)
|
|
262
|
+
except Exception as exc: # noqa: BLE001
|
|
263
|
+
return {"ok": False, "err": str(exc)}
|
|
264
|
+
if action == "continue":
|
|
265
|
+
outcome = await_future(int(req.get("waitMs") or 15000))
|
|
266
|
+
return {"ok": True, "state": outcome, "err": _last_run_error or ""}
|
|
267
|
+
if action == "quit":
|
|
268
|
+
_future = None
|
|
269
|
+
return {"ok": True, "state": "debug-quit"}
|
|
270
|
+
return {"ok": True, "state": "paused", "stack": dbstack_text()}
|
|
271
|
+
|
|
272
|
+
if action == "finish":
|
|
273
|
+
outcome = await_future(int(req.get("waitMs") or 15000))
|
|
274
|
+
return {"ok": True, "state": outcome, "err": _last_run_error or ""}
|
|
275
|
+
|
|
276
|
+
return {"ok": False, "err": "unknown debug action: %s" % action}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _format_value(value):
|
|
280
|
+
"""Render a MATLAB value for the model without dumping it as Python repr."""
|
|
281
|
+
if value is None:
|
|
282
|
+
return ""
|
|
283
|
+
if isinstance(value, float):
|
|
284
|
+
return repr(value)
|
|
285
|
+
if isinstance(value, (int, bool, str)):
|
|
286
|
+
return str(value)
|
|
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)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _load_json(payload, label):
|
|
295
|
+
"""Parse the JSON text a MATLAB helper returned."""
|
|
296
|
+
try:
|
|
297
|
+
return json.loads(payload)
|
|
298
|
+
except Exception as exc: # noqa: BLE001
|
|
299
|
+
raise RuntimeError("MATLAB %s returned unparsable JSON: %s" % (label, exc))
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _figure_records(payload, label):
|
|
303
|
+
"""Normalize a MATLAB figure payload into a list of record dicts.
|
|
304
|
+
|
|
305
|
+
JSONENCODE emits a bare OBJECT when the struct array holds exactly one
|
|
306
|
+
element and an ARRAY otherwise, so both shapes are legitimate. Iterating the
|
|
307
|
+
object directly would walk its KEY names instead of its records, which is
|
|
308
|
+
why this normalizes before any caller sees it.
|
|
309
|
+
"""
|
|
310
|
+
parsed = _load_json(payload, label)
|
|
311
|
+
if isinstance(parsed, dict):
|
|
312
|
+
return [parsed]
|
|
313
|
+
if isinstance(parsed, list):
|
|
314
|
+
return [entry for entry in parsed if isinstance(entry, dict)]
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _figure_record(entry):
|
|
319
|
+
"""Reduce one MATLAB figure record to the scalars the caller needs."""
|
|
320
|
+
if not isinstance(entry, dict):
|
|
321
|
+
return {"number": None, "name": "", "visible": "", "path": "", "error": ""}
|
|
322
|
+
number = entry.get("number")
|
|
323
|
+
return {
|
|
324
|
+
"number": int(number) if isinstance(number, (int, float)) else None,
|
|
325
|
+
"name": str(entry.get("name") or ""),
|
|
326
|
+
"visible": str(entry.get("visible") or ""),
|
|
327
|
+
"path": str(entry.get("path") or ""),
|
|
328
|
+
"error": str(entry.get("error") or ""),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def op_figure(req):
|
|
333
|
+
action = req.get("action", "save")
|
|
334
|
+
# Validate before touching the engine: a typo must not pay a MATLAB launch.
|
|
335
|
+
if action not in ("list", "save", "close"):
|
|
336
|
+
return {"ok": False, "err": "unknown figure action: %s" % action}
|
|
337
|
+
eng = engine()
|
|
338
|
+
|
|
339
|
+
if action == "list":
|
|
340
|
+
try:
|
|
341
|
+
records = _figure_records(eng.dsh_figure_info(nargout=1), "dsh_figure_info")
|
|
342
|
+
except Exception as exc: # noqa: BLE001
|
|
343
|
+
return {"ok": False, "err": str(exc)}
|
|
344
|
+
return {"ok": True, "figures": [_figure_record(entry) for entry in records]}
|
|
345
|
+
|
|
346
|
+
if action == "save":
|
|
347
|
+
which = int(req.get("figure") or 0)
|
|
348
|
+
directory = str(req.get("dir") or os.path.join(os.getcwd(), ".matlab-figures"))
|
|
349
|
+
try:
|
|
350
|
+
records = _figure_records(
|
|
351
|
+
eng.dsh_figure_save(directory, float(which), nargout=1), "dsh_figure_save"
|
|
352
|
+
)
|
|
353
|
+
except Exception as exc: # noqa: BLE001
|
|
354
|
+
return {"ok": False, "err": str(exc)}
|
|
355
|
+
return {
|
|
356
|
+
"ok": True,
|
|
357
|
+
"dir": directory,
|
|
358
|
+
"figures": [_figure_record(entry) for entry in records],
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if action == "close":
|
|
362
|
+
which = int(req.get("figure") or 0)
|
|
363
|
+
command = "close all" if which == 0 else "close %d" % which
|
|
364
|
+
out, err = control(command)
|
|
365
|
+
return {"ok": err == "", "out": out, "err": err}
|
|
366
|
+
|
|
367
|
+
# Unreachable: the action was validated at the top of this function.
|
|
368
|
+
return {"ok": False, "err": "unknown figure action: %s" % action}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def dispatch(req):
|
|
372
|
+
global _engine
|
|
373
|
+
op = req.get("op", "")
|
|
374
|
+
if op == "ping":
|
|
375
|
+
return {"ok": True, "engineRunning": _engine is not None}
|
|
376
|
+
if op == "start":
|
|
377
|
+
eng = engine()
|
|
378
|
+
version = eng.eval("version", nargout=1)
|
|
379
|
+
return {"ok": True, "version": str(version)}
|
|
380
|
+
if op == "eval":
|
|
381
|
+
return op_eval(req)
|
|
382
|
+
if op == "debug":
|
|
383
|
+
return op_debug(req)
|
|
384
|
+
if op == "figure":
|
|
385
|
+
return op_figure(req)
|
|
386
|
+
if op == "shutdown":
|
|
387
|
+
if _engine is not None:
|
|
388
|
+
try:
|
|
389
|
+
_engine.quit()
|
|
390
|
+
except Exception: # noqa: BLE001
|
|
391
|
+
pass
|
|
392
|
+
_engine = None
|
|
393
|
+
emit({"id": req.get("id"), "ok": True, "state": "shutdown"})
|
|
394
|
+
raise SystemExit(0)
|
|
395
|
+
return {"ok": False, "err": "unknown op: %s" % op}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def main():
|
|
399
|
+
for raw in _STDIN:
|
|
400
|
+
line = raw.decode("utf-8-sig", "replace").strip()
|
|
401
|
+
if not line:
|
|
402
|
+
continue
|
|
403
|
+
try:
|
|
404
|
+
req = json.loads(line)
|
|
405
|
+
except Exception as exc: # noqa: BLE001
|
|
406
|
+
emit({"id": None, "ok": False, "err": "bad request json: %s" % exc})
|
|
407
|
+
continue
|
|
408
|
+
try:
|
|
409
|
+
response = dispatch(req)
|
|
410
|
+
except SystemExit:
|
|
411
|
+
raise
|
|
412
|
+
except Exception as exc: # noqa: BLE001
|
|
413
|
+
response = {"ok": False, "err": "%s: %s" % (type(exc).__name__, exc)}
|
|
414
|
+
response["id"] = req.get("id")
|
|
415
|
+
emit(response)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
main()
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Self-test for the MATLAB bridge driver.
|
|
3
|
+
|
|
4
|
+
Exercises the JSON-lines protocol end to end against a real MATLAB session:
|
|
5
|
+
engine startup, base-workspace output capture, error reporting, figure export,
|
|
6
|
+
and the full breakpoint stepping sequence. Run this after touching ml_driver.py,
|
|
7
|
+
dsh_evalbase.m or the dsh_figure_* helpers -- a failure here is unambiguously a
|
|
8
|
+
driver bug rather than a Cordis plugin or transport bug.
|
|
9
|
+
|
|
10
|
+
python selftest.py # run everything
|
|
11
|
+
python selftest.py --no-debug # skip the MATLAB-starting debug half
|
|
12
|
+
|
|
13
|
+
Exits 0 when every check passes, 1 otherwise.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import itertools
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
26
|
+
DRIVER = os.path.join(HERE, "ml_driver.py")
|
|
27
|
+
PYLIBS = os.path.join(HERE, "pylibs")
|
|
28
|
+
PYEXE = sys.executable
|
|
29
|
+
|
|
30
|
+
FIXTURE = """function result = bridge_fixture()
|
|
31
|
+
a = 1;
|
|
32
|
+
b = 2;
|
|
33
|
+
c = a + b;
|
|
34
|
+
d = c * 10;
|
|
35
|
+
result = d;
|
|
36
|
+
end
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
failures = []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check(label, condition, detail=""):
|
|
43
|
+
status = "PASS" if condition else "FAIL"
|
|
44
|
+
print("[%s] %s%s" % (status, label, (" -- " + detail) if detail else ""), flush=True)
|
|
45
|
+
if not condition:
|
|
46
|
+
failures.append(label)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Driver:
|
|
50
|
+
def __init__(self):
|
|
51
|
+
env = dict(os.environ)
|
|
52
|
+
env["PYTHONPATH"] = PYLIBS
|
|
53
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
54
|
+
self.proc = subprocess.Popen(
|
|
55
|
+
[PYEXE, "-u", DRIVER],
|
|
56
|
+
stdin=subprocess.PIPE,
|
|
57
|
+
stdout=subprocess.PIPE,
|
|
58
|
+
stderr=subprocess.STDOUT,
|
|
59
|
+
text=True,
|
|
60
|
+
encoding="utf-8",
|
|
61
|
+
env=env,
|
|
62
|
+
)
|
|
63
|
+
self.ids = itertools.count(1)
|
|
64
|
+
self.chatter = []
|
|
65
|
+
|
|
66
|
+
def call(self, op, **kw):
|
|
67
|
+
req = {"id": next(self.ids), "op": op}
|
|
68
|
+
req.update(kw)
|
|
69
|
+
self.proc.stdin.write(json.dumps(req) + "\n")
|
|
70
|
+
self.proc.stdin.flush()
|
|
71
|
+
while True:
|
|
72
|
+
line = self.proc.stdout.readline()
|
|
73
|
+
if not line:
|
|
74
|
+
raise RuntimeError("driver died while waiting for %r" % op)
|
|
75
|
+
if line.startswith("@@DSH:"):
|
|
76
|
+
return json.loads(line[len("@@DSH:"):])
|
|
77
|
+
self.chatter.append(line.rstrip())
|
|
78
|
+
|
|
79
|
+
def close(self):
|
|
80
|
+
try:
|
|
81
|
+
self.call("shutdown")
|
|
82
|
+
except Exception: # noqa: BLE001
|
|
83
|
+
pass
|
|
84
|
+
try:
|
|
85
|
+
self.proc.wait(timeout=30)
|
|
86
|
+
except Exception: # noqa: BLE001
|
|
87
|
+
self.proc.kill()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run_eval_checks(driver):
|
|
91
|
+
started = time.time()
|
|
92
|
+
resp = driver.call("start")
|
|
93
|
+
check("engine starts", resp.get("ok") is True, "version=%s" % resp.get("version"))
|
|
94
|
+
print(" engine start took %.1fs" % (time.time() - started), flush=True)
|
|
95
|
+
|
|
96
|
+
resp = driver.call("eval", code="x = 41")
|
|
97
|
+
out = resp.get("out", "")
|
|
98
|
+
# MATLAB's display puts a newline between `x =` and the value, so compare on
|
|
99
|
+
# collapsed whitespace rather than against a contiguous "x = 41".
|
|
100
|
+
check("assignment echoes like the command window", "x = 41" in " ".join(out.split()), repr(out))
|
|
101
|
+
|
|
102
|
+
resp = driver.call("eval", code="x + 1")
|
|
103
|
+
out = resp.get("out", "")
|
|
104
|
+
check("bare expression echoes ans", "ans" in out and "42" in out, repr(out))
|
|
105
|
+
check("bare expression is not echoed twice", out.count("42") == 1, repr(out))
|
|
106
|
+
|
|
107
|
+
resp = driver.call("eval", code="disp('hello');\ny = x * 2;\nfprintf('y=%d\\n', y);")
|
|
108
|
+
check("multi-line output captured", "hello" in resp.get("out", "") and "y=82" in resp.get("out", ""), repr(resp.get("out")))
|
|
109
|
+
|
|
110
|
+
resp = driver.call("eval", code="disp('hi')")
|
|
111
|
+
check("output-less call does not error", resp.get("ok") is True and resp.get("out", "").strip() == "hi", repr(resp))
|
|
112
|
+
|
|
113
|
+
resp = driver.call("eval", code="format short")
|
|
114
|
+
check("command syntax is not treated as an expression", resp.get("ok") is True, repr(resp))
|
|
115
|
+
|
|
116
|
+
resp = driver.call("eval", code="no_such_function_here(3)")
|
|
117
|
+
check("error is reported", resp.get("ok") is False and resp.get("err"), repr(resp.get("err")))
|
|
118
|
+
check("error carries a stack", bool(resp.get("stack")), repr(resp.get("stack")))
|
|
119
|
+
|
|
120
|
+
resp = driver.call("eval", code="x")
|
|
121
|
+
check("state persists across calls", "41" in resp.get("out", ""), repr(resp.get("out")))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_figure_checks(driver, out_dir):
|
|
125
|
+
resp = driver.call(
|
|
126
|
+
"eval",
|
|
127
|
+
code="close all\nfigure('Visible','off')\nplot(1:5, [1 4 9 16 25], 'o-')\ntitle('selftest')\n",
|
|
128
|
+
)
|
|
129
|
+
check("a plot can be created", resp.get("ok") is True, repr(resp.get("err")))
|
|
130
|
+
|
|
131
|
+
resp = driver.call("figure", action="list")
|
|
132
|
+
figures = resp.get("figures", [])
|
|
133
|
+
check("figure list finds the open figure", resp.get("ok") is True and len(figures) == 1, repr(resp))
|
|
134
|
+
|
|
135
|
+
resp = driver.call("figure", action="save", dir=out_dir)
|
|
136
|
+
figures = resp.get("figures", [])
|
|
137
|
+
check("figure save reports one path", resp.get("ok") is True and len(figures) == 1, repr(resp))
|
|
138
|
+
|
|
139
|
+
path = figures[0].get("path") if figures else None
|
|
140
|
+
check("saved figure has no error", bool(figures) and figures[0].get("error") == "", repr(figures))
|
|
141
|
+
check("exported PNG exists", bool(path) and os.path.isfile(path), repr(path))
|
|
142
|
+
size = os.path.getsize(path) if path and os.path.isfile(path) else 0
|
|
143
|
+
check("exported PNG is not empty", size > 1000, "%d bytes" % size)
|
|
144
|
+
|
|
145
|
+
magic = b""
|
|
146
|
+
if path and os.path.isfile(path):
|
|
147
|
+
with open(path, "rb") as handle:
|
|
148
|
+
magic = handle.read(8)
|
|
149
|
+
check("exported file is a real PNG", magic == b"\x89PNG\r\n\x1a\n", repr(magic))
|
|
150
|
+
|
|
151
|
+
resp = driver.call("figure", action="save", figure=99, dir=out_dir)
|
|
152
|
+
check("saving a nonexistent figure is not an error",
|
|
153
|
+
resp.get("ok") is True and resp.get("figures") == [], repr(resp))
|
|
154
|
+
|
|
155
|
+
resp = driver.call("figure", action="close")
|
|
156
|
+
check("close succeeds", resp.get("ok") is True, repr(resp))
|
|
157
|
+
|
|
158
|
+
resp = driver.call("figure", action="list")
|
|
159
|
+
check("no figures remain after close", resp.get("ok") is True and resp.get("figures") == [], repr(resp))
|
|
160
|
+
|
|
161
|
+
resp = driver.call("figure", action="nonsense")
|
|
162
|
+
check("an invalid figure action is rejected", resp.get("ok") is False, repr(resp))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def run_debug_checks(driver, fixture_dir):
|
|
166
|
+
driver.call("eval", code="addpath('%s')" % fixture_dir.replace("\\", "\\\\"))
|
|
167
|
+
|
|
168
|
+
resp = driver.call("debug", action="break", file="bridge_fixture", line=4)
|
|
169
|
+
check("breakpoint is set", resp.get("ok") is True, repr(resp))
|
|
170
|
+
|
|
171
|
+
resp = driver.call("debug", action="run", code="bridge_fixture", waitMs=15000)
|
|
172
|
+
check("run pauses at the breakpoint", resp.get("state") == "paused", repr(resp.get("state")))
|
|
173
|
+
|
|
174
|
+
resp = driver.call("debug", action="status")
|
|
175
|
+
check("status reports paused", resp.get("paused") is True, repr(resp))
|
|
176
|
+
|
|
177
|
+
resp = driver.call("debug", action="vars")
|
|
178
|
+
out = resp.get("out", "")
|
|
179
|
+
check("paused frame exposes locals", "a" in out and "b" in out, repr(out))
|
|
180
|
+
|
|
181
|
+
resp = driver.call("debug", action="get", name="a")
|
|
182
|
+
check("reads a local by name", resp.get("ok") is True and resp.get("out", "").startswith("1"), repr(resp))
|
|
183
|
+
|
|
184
|
+
resp = driver.call("debug", action="get", name="c")
|
|
185
|
+
check("unassigned local is reported as an error", resp.get("ok") is False, repr(resp))
|
|
186
|
+
|
|
187
|
+
resp = driver.call("debug", action="step")
|
|
188
|
+
check("step keeps the session paused", resp.get("state") == "paused", repr(resp.get("state")))
|
|
189
|
+
|
|
190
|
+
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", "").startswith("3"), repr(resp))
|
|
192
|
+
|
|
193
|
+
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", "").startswith("3"), repr(resp))
|
|
195
|
+
|
|
196
|
+
resp = driver.call("debug", action="stack")
|
|
197
|
+
check("stack is available while paused", resp.get("ok") is True and bool(resp.get("out")), repr(resp.get("out")))
|
|
198
|
+
|
|
199
|
+
resp = driver.call("debug", action="continue")
|
|
200
|
+
check("continue completes the run", resp.get("state") == "completed", repr(resp))
|
|
201
|
+
|
|
202
|
+
resp = driver.call("debug", action="status")
|
|
203
|
+
check("status reports not paused after continue", resp.get("paused") is False, repr(resp))
|
|
204
|
+
|
|
205
|
+
resp = driver.call("debug", action="step")
|
|
206
|
+
check("stepping outside a pause is refused cleanly", resp.get("ok") is False, repr(resp))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main():
|
|
210
|
+
parser = argparse.ArgumentParser()
|
|
211
|
+
parser.add_argument("--no-debug", action="store_true", help="skip checks that start MATLAB")
|
|
212
|
+
args = parser.parse_args()
|
|
213
|
+
|
|
214
|
+
if not os.path.isdir(PYLIBS):
|
|
215
|
+
print("engine libs missing at %s -- run setup_engine.ps1 first" % PYLIBS, flush=True)
|
|
216
|
+
return 1
|
|
217
|
+
|
|
218
|
+
with tempfile.TemporaryDirectory(prefix="dsh-matlab-selftest-") as tmp:
|
|
219
|
+
with open(os.path.join(tmp, "bridge_fixture.m"), "w", encoding="utf-8", newline="\n") as handle:
|
|
220
|
+
handle.write(FIXTURE)
|
|
221
|
+
|
|
222
|
+
driver = Driver()
|
|
223
|
+
try:
|
|
224
|
+
resp = driver.call("ping")
|
|
225
|
+
check("driver answers ping", resp.get("ok") is True, repr(resp))
|
|
226
|
+
check("engine is lazy before first use", resp.get("engineRunning") is False, repr(resp))
|
|
227
|
+
|
|
228
|
+
run_eval_checks(driver)
|
|
229
|
+
run_figure_checks(driver, tmp)
|
|
230
|
+
if not args.no_debug:
|
|
231
|
+
run_debug_checks(driver, tmp)
|
|
232
|
+
finally:
|
|
233
|
+
driver.close()
|
|
234
|
+
|
|
235
|
+
print(flush=True)
|
|
236
|
+
if failures:
|
|
237
|
+
print("SELFTEST FAILED: %d check(s): %s" % (len(failures), ", ".join(failures)), flush=True)
|
|
238
|
+
return 1
|
|
239
|
+
print("SELFTEST PASSED", flush=True)
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
sys.exit(main())
|